intentdna 1.5.19 → 1.5.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  {
10
10
  "name": "intentdna",
11
11
  "description": "Declarative policy layer for AI agent behavior with plugin-managed hook runtime for Claude Code.",
12
- "version": "1.5.19",
12
+ "version": "1.5.21",
13
13
  "author": {
14
14
  "name": "Samuel"
15
15
  },
@@ -25,5 +25,5 @@
25
25
  ]
26
26
  }
27
27
  ],
28
- "version": "1.5.19"
28
+ "version": "1.5.21"
29
29
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.19",
3
+ "version": "1.5.21",
4
4
  "description": "Declarative policy layer for AI agent behavior",
5
5
  "author": {
6
6
  "name": "Samuel"
package/README.md CHANGED
@@ -8,10 +8,11 @@ Intent DNA turns intent, policy, and accumulated experience into enforceable age
8
8
 
9
9
  ## What It Is
10
10
 
11
- Intent DNA is a compiler and runtime toolkit for managing agent behavior across three layers:
11
+ Intent DNA is a compiler and runtime toolkit for managing agent behavior across four layers:
12
12
 
13
13
  - **Behavioral guidance** via generated prompt/context files
14
14
  - **Deterministic enforcement** via hooks and runtime checks
15
+ - **Artifact handoff** via session-scoped manifests and resolver facts
15
16
  - **Feedback loops** via trace, audit, and epigenetic evolution
16
17
 
17
18
  It governs the agents operating on your systems rather than re-implementing the systems themselves.
@@ -81,11 +82,12 @@ Typical outputs include:
81
82
  |--------|---------|
82
83
  | `CLAUDE.md` | Behavioral guidance |
83
84
  | `.dna/compiled/ir.json` | Runtime-readable compiled constraints |
85
+ | `.dna/state/sessions/*/handoffs/*.json` | Local artifact manifests for workflow handoff facts |
84
86
  | `.claude/agents/*.md` | Role definitions |
85
87
  | `.claude/skills/*/SKILL.md` | Workflow entrypoints |
86
88
  | `.claude/settings.json` or plugin runtime | Hook wiring |
87
89
 
88
- **Key idea:** prompts can drift, but runtime constraints and feedback loops make behavior governable.
90
+ **Key idea:** prompts can drift, but runtime constraints, resolver-backed handoffs, and feedback loops make behavior governable.
89
91
 
90
92
  ---
91
93
 
@@ -97,6 +99,7 @@ Intent DNA currently focuses on:
97
99
  - Role-based agent workflows
98
100
  - Template-driven project bootstrap and upgrades
99
101
  - Deterministic guardrails and stateful enforcement
102
+ - Resolver-backed artifact handoff between workflow steps
100
103
  - Dogfood-driven template iteration
101
104
 
102
105
  Adapter and ecosystem direction are tracked in [ROADMAP.md](ROADMAP.md), not duplicated here.
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Fail-open: all errors → { continue: true, suppressOutput: true }
16
16
  */
17
- import type { ConstraintIR, VerifierSpec } from "../schema/types.js";
17
+ import type { ArtifactFact, ConstraintIR, VerifierSpec } from "../schema/types.js";
18
18
  import type { HookOutput } from "./protocol.js";
19
19
  import { blockOutput } from "./protocol.js";
20
20
  import { readWorkflowState } from "./state.js";
@@ -39,6 +39,20 @@ export declare function computeSummary(traces: Array<{
39
39
  reason?: string;
40
40
  }>): SessionSummary;
41
41
  export declare function formatSummary(s: SessionSummary): string | null;
42
+ declare function recordProducedArtifacts(projectDir: string, ir: ConstraintIR, wfState: {
43
+ workflow: string;
44
+ current_step: string;
45
+ inputs?: Record<string, string>;
46
+ resolved_variables?: Record<string, string>;
47
+ }, sessionId?: string): Promise<ArtifactFact[]>;
48
+ declare function resolveWorkflowArtifactFacts(projectDir: string, ir: ConstraintIR, wfState: {
49
+ workflow: string;
50
+ current_step: string;
51
+ inputs?: Record<string, string>;
52
+ resolved_variables?: Record<string, string>;
53
+ }, sessionId?: string): Promise<ArtifactFact[]>;
54
+ export declare const recordProducedArtifactsForTest: typeof recordProducedArtifacts;
55
+ export declare const resolveWorkflowArtifactFactsForTest: typeof resolveWorkflowArtifactFacts;
42
56
  export declare function appendStopVerifierWarnings(output: HookOutput, verifierResults: VerifierResultEntry[], currentStep?: string): HookOutput;
43
57
  export declare function runVerifiersForTest(projectDir: string, ir: ConstraintIR, workflowState: {
44
58
  workflow: string;
@@ -81,3 +95,4 @@ export declare function handlePreToolGates(ir: ConstraintIR, rawInput: Record<st
81
95
  * Fail-open: any exception returns null.
82
96
  */
83
97
  export declare function buildWorkflowGuidance(projectDir: string, wfState: Awaited<ReturnType<typeof readWorkflowState>>, sessionId?: string): Promise<string | null>;
98
+ export {};
package/dist/hooks/cli.js CHANGED
@@ -14,14 +14,14 @@
14
14
  *
15
15
  * Fail-open: all errors → { continue: true, suppressOutput: true }
16
16
  */
17
- import { readFile, realpath, stat } from "node:fs/promises";
17
+ import { mkdir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises";
18
18
  import { spawn } from "node:child_process";
19
19
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
20
20
  import { randomUUID } from "node:crypto";
21
21
  import { readStdin, writeOutput, silentOutput, allowOutput, blockOutput, stopOutput } from "./protocol.js";
22
22
  import { validateHookInput } from "./schema.js";
23
23
  import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, extractBashWritePaths, checkReflectionLimit, checkContextReadiness, checkWorkflowBoundary, } from "./enforce.js";
24
- import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces, cleanStaleState, readSurgeonAttempts, writeSurgeonAttempts, appendSessionRead, readSessionReads, appendVerifierResult } from "./state.js";
24
+ import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces, cleanStaleState, readSurgeonAttempts, writeSurgeonAttempts, appendSessionRead, readSessionReads, appendVerifierResult, appendCompletedArtifact, artifactIdentity, buildArtifactKey, readArtifactManifest, resolveArtifactTemplate, safePathComponent, writeArtifactManifest } from "./state.js";
25
25
  import { writeAuditEvent } from "../audit/index.js";
26
26
  // ── Constants ──────────────────────────────────────────────
27
27
  const DEFAULT_IR_PATH = ".dna/compiled/ir.json";
@@ -116,13 +116,15 @@ async function main() {
116
116
  completed_artifacts: wfStateRaw.completed_artifacts,
117
117
  iteration: wfStateRaw.iteration, // G4: pass iteration for state-driven rules
118
118
  };
119
- // Re-run fallback: scan consumed artifact paths on disk so
120
- // enforceHandoffConsumes can skip blocks for files that already exist.
121
- if (event === "PreToolUse") {
122
- state.existingArtifactPaths = await scanConsumedArtifactPaths(projectDir, ir, wfStateRaw.workflow, wfStateRaw.current_step);
123
- }
119
+ state.artifactFacts = await resolveWorkflowArtifactFacts(projectDir, ir, wfStateRaw, sessionId);
124
120
  }
125
121
  }
122
+ if (event === "PreToolUse" && wfStateRaw?.active) {
123
+ try {
124
+ await captureGitCommitBefore(projectDir, ir, wfStateRaw, rawInput, sessionId);
125
+ }
126
+ catch { /* fail-open */ }
127
+ }
126
128
  // Phase 2B: PreToolUse gates (workflow boundary + context gate).
127
129
  // Run before dispatch so the block shortcircuits the rest of the pipeline.
