Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<runDir>/exports`, Jig mirrors the same event byte-for-byte into
`<runDir>/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).

Expand Down Expand Up @@ -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 `<runDir>/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.

Expand Down
11 changes: 9 additions & 2 deletions docs/design/decisions/0032-export-audit-records.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<runDir>/exports`, Jig also
mirrors the same event byte-for-byte into `<runDir>/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.
Expand All @@ -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 `<runDir>/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
Expand Down
27 changes: 25 additions & 2 deletions packages/jig-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
createJigSession,
createSetupArtifacts,
type DecideRunResult,
type ExportAuditRecord,
type ExportRunResult,
InspectRunError,
type IntegrityVerification,
Expand Down Expand Up @@ -274,6 +275,8 @@ async function handleInspect(args: string[]): Promise<void> {
inspection.cacheParseError,
inspection.integrity,
inspection.resumeDiagnostics,
inspection.exportAudit,
inspection.exportAuditDiagnostics,
);
return;
}
Expand Down Expand Up @@ -495,9 +498,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');
Expand Down Expand Up @@ -531,6 +535,8 @@ function renderProjectionInspection(
cacheParseError: string | null,
integrity: IntegrityVerification,
resumeDiagnostics: ProjectionIssue[] = [],
exportAudit: ExportAuditRecord[] = [],
exportAuditDiagnostics: ProjectionIssue[] = [],
): void {
console.log('\n--- Run Inspection ---');
console.log(`Run ID: ${projection.runId}`);
Expand Down Expand Up @@ -576,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',
Expand Down Expand Up @@ -606,6 +612,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');
}

Expand Down
118 changes: 116 additions & 2 deletions packages/jig-cli/tests/cli.int.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<void> {
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}`,
Expand Down Expand Up @@ -245,3 +255,107 @@ 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/);

const driverExit = new Promise<void>((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();
return;
}
reject(new Error(`driver exited with code ${String(code)}`));
});
driver.once('error', reject);
});
writeFileSync(continueFile, 'continue\n');
await driverExit;

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 });
}
});
58 changes: 58 additions & 0 deletions packages/jig-cli/tests/cli.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,64 @@ 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('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);
Expand Down
63 changes: 63 additions & 0 deletions packages/jig-sdk/src/export-audit.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading