intentdna 1.8.3 → 1.8.5

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.
@@ -0,0 +1,137 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, realpath, stat } from "node:fs/promises";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { errorResult, textResult } from "./server.js";
5
+ function sha256(value) {
6
+ return createHash("sha256").update(value).digest("hex");
7
+ }
8
+ function isContained(root, target) {
9
+ return target === root || target.startsWith(`${root}${sep}`);
10
+ }
11
+ function canonicalJson(value) {
12
+ if (Array.isArray(value))
13
+ return `[${value.map(canonicalJson).join(",")}]`;
14
+ if (value && typeof value === "object") {
15
+ const record = value;
16
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
17
+ }
18
+ return JSON.stringify(value) ?? "null";
19
+ }
20
+ function decodeJsonPointer(pointer) {
21
+ if (!pointer.startsWith("/") || /~(?:[^01]|$)/.test(pointer)) {
22
+ throw new Error("exclude_json_pointers must contain valid non-root JSON pointers");
23
+ }
24
+ return pointer.slice(1).split("/").map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
25
+ }
26
+ function omitJsonPointer(value, pointer) {
27
+ const segments = decodeJsonPointer(pointer);
28
+ let cursor = value;
29
+ for (const segment of segments.slice(0, -1)) {
30
+ if (!cursor || typeof cursor !== "object" || Array.isArray(cursor) || !Object.hasOwn(cursor, segment)) {
31
+ throw new Error(`JSON pointer does not identify an object member: ${pointer}`);
32
+ }
33
+ cursor = cursor[segment];
34
+ }
35
+ const leaf = segments.at(-1);
36
+ if (!cursor || typeof cursor !== "object" || Array.isArray(cursor) || !Object.hasOwn(cursor, leaf)) {
37
+ throw new Error(`JSON pointer does not identify an object member: ${pointer}`);
38
+ }
39
+ delete cursor[leaf];
40
+ }
41
+ async function resolveArtifact(projectDir, requestedPath) {
42
+ if (!requestedPath || requestedPath.includes("\0") || isAbsolute(requestedPath)) {
43
+ throw new Error("path must be a non-empty project-relative path");
44
+ }
45
+ const root = await realpath(projectDir);
46
+ const lexicalTarget = resolve(root, requestedPath);
47
+ if (!isContained(root, lexicalTarget))
48
+ throw new Error("path escapes project directory");
49
+ let absolutePath;
50
+ try {
51
+ absolutePath = await realpath(lexicalTarget);
52
+ }
53
+ catch {
54
+ throw new Error("artifact is not a readable regular file");
55
+ }
56
+ if (!isContained(root, absolutePath))
57
+ throw new Error("path resolves outside project directory");
58
+ const artifactStat = await stat(absolutePath);
59
+ if (!artifactStat.isFile())
60
+ throw new Error("artifact is not a readable regular file");
61
+ return {
62
+ absolutePath,
63
+ projectRelativePath: relative(root, lexicalTarget).split(sep).join("/"),
64
+ };
65
+ }
66
+ export function createArtifactTools(projectDir) {
67
+ return [
68
+ {
69
+ name: "dna_artifact_digest",
70
+ description: "Read-only SHA-256 digest for one project-relative regular file. Supports raw bytes, whitespace-normalized text, and canonical JSON with explicit object-member exclusions; refuses project escapes and writes nothing.",
71
+ inputSchema: {
72
+ type: "object",
73
+ properties: {
74
+ path: { type: "string", description: "Project-relative path to a regular file" },
75
+ mode: {
76
+ type: "string",
77
+ enum: ["bytes", "whitespace_normalized_text", "canonical_json"],
78
+ description: "Digest representation (default: bytes)",
79
+ },
80
+ exclude_json_pointers: {
81
+ type: "array",
82
+ items: { type: "string" },
83
+ description: "For canonical_json only, object members to omit using RFC 6901 JSON pointers",
84
+ },
85
+ },
86
+ required: ["path"],
87
+ },
88
+ handler: async (args) => {
89
+ try {
90
+ const requestedPath = typeof args.path === "string" ? args.path : "";
91
+ const mode = (args.mode ?? "bytes");
92
+ if (!["bytes", "whitespace_normalized_text", "canonical_json"].includes(mode)) {
93
+ return errorResult("mode must be bytes, whitespace_normalized_text, or canonical_json");
94
+ }
95
+ const excluded = args.exclude_json_pointers === undefined
96
+ ? []
97
+ : Array.isArray(args.exclude_json_pointers) && args.exclude_json_pointers.every((item) => typeof item === "string")
98
+ ? args.exclude_json_pointers
99
+ : undefined;
100
+ if (!excluded)
101
+ return errorResult("exclude_json_pointers must be an array of strings");
102
+ if (mode !== "canonical_json" && excluded.length > 0) {
103
+ return errorResult("exclude_json_pointers is only valid with canonical_json mode");
104
+ }
105
+ const artifact = await resolveArtifact(projectDir, requestedPath);
106
+ const content = await readFile(artifact.absolutePath);
107
+ const artifactStat = await stat(artifact.absolutePath);
108
+ const contentSha256 = sha256(content);
109
+ let digest = contentSha256;
110
+ if (mode === "whitespace_normalized_text") {
111
+ digest = sha256(content.toString("utf-8").replace(/\s+/g, " ").trim());
112
+ }
113
+ else if (mode === "canonical_json") {
114
+ const parsed = JSON.parse(content.toString("utf-8"));
115
+ for (const pointer of excluded)
116
+ omitJsonPointer(parsed, pointer);
117
+ digest = sha256(canonicalJson(parsed));
118
+ }
119
+ return textResult(JSON.stringify({
120
+ schema_version: "intentdna.artifact_digest.v1",
121
+ path: artifact.projectRelativePath,
122
+ mode,
123
+ sha256: digest,
124
+ content_sha256: contentSha256,
125
+ size_bytes: content.byteLength,
126
+ modified_at: artifactStat.mtime.toISOString(),
127
+ excluded_json_pointers: excluded,
128
+ read_only: true,
129
+ }, null, 2));
130
+ }
131
+ catch (error) {
132
+ return errorResult(error instanceof Error ? error.message : "artifact digest failed");
133
+ }
134
+ },
135
+ },
136
+ ];
137
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ConstraintIR, RoleDef } from "../schema/types.js";
2
2
  import type { SyncOutputArtifactId } from "./output-artifact-registry.js";
