intentdna 1.5.20 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -2
- package/dist/hooks/cli.d.ts +16 -1
- package/dist/hooks/cli.js +242 -33
- package/dist/hooks/enforce.d.ts +5 -5
- package/dist/hooks/enforce.js +53 -28
- package/dist/hooks/state.d.ts +21 -1
- package/dist/hooks/state.js +119 -4
- package/dist/mcp/tools-state.js +8 -0
- package/dist/runtime/settings-adapter.js +2 -2
- package/dist/runtime/skill-adapter.js +6 -0
- package/dist/schema/types.d.ts +42 -5
- package/dist/schema/validate.js +10 -0
- package/dist/templates/code-review-pipeline.dna.yaml +1 -0
- package/dist/templates/flutter-refactoring-rescue.dna.yaml +3 -0
- package/dist/templates/flutter-rewrite.dna.yaml +14 -8
- package/dist/templates/full-pipeline.dna.yaml +3 -0
- package/package.json +1 -1
- package/spec/schema-spec.md +3 -0
|
@@ -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.
|
|
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.
|
|
28
|
+
"version": "1.5.21"
|
|
29
29
|
}
|
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
|
|
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.
|
package/dist/hooks/cli.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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
|
|
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
|
|
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}.`),
|
package/dist/hooks/enforce.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
35
|
-
|
|
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
|
-
|
|
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;
|
package/dist/hooks/enforce.js
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
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
|
|
393
|
+
const facts = wfState.artifact_facts ?? [];
|
|
399
394
|
for (const produced of currentEntry.produces) {
|
|
400
|
-
if (produced.
|
|
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}'
|
|
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;
|
package/dist/hooks/state.d.ts
CHANGED
|
@@ -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>;
|
package/dist/hooks/state.js
CHANGED
|
@@ -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
|
-
|
|
125
|
-
|
|
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:
|
|
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);
|
package/dist/mcp/tools-state.js
CHANGED
|
@@ -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 = [];
|
package/dist/schema/types.d.ts
CHANGED
|
@@ -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 {
|
package/dist/schema/validate.js
CHANGED
|
@@ -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)) {
|
|
@@ -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.
|
|
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,
|
|
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"
|
|
@@ -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:
|
|
@@ -474,9 +475,9 @@ workflows:
|
|
|
474
475
|
depends_on: [fix_bugs]
|
|
475
476
|
description: "Review surgeon changes before verification"
|
|
476
477
|
prompt: |
|
|
477
|
-
Read diagnosis spec
|
|
478
|
+
Read diagnosis spec and behavior doc before reviewing the surgeon's handoff changes.
|
|
478
479
|
|
|
479
|
-
Review only the surgeon's changes:
|
|
480
|
+
Review only the surgeon's handoff changes:
|
|
480
481
|
1. Are changes limited to BUG / UNIMPLEMENTED / INFRA / TEST_BUG items in the approved diagnosis?
|
|
481
482
|
2. Is REMOVED handling absent unless explicitly approved by user?
|
|
482
483
|
3. Does implementation preserve v1 behavior and follow v2 architecture?
|
|
@@ -495,6 +496,8 @@ workflows:
|
|
|
495
496
|
}
|
|
496
497
|
```
|
|
497
498
|
|
|
499
|
+
Write the same verdict JSON to .dna/reviews/$ARGUMENTS-review.json before finishing.
|
|
500
|
+
|
|
498
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.
|
|
499
502
|
handoff:
|
|
500
503
|
consumes:
|
|
@@ -508,7 +511,8 @@ workflows:
|
|
|
508
511
|
path: "docs/behavior/$ARGUMENTS.md"
|
|
509
512
|
description: "Behavior document"
|
|
510
513
|
produces:
|
|
511
|
-
- type:
|
|
514
|
+
- type: file
|
|
515
|
+
path: ".dna/reviews/$ARGUMENTS-review.json"
|
|
512
516
|
description: "Review verdict (APPROVE or REQUEST_CHANGES)"
|
|
513
517
|
|
|
514
518
|
- id: verify_report
|
|
@@ -551,8 +555,9 @@ workflows:
|
|
|
551
555
|
- BLOCKED: user confirmation is needed or repeated no-progress prevents safe continuation
|
|
552
556
|
handoff:
|
|
553
557
|
consumes:
|
|
554
|
-
- type:
|
|
558
|
+
- type: file
|
|
555
559
|
from: review_changes
|
|
560
|
+
path: ".dna/reviews/$ARGUMENTS-review.json"
|
|
556
561
|
description: "Review verdict from fix_reviewer"
|
|
557
562
|
- type: git_commit
|
|
558
563
|
from: fix_bugs
|
|
@@ -565,4 +570,5 @@ workflows:
|
|
|
565
570
|
path: ".dna/fix-progress/$ARGUMENTS.md"
|
|
566
571
|
description: "Durable cumulative fix-progress artifact for the next fix invocation"
|
|
567
572
|
- type: summary
|
|
573
|
+
required: false
|
|
568
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
package/spec/schema-spec.md
CHANGED
|
@@ -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:
|