contract-driven-delivery 1.11.0 → 1.16.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 (41) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/README.md +194 -24
  3. package/assets/CLAUDE.template.md +10 -0
  4. package/assets/CODEX.template.md +39 -0
  5. package/assets/agents/backend-engineer.md +4 -23
  6. package/assets/agents/change-classifier.md +130 -24
  7. package/assets/agents/ci-cd-gatekeeper.md +4 -23
  8. package/assets/agents/contract-reviewer.md +4 -23
  9. package/assets/agents/dependency-security-reviewer.md +4 -22
  10. package/assets/agents/e2e-resilience-engineer.md +4 -23
  11. package/assets/agents/frontend-engineer.md +4 -23
  12. package/assets/agents/monkey-test-engineer.md +4 -23
  13. package/assets/agents/qa-reviewer.md +4 -23
  14. package/assets/agents/repo-context-scanner.md +4 -22
  15. package/assets/agents/spec-architect.md +4 -23
  16. package/assets/agents/spec-drift-auditor.md +4 -22
  17. package/assets/agents/stress-soak-engineer.md +4 -23
  18. package/assets/agents/test-strategist.md +4 -23
  19. package/assets/agents/ui-ux-reviewer.md +4 -22
  20. package/assets/agents/visual-reviewer.md +4 -22
  21. package/assets/cdd/context-policy.json +25 -0
  22. package/assets/cdd/model-policy.json +24 -0
  23. package/assets/contracts/api/api-contract.md +3 -0
  24. package/assets/contracts/api/api-inventory.md +7 -0
  25. package/assets/contracts/api/error-format.md +7 -0
  26. package/assets/contracts/business/business-rules.md +3 -0
  27. package/assets/contracts/ci/ci-gate-contract.md +3 -0
  28. package/assets/contracts/css/css-contract.md +3 -0
  29. package/assets/contracts/css/design-tokens.md +7 -0
  30. package/assets/contracts/data/data-shape-contract.md +3 -0
  31. package/assets/contracts/env/env-contract.md +3 -0
  32. package/assets/hooks/post-tool-use-files-read.sh +55 -0
  33. package/assets/skills/cdd-close/SKILL.md +37 -10
  34. package/assets/skills/cdd-new/SKILL.md +200 -164
  35. package/assets/skills/cdd-resume/SKILL.md +31 -3
  36. package/assets/skills/contract-driven-delivery/references/agent-log-protocol.md +117 -0
  37. package/assets/specs-templates/context-manifest.md +44 -0
  38. package/assets/specs-templates/tasks.md +4 -0
  39. package/dist/cli/index.js +2149 -342
  40. package/docs/release-checklist.md +39 -0
  41. package/package.json +6 -3
package/dist/cli/index.js CHANGED
@@ -8,6 +8,38 @@ var __export = (target, all) => {
8
8
  __defProp(target, name, { get: all[name], enumerable: true });
9
9
  };
10
10
 
11
+ // src/utils/paths.ts
12
+ import { join, dirname } from "path";
13
+ import { fileURLToPath } from "url";
14
+ import { homedir } from "os";
15
+ var __dirname, PACKAGE_ROOT, ASSETS_DIR, CLAUDE_HOME, AGENTS_HOME, SKILLS_HOME, ASSET;
16
+ var init_paths = __esm({
17
+ "src/utils/paths.ts"() {
18
+ "use strict";
19
+ __dirname = dirname(fileURLToPath(import.meta.url));
20
+ PACKAGE_ROOT = join(__dirname, "..", "..");
21
+ ASSETS_DIR = join(PACKAGE_ROOT, "assets");
22
+ CLAUDE_HOME = join(homedir(), ".claude");
23
+ AGENTS_HOME = join(CLAUDE_HOME, "agents");
24
+ SKILLS_HOME = join(CLAUDE_HOME, "skills");
25
+ ASSET = {
26
+ agents: join(ASSETS_DIR, "agents"),
27
+ skills: join(ASSETS_DIR, "skills"),
28
+ skill: join(ASSETS_DIR, "skills", "contract-driven-delivery"),
29
+ contracts: join(ASSETS_DIR, "contracts"),
30
+ specsTemplates: join(ASSETS_DIR, "specs-templates"),
31
+ testsTemplates: join(ASSETS_DIR, "tests-templates"),
32
+ ci: join(ASSETS_DIR, "ci"),
33
+ githubWorkflows: join(ASSETS_DIR, "github-workflows"),
34
+ hooks: join(ASSETS_DIR, "hooks"),
35
+ claudeTemplate: join(ASSETS_DIR, "CLAUDE.template.md"),
36
+ codexTemplate: join(ASSETS_DIR, "CODEX.template.md"),
37
+ agentsTemplate: join(ASSETS_DIR, "AGENTS.template.md"),
38
+ cddConfig: join(ASSETS_DIR, "cdd")
39
+ };
40
+ }
41
+ });
42
+
11
43
  // src/utils/logger.ts
12
44
  var RESET, CYAN, GREEN, YELLOW, RED, DIM, log;
