intentdna 1.6.5 → 1.7.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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/cli/commands/compile.js +2 -47
  4. package/dist/cli/commands/context.d.ts +8 -0
  5. package/dist/cli/commands/context.js +63 -0
  6. package/dist/cli/commands/feedback.d.ts +1 -0
  7. package/dist/cli/commands/feedback.js +11 -0
  8. package/dist/cli/commands/init.js +11 -63
  9. package/dist/cli/commands/run.js +5 -4
  10. package/dist/cli/commands/show.js +2 -38
  11. package/dist/cli/commands/sync.d.ts +9 -6
  12. package/dist/cli/commands/sync.js +186 -184
  13. package/dist/cli/commands/templates.d.ts +10 -1
  14. package/dist/cli/commands/templates.js +50 -1
  15. package/dist/cli/commands/validate.js +15 -9
  16. package/dist/cli/commands/verify.js +97 -25
  17. package/dist/cli/index.js +76 -11
  18. package/dist/compiler/cascade.d.ts +3 -1
  19. package/dist/compiler/cascade.js +51 -0
  20. package/dist/compiler/compile.js +37 -0
  21. package/dist/compiler/diagnostics.d.ts +17 -0
  22. package/dist/compiler/diagnostics.js +30 -0
  23. package/dist/compiler/index.d.ts +3 -0
  24. package/dist/compiler/index.js +8 -11
  25. package/dist/compiler/input-resolver.d.ts +25 -0
  26. package/dist/compiler/input-resolver.js +175 -0
  27. package/dist/hooks/cli.d.ts +10 -1
  28. package/dist/hooks/cli.js +37 -22
  29. package/dist/hooks/state.d.ts +2 -0
  30. package/dist/hooks/state.js +23 -2
  31. package/dist/mcp/index.js +2 -0
  32. package/dist/mcp/tools-compile.js +18 -49
  33. package/dist/mcp/tools-context.d.ts +2 -0
  34. package/dist/mcp/tools-context.js +85 -0
  35. package/dist/mcp/tools-enforce.d.ts +2 -2
  36. package/dist/mcp/tools-enforce.js +19 -49
  37. package/dist/mcp/tools-observability.js +24 -0
  38. package/dist/report/kernel-signals.js +3 -0
  39. package/dist/report/report-package.d.ts +56 -0
  40. package/dist/report/report-package.js +85 -0
  41. package/dist/runtime/agent-md.d.ts +1 -0
  42. package/dist/runtime/agent-md.js +21 -3
  43. package/dist/runtime/context-sources.d.ts +14 -0
  44. package/dist/runtime/context-sources.js +60 -0
  45. package/dist/runtime/skill-adapter.d.ts +32 -4
  46. package/dist/runtime/skill-adapter.js +184 -9
  47. package/dist/runtime/workflow-runner.d.ts +1 -1
  48. package/dist/runtime/workflow-runner.js +1 -1
  49. package/dist/schema/types.d.ts +33 -0
  50. package/dist/schema/validate.js +156 -2
  51. package/dist/schema/validators/controllers.js +16 -0
  52. package/dist/signals/index.d.ts +10 -0
  53. package/dist/signals/index.js +90 -5
  54. package/dist/templates/catalog.d.ts +19 -0
  55. package/dist/templates/catalog.js +57 -0
  56. package/dist/templates/flutter-rewrite.dna.yaml +2 -2
  57. package/package.json +1 -1
  58. package/spec/foundation-hardening.md +2 -1