128
130
  if (event === "PreToolUse") {
@@ -154,6 +156,7 @@ async function main() {
154
156
  current_role: wfState.current_role,
155
157
  started_at: wfState.started_at,
156
158
  completed_artifacts: wfState.completed_artifacts,
159
+ artifact_facts: wfState.active ? await finalizeAndResolveArtifacts(projectDir, ir, wfState, sessionId) : [],
157
160
  } : null;
158
161
  const verifierResults = stopContext?.active
159
162
  ? await runStopVerifiersForTest(projectDir, ir, stopContext, sessionId)
@@ -245,6 +248,13 @@ async function main() {
245
248
  }
246
249
  }
247
250
  catch { /* fail-open */ }
251
+ try {
252
+ if (wfStateRaw?.active) {
253
+ await recordGitCommitHandoff(projectDir, ir, wfStateRaw, rawInput, sessionId);
254
+ await recordProducedArtifacts(projectDir, ir, wfStateRaw, sessionId);
255
+ }
256
+ }
257
+ catch { /* fail-open */ }
248
258
  }
249
259
  // Extract target file path for trace + pattern detection
250
260
  const toolInput = typeof rawInput.tool_input === "object" && rawInput.tool_input !== null
@@ -395,36 +405,151 @@ async function loadIR(irPath) {
395
405
  return null;
396
406
  }
397
407
  }