13
45
  var init_logger = __esm({
@@ -42,131 +74,621 @@ var init_logger = __esm({
42
74
  }
43
75
  });
44
76
 
45
- // src/commands/archive.ts
46
- var archive_exports = {};
47
- __export(archive_exports, {
48
- archive: () => archive
77
+ // src/utils/provider.ts
78
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
79
+ import { join as join5 } from "path";
80
+ function validateProviderOption(provider) {
81
+ return provider === "auto" || provider === "claude" || provider === "codex" || provider === "both";
82
+ }
83
+ function inferProvider(cwd, requested = "auto") {
84
+ if (requested !== "auto")
85
+ return requested;
86
+ const modelPolicyPath = join5(cwd, ".cdd", "model-policy.json");
87
+ if (existsSync4(modelPolicyPath)) {
88
+ try {
89
+ const policy = JSON.parse(readFileSync3(modelPolicyPath, "utf8"));
90
+ if (policy.provider === "claude" || policy.provider === "codex" || policy.provider === "both") {
91
+ return policy.provider;
92
+ }
93
+ } catch {
94
+ }
95
+ }
96
+ const hasClaude = existsSync4(join5(cwd, "CLAUDE.md")) || existsSync4(join5(cwd, "AGENTS.md"));
97
+ const hasCodex = existsSync4(join5(cwd, "CODEX.md"));
98
+ if (hasClaude && hasCodex)
99
+ return "both";
100
+ if (hasCodex)
101
+ return "codex";
102
+ return "claude";
103
+ }
104
+ var init_provider = __esm({
105
+ "src/utils/provider.ts"() {
106
+ "use strict";
107
+ }
49
108
  });
50
- import { join as join10 } from "path";
51
- import { existsSync as existsSync9, mkdirSync as mkdirSync4, renameSync, readFileSync as readFileSync6, writeFileSync as writeFileSync3, appendFileSync, cpSync as cpSync2, rmSync as rmSync2 } from "fs";
52
- async function archive(changeId) {
53
- const cwd = process.cwd();
54
- const changeDir = join10(cwd, "specs", "changes", changeId);
55
- const archiveYear = (/* @__PURE__ */ new Date()).getFullYear().toString();
56
- const archiveBase = join10(cwd, "specs", "archive", archiveYear);
57
- const archiveDir = join10(archiveBase, changeId);
58
- const indexPath = join10(cwd, "specs", "archive", "INDEX.md");
59
- if (!existsSync9(changeDir)) {
60
- log.error(`Change not found: specs/changes/${changeId}`);
61
- process.exit(1);
109
+
110
+ // src/commands/context-scan.ts
111
+ var context_scan_exports = {};
112
+ __export(context_scan_exports, {
113
+ contextScan: () => contextScan
114
+ });
115
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync5, readdirSync as readdirSync4, writeFileSync as writeFileSync2 } from "fs";
116
+ import { createHash as createHash2 } from "crypto";
117
+ import { basename, dirname as dirname3, join as join7, relative as relative2 } from "path";
118
+ function sha256OfFile(path) {
119
+ try {
120
+ return createHash2("sha256").update(readFileSync5(path)).digest("hex");
121
+ } catch {
122
+ return "";
62
123
  }
63
- if (existsSync9(archiveDir)) {
64
- log.error(`Already archived: specs/archive/${archiveYear}/${changeId}`);
65
- process.exit(1);
124
+ }
125
+ function inputsDigest(paths) {
126
+ const combined = paths.slice().sort().map((p) => `${p}:${sha256OfFile(p)}`).join("\n");
127
+ return createHash2("sha256").update(combined).digest("hex");
128
+ }
129
+ function stripGlobSuffix(pattern) {
130
+ return pattern.replace(/\/\*\*$/, "").replace(/\/\*$/, "");
131
+ }
132
+ function getForbiddenPaths(cwd) {
133
+ const forbidden = new Set(DEFAULT_FORBIDDEN);
134
+ const policyPath = join7(cwd, ".cdd", "context-policy.json");
135
+ try {
136
+ if (existsSync6(policyPath)) {
137
+ const policy = JSON.parse(readFileSync5(policyPath, "utf8"));
138
+ for (const pattern of policy.forbiddenPaths ?? []) {
139
+ forbidden.add(stripGlobSuffix(pattern));
140
+ }
141
+ }
142
+ } catch {
143
+ log.warn("Could not parse .cdd/context-policy.json; using default context-scan excludes.");
66
144
  }
67
- const tasksPath = join10(changeDir, "tasks.md");
68
- if (existsSync9(tasksPath)) {
69
- const content = readFileSync6(tasksPath, "utf8");
70
- if (content.includes("status: gate-blocked")) {
71
- log.warn("tasks.md has status: gate-blocked \u2014 archiving anyway (change was paused).");
145
+ return [...forbidden];
146
+ }
147
+ function isForbidden(relPath, forbidden) {
148
+ const normalized = relPath.replace(/\\/g, "/");
149
+ return forbidden.some((pattern) => normalized === pattern || normalized.startsWith(`${pattern}/`));
150
+ }
151
+ function buildTree(dir, cwd, forbidden, stats, prefix = "", depth = 0) {
152
+ const entries = readdirSync4(dir, { withFileTypes: true }).sort((a, b) => {
153
+ if (a.isDirectory() === b.isDirectory())
154
+ return a.name.localeCompare(b.name);
155
+ return a.isDirectory() ? -1 : 1;
156
+ });
157
+ let output = "";
158
+ const visible = entries.filter((entry) => {
159
+ const relPath = relative2(cwd, join7(dir, entry.name));
160
+ return !isForbidden(relPath, forbidden);
161
+ });
162
+ const truncated = visible.length > PER_DIR_ENTRY_CAP;
163
+ const shown = truncated ? visible.slice(0, PER_DIR_ENTRY_CAP) : visible;
164
+ if (truncated)
165
+ stats.truncatedDirs += 1;
166
+ shown.forEach((entry, index) => {
167
+ const fullPath = join7(dir, entry.name);
168
+ const isLast = index === shown.length - 1 && !truncated;
169
+ const connector = isLast ? "\\-- " : "|-- ";
170
+ output += `${prefix}${connector}${entry.name}${entry.isDirectory() ? "/" : ""}
171
+ `;
172
+ if (entry.isDirectory()) {
173
+ stats.dirs += 1;
174
+ if (depth >= 3) {
175
+ stats.omittedDirs += 1;
176
+ output += `${prefix}${isLast ? " " : "| "}\\-- ... (max depth)
177
+ `;
178
+ } else {
179
+ output += buildTree(fullPath, cwd, forbidden, stats, prefix + (isLast ? " " : "| "), depth + 1);
180
+ }
181
+ } else {
182
+ stats.files += 1;
72
183
  }
73
- const pending = (content.match(/^\s*-\s*\[ \]/gm) || []).length;
74
- if (pending > 0) {
75
- log.warn(`${pending} task(s) still pending ([ ]). Archive anyway.`);
184
+ });
185
+ if (truncated) {
186
+ output += `${prefix}\\-- ... (${visible.length - PER_DIR_ENTRY_CAP} more entries truncated; cap=${PER_DIR_ENTRY_CAP})
187
+ `;
188
+ }
189
+ return output;
190
+ }
191
+ function firstHeading(content) {
192
+ const match = content.match(/^#\s+(.+)$/m);
193
+ return match?.[1]?.trim();
194
+ }
195
+ function deriveContractType(relPath, metadata) {
196
+ if (metadata.contract)
197
+ return metadata.contract;
198
+ const parts = relPath.split("/");
199
+ return parts.length >= 2 ? parts[1] : "unknown";
200
+ }
201
+ function parseContractMetadata(content) {
202
+ const metadata = {};
203
+ let summary;
204
+ const cddMatch = content.match(/<!--\s*cdd:([\s\S]*?)-->/);
205
+ const yamlMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
206
+ const block = cddMatch?.[1] ?? yamlMatch?.[1];
207
+ if (block) {
208
+ for (const line of block.split(/\r?\n/)) {
209
+ const colon = line.indexOf(":");
210
+ if (colon === -1)
211
+ continue;
212
+ const key = line.slice(0, colon).trim();
213
+ const value = line.slice(colon + 1).trim();
214
+ if (!key || !value)
215
+ continue;
216
+ if (key === "summary")
217
+ summary = value;
218
+ else
219
+ metadata[key] = value;
76
220
  }
77
221
  }
78
- if (!existsSync9(archiveBase)) {
79
- mkdirSync4(archiveBase, { recursive: true });
222
+ if (!summary) {
223
+ const summaryMatch = content.match(/#+\s*Summary\s*\r?\n+([^#\r\n][^\r\n]*)/i);
224
+ summary = summaryMatch?.[1]?.trim();
80
225
  }
81
- try {
82
- renameSync(changeDir, archiveDir);
83
- } catch (err) {
84
- if (err.code === "EXDEV") {
85
- cpSync2(changeDir, archiveDir, { recursive: true });
86
- rmSync2(changeDir, { recursive: true, force: true });
87
- } else {
88
- throw err;
226
+ return { title: firstHeading(content), summary, metadata };
227
+ }
228
+ function findContractFiles(dir, found = []) {
229
+ if (!existsSync6(dir))
230
+ return found;
231
+ for (const entry of readdirSync4(dir, { withFileTypes: true })) {
232
+ const fullPath = join7(dir, entry.name);
233
+ if (entry.isDirectory())
234
+ findContractFiles(fullPath, found);
235
+ else if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "INDEX.md" && entry.name !== "CHANGELOG.md")
236
+ found.push(fullPath);
237
+ }
238
+ return found;
239
+ }
240
+ async function contextScan(opts = {}) {
241
+ const cwd = process.cwd();
242
+ const specsContextDir = join7(cwd, "specs", "context");
243
+ mkdirSync3(specsContextDir, { recursive: true });
244
+ const forbidden = getForbiddenPaths(cwd);
245
+ const surface = opts.surface;
246
+ let scanRoot = cwd;
247
+ if (surface) {
248
+ const resolvedSurface = join7(cwd, surface);
249
+ if (!existsSync6(resolvedSurface)) {
250
+ log.error(`--surface path not found: ${surface}`);
251
+ process.exit(1);
89
252
  }
90
- }
91
- log.ok(`Archived: specs/changes/${changeId} \u2192 specs/archive/${archiveYear}/${changeId}`);
92
- const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
93
- const indexLine = `| ${changeId} | ${archiveYear} | ${today} | specs/archive/${archiveYear}/${changeId}/ |
253
+ if (!resolvedSurface.startsWith(cwd)) {
254
+ log.error(`--surface must be inside the repo: ${surface}`);
255
+ process.exit(1);
256
+ }
257
+ scanRoot = resolvedSurface;
258
+ }
259
+ const treeStats = { dirs: 0, files: 0, omittedDirs: 0, truncatedDirs: 0 };
260
+ const tree = buildTree(scanRoot, cwd, forbidden, treeStats);
261
+ const policyPath = join7(cwd, ".cdd", "context-policy.json");
262
+ const projectMapInputs = [policyPath].filter(existsSync6);
263
+ writeFileSync2(
264
+ join7(specsContextDir, "project-map.md"),
265
+ [
266
+ "---",
267
+ "artifact: project-map",
268
+ "generated-by: cdd-kit context-scan",
269
+ "schema-version: 1",
270
+ `root: ${basename(cwd)}`,
271
+ ...surface ? [`surface: ${surface}`] : [],
272
+ `visible-dirs: ${treeStats.dirs}`,
273
+ `visible-files: ${treeStats.files}`,
274
+ `omitted-dirs: ${treeStats.omittedDirs}`,
275
+ `truncated-dirs: ${treeStats.truncatedDirs}`,
276
+ `inputs-digest: ${inputsDigest(projectMapInputs)}`,
277
+ "---",
278
+ "",
279
+ "# Project Map",
280
+ "",
281
+ "Use this deterministic map to choose candidate context paths before reading files.",
282
+ "",
283
+ "## Excluded Paths",
284
+ ...forbidden.map((path) => `- ${path}`),
285
+ "",
286
+ "## Tree",
287
+ "",
288
+ "```",
289
+ `${basename(cwd)}/`,
290
+ tree.trimEnd(),
291
+ "```",
292
+ ""
293
+ ].join("\n"),
294
+ "utf8"
295
+ );
296
+ log.ok("Created specs/context/project-map.md");
297
+ const contractFiles = findContractFiles(join7(cwd, "contracts")).sort((a, b) => relative2(cwd, a).localeCompare(relative2(cwd, b)));
298
+ const contractEntries = [];
299
+ const inventoryRows = [];
300
+ let missingSummary = 0;
301
+ for (const file of contractFiles) {
302
+ const relPath = relative2(cwd, file).replace(/\\/g, "/");
303
+ const dir = dirname3(relPath).replace(/\\/g, "/");
304
+ const { title, summary, metadata } = parseContractMetadata(readFileSync5(file, "utf8"));
305
+ const contractType = deriveContractType(relPath, metadata);
306
+ const owner = metadata.owner ?? "unknown";
307
+ const surface2 = metadata.surface ?? dir;
308
+ const summaryText = summary ?? "MISSING - add YAML frontmatter `summary:` or `<!-- cdd: summary: ... -->`.";
309
+ inventoryRows.push(`| ${relPath} | ${contractType} | ${surface2} | ${owner} | ${summary ? "yes" : "no"} |`);
310
+ let entry = `## ${relPath}
311
+ `;
312
+ entry += `- path: \`${relPath}\`
313
+ `;
314
+ entry += `- type: ${contractType}
315
+ `;
316
+ entry += `- directory: ${dir}
94
317
  `;
95
- if (!existsSync9(indexPath)) {
96
- writeFileSync3(indexPath, `# Archive Index
318
+ if (title)
319
+ entry += `- title: ${title}
320
+ `;
321
+ for (const [key, value] of Object.entries(metadata)) {
322
+ if (key === "contract")
323
+ continue;
324
+ entry += `- ${key}: ${value}
325
+ `;
326
+ }
327
+ entry += `- summary: ${summaryText}
97
328
 
98
- | change-id | year | archived-date | path |
99
- |---|---|---|---|
100
- ${indexLine}`, "utf8");
329
+ `;
330
+ contractEntries.push(entry);
331
+ if (!summary) {
332
+ missingSummary += 1;
333
+ }
334
+ }
335
+ const contractIndex = [
336
+ "---",
337
+ "artifact: contracts-index",
338
+ "generated-by: cdd-kit context-scan",
339
+ "schema-version: 1",
340
+ `contract-count: ${contractFiles.length}`,
341
+ `missing-summary-count: ${missingSummary}`,
342
+ `inputs-digest: ${inputsDigest(contractFiles)}`,
343
+ "---",
344
+ "",
345
+ "# Contracts Index",
346
+ "",
347
+ "Generated from deterministic metadata. Add YAML frontmatter fields such as `summary`, `owner`, and `surface` to improve classifier accuracy.",
348
+ "",
349
+ "## Contract Inventory",
350
+ "",
351
+ "| path | type | surface | owner | has-summary |",
352
+ "|---|---|---|---|---|",
353
+ ...inventoryRows,
354
+ "",
355
+ "## Contract Details",
356
+ "",
357
+ ...contractEntries
358
+ ].join("\n");
359
+ writeFileSync2(join7(specsContextDir, "contracts-index.md"), contractIndex, "utf8");
360
+ if (missingSummary > 0) {
361
+ log.warn(`Created specs/context/contracts-index.md with ${missingSummary} missing summary warning(s).`);
101
362
  } else {
102
- appendFileSync(indexPath, indexLine, "utf8");
363
+ log.ok("Created specs/context/contracts-index.md");
103
364
  }
104
- log.ok(`Index updated: specs/archive/INDEX.md`);
105
- log.blank();
106
- log.info(`Next: promote durable learnings from archive.md to contracts/ or CLAUDE.md`);
107
365
  }
108
- var init_archive = __esm({
109
- "src/commands/archive.ts"() {
366
+ var DEFAULT_FORBIDDEN, PER_DIR_ENTRY_CAP;
367
+ var init_context_scan = __esm({
368
+ "src/commands/context-scan.ts"() {
110
369
  "use strict";
111
370
  init_logger();
371
+ DEFAULT_FORBIDDEN = [
372
+ ".claude",
373
+ ".git",
374
+ "node_modules",
375
+ "dist",
376
+ "build",
377
+ "assets",
378
+ "specs/archive",
379
+ "specs/changes"
380
+ ];
381
+ PER_DIR_ENTRY_CAP = 50;
112
382
  }
113
383
  });
114
384
 
115
- // src/commands/abandon.ts
116
- var abandon_exports = {};
117
- __export(abandon_exports, {
118
- abandon: () => abandon
385
+ // src/commands/doctor.ts
386
+ var doctor_exports = {};
387
+ __export(doctor_exports, {
388
+ doctor: () => doctor
119
389
  });
120
- import { join as join11 } from "path";
121
- import { existsSync as existsSync10, readFileSync as readFileSync7, writeFileSync as writeFileSync4, appendFileSync as appendFileSync2, mkdirSync as mkdirSync5 } from "fs";
122
- async function abandon(changeId, opts) {
123
- const cwd = process.cwd();
124
- const changeDir = join11(cwd, "specs", "changes", changeId);
125
- const tasksPath = join11(changeDir, "tasks.md");
126
- if (!existsSync10(changeDir)) {
127
- log.error(`Change not found: specs/changes/${changeId}`);
390
+ import { existsSync as existsSync11, readdirSync as readdirSync7, readFileSync as readFileSync9 } from "fs";
391
+ import { createHash as createHash4 } from "crypto";
392
+ import { join as join12 } from "path";
393
+ function fileExists(cwd, relPath) {
394
+ return existsSync11(join12(cwd, relPath));
395
+ }
396
+ function findFiles(dir, predicate, found = []) {
397
+ if (!existsSync11(dir))
398
+ return found;
399
+ for (const entry of readdirSync7(dir, { withFileTypes: true })) {
400
+ const fullPath = join12(dir, entry.name);
401
+ if (entry.isDirectory())
402
+ findFiles(fullPath, predicate, found);
403
+ else if (entry.isFile() && predicate(entry.name))
404
+ found.push(fullPath);
405
+ }
406
+ return found;
407
+ }
408
+ function sha256OfFile3(path) {
409
+ try {
410
+ return createHash4("sha256").update(readFileSync9(path)).digest("hex");
411
+ } catch {
412
+ return "";
413
+ }
414
+ }
415
+ function inputDigest(paths) {
416
+ const combined = paths.slice().sort().map((p) => `${p}:${sha256OfFile3(p)}`).join("\n");
417
+ return createHash4("sha256").update(combined).digest("hex");
418
+ }
419
+ function readContextIndexMetadata(filePath) {
420
+ if (!existsSync11(filePath))
421
+ return {};
422
+ const text = readFileSync9(filePath, "utf8");
423
+ const out = {};
424
+ const digestMatch = text.match(/^inputs-digest:\s*([a-f0-9]+)/m);
425
+ if (digestMatch)
426
+ out.inputsDigest = digestMatch[1];
427
+ const missingMatch = text.match(/^missing-summary-count:\s*(\d+)/m);
428
+ if (missingMatch)
429
+ out.missingSummary = Number(missingMatch[1]);
430
+ return out;
431
+ }
432
+ function checkContextFreshness(cwd) {
433
+ const findings = [];
434
+ const projectMap = join12(cwd, "specs", "context", "project-map.md");
435
+ const contractsIndex = join12(cwd, "specs", "context", "contracts-index.md");
436
+ const contextPolicy = join12(cwd, ".cdd", "context-policy.json");
437
+ const contractFiles = findFiles(
438
+ join12(cwd, "contracts"),
439
+ (name) => name.endsWith(".md") && name !== "INDEX.md" && name !== "CHANGELOG.md"
440
+ );
441
+ if (!existsSync11(projectMap) || !existsSync11(contractsIndex)) {
442
+ findings.push({
443
+ level: "warning",
444
+ message: "specs/context indexes are missing; run cdd-kit context-scan before classification"
445
+ });
446
+ return findings;
447
+ }
448
+ const projectMapMeta = readContextIndexMetadata(projectMap);
449
+ const contractsIndexMeta = readContextIndexMetadata(contractsIndex);
450
+ const projectInputDigest = inputDigest([contextPolicy].filter(existsSync11));
451
+ if (projectMapMeta.inputsDigest === void 0) {
452
+ findings.push({
453
+ level: "warning",
454
+ message: "specs/context/project-map.md was generated by an older cdd-kit (no inputs-digest); re-run cdd-kit context-scan"
455
+ });
456
+ } else if (projectInputDigest && projectMapMeta.inputsDigest !== projectInputDigest) {
457
+ findings.push({
458
+ level: "warning",
459
+ message: "specs/context/project-map.md inputs changed (.cdd/context-policy.json); re-run cdd-kit context-scan"
460
+ });
461
+ }
462
+ const contractsInputDigest = inputDigest(contractFiles);
463
+ if (contractsIndexMeta.inputsDigest === void 0) {
464
+ findings.push({
465
+ level: "warning",
466
+ message: "specs/context/contracts-index.md was generated by an older cdd-kit (no inputs-digest); re-run cdd-kit context-scan"
467
+ });
468
+ } else if (contractsInputDigest && contractsIndexMeta.inputsDigest !== contractsInputDigest) {
469
+ findings.push({
470
+ level: "warning",
471
+ message: "specs/context/contracts-index.md inputs changed (contracts/*); re-run cdd-kit context-scan"
472
+ });
473
+ }
474
+ if (contractsIndexMeta.missingSummary !== void 0 && contractsIndexMeta.missingSummary > 0) {
475
+ findings.push({
476
+ level: "warning",
477
+ message: `contracts-index reports ${contractsIndexMeta.missingSummary} contract(s) without deterministic summary metadata`
478
+ });
479
+ }
480
+ if (findings.length === 0) {
481
+ findings.push({ level: "ok", message: "context indexes are present and fresh" });
482
+ }
483
+ return findings;
484
+ }
485
+ function readAgentModel(path) {
486
+ try {
487
+ const text = readFileSync9(path, "utf8");
488
+ const m = text.match(/^model:\s*(\S+)/m);
489
+ return m ? m[1] : null;
490
+ } catch {
491
+ return null;
492
+ }
493
+ }
494
+ function checkModelPolicyDrift(cwd) {
495
+ const policyPath = join12(cwd, ".cdd", "model-policy.json");
496
+ if (!existsSync11(policyPath))
497
+ return [];
498
+ let policy;
499
+ try {
500
+ policy = JSON.parse(readFileSync9(policyPath, "utf8"));
501
+ } catch {
502
+ return [{ level: "warning", message: ".cdd/model-policy.json is not valid JSON" }];
503
+ }
504
+ const roles = policy.roles ?? {};
505
+ if (Object.keys(roles).length === 0) {
506
+ return [{
507
+ level: "warning",
508
+ message: ".cdd/model-policy.json has no role bindings; run cdd-kit upgrade to install defaults"
509
+ }];
510
+ }
511
+ const candidateDirs = [
512
+ join12(cwd, ".claude", "agents"),
513
+ process.env.HOME ? join12(process.env.HOME, ".claude", "agents") : "",
514
+ process.env.USERPROFILE ? join12(process.env.USERPROFILE, ".claude", "agents") : ""
515
+ ].filter((p) => p && existsSync11(p));
516
+ if (candidateDirs.length === 0)
517
+ return [];
518
+ const findings = [];
519
+ for (const [role, expected] of Object.entries(roles)) {
520
+ let foundAny = false;
521
+ for (const dir of candidateDirs) {
522
+ const path = join12(dir, `${role}.md`);
523
+ if (!existsSync11(path))
524
+ continue;
525
+ foundAny = true;
526
+ const actual = readAgentModel(path);
527
+ if (actual && actual !== expected) {
528
+ findings.push({
529
+ level: "warning",
530
+ message: `model-policy drift: ${role} expected ${expected}, agent prompt uses ${actual} (${path})`
531
+ });
532
+ }
533
+ }
534
+ if (!foundAny) {
535
+ }
536
+ }
537
+ if (findings.length === 0) {
538
+ findings.push({ level: "ok", message: "model-policy roles match installed agent prompts" });
539
+ }
540
+ return findings;
541
+ }
542
+ function buildDoctorReport(cwd, opts) {
543
+ const requestedProvider = opts.provider ?? "auto";
544
+ if (!validateProviderOption(requestedProvider)) {
545
+ log.error(`Invalid provider: ${requestedProvider}. Use auto, claude, codex, or both.`);
128
546
  process.exit(1);
129
547
  }
130
- if (existsSync10(tasksPath)) {
131
- let content = readFileSync7(tasksPath, "utf8");
132
- if (content.match(/^status:/m)) {
133
- content = content.replace(/^status: .*/m, "status: abandoned");
548
+ const strict = opts.strict ?? false;
549
+ const provider = inferProvider(cwd, requestedProvider);
550
+ const findings = [];
551
+ for (const relPath of ["contracts", "specs/templates", ".cdd/context-policy.json", ".cdd/model-policy.json"]) {
552
+ findings.push(fileExists(cwd, relPath) ? { level: "ok", message: `${relPath} exists` } : { level: "warning", message: `${relPath} is missing; run cdd-kit upgrade --yes` });
553
+ }
554
+ if ((provider === "claude" || provider === "both") && !fileExists(cwd, "CLAUDE.md")) {
555
+ findings.push({ level: "warning", message: "CLAUDE.md is missing for Claude provider; run cdd-kit upgrade --provider claude --yes" });
556
+ }
557
+ if ((provider === "claude" || provider === "both") && !fileExists(cwd, "AGENTS.md")) {
558
+ findings.push({ level: "warning", message: "AGENTS.md is missing for Claude provider; run cdd-kit upgrade --provider claude --yes" });
559
+ }
560
+ if ((provider === "codex" || provider === "both") && !fileExists(cwd, "CODEX.md")) {
561
+ findings.push({ level: "warning", message: "CODEX.md is missing for Codex provider; run cdd-kit upgrade --provider codex --yes" });
562
+ }
563
+ findings.push(...checkContextFreshness(cwd));
564
+ findings.push(...checkModelPolicyDrift(cwd));
565
+ const errors = findings.filter((finding) => finding.level === "error").length;
566
+ const warnings = findings.filter((finding) => finding.level === "warning").length;
567
+ return {
568
+ provider,
569
+ strict,
570
+ findings,
571
+ errors,
572
+ warnings,
573
+ ok: errors === 0 && (!strict || warnings === 0)
574
+ };
575
+ }
576
+ async function attemptAutoFixes(cwd, report) {
577
+ const fixed = [];
578
+ const remaining = [];
579
+ for (const finding of report.findings) {
580
+ if (finding.level !== "warning") {
581
+ remaining.push(finding);
582
+ continue;
583
+ }
584
+ if (/specs\/context indexes are missing|inputs changed|older cdd-kit|older than/i.test(finding.message)) {
585
+ try {
586
+ const { contextScan: contextScan2 } = await Promise.resolve().then(() => (init_context_scan(), context_scan_exports));
587
+ await contextScan2();
588
+ fixed.push(`ran context-scan to refresh specs/context/`);
589
+ continue;
590
+ } catch (err) {
591
+ remaining.push({ level: "warning", message: `${finding.message} (auto-fix failed: ${err.message})` });
592
+ continue;
593
+ }
594
+ }
595
+ if (/model-policy\.json has no role bindings/i.test(finding.message)) {
596
+ const policyPath = join12(cwd, ".cdd", "model-policy.json");
597
+ try {
598
+ let existing = {};
599
+ try {
600
+ existing = JSON.parse(readFileSync9(policyPath, "utf8"));
601
+ } catch {
602
+ }
603
+ const merged = {
604
+ ...existing,
605
+ roles: {
606
+ "change-classifier": "claude-opus-4-7",
607
+ "spec-architect": "claude-opus-4-7",
608
+ "qa-reviewer": "claude-opus-4-7",
609
+ "contract-reviewer": "claude-sonnet-4-6",
610
+ "test-strategist": "claude-sonnet-4-6",
611
+ "backend-engineer": "claude-sonnet-4-6",
612
+ "frontend-engineer": "claude-sonnet-4-6",
613
+ "ci-cd-gatekeeper": "claude-sonnet-4-6",
614
+ "e2e-resilience-engineer": "claude-sonnet-4-6",
615
+ "monkey-test-engineer": "claude-sonnet-4-6",
616
+ "stress-soak-engineer": "claude-sonnet-4-6",
617
+ "ui-ux-reviewer": "claude-sonnet-4-6",
618
+ "visual-reviewer": "claude-sonnet-4-6",
619
+ "dependency-security-reviewer": "claude-sonnet-4-6",
620
+ "spec-drift-auditor": "claude-sonnet-4-6",
621
+ "repo-context-scanner": "claude-haiku-4-5"
622
+ }
623
+ };
624
+ const { writeFileSync: writeFileSync10 } = await import("fs");
625
+ writeFileSync10(policyPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
626
+ fixed.push(`populated .cdd/model-policy.json with default role bindings`);
627
+ continue;
628
+ } catch (err) {
629
+ remaining.push({ level: "warning", message: `${finding.message} (auto-fix failed: ${err.message})` });
630
+ continue;
631
+ }
632
+ }
633
+ if (/\.cdd\/.*is missing|run cdd-kit upgrade/i.test(finding.message)) {
634
+ remaining.push({
635
+ level: "warning",
636
+ message: `${finding.message} (run \`cdd-kit upgrade --yes\` manually \u2014 too invasive for --fix)`
637
+ });
638
+ continue;
639
+ }
640
+ remaining.push(finding);
641
+ }
642
+ return { fixed, remaining };
643
+ }
644
+ async function doctor(opts = {}) {
645
+ const cwd = process.cwd();
646
+ let report = buildDoctorReport(cwd, opts);
647
+ if (opts.fix && !opts.json) {
648
+ log.blank();
649
+ log.info("Doctor --fix: attempting safe auto-resolutions\u2026");
650
+ const { fixed, remaining } = await attemptAutoFixes(cwd, report);
651
+ for (const f of fixed)
652
+ log.ok(`fixed: ${f}`);
653
+ if (fixed.length > 0) {
654
+ report = buildDoctorReport(cwd, opts);
134
655
  } else {
135
- content = `---
136
- change-id: ${changeId}
137
- status: abandoned
138
- ---
139
-
140
- ` + content;
656
+ log.info("no auto-fixable findings");
141
657
  }
142
- writeFileSync4(tasksPath, content, "utf8");
143
658
  }
144
- const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
145
- const archiveDir = join11(cwd, "specs", "archive");
146
- const indexPath = join11(archiveDir, "INDEX.md");
147
- const reason = opts.reason ?? "no reason given";
148
- const indexLine = `| ${changeId} | abandoned | ${today} | ${reason} |
149
- `;
150
- if (!existsSync10(archiveDir)) {
151
- mkdirSync5(archiveDir, { recursive: true });
659
+ if (opts.json) {
660
+ console.log(JSON.stringify(report, null, 2));
661
+ if (!report.ok)
662
+ process.exit(1);
663
+ return;
152
664
  }
153
- if (!existsSync10(indexPath)) {
154
- writeFileSync4(indexPath, `# Archive Index
155
-
156
- | change-id | status | date | notes |
157
- |---|---|---|---|
158
- ${indexLine}`, "utf8");
665
+ log.blank();
666
+ log.info(`Doctor provider: ${report.provider}`);
667
+ for (const finding of report.findings) {
668
+ if (finding.level === "ok")
669
+ log.ok(finding.message);
670
+ else if (finding.level === "warning")
671
+ log.warn(finding.message);
672
+ else
673
+ log.error(finding.message);
674
+ }
675
+ log.blank();
676
+ if (!report.ok) {
677
+ log.error(report.strict && report.errors === 0 ? `doctor failed in strict mode with ${report.warnings} warning(s)` : `doctor failed with ${report.errors} error(s)`);
678
+ process.exit(1);
679
+ }
680
+ if (report.warnings > 0) {
681
+ log.warn(`doctor completed with ${report.warnings} warning(s)`);
159
682
  } else {
160
- appendFileSync2(indexPath, indexLine, "utf8");
683
+ log.ok("doctor passed");
161
684
  }
162
- log.ok(`Change ${changeId} marked as abandoned.`);
163
- log.info(`specs/changes/${changeId}/ remains on disk (git history preserved).`);
164
- log.info(`Run \`cdd-kit archive ${changeId}\` to physically move it, or leave it for git history.`);
685
+ log.blank();
165
686
  }
166
- var init_abandon = __esm({
167
- "src/commands/abandon.ts"() {
687
+ var init_doctor = __esm({
688
+ "src/commands/doctor.ts"() {
168
689
  "use strict";
169
690
  init_logger();
691
+ init_provider();
170
692
  }
171
693
  });
172
694
 
@@ -175,16 +697,120 @@ var migrate_exports = {};
175
697
  __export(migrate_exports, {
176
698
  migrate: () => migrate
177
699
  });
178
- import { join as join12 } from "path";
179
- import { existsSync as existsSync11, readdirSync as readdirSync6, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
180
- function migrateOne(changeId, changeDir, dryRun) {
700
+ import { join as join13 } from "path";
701
+ import { cpSync as cpSync2, existsSync as existsSync12, mkdirSync as mkdirSync5, readdirSync as readdirSync8, readFileSync as readFileSync10, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
702
+ function backupChangeDir(cwd, changeId, sessionStamp) {
703
+ const backupRoot = join13(cwd, ".cdd", "migrate-backup", sessionStamp);
704
+ const backupDir2 = join13(backupRoot, changeId);
705
+ mkdirSync5(backupRoot, { recursive: true });
706
+ const sourceDir = join13(cwd, "specs", "changes", changeId);
707
+ if (existsSync12(sourceDir)) {
708
+ cpSync2(sourceDir, backupDir2, { recursive: true });
709
+ }
710
+ return backupDir2;
711
+ }
712
+ function buildLegacyContextManifest(changeId) {
713
+ return [
714
+ "# Context Manifest",
715
+ "",
716
+ "Generated by `cdd-kit migrate` for an existing change.",
717
+ "Legacy manifest. Forbidden paths come from `.cdd/context-policy.json`.",
718
+ "",
719
+ "## Affected Surfaces",
720
+ "- legacy-unknown",
721
+ "",
722
+ "## Allowed Paths",
723
+ `- specs/changes/${changeId}/`,
724
+ "",
725
+ "## Required Contracts",
726
+ "- legacy-unknown",
727
+ "",
728
+ "## Required Tests",
729
+ "- legacy-unknown",
730
+ "",
731
+ "## Agent Work Packets",
732
+ "",
733
+ "## Context Expansion Requests",
734
+ "-",
735
+ "",
736
+ "## Approved Expansions",
737
+ "-",
738
+ ""
739
+ ].join("\n");
740
+ }
741
+ function upsertFrontmatterField(content, field, value) {
742
+ if (!content.startsWith("---\n"))
743
+ return content;
744
+ const closing = content.indexOf("\n---", 4);
745
+ if (closing === -1)
746
+ return content;
747
+ const frontmatter = content.slice(4, closing);
748
+ const body = content.slice(closing);
749
+ const fieldPattern = new RegExp(`^${field}:.*$`, "m");
750
+ const nextFrontmatter = fieldPattern.test(frontmatter) ? frontmatter.replace(fieldPattern, `${field}: ${value}`) : `${frontmatter.trimEnd()}
751
+ ${field}: ${value}`;
752
+ return `---
753
+ ${nextFrontmatter}${body}`;
754
+ }
755
+ function buildContextGovernedManifest(changeId) {
756
+ return [
757
+ "# Context Manifest",
758
+ "",
759
+ "Generated by `cdd-kit migrate --enable-context-governance` for an existing change.",
760
+ "Review and narrow the allowed paths before assigning implementation work.",
761
+ "Forbidden paths come from `.cdd/context-policy.json`.",
762
+ "",
763
+ "## Affected Surfaces",
764
+ "- legacy-unknown",
765
+ "",
766
+ "## Allowed Paths",
767
+ `- specs/changes/${changeId}/`,
768
+ "- specs/context/project-map.md",
769
+ "- specs/context/contracts-index.md",
770
+ "",
771
+ "## Required Contracts",
772
+ "- legacy-unknown",
773
+ "",
774
+ "## Required Tests",
775
+ "- legacy-unknown",
776
+ "",
777
+ "## Agent Work Packets",
778
+ "",
779
+ "### change-classifier",
780
+ "- allowed:",
781
+ ` - specs/changes/${changeId}/`,
782
+ " - specs/context/project-map.md",
783
+ " - specs/context/contracts-index.md",
784
+ "",
785
+ "## Context Expansion Requests",
786
+ "",
787
+ "<!--",
788
+ "Agents must request context expansion instead of reading outside their work packet.",
789
+ "Use this format only for real requests:",
790
+ "",
791
+ "- request-id: CER-001",
792
+ " requested_paths:",
793
+ " - src/example.ts",
794
+ " reason: why this file is required",
795
+ " status: pending",
796
+ "-->",
797
+ "",
798
+ "## Approved Expansions",
799
+ "-",
800
+ ""
801
+ ].join("\n");
802
+ }
803
+ function migrateOne(changeId, changeDir, enableContextGovernance) {
181
804
  const changed = [];
182
805
  const warnings = [];
183
- const tasksPath = join12(changeDir, "tasks.md");
184
- if (existsSync11(tasksPath)) {
185
- let content = readFileSync8(tasksPath, "utf8");
806
+ const pending = [];
807
+ let detectedTier = null;
808
+ const tasksPath = join13(changeDir, "tasks.md");
809
+ if (existsSync12(tasksPath)) {
810
+ let content = readFileSync10(tasksPath, "utf8");
186
811
  const norm = content.replace(/\r\n/g, "\n");
187
812
  let modified = false;
813
+ const taskChanges = [];
188
814
  if (!norm.startsWith("---")) {
189
815
  const bareStatusMatch = norm.match(/^status:\s*(\S+)/m);
190
816
  const inferredStatus = bareStatusMatch ? bareStatusMatch[1] : "in-progress";
@@ -198,6 +824,7 @@ status: ${inferredStatus}
198
824
 
199
825
  ` + content;
200
826
  modified = true;
827
+ taskChanges.push("added YAML frontmatter");
201
828
  }
202
829
  if (!content.includes("[x]=done")) {
203
830
  content = content.replace(
@@ -207,22 +834,28 @@ status: ${inferredStatus}
207
834
  `
208
835
  );
209
836
  modified = true;
837
+ taskChanges.push("added [x]/[-]/[ ] legend comment");
838
+ }
839
+ if (enableContextGovernance && !/^context-governance:\s*v1\b/m.test(content)) {
840
+ content = upsertFrontmatterField(content, "context-governance", "v1");
841
+ modified = true;
842
+ taskChanges.push("enabled context-governance: v1");
210
843
  }
211
844
  if (modified) {
212
- changed.push("tasks.md: added YAML frontmatter (status: in-progress) + legend comment");
213
- if (!dryRun)
214
- writeFileSync5(tasksPath, content, "utf8");
845
+ changed.push(`tasks.md: ${taskChanges.join("; ")}`);
846
+ pending.push({ path: tasksPath, content });
215
847
  }
216
848
  } else {
217
849
  warnings.push("tasks.md not found \u2014 skipping frontmatter migration");
218
850
  }
219
- const classifPath = join12(changeDir, "change-classification.md");
220
- if (existsSync11(classifPath)) {
221
- const content = readFileSync8(classifPath, "utf8");
851
+ const classifPath = join13(changeDir, "change-classification.md");
852
+ if (existsSync12(classifPath)) {
853
+ const content = readFileSync10(classifPath, "utf8");
222
854
  const hasNewTierFormat = /^## Tier\s*\n\s*-\s*\d\s*$/m.test(content);
855
+ const oldMatch = content.match(/\*\*Tier[:\*]+\s*(?:Tier\s*)?(\d)/i) ?? content.match(/^-?\s*Tier:\s*(?:Tier\s*)?(\d)/mi);
856
+ if (oldMatch)
857
+ detectedTier = oldMatch[1];
223
858
  if (!hasNewTierFormat) {
224
- const oldMatch = content.match(/\*\*Tier[:\*]+\s*(?:Tier\s*)?(\d)/i) ?? content.match(/^-?\s*Tier:\s*(?:Tier\s*)?(\d)/mi);
225
- const detectedTier = oldMatch ? oldMatch[1] : null;
226
859
  if (detectedTier) {
227
860
  const addition = `
228
861
  ## Tier
@@ -232,40 +865,100 @@ status: ${inferredStatus}
232
865
  changed.push(
233
866
  `change-classification.md: appended "## Tier\\n- ${detectedTier}" (converted from old format)`
234
867
  );
235
- if (!dryRun)
236
- writeFileSync5(classifPath, content + addition, "utf8");
868
+ pending.push({ path: classifPath, content: content + addition });
237
869
  }
238
870
  } else {
239
871
  warnings.push(
240
- "change-classification.md: could not detect tier (no **Tier:** N or ## Tier N found). gate tier-based agent-log checks will be skipped for this change."
872
+ "change-classification.md: could not detect tier (no **Tier:** N or ## Tier N found). Set `tier: <0-5>` in tasks.md frontmatter to enable tier-based gate checks."
241
873
  );
242
874
  }
875
+ } else {
876
+ const structured = content.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
877
+ if (structured)
878
+ detectedTier = structured[1];
879
+ }
880
+ }
881
+ if (existsSync12(tasksPath)) {
882
+ const tasksWrite = pending.find((p) => p.path === tasksPath);
883
+ let content = tasksWrite ? tasksWrite.content : readFileSync10(tasksPath, "utf8");
884
+ let modified = false;
885
+ const subChanges = [];
886
+ if (detectedTier && !/^tier:\s*\d/m.test(content)) {
887
+ content = upsertFrontmatterField(content, "tier", detectedTier);
888
+ modified = true;
889
+ subChanges.push(`backfilled tier: ${detectedTier}`);
890
+ }
891
+ if (!/^archive-tasks:/m.test(content)) {
892
+ content = upsertFrontmatterField(content, "archive-tasks", '["7.1", "7.2"]');
893
+ modified = true;
894
+ subChanges.push("added default archive-tasks list");
895
+ }
896
+ if (modified) {
897
+ if (tasksWrite) {
898
+ tasksWrite.content = content;
899
+ } else {
900
+ pending.push({ path: tasksPath, content });
901
+ }
902
+ changed.push(`tasks.md: ${subChanges.join("; ")}`);
903
+ }
904
+ }
905
+ const manifestPath = join13(changeDir, "context-manifest.md");
906
+ if (!existsSync12(manifestPath)) {
907
+ changed.push(enableContextGovernance ? "context-manifest.md: added context-governance v1 manifest scaffold" : "context-manifest.md: added legacy context manifest scaffold");
908
+ pending.push({
909
+ path: manifestPath,
910
+ content: enableContextGovernance ? buildContextGovernedManifest(changeId) : buildLegacyContextManifest(changeId)
911
+ });
912
+ } else if (enableContextGovernance) {
913
+ warnings.push("context-manifest.md already exists \u2014 review allowed paths before relying on context-governance: v1");
914
+ }
915
+ return { result: { changed, warnings }, pending };
916
+ }
917
+ function commitWritesAtomically(pending) {
918
+ const renames = [];
919
+ try {
920
+ for (const write of pending) {
921
+ const tmp = `${write.path}.cdd-migrate.tmp`;
922
+ writeFileSync5(tmp, write.content, "utf8");
923
+ renames.push({ tmp, final: write.path });
924
+ }
925
+ } catch (err) {
926
+ for (const r of renames) {
927
+ try {
928
+ rmSync2(r.tmp, { force: true });
929
+ } catch {
930
+ }
243
931
  }
932
+ throw err;
933
+ }
934
+ for (const r of renames) {
935
+ renameSync(r.tmp, r.final);
244
936
  }
245
- return { changed, warnings };
246
937
  }
247
938
  async function migrate(changeId, opts = {}) {
248
939
  const cwd = process.cwd();
249
940
  const dryRun = opts.dryRun ?? false;
941
+ const enableContextGovernance = opts.enableContextGovernance ?? false;
942
+ const noBackup = opts.noBackup ?? false;
250
943
  const idsToMigrate = [];
251
944
  if (opts.all) {
252
- const changesDir = join12(cwd, "specs", "changes");
253
- if (!existsSync11(changesDir)) {
945
+ const changesDir = join13(cwd, "specs", "changes");
946
+ if (!existsSync12(changesDir)) {
254
947
  log.info("No specs/changes/ directory found \u2014 nothing to migrate.");
255
948
  return;
256
949
  }
257
950
  idsToMigrate.push(
258
- ...readdirSync6(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
951
+ ...readdirSync8(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
259
952
  );
260
953
  } else if (changeId) {
261
- const specificDir = join12(cwd, "specs", "changes", changeId);
262
- if (!existsSync11(specificDir)) {
954
+ const specificDir = join13(cwd, "specs", "changes", changeId);
955
+ if (!existsSync12(specificDir)) {
263
956
  log.error(`Change not found: specs/changes/${changeId}`);
264
957
  process.exit(1);
265
958
  }
266
959
  idsToMigrate.push(changeId);
267
960
  } else {
268
- log.error("Usage: cdd-kit migrate <change-id> | cdd-kit migrate --all [--dry-run]");
961
+ log.error("Usage: cdd-kit migrate <change-id> | cdd-kit migrate --all [--dry-run] [--no-backup]");
269
962
  process.exit(1);
270
963
  }
271
964
  if (idsToMigrate.length === 0) {
@@ -276,35 +969,54 @@ async function migrate(changeId, opts = {}) {
276
969
  log.info("Dry run \u2014 no files will be written.");
277
970
  log.blank();
278
971
  }
972
+ const sessionStamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
279
973
  let migratedCount = 0;
280
974
  let upToDateCount = 0;
975
+ const backupRoot = join13(cwd, ".cdd", "migrate-backup", sessionStamp);
281
976
  for (const id of idsToMigrate) {
282
- const changeDir = join12(cwd, "specs", "changes", id);
283
- if (!existsSync11(changeDir)) {
977
+ const changeDir = join13(cwd, "specs", "changes", id);
978
+ if (!existsSync12(changeDir)) {
284
979
  log.warn(` ${id}: directory not found \u2014 skipping`);
285
980
  continue;
286
981
  }
287
- const { changed, warnings } = migrateOne(id, changeDir, dryRun);
288
- if (changed.length > 0) {
289
- log.ok(` ${id}: migrated`);
290
- for (const c of changed)
291
- log.info(` + ${c}`);
292
- migratedCount++;
293
- } else {
982
+ const { result, pending } = migrateOne(id, changeDir, enableContextGovernance);
983
+ const { changed, warnings } = result;
984
+ if (changed.length === 0) {
294
985
  log.info(` ${id}: already up to date`);
295
986
  upToDateCount++;
987
+ for (const w of warnings)
988
+ log.warn(` ${id}: ${w}`);
989
+ continue;
296
990
  }
297
- for (const w of warnings) {
298
- log.warn(` ${id}: ${w}`);
991
+ if (!dryRun) {
992
+ try {
993
+ if (!noBackup)
994
+ backupChangeDir(cwd, id, sessionStamp);
995
+ commitWritesAtomically(pending);
996
+ } catch (err) {
997
+ log.error(` ${id}: migration failed \u2014 ${err.message}`);
998
+ if (!noBackup) {
999
+ log.error(` ${id}: restore from .cdd/migrate-backup/${sessionStamp}/${id}/`);
1000
+ }
1001
+ process.exit(1);
1002
+ }
299
1003
  }
1004
+ log.ok(` ${id}: migrated`);
1005
+ for (const c of changed)
1006
+ log.info(` + ${c}`);
1007
+ migratedCount++;
1008
+ for (const w of warnings)
1009
+ log.warn(` ${id}: ${w}`);
300
1010
  }
301
1011
  log.blank();
302
1012
  if (dryRun) {
303
1013
  log.info(`Dry run complete: ${migratedCount} change(s) would be updated, ${upToDateCount} already up to date.`);
304
1014
  } else {
305
1015
  log.ok(`Migration complete: ${migratedCount} updated, ${upToDateCount} already up to date.`);
306
- if (migratedCount > 0) {
307
- log.info('Next: git add specs/changes/ && git commit -m "chore: migrate changes to v1.11.0 format"');
1016
+ if (migratedCount > 0 && !noBackup) {
1017
+ log.info(`Backup: ${backupRoot}`);
1018
+ log.info('Next: git add specs/changes/ && git commit -m "chore: migrate changes to current cdd-kit format"');
1019
+ log.info("When stable, remove backup: rm -rf .cdd/migrate-backup/");
308
1020
  }
309
1021
  }
310
1022
  }
@@ -315,84 +1027,580 @@ var init_migrate = __esm({
315
1027
  }
316
1028
  });
317
1029
 
1030
+ // src/commands/upgrade.ts
1031
+ var upgrade_exports = {};
1032
+ __export(upgrade_exports, {
1033
+ upgrade: () => upgrade
1034
+ });
1035
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, readdirSync as readdirSync9, copyFileSync as copyFileSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync6 } from "fs";
1036
+ import { dirname as dirname4, join as join14, relative as relative3 } from "path";
1037
+ function planMissingFiles(srcDir, destDir, label, planned) {
1038
+ if (!existsSync13(srcDir))
1039
+ return;
1040
+ for (const entry of readdirSync9(srcDir, { withFileTypes: true })) {
1041
+ const src = join14(srcDir, entry.name);
1042
+ const dest = join14(destDir, entry.name);
1043
+ if (entry.isDirectory()) {
1044
+ planMissingFiles(src, dest, join14(label, entry.name), planned);
1045
+ continue;
1046
+ }
1047
+ if (!existsSync13(dest)) {
1048
+ planned.push({ src, dest, rel: join14(label, relative3(srcDir, src)) });
1049
+ }
1050
+ }
1051
+ }
1052
+ function planProviderGuidance(cwd, provider, planned) {
1053
+ if (provider === "claude" || provider === "both") {
1054
+ if (!existsSync13(join14(cwd, "CLAUDE.md"))) {
1055
+ planned.push({ src: ASSET.claudeTemplate, dest: join14(cwd, "CLAUDE.md"), rel: "CLAUDE.md" });
1056
+ }
1057
+ if (!existsSync13(join14(cwd, "AGENTS.md"))) {
1058
+ planned.push({ src: ASSET.agentsTemplate, dest: join14(cwd, "AGENTS.md"), rel: "AGENTS.md" });
1059
+ }
1060
+ }
1061
+ if ((provider === "codex" || provider === "both") && !existsSync13(join14(cwd, "CODEX.md"))) {
1062
+ planned.push({ src: ASSET.codexTemplate, dest: join14(cwd, "CODEX.md"), rel: "CODEX.md" });
1063
+ }
1064
+ }
1065
+ function applyCopy(plan) {
1066
+ for (const item of plan) {
1067
+ mkdirSync6(dirname4(item.dest), { recursive: true });
1068
+ copyFileSync3(item.src, item.dest);
1069
+ }
1070
+ }
1071
+ async function upgrade(opts = {}) {
1072
+ const cwd = process.cwd();
1073
+ const requestedProvider = opts.provider ?? "auto";
1074
+ if (!validateProviderOption(requestedProvider)) {
1075
+ log.error(`Invalid provider: ${requestedProvider}. Use auto, claude, codex, or both.`);
1076
+ process.exit(1);
1077
+ }
1078
+ const provider = inferProvider(cwd, requestedProvider);
1079
+ const plan = [];
1080
+ planMissingFiles(ASSET.contracts, join14(cwd, "contracts"), "contracts", plan);
1081
+ planMissingFiles(ASSET.specsTemplates, join14(cwd, "specs", "templates"), "specs/templates", plan);
1082
+ planMissingFiles(ASSET.testsTemplates, join14(cwd, "tests", "templates"), "tests/templates", plan);
1083
+ planMissingFiles(ASSET.ci, join14(cwd, "ci"), "ci", plan);
1084
+ planMissingFiles(ASSET.githubWorkflows, join14(cwd, ".github", "workflows"), ".github/workflows", plan);
1085
+ planMissingFiles(ASSET.cddConfig, join14(cwd, ".cdd"), ".cdd", plan);
1086
+ planProviderGuidance(cwd, provider, plan);
1087
+ log.blank();
1088
+ log.info(`Upgrade provider: ${provider}`);
1089
+ if (plan.length === 0) {
1090
+ log.ok("No missing cdd-kit project files found.");
1091
+ if (opts.migrateChanges) {
1092
+ log.blank();
1093
+ log.info("Running change migration flow...");
1094
+ await migrate(void 0, {
1095
+ all: true,
1096
+ dryRun: !opts.yes,
1097
+ enableContextGovernance: opts.enableContextGovernance
1098
+ });
1099
+ }
1100
+ log.blank();
1101
+ return;
1102
+ }
1103
+ log.info(`${plan.length} missing file(s) detected:`);
1104
+ for (const item of plan)
1105
+ log.dim(` + ${item.rel.replace(/\\/g, "/")}`);
1106
+ if (!opts.yes) {
1107
+ log.blank();
1108
+ log.info("Dry run only. Re-run with --yes to write missing files.");
1109
+ if (opts.migrateChanges) {
1110
+ log.blank();
1111
+ log.info("Previewing existing change migration because --migrate-changes was requested.");
1112
+ await migrate(void 0, {
1113
+ all: true,
1114
+ dryRun: true,
1115
+ enableContextGovernance: opts.enableContextGovernance
1116
+ });
1117
+ }
1118
+ log.blank();
1119
+ return;
1120
+ }
1121
+ applyCopy(plan);
1122
+ const modelPolicyPath = join14(cwd, ".cdd", "model-policy.json");
1123
+ if (existsSync13(modelPolicyPath)) {
1124
+ let existing = {};
1125
+ try {
1126
+ existing = JSON.parse(readFileSync11(modelPolicyPath, "utf8"));
1127
+ } catch {
1128
+ }
1129
+ const merged = {
1130
+ ...existing,
1131
+ provider,
1132
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
1133
+ roles: existing.roles && typeof existing.roles === "object" ? existing.roles : {}
1134
+ };
1135
+ writeFileSync6(modelPolicyPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
1136
+ }
1137
+ log.blank();
1138
+ log.ok(`Upgrade complete: ${plan.length} missing file(s) added.`);
1139
+ log.info("Existing project guidance and contracts were preserved.");
1140
+ if (opts.migrateChanges) {
1141
+ log.blank();
1142
+ log.info("Running change migration flow...");
1143
+ await migrate(void 0, {
1144
+ all: true,
1145
+ dryRun: false,
1146
+ enableContextGovernance: opts.enableContextGovernance
1147
+ });
1148
+ }
1149
+ log.blank();
1150
+ }
1151
+ var init_upgrade = __esm({
1152
+ "src/commands/upgrade.ts"() {
1153
+ "use strict";
1154
+ init_paths();
1155
+ init_logger();
1156
+ init_provider();
1157
+ init_migrate();
1158
+ }
1159
+ });
1160
+
1161
+ // src/commands/archive.ts
1162
+ var archive_exports = {};
1163
+ __export(archive_exports, {
1164
+ archive: () => archive
1165
+ });
1166
+ import { join as join15 } from "path";
1167
+ import { existsSync as existsSync14, mkdirSync as mkdirSync7, renameSync as renameSync2, readFileSync as readFileSync12, writeFileSync as writeFileSync7, appendFileSync, cpSync as cpSync3, rmSync as rmSync3 } from "fs";
1168
+ async function archive(changeId) {
1169
+ const cwd = process.cwd();
1170
+ const changeDir = join15(cwd, "specs", "changes", changeId);
1171
+ const archiveYear = (/* @__PURE__ */ new Date()).getFullYear().toString();
1172
+ const archiveBase = join15(cwd, "specs", "archive", archiveYear);
1173
+ const archiveDir = join15(archiveBase, changeId);
1174
+ const indexPath = join15(cwd, "specs", "archive", "INDEX.md");
1175
+ if (!existsSync14(changeDir)) {
1176
+ log.error(`Change not found: specs/changes/${changeId}`);
1177
+ process.exit(1);
1178
+ }
1179
+ if (existsSync14(archiveDir)) {
1180
+ log.error(`Already archived: specs/archive/${archiveYear}/${changeId}`);
1181
+ process.exit(1);
1182
+ }
1183
+ const tasksPath = join15(changeDir, "tasks.md");
1184
+ if (existsSync14(tasksPath)) {
1185
+ const content = readFileSync12(tasksPath, "utf8");
1186
+ if (content.includes("status: gate-blocked")) {
1187
+ log.warn("tasks.md has status: gate-blocked \u2014 archiving anyway (change was paused).");
1188
+ }
1189
+ const pending = (content.match(/^\s*-\s*\[ \]/gm) || []).length;
1190
+ if (pending > 0) {
1191
+ log.warn(`${pending} task(s) still pending ([ ]). Archive anyway.`);
1192
+ }
1193
+ }
1194
+ if (!existsSync14(archiveBase)) {
1195
+ mkdirSync7(archiveBase, { recursive: true });
1196
+ }
1197
+ try {
1198
+ renameSync2(changeDir, archiveDir);
1199
+ } catch (err) {
1200
+ if (err.code === "EXDEV") {
1201
+ cpSync3(changeDir, archiveDir, { recursive: true });
1202
+ rmSync3(changeDir, { recursive: true, force: true });
1203
+ } else {
1204
+ throw err;
1205
+ }
1206
+ }
1207
+ log.ok(`Archived: specs/changes/${changeId} \u2192 specs/archive/${archiveYear}/${changeId}`);
1208
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1209
+ const indexLine = `| ${changeId} | ${archiveYear} | ${today} | specs/archive/${archiveYear}/${changeId}/ |
1210
+ `;
1211
+ if (!existsSync14(indexPath)) {
1212
+ writeFileSync7(indexPath, `# Archive Index
1213
+
1214
+ | change-id | year | archived-date | path |
1215
+ |---|---|---|---|
1216
+ ${indexLine}`, "utf8");
1217
+ } else {
1218
+ appendFileSync(indexPath, indexLine, "utf8");
1219
+ }
1220
+ log.ok(`Index updated: specs/archive/INDEX.md`);
1221
+ log.blank();
1222
+ log.info(`Next: promote durable learnings from archive.md to contracts/ or CLAUDE.md`);
1223
+ }
1224
+ var init_archive = __esm({
1225
+ "src/commands/archive.ts"() {
1226
+ "use strict";
1227
+ init_logger();
1228
+ }
1229
+ });
1230
+
1231
+ // src/commands/abandon.ts
1232
+ var abandon_exports = {};
1233
+ __export(abandon_exports, {
1234
+ abandon: () => abandon
1235
+ });
1236
+ import { join as join16 } from "path";
1237
+ import { existsSync as existsSync15, readFileSync as readFileSync13, writeFileSync as writeFileSync8, appendFileSync as appendFileSync2, mkdirSync as mkdirSync8 } from "fs";
1238
+ async function abandon(changeId, opts) {
1239
+ const cwd = process.cwd();
1240
+ const changeDir = join16(cwd, "specs", "changes", changeId);
1241
+ const tasksPath = join16(changeDir, "tasks.md");
1242
+ if (!existsSync15(changeDir)) {
1243
+ log.error(`Change not found: specs/changes/${changeId}`);
1244
+ process.exit(1);
1245
+ }
1246
+ if (existsSync15(tasksPath)) {
1247
+ let content = readFileSync13(tasksPath, "utf8");
1248
+ if (content.match(/^status:/m)) {
1249
+ content = content.replace(/^status: .*/m, "status: abandoned");
1250
+ } else {
1251
+ content = `---
1252
+ change-id: ${changeId}
1253
+ status: abandoned
1254
+ ---
1255
+
1256
+ ` + content;
1257
+ }
1258
+ writeFileSync8(tasksPath, content, "utf8");
1259
+ }
1260
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1261
+ const archiveDir = join16(cwd, "specs", "archive");
1262
+ const indexPath = join16(archiveDir, "INDEX.md");
1263
+ const reason = opts.reason ?? "no reason given";
1264
+ const indexLine = `| ${changeId} | abandoned | ${today} | ${reason} |
1265
+ `;
1266
+ if (!existsSync15(archiveDir)) {
1267
+ mkdirSync8(archiveDir, { recursive: true });
1268
+ }
1269
+ if (!existsSync15(indexPath)) {
1270
+ writeFileSync8(indexPath, `# Archive Index
1271
+
1272
+ | change-id | status | date | notes |
1273
+ |---|---|---|---|
1274
+ ${indexLine}`, "utf8");
1275
+ } else {
1276
+ appendFileSync2(indexPath, indexLine, "utf8");
1277
+ }
1278
+ log.ok(`Change ${changeId} marked as abandoned.`);
1279
+ log.info(`specs/changes/${changeId}/ remains on disk (git history preserved).`);
1280
+ log.info(`Run \`cdd-kit archive ${changeId}\` to physically move it, or leave it for git history.`);
1281
+ }
1282
+ var init_abandon = __esm({
1283
+ "src/commands/abandon.ts"() {
1284
+ "use strict";
1285
+ init_logger();
1286
+ }
1287
+ });
1288
+
318
1289
  // src/commands/list-changes.ts
319
1290
  var list_changes_exports = {};
320
1291
  __export(list_changes_exports, {
321
1292
  listChanges: () => listChanges
322
1293
  });
323
- import { join as join13 } from "path";
324
- import { existsSync as existsSync12, readdirSync as readdirSync7, readFileSync as readFileSync9 } from "fs";
1294
+ import { join as join17 } from "path";
1295
+ import { existsSync as existsSync16, readdirSync as readdirSync10, readFileSync as readFileSync14 } from "fs";
325
1296
  async function listChanges() {
326
1297
  const cwd = process.cwd();
327
- const changesDir = join13(cwd, "specs", "changes");
1298
+ const changesDir = join17(cwd, "specs", "changes");
328
1299
  log.blank();
329
1300
  const active = [];
330
- if (existsSync12(changesDir)) {
331
- active.push(...readdirSync7(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name));
1301
+ if (existsSync16(changesDir)) {
1302
+ active.push(...readdirSync10(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name));
1303
+ }
1304
+ if (active.length === 0) {
1305
+ log.info("No active changes in specs/changes/");
1306
+ } else {
1307
+ log.info("Active changes:");
1308
+ for (const id of active) {
1309
+ const tasksPath = join17(changesDir, id, "tasks.md");
1310
+ let status = "in-progress";
1311
+ let pending = 0;
1312
+ if (existsSync16(tasksPath)) {
1313
+ const content = readFileSync14(tasksPath, "utf8");
1314
+ if (content.includes("status: gate-blocked"))
1315
+ status = "gate-blocked";
1316
+ else if (content.includes("status: abandoned"))
1317
+ status = "abandoned";
1318
+ pending = (content.match(/^\s*-\s*\[ \]/gm) || []).length;
1319
+ }
1320
+ const pendingStr = pending > 0 ? ` (${pending} pending)` : "";
1321
+ log.info(` ${id} [${status}]${pendingStr}`);
1322
+ }
1323
+ }
1324
+ log.blank();
1325
+ }
1326
+ var init_list_changes = __esm({
1327
+ "src/commands/list-changes.ts"() {
1328
+ "use strict";
1329
+ init_logger();
1330
+ }
1331
+ });
1332
+
1333
+ // src/commands/context.ts
1334
+ var context_exports = {};
1335
+ __export(context_exports, {
1336
+ approveAllPending: () => approveAllPending,
1337
+ approveContextExpansion: () => approveContextExpansion,
1338
+ listContextExpansions: () => listContextExpansions,
1339
+ rejectAllPending: () => rejectAllPending,
1340
+ rejectContextExpansion: () => rejectContextExpansion,
1341
+ requestContextExpansion: () => requestContextExpansion
1342
+ });
1343
+ import { existsSync as existsSync17, readFileSync as readFileSync15, writeFileSync as writeFileSync9 } from "fs";
1344
+ import { join as join18 } from "path";
1345
+ function normalizePath(path) {
1346
+ return path.replace(/\\/g, "/").replace(/^\.\//, "").trim();
1347
+ }
1348
+ function validateRepoRelativePath(path) {
1349
+ if (/^[a-zA-Z]:\//.test(path) || path.startsWith("/")) {
1350
+ return `requested path must be repo-relative: ${path}`;
1351
+ }
1352
+ if (path.split("/").includes("..")) {
1353
+ return `requested path must not contain "..": ${path}`;
1354
+ }
1355
+ return null;
1356
+ }
1357
+ function manifestPathFor(changeId) {
1358
+ return join18(process.cwd(), "specs", "changes", changeId, "context-manifest.md");
1359
+ }
1360
+ function readManifest(changeId) {
1361
+ const manifestPath = manifestPathFor(changeId);
1362
+ if (!existsSync17(manifestPath)) {
1363
+ log.error(`context manifest not found: specs/changes/${changeId}/context-manifest.md`);
1364
+ process.exit(1);
1365
+ }
1366
+ return readFileSync15(manifestPath, "utf8");
1367
+ }
1368
+ function writeManifest(changeId, content) {
1369
+ writeFileSync9(manifestPathFor(changeId), content.endsWith("\n") ? content : `${content}
1370
+ `, "utf8");
1371
+ }
1372
+ function sectionBody(content, heading) {
1373
+ const match = content.match(new RegExp(`## ${heading}\\s*\\n([\\s\\S]*?)(?=\\n## |$)`));
1374
+ return match?.[1] ?? "";
1375
+ }
1376
+ function parseRequests(content) {
1377
+ const body = sectionBody(content, "Context Expansion Requests");
1378
+ if (!body.trim())
1379
+ return [];
1380
+ const requests = [];
1381
+ const blocks = body.split(/(?=^\s*-\s*request-id:\s*)/m);
1382
+ for (const block of blocks) {
1383
+ const idMatch = block.match(/^\s*-\s*request-id:\s*(\S+)/m);
1384
+ if (!idMatch)
1385
+ continue;
1386
+ const statusMatch = block.match(/^\s*status:\s*(\S+)/im);
1387
+ const reasonMatch = block.match(/^\s*reason:\s*(.+)$/im);
1388
+ const paths = [];
1389
+ let inPaths = false;
1390
+ for (const line of block.split(/\r?\n/)) {
1391
+ if (/^\s*requested_paths:\s*$/.test(line)) {
1392
+ inPaths = true;
1393
+ continue;
1394
+ }
1395
+ if (!inPaths)
1396
+ continue;
1397
+ const item = line.match(/^\s*-\s+(.+?)\s*$/);
1398
+ if (item) {
1399
+ paths.push(normalizePath(item[1]));
1400
+ continue;
1401
+ }
1402
+ if (/^\s*[a-zA-Z_-]+:\s*/.test(line))
1403
+ break;
1404
+ }
1405
+ requests.push({
1406
+ requestId: idMatch[1],
1407
+ paths,
1408
+ reason: reasonMatch?.[1]?.trim(),
1409
+ status: statusMatch?.[1]?.trim().toLowerCase() ?? "unknown"
1410
+ });
1411
+ }
1412
+ return requests;
1413
+ }
1414
+ function approvedExpansionSet(content) {
1415
+ const body = sectionBody(content, "Approved Expansions");
1416
+ const approved = /* @__PURE__ */ new Set();
1417
+ for (const line of body.split(/\r?\n/)) {
1418
+ const item = line.match(/^\s*-\s+(.+?)\s*$/);
1419
+ if (!item)
1420
+ continue;
1421
+ const value = normalizePath(item[1]);
1422
+ if (value && value !== "-")
1423
+ approved.add(value);
1424
+ }
1425
+ return approved;
1426
+ }
1427
+ function replaceSection(content, heading, lines) {
1428
+ const nextSection = [`## ${heading}`, ...lines, ""].join("\n");
1429
+ const pattern = new RegExp(`## ${heading}\\s*\\n[\\s\\S]*?(?=\\n## |$)`);
1430
+ if (pattern.test(content))
1431
+ return content.replace(pattern, nextSection.trimEnd());
1432
+ return `${content.trimEnd()}
1433
+
1434
+ ${nextSection}`;
1435
+ }
1436
+ function renderRequests(requests) {
1437
+ if (requests.length === 0)
1438
+ return ["-"];
1439
+ const lines = [];
1440
+ for (const request of requests) {
1441
+ lines.push(`- request-id: ${request.requestId}`);
1442
+ lines.push(" requested_paths:");
1443
+ for (const path of request.paths)
1444
+ lines.push(` - ${path}`);
1445
+ if (request.reason)
1446
+ lines.push(` reason: ${request.reason}`);
1447
+ lines.push(` status: ${request.status}`);
1448
+ lines.push("");
1449
+ }
1450
+ if (lines[lines.length - 1] === "")
1451
+ lines.pop();
1452
+ return lines;
1453
+ }
1454
+ function setRequestStatus(content, requestId, status) {
1455
+ const requests = parseRequests(content);
1456
+ const target = requests.find((request) => request.requestId === requestId);
1457
+ if (!target) {
1458
+ log.error(`context expansion request not found: ${requestId}`);
1459
+ process.exit(1);
1460
+ }
1461
+ if (target.status !== "pending") {
1462
+ log.error(`pending context expansion request not found: ${requestId}`);
1463
+ process.exit(1);
1464
+ }
1465
+ const next = requests.map((request) => request.requestId === requestId ? { ...request, status } : request);
1466
+ return replaceSection(content, "Context Expansion Requests", renderRequests(next));
1467
+ }
1468
+ async function requestContextExpansion(changeId, requestId, paths, reason) {
1469
+ if (paths.length === 0) {
1470
+ log.error("at least one --path value is required");
1471
+ process.exit(1);
1472
+ }
1473
+ const normalizedPaths = [...new Set(paths.map(normalizePath).filter(Boolean))];
1474
+ for (const path of normalizedPaths) {
1475
+ const validationError = validateRepoRelativePath(path);
1476
+ if (validationError) {
1477
+ log.error(validationError);
1478
+ process.exit(1);
1479
+ }
1480
+ }
1481
+ const content = readManifest(changeId);
1482
+ const requests = parseRequests(content);
1483
+ if (requests.some((request) => request.requestId === requestId)) {
1484
+ log.error(`context expansion request already exists: ${requestId}`);
1485
+ process.exit(1);
1486
+ }
1487
+ const next = replaceSection(content, "Context Expansion Requests", renderRequests([
1488
+ ...requests,
1489
+ { requestId, paths: normalizedPaths, reason, status: "pending" }
1490
+ ]));
1491
+ writeManifest(changeId, next);
1492
+ log.ok(`recorded context expansion request ${requestId} for ${changeId}`);
1493
+ for (const path of normalizedPaths)
1494
+ log.info(` ${path}`);
1495
+ }
1496
+ async function listContextExpansions(changeId, json = false) {
1497
+ const requests = parseRequests(readManifest(changeId));
1498
+ if (json) {
1499
+ console.log(JSON.stringify({ changeId, requests }, null, 2));
1500
+ return;
1501
+ }
1502
+ if (requests.length === 0) {
1503
+ log.info(`no context expansion requests for ${changeId}`);
1504
+ return;
1505
+ }
1506
+ log.info(`context expansion requests for ${changeId}`);
1507
+ for (const request of requests) {
1508
+ log.info(`- ${request.requestId} [${request.status}] ${request.reason ?? ""}`.trimEnd());
1509
+ for (const path of request.paths)
1510
+ log.dim(` ${path}`);
1511
+ }
1512
+ }
1513
+ function applyApproval(content, request) {
1514
+ for (const path of request.paths) {
1515
+ const validationError = validateRepoRelativePath(path);
1516
+ if (validationError) {
1517
+ log.error(validationError);
1518
+ process.exit(1);
1519
+ }
1520
+ }
1521
+ const approved = approvedExpansionSet(content);
1522
+ for (const path of request.paths)
1523
+ approved.add(path);
1524
+ let next = replaceSection(content, "Approved Expansions", [...approved].sort().map((p) => `- ${p}`));
1525
+ next = setRequestStatus(next, request.requestId, "approved");
1526
+ return next;
1527
+ }
1528
+ async function approveContextExpansion(changeId, requestId) {
1529
+ const content = readManifest(changeId);
1530
+ const request = parseRequests(content).find((item) => item.requestId === requestId && item.status === "pending");
1531
+ if (!request) {
1532
+ log.error(`pending context expansion request not found: ${requestId}`);
1533
+ process.exit(1);
1534
+ }
1535
+ if (request.paths.length === 0) {
1536
+ log.error(`context expansion request has no requested_paths: ${requestId}`);
1537
+ process.exit(1);
332
1538
  }
333
- if (active.length === 0) {
334
- log.info("No active changes in specs/changes/");
335
- } else {
336
- log.info("Active changes:");
337
- for (const id of active) {
338
- const tasksPath = join13(changesDir, id, "tasks.md");
339
- let status = "in-progress";
340
- let pending = 0;
341
- if (existsSync12(tasksPath)) {
342
- const content = readFileSync9(tasksPath, "utf8");
343
- if (content.includes("status: gate-blocked"))
344
- status = "gate-blocked";
345
- else if (content.includes("status: abandoned"))
346
- status = "abandoned";
347
- pending = (content.match(/^\s*-\s*\[ \]/gm) || []).length;
348
- }
349
- const pendingStr = pending > 0 ? ` (${pending} pending)` : "";
350
- log.info(` ${id} [${status}]${pendingStr}`);
1539
+ const next = applyApproval(content, request);
1540
+ writeManifest(changeId, next);
1541
+ log.ok(`approved context expansion ${requestId} for ${changeId}`);
1542
+ for (const path of request.paths)
1543
+ log.info(` ${path}`);
1544
+ }
1545
+ async function approveAllPending(changeId) {
1546
+ let content = readManifest(changeId);
1547
+ const pending = parseRequests(content).filter((r) => r.status === "pending");
1548
+ if (pending.length === 0) {
1549
+ log.info(`no pending context expansion requests for ${changeId}`);
1550
+ return;
1551
+ }
1552
+ const skipped = [];
1553
+ let approvedCount = 0;
1554
+ for (const request of pending) {
1555
+ if (request.paths.length === 0) {
1556
+ skipped.push(`${request.requestId} (no requested_paths)`);
1557
+ continue;
351
1558
  }
1559
+ content = applyApproval(content, request);
1560
+ approvedCount += 1;
1561
+ }
1562
+ writeManifest(changeId, content);
1563
+ log.ok(`approved ${approvedCount} pending context expansion request(s) for ${changeId}`);
1564
+ for (const reason of skipped) {
1565
+ log.warn(` skipped ${reason}`);
352
1566
  }
353
- log.blank();
354
1567
  }
355
- var init_list_changes = __esm({
356
- "src/commands/list-changes.ts"() {
1568
+ async function rejectContextExpansion(changeId, requestId) {
1569
+ const next = setRequestStatus(readManifest(changeId), requestId, "rejected");
1570
+ writeManifest(changeId, next);
1571
+ log.ok(`rejected context expansion ${requestId} for ${changeId}`);
1572
+ }
1573
+ async function rejectAllPending(changeId) {
1574
+ let content = readManifest(changeId);
1575
+ const pending = parseRequests(content).filter((r) => r.status === "pending");
1576
+ if (pending.length === 0) {
1577
+ log.info(`no pending context expansion requests for ${changeId}`);
1578
+ return;
1579
+ }
1580
+ for (const request of pending) {
1581
+ content = setRequestStatus(content, request.requestId, "rejected");
1582
+ }
1583
+ writeManifest(changeId, content);
1584
+ log.ok(`rejected ${pending.length} pending context expansion request(s) for ${changeId}`);
1585
+ }
1586
+ var init_context = __esm({
1587
+ "src/commands/context.ts"() {
357
1588
  "use strict";
358
1589
  init_logger();
359
1590
  }
360
1591
  });
361
1592
 
362
1593
  // src/cli/index.ts
363
- import { readFileSync as readFileSync10 } from "fs";
1594
+ import { readFileSync as readFileSync16 } from "fs";
364
1595
  import { fileURLToPath as fileURLToPath2 } from "url";
365
- import { dirname as dirname3, join as join14 } from "path";
1596
+ import { dirname as dirname5, join as join19 } from "path";
366
1597
  import { Command } from "commander";
367
1598
 
368
1599
  // src/commands/init.ts
1600
+ init_paths();
369
1601
  import { join as join4 } from "path";
370
1602
  import { rmSync, readFileSync as readFileSync2, writeFileSync, existsSync as existsSync3, readdirSync as readdirSync2 } from "fs";
371
1603
 
372
- // src/utils/paths.ts
373
- import { join, dirname } from "path";
374
- import { fileURLToPath } from "url";
375
- import { homedir } from "os";
376
- var __dirname = dirname(fileURLToPath(import.meta.url));
377
- var PACKAGE_ROOT = join(__dirname, "..", "..");
378
- var ASSETS_DIR = join(PACKAGE_ROOT, "assets");
379
- var CLAUDE_HOME = join(homedir(), ".claude");
380
- var AGENTS_HOME = join(CLAUDE_HOME, "agents");
381
- var SKILLS_HOME = join(CLAUDE_HOME, "skills");
382
- var ASSET = {
383
- agents: join(ASSETS_DIR, "agents"),
384
- skills: join(ASSETS_DIR, "skills"),
385
- skill: join(ASSETS_DIR, "skills", "contract-driven-delivery"),
386
- contracts: join(ASSETS_DIR, "contracts"),
387
- specsTemplates: join(ASSETS_DIR, "specs-templates"),
388
- testsTemplates: join(ASSETS_DIR, "tests-templates"),
389
- ci: join(ASSETS_DIR, "ci"),
390
- githubWorkflows: join(ASSETS_DIR, "github-workflows"),
391
- hooks: join(ASSETS_DIR, "hooks"),
392
- claudeTemplate: join(ASSETS_DIR, "CLAUDE.template.md"),
393
- agentsTemplate: join(ASSETS_DIR, "AGENTS.template.md")
394
- };
395
-
396
1604
  // src/utils/copy.ts
397
1605
  init_logger();
398
1606
  import {
@@ -607,8 +1815,14 @@ async function init(opts) {
607
1815
  log.error("--global-only and --local-only are mutually exclusive.");
608
1816
  process.exit(1);
609
1817
  }
1818
+ if (!["claude", "codex", "both"].includes(opts.provider)) {
1819
+ log.error(`Invalid provider: ${opts.provider}. Use claude, codex, or both.`);
1820
+ process.exit(1);
1821
+ }
610
1822
  const cwd = process.cwd();
611
1823
  const createdPaths = [];
1824
+ const installClaude = opts.provider === "claude" || opts.provider === "both";
1825
+ const installCodex = opts.provider === "codex" || opts.provider === "both";
612
1826
  function track(paths) {
613
1827
  createdPaths.push(...paths);
614
1828
  }
@@ -627,7 +1841,7 @@ async function init(opts) {
627
1841
  log.info("Initialising contract-driven-delivery kit\u2026");
628
1842
  log.blank();
629
1843
  try {
630
- if (!opts.localOnly) {
1844
+ if (!opts.localOnly && installClaude) {
631
1845
  log.info(`Installing agents \u2192 ${AGENTS_HOME}`);
632
1846
  const { count: agentCount, created: agentCreated } = copyDirTracked(ASSET.agents, AGENTS_HOME, { overwrite: true });
633
1847
  track(agentCreated);
@@ -643,6 +1857,9 @@ async function init(opts) {
643
1857
  }
644
1858
  log.ok(`${totalSkillFiles} skill file(s) installed (${skillDirs.length} skills).`);
645
1859
  log.blank();
1860
+ } else if (!opts.localOnly && installCodex) {
1861
+ log.info("No global assets for provider: codex.");
1862
+ log.blank();
646
1863
  }
647
1864
  if (!opts.globalOnly) {
648
1865
  log.info(`Scaffolding project files in ${cwd}`);
@@ -674,6 +1891,28 @@ async function init(opts) {
674
1891
  );
675
1892
  track(ciCreated);
676
1893
  log.ok(`ci/ \u2014 ${ciCount} file(s) written.`);
1894
+ const { count: cddConfigCount, created: cddConfigCreated } = copyDirTracked(
1895
+ ASSET.cddConfig,
1896
+ join4(cwd, ".cdd"),
1897
+ { overwrite: opts.force, label: ".cdd" }
1898
+ );
1899
+ track(cddConfigCreated);
1900
+ log.ok(`.cdd/ - ${cddConfigCount} file(s) written.`);
1901
+ const modelPolicyPath = join4(cwd, ".cdd", "model-policy.json");
1902
+ if (existsSync3(modelPolicyPath)) {
1903
+ let existing = {};
1904
+ try {
1905
+ existing = JSON.parse(readFileSync2(modelPolicyPath, "utf8"));
1906
+ } catch {
1907
+ }
1908
+ const merged = {
1909
+ ...existing,
1910
+ provider: opts.provider,
1911
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
1912
+ roles: existing.roles && typeof existing.roles === "object" && Object.keys(existing.roles).length > 0 ? existing.roles : {}
1913
+ };
1914
+ writeFileSync(modelPolicyPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
1915
+ }
677
1916
  const { count: wfCount, created: wfCreated } = copyDirTracked(
678
1917
  ASSET.githubWorkflows,
679
1918
  join4(cwd, ".github", "workflows"),
@@ -717,24 +1956,37 @@ async function init(opts) {
717
1956
  }
718
1957
  }
719
1958
  }
720
- const { written: claudeWritten, created: claudeCreated } = copyFileTracked(
721
- ASSET.claudeTemplate,
722
- join4(cwd, "CLAUDE.md"),
723
- { overwrite: false, label: "CLAUDE.md" }
724
- );
725
- if (claudeCreated)
726
- track([join4(cwd, "CLAUDE.md")]);
727
- if (claudeWritten)
728
- log.ok("CLAUDE.md created.");
729
- const { written: agentsWritten, created: agentsCreated } = copyFileTracked(
730
- ASSET.agentsTemplate,
731
- join4(cwd, "AGENTS.md"),
732
- { overwrite: false, label: "AGENTS.md" }
733
- );
734
- if (agentsCreated)
735
- track([join4(cwd, "AGENTS.md")]);
736
- if (agentsWritten)
737
- log.ok("AGENTS.md created.");
1959
+ if (installClaude) {
1960
+ const { written: claudeWritten, created: claudeCreated } = copyFileTracked(
1961
+ ASSET.claudeTemplate,
1962
+ join4(cwd, "CLAUDE.md"),
1963
+ { overwrite: false, label: "CLAUDE.md" }
1964
+ );
1965
+ if (claudeCreated)
1966
+ track([join4(cwd, "CLAUDE.md")]);
1967
+ if (claudeWritten)
1968
+ log.ok("CLAUDE.md created.");
1969
+ const { written: agentsWritten, created: agentsCreated } = copyFileTracked(
1970
+ ASSET.agentsTemplate,
1971
+ join4(cwd, "AGENTS.md"),
1972
+ { overwrite: false, label: "AGENTS.md" }
1973
+ );
1974
+ if (agentsCreated)
1975
+ track([join4(cwd, "AGENTS.md")]);
1976
+ if (agentsWritten)
1977
+ log.ok("AGENTS.md created.");
1978
+ }
1979
+ if (installCodex) {
1980
+ const { written: codexWritten, created: codexCreated } = copyFileTracked(
1981
+ ASSET.codexTemplate,
1982
+ join4(cwd, "CODEX.md"),
1983
+ { overwrite: false, label: "CODEX.md" }
1984
+ );
1985
+ if (codexCreated)
1986
+ track([join4(cwd, "CODEX.md")]);
1987
+ if (codexWritten)
1988
+ log.ok("CODEX.md created.");
1989
+ }
738
1990
  log.blank();
739
1991
  }
740
1992
  } catch (err) {
@@ -744,33 +1996,39 @@ async function init(opts) {
744
1996
  }
745
1997
  log.ok("Done.");
746
1998
  log.blank();
747
- log.info("Use the contract-driven-delivery skill in Claude Code to scan this repo.");
1999
+ if (opts.provider === "codex") {
2000
+ log.info("Use CODEX.md and cdd-kit commands to run the contract-driven workflow.");
2001
+ } else {
2002
+ log.info("Use the contract-driven-delivery skill in Claude Code to scan this repo.");
2003
+ }
748
2004
  log.blank();
749
2005
  }
750
2006
 
751
2007
  // src/commands/update.ts
752
- import { join as join5 } from "path";
753
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readdirSync as readdirSync3, copyFileSync as copyFileSync2, readFileSync as readFileSync3 } from "fs";
754
- import { createHash } from "crypto";
2008
+ init_paths();
755
2009
  init_logger();
2010
+ init_provider();
2011
+ import { join as join6 } from "path";
2012
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readdirSync as readdirSync3, copyFileSync as copyFileSync2, readFileSync as readFileSync4 } from "fs";
2013
+ import { createHash } from "crypto";
756
2014
  import { homedir as homedir2 } from "os";
757
2015
  function fileHash(filePath) {
758
- const buf = readFileSync3(filePath);
2016
+ const buf = readFileSync4(filePath);
759
2017
  return createHash("sha256").update(buf).digest("hex");
760
2018
  }
761
2019
  function diffDir(src, dest) {
762
2020
  const entries = [];
763
- if (!existsSync4(src))
2021
+ if (!existsSync5(src))
764
2022
  return entries;
765
2023
  function walk(currentSrc, currentDest) {
766
2024
  const items = readdirSync3(currentSrc, { withFileTypes: true });
767
2025
  for (const item of items) {
768
- const srcPath = join5(currentSrc, item.name);
769
- const destPath = join5(currentDest, item.name);
2026
+ const srcPath = join6(currentSrc, item.name);
2027
+ const destPath = join6(currentDest, item.name);
770
2028
  if (item.isDirectory()) {
771
2029
  walk(srcPath, destPath);
772
2030
  } else {
773
- if (!existsSync4(destPath)) {
2031
+ if (!existsSync5(destPath)) {
774
2032
  entries.push({ src: srcPath, dest: destPath, action: "add" });
775
2033
  } else if (fileHash(srcPath) !== fileHash(destPath)) {
776
2034
  entries.push({ src: srcPath, dest: destPath, action: "overwrite" });
@@ -788,21 +2046,21 @@ function applyDir(entries) {
788
2046
  for (const e of entries) {
789
2047
  if (e.action === "skip")
790
2048
  continue;
791
- mkdirSync2(join5(e.dest, ".."), { recursive: true });
2049
+ mkdirSync2(join6(e.dest, ".."), { recursive: true });
792
2050
  copyFileSync2(e.src, e.dest);
793
2051
  count += 1;
794
2052
  }
795
2053
  return count;
796
2054
  }
797
2055
  function backupDir(dir, backupDest) {
798
- if (!existsSync4(dir))
2056
+ if (!existsSync5(dir))
799
2057
  return;
800
2058
  mkdirSync2(backupDest, { recursive: true });
801
2059
  function walk(src, dst) {
802
2060
  const items = readdirSync3(src, { withFileTypes: true });
803
2061
  for (const item of items) {
804
- const s = join5(src, item.name);
805
- const d = join5(dst, item.name);
2062
+ const s = join6(src, item.name);
2063
+ const d = join6(dst, item.name);
806
2064
  if (item.isDirectory()) {
807
2065
  mkdirSync2(d, { recursive: true });
808
2066
  walk(s, d);
@@ -814,15 +2072,29 @@ function backupDir(dir, backupDest) {
814
2072
  }
815
2073
  async function update(opts) {
816
2074
  log.blank();
817
- const skillDest = join5(SKILLS_HOME, "contract-driven-delivery");
818
- const agentDiff = diffDir(ASSET.agents, AGENTS_HOME);
819
- const skillDiff = diffDir(ASSET.skill, skillDest);
2075
+ const cwd = process.cwd();
2076
+ const requestedProvider = opts.provider ?? "auto";
2077
+ if (!validateProviderOption(requestedProvider)) {
2078
+ log.error(`Invalid provider: ${requestedProvider}. Use auto, claude, codex, or both.`);
2079
+ process.exit(1);
2080
+ }
2081
+ const provider = inferProvider(cwd, requestedProvider);
2082
+ const updateClaudeAssets = provider === "claude" || provider === "both";
2083
+ const skillDest = join6(SKILLS_HOME, "contract-driven-delivery");
2084
+ const agentDiff = updateClaudeAssets ? diffDir(ASSET.agents, AGENTS_HOME) : [];
2085
+ const skillDiff = updateClaudeAssets ? diffDir(ASSET.skill, skillDest) : [];
820
2086
  const toWrite = [...agentDiff, ...skillDiff].filter((e) => e.action !== "skip");
821
2087
  const toAdd = toWrite.filter((e) => e.action === "add");
822
2088
  const toOver = toWrite.filter((e) => e.action === "overwrite");
823
2089
  const toSkip = [...agentDiff, ...skillDiff].filter((e) => e.action === "skip");
824
- log.info(`Dry-run diff \u2014 agents: ${AGENTS_HOME}`);
825
- log.info(`Dry-run diff \u2014 skill: ${skillDest}`);
2090
+ log.info(`Provider: ${provider}`);
2091
+ if (updateClaudeAssets) {
2092
+ log.info(`Dry-run diff \u2014 agents: ${AGENTS_HOME}`);
2093
+ log.info(`Dry-run diff \u2014 skill: ${skillDest}`);
2094
+ } else {
2095
+ log.info("Codex provider has no global cdd-kit assets to update.");
2096
+ log.info("Project files are preserved; run cdd-kit init --local-only --provider codex to add missing local guidance.");
2097
+ }
826
2098
  log.blank();
827
2099
  if (toAdd.length)
828
2100
  log.info(` + ${toAdd.length} file(s) would be added`);
@@ -844,19 +2116,21 @@ async function update(opts) {
844
2116
  return;
845
2117
  }
846
2118
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
847
- const backupRoot = join5(homedir2(), ".claude", ".cdd-kit-backup", timestamp);
2119
+ const backupRoot = join6(homedir2(), ".claude", ".cdd-kit-backup", timestamp);
848
2120
  log.blank();
849
2121
  log.info(`Backing up to ${backupRoot} \u2026`);
850
- backupDir(AGENTS_HOME, join5(backupRoot, "agents"));
851
- backupDir(skillDest, join5(backupRoot, "skill"));
2122
+ backupDir(AGENTS_HOME, join6(backupRoot, "agents"));
2123
+ backupDir(skillDest, join6(backupRoot, "skill"));
852
2124
  log.ok(`Backup complete: ${backupRoot}`);
853
2125
  log.blank();
854
- log.info(`Updating agents \u2192 ${AGENTS_HOME}`);
855
- const agentCount = applyDir(agentDiff);
856
- log.ok(`${agentCount} agent file(s) updated.`);
857
- log.info(`Updating skill \u2192 ${skillDest}`);
858
- const skillCount = applyDir(skillDiff);
859
- log.ok(`${skillCount} skill file(s) updated.`);
2126
+ if (updateClaudeAssets) {
2127
+ log.info(`Updating agents \u2192 ${AGENTS_HOME}`);
2128
+ const agentCount = applyDir(agentDiff);
2129
+ log.ok(`${agentCount} agent file(s) updated.`);
2130
+ log.info(`Updating skill \u2192 ${skillDest}`);
2131
+ const skillCount = applyDir(skillDiff);
2132
+ log.ok(`${skillCount} skill file(s) updated.`);
2133
+ }
860
2134
  log.blank();
861
2135
  log.info("Project files (contracts/, specs/, tests/, ci/) were not changed.");
862
2136
  log.ok("Update complete.");
@@ -865,33 +2139,101 @@ async function update(opts) {
865
2139
  }
866
2140
 
867
2141
  // src/commands/new-change.ts
868
- import { join as join6 } from "path";
869
- import { existsSync as existsSync5, readdirSync as readdirSync4 } from "fs";
2142
+ init_paths();
2143
+ import { join as join8 } from "path";
2144
+ import { createHash as createHash3 } from "crypto";
2145
+ import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync5, writeFileSync as writeFileSync3 } from "fs";
870
2146
  init_logger();
2147
+ init_context_scan();
2148
+ function sha256OfFile2(path) {
2149
+ try {
2150
+ return createHash3("sha256").update(readFileSync6(path)).digest("hex");
2151
+ } catch {
2152
+ return "";
2153
+ }
2154
+ }
2155
+ function inputsDigest2(paths) {
2156
+ const combined = paths.slice().sort().map((p) => `${p}:${sha256OfFile2(p)}`).join("\n");
2157
+ return createHash3("sha256").update(combined).digest("hex");
2158
+ }
2159
+ function findContractFiles2(dir, found = []) {
2160
+ if (!existsSync7(dir))
2161
+ return found;
2162
+ for (const entry of readdirSync5(dir, { withFileTypes: true })) {
2163
+ const fullPath = join8(dir, entry.name);
2164
+ if (entry.isDirectory())
2165
+ findContractFiles2(fullPath, found);
2166
+ else if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "INDEX.md" && entry.name !== "CHANGELOG.md") {
2167
+ found.push(fullPath);
2168
+ }
2169
+ }
2170
+ return found;
2171
+ }
2172
+ function readIndexDigest(filePath) {
2173
+ if (!existsSync7(filePath))
2174
+ return null;
2175
+ const m = readFileSync6(filePath, "utf8").match(/^inputs-digest:\s*([a-f0-9]+)/m);
2176
+ return m ? m[1] : null;
2177
+ }
2178
+ async function ensureFreshContextIndexes(cwd) {
2179
+ const projectMap = join8(cwd, "specs", "context", "project-map.md");
2180
+ const contractsIndex = join8(cwd, "specs", "context", "contracts-index.md");
2181
+ const policyPath = join8(cwd, ".cdd", "context-policy.json");
2182
+ const policyInputs = [policyPath].filter(existsSync7);
2183
+ const contractFiles = findContractFiles2(join8(cwd, "contracts"));
2184
+ const wantProjectDigest = inputsDigest2(policyInputs);
2185
+ const wantContractsDigest = inputsDigest2(contractFiles);
2186
+ const haveProjectDigest = readIndexDigest(projectMap);
2187
+ const haveContractsDigest = readIndexDigest(contractsIndex);
2188
+ const needsScan = !existsSync7(projectMap) || !existsSync7(contractsIndex) || haveProjectDigest !== wantProjectDigest || haveContractsDigest !== wantContractsDigest;
2189
+ if (!needsScan)
2190
+ return;
2191
+ log.info("context indexes missing or stale \u2014 running cdd-kit context-scan\u2026");
2192
+ await contextScan();
2193
+ log.dim(" (skip with --skip-scan)");
2194
+ }
871
2195
  var REQUIRED_TEMPLATES = [
872
2196
  "change-request.md",
873
2197
  "change-classification.md",
874
2198
  "test-plan.md",
875
2199
  "ci-gates.md",
876
- "tasks.md"
2200
+ "tasks.md",
2201
+ "context-manifest.md"
877
2202
  ];
878
2203
  function listOptional() {
879
2204
  try {
880
- const all = readdirSync4(ASSET.specsTemplates).filter((f) => f.endsWith(".md"));
2205
+ const all = readdirSync5(ASSET.specsTemplates).filter((f) => f.endsWith(".md"));
881
2206
  return all.filter((f) => !REQUIRED_TEMPLATES.includes(f));
882
2207
  } catch {
883
2208
  return [];
884
2209
  }
885
2210
  }
886
2211
  var SAFE_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
2212
+ function parseDependsOn(raw) {
2213
+ if (!raw)
2214
+ return [];
2215
+ return raw.split(",").map((item) => item.trim()).filter(Boolean);
2216
+ }
2217
+ function formatDependsOn(ids) {
2218
+ if (ids.length === 0)
2219
+ return "depends-on: []";
2220
+ return `depends-on: [${ids.join(", ")}]`;
2221
+ }
887
2222
  async function newChange(name, opts) {
888
2223
  if (!SAFE_NAME.test(name)) {
889
2224
  log.error(`Invalid change name: "${name}". Use letters, numbers, hyphens, or underscores (max 64 chars).`);
890
2225
  process.exit(1);
891
2226
  }
2227
+ const dependencies = parseDependsOn(opts.dependsOn);
2228
+ for (const dep of dependencies) {
2229
+ if (!SAFE_NAME.test(dep)) {
2230
+ log.error(`Invalid dependency name: "${dep}". Use letters, numbers, hyphens, or underscores (max 64 chars).`);
2231
+ process.exit(1);
2232
+ }
2233
+ }
892
2234
  const cwd = process.cwd();
893
- const changeDir = join6(cwd, "specs", "changes", name);
894
- if (existsSync5(changeDir)) {
2235
+ const changeDir = join8(cwd, "specs", "changes", name);
2236
+ if (existsSync7(changeDir)) {
895
2237
  if (opts.force) {
896
2238
  log.warn(`Forcing re-scaffold of existing change directory: ${changeDir}`);
897
2239
  log.warn("Existing files will NOT be deleted; only template files will be overwritten.");
@@ -901,15 +2243,22 @@ async function newChange(name, opts) {
901
2243
  return;
902
2244
  }
903
2245
  }
2246
+ if (!opts.skipScan) {
2247
+ try {
2248
+ await ensureFreshContextIndexes(cwd);
2249
+ } catch (err) {
2250
+ log.warn(`context-scan failed: ${err.message}; continuing without fresh indexes`);
2251
+ }
2252
+ }
904
2253
  log.blank();
905
2254
  log.info(`Creating change scaffold: specs/changes/${name}`);
906
2255
  ensureDir(changeDir);
907
2256
  const templates = opts.all ? [...REQUIRED_TEMPLATES, ...listOptional()] : [...REQUIRED_TEMPLATES];
908
2257
  let written = 0;
909
2258
  for (const tmpl of templates) {
910
- const src = join6(ASSET.specsTemplates, tmpl);
911
- const dest = join6(changeDir, tmpl);
912
- if (!existsSync5(src)) {
2259
+ const src = join8(ASSET.specsTemplates, tmpl);
2260
+ const dest = join8(changeDir, tmpl);
2261
+ if (!existsSync7(src)) {
913
2262
  log.warn(`Template not found, skipping: ${tmpl}`);
914
2263
  continue;
915
2264
  }
@@ -917,16 +2266,26 @@ async function newChange(name, opts) {
917
2266
  log.dim(tmpl);
918
2267
  written += 1;
919
2268
  }
2269
+ if (dependencies.length > 0) {
2270
+ const tasksPath = join8(changeDir, "tasks.md");
2271
+ if (existsSync7(tasksPath)) {
2272
+ const tasks = readFileSync6(tasksPath, "utf8");
2273
+ const nextTasks = tasks.replace(/^depends-on:\s*.*$/m, formatDependsOn(dependencies));
2274
+ writeFileSync3(tasksPath, nextTasks, "utf8");
2275
+ log.dim(`depends-on: ${dependencies.join(", ")}`);
2276
+ }
2277
+ }
920
2278
  log.blank();
921
2279
  log.ok(`${written} template(s) created in specs/changes/${name}`);
922
2280
  log.blank();
923
2281
  }
924
2282
 
925
2283
  // src/commands/validate.ts
926
- import { join as join7 } from "path";
927
- import { existsSync as existsSync6 } from "fs";
928
- import { spawnSync } from "child_process";
2284
+ init_paths();
929
2285
  init_logger();
2286
+ import { join as join9 } from "path";
2287
+ import { existsSync as existsSync8 } from "fs";
2288
+ import { spawnSync } from "child_process";
930
2289
  var VALIDATORS = [
931
2290
  {
932
2291
  flag: "contracts",
@@ -958,15 +2317,15 @@ async function validate(opts) {
958
2317
  log.error(e instanceof Error ? e.message : String(e));
959
2318
  process.exit(1);
960
2319
  }
961
- const scriptsDir = join7(ASSET.skill, "scripts");
2320
+ const scriptsDir = join9(ASSET.skill, "scripts");
962
2321
  const runAll = !opts.contracts && !opts.env && !opts.ci && !opts.spec && !opts.versions;
963
2322
  log.blank();
964
2323
  let failed = false;
965
2324
  for (const v of VALIDATORS) {
966
2325
  if (!runAll && !opts[v.flag])
967
2326
  continue;
968
- const scriptPath = join7(scriptsDir, v.script);
969
- if (!existsSync6(scriptPath)) {
2327
+ const scriptPath = join9(scriptsDir, v.script);
2328
+ if (!existsSync8(scriptPath)) {
970
2329
  log.warn(`${v.label}: script not found, skipping (${v.script})`);
971
2330
  log.blank();
972
2331
  continue;
@@ -982,8 +2341,8 @@ async function validate(opts) {
982
2341
  log.blank();
983
2342
  if (v.chain) {
984
2343
  for (const chained of v.chain) {
985
- const chainedPath = join7(scriptsDir, chained.script);
986
- if (!existsSync6(chainedPath)) {
2344
+ const chainedPath = join9(scriptsDir, chained.script);
2345
+ if (!existsSync8(chainedPath)) {
987
2346
  log.warn(`${chained.label}: script not found, skipping (${chained.script})`);
988
2347
  log.blank();
989
2348
  continue;
@@ -1011,15 +2370,15 @@ async function validate(opts) {
1011
2370
 
1012
2371
  // src/commands/gate.ts
1013
2372
  init_logger();
1014
- import { existsSync as existsSync7, readFileSync as readFileSync4, readdirSync as readdirSync5 } from "fs";
1015
- import { join as join8 } from "path";
1016
- import { spawnSync as spawnSync2 } from "child_process";
2373
+ import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync6 } from "fs";
2374
+ import { join as join10 } from "path";
1017
2375
  var REQUIRED_FILES = [
1018
2376
  "change-request.md",
1019
2377
  "change-classification.md",
1020
2378
  "test-plan.md",
1021
2379
  "ci-gates.md",
1022
- "tasks.md"
2380
+ "tasks.md",
2381
+ "context-manifest.md"
1023
2382
  ];
1024
2383
  var TIER_PATTERN = /\b(tier\s*[0-5]|low|medium|high|critical)\b/i;
1025
2384
  var MIN_CHARS = {
@@ -1027,46 +2386,426 @@ var MIN_CHARS = {
1027
2386
  "test-plan.md": 200,
1028
2387
  "ci-gates.md": 150,
1029
2388
  "change-request.md": 100,
1030
- "tasks.md": 100
2389
+ "tasks.md": 100,
2390
+ "context-manifest.md": 50
1031
2391
  };
1032
2392
  function meaningfulChars(text) {
1033
2393
  return text.split("\n").map((l) => l.trim()).filter((l) => l).filter((l) => !l.startsWith("#")).filter((l) => !/^[|\s\-:]+$/.test(l)).filter((l) => !l.startsWith("<!--")).join("").length;
1034
2394
  }
2395
+ function stripHtmlComments(text) {
2396
+ return text.replace(/<!--[\s\S]*?-->/g, "");
2397
+ }
2398
+ function pathMatches(relPath, patterns, currentChangeId) {
2399
+ const normalized = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
2400
+ return patterns.some((rawPattern) => {
2401
+ const pattern = rawPattern.replace(/\\/g, "/").replace(/^\.\//, "");
2402
+ if (pattern === "specs/changes/*" && currentChangeId) {
2403
+ const current = `specs/changes/${currentChangeId}`;
2404
+ if (normalized === current || normalized.startsWith(`${current}/`))
2405
+ return false;
2406
+ return normalized.startsWith("specs/changes/");
2407
+ }
2408
+ if (pattern.endsWith("/**")) {
2409
+ const base = pattern.slice(0, -3);
2410
+ return normalized === base || normalized.startsWith(`${base}/`);
2411
+ }
2412
+ if (pattern.endsWith("/*")) {
2413
+ const base = pattern.slice(0, -2);
2414
+ if (!normalized.startsWith(`${base}/`))
2415
+ return false;
2416
+ return !normalized.slice(base.length + 1).includes("/");
2417
+ }
2418
+ return normalized === pattern || normalized.startsWith(`${pattern}/`);
2419
+ });
2420
+ }
2421
+ function parseListSection(content, heading) {
2422
+ const clean = stripHtmlComments(content);
2423
+ const match = clean.match(new RegExp(`## ${heading}\\s*\\n([\\s\\S]*?)(?:\\n## |$)`));
2424
+ if (!match)
2425
+ return [];
2426
+ return match[1].split(/\r?\n/).map((line) => line.replace(/^\s*-\s*/, "").trim()).filter((item) => item && item !== "-" && item.toLowerCase() !== "none");
2427
+ }
2428
+ function parseContextManifest(content) {
2429
+ const clean = stripHtmlComments(content);
2430
+ const requestMatch = clean.match(/## Context Expansion Requests\s*\n([\s\S]*?)(?:\n## |$)/);
2431
+ const pendingExpansions = requestMatch ? (requestMatch[1].match(/^\s*-\s*status:\s*pending\b/gim) || []).length : 0;
2432
+ return {
2433
+ allowedPaths: parseListSection(content, "Allowed Paths"),
2434
+ approvedExpansions: parseListSection(content, "Approved Expansions"),
2435
+ pendingExpansions
2436
+ };
2437
+ }
2438
+ function loadContextPolicy(cwd) {
2439
+ const defaults = {
2440
+ forbiddenPaths: [
2441
+ ".claude/worktrees/**",
2442
+ ".git/**",
2443
+ "node_modules/**",
2444
+ "dist/**",
2445
+ "build/**",
2446
+ "assets/**",
2447
+ "specs/archive/**",
2448
+ "specs/changes/*"
2449
+ ],
2450
+ audit: {
2451
+ requireFilesRead: true,
2452
+ unknownFilesRead: "warn-for-legacy-fail-for-new"
2453
+ }
2454
+ };
2455
+ const policyPath = join10(cwd, ".cdd", "context-policy.json");
2456
+ if (!existsSync9(policyPath))
2457
+ return defaults;
2458
+ try {
2459
+ const custom = JSON.parse(readFileSync7(policyPath, "utf8"));
2460
+ return {
2461
+ ...defaults,
2462
+ ...custom,
2463
+ forbiddenPaths: Array.from(/* @__PURE__ */ new Set([...defaults.forbiddenPaths, ...custom.forbiddenPaths ?? []])),
2464
+ audit: { ...defaults.audit, ...custom.audit ?? {} }
2465
+ };
2466
+ } catch {
2467
+ log.warn("could not parse .cdd/context-policy.json; using default context policy");
2468
+ return defaults;
2469
+ }
2470
+ }
2471
+ function isContextGovernedChange(changeDir) {
2472
+ const tasksPath = join10(changeDir, "tasks.md");
2473
+ if (!existsSync9(tasksPath))
2474
+ return false;
2475
+ return /^context-governance:\s*v1\b/m.test(readFileSync7(tasksPath, "utf8"));
2476
+ }
2477
+ var KNOWN_FRONTMATTER_KEYS = /* @__PURE__ */ new Set([
2478
+ "change-id",
2479
+ "status",
2480
+ "tier",
2481
+ "archive-tasks",
2482
+ "context-governance",
2483
+ "depends-on",
2484
+ // Allowed but informational only:
2485
+ "token-budget",
2486
+ "created",
2487
+ "completed"
2488
+ ]);
2489
+ var VALID_TASK_STATUSES = /* @__PURE__ */ new Set(["in-progress", "completed", "complete", "done", "gate-blocked", "abandoned", "needs-review"]);
2490
+ function lintFrontmatter(content, errors, warnings) {
2491
+ const fm = parseTaskFrontmatter(content);
2492
+ if (!fm["change-id"]) {
2493
+ errors.push("tasks.md frontmatter: missing required `change-id`");
2494
+ }
2495
+ if (!fm.status) {
2496
+ errors.push("tasks.md frontmatter: missing required `status`");
2497
+ } else if (!VALID_TASK_STATUSES.has(fm.status.toLowerCase())) {
2498
+ errors.push(`tasks.md frontmatter: invalid status \`${fm.status}\` (expected one of: ${[...VALID_TASK_STATUSES].join(", ")})`);
2499
+ }
2500
+ if (fm.tier !== void 0 && fm.tier !== "") {
2501
+ const n = parseInt(fm.tier, 10);
2502
+ if (Number.isNaN(n) || n < 0 || n > 5) {
2503
+ errors.push(`tasks.md frontmatter: invalid tier \`${fm.tier}\` (expected 0-5)`);
2504
+ }
2505
+ }
2506
+ for (const key of Object.keys(fm)) {
2507
+ if (!KNOWN_FRONTMATTER_KEYS.has(key)) {
2508
+ const lower = key.toLowerCase();
2509
+ const suggestion = KNOWN_FRONTMATTER_KEYS.has(lower) ? ` (did you mean \`${lower}\`?)` : "";
2510
+ warnings.push(`tasks.md frontmatter: unknown key \`${key}\`${suggestion}`);
2511
+ }
2512
+ }
2513
+ }
2514
+ function parseTaskFrontmatter(content) {
2515
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2516
+ if (!match)
2517
+ return {};
2518
+ const out = {};
2519
+ for (const line of match[1].split(/\r?\n/)) {
2520
+ const colon = line.indexOf(":");
2521
+ if (colon === -1)
2522
+ continue;
2523
+ const key = line.slice(0, colon).trim();
2524
+ if (!key)
2525
+ continue;
2526
+ out[key] = line.slice(colon + 1).trim();
2527
+ }
2528
+ return out;
2529
+ }
2530
+ function parseListField(raw) {
2531
+ if (!raw)
2532
+ return [];
2533
+ const trimmed = raw.trim();
2534
+ if (!trimmed || trimmed === "[]")
2535
+ return [];
2536
+ const inner = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
2537
+ return inner.split(",").map((item) => item.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
2538
+ }
2539
+ function parseDependsOn2(content) {
2540
+ return parseListField(parseTaskFrontmatter(content)["depends-on"]);
2541
+ }
2542
+ function parseTaskStatus(content) {
2543
+ const fm = parseTaskFrontmatter(content);
2544
+ return (fm.status ?? "in-progress").toLowerCase();
2545
+ }
2546
+ function resolveTier(changeDir) {
2547
+ const classifPath = join10(changeDir, "change-classification.md");
2548
+ const classificationPresent = existsSync9(classifPath);
2549
+ const classificationText = classificationPresent ? readFileSync7(classifPath, "utf8") : "";
2550
+ const classificationHasLooseMarker = classificationPresent && TIER_PATTERN.test(classificationText);
2551
+ const tasksPath = join10(changeDir, "tasks.md");
2552
+ if (existsSync9(tasksPath)) {
2553
+ const fm = parseTaskFrontmatter(readFileSync7(tasksPath, "utf8"));
2554
+ const raw = fm.tier;
2555
+ if (raw && raw !== "") {
2556
+ const n = parseInt(raw, 10);
2557
+ if (!Number.isNaN(n) && n >= 0 && n <= 5) {
2558
+ return { tier: n, source: "tasks-frontmatter", classificationPresent, classificationHasLooseMarker };
2559
+ }
2560
+ }
2561
+ }
2562
+ if (classificationPresent) {
2563
+ const structured = classificationText.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
2564
+ if (structured) {
2565
+ const n = parseInt(structured[1], 10);
2566
+ if (!Number.isNaN(n) && n >= 0 && n <= 5) {
2567
+ return { tier: n, source: "classification-structured", classificationPresent, classificationHasLooseMarker };
2568
+ }
2569
+ }
2570
+ const bold = classificationText.match(/\*\*Tier:\*\*\s*Tier\s*(\d)\b/i);
2571
+ if (bold) {
2572
+ const n = parseInt(bold[1], 10);
2573
+ if (!Number.isNaN(n) && n >= 0 && n <= 5) {
2574
+ return { tier: n, source: "classification-bold", classificationPresent, classificationHasLooseMarker };
2575
+ }
2576
+ }
2577
+ }
2578
+ return { tier: null, source: "none", classificationPresent, classificationHasLooseMarker };
2579
+ }
2580
+ var DEFAULT_ARCHIVE_TASKS = ["7.1", "7.2"];
2581
+ function getArchiveTaskIds(content) {
2582
+ const fm = parseTaskFrontmatter(content);
2583
+ const parsed = parseListField(fm["archive-tasks"]);
2584
+ return parsed.length > 0 ? parsed : DEFAULT_ARCHIVE_TASKS;
2585
+ }
2586
+ function enforceTierRequirements(changeDir, agentLogDir, errors, warnings) {
2587
+ const resolution = resolveTier(changeDir);
2588
+ if (resolution.tier === null) {
2589
+ if (resolution.classificationPresent && !resolution.classificationHasLooseMarker) {
2590
+ errors.push(
2591
+ "change-classification.md: missing tier marker. Set `tier: <0-5>` in tasks.md frontmatter (preferred) or include `## Tier\\n- N` in change-classification.md."
2592
+ );
2593
+ }
2594
+ return;
2595
+ }
2596
+ if (resolution.source === "classification-bold") {
2597
+ warnings.push(
2598
+ "tier marker is bold-text only (legacy format); set `tier: <0-5>` in tasks.md frontmatter so tier-specific agent requirements are enforced."
2599
+ );
2600
+ return;
2601
+ }
2602
+ const tier = resolution.tier;
2603
+ const agentLogFiles = agentLogDir && existsSync9(agentLogDir) ? readdirSync6(agentLogDir).map((f) => f.replace(".md", "")) : [];
2604
+ if (tier <= 1) {
2605
+ for (const required of ["e2e-resilience-engineer", "monkey-test-engineer", "stress-soak-engineer"]) {
2606
+ if (!agentLogFiles.includes(required)) {
2607
+ errors.push(`Tier ${tier} change requires agent-log/${required}.md (high-risk change \u2014 E2E/monkey/stress testing mandatory)`);
2608
+ }
2609
+ }
2610
+ }
2611
+ if (tier <= 3) {
2612
+ for (const required of ["contract-reviewer", "qa-reviewer"]) {
2613
+ if (!agentLogFiles.includes(required)) {
2614
+ errors.push(`Tier ${tier} change requires agent-log/${required}.md`);
2615
+ }
2616
+ }
2617
+ }
2618
+ if (resolution.source === "tasks-frontmatter" && resolution.classificationPresent) {
2619
+ const text = readFileSync7(join10(changeDir, "change-classification.md"), "utf8");
2620
+ const structured = text.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
2621
+ const bold = text.match(/\*\*Tier:\*\*\s*Tier\s*(\d)\b/i);
2622
+ const classifTier = structured ? parseInt(structured[1], 10) : bold ? parseInt(bold[1], 10) : NaN;
2623
+ if (!Number.isNaN(classifTier) && classifTier !== tier) {
2624
+ warnings.push(
2625
+ `tier mismatch: tasks.md frontmatter says ${tier}, change-classification.md says ${classifTier} (frontmatter wins; reconcile classification).`
2626
+ );
2627
+ }
2628
+ }
2629
+ }
2630
+ function isArchivedChange(cwd, changeId) {
2631
+ const archiveRoot = join10(cwd, "specs", "archive");
2632
+ if (!existsSync9(archiveRoot))
2633
+ return false;
2634
+ const years = readdirSync6(archiveRoot, { withFileTypes: true }).filter((d) => d.isDirectory());
2635
+ return years.some((year) => existsSync9(join10(archiveRoot, year.name, changeId)));
2636
+ }
2637
+ function detectDependencyCycle(cwd, startChangeId) {
2638
+ const visited = /* @__PURE__ */ new Set();
2639
+ const stack = [];
2640
+ function visit(id) {
2641
+ if (stack.includes(id)) {
2642
+ return [...stack.slice(stack.indexOf(id)), id];
2643
+ }
2644
+ if (visited.has(id))
2645
+ return null;
2646
+ visited.add(id);
2647
+ stack.push(id);
2648
+ const tasksPath = join10(cwd, "specs", "changes", id, "tasks.md");
2649
+ if (existsSync9(tasksPath)) {
2650
+ const deps = parseDependsOn2(readFileSync7(tasksPath, "utf8"));
2651
+ for (const dep of deps) {
2652
+ const found = visit(dep);
2653
+ if (found)
2654
+ return found;
2655
+ }
2656
+ }
2657
+ stack.pop();
2658
+ return null;
2659
+ }
2660
+ return visit(startChangeId);
2661
+ }
2662
+ function validateDependencies(cwd, changeId, changeDir) {
2663
+ const tasksPath = join10(changeDir, "tasks.md");
2664
+ if (!existsSync9(tasksPath))
2665
+ return [];
2666
+ const dependencies = parseDependsOn2(readFileSync7(tasksPath, "utf8"));
2667
+ const errors = [];
2668
+ const cycle = detectDependencyCycle(cwd, changeId);
2669
+ if (cycle) {
2670
+ errors.push(`depends-on cycle detected: ${cycle.join(" \u2192 ")}`);
2671
+ }
2672
+ for (const dep of dependencies) {
2673
+ if (dep === changeId) {
2674
+ errors.push(`tasks.md: change cannot depend on itself (${dep})`);
2675
+ continue;
2676
+ }
2677
+ const upstreamDir = join10(cwd, "specs", "changes", dep);
2678
+ if (existsSync9(upstreamDir)) {
2679
+ const upstreamTasks = join10(upstreamDir, "tasks.md");
2680
+ if (!existsSync9(upstreamTasks)) {
2681
+ errors.push(`dependency ${dep}: missing tasks.md`);
2682
+ continue;
2683
+ }
2684
+ const status = parseTaskStatus(readFileSync7(upstreamTasks, "utf8"));
2685
+ if (!["complete", "completed", "done"].includes(status)) {
2686
+ errors.push(`dependency ${dep}: upstream change is not completed (status: ${status})`);
2687
+ }
2688
+ continue;
2689
+ }
2690
+ if (!isArchivedChange(cwd, dep)) {
2691
+ errors.push(`dependency ${dep}: upstream change not found in specs/changes/ or specs/archive/`);
2692
+ }
2693
+ }
2694
+ return errors;
2695
+ }
2696
+ function parseFilesRead(content) {
2697
+ const clean = stripHtmlComments(content);
2698
+ const allLines = clean.split(/\r?\n/);
2699
+ const startIndex = allLines.findIndex((line) => /^\s*-\s*files-read:\s*$/.test(line));
2700
+ if (startIndex === -1)
2701
+ return { present: false, files: [], errors: [] };
2702
+ const files = [];
2703
+ const errors = [];
2704
+ const lines = [];
2705
+ for (let i = startIndex + 1; i < allLines.length; i++) {
2706
+ const line = allLines[i];
2707
+ if (/^-\s*[a-zA-Z][\w-]*:\s*/.test(line) || /^#/.test(line))
2708
+ break;
2709
+ lines.push(line);
2710
+ }
2711
+ for (const rawLine of lines) {
2712
+ if (!rawLine.trim())
2713
+ continue;
2714
+ const itemMatch = rawLine.match(/^\s{2,}-\s+(.+?)\s*$/);
2715
+ if (!itemMatch) {
2716
+ errors.push(`invalid files-read entry format: ${rawLine.trim()}`);
2717
+ continue;
2718
+ }
2719
+ const item = itemMatch[1].trim();
2720
+ if (!item || item === "-" || item.toLowerCase() === "none" || item.toLowerCase() === "unknown") {
2721
+ continue;
2722
+ }
2723
+ const normalized = item.replace(/\\/g, "/").replace(/^\.\//, "");
2724
+ if (/^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("/")) {
2725
+ errors.push(`files-read path must be repo-relative: ${item}`);
2726
+ continue;
2727
+ }
2728
+ if (normalized.split("/").includes("..")) {
2729
+ errors.push(`files-read path must not contain "..": ${item}`);
2730
+ continue;
2731
+ }
2732
+ files.push(normalized);
2733
+ }
2734
+ if (files.length === 0 && errors.length === 0) {
2735
+ errors.push("files-read section must list repo-relative paths or omit the section for legacy changes");
2736
+ }
2737
+ return { present: true, files, errors };
2738
+ }
1035
2739
  async function gate(changeId, opts = {}) {
1036
2740
  const strict = opts.strict ?? false;
2741
+ const lax = opts.lax ?? false;
1037
2742
  const cwd = process.cwd();
1038
- const changeDir = join8(cwd, "specs", "changes", changeId);
1039
- if (!existsSync7(changeDir)) {
2743
+ const changeDir = join10(cwd, "specs", "changes", changeId);
2744
+ if (!existsSync9(changeDir)) {
1040
2745
  log.error(`change not found: ${changeId} (looked in ${changeDir})`);
1041
2746
  process.exit(1);
1042
2747
  }
1043
2748
  const errors = [];
1044
2749
  const warnings = [];
2750
+ const contextPolicy = loadContextPolicy(cwd);
2751
+ const isNewChange = isContextGovernedChange(changeDir);
2752
+ const manifestPath = join10(changeDir, "context-manifest.md");
2753
+ const hasManifest = existsSync9(manifestPath);
2754
+ let allowedPaths = [];
2755
+ let approvedExpansions = [];
2756
+ errors.push(...validateDependencies(cwd, changeId, changeDir));
2757
+ if (hasManifest) {
2758
+ const manifest = parseContextManifest(readFileSync7(manifestPath, "utf8"));
2759
+ allowedPaths = manifest.allowedPaths;
2760
+ approvedExpansions = manifest.approvedExpansions;
2761
+ if (manifest.pendingExpansions > 0) {
2762
+ errors.push(`context-manifest.md: has ${manifest.pendingExpansions} pending context expansion request(s)`);
2763
+ }
2764
+ }
1045
2765
  for (const f of REQUIRED_FILES) {
1046
- if (!existsSync7(join8(changeDir, f))) {
2766
+ if (f === "context-manifest.md") {
2767
+ if (!hasManifest) {
2768
+ if (isNewChange || strict) {
2769
+ errors.push("missing required artifact: context-manifest.md");
2770
+ } else {
2771
+ warnings.push("missing context-manifest.md (legacy change; run cdd-kit migrate after upgrading)");
2772
+ }
2773
+ }
2774
+ continue;
2775
+ }
2776
+ if (!existsSync9(join10(changeDir, f))) {
1047
2777
  errors.push(`missing required artifact: ${f}`);
1048
2778
  }
1049
2779
  }
1050
2780
  if (errors.length === 0) {
1051
2781
  for (const f of REQUIRED_FILES) {
1052
- const content = readFileSync4(join8(changeDir, f), "utf8");
2782
+ if (f === "context-manifest.md" && !hasManifest)
2783
+ continue;
2784
+ const content = readFileSync7(join10(changeDir, f), "utf8");
1053
2785
  const minChars = MIN_CHARS[f] ?? 100;
1054
2786
  if (meaningfulChars(content) < minChars) {
1055
2787
  errors.push(`${f}: appears to be a stub (< ${minChars} meaningful chars)`);
1056
2788
  }
1057
2789
  }
1058
- const classifPath = join8(changeDir, "change-classification.md");
1059
- if (existsSync7(classifPath)) {
1060
- const text = readFileSync4(classifPath, "utf8");
1061
- if (!TIER_PATTERN.test(text)) {
1062
- errors.push("change-classification.md: missing tier/risk marker (Tier 0-5 or low/medium/high/critical)");
1063
- }
2790
+ const classifPath = join10(changeDir, "change-classification.md");
2791
+ const tierResolution = resolveTier(changeDir);
2792
+ if (tierResolution.tier === null && existsSync9(classifPath) && !tierResolution.classificationHasLooseMarker) {
2793
+ errors.push("change-classification.md: missing tier/risk marker (set tier in tasks.md frontmatter, or include Tier 0-5 / low|medium|high|critical in change-classification.md)");
1064
2794
  }
1065
2795
  }
1066
- const tasksPath = join8(changeDir, "tasks.md");
1067
- if (existsSync7(tasksPath)) {
1068
- const tasksContent = readFileSync4(tasksPath, "utf8");
1069
- const nonArchivePending = (tasksContent.match(/^\s*-\s*\[ \] (?!7\.[12])/gm) || []).length;
2796
+ const tasksPath = join10(changeDir, "tasks.md");
2797
+ if (existsSync9(tasksPath)) {
2798
+ const tasksContent = readFileSync7(tasksPath, "utf8");
2799
+ lintFrontmatter(tasksContent, errors, warnings);
2800
+ const archiveTaskIds = new Set(getArchiveTaskIds(tasksContent));
2801
+ const pendingMatches = tasksContent.match(/^\s*-\s*\[ \]\s+([\d.]+)?[^\n]*/gm) || [];
2802
+ const nonArchivePending = pendingMatches.filter((line) => {
2803
+ const idMatch = line.match(/\[ \]\s+([\d.]+)/);
2804
+ const id = idMatch?.[1];
2805
+ if (!id)
2806
+ return true;
2807
+ return !archiveTaskIds.has(id);
2808
+ }).length;
1070
2809
  if (nonArchivePending > 0) {
1071
2810
  if (strict) {
1072
2811
  errors.push(`${nonArchivePending} task(s) still pending (use [-] for N/A items, [x] for done). Run gate without --strict during development.`);
@@ -1075,11 +2814,55 @@ async function gate(changeId, opts = {}) {
1075
2814
  }
1076
2815
  }
1077
2816
  }
1078
- const agentLogDir = join8(changeDir, "agent-log");
1079
- if (existsSync7(agentLogDir)) {
1080
- const logFiles = readdirSync5(agentLogDir).filter((f) => f.endsWith(".md"));
2817
+ const agentLogDir = join10(changeDir, "agent-log");
2818
+ if (existsSync9(agentLogDir)) {
2819
+ const logFiles = readdirSync6(agentLogDir).filter((f) => f.endsWith(".md"));
1081
2820
  for (const f of logFiles) {
1082
- const content = readFileSync4(join8(agentLogDir, f), "utf8");
2821
+ const content = readFileSync7(join10(agentLogDir, f), "utf8");
2822
+ const filesRead = parseFilesRead(content);
2823
+ if (!filesRead.present) {
2824
+ if (contextPolicy.audit.requireFilesRead) {
2825
+ const msg = `agent-log/${f}: missing "- files-read:" section`;
2826
+ if (isNewChange || strict || contextPolicy.audit.unknownFilesRead !== "warn-for-legacy-fail-for-new") {
2827
+ errors.push(msg);
2828
+ } else {
2829
+ warnings.push(`${msg} (legacy warning only)`);
2830
+ }
2831
+ }
2832
+ } else {
2833
+ for (const parseError of filesRead.errors) {
2834
+ errors.push(`agent-log/${f}: ${parseError}`);
2835
+ }
2836
+ for (const pathRead of filesRead.files) {
2837
+ if (pathMatches(pathRead, contextPolicy.forbiddenPaths, changeId)) {
2838
+ errors.push(`agent-log/${f}: read forbidden path -> ${pathRead}`);
2839
+ }
2840
+ if (hasManifest && allowedPaths.length > 0 && !pathMatches(pathRead, allowedPaths) && !pathMatches(pathRead, approvedExpansions)) {
2841
+ errors.push(`agent-log/${f}: read unauthorized path -> ${pathRead} (not in allowed paths or approved expansions)`);
2842
+ }
2843
+ }
2844
+ const runtimeLog = join10(cwd, ".cdd", "runtime", `${changeId}-files-read.jsonl`);
2845
+ if (existsSync9(runtimeLog)) {
2846
+ const runtimePaths = readFileSync7(runtimeLog, "utf8").split("\n").filter(Boolean).map((line) => {
2847
+ try {
2848
+ return JSON.parse(line).path;
2849
+ } catch {
2850
+ return void 0;
2851
+ }
2852
+ }).filter((p) => Boolean(p)).map((p) => p.replace(/\\/g, "/").replace(/^\.\//, ""));
2853
+ const declared = new Set(filesRead.files);
2854
+ const undeclared = runtimePaths.filter((p) => !declared.has(p));
2855
+ if (undeclared.length > 0) {
2856
+ const sample = undeclared.slice(0, 5).join(", ");
2857
+ const more = undeclared.length > 5 ? ` (+${undeclared.length - 5} more)` : "";
2858
+ const msg = `agent-log/${f}: runtime log shows ${undeclared.length} read(s) not declared in files-read: ${sample}${more}`;
2859
+ if (strict)
2860
+ errors.push(msg);
2861
+ else
2862
+ warnings.push(msg);
2863
+ }
2864
+ }
2865
+ }
1083
2866
  const statusMatch = content.match(/^\s*-\s*status:\s*(complete|needs-review|blocked)\s*$/m);
1084
2867
  if (!statusMatch) {
1085
2868
  errors.push(`agent-log/${f}: missing or invalid "status:" line (must be complete | needs-review | blocked)`);
@@ -1092,7 +2875,7 @@ async function gate(changeId, opts = {}) {
1092
2875
  errors.push(`agent-log/${f}: status=blocked requires concrete "next-action:" line (>= 10 chars, not "none")`);
1093
2876
  }
1094
2877
  }
1095
- if (strict) {
2878
+ if (!lax) {
1096
2879
  const artifactsMatch = content.match(/- artifacts:([\s\S]*?)(?:\n- |\n#|$)/);
1097
2880
  if (artifactsMatch) {
1098
2881
  const artifactLines = artifactsMatch[1].split("\n").filter((l) => l.trim().startsWith("-"));
@@ -1100,8 +2883,8 @@ async function gate(changeId, opts = {}) {
1100
2883
  const pointer = line.replace(/^\s*-\s*[\w-]+:\s*/, "").trim();
1101
2884
  const pathPart = pointer.split(":")[0];
1102
2885
  if (pathPart.includes("/") && !pointer.startsWith("http")) {
1103
- const abs = join8(cwd, pathPart);
1104
- if (!existsSync7(abs)) {
2886
+ const abs = join10(cwd, pathPart);
2887
+ if (!existsSync9(abs)) {
1105
2888
  errors.push(`agent-log/${f}: artifact pointer not found: ${pathPart}`);
1106
2889
  }
1107
2890
  }
@@ -1109,48 +2892,9 @@ async function gate(changeId, opts = {}) {
1109
2892
  }
1110
2893
  }
1111
2894
  }
1112
- const classifPath = join8(changeDir, "change-classification.md");
1113
- if (existsSync7(classifPath)) {
1114
- const classificationContent = readFileSync4(classifPath, "utf8");
1115
- const tierMatch = classificationContent.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
1116
- const tier = tierMatch ? parseInt(tierMatch[1]) : null;
1117
- if (tier !== null) {
1118
- const agentLogFiles = readdirSync5(agentLogDir).map((f) => f.replace(".md", ""));
1119
- if (tier <= 1) {
1120
- for (const required of ["e2e-resilience-engineer", "monkey-test-engineer", "stress-soak-engineer"]) {
1121
- if (!agentLogFiles.includes(required)) {
1122
- errors.push(`Tier ${tier} change requires agent-log/${required}.md (high-risk change \u2014 E2E/monkey/stress testing mandatory)`);
1123
- }
1124
- }
1125
- }
1126
- if (tier <= 3) {
1127
- for (const required of ["contract-reviewer", "qa-reviewer"]) {
1128
- if (!agentLogFiles.includes(required)) {
1129
- errors.push(`Tier ${tier} change requires agent-log/${required}.md`);
1130
- }
1131
- }
1132
- }
1133
- }
1134
- }
2895
+ enforceTierRequirements(changeDir, agentLogDir, errors, warnings);
1135
2896
  } else {
1136
- const classifPath = join8(changeDir, "change-classification.md");
1137
- if (existsSync7(classifPath)) {
1138
- const classificationContent = readFileSync4(classifPath, "utf8");
1139
- const tierMatch = classificationContent.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
1140
- const tier = tierMatch ? parseInt(tierMatch[1]) : null;
1141
- if (tier !== null) {
1142
- if (tier <= 1) {
1143
- for (const required of ["e2e-resilience-engineer", "monkey-test-engineer", "stress-soak-engineer"]) {
1144
- errors.push(`Tier ${tier} change requires agent-log/${required}.md (high-risk change \u2014 E2E/monkey/stress testing mandatory)`);
1145
- }
1146
- }
1147
- if (tier <= 3) {
1148
- for (const required of ["contract-reviewer", "qa-reviewer"]) {
1149
- errors.push(`Tier ${tier} change requires agent-log/${required}.md`);
1150
- }
1151
- }
1152
- }
1153
- }
2897
+ enforceTierRequirements(changeDir, null, errors, warnings);
1154
2898
  }
1155
2899
  for (const w of warnings) {
1156
2900
  log.warn(` ${w}`);
@@ -1163,12 +2907,10 @@ async function gate(changeId, opts = {}) {
1163
2907
  process.exit(1);
1164
2908
  }
1165
2909
  log.info(`gate: running contract validators for ${changeId}\u2026`);
1166
- const r = spawnSync2(process.execPath, [process.argv[1], "validate", "--contracts", "--env", "--ci", "--versions"], {
1167
- cwd,
1168
- stdio: "inherit"
1169
- });
1170
- if (r.status !== 0) {
1171
- log.error(`gate failed for change: ${changeId} (validators returned non-zero)`);
2910
+ try {
2911
+ await validate({ contracts: true, env: true, ci: true, spec: false, versions: true });
2912
+ } catch (err) {
2913
+ log.error(`gate failed for change: ${changeId} (validators threw): ${err.message}`);
1172
2914
  process.exit(1);
1173
2915
  }
1174
2916
  for (const w of warnings) {
@@ -1178,27 +2920,28 @@ async function gate(changeId, opts = {}) {
1178
2920
  }
1179
2921
 
1180
2922
  // src/commands/install-hooks.ts
1181
- import { existsSync as existsSync8, readFileSync as readFileSync5, writeFileSync as writeFileSync2, chmodSync, mkdirSync as mkdirSync3 } from "fs";
1182
- import { join as join9 } from "path";
2923
+ init_paths();
1183
2924
  init_logger();
2925
+ import { existsSync as existsSync10, readFileSync as readFileSync8, writeFileSync as writeFileSync4, chmodSync, mkdirSync as mkdirSync4 } from "fs";
2926
+ import { join as join11 } from "path";
1184
2927
  var START_MARKER = "# cdd-kit-managed-block-start";
1185
2928
  var END_MARKER = "# cdd-kit-managed-block-end";
1186
2929
  async function installHooks() {
1187
2930
  const cwd = process.cwd();
1188
- const gitDir = join9(cwd, ".git");
1189
- if (!existsSync8(gitDir)) {
2931
+ const gitDir = join11(cwd, ".git");
2932
+ if (!existsSync10(gitDir)) {
1190
2933
  log.error("not a git repository (no .git/ found in cwd)");
1191
2934
  process.exit(1);
1192
2935
  }
1193
- const hooksDir = join9(gitDir, "hooks");
1194
- mkdirSync3(hooksDir, { recursive: true });
1195
- const dest = join9(hooksDir, "pre-commit");
1196
- const ourHook = readFileSync5(join9(ASSET.hooks, "pre-commit"), "utf8");
2936
+ const hooksDir = join11(gitDir, "hooks");
2937
+ mkdirSync4(hooksDir, { recursive: true });
2938
+ const dest = join11(hooksDir, "pre-commit");
2939
+ const ourHook = readFileSync8(join11(ASSET.hooks, "pre-commit"), "utf8");
1197
2940
  let final;
1198
- if (!existsSync8(dest)) {
2941
+ if (!existsSync10(dest)) {
1199
2942
  final = ourHook;
1200
2943
  } else {
1201
- const existing = readFileSync5(dest, "utf8");
2944
+ const existing = readFileSync8(dest, "utf8");
1202
2945
  const startIdx = existing.indexOf(START_MARKER);
1203
2946
  const endIdx = existing.indexOf(END_MARKER);
1204
2947
  if (startIdx >= 0 && endIdx > startIdx) {
@@ -1222,7 +2965,7 @@ async function installHooks() {
1222
2965
  }
1223
2966
  }
1224
2967
  }
1225
- writeFileSync2(dest, final, "utf8");
2968
+ writeFileSync4(dest, final, "utf8");
1226
2969
  try {
1227
2970
  chmodSync(dest, 493);
1228
2971
  } catch {
@@ -1232,22 +2975,36 @@ async function installHooks() {
1232
2975
  }
1233
2976
 
1234
2977
  // src/cli/index.ts
1235
- var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
1236
- var pkg = JSON.parse(readFileSync10(join14(__dirname2, "..", "..", "package.json"), "utf8"));
2978
+ var __dirname2 = dirname5(fileURLToPath2(import.meta.url));
2979
+ var pkg = JSON.parse(readFileSync16(join19(__dirname2, "..", "..", "package.json"), "utf8"));
1237
2980
  var program = new Command();
1238
2981
  program.name("cdd-kit").description("Contract-Driven Delivery Kit CLI").version(pkg.version);
1239
2982
  program.command("init").description(
1240
2983
  "Install agents/skill into ~/.claude and scaffold project files in cwd"
1241
- ).option("--global-only", "Only install into ~/.claude, skip project files", false).option("--local-only", "Only scaffold project files, skip ~/.claude", false).option("--force", "Overwrite existing project files", false).action(
2984
+ ).option("--global-only", "Only install into ~/.claude, skip project files", false).option("--local-only", "Only scaffold project files, skip ~/.claude", false).option("--force", "Overwrite existing project files", false).option("--provider <provider>", "Provider adapter to scaffold: claude, codex, or both", "claude").action(
1242
2985
  (opts) => init({
1243
2986
  globalOnly: opts.globalOnly,
1244
2987
  localOnly: opts.localOnly,
1245
- force: opts.force
2988
+ force: opts.force,
2989
+ provider: opts.provider
1246
2990
  })
1247
2991
  );
1248
- program.command("update").description("Update ~/.claude agents and skill (does not touch project files)").option("--yes", "Apply changes (default is dry-run)", false).action((opts) => update({ yes: opts.yes }));
1249
- program.command("new <name>").description("Scaffold a new change directory under specs/changes/<name>").option("--all", "Include optional templates in addition to required ones", false).option("--force", "Overwrite existing template files in the change folder", false).action(
1250
- (name, opts) => newChange(name, { all: opts.all, force: opts.force })
2992
+ program.command("update").description("Update provider assets for the current project (does not overwrite project guidance files)").option("--yes", "Apply changes (default is dry-run)", false).option("--provider <provider>", "Provider adapter to update: auto, claude, codex, or both", "auto").action((opts) => update({ yes: opts.yes, provider: opts.provider }));
2993
+ program.command("doctor").description("Inspect cdd-kit repo health, provider guidance, and context index freshness").option("--strict", "Treat warnings as errors", false).option("--json", "Print a machine-readable health report", false).option("--provider <provider>", "Provider adapter to inspect: auto, claude, codex, or both", "auto").option("--fix", "Auto-resolve safe warnings (stale context indexes, missing role bindings)", false).action(async (opts) => {
2994
+ const { doctor: doctor2 } = await Promise.resolve().then(() => (init_doctor(), doctor_exports));
2995
+ await doctor2({ strict: opts.strict, json: opts.json, provider: opts.provider, fix: opts.fix });
2996
+ });
2997
+ program.command("upgrade").description("Add missing cdd-kit repo-level files without overwriting existing project files").option("--yes", "Apply changes (default is dry-run)", false).option("--migrate-changes", "Also migrate existing specs/changes/* directories", false).option("--enable-context-governance", "When migrating changes, opt them into context-governance: v1", false).option("--provider <provider>", "Provider adapter to scaffold: auto, claude, codex, or both", "auto").action(async (opts) => {
2998
+ const { upgrade: upgrade2 } = await Promise.resolve().then(() => (init_upgrade(), upgrade_exports));
2999
+ await upgrade2({
3000
+ yes: opts.yes,
3001
+ migrateChanges: opts.migrateChanges,
3002
+ enableContextGovernance: opts.enableContextGovernance,
3003
+ provider: opts.provider
3004
+ });
3005
+ });
3006
+ program.command("new <name>").description("Scaffold a new change directory under specs/changes/<name>").option("--all", "Include optional templates in addition to required ones", false).option("--force", "Overwrite existing template files in the change folder", false).option("--depends-on <change-ids>", "Comma-separated upstream change ids that must complete first").option("--skip-scan", "Skip the auto context-scan when indexes are stale (advanced)", false).action(
3007
+ (name, opts) => newChange(name, { all: opts.all, force: opts.force, dependsOn: opts.dependsOn, skipScan: opts.skipScan })
1251
3008
  );
1252
3009
  program.command("validate").description("Run validation scripts (defaults to all)").option("--contracts", "Validate API/data/CSS contracts (use --env separately for env)", false).option("--env", "Validate env contract", false).option("--ci", "Validate CI gate policy", false).option("--spec", "Validate spec traceability", false).option("--versions", "Validate contract frontmatter and version bumps", false).action(
1253
3010
  (opts) => validate({
@@ -1258,8 +3015,8 @@ program.command("validate").description("Run validation scripts (defaults to all
1258
3015
  versions: opts.versions
1259
3016
  })
1260
3017
  );
1261
- program.command("gate <change-id>").description("Run full orchestration gate for a change (required artifacts, content, tier, contracts)").option("--strict", "Treat pending tasks (except section 7) as errors, and validate artifact pointers", false).action(async (id, opts) => {
1262
- await gate(id, { strict: opts.strict });
3018
+ program.command("gate <change-id>").description("Run full orchestration gate for a change (required artifacts, content, tier, contracts)").option("--strict", "Treat pending tasks (except section 7) as errors, and treat runtime/declared files-read drift as errors", false).option("--lax", "Skip artifact-pointer existence check (for legacy repos with stale logs)", false).action(async (id, opts) => {
3019
+ await gate(id, { strict: opts.strict, lax: opts.lax });
1263
3020
  });
1264
3021
  program.command("archive <change-id>").description("Move a completed change from specs/changes/ to specs/archive/<year>/").action(async (changeId) => {
1265
3022
  const { archive: archive2 } = await Promise.resolve().then(() => (init_archive(), archive_exports));
@@ -1269,9 +3026,14 @@ program.command("abandon <change-id>").description("Mark a change as abandoned (
1269
3026
  const { abandon: abandon2 } = await Promise.resolve().then(() => (init_abandon(), abandon_exports));
1270
3027
  await abandon2(changeId, opts);
1271
3028
  });
1272
- program.command("migrate [change-id]").description("Upgrade existing change directories to v1.11.0 format (tasks.md frontmatter + tier format)").option("--all", "Migrate all changes in specs/changes/", false).option("--dry-run", "Show what would change without writing files", false).action(async (changeId, opts = {}) => {
3029
+ program.command("migrate [change-id]").description("Upgrade existing change directories to the current cdd-kit format (tasks.md frontmatter + tier format)").option("--all", "Migrate all changes in specs/changes/", false).option("--dry-run", "Show what would change without writing files", false).option("--enable-context-governance", "Opt legacy changes into context-governance: v1 hard gate behavior", false).option("--no-backup", "Skip the per-session backup at .cdd/migrate-backup/<stamp>/ (not recommended)").action(async (changeId, opts = {}) => {
1273
3030
  const { migrate: migrate2 } = await Promise.resolve().then(() => (init_migrate(), migrate_exports));
1274
- await migrate2(changeId, opts);
3031
+ await migrate2(changeId, {
3032
+ all: opts.all,
3033
+ dryRun: opts.dryRun,
3034
+ enableContextGovernance: opts.enableContextGovernance,
3035
+ noBackup: opts.backup === false
3036
+ });
1275
3037
  });
1276
3038
  program.command("list").description("List active changes in specs/changes/").action(async () => {
1277
3039
  const { listChanges: listChanges2 } = await Promise.resolve().then(() => (init_list_changes(), list_changes_exports));
@@ -1293,4 +3055,49 @@ program.command("detect-stack").description("Detect the project tech stack and p
1293
3055
  );
1294
3056
  }
1295
3057
  });
3058
+ program.command("context-scan").description("Deterministically scan project context and generate specs/context maps").option("--surface <path>", "Limit project-map tree to a sub-directory (e.g. --surface src/server)").action(async (opts) => {
3059
+ const { contextScan: contextScan2 } = await Promise.resolve().then(() => (init_context_scan(), context_scan_exports));
3060
+ await contextScan2({ surface: opts.surface });
3061
+ });
3062
+ var context = program.command("context").description("Manage context governance manifests");
3063
+ context.command("request <change-id> <request-id>").description("Record a new pending Context Expansion Request").requiredOption("--path <paths...>", "Repo-relative path(s) requested by the agent").option("--reason <text>", "Reason the extra context is required").action(async (changeId, requestId, opts) => {
3064
+ const { requestContextExpansion: requestContextExpansion2 } = await Promise.resolve().then(() => (init_context(), context_exports));
3065
+ await requestContextExpansion2(changeId, requestId, opts.path, opts.reason);
3066
+ });
3067
+ context.command("approve <change-id> [request-id]").description("Approve a pending Context Expansion Request (or all with --all-pending)").option("--all-pending", "Approve every pending Context Expansion Request for this change", false).action(async (changeId, requestId, opts) => {
3068
+ const { approveContextExpansion: approveContextExpansion2, approveAllPending: approveAllPending2 } = await Promise.resolve().then(() => (init_context(), context_exports));
3069
+ if (opts.allPending) {
3070
+ if (requestId) {
3071
+ console.error("--all-pending cannot be combined with a request-id");
3072
+ process.exit(1);
3073
+ }
3074
+ await approveAllPending2(changeId);
3075
+ } else {
3076
+ if (!requestId) {
3077
+ console.error("request-id is required (or pass --all-pending)");
3078
+ process.exit(1);
3079
+ }
3080
+ await approveContextExpansion2(changeId, requestId);
3081
+ }
3082
+ });
3083
+ context.command("reject <change-id> [request-id]").description("Reject a pending Context Expansion Request (or all with --all-pending)").option("--all-pending", "Reject every pending Context Expansion Request for this change", false).action(async (changeId, requestId, opts) => {
3084
+ const { rejectContextExpansion: rejectContextExpansion2, rejectAllPending: rejectAllPending2 } = await Promise.resolve().then(() => (init_context(), context_exports));
3085
+ if (opts.allPending) {
3086
+ if (requestId) {
3087
+ console.error("--all-pending cannot be combined with a request-id");
3088
+ process.exit(1);
3089
+ }
3090
+ await rejectAllPending2(changeId);
3091
+ } else {
3092
+ if (!requestId) {
3093
+ console.error("request-id is required (or pass --all-pending)");
3094
+ process.exit(1);
3095
+ }
3096
+ await rejectContextExpansion2(changeId, requestId);
3097
+ }
3098
+ });
3099
+ context.command("list <change-id>").description("List Context Expansion Requests for a change").option("--json", "Print machine-readable JSON", false).action(async (changeId, opts) => {
3100
+ const { listContextExpansions: listContextExpansions2 } = await Promise.resolve().then(() => (init_context(), context_exports));
3101
+ await listContextExpansions2(changeId, opts.json);
3102
+ });
1296
3103
  program.parse();