3
3
  import { type CompiledIRFile } from "./plugin-adapter.js";
4
- import { type DNASettings, type HookEventKey } from "./settings-adapter.js";
4
+ import { type DNASettings, type HookEventKey, type IntentDNASettingsHookCleanupResult } from "./settings-adapter.js";
5
5
  import type { AdapterCapabilityReport, CapabilityReportEntry, PlannedArtifact, SyncTargetPlan } from "./sync-target-plan.js";
6
6
  export type ClaudeSyncMode = "plugin" | "bin";
7
7
  export interface ClaudeCorePlannedArtifact extends PlannedArtifact {
@@ -31,6 +31,7 @@ export interface ClaudeCoreSyncTargetPlan extends SyncTargetPlan {
31
31
  legacyHooksDir: string;
32
32
  mcpConfigPath: string;
33
33
  settingsHookRegistration?: ClaudeSettingsHookRegistration;
34
+ pluginSettingsPath?: string;
34
35
  pluginSkipsSettings: boolean;
35
36
  plannedArtifacts: ClaudeCorePlannedArtifact[];
36
37
  capabilityReport: ClaudeCoreAdapterCapabilityReport;
@@ -38,6 +39,7 @@ export interface ClaudeCoreSyncTargetPlan extends SyncTargetPlan {
38
39
  export interface ExecuteClaudeCoreSyncTargetPlanResult {
39
40
  messages: string[];
40
41
  removedLegacyHooks: string[];
42
+ settingsHookCleanup?: IntentDNASettingsHookCleanupResult;
41
43
  }
42
44
  /**
43
45
  * Remove legacy bash hook scripts written by previous Intent DNA versions.
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { createCompiledIR, writeCompiledIR } from "./plugin-adapter.js";
4
- import { compilePluginSettings, detectEnabledEvents, mergeSettingsFile, recommendHookTimeouts } from "./settings-adapter.js";
4
+ import { compilePluginSettings, detectEnabledEvents, mergeSettingsFile, recommendHookTimeouts, removeIntentDNASettingsHooks, } from "./settings-adapter.js";
5
5
  import { formatSyncTargetPlanDiagnostics } from "./sync-target-plan.js";
6
6
  const LEGACY_BASH_HOOKS = [
7
7
  "dna-pre-tool-use.sh",
@@ -83,6 +83,7 @@ export function createClaudeCoreSyncTargetPlan(options) {
83
83
  legacyHooksDir,
84
84
  mcpConfigPath,
85
85
  settingsHookRegistration,
86
+ pluginSettingsPath: options.mode === "plugin" ? options.settingsPath : undefined,
86
87
  pluginSkipsSettings: options.mode === "plugin" && !!options.settingsPath,
87
88
  plannedArtifacts,
88
89
  capabilityReport: createClaudeCapabilityReport(plannedArtifacts, options.mode, settingsHookRegistration),
@@ -238,6 +239,7 @@ async function registerDNAMCPServer(projectDir) {
238
239
  }
239
240
  export async function executeClaudeCoreSyncTargetPlan(plan, projectDir) {
240
241
  const messages = [];
242
+ let settingsHookCleanup;
241
243
  const writtenIrPath = await writeCompiledIR(projectDir, plan.compiledIR);
242
244
  messages.push(`Wrote compiled IR: ${writtenIrPath}`);
243
245
  const removedLegacyHooks = await removeLegacyBashHooks(plan.legacyHooksDir);
@@ -253,9 +255,19 @@ export async function executeClaudeCoreSyncTargetPlan(plan, projectDir) {
253
255
  messages.push(`Bin mode: registered ${plan.settingsHookRegistration.enabledEvents.length} hook event(s) in ${plan.settingsHookRegistration.path}`);
254
256
  }
255
257
  else if (plan.pluginSkipsSettings) {
256
- messages.push("Plugin mode: hooks managed by plugin framework (skipping settings.json)");
258
+ settingsHookCleanup = await removeIntentDNASettingsHooks(plan.pluginSettingsPath);
259
+ if (settingsHookCleanup.removedCommands > 0) {
260
+ const action = settingsHookCleanup.removedFile ? "removed" : "cleaned";
261
+ messages.push(`Plugin mode: ${action} ${settingsHookCleanup.removedCommands} stale bin-mode dna-hook command(s) from ${plan.pluginSettingsPath}; hooks are managed by plugin framework`);
262
+ }
263
+ else {
264
+ messages.push("Plugin mode: hooks managed by plugin framework (settings.json not written)");
265
+ }
266
+ if (settingsHookCleanup.warning) {
267
+ messages.push(`Plugin mode: preserved settings.json without cleanup (${settingsHookCleanup.warning})`);
268
+ }
257
269
  }
258
- return { messages, removedLegacyHooks };
270
+ return { messages, removedLegacyHooks, settingsHookCleanup };
259
271
  }
260
272
  export function formatClaudeCoreSyncTargetPlanDryRun(plan) {
261
273
  const lines = [
@@ -267,7 +279,7 @@ export function formatClaudeCoreSyncTargetPlanDryRun(plan) {
267
279
  lines.push(`Dry-run: would register ${plan.settingsHookRegistration.enabledEvents.length} hook event(s) in ${plan.settingsHookRegistration.path}`);
268
280
  }
269
281
  else if (plan.pluginSkipsSettings) {
270
- lines.push("Plugin mode: hooks managed by plugin framework (skipping settings.json)");
282
+ lines.push("Plugin mode: hooks managed by plugin framework; would remove stale bin-mode dna-hook commands if present and would not register settings hooks");
271
283
  }
272
284
  const capabilityEntries = Object.entries(plan.capabilityReport.capabilities)
273
285
  .sort(([left], [right]) => left.localeCompare(right));
@@ -18,6 +18,13 @@ export interface SettingsHookEntry {
18
18
  export interface DNASettings {
19
19
  hooks: Record<string, SettingsHookEntry[]>;
20
20
  }
21
+ export interface IntentDNASettingsHookCleanupResult {
22
+ changed: boolean;
23
+ removedCommands: number;
24
+ removedEvents: string[];
25
+ removedFile: boolean;
26
+ warning?: string;
27
+ }
21
28
  /**
22
29
  * Compile settings.json hook configuration for dna-hook binary.
23
30
  * Uses `dna-hook <event>` commands.
@@ -34,3 +41,8 @@ export declare function detectEnabledEvents(ir: import("../schema/types.js").Con
34
41
  * Replaces DNA hooks (identified by "dna-" prefix), keeps user hooks.
35
42
  */
36
43
  export declare function mergeSettingsFile(dnaSettings: DNASettings, settingsPath: string): Promise<void>;
44
+ /**
45
+ * Remove bin-mode IntentDNA hook commands when Claude plugin hooks own dispatch.
46
+ * Invalid or user-owned settings are preserved without rewriting the file.
47
+ */
48
+ export declare function removeIntentDNASettingsHooks(settingsPath: string): Promise<IntentDNASettingsHookCleanupResult>;
@@ -6,9 +6,9 @@
6
6
  *
7
7
  * See docs/archive/2026H1/references/cc-source-analysis.md §2 for the full hook event catalog.
8
8
  */
9
- import { readFile, writeFile } from "node:fs/promises";
9
+ import { readFile, unlink, writeFile } from "node:fs/promises";
10
10
  import { getHookEventBySettingsKey, settingsHookEventKeys } from "../hooks/event-registry.js";
11
- // ── Plugin Mode (Node.js hooks via dna-hook binary) ───────
11
+ // ── Bin Mode (project settings via dna-hook binary) ───────
12
12
  /**
13
13
  * Compile settings.json hook configuration for dna-hook binary.
14
14
  * Uses `dna-hook <event>` commands.
@@ -46,6 +46,7 @@ export function recommendHookTimeouts(ir, baseTimeout = 10) {
46
46
  spec.checkpoint?.assert === "no_test_regression")) ?? false;
47
47
  if (hasStopCommandVerifiers) {
48
48
  timeouts.stop = Math.max(baseTimeout, 45);
49
+ timeouts.subagentStop = Math.max(baseTimeout, 45);
49
50
  }
50
51
  return timeouts;
51
52
  }
@@ -70,7 +71,7 @@ export function detectEnabledEvents(ir) {
70
71
  if (ir.prompt_directives.some(d => d.priority === "high")) {
71
72
  events.push("userPromptSubmit");
72
73
  }
73
- if (hasRoleScope) {
74
+ if (hasRoleScope || hasStopVerifiers) {
74
75
  events.push("subagentStop");
75
76
  }
76
77
  if (ir.prompt_directives.length > 0) {
@@ -130,3 +131,120 @@ export async function mergeSettingsFile(dnaSettings, settingsPath) {
130
131
  existing.hooks = mergedHooks;
131
132
  await writeFile(settingsPath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
132
133
  }
134
+ const INTENTDNA_SETTINGS_HOOK_COMMANDS = new Set(settingsHookEventKeys().map((key) => `dna-hook ${getHookEventBySettingsKey(key).event}`));
135
+ function isIntentDNASettingsHookCommand(value, eventName) {
136
+ if (!value || typeof value !== "object" || Array.isArray(value))
137
+ return false;
138
+ const hook = value;
139
+ const expectedCommand = `dna-hook ${eventName}`;
140
+ return hook.type === "command"
141
+ && typeof hook.command === "string"
142
+ && hook.command === expectedCommand
143
+ && INTENTDNA_SETTINGS_HOOK_COMMANDS.has(expectedCommand);
144
+ }
145
+ function isGeneratedIntentDNASettingsHookEntry(entry) {
146
+ return entry.matcher === ""
147
+ && Object.keys(entry).every((key) => key === "matcher" || key === "hooks");
148
+ }
149
+ /**
150
+ * Remove bin-mode IntentDNA hook commands when Claude plugin hooks own dispatch.
151
+ * Invalid or user-owned settings are preserved without rewriting the file.
152
+ */
153
+ export async function removeIntentDNASettingsHooks(settingsPath) {
154
+ const unchanged = (warning) => ({
155
+ changed: false,
156
+ removedCommands: 0,
157
+ removedEvents: [],
158
+ removedFile: false,
159
+ ...(warning ? { warning } : {}),
160
+ });
161
+ let raw;
162
+ try {
163
+ raw = await readFile(settingsPath, "utf-8");
164
+ }
165
+ catch (error) {
166
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
167
+ return code === "ENOENT" ? unchanged() : unchanged("could not read settings.json");
168
+ }
169
+ let parsed;
170
+ try {
171
+ parsed = JSON.parse(raw);
172
+ }
173
+ catch {
174
+ return unchanged("settings.json is not valid JSON");
175
+ }
176
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
177
+ return unchanged("settings.json root is not an object");
178
+ }
179
+ const settings = parsed;
180
+ const rawHooks = settings.hooks;
181
+ if (!rawHooks || typeof rawHooks !== "object" || Array.isArray(rawHooks)) {
182
+ return unchanged();
183
+ }
184
+ const cleanedHooks = {};
185
+ const removedEvents = [];
186
+ let removedCommands = 0;
187
+ for (const [eventName, rawEntries] of Object.entries(rawHooks)) {
188
+ if (!Array.isArray(rawEntries)) {
189
+ cleanedHooks[eventName] = rawEntries;
190
+ continue;
191
+ }
192
+ let eventRemoved = false;
193
+ const cleanedEntries = [];
194
+ for (const rawEntry of rawEntries) {
195
+ if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
196
+ cleanedEntries.push(rawEntry);
197
+ continue;
198
+ }
199
+ const entry = rawEntry;
200
+ if (!Array.isArray(entry.hooks)) {
201
+ cleanedEntries.push(rawEntry);
202
+ continue;
203
+ }
204
+ let entryRemovedCommands = 0;
205
+ const retainedCommands = entry.hooks.filter((hook) => {
206
+ const owned = isIntentDNASettingsHookCommand(hook, eventName);
207
+ if (owned) {
208
+ removedCommands += 1;
209
+ entryRemovedCommands += 1;
210
+ eventRemoved = true;
211
+ }
212
+ return !owned;
213
+ });
214
+ if (entryRemovedCommands === 0) {
215
+ cleanedEntries.push(rawEntry);
216
+ }
217
+ else if (retainedCommands.length > 0 || !isGeneratedIntentDNASettingsHookEntry(entry)) {
218
+ cleanedEntries.push({ ...entry, hooks: retainedCommands });
219
+ }
220
+ }
221
+ if (eventRemoved)
222
+ removedEvents.push(eventName);
223
+ if (!eventRemoved || cleanedEntries.length > 0)
224
+ cleanedHooks[eventName] = cleanedEntries;
225
+ }
226
+ if (removedCommands === 0)
227
+ return unchanged();
228
+ if (Object.keys(cleanedHooks).length > 0) {
229
+ settings.hooks = cleanedHooks;
230
+ }
231
+ else {
232
+ delete settings.hooks;
233
+ }
234
+ if (Object.keys(settings).length === 0) {
235
+ await unlink(settingsPath);
236
+ return {
237
+ changed: true,
238
+ removedCommands,
239
+ removedEvents,
240
+ removedFile: true,
241
+ };
242
+ }
243
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
244
+ return {
245
+ changed: true,
246
+ removedCommands,
247
+ removedEvents,
248
+ removedFile: false,
249
+ };
250
+ }
@@ -448,10 +448,19 @@ function workflowSkillMapEntry(skillName, dirName, plan) {
448
448
  produces,
449
449
  };
450
450
  }
