From 34d9cbf40355b2d1be5e9cb40eebfb6c2d4cc461 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 6 Jul 2026 16:27:19 +0300 Subject: [PATCH 1/2] feat: surface export attribution in observations --- .../phases/08-observation-surfaces.md | 3 + .../phases/10-export-audit-record.md | 15 ++- .../decisions/0032-export-audit-records.md | 11 +- packages/jig-cli/src/cli.ts | 23 +++- packages/jig-cli/tests/cli.int.test.ts | 109 +++++++++++++++++- packages/jig-cli/tests/cli.unit.test.ts | 37 ++++++ packages/jig-sdk/src/export-audit.ts | 63 ++++++++++ packages/jig-sdk/src/export.ts | 27 ++++- packages/jig-sdk/src/index.ts | 1 + packages/jig-sdk/src/projection.ts | 1 + packages/jig-sdk/src/sdk.ts | 40 ++++++- packages/jig-sdk/tests/export.unit.test.ts | 29 ++++- 12 files changed, 342 insertions(+), 17 deletions(-) create mode 100644 packages/jig-sdk/src/export-audit.ts diff --git a/docs/delivery/target-state-implementation/phases/08-observation-surfaces.md b/docs/delivery/target-state-implementation/phases/08-observation-surfaces.md index 51c8673..848a379 100644 --- a/docs/delivery/target-state-implementation/phases/08-observation-surfaces.md +++ b/docs/delivery/target-state-implementation/phases/08-observation-surfaces.md @@ -110,6 +110,9 @@ and that ask-why answers from the run's own record. - `pnpm check`; projection unit tests for every new derivation; an integration test driving a run and watching it from a second process. +- Current posture note: the shipped watch surface projects LIVE posture into the settled + snapshot signals (`progressing`, `idle`, `parked`, `blocked`, `finished`); it does not emit a + separate literal `stalled` status string. - Reviewer axes: purity of projections, additive-only record changes, SEE-3 fidelity (answers come from the record), LIVE thresholds policy-sourced not hardcoded. diff --git a/docs/delivery/target-state-implementation/phases/10-export-audit-record.md b/docs/delivery/target-state-implementation/phases/10-export-audit-record.md index abe19ca..13f0cd6 100644 --- a/docs/delivery/target-state-implementation/phases/10-export-audit-record.md +++ b/docs/delivery/target-state-implementation/phases/10-export-audit-record.md @@ -58,9 +58,13 @@ guarantee-5 commitment with no implementation trace at all. says so honestly. - Redaction is applied at export time from recorded posture, not re-derived by scanning; an ambiguity is a diagnosable stop (consistent with the append-time rule). -- One export invocation, one audit event in the settled location; goldens byte-identical (export - reads records, it does not add reference-path events — any export audit event must not land in - the exported run's log after finalization; design the audit location with the records owner). +- One export invocation, one audit event payload in the settled locations: the selected export + directory keeps the returned export-local audit file, and when that directory differs from + `/exports`, Jig mirrors the same event byte-for-byte into + `/exports/export-audit.jsonl` as the run-owned discoverability sidecar for + `inspect`/`ask-why`; goldens byte-identical (export reads records, it does not add + reference-path events — no export audit event may land in the exported run's log after + finalization). - No upload, no external sink — export writes a local artifact (`CFG-7` consumers take it from there). @@ -93,8 +97,9 @@ guarantee-5 commitment with no implementation trace at all. 3. A tampered event log (integrity sidecar mismatch) refuses export through the settled drift surface; the refusal is recorded and explained. 4. Re-export yields a new, distinguishable artifact; no path mutates an existing export. -5. Exporting a live run refuses with guidance; the export invocation carries one audit event in - the settled location. +5. Exporting a live run refuses with guidance; the export invocation carries one audit event + payload in the settled export-local audit file and, when needed for discoverability, a + byte-identical mirror in `/exports/export-audit.jsonl`. 6. The encoding/drift decisions are recorded in the design layer (ADR or deepened records.md) before the implementing code merges; goldens byte-identical. diff --git a/docs/design/decisions/0032-export-audit-records.md b/docs/design/decisions/0032-export-audit-records.md index dbe766e..2a55421 100644 --- a/docs/design/decisions/0032-export-audit-records.md +++ b/docs/design/decisions/0032-export-audit-records.md @@ -31,8 +31,12 @@ The export encoding is JSON with format id `jig.audit-export.v0`. The artifact c - redacted exported events with line numbers; and - visible withheld-event entries for unsupported, missing, or ambiguous export posture. -Export attempts write one audit event to `exports/export-audit.jsonl` (or the caller-provided -output directory), not to the run's authoritative `events.jsonl`. +Export attempts write one audit event to the caller-visible audit file in the selected export +directory: `exports/export-audit.jsonl` by default, or `export-audit.jsonl` in the caller-provided +output directory. When a caller-provided output directory differs from `/exports`, Jig also +mirrors the same event byte-for-byte into `/exports/export-audit.jsonl` as the run-owned +discoverability sidecar that `inspect` and run-level `ask-why` read. Neither copy is written to the +run's authoritative `events.jsonl`. - Successful attempts write `export.prepared` with artifact path and SHA-256. - Refused attempts write `export.denied` with a reason. @@ -48,6 +52,9 @@ timestamped artifact with a unique suffix and leaves prior artifacts intact. ## Consequences - Export can satisfy `SEE-6` without changing replay semantics or mutating finalized run logs. +- Consumers have one authority split: the selected export directory is the returned export-local + audit location, while `/exports/export-audit.jsonl` is the canonical run-owned + discoverability sidecar for later observation surfaces. - Compliance handoff gets one self-contained JSON artifact plus a local audit trail of export attempts. - Unknown or ambiguous export posture never silently leaks content; the artifact either withholds diff --git a/packages/jig-cli/src/cli.ts b/packages/jig-cli/src/cli.ts index 05ae169..4c90037 100644 --- a/packages/jig-cli/src/cli.ts +++ b/packages/jig-cli/src/cli.ts @@ -4,6 +4,7 @@ import { createJigSession, createSetupArtifacts, type DecideRunResult, + type ExportAuditRecord, type ExportRunResult, InspectRunError, type IntegrityVerification, @@ -274,6 +275,7 @@ async function handleInspect(args: string[]): Promise { inspection.cacheParseError, inspection.integrity, inspection.resumeDiagnostics, + inspection.exportAudit, ); return; } @@ -495,9 +497,10 @@ function renderAskWhy(result: AskWhyResult): void { if (result.citations.length > 0) { console.log('Citations:'); for (const citation of result.citations) { + const source = citation.source ? `${citation.source}:` : ''; const reason = citation.reason ? ` reason=${citation.reason}` : ''; const details = citation.details?.length ? ` ${citation.details.join(' ')}` : ''; - console.log(` - line ${citation.line}: ${citation.family}${reason}${details}`); + console.log(` - ${source}line ${citation.line}: ${citation.family}${reason}${details}`); } } console.log('---------------\n'); @@ -531,6 +534,7 @@ function renderProjectionInspection( cacheParseError: string | null, integrity: IntegrityVerification, resumeDiagnostics: ProjectionIssue[] = [], + exportAudit: ExportAuditRecord[] = [], ): void { console.log('\n--- Run Inspection ---'); console.log(`Run ID: ${projection.runId}`); @@ -606,6 +610,23 @@ function renderProjectionInspection( console.log(`\nChanged files: ${projection.changedFiles.join(', ')}`); } + if (exportAudit.length > 0) { + const latestExport = exportAudit[exportAudit.length - 1]; + if (latestExport) { + console.log('\nLatest Export Attempt:'); + console.log(` - ${latestExport.event.family} at ${latestExport.event.timestamp}`); + if (latestExport.event.artifactPath) { + console.log(` Artifact: ${latestExport.event.artifactPath}`); + } + if (latestExport.event.artifactSha256) { + console.log(` SHA-256: ${latestExport.event.artifactSha256}`); + } + if (latestExport.event.reason) { + console.log(` Reason: ${latestExport.event.reason}`); + } + } + } + console.log('----------------------\n'); } diff --git a/packages/jig-cli/tests/cli.int.test.ts b/packages/jig-cli/tests/cli.int.test.ts index e3ac08d..2472c89 100644 --- a/packages/jig-cli/tests/cli.int.test.ts +++ b/packages/jig-cli/tests/cli.int.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert'; -import { execSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { execSync, spawn } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { RunRecord } from '@agentic-workflow-kit/jig-sdk'; @@ -10,6 +10,16 @@ const configFlag = '--config tests/fixtures/m5b-local-mvp/local-config.json'; const policyFlag = '--policy tests/fixtures/m5b-local-mvp/local-policy.json'; const successOutputFlag = '--scripted-output tests/fixtures/m5b-local-mvp/scripted-worker-success.json'; +async function waitForPath(path: string, timeoutMs = 5_000): Promise { + const startedAt = Date.now(); + while (!existsSync(path)) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error(`Timed out waiting for ${path}`); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + test('CLI smoke test: valid minimal plan', () => { const output = execSync( `node packages/jig-cli/bin/jig.js run tests/fixtures/m5b-local-mvp/minimal-plan.json ${configFlag} ${policyFlag} ${successOutputFlag}`, @@ -245,3 +255,98 @@ test('CLI inspect test: policy denial shows reason', () => { const inspectOutput = execSync(`node packages/jig-cli/bin/jig.js inspect ${runDir}`, { encoding: 'utf8' }); assert.match(inspectOutput, /Reason: Policy denial: allowLocalDryRun is not true/); }); + +test('P08-F11: watch observes a live run from a second process and then the finished record', async () => { + const runRoot = mkdtempSync(join(tmpdir(), 'jig-watch-int-')); + const runDir = join(runRoot, 'run-live-watch'); + mkdirSync(runDir, { recursive: true }); + const readyFile = join(runRoot, 'ready'); + const continueFile = join(runRoot, 'continue'); + + const driverScript = ` + const { mkdirSync, writeFileSync, appendFileSync, existsSync } = require('node:fs'); + const { join } = require('node:path'); + const runDir = process.argv[1]; + const readyFile = process.argv[2]; + const continueFile = process.argv[3]; + const eventsPath = join(runDir, 'events.jsonl'); + const write = (event) => appendFileSync(eventsPath, JSON.stringify(event) + '\\n'); + mkdirSync(runDir, { recursive: true }); + writeFileSync(eventsPath, ''); + write({ + family: 'run.started', + actor: 'runner', + timestamp: '2026-07-06T09:00:00.000Z', + runId: 'run-cli-watch-int', + planId: 'plan-cli-watch-int', + mode: 'local-dry-run', + binding: { + policyRef: 'policy-cli-watch-int', + configRef: 'mode=local-dry-run;recordDir=runs', + workspace: { repoRoot: runDir, head: '0123456789abcdef0123456789abcdef01234567', changeSetHash: 'workspace-clean' } + }, + posture: { record: 'safe-for-owner-record', export: 'redacted' }, + planSnapshot: { ref: 'plan.snapshot.json' }, + policySnapshot: { ref: 'policy.snapshot.json' } + }); + write({ + family: 'story.started', + actor: 'runner', + timestamp: '2026-07-06T09:00:01.000Z', + storyId: 'STORY-1' + }); + writeFileSync(readyFile, 'ready\\n'); + const timer = setInterval(() => { + if (!existsSync(continueFile)) return; + clearInterval(timer); + write({ + family: 'story.done', + actor: 'runner', + timestamp: '2026-07-06T09:00:02.000Z', + storyId: 'STORY-1' + }); + write({ + family: 'run.completed', + actor: 'runner', + timestamp: '2026-07-06T09:00:03.000Z' + }); + process.exit(0); + }, 20); + `; + + const driver = spawn(process.execPath, ['-e', driverScript, runDir, readyFile, continueFile], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + try { + await waitForPath(readyFile); + + const watchWhileRunning = execSync(`node packages/jig-cli/bin/jig.js watch ${runDir}`, { + encoding: 'utf8', + }); + assert.match(watchWhileRunning, /--- Run Watch ---/); + assert.match(watchWhileRunning, /Signal: progressing/); + assert.match(watchWhileRunning, /Progressing:\n {2}- STORY-1: started/); + + writeFileSync(continueFile, 'continue\n'); + await new Promise((resolve, reject) => { + driver.once('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`driver exited with code ${String(code)}`)); + }); + driver.once('error', reject); + }); + + const watchAfterCompletion = execSync(`node packages/jig-cli/bin/jig.js watch ${runDir}`, { + encoding: 'utf8', + }); + assert.match(watchAfterCompletion, /Signal: finished/); + assert.match(watchAfterCompletion, /Done:\n {2}- STORY-1: done/); + } finally { + driver.kill(); + rmSync(runRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/jig-cli/tests/cli.unit.test.ts b/packages/jig-cli/tests/cli.unit.test.ts index 2b9590b..3f0a42c 100644 --- a/packages/jig-cli/tests/cli.unit.test.ts +++ b/packages/jig-cli/tests/cli.unit.test.ts @@ -1569,6 +1569,43 @@ test('P08-AC-3: ask-why explains stopped runs without an explicit stop reason', assert.match(loggedLines(), /because unknown-stop-cause/); }); +test('P10-F10: custom-dir export remains attributable from inspect and run-level ask-why', async () => { + const runDir = join(workDir, 'custom-export-attribution-run-dir'); + const outputDir = join(workDir, 'custom-export-audit-dir'); + mkdirSync(runDir, { recursive: true }); + writeFileSync(join(runDir, 'events.jsonl'), stringifyJsonl(stoppedProjectionEvents())); + + setArgv('export', runDir, '--output-dir', outputDir); + await run(); + expect(exitSpy).not.toHaveBeenCalled(); + + const customAudit = readFileSync(join(outputDir, 'export-audit.jsonl'), 'utf8'); + const canonicalAudit = readFileSync(join(runDir, 'exports', 'export-audit.jsonl'), 'utf8'); + assert.match(customAudit, /"family":"export\.prepared"/); + assert.strictEqual(canonicalAudit, customAudit); + + logSpy.mockClear(); + setArgv('inspect', runDir); + await run(); + expect(exitSpy).not.toHaveBeenCalled(); + + const inspectOutput = loggedLines(); + assert.match(inspectOutput, /Latest Export Attempt:/); + assert.match(inspectOutput, /export\.prepared at /); + assert.match(inspectOutput, /Artifact: .*custom-export-audit-dir.*audit-export.*\.json/); + assert.match(inspectOutput, /SHA-256: /); + + logSpy.mockClear(); + setArgv('ask-why', runDir); + await run(); + expect(exitSpy).not.toHaveBeenCalled(); + + const whyOutput = loggedLines(); + assert.match(whyOutput, /This run was exported at /); + assert.match(whyOutput, /exports\/export-audit\.jsonl:line 1: export\.prepared/); + assert.match(whyOutput, /artifact=.*custom-export-audit-dir.*audit-export.*\.json/); +}); + test('P08-AC-3: ask-why surfaces operator errors', async () => { setArgv('ask-why', join(workDir, 'missing-why-run')); await expect(run()).rejects.toBeInstanceOf(ProcessExitSentinel); diff --git a/packages/jig-sdk/src/export-audit.ts b/packages/jig-sdk/src/export-audit.ts new file mode 100644 index 0000000..31c93b4 --- /dev/null +++ b/packages/jig-sdk/src/export-audit.ts @@ -0,0 +1,63 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export interface ExportAuditEntry { + family: 'export.prepared' | 'export.denied'; + actor: 'owner'; + timestamp: string; + runId?: string; + artifactPath?: string; + artifactSha256?: string; + reason?: string; +} + +export interface ExportAuditRecord { + path: string; + line: number; + event: ExportAuditEntry; +} + +export function readExportAudit(runDir: string): ExportAuditRecord[] { + const auditPath = join(runDir, 'exports', 'export-audit.jsonl'); + if (!existsSync(auditPath)) { + return []; + } + + const lines = readFileSync(auditPath, 'utf8').split('\n'); + const records: ExportAuditRecord[] = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line === '' && index === lines.length - 1) { + break; + } + if (line.trim() === '') { + throw new Error(`Malformed export audit line ${index + 1}: empty line is not a valid audit event`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Malformed export audit line ${index + 1}: ${message}`); + } + + if ( + typeof parsed !== 'object' || + parsed === null || + !('family' in parsed) || + (((parsed as { family?: unknown }).family as string) !== 'export.prepared' && + ((parsed as { family?: unknown }).family as string) !== 'export.denied') + ) { + throw new Error(`Malformed export audit line ${index + 1}: audit event must have export family`); + } + + records.push({ + path: auditPath, + line: index + 1, + event: parsed as ExportAuditEntry, + }); + } + + return records; +} diff --git a/packages/jig-sdk/src/export.ts b/packages/jig-sdk/src/export.ts index c44078b..15331e1 100644 --- a/packages/jig-sdk/src/export.ts +++ b/packages/jig-sdk/src/export.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'node:crypto'; import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { basename, join } from 'node:path'; +import { basename, join, resolve } from 'node:path'; import { type IntegrityVerification, launchBindingExpectsIntegrity, verifyIntegritySidecar } from './integrity.js'; import { parseEventsJsonl, projectRunEvents, type RunProjection } from './projection.js'; import { RedactionAmbiguityError, redactValue } from './redaction.js'; @@ -105,16 +105,33 @@ function exportDirFor(input: ExportRunInput): string { return input.outputDir ?? join(input.runDir, EXPORTS_DIR); } +function canonicalAuditDir(runDir: string): string { + return join(runDir, EXPORTS_DIR); +} + +function auditPathForDir(outputDir: string): string { + return join(outputDir, EXPORT_AUDIT_FILE); +} + function appendAuditEvent(outputDir: string, event: ExportAuditEvent): string { mkdirSync(outputDir, { recursive: true }); - const auditPath = join(outputDir, EXPORT_AUDIT_FILE); + const auditPath = auditPathForDir(outputDir); appendFileSync(auditPath, `${JSON.stringify(event)}\n`); return auditPath; } -function refuse(input: ExportRunInput, reason: string, runId?: string): ExportRunResult { +function appendDiscoverableAuditEvent(input: ExportRunInput, event: ExportAuditEvent): string { const outputDir = exportDirFor(input); - const auditEventPath = appendAuditEvent(outputDir, { + const auditEventPath = appendAuditEvent(outputDir, event); + const canonicalDir = canonicalAuditDir(input.runDir); + if (resolve(auditPathForDir(canonicalDir)) !== resolve(auditPathForDir(outputDir))) { + appendAuditEvent(canonicalDir, event); + } + return auditEventPath; +} + +function refuse(input: ExportRunInput, reason: string, runId?: string): ExportRunResult { + const auditEventPath = appendDiscoverableAuditEvent(input, { family: 'export.denied', actor: 'owner', timestamp: new Date().toISOString(), @@ -264,7 +281,7 @@ export function exportRun(input: ExportRunInput): ExportRunResult { const path = artifactPath(outputDir, projection, createdAt); writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`, { flag: 'wx' }); const artifactSha256 = sha256(readFileSync(path)); - const auditEventPath = appendAuditEvent(outputDir, { + const auditEventPath = appendDiscoverableAuditEvent(input, { family: 'export.prepared', actor: 'owner', timestamp: createdAt, diff --git a/packages/jig-sdk/src/index.ts b/packages/jig-sdk/src/index.ts index 97219b4..b5e1fa9 100644 --- a/packages/jig-sdk/src/index.ts +++ b/packages/jig-sdk/src/index.ts @@ -1,4 +1,5 @@ export { ProviderSelectionError } from './bootstrap.js'; +export type { ExportAuditEntry, ExportAuditRecord } from './export-audit.js'; export { LocalHarness } from './harness.js'; export { type CandidateIntakeResult, diff --git a/packages/jig-sdk/src/projection.ts b/packages/jig-sdk/src/projection.ts index 4b9b265..1fa04c4 100644 --- a/packages/jig-sdk/src/projection.ts +++ b/packages/jig-sdk/src/projection.ts @@ -119,6 +119,7 @@ export interface WatchProjection { } export interface WhyCitation { + source?: string; line: number; family: string; storyId?: string; diff --git a/packages/jig-sdk/src/sdk.ts b/packages/jig-sdk/src/sdk.ts index c5c4b38..6ab6c80 100644 --- a/packages/jig-sdk/src/sdk.ts +++ b/packages/jig-sdk/src/sdk.ts @@ -2,6 +2,7 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { type ComposeRunPortsOptions, composeReferenceRun } from './bootstrap.js'; import { type ExportRunInput, type ExportRunResult, exportRun } from './export.js'; +import { type ExportAuditRecord, readExportAudit } from './export-audit.js'; import { createInMemoryStoryWorkspaceIsolation, LocalHarness } from './harness.js'; import { intakeCandidates } from './intake.js'; import { @@ -22,6 +23,7 @@ import { projectWatch, type RunProjection, type WatchProjection, + type WhyCitation, } from './projection.js'; import { RecordManager } from './records.js'; import { @@ -132,6 +134,7 @@ export interface ProjectionInspectionResult { cacheParseError: string | null; integrity: IntegrityVerification; resumeDiagnostics: ProjectionIssue[]; + exportAudit: ExportAuditRecord[]; } export interface LegacyInspectionResult { @@ -207,6 +210,27 @@ function verifyInspectIntegrity(runDir: string, eventsJsonl: string): IntegrityV }); } +function describeLatestExport(record: ExportAuditRecord): string { + const event = record.event; + if (event.family === 'export.prepared') { + return `This run was exported at ${event.timestamp}.`; + } + return `The latest export attempt for this run was refused at ${event.timestamp}${event.reason ? ` (${event.reason})` : ''}.`; +} + +function exportCitation(record: ExportAuditRecord): WhyCitation { + const details: string[] = []; + if (record.event.artifactPath) details.push(`artifact=${record.event.artifactPath}`); + if (record.event.artifactSha256) details.push(`sha256=${record.event.artifactSha256}`); + return { + source: 'exports/export-audit.jsonl', + line: record.line, + family: record.event.family, + ...(typeof record.event.reason === 'string' ? { reason: record.event.reason } : {}), + ...(details.length > 0 ? { details } : {}), + }; +} + function readProjectionInputs(runDir: string): { eventsJsonl: string; runRecord: RunRecord | null } { if (!existsSync(runDir)) { throw new Error(`Run directory "${runDir}" does not exist`); @@ -504,6 +528,7 @@ export function createJigSession(options: CreateJigSessionOptions = {}): JigSess cacheParseError, integrity, resumeDiagnostics: resumeInspectionDiagnostics(projection), + exportAudit: readExportAudit(input.runDir), }; } catch (err) { if (runRecord && isLegacyProjectionFallback(err)) { @@ -542,7 +567,20 @@ export function createJigSession(options: CreateJigSessionOptions = {}): JigSess askWhy: async (input): Promise => { const { eventsJsonl, runRecord } = readProjectionInputs(input.runDir); - return askWhyFromEvents({ eventsJsonl, runRecord, storyId: input.storyId }); + const result = askWhyFromEvents({ eventsJsonl, runRecord, storyId: input.storyId }); + if (input.storyId) { + return result; + } + const exportAudit = readExportAudit(input.runDir); + const latestExport = exportAudit.at(-1); + if (!latestExport) { + return result; + } + return { + ...result, + answer: `${result.answer} ${describeLatestExport(latestExport)}`, + citations: [...result.citations, exportCitation(latestExport)], + }; }, acknowledgeNotice: async (input): Promise => { diff --git a/packages/jig-sdk/tests/export.unit.test.ts b/packages/jig-sdk/tests/export.unit.test.ts index aa2e1d3..991fc1f 100644 --- a/packages/jig-sdk/tests/export.unit.test.ts +++ b/packages/jig-sdk/tests/export.unit.test.ts @@ -279,5 +279,32 @@ test('export: honors a custom outputDir instead of the default runDir/exports lo assert.strictEqual(result.status, 'exported'); assert.ok(result.artifactPath?.startsWith(outputDir)); - assert.strictEqual(existsSync(join(runDir, 'exports')), false); + assert.ok(existsSync(join(outputDir, 'export-audit.jsonl'))); + assert.ok(existsSync(join(runDir, 'exports', 'export-audit.jsonl'))); + assert.strictEqual( + readFileSync(join(runDir, 'exports', 'export-audit.jsonl'), 'utf8'), + readFileSync(join(outputDir, 'export-audit.jsonl'), 'utf8'), + ); +}); + +test('export: trailing-slash canonical outputDir does not duplicate the audit event', async () => { + const recordDir = tempRecordDir(); + const manager = new RecordManager(); + manager.init(plan(), config(recordDir), policy()); + manager.recordEvent({ family: 'run.started' }); + manager.recordEvent({ family: 'story.started', storyId: 'STORY-1' }); + manager.recordEvent({ family: 'story.done', storyId: 'STORY-1', changedFiles: [] }); + manager.recordEvent({ family: 'run.completed' }); + await manager.finalize('success'); + const runDir = runDirFor(recordDir); + + const outputDir = `${join(runDir, 'exports')}/`; + const result = exportRun({ runDir, outputDir }); + + assert.strictEqual(result.status, 'exported'); + const auditLines = readFileSync(join(runDir, 'exports', 'export-audit.jsonl'), 'utf8') + .trim() + .split('\n'); + assert.strictEqual(auditLines.length, 1); + assert.match(auditLines[0] ?? '', /"family":"export\.prepared"/); }); From 735b33b6300ae9ae16bd5d5d6f4fa68cc00f57da Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 6 Jul 2026 16:35:52 +0300 Subject: [PATCH 2/2] fix: harden export observation sidecars --- packages/jig-cli/src/cli.ts | 4 +++- packages/jig-cli/tests/cli.int.test.ts | 13 +++++++++-- packages/jig-cli/tests/cli.unit.test.ts | 21 +++++++++++++++++ packages/jig-sdk/src/sdk.ts | 30 +++++++++++++++++++++++-- packages/jig-sdk/tests/sdk.unit.test.ts | 19 ++++++++++++++++ 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/packages/jig-cli/src/cli.ts b/packages/jig-cli/src/cli.ts index 4c90037..cb85203 100644 --- a/packages/jig-cli/src/cli.ts +++ b/packages/jig-cli/src/cli.ts @@ -276,6 +276,7 @@ async function handleInspect(args: string[]): Promise { inspection.integrity, inspection.resumeDiagnostics, inspection.exportAudit, + inspection.exportAuditDiagnostics, ); return; } @@ -535,6 +536,7 @@ function renderProjectionInspection( integrity: IntegrityVerification, resumeDiagnostics: ProjectionIssue[] = [], exportAudit: ExportAuditRecord[] = [], + exportAuditDiagnostics: ProjectionIssue[] = [], ): void { console.log('\n--- Run Inspection ---'); console.log(`Run ID: ${projection.runId}`); @@ -580,7 +582,7 @@ function renderProjectionInspection( } } - const diagnostics = [...projection.diagnostics, ...resumeDiagnostics]; + const diagnostics = [...projection.diagnostics, ...resumeDiagnostics, ...exportAuditDiagnostics]; if (cacheParseError) { diagnostics.push({ code: 'run.json-cache-unreadable', diff --git a/packages/jig-cli/tests/cli.int.test.ts b/packages/jig-cli/tests/cli.int.test.ts index 2472c89..b9d003d 100644 --- a/packages/jig-cli/tests/cli.int.test.ts +++ b/packages/jig-cli/tests/cli.int.test.ts @@ -328,8 +328,15 @@ test('P08-F11: watch observes a live run from a second process and then the fini assert.match(watchWhileRunning, /Signal: progressing/); assert.match(watchWhileRunning, /Progressing:\n {2}- STORY-1: started/); - writeFileSync(continueFile, 'continue\n'); - await new Promise((resolve, reject) => { + const driverExit = new Promise((resolve, reject) => { + if (driver.exitCode !== null) { + if (driver.exitCode === 0) { + resolve(); + return; + } + reject(new Error(`driver exited with code ${String(driver.exitCode)}`)); + return; + } driver.once('exit', (code) => { if (code === 0) { resolve(); @@ -339,6 +346,8 @@ test('P08-F11: watch observes a live run from a second process and then the fini }); driver.once('error', reject); }); + writeFileSync(continueFile, 'continue\n'); + await driverExit; const watchAfterCompletion = execSync(`node packages/jig-cli/bin/jig.js watch ${runDir}`, { encoding: 'utf8', diff --git a/packages/jig-cli/tests/cli.unit.test.ts b/packages/jig-cli/tests/cli.unit.test.ts index 3f0a42c..259eb21 100644 --- a/packages/jig-cli/tests/cli.unit.test.ts +++ b/packages/jig-cli/tests/cli.unit.test.ts @@ -1606,6 +1606,27 @@ test('P10-F10: custom-dir export remains attributable from inspect and run-level assert.match(whyOutput, /artifact=.*custom-export-audit-dir.*audit-export.*\.json/); }); +test('P10 review: malformed export audit sidecar becomes an inspect diagnostic and does not break ask-why', async () => { + const runDir = join(workDir, 'malformed-export-audit-cli-run-dir'); + mkdirSync(join(runDir, 'exports'), { recursive: true }); + writeFileSync(join(runDir, 'events.jsonl'), stringifyJsonl(stoppedProjectionEvents())); + writeFileSync(join(runDir, 'exports', 'export-audit.jsonl'), '{"family":"export.prepared"\n'); + + setArgv('inspect', runDir); + await run(); + expect(exitSpy).not.toHaveBeenCalled(); + const inspectOutput = loggedLines(); + assert.match(inspectOutput, /export-audit-unreadable: export audit sidecar unreadable and ignored/); + + logSpy.mockClear(); + setArgv('ask-why', runDir); + await run(); + expect(exitSpy).not.toHaveBeenCalled(); + const whyOutput = loggedLines(); + assert.match(whyOutput, /The run stopped at after:STORY-2\.parked/); + assert.doesNotMatch(whyOutput, /exports\/export-audit\.jsonl/); +}); + test('P08-AC-3: ask-why surfaces operator errors', async () => { setArgv('ask-why', join(workDir, 'missing-why-run')); await expect(run()).rejects.toBeInstanceOf(ProcessExitSentinel); diff --git a/packages/jig-sdk/src/sdk.ts b/packages/jig-sdk/src/sdk.ts index 6ab6c80..ba9eeb4 100644 --- a/packages/jig-sdk/src/sdk.ts +++ b/packages/jig-sdk/src/sdk.ts @@ -135,6 +135,7 @@ export interface ProjectionInspectionResult { integrity: IntegrityVerification; resumeDiagnostics: ProjectionIssue[]; exportAudit: ExportAuditRecord[]; + exportAuditDiagnostics: ProjectionIssue[]; } export interface LegacyInspectionResult { @@ -231,6 +232,29 @@ function exportCitation(record: ExportAuditRecord): WhyCitation { }; } +function readInspectableExportAudit(runDir: string): { + exportAudit: ExportAuditRecord[]; + exportAuditDiagnostics: ProjectionIssue[]; +} { + try { + return { + exportAudit: readExportAudit(runDir), + exportAuditDiagnostics: [], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + exportAudit: [], + exportAuditDiagnostics: [ + { + code: 'export-audit-unreadable', + message: `export audit sidecar unreadable and ignored: ${message}`, + }, + ], + }; + } +} + function readProjectionInputs(runDir: string): { eventsJsonl: string; runRecord: RunRecord | null } { if (!existsSync(runDir)) { throw new Error(`Run directory "${runDir}" does not exist`); @@ -521,6 +545,7 @@ export function createJigSession(options: CreateJigSessionOptions = {}): JigSess try { const projection = projectRunEvents({ eventsJsonl, runRecord }); + const exportAudit = readInspectableExportAudit(input.runDir); return { kind: 'projection', runDir: input.runDir, @@ -528,7 +553,8 @@ export function createJigSession(options: CreateJigSessionOptions = {}): JigSess cacheParseError, integrity, resumeDiagnostics: resumeInspectionDiagnostics(projection), - exportAudit: readExportAudit(input.runDir), + exportAudit: exportAudit.exportAudit, + exportAuditDiagnostics: exportAudit.exportAuditDiagnostics, }; } catch (err) { if (runRecord && isLegacyProjectionFallback(err)) { @@ -571,7 +597,7 @@ export function createJigSession(options: CreateJigSessionOptions = {}): JigSess if (input.storyId) { return result; } - const exportAudit = readExportAudit(input.runDir); + const exportAudit = readInspectableExportAudit(input.runDir).exportAudit; const latestExport = exportAudit.at(-1); if (!latestExport) { return result; diff --git a/packages/jig-sdk/tests/sdk.unit.test.ts b/packages/jig-sdk/tests/sdk.unit.test.ts index 50d981a..c82eb29 100644 --- a/packages/jig-sdk/tests/sdk.unit.test.ts +++ b/packages/jig-sdk/tests/sdk.unit.test.ts @@ -1360,6 +1360,25 @@ test('P08-AC-3/4: operator askWhy cites the recorded story cause', async () => { assert.ok(result.citations.some((citation) => citation.family === 'story.parked' && citation.line === 6)); }); +test('P10 review: malformed export audit sidecar is ignored for inspect and run-level askWhy', async () => { + const session = createJigSession(); + const runDir = writeObservationRun('malformed-export-audit-run'); + mkdirSync(join(runDir, 'exports'), { recursive: true }); + writeFileSync(join(runDir, 'exports', 'export-audit.jsonl'), '{"family":"export.prepared"\n'); + + const inspection = await session.operator.inspect({ runDir }); + assert.strictEqual(inspection.kind, 'projection'); + assert.deepStrictEqual(inspection.exportAudit, []); + assert.deepStrictEqual( + inspection.exportAuditDiagnostics.map((diagnostic) => diagnostic.code), + ['export-audit-unreadable'], + ); + + const why = await session.operator.askWhy({ runDir }); + assert.match(why.answer, /The run stopped at after:STORY-2\.parked/); + assert.ok(!why.citations.some((citation) => citation.family === 'export.prepared')); +}); + test('P08-AC-2/4: operator acknowledgeNotice appends owner notice state', async () => { const session = createJigSession(); const runDir = writeObservationRun();