intentdna 1.5.20 → 1.6.0

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.20",
12
+ "version": "1.6.0",
13
13
  "author": {
14
14
  "name": "Samuel"
15
15
  },
@@ -25,5 +25,5 @@
25
25
  ]
26
26
  }
27
27
  ],
28
- "version": "1.5.20"
28
+ "version": "1.6.0"
29
29
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "intentdna",
3
- "version": "1.5.20",
3
+ "version": "1.6.0",
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.
@@ -20,7 +20,7 @@ import { parseYAML } from "../../schema/yaml-parser.js";
20
20
  import { compileToMarkdown, injectIntoFile, removeFromFile } from "../../runtime/markdown.js";
21
21
  import { compileAllRolesToAgentMD, writeAgentMDFiles, removeAgentMDFiles } from "../../runtime/agent-md.js";
22
22
  import { removeWorkflowScripts } from "../../runtime/workflow-runner.js";
23
- import { compileWorkflowToSkill, writeSkillFiles } from "../../runtime/skill-adapter.js";
23
+ import { compileWorkflowToSkill, compileControllerToSkill, writeSkillFiles } from "../../runtime/skill-adapter.js";
24
24
  import { compilePluginSettings, detectEnabledEvents, mergeSettingsFile, recommendHookTimeouts } from "../../runtime/settings-adapter.js";
25
25
  import { createCompiledIR, writeCompiledIR } from "../../runtime/plugin-adapter.js";
26
26
  import { cascadeDNA } from "../../compiler/cascade.js";