451
+ function inferredWorkflowAsset(ir) {
452
+ const sourceIds = [...new Set(ir?.source_dna_ids ?? [])]
453
+ .filter((sourceId) => sourceId.length > 0)
454
+ .filter((sourceId) => !sourceId.startsWith("species:"))
455
+ .filter((sourceId) => !sourceId.startsWith("enterprise:"));
456
+ return sourceIds.length === 1 ? sourceIds[0] : undefined;
457
+ }
451
458
  export function compileWorkflowToSkill(plan, roles, ir, variables, options) {
452
459
  const lines = [];
453
460
  const skillName = `dna-${toKebabCase(plan.name)}`;
454
461
  const surface = options?.surface ?? "claude_code";
462
+ const workflowId = plan.workflow_key ?? plan.source_workflow;
463
+ const workflowAsset = inferredWorkflowAsset(ir);
455
464
  assertSafeGeneratedName(skillName, "skill name");
456
465
  // Frontmatter
457
466
  lines.push("---");
@@ -478,9 +487,20 @@ export function compileWorkflowToSkill(plan, roles, ir, variables, options) {
478
487
  lines.push("");
479
488
  }
480
489
  lines.push("<Workflow_State>");
481
- lines.push("At workflow start, persist runtime inputs in DNA workflow state before executing steps.");
490
+ lines.push("At workflow start, call the MCP tool `dna_workflow_write` before any step or hook-triggering action so hook verifier evidence is attributed to this workflow instead of `*:direct`.");
491
+ lines.push(`Use workflow id \`${workflowId}\`.`);
492
+ if (workflowAsset) {
493
+ lines.push(`Use workflow_asset \`${workflowAsset}\`; it is the single non-species/non-enterprise IR source id.`);
494
+ }
495
+ else {
496
+ lines.push("Do not set workflow_asset: IR does not provide exactly one non-species/non-enterprise source id, so the asset cannot be inferred safely.");
497
+ }
498
+ const firstStep = plan.steps[0];
499
+ lines.push(`Initial state write: workflow=${workflowId}, current_step=${firstStep.id}, current_role=${firstStep.role}, iteration=1, active=true.`);
482
500
  lines.push("Record the invocation argument as inputs.ARGUMENTS so handoff artifact paths like $ARGUMENTS can be resolved deterministically.");
483
501
  lines.push("If template variables are shown below, persist their resolved values as resolved_variables.");
502
+ lines.push("Before each workflow step, call `dna_workflow_write` again with workflow, workflow_asset only when safely inferred above, current_step, current_role, iteration, active=true, inputs.ARGUMENTS, and resolved_variables.");
503
+ lines.push("At workflow completion or terminal failure, call `dna_workflow_write` with active=false using the same workflow id and safe workflow_asset value.");
484
504
  lines.push("</Workflow_State>");
485
505
  lines.push("");
486
506
  // Required Context — context files that must be read before any work
@@ -607,6 +627,7 @@ export function compileWorkflowToSkill(plan, roles, ir, variables, options) {
607
627
  const agentPrompt = escapePrompt(promptParts.join(" "));
608
628
  lines.push(`${stepNumber}. **${step.id}**${optional}`);
609
629
  lines.push(` ${step.description}${runIf}`);
630
+ lines.push(` Before this step, call \`dna_workflow_write\` with workflow=${workflowId}, current_step=${step.id}, current_role=${step.role}, iteration=<current iteration>, active=true, inputs.ARGUMENTS, resolved_variables, and ${workflowAsset ? `workflow_asset=${workflowAsset}` : "no workflow_asset unless one has been explicitly supplied by trusted workflow state"}.`);
610
631
  lines.push("");
611
632
  if (humanOwned) {
612
633
  lines.push(" **Human-owned gate (`model: human`)**");
@@ -640,6 +661,11 @@ export function compileWorkflowToSkill(plan, roles, ir, variables, options) {
640
661
  const artifacts = step.handoff.produces.map(p => p.description).join(", ");
641
662
  lines.push(` Verify produced artifacts: ${artifacts}`);
642
663
  }
664
+ pushWorkflowStepVerifierLines(lines, step, " ");
665
+ if (step.completion?.length || step.checkpoints?.length) {
666
+ lines.push(" If any completion/checkpoint gate fails, preserve the exact verifier diagnostics verbatim: id, check/assert or command, message, evidence, target/artifact, and exit_code when available.");
667
+ lines.push(` Feed those exact diagnostics to ${step.on_fail === "retry_with_feedback" ? "this step's on_fail retry" : "the retry_from/on_fail step"} before any retry, reanalysis, or downstream transition.`);
668
+ }
643
669
  lines.push("");
644
670
  // Reflection gate: inject Reflection_Gate after steps with max_attempts
645
671
  if (step.max_attempts) {
@@ -648,8 +674,9 @@ export function compileWorkflowToSkill(plan, roles, ir, variables, options) {
648
674
  const blockedPath = step.blocked_items_path ?? "blocked_items.md";
649
675
  lines.push(`<Reflection_Gate>`);
650
676
  lines.push(`max_attempts=${step.max_attempts}, handoff_to=${handoffStep}, max_handoffs=${maxH}`);
651
- lines.push(`No test progress after ${step.max_attempts} attempts → handoff to ${handoffStep} for re-analysis.`);
652
- lines.push(`After ${maxH} handoffs with no progress SKIP and record to ${blockedPath}.`);
677
+ lines.push(`No declared completion progress after ${step.max_attempts} attempts → handoff to ${handoffStep} for re-analysis.`);
678
+ lines.push(`When the declared gates are test or no_test_regression gates, preserve the existing test-progress meaning as part of declared completion progress.`);
679
+ lines.push(`After ${maxH} handoffs with no declared completion progress → SKIP and record to ${blockedPath}.`);
653
680
  lines.push(`</Reflection_Gate>`);
654
681
  lines.push("");
655
682
  }
@@ -662,7 +689,9 @@ export function compileWorkflowToSkill(plan, roles, ir, variables, options) {
662
689
  lines.push(`- max_retries: ${plan.retry.max_retries}`);
663
690
  lines.push(`- retry_from: ${plan.retry.retry_from ?? "first_failed_step"}`);
664
691
  lines.push(`- backoff: ${plan.retry.backoff}`);
665
- lines.push("- On a blocking checkpoint or validator failure, preserve the exact diagnostics and return them to the retry_from step.");
692
+ lines.push("- On a blocking completion, checkpoint, or validator failure, preserve the exact diagnostics and return them verbatim to the retry_from/on_fail step.");
693
+ lines.push("- Exact diagnostics means every available id/result_id, check/assert or command, message, evidence, target/artifact, and exit_code. Do not paraphrase away failed paths, command output, or policy-denied exit codes.");
694
+ lines.push("- Retry prompts must include the prior failed step id, the declared gates that failed, and the exact verifier diagnostics before asking for another attempt.");
666
695
  lines.push("- Repeat only the retry slice and its dependent steps; do not continue to save, adoption, or sync while validation is red.");
667
696
  lines.push("- Stop and report the remaining diagnostics when the retry budget is exhausted.");
668
697
  lines.push("</Retry_Policy>");
@@ -797,6 +826,7 @@ export function compileWorkflowToSkill(plan, roles, ir, variables, options) {
797
826
  }
798
827
  // Workflow Boundary — prevent cross-workflow execution
799
828
  lines.push("<Workflow_Boundary>");
829
+ lines.push(`Before reporting final success, terminal failure, or exhaustion, call \`dna_workflow_write\` with workflow=${workflowId}, current_step=workflow_complete, current_role=workflow, active=false, and ${workflowAsset ? `workflow_asset=${workflowAsset}` : "no workflow_asset unless one has been explicitly supplied by trusted workflow state"}.`);
800
830
  lines.push("This workflow is COMPLETE. Do NOT proceed to any other workflow.");
801
831
  lines.push("Report your results and STOP. The user will decide the next step.");
802
832
  lines.push("</Workflow_Boundary>");
@@ -1,6 +1,8 @@
1
1
  import type { CompletionCheck, ConstraintIR, StepCheckpoint, VerifierCommandPolicy } from "../schema/types.js";
2
2
  export declare const DEFAULT_VERIFIER_COMMAND_TIMEOUT_MS = 30000;
3
3
  export declare const MAX_VERIFIER_EVIDENCE_BYTES = 4096;
4
+ export declare const VERIFIER_DIAGNOSTIC_PREFIX = "INTENTDNA_DIAGNOSTIC:";
5
+ export declare const MAX_VERIFIER_DIAGNOSTIC_BYTES = 2048;
4
6
  export interface VerifierRuntimeContext {
5
7
  projectDir: string;
6
8
  variables?: Record<string, string>;
@@ -29,7 +31,7 @@ export declare function hasUnresolvedVerifierTemplate(value: string): boolean;
29
31
  export declare function unresolvedVerifierTemplateMessage(value: string): string;
30
32
  export declare function hasUnsafeShellControl(command: string): boolean;
31
33
  export declare function isVerifierCommandAllowed(policy: VerifierCommandPolicy | undefined, command: string): boolean;
32
- export declare function verifierCommandPolicyMessage(command: string): string;
34
+ export declare function verifierCommandPolicyMessage(_command: string): string;
33
35
  export declare function isBuiltinAssertAllowed(policy: VerifierCommandPolicy | undefined, assertName: string): boolean;
34
36
  export declare function verifierAssertPolicyMessage(assertName: string): string;
35
37
  export declare function trimVerifierEvidence(raw: string | undefined): string | undefined;