contract-driven-delivery 1.11.0 → 1.12.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 (39) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +174 -17
  3. package/assets/CLAUDE.template.md +18 -0
  4. package/assets/CODEX.template.md +39 -0
  5. package/assets/agents/backend-engineer.md +2 -0
  6. package/assets/agents/change-classifier.md +47 -1
  7. package/assets/agents/ci-cd-gatekeeper.md +2 -0
  8. package/assets/agents/contract-reviewer.md +2 -0
  9. package/assets/agents/dependency-security-reviewer.md +2 -0
  10. package/assets/agents/e2e-resilience-engineer.md +2 -0
  11. package/assets/agents/frontend-engineer.md +2 -0
  12. package/assets/agents/monkey-test-engineer.md +2 -0
  13. package/assets/agents/qa-reviewer.md +2 -0
  14. package/assets/agents/repo-context-scanner.md +2 -0
  15. package/assets/agents/spec-architect.md +2 -0
  16. package/assets/agents/spec-drift-auditor.md +2 -0
  17. package/assets/agents/stress-soak-engineer.md +2 -0
  18. package/assets/agents/test-strategist.md +2 -0
  19. package/assets/agents/ui-ux-reviewer.md +2 -0
  20. package/assets/agents/visual-reviewer.md +2 -0
  21. package/assets/cdd/context-policy.json +25 -0
  22. package/assets/cdd/model-policy.json +5 -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/skills/cdd-close/SKILL.md +37 -10
  33. package/assets/skills/cdd-new/SKILL.md +44 -11
  34. package/assets/skills/cdd-resume/SKILL.md +30 -2
  35. package/assets/specs-templates/context-manifest.md +49 -0
  36. package/assets/specs-templates/tasks.md +2 -0
  37. package/dist/cli/index.js +1508 -256
  38. package/docs/release-checklist.md +39 -0
  39. 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,184 @@ 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
49
- });
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);
62
- }
63
- if (existsSync9(archiveDir)) {
64
- log.error(`Already archived: specs/archive/${archiveYear}/${changeId}`);
65
- process.exit(1);
66
- }
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).");
72
- }
73
- const pending = (content.match(/^\s*-\s*\[ \]/gm) || []).length;
74
- if (pending > 0) {
75
- log.warn(`${pending} task(s) still pending ([ ]). Archive anyway.`);
76
- }
77
- }
78
- if (!existsSync9(archiveBase)) {
79
- mkdirSync4(archiveBase, { recursive: true });
80
- }
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;
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 {
89
94
  }
90
95
  }
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}/ |
94
- `;
95
- if (!existsSync9(indexPath)) {
96
- writeFileSync3(indexPath, `# Archive Index
97
-
98
- | change-id | year | archived-date | path |
99
- |---|---|---|---|
100
- ${indexLine}`, "utf8");
101
- } else {
102
- appendFileSync(indexPath, indexLine, "utf8");
103
- }
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`);
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";
107
103
  }