@@ -0,0 +1,60 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import { compileFromFiles, detectDNAConfigs, expandDNAInputFiles } from "../compiler/index.js";
4
+ export async function loadContextSourceIndex(projectDir, files) {
5
+ const inputFiles = files && files.length > 0
6
+ ? files.map((file) => resolve(projectDir, file))
7
+ : await detectDNAConfigs(projectDir);
8
+ if (inputFiles.length === 0)
9
+ return { sources: [], planning_instructions: [] };
10
+ const expandedFiles = await expandDNAInputFiles(inputFiles);
11
+ const ir = await compileFromFiles(expandedFiles);
12
+ return {
13
+ sources: ir.context_sources ?? [],
14
+ planning_instructions: ir.planning_context?.instructions ?? [],
15
+ };
16
+ }
17
+ export function searchContextSources(index, query) {
18
+ const normalized = query.trim().toLowerCase();
19
+ if (!normalized)
20
+ return index.sources;
21
+ return index.sources.filter((source) => {
22
+ const haystack = [
23
+ source.id,
24
+ source.type,
25
+ source.path,
26
+ source.url,
27
+ source.title,
28
+ source.description,
29
+ ...(source.tags ?? []),
30
+ ].filter(Boolean).join("\n").toLowerCase();
31
+ return haystack.includes(normalized);
32
+ });
33
+ }
34
+ export function findContextSource(index, id) {
35
+ return index.sources.find((source) => source.id === id);
36
+ }
37
+ export async function readContextSourceDocument(projectDir, source) {
38
+ if (source.type !== "file" && source.type !== "doc") {
39
+ return { source };
40
+ }
41
+ if (!source.path)
42
+ return { source };
43
+ if (isAbsolute(source.path) || source.path.split(/[\\/]+/).includes("..")) {
44
+ throw new Error(`Context source '${source.id}' path must stay within the project`);
45
+ }
46
+ const projectRoot = await realpath(projectDir);
47
+ const filePath = await realpath(resolve(projectRoot, source.path));
48
+ const rel = relative(projectRoot, filePath);
49
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
50
+ throw new Error(`Context source '${source.id}' path must stay within the project`);
51
+ }
52
+ const content = await readFile(filePath, "utf-8");
53
+ return { source, content };
54
+ }
55
+ export function formatContextSource(source) {
56
+ const locator = source.path ?? source.url ?? "(no locator)";
57
+ const title = source.title ? ` — ${source.title}` : "";
58
+ const tags = source.tags?.length ? ` [${source.tags.join(", ")}]` : "";
59
+ return `${source.id}: ${source.type} ${locator}${title}${tags}`;
60
+ }
@@ -7,17 +7,45 @@
7
7
  * Skills are compiled views of DNA's structured definitions — not stored content.
8
8
  */
9
9
  import type { WorkflowPlan, WorkflowDef, RoleDef, ConstraintIR, ControllerDef } from "../schema/types.js";
10
+ export interface SkillFile {
11
+ relativePath: string;
12
+ content: string;
13
+ }
14
+ export interface SkillMapEntry {
15
+ name: string;
16
+ dirName: string;
17
+ source: string;
18
+ kind: "workflow" | "controller";
19
+ bodyPath: string;
20
+ roles: string[];
21
+ consumes: Array<{
22
+ step_id: string;
23
+ type: string;
24
+ path?: string;
25
+ name?: string;
26
+ artifact_id?: string;
27
+ description: string;
28
+ }>;
29
+ produces: Array<{
30
+ step_id: string;
31
+ type: string;
32
+ path?: string;
33
+ name?: string;
34
+ artifact_id?: string;
35
+ description: string;
36
+ }>;
37
+ }
10
38
  export interface SkillResult {
11
39
  name: string;
12
40
  fileName: string;
13
41
  dirName: string;
14
42
  content: string;
43
+ launcherContent?: string;
44
+ bodyContent?: string;
45
+ bodyFileName?: string;
46
+ mapEntry?: SkillMapEntry;
15
47
  }
16
48
  export declare function compileControllerToSkill(controllerKey: string, controller: ControllerDef, variables?: Record<string, string>, workflows?: Record<string, WorkflowDef>): SkillResult;
17
- /**
18
- * Compile a WorkflowPlan + roles + IR into a SKILL.md file.
19
- * Variables from DNA config are substituted into prompts and descriptions.
20
- */
21
49
  export declare function compileWorkflowToSkill(plan: WorkflowPlan, roles: Record<string, RoleDef>, ir?: ConstraintIR, variables?: Record<string, string>): SkillResult;
22
50
  export declare function writeSkillFiles(results: SkillResult[], outputDir: string): Promise<string[]>;
23
51
  export declare function removeSkillFiles(outputDir: string): Promise<number>;
@@ -7,10 +7,18 @@
7
7
  * Skills are compiled views of DNA's structured definitions — not stored content.
8
8
  */
9
9
  import { mkdir, writeFile, readdir, readFile, rm } from "node:fs/promises";
10
- import { join } from "node:path";
10
+ import { join, resolve, sep } from "node:path";
11
11
  import { getControllerKindMetadata } from "../schema/controller-registry.js";
12
- import { toKebabCase } from "./agent-md.js";
12
+ import { assertSafeGeneratedName, toKebabCase } from "./agent-md.js";
13
13
  const SENTINEL = "<!-- intentdna:managed — do not edit manually -->";
14
+ function resolveContainedPath(baseDir, ...segments) {
15
+ const base = resolve(baseDir);
16
+ const target = resolve(baseDir, ...segments);
17
+ if (target !== base && !target.startsWith(`${base}${sep}`)) {
18
+ throw new Error(`generated path escapes output directory: ${target}`);
19
+ }
20
+ return target;
21
+ }
14
22
  // ── Compile Controller → Skill ─────────────────────────────
