diff --git a/src/workflows/evidence.ts b/src/workflows/evidence.ts deleted file mode 100644 index c8396dc..0000000 --- a/src/workflows/evidence.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** Evidence coverage is explicit so an unavailable source cannot look searched. */ - -export const EVIDENCE_CATEGORIES = [ - "source-control", - "issue-tracker", - "long-form-documents", - "real-time-chat", - "infrastructure-observability", - "error-tracking", - "product-analytics", -] as const; - -export type EvidenceCategory = (typeof EVIDENCE_CATEGORIES)[number]; - -export interface EvidenceSource { - category: EvidenceCategory; - status: "available" | "gap"; - mcpNames: string[]; - reason?: string; -} - -export interface EvidenceMappingOptions { - availableMcps: readonly ( - | string - | { name: string; tools?: readonly string[]; resources?: readonly string[] } - )[]; -} - -export interface EvidenceMappingResult { - sources: EvidenceSource[]; - gaps: string[]; -} - -const MATCHERS: Record, RegExp> = { - "issue-tracker": /linear|jira|github[-_ ]?issues?|plane|shortcut|asana|youtrack/i, - "long-form-documents": /notion|confluence|google[-_ ]?docs?|coda|slite|document/i, - "real-time-chat": /slack|discord|teams?|mattermost|chat/i, - "infrastructure-observability": - /datadog|new[-_ ]?relic|honeycomb|grafana|splunk|observability|prometheus/i, - "error-tracking": /sentry|rollbar|bugsnag|airbrake|exception|error[-_ ]?track/i, - "product-analytics": /databricks|snowflake|bigquery|clickhouse|dbt|redshift|analytics|warehouse/i, -}; - -const CATEGORY_LABELS: Record = { - "source-control": "source control", - "issue-tracker": "issue tracker", - "long-form-documents": "long-form documents", - "real-time-chat": "real-time chat", - "infrastructure-observability": "infrastructure observability", - "error-tracking": "error tracking", - "product-analytics": "product analytics", -}; - -function mcpName( - mcp: string | { name: string; tools?: readonly string[]; resources?: readonly string[] }, -): string { - return typeof mcp === "string" ? mcp : mcp.name; -} - -function matchingNames( - category: Exclude, - names: readonly string[], -): string[] { - const matcher = MATCHERS[category]; - return names.filter((name) => matcher.test(name)); -} - -/** Map the seven evidence categories without dropping unavailable categories. */ -export function mapEvidenceSources(options: EvidenceMappingOptions): EvidenceMappingResult { - const names = options.availableMcps.map(mcpName).filter((name) => name.trim().length > 0); - const sources: EvidenceSource[] = [ - { category: "source-control", status: "available", mcpNames: ["git/gh"] }, - ]; - const gaps: string[] = []; - for (const category of EVIDENCE_CATEGORIES) { - if (category === "source-control") continue; - const matched = matchingNames(category, names); - if (matched.length > 0) { - sources.push({ category, status: "available", mcpNames: [...new Set(matched)] }); - } else { - const reason = `No matching MCP available for ${CATEGORY_LABELS[category]}; this evidence category is an explicit gap.`; - sources.push({ category, status: "gap", mcpNames: [], reason }); - gaps.push(reason); - } - } - return { sources, gaps }; -} - -export const buildEvidenceCoverageMap = mapEvidenceSources; diff --git a/src/workflows/sessions.ts b/src/workflows/sessions.ts deleted file mode 100644 index 90e5f4e..0000000 --- a/src/workflows/sessions.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { existsSync, lstatSync, readdirSync } from "node:fs"; -import { basename, dirname, relative, resolve, sep } from "node:path"; - -export interface SessionDiscoveryOptions { - /** PI_SESSION_FILE may be one active JSONL file or a project-scoped directory. */ - piSessionFile?: string; - env?: Readonly>; - projectDirectory: string; - sessionId?: string; -} - -function projectNames(projectDirectory: string): Set { - const absolute = resolve(projectDirectory); - const slug = absolute.replace(/^[/\\]+/, "").replace(/[\\/]/g, "-"); - return new Set([`--${slug}--`]); -} - -function within(parent: string, candidate: string): boolean { - const child = relative(resolve(parent), resolve(candidate)); - return ( - child === "" || (child !== ".." && !child.startsWith(`..${sep}`) && !child.startsWith("/")) - ); -} - -function belongsToProject(candidate: string, projectDirectory: string): boolean { - if (within(projectDirectory, candidate)) return true; - const names = projectNames(projectDirectory); - // A session file can itself be named like Pi's wrapped project slug, but - // that is not evidence that its arbitrary parent directory is this project. - // Real Pi layouts put the slug in a directory above the JSONL file. - return resolve(dirname(candidate)) - .split(/[\\/]/) - .some((part) => names.has(part)); -} - -function sessionIdMatches(candidate: string, sessionId: string | undefined): boolean { - return sessionId === undefined || basename(candidate).replace(/\.jsonl$/i, "") === sessionId; -} - -function walkJsonl( - directory: string, - projectDirectory: string, - sessionId?: string, - scoped = false, -): string[] { - if (!scoped && !belongsToProject(directory, projectDirectory)) { - // A shared transcript root is allowed, but only project-named descendants are traversed. - const descendants = readdirSync(directory, { withFileTypes: true }); - return descendants - .filter((entry) => entry.isDirectory() && projectNames(projectDirectory).has(entry.name)) - .flatMap((entry) => - walkJsonl(resolve(directory, entry.name), projectDirectory, sessionId, true), - ); - } - return readdirSync(directory, { withFileTypes: true }) - .sort((a, b) => a.name.localeCompare(b.name)) - .flatMap((entry) => { - const path = resolve(directory, entry.name); - if (entry.isDirectory()) return walkJsonl(path, projectDirectory, sessionId, scoped); - if (entry.isFile() && /\.jsonl$/i.test(entry.name) && sessionIdMatches(path, sessionId)) - return [path]; - return []; - }); -} - -/** Discover only the explicitly configured active project's JSONL files. */ -export function discoverSessionFiles(options: SessionDiscoveryOptions): string[] { - const configured = options.piSessionFile ?? options.env?.PI_SESSION_FILE; - if (!configured) return []; - const configuredPath = resolve(configured); - if (!existsSync(configuredPath)) return []; - const metadata = lstatSync(configuredPath); - if (metadata.isFile()) { - return /\.jsonl$/i.test(configuredPath) && - belongsToProject(configuredPath, options.projectDirectory) && - sessionIdMatches(configuredPath, options.sessionId) - ? [configuredPath] - : []; - } - if (!metadata.isDirectory()) return []; - return walkJsonl(configuredPath, options.projectDirectory, options.sessionId); -} - -export interface ParsedSession { - entries: Record[]; - invalidLines: number; -} - -/** Parse JSONL defensively; malformed or non-object lines are reported, never executed. */ -export function parseSessionJsonl(contents: string): ParsedSession { - const entries: Record[] = []; - let invalidLines = 0; - for (const line of contents.split(/\r?\n/)) { - if (!line.trim()) continue; - try { - const parsed: unknown = JSON.parse(line); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - invalidLines++; - } else { - entries.push(parsed as Record); - } - } catch { - invalidLines++; - } - } - return { entries, invalidLines }; -} - -export async function readSessionFile(filePath: string): Promise { - return readFile(filePath, "utf8"); -} - -export async function parseActiveSession( - filePath: string, - projectDirectory: string, -): Promise { - if ( - !discoverSessionFiles({ piSessionFile: filePath, projectDirectory }).includes(resolve(filePath)) - ) { - throw new Error("Active session is outside the provided project/session boundary."); - } - return parseSessionJsonl(await readSessionFile(filePath)); -} diff --git a/src/workflows/wake.ts b/src/workflows/wake.ts deleted file mode 100644 index dca563e..0000000 --- a/src/workflows/wake.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** Choose one Pi-native wake primitive for work that outlives the current turn. */ - -export type WakeMechanism = "async-child-wait" | "event-subscription" | "schedule"; - -export interface LongRunWakeRequest { - childRunId?: string; - event?: string; - schedule?: string; -} - -export type LongRunWakePlan = - | { mechanism: "async-child-wait"; childRunId: string } - | { mechanism: "event-subscription"; event: string } - | { mechanism: "schedule"; schedule: string }; - -function nonEmpty(value: string | undefined): string | undefined { - const normalized = value?.trim(); - return normalized || undefined; -} - -/** Child completion wins over event and time fallbacks because it is the precise dependency. */ -export function planLongRunWake(request: LongRunWakeRequest): LongRunWakePlan { - const childRunId = nonEmpty(request.childRunId); - if (childRunId) return { mechanism: "async-child-wait", childRunId }; - const event = nonEmpty(request.event); - if (event) return { mechanism: "event-subscription", event }; - const schedule = nonEmpty(request.schedule); - if (schedule) return { mechanism: "schedule", schedule }; - throw new Error( - "A long-run wake requires childRunId, event, or schedule; polling is not supported.", - ); -} - -export const chooseWakeStrategy = planLongRunWake; diff --git a/test/workflows/workflows.test.mjs b/test/workflows/workflows.test.mjs index 156b657..f2a8e26 100644 --- a/test/workflows/workflows.test.mjs +++ b/test/workflows/workflows.test.mjs @@ -1,15 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; import { createJiti } from "jiti"; const jiti = createJiti(import.meta.url, { interopDefault: true }); const workflows = await jiti.import("../../src/workflows/delegation.ts"); -const evidence = await jiti.import("../../src/workflows/evidence.ts"); -const sessions = await jiti.import("../../src/workflows/sessions.ts"); -const wake = await jiti.import("../../src/workflows/wake.ts"); test("semantic roles map to Pi agents and preserve read-only boundaries", () => { assert.equal(workflows.agentForRole("explore"), "scout"); @@ -70,110 +64,3 @@ test("workflow builders emit supported, awaited APIs and safely serialized input /worktree must be boolean/, ); }); - -test("evidence mapping keeps unavailable MCP categories as explicit gaps", () => { - const result = evidence.mapEvidenceSources({ - availableMcps: ["linear", "slack"], - }); - assert.equal( - result.sources.find((source) => source.category === "issue-tracker")?.status, - "available", - ); - assert.equal( - result.sources.find((source) => source.category === "real-time-chat")?.status, - "available", - ); - assert.ok(result.gaps.some((gap) => /long-form documents/i.test(gap))); - assert.ok(result.gaps.some((gap) => /error tracking/i.test(gap))); - assert.equal(result.sources.length, evidence.EVIDENCE_CATEGORIES.length); -}); - -test("session discovery stays inside the active project and parses only supplied JSONL", async () => { - const root = await mkdtemp(join(tmpdir(), "pi-session-")); - const projectA = join(root, "project-a"); - const projectB = join(root, "project-b"); - await mkdir(projectA); - await mkdir(projectB); - const active = join(projectA, "active.jsonl"); - await writeFile( - active, - '{"type":"message","text":"ok"}\nnot-json\n{"type":"message","text":"still"}\n', - ); - await writeFile(join(projectB, "unrelated.jsonl"), '{"text":"secret"}\n'); - const found = sessions.discoverSessionFiles({ - piSessionFile: active, - projectDirectory: projectA, - }); - assert.deepEqual(found, [active]); - const parsed = sessions.parseSessionJsonl(await sessions.readSessionFile(active)); - assert.deepEqual(parsed.entries, [ - { type: "message", text: "ok" }, - { type: "message", text: "still" }, - ]); - assert.equal(parsed.invalidLines, 1); - assert.deepEqual( - sessions.discoverSessionFiles({ piSessionFile: active, projectDirectory: projectB }), - [], - ); -}); - -test("session discovery recognizes Pi's wrapped project directory slug", async () => { - const root = await mkdtemp(join(tmpdir(), "pi-session-store-")); - const project = join(root, "workspace", "app"); - const slug = project.replace(/^[/\\]+/, "").replace(/[\\/]/g, "-"); - const projectSessions = join(root, `--${slug}--`); - await mkdir(project, { recursive: true }); - await mkdir(projectSessions); - const active = join(projectSessions, "active.jsonl"); - await writeFile(active, '{"type":"session"}\n'); - - assert.deepEqual( - sessions.discoverSessionFiles({ piSessionFile: active, projectDirectory: project }), - [active], - ); -}); - -test("session discovery rejects an unrelated path that merely contains the project basename", async () => { - const root = await mkdtemp(join(tmpdir(), "pi-session-impostor-")); - const project = join(root, "workspace", "app"); - const impostorDirectory = join(root, "other", "app"); - await mkdir(project, { recursive: true }); - await mkdir(impostorDirectory, { recursive: true }); - const impostor = join(impostorDirectory, "active.jsonl"); - await writeFile(impostor, '{"type":"session"}\n'); - - assert.deepEqual( - sessions.discoverSessionFiles({ piSessionFile: impostor, projectDirectory: project }), - [], - ); -}); - -test("session discovery rejects a project-slug JSONL basename outside Pi's session layout", async () => { - const root = await mkdtemp(join(tmpdir(), "pi-session-slug-impostor-")); - const project = join(root, "workspace", "app"); - await mkdir(project, { recursive: true }); - const slug = project.replace(/^[/\\]+/, "").replace(/[\\/]/g, "-"); - const impostor = join(root, `--${slug}--.jsonl`); - await writeFile(impostor, '{"type":"session"}\n'); - - assert.deepEqual( - sessions.discoverSessionFiles({ piSessionFile: impostor, projectDirectory: project }), - [], - ); -}); - -test("long-run plans choose a native wake mechanism instead of polling", () => { - assert.deepEqual(wake.planLongRunWake({ childRunId: "run-1" }), { - mechanism: "async-child-wait", - childRunId: "run-1", - }); - assert.deepEqual(wake.planLongRunWake({ event: "ci.completed" }), { - mechanism: "event-subscription", - event: "ci.completed", - }); - assert.deepEqual(wake.planLongRunWake({ schedule: "+30m" }), { - mechanism: "schedule", - schedule: "+30m", - }); - assert.throws(() => wake.planLongRunWake({}), /childRunId, event, or schedule/); -});