@@ -127,11 +127,9 @@ export async function cleanStaleDNAFiles(dir, type, activeNamespaces) {
127
127
  const entries = await readdir(dir, { withFileTypes: true });
128
128
  for (const entry of entries) {
129
129
  const fullPath = resolve(dir, entry.name);
130
- // Extract namespace from filename: dna-{namespace}-*
131
- const nsMatch = entry.name.match(/^dna-([a-z0-9_-]+)-/);
132
- const fileNamespace = nsMatch ? nsMatch[1] : null;
133
- // Skip if namespace doesn't match active namespaces
134
- if (!fileNamespace || !activeNamespaces.includes(fileNamespace)) {
130
+ // Match active namespace prefixes directly so multi-hyphen names like
131
+ // dna-frw-fix-loop still resolve to namespace frw.
132
+ if (!activeNamespaces.some(ns => entry.name.startsWith(`dna-${ns}-`))) {
135
133
  continue;
136
134
  }
137
135
  if (type === "agents" && entry.isFile() && entry.name.endsWith(".md")) {
@@ -653,6 +651,7 @@ export async function runSync(opts) {
653
651
  const dnas = loadedDNAs;
654
652
  const cascadedForSkills = cascadeDNA(dnas);
655
653
  const workflows = cascadedForSkills.workflows;
654
+ const controllers = cascadedForSkills.controllers;
656
655
  const roles = cascadedForSkills.roles;
657
656
  // Collect and resolve variables from all DNA files
658
657
  const rawVars = {};
@@ -676,8 +675,8 @@ export async function runSync(opts) {
676
675
  process.stderr.write(`MCP: wrote ${Object.keys(mcpDeps).length} server(s) to ${mcpJsonPath}\n`);
677
676
  }
678
677
  }
679
- if (Object.keys(workflows).length === 0) {
680
- process.stderr.write("No workflow defined — no skills to generate\n");
678
+ if (Object.keys(workflows).length === 0 && Object.keys(controllers).length === 0) {
679
+ process.stderr.write("No workflow or controller defined — no skills to generate\n");
681
680
  }
682
681
  else {
683
682
  const { compileWorkflow } = await import("../../compiler/workflow.js");
@@ -688,6 +687,9 @@ export async function runSync(opts) {
688
687
  skillResults.push(compileWorkflowToSkill(result.plan, roles, ir, variables));
689
688
  }
690
689
  }
690
+ for (const [name, controllerDef] of Object.entries(controllers)) {
691
+ skillResults.push(compileControllerToSkill(name, controllerDef, variables, workflows));
692
+ }
691
693
  if (skillResults.length > 0) {
692
694
  const written = await writeSkillFiles(skillResults, opts.skillsDir);
693
695
  process.stderr.write(`Generated ${written.length} skill file(s) in ${opts.skillsDir}\n`);
@@ -10,12 +10,13 @@
10
10
  * - attract/repel: same target accumulates, different targets independent
11
11
  * - amplify/suppress: factors multiply (1.5x × 2.0x = 3.0x)
12
12
  */
13
- import type { IntentDNA, Gene, ContextRegion, EpigeneticMarker, RoleDef, WorkflowDef, LegibilityAssetMap, VerifierCommandPolicy } from "../schema/types.js";
13
+ import type { IntentDNA, Gene, ContextRegion, EpigeneticMarker, RoleDef, WorkflowDef, LegibilityAssetMap, VerifierCommandPolicy, ControllerDef } from "../schema/types.js";
14
14
  export interface CascadedDNA {
15
15
  genes: Record<string, Gene>;
16
16
  contexts: Record<string, ContextRegion>;
17
17
  roles: Record<string, RoleDef>;
18
18
  workflows: Record<string, WorkflowDef>;
19
+ controllers: Record<string, ControllerDef>;
19
20
  epigenetic_markers: EpigeneticMarker[];
20
21
  source_ids: string[];
21
22
  /** Warnings from cascade process (e.g., cross-namespace gene collisions) */
@@ -91,6 +91,20 @@ function prefixRecordKeys(record, ns) {
91
91
  return record;
92
92
  return Object.fromEntries(Object.entries(record).map(([key, value]) => [`${ns}_${key}`, value]));
93
93
  }
94
+ function prefixController(controller, ns) {
95
+ return {
96
+ ...controller,
97
+ diagnosis_workflow: `${ns}_${controller.diagnosis_workflow}`,
98
+ fix_workflow: `${ns}_${controller.fix_workflow}`,
99
+ roles: {
100
+ analyzer: `${ns}_${controller.roles.analyzer}`,
101
+ analysis_reviewer: `${ns}_${controller.roles.analysis_reviewer}`,
102
+ surgeon: `${ns}_${controller.roles.surgeon}`,
103
+ fix_reviewer: `${ns}_${controller.roles.fix_reviewer}`,
104
+ test_runner: `${ns}_${controller.roles.test_runner}`,
105
+ },
106
+ };
107
+ }
94
108
  function prefixLegibilityAssets(map, ns) {
95
109
  if (!map)
96
110
  return undefined;
@@ -176,6 +190,7 @@ export function cascadeDNA(layers) {
176
190
  const mergedContexts = {};
177
191
  const mergedRoles = {};
178
192
  const mergedWorkflows = {};
193
+ const mergedControllers = {};
179
194
  const allMarkers = [];
180
195
  const sourceIds = [];
181
196
  const warnings = [];
@@ -225,6 +240,15 @@ export function cascadeDNA(layers) {
225
240
  mergedWorkflows[name] = wf;
226
241
  }
227
242
  }
243
+ // Merge controllers: namespace-prefixed keys + workflow/role references
244
+ for (const [name, controller] of Object.entries(dna.controllers ?? {})) {
245
+ if (ns) {
246
+ mergedControllers[`${ns}_${name}`] = prefixController(controller, ns);
247
+ }
248
+ else {
249
+ mergedControllers[name] = controller;
250
+ }
251
+ }
228
252
  // Collect epigenetic markers
229
253
  if (dna.epigenetic?.markers) {
230
254
  allMarkers.push(...dna.epigenetic.markers);
@@ -250,6 +274,7 @@ export function cascadeDNA(layers) {
250
274
  contexts: mergedContexts,
251
275
  roles: mergedRoles,
252
276
  workflows: mergedWorkflows,
277
+ controllers: mergedControllers,
253
278
  epigenetic_markers: allMarkers,
254
279
  source_ids: sourceIds,
255
280
  warnings: warnings.length > 0 ? warnings : undefined,
@@ -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,9 +112,9 @@ 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
- /** Check if a file path is allowed by a list of write globs (prefix matching). */
117
+ /** Check if a file path is allowed by a list of write globs. */
118
118
  export declare function checkWriteAllowed(filePath: string, allowedGlobs: string[]): boolean;
119
119
  /**
120
120
  * Extract directory prefix from a glob pattern.