108
- var init_archive = __esm({
109
- "src/commands/archive.ts"() {
104
+ var init_provider = __esm({
105
+ "src/utils/provider.ts"() {
110
106
  "use strict";
111
- init_logger();
112
107
  }
113
108
  });
114
109
 
115
- // src/commands/abandon.ts
116
- var abandon_exports = {};
117
- __export(abandon_exports, {
118
- abandon: () => abandon
110
+ // src/commands/doctor.ts
111
+ var doctor_exports = {};
112
+ __export(doctor_exports, {
113
+ doctor: () => doctor
119
114
  });
115
+ import { existsSync as existsSync10, readdirSync as readdirSync6, readFileSync as readFileSync8, statSync as statSync2 } from "fs";
120
116
  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}`);
117
+ function fileExists(cwd, relPath) {
118
+ return existsSync10(join11(cwd, relPath));
119
+ }
120
+ function findFiles(dir, predicate, found = []) {
121
+ if (!existsSync10(dir))
122
+ return found;
123
+ for (const entry of readdirSync6(dir, { withFileTypes: true })) {
124
+ const fullPath = join11(dir, entry.name);
125
+ if (entry.isDirectory())
126
+ findFiles(fullPath, predicate, found);
127
+ else if (entry.isFile() && predicate(entry.name))
128
+ found.push(fullPath);
129
+ }
130
+ return found;
131
+ }
132
+ function newestMtime(paths) {
133
+ let newest = 0;
134
+ for (const path of paths) {
135
+ try {
136
+ newest = Math.max(newest, statSync2(path).mtimeMs);
137
+ } catch {
138
+ }
139
+ }
140
+ return newest;
141
+ }
142
+ function readMissingSummaryCount(cwd) {
143
+ const indexPath = join11(cwd, "specs", "context", "contracts-index.md");
144
+ if (!existsSync10(indexPath))
145
+ return null;
146
+ const match = readFileSync8(indexPath, "utf8").match(/^missing-summary-count:\s*(\d+)/m);
147
+ return match ? Number(match[1]) : null;
148
+ }
149
+ function checkContextFreshness(cwd) {
150
+ const findings = [];
151
+ const projectMap = join11(cwd, "specs", "context", "project-map.md");
152
+ const contractsIndex = join11(cwd, "specs", "context", "contracts-index.md");
153
+ const contextPolicy = join11(cwd, ".cdd", "context-policy.json");
154
+ const contractFiles = findFiles(join11(cwd, "contracts"), (name) => name.endsWith(".md"));
155
+ if (!existsSync10(projectMap) || !existsSync10(contractsIndex)) {
156
+ findings.push({
157
+ level: "warning",
158
+ message: "specs/context indexes are missing; run cdd-kit context-scan before classification"
159
+ });
160
+ return findings;
161
+ }
162
+ const projectInputs = [contextPolicy].filter(existsSync10);
163
+ if (projectInputs.length > 0 && statSync2(projectMap).mtimeMs < newestMtime(projectInputs)) {
164
+ findings.push({
165
+ level: "warning",
166
+ message: "specs/context/project-map.md is older than .cdd/context-policy.json; run cdd-kit context-scan"
167
+ });
168
+ }
169
+ if (contractFiles.length > 0 && statSync2(contractsIndex).mtimeMs < newestMtime(contractFiles)) {
170
+ findings.push({
171
+ level: "warning",
172
+ message: "specs/context/contracts-index.md is older than contracts/; run cdd-kit context-scan"
173
+ });
174
+ }
175
+ const missingSummaryCount = readMissingSummaryCount(cwd);
176
+ if (missingSummaryCount !== null && missingSummaryCount > 0) {
177
+ findings.push({
178
+ level: "warning",
179
+ message: `contracts-index reports ${missingSummaryCount} contract(s) without deterministic summary metadata`
180
+ });
181
+ }
182
+ if (findings.length === 0) {
183
+ findings.push({ level: "ok", message: "context indexes are present and fresh" });
184
+ }
185
+ return findings;
186
+ }
187
+ function buildDoctorReport(cwd, opts) {
188
+ const requestedProvider = opts.provider ?? "auto";
189
+ if (!validateProviderOption(requestedProvider)) {
190
+ log.error(`Invalid provider: ${requestedProvider}. Use auto, claude, codex, or both.`);
128
191
  process.exit(1);
129
192
  }
130
- if (existsSync10(tasksPath)) {
131
- let content = readFileSync7(tasksPath, "utf8");
132
- if (content.match(/^status:/m)) {
133
- content = content.replace(/^status: .*/m, "status: abandoned");
134
- } else {
135
- content = `---
136
- change-id: ${changeId}
137
- status: abandoned
138
- ---
139
-
140
- ` + content;
141
- }
142
- writeFileSync4(tasksPath, content, "utf8");
193
+ const strict = opts.strict ?? false;
194
+ const provider = inferProvider(cwd, requestedProvider);
195
+ const findings = [];
196
+ for (const relPath of ["contracts", "specs/templates", ".cdd/context-policy.json", ".cdd/model-policy.json"]) {
197
+ findings.push(fileExists(cwd, relPath) ? { level: "ok", message: `${relPath} exists` } : { level: "warning", message: `${relPath} is missing; run cdd-kit upgrade --yes` });
143
198
  }
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 });
199
+ if ((provider === "claude" || provider === "both") && !fileExists(cwd, "CLAUDE.md")) {
200
+ findings.push({ level: "warning", message: "CLAUDE.md is missing for Claude provider; run cdd-kit upgrade --provider claude --yes" });
152
201
  }
153
- if (!existsSync10(indexPath)) {
154
- writeFileSync4(indexPath, `# Archive Index
155
-
156
- | change-id | status | date | notes |
157
- |---|---|---|---|
158
- ${indexLine}`, "utf8");
202
+ if ((provider === "claude" || provider === "both") && !fileExists(cwd, "AGENTS.md")) {
203
+ findings.push({ level: "warning", message: "AGENTS.md is missing for Claude provider; run cdd-kit upgrade --provider claude --yes" });
204
+ }
205
+ if ((provider === "codex" || provider === "both") && !fileExists(cwd, "CODEX.md")) {
206
+ findings.push({ level: "warning", message: "CODEX.md is missing for Codex provider; run cdd-kit upgrade --provider codex --yes" });
207
+ }
208
+ findings.push(...checkContextFreshness(cwd));
209
+ const errors = findings.filter((finding) => finding.level === "error").length;
210
+ const warnings = findings.filter((finding) => finding.level === "warning").length;
211
+ return {
212
+ provider,
213
+ strict,
214
+ findings,
215
+ errors,
216
+ warnings,
217
+ ok: errors === 0 && (!strict || warnings === 0)
218
+ };
219
+ }
220
+ async function doctor(opts = {}) {
221
+ const report = buildDoctorReport(process.cwd(), opts);
222
+ if (opts.json) {
223
+ console.log(JSON.stringify(report, null, 2));
224
+ if (!report.ok)
225
+ process.exit(1);
226
+ return;
227
+ }
228
+ log.blank();
229
+ log.info(`Doctor provider: ${report.provider}`);
230
+ for (const finding of report.findings) {
231
+ if (finding.level === "ok")
232
+ log.ok(finding.message);
233
+ else if (finding.level === "warning")
234
+ log.warn(finding.message);
235
+ else
236
+ log.error(finding.message);
237
+ }
238
+ log.blank();
239
+ if (!report.ok) {
240
+ log.error(report.strict && report.errors === 0 ? `doctor failed in strict mode with ${report.warnings} warning(s)` : `doctor failed with ${report.errors} error(s)`);
241
+ process.exit(1);
242
+ }
243
+ if (report.warnings > 0) {
244
+ log.warn(`doctor completed with ${report.warnings} warning(s)`);
159
245
  } else {
160
- appendFileSync2(indexPath, indexLine, "utf8");
246
+ log.ok("doctor passed");
161
247
  }
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.`);
248
+ log.blank();
165
249
  }
166
- var init_abandon = __esm({
167
- "src/commands/abandon.ts"() {
250
+ var init_doctor = __esm({
251
+ "src/commands/doctor.ts"() {
168
252
  "use strict";
169
253
  init_logger();
254
+ init_provider();
170
255
  }
171
256
  });
172
257
 
@@ -176,15 +261,126 @@ __export(migrate_exports, {
176
261
  migrate: () => migrate
177
262
  });
178
263
  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) {
264
+ import { existsSync as existsSync11, readdirSync as readdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
265
+ function buildLegacyContextManifest(changeId) {
266
+ return [
267
+ "# Context Manifest",
268
+ "",
269
+ "Generated by `cdd-kit migrate` for an existing change.",
270
+ "This legacy manifest records a conservative default context boundary without enabling context-governance: v1.",
271
+ "",
272
+ "## Affected Surfaces",
273
+ "- legacy-unknown",
274
+ "",
275
+ "## Allowed Paths",
276
+ `- specs/changes/${changeId}/`,
277
+ "",
278
+ "## Forbidden Paths",
279
+ "- .claude/worktrees/**",
280
+ "- .git/**",
281
+ "- node_modules/**",
282
+ "- dist/**",
283
+ "- build/**",
284
+ "- assets/**",
285
+ "- specs/archive/**",
286
+ `- specs/changes/* except specs/changes/${changeId}/`,
287
+ "",
288
+ "## Required Contracts",
289
+ "- legacy-unknown",
290
+ "",
291
+ "## Required Tests",
292
+ "- legacy-unknown",
293
+ "",
294
+ "## Agent Work Packets",
295
+ "",
296
+ "## Context Expansion Requests",
297
+ "-",
298
+ "",
299
+ "## Approved Expansions",
300
+ "-",
301
+ ""
302
+ ].join("\n");
303
+ }
304
+ function upsertFrontmatterField(content, field, value) {
305
+ if (!content.startsWith("---\n"))
306
+ return content;
307
+ const closing = content.indexOf("\n---", 4);
308
+ if (closing === -1)
309
+ return content;
310
+ const frontmatter = content.slice(4, closing);
311
+ const body = content.slice(closing);
312
+ const fieldPattern = new RegExp(`^${field}:.*$`, "m");
313
+ const nextFrontmatter = fieldPattern.test(frontmatter) ? frontmatter.replace(fieldPattern, `${field}: ${value}`) : `${frontmatter.trimEnd()}
314
+ ${field}: ${value}`;
315
+ return `---
316
+ ${nextFrontmatter}${body}`;
317
+ }
318
+ function buildContextGovernedManifest(changeId) {
319
+ return [
320
+ "# Context Manifest",
321
+ "",
322
+ "Generated by `cdd-kit migrate --enable-context-governance` for an existing change.",
323
+ "Review and narrow the allowed paths before assigning implementation work.",
324
+ "",
325
+ "## Affected Surfaces",
326
+ "- legacy-unknown",
327
+ "",
328
+ "## Allowed Paths",
329
+ `- specs/changes/${changeId}/`,
330
+ "- specs/context/project-map.md",
331
+ "- specs/context/contracts-index.md",
332
+ "",
333
+ "## Forbidden Paths",
334
+ "- .claude/worktrees/**",
335
+ "- .git/**",
336
+ "- node_modules/**",
337
+ "- dist/**",
338
+ "- build/**",
339
+ "- assets/**",
340
+ "- specs/archive/**",
341
+ `- specs/changes/* except specs/changes/${changeId}/`,
342
+ "",
343
+ "## Required Contracts",
344
+ "- legacy-unknown",
345
+ "",
346
+ "## Required Tests",
347
+ "- legacy-unknown",
348
+ "",
349
+ "## Agent Work Packets",
350
+ "",
351
+ "### change-classifier",
352
+ "- allowed:",
353
+ ` - specs/changes/${changeId}/`,
354
+ " - specs/context/project-map.md",
355
+ " - specs/context/contracts-index.md",
356
+ "",
357
+ "## Context Expansion Requests",
358
+ "",
359
+ "<!--",
360
+ "Agents must request context expansion instead of reading outside their work packet.",
361
+ "Use this format only for real requests:",
362
+ "",
363
+ "- request-id: CER-001",
364
+ " requested_paths:",
365
+ " - src/example.ts",
366
+ " reason: why this file is required",
367
+ " status: pending",
368
+ "-->",
369
+ "",
370
+ "## Approved Expansions",
371
+ "-",
372
+ ""
373
+ ].join("\n");
374
+ }
375
+ function migrateOne(changeId, changeDir, dryRun, enableContextGovernance) {
181
376
  const changed = [];
182
377
  const warnings = [];
183
378
  const tasksPath = join12(changeDir, "tasks.md");
184
379
  if (existsSync11(tasksPath)) {
185
- let content = readFileSync8(tasksPath, "utf8");
380
+ let content = readFileSync9(tasksPath, "utf8");
186
381
  const norm = content.replace(/\r\n/g, "\n");
187
382
  let modified = false;
383
+ const taskChanges = [];
188
384
  if (!norm.startsWith("---")) {
189
385
  const bareStatusMatch = norm.match(/^status:\s*(\S+)/m);
190
386
  const inferredStatus = bareStatusMatch ? bareStatusMatch[1] : "in-progress";
@@ -198,6 +394,7 @@ status: ${inferredStatus}
198
394
 
199
395
  ` + content;
200
396
  modified = true;
397
+ taskChanges.push("added YAML frontmatter");
201
398
  }
202
399
  if (!content.includes("[x]=done")) {
203
400
  content = content.replace(
@@ -207,18 +404,24 @@ status: ${inferredStatus}
207
404
  `
208
405
  );
209
406
  modified = true;
407
+ taskChanges.push("added [x]/[-]/[ ] legend comment");
408
+ }
409
+ if (enableContextGovernance && !/^context-governance:\s*v1\b/m.test(content)) {
410
+ content = upsertFrontmatterField(content, "context-governance", "v1");
411
+ modified = true;
412
+ taskChanges.push("enabled context-governance: v1");
210
413
  }
211
414
  if (modified) {
212
- changed.push("tasks.md: added YAML frontmatter (status: in-progress) + legend comment");
415
+ changed.push(`tasks.md: ${taskChanges.join("; ")}`);
213
416
  if (!dryRun)
214
- writeFileSync5(tasksPath, content, "utf8");
417
+ writeFileSync4(tasksPath, content, "utf8");
215
418
  }
216
419
  } else {
217
420
  warnings.push("tasks.md not found \u2014 skipping frontmatter migration");
218
421
  }
219
422
  const classifPath = join12(changeDir, "change-classification.md");
220
423
  if (existsSync11(classifPath)) {
221
- const content = readFileSync8(classifPath, "utf8");
424
+ const content = readFileSync9(classifPath, "utf8");
222
425
  const hasNewTierFormat = /^## Tier\s*\n\s*-\s*\d\s*$/m.test(content);
223
426
  if (!hasNewTierFormat) {
224
427
  const oldMatch = content.match(/\*\*Tier[:\*]+\s*(?:Tier\s*)?(\d)/i) ?? content.match(/^-?\s*Tier:\s*(?:Tier\s*)?(\d)/mi);
@@ -233,7 +436,7 @@ status: ${inferredStatus}
233
436
  `change-classification.md: appended "## Tier\\n- ${detectedTier}" (converted from old format)`
234
437
  );
235
438
  if (!dryRun)
236
- writeFileSync5(classifPath, content + addition, "utf8");
439
+ writeFileSync4(classifPath, content + addition, "utf8");
237
440
  }
238
441
  } else {
239
442
  warnings.push(
@@ -242,11 +445,25 @@ status: ${inferredStatus}
242
445
  }
243
446
  }
244
447
  }
448
+ const manifestPath = join12(changeDir, "context-manifest.md");
449
+ if (!existsSync11(manifestPath)) {
450
+ changed.push(enableContextGovernance ? "context-manifest.md: added context-governance v1 manifest scaffold" : "context-manifest.md: added legacy context manifest scaffold");
451
+ if (!dryRun) {
452
+ writeFileSync4(
453
+ manifestPath,
454
+ enableContextGovernance ? buildContextGovernedManifest(changeId) : buildLegacyContextManifest(changeId),
455
+ "utf8"
456
+ );
457
+ }
458
+ } else if (enableContextGovernance) {
459
+ warnings.push("context-manifest.md already exists \u2014 review allowed paths before relying on context-governance: v1");
460
+ }
245
461
  return { changed, warnings };
246
462
  }
247
463
  async function migrate(changeId, opts = {}) {
248
464
  const cwd = process.cwd();
249
465
  const dryRun = opts.dryRun ?? false;
466
+ const enableContextGovernance = opts.enableContextGovernance ?? false;
250
467
  const idsToMigrate = [];
251
468
  if (opts.all) {
252
469
  const changesDir = join12(cwd, "specs", "changes");
@@ -255,7 +472,7 @@ async function migrate(changeId, opts = {}) {
255
472
  return;
256
473
  }
257
474
  idsToMigrate.push(
258
- ...readdirSync6(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
475
+ ...readdirSync7(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
259
476
  );
260
477
  } else if (changeId) {
261
478
  const specificDir = join12(cwd, "specs", "changes", changeId);
@@ -284,7 +501,7 @@ async function migrate(changeId, opts = {}) {
284
501
  log.warn(` ${id}: directory not found \u2014 skipping`);
285
502
  continue;
286
503
  }
287
- const { changed, warnings } = migrateOne(id, changeDir, dryRun);
504
+ const { changed, warnings } = migrateOne(id, changeDir, dryRun, enableContextGovernance);
288
505
  if (changed.length > 0) {
289
506
  log.ok(` ${id}: migrated`);
290
507
  for (const c of changed)
@@ -304,7 +521,7 @@ async function migrate(changeId, opts = {}) {
304
521
  } else {
305
522
  log.ok(`Migration complete: ${migratedCount} updated, ${upToDateCount} already up to date.`);
306
523
  if (migratedCount > 0) {
307
- log.info('Next: git add specs/changes/ && git commit -m "chore: migrate changes to v1.11.0 format"');
524
+ log.info('Next: git add specs/changes/ && git commit -m "chore: migrate changes to current cdd-kit format"');
308
525
  }
309
526
  }
310
527
  }
@@ -315,31 +532,283 @@ var init_migrate = __esm({
315
532
  }
316
533
  });
317
534
 
535
+ // src/commands/upgrade.ts
536
+ var upgrade_exports = {};
537
+ __export(upgrade_exports, {
538
+ upgrade: () => upgrade
539
+ });
540
+ import { existsSync as existsSync12, mkdirSync as mkdirSync4, readdirSync as readdirSync8, copyFileSync as copyFileSync3, writeFileSync as writeFileSync5 } from "fs";
541
+ import { dirname as dirname3, join as join13, relative as relative2 } from "path";
542
+ function planMissingFiles(srcDir, destDir, label, planned) {
543
+ if (!existsSync12(srcDir))
544
+ return;
545
+ for (const entry of readdirSync8(srcDir, { withFileTypes: true })) {
546
+ const src = join13(srcDir, entry.name);
547
+ const dest = join13(destDir, entry.name);
548
+ if (entry.isDirectory()) {
549
+ planMissingFiles(src, dest, join13(label, entry.name), planned);
550
+ continue;
551
+ }
552
+ if (!existsSync12(dest)) {
553
+ planned.push({ src, dest, rel: join13(label, relative2(srcDir, src)) });
554
+ }
555
+ }
556
+ }
557
+ function planProviderGuidance(cwd, provider, planned) {
558
+ if (provider === "claude" || provider === "both") {
559
+ if (!existsSync12(join13(cwd, "CLAUDE.md"))) {
560
+ planned.push({ src: ASSET.claudeTemplate, dest: join13(cwd, "CLAUDE.md"), rel: "CLAUDE.md" });
561
+ }
562
+ if (!existsSync12(join13(cwd, "AGENTS.md"))) {
563
+ planned.push({ src: ASSET.agentsTemplate, dest: join13(cwd, "AGENTS.md"), rel: "AGENTS.md" });
564
+ }
565
+ }
566
+ if ((provider === "codex" || provider === "both") && !existsSync12(join13(cwd, "CODEX.md"))) {
567
+ planned.push({ src: ASSET.codexTemplate, dest: join13(cwd, "CODEX.md"), rel: "CODEX.md" });
568
+ }
569
+ }
570
+ function applyCopy(plan) {
571
+ for (const item of plan) {
572
+ mkdirSync4(dirname3(item.dest), { recursive: true });
573
+ copyFileSync3(item.src, item.dest);
574
+ }
575
+ }
576
+ async function upgrade(opts = {}) {
577
+ const cwd = process.cwd();
578
+ const requestedProvider = opts.provider ?? "auto";
579
+ if (!validateProviderOption(requestedProvider)) {
580
+ log.error(`Invalid provider: ${requestedProvider}. Use auto, claude, codex, or both.`);
581
+ process.exit(1);
582
+ }
583
+ const provider = inferProvider(cwd, requestedProvider);
584
+ const plan = [];
585
+ planMissingFiles(ASSET.contracts, join13(cwd, "contracts"), "contracts", plan);
586
+ planMissingFiles(ASSET.specsTemplates, join13(cwd, "specs", "templates"), "specs/templates", plan);
587
+ planMissingFiles(ASSET.testsTemplates, join13(cwd, "tests", "templates"), "tests/templates", plan);
588
+ planMissingFiles(ASSET.ci, join13(cwd, "ci"), "ci", plan);
589
+ planMissingFiles(ASSET.githubWorkflows, join13(cwd, ".github", "workflows"), ".github/workflows", plan);
590
+ planMissingFiles(ASSET.cddConfig, join13(cwd, ".cdd"), ".cdd", plan);
591
+ planProviderGuidance(cwd, provider, plan);
592
+ log.blank();
593
+ log.info(`Upgrade provider: ${provider}`);
594
+ if (plan.length === 0) {
595
+ log.ok("No missing cdd-kit project files found.");
596
+ if (opts.migrateChanges) {
597
+ log.blank();
598
+ log.info("Running change migration flow...");
599
+ await migrate(void 0, {
600
+ all: true,
601
+ dryRun: !opts.yes,
602
+ enableContextGovernance: opts.enableContextGovernance
603
+ });
604
+ }
605
+ log.blank();
606
+ return;
607
+ }
608
+ log.info(`${plan.length} missing file(s) detected:`);
609
+ for (const item of plan)
610
+ log.dim(` + ${item.rel.replace(/\\/g, "/")}`);
611
+ if (!opts.yes) {
612
+ log.blank();
613
+ log.info("Dry run only. Re-run with --yes to write missing files.");
614
+ if (opts.migrateChanges) {
615
+ log.blank();
616
+ log.info("Previewing existing change migration because --migrate-changes was requested.");
617
+ await migrate(void 0, {
618
+ all: true,
619
+ dryRun: true,
620
+ enableContextGovernance: opts.enableContextGovernance
621
+ });
622
+ }
623
+ log.blank();
624
+ return;
625
+ }
626
+ applyCopy(plan);
627
+ const modelPolicyPath = join13(cwd, ".cdd", "model-policy.json");
628
+ if (existsSync12(modelPolicyPath)) {
629
+ writeFileSync5(modelPolicyPath, JSON.stringify({
630
+ provider,
631
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
632
+ roles: {}
633
+ }, null, 2) + "\n", "utf8");
634
+ }
635
+ log.blank();
636
+ log.ok(`Upgrade complete: ${plan.length} missing file(s) added.`);
637
+ log.info("Existing project guidance and contracts were preserved.");
638
+ if (opts.migrateChanges) {
639
+ log.blank();
640
+ log.info("Running change migration flow...");
641
+ await migrate(void 0, {
642
+ all: true,
643
+ dryRun: false,
644
+ enableContextGovernance: opts.enableContextGovernance
645
+ });
646
+ }
647
+ log.blank();
648
+ }
649
+ var init_upgrade = __esm({
650
+ "src/commands/upgrade.ts"() {
651
+ "use strict";
652
+ init_paths();
653
+ init_logger();
654
+ init_provider();
655
+ init_migrate();
656
+ }
657
+ });
658
+
659
+ // src/commands/archive.ts
660
+ var archive_exports = {};
661
+ __export(archive_exports, {
662
+ archive: () => archive
663
+ });
664
+ import { join as join14 } from "path";
665
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5, renameSync, readFileSync as readFileSync10, writeFileSync as writeFileSync6, appendFileSync, cpSync as cpSync2, rmSync as rmSync2 } from "fs";
666
+ async function archive(changeId) {
667
+ const cwd = process.cwd();
668
+ const changeDir = join14(cwd, "specs", "changes", changeId);
669
+ const archiveYear = (/* @__PURE__ */ new Date()).getFullYear().toString();
670
+ const archiveBase = join14(cwd, "specs", "archive", archiveYear);
671
+ const archiveDir = join14(archiveBase, changeId);
672
+ const indexPath = join14(cwd, "specs", "archive", "INDEX.md");
673
+ if (!existsSync13(changeDir)) {
674
+ log.error(`Change not found: specs/changes/${changeId}`);
675
+ process.exit(1);
676
+ }
677
+ if (existsSync13(archiveDir)) {
678
+ log.error(`Already archived: specs/archive/${archiveYear}/${changeId}`);
679
+ process.exit(1);
680
+ }
681
+ const tasksPath = join14(changeDir, "tasks.md");
682
+ if (existsSync13(tasksPath)) {
683
+ const content = readFileSync10(tasksPath, "utf8");
684
+ if (content.includes("status: gate-blocked")) {
685
+ log.warn("tasks.md has status: gate-blocked \u2014 archiving anyway (change was paused).");
686
+ }
687
+ const pending = (content.match(/^\s*-\s*\[ \]/gm) || []).length;
688
+ if (pending > 0) {
689
+ log.warn(`${pending} task(s) still pending ([ ]). Archive anyway.`);
690
+ }
691
+ }
692
+ if (!existsSync13(archiveBase)) {
693
+ mkdirSync5(archiveBase, { recursive: true });
694
+ }
695
+ try {
696
+ renameSync(changeDir, archiveDir);
697
+ } catch (err) {
698
+ if (err.code === "EXDEV") {
699
+ cpSync2(changeDir, archiveDir, { recursive: true });
700
+ rmSync2(changeDir, { recursive: true, force: true });
701
+ } else {
702
+ throw err;
703
+ }
704
+ }
705
+ log.ok(`Archived: specs/changes/${changeId} \u2192 specs/archive/${archiveYear}/${changeId}`);
706
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
707
+ const indexLine = `| ${changeId} | ${archiveYear} | ${today} | specs/archive/${archiveYear}/${changeId}/ |
708
+ `;
709
+ if (!existsSync13(indexPath)) {
710
+ writeFileSync6(indexPath, `# Archive Index
711
+
712
+ | change-id | year | archived-date | path |
713
+ |---|---|---|---|
714
+ ${indexLine}`, "utf8");
715
+ } else {
716
+ appendFileSync(indexPath, indexLine, "utf8");
717
+ }
718
+ log.ok(`Index updated: specs/archive/INDEX.md`);
719
+ log.blank();
720
+ log.info(`Next: promote durable learnings from archive.md to contracts/ or CLAUDE.md`);
721
+ }
722
+ var init_archive = __esm({
723
+ "src/commands/archive.ts"() {
724
+ "use strict";
725
+ init_logger();
726
+ }
727
+ });
728
+
729
+ // src/commands/abandon.ts
730
+ var abandon_exports = {};
731
+ __export(abandon_exports, {
732
+ abandon: () => abandon
733
+ });
734
+ import { join as join15 } from "path";
735
+ import { existsSync as existsSync14, readFileSync as readFileSync11, writeFileSync as writeFileSync7, appendFileSync as appendFileSync2, mkdirSync as mkdirSync6 } from "fs";
736
+ async function abandon(changeId, opts) {
737
+ const cwd = process.cwd();
738
+ const changeDir = join15(cwd, "specs", "changes", changeId);
739
+ const tasksPath = join15(changeDir, "tasks.md");
740
+ if (!existsSync14(changeDir)) {
741
+ log.error(`Change not found: specs/changes/${changeId}`);
742
+ process.exit(1);
743
+ }
744
+ if (existsSync14(tasksPath)) {
745
+ let content = readFileSync11(tasksPath, "utf8");
746
+ if (content.match(/^status:/m)) {
747
+ content = content.replace(/^status: .*/m, "status: abandoned");
748
+ } else {
749
+ content = `---
750
+ change-id: ${changeId}
751
+ status: abandoned
752
+ ---
753
+
754
+ ` + content;
755
+ }
756
+ writeFileSync7(tasksPath, content, "utf8");
757
+ }
758
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
759
+ const archiveDir = join15(cwd, "specs", "archive");
760
+ const indexPath = join15(archiveDir, "INDEX.md");
761
+ const reason = opts.reason ?? "no reason given";
762
+ const indexLine = `| ${changeId} | abandoned | ${today} | ${reason} |
763
+ `;
764
+ if (!existsSync14(archiveDir)) {
765
+ mkdirSync6(archiveDir, { recursive: true });
766
+ }
767
+ if (!existsSync14(indexPath)) {
768
+ writeFileSync7(indexPath, `# Archive Index
769
+
770
+ | change-id | status | date | notes |
771
+ |---|---|---|---|
772
+ ${indexLine}`, "utf8");
773
+ } else {
774
+ appendFileSync2(indexPath, indexLine, "utf8");
775
+ }
776
+ log.ok(`Change ${changeId} marked as abandoned.`);
777
+ log.info(`specs/changes/${changeId}/ remains on disk (git history preserved).`);
778
+ log.info(`Run \`cdd-kit archive ${changeId}\` to physically move it, or leave it for git history.`);
779
+ }
780
+ var init_abandon = __esm({
781
+ "src/commands/abandon.ts"() {
782
+ "use strict";
783
+ init_logger();
784
+ }
785
+ });
786
+
318
787
  // src/commands/list-changes.ts
319
788
  var list_changes_exports = {};
320
789
  __export(list_changes_exports, {
321
790
  listChanges: () => listChanges
322
791
  });
323
- import { join as join13 } from "path";
324
- import { existsSync as existsSync12, readdirSync as readdirSync7, readFileSync as readFileSync9 } from "fs";
792
+ import { join as join16 } from "path";
793
+ import { existsSync as existsSync15, readdirSync as readdirSync9, readFileSync as readFileSync12 } from "fs";
325
794
  async function listChanges() {
326
795
  const cwd = process.cwd();
327
- const changesDir = join13(cwd, "specs", "changes");
796
+ const changesDir = join16(cwd, "specs", "changes");
328
797
  log.blank();
329
798
  const active = [];
330
- if (existsSync12(changesDir)) {
331
- active.push(...readdirSync7(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name));
799
+ if (existsSync15(changesDir)) {
800
+ active.push(...readdirSync9(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name));
332
801
  }
333
802
  if (active.length === 0) {
334
803
  log.info("No active changes in specs/changes/");
335
804
  } else {
336
805
  log.info("Active changes:");
337
806
  for (const id of active) {
338
- const tasksPath = join13(changesDir, id, "tasks.md");
807
+ const tasksPath = join16(changesDir, id, "tasks.md");
339
808
  let status = "in-progress";
340
809
  let pending = 0;
341
- if (existsSync12(tasksPath)) {
342
- const content = readFileSync9(tasksPath, "utf8");
810
+ if (existsSync15(tasksPath)) {
811
+ const content = readFileSync12(tasksPath, "utf8");
343
812
  if (content.includes("status: gate-blocked"))
344
813
  status = "gate-blocked";
345
814
  else if (content.includes("status: abandoned"))
@@ -359,40 +828,469 @@ var init_list_changes = __esm({
359
828
  }
360
829
  });
361
830
 
831
+ // src/commands/context-scan.ts
832
+ var context_scan_exports = {};
833
+ __export(context_scan_exports, {
834
+ contextScan: () => contextScan
835
+ });
836
+ import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync13, readdirSync as readdirSync10, writeFileSync as writeFileSync8 } from "fs";
837
+ import { basename, dirname as dirname4, join as join17, relative as relative3 } from "path";
838
+ function stripGlobSuffix(pattern) {
839
+ return pattern.replace(/\/\*\*$/, "").replace(/\/\*$/, "");
840
+ }
841
+ function getForbiddenPaths(cwd) {
842
+ const forbidden = new Set(DEFAULT_FORBIDDEN);
843
+ const policyPath = join17(cwd, ".cdd", "context-policy.json");
844
+ try {
845
+ if (existsSync16(policyPath)) {
846
+ const policy = JSON.parse(readFileSync13(policyPath, "utf8"));
847
+ for (const pattern of policy.forbiddenPaths ?? []) {
848
+ forbidden.add(stripGlobSuffix(pattern));
849
+ }
850
+ }
851
+ } catch {
852
+ log.warn("Could not parse .cdd/context-policy.json; using default context-scan excludes.");
853
+ }
854
+ return [...forbidden];
855
+ }
856
+ function isForbidden(relPath, forbidden) {
857
+ const normalized = relPath.replace(/\\/g, "/");
858
+ return forbidden.some((pattern) => normalized === pattern || normalized.startsWith(`${pattern}/`));
859
+ }
860
+ function buildTree(dir, cwd, forbidden, stats, prefix = "", depth = 0) {
861
+ const entries = readdirSync10(dir, { withFileTypes: true }).sort((a, b) => {
862
+ if (a.isDirectory() === b.isDirectory())
863
+ return a.name.localeCompare(b.name);
864
+ return a.isDirectory() ? -1 : 1;
865
+ });
866
+ let output = "";
867
+ const visible = entries.filter((entry) => {
868
+ const relPath = relative3(cwd, join17(dir, entry.name));
869
+ return !isForbidden(relPath, forbidden);
870
+ });
871
+ visible.forEach((entry, index) => {
872
+ const fullPath = join17(dir, entry.name);
873
+ const isLast = index === visible.length - 1;
874
+ const connector = isLast ? "\\-- " : "|-- ";
875
+ output += `${prefix}${connector}${entry.name}${entry.isDirectory() ? "/" : ""}
876
+ `;
877
+ if (entry.isDirectory()) {
878
+ stats.dirs += 1;
879
+ if (depth >= 3) {
880
+ stats.omittedDirs += 1;
881
+ output += `${prefix}${isLast ? " " : "| "}\\-- ... (max depth)
882
+ `;
883
+ } else {
884
+ output += buildTree(fullPath, cwd, forbidden, stats, prefix + (isLast ? " " : "| "), depth + 1);
885
+ }
886
+ } else {
887
+ stats.files += 1;
888
+ }
889
+ });
890
+ return output;
891
+ }
892
+ function firstHeading(content) {
893
+ const match = content.match(/^#\s+(.+)$/m);
894
+ return match?.[1]?.trim();
895
+ }
896
+ function deriveContractType(relPath, metadata) {
897
+ if (metadata.contract)
898
+ return metadata.contract;
899
+ const parts = relPath.split("/");
900
+ return parts.length >= 2 ? parts[1] : "unknown";
901
+ }
902
+ function parseContractMetadata(content) {
903
+ const metadata = {};
904
+ let summary;
905
+ const cddMatch = content.match(/<!--\s*cdd:([\s\S]*?)-->/);
906
+ const yamlMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
907
+ const block = cddMatch?.[1] ?? yamlMatch?.[1];
908
+ if (block) {
909
+ for (const line of block.split(/\r?\n/)) {
910
+ const colon = line.indexOf(":");
911
+ if (colon === -1)
912
+ continue;
913
+ const key = line.slice(0, colon).trim();
914
+ const value = line.slice(colon + 1).trim();
915
+ if (!key || !value)
916
+ continue;
917
+ if (key === "summary")
918
+ summary = value;
919
+ else
920
+ metadata[key] = value;
921
+ }
922
+ }
923
+ if (!summary) {
924
+ const summaryMatch = content.match(/#+\s*Summary\s*\r?\n+([^#\r\n][^\r\n]*)/i);
925
+ summary = summaryMatch?.[1]?.trim();
926
+ }
927
+ return { title: firstHeading(content), summary, metadata };
928
+ }
929
+ function findContractFiles(dir, found = []) {
930
+ if (!existsSync16(dir))
931
+ return found;
932
+ for (const entry of readdirSync10(dir, { withFileTypes: true })) {
933
+ const fullPath = join17(dir, entry.name);
934
+ if (entry.isDirectory())
935
+ findContractFiles(fullPath, found);
936
+ else if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "INDEX.md" && entry.name !== "CHANGELOG.md")
937
+ found.push(fullPath);
938
+ }
939
+ return found;
940
+ }
941
+ async function contextScan() {
942
+ const cwd = process.cwd();
943
+ const specsContextDir = join17(cwd, "specs", "context");
944
+ mkdirSync7(specsContextDir, { recursive: true });
945
+ const forbidden = getForbiddenPaths(cwd);
946
+ const treeStats = { dirs: 0, files: 0, omittedDirs: 0 };
947
+ const tree = buildTree(cwd, cwd, forbidden, treeStats);
948
+ writeFileSync8(
949
+ join17(specsContextDir, "project-map.md"),
950
+ [
951
+ "---",
952
+ "artifact: project-map",
953
+ "generated-by: cdd-kit context-scan",
954
+ "schema-version: 1",
955
+ `root: ${basename(cwd)}`,
956
+ `visible-dirs: ${treeStats.dirs}`,
957
+ `visible-files: ${treeStats.files}`,
958
+ `omitted-dirs: ${treeStats.omittedDirs}`,
959
+ "---",
960
+ "",
961
+ "# Project Map",
962
+ "",
963
+ "Use this deterministic map to choose candidate context paths before reading files.",
964
+ "",
965
+ "## Excluded Paths",
966
+ ...forbidden.map((path) => `- ${path}`),
967
+ "",
968
+ "## Tree",
969
+ "",
970
+ "```",
971
+ `${basename(cwd)}/`,
972
+ tree.trimEnd(),
973
+ "```",
974
+ ""
975
+ ].join("\n"),
976
+ "utf8"
977
+ );
978
+ log.ok("Created specs/context/project-map.md");
979
+ const contractFiles = findContractFiles(join17(cwd, "contracts")).sort((a, b) => relative3(cwd, a).localeCompare(relative3(cwd, b)));
980
+ const contractEntries = [];
981
+ const inventoryRows = [];
982
+ let missingSummary = 0;
983
+ for (const file of contractFiles) {
984
+ const relPath = relative3(cwd, file).replace(/\\/g, "/");
985
+ const dir = dirname4(relPath).replace(/\\/g, "/");
986
+ const { title, summary, metadata } = parseContractMetadata(readFileSync13(file, "utf8"));
987
+ const contractType = deriveContractType(relPath, metadata);
988
+ const owner = metadata.owner ?? "unknown";
989
+ const surface = metadata.surface ?? dir;
990
+ const summaryText = summary ?? "MISSING - add YAML frontmatter `summary:` or `<!-- cdd: summary: ... -->`.";
991
+ inventoryRows.push(`| ${relPath} | ${contractType} | ${surface} | ${owner} | ${summary ? "yes" : "no"} |`);
992
+ let entry = `## ${relPath}
993
+ `;
994
+ entry += `- path: \`${relPath}\`
995
+ `;
996
+ entry += `- type: ${contractType}
997
+ `;
998
+ entry += `- directory: ${dir}
999
+ `;
1000
+ if (title)
1001
+ entry += `- title: ${title}
1002
+ `;
1003
+ for (const [key, value] of Object.entries(metadata)) {
1004
+ if (key === "contract")
1005
+ continue;
1006
+ entry += `- ${key}: ${value}
1007
+ `;
1008
+ }
1009
+ entry += `- summary: ${summaryText}
1010
+
1011
+ `;
1012
+ contractEntries.push(entry);
1013
+ if (!summary) {
1014
+ missingSummary += 1;
1015
+ }
1016
+ }
1017
+ const contractIndex = [
1018
+ "---",
1019
+ "artifact: contracts-index",
1020
+ "generated-by: cdd-kit context-scan",
1021
+ "schema-version: 1",
1022
+ `contract-count: ${contractFiles.length}`,
1023
+ `missing-summary-count: ${missingSummary}`,
1024
+ "---",
1025
+ "",
1026
+ "# Contracts Index",
1027
+ "",
1028
+ "Generated from deterministic metadata. Add YAML frontmatter fields such as `summary`, `owner`, and `surface` to improve classifier accuracy.",
1029
+ "",
1030
+ "## Contract Inventory",
1031
+ "",
1032
+ "| path | type | surface | owner | has-summary |",
1033
+ "|---|---|---|---|---|",
1034
+ ...inventoryRows,
1035
+ "",
1036
+ "## Contract Details",
1037
+ "",
1038
+ ...contractEntries
1039
+ ].join("\n");
1040
+ writeFileSync8(join17(specsContextDir, "contracts-index.md"), contractIndex, "utf8");
1041
+ if (missingSummary > 0) {
1042
+ log.warn(`Created specs/context/contracts-index.md with ${missingSummary} missing summary warning(s).`);
1043
+ } else {
1044
+ log.ok("Created specs/context/contracts-index.md");
1045
+ }
1046
+ }
1047
+ var DEFAULT_FORBIDDEN;
1048
+ var init_context_scan = __esm({
1049
+ "src/commands/context-scan.ts"() {
1050
+ "use strict";
1051
+ init_logger();
1052
+ DEFAULT_FORBIDDEN = [
1053
+ ".claude",
1054
+ ".git",
1055
+ "node_modules",
1056
+ "dist",
1057
+ "build",
1058
+ "assets",
1059
+ "specs/archive",
1060
+ "specs/changes"
1061
+ ];
1062
+ }
1063
+ });
1064
+
1065
+ // src/commands/context.ts
1066
+ var context_exports = {};
1067
+ __export(context_exports, {
1068
+ approveContextExpansion: () => approveContextExpansion,
1069
+ listContextExpansions: () => listContextExpansions,
1070
+ rejectContextExpansion: () => rejectContextExpansion,
1071
+ requestContextExpansion: () => requestContextExpansion
1072
+ });
1073
+ import { existsSync as existsSync17, readFileSync as readFileSync14, writeFileSync as writeFileSync9 } from "fs";
1074
+ import { join as join18 } from "path";
1075
+ function normalizePath(path) {
1076
+ return path.replace(/\\/g, "/").replace(/^\.\//, "").trim();
1077
+ }
1078
+ function validateRepoRelativePath(path) {
1079
+ if (/^[a-zA-Z]:\//.test(path) || path.startsWith("/")) {
1080
+ return `requested path must be repo-relative: ${path}`;
1081
+ }
1082
+ if (path.split("/").includes("..")) {
1083
+ return `requested path must not contain "..": ${path}`;
1084
+ }
1085
+ return null;
1086
+ }
1087
+ function manifestPathFor(changeId) {
1088
+ return join18(process.cwd(), "specs", "changes", changeId, "context-manifest.md");
1089
+ }
1090
+ function readManifest(changeId) {
1091
+ const manifestPath = manifestPathFor(changeId);
1092
+ if (!existsSync17(manifestPath)) {
1093
+ log.error(`context manifest not found: specs/changes/${changeId}/context-manifest.md`);
1094
+ process.exit(1);
1095
+ }
1096
+ return readFileSync14(manifestPath, "utf8");
1097
+ }
1098
+ function writeManifest(changeId, content) {
1099
+ writeFileSync9(manifestPathFor(changeId), content.endsWith("\n") ? content : `${content}
1100
+ `, "utf8");
1101
+ }
1102
+ function sectionBody(content, heading) {
1103
+ const match = content.match(new RegExp(`## ${heading}\\s*\\n([\\s\\S]*?)(?=\\n## |$)`));
1104
+ return match?.[1] ?? "";
1105
+ }
1106
+ function parseRequests(content) {
1107
+ const body = sectionBody(content, "Context Expansion Requests");
1108
+ if (!body.trim())
1109
+ return [];
1110
+ const requests = [];
1111
+ const blocks = body.split(/(?=^\s*-\s*request-id:\s*)/m);
1112
+ for (const block of blocks) {
1113
+ const idMatch = block.match(/^\s*-\s*request-id:\s*(\S+)/m);
1114
+ if (!idMatch)
1115
+ continue;
1116
+ const statusMatch = block.match(/^\s*status:\s*(\S+)/im);
1117
+ const reasonMatch = block.match(/^\s*reason:\s*(.+)$/im);
1118
+ const paths = [];
1119
+ let inPaths = false;
1120
+ for (const line of block.split(/\r?\n/)) {
1121
+ if (/^\s*requested_paths:\s*$/.test(line)) {
1122
+ inPaths = true;
1123
+ continue;
1124
+ }
1125
+ if (!inPaths)
1126
+ continue;
1127
+ const item = line.match(/^\s*-\s+(.+?)\s*$/);
1128
+ if (item) {
1129
+ paths.push(normalizePath(item[1]));
1130
+ continue;
1131
+ }
1132
+ if (/^\s*[a-zA-Z_-]+:\s*/.test(line))
1133
+ break;
1134
+ }
1135
+ requests.push({
1136
+ requestId: idMatch[1],
1137
+ paths,
1138
+ reason: reasonMatch?.[1]?.trim(),
1139
+ status: statusMatch?.[1]?.trim().toLowerCase() ?? "unknown"
1140
+ });
1141
+ }
1142
+ return requests;
1143
+ }
1144
+ function approvedExpansionSet(content) {
1145
+ const body = sectionBody(content, "Approved Expansions");
1146
+ const approved = /* @__PURE__ */ new Set();
1147
+ for (const line of body.split(/\r?\n/)) {
1148
+ const item = line.match(/^\s*-\s+(.+?)\s*$/);
1149
+ if (!item)
1150
+ continue;
1151
+ const value = normalizePath(item[1]);
1152
+ if (value && value !== "-")
1153
+ approved.add(value);
1154
+ }
1155
+ return approved;
1156
+ }
1157
+ function replaceSection(content, heading, lines) {
1158
+ const nextSection = [`## ${heading}`, ...lines, ""].join("\n");
1159
+ const pattern = new RegExp(`## ${heading}\\s*\\n[\\s\\S]*?(?=\\n## |$)`);
1160
+ if (pattern.test(content))
1161
+ return content.replace(pattern, nextSection.trimEnd());
1162
+ return `${content.trimEnd()}
1163
+
1164
+ ${nextSection}`;
1165
+ }
1166
+ function renderRequests(requests) {
1167
+ if (requests.length === 0)
1168
+ return ["-"];
1169
+ const lines = [];
1170
+ for (const request of requests) {
1171
+ lines.push(`- request-id: ${request.requestId}`);
1172
+ lines.push(" requested_paths:");
1173
+ for (const path of request.paths)
1174
+ lines.push(` - ${path}`);
1175
+ if (request.reason)
1176
+ lines.push(` reason: ${request.reason}`);
1177
+ lines.push(` status: ${request.status}`);
1178
+ lines.push("");
1179
+ }
1180
+ if (lines[lines.length - 1] === "")
1181
+ lines.pop();
1182
+ return lines;
1183
+ }
1184
+ function setRequestStatus(content, requestId, status) {
1185
+ const requests = parseRequests(content);
1186
+ const target = requests.find((request) => request.requestId === requestId);
1187
+ if (!target) {
1188
+ log.error(`context expansion request not found: ${requestId}`);
1189
+ process.exit(1);
1190
+ }
1191
+ if (target.status !== "pending") {
1192
+ log.error(`pending context expansion request not found: ${requestId}`);
1193
+ process.exit(1);
1194
+ }
1195
+ const next = requests.map((request) => request.requestId === requestId ? { ...request, status } : request);
1196
+ return replaceSection(content, "Context Expansion Requests", renderRequests(next));
1197
+ }
1198
+ async function requestContextExpansion(changeId, requestId, paths, reason) {
1199
+ if (paths.length === 0) {
1200
+ log.error("at least one --path value is required");
1201
+ process.exit(1);
1202
+ }
1203
+ const normalizedPaths = [...new Set(paths.map(normalizePath).filter(Boolean))];
1204
+ for (const path of normalizedPaths) {
1205
+ const validationError = validateRepoRelativePath(path);
1206
+ if (validationError) {
1207
+ log.error(validationError);
1208
+ process.exit(1);
1209
+ }
1210
+ }
1211
+ const content = readManifest(changeId);
1212
+ const requests = parseRequests(content);
1213
+ if (requests.some((request) => request.requestId === requestId)) {
1214
+ log.error(`context expansion request already exists: ${requestId}`);
1215
+ process.exit(1);
1216
+ }
1217
+ const next = replaceSection(content, "Context Expansion Requests", renderRequests([
1218
+ ...requests,
1219
+ { requestId, paths: normalizedPaths, reason, status: "pending" }
1220
+ ]));
1221
+ writeManifest(changeId, next);
1222
+ log.ok(`recorded context expansion request ${requestId} for ${changeId}`);
1223
+ for (const path of normalizedPaths)
1224
+ log.info(` ${path}`);
1225
+ }
1226
+ async function listContextExpansions(changeId, json = false) {
1227
+ const requests = parseRequests(readManifest(changeId));
1228
+ if (json) {
1229
+ console.log(JSON.stringify({ changeId, requests }, null, 2));
1230
+ return;
1231
+ }
1232
+ if (requests.length === 0) {
1233
+ log.info(`no context expansion requests for ${changeId}`);
1234
+ return;
1235
+ }
1236
+ log.info(`context expansion requests for ${changeId}`);
1237
+ for (const request of requests) {
1238
+ log.info(`- ${request.requestId} [${request.status}] ${request.reason ?? ""}`.trimEnd());
1239
+ for (const path of request.paths)
1240
+ log.dim(` ${path}`);
1241
+ }
1242
+ }
1243
+ async function approveContextExpansion(changeId, requestId) {
1244
+ const content = readManifest(changeId);
1245
+ const request = parseRequests(content).find((item) => item.requestId === requestId && item.status === "pending");
1246
+ if (!request) {
1247
+ log.error(`pending context expansion request not found: ${requestId}`);
1248
+ process.exit(1);
1249
+ }
1250
+ if (request.paths.length === 0) {
1251
+ log.error(`context expansion request has no requested_paths: ${requestId}`);
1252
+ process.exit(1);
1253
+ }
1254
+ for (const path of request.paths) {
1255
+ const validationError = validateRepoRelativePath(path);
1256
+ if (validationError) {
1257
+ log.error(validationError);
1258
+ process.exit(1);
1259
+ }
1260
+ }
1261
+ const approved = approvedExpansionSet(content);
1262
+ for (const path of request.paths)
1263
+ approved.add(path);
1264
+ let next = replaceSection(content, "Approved Expansions", [...[...approved].sort().map((path) => `- ${path}`)]);
1265
+ next = setRequestStatus(next, requestId, "approved");
1266
+ writeManifest(changeId, next);
1267
+ log.ok(`approved context expansion ${requestId} for ${changeId}`);
1268
+ for (const path of request.paths)
1269
+ log.info(` ${path}`);
1270
+ }
1271
+ async function rejectContextExpansion(changeId, requestId) {
1272
+ const next = setRequestStatus(readManifest(changeId), requestId, "rejected");
1273
+ writeManifest(changeId, next);
1274
+ log.ok(`rejected context expansion ${requestId} for ${changeId}`);
1275
+ }
1276
+ var init_context = __esm({
1277
+ "src/commands/context.ts"() {
1278
+ "use strict";
1279
+ init_logger();
1280
+ }
1281
+ });
1282
+
362
1283
  // src/cli/index.ts
363
- import { readFileSync as readFileSync10 } from "fs";
1284
+ import { readFileSync as readFileSync15 } from "fs";
364
1285
  import { fileURLToPath as fileURLToPath2 } from "url";
365
- import { dirname as dirname3, join as join14 } from "path";
1286
+ import { dirname as dirname5, join as join19 } from "path";
366
1287
  import { Command } from "commander";
367
1288
 
368
1289
  // src/commands/init.ts
1290
+ init_paths();
369
1291
  import { join as join4 } from "path";
370
1292
  import { rmSync, readFileSync as readFileSync2, writeFileSync, existsSync as existsSync3, readdirSync as readdirSync2 } from "fs";
371
1293
 
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
1294
  // src/utils/copy.ts
397
1295
  init_logger();
398
1296
  import {
@@ -607,8 +1505,14 @@ async function init(opts) {
607
1505
  log.error("--global-only and --local-only are mutually exclusive.");
608
1506
  process.exit(1);
609
1507
  }
1508
+ if (!["claude", "codex", "both"].includes(opts.provider)) {
1509
+ log.error(`Invalid provider: ${opts.provider}. Use claude, codex, or both.`);
1510
+ process.exit(1);
1511
+ }
610
1512
  const cwd = process.cwd();
611
1513
  const createdPaths = [];
1514
+ const installClaude = opts.provider === "claude" || opts.provider === "both";
1515
+ const installCodex = opts.provider === "codex" || opts.provider === "both";
612
1516
  function track(paths) {
613
1517
  createdPaths.push(...paths);
614
1518
  }
@@ -627,7 +1531,7 @@ async function init(opts) {
627
1531
  log.info("Initialising contract-driven-delivery kit\u2026");
628
1532
  log.blank();
629
1533
  try {
630
- if (!opts.localOnly) {
1534
+ if (!opts.localOnly && installClaude) {
631
1535
  log.info(`Installing agents \u2192 ${AGENTS_HOME}`);
632
1536
  const { count: agentCount, created: agentCreated } = copyDirTracked(ASSET.agents, AGENTS_HOME, { overwrite: true });
633
1537
  track(agentCreated);
@@ -643,6 +1547,9 @@ async function init(opts) {
643
1547
  }
644
1548
  log.ok(`${totalSkillFiles} skill file(s) installed (${skillDirs.length} skills).`);
645
1549
  log.blank();
1550
+ } else if (!opts.localOnly && installCodex) {
1551
+ log.info("No global assets for provider: codex.");
1552
+ log.blank();
646
1553
  }
647
1554
  if (!opts.globalOnly) {
648
1555
  log.info(`Scaffolding project files in ${cwd}`);
@@ -674,6 +1581,21 @@ async function init(opts) {
674
1581
  );
675
1582
  track(ciCreated);
676
1583
  log.ok(`ci/ \u2014 ${ciCount} file(s) written.`);
1584
+ const { count: cddConfigCount, created: cddConfigCreated } = copyDirTracked(
1585
+ ASSET.cddConfig,
1586
+ join4(cwd, ".cdd"),
1587
+ { overwrite: opts.force, label: ".cdd" }
1588
+ );
1589
+ track(cddConfigCreated);
1590
+ log.ok(`.cdd/ - ${cddConfigCount} file(s) written.`);
1591
+ const modelPolicyPath = join4(cwd, ".cdd", "model-policy.json");
1592
+ if (existsSync3(modelPolicyPath)) {
1593
+ writeFileSync(modelPolicyPath, JSON.stringify({
1594
+ provider: opts.provider,
1595
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
1596
+ roles: {}
1597
+ }, null, 2) + "\n", "utf8");
1598
+ }
677
1599
  const { count: wfCount, created: wfCreated } = copyDirTracked(
678
1600
  ASSET.githubWorkflows,
679
1601
  join4(cwd, ".github", "workflows"),
@@ -717,24 +1639,37 @@ async function init(opts) {
717
1639
  }
718
1640
  }
719
1641
  }
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.");
1642
+ if (installClaude) {
1643
+ const { written: claudeWritten, created: claudeCreated } = copyFileTracked(
1644
+ ASSET.claudeTemplate,
1645
+ join4(cwd, "CLAUDE.md"),
1646
+ { overwrite: false, label: "CLAUDE.md" }
1647
+ );
1648
+ if (claudeCreated)
1649
+ track([join4(cwd, "CLAUDE.md")]);
1650
+ if (claudeWritten)
1651
+ log.ok("CLAUDE.md created.");
1652
+ const { written: agentsWritten, created: agentsCreated } = copyFileTracked(
1653
+ ASSET.agentsTemplate,
1654
+ join4(cwd, "AGENTS.md"),
1655
+ { overwrite: false, label: "AGENTS.md" }
1656
+ );
1657
+ if (agentsCreated)
1658
+ track([join4(cwd, "AGENTS.md")]);
1659
+ if (agentsWritten)
1660
+ log.ok("AGENTS.md created.");
1661
+ }
1662
+ if (installCodex) {
1663
+ const { written: codexWritten, created: codexCreated } = copyFileTracked(
1664
+ ASSET.codexTemplate,
1665
+ join4(cwd, "CODEX.md"),
1666
+ { overwrite: false, label: "CODEX.md" }
1667
+ );
1668
+ if (codexCreated)
1669
+ track([join4(cwd, "CODEX.md")]);
1670
+ if (codexWritten)
1671
+ log.ok("CODEX.md created.");
1672
+ }
738
1673
  log.blank();
739
1674
  }
740
1675
  } catch (err) {
@@ -744,33 +1679,39 @@ async function init(opts) {
744
1679
  }
745
1680
  log.ok("Done.");
746
1681
  log.blank();
747
- log.info("Use the contract-driven-delivery skill in Claude Code to scan this repo.");
1682
+ if (opts.provider === "codex") {
1683
+ log.info("Use CODEX.md and cdd-kit commands to run the contract-driven workflow.");
1684
+ } else {
1685
+ log.info("Use the contract-driven-delivery skill in Claude Code to scan this repo.");
1686
+ }
748
1687
  log.blank();
749
1688
  }
750
1689
 
751
1690
  // 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";
1691
+ init_paths();
755
1692
  init_logger();
1693
+ init_provider();
1694
+ import { join as join6 } from "path";
1695
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readdirSync as readdirSync3, copyFileSync as copyFileSync2, readFileSync as readFileSync4 } from "fs";
1696
+ import { createHash } from "crypto";
756
1697
  import { homedir as homedir2 } from "os";
757
1698
  function fileHash(filePath) {
758
- const buf = readFileSync3(filePath);
1699
+ const buf = readFileSync4(filePath);
759
1700
  return createHash("sha256").update(buf).digest("hex");
760
1701
  }
761
1702
  function diffDir(src, dest) {
762
1703
  const entries = [];
763
- if (!existsSync4(src))
1704
+ if (!existsSync5(src))
764
1705
  return entries;
765
1706
  function walk(currentSrc, currentDest) {
766
1707
  const items = readdirSync3(currentSrc, { withFileTypes: true });
767
1708
  for (const item of items) {
768
- const srcPath = join5(currentSrc, item.name);
769
- const destPath = join5(currentDest, item.name);
1709
+ const srcPath = join6(currentSrc, item.name);
1710
+ const destPath = join6(currentDest, item.name);
770
1711
  if (item.isDirectory()) {
771
1712
  walk(srcPath, destPath);
772
1713
  } else {
773
- if (!existsSync4(destPath)) {
1714
+ if (!existsSync5(destPath)) {
774
1715
  entries.push({ src: srcPath, dest: destPath, action: "add" });
775
1716
  } else if (fileHash(srcPath) !== fileHash(destPath)) {
776
1717
  entries.push({ src: srcPath, dest: destPath, action: "overwrite" });
@@ -788,21 +1729,21 @@ function applyDir(entries) {
788
1729
  for (const e of entries) {
789
1730
  if (e.action === "skip")
790
1731
  continue;
791
- mkdirSync2(join5(e.dest, ".."), { recursive: true });
1732
+ mkdirSync2(join6(e.dest, ".."), { recursive: true });
792
1733
  copyFileSync2(e.src, e.dest);
793
1734
  count += 1;
794
1735
  }
795
1736
  return count;
796
1737
  }
797
1738
  function backupDir(dir, backupDest) {
798
- if (!existsSync4(dir))
1739
+ if (!existsSync5(dir))
799
1740
  return;
800
1741
  mkdirSync2(backupDest, { recursive: true });
801
1742
  function walk(src, dst) {
802
1743
  const items = readdirSync3(src, { withFileTypes: true });
803
1744
  for (const item of items) {
804
- const s = join5(src, item.name);
805
- const d = join5(dst, item.name);
1745
+ const s = join6(src, item.name);
1746
+ const d = join6(dst, item.name);
806
1747
  if (item.isDirectory()) {
807
1748
  mkdirSync2(d, { recursive: true });
808
1749
  walk(s, d);
@@ -814,15 +1755,29 @@ function backupDir(dir, backupDest) {
814
1755
  }
815
1756
  async function update(opts) {
816
1757
  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);
1758
+ const cwd = process.cwd();
1759
+ const requestedProvider = opts.provider ?? "auto";
1760
+ if (!validateProviderOption(requestedProvider)) {
1761
+ log.error(`Invalid provider: ${requestedProvider}. Use auto, claude, codex, or both.`);
1762
+ process.exit(1);
1763
+ }
1764
+ const provider = inferProvider(cwd, requestedProvider);
1765
+ const updateClaudeAssets = provider === "claude" || provider === "both";
1766
+ const skillDest = join6(SKILLS_HOME, "contract-driven-delivery");
1767
+ const agentDiff = updateClaudeAssets ? diffDir(ASSET.agents, AGENTS_HOME) : [];
1768
+ const skillDiff = updateClaudeAssets ? diffDir(ASSET.skill, skillDest) : [];
820
1769
  const toWrite = [...agentDiff, ...skillDiff].filter((e) => e.action !== "skip");
821
1770
  const toAdd = toWrite.filter((e) => e.action === "add");
822
1771
  const toOver = toWrite.filter((e) => e.action === "overwrite");
823
1772
  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}`);
1773
+ log.info(`Provider: ${provider}`);
1774
+ if (updateClaudeAssets) {
1775
+ log.info(`Dry-run diff \u2014 agents: ${AGENTS_HOME}`);
1776
+ log.info(`Dry-run diff \u2014 skill: ${skillDest}`);
1777
+ } else {
1778
+ log.info("Codex provider has no global cdd-kit assets to update.");
1779
+ log.info("Project files are preserved; run cdd-kit init --local-only --provider codex to add missing local guidance.");
1780
+ }
826
1781
  log.blank();
827
1782
  if (toAdd.length)
828
1783
  log.info(` + ${toAdd.length} file(s) would be added`);
@@ -844,19 +1799,21 @@ async function update(opts) {
844
1799
  return;
845
1800
  }
846
1801
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
847
- const backupRoot = join5(homedir2(), ".claude", ".cdd-kit-backup", timestamp);
1802
+ const backupRoot = join6(homedir2(), ".claude", ".cdd-kit-backup", timestamp);
848
1803
  log.blank();
849
1804
  log.info(`Backing up to ${backupRoot} \u2026`);
850
- backupDir(AGENTS_HOME, join5(backupRoot, "agents"));
851
- backupDir(skillDest, join5(backupRoot, "skill"));
1805
+ backupDir(AGENTS_HOME, join6(backupRoot, "agents"));
1806
+ backupDir(skillDest, join6(backupRoot, "skill"));
852
1807
  log.ok(`Backup complete: ${backupRoot}`);
853
1808
  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.`);
1809
+ if (updateClaudeAssets) {
1810
+ log.info(`Updating agents \u2192 ${AGENTS_HOME}`);
1811
+ const agentCount = applyDir(agentDiff);
1812
+ log.ok(`${agentCount} agent file(s) updated.`);
1813
+ log.info(`Updating skill \u2192 ${skillDest}`);
1814
+ const skillCount = applyDir(skillDiff);
1815
+ log.ok(`${skillCount} skill file(s) updated.`);
1816
+ }
860
1817
  log.blank();
861
1818
  log.info("Project files (contracts/, specs/, tests/, ci/) were not changed.");
862
1819
  log.ok("Update complete.");
@@ -865,15 +1822,17 @@ async function update(opts) {
865
1822
  }
866
1823
 
867
1824
  // src/commands/new-change.ts
868
- import { join as join6 } from "path";
869
- import { existsSync as existsSync5, readdirSync as readdirSync4 } from "fs";
1825
+ init_paths();
1826
+ import { join as join7 } from "path";
1827
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync4, writeFileSync as writeFileSync2 } from "fs";
870
1828
  init_logger();
871
1829
  var REQUIRED_TEMPLATES = [
872
1830
  "change-request.md",
873
1831
  "change-classification.md",
874
1832
  "test-plan.md",
875
1833
  "ci-gates.md",
876
- "tasks.md"
1834
+ "tasks.md",
1835
+ "context-manifest.md"
877
1836
  ];
878
1837
  function listOptional() {
879
1838
  try {
@@ -884,14 +1843,31 @@ function listOptional() {
884
1843
  }
885
1844
  }
886
1845
  var SAFE_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
1846
+ function parseDependsOn(raw) {
1847
+ if (!raw)
1848
+ return [];
1849
+ return raw.split(",").map((item) => item.trim()).filter(Boolean);
1850
+ }
1851
+ function formatDependsOn(ids) {
1852
+ if (ids.length === 0)
1853
+ return "depends-on: []";
1854
+ return `depends-on: [${ids.join(", ")}]`;
1855
+ }
887
1856
  async function newChange(name, opts) {
888
1857
  if (!SAFE_NAME.test(name)) {
889
1858
  log.error(`Invalid change name: "${name}". Use letters, numbers, hyphens, or underscores (max 64 chars).`);
890
1859
  process.exit(1);
891
1860
  }
1861
+ const dependencies = parseDependsOn(opts.dependsOn);
1862
+ for (const dep of dependencies) {
1863
+ if (!SAFE_NAME.test(dep)) {
1864
+ log.error(`Invalid dependency name: "${dep}". Use letters, numbers, hyphens, or underscores (max 64 chars).`);
1865
+ process.exit(1);
1866
+ }
1867
+ }
892
1868
  const cwd = process.cwd();
893
- const changeDir = join6(cwd, "specs", "changes", name);
894
- if (existsSync5(changeDir)) {
1869
+ const changeDir = join7(cwd, "specs", "changes", name);
1870
+ if (existsSync6(changeDir)) {
895
1871
  if (opts.force) {
896
1872
  log.warn(`Forcing re-scaffold of existing change directory: ${changeDir}`);
897
1873
  log.warn("Existing files will NOT be deleted; only template files will be overwritten.");
@@ -907,9 +1883,9 @@ async function newChange(name, opts) {
907
1883
  const templates = opts.all ? [...REQUIRED_TEMPLATES, ...listOptional()] : [...REQUIRED_TEMPLATES];
908
1884
  let written = 0;
909
1885
  for (const tmpl of templates) {
910
- const src = join6(ASSET.specsTemplates, tmpl);
911
- const dest = join6(changeDir, tmpl);
912
- if (!existsSync5(src)) {
1886
+ const src = join7(ASSET.specsTemplates, tmpl);
1887
+ const dest = join7(changeDir, tmpl);
1888
+ if (!existsSync6(src)) {
913
1889
  log.warn(`Template not found, skipping: ${tmpl}`);
914
1890
  continue;
915
1891
  }
@@ -917,16 +1893,26 @@ async function newChange(name, opts) {
917
1893
  log.dim(tmpl);
918
1894
  written += 1;
919
1895
  }
1896
+ if (dependencies.length > 0) {
1897
+ const tasksPath = join7(changeDir, "tasks.md");
1898
+ if (existsSync6(tasksPath)) {
1899
+ const tasks = readFileSync5(tasksPath, "utf8");
1900
+ const nextTasks = tasks.replace(/^depends-on:\s*.*$/m, formatDependsOn(dependencies));
1901
+ writeFileSync2(tasksPath, nextTasks, "utf8");
1902
+ log.dim(`depends-on: ${dependencies.join(", ")}`);
1903
+ }
1904
+ }
920
1905
  log.blank();
921
1906
  log.ok(`${written} template(s) created in specs/changes/${name}`);
922
1907
  log.blank();
923
1908
  }
924
1909
 
925
1910
  // src/commands/validate.ts
926
- import { join as join7 } from "path";
927
- import { existsSync as existsSync6 } from "fs";
928
- import { spawnSync } from "child_process";
1911
+ init_paths();
929
1912
  init_logger();
1913
+ import { join as join8 } from "path";
1914
+ import { existsSync as existsSync7 } from "fs";
1915
+ import { spawnSync } from "child_process";
930
1916
  var VALIDATORS = [
931
1917
  {
932
1918
  flag: "contracts",
@@ -958,15 +1944,15 @@ async function validate(opts) {
958
1944
  log.error(e instanceof Error ? e.message : String(e));
959
1945
  process.exit(1);
960
1946
  }
961
- const scriptsDir = join7(ASSET.skill, "scripts");
1947
+ const scriptsDir = join8(ASSET.skill, "scripts");
962
1948
  const runAll = !opts.contracts && !opts.env && !opts.ci && !opts.spec && !opts.versions;
963
1949
  log.blank();
964
1950
  let failed = false;
965
1951
  for (const v of VALIDATORS) {
966
1952
  if (!runAll && !opts[v.flag])
967
1953
  continue;
968
- const scriptPath = join7(scriptsDir, v.script);
969
- if (!existsSync6(scriptPath)) {
1954
+ const scriptPath = join8(scriptsDir, v.script);
1955
+ if (!existsSync7(scriptPath)) {
970
1956
  log.warn(`${v.label}: script not found, skipping (${v.script})`);
971
1957
  log.blank();
972
1958
  continue;
@@ -982,8 +1968,8 @@ async function validate(opts) {
982
1968
  log.blank();
983
1969
  if (v.chain) {
984
1970
  for (const chained of v.chain) {
985
- const chainedPath = join7(scriptsDir, chained.script);
986
- if (!existsSync6(chainedPath)) {
1971
+ const chainedPath = join8(scriptsDir, chained.script);
1972
+ if (!existsSync7(chainedPath)) {
987
1973
  log.warn(`${chained.label}: script not found, skipping (${chained.script})`);
988
1974
  log.blank();
989
1975
  continue;
@@ -1011,15 +1997,16 @@ async function validate(opts) {
1011
1997
 
1012
1998
  // src/commands/gate.ts
1013
1999
  init_logger();
1014
- import { existsSync as existsSync7, readFileSync as readFileSync4, readdirSync as readdirSync5 } from "fs";
1015
- import { join as join8 } from "path";
2000
+ import { existsSync as existsSync8, readFileSync as readFileSync6, readdirSync as readdirSync5 } from "fs";
2001
+ import { join as join9 } from "path";
1016
2002
  import { spawnSync as spawnSync2 } from "child_process";
1017
2003
  var REQUIRED_FILES = [
1018
2004
  "change-request.md",
1019
2005
  "change-classification.md",
1020
2006
  "test-plan.md",
1021
2007
  "ci-gates.md",
1022
- "tasks.md"
2008
+ "tasks.md",
2009
+ "context-manifest.md"
1023
2010
  ];
1024
2011
  var TIER_PATTERN = /\b(tier\s*[0-5]|low|medium|high|critical)\b/i;
1025
2012
  var MIN_CHARS = {
@@ -1027,45 +2014,251 @@ var MIN_CHARS = {
1027
2014
  "test-plan.md": 200,
1028
2015
  "ci-gates.md": 150,
1029
2016
  "change-request.md": 100,
1030
- "tasks.md": 100
2017
+ "tasks.md": 100,
2018
+ "context-manifest.md": 50
1031
2019
  };
1032
2020
  function meaningfulChars(text) {
1033
2021
  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
2022
  }
2023
+ function stripHtmlComments(text) {
2024
+ return text.replace(/<!--[\s\S]*?-->/g, "");
2025
+ }
2026
+ function pathMatches(relPath, patterns, currentChangeId) {
2027
+ const normalized = relPath.replace(/\\/g, "/").replace(/^\.\//, "");
2028
+ return patterns.some((rawPattern) => {
2029
+ const pattern = rawPattern.replace(/\\/g, "/").replace(/^\.\//, "");
2030
+ if (pattern === "specs/changes/*" && currentChangeId) {
2031
+ const current = `specs/changes/${currentChangeId}`;
2032
+ if (normalized === current || normalized.startsWith(`${current}/`))
2033
+ return false;
2034
+ return normalized.startsWith("specs/changes/");
2035
+ }
2036
+ if (pattern.endsWith("/**")) {
2037
+ const base = pattern.slice(0, -3);
2038
+ return normalized === base || normalized.startsWith(`${base}/`);
2039
+ }
2040
+ if (pattern.endsWith("/*")) {
2041
+ const base = pattern.slice(0, -2);
2042
+ if (!normalized.startsWith(`${base}/`))
2043
+ return false;
2044
+ return !normalized.slice(base.length + 1).includes("/");
2045
+ }
2046
+ return normalized === pattern || normalized.startsWith(`${pattern}/`);
2047
+ });
2048
+ }
2049
+ function parseListSection(content, heading) {
2050
+ const clean = stripHtmlComments(content);
2051
+ const match = clean.match(new RegExp(`## ${heading}\\s*\\n([\\s\\S]*?)(?:\\n## |$)`));
2052
+ if (!match)
2053
+ return [];
2054
+ return match[1].split(/\r?\n/).map((line) => line.replace(/^\s*-\s*/, "").trim()).filter((item) => item && item !== "-" && item.toLowerCase() !== "none");
2055
+ }
2056
+ function parseContextManifest(content) {
2057
+ const clean = stripHtmlComments(content);
2058
+ const requestMatch = clean.match(/## Context Expansion Requests\s*\n([\s\S]*?)(?:\n## |$)/);
2059
+ const pendingExpansions = requestMatch ? (requestMatch[1].match(/^\s*-\s*status:\s*pending\b/gim) || []).length : 0;
2060
+ return {
2061
+ allowedPaths: parseListSection(content, "Allowed Paths"),
2062
+ approvedExpansions: parseListSection(content, "Approved Expansions"),
2063
+ pendingExpansions
2064
+ };
2065
+ }
2066
+ function loadContextPolicy(cwd) {
2067
+ const defaults = {
2068
+ forbiddenPaths: [
2069
+ ".claude/worktrees/**",
2070
+ ".git/**",
2071
+ "node_modules/**",
2072
+ "dist/**",
2073
+ "build/**",
2074
+ "assets/**",
2075
+ "specs/archive/**",
2076
+ "specs/changes/*"
2077
+ ],
2078
+ audit: {
2079
+ requireFilesRead: true,
2080
+ unknownFilesRead: "warn-for-legacy-fail-for-new"
2081
+ }
2082
+ };
2083
+ const policyPath = join9(cwd, ".cdd", "context-policy.json");
2084
+ if (!existsSync8(policyPath))
2085
+ return defaults;
2086
+ try {
2087
+ const custom = JSON.parse(readFileSync6(policyPath, "utf8"));
2088
+ return {
2089
+ ...defaults,
2090
+ ...custom,
2091
+ forbiddenPaths: Array.from(/* @__PURE__ */ new Set([...defaults.forbiddenPaths, ...custom.forbiddenPaths ?? []])),
2092
+ audit: { ...defaults.audit, ...custom.audit ?? {} }
2093
+ };
2094
+ } catch {
2095
+ log.warn("could not parse .cdd/context-policy.json; using default context policy");
2096
+ return defaults;
2097
+ }
2098
+ }
2099
+ function isContextGovernedChange(changeDir) {
2100
+ const tasksPath = join9(changeDir, "tasks.md");
2101
+ if (!existsSync8(tasksPath))
2102
+ return false;
2103
+ return /^context-governance:\s*v1\b/m.test(readFileSync6(tasksPath, "utf8"));
2104
+ }
2105
+ function parseDependsOn2(content) {
2106
+ const lineMatch = content.match(/^depends-on:\s*(.+)$/m);
2107
+ if (!lineMatch)
2108
+ return [];
2109
+ const raw = lineMatch[1].trim();
2110
+ if (!raw || raw === "[]")
2111
+ return [];
2112
+ if (raw.startsWith("[") && raw.endsWith("]")) {
2113
+ return raw.slice(1, -1).split(",").map((item) => item.trim()).filter(Boolean);
2114
+ }
2115
+ return raw.split(",").map((item) => item.trim()).filter(Boolean);
2116
+ }
2117
+ function parseTaskStatus(content) {
2118
+ const match = content.match(/^status:\s*([a-zA-Z0-9_-]+)/m);
2119
+ return match ? match[1].trim().toLowerCase() : "in-progress";
2120
+ }
2121
+ function isArchivedChange(cwd, changeId) {
2122
+ const archiveRoot = join9(cwd, "specs", "archive");
2123
+ if (!existsSync8(archiveRoot))
2124
+ return false;
2125
+ const years = readdirSync5(archiveRoot, { withFileTypes: true }).filter((d) => d.isDirectory());
2126
+ return years.some((year) => existsSync8(join9(archiveRoot, year.name, changeId)));
2127
+ }
2128
+ function validateDependencies(cwd, changeId, changeDir) {
2129
+ const tasksPath = join9(changeDir, "tasks.md");
2130
+ if (!existsSync8(tasksPath))
2131
+ return [];
2132
+ const dependencies = parseDependsOn2(readFileSync6(tasksPath, "utf8"));
2133
+ const errors = [];
2134
+ for (const dep of dependencies) {
2135
+ if (dep === changeId) {
2136
+ errors.push(`tasks.md: change cannot depend on itself (${dep})`);
2137
+ continue;
2138
+ }
2139
+ const upstreamDir = join9(cwd, "specs", "changes", dep);
2140
+ if (existsSync8(upstreamDir)) {
2141
+ const upstreamTasks = join9(upstreamDir, "tasks.md");
2142
+ if (!existsSync8(upstreamTasks)) {
2143
+ errors.push(`dependency ${dep}: missing tasks.md`);
2144
+ continue;
2145
+ }
2146
+ const status = parseTaskStatus(readFileSync6(upstreamTasks, "utf8"));
2147
+ if (!["complete", "completed", "done"].includes(status)) {
2148
+ errors.push(`dependency ${dep}: upstream change is not completed (status: ${status})`);
2149
+ }
2150
+ continue;
2151
+ }
2152
+ if (!isArchivedChange(cwd, dep)) {
2153
+ errors.push(`dependency ${dep}: upstream change not found in specs/changes/ or specs/archive/`);
2154
+ }
2155
+ }
2156
+ return errors;
2157
+ }
2158
+ function parseFilesRead(content) {
2159
+ const clean = stripHtmlComments(content);
2160
+ const allLines = clean.split(/\r?\n/);
2161
+ const startIndex = allLines.findIndex((line) => /^\s*-\s*files-read:\s*$/.test(line));
2162
+ if (startIndex === -1)
2163
+ return { present: false, files: [], errors: [] };
2164
+ const files = [];
2165
+ const errors = [];
2166
+ const lines = [];
2167
+ for (let i = startIndex + 1; i < allLines.length; i++) {
2168
+ const line = allLines[i];
2169
+ if (/^-\s*[a-zA-Z][\w-]*:\s*/.test(line) || /^#/.test(line))
2170
+ break;
2171
+ lines.push(line);
2172
+ }
2173
+ for (const rawLine of lines) {
2174
+ if (!rawLine.trim())
2175
+ continue;
2176
+ const itemMatch = rawLine.match(/^\s{2,}-\s+(.+?)\s*$/);
2177
+ if (!itemMatch) {
2178
+ errors.push(`invalid files-read entry format: ${rawLine.trim()}`);
2179
+ continue;
2180
+ }
2181
+ const item = itemMatch[1].trim();
2182
+ if (!item || item === "-" || item.toLowerCase() === "none" || item.toLowerCase() === "unknown") {
2183
+ continue;
2184
+ }
2185
+ const normalized = item.replace(/\\/g, "/").replace(/^\.\//, "");
2186
+ if (/^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("/")) {
2187
+ errors.push(`files-read path must be repo-relative: ${item}`);
2188
+ continue;
2189
+ }
2190
+ if (normalized.split("/").includes("..")) {
2191
+ errors.push(`files-read path must not contain "..": ${item}`);
2192
+ continue;
2193
+ }
2194
+ files.push(normalized);
2195
+ }
2196
+ if (files.length === 0 && errors.length === 0) {
2197
+ errors.push("files-read section must list repo-relative paths or omit the section for legacy changes");
2198
+ }
2199
+ return { present: true, files, errors };
2200
+ }
1035
2201
  async function gate(changeId, opts = {}) {
1036
2202
  const strict = opts.strict ?? false;
1037
2203
  const cwd = process.cwd();
1038
- const changeDir = join8(cwd, "specs", "changes", changeId);
1039
- if (!existsSync7(changeDir)) {
2204
+ const changeDir = join9(cwd, "specs", "changes", changeId);
2205
+ if (!existsSync8(changeDir)) {
1040
2206
  log.error(`change not found: ${changeId} (looked in ${changeDir})`);
1041
2207
  process.exit(1);
1042
2208
  }
1043
2209
  const errors = [];
1044
2210
  const warnings = [];
2211
+ const contextPolicy = loadContextPolicy(cwd);
2212
+ const isNewChange = isContextGovernedChange(changeDir);
2213
+ const manifestPath = join9(changeDir, "context-manifest.md");
2214
+ const hasManifest = existsSync8(manifestPath);
2215
+ let allowedPaths = [];
2216
+ let approvedExpansions = [];
2217
+ errors.push(...validateDependencies(cwd, changeId, changeDir));
2218
+ if (hasManifest) {
2219
+ const manifest = parseContextManifest(readFileSync6(manifestPath, "utf8"));
2220
+ allowedPaths = manifest.allowedPaths;
2221
+ approvedExpansions = manifest.approvedExpansions;
2222
+ if (manifest.pendingExpansions > 0) {
2223
+ errors.push(`context-manifest.md: has ${manifest.pendingExpansions} pending context expansion request(s)`);
2224
+ }
2225
+ }
1045
2226
  for (const f of REQUIRED_FILES) {
1046
- if (!existsSync7(join8(changeDir, f))) {
2227
+ if (f === "context-manifest.md") {
2228
+ if (!hasManifest) {
2229
+ if (isNewChange || strict) {
2230
+ errors.push("missing required artifact: context-manifest.md");
2231
+ } else {
2232
+ warnings.push("missing context-manifest.md (legacy change; run cdd-kit migrate after upgrading)");
2233
+ }
2234
+ }
2235
+ continue;
2236
+ }
2237
+ if (!existsSync8(join9(changeDir, f))) {
1047
2238
  errors.push(`missing required artifact: ${f}`);
1048
2239
  }
1049
2240
  }
1050
2241
  if (errors.length === 0) {
1051
2242
  for (const f of REQUIRED_FILES) {
1052
- const content = readFileSync4(join8(changeDir, f), "utf8");
2243
+ if (f === "context-manifest.md" && !hasManifest)
2244
+ continue;
2245
+ const content = readFileSync6(join9(changeDir, f), "utf8");
1053
2246
  const minChars = MIN_CHARS[f] ?? 100;
1054
2247
  if (meaningfulChars(content) < minChars) {
1055
2248
  errors.push(`${f}: appears to be a stub (< ${minChars} meaningful chars)`);
1056
2249
  }
1057
2250
  }
1058
- const classifPath = join8(changeDir, "change-classification.md");
1059
- if (existsSync7(classifPath)) {
1060
- const text = readFileSync4(classifPath, "utf8");
2251
+ const classifPath = join9(changeDir, "change-classification.md");
2252
+ if (existsSync8(classifPath)) {
2253
+ const text = readFileSync6(classifPath, "utf8");
1061
2254
  if (!TIER_PATTERN.test(text)) {
1062
2255
  errors.push("change-classification.md: missing tier/risk marker (Tier 0-5 or low/medium/high/critical)");
1063
2256
  }
1064
2257
  }
1065
2258
  }
1066
- const tasksPath = join8(changeDir, "tasks.md");
1067
- if (existsSync7(tasksPath)) {
1068
- const tasksContent = readFileSync4(tasksPath, "utf8");
2259
+ const tasksPath = join9(changeDir, "tasks.md");
2260
+ if (existsSync8(tasksPath)) {
2261
+ const tasksContent = readFileSync6(tasksPath, "utf8");
1069
2262
  const nonArchivePending = (tasksContent.match(/^\s*-\s*\[ \] (?!7\.[12])/gm) || []).length;
1070
2263
  if (nonArchivePending > 0) {
1071
2264
  if (strict) {
@@ -1075,11 +2268,34 @@ async function gate(changeId, opts = {}) {
1075
2268
  }
1076
2269
  }
1077
2270
  }
1078
- const agentLogDir = join8(changeDir, "agent-log");
1079
- if (existsSync7(agentLogDir)) {
2271
+ const agentLogDir = join9(changeDir, "agent-log");
2272
+ if (existsSync8(agentLogDir)) {
1080
2273
  const logFiles = readdirSync5(agentLogDir).filter((f) => f.endsWith(".md"));
1081
2274
  for (const f of logFiles) {
1082
- const content = readFileSync4(join8(agentLogDir, f), "utf8");
2275
+ const content = readFileSync6(join9(agentLogDir, f), "utf8");
2276
+ const filesRead = parseFilesRead(content);
2277
+ if (!filesRead.present) {
2278
+ if (contextPolicy.audit.requireFilesRead) {
2279
+ const msg = `agent-log/${f}: missing "- files-read:" section`;
2280
+ if (isNewChange || strict || contextPolicy.audit.unknownFilesRead !== "warn-for-legacy-fail-for-new") {
2281
+ errors.push(msg);
2282
+ } else {
2283
+ warnings.push(`${msg} (legacy warning only)`);
2284
+ }
2285
+ }
2286
+ } else {
2287
+ for (const parseError of filesRead.errors) {
2288
+ errors.push(`agent-log/${f}: ${parseError}`);
2289
+ }
2290
+ for (const pathRead of filesRead.files) {
2291
+ if (pathMatches(pathRead, contextPolicy.forbiddenPaths, changeId)) {
2292
+ errors.push(`agent-log/${f}: read forbidden path -> ${pathRead}`);
2293
+ }
2294
+ if (hasManifest && allowedPaths.length > 0 && !pathMatches(pathRead, allowedPaths) && !pathMatches(pathRead, approvedExpansions)) {
2295
+ errors.push(`agent-log/${f}: read unauthorized path -> ${pathRead} (not in allowed paths or approved expansions)`);
2296
+ }
2297
+ }
2298
+ }
1083
2299
  const statusMatch = content.match(/^\s*-\s*status:\s*(complete|needs-review|blocked)\s*$/m);
1084
2300
  if (!statusMatch) {
1085
2301
  errors.push(`agent-log/${f}: missing or invalid "status:" line (must be complete | needs-review | blocked)`);
@@ -1100,8 +2316,8 @@ async function gate(changeId, opts = {}) {
1100
2316
  const pointer = line.replace(/^\s*-\s*[\w-]+:\s*/, "").trim();
1101
2317
  const pathPart = pointer.split(":")[0];
1102
2318
  if (pathPart.includes("/") && !pointer.startsWith("http")) {
1103
- const abs = join8(cwd, pathPart);
1104
- if (!existsSync7(abs)) {
2319
+ const abs = join9(cwd, pathPart);
2320
+ if (!existsSync8(abs)) {
1105
2321
  errors.push(`agent-log/${f}: artifact pointer not found: ${pathPart}`);
1106
2322
  }
1107
2323
  }
@@ -1109,9 +2325,9 @@ async function gate(changeId, opts = {}) {
1109
2325
  }
1110
2326
  }
1111
2327
  }
1112
- const classifPath = join8(changeDir, "change-classification.md");
1113
- if (existsSync7(classifPath)) {
1114
- const classificationContent = readFileSync4(classifPath, "utf8");
2328
+ const classifPath = join9(changeDir, "change-classification.md");
2329
+ if (existsSync8(classifPath)) {
2330
+ const classificationContent = readFileSync6(classifPath, "utf8");
1115
2331
  const tierMatch = classificationContent.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
1116
2332
  const tier = tierMatch ? parseInt(tierMatch[1]) : null;
1117
2333
  if (tier !== null) {
@@ -1133,9 +2349,9 @@ async function gate(changeId, opts = {}) {
1133
2349
  }
1134
2350
  }
1135
2351
  } else {
1136
- const classifPath = join8(changeDir, "change-classification.md");
1137
- if (existsSync7(classifPath)) {
1138
- const classificationContent = readFileSync4(classifPath, "utf8");
2352
+ const classifPath = join9(changeDir, "change-classification.md");
2353
+ if (existsSync8(classifPath)) {
2354
+ const classificationContent = readFileSync6(classifPath, "utf8");
1139
2355
  const tierMatch = classificationContent.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
1140
2356
  const tier = tierMatch ? parseInt(tierMatch[1]) : null;
1141
2357
  if (tier !== null) {
@@ -1178,27 +2394,28 @@ async function gate(changeId, opts = {}) {
1178
2394
  }
1179
2395
 
1180
2396
  // 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";
2397
+ init_paths();
1183
2398
  init_logger();
2399
+ import { existsSync as existsSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync3, chmodSync, mkdirSync as mkdirSync3 } from "fs";
2400
+ import { join as join10 } from "path";
1184
2401
  var START_MARKER = "# cdd-kit-managed-block-start";
1185
2402
  var END_MARKER = "# cdd-kit-managed-block-end";
1186
2403
  async function installHooks() {
1187
2404
  const cwd = process.cwd();
1188
- const gitDir = join9(cwd, ".git");
1189
- if (!existsSync8(gitDir)) {
2405
+ const gitDir = join10(cwd, ".git");
2406
+ if (!existsSync9(gitDir)) {
1190
2407
  log.error("not a git repository (no .git/ found in cwd)");
1191
2408
  process.exit(1);
1192
2409
  }
1193
- const hooksDir = join9(gitDir, "hooks");
2410
+ const hooksDir = join10(gitDir, "hooks");
1194
2411
  mkdirSync3(hooksDir, { recursive: true });
1195
- const dest = join9(hooksDir, "pre-commit");
1196
- const ourHook = readFileSync5(join9(ASSET.hooks, "pre-commit"), "utf8");
2412
+ const dest = join10(hooksDir, "pre-commit");
2413
+ const ourHook = readFileSync7(join10(ASSET.hooks, "pre-commit"), "utf8");
1197
2414
  let final;
1198
- if (!existsSync8(dest)) {
2415
+ if (!existsSync9(dest)) {
1199
2416
  final = ourHook;
1200
2417
  } else {
1201
- const existing = readFileSync5(dest, "utf8");
2418
+ const existing = readFileSync7(dest, "utf8");
1202
2419
  const startIdx = existing.indexOf(START_MARKER);
1203
2420
  const endIdx = existing.indexOf(END_MARKER);
1204
2421
  if (startIdx >= 0 && endIdx > startIdx) {
@@ -1222,7 +2439,7 @@ async function installHooks() {
1222
2439
  }
1223
2440
  }
1224
2441
  }
1225
- writeFileSync2(dest, final, "utf8");
2442
+ writeFileSync3(dest, final, "utf8");
1226
2443
  try {
1227
2444
  chmodSync(dest, 493);
1228
2445
  } catch {
@@ -1232,22 +2449,36 @@ async function installHooks() {
1232
2449
  }
1233
2450
 
1234
2451
  // src/cli/index.ts
1235
- var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
1236
- var pkg = JSON.parse(readFileSync10(join14(__dirname2, "..", "..", "package.json"), "utf8"));
2452
+ var __dirname2 = dirname5(fileURLToPath2(import.meta.url));
2453
+ var pkg = JSON.parse(readFileSync15(join19(__dirname2, "..", "..", "package.json"), "utf8"));
1237
2454
  var program = new Command();
1238
2455
  program.name("cdd-kit").description("Contract-Driven Delivery Kit CLI").version(pkg.version);
1239
2456
  program.command("init").description(
1240
2457
  "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(
2458
+ ).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
2459
  (opts) => init({
1243
2460
  globalOnly: opts.globalOnly,
1244
2461
  localOnly: opts.localOnly,
1245
- force: opts.force
2462
+ force: opts.force,
2463
+ provider: opts.provider
1246
2464
  })
1247
2465
  );
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 })
2466
+ 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 }));
2467
+ 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").action(async (opts) => {
2468
+ const { doctor: doctor2 } = await Promise.resolve().then(() => (init_doctor(), doctor_exports));
2469
+ await doctor2({ strict: opts.strict, json: opts.json, provider: opts.provider });
2470
+ });
2471
+ 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) => {
2472
+ const { upgrade: upgrade2 } = await Promise.resolve().then(() => (init_upgrade(), upgrade_exports));
2473
+ await upgrade2({
2474
+ yes: opts.yes,
2475
+ migrateChanges: opts.migrateChanges,
2476
+ enableContextGovernance: opts.enableContextGovernance,
2477
+ provider: opts.provider
2478
+ });
2479
+ });
2480
+ 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").action(
2481
+ (name, opts) => newChange(name, { all: opts.all, force: opts.force, dependsOn: opts.dependsOn })
1251
2482
  );
1252
2483
  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
2484
  (opts) => validate({
@@ -1269,7 +2500,7 @@ program.command("abandon <change-id>").description("Mark a change as abandoned (
1269
2500
  const { abandon: abandon2 } = await Promise.resolve().then(() => (init_abandon(), abandon_exports));
1270
2501
  await abandon2(changeId, opts);
1271
2502
  });
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 = {}) => {
2503
+ 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).action(async (changeId, opts = {}) => {
1273
2504
  const { migrate: migrate2 } = await Promise.resolve().then(() => (init_migrate(), migrate_exports));
1274
2505
  await migrate2(changeId, opts);
1275
2506
  });
@@ -1293,4 +2524,25 @@ program.command("detect-stack").description("Detect the project tech stack and p
1293
2524
  );
1294
2525
  }
1295
2526
  });
2527
+ program.command("context-scan").description("Deterministically scan project context and generate specs/context maps").action(async () => {
2528
+ const { contextScan: contextScan2 } = await Promise.resolve().then(() => (init_context_scan(), context_scan_exports));
2529
+ await contextScan2();
2530
+ });
2531
+ var context = program.command("context").description("Manage context governance manifests");
2532
+ 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) => {
2533
+ const { requestContextExpansion: requestContextExpansion2 } = await Promise.resolve().then(() => (init_context(), context_exports));
2534
+ await requestContextExpansion2(changeId, requestId, opts.path, opts.reason);
2535
+ });
2536
+ context.command("approve <change-id> <request-id>").description("Approve a pending Context Expansion Request and add its paths to Approved Expansions").action(async (changeId, requestId) => {
2537
+ const { approveContextExpansion: approveContextExpansion2 } = await Promise.resolve().then(() => (init_context(), context_exports));
2538
+ await approveContextExpansion2(changeId, requestId);
2539
+ });
2540
+ context.command("reject <change-id> <request-id>").description("Reject a pending Context Expansion Request").action(async (changeId, requestId) => {
2541
+ const { rejectContextExpansion: rejectContextExpansion2 } = await Promise.resolve().then(() => (init_context(), context_exports));
2542
+ await rejectContextExpansion2(changeId, requestId);
2543
+ });
2544
+ context.command("list <change-id>").description("List Context Expansion Requests for a change").option("--json", "Print machine-readable JSON", false).action(async (changeId, opts) => {
2545
+ const { listContextExpansions: listContextExpansions2 } = await Promise.resolve().then(() => (init_context(), context_exports));
2546
+ await listContextExpansions2(changeId, opts.json);
2547
+ });
1296
2548
  program.parse();