contract-driven-delivery 1.12.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -107,21 +107,297 @@ var init_provider = __esm({
107
107
  }
108
108
  });
109
109
 
110
+ // src/commands/context-scan.ts
111
+ var context_scan_exports = {};
112
+ __export(context_scan_exports, {
113
+ contextScan: () => contextScan
114
+ });
115
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync5, readdirSync as readdirSync4, writeFileSync as writeFileSync2 } from "fs";
116
+ import { createHash as createHash2 } from "crypto";
117
+ import { basename, dirname as dirname3, join as join7, relative as relative2 } from "path";
118
+ function sha256OfFile(path) {
119
+ try {
120
+ return createHash2("sha256").update(readFileSync5(path)).digest("hex");
121
+ } catch {
122
+ return "";
123
+ }
124
+ }
125
+ function inputsDigest(paths) {
126
+ const combined = paths.slice().sort().map((p) => `${p}:${sha256OfFile(p)}`).join("\n");
127
+ return createHash2("sha256").update(combined).digest("hex");
128
+ }
129
+ function stripGlobSuffix(pattern) {
130
+ return pattern.replace(/\/\*\*$/, "").replace(/\/\*$/, "");
131
+ }
132
+ function getForbiddenPaths(cwd) {
133
+ const forbidden = new Set(DEFAULT_FORBIDDEN);
134
+ const policyPath = join7(cwd, ".cdd", "context-policy.json");
135
+ try {
136
+ if (existsSync6(policyPath)) {
137
+ const policy = JSON.parse(readFileSync5(policyPath, "utf8"));
138
+ for (const pattern of policy.forbiddenPaths ?? []) {
139
+ forbidden.add(stripGlobSuffix(pattern));
140
+ }
141
+ }
142
+ } catch {
143
+ log.warn("Could not parse .cdd/context-policy.json; using default context-scan excludes.");
144
+ }
145
+ return [...forbidden];
146
+ }
147
+ function isForbidden(relPath, forbidden) {
148
+ const normalized = relPath.replace(/\\/g, "/");
149
+ return forbidden.some((pattern) => normalized === pattern || normalized.startsWith(`${pattern}/`));
150
+ }
151
+ function buildTree(dir, cwd, forbidden, stats, prefix = "", depth = 0) {
152
+ const entries = readdirSync4(dir, { withFileTypes: true }).sort((a, b) => {
153
+ if (a.isDirectory() === b.isDirectory())
154
+ return a.name.localeCompare(b.name);
155
+ return a.isDirectory() ? -1 : 1;
156
+ });
157
+ let output = "";
158
+ const visible = entries.filter((entry) => {
159
+ const relPath = relative2(cwd, join7(dir, entry.name));
160
+ return !isForbidden(relPath, forbidden);
161
+ });
162
+ const truncated = visible.length > PER_DIR_ENTRY_CAP;
163
+ const shown = truncated ? visible.slice(0, PER_DIR_ENTRY_CAP) : visible;
164
+ if (truncated)
165
+ stats.truncatedDirs += 1;
166
+ shown.forEach((entry, index) => {
167
+ const fullPath = join7(dir, entry.name);
168
+ const isLast = index === shown.length - 1 && !truncated;
169
+ const connector = isLast ? "\\-- " : "|-- ";
170
+ output += `${prefix}${connector}${entry.name}${entry.isDirectory() ? "/" : ""}
171
+ `;
172
+ if (entry.isDirectory()) {
173
+ stats.dirs += 1;
174
+ if (depth >= 3) {
175
+ stats.omittedDirs += 1;
176
+ output += `${prefix}${isLast ? " " : "| "}\\-- ... (max depth)
177
+ `;
178
+ } else {
179
+ output += buildTree(fullPath, cwd, forbidden, stats, prefix + (isLast ? " " : "| "), depth + 1);
180
+ }
181
+ } else {
182
+ stats.files += 1;
183
+ }
184
+ });
185
+ if (truncated) {
186
+ output += `${prefix}\\-- ... (${visible.length - PER_DIR_ENTRY_CAP} more entries truncated; cap=${PER_DIR_ENTRY_CAP})
187
+ `;
188
+ }
189
+ return output;
190
+ }
191
+ function firstHeading(content) {
192
+ const match = content.match(/^#\s+(.+)$/m);
193
+ return match?.[1]?.trim();
194
+ }
195
+ function deriveContractType(relPath, metadata) {
196
+ if (metadata.contract)
197
+ return metadata.contract;
198
+ const parts = relPath.split("/");
199
+ return parts.length >= 2 ? parts[1] : "unknown";
200
+ }
201
+ function parseContractMetadata(content) {
202
+ const metadata = {};
203
+ let summary;
204
+ const cddMatch = content.match(/<!--\s*cdd:([\s\S]*?)-->/);
205
+ const yamlMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
206
+ const block = cddMatch?.[1] ?? yamlMatch?.[1];
207
+ if (block) {
208
+ for (const line of block.split(/\r?\n/)) {
209
+ const colon = line.indexOf(":");
210
+ if (colon === -1)
211
+ continue;
212
+ const key = line.slice(0, colon).trim();
213
+ const value = line.slice(colon + 1).trim();
214
+ if (!key || !value)
215
+ continue;
216
+ if (key === "summary")
217
+ summary = value;
218
+ else
219
+ metadata[key] = value;
220
+ }
221
+ }
222
+ if (!summary) {
223
+ const summaryMatch = content.match(/#+\s*Summary\s*\r?\n+([^#\r\n][^\r\n]*)/i);
224
+ summary = summaryMatch?.[1]?.trim();
225
+ }
226
+ return { title: firstHeading(content), summary, metadata };
227
+ }
228
+ function findContractFiles(dir, found = []) {
229
+ if (!existsSync6(dir))
230
+ return found;
231
+ for (const entry of readdirSync4(dir, { withFileTypes: true })) {
232
+ const fullPath = join7(dir, entry.name);
233
+ if (entry.isDirectory())
234
+ findContractFiles(fullPath, found);
235
+ else if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "INDEX.md" && entry.name !== "CHANGELOG.md")
236
+ found.push(fullPath);
237
+ }
238
+ return found;
239
+ }
240
+ async function contextScan(opts = {}) {
241
+ const cwd = process.cwd();
242
+ const specsContextDir = join7(cwd, "specs", "context");
243
+ mkdirSync3(specsContextDir, { recursive: true });
244
+ const forbidden = getForbiddenPaths(cwd);
245
+ const surface = opts.surface;
246
+ let scanRoot = cwd;
247
+ if (surface) {
248
+ const resolvedSurface = join7(cwd, surface);
249
+ if (!existsSync6(resolvedSurface)) {
250
+ log.error(`--surface path not found: ${surface}`);
251
+ process.exit(1);
252
+ }
253
+ if (!resolvedSurface.startsWith(cwd)) {
254
+ log.error(`--surface must be inside the repo: ${surface}`);
255
+ process.exit(1);
256
+ }
257
+ scanRoot = resolvedSurface;
258
+ }
259
+ const treeStats = { dirs: 0, files: 0, omittedDirs: 0, truncatedDirs: 0 };
260
+ const tree = buildTree(scanRoot, cwd, forbidden, treeStats);
261
+ const policyPath = join7(cwd, ".cdd", "context-policy.json");
262
+ const projectMapInputs = [policyPath].filter(existsSync6);
263
+ writeFileSync2(
264
+ join7(specsContextDir, "project-map.md"),
265
+ [
266
+ "---",
267
+ "artifact: project-map",
268
+ "generated-by: cdd-kit context-scan",
269
+ "schema-version: 1",
270
+ `root: ${basename(cwd)}`,
271
+ ...surface ? [`surface: ${surface}`] : [],
272
+ `visible-dirs: ${treeStats.dirs}`,
273
+ `visible-files: ${treeStats.files}`,
274
+ `omitted-dirs: ${treeStats.omittedDirs}`,
275
+ `truncated-dirs: ${treeStats.truncatedDirs}`,
276
+ `inputs-digest: ${inputsDigest(projectMapInputs)}`,
277
+ "---",
278
+ "",
279
+ "# Project Map",
280
+ "",
281
+ "Use this deterministic map to choose candidate context paths before reading files.",
282
+ "",
283
+ "## Excluded Paths",
284
+ ...forbidden.map((path) => `- ${path}`),
285
+ "",
286
+ "## Tree",
287
+ "",
288
+ "```",
289
+ `${basename(cwd)}/`,
290
+ tree.trimEnd(),
291
+ "```",
292
+ ""
293
+ ].join("\n"),
294
+ "utf8"
295
+ );
296
+ log.ok("Created specs/context/project-map.md");
297
+ const contractFiles = findContractFiles(join7(cwd, "contracts")).sort((a, b) => relative2(cwd, a).localeCompare(relative2(cwd, b)));
298
+ const contractEntries = [];
299
+ const inventoryRows = [];
300
+ let missingSummary = 0;
301
+ for (const file of contractFiles) {
302
+ const relPath = relative2(cwd, file).replace(/\\/g, "/");
303
+ const dir = dirname3(relPath).replace(/\\/g, "/");
304
+ const { title, summary, metadata } = parseContractMetadata(readFileSync5(file, "utf8"));
305
+ const contractType = deriveContractType(relPath, metadata);
306
+ const owner = metadata.owner ?? "unknown";
307
+ const surface2 = metadata.surface ?? dir;
308
+ const summaryText = summary ?? "MISSING - add YAML frontmatter `summary:` or `<!-- cdd: summary: ... -->`.";
309
+ inventoryRows.push(`| ${relPath} | ${contractType} | ${surface2} | ${owner} | ${summary ? "yes" : "no"} |`);
310
+ let entry = `## ${relPath}
311
+ `;
312
+ entry += `- path: \`${relPath}\`
313
+ `;
314
+ entry += `- type: ${contractType}
315
+ `;
316
+ entry += `- directory: ${dir}
317
+ `;
318
+ if (title)
319
+ entry += `- title: ${title}
320
+ `;
321
+ for (const [key, value] of Object.entries(metadata)) {
322
+ if (key === "contract")
323
+ continue;
324
+ entry += `- ${key}: ${value}
325
+ `;
326
+ }
327
+ entry += `- summary: ${summaryText}
328
+
329
+ `;
330
+ contractEntries.push(entry);
331
+ if (!summary) {
332
+ missingSummary += 1;
333
+ }
334
+ }
335
+ const contractIndex = [
336
+ "---",
337
+ "artifact: contracts-index",
338
+ "generated-by: cdd-kit context-scan",
339
+ "schema-version: 1",
340
+ `contract-count: ${contractFiles.length}`,
341
+ `missing-summary-count: ${missingSummary}`,
342
+ `inputs-digest: ${inputsDigest(contractFiles)}`,
343
+ "---",
344
+ "",
345
+ "# Contracts Index",
346
+ "",
347
+ "Generated from deterministic metadata. Add YAML frontmatter fields such as `summary`, `owner`, and `surface` to improve classifier accuracy.",
348
+ "",
349
+ "## Contract Inventory",
350
+ "",
351
+ "| path | type | surface | owner | has-summary |",
352
+ "|---|---|---|---|---|",
353
+ ...inventoryRows,
354
+ "",
355
+ "## Contract Details",
356
+ "",
357
+ ...contractEntries
358
+ ].join("\n");
359
+ writeFileSync2(join7(specsContextDir, "contracts-index.md"), contractIndex, "utf8");
360
+ if (missingSummary > 0) {
361
+ log.warn(`Created specs/context/contracts-index.md with ${missingSummary} missing summary warning(s).`);
362
+ } else {
363
+ log.ok("Created specs/context/contracts-index.md");
364
+ }
365
+ }
366
+ var DEFAULT_FORBIDDEN, PER_DIR_ENTRY_CAP;
367
+ var init_context_scan = __esm({
368
+ "src/commands/context-scan.ts"() {
369
+ "use strict";
370
+ init_logger();
371
+ DEFAULT_FORBIDDEN = [
372
+ ".claude",
373
+ ".git",
374
+ "node_modules",
375
+ "dist",
376
+ "build",
377
+ "assets",
378
+ "specs/archive",
379
+ "specs/changes"
380
+ ];
381
+ PER_DIR_ENTRY_CAP = 50;
382
+ }
383
+ });
384
+
110
385
  // src/commands/doctor.ts
111
386
  var doctor_exports = {};
112
387
  __export(doctor_exports, {
113
388
  doctor: () => doctor
114
389
  });
115
- import { existsSync as existsSync10, readdirSync as readdirSync6, readFileSync as readFileSync8, statSync as statSync2 } from "fs";
116
- import { join as join11 } from "path";
390
+ import { existsSync as existsSync11, readdirSync as readdirSync7, readFileSync as readFileSync9 } from "fs";
391
+ import { createHash as createHash4 } from "crypto";
392
+ import { join as join12 } from "path";
117
393
  function fileExists(cwd, relPath) {
118
- return existsSync10(join11(cwd, relPath));
394
+ return existsSync11(join12(cwd, relPath));
119
395
  }
120
396
  function findFiles(dir, predicate, found = []) {
121
- if (!existsSync10(dir))
397
+ if (!existsSync11(dir))
122
398
  return found;
123
- for (const entry of readdirSync6(dir, { withFileTypes: true })) {
124
- const fullPath = join11(dir, entry.name);
399
+ for (const entry of readdirSync7(dir, { withFileTypes: true })) {
400
+ const fullPath = join12(dir, entry.name);
125
401
  if (entry.isDirectory())
126
402
  findFiles(fullPath, predicate, found);
127
403
  else if (entry.isFile() && predicate(entry.name))
@@ -129,54 +405,76 @@ function findFiles(dir, predicate, found = []) {
129
405
  }
130
406
  return found;
131
407
  }
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
- }
408
+ function sha256OfFile3(path) {
409
+ try {
410
+ return createHash4("sha256").update(readFileSync9(path)).digest("hex");
411
+ } catch {
412
+ return "";
139
413
  }
140
- return newest;
141
414
  }
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;
415
+ function inputDigest(paths) {
416
+ const combined = paths.slice().sort().map((p) => `${p}:${sha256OfFile3(p)}`).join("\n");
417
+ return createHash4("sha256").update(combined).digest("hex");
418
+ }
419
+ function readContextIndexMetadata(filePath) {
420
+ if (!existsSync11(filePath))
421
+ return {};
422
+ const text = readFileSync9(filePath, "utf8");
423
+ const out = {};
424
+ const digestMatch = text.match(/^inputs-digest:\s*([a-f0-9]+)/m);
425
+ if (digestMatch)
426
+ out.inputsDigest = digestMatch[1];
427
+ const missingMatch = text.match(/^missing-summary-count:\s*(\d+)/m);
428
+ if (missingMatch)
429
+ out.missingSummary = Number(missingMatch[1]);
430
+ return out;
148
431
  }
149
432
  function checkContextFreshness(cwd) {
150
433
  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)) {
434
+ const projectMap = join12(cwd, "specs", "context", "project-map.md");
435
+ const contractsIndex = join12(cwd, "specs", "context", "contracts-index.md");
436
+ const contextPolicy = join12(cwd, ".cdd", "context-policy.json");
437
+ const contractFiles = findFiles(
438
+ join12(cwd, "contracts"),
439
+ (name) => name.endsWith(".md") && name !== "INDEX.md" && name !== "CHANGELOG.md"
440
+ );
441
+ if (!existsSync11(projectMap) || !existsSync11(contractsIndex)) {
156
442
  findings.push({
157
443
  level: "warning",
158
444
  message: "specs/context indexes are missing; run cdd-kit context-scan before classification"
159
445
  });
160
446
  return findings;
161
447
  }
162
- const projectInputs = [contextPolicy].filter(existsSync10);
163
- if (projectInputs.length > 0 && statSync2(projectMap).mtimeMs < newestMtime(projectInputs)) {
448
+ const projectMapMeta = readContextIndexMetadata(projectMap);
449
+ const contractsIndexMeta = readContextIndexMetadata(contractsIndex);
450
+ const projectInputDigest = inputDigest([contextPolicy].filter(existsSync11));
451
+ if (projectMapMeta.inputsDigest === void 0) {
452
+ findings.push({
453
+ level: "warning",
454
+ message: "specs/context/project-map.md was generated by an older cdd-kit (no inputs-digest); re-run cdd-kit context-scan"
455
+ });
456
+ } else if (projectInputDigest && projectMapMeta.inputsDigest !== projectInputDigest) {
164
457
  findings.push({
165
458
  level: "warning",
166
- message: "specs/context/project-map.md is older than .cdd/context-policy.json; run cdd-kit context-scan"
459
+ message: "specs/context/project-map.md inputs changed (.cdd/context-policy.json); re-run cdd-kit context-scan"
167
460
  });
168
461
  }
169
- if (contractFiles.length > 0 && statSync2(contractsIndex).mtimeMs < newestMtime(contractFiles)) {
462
+ const contractsInputDigest = inputDigest(contractFiles);
463
+ if (contractsIndexMeta.inputsDigest === void 0) {
170
464
  findings.push({
171
465
  level: "warning",
172
- message: "specs/context/contracts-index.md is older than contracts/; run cdd-kit context-scan"
466
+ message: "specs/context/contracts-index.md was generated by an older cdd-kit (no inputs-digest); re-run cdd-kit context-scan"
467
+ });
468
+ } else if (contractsInputDigest && contractsIndexMeta.inputsDigest !== contractsInputDigest) {
469
+ findings.push({
470
+ level: "warning",
471
+ message: "specs/context/contracts-index.md inputs changed (contracts/*); re-run cdd-kit context-scan"
173
472
  });
174
473
  }
175
- const missingSummaryCount = readMissingSummaryCount(cwd);
176
- if (missingSummaryCount !== null && missingSummaryCount > 0) {
474
+ if (contractsIndexMeta.missingSummary !== void 0 && contractsIndexMeta.missingSummary > 0) {
177
475
  findings.push({
178
476
  level: "warning",
179
- message: `contracts-index reports ${missingSummaryCount} contract(s) without deterministic summary metadata`
477
+ message: `contracts-index reports ${contractsIndexMeta.missingSummary} contract(s) without deterministic summary metadata`
180
478
  });
181
479
  }
182
480
  if (findings.length === 0) {
@@ -184,6 +482,63 @@ function checkContextFreshness(cwd) {
184
482
  }
185
483
  return findings;
186
484
  }
485
+ function readAgentModel(path) {
486
+ try {
487
+ const text = readFileSync9(path, "utf8");
488
+ const m = text.match(/^model:\s*(\S+)/m);
489
+ return m ? m[1] : null;
490
+ } catch {
491
+ return null;
492
+ }
493
+ }
494
+ function checkModelPolicyDrift(cwd) {
495
+ const policyPath = join12(cwd, ".cdd", "model-policy.json");
496
+ if (!existsSync11(policyPath))
497
+ return [];
498
+ let policy;
499
+ try {
500
+ policy = JSON.parse(readFileSync9(policyPath, "utf8"));
501
+ } catch {
502
+ return [{ level: "warning", message: ".cdd/model-policy.json is not valid JSON" }];
503
+ }
504
+ const roles = policy.roles ?? {};
505
+ if (Object.keys(roles).length === 0) {
506
+ return [{
507
+ level: "warning",
508
+ message: ".cdd/model-policy.json has no role bindings; run cdd-kit upgrade to install defaults"
509
+ }];
510
+ }
511
+ const candidateDirs = [
512
+ join12(cwd, ".claude", "agents"),
513
+ process.env.HOME ? join12(process.env.HOME, ".claude", "agents") : "",
514
+ process.env.USERPROFILE ? join12(process.env.USERPROFILE, ".claude", "agents") : ""
515
+ ].filter((p) => p && existsSync11(p));
516
+ if (candidateDirs.length === 0)
517
+ return [];
518
+ const findings = [];
519
+ for (const [role, expected] of Object.entries(roles)) {
520
+ let foundAny = false;
521
+ for (const dir of candidateDirs) {
522
+ const path = join12(dir, `${role}.md`);
523
+ if (!existsSync11(path))
524
+ continue;
525
+ foundAny = true;
526
+ const actual = readAgentModel(path);
527
+ if (actual && actual !== expected) {
528
+ findings.push({
529
+ level: "warning",
530
+ message: `model-policy drift: ${role} expected ${expected}, agent prompt uses ${actual} (${path})`
531
+ });
532
+ }
533
+ }
534
+ if (!foundAny) {
535
+ }
536
+ }
537
+ if (findings.length === 0) {
538
+ findings.push({ level: "ok", message: "model-policy roles match installed agent prompts" });
539
+ }
540
+ return findings;
541
+ }
187
542
  function buildDoctorReport(cwd, opts) {
188
543
  const requestedProvider = opts.provider ?? "auto";
189
544
  if (!validateProviderOption(requestedProvider)) {
@@ -206,6 +561,7 @@ function buildDoctorReport(cwd, opts) {
206
561
  findings.push({ level: "warning", message: "CODEX.md is missing for Codex provider; run cdd-kit upgrade --provider codex --yes" });
207
562
  }
208
563
  findings.push(...checkContextFreshness(cwd));
564
+ findings.push(...checkModelPolicyDrift(cwd));
209
565
  const errors = findings.filter((finding) => finding.level === "error").length;
210
566
  const warnings = findings.filter((finding) => finding.level === "warning").length;
211
567
  return {
@@ -217,8 +573,89 @@ function buildDoctorReport(cwd, opts) {
217
573
  ok: errors === 0 && (!strict || warnings === 0)
218
574
  };
219
575
  }
576
+ async function attemptAutoFixes(cwd, report) {
577
+ const fixed = [];
578
+ const remaining = [];
579
+ for (const finding of report.findings) {
580
+ if (finding.level !== "warning") {
581
+ remaining.push(finding);
582
+ continue;
583
+ }
584
+ if (/specs\/context indexes are missing|inputs changed|older cdd-kit|older than/i.test(finding.message)) {
585
+ try {
586
+ const { contextScan: contextScan2 } = await Promise.resolve().then(() => (init_context_scan(), context_scan_exports));
587
+ await contextScan2();
588
+ fixed.push(`ran context-scan to refresh specs/context/`);
589
+ continue;
590
+ } catch (err) {
591
+ remaining.push({ level: "warning", message: `${finding.message} (auto-fix failed: ${err.message})` });
592
+ continue;
593
+ }
594
+ }
595
+ if (/model-policy\.json has no role bindings/i.test(finding.message)) {
596
+ const policyPath = join12(cwd, ".cdd", "model-policy.json");
597
+ try {
598
+ let existing = {};
599
+ try {
600
+ existing = JSON.parse(readFileSync9(policyPath, "utf8"));
601
+ } catch {
602
+ }
603
+ const merged = {
604
+ ...existing,
605
+ roles: {
606
+ "change-classifier": "claude-opus-4-7",
607
+ "spec-architect": "claude-opus-4-7",
608
+ "qa-reviewer": "claude-opus-4-7",
609
+ "contract-reviewer": "claude-sonnet-4-6",
610
+ "test-strategist": "claude-sonnet-4-6",
611
+ "backend-engineer": "claude-sonnet-4-6",
612
+ "frontend-engineer": "claude-sonnet-4-6",
613
+ "ci-cd-gatekeeper": "claude-sonnet-4-6",
614
+ "e2e-resilience-engineer": "claude-sonnet-4-6",
615
+ "monkey-test-engineer": "claude-sonnet-4-6",
616
+ "stress-soak-engineer": "claude-sonnet-4-6",
617
+ "ui-ux-reviewer": "claude-sonnet-4-6",
618
+ "visual-reviewer": "claude-sonnet-4-6",
619
+ "dependency-security-reviewer": "claude-sonnet-4-6",
620
+ "spec-drift-auditor": "claude-sonnet-4-6",
621
+ "repo-context-scanner": "claude-haiku-4-5"
622
+ }
623
+ };
624
+ const { writeFileSync: writeFileSync10 } = await import("fs");
625
+ writeFileSync10(policyPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
626
+ fixed.push(`populated .cdd/model-policy.json with default role bindings`);
627
+ continue;
628
+ } catch (err) {
629
+ remaining.push({ level: "warning", message: `${finding.message} (auto-fix failed: ${err.message})` });
630
+ continue;
631
+ }
632
+ }
633
+ if (/\.cdd\/.*is missing|run cdd-kit upgrade/i.test(finding.message)) {
634
+ remaining.push({
635
+ level: "warning",
636
+ message: `${finding.message} (run \`cdd-kit upgrade --yes\` manually \u2014 too invasive for --fix)`
637
+ });
638
+ continue;
639
+ }
640
+ remaining.push(finding);
641
+ }
642
+ return { fixed, remaining };
643
+ }
220
644
  async function doctor(opts = {}) {
221
- const report = buildDoctorReport(process.cwd(), opts);
645
+ const cwd = process.cwd();
646
+ let report = buildDoctorReport(cwd, opts);
647
+ if (opts.fix && !opts.json) {
648
+ log.blank();
649
+ log.info("Doctor --fix: attempting safe auto-resolutions\u2026");
650
+ const { fixed, remaining } = await attemptAutoFixes(cwd, report);
651
+ for (const f of fixed)
652
+ log.ok(`fixed: ${f}`);
653
+ if (fixed.length > 0) {
654
+ report = buildDoctorReport(cwd, opts);
655
+ } else {
656
+ log.info("no auto-fixable findings");
657
+ }
658
+ }
222
659
  if (opts.json) {
223
660
  console.log(JSON.stringify(report, null, 2));
224
661
  if (!report.ok)
@@ -260,14 +697,24 @@ var migrate_exports = {};
260
697
  __export(migrate_exports, {
261
698
  migrate: () => migrate
262
699
  });
263
- import { join as join12 } from "path";
264
- import { existsSync as existsSync11, readdirSync as readdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
700
+ import { join as join13 } from "path";
701
+ import { cpSync as cpSync2, existsSync as existsSync12, mkdirSync as mkdirSync5, readdirSync as readdirSync8, readFileSync as readFileSync10, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
702
+ function backupChangeDir(cwd, changeId, sessionStamp) {
703
+ const backupRoot = join13(cwd, ".cdd", "migrate-backup", sessionStamp);
704
+ const backupDir2 = join13(backupRoot, changeId);
705
+ mkdirSync5(backupRoot, { recursive: true });
706
+ const sourceDir = join13(cwd, "specs", "changes", changeId);
707
+ if (existsSync12(sourceDir)) {
708
+ cpSync2(sourceDir, backupDir2, { recursive: true });
709
+ }
710
+ return backupDir2;
711
+ }
265
712
  function buildLegacyContextManifest(changeId) {
266
713
  return [
267
714
  "# Context Manifest",
268
715
  "",
269
716
  "Generated by `cdd-kit migrate` for an existing change.",
270
- "This legacy manifest records a conservative default context boundary without enabling context-governance: v1.",
717
+ "Legacy manifest. Forbidden paths come from `.cdd/context-policy.json`.",
271
718
  "",
272
719
  "## Affected Surfaces",
273
720
  "- legacy-unknown",
@@ -275,16 +722,6 @@ function buildLegacyContextManifest(changeId) {
275
722
  "## Allowed Paths",
276
723
  `- specs/changes/${changeId}/`,
277
724
  "",
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
725
  "## Required Contracts",
289
726
  "- legacy-unknown",
290
727
  "",
@@ -321,6 +758,7 @@ function buildContextGovernedManifest(changeId) {
321
758
  "",
322
759
  "Generated by `cdd-kit migrate --enable-context-governance` for an existing change.",
323
760
  "Review and narrow the allowed paths before assigning implementation work.",
761
+ "Forbidden paths come from `.cdd/context-policy.json`.",
324
762
  "",
325
763
  "## Affected Surfaces",
326
764
  "- legacy-unknown",
@@ -330,16 +768,6 @@ function buildContextGovernedManifest(changeId) {
330
768
  "- specs/context/project-map.md",
331
769
  "- specs/context/contracts-index.md",
332
770
  "",
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
771
  "## Required Contracts",
344
772
  "- legacy-unknown",
345
773
  "",
@@ -372,12 +800,14 @@ function buildContextGovernedManifest(changeId) {
372
800
  ""
373
801
  ].join("\n");
374
802
  }
375
- function migrateOne(changeId, changeDir, dryRun, enableContextGovernance) {
803
+ function migrateOne(changeId, changeDir, enableContextGovernance) {
376
804
  const changed = [];
377
805
  const warnings = [];
378
- const tasksPath = join12(changeDir, "tasks.md");
379
- if (existsSync11(tasksPath)) {
380
- let content = readFileSync9(tasksPath, "utf8");
806
+ const pending = [];
807
+ let detectedTier = null;
808
+ const tasksPath = join13(changeDir, "tasks.md");
809
+ if (existsSync12(tasksPath)) {
810
+ let content = readFileSync10(tasksPath, "utf8");
381
811
  const norm = content.replace(/\r\n/g, "\n");
382
812
  let modified = false;
383
813
  const taskChanges = [];
@@ -413,19 +843,19 @@ status: ${inferredStatus}
413
843
  }
414
844
  if (modified) {
415
845
  changed.push(`tasks.md: ${taskChanges.join("; ")}`);
416
- if (!dryRun)
417
- writeFileSync4(tasksPath, content, "utf8");
846
+ pending.push({ path: tasksPath, content });
418
847
  }
419
848
  } else {
420
849
  warnings.push("tasks.md not found \u2014 skipping frontmatter migration");
421
850
  }
422
- const classifPath = join12(changeDir, "change-classification.md");
423
- if (existsSync11(classifPath)) {
424
- const content = readFileSync9(classifPath, "utf8");
851
+ const classifPath = join13(changeDir, "change-classification.md");
852
+ if (existsSync12(classifPath)) {
853
+ const content = readFileSync10(classifPath, "utf8");
425
854
  const hasNewTierFormat = /^## Tier\s*\n\s*-\s*\d\s*$/m.test(content);
855
+ const oldMatch = content.match(/\*\*Tier[:\*]+\s*(?:Tier\s*)?(\d)/i) ?? content.match(/^-?\s*Tier:\s*(?:Tier\s*)?(\d)/mi);
856
+ if (oldMatch)
857
+ detectedTier = oldMatch[1];
426
858
  if (!hasNewTierFormat) {
427
- const oldMatch = content.match(/\*\*Tier[:\*]+\s*(?:Tier\s*)?(\d)/i) ?? content.match(/^-?\s*Tier:\s*(?:Tier\s*)?(\d)/mi);
428
- const detectedTier = oldMatch ? oldMatch[1] : null;
429
859
  if (detectedTier) {
430
860
  const addition = `
431
861
  ## Tier
@@ -435,54 +865,100 @@ status: ${inferredStatus}
435
865
  changed.push(
436
866
  `change-classification.md: appended "## Tier\\n- ${detectedTier}" (converted from old format)`
437
867
  );
438
- if (!dryRun)
439
- writeFileSync4(classifPath, content + addition, "utf8");
868
+ pending.push({ path: classifPath, content: content + addition });
440
869
  }
441
870
  } else {
442
871
  warnings.push(
443
- "change-classification.md: could not detect tier (no **Tier:** N or ## Tier N found). gate tier-based agent-log checks will be skipped for this change."
872
+ "change-classification.md: could not detect tier (no **Tier:** N or ## Tier N found). Set `tier: <0-5>` in tasks.md frontmatter to enable tier-based gate checks."
444
873
  );
445
874
  }
875
+ } else {
876
+ const structured = content.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
877
+ if (structured)
878
+ detectedTier = structured[1];
446
879
  }
447
880
  }
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
- );
881
+ if (existsSync12(tasksPath)) {
882
+ const tasksWrite = pending.find((p) => p.path === tasksPath);
883
+ let content = tasksWrite ? tasksWrite.content : readFileSync10(tasksPath, "utf8");
884
+ let modified = false;
885
+ const subChanges = [];
886
+ if (detectedTier && !/^tier:\s*\d/m.test(content)) {
887
+ content = upsertFrontmatterField(content, "tier", detectedTier);
888
+ modified = true;
889
+ subChanges.push(`backfilled tier: ${detectedTier}`);
890
+ }
891
+ if (!/^archive-tasks:/m.test(content)) {
892
+ content = upsertFrontmatterField(content, "archive-tasks", '["7.1", "7.2"]');
893
+ modified = true;
894
+ subChanges.push("added default archive-tasks list");
895
+ }
896
+ if (modified) {
897
+ if (tasksWrite) {
898
+ tasksWrite.content = content;
899
+ } else {
900
+ pending.push({ path: tasksPath, content });
901
+ }
902
+ changed.push(`tasks.md: ${subChanges.join("; ")}`);
457
903
  }
904
+ }
905
+ const manifestPath = join13(changeDir, "context-manifest.md");
906
+ if (!existsSync12(manifestPath)) {
907
+ changed.push(enableContextGovernance ? "context-manifest.md: added context-governance v1 manifest scaffold" : "context-manifest.md: added legacy context manifest scaffold");
908
+ pending.push({
909
+ path: manifestPath,
910
+ content: enableContextGovernance ? buildContextGovernedManifest(changeId) : buildLegacyContextManifest(changeId)
911
+ });
458
912
  } else if (enableContextGovernance) {
459
913
  warnings.push("context-manifest.md already exists \u2014 review allowed paths before relying on context-governance: v1");
460
914
  }
461
- return { changed, warnings };
915
+ return { result: { changed, warnings }, pending };
916
+ }
917
+ function commitWritesAtomically(pending) {
918
+ const renames = [];
919
+ try {
920
+ for (const write of pending) {
921
+ const tmp = `${write.path}.cdd-migrate.tmp`;
922
+ writeFileSync5(tmp, write.content, "utf8");
923
+ renames.push({ tmp, final: write.path });
924
+ }
925
+ } catch (err) {
926
+ for (const r of renames) {
927
+ try {
928
+ rmSync2(r.tmp, { force: true });
929
+ } catch {
930
+ }
931
+ }
932
+ throw err;
933
+ }
934
+ for (const r of renames) {
935
+ renameSync(r.tmp, r.final);
936
+ }
462
937
  }
463
938
  async function migrate(changeId, opts = {}) {
464
939
  const cwd = process.cwd();
465
940
  const dryRun = opts.dryRun ?? false;
466
941
  const enableContextGovernance = opts.enableContextGovernance ?? false;
942
+ const noBackup = opts.noBackup ?? false;
467
943
  const idsToMigrate = [];
468
944
  if (opts.all) {
469
- const changesDir = join12(cwd, "specs", "changes");
470
- if (!existsSync11(changesDir)) {
945
+ const changesDir = join13(cwd, "specs", "changes");
946
+ if (!existsSync12(changesDir)) {
471
947
  log.info("No specs/changes/ directory found \u2014 nothing to migrate.");
472
948
  return;
473
949
  }
474
950
  idsToMigrate.push(
475
- ...readdirSync7(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
951
+ ...readdirSync8(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
476
952
  );
477
953
  } else if (changeId) {
478
- const specificDir = join12(cwd, "specs", "changes", changeId);
479
- if (!existsSync11(specificDir)) {
954
+ const specificDir = join13(cwd, "specs", "changes", changeId);
955
+ if (!existsSync12(specificDir)) {
480
956
  log.error(`Change not found: specs/changes/${changeId}`);
481
957
  process.exit(1);
482
958
  }
483
959
  idsToMigrate.push(changeId);
484
960
  } else {
485
- log.error("Usage: cdd-kit migrate <change-id> | cdd-kit migrate --all [--dry-run]");
961
+ log.error("Usage: cdd-kit migrate <change-id> | cdd-kit migrate --all [--dry-run] [--no-backup]");
486
962
  process.exit(1);
487
963
  }
488
964
  if (idsToMigrate.length === 0) {
@@ -493,35 +969,54 @@ async function migrate(changeId, opts = {}) {
493
969
  log.info("Dry run \u2014 no files will be written.");
494
970
  log.blank();
495
971
  }
972
+ const sessionStamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
496
973
  let migratedCount = 0;
497
974
  let upToDateCount = 0;
975
+ const backupRoot = join13(cwd, ".cdd", "migrate-backup", sessionStamp);
498
976
  for (const id of idsToMigrate) {
499
- const changeDir = join12(cwd, "specs", "changes", id);
500
- if (!existsSync11(changeDir)) {
977
+ const changeDir = join13(cwd, "specs", "changes", id);
978
+ if (!existsSync12(changeDir)) {
501
979
  log.warn(` ${id}: directory not found \u2014 skipping`);
502
980
  continue;
503
981
  }
504
- const { changed, warnings } = migrateOne(id, changeDir, dryRun, enableContextGovernance);
505
- if (changed.length > 0) {
506
- log.ok(` ${id}: migrated`);
507
- for (const c of changed)
508
- log.info(` + ${c}`);
509
- migratedCount++;
510
- } else {
982
+ const { result, pending } = migrateOne(id, changeDir, enableContextGovernance);
983
+ const { changed, warnings } = result;
984
+ if (changed.length === 0) {
511
985
  log.info(` ${id}: already up to date`);
512
986
  upToDateCount++;
987
+ for (const w of warnings)
988
+ log.warn(` ${id}: ${w}`);
989
+ continue;
513
990
  }
514
- for (const w of warnings) {
515
- log.warn(` ${id}: ${w}`);
991
+ if (!dryRun) {
992
+ try {
993
+ if (!noBackup)
994
+ backupChangeDir(cwd, id, sessionStamp);
995
+ commitWritesAtomically(pending);
996
+ } catch (err) {
997
+ log.error(` ${id}: migration failed \u2014 ${err.message}`);
998
+ if (!noBackup) {
999
+ log.error(` ${id}: restore from .cdd/migrate-backup/${sessionStamp}/${id}/`);
1000
+ }
1001
+ process.exit(1);
1002
+ }
516
1003
  }
1004
+ log.ok(` ${id}: migrated`);
1005
+ for (const c of changed)
1006
+ log.info(` + ${c}`);
1007
+ migratedCount++;
1008
+ for (const w of warnings)
1009
+ log.warn(` ${id}: ${w}`);
517
1010
  }
518
1011
  log.blank();
519
1012
  if (dryRun) {
520
1013
  log.info(`Dry run complete: ${migratedCount} change(s) would be updated, ${upToDateCount} already up to date.`);
521
1014
  } else {
522
1015
  log.ok(`Migration complete: ${migratedCount} updated, ${upToDateCount} already up to date.`);
523
- if (migratedCount > 0) {
1016
+ if (migratedCount > 0 && !noBackup) {
1017
+ log.info(`Backup: ${backupRoot}`);
524
1018
  log.info('Next: git add specs/changes/ && git commit -m "chore: migrate changes to current cdd-kit format"');
1019
+ log.info("When stable, remove backup: rm -rf .cdd/migrate-backup/");
525
1020
  }
526
1021
  }
527
1022
  }
@@ -537,39 +1032,39 @@ var upgrade_exports = {};
537
1032
  __export(upgrade_exports, {
538
1033
  upgrade: () => upgrade
539
1034
  });
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";
1035
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, readdirSync as readdirSync9, copyFileSync as copyFileSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync6 } from "fs";
1036
+ import { dirname as dirname4, join as join14, relative as relative3 } from "path";
542
1037
  function planMissingFiles(srcDir, destDir, label, planned) {
543
- if (!existsSync12(srcDir))
1038
+ if (!existsSync13(srcDir))
544
1039
  return;
545
- for (const entry of readdirSync8(srcDir, { withFileTypes: true })) {
546
- const src = join13(srcDir, entry.name);
547
- const dest = join13(destDir, entry.name);
1040
+ for (const entry of readdirSync9(srcDir, { withFileTypes: true })) {
1041
+ const src = join14(srcDir, entry.name);
1042
+ const dest = join14(destDir, entry.name);
548
1043
  if (entry.isDirectory()) {
549
- planMissingFiles(src, dest, join13(label, entry.name), planned);
1044
+ planMissingFiles(src, dest, join14(label, entry.name), planned);
550
1045
  continue;
551
1046
  }
552
- if (!existsSync12(dest)) {
553
- planned.push({ src, dest, rel: join13(label, relative2(srcDir, src)) });
1047
+ if (!existsSync13(dest)) {
1048
+ planned.push({ src, dest, rel: join14(label, relative3(srcDir, src)) });
554
1049
  }
555
1050
  }
556
1051
  }
557
1052
  function planProviderGuidance(cwd, provider, planned) {
558
1053
  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" });
1054
+ if (!existsSync13(join14(cwd, "CLAUDE.md"))) {
1055
+ planned.push({ src: ASSET.claudeTemplate, dest: join14(cwd, "CLAUDE.md"), rel: "CLAUDE.md" });
561
1056
  }
562
- if (!existsSync12(join13(cwd, "AGENTS.md"))) {
563
- planned.push({ src: ASSET.agentsTemplate, dest: join13(cwd, "AGENTS.md"), rel: "AGENTS.md" });
1057
+ if (!existsSync13(join14(cwd, "AGENTS.md"))) {
1058
+ planned.push({ src: ASSET.agentsTemplate, dest: join14(cwd, "AGENTS.md"), rel: "AGENTS.md" });
564
1059
  }
565
1060
  }
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" });
1061
+ if ((provider === "codex" || provider === "both") && !existsSync13(join14(cwd, "CODEX.md"))) {
1062
+ planned.push({ src: ASSET.codexTemplate, dest: join14(cwd, "CODEX.md"), rel: "CODEX.md" });
568
1063
  }
569
1064
  }
570
1065
  function applyCopy(plan) {
571
1066
  for (const item of plan) {
572
- mkdirSync4(dirname3(item.dest), { recursive: true });
1067
+ mkdirSync6(dirname4(item.dest), { recursive: true });
573
1068
  copyFileSync3(item.src, item.dest);
574
1069
  }
575
1070
  }
@@ -582,12 +1077,12 @@ async function upgrade(opts = {}) {
582
1077
  }
583
1078
  const provider = inferProvider(cwd, requestedProvider);
584
1079
  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);
1080
+ planMissingFiles(ASSET.contracts, join14(cwd, "contracts"), "contracts", plan);
1081
+ planMissingFiles(ASSET.specsTemplates, join14(cwd, "specs", "templates"), "specs/templates", plan);
1082
+ planMissingFiles(ASSET.testsTemplates, join14(cwd, "tests", "templates"), "tests/templates", plan);
1083
+ planMissingFiles(ASSET.ci, join14(cwd, "ci"), "ci", plan);
1084
+ planMissingFiles(ASSET.githubWorkflows, join14(cwd, ".github", "workflows"), ".github/workflows", plan);
1085
+ planMissingFiles(ASSET.cddConfig, join14(cwd, ".cdd"), ".cdd", plan);
591
1086
  planProviderGuidance(cwd, provider, plan);
592
1087
  log.blank();
593
1088
  log.info(`Upgrade provider: ${provider}`);
@@ -624,13 +1119,20 @@ async function upgrade(opts = {}) {
624
1119
  return;
625
1120
  }
626
1121
  applyCopy(plan);
627
- const modelPolicyPath = join13(cwd, ".cdd", "model-policy.json");
628
- if (existsSync12(modelPolicyPath)) {
629
- writeFileSync5(modelPolicyPath, JSON.stringify({
1122
+ const modelPolicyPath = join14(cwd, ".cdd", "model-policy.json");
1123
+ if (existsSync13(modelPolicyPath)) {
1124
+ let existing = {};
1125
+ try {
1126
+ existing = JSON.parse(readFileSync11(modelPolicyPath, "utf8"));
1127
+ } catch {
1128
+ }
1129
+ const merged = {
1130
+ ...existing,
630
1131
  provider,
631
1132
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
632
- roles: {}
633
- }, null, 2) + "\n", "utf8");
1133
+ roles: existing.roles && typeof existing.roles === "object" ? existing.roles : {}
1134
+ };
1135
+ writeFileSync6(modelPolicyPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
634
1136
  }
635
1137
  log.blank();
636
1138
  log.ok(`Upgrade complete: ${plan.length} missing file(s) added.`);
@@ -661,26 +1163,26 @@ var archive_exports = {};
661
1163
  __export(archive_exports, {
662
1164
  archive: () => archive
663
1165
  });
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";
1166
+ import { join as join15 } from "path";
1167
+ import { existsSync as existsSync14, mkdirSync as mkdirSync7, renameSync as renameSync2, readFileSync as readFileSync12, writeFileSync as writeFileSync7, appendFileSync, cpSync as cpSync3, rmSync as rmSync3 } from "fs";
666
1168
  async function archive(changeId) {
667
1169
  const cwd = process.cwd();
668
- const changeDir = join14(cwd, "specs", "changes", changeId);
1170
+ const changeDir = join15(cwd, "specs", "changes", changeId);
669
1171
  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)) {
1172
+ const archiveBase = join15(cwd, "specs", "archive", archiveYear);
1173
+ const archiveDir = join15(archiveBase, changeId);
1174
+ const indexPath = join15(cwd, "specs", "archive", "INDEX.md");
1175
+ if (!existsSync14(changeDir)) {
674
1176
  log.error(`Change not found: specs/changes/${changeId}`);
675
1177
  process.exit(1);
676
1178
  }
677
- if (existsSync13(archiveDir)) {
1179
+ if (existsSync14(archiveDir)) {
678
1180
  log.error(`Already archived: specs/archive/${archiveYear}/${changeId}`);
679
1181
  process.exit(1);
680
1182
  }
681
- const tasksPath = join14(changeDir, "tasks.md");
682
- if (existsSync13(tasksPath)) {
683
- const content = readFileSync10(tasksPath, "utf8");
1183
+ const tasksPath = join15(changeDir, "tasks.md");
1184
+ if (existsSync14(tasksPath)) {
1185
+ const content = readFileSync12(tasksPath, "utf8");
684
1186
  if (content.includes("status: gate-blocked")) {
685
1187
  log.warn("tasks.md has status: gate-blocked \u2014 archiving anyway (change was paused).");
686
1188
  }
@@ -689,15 +1191,15 @@ async function archive(changeId) {
689
1191
  log.warn(`${pending} task(s) still pending ([ ]). Archive anyway.`);
690
1192
  }
691
1193
  }
692
- if (!existsSync13(archiveBase)) {
693
- mkdirSync5(archiveBase, { recursive: true });
1194
+ if (!existsSync14(archiveBase)) {
1195
+ mkdirSync7(archiveBase, { recursive: true });
694
1196
  }
695
1197
  try {
696
- renameSync(changeDir, archiveDir);
1198
+ renameSync2(changeDir, archiveDir);
697
1199
  } catch (err) {
698
1200
  if (err.code === "EXDEV") {
699
- cpSync2(changeDir, archiveDir, { recursive: true });
700
- rmSync2(changeDir, { recursive: true, force: true });
1201
+ cpSync3(changeDir, archiveDir, { recursive: true });
1202
+ rmSync3(changeDir, { recursive: true, force: true });
701
1203
  } else {
702
1204
  throw err;
703
1205
  }
@@ -706,8 +1208,8 @@ async function archive(changeId) {
706
1208
  const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
707
1209
  const indexLine = `| ${changeId} | ${archiveYear} | ${today} | specs/archive/${archiveYear}/${changeId}/ |
708
1210
  `;
709
- if (!existsSync13(indexPath)) {
710
- writeFileSync6(indexPath, `# Archive Index
1211
+ if (!existsSync14(indexPath)) {
1212
+ writeFileSync7(indexPath, `# Archive Index
711
1213
 
712
1214
  | change-id | year | archived-date | path |
713
1215
  |---|---|---|---|
@@ -720,357 +1222,125 @@ ${indexLine}`, "utf8");
720
1222
  log.info(`Next: promote durable learnings from archive.md to contracts/ or CLAUDE.md`);
721
1223
  }
722
1224
  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
-
787
- // src/commands/list-changes.ts
788
- var list_changes_exports = {};
789
- __export(list_changes_exports, {
790
- listChanges: () => listChanges
791
- });
792
- import { join as join16 } from "path";
793
- import { existsSync as existsSync15, readdirSync as readdirSync9, readFileSync as readFileSync12 } from "fs";
794
- async function listChanges() {
795
- const cwd = process.cwd();
796
- const changesDir = join16(cwd, "specs", "changes");
797
- log.blank();
798
- const active = [];
799
- if (existsSync15(changesDir)) {
800
- active.push(...readdirSync9(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name));
801
- }
802
- if (active.length === 0) {
803
- log.info("No active changes in specs/changes/");
804
- } else {
805
- log.info("Active changes:");
806
- for (const id of active) {
807
- const tasksPath = join16(changesDir, id, "tasks.md");
808
- let status = "in-progress";
809
- let pending = 0;
810
- if (existsSync15(tasksPath)) {
811
- const content = readFileSync12(tasksPath, "utf8");
812
- if (content.includes("status: gate-blocked"))
813
- status = "gate-blocked";
814
- else if (content.includes("status: abandoned"))
815
- status = "abandoned";
816
- pending = (content.match(/^\s*-\s*\[ \]/gm) || []).length;
817
- }
818
- const pendingStr = pending > 0 ? ` (${pending} pending)` : "";
819
- log.info(` ${id} [${status}]${pendingStr}`);
820
- }
821
- }
822
- log.blank();
823
- }
824
- var init_list_changes = __esm({
825
- "src/commands/list-changes.ts"() {
826
- "use strict";
827
- init_logger();
828
- }
829
- });
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}
1225
+ "src/commands/archive.ts"() {
1226
+ "use strict";
1227
+ init_logger();
1228
+ }
1229
+ });
1010
1230
 
1011
- `;
1012
- contractEntries.push(entry);
1013
- if (!summary) {
1014
- missingSummary += 1;
1231
+ // src/commands/abandon.ts
1232
+ var abandon_exports = {};
1233
+ __export(abandon_exports, {
1234
+ abandon: () => abandon
1235
+ });
1236
+ import { join as join16 } from "path";
1237
+ import { existsSync as existsSync15, readFileSync as readFileSync13, writeFileSync as writeFileSync8, appendFileSync as appendFileSync2, mkdirSync as mkdirSync8 } from "fs";
1238
+ async function abandon(changeId, opts) {
1239
+ const cwd = process.cwd();
1240
+ const changeDir = join16(cwd, "specs", "changes", changeId);
1241
+ const tasksPath = join16(changeDir, "tasks.md");
1242
+ if (!existsSync15(changeDir)) {
1243
+ log.error(`Change not found: specs/changes/${changeId}`);
1244
+ process.exit(1);
1245
+ }
1246
+ if (existsSync15(tasksPath)) {
1247
+ let content = readFileSync13(tasksPath, "utf8");
1248
+ if (content.match(/^status:/m)) {
1249
+ content = content.replace(/^status: .*/m, "status: abandoned");
1250
+ } else {
1251
+ content = `---
1252
+ change-id: ${changeId}
1253
+ status: abandoned
1254
+ ---
1255
+
1256
+ ` + content;
1015
1257
  }
1258
+ writeFileSync8(tasksPath, content, "utf8");
1016
1259
  }
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).`);
1260
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1261
+ const archiveDir = join16(cwd, "specs", "archive");
1262
+ const indexPath = join16(archiveDir, "INDEX.md");
1263
+ const reason = opts.reason ?? "no reason given";
1264
+ const indexLine = `| ${changeId} | abandoned | ${today} | ${reason} |
1265
+ `;
1266
+ if (!existsSync15(archiveDir)) {
1267
+ mkdirSync8(archiveDir, { recursive: true });
1268
+ }
1269
+ if (!existsSync15(indexPath)) {
1270
+ writeFileSync8(indexPath, `# Archive Index
1271
+
1272
+ | change-id | status | date | notes |
1273
+ |---|---|---|---|
1274
+ ${indexLine}`, "utf8");
1043
1275
  } else {
1044
- log.ok("Created specs/context/contracts-index.md");
1276
+ appendFileSync2(indexPath, indexLine, "utf8");
1045
1277
  }
1278
+ log.ok(`Change ${changeId} marked as abandoned.`);
1279
+ log.info(`specs/changes/${changeId}/ remains on disk (git history preserved).`);
1280
+ log.info(`Run \`cdd-kit archive ${changeId}\` to physically move it, or leave it for git history.`);
1046
1281
  }
1047
- var DEFAULT_FORBIDDEN;
1048
- var init_context_scan = __esm({
1049
- "src/commands/context-scan.ts"() {
1282
+ var init_abandon = __esm({
1283
+ "src/commands/abandon.ts"() {
1284
+ "use strict";
1285
+ init_logger();
1286
+ }
1287
+ });
1288
+
1289
+ // src/commands/list-changes.ts
1290
+ var list_changes_exports = {};
1291
+ __export(list_changes_exports, {
1292
+ listChanges: () => listChanges
1293
+ });
1294
+ import { join as join17 } from "path";
1295
+ import { existsSync as existsSync16, readdirSync as readdirSync10, readFileSync as readFileSync14 } from "fs";
1296
+ async function listChanges() {
1297
+ const cwd = process.cwd();
1298
+ const changesDir = join17(cwd, "specs", "changes");
1299
+ log.blank();
1300
+ const active = [];
1301
+ if (existsSync16(changesDir)) {
1302
+ active.push(...readdirSync10(changesDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name));
1303
+ }
1304
+ if (active.length === 0) {
1305
+ log.info("No active changes in specs/changes/");
1306
+ } else {
1307
+ log.info("Active changes:");
1308
+ for (const id of active) {
1309
+ const tasksPath = join17(changesDir, id, "tasks.md");
1310
+ let status = "in-progress";
1311
+ let pending = 0;
1312
+ if (existsSync16(tasksPath)) {
1313
+ const content = readFileSync14(tasksPath, "utf8");
1314
+ if (content.includes("status: gate-blocked"))
1315
+ status = "gate-blocked";
1316
+ else if (content.includes("status: abandoned"))
1317
+ status = "abandoned";
1318
+ pending = (content.match(/^\s*-\s*\[ \]/gm) || []).length;
1319
+ }
1320
+ const pendingStr = pending > 0 ? ` (${pending} pending)` : "";
1321
+ log.info(` ${id} [${status}]${pendingStr}`);
1322
+ }
1323
+ }
1324
+ log.blank();
1325
+ }
1326
+ var init_list_changes = __esm({
1327
+ "src/commands/list-changes.ts"() {
1050
1328
  "use strict";
1051
1329
  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
1330
  }
1063
1331
  });
1064
1332
 
1065
1333
  // src/commands/context.ts
1066
1334
  var context_exports = {};
1067
1335
  __export(context_exports, {
1336
+ approveAllPending: () => approveAllPending,
1068
1337
  approveContextExpansion: () => approveContextExpansion,
1069
1338
  listContextExpansions: () => listContextExpansions,
1339
+ rejectAllPending: () => rejectAllPending,
1070
1340
  rejectContextExpansion: () => rejectContextExpansion,
1071
1341
  requestContextExpansion: () => requestContextExpansion
1072
1342
  });
1073
- import { existsSync as existsSync17, readFileSync as readFileSync14, writeFileSync as writeFileSync9 } from "fs";
1343
+ import { existsSync as existsSync17, readFileSync as readFileSync15, writeFileSync as writeFileSync9 } from "fs";
1074
1344
  import { join as join18 } from "path";
1075
1345
  function normalizePath(path) {
1076
1346
  return path.replace(/\\/g, "/").replace(/^\.\//, "").trim();
@@ -1093,7 +1363,7 @@ function readManifest(changeId) {
1093
1363
  log.error(`context manifest not found: specs/changes/${changeId}/context-manifest.md`);
1094
1364
  process.exit(1);
1095
1365
  }
1096
- return readFileSync14(manifestPath, "utf8");
1366
+ return readFileSync15(manifestPath, "utf8");
1097
1367
  }
1098
1368
  function writeManifest(changeId, content) {
1099
1369
  writeFileSync9(manifestPathFor(changeId), content.endsWith("\n") ? content : `${content}
@@ -1240,6 +1510,21 @@ async function listContextExpansions(changeId, json = false) {
1240
1510
  log.dim(` ${path}`);
1241
1511
  }
1242
1512
  }
1513
+ function applyApproval(content, request) {
1514
+ for (const path of request.paths) {
1515
+ const validationError = validateRepoRelativePath(path);
1516
+ if (validationError) {
1517
+ log.error(validationError);
1518
+ process.exit(1);
1519
+ }
1520
+ }
1521
+ const approved = approvedExpansionSet(content);
1522
+ for (const path of request.paths)
1523
+ approved.add(path);
1524
+ let next = replaceSection(content, "Approved Expansions", [...approved].sort().map((p) => `- ${p}`));
1525
+ next = setRequestStatus(next, request.requestId, "approved");
1526
+ return next;
1527
+ }
1243
1528
  async function approveContextExpansion(changeId, requestId) {
1244
1529
  const content = readManifest(changeId);
1245
1530
  const request = parseRequests(content).find((item) => item.requestId === requestId && item.status === "pending");
@@ -1251,28 +1536,53 @@ async function approveContextExpansion(changeId, requestId) {
1251
1536
  log.error(`context expansion request has no requested_paths: ${requestId}`);
1252
1537
  process.exit(1);
1253
1538
  }
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");
1539
+ const next = applyApproval(content, request);
1266
1540
  writeManifest(changeId, next);
1267
1541
  log.ok(`approved context expansion ${requestId} for ${changeId}`);
1268
1542
  for (const path of request.paths)
1269
1543
  log.info(` ${path}`);
1270
1544
  }
1545
+ async function approveAllPending(changeId) {
1546
+ let content = readManifest(changeId);
1547
+ const pending = parseRequests(content).filter((r) => r.status === "pending");
1548
+ if (pending.length === 0) {
1549
+ log.info(`no pending context expansion requests for ${changeId}`);
1550
+ return;
1551
+ }
1552
+ const skipped = [];
1553
+ let approvedCount = 0;
1554
+ for (const request of pending) {
1555
+ if (request.paths.length === 0) {
1556
+ skipped.push(`${request.requestId} (no requested_paths)`);
1557
+ continue;
1558
+ }
1559
+ content = applyApproval(content, request);
1560
+ approvedCount += 1;
1561
+ }
1562
+ writeManifest(changeId, content);
1563
+ log.ok(`approved ${approvedCount} pending context expansion request(s) for ${changeId}`);
1564
+ for (const reason of skipped) {
1565
+ log.warn(` skipped ${reason}`);
1566
+ }
1567
+ }
1271
1568
  async function rejectContextExpansion(changeId, requestId) {
1272
1569
  const next = setRequestStatus(readManifest(changeId), requestId, "rejected");
1273
1570
  writeManifest(changeId, next);
1274
1571
  log.ok(`rejected context expansion ${requestId} for ${changeId}`);
1275
1572
  }
1573
+ async function rejectAllPending(changeId) {
1574
+ let content = readManifest(changeId);
1575
+ const pending = parseRequests(content).filter((r) => r.status === "pending");
1576
+ if (pending.length === 0) {
1577
+ log.info(`no pending context expansion requests for ${changeId}`);
1578
+ return;
1579
+ }
1580
+ for (const request of pending) {
1581
+ content = setRequestStatus(content, request.requestId, "rejected");
1582
+ }
1583
+ writeManifest(changeId, content);
1584
+ log.ok(`rejected ${pending.length} pending context expansion request(s) for ${changeId}`);
1585
+ }
1276
1586
  var init_context = __esm({
1277
1587
  "src/commands/context.ts"() {
1278
1588
  "use strict";
@@ -1281,7 +1591,7 @@ var init_context = __esm({
1281
1591
  });
1282
1592
 
1283
1593
  // src/cli/index.ts
1284
- import { readFileSync as readFileSync15 } from "fs";
1594
+ import { readFileSync as readFileSync16 } from "fs";
1285
1595
  import { fileURLToPath as fileURLToPath2 } from "url";
1286
1596
  import { dirname as dirname5, join as join19 } from "path";
1287
1597
  import { Command } from "commander";
@@ -1590,11 +1900,18 @@ async function init(opts) {
1590
1900
  log.ok(`.cdd/ - ${cddConfigCount} file(s) written.`);
1591
1901
  const modelPolicyPath = join4(cwd, ".cdd", "model-policy.json");
1592
1902
  if (existsSync3(modelPolicyPath)) {
1593
- writeFileSync(modelPolicyPath, JSON.stringify({
1903
+ let existing = {};
1904
+ try {
1905
+ existing = JSON.parse(readFileSync2(modelPolicyPath, "utf8"));
1906
+ } catch {
1907
+ }
1908
+ const merged = {
1909
+ ...existing,
1594
1910
  provider: opts.provider,
1595
1911
  generated_at: (/* @__PURE__ */ new Date()).toISOString(),
1596
- roles: {}
1597
- }, null, 2) + "\n", "utf8");
1912
+ roles: existing.roles && typeof existing.roles === "object" && Object.keys(existing.roles).length > 0 ? existing.roles : {}
1913
+ };
1914
+ writeFileSync(modelPolicyPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
1598
1915
  }
1599
1916
  const { count: wfCount, created: wfCreated } = copyDirTracked(
1600
1917
  ASSET.githubWorkflows,
@@ -1823,9 +2140,58 @@ async function update(opts) {
1823
2140
 
1824
2141
  // src/commands/new-change.ts
1825
2142
  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";
2143
+ import { join as join8 } from "path";
2144
+ import { createHash as createHash3 } from "crypto";
2145
+ import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync5, writeFileSync as writeFileSync3 } from "fs";
1828
2146
  init_logger();
2147
+ init_context_scan();
2148
+ function sha256OfFile2(path) {
2149
+ try {
2150
+ return createHash3("sha256").update(readFileSync6(path)).digest("hex");
2151
+ } catch {
2152
+ return "";
2153
+ }
2154
+ }
2155
+ function inputsDigest2(paths) {
2156
+ const combined = paths.slice().sort().map((p) => `${p}:${sha256OfFile2(p)}`).join("\n");
2157
+ return createHash3("sha256").update(combined).digest("hex");
2158
+ }
2159
+ function findContractFiles2(dir, found = []) {
2160
+ if (!existsSync7(dir))
2161
+ return found;
2162
+ for (const entry of readdirSync5(dir, { withFileTypes: true })) {
2163
+ const fullPath = join8(dir, entry.name);
2164
+ if (entry.isDirectory())
2165
+ findContractFiles2(fullPath, found);
2166
+ else if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "INDEX.md" && entry.name !== "CHANGELOG.md") {
2167
+ found.push(fullPath);
2168
+ }
2169
+ }
2170
+ return found;
2171
+ }
2172
+ function readIndexDigest(filePath) {
2173
+ if (!existsSync7(filePath))
2174
+ return null;
2175
+ const m = readFileSync6(filePath, "utf8").match(/^inputs-digest:\s*([a-f0-9]+)/m);
2176
+ return m ? m[1] : null;
2177
+ }
2178
+ async function ensureFreshContextIndexes(cwd) {
2179
+ const projectMap = join8(cwd, "specs", "context", "project-map.md");
2180
+ const contractsIndex = join8(cwd, "specs", "context", "contracts-index.md");
2181
+ const policyPath = join8(cwd, ".cdd", "context-policy.json");
2182
+ const policyInputs = [policyPath].filter(existsSync7);
2183
+ const contractFiles = findContractFiles2(join8(cwd, "contracts"));
2184
+ const wantProjectDigest = inputsDigest2(policyInputs);
2185
+ const wantContractsDigest = inputsDigest2(contractFiles);
2186
+ const haveProjectDigest = readIndexDigest(projectMap);
2187
+ const haveContractsDigest = readIndexDigest(contractsIndex);
2188
+ const needsScan = !existsSync7(projectMap) || !existsSync7(contractsIndex) || haveProjectDigest !== wantProjectDigest || haveContractsDigest !== wantContractsDigest;
2189
+ if (!needsScan)
2190
+ return;
2191
+ log.info("context indexes missing or stale \u2014 running cdd-kit context-scan\u2026");
2192
+ await contextScan();
2193
+ log.dim(" (skip with --skip-scan)");
2194
+ }
1829
2195
  var REQUIRED_TEMPLATES = [
1830
2196
  "change-request.md",
1831
2197
  "change-classification.md",
@@ -1836,7 +2202,7 @@ var REQUIRED_TEMPLATES = [
1836
2202
  ];
1837
2203
  function listOptional() {
1838
2204
  try {
1839
- const all = readdirSync4(ASSET.specsTemplates).filter((f) => f.endsWith(".md"));
2205
+ const all = readdirSync5(ASSET.specsTemplates).filter((f) => f.endsWith(".md"));
1840
2206
  return all.filter((f) => !REQUIRED_TEMPLATES.includes(f));
1841
2207
  } catch {
1842
2208
  return [];
@@ -1866,8 +2232,8 @@ async function newChange(name, opts) {
1866
2232
  }
1867
2233
  }
1868
2234
  const cwd = process.cwd();
1869
- const changeDir = join7(cwd, "specs", "changes", name);
1870
- if (existsSync6(changeDir)) {
2235
+ const changeDir = join8(cwd, "specs", "changes", name);
2236
+ if (existsSync7(changeDir)) {
1871
2237
  if (opts.force) {
1872
2238
  log.warn(`Forcing re-scaffold of existing change directory: ${changeDir}`);
1873
2239
  log.warn("Existing files will NOT be deleted; only template files will be overwritten.");
@@ -1877,15 +2243,22 @@ async function newChange(name, opts) {
1877
2243
  return;
1878
2244
  }
1879
2245
  }
2246
+ if (!opts.skipScan) {
2247
+ try {
2248
+ await ensureFreshContextIndexes(cwd);
2249
+ } catch (err) {
2250
+ log.warn(`context-scan failed: ${err.message}; continuing without fresh indexes`);
2251
+ }
2252
+ }
1880
2253
  log.blank();
1881
2254
  log.info(`Creating change scaffold: specs/changes/${name}`);
1882
2255
  ensureDir(changeDir);
1883
2256
  const templates = opts.all ? [...REQUIRED_TEMPLATES, ...listOptional()] : [...REQUIRED_TEMPLATES];
1884
2257
  let written = 0;
1885
2258
  for (const tmpl of templates) {
1886
- const src = join7(ASSET.specsTemplates, tmpl);
1887
- const dest = join7(changeDir, tmpl);
1888
- if (!existsSync6(src)) {
2259
+ const src = join8(ASSET.specsTemplates, tmpl);
2260
+ const dest = join8(changeDir, tmpl);
2261
+ if (!existsSync7(src)) {
1889
2262
  log.warn(`Template not found, skipping: ${tmpl}`);
1890
2263
  continue;
1891
2264
  }
@@ -1894,11 +2267,11 @@ async function newChange(name, opts) {
1894
2267
  written += 1;
1895
2268
  }
1896
2269
  if (dependencies.length > 0) {
1897
- const tasksPath = join7(changeDir, "tasks.md");
1898
- if (existsSync6(tasksPath)) {
1899
- const tasks = readFileSync5(tasksPath, "utf8");
2270
+ const tasksPath = join8(changeDir, "tasks.md");
2271
+ if (existsSync7(tasksPath)) {
2272
+ const tasks = readFileSync6(tasksPath, "utf8");
1900
2273
  const nextTasks = tasks.replace(/^depends-on:\s*.*$/m, formatDependsOn(dependencies));
1901
- writeFileSync2(tasksPath, nextTasks, "utf8");
2274
+ writeFileSync3(tasksPath, nextTasks, "utf8");
1902
2275
  log.dim(`depends-on: ${dependencies.join(", ")}`);
1903
2276
  }
1904
2277
  }
@@ -1910,8 +2283,8 @@ async function newChange(name, opts) {
1910
2283
  // src/commands/validate.ts
1911
2284
  init_paths();
1912
2285
  init_logger();
1913
- import { join as join8 } from "path";
1914
- import { existsSync as existsSync7 } from "fs";
2286
+ import { join as join9 } from "path";
2287
+ import { existsSync as existsSync8 } from "fs";
1915
2288
  import { spawnSync } from "child_process";
1916
2289
  var VALIDATORS = [
1917
2290
  {
@@ -1944,15 +2317,15 @@ async function validate(opts) {
1944
2317
  log.error(e instanceof Error ? e.message : String(e));
1945
2318
  process.exit(1);
1946
2319
  }
1947
- const scriptsDir = join8(ASSET.skill, "scripts");
2320
+ const scriptsDir = join9(ASSET.skill, "scripts");
1948
2321
  const runAll = !opts.contracts && !opts.env && !opts.ci && !opts.spec && !opts.versions;
1949
2322
  log.blank();
1950
2323
  let failed = false;
1951
2324
  for (const v of VALIDATORS) {
1952
2325
  if (!runAll && !opts[v.flag])
1953
2326
  continue;
1954
- const scriptPath = join8(scriptsDir, v.script);
1955
- if (!existsSync7(scriptPath)) {
2327
+ const scriptPath = join9(scriptsDir, v.script);
2328
+ if (!existsSync8(scriptPath)) {
1956
2329
  log.warn(`${v.label}: script not found, skipping (${v.script})`);
1957
2330
  log.blank();
1958
2331
  continue;
@@ -1968,8 +2341,8 @@ async function validate(opts) {
1968
2341
  log.blank();
1969
2342
  if (v.chain) {
1970
2343
  for (const chained of v.chain) {
1971
- const chainedPath = join8(scriptsDir, chained.script);
1972
- if (!existsSync7(chainedPath)) {
2344
+ const chainedPath = join9(scriptsDir, chained.script);
2345
+ if (!existsSync8(chainedPath)) {
1973
2346
  log.warn(`${chained.label}: script not found, skipping (${chained.script})`);
1974
2347
  log.blank();
1975
2348
  continue;
@@ -1997,9 +2370,8 @@ async function validate(opts) {
1997
2370
 
1998
2371
  // src/commands/gate.ts
1999
2372
  init_logger();
2000
- import { existsSync as existsSync8, readFileSync as readFileSync6, readdirSync as readdirSync5 } from "fs";
2001
- import { join as join9 } from "path";
2002
- import { spawnSync as spawnSync2 } from "child_process";
2373
+ import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync6 } from "fs";
2374
+ import { join as join10 } from "path";
2003
2375
  var REQUIRED_FILES = [
2004
2376
  "change-request.md",
2005
2377
  "change-classification.md",
@@ -2080,11 +2452,11 @@ function loadContextPolicy(cwd) {
2080
2452
  unknownFilesRead: "warn-for-legacy-fail-for-new"
2081
2453
  }
2082
2454
  };
2083
- const policyPath = join9(cwd, ".cdd", "context-policy.json");
2084
- if (!existsSync8(policyPath))
2455
+ const policyPath = join10(cwd, ".cdd", "context-policy.json");
2456
+ if (!existsSync9(policyPath))
2085
2457
  return defaults;
2086
2458
  try {
2087
- const custom = JSON.parse(readFileSync6(policyPath, "utf8"));
2459
+ const custom = JSON.parse(readFileSync7(policyPath, "utf8"));
2088
2460
  return {
2089
2461
  ...defaults,
2090
2462
  ...custom,
@@ -2097,53 +2469,219 @@ function loadContextPolicy(cwd) {
2097
2469
  }
2098
2470
  }
2099
2471
  function isContextGovernedChange(changeDir) {
2100
- const tasksPath = join9(changeDir, "tasks.md");
2101
- if (!existsSync8(tasksPath))
2472
+ const tasksPath = join10(changeDir, "tasks.md");
2473
+ if (!existsSync9(tasksPath))
2102
2474
  return false;
2103
- return /^context-governance:\s*v1\b/m.test(readFileSync6(tasksPath, "utf8"));
2475
+ return /^context-governance:\s*v1\b/m.test(readFileSync7(tasksPath, "utf8"));
2476
+ }
2477
+ var KNOWN_FRONTMATTER_KEYS = /* @__PURE__ */ new Set([
2478
+ "change-id",
2479
+ "status",
2480
+ "tier",
2481
+ "archive-tasks",
2482
+ "context-governance",
2483
+ "depends-on",
2484
+ // Allowed but informational only:
2485
+ "token-budget",
2486
+ "created",
2487
+ "completed"
2488
+ ]);
2489
+ var VALID_TASK_STATUSES = /* @__PURE__ */ new Set(["in-progress", "completed", "complete", "done", "gate-blocked", "abandoned", "needs-review"]);
2490
+ function lintFrontmatter(content, errors, warnings) {
2491
+ const fm = parseTaskFrontmatter(content);
2492
+ if (!fm["change-id"]) {
2493
+ errors.push("tasks.md frontmatter: missing required `change-id`");
2494
+ }
2495
+ if (!fm.status) {
2496
+ errors.push("tasks.md frontmatter: missing required `status`");
2497
+ } else if (!VALID_TASK_STATUSES.has(fm.status.toLowerCase())) {
2498
+ errors.push(`tasks.md frontmatter: invalid status \`${fm.status}\` (expected one of: ${[...VALID_TASK_STATUSES].join(", ")})`);
2499
+ }
2500
+ if (fm.tier !== void 0 && fm.tier !== "") {
2501
+ const n = parseInt(fm.tier, 10);
2502
+ if (Number.isNaN(n) || n < 0 || n > 5) {
2503
+ errors.push(`tasks.md frontmatter: invalid tier \`${fm.tier}\` (expected 0-5)`);
2504
+ }
2505
+ }
2506
+ for (const key of Object.keys(fm)) {
2507
+ if (!KNOWN_FRONTMATTER_KEYS.has(key)) {
2508
+ const lower = key.toLowerCase();
2509
+ const suggestion = KNOWN_FRONTMATTER_KEYS.has(lower) ? ` (did you mean \`${lower}\`?)` : "";
2510
+ warnings.push(`tasks.md frontmatter: unknown key \`${key}\`${suggestion}`);
2511
+ }
2512
+ }
2513
+ }
2514
+ function parseTaskFrontmatter(content) {
2515
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2516
+ if (!match)
2517
+ return {};
2518
+ const out = {};
2519
+ for (const line of match[1].split(/\r?\n/)) {
2520
+ const colon = line.indexOf(":");
2521
+ if (colon === -1)
2522
+ continue;
2523
+ const key = line.slice(0, colon).trim();
2524
+ if (!key)
2525
+ continue;
2526
+ out[key] = line.slice(colon + 1).trim();
2527
+ }
2528
+ return out;
2104
2529
  }
2105
- function parseDependsOn2(content) {
2106
- const lineMatch = content.match(/^depends-on:\s*(.+)$/m);
2107
- if (!lineMatch)
2530
+ function parseListField(raw) {
2531
+ if (!raw)
2108
2532
  return [];
2109
- const raw = lineMatch[1].trim();
2110
- if (!raw || raw === "[]")
2533
+ const trimmed = raw.trim();
2534
+ if (!trimmed || trimmed === "[]")
2111
2535
  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);
2536
+ const inner = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
2537
+ return inner.split(",").map((item) => item.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
2538
+ }
2539
+ function parseDependsOn2(content) {
2540
+ return parseListField(parseTaskFrontmatter(content)["depends-on"]);
2116
2541
  }
2117
2542
  function parseTaskStatus(content) {
2118
- const match = content.match(/^status:\s*([a-zA-Z0-9_-]+)/m);
2119
- return match ? match[1].trim().toLowerCase() : "in-progress";
2543
+ const fm = parseTaskFrontmatter(content);
2544
+ return (fm.status ?? "in-progress").toLowerCase();
2545
+ }
2546
+ function resolveTier(changeDir) {
2547
+ const classifPath = join10(changeDir, "change-classification.md");
2548
+ const classificationPresent = existsSync9(classifPath);
2549
+ const classificationText = classificationPresent ? readFileSync7(classifPath, "utf8") : "";
2550
+ const classificationHasLooseMarker = classificationPresent && TIER_PATTERN.test(classificationText);
2551
+ const tasksPath = join10(changeDir, "tasks.md");
2552
+ if (existsSync9(tasksPath)) {
2553
+ const fm = parseTaskFrontmatter(readFileSync7(tasksPath, "utf8"));
2554
+ const raw = fm.tier;
2555
+ if (raw && raw !== "") {
2556
+ const n = parseInt(raw, 10);
2557
+ if (!Number.isNaN(n) && n >= 0 && n <= 5) {
2558
+ return { tier: n, source: "tasks-frontmatter", classificationPresent, classificationHasLooseMarker };
2559
+ }
2560
+ }
2561
+ }
2562
+ if (classificationPresent) {
2563
+ const structured = classificationText.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
2564
+ if (structured) {
2565
+ const n = parseInt(structured[1], 10);
2566
+ if (!Number.isNaN(n) && n >= 0 && n <= 5) {
2567
+ return { tier: n, source: "classification-structured", classificationPresent, classificationHasLooseMarker };
2568
+ }
2569
+ }
2570
+ const bold = classificationText.match(/\*\*Tier:\*\*\s*Tier\s*(\d)\b/i);
2571
+ if (bold) {
2572
+ const n = parseInt(bold[1], 10);
2573
+ if (!Number.isNaN(n) && n >= 0 && n <= 5) {
2574
+ return { tier: n, source: "classification-bold", classificationPresent, classificationHasLooseMarker };
2575
+ }
2576
+ }
2577
+ }
2578
+ return { tier: null, source: "none", classificationPresent, classificationHasLooseMarker };
2579
+ }
2580
+ var DEFAULT_ARCHIVE_TASKS = ["7.1", "7.2"];
2581
+ function getArchiveTaskIds(content) {
2582
+ const fm = parseTaskFrontmatter(content);
2583
+ const parsed = parseListField(fm["archive-tasks"]);
2584
+ return parsed.length > 0 ? parsed : DEFAULT_ARCHIVE_TASKS;
2585
+ }
2586
+ function enforceTierRequirements(changeDir, agentLogDir, errors, warnings) {
2587
+ const resolution = resolveTier(changeDir);
2588
+ if (resolution.tier === null) {
2589
+ if (resolution.classificationPresent && !resolution.classificationHasLooseMarker) {
2590
+ errors.push(
2591
+ "change-classification.md: missing tier marker. Set `tier: <0-5>` in tasks.md frontmatter (preferred) or include `## Tier\\n- N` in change-classification.md."
2592
+ );
2593
+ }
2594
+ return;
2595
+ }
2596
+ if (resolution.source === "classification-bold") {
2597
+ warnings.push(
2598
+ "tier marker is bold-text only (legacy format); set `tier: <0-5>` in tasks.md frontmatter so tier-specific agent requirements are enforced."
2599
+ );
2600
+ return;
2601
+ }
2602
+ const tier = resolution.tier;
2603
+ const agentLogFiles = agentLogDir && existsSync9(agentLogDir) ? readdirSync6(agentLogDir).map((f) => f.replace(".md", "")) : [];
2604
+ if (tier <= 1) {
2605
+ for (const required of ["e2e-resilience-engineer", "monkey-test-engineer", "stress-soak-engineer"]) {
2606
+ if (!agentLogFiles.includes(required)) {
2607
+ errors.push(`Tier ${tier} change requires agent-log/${required}.md (high-risk change \u2014 E2E/monkey/stress testing mandatory)`);
2608
+ }
2609
+ }
2610
+ }
2611
+ if (tier <= 3) {
2612
+ for (const required of ["contract-reviewer", "qa-reviewer"]) {
2613
+ if (!agentLogFiles.includes(required)) {
2614
+ errors.push(`Tier ${tier} change requires agent-log/${required}.md`);
2615
+ }
2616
+ }
2617
+ }
2618
+ if (resolution.source === "tasks-frontmatter" && resolution.classificationPresent) {
2619
+ const text = readFileSync7(join10(changeDir, "change-classification.md"), "utf8");
2620
+ const structured = text.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
2621
+ const bold = text.match(/\*\*Tier:\*\*\s*Tier\s*(\d)\b/i);
2622
+ const classifTier = structured ? parseInt(structured[1], 10) : bold ? parseInt(bold[1], 10) : NaN;
2623
+ if (!Number.isNaN(classifTier) && classifTier !== tier) {
2624
+ warnings.push(
2625
+ `tier mismatch: tasks.md frontmatter says ${tier}, change-classification.md says ${classifTier} (frontmatter wins; reconcile classification).`
2626
+ );
2627
+ }
2628
+ }
2120
2629
  }
2121
2630
  function isArchivedChange(cwd, changeId) {
2122
- const archiveRoot = join9(cwd, "specs", "archive");
2123
- if (!existsSync8(archiveRoot))
2631
+ const archiveRoot = join10(cwd, "specs", "archive");
2632
+ if (!existsSync9(archiveRoot))
2124
2633
  return false;
2125
- const years = readdirSync5(archiveRoot, { withFileTypes: true }).filter((d) => d.isDirectory());
2126
- return years.some((year) => existsSync8(join9(archiveRoot, year.name, changeId)));
2634
+ const years = readdirSync6(archiveRoot, { withFileTypes: true }).filter((d) => d.isDirectory());
2635
+ return years.some((year) => existsSync9(join10(archiveRoot, year.name, changeId)));
2636
+ }
2637
+ function detectDependencyCycle(cwd, startChangeId) {
2638
+ const visited = /* @__PURE__ */ new Set();
2639
+ const stack = [];
2640
+ function visit(id) {
2641
+ if (stack.includes(id)) {
2642
+ return [...stack.slice(stack.indexOf(id)), id];
2643
+ }
2644
+ if (visited.has(id))
2645
+ return null;
2646
+ visited.add(id);
2647
+ stack.push(id);
2648
+ const tasksPath = join10(cwd, "specs", "changes", id, "tasks.md");
2649
+ if (existsSync9(tasksPath)) {
2650
+ const deps = parseDependsOn2(readFileSync7(tasksPath, "utf8"));
2651
+ for (const dep of deps) {
2652
+ const found = visit(dep);
2653
+ if (found)
2654
+ return found;
2655
+ }
2656
+ }
2657
+ stack.pop();
2658
+ return null;
2659
+ }
2660
+ return visit(startChangeId);
2127
2661
  }
2128
2662
  function validateDependencies(cwd, changeId, changeDir) {
2129
- const tasksPath = join9(changeDir, "tasks.md");
2130
- if (!existsSync8(tasksPath))
2663
+ const tasksPath = join10(changeDir, "tasks.md");
2664
+ if (!existsSync9(tasksPath))
2131
2665
  return [];
2132
- const dependencies = parseDependsOn2(readFileSync6(tasksPath, "utf8"));
2666
+ const dependencies = parseDependsOn2(readFileSync7(tasksPath, "utf8"));
2133
2667
  const errors = [];
2668
+ const cycle = detectDependencyCycle(cwd, changeId);
2669
+ if (cycle) {
2670
+ errors.push(`depends-on cycle detected: ${cycle.join(" \u2192 ")}`);
2671
+ }
2134
2672
  for (const dep of dependencies) {
2135
2673
  if (dep === changeId) {
2136
2674
  errors.push(`tasks.md: change cannot depend on itself (${dep})`);
2137
2675
  continue;
2138
2676
  }
2139
- const upstreamDir = join9(cwd, "specs", "changes", dep);
2140
- if (existsSync8(upstreamDir)) {
2141
- const upstreamTasks = join9(upstreamDir, "tasks.md");
2142
- if (!existsSync8(upstreamTasks)) {
2677
+ const upstreamDir = join10(cwd, "specs", "changes", dep);
2678
+ if (existsSync9(upstreamDir)) {
2679
+ const upstreamTasks = join10(upstreamDir, "tasks.md");
2680
+ if (!existsSync9(upstreamTasks)) {
2143
2681
  errors.push(`dependency ${dep}: missing tasks.md`);
2144
2682
  continue;
2145
2683
  }
2146
- const status = parseTaskStatus(readFileSync6(upstreamTasks, "utf8"));
2684
+ const status = parseTaskStatus(readFileSync7(upstreamTasks, "utf8"));
2147
2685
  if (!["complete", "completed", "done"].includes(status)) {
2148
2686
  errors.push(`dependency ${dep}: upstream change is not completed (status: ${status})`);
2149
2687
  }
@@ -2200,9 +2738,10 @@ function parseFilesRead(content) {
2200
2738
  }
2201
2739
  async function gate(changeId, opts = {}) {
2202
2740
  const strict = opts.strict ?? false;
2741
+ const lax = opts.lax ?? false;
2203
2742
  const cwd = process.cwd();
2204
- const changeDir = join9(cwd, "specs", "changes", changeId);
2205
- if (!existsSync8(changeDir)) {
2743
+ const changeDir = join10(cwd, "specs", "changes", changeId);
2744
+ if (!existsSync9(changeDir)) {
2206
2745
  log.error(`change not found: ${changeId} (looked in ${changeDir})`);
2207
2746
  process.exit(1);
2208
2747
  }
@@ -2210,13 +2749,13 @@ async function gate(changeId, opts = {}) {
2210
2749
  const warnings = [];
2211
2750
  const contextPolicy = loadContextPolicy(cwd);
2212
2751
  const isNewChange = isContextGovernedChange(changeDir);
2213
- const manifestPath = join9(changeDir, "context-manifest.md");
2214
- const hasManifest = existsSync8(manifestPath);
2752
+ const manifestPath = join10(changeDir, "context-manifest.md");
2753
+ const hasManifest = existsSync9(manifestPath);
2215
2754
  let allowedPaths = [];
2216
2755
  let approvedExpansions = [];
2217
2756
  errors.push(...validateDependencies(cwd, changeId, changeDir));
2218
2757
  if (hasManifest) {
2219
- const manifest = parseContextManifest(readFileSync6(manifestPath, "utf8"));
2758
+ const manifest = parseContextManifest(readFileSync7(manifestPath, "utf8"));
2220
2759
  allowedPaths = manifest.allowedPaths;
2221
2760
  approvedExpansions = manifest.approvedExpansions;
2222
2761
  if (manifest.pendingExpansions > 0) {
@@ -2234,7 +2773,7 @@ async function gate(changeId, opts = {}) {
2234
2773
  }
2235
2774
  continue;
2236
2775
  }
2237
- if (!existsSync8(join9(changeDir, f))) {
2776
+ if (!existsSync9(join10(changeDir, f))) {
2238
2777
  errors.push(`missing required artifact: ${f}`);
2239
2778
  }
2240
2779
  }
@@ -2242,24 +2781,31 @@ async function gate(changeId, opts = {}) {
2242
2781
  for (const f of REQUIRED_FILES) {
2243
2782
  if (f === "context-manifest.md" && !hasManifest)
2244
2783
  continue;
2245
- const content = readFileSync6(join9(changeDir, f), "utf8");
2784
+ const content = readFileSync7(join10(changeDir, f), "utf8");
2246
2785
  const minChars = MIN_CHARS[f] ?? 100;
2247
2786
  if (meaningfulChars(content) < minChars) {
2248
2787
  errors.push(`${f}: appears to be a stub (< ${minChars} meaningful chars)`);
2249
2788
  }
2250
2789
  }
2251
- const classifPath = join9(changeDir, "change-classification.md");
2252
- if (existsSync8(classifPath)) {
2253
- const text = readFileSync6(classifPath, "utf8");
2254
- if (!TIER_PATTERN.test(text)) {
2255
- errors.push("change-classification.md: missing tier/risk marker (Tier 0-5 or low/medium/high/critical)");
2256
- }
2257
- }
2258
- }
2259
- const tasksPath = join9(changeDir, "tasks.md");
2260
- if (existsSync8(tasksPath)) {
2261
- const tasksContent = readFileSync6(tasksPath, "utf8");
2262
- const nonArchivePending = (tasksContent.match(/^\s*-\s*\[ \] (?!7\.[12])/gm) || []).length;
2790
+ const classifPath = join10(changeDir, "change-classification.md");
2791
+ const tierResolution = resolveTier(changeDir);
2792
+ if (tierResolution.tier === null && existsSync9(classifPath) && !tierResolution.classificationHasLooseMarker) {
2793
+ errors.push("change-classification.md: missing tier/risk marker (set tier in tasks.md frontmatter, or include Tier 0-5 / low|medium|high|critical in change-classification.md)");
2794
+ }
2795
+ }
2796
+ const tasksPath = join10(changeDir, "tasks.md");
2797
+ if (existsSync9(tasksPath)) {
2798
+ const tasksContent = readFileSync7(tasksPath, "utf8");
2799
+ lintFrontmatter(tasksContent, errors, warnings);
2800
+ const archiveTaskIds = new Set(getArchiveTaskIds(tasksContent));
2801
+ const pendingMatches = tasksContent.match(/^\s*-\s*\[ \]\s+([\d.]+)?[^\n]*/gm) || [];
2802
+ const nonArchivePending = pendingMatches.filter((line) => {
2803
+ const idMatch = line.match(/\[ \]\s+([\d.]+)/);
2804
+ const id = idMatch?.[1];
2805
+ if (!id)
2806
+ return true;
2807
+ return !archiveTaskIds.has(id);
2808
+ }).length;
2263
2809
  if (nonArchivePending > 0) {
2264
2810
  if (strict) {
2265
2811
  errors.push(`${nonArchivePending} task(s) still pending (use [-] for N/A items, [x] for done). Run gate without --strict during development.`);
@@ -2268,11 +2814,11 @@ async function gate(changeId, opts = {}) {
2268
2814
  }
2269
2815
  }
2270
2816
  }
2271
- const agentLogDir = join9(changeDir, "agent-log");
2272
- if (existsSync8(agentLogDir)) {
2273
- const logFiles = readdirSync5(agentLogDir).filter((f) => f.endsWith(".md"));
2817
+ const agentLogDir = join10(changeDir, "agent-log");
2818
+ if (existsSync9(agentLogDir)) {
2819
+ const logFiles = readdirSync6(agentLogDir).filter((f) => f.endsWith(".md"));
2274
2820
  for (const f of logFiles) {
2275
- const content = readFileSync6(join9(agentLogDir, f), "utf8");
2821
+ const content = readFileSync7(join10(agentLogDir, f), "utf8");
2276
2822
  const filesRead = parseFilesRead(content);
2277
2823
  if (!filesRead.present) {
2278
2824
  if (contextPolicy.audit.requireFilesRead) {
@@ -2295,6 +2841,27 @@ async function gate(changeId, opts = {}) {
2295
2841
  errors.push(`agent-log/${f}: read unauthorized path -> ${pathRead} (not in allowed paths or approved expansions)`);
2296
2842
  }
2297
2843
  }
2844
+ const runtimeLog = join10(cwd, ".cdd", "runtime", `${changeId}-files-read.jsonl`);
2845
+ if (existsSync9(runtimeLog)) {
2846
+ const runtimePaths = readFileSync7(runtimeLog, "utf8").split("\n").filter(Boolean).map((line) => {
2847
+ try {
2848
+ return JSON.parse(line).path;
2849
+ } catch {
2850
+ return void 0;
2851
+ }
2852
+ }).filter((p) => Boolean(p)).map((p) => p.replace(/\\/g, "/").replace(/^\.\//, ""));
2853
+ const declared = new Set(filesRead.files);
2854
+ const undeclared = runtimePaths.filter((p) => !declared.has(p));
2855
+ if (undeclared.length > 0) {
2856
+ const sample = undeclared.slice(0, 5).join(", ");
2857
+ const more = undeclared.length > 5 ? ` (+${undeclared.length - 5} more)` : "";
2858
+ const msg = `agent-log/${f}: runtime log shows ${undeclared.length} read(s) not declared in files-read: ${sample}${more}`;
2859
+ if (strict)
2860
+ errors.push(msg);
2861
+ else
2862
+ warnings.push(msg);
2863
+ }
2864
+ }
2298
2865
  }
2299
2866
  const statusMatch = content.match(/^\s*-\s*status:\s*(complete|needs-review|blocked)\s*$/m);
2300
2867
  if (!statusMatch) {
@@ -2308,7 +2875,7 @@ async function gate(changeId, opts = {}) {
2308
2875
  errors.push(`agent-log/${f}: status=blocked requires concrete "next-action:" line (>= 10 chars, not "none")`);
2309
2876
  }
2310
2877
  }
2311
- if (strict) {
2878
+ if (!lax) {
2312
2879
  const artifactsMatch = content.match(/- artifacts:([\s\S]*?)(?:\n- |\n#|$)/);
2313
2880
  if (artifactsMatch) {
2314
2881
  const artifactLines = artifactsMatch[1].split("\n").filter((l) => l.trim().startsWith("-"));
@@ -2316,8 +2883,8 @@ async function gate(changeId, opts = {}) {
2316
2883
  const pointer = line.replace(/^\s*-\s*[\w-]+:\s*/, "").trim();
2317
2884
  const pathPart = pointer.split(":")[0];
2318
2885
  if (pathPart.includes("/") && !pointer.startsWith("http")) {
2319
- const abs = join9(cwd, pathPart);
2320
- if (!existsSync8(abs)) {
2886
+ const abs = join10(cwd, pathPart);
2887
+ if (!existsSync9(abs)) {
2321
2888
  errors.push(`agent-log/${f}: artifact pointer not found: ${pathPart}`);
2322
2889
  }
2323
2890
  }
@@ -2325,48 +2892,9 @@ async function gate(changeId, opts = {}) {
2325
2892
  }
2326
2893
  }
2327
2894
  }
2328
- const classifPath = join9(changeDir, "change-classification.md");
2329
- if (existsSync8(classifPath)) {
2330
- const classificationContent = readFileSync6(classifPath, "utf8");
2331
- const tierMatch = classificationContent.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
2332
- const tier = tierMatch ? parseInt(tierMatch[1]) : null;
2333
- if (tier !== null) {
2334
- const agentLogFiles = readdirSync5(agentLogDir).map((f) => f.replace(".md", ""));
2335
- if (tier <= 1) {
2336
- for (const required of ["e2e-resilience-engineer", "monkey-test-engineer", "stress-soak-engineer"]) {
2337
- if (!agentLogFiles.includes(required)) {
2338
- errors.push(`Tier ${tier} change requires agent-log/${required}.md (high-risk change \u2014 E2E/monkey/stress testing mandatory)`);
2339
- }
2340
- }
2341
- }
2342
- if (tier <= 3) {
2343
- for (const required of ["contract-reviewer", "qa-reviewer"]) {
2344
- if (!agentLogFiles.includes(required)) {
2345
- errors.push(`Tier ${tier} change requires agent-log/${required}.md`);
2346
- }
2347
- }
2348
- }
2349
- }
2350
- }
2895
+ enforceTierRequirements(changeDir, agentLogDir, errors, warnings);
2351
2896
  } else {
2352
- const classifPath = join9(changeDir, "change-classification.md");
2353
- if (existsSync8(classifPath)) {
2354
- const classificationContent = readFileSync6(classifPath, "utf8");
2355
- const tierMatch = classificationContent.match(/^## Tier\s*\n\s*-\s*(\d)\s*$/m);
2356
- const tier = tierMatch ? parseInt(tierMatch[1]) : null;
2357
- if (tier !== null) {
2358
- if (tier <= 1) {
2359
- for (const required of ["e2e-resilience-engineer", "monkey-test-engineer", "stress-soak-engineer"]) {
2360
- errors.push(`Tier ${tier} change requires agent-log/${required}.md (high-risk change \u2014 E2E/monkey/stress testing mandatory)`);
2361
- }
2362
- }
2363
- if (tier <= 3) {
2364
- for (const required of ["contract-reviewer", "qa-reviewer"]) {
2365
- errors.push(`Tier ${tier} change requires agent-log/${required}.md`);
2366
- }
2367
- }
2368
- }
2369
- }
2897
+ enforceTierRequirements(changeDir, null, errors, warnings);
2370
2898
  }
2371
2899
  for (const w of warnings) {
2372
2900
  log.warn(` ${w}`);
@@ -2379,12 +2907,10 @@ async function gate(changeId, opts = {}) {
2379
2907
  process.exit(1);
2380
2908
  }
2381
2909
  log.info(`gate: running contract validators for ${changeId}\u2026`);
2382
- const r = spawnSync2(process.execPath, [process.argv[1], "validate", "--contracts", "--env", "--ci", "--versions"], {
2383
- cwd,
2384
- stdio: "inherit"
2385
- });
2386
- if (r.status !== 0) {
2387
- log.error(`gate failed for change: ${changeId} (validators returned non-zero)`);
2910
+ try {
2911
+ await validate({ contracts: true, env: true, ci: true, spec: false, versions: true });
2912
+ } catch (err) {
2913
+ log.error(`gate failed for change: ${changeId} (validators threw): ${err.message}`);
2388
2914
  process.exit(1);
2389
2915
  }
2390
2916
  for (const w of warnings) {
@@ -2396,26 +2922,26 @@ async function gate(changeId, opts = {}) {
2396
2922
  // src/commands/install-hooks.ts
2397
2923
  init_paths();
2398
2924
  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";
2925
+ import { existsSync as existsSync10, readFileSync as readFileSync8, writeFileSync as writeFileSync4, chmodSync, mkdirSync as mkdirSync4 } from "fs";
2926
+ import { join as join11 } from "path";
2401
2927
  var START_MARKER = "# cdd-kit-managed-block-start";
2402
2928
  var END_MARKER = "# cdd-kit-managed-block-end";
2403
2929
  async function installHooks() {
2404
2930
  const cwd = process.cwd();
2405
- const gitDir = join10(cwd, ".git");
2406
- if (!existsSync9(gitDir)) {
2931
+ const gitDir = join11(cwd, ".git");
2932
+ if (!existsSync10(gitDir)) {
2407
2933
  log.error("not a git repository (no .git/ found in cwd)");
2408
2934
  process.exit(1);
2409
2935
  }
2410
- const hooksDir = join10(gitDir, "hooks");
2411
- mkdirSync3(hooksDir, { recursive: true });
2412
- const dest = join10(hooksDir, "pre-commit");
2413
- const ourHook = readFileSync7(join10(ASSET.hooks, "pre-commit"), "utf8");
2936
+ const hooksDir = join11(gitDir, "hooks");
2937
+ mkdirSync4(hooksDir, { recursive: true });
2938
+ const dest = join11(hooksDir, "pre-commit");
2939
+ const ourHook = readFileSync8(join11(ASSET.hooks, "pre-commit"), "utf8");
2414
2940
  let final;
2415
- if (!existsSync9(dest)) {
2941
+ if (!existsSync10(dest)) {
2416
2942
  final = ourHook;
2417
2943
  } else {
2418
- const existing = readFileSync7(dest, "utf8");
2944
+ const existing = readFileSync8(dest, "utf8");
2419
2945
  const startIdx = existing.indexOf(START_MARKER);
2420
2946
  const endIdx = existing.indexOf(END_MARKER);
2421
2947
  if (startIdx >= 0 && endIdx > startIdx) {
@@ -2439,7 +2965,7 @@ async function installHooks() {
2439
2965
  }
2440
2966
  }
2441
2967
  }
2442
- writeFileSync3(dest, final, "utf8");
2968
+ writeFileSync4(dest, final, "utf8");
2443
2969
  try {
2444
2970
  chmodSync(dest, 493);
2445
2971
  } catch {
@@ -2450,7 +2976,7 @@ async function installHooks() {
2450
2976
 
2451
2977
  // src/cli/index.ts
2452
2978
  var __dirname2 = dirname5(fileURLToPath2(import.meta.url));
2453
- var pkg = JSON.parse(readFileSync15(join19(__dirname2, "..", "..", "package.json"), "utf8"));
2979
+ var pkg = JSON.parse(readFileSync16(join19(__dirname2, "..", "..", "package.json"), "utf8"));
2454
2980
  var program = new Command();
2455
2981
  program.name("cdd-kit").description("Contract-Driven Delivery Kit CLI").version(pkg.version);
2456
2982
  program.command("init").description(
@@ -2464,9 +2990,9 @@ program.command("init").description(
2464
2990
  })
2465
2991
  );
2466
2992
  program.command("update").description("Update provider assets for the current project (does not overwrite project guidance files)").option("--yes", "Apply changes (default is dry-run)", false).option("--provider <provider>", "Provider adapter to update: auto, claude, codex, or both", "auto").action((opts) => update({ yes: opts.yes, provider: opts.provider }));
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) => {
2993
+ program.command("doctor").description("Inspect cdd-kit repo health, provider guidance, and context index freshness").option("--strict", "Treat warnings as errors", false).option("--json", "Print a machine-readable health report", false).option("--provider <provider>", "Provider adapter to inspect: auto, claude, codex, or both", "auto").option("--fix", "Auto-resolve safe warnings (stale context indexes, missing role bindings)", false).action(async (opts) => {
2468
2994
  const { doctor: doctor2 } = await Promise.resolve().then(() => (init_doctor(), doctor_exports));
2469
- await doctor2({ strict: opts.strict, json: opts.json, provider: opts.provider });
2995
+ await doctor2({ strict: opts.strict, json: opts.json, provider: opts.provider, fix: opts.fix });
2470
2996
  });
2471
2997
  program.command("upgrade").description("Add missing cdd-kit repo-level files without overwriting existing project files").option("--yes", "Apply changes (default is dry-run)", false).option("--migrate-changes", "Also migrate existing specs/changes/* directories", false).option("--enable-context-governance", "When migrating changes, opt them into context-governance: v1", false).option("--provider <provider>", "Provider adapter to scaffold: auto, claude, codex, or both", "auto").action(async (opts) => {
2472
2998
  const { upgrade: upgrade2 } = await Promise.resolve().then(() => (init_upgrade(), upgrade_exports));
@@ -2477,8 +3003,8 @@ program.command("upgrade").description("Add missing cdd-kit repo-level files wit
2477
3003
  provider: opts.provider
2478
3004
  });
2479
3005
  });
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 })
3006
+ program.command("new <name>").description("Scaffold a new change directory under specs/changes/<name>").option("--all", "Include optional templates in addition to required ones", false).option("--force", "Overwrite existing template files in the change folder", false).option("--depends-on <change-ids>", "Comma-separated upstream change ids that must complete first").option("--skip-scan", "Skip the auto context-scan when indexes are stale (advanced)", false).action(
3007
+ (name, opts) => newChange(name, { all: opts.all, force: opts.force, dependsOn: opts.dependsOn, skipScan: opts.skipScan })
2482
3008
  );
2483
3009
  program.command("validate").description("Run validation scripts (defaults to all)").option("--contracts", "Validate API/data/CSS contracts (use --env separately for env)", false).option("--env", "Validate env contract", false).option("--ci", "Validate CI gate policy", false).option("--spec", "Validate spec traceability", false).option("--versions", "Validate contract frontmatter and version bumps", false).action(
2484
3010
  (opts) => validate({
@@ -2489,8 +3015,8 @@ program.command("validate").description("Run validation scripts (defaults to all
2489
3015
  versions: opts.versions
2490
3016
  })
2491
3017
  );
2492
- program.command("gate <change-id>").description("Run full orchestration gate for a change (required artifacts, content, tier, contracts)").option("--strict", "Treat pending tasks (except section 7) as errors, and validate artifact pointers", false).action(async (id, opts) => {
2493
- await gate(id, { strict: opts.strict });
3018
+ program.command("gate <change-id>").description("Run full orchestration gate for a change (required artifacts, content, tier, contracts)").option("--strict", "Treat pending tasks (except section 7) as errors, and treat runtime/declared files-read drift as errors", false).option("--lax", "Skip artifact-pointer existence check (for legacy repos with stale logs)", false).action(async (id, opts) => {
3019
+ await gate(id, { strict: opts.strict, lax: opts.lax });
2494
3020
  });
2495
3021
  program.command("archive <change-id>").description("Move a completed change from specs/changes/ to specs/archive/<year>/").action(async (changeId) => {
2496
3022
  const { archive: archive2 } = await Promise.resolve().then(() => (init_archive(), archive_exports));
@@ -2500,9 +3026,14 @@ program.command("abandon <change-id>").description("Mark a change as abandoned (
2500
3026
  const { abandon: abandon2 } = await Promise.resolve().then(() => (init_abandon(), abandon_exports));
2501
3027
  await abandon2(changeId, opts);
2502
3028
  });
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 = {}) => {
3029
+ program.command("migrate [change-id]").description("Upgrade existing change directories to the current cdd-kit format (tasks.md frontmatter + tier format)").option("--all", "Migrate all changes in specs/changes/", false).option("--dry-run", "Show what would change without writing files", false).option("--enable-context-governance", "Opt legacy changes into context-governance: v1 hard gate behavior", false).option("--no-backup", "Skip the per-session backup at .cdd/migrate-backup/<stamp>/ (not recommended)").action(async (changeId, opts = {}) => {
2504
3030
  const { migrate: migrate2 } = await Promise.resolve().then(() => (init_migrate(), migrate_exports));
2505
- await migrate2(changeId, opts);
3031
+ await migrate2(changeId, {
3032
+ all: opts.all,
3033
+ dryRun: opts.dryRun,
3034
+ enableContextGovernance: opts.enableContextGovernance,
3035
+ noBackup: opts.backup === false
3036
+ });
2506
3037
  });
2507
3038
  program.command("list").description("List active changes in specs/changes/").action(async () => {
2508
3039
  const { listChanges: listChanges2 } = await Promise.resolve().then(() => (init_list_changes(), list_changes_exports));
@@ -2524,22 +3055,46 @@ program.command("detect-stack").description("Detect the project tech stack and p
2524
3055
  );
2525
3056
  }
2526
3057
  });
2527
- program.command("context-scan").description("Deterministically scan project context and generate specs/context maps").action(async () => {
3058
+ program.command("context-scan").description("Deterministically scan project context and generate specs/context maps").option("--surface <path>", "Limit project-map tree to a sub-directory (e.g. --surface src/server)").action(async (opts) => {
2528
3059
  const { contextScan: contextScan2 } = await Promise.resolve().then(() => (init_context_scan(), context_scan_exports));
2529
- await contextScan2();
3060
+ await contextScan2({ surface: opts.surface });
2530
3061
  });
2531
3062
  var context = program.command("context").description("Manage context governance manifests");
2532
3063
  context.command("request <change-id> <request-id>").description("Record a new pending Context Expansion Request").requiredOption("--path <paths...>", "Repo-relative path(s) requested by the agent").option("--reason <text>", "Reason the extra context is required").action(async (changeId, requestId, opts) => {
2533
3064
  const { requestContextExpansion: requestContextExpansion2 } = await Promise.resolve().then(() => (init_context(), context_exports));
2534
3065
  await requestContextExpansion2(changeId, requestId, opts.path, opts.reason);
2535
3066
  });
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);
3067
+ context.command("approve <change-id> [request-id]").description("Approve a pending Context Expansion Request (or all with --all-pending)").option("--all-pending", "Approve every pending Context Expansion Request for this change", false).action(async (changeId, requestId, opts) => {
3068
+ const { approveContextExpansion: approveContextExpansion2, approveAllPending: approveAllPending2 } = await Promise.resolve().then(() => (init_context(), context_exports));
3069
+ if (opts.allPending) {
3070
+ if (requestId) {
3071
+ console.error("--all-pending cannot be combined with a request-id");
3072
+ process.exit(1);
3073
+ }
3074
+ await approveAllPending2(changeId);
3075
+ } else {
3076
+ if (!requestId) {
3077
+ console.error("request-id is required (or pass --all-pending)");
3078
+ process.exit(1);
3079
+ }
3080
+ await approveContextExpansion2(changeId, requestId);
3081
+ }
2539
3082
  });
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);
3083
+ context.command("reject <change-id> [request-id]").description("Reject a pending Context Expansion Request (or all with --all-pending)").option("--all-pending", "Reject every pending Context Expansion Request for this change", false).action(async (changeId, requestId, opts) => {
3084
+ const { rejectContextExpansion: rejectContextExpansion2, rejectAllPending: rejectAllPending2 } = await Promise.resolve().then(() => (init_context(), context_exports));
3085
+ if (opts.allPending) {
3086
+ if (requestId) {
3087
+ console.error("--all-pending cannot be combined with a request-id");
3088
+ process.exit(1);
3089
+ }
3090
+ await rejectAllPending2(changeId);
3091
+ } else {
3092
+ if (!requestId) {
3093
+ console.error("request-id is required (or pass --all-pending)");
3094
+ process.exit(1);
3095
+ }
3096
+ await rejectContextExpansion2(changeId, requestId);
3097
+ }
2543
3098
  });
2544
3099
  context.command("list <change-id>").description("List Context Expansion Requests for a change").option("--json", "Print machine-readable JSON", false).action(async (changeId, opts) => {
2545
3100
  const { listContextExpansions: listContextExpansions2 } = await Promise.resolve().then(() => (init_context(), context_exports));