15
23
  export function compileControllerToSkill(controllerKey, controller, variables, workflows) {
16
24
  const kindMetadata = getControllerKindMetadata(controller.kind);
@@ -18,6 +26,7 @@ export function compileControllerToSkill(controllerKey, controller, variables, w
18
26
  throw new Error(`Unsupported controller kind: ${controller.kind}`);
19
27
  }
20
28
  const skillName = `dna-${toKebabCase(controllerKey)}`;
29
+ assertSafeGeneratedName(skillName, "skill name");
21
30
  const analyzerAgent = `dna-${toKebabCase(controller.roles.analyzer)}`;
22
31
  const analysisReviewerAgent = `dna-${toKebabCase(controller.roles.analysis_reviewer)}`;
23
32
  const surgeonAgent = `dna-${toKebabCase(controller.roles.surgeon)}`;
@@ -136,6 +145,7 @@ export function compileControllerToSkill(controllerKey, controller, variables, w
136
145
  lines.push("</Verdict_Mapping>");
137
146
  lines.push("");
138
147
  lines.push("<Stop_Report>");
148
+ lines.push("Every critical decision in the stop report must cite at least one verifier result_id, trace_id, or ArtifactManifest artifact id; prose-only claims are not sufficient.");
139
149
  for (const line of policy.stop_report) {
140
150
  lines.push(line);
141
151
  }
@@ -155,7 +165,28 @@ export function compileControllerToSkill(controllerKey, controller, variables, w
155
165
  }
156
166
  const dirName = skillName;
157
167
  const fileName = join(dirName, "SKILL.md");
158
- return { name: skillName, fileName, dirName, content };
168
+ return attachSkillBody({ name: skillName, fileName, dirName, content }, {
169
+ description: controller.description || controller.name,
170
+ triggers: [toKebabCase(controllerKey), `run ${toKebabCase(controllerKey)}`],
171
+ kind: "controller",
172
+ mapEntry: {
173
+ name: skillName,
174
+ dirName,
175
+ source: controllerKey,
176
+ kind: "controller",
177
+ bodyPath: join(dirName, "skill-bodies", "body.md"),
178
+ roles: Object.values(controller.roles),
179
+ consumes: [
180
+ { step_id: "controller", type: "file", path: controller.diagnosis_artifact_path, description: "Diagnosis artifact" },
181
+ { step_id: "controller", type: "file", path: controller.diagnosis_review_artifact_path, description: "Diagnosis review artifact" },
182
+ { step_id: "controller", type: "file", path: controller.review_artifact_path, description: "Fix review artifact" },
183
+ { step_id: "controller", type: "file", path: controller.progress_artifact_path, description: "Progress JSON artifact" },
184
+ ],
185
+ produces: [
186
+ { step_id: "controller", type: "file", path: controller.progress_artifact_path, description: "Progress JSON artifact" },
187
+ ],
188
+ },
189
+ });
159
190
  }
160
191
  function requireStepByRole(workflow, role, workflowName) {
161
192
  const step = workflow?.steps.find((candidate) => candidate.role === role);
@@ -164,6 +195,73 @@ function requireStepByRole(workflow, role, workflowName) {
164
195
  }
165
196
  return step;
166
197
  }
198
+ function createSkillLauncher(params) {
199
+ const lines = [];
200
+ lines.push("---");
201
+ lines.push(`name: ${params.skillName}`);
202
+ lines.push(`description: "Use when user says /${params.skillName}. ${escapeYaml(params.description)}"`);
203
+ lines.push("user-invocable: true");
204
+ lines.push("triggers:");
205
+ for (const trigger of params.triggers)
206
+ lines.push(` - "${escapeYaml(trigger)}"`);
207
+ lines.push("---");
208
+ lines.push("");
209
+ lines.push(SENTINEL);
210
+ lines.push(`<!-- Compiled: ${new Date().toISOString()} -->`);
211
+ lines.push("");
212
+ lines.push(`# /${params.skillName}`);
213
+ lines.push("");
214
+ lines.push("<Purpose>");
215
+ lines.push(params.description);
216
+ lines.push("</Purpose>");
217
+ lines.push("");
218
+ lines.push("<Skill_Body>");
219
+ lines.push(`Full ${params.kind} instructions live in \`skill-bodies/${params.bodyFileName}\`.`);
220
+ lines.push("Read that body before executing any step; the launcher is only an entrypoint.");
221
+ lines.push("</Skill_Body>");
222
+ lines.push("");
223
+ if (params.bodyContent.includes("<Required_Context>")) {
224
+ lines.push("<Required_Context>");
225
+ lines.push("The skill body contains required context files. Read them before any Edit, Write, or Bash action.");
226
+ lines.push("</Required_Context>");
227
+ lines.push("");
228
+ }
229
+ if (params.bodyContent.includes("<Advisory_Context>")) {
230
+ lines.push("<Advisory_Context>");
231
+ lines.push("The skill body contains planning-only advisory context sources. Quote and cite them when used; they never satisfy handoff or enforcement checks.");
232
+ lines.push("</Advisory_Context>");
233
+ lines.push("");
234
+ }
235
+ if (params.bodyContent.includes("<Handoff>")) {
236
+ lines.push("<Handoff>");
237
+ lines.push("Write human-readable handoff notes when requested, but do not treat handoff.md as a machine fact.");
238
+ lines.push("ArtifactManifest entries written by runtime hooks remain the machine source of truth for handoff satisfaction.");
239
+ lines.push("</Handoff>");
240
+ lines.push("");
241
+ }
242
+ lines.push("<Execution>");
243
+ lines.push(`Follow \`skill-bodies/${params.bodyFileName}\` exactly.`);
244
+ lines.push("</Execution>");
245
+ return lines.join("\n") + "\n";
246
+ }
247
+ function attachSkillBody(result, params) {
248
+ const bodyFileName = "body.md";
249
+ const launcherContent = createSkillLauncher({
250
+ skillName: result.name,
251
+ description: params.description,
252
+ triggers: params.triggers,
253
+ bodyFileName,
254
+ bodyContent: result.content,
255
+ kind: params.kind,
256
+ });
257
+ return {
258
+ ...result,
259
+ launcherContent,
260
+ bodyContent: result.content,
261
+ bodyFileName,
262
+ mapEntry: params.mapEntry,
263
+ };
264
+ }
167
265
  function normalizeControllerPolicy(controller) {
168
266
  const defaults = defaultControllerPolicy(controller);
169
267
  const policy = {
@@ -234,7 +332,7 @@ function defaultControllerPolicy(controller) {
234
332
  "- MAX_ROUNDS: stop and report max rounds reached",
235
333
  ],
236
334
  stop_report: [
237
- "On REQUEST_CHANGES, BLOCKED, or MAX_ROUNDS, explain the stop reason, the evidence, and the next action.",
335
+ "On REQUEST_CHANGES, BLOCKED, or MAX_ROUNDS, explain the stop reason, the evidence, the next action, and cite at least one verifier result_id, trace_id, or ArtifactManifest artifact id.",
238
336
  ],
239
337
  constraints: [
240
338
  `The controller must schedule only from ${controller.progress_artifact_path}.`,
@@ -247,9 +345,46 @@ function defaultControllerPolicy(controller) {
247
345
  * Compile a WorkflowPlan + roles + IR into a SKILL.md file.
248
346
  * Variables from DNA config are substituted into prompts and descriptions.
249
347
  */
348
+ function workflowSkillMapEntry(skillName, dirName, plan) {
349
+ const consumes = [];
350
+ const produces = [];
351
+ for (const step of plan.steps) {
352
+ for (const artifact of step.handoff?.consumes ?? []) {
353
+ consumes.push({
354
+ step_id: step.id,
355
+ type: artifact.type,
356
+ path: artifact.path,
357
+ name: artifact.name,
358
+ artifact_id: artifact.artifact_id,
359
+ description: artifact.description,
360
+ });
361
+ }
362
+ for (const artifact of step.handoff?.produces ?? []) {
363
+ produces.push({
364
+ step_id: step.id,
365
+ type: artifact.type,
366
+ path: artifact.path,
367
+ name: artifact.name,
368
+ artifact_id: artifact.artifact_id,
369
+ description: artifact.description,
370
+ });
371
+ }
372
+ }
373
+ return {
374
+ name: skillName,
375
+ dirName,
376
+ source: plan.workflow_key ?? plan.source_workflow,
377
+ kind: "workflow",
378
+ bodyPath: join(dirName, "skill-bodies", "body.md"),
379
+ roles: [...new Set(plan.steps.map((step) => step.role))],
380
+ consumes,
381
+ produces,
382
+ };
383
+ }
250
384
  export function compileWorkflowToSkill(plan, roles, ir, variables) {
251
385
  const lines = [];
252
386
  const skillName = `dna-${toKebabCase(plan.name)}`;
387
+ assertSafeGeneratedName(skillName, "skill name");
253
388
  // Frontmatter
254
389
  lines.push("---");
255
390
  lines.push(`name: ${skillName}`);
@@ -341,6 +476,22 @@ export function compileWorkflowToSkill(plan, roles, ir, variables) {
341
476
  lines.push("</Required_Context>");
342
477
  lines.push("");
343
478
  }
479
+ if (ir?.context_sources?.length) {
480
+ lines.push("<Advisory_Context>");
481
+ lines.push("These sources are planning-only references. They do not satisfy ArtifactManifest, handoff consumes, completion checks, or enforcement facts.");
482
+ lines.push("If you use them, quote the relevant passage and cite the source id.");
483
+ lines.push("");
484
+ for (const source of ir.context_sources) {
485
+ const locator = source.path ?? source.url ?? "(no locator)";
486
+ const title = source.title ? ` — ${source.title}` : "";
487
+ lines.push(`- ${source.id}: ${source.type} ${locator}${title}`);
488
+ }
489
+ for (const instruction of ir.planning_context?.instructions ?? []) {
490
+ lines.push(`- Planning instruction: ${instruction}`);
491
+ }
492
+ lines.push("</Advisory_Context>");
493
+ lines.push("");
494
+ }
344
495
  // Steps — each step MUST be executed via Agent() tool call
345
496
  const usedRoles = new Set(plan.steps.map((s) => s.role));
346
497
  // Pre-compute handoff targets: steps that are pointed to by another step's handoff_to
@@ -489,6 +640,8 @@ export function compileWorkflowToSkill(plan, roles, ir, variables) {
489
640
  const stepsWithHandoff = plan.steps.filter((s) => s.handoff && (s.handoff.consumes?.length || s.handoff.produces?.length));
490
641
  if (stepsWithHandoff.length > 0) {
491
642
  lines.push("<Handoff>");
643
+ lines.push("Human-readable handoff notes are useful for review, but they do not satisfy machine handoff gates.");
644
+ lines.push("ArtifactManifest records written by runtime hooks remain the machine source of truth for produced/consumed artifacts.");
492
645
  for (const step of stepsWithHandoff) {
493
646
  const h = step.handoff;
494
647
  if (h.produces && h.produces.length > 0) {
@@ -532,7 +685,12 @@ export function compileWorkflowToSkill(plan, roles, ir, variables) {
532
685
  }
533
686
  const dirName = skillName;
534
687
  const fileName = join(dirName, "SKILL.md");
535
- return { name: skillName, fileName, dirName, content };
688
+ return attachSkillBody({ name: skillName, fileName, dirName, content }, {
689
+ description: plan.description || plan.name,
690
+ triggers: [plan.name, `run ${plan.name}`],
691
+ kind: "workflow",
692
+ mapEntry: workflowSkillMapEntry(skillName, dirName, plan),
693
+ });
536
694
  }
537
695
  /** Collect all {{var_name}} references from a workflow plan. */
538
696
  function collectVariableRefs(plan) {
@@ -555,12 +713,29 @@ function collectVariableRefs(plan) {
555
713
  // ── File Operations ────────────────────────────────────────
556
714
  export async function writeSkillFiles(results, outputDir) {
557
715
  const written = [];
716
+ const mapEntries = [];
558
717
  for (const result of results) {
559
- const dir = join(outputDir, result.dirName);
718
+ assertSafeGeneratedName(result.dirName, "skill directory");
719
+ const dir = resolveContainedPath(outputDir, result.dirName);
560
720
  await mkdir(dir, { recursive: true });
561
- const path = join(dir, "SKILL.md");
562
- await writeFile(path, result.content, "utf-8");
563
- written.push(path);
721
+ const skillPath = resolveContainedPath(dir, "SKILL.md");
722
+ await writeFile(skillPath, result.launcherContent ?? result.content, "utf-8");
723
+ written.push(skillPath);
724
+ if (result.bodyContent && result.bodyFileName) {
725
+ assertSafeGeneratedName(result.bodyFileName.replace(/\.md$/, ""), "skill body file");
726
+ const bodyDir = resolveContainedPath(dir, "skill-bodies");
727
+ await mkdir(bodyDir, { recursive: true });
728
+ const bodyPath = resolveContainedPath(bodyDir, result.bodyFileName);
729
+ await writeFile(bodyPath, result.bodyContent, "utf-8");
730
+ written.push(bodyPath);
731
+ }
732
+ if (result.mapEntry)
733
+ mapEntries.push(result.mapEntry);
734
+ }
735
+ if (mapEntries.length > 0) {
736
+ const mapPath = resolveContainedPath(outputDir, ".intentdna-skill-map.json");
737
+ await writeFile(mapPath, JSON.stringify({ generated_at: new Date().toISOString(), skills: mapEntries }, null, 2) + "\n", "utf-8");
738
+ written.push(mapPath);
564
739
  }
565
740
  return written;
566
741
  }
@@ -19,7 +19,7 @@ export interface WorkflowShellOptions {
19
19
  maxBudgetPerAgent?: number;
20
20
  /** Output format for agent results (default "json") */
21
21
  outputFormat?: "json" | "text";
22
- /** Permission mode (default "bypassPermissions") */
22
+ /** Permission mode (default "default") */
23
23
  permissionMode?: string;
24
24
  /** Log directory relative to project root (default ".") */
25
25
  logDir?: string;
@@ -466,7 +466,7 @@ export function compileWorkflowToShell(plan, options) {
466
466
  agentPrefix: options?.agentPrefix ?? "dna-",
467
467
  maxBudgetPerAgent: options?.maxBudgetPerAgent ?? 10,
468
468
  outputFormat: options?.outputFormat ?? "json",
469
- permissionMode: options?.permissionMode ?? "bypassPermissions",
469
+ permissionMode: options?.permissionMode ?? "default",
470
470
  logDir: options?.logDir ?? ".",
471
471
  pauseSupport: options?.pauseSupport ?? false,
472
472
  };
@@ -295,6 +295,35 @@ export interface LegibilityAssetMap {
295
295
  per_role?: Record<string, LegibilityAsset[]>;
296
296
  per_workflow?: Record<string, LegibilityAsset[]>;
297
297
  }
298
+ export type AdvisoryContextSourceType = "file" | "url" | "doc" | "note";
299
+ export interface AdvisoryContextSource {
300
+ id: string;
301
+ type: AdvisoryContextSourceType;
302
+ path?: string;
303
+ url?: string;
304
+ title?: string;
305
+ description?: string;
306
+ tags?: string[];
307
+ }
308
+ export interface PlanningContext {
309
+ sources: string[];
310
+ instructions?: string[];
311
+ require_quotes?: boolean;
312
+ require_attribution?: boolean;
313
+ }
314
+ export interface CompiledAdvisoryContextSource extends AdvisoryContextSource {
315
+ usage: "planning_only";
316
+ non_enforcing: true;
317
+ requires_quotes: boolean;
318
+ requires_attribution: boolean;
319
+ }
320
+ export interface CompiledPlanningContext {
321
+ sources: string[];
322
+ instructions?: string[];
323
+ require_quotes: boolean;
324
+ require_attribution: boolean;
325
+ non_enforcing: true;
326
+ }
298
327
  export interface IntentDNA {
299
328
  $schema?: string;
300
329
  version: string;
@@ -311,6 +340,8 @@ export interface IntentDNA {
311
340
  variables?: Record<string, string | VariableDef>;
312
341
  mcp?: Record<string, MCPServerDef>;
313
342
  legibility_assets?: LegibilityAssetMap;
343
+ context_sources?: AdvisoryContextSource[];
344
+ planning_context?: PlanningContext;
314
345
  context_files?: {
315
346
  mandatory?: string[];
316
347
  per_role?: Record<string, string[]>;
@@ -439,6 +470,8 @@ export interface ConstraintIR {
439
470
  post_execution_validators: PostExecutionValidator[];
440
471
  verifier_specs?: VerifierSpec[];
441
472
  context_injections: ContextInjection[];
473
+ context_sources?: CompiledAdvisoryContextSource[];
474
+ planning_context?: CompiledPlanningContext;
442
475
  source_dna_ids: string[];
443
476
  compiled_at: string;
444
477
  active_context?: string;
@@ -7,7 +7,25 @@
7
7
  * - Codon values in valid ranges
8
8
  * - No circular inheritance
9
9
  */
10
+ import { isAbsolute } from "node:path";
10
11
  import { validateController } from "./validators/controllers.js";
12
+ const SAFE_GENERATED_IDENTIFIER = /^[A-Za-z0-9_-]+$/;
13
+ const SAFE_GENERATED_DISPLAY_NAME = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/;
14
+ function isSafeGeneratedIdentifier(value) {
15
+ return SAFE_GENERATED_IDENTIFIER.test(value) && !value.includes("..") && !value.includes("/") && !value.includes("\\");
16
+ }
17
+ function isSafeGeneratedDisplayName(value) {
18
+ return SAFE_GENERATED_DISPLAY_NAME.test(value) && !value.includes("..");
19
+ }
20
+ function isSafeProjectTemplatePath(value) {
21
+ const withoutPlaceholders = value.replace(/\{\{[A-Za-z_][A-Za-z0-9_]*\}\}/g, "placeholder");
22
+ return value.trim().length > 0 &&
23
+ !isAbsolute(value) &&
24
+ !value.split(/[\\/]+/).includes("..") &&
25
+ !/[*?[\]{}]/.test(withoutPlaceholders) &&
26
+ !value.includes("${") &&
27
+ !value.includes("$(`");
28
+ }
11
29
  function isLegibilityAssetArray(value) {
12
30
  return Array.isArray(value) && value.every((item) => typeof item === "object" && item !== null &&
13
31
  typeof item.type === "string" &&
@@ -72,6 +90,126 @@ function validateLegibilityAssets(dna, roleNamesSet, errors, warnings) {
72
90
  }
73
91
  }
74
92
  }
93
+ function validateContextSources(dna, errors) {
94
+ const sources = dna.context_sources;
95
+ if (sources !== undefined) {
96
+ if (!Array.isArray(sources)) {
97
+ errors.push({ path: "context_sources", message: "must be an array" });
98
+ }
99
+ else {
100
+ const seen = new Set();
101
+ for (let i = 0; i < sources.length; i++) {
102
+ const path = `context_sources[${i}]`;
103
+ if (typeof sources[i] !== "object" || sources[i] === null || Array.isArray(sources[i])) {
104
+ errors.push({ path, message: "context source must be an object" });
105
+ continue;
106
+ }
107
+ const source = sources[i];
108
+ if (!source.id || typeof source.id !== "string") {
109
+ errors.push({ path: `${path}.id`, message: "context source requires non-empty id" });
110
+ }
111
+ else if (seen.has(source.id)) {
112
+ errors.push({ path: `${path}.id`, message: `duplicate context source id '${source.id}'` });
113
+ }
114
+ else {
115
+ seen.add(source.id);
116
+ }
117
+ if (!["file", "url", "doc", "note"].includes(source.type)) {
118
+ errors.push({ path: `${path}.type`, message: "must be one of: file, url, doc, note" });
119
+ }
120
+ if (source.type === "file" || source.type === "doc") {
121
+ if (typeof source.path !== "string" || !source.path) {
122
+ errors.push({ path: `${path}.path`, message: `${source.type} context source requires non-empty path` });
123
+ }
124
+ else if (isAbsolute(source.path) || source.path.split(/[\\/]+/).includes("..")) {
125
+ errors.push({ path: `${path}.path`, message: "context source path must stay within the project" });
126
+ }
127
+ }
128
+ if (source.type === "url" && (typeof source.url !== "string" || !source.url)) {
129
+ errors.push({ path: `${path}.url`, message: "url context source requires non-empty url" });
130
+ }
131
+ if (source.tags !== undefined && (!Array.isArray(source.tags) || source.tags.some((tag) => typeof tag !== "string" || !tag))) {
132
+ errors.push({ path: `${path}.tags`, message: "tags must be an array of non-empty strings" });
133
+ }
134
+ }
135
+ }
136
+ }
137
+ if (dna.planning_context) {
138
+ const pc = dna.planning_context;
139
+ if (!Array.isArray(pc.sources) || pc.sources.some((sourceId) => typeof sourceId !== "string" || !sourceId)) {
140
+ errors.push({ path: "planning_context.sources", message: "must be an array of non-empty context source ids" });
141
+ }
142
+ else {
143
+ const sourceList = Array.isArray(sources) ? sources : [];
144
+ const declared = new Set(sourceList.map((source) => source.id));
145
+ for (const sourceId of pc.sources) {
146
+ if (!declared.has(sourceId)) {
147
+ errors.push({ path: "planning_context.sources", message: `references unknown context source '${sourceId}'` });
148
+ }
149
+ }
150
+ }
151
+ if (pc.instructions !== undefined && (!Array.isArray(pc.instructions) || pc.instructions.some((instruction) => typeof instruction !== "string" || !instruction))) {
152
+ errors.push({ path: "planning_context.instructions", message: "must be an array of non-empty strings" });
153
+ }
154
+ if (pc.require_quotes !== undefined && typeof pc.require_quotes !== "boolean") {
155
+ errors.push({ path: "planning_context.require_quotes", message: "must be a boolean" });
156
+ }
157
+ if (pc.require_attribution !== undefined && typeof pc.require_attribution !== "boolean") {
158
+ errors.push({ path: "planning_context.require_attribution", message: "must be a boolean" });
159
+ }
160
+ }
161
+ }
162
+ function contextSourceIdentities(source) {
163
+ const identities = [source.id];
164
+ if (source.type === "file" || source.type === "doc")
165
+ identities.push(source.path);
166
+ if (source.type === "url")
167
+ identities.push(source.url);
168
+ return identities.filter((identity) => typeof identity === "string" && identity.length > 0);
169
+ }
170
+ function handoffArtifactIdentities(artifact) {
171
+ return [artifact.artifact_id, artifact.name, artifact.path, artifact.type === "git_commit" ? "git_commit" : undefined]
172
+ .filter((identity) => typeof identity === "string" && identity.length > 0);
173
+ }
174
+ function validateContextSourceHandoffOverlap(dna, errors) {
175
+ const contextSources = Array.isArray(dna.context_sources) ? dna.context_sources : [];
176
+ if (contextSources.length === 0)
177
+ return;
178
+ const advisoryIdentities = new Set(contextSources.flatMap(contextSourceIdentities));
179
+ if (advisoryIdentities.size === 0)
180
+ return;
181
+ const checkArtifact = (artifact, path) => {
182
+ if (artifact.required === false)
183
+ return;
184
+ for (const identity of handoffArtifactIdentities(artifact)) {
185
+ if (!advisoryIdentities.has(identity))
186
+ continue;
187
+ errors.push({
188
+ path,
189
+ message: `planning-only context source '${identity}' cannot be used as a required handoff artifact`,
190
+ });
191
+ return;
192
+ }
193
+ };
194
+ const checkWorkflow = (workflow, path) => {
195
+ for (let i = 0; i < (workflow.steps ?? []).length; i++) {
196
+ const step = workflow.steps[i];
197
+ for (const direction of ["consumes", "produces"]) {
198
+ const artifacts = step.handoff?.[direction] ?? [];
199
+ for (let j = 0; j < artifacts.length; j++) {
200
+ checkArtifact(artifacts[j], `${path}.steps[${i}].handoff.${direction}[${j}]`);
201
+ }
202
+ }
203
+ }
204
+ };
205
+ if (dna.workflow)
206
+ checkWorkflow(dna.workflow, "workflow");
207
+ if (dna.workflows) {
208
+ for (const [wfName, wfDef] of Object.entries(dna.workflows)) {
209
+ checkWorkflow(wfDef, `workflows.${wfName}`);
210
+ }
211
+ }
212
+ }
75
213
  function validateCodon(codon, path) {
76
214
  const errors = [];
77
215
  switch (codon.type) {
@@ -150,6 +288,9 @@ function validateContext(name, ctx, geneNames) {
150
288
  function validateRole(name, role, geneNames) {
151
289
  const errors = [];
152
290
  const path = `roles.${name}`;
291
+ if (!isSafeGeneratedIdentifier(name)) {
292
+ errors.push({ path, message: "role name must contain only letters, digits, underscores, and hyphens" });
293
+ }
153
294
  // description required
154
295
  if (!role.description) {
155
296
  errors.push({ path: `${path}.description`, message: "role requires 'description'" });
@@ -245,8 +386,10 @@ function validateRole(name, role, geneNames) {
245
386
  if (os.converter !== undefined && (typeof os.converter !== "string" || !os.converter)) {
246
387
  errors.push({ path: `${osPath}.converter`, message: "must be a non-empty string" });
247
388
  }
248
- if (os.path !== undefined && (typeof os.path !== "string" || !os.path)) {
249
- errors.push({ path: `${osPath}.path`, message: "must be a non-empty string" });
389
+ if (os.path !== undefined) {
390
+ if (typeof os.path !== "string" || !isSafeProjectTemplatePath(os.path)) {
391
+ errors.push({ path: `${osPath}.path`, message: "must be a safe project-relative path template" });
392
+ }
250
393
  }
251
394
  }
252
395
  return errors;
@@ -307,6 +450,9 @@ function validateWorkflow(workflow, roleNames, pathPrefix) {
307
450
  if (!workflow.name) {
308
451
  errors.push({ path: `${path}.name`, message: "workflow requires 'name'" });
309
452
  }
453
+ else if (!isSafeGeneratedDisplayName(workflow.name)) {
454
+ errors.push({ path: `${path}.name`, message: "workflow name must contain only letters, digits, spaces, underscores, and hyphens" });
455
+ }
310
456
  // steps required and non-empty
311
457
  if (!workflow.steps || workflow.steps.length === 0) {
312
458
  errors.push({ path: `${path}.steps`, message: "workflow requires at least one step" });
@@ -322,6 +468,9 @@ function validateWorkflow(workflow, roleNames, pathPrefix) {
322
468
  if (!step.id) {
323
469
  errors.push({ path: `${stepPath}.id`, message: "step requires 'id'" });
324
470
  }
471
+ else if (!isSafeGeneratedIdentifier(step.id)) {
472
+ errors.push({ path: `${stepPath}.id`, message: "step id must contain only letters, digits, underscores, and hyphens" });
473
+ }
325
474
  else if (seenStepIds.has(step.id)) {
326
475
  errors.push({ path: `${stepPath}.id`, message: `duplicate step id '${step.id}'` });
327
476
  }
@@ -333,6 +482,9 @@ function validateWorkflow(workflow, roleNames, pathPrefix) {
333
482
  if (!step.role) {
334
483
  errors.push({ path: `${stepPath}.role`, message: "step requires 'role'" });
335
484
  }
485
+ else if (!isSafeGeneratedIdentifier(step.role)) {
486
+ errors.push({ path: `${stepPath}.role`, message: "step role must contain only letters, digits, underscores, and hyphens" });
487
+ }
336
488
  else if (roleNames.size > 0 && !roleNames.has(step.role)) {
337
489
  errors.push({
338
490
  path: `${stepPath}.role`,
@@ -583,6 +735,7 @@ export function validateDNA(dna) {
583
735
  errors.push(...validateWorkflow(wfDef, roleNamesSet, `workflows.${wfName}`));
584
736
  }
585
737
  }
738
+ validateContextSourceHandoffOverlap(dna, errors);
586
739
  if (dna.controllers) {
587
740
  const workflowsForControllers = { ...(dna.workflows ?? {}) };
588
741
  if (dna.workflow)
@@ -621,6 +774,7 @@ export function validateDNA(dna) {
621
774
  }
622
775
  }
623
776
  validateLegibilityAssets(dna, roleNamesSet, errors, warnings);
777
+ validateContextSources(dna, errors);
624
778
  // Validate context_files
625
779
  if (dna.context_files) {
626
780
  const cf = dna.context_files;