398
- // ── Artifact Scanning ─────────────────────────────────────
399
- /**
400
- * Check consumed artifact paths on disk for the current workflow step.
401
- * Returns a Set of artifact paths that exist, enabling re-run idempotency
402
- * (prior run's output files satisfy the current step's consumes).
403
- */
404
- async function scanConsumedArtifactPaths(projectDir, ir, workflowName, currentStep) {
405
- const paths = new Set();
406
- if (!ir.workflows_ir)
407
- return paths;
408
- const activeWf = ir.workflows_ir.find(w => w.workflow_name === workflowName);
409
- if (!activeWf)
410
- return paths;
411
- const entry = activeWf.handoff_chain.find(h => h.step_id === currentStep);
412
- if (!entry?.consumes)
413
- return paths;
414
- for (const consumed of entry.consumes) {
415
- if (!consumed.path)
408
+ // ── Artifact Manifest Resolution ─────────────────────────
409
+ function workflowEntry(ir, workflowName, stepId) {
410
+ return ir.workflows_ir
411
+ ?.find(w => w.workflow_name === workflowName)
412
+ ?.handoff_chain.find(h => h.step_id === stepId) ?? null;
413
+ }
414
+ function findProducerStep(ir, workflowName, consumed) {
415
+ if (consumed.from)
416
+ return consumed.from;
417
+ const workflow = ir.workflows_ir?.find(w => w.workflow_name === workflowName);
418
+ const producer = workflow?.handoff_chain.find(h => h.produces?.some(p => p.type === consumed.type && ((p.path && consumed.path && p.path === consumed.path) ||
419
+ (p.name && consumed.name && p.name === consumed.name) ||
420
+ (p.artifact_id && consumed.artifact_id && p.artifact_id === consumed.artifact_id))));
421
+ return producer?.step_id ?? "external";
422
+ }
423
+ async function projectRelativeExistingPath(projectDir, path) {
424
+ const fullPath = resolve(projectDir, path);
425
+ let realProject;
426
+ let realTarget;
427
+ try {
428
+ realProject = await realpath(projectDir);
429
+ realTarget = await realpath(fullPath);
430
+ }
431
+ catch {
432
+ return null;
433
+ }
434
+ const rel = relative(realProject, realTarget);
435
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
436
+ return null;
437
+ await stat(realTarget);
438
+ return rel;
439
+ }
440
+ function resolvedArtifactIdentity(artifact, wfState) {
441
+ const resolvedPath = artifact.path ? resolveArtifactTemplate(artifact.path, wfState) : undefined;
442
+ if (artifact.path && !resolvedPath)
443
+ return null;
444
+ const identity = artifactIdentity(artifact, resolvedPath ?? undefined);
445
+ return identity ? { identity, resolvedPath: resolvedPath ?? undefined } : null;
446
+ }
447
+ async function writeManifestForArtifact(projectDir, workflowName, sessionId, producerStep, artifact, wfState, metadata) {
448
+ const resolved = resolvedArtifactIdentity(artifact, wfState);
449
+ if (!resolved)
450
+ return null;
451
+ const key = buildArtifactKey({ workflow: workflowName, sessionId, producerStep, type: artifact.type, identity: resolved.identity });
452
+ const fact = await writeArtifactManifest(projectDir, {
453
+ key,
454
+ type: artifact.type,
455
+ workflow: workflowName,
456
+ session_id: sessionId,
457
+ producer_step: producerStep,
458
+ identity: resolved.identity,
459
+ path: resolved.resolvedPath,
460
+ contract_path: artifact.path,
461
+ name: artifact.name,
462
+ artifact_id: artifact.artifact_id,
463
+ metadata,
464
+ });
465
+ await appendCompletedArtifact(projectDir, producerStep, {
466
+ type: artifact.type,
467
+ path: fact.manifest_path,
468
+ artifact_id: fact.id,
469
+ metadata,
470
+ }, sessionId);
471
+ return fact;
472
+ }
473
+ async function recordProducedArtifacts(projectDir, ir, wfState, sessionId) {
474
+ const sid = sessionId ?? "";
475
+ if (!sid)
476
+ return [];
477
+ const entry = workflowEntry(ir, wfState.workflow, wfState.current_step);
478
+ const facts = [];
479
+ for (const produced of entry?.produces ?? []) {
480
+ if (produced.type === "git_commit")
416
481
  continue;
417
- const fullPath = resolve(projectDir, consumed.path);
418
- try {
419
- await stat(fullPath);
420
- paths.add(consumed.path);
482
+ const resolved = resolvedArtifactIdentity(produced, wfState);
483
+ if (!resolved)
484
+ continue;
485
+ let metadata;
486
+ if (resolved.resolvedPath) {
487
+ const rel = await projectRelativeExistingPath(projectDir, resolved.resolvedPath);
488
+ if (!rel)
489
+ continue;
490
+ metadata = { source_path: rel };
421
491
  }
422
- catch {
423
- // File doesn't exist — not an error, just means artifact not available
492
+ else if (produced.type !== "summary" && produced.type !== "state" && produced.type !== "test_result") {
493
+ continue;
494
+ }
495
+ const fact = await writeManifestForArtifact(projectDir, wfState.workflow, sid, wfState.current_step, produced, wfState, metadata);
496
+ if (fact && await verifyArtifactFact(projectDir, fact))
497
+ facts.push(fact);
498
+ }
499
+ return facts;
500
+ }
501
+ async function verifyArtifactFact(projectDir, fact) {
502
+ if (fact.type === "file" || fact.type === "directory") {
503
+ if (!fact.path)
504
+ return false;
505
+ return (await projectRelativeExistingPath(projectDir, fact.path)) !== null;
506
+ }
507
+ if (fact.type === "test_result") {
508
+ if (!fact.path)
509
+ return true;
510
+ return (await projectRelativeExistingPath(projectDir, fact.path)) !== null;
511
+ }
512
+ if (fact.type === "git_commit") {
513
+ const commit = typeof fact.metadata?.commit === "string" ? fact.metadata.commit : undefined;
514
+ if (!commit)
515
+ return false;
516
+ return (await runGit(projectDir, ["cat-file", "-e", `${commit}^{commit}`])) !== null;
517
+ }
518
+ return true;
519
+ }
520
+ async function resolveWorkflowArtifactFacts(projectDir, ir, wfState, sessionId) {
521
+ const sid = sessionId ?? "";
522
+ if (!sid)
523
+ return [];
524
+ const facts = [];
525
+ const entry = workflowEntry(ir, wfState.workflow, wfState.current_step);
526
+ const contracts = [...(entry?.consumes ?? []), ...(entry?.produces ?? [])];
527
+ for (const artifact of contracts) {
528
+ if (artifact.required === false)
529
+ continue;
530
+ const producerStep = entry?.produces?.includes(artifact) ? wfState.current_step : findProducerStep(ir, wfState.workflow, artifact);
531
+ const resolved = resolvedArtifactIdentity(artifact, wfState);
532
+ if (!resolved)
533
+ continue;
534
+ const key = buildArtifactKey({ workflow: wfState.workflow, sessionId: sid, producerStep, type: artifact.type, identity: resolved.identity });
535
+ let fact = await readArtifactManifest(projectDir, key);
536
+ if (!fact && producerStep === "external" && artifact.path && resolved.resolvedPath) {
537
+ const rel = await projectRelativeExistingPath(projectDir, resolved.resolvedPath);
538
+ if (rel) {
539
+ fact = await writeManifestForArtifact(projectDir, wfState.workflow, sid, "external", artifact, wfState, { source_path: rel, external_input: true });
540
+ }
424
541
  }
542
+ if (fact && await verifyArtifactFact(projectDir, fact))
543
+ facts.push(fact);
425
544
  }
426
- return paths;
545
+ return facts;
546
+ }
547
+ async function finalizeAndResolveArtifacts(projectDir, ir, wfState, sessionId) {
548
+ await recordProducedArtifacts(projectDir, ir, wfState, sessionId);
549
+ return resolveWorkflowArtifactFacts(projectDir, ir, wfState, sessionId);
427
550
  }
551
+ export const recordProducedArtifactsForTest = recordProducedArtifacts;
552
+ export const resolveWorkflowArtifactFactsForTest = resolveWorkflowArtifactFacts;
428
553
  function clearBlockingVerifierCheckpoints(ir, workflowState) {
429
554
  if (!workflowState || !ir.verifier_specs || ir.verifier_specs.length === 0)
430
555
  return ir;
@@ -1002,6 +1127,89 @@ export async function runStopVerifiersForTest(projectDir, ir, workflowState, ses
1002
1127
  results.push(...await runVerifiersForTest(projectDir, ir, workflowState, "stop", sessionId, options));
1003
1128
  return results;
1004
1129
  }
1130
+ // ── Handoff Artifact Recording ───────────────────────────
1131
+ function stepProducesGitCommit(ir, workflow, stepId) {
1132
+ const activeWf = ir.workflows_ir?.find(w => w.workflow_name === workflow);
1133
+ const currentEntry = activeWf?.handoff_chain.find(h => h.step_id === stepId);
1134
+ return Boolean(currentEntry?.produces?.some(p => p.type === "git_commit"));
1135
+ }
1136
+ async function runGit(projectDir, args) {
1137
+ return new Promise((resolveResult) => {
1138
+ const child = spawn("git", args, { cwd: projectDir, stdio: ["ignore", "pipe", "ignore"] });
1139
+ const chunks = [];
1140
+ child.stdout.on("data", (chunk) => chunks.push(chunk));
1141
+ child.on("close", (code) => {
1142
+ if (code !== 0) {
1143
+ resolveResult(null);
1144
+ return;
1145
+ }
1146
+ resolveResult(Buffer.concat(chunks).toString("utf-8").trim());
1147
+ });
1148
+ child.on("error", () => resolveResult(null));
1149
+ });
1150
+ }
1151
+ function gitCommitCapturePath(projectDir, workflow, stepId, sessionId) {
1152
+ const safeWorkflow = safePathComponent(workflow, "workflow");
1153
+ const safeStepId = safePathComponent(stepId, "step_id");
1154
+ if (!sessionId)
1155
+ return join(projectDir, ".dna", "state", "git-commit-before", safeWorkflow, `${safeStepId}.json`);
1156
+ return join(projectDir, ".dna", "state", "sessions", safePathComponent(sessionId, "session_id"), "handoffs", safeWorkflow, safeStepId, "git-commit-before.json");
1157
+ }
1158
+ async function captureGitCommitBefore(projectDir, ir, workflowState, input, sessionId) {
1159
+ if (!sessionId)
1160
+ return;
1161
+ if (!stepProducesGitCommit(ir, workflowState.workflow, workflowState.current_step))
1162
+ return;
1163
+ if (input.tool_name !== "Bash")
1164
+ return;
1165
+ const command = typeof input.tool_input?.command === "string"
1166
+ ? input.tool_input.command
1167
+ : "";
1168
+ if (!/\bgit\s+commit\b/.test(command))
1169
+ return;
1170
+ const before = await runGit(projectDir, ["rev-parse", "HEAD"]);
1171
+ if (!before)
1172
+ return;
1173
+ const capturePath = gitCommitCapturePath(projectDir, workflowState.workflow, workflowState.current_step, sessionId);
1174
+ await mkdir(dirname(capturePath), { recursive: true });
1175
+ await writeFile(capturePath, JSON.stringify({ before, captured_at: new Date().toISOString() }, null, 2), "utf-8");
1176
+ }
1177
+ async function recordGitCommitHandoff(projectDir, ir, workflowState, input, sessionId) {
1178
+ if (!sessionId)
1179
+ return;
1180
+ if (!stepProducesGitCommit(ir, workflowState.workflow, workflowState.current_step))
1181
+ return;
1182
+ if (input.tool_name !== "Bash")
1183
+ return;
1184
+ const command = typeof input.tool_input?.command === "string"
1185
+ ? input.tool_input.command
1186
+ : "";
1187
+ if (!/\bgit\s+commit\b/.test(command))
1188
+ return;
1189
+ const capturePath = gitCommitCapturePath(projectDir, workflowState.workflow, workflowState.current_step, sessionId);
1190
+ let base;
1191
+ try {
1192
+ const capture = JSON.parse(await readFile(capturePath, "utf-8"));
1193
+ base = capture.before;
1194
+ await unlink(capturePath).catch(() => { });
1195
+ }
1196
+ catch {
1197
+ return;
1198
+ }
1199
+ if (!base)
1200
+ return;
1201
+ const commit = await runGit(projectDir, ["rev-parse", "HEAD"]);
1202
+ if (!commit || commit === base)
1203
+ return;
1204
+ const subject = await runGit(projectDir, ["log", "-1", "--format=%s", commit]);
1205
+ const changedPaths = base
1206
+ ? (await runGit(projectDir, ["diff", "--name-only", base, commit]))?.split("\n").filter(Boolean) ?? []
1207
+ : [];
1208
+ const dirtyAfter = (await runGit(projectDir, ["status", "--porcelain"]))?.split("\n").filter(Boolean) ?? [];
1209
+ const entry = workflowEntry(ir, workflowState.workflow, workflowState.current_step);
1210
+ const contract = entry?.produces?.find(p => p.type === "git_commit") ?? { type: "git_commit", description: "Git commit" };
1211
+ await writeManifestForArtifact(projectDir, workflowState.workflow, sessionId ?? "", workflowState.current_step, contract, workflowState, { commit, base, subject, changed_paths: changedPaths, dirty_after: dirtyAfter });
1212
+ }
1005
1213
  // ── Surgeon Reflection Gate ──────────────────────────────
1006
1214
  /** Regex to parse test green count from vitest/flutter test output */
1007
1215
  const TEST_PASSED_RE = /(\d+)\s+(?:tests?\s+)?passed/i;
@@ -1176,7 +1384,8 @@ export async function handlePreToolGates(ir, rawInput, wfState, projectDir, sess
1176
1384
  }
1177
1385
  if (required.length > 0) {
1178
1386
  const sessionReadsState = await readSessionReads(projectDir, sessionId);
1179
- const ready = checkContextReadiness(sessionReadsState.read_files, required);
1387
+ const resolvedRequired = required.map(path => resolveArtifactTemplate(path, wfState) ?? path);
1388
+ const ready = checkContextReadiness(sessionReadsState.read_files, resolvedRequired);
1180
1389
  if (!ready.ready) {
1181
1390
  return {
1182
1391
  output: blockOutput(`[Intent DNA] Context Gate: required context files not yet read — ${ready.missing.join(", ")}. Read them before attempting ${toolName}.`),
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * Step checkpoints are handled by the CLI, not here.
15
15
  */
16
- import type { ConstraintIR, RoleDef, CompletedArtifactEntry } from "../schema/types.js";
16
+ import type { ConstraintIR, RoleDef, ArtifactFact, CompletedArtifactEntry } from "../schema/types.js";
17
17
  import type { PreToolUseInput, PostToolUseInput, UserPromptSubmitInput, SubagentStopInput, NotificationInput, EnforceResult } from "./protocol.js";
18
18
  /** SessionStart input fields */
19
19
  export interface SessionStartInput {
@@ -31,9 +31,8 @@ export interface EnforceState {
31
31
  /** G4: current workflow iteration (for relax_after_iteration rules) */
32
32
  iteration?: number;
33
33
  };
34
- /** Artifact paths that exist on disk fallback for re-run idempotency.
35
- * CLI checks disk, passes paths here so enforce stays pure (no I/O). */
36
- existingArtifactPaths?: Set<string>;
34
+ /** Resolved artifact facts loaded by CLI/state before pure enforcement. */
35
+ artifactFacts?: ArtifactFact[];
37
36
  }
38
37
  /**
39
38
  * Enforce PreToolUse constraints.
@@ -90,6 +89,7 @@ export interface StopWorkflowContext {
90
89
  current_role: string;
91
90
  started_at: string;
92
91
  completed_artifacts?: CompletedArtifactEntry[];
92
+ artifact_facts?: ArtifactFact[];
93
93
  }
94
94
  /**
95
95
  * Enforce Stop hook — verify workflow checkpoint completion.
@@ -112,7 +112,7 @@ export declare function enforceStop(ir: ConstraintIR, input: StopEnforceInput, w
112
112
  export declare function enforceHandoffProduces(ir: ConstraintIR, wfState: {
113
113
  current_step: string;
114
114
  workflow: string;
115
- completed_artifacts?: CompletedArtifactEntry[];
115
+ artifact_facts?: ArtifactFact[];
116
116
  }): EnforceResult | null;
117
117
  /** Check if a file path is allowed by a list of write globs (prefix matching). */
118
118
  export declare function checkWriteAllowed(filePath: string, allowedGlobs: string[]): boolean;
@@ -58,7 +58,7 @@ export function enforcePreToolUse(ir, input, state, roles) {
58
58
  }
59
59
  // Layer 6: Handoff — check consumed artifacts are available
60
60
  if (state?.workflowState && ir.workflows_ir) {
61
- const result = enforceHandoffConsumes(ir, state.workflowState, state.existingArtifactPaths);
61
+ const result = enforceHandoffConsumes(ir, state.workflowState, state.artifactFacts ?? []);
62
62
  if (result)
63
63
  return { output: result, trace: { matched_rule: "handoff", step_id: state.workflowState.current_step } };
64
64
  }
@@ -283,20 +283,18 @@ export function enforceStop(ir, input, workflowState) {
283
283
  }
284
284
  }
285
285
  }
286
+ const handoffResult = enforceHandoffProduces(ir, {
287
+ current_step: workflowState.current_step,
288
+ workflow: workflowState.workflow,
289
+ artifact_facts: workflowState.artifact_facts ?? [],
290
+ });
291
+ if (handoffResult)
292
+ return handoffResult;
286
293
  if (workflowCheckpoints.length === 0) {
287
294
  return null;
288
295
  }
289
296
  const blocking = workflowCheckpoints.flatMap(cp => cp.checkpoints.filter(c => (c.action ?? "block") === "block"));
290
297
  if (blocking.length === 0) {
291
- if (workflowState.workflow) {
292
- const handoffResult = enforceHandoffProduces(ir, {
293
- current_step: workflowState.current_step,
294
- workflow: workflowState.workflow,
295
- completed_artifacts: workflowState.completed_artifacts,
296
- });
297
- if (handoffResult)
298
- return handoffResult;
299
- }
300
298
  return null;
301
299
  }
302
300
  const messages = blocking.map(c => c.message);
@@ -359,26 +357,23 @@ function getStepAdditionalPaths(ir, state) {
359
357
  * artifacts have been produced by a preceding step.
360
358
  * Returns block output if a required artifact is missing, null if all satisfied.
361
359
  */
362
- function enforceHandoffConsumes(ir, wfState, existingPaths) {
360
+ function enforceHandoffConsumes(ir, wfState, artifactFacts) {
363
361
  const activeWf = ir.workflows_ir?.find(w => w.workflow_name === wfState.workflow);
364
- if (!activeWf || activeWf.handoff_chain.length === 0)
362
+ if (!activeWf || (activeWf.handoff_chain?.length ?? 0) === 0)
365
363
  return null;
366
364
  const currentEntry = activeWf.handoff_chain.find(h => h.step_id === wfState.current_step);
367
365
  if (!currentEntry?.consumes || currentEntry.consumes.length === 0)
368
366
  return null;
369
- const completedStepIds = new Set((wfState.completed_artifacts ?? []).map(a => a.step_id));
370
- // For each consumed artifact, find the producing step and check it completed
371
367
  for (const consumed of currentEntry.consumes) {
372
- // Find which step produces this artifact
373
- const producer = activeWf.handoff_chain.find(h => h.produces?.some(p => p.type === consumed.type && ((p.path && consumed.path && p.path === consumed.path) ||
374
- (consumed.from && h.step_id === consumed.from))));
375
- if (producer && !completedStepIds.has(producer.step_id)) {
376
- // Re-run fallback: if the consumed artifact file exists on disk
377
- // (from a prior run), treat the dependency as satisfied.
378
- if (consumed.path && existingPaths?.has(consumed.path)) {
379
- continue;
380
- }
381
- return blockOutput(`[Intent DNA] Step '${wfState.current_step}' requires artifact from step '${producer.step_id}' (${consumed.description}). Run step '${producer.step_id}' first.`);
368
+ if (consumed.required === false)
369
+ continue;
370
+ const producerStep = consumed.from ?? findProducerStep(activeWf.handoff_chain, consumed) ?? "external";
371
+ const fact = artifactFacts.find(a => artifactMatches(a, consumed, producerStep, wfState.workflow));
372
+ if (!fact) {
373
+ return blockOutput(`[Intent DNA] Step '${wfState.current_step}' requires artifact '${consumed.description}' from '${producerStep}', but no verified manifest was resolved.`);
374
+ }
375
+ if (consumed.type === "git_commit" && typeof fact.metadata?.commit !== "string") {
376
+ return blockOutput(`[Intent DNA] Step '${wfState.current_step}' requires git_commit artifact from '${producerStep}', but no commit identity was recorded.`);
382
377
  }
383
378
  }
384
379
  return null;
@@ -390,22 +385,52 @@ function enforceHandoffConsumes(ir, wfState, existingPaths) {
390
385
  */
391
386
  export function enforceHandoffProduces(ir, wfState) {
392
387
  const activeWf = ir.workflows_ir?.find(w => w.workflow_name === wfState.workflow);
393
- if (!activeWf || activeWf.handoff_chain.length === 0)
388
+ if (!activeWf || (activeWf.handoff_chain?.length ?? 0) === 0)
394
389
  return null;
395
390
  const currentEntry = activeWf.handoff_chain.find(h => h.step_id === wfState.current_step);
396
391
  if (!currentEntry?.produces || currentEntry.produces.length === 0)
397
392
  return null;
398
- const currentArtifacts = (wfState.completed_artifacts ?? []).find(a => a.step_id === wfState.current_step);
393
+ const facts = wfState.artifact_facts ?? [];
399
394
  for (const produced of currentEntry.produces) {
400
- if (produced.path && (!currentArtifacts || !currentArtifacts.artifacts.some(a => a.path === produced.path))) {
395
+ if (produced.required === false)
396
+ continue;
397
+ const fact = facts.find(a => artifactMatches(a, produced, wfState.current_step, wfState.workflow));
398
+ if (!fact) {
399
+ return {
400
+ output: blockOutput(`[Intent DNA] Step '${wfState.current_step}' must produce artifact '${produced.description}' before proceeding; no verified manifest was resolved.`),
401
+ trace: { matched_rule: "handoff", step_id: wfState.current_step },
402
+ };
403
+ }
404
+ if (produced.type === "git_commit" && typeof fact.metadata?.commit !== "string") {
401
405
  return {
402
- output: blockOutput(`[Intent DNA] Step '${wfState.current_step}' must produce artifact '${produced.description}' (path: ${produced.path}) before proceeding.`),
406
+ output: blockOutput(`[Intent DNA] Step '${wfState.current_step}' must produce git_commit artifact '${produced.description}' with recorded commit identity before proceeding.`),
403
407
  trace: { matched_rule: "handoff", step_id: wfState.current_step },
404
408
  };
405
409
  }
406
410
  }
407
411
  return null;
408
412
  }
413
+ function findProducerStep(handoffChain, consumed) {
414
+ const producer = handoffChain.find(h => h.produces?.some(p => p.type === consumed.type && ((p.path && consumed.path && p.path === consumed.path) ||
415
+ (p.name && consumed.name && p.name === consumed.name) ||
416
+ (p.artifact_id && consumed.artifact_id && p.artifact_id === consumed.artifact_id))));
417
+ return producer?.step_id ?? null;
418
+ }
419
+ function artifactMatches(fact, artifact, producerStep, workflow) {
420
+ if (fact.workflow !== workflow)
421
+ return false;
422
+ if (fact.producer_step !== producerStep)
423
+ return false;
424
+ if (fact.type !== artifact.type)
425
+ return false;
426
+ if (artifact.path && fact.contract_path !== artifact.path && fact.path !== artifact.path)
427
+ return false;
428
+ if (artifact.name && fact.name !== artifact.name && fact.identity !== artifact.name)
429
+ return false;
430
+ if (artifact.artifact_id && fact.artifact_id !== artifact.artifact_id && fact.identity !== artifact.artifact_id)
431
+ return false;
432
+ return true;
433
+ }
409
434
  function enforceRoleScope(rolesScopeMap, input, additionalWritePaths = []) {
410
435
  if (!input.agent_type)
411
436
  return null;
@@ -9,7 +9,7 @@
9
9
  * All read operations are fail-safe (return null on error, never throw).
10
10
  * All write operations use atomic temp+rename pattern.
11
11
  */
12
- import type { CompletedArtifactEntry, VerifierKind, VerifierSeverity, VerifierWhen } from "../schema/types.js";
12
+ import type { ArtifactFact, ArtifactKey, ArtifactManifest, CompletedArtifactEntry, HandoffArtifact, HandoffType, VerifierKind, VerifierSeverity, VerifierWhen } from "../schema/types.js";
13
13
  /** Workflow state written by runtime and read by hooks */
14
14
  export interface DNAWorkflowState {
15
15
  active: boolean;
@@ -19,6 +19,8 @@ export interface DNAWorkflowState {
19
19
  iteration: number;
20
20
  session_id: string;
21
21
  started_at: string;
22
+ inputs?: Record<string, string>;
23
+ resolved_variables?: Record<string, string>;
22
24
  completed_artifacts?: CompletedArtifactEntry[];
23
25
  }
24
26
  /** Audit log entry (one per line in JSON Lines format) */
@@ -48,12 +50,28 @@ export interface SurgeonAttemptState {
48
50
  lesson: string;
49
51
  }>;
50
52
  }
53
+ export declare function safePathComponent(value: string, label: string): string;
51
54
  /**
52
55
  * Resolve state directory path.
53
56
  * With session isolation: `.dna/state/sessions/<sessionId>/`
54
57
  * Without: `.dna/state/`
55
58
  */
56
59
  export declare function resolveStateDir(projectDir: string, sessionId?: string): string;
60
+ export declare const ARTIFACT_RESOLVER_VERSION = 1;
61
+ export declare function isRequiredArtifact(artifact: HandoffArtifact): boolean;
62
+ export declare function artifactIdentity(artifact: HandoffArtifact, resolvedPath?: string): string | null;
63
+ export declare function artifactHash(key: ArtifactKey): string;
64
+ export declare function buildArtifactKey(params: {
65
+ workflow: string;
66
+ sessionId: string;
67
+ producerStep: string;
68
+ type: HandoffType;
69
+ identity: string;
70
+ }): ArtifactKey;
71
+ export declare function artifactManifestPath(projectDir: string, key: ArtifactKey): string;
72
+ export declare function resolveArtifactTemplate(template: string, state?: Pick<DNAWorkflowState, "inputs" | "resolved_variables">): string | null;
73
+ export declare function writeArtifactManifest(projectDir: string, manifest: Omit<ArtifactManifest, "id" | "recorded_at" | "resolver_version">): Promise<ArtifactFact>;
74
+ export declare function readArtifactManifest(projectDir: string, key: ArtifactKey): Promise<ArtifactFact | null>;
57
75
  /**
58
76
  * Read current workflow state. Returns null if not found or stale.
59
77
  * @deprecated Prefer `new DNAStateManager(projectDir, sessionId).readWorkflowState()`.
@@ -83,6 +101,8 @@ export declare function appendAudit(projectDir: string, entry: AuditEntry): Prom
83
101
  export declare function appendCompletedArtifact(projectDir: string, stepId: string, artifact: {
84
102
  type: string;
85
103
  path: string;
104
+ artifact_id?: string;
105
+ metadata?: Record<string, unknown>;
86
106
  }, sessionId?: string): Promise<void>;
87
107
  /** Atomic write: write to temp file then rename. */
88
108
  export declare function atomicWrite(filePath: string, data: string): Promise<void>;
@@ -10,10 +10,27 @@
10
10
  * All write operations use atomic temp+rename pattern.
11
11
  */
12
12
  import { readFile, writeFile, rename, mkdir, appendFile, unlink, readdir, stat } from "node:fs/promises";
13
+ import { createHash } from "node:crypto";
13
14
  import { join, dirname } from "node:path";
14
15
  // ── Constants ──────────────────────────────────────────────
15
16
  const WORKFLOW_FILE = "workflow.json";
16
17
  const DEFAULT_STALENESS_MS = 2 * 60 * 60 * 1000; // 2 hours
18
+ const SAFE_PATH_COMPONENT = /^[A-Za-z0-9._-]{1,128}$/;
19
+ const USER_ARTIFACT_PATH_ARGUMENTS = new Set(["ARGUMENTS", "arguments", "task_id"]);
20
+ export function safePathComponent(value, label) {
21
+ if (value === "." || value === ".." || !SAFE_PATH_COMPONENT.test(value)) {
22
+ throw new Error(`Invalid ${label}`);
23
+ }
24
+ return value;
25
+ }
26
+ function safeUserArtifactPathArgument(value, label) {
27
+ try {
28
+ return safePathComponent(value, label);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
17
34
  // ── Path Resolution ────────────────────────────────────────
18
35
  /**
19
36
  * Resolve state directory path.
@@ -23,10 +40,97 @@ const DEFAULT_STALENESS_MS = 2 * 60 * 60 * 1000; // 2 hours
23
40
  export function resolveStateDir(projectDir, sessionId) {
24
41
  const base = join(projectDir, ".dna", "state");
25
42
  if (sessionId) {
26
- return join(base, "sessions", sessionId);
43
+ return join(base, "sessions", safePathComponent(sessionId, "session_id"));
27
44
  }
28
45
  return base;
29
46
  }
47
+ export const ARTIFACT_RESOLVER_VERSION = 1;
48
+ export function isRequiredArtifact(artifact) {
49
+ return artifact.required !== false;
50
+ }
51
+ export function artifactIdentity(artifact, resolvedPath) {
52
+ return artifact.artifact_id ?? artifact.name ?? resolvedPath ?? (artifact.type === "git_commit" ? "git_commit" : null);
53
+ }
54
+ export function artifactHash(key) {
55
+ return createHash("sha256")
56
+ .update(JSON.stringify(key))
57
+ .digest("hex")
58
+ .slice(0, 16);
59
+ }
60
+ export function buildArtifactKey(params) {
61
+ return {
62
+ workflow: params.workflow,
63
+ session_id: params.sessionId,
64
+ producer_step: params.producerStep,
65
+ type: params.type,
66
+ identity: params.identity,
67
+ };
68
+ }
69
+ export function artifactManifestPath(projectDir, key) {
70
+ return join(resolveStateDir(projectDir, key.session_id), "handoffs", safePathComponent(key.workflow, "workflow"), safePathComponent(key.producer_step, "producer_step"), `${artifactHash(key)}.json`);
71
+ }
72
+ export function resolveArtifactTemplate(template, state) {
73
+ let resolved = template;
74
+ const inputs = state?.inputs ?? {};
75
+ const resolvedVariables = state?.resolved_variables ?? {};
76
+ resolved = resolved.replace(/\{\{(\w+)\}\}/g, (_match, name) => {
77
+ const inputValue = inputs[name];
78
+ if (inputValue !== undefined) {
79
+ return safeUserArtifactPathArgument(inputValue, name) ?? `{{${name}}}`;
80
+ }
81
+ const resolvedValue = resolvedVariables[name];
82
+ if (resolvedValue === undefined)
83
+ return `{{${name}}}`;
84
+ if (USER_ARTIFACT_PATH_ARGUMENTS.has(name)) {
85
+ return safeUserArtifactPathArgument(resolvedValue, name) ?? `{{${name}}}`;
86
+ }
87
+ return resolvedValue;
88
+ });
89
+ const args = inputs.ARGUMENTS ?? inputs.arguments ?? inputs.task_id ?? resolvedVariables.ARGUMENTS ?? resolvedVariables.arguments ?? resolvedVariables.task_id;
90
+ if (resolved.includes("$ARGUMENTS")) {
91
+ if (args === undefined)
92
+ return null;
93
+ const safeArgs = safeUserArtifactPathArgument(args, "ARGUMENTS");
94
+ if (!safeArgs)
95
+ return null;
96
+ resolved = resolved.replaceAll("$ARGUMENTS", safeArgs);
97
+ }
98
+ if (/\{\{\w+\}\}/.test(resolved))
99
+ return null;
100
+ return resolved;
101
+ }
102
+ export async function writeArtifactManifest(projectDir, manifest) {
103
+ const id = artifactHash(manifest.key);
104
+ const full = {
105
+ ...manifest,
106
+ id,
107
+ recorded_at: new Date().toISOString(),
108
+ resolver_version: ARTIFACT_RESOLVER_VERSION,
109
+ };
110
+ const manifestPath = artifactManifestPath(projectDir, manifest.key);
111
+ await atomicWrite(manifestPath, JSON.stringify(full, null, 2));
112
+ return { ...full, manifest_path: manifestPath };
113
+ }
114
+ export async function readArtifactManifest(projectDir, key) {
115
+ const manifestPath = artifactManifestPath(projectDir, key);
116
+ try {
117
+ const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
118
+ if (manifest.resolver_version !== ARTIFACT_RESOLVER_VERSION)
119
+ return null;
120
+ if (manifest.key.workflow !== key.workflow || manifest.key.session_id !== key.session_id ||
121
+ manifest.key.producer_step !== key.producer_step || manifest.key.type !== key.type ||
122
+ manifest.key.identity !== key.identity)
123
+ return null;
124
+ if (manifest.workflow !== key.workflow || manifest.session_id !== key.session_id ||
125
+ manifest.producer_step !== key.producer_step || manifest.type !== key.type ||
126
+ manifest.identity !== key.identity)
127
+ return null;
128
+ return { ...manifest, manifest_path: manifestPath };
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
30
134
  // ── Workflow State ─────────────────────────────────────────
31
135
  /**
32
136
  * Read current workflow state. Returns null if not found or stale.
@@ -121,13 +225,24 @@ export async function appendCompletedArtifact(projectDir, stepId, artifact, sess
121
225
  stepEntry = { step_id: stepId, artifacts: [] };
122
226
  artifacts.push(stepEntry);
123
227
  }
124
- // Dedup: don't add if same path already recorded
125
- if (stepEntry.artifacts.some(a => a.path === artifact.path))
228
+ const verifiedAt = new Date().toISOString();
229
+ const existing = stepEntry.artifacts.find(a => a.type === artifact.type && a.path === artifact.path);
230
+ if (existing) {
231
+ existing.verified_at = verifiedAt;
232
+ if (artifact.artifact_id)
233
+ existing.artifact_id = artifact.artifact_id;
234
+ if (artifact.metadata)
235
+ existing.metadata = artifact.metadata;
236
+ state.completed_artifacts = artifacts;
237
+ await writeWorkflowState(projectDir, state, sessionId);
126
238
  return;
239
+ }
127
240
  stepEntry.artifacts.push({
128
241
  type: artifact.type,
129
242
  path: artifact.path,
130
- verified_at: new Date().toISOString(),
243
+ verified_at: verifiedAt,
244
+ ...(artifact.artifact_id ? { artifact_id: artifact.artifact_id } : {}),
245
+ ...(artifact.metadata ? { metadata: artifact.metadata } : {}),
131
246
  });
132
247
  state.completed_artifacts = artifacts;
133
248
  await writeWorkflowState(projectDir, state, sessionId);
@@ -11,6 +11,10 @@ import { resolve } from "node:path";
11
11
  import { readWorkflowState, writeWorkflowState, readTraces, listSessions } from "../hooks/state.js";
12
12
  import { loadCompiledIR } from "../runtime/plugin-adapter.js";
13
13
  import { textResult } from "./server.js";
14
+ function isStringRecord(value) {
15
+ return typeof value === "object" && value !== null &&
16
+ Object.values(value).every(v => typeof v === "string");
17
+ }
14
18
  export function createStateTools(projectDir) {
15
19
  return [
16
20
  // ── dna_status ────────────────────────────────────────
@@ -95,6 +99,8 @@ export function createStateTools(projectDir) {
95
99
  iteration: { type: "number", description: "Current iteration number" },
96
100
  active: { type: "boolean", description: "Whether workflow is active (false to deactivate)" },
97
101
  session_id: { type: "string", description: "Session ID for isolation" },
102
+ inputs: { type: "object", description: "Workflow runtime inputs such as ARGUMENTS" },
103
+ resolved_variables: { type: "object", description: "Resolved template variables for artifact paths" },
98
104
  },
99
105
  required: ["workflow", "current_step", "current_role"],
100
106
  },
@@ -109,6 +115,8 @@ export function createStateTools(projectDir) {
109
115
  iteration: typeof args.iteration === "number" ? args.iteration : (existing?.iteration ?? 1),
110
116
  session_id: sessionId ?? existing?.session_id ?? "",
111
117
  started_at: existing?.started_at ?? new Date().toISOString(),
118
+ inputs: isStringRecord(args.inputs) ? args.inputs : existing?.inputs,
119
+ resolved_variables: isStringRecord(args.resolved_variables) ? args.resolved_variables : existing?.resolved_variables,
112
120
  completed_artifacts: existing?.completed_artifacts,
113
121
  };
114
122
  await writeWorkflowState(projectDir, state, sessionId);
@@ -70,10 +70,10 @@ export function detectEnabledEvents(ir) {
70
70
  const hasStopVerifiers = ir.verifier_specs?.some((spec) => spec.when === "stop" || spec.when === "pre_handoff" || spec.when === "post_step") ?? false;
71
71
  const hasPostToolVerifiers = ir.verifier_specs?.some((spec) => spec.when === "post_tool_use") ?? false;
72
72
  const hasHandoffs = ir.workflows_ir?.some((wf) => wf.handoff_chain.length > 0) ?? false;
73
- if (hasGates || hasFilters || hasRoleScope || hasCheckpoints) {
73
+ if (hasGates || hasFilters || hasRoleScope || hasCheckpoints || hasHandoffs) {
74
74
  events.push("preToolUse");
75
75
  }
76
- if (ir.post_execution_validators.length > 0 || hasPostToolVerifiers) {
76
+ if (ir.post_execution_validators.length > 0 || hasPostToolVerifiers || hasHandoffs) {
77
77
  events.push("postToolUse");
78
78
  }
79
79
  if (ir.prompt_directives.some(d => d.priority === "high")) {
@@ -42,6 +42,12 @@ export function compileWorkflowToSkill(plan, roles, ir, variables) {
42
42
  lines.push("</Purpose>");
43
43
  lines.push("");
44
44
  }
45
+ lines.push("<Workflow_State>");
46
+ lines.push("At workflow start, persist runtime inputs in DNA workflow state before executing steps.");
47
+ lines.push("Record the invocation argument as inputs.ARGUMENTS so handoff artifact paths like $ARGUMENTS can be resolved deterministically.");
48
+ lines.push("If template variables are shown below, persist their resolved values as resolved_variables.");
49
+ lines.push("</Workflow_State>");
50
+ lines.push("");
45
51
  // Required Context — context files that must be read before any work
46
52
  const workflowKeys = [plan.workflow_key, plan.source_workflow, plan.name].filter((key, index, keys) => Boolean(key) && keys.indexOf(key) === index);
47
53
  const requiredReadAssets = [];
@@ -94,9 +94,38 @@ export type HandoffType = "file" | "directory" | "test_result" | "git_commit" |
94
94
  export interface HandoffArtifact {
95
95
  type: HandoffType;
96
96
  path?: string;
97
+ name?: string;
98
+ artifact_id?: string;
97
99
  from?: string;
100
+ required?: boolean;
98
101
  description: string;
99
102
  }
103
+ export interface ArtifactKey {
104
+ workflow: string;
105
+ session_id: string;
106
+ producer_step: string;
107
+ type: HandoffType;
108
+ identity: string;
109
+ }
110
+ export interface ArtifactManifest {
111
+ id: string;
112
+ key: ArtifactKey;
113
+ type: HandoffType;
114
+ workflow: string;
115
+ session_id: string;
116
+ producer_step: string;
117
+ identity: string;
118
+ path?: string;
119
+ contract_path?: string;
120
+ name?: string;
121
+ artifact_id?: string;
122
+ metadata?: Record<string, unknown>;
123
+ recorded_at: string;
124
+ resolver_version: number;
125
+ }
126
+ export interface ArtifactFact extends ArtifactManifest {
127
+ manifest_path: string;
128
+ }
100
129
  /** Handoff declaration for a workflow step */
101
130
  export interface StepHandoff {
102
131
  consumes?: HandoffArtifact[];
@@ -344,13 +373,19 @@ export interface WorkflowIR {
344
373
  step_enforce_rules?: StepEnforceRule[];
345
374
  }
346
375
  /** Record of artifacts completed by a step — used for cross-session resume */
376
+ export interface CompletedArtifactMetadata {
377
+ [key: string]: unknown;
378
+ }
379
+ export interface CompletedArtifactRecord {
380
+ type: string;
381
+ path: string;
382
+ verified_at: string;
383
+ artifact_id?: string;
384
+ metadata?: CompletedArtifactMetadata;
385
+ }
347
386
  export interface CompletedArtifactEntry {
348
387
  step_id: string;
349
- artifacts: {
350
- type: string;
351
- path: string;
352
- verified_at: string;
353
- }[];
388
+ artifacts: CompletedArtifactRecord[];
354
389
  }
355
390
  /** Runtime workflow state written to .dna/state/workflow.json */
356
391
  export interface WorkflowState {
@@ -361,6 +396,8 @@ export interface WorkflowState {
361
396
  iteration: number;
362
397
  session_id: string;
363
398
  started_at: string;
399
+ inputs?: Record<string, string>;
400
+ resolved_variables?: Record<string, string>;
364
401
  completed_artifacts?: CompletedArtifactEntry[];
365
402
  }
366
403
  export interface ConstraintIR {
@@ -382,6 +382,16 @@ function validateWorkflow(workflow, roleNames, pathPrefix) {
382
382
  }
383
383
  }
384
384
  }
385
+ for (const direction of ["consumes", "produces"]) {
386
+ const artifacts = step.handoff?.[direction] ?? [];
387
+ for (let j = 0; j < artifacts.length; j++) {
388
+ const artifact = artifacts[j];
389
+ const artifactPath = `${stepPath}.handoff.${direction}[${j}]`;
390
+ if (artifact.required !== false && artifact.type !== "git_commit" && !artifact.path && !artifact.name && !artifact.artifact_id) {
391
+ errors.push({ path: artifactPath, message: "required handoff artifacts must define path, name, or artifact_id" });
392
+ }
393
+ }
394
+ }
385
395
  // checkpoints validation
386
396
  if (step.checkpoints) {
387
397
  if (!Array.isArray(step.checkpoints)) {
@@ -99,6 +99,7 @@ workflow:
99
99
  description: "Implementation to review"
100
100
  produces:
101
101
  - type: summary
102
+ required: false
102
103
  description: "Review verdict (PASS/FAIL)"
103
104
  transitions:
104
105
  - from: review
@@ -133,6 +133,7 @@ workflows:
133
133
  handoff:
134
134
  produces:
135
135
  - type: summary
136
+ name: breakpoint_analysis
136
137
  description: "Breakpoint analysis with file paths and line numbers"
137
138
  - id: fix
138
139
  role: surgeon
@@ -143,6 +144,7 @@ workflows:
143
144
  consumes:
144
145
  - type: summary
145
146
  from: trace
147
+ name: breakpoint_analysis
146
148
  description: "Breakpoint analysis from investigator"
147
149
  produces:
148
150
  - type: git_commit
@@ -159,4 +161,5 @@ workflows:
159
161
  description: "Fix commit to verify"
160
162
  produces:
161
163
  - type: summary
164
+ required: false
162
165
  description: "Verification result with screenshots"
@@ -254,13 +254,13 @@ roles:
254
254
  - "Output a structured verdict block with verdict, artifact_reviewed, sections_to_fix, evidence_paths, confidence, and summary"
255
255
 
256
256
  fix_reviewer:
257
- description: "Reviews surgeon changes against approved diagnosis, v1 evidence, and v2 architecture. Read-only."
257
+ description: "Reviews surgeon changes against approved diagnosis, v1 evidence, and v2 architecture. Writes only the structured review verdict artifact."
258
258
  tool_permissions:
259
- allow: [Read, Grep, Glob, Bash]
260
- deny: [Edit, Write, NotebookEdit]
259
+ allow: [Read, Grep, Glob, Bash, Write]
260
+ deny: [Edit, NotebookEdit]
261
261
  scope:
262
262
  read: ["**/*"]
263
- write: []
263
+ write: [".dna/reviews/**"]
264
264
  instructions:
265
265
  - "Read diagnosis spec, behavior doc, and git diff before judging"
266
266
  - "Verify changes are minimal and limited to the approved diagnosis classifications"
@@ -271,11 +271,11 @@ roles:
271
271
  test_runner:
272
272
  description: "Runs tests and reports progress delta."
273
273
  tool_permissions:
274
- allow: [Read, Grep, Glob, Bash]
275
- deny: [Edit, Write, NotebookEdit]
274
+ allow: [Read, Grep, Glob, Bash, Write]
275
+ deny: [Edit, NotebookEdit]
276
276
  scope:
277
277
  read: ["**/*"]
278
- write: []
278
+ write: [".dna/fix-progress/**"]
279
279
  instructions:
280
280
  - "Run flutter test for the target module"
281
281
  - "Parse output: green/red/skip counts"
@@ -424,6 +424,7 @@ workflows:
424
424
  description: "Diagnosis spec from analyzer"
425
425
  produces:
426
426
  - type: summary
427
+ required: false
427
428
  description: "Review verdict (APPROVE or REQUEST_REANALYSIS)"
428
429
 
429
430
  fix:
@@ -435,7 +436,13 @@ workflows:
435
436
  blocked_items_path: "docs/behavior/blocked_items.md"
436
437
  description: "Fix issues according to diagnosis spec classifications"
437
438
  prompt: |
438
- This invocation is one fix round. If prior verify_report or experience_chain context is provided, read it first and do not repeat failed approaches.
439
+ This invocation is one fix round.
440
+
441
+ Before editing, check whether .dna/fix-progress/$ARGUMENTS.md exists.
442
+ - If it exists, read it before the diagnosis spec and continue from its Next Round Focus.
443
+ - If it records REQUEST_CHANGES or BLOCKED_BY_REVIEW, address those review issues before starting unrelated diagnosis items.
444
+ - Use Failed approaches / Do not repeat to avoid repeating unsuccessful fixes.
445
+ - If it does not exist, treat this as round 1.
439
446
 
440
447
  Read context + diagnosis spec first.
441
448
 
@@ -468,9 +475,9 @@ workflows:
468
475
  depends_on: [fix_bugs]
469
476
  description: "Review surgeon changes before verification"
470
477
  prompt: |
471
- Read diagnosis spec, behavior doc, and git diff.
478
+ Read diagnosis spec and behavior doc before reviewing the surgeon's handoff changes.
472
479
 
473
- Review only the surgeon's changes:
480
+ Review only the surgeon's handoff changes:
474
481
  1. Are changes limited to BUG / UNIMPLEMENTED / INFRA / TEST_BUG items in the approved diagnosis?
475
482
  2. Is REMOVED handling absent unless explicitly approved by user?
476
483
  3. Does implementation preserve v1 behavior and follow v2 architecture?
@@ -489,7 +496,9 @@ workflows:
489
496
  }
490
497
  ```
491
498
 
492
- REQUEST_CHANGES include failure_reason and concrete next_round_focus for the next fix invocation.
499
+ Write the same verdict JSON to .dna/reviews/$ARGUMENTS-review.json before finishing.
500
+
501
+ REQUEST_CHANGES → include failure_reason and concrete next_round_focus for the next fix invocation. The next_round_focus must name the priority/classification and specific test file or evidence path to address first.
493
502
  handoff:
494
503
  consumes:
495
504
  - type: git_commit
@@ -502,7 +511,8 @@ workflows:
502
511
  path: "docs/behavior/$ARGUMENTS.md"
503
512
  description: "Behavior document"
504
513
  produces:
505
- - type: summary
514
+ - type: file
515
+ path: ".dna/reviews/$ARGUMENTS-review.json"
506
516
  description: "Review verdict (APPROVE or REQUEST_CHANGES)"
507
517
 
508
518
  - id: verify_report
@@ -528,6 +538,16 @@ workflows:
528
538
  - Failure reason: why progress stopped, if any
529
539
  - Next round focus: the priority/classification/test file to continue with, if verdict is CONTINUE or REQUEST_CHANGES
530
540
 
541
+ Always write or update .dna/fix-progress/$ARGUMENTS.md before finishing, even when review blocks verification. Include these sections:
542
+ - Current verdict: Verdict, Review, Last fix commit, Updated at
543
+ - Round summary: Fixed, New red, Blocked, Remaining, Failure reason
544
+ - Next Round Focus: non-empty when Verdict is CONTINUE or REQUEST_CHANGES
545
+ - Review Issues To Address First: reviewer issues when Review is REQUEST_CHANGES
546
+ - Failed approaches / Do not repeat: approaches that failed or caused regressions
547
+ - Evidence: commands run and relevant paths
548
+
549
+ If Verdict is PASS, mark the module complete in .dna/fix-progress/$ARGUMENTS.md.
550
+
531
551
  Verdict meanings:
532
552
  - PASS: all approved diagnosis items are fixed or intentionally skipped
533
553
  - CONTINUE: this round made progress and remaining approved items should continue in the next fix invocation
@@ -535,8 +555,9 @@ workflows:
535
555
  - BLOCKED: user confirmation is needed or repeated no-progress prevents safe continuation
536
556
  handoff:
537
557
  consumes:
538
- - type: summary
558
+ - type: file
539
559
  from: review_changes
560
+ path: ".dna/reviews/$ARGUMENTS-review.json"
540
561
  description: "Review verdict from fix_reviewer"
541
562
  - type: git_commit
542
563
  from: fix_bugs
@@ -545,5 +566,9 @@ workflows:
545
566
  path: ".dna/specs/diagnosis-$ARGUMENTS.md"
546
567
  description: "Diagnosis spec"
547
568
  produces:
569
+ - type: file
570
+ path: ".dna/fix-progress/$ARGUMENTS.md"
571
+ description: "Durable cumulative fix-progress artifact for the next fix invocation"
548
572
  - type: summary
573
+ required: false
549
574
  description: "Verification report with test delta"
@@ -142,6 +142,7 @@ workflow:
142
142
  description: "Implementation to test"
143
143
  produces:
144
144
  - type: test_result
145
+ name: test_results
145
146
  description: "Test results after fixes"
146
147
  - id: review
147
148
  role: reviewer
@@ -151,9 +152,11 @@ workflow:
151
152
  consumes:
152
153
  - type: test_result
153
154
  from: test
155
+ name: test_results
154
156
  description: "Test results to review"
155
157
  produces:
156
158
  - type: summary
159
+ required: false
157
160
  description: "Review verdict (PASS/FAIL)"
158
161
  transitions:
159
162
  - from: review
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.19",
3
+ "version": "1.5.21",
4
4
  "description": "Intent DNA — Declarative policy layer for AI agent behavior",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -239,6 +239,8 @@ output_schema:
239
239
 
240
240
  Workflows define multi-step agent pipelines with dependency ordering, parallel execution, retry, and artifact handoff.
241
241
 
242
+ Handoff artifacts compile into local session-scoped manifests. A required artifact must have a stable identity from `path`, `name`, or `artifact_id`; required `summary`/`state` artifacts without one are invalid. Runtime enforcement treats the manifest as the local fact source and uses workflow inputs such as `$ARGUMENTS` before resolving artifact keys. User-provided workflow `inputs` used in artifact path templates are constrained to safe single path components; trusted `resolved_variables` may still provide configured path prefixes. This blocks prompt/path/metadata-only satisfaction; cryptographic protection against direct manifest forgery is deferred to signing/registry work.
243
+
242
244
  ### Single Workflow
243
245
  ```yaml
244
246
  workflow:
@@ -266,6 +268,7 @@ workflow:
266
268
  description: "Implementation to review"
267
269
  produces:
268
270
  - type: summary
271
+ required: false
269
272
  description: "Review verdict"
270
273
 
271
274
  transitions: