intentdna 1.5.16 → 1.5.18

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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +72 -92
  4. package/dist/audit/index.d.ts +12 -3
  5. package/dist/cli/commands/feedback.d.ts +3 -0
  6. package/dist/cli/commands/feedback.js +33 -11
  7. package/dist/cli/commands/run.js +1 -1
  8. package/dist/cli/commands/sync.js +5 -5
  9. package/dist/compiler/cascade.d.ts +3 -1
  10. package/dist/compiler/cascade.js +86 -2
  11. package/dist/compiler/compile.js +93 -3
  12. package/dist/compiler/workflow.d.ts +1 -0
  13. package/dist/compiler/workflow.js +1 -0
  14. package/dist/evolution/trace-bridge.d.ts +4 -5
  15. package/dist/evolution/trace-bridge.js +31 -55
  16. package/dist/hooks/cli.d.ts +18 -1
  17. package/dist/hooks/cli.js +624 -24
  18. package/dist/hooks/enforce.js +3 -2
  19. package/dist/hooks/state.d.ts +20 -1
  20. package/dist/hooks/state.js +55 -0
  21. package/dist/runtime/markdown.d.ts +1 -1
  22. package/dist/runtime/markdown.js +149 -0
  23. package/dist/runtime/settings-adapter.d.ts +3 -2
  24. package/dist/runtime/settings-adapter.js +26 -4
  25. package/dist/runtime/skill-adapter.js +49 -23
  26. package/dist/schema/types.d.ts +39 -0
  27. package/dist/schema/validate.js +143 -0
  28. package/dist/signals/index.d.ts +45 -0
  29. package/dist/signals/index.js +117 -0
  30. package/dist/templates/code-review-pipeline.dna.yaml +4 -0
  31. package/dist/templates/flutter-rewrite.dna.yaml +139 -212
  32. package/dist/templates/full-pipeline.dna.yaml +4 -0
  33. package/dist/templates/mobile-dev.dna.yaml +5 -0
  34. package/hooks/hooks.json +1 -1
  35. package/package.json +1 -1
  36. package/spec/control-plane-convergence-handoff-2026-04-24.md +432 -0
package/dist/hooks/cli.js CHANGED
@@ -14,13 +14,15 @@
14
14
  *
15
15
  * Fail-open: all errors → { continue: true, suppressOutput: true }
16
16
  */
17
- import { readFile, stat } from "node:fs/promises";
18
- import { join, resolve } from "node:path";
17
+ import { readFile, realpath, stat } from "node:fs/promises";
18
+ import { spawn } from "node:child_process";
19
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
19
20
  import { randomUUID } from "node:crypto";
20
21
  import { readStdin, writeOutput, silentOutput, allowOutput, blockOutput } from "./protocol.js";
21
22
  import { validateHookInput } from "./schema.js";
22
23
  import { enforcePreToolUse, enforcePostToolUse, enforceUserPromptSubmit, enforceSubagentStop, enforcePreCompact, enforceNotification, enforceSessionStart, enforceStop, extractBashWritePaths, checkReflectionLimit, checkContextReadiness, checkWorkflowBoundary, } from "./enforce.js";
23
- import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces, cleanStaleState, readSurgeonAttempts, writeSurgeonAttempts, appendSessionRead, readSessionReads } from "./state.js";
24
+ import { appendAudit, readWorkflowState, appendTrace, rotateTraces, readTraces, cleanStaleState, readSurgeonAttempts, writeSurgeonAttempts, appendSessionRead, readSessionReads, appendVerifierResult } from "./state.js";
25
+ import { writeAuditEvent } from "../audit/index.js";
24
26
  // ── Constants ──────────────────────────────────────────────
25
27
  const DEFAULT_IR_PATH = ".dna/compiled/ir.json";
26
28
  const VALID_EVENTS = new Set([
@@ -104,7 +106,7 @@ async function main() {
104
106
  const state = {};
105
107
  let wfStateRaw = null;
106
108
  // Load workflow state for events that need it (handoff context + PreCompact preservation)
107
- if (event === "PreToolUse" || event === "PreCompact") {
109
+ if (event === "PreToolUse" || event === "PostToolUse" || event === "PreCompact") {
108
110
  wfStateRaw = await readWorkflowState(projectDir, sessionId);
109
111
  if (wfStateRaw && wfStateRaw.active) {
110
112
  state.workflowState = {
@@ -153,12 +155,21 @@ async function main() {
153
155
  started_at: wfState.started_at,
154
156
  completed_artifacts: wfState.completed_artifacts,
155
157
  } : null;
156
- let stopResult = enforceStop(ir, {
157
- cwd: typeof rawInput.cwd === "string" ? rawInput.cwd : undefined,
158
- sessionId: sessionId,
159
- stop_reason: typeof rawInput.stop_reason === "string" ? rawInput.stop_reason : undefined,
160
- }, stopContext);
161
- let stopOutput = stopResult?.output ?? silentOutput();
158
+ const verifierResults = stopContext?.active
159
+ ? await runStopVerifiersForTest(projectDir, ir, stopContext, sessionId)
160
+ : [];
161
+ const blockingVerifierFailures = verifierResults.filter((result) => result.status === "fail" && result.severity === "block");
162
+ let stopOutput = blockingVerifierFailures.length > 0
163
+ ? blockOutput(`[Intent DNA] Verifier failures at step '${stopContext.current_step}':\n` +
164
+ blockingVerifierFailures.map((result) => ` - ${result.message ?? result.verifier_id}`).join("\n"))
165
+ : (enforceStop(clearBlockingVerifierCheckpoints(ir, stopContext), {
166
+ cwd: typeof rawInput.cwd === "string" ? rawInput.cwd : undefined,
167
+ sessionId: sessionId,
168
+ stop_reason: typeof rawInput.stop_reason === "string" ? rawInput.stop_reason : undefined,
169
+ }, stopContext)?.output ?? silentOutput());
170
+ if (blockingVerifierFailures.length === 0) {
171
+ stopOutput = appendStopVerifierWarnings(stopOutput, verifierResults, stopContext?.current_step);
172
+ }
162
173
  // Session summary: aggregate block/warn stats from trace
163
174
  try {
164
175
  const traces = await readTraces(projectDir, 1, sessionId);
@@ -216,7 +227,7 @@ async function main() {
216
227
  const readPath = typeof rawInput.tool_input?.file_path === "string"
217
228
  ? rawInput.tool_input.file_path : undefined;
218
229
  if (readPath) {
219
- await appendSessionRead(projectDir, readPath, sessionId);
230
+ await appendSessionRead(projectDir, await toProjectRelativePath(projectDir, readPath), sessionId);
220
231
  }
221
232
  }
222
233
  }
@@ -224,12 +235,22 @@ async function main() {
224
235
  try {
225
236
  const reflectionOutput = await handleSurgeonReflection(ir, rawInput, projectDir, sessionId);
226
237
  if (reflectionOutput) {
227
- // Merge: keep original output but append reflection warning
228
- if (output.suppressOutput) {
229
- output = reflectionOutput;
230
- }
231
- else if (output.reason) {
232
- output = { ...output, reason: output.reason + "\n" + (reflectionOutput.reason ?? "") };
238
+ output = appendOutputText(output, reflectionOutput.reason ?? reflectionOutput.hookSpecificOutput?.additionalContext, "PostToolUse");
239
+ }
240
+ }
241
+ catch { /* fail-open */ }
242
+ try {
243
+ if (wfStateRaw?.active) {
244
+ const verifierResults = await runVerifiersForTest(projectDir, ir, {
245
+ workflow: wfStateRaw.workflow,
246
+ current_step: wfStateRaw.current_step,
247
+ current_role: wfStateRaw.current_role,
248
+ }, "post_tool_use", sessionId);
249
+ const failingResults = verifierResults.filter((result) => result.status === "fail");
250
+ if (failingResults.length > 0) {
251
+ const verifierText = `[Intent DNA] PostToolUse verifier failures at step '${wfStateRaw.current_step}':\n` +
252
+ failingResults.map((result) => ` - ${result.message ?? result.verifier_id}`).join("\n");
253
+ output = appendOutputText(output, verifierText, "PostToolUse");
233
254
  }
234
255
  }
235
256
  }
@@ -414,6 +435,569 @@ async function scanConsumedArtifactPaths(projectDir, ir, workflowName, currentSt
414
435
  }
415
436
  return paths;
416
437
  }
438
+ function clearBlockingVerifierCheckpoints(ir, workflowState) {
439
+ if (!workflowState || !ir.verifier_specs || ir.verifier_specs.length === 0)
440
+ return ir;
441
+ const blockedIds = new Set(ir.verifier_specs
442
+ .filter((spec) => (spec.when === "stop" || spec.when === "post_step") &&
443
+ spec.kind === "checkpoint" &&
444
+ spec.severity === "block" &&
445
+ spec.workflow_name === workflowState.workflow &&
446
+ spec.step_id === workflowState.current_step)
447
+ .map((spec) => spec.checkpoint?.assert)
448
+ .filter((value) => typeof value === "string"));
449
+ if (blockedIds.size === 0)
450
+ return ir;
451
+ const filterCheckpointList = (checkpoints) => checkpoints?.map((entry) => ({
452
+ ...entry,
453
+ checkpoints: entry.checkpoints.filter((cp) => !blockedIds.has(cp.assert) || (cp.action ?? "block") !== "block"),
454
+ })).filter((entry) => entry.checkpoints.length > 0);
455
+ return {
456
+ ...ir,
457
+ step_checkpoints: filterCheckpointList(ir.step_checkpoints),
458
+ workflows_ir: ir.workflows_ir?.map((workflow) => workflow.workflow_name !== workflowState.workflow
459
+ ? workflow
460
+ : {
461
+ ...workflow,
462
+ step_checkpoints: filterCheckpointList(workflow.step_checkpoints) ?? [],
463
+ }),
464
+ };
465
+ }
466
+ const DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS = 30_000;
467
+ const MAX_VERIFIER_EVIDENCE_BYTES = 4096;
468
+ function appendOutputText(output, text, hookEventName) {
469
+ if (!text)
470
+ return output;
471
+ if (output.continue === false) {
472
+ return {
473
+ ...output,
474
+ reason: output.reason ? `${output.reason}\n${text}` : text,
475
+ };
476
+ }
477
+ const existing = output.hookSpecificOutput?.additionalContext;
478
+ if (existing) {
479
+ return {
480
+ ...output,
481
+ suppressOutput: undefined,
482
+ hookSpecificOutput: {
483
+ ...output.hookSpecificOutput,
484
+ hookEventName: output.hookSpecificOutput?.hookEventName ?? hookEventName,
485
+ additionalContext: `${existing}\n${text}`,
486
+ },
487
+ };
488
+ }
489
+ return allowOutput(text, hookEventName);
490
+ }
491
+ export function appendStopVerifierWarnings(output, verifierResults, currentStep) {
492
+ const warningVerifierFailures = verifierResults.filter((result) => result.status === "fail" && result.severity === "warn");
493
+ if (warningVerifierFailures.length === 0)
494
+ return output;
495
+ const warningText = `[Intent DNA] Verifier warnings at step '${currentStep ?? "unknown"}':\n` +
496
+ warningVerifierFailures.map((result) => ` - ${result.message ?? result.verifier_id}`).join("\n");
497
+ return appendOutputText(output, warningText, "Stop");
498
+ }
499
+ function trimEvidence(raw) {
500
+ if (!raw)
501
+ return undefined;
502
+ const trimmed = raw.trim();
503
+ if (!trimmed)
504
+ return undefined;
505
+ return trimmed.length > MAX_VERIFIER_EVIDENCE_BYTES
506
+ ? trimmed.slice(0, MAX_VERIFIER_EVIDENCE_BYTES)
507
+ : trimmed;
508
+ }
509
+ function hasUnsafeShellControl(command) {
510
+ let inSingle = false;
511
+ let inDouble = false;
512
+ let escaped = false;
513
+ for (let i = 0; i < command.length; i++) {
514
+ const ch = command[i];
515
+ const next = command[i + 1] ?? "";
516
+ if (escaped) {
517
+ escaped = false;
518
+ continue;
519
+ }
520
+ if (ch === "\\" && !inSingle) {
521
+ escaped = true;
522
+ continue;
523
+ }
524
+ if (ch === "'" && !inDouble) {
525
+ inSingle = !inSingle;
526
+ continue;
527
+ }
528
+ if (ch === '"' && !inSingle) {
529
+ inDouble = !inDouble;
530
+ continue;
531
+ }
532
+ if (!inSingle && !inDouble) {
533
+ if (ch === ";" || ch === "`" || ch === "\n" || ch === "\r")
534
+ return true;
535
+ if ((ch === "&" || ch === "|" || ch === "<" || ch === ">") && next === ch)
536
+ return true;
537
+ if (ch === "$" && next === "(")
538
+ return true;
539
+ if ((ch === "<" || ch === ">") && next === "(")
540
+ return true;
541
+ if (ch === "|" || ch === "&" || ch === "<" || ch === ">")
542
+ return true;
543
+ }
544
+ if (inDouble) {
545
+ if (ch === "`")
546
+ return true;
547
+ if (ch === "$" && next === "(")
548
+ return true;
549
+ }
550
+ }
551
+ return false;
552
+ }
553
+ function isVerifierCommandAllowed(ir, command) {
554
+ const normalized = command.trim();
555
+ const policy = ir.verifier_policy;
556
+ if (!policy)
557
+ return false;
558
+ if (policy.allow_commands?.includes(normalized))
559
+ return true;
560
+ if (hasUnsafeShellControl(normalized))
561
+ return false;
562
+ return policy.allow_command_prefixes?.some((prefix) => normalized.startsWith(prefix)) ?? false;
563
+ }
564
+ function verifierCommandPolicyMessage(command) {
565
+ return `Verifier command not allowed by verifier_policy: ${command}`;
566
+ }
567
+ function isBuiltinAssertAllowed(ir, assertName) {
568
+ const policy = ir.verifier_policy;
569
+ return policy?.allow_builtin_asserts?.includes(assertName) ?? false;
570
+ }
571
+ function verifierAssertPolicyMessage(assertName) {
572
+ return `Verifier assert not allowed by verifier_policy: ${assertName}`;
573
+ }
574
+ function summarizeCommandEvidence(stdout, stderr) {
575
+ const stdoutBytes = Buffer.byteLength(stdout, "utf8");
576
+ const stderrBytes = Buffer.byteLength(stderr, "utf8");
577
+ if (stdoutBytes === 0 && stderrBytes === 0)
578
+ return undefined;
579
+ return trimEvidence(`stdout_bytes=${stdoutBytes} stderr_bytes=${stderrBytes}`);
580
+ }
581
+ async function execVerifierCommand(projectDir, command, timeoutMs = DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS) {
582
+ return new Promise((resolvePromise) => {
583
+ const child = spawn("bash", ["-lc", command], { cwd: projectDir, stdio: ["ignore", "pipe", "pipe"] });
584
+ let stdout = "";
585
+ let stderr = "";
586
+ let settled = false;
587
+ const settle = (result) => {
588
+ if (settled)
589
+ return;
590
+ settled = true;
591
+ clearTimeout(timeoutId);
592
+ resolvePromise(result);
593
+ };
594
+ const timeoutId = setTimeout(() => {
595
+ child.kill("SIGTERM");
596
+ setTimeout(() => child.kill("SIGKILL"), 1000).unref();
597
+ settle({ passed: false, timedOut: true, exitCode: 124, evidence: summarizeCommandEvidence(stdout, stderr) });
598
+ }, timeoutMs);
599
+ child.stdout.on("data", (chunk) => {
600
+ stdout += String(chunk);
601
+ if (stdout.length > MAX_VERIFIER_EVIDENCE_BYTES) {
602
+ stdout = stdout.slice(0, MAX_VERIFIER_EVIDENCE_BYTES);
603
+ }
604
+ });
605
+ child.stderr.on("data", (chunk) => {
606
+ stderr += String(chunk);
607
+ if (stderr.length > MAX_VERIFIER_EVIDENCE_BYTES) {
608
+ stderr = stderr.slice(0, MAX_VERIFIER_EVIDENCE_BYTES);
609
+ }
610
+ });
611
+ child.on("error", (_error) => settle({
612
+ passed: false,
613
+ timedOut: false,
614
+ exitCode: 1,
615
+ evidence: summarizeCommandEvidence(stdout, stderr),
616
+ }));
617
+ child.on("close", (code) => settle({
618
+ passed: code === 0,
619
+ timedOut: false,
620
+ exitCode: code ?? 1,
621
+ evidence: summarizeCommandEvidence(stdout, stderr),
622
+ }));
623
+ });
624
+ }
625
+ async function runCheckpointVerifier(projectDir, ir, checkpoint, commandTimeoutMs = DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS) {
626
+ const assertCommands = {
627
+ clean_working_tree: "git status --porcelain",
628
+ lint_passing: "npm run lint --silent 2>/dev/null",
629
+ build_passing: "npm run build --silent 2>/dev/null",
630
+ };
631
+ if (checkpoint.command) {
632
+ if (!isVerifierCommandAllowed(ir, checkpoint.command)) {
633
+ return {
634
+ passed: false,
635
+ target: checkpoint.command,
636
+ evidence: "policy_denied",
637
+ exit_code: 126,
638
+ message: verifierCommandPolicyMessage(checkpoint.command),
639
+ };
640
+ }
641
+ const commandResult = await execVerifierCommand(projectDir, checkpoint.command, commandTimeoutMs);
642
+ return {
643
+ passed: commandResult.passed,
644
+ target: checkpoint.command,
645
+ evidence: commandResult.evidence,
646
+ exit_code: commandResult.exitCode,
647
+ message: commandResult.passed
648
+ ? checkpoint.message
649
+ : commandResult.timedOut
650
+ ? `Verifier command timed out after ${commandTimeoutMs}ms: ${checkpoint.command}`
651
+ : `Verifier command failed: ${checkpoint.command}`,
652
+ };
653
+ }
654
+ if (checkpoint.assert === "clean_working_tree") {
655
+ if (!isBuiltinAssertAllowed(ir, checkpoint.assert)) {
656
+ return {
657
+ passed: false,
658
+ target: checkpoint.assert,
659
+ evidence: "policy_denied",
660
+ exit_code: 126,
661
+ message: verifierAssertPolicyMessage(checkpoint.assert),
662
+ };
663
+ }
664
+ try {
665
+ const raw = await new Promise((resolvePromise, rejectPromise) => {
666
+ const child = spawn("git", ["status", "--porcelain"], { cwd: projectDir, stdio: ["ignore", "pipe", "ignore"] });
667
+ let stdout = "";
668
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); });
669
+ child.on("error", rejectPromise);
670
+ child.on("close", (code) => code === 0 ? resolvePromise(stdout) : rejectPromise(new Error("git status failed")));
671
+ });
672
+ const changedEntries = raw.split("\n").filter(Boolean).length;
673
+ return {
674
+ passed: raw.trim() === "",
675
+ target: checkpoint.assert,
676
+ evidence: `changed_entries=${changedEntries}`,
677
+ exit_code: 0,
678
+ message: checkpoint.message,
679
+ };
680
+ }
681
+ catch {
682
+ return { passed: false, target: checkpoint.assert, exit_code: 1, message: checkpoint.message };
683
+ }
684
+ }
685
+ if (checkpoint.assert === "no_test_regression") {
686
+ if (!isBuiltinAssertAllowed(ir, checkpoint.assert)) {
687
+ return {
688
+ passed: false,
689
+ target: checkpoint.assert,
690
+ evidence: "policy_denied",
691
+ exit_code: 126,
692
+ message: verifierAssertPolicyMessage(checkpoint.assert),
693
+ };
694
+ }
695
+ try {
696
+ const baseline = parseInt(await readFile(resolve(projectDir, ".dna/test-baseline"), "utf-8").catch(() => "0"), 10);
697
+ const current = parseInt(await readFile(resolve(projectDir, ".dna/test-current"), "utf-8").catch(() => "0"), 10);
698
+ return {
699
+ passed: current <= baseline,
700
+ target: checkpoint.assert,
701
+ evidence: `baseline=${baseline} current=${current}`,
702
+ exit_code: 0,
703
+ message: checkpoint.message,
704
+ };
705
+ }
706
+ catch {
707
+ return { passed: false, target: checkpoint.assert, exit_code: 1, message: checkpoint.message };
708
+ }
709
+ }
710
+ if (assertCommands[checkpoint.assert]) {
711
+ if (!isBuiltinAssertAllowed(ir, checkpoint.assert)) {
712
+ return {
713
+ passed: false,
714
+ target: checkpoint.assert,
715
+ evidence: "policy_denied",
716
+ exit_code: 126,
717
+ message: verifierAssertPolicyMessage(checkpoint.assert),
718
+ };
719
+ }
720
+ const commandResult = await execVerifierCommand(projectDir, assertCommands[checkpoint.assert], commandTimeoutMs);
721
+ return {
722
+ passed: commandResult.passed,
723
+ target: checkpoint.assert,
724
+ evidence: commandResult.evidence,
725
+ exit_code: commandResult.exitCode,
726
+ message: commandResult.passed
727
+ ? checkpoint.message
728
+ : commandResult.timedOut
729
+ ? `Verifier command timed out after ${commandTimeoutMs}ms: ${assertCommands[checkpoint.assert]}`
730
+ : checkpoint.message,
731
+ };
732
+ }
733
+ return { passed: false, target: checkpoint.assert, exit_code: 1, message: checkpoint.message };
734
+ }
735
+ function isWithinProjectRoot(projectRoot, targetPath) {
736
+ const relPath = relative(projectRoot, targetPath);
737
+ return relPath === "" || (!relPath.startsWith("..") && !isAbsolute(relPath));
738
+ }
739
+ async function resolveVerifierTarget(projectDir, targetPath) {
740
+ const projectRoot = await realpath(projectDir).catch(() => resolve(projectDir));
741
+ const resolvedPath = resolve(projectDir, targetPath);
742
+ if (!isWithinProjectRoot(projectRoot, resolvedPath)) {
743
+ return null;
744
+ }
745
+ const canonicalParent = await realpath(dirname(resolvedPath)).catch(() => null);
746
+ if (canonicalParent && !isWithinProjectRoot(projectRoot, canonicalParent)) {
747
+ return null;
748
+ }
749
+ const canonicalTarget = await realpath(resolvedPath).catch(() => null);
750
+ if (canonicalTarget && !isWithinProjectRoot(projectRoot, canonicalTarget)) {
751
+ return null;
752
+ }
753
+ return canonicalTarget ?? resolvedPath;
754
+ }
755
+ async function toProjectRelativePath(projectDir, filePath) {
756
+ const projectRoot = await realpath(projectDir).catch(() => resolve(projectDir));
757
+ const resolvedPath = resolve(projectDir, filePath);
758
+ const canonicalPath = await realpath(resolvedPath).catch(() => resolvedPath);
759
+ const targetPath = isWithinProjectRoot(projectRoot, canonicalPath) ? canonicalPath : resolvedPath;
760
+ const relPath = relative(projectRoot, targetPath);
761
+ return relPath === "" ? "." : relPath;
762
+ }
763
+ function getCompletionCheckFields(completion) {
764
+ if (typeof completion !== "object" || completion === null || Array.isArray(completion))
765
+ return [];
766
+ const fields = [];
767
+ if (completion.file_exists !== undefined)
768
+ fields.push("file_exists");
769
+ if (completion.file_not_empty !== undefined)
770
+ fields.push("file_not_empty");
771
+ if (completion.file_contains !== undefined)
772
+ fields.push("file_contains");
773
+ if (completion.command_success !== undefined)
774
+ fields.push("command_success");
775
+ return fields;
776
+ }
777
+ function validateCompletionVerifier(completion) {
778
+ const fields = getCompletionCheckFields(completion);
779
+ if (fields.length !== 1) {
780
+ return {
781
+ message: "Completion verifier must define exactly one check",
782
+ evidence: `fields:${fields.join(",") || "none"}`,
783
+ };
784
+ }
785
+ const field = fields[0];
786
+ if (field === "file_exists" && (typeof completion.file_exists !== "string" || !completion.file_exists)) {
787
+ return { message: "Completion verifier file_exists must be a non-empty string", evidence: "invalid:file_exists" };
788
+ }
789
+ if (field === "file_not_empty" && (typeof completion.file_not_empty !== "string" || !completion.file_not_empty)) {
790
+ return { message: "Completion verifier file_not_empty must be a non-empty string", evidence: "invalid:file_not_empty" };
791
+ }
792
+ if (field === "command_success" && (typeof completion.command_success !== "string" || !completion.command_success)) {
793
+ return { message: "Completion verifier command_success must be a non-empty string", evidence: "invalid:command_success" };
794
+ }
795
+ if (field === "file_contains") {
796
+ const fileContains = completion.file_contains;
797
+ if (typeof fileContains !== "object" || fileContains === null) {
798
+ return { message: "Completion verifier file_contains must define path and pattern", evidence: "invalid:file_contains" };
799
+ }
800
+ if (typeof fileContains.path !== "string" || !fileContains.path) {
801
+ return { message: "Completion verifier file_contains.path must be a non-empty string", evidence: "invalid:file_contains.path" };
802
+ }
803
+ if (typeof fileContains.pattern !== "string" || !fileContains.pattern) {
804
+ return { message: "Completion verifier file_contains.pattern must be a non-empty string", evidence: "invalid:file_contains.pattern" };
805
+ }
806
+ }
807
+ return null;
808
+ }
809
+ function getAuditCheckType(spec) {
810
+ if (spec.completion?.file_exists !== undefined)
811
+ return "file_exists";
812
+ if (spec.completion?.file_not_empty !== undefined)
813
+ return "file_not_empty";
814
+ if (spec.completion?.file_contains !== undefined)
815
+ return "file_contains";
816
+ if (spec.completion?.command_success !== undefined)
817
+ return "command_success";
818
+ if (spec.checkpoint?.command)
819
+ return "command_success";
820
+ return "checkpoint_assert";
821
+ }
822
+ async function writeVerifierAuditEvent(projectDir, result, spec) {
823
+ await writeAuditEvent({
824
+ timestamp: result.timestamp,
825
+ event_type: "completion_check",
826
+ workflow: result.workflow,
827
+ step_id: result.step_id,
828
+ target: result.target ?? spec.checkpoint?.assert ?? spec.id,
829
+ result: result.status,
830
+ check_type: getAuditCheckType(spec),
831
+ verifier_id: result.verifier_id,
832
+ verifier_when: result.when,
833
+ verifier_severity: result.severity,
834
+ verifier_kind: result.kind,
835
+ evidence: result.evidence,
836
+ exit_code: result.exit_code,
837
+ artifact: result.artifact,
838
+ message: result.message,
839
+ }, {
840
+ enabled: true,
841
+ storage: join(projectDir, ".dna", "audit"),
842
+ track: ["completion_checks"],
843
+ });
844
+ }
845
+ async function runVerifierSpec(projectDir, ir, spec, workflowState, sessionId, options) {
846
+ let passed = true;
847
+ let target;
848
+ let evidence;
849
+ let exit_code;
850
+ let artifact;
851
+ let message = spec.checkpoint?.message;
852
+ if (spec.kind === "checkpoint" && spec.checkpoint) {
853
+ const checkpointResult = await runCheckpointVerifier(projectDir, ir, spec.checkpoint, options?.commandTimeoutMs);
854
+ passed = checkpointResult.passed;
855
+ target = checkpointResult.target;
856
+ evidence = checkpointResult.evidence;
857
+ exit_code = checkpointResult.exit_code;
858
+ artifact = checkpointResult.artifact;
859
+ message = checkpointResult.message;
860
+ }
861
+ else if (spec.kind === "completion") {
862
+ const completion = spec.completion;
863
+ if (!completion) {
864
+ passed = false;
865
+ evidence = "fields:none";
866
+ message = "Completion verifier must define exactly one check";
867
+ }
868
+ else {
869
+ const invalidCompletion = validateCompletionVerifier(completion);
870
+ if (invalidCompletion) {
871
+ passed = false;
872
+ evidence = invalidCompletion.evidence;
873
+ message = invalidCompletion.message;
874
+ }
875
+ else if (completion.file_exists) {
876
+ target = completion.file_exists;
877
+ const resolvedTarget = await resolveVerifierTarget(projectDir, completion.file_exists);
878
+ artifact = resolvedTarget ?? undefined;
879
+ evidence = resolvedTarget ? `exists:${resolvedTarget}` : "path_escape";
880
+ if (!resolvedTarget) {
881
+ passed = false;
882
+ message = `Verifier target escapes project root: ${completion.file_exists}`;
883
+ }
884
+ else {
885
+ try {
886
+ await stat(resolvedTarget);
887
+ }
888
+ catch {
889
+ passed = false;
890
+ message = `Required file missing: ${completion.file_exists}`;
891
+ }
892
+ }
893
+ }
894
+ else if (completion.file_not_empty) {
895
+ target = completion.file_not_empty;
896
+ const resolvedTarget = await resolveVerifierTarget(projectDir, completion.file_not_empty);
897
+ artifact = resolvedTarget ?? undefined;
898
+ evidence = resolvedTarget ? `not_empty:${resolvedTarget}` : "path_escape";
899
+ if (!resolvedTarget) {
900
+ passed = false;
901
+ message = `Verifier target escapes project root: ${completion.file_not_empty}`;
902
+ }
903
+ else {
904
+ try {
905
+ const raw = await readFile(resolvedTarget, "utf-8");
906
+ evidence = `bytes=${Buffer.byteLength(raw, "utf8")}`;
907
+ if (raw.trim().length === 0) {
908
+ passed = false;
909
+ message = `Required file is empty: ${completion.file_not_empty}`;
910
+ }
911
+ }
912
+ catch {
913
+ passed = false;
914
+ message = `Required file missing: ${completion.file_not_empty}`;
915
+ }
916
+ }
917
+ }
918
+ else if (completion.file_contains) {
919
+ target = completion.file_contains.path;
920
+ const resolvedTarget = await resolveVerifierTarget(projectDir, completion.file_contains.path);
921
+ artifact = resolvedTarget ?? undefined;
922
+ evidence = `pattern:${completion.file_contains.pattern}`;
923
+ if (!resolvedTarget) {
924
+ passed = false;
925
+ message = `Verifier target escapes project root: ${completion.file_contains.path}`;
926
+ }
927
+ else {
928
+ try {
929
+ const raw = await readFile(resolvedTarget, "utf-8");
930
+ const re = new RegExp(completion.file_contains.pattern);
931
+ if (!re.test(raw)) {
932
+ passed = false;
933
+ message = `Required pattern missing in ${completion.file_contains.path}`;
934
+ }
935
+ }
936
+ catch {
937
+ passed = false;
938
+ message = `Required file missing: ${completion.file_contains.path}`;
939
+ }
940
+ }
941
+ }
942
+ else if (completion.command_success) {
943
+ target = completion.command_success;
944
+ if (!isVerifierCommandAllowed(ir, completion.command_success)) {
945
+ passed = false;
946
+ evidence = "policy_denied";
947
+ exit_code = 126;
948
+ message = verifierCommandPolicyMessage(completion.command_success);
949
+ }
950
+ else {
951
+ const commandResult = await execVerifierCommand(projectDir, completion.command_success, options?.commandTimeoutMs);
952
+ passed = commandResult.passed;
953
+ evidence = commandResult.evidence;
954
+ exit_code = commandResult.exitCode;
955
+ if (!passed) {
956
+ message = commandResult.timedOut
957
+ ? `Verifier command timed out after ${options?.commandTimeoutMs ?? DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS}ms: ${completion.command_success}`
958
+ : `Verifier command failed: ${completion.command_success}`;
959
+ }
960
+ }
961
+ }
962
+ }
963
+ }
964
+ const entry = {
965
+ verifier_id: spec.id,
966
+ when: spec.when,
967
+ severity: spec.severity,
968
+ kind: spec.kind,
969
+ workflow: workflowState.workflow,
970
+ step_id: workflowState.current_step,
971
+ source_genes: spec.source_genes,
972
+ status: passed ? "pass" : "fail",
973
+ target,
974
+ evidence,
975
+ exit_code,
976
+ artifact,
977
+ message,
978
+ timestamp: new Date().toISOString(),
979
+ };
980
+ await appendVerifierResult(projectDir, entry, sessionId);
981
+ await writeVerifierAuditEvent(projectDir, entry, spec).catch(() => { });
982
+ return entry;
983
+ }
984
+ export async function runVerifiersForTest(projectDir, ir, workflowState, when, sessionId, options) {
985
+ const specs = (ir.verifier_specs ?? []).filter((spec) => spec.when === when &&
986
+ spec.workflow_name === workflowState.workflow &&
987
+ spec.step_id === workflowState.current_step);
988
+ const results = [];
989
+ for (const spec of specs) {
990
+ results.push(await runVerifierSpec(projectDir, ir, spec, workflowState, sessionId, options));
991
+ }
992
+ return results;
993
+ }
994
+ export async function runStopVerifiersForTest(projectDir, ir, workflowState, sessionId, options) {
995
+ const results = [];
996
+ results.push(...await runVerifiersForTest(projectDir, ir, workflowState, "post_step", sessionId, options));
997
+ results.push(...await runVerifiersForTest(projectDir, ir, workflowState, "pre_handoff", sessionId, options));
998
+ results.push(...await runVerifiersForTest(projectDir, ir, workflowState, "stop", sessionId, options));
999
+ return results;
1000
+ }
417
1001
  // ── Surgeon Reflection Gate ──────────────────────────────
418
1002
  /** Regex to parse test green count from vitest/flutter test output */
419
1003
  const TEST_PASSED_RE = /(\d+)\s+(?:tests?\s+)?passed/i;
@@ -560,15 +1144,31 @@ export async function handlePreToolGates(ir, rawInput, wfState, projectDir, sess
560
1144
  }
561
1145
  // Gate 2: Context Gate — block write tools when required context files unread.
562
1146
  const writeTools = new Set(["Edit", "Write", "Bash"]);
563
- if (writeTools.has(toolName) && wfState?.active && ir.context_files) {
1147
+ if (writeTools.has(toolName) && wfState?.active) {
564
1148
  const required = [];
565
- if (ir.context_files.mandatory)
566
- required.push(...ir.context_files.mandatory);
567
- if (ir.context_files.per_role?.[wfState.current_role]) {
568
- required.push(...ir.context_files.per_role[wfState.current_role]);
1149
+ if (ir.legibility_assets) {
1150
+ for (const asset of ir.legibility_assets.mandatory ?? []) {
1151
+ if (asset.type === "required_read" && !required.includes(asset.path))
1152
+ required.push(asset.path);
1153
+ }
1154
+ for (const asset of ir.legibility_assets.per_role?.[wfState.current_role] ?? []) {
1155
+ if (asset.type === "required_read" && !required.includes(asset.path))
1156
+ required.push(asset.path);
1157
+ }
1158
+ for (const asset of ir.legibility_assets.per_workflow?.[wfState.workflow] ?? []) {
1159
+ if (asset.type === "required_read" && !required.includes(asset.path))
1160
+ required.push(asset.path);
1161
+ }
569
1162
  }
570
- if (ir.context_files.per_workflow?.[wfState.workflow]) {
571
- required.push(...ir.context_files.per_workflow[wfState.workflow]);
1163
+ if (required.length === 0 && ir.context_files) {
1164
+ if (ir.context_files.mandatory)
1165
+ required.push(...ir.context_files.mandatory);
1166
+ if (ir.context_files.per_role?.[wfState.current_role]) {
1167
+ required.push(...ir.context_files.per_role[wfState.current_role]);
1168
+ }
1169
+ if (ir.context_files.per_workflow?.[wfState.workflow]) {
1170
+ required.push(...ir.context_files.per_workflow[wfState.workflow]);
1171
+ }
572
1172
  }
573
1173
  if (required.length > 0) {
574
1174
  const sessionReadsState = await readSessionReads(projectDir, sessionId);