intentdna 1.5.20 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,7 +58,7 @@ export function enforcePreToolUse(ir, input, state, roles) {
58
58
  }
59
59
  // Layer 6: Handoff — check consumed artifacts are available
60
60
  if (state?.workflowState && ir.workflows_ir) {
61
- const result = enforceHandoffConsumes(ir, state.workflowState, state.existingArtifactPaths);
61
+ const result = enforceHandoffConsumes(ir, state.workflowState, state.artifactFacts ?? []);
62
62
  if (result)
63
63
  return { output: result, trace: { matched_rule: "handoff", step_id: state.workflowState.current_step } };
64
64
  }
@@ -283,20 +283,18 @@ export function enforceStop(ir, input, workflowState) {
283
283
  }
284
284
  }
285
285
  }
286
+ const handoffResult = enforceHandoffProduces(ir, {
287
+ current_step: workflowState.current_step,
288
+ workflow: workflowState.workflow,
289
+ artifact_facts: workflowState.artifact_facts ?? [],
290
+ });
291
+ if (handoffResult)
292
+ return handoffResult;
286
293
  if (workflowCheckpoints.length === 0) {
287
294
  return null;
288
295
  }
289
296
  const blocking = workflowCheckpoints.flatMap(cp => cp.checkpoints.filter(c => (c.action ?? "block") === "block"));
290
297
  if (blocking.length === 0) {
291
- if (workflowState.workflow) {
292
- const handoffResult = enforceHandoffProduces(ir, {
293
- current_step: workflowState.current_step,
294
- workflow: workflowState.workflow,
295
- completed_artifacts: workflowState.completed_artifacts,
296
- });
297
- if (handoffResult)
298
- return handoffResult;
299
- }
300
298
  return null;
301
299
  }
302
300
  const messages = blocking.map(c => c.message);
@@ -359,26 +357,23 @@ function getStepAdditionalPaths(ir, state) {
359
357
  * artifacts have been produced by a preceding step.
360
358
  * Returns block output if a required artifact is missing, null if all satisfied.
361
359
  */
362
- function enforceHandoffConsumes(ir, wfState, existingPaths) {
360
+ function enforceHandoffConsumes(ir, wfState, artifactFacts) {
363
361
  const activeWf = ir.workflows_ir?.find(w => w.workflow_name === wfState.workflow);
364
- if (!activeWf || activeWf.handoff_chain.length === 0)
362
+ if (!activeWf || (activeWf.handoff_chain?.length ?? 0) === 0)
365
363
  return null;
366
364
  const currentEntry = activeWf.handoff_chain.find(h => h.step_id === wfState.current_step);
367
365
  if (!currentEntry?.consumes || currentEntry.consumes.length === 0)
368
366
  return null;
369
- const completedStepIds = new Set((wfState.completed_artifacts ?? []).map(a => a.step_id));
370
- // For each consumed artifact, find the producing step and check it completed
371
367
  for (const consumed of currentEntry.consumes) {
372
- // Find which step produces this artifact
373
- const producer = activeWf.handoff_chain.find(h => h.produces?.some(p => p.type === consumed.type && ((p.path && consumed.path && p.path === consumed.path) ||
374
- (consumed.from && h.step_id === consumed.from))));
375
- if (producer && !completedStepIds.has(producer.step_id)) {
376
- // Re-run fallback: if the consumed artifact file exists on disk
377
- // (from a prior run), treat the dependency as satisfied.
378
- if (consumed.path && existingPaths?.has(consumed.path)) {
379
- continue;
380
- }
381
- return blockOutput(`[Intent DNA] Step '${wfState.current_step}' requires artifact from step '${producer.step_id}' (${consumed.description}). Run step '${producer.step_id}' first.`);
368
+ if (consumed.required === false)
369
+ continue;
370
+ const producerStep = consumed.from ?? findProducerStep(activeWf.handoff_chain, consumed) ?? "external";
371
+ const fact = artifactFacts.find(a => artifactMatches(a, consumed, producerStep, wfState.workflow));
372
+ if (!fact) {
373
+ return blockOutput(`[Intent DNA] Step '${wfState.current_step}' requires artifact '${consumed.description}' from '${producerStep}', but no verified manifest was resolved.`);
374
+ }
375
+ if (consumed.type === "git_commit" && typeof fact.metadata?.commit !== "string") {
376
+ return blockOutput(`[Intent DNA] Step '${wfState.current_step}' requires git_commit artifact from '${producerStep}', but no commit identity was recorded.`);
382
377
  }
383
378
  }
384
379
  return null;
@@ -390,22 +385,52 @@ function enforceHandoffConsumes(ir, wfState, existingPaths) {
390
385
  */
391
386
  export function enforceHandoffProduces(ir, wfState) {
392
387
  const activeWf = ir.workflows_ir?.find(w => w.workflow_name === wfState.workflow);
393
- if (!activeWf || activeWf.handoff_chain.length === 0)
388
+ if (!activeWf || (activeWf.handoff_chain?.length ?? 0) === 0)
394
389
  return null;
395
390
  const currentEntry = activeWf.handoff_chain.find(h => h.step_id === wfState.current_step);
396
391
  if (!currentEntry?.produces || currentEntry.produces.length === 0)
397
392
  return null;
398
- const currentArtifacts = (wfState.completed_artifacts ?? []).find(a => a.step_id === wfState.current_step);
393
+ const facts = wfState.artifact_facts ?? [];
399
394
  for (const produced of currentEntry.produces) {
400
- if (produced.path && (!currentArtifacts || !currentArtifacts.artifacts.some(a => a.path === produced.path))) {
395
+ if (produced.required === false)
396
+ continue;
397
+ const fact = facts.find(a => artifactMatches(a, produced, wfState.current_step, wfState.workflow));
398
+ if (!fact) {
401
399
  return {
402
- output: blockOutput(`[Intent DNA] Step '${wfState.current_step}' must produce artifact '${produced.description}' (path: ${produced.path}) before proceeding.`),
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") {
405
+ return {
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;
@@ -414,7 +439,7 @@ function enforceRoleScope(rolesScopeMap, input, additionalWritePaths = []) {
414
439
  const filePath = extractFilePath(input);
415
440
  if (!filePath)
416
441
  return null;
417
- return checkPathAgainstScope(rolesScopeMap, input, filePath, additionalWritePaths);
442
+ return checkPathAgainstScope(rolesScopeMap, input, filePath, additionalWritePaths, true);
418
443
  }
419
444
  // Bash: extract potential write targets from command
420
445
  if (input.tool_name === "Bash") {
@@ -425,7 +450,7 @@ function enforceRoleScope(rolesScopeMap, input, additionalWritePaths = []) {
425
450
  if (writePaths.length === 0)
426
451
  return null;
427
452
  for (const p of writePaths) {
428
- const result = checkPathAgainstScope(rolesScopeMap, input, p, additionalWritePaths);
453
+ const result = checkPathAgainstScope(rolesScopeMap, input, p, additionalWritePaths, false);
429
454
  if (result)
430
455
  return result;
431
456
  }
@@ -434,7 +459,7 @@ function enforceRoleScope(rolesScopeMap, input, additionalWritePaths = []) {
434
459
  return null;
435
460
  }
436
461
  /** Check a single file path against role scope. Shared by Write tools and Bash. */
437
- function checkPathAgainstScope(rolesScopeMap, input, filePath, additionalWritePaths = []) {
462
+ function checkPathAgainstScope(rolesScopeMap, input, filePath, additionalWritePaths = [], blockViolation = true) {
438
463
  const cwd = input.cwd;
439
464
  let relativePath = cwd && filePath.startsWith("/") ? relative(cwd, filePath) : filePath;
440
465
  // Normalize to resolve traversal (e.g., "src/../../test/x.ts" → "../test/x.ts")
@@ -449,10 +474,16 @@ function checkPathAgainstScope(rolesScopeMap, input, filePath, additionalWritePa
449
474
  ? [...writeGlobs, ...additionalWritePaths]
450
475
  : writeGlobs;
451
476
  if (allWriteGlobs.length === 0) {
452
- return allowOutput(`WARN [Intent DNA]: Role '${entry.role_name}' has no write permission (path: ${filePath}). Merge-time scope gate will filter.`);
477
+ const message = `Role '${entry.role_name}' has no write permission (path: ${filePath}).`;
478
+ return blockViolation
479
+ ? blockOutput(`[Intent DNA]: ${message}`)
480
+ : allowOutput(`WARN [Intent DNA]: ${message}`);
453
481
  }
454
482
  if (!checkWriteAllowed(relativePath, allWriteGlobs)) {
455
- return allowOutput(`WARN [Intent DNA]: Role '${entry.role_name}' cannot write to '${filePath}' (allowed: ${allWriteGlobs.join(", ")}). Merge-time scope gate will filter.`);
483
+ const message = `Role '${entry.role_name}' cannot write to '${filePath}' (allowed: ${allWriteGlobs.join(", ")}).`;
484
+ return blockViolation
485
+ ? blockOutput(`[Intent DNA]: ${message}`)
486
+ : allowOutput(`WARN [Intent DNA]: ${message}`);
456
487
  }
457
488
  return null; // Role matched, write allowed
458
489
  }
@@ -540,20 +571,48 @@ function enforceOutputSchema(roles, input) {
540
571
  return null;
541
572
  }
542
573
  // ── Public Helpers ──────────────────────────────────────────
543
- /** Check if a file path is allowed by a list of write globs (prefix matching). */
574
+ /** Check if a file path is allowed by a list of write globs. */
544
575
  export function checkWriteAllowed(filePath, allowedGlobs) {
576
+ const normalizedPath = normalize(filePath).replace(/\\/g, "/");
545
577
  for (const glob of allowedGlobs) {
546
- const prefix = globToPrefix(glob);
547
- if (prefix.length === 0)
548
- return true; // Root glob like "*.md" — allow all
549
- if (filePath.startsWith(prefix))
550
- return true;
551
- // Also check exact match for non-glob patterns
552
- if (!glob.includes("*") && filePath === glob)
578
+ const normalizedGlob = normalize(glob).replace(/\\/g, "/");
579
+ if (normalizedGlob.includes("*")) {
580
+ if (globToRegExp(normalizedGlob).test(normalizedPath))
581
+ return true;
582
+ continue;
583
+ }
584
+ if (normalizedPath.startsWith(normalizedGlob))
553
585
  return true;
554
586
  }
555
587
  return false;
556
588
  }
589
+ function globToRegExp(glob) {
590
+ let source = "^";
591
+ for (let i = 0; i < glob.length; i++) {
592
+ const char = glob[i];
593
+ if (char === "*") {
594
+ if (glob[i + 1] === "*") {
595
+ if (glob[i + 2] === "/") {
596
+ source += "(?:.*/)?";
597
+ i += 2;
598
+ }
599
+ else {
600
+ source += ".*";
601
+ i += 1;
602
+ }
603
+ }
604
+ else {
605
+ source += "[^/]*";
606
+ }
607
+ continue;
608
+ }
609
+ source += escapeRegExp(char);
610
+ }
611
+ return new RegExp(`${source}$`);
612
+ }
613
+ function escapeRegExp(char) {
614
+ return char.replace(/[\\^$+?.()|[\]{}]/g, "\\$&");
615
+ }
557
616
  /**
558
617
  * Extract directory prefix from a glob pattern.
559
618
  * "src/**\/*.ts" → "src/"
@@ -624,7 +683,9 @@ function isWriteTool(toolName) {
624
683
  return WRITE_TOOLS.has(toolName);
625
684
  }
626
685
  function extractFilePath(input) {
627
- const fp = input.tool_input.file_path;
686
+ const fp = input.tool_name === "NotebookEdit"
687
+ ? input.tool_input.notebook_path
688
+ : input.tool_input.file_path;
628
689
  if (typeof fp === "string" && fp)
629
690
  return fp;
630
691
  return null;
@@ -9,7 +9,7 @@
9
9
  * All read operations are fail-safe (return null on error, never throw).
10
10
  * All write operations use atomic temp+rename pattern.
11
11
  */
12
- import type { CompletedArtifactEntry, VerifierKind, VerifierSeverity, VerifierWhen } from "../schema/types.js";
12
+ import type { ArtifactFact, ArtifactKey, ArtifactManifest, CompletedArtifactEntry, HandoffArtifact, HandoffType, VerifierKind, VerifierSeverity, VerifierWhen } from "../schema/types.js";
13
13
  /** Workflow state written by runtime and read by hooks */
14
14
  export interface DNAWorkflowState {
15
15
  active: boolean;
@@ -19,6 +19,8 @@ export interface DNAWorkflowState {
19
19
  iteration: number;
20
20
  session_id: string;
21
21
  started_at: string;
22
+ inputs?: Record<string, string>;
23
+ resolved_variables?: Record<string, string>;
22
24
  completed_artifacts?: CompletedArtifactEntry[];
23
25
  }
24
26
  /** Audit log entry (one per line in JSON Lines format) */
@@ -48,12 +50,28 @@ export interface SurgeonAttemptState {
48
50
  lesson: string;
49
51
  }>;
50
52
  }
53
+ export declare function safePathComponent(value: string, label: string): string;
51
54
  /**
52
55
  * Resolve state directory path.
53
56
  * With session isolation: `.dna/state/sessions/<sessionId>/`
54
57
  * Without: `.dna/state/`
55
58
  */
56
59
  export declare function resolveStateDir(projectDir: string, sessionId?: string): string;
60
+ export declare const ARTIFACT_RESOLVER_VERSION = 1;
61
+ export declare function isRequiredArtifact(artifact: HandoffArtifact): boolean;
62
+ export declare function artifactIdentity(artifact: HandoffArtifact, resolvedPath?: string): string | null;
63
+ export declare function artifactHash(key: ArtifactKey): string;
64
+ export declare function buildArtifactKey(params: {
65
+ workflow: string;
66
+ sessionId: string;
67
+ producerStep: string;
68
+ type: HandoffType;
69
+ identity: string;
70
+ }): ArtifactKey;
71
+ export declare function artifactManifestPath(projectDir: string, key: ArtifactKey): string;
72
+ export declare function resolveArtifactTemplate(template: string, state?: Pick<DNAWorkflowState, "inputs" | "resolved_variables">): string | null;
73
+ export declare function writeArtifactManifest(projectDir: string, manifest: Omit<ArtifactManifest, "id" | "recorded_at" | "resolver_version">): Promise<ArtifactFact>;
74
+ export declare function readArtifactManifest(projectDir: string, key: ArtifactKey): Promise<ArtifactFact | null>;
57
75
  /**
58
76
  * Read current workflow state. Returns null if not found or stale.
59
77
  * @deprecated Prefer `new DNAStateManager(projectDir, sessionId).readWorkflowState()`.
@@ -83,6 +101,8 @@ export declare function appendAudit(projectDir: string, entry: AuditEntry): Prom
83
101
  export declare function appendCompletedArtifact(projectDir: string, stepId: string, artifact: {
84
102
  type: string;
85
103
  path: string;
104
+ artifact_id?: string;
105
+ metadata?: Record<string, unknown>;
86
106
  }, sessionId?: string): Promise<void>;
87
107
  /** Atomic write: write to temp file then rename. */
88
108
  export declare function atomicWrite(filePath: string, data: string): Promise<void>;
@@ -10,10 +10,27 @@
10
10
  * All write operations use atomic temp+rename pattern.
11
11
  */
12
12
  import { readFile, writeFile, rename, mkdir, appendFile, unlink, readdir, stat } from "node:fs/promises";
13
+ import { createHash } from "node:crypto";
13
14
  import { join, dirname } from "node:path";
14
15
  // ── Constants ──────────────────────────────────────────────
15
16
  const WORKFLOW_FILE = "workflow.json";
16
17
  const DEFAULT_STALENESS_MS = 2 * 60 * 60 * 1000; // 2 hours
18
+ const SAFE_PATH_COMPONENT = /^[A-Za-z0-9._-]{1,128}$/;
19
+ const USER_ARTIFACT_PATH_ARGUMENTS = new Set(["ARGUMENTS", "arguments", "task_id"]);
20
+ export function safePathComponent(value, label) {
21
+ if (value === "." || value === ".." || !SAFE_PATH_COMPONENT.test(value)) {
22
+ throw new Error(`Invalid ${label}`);
23
+ }
24
+ return value;
25
+ }
26
+ function safeUserArtifactPathArgument(value, label) {
27
+ try {
28
+ return safePathComponent(value, label);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
17
34
  // ── Path Resolution ────────────────────────────────────────
18
35
  /**
19
36
  * Resolve state directory path.
@@ -23,10 +40,97 @@ const DEFAULT_STALENESS_MS = 2 * 60 * 60 * 1000; // 2 hours
23
40
  export function resolveStateDir(projectDir, sessionId) {
24
41
  const base = join(projectDir, ".dna", "state");
25
42
  if (sessionId) {
26
- return join(base, "sessions", sessionId);
43
+ return join(base, "sessions", safePathComponent(sessionId, "session_id"));
27
44
  }
28
45
  return base;
29
46
  }
47
+ export const ARTIFACT_RESOLVER_VERSION = 1;
48
+ export function isRequiredArtifact(artifact) {
49
+ return artifact.required !== false;
50
+ }
51
+ export function artifactIdentity(artifact, resolvedPath) {
52
+ return artifact.artifact_id ?? artifact.name ?? resolvedPath ?? (artifact.type === "git_commit" ? "git_commit" : null);
53
+ }
54
+ export function artifactHash(key) {
55
+ return createHash("sha256")
56
+ .update(JSON.stringify(key))
57
+ .digest("hex")
58
+ .slice(0, 16);
59
+ }
60
+ export function buildArtifactKey(params) {
61
+ return {
62
+ workflow: params.workflow,
63
+ session_id: params.sessionId,
64
+ producer_step: params.producerStep,
65
+ type: params.type,
66
+ identity: params.identity,
67
+ };
68
+ }
69
+ export function artifactManifestPath(projectDir, key) {
70
+ return join(resolveStateDir(projectDir, key.session_id), "handoffs", safePathComponent(key.workflow, "workflow"), safePathComponent(key.producer_step, "producer_step"), `${artifactHash(key)}.json`);
71
+ }
72
+ export function resolveArtifactTemplate(template, state) {
73
+ let resolved = template;
74
+ const inputs = state?.inputs ?? {};
75
+ const resolvedVariables = state?.resolved_variables ?? {};
76
+ resolved = resolved.replace(/\{\{(\w+)\}\}/g, (_match, name) => {
77
+ const inputValue = inputs[name];
78
+ if (inputValue !== undefined) {
79
+ return safeUserArtifactPathArgument(inputValue, name) ?? `{{${name}}}`;
80
+ }
81
+ const resolvedValue = resolvedVariables[name];
82
+ if (resolvedValue === undefined)
83
+ return `{{${name}}}`;
84
+ if (USER_ARTIFACT_PATH_ARGUMENTS.has(name)) {
85
+ return safeUserArtifactPathArgument(resolvedValue, name) ?? `{{${name}}}`;
86
+ }
87
+ return resolvedValue;
88
+ });
89
+ const args = inputs.ARGUMENTS ?? inputs.arguments ?? inputs.task_id ?? resolvedVariables.ARGUMENTS ?? resolvedVariables.arguments ?? resolvedVariables.task_id;
90
+ if (resolved.includes("$ARGUMENTS")) {
91
+ if (args === undefined)
92
+ return null;
93
+ const safeArgs = safeUserArtifactPathArgument(args, "ARGUMENTS");
94
+ if (!safeArgs)
95
+ return null;
96
+ resolved = resolved.replaceAll("$ARGUMENTS", safeArgs);
97
+ }
98
+ if (/\{\{\w+\}\}/.test(resolved))
99
+ return null;
100
+ return resolved;
101
+ }
102
+ export async function writeArtifactManifest(projectDir, manifest) {
103
+ const id = artifactHash(manifest.key);
104
+ const full = {
105
+ ...manifest,
106
+ id,
107
+ recorded_at: new Date().toISOString(),
108
+ resolver_version: ARTIFACT_RESOLVER_VERSION,
109
+ };
110
+ const manifestPath = artifactManifestPath(projectDir, manifest.key);
111
+ await atomicWrite(manifestPath, JSON.stringify(full, null, 2));
112
+ return { ...full, manifest_path: manifestPath };
113
+ }
114
+ export async function readArtifactManifest(projectDir, key) {
115
+ const manifestPath = artifactManifestPath(projectDir, key);
116
+ try {
117
+ const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
118
+ if (manifest.resolver_version !== ARTIFACT_RESOLVER_VERSION)
119
+ return null;
120
+ if (manifest.key.workflow !== key.workflow || manifest.key.session_id !== key.session_id ||
121
+ manifest.key.producer_step !== key.producer_step || manifest.key.type !== key.type ||
122
+ manifest.key.identity !== key.identity)
123
+ return null;
124
+ if (manifest.workflow !== key.workflow || manifest.session_id !== key.session_id ||
125
+ manifest.producer_step !== key.producer_step || manifest.type !== key.type ||
126
+ manifest.identity !== key.identity)
127
+ return null;
128
+ return { ...manifest, manifest_path: manifestPath };
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
30
134
  // ── Workflow State ─────────────────────────────────────────
31
135
  /**
32
136
  * Read current workflow state. Returns null if not found or stale.
@@ -121,13 +225,24 @@ export async function appendCompletedArtifact(projectDir, stepId, artifact, sess
121
225
  stepEntry = { step_id: stepId, artifacts: [] };
122
226
  artifacts.push(stepEntry);
123
227
  }
124
- // Dedup: don't add if same path already recorded
125
- if (stepEntry.artifacts.some(a => a.path === artifact.path))
228
+ const verifiedAt = new Date().toISOString();
229
+ const existing = stepEntry.artifacts.find(a => a.type === artifact.type && a.path === artifact.path);
230
+ if (existing) {
231
+ existing.verified_at = verifiedAt;
232
+ if (artifact.artifact_id)
233
+ existing.artifact_id = artifact.artifact_id;
234
+ if (artifact.metadata)
235
+ existing.metadata = artifact.metadata;
236
+ state.completed_artifacts = artifacts;
237
+ await writeWorkflowState(projectDir, state, sessionId);
126
238
  return;
239
+ }
127
240
  stepEntry.artifacts.push({
128
241
  type: artifact.type,
129
242
  path: artifact.path,
130
- verified_at: new Date().toISOString(),
243
+ verified_at: verifiedAt,
244
+ ...(artifact.artifact_id ? { artifact_id: artifact.artifact_id } : {}),
245
+ ...(artifact.metadata ? { metadata: artifact.metadata } : {}),
131
246
  });
132
247
  state.completed_artifacts = artifacts;
133
248
  await writeWorkflowState(projectDir, state, sessionId);
@@ -11,6 +11,10 @@ import { resolve } from "node:path";
11
11
  import { readWorkflowState, writeWorkflowState, readTraces, listSessions } from "../hooks/state.js";
12
12
  import { loadCompiledIR } from "../runtime/plugin-adapter.js";
13
13
  import { textResult } from "./server.js";
14
+ function isStringRecord(value) {
15
+ return typeof value === "object" && value !== null &&
16
+ Object.values(value).every(v => typeof v === "string");
17
+ }
14
18
  export function createStateTools(projectDir) {
15
19
  return [
16
20
  // ── dna_status ────────────────────────────────────────
@@ -95,6 +99,8 @@ export function createStateTools(projectDir) {
95
99
  iteration: { type: "number", description: "Current iteration number" },
96
100
  active: { type: "boolean", description: "Whether workflow is active (false to deactivate)" },
97
101
  session_id: { type: "string", description: "Session ID for isolation" },
102
+ inputs: { type: "object", description: "Workflow runtime inputs such as ARGUMENTS" },
103
+ resolved_variables: { type: "object", description: "Resolved template variables for artifact paths" },
98
104
  },
99
105
  required: ["workflow", "current_step", "current_role"],
100
106
  },
@@ -109,6 +115,8 @@ export function createStateTools(projectDir) {
109
115
  iteration: typeof args.iteration === "number" ? args.iteration : (existing?.iteration ?? 1),
110
116
  session_id: sessionId ?? existing?.session_id ?? "",
111
117
  started_at: existing?.started_at ?? new Date().toISOString(),
118
+ inputs: isStringRecord(args.inputs) ? args.inputs : existing?.inputs,
119
+ resolved_variables: isStringRecord(args.resolved_variables) ? args.resolved_variables : existing?.resolved_variables,
112
120
  completed_artifacts: existing?.completed_artifacts,
113
121
  };
114
122
  await writeWorkflowState(projectDir, state, sessionId);
@@ -70,10 +70,10 @@ export function detectEnabledEvents(ir) {
70
70
  const hasStopVerifiers = ir.verifier_specs?.some((spec) => spec.when === "stop" || spec.when === "pre_handoff" || spec.when === "post_step") ?? false;
71
71
  const hasPostToolVerifiers = ir.verifier_specs?.some((spec) => spec.when === "post_tool_use") ?? false;
72
72
  const hasHandoffs = ir.workflows_ir?.some((wf) => wf.handoff_chain.length > 0) ?? false;
73
- if (hasGates || hasFilters || hasRoleScope || hasCheckpoints) {
73
+ if (hasGates || hasFilters || hasRoleScope || hasCheckpoints || hasHandoffs) {
74
74
  events.push("preToolUse");
75
75
  }
76
- if (ir.post_execution_validators.length > 0 || hasPostToolVerifiers) {
76
+ if (ir.post_execution_validators.length > 0 || hasPostToolVerifiers || hasHandoffs) {
77
77
  events.push("postToolUse");
78
78
  }
79
79
  if (ir.prompt_directives.some(d => d.priority === "high")) {
@@ -6,13 +6,14 @@
6
6
  *
7
7
  * Skills are compiled views of DNA's structured definitions — not stored content.
8
8
  */
9
- import type { WorkflowPlan, RoleDef, ConstraintIR } from "../schema/types.js";
9
+ import type { WorkflowPlan, WorkflowDef, RoleDef, ConstraintIR, ControllerDef } from "../schema/types.js";
10
10
  export interface SkillResult {
11
11
  name: string;
12
12
  fileName: string;
13
13
  dirName: string;
14
14
  content: string;
15
15
  }
16
+ export declare function compileControllerToSkill(controllerKey: string, controller: ControllerDef, variables?: Record<string, string>, workflows?: Record<string, WorkflowDef>): SkillResult;
16
17
  /**
17
18
  * Compile a WorkflowPlan + roles + IR into a SKILL.md file.
18
19
  * Variables from DNA config are substituted into prompts and descriptions.