contract-driven-delivery 1.0.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +113 -18
  2. package/assets/CLAUDE.template.md +59 -3
  3. package/assets/agents/backend-engineer.md +43 -0
  4. package/assets/agents/change-classifier.md +40 -0
  5. package/assets/agents/ci-cd-gatekeeper.md +53 -4
  6. package/assets/agents/contract-reviewer.md +49 -3
  7. package/assets/agents/dependency-security-reviewer.md +95 -0
  8. package/assets/agents/e2e-resilience-engineer.md +42 -1
  9. package/assets/agents/frontend-engineer.md +44 -1
  10. package/assets/agents/monkey-test-engineer.md +40 -1
  11. package/assets/agents/qa-reviewer.md +52 -0
  12. package/assets/agents/repo-context-scanner.md +40 -0
  13. package/assets/agents/spec-architect.md +77 -3
  14. package/assets/agents/spec-drift-auditor.md +40 -0
  15. package/assets/agents/stress-soak-engineer.md +42 -0
  16. package/assets/agents/test-strategist.md +44 -1
  17. package/assets/agents/ui-ux-reviewer.md +41 -1
  18. package/assets/agents/visual-reviewer.md +41 -1
  19. package/assets/ci/github-actions/contract-driven-gates.yml +50 -5
  20. package/assets/ci-templates/bun.yml +5 -0
  21. package/assets/ci-templates/conda.yml +11 -0
  22. package/assets/ci-templates/go.yml +12 -0
  23. package/assets/ci-templates/npm.yml +6 -0
  24. package/assets/ci-templates/pip.yml +10 -0
  25. package/assets/ci-templates/pnpm.yml +9 -0
  26. package/assets/ci-templates/poetry.yml +12 -0
  27. package/assets/ci-templates/rust.yml +12 -0
  28. package/assets/ci-templates/unknown.yml +4 -0
  29. package/assets/ci-templates/uv.yml +12 -0
  30. package/assets/ci-templates/yarn.yml +6 -0
  31. package/assets/contracts/CHANGELOG.md +27 -0
  32. package/assets/contracts/api/api-contract.md +7 -0
  33. package/assets/contracts/business/business-rules.md +7 -0
  34. package/assets/contracts/ci/ci-gate-contract.md +7 -0
  35. package/assets/contracts/css/css-contract.md +7 -0
  36. package/assets/contracts/data/data-shape-contract.md +7 -0
  37. package/assets/contracts/env/env-contract.md +7 -0
  38. package/assets/hooks/pre-commit +23 -0
  39. package/assets/skill/SKILL.md +20 -4
  40. package/assets/skill/scripts/detect_project_profile.py +68 -1
  41. package/assets/skill/scripts/generate_change_scaffold.py +2 -2
  42. package/assets/skill/scripts/validate_api_semantic.py +162 -0
  43. package/assets/skill/scripts/validate_ci_gates.py +34 -6
  44. package/assets/skill/scripts/validate_contract_versions.py +385 -0
  45. package/assets/skill/scripts/validate_contracts.py +25 -1
  46. package/assets/skill/scripts/validate_env_contract.py +3 -1
  47. package/assets/skill/scripts/validate_env_semantic.py +182 -0
  48. package/assets/skill/scripts/validate_spec_traceability.py +34 -8
  49. package/assets/tests-templates/soak/k6-example.js +19 -0
  50. package/assets/tests-templates/soak/locust-example.py +21 -0
  51. package/assets/tests-templates/soak/soak-profile.md +16 -0
  52. package/assets/tests-templates/stress/artillery-example.yml +27 -0
  53. package/assets/tests-templates/stress/k6-example.js +22 -0
  54. package/assets/tests-templates/stress/load-profile.md +14 -0
  55. package/assets/tests-templates/stress/locust-example.py +21 -0
  56. package/dist/cli/index.js +593 -106
  57. package/package.json +7 -4
  58. package/assets/skill/agents/openai.yaml +0 -2
package/dist/cli/index.js CHANGED
@@ -1,8 +1,12 @@
1
1
  // src/cli/index.ts
2
+ import { readFileSync as readFileSync6 } from "fs";
3
+ import { fileURLToPath as fileURLToPath2 } from "url";
4
+ import { dirname as dirname3, join as join10 } from "path";
2
5
  import { Command } from "commander";
3
6
 
4
7
  // src/commands/init.ts
5
- import { join as join3 } from "path";
8
+ import { join as join4 } from "path";
9
+ import { rmSync, readFileSync as readFileSync2, writeFileSync, existsSync as existsSync3 } from "fs";
6
10
 
7
11
  // src/utils/paths.ts
8
12
  import { join, dirname } from "path";
@@ -21,6 +25,7 @@ var ASSET = {
21
25
  specsTemplates: join(ASSETS_DIR, "specs-templates"),
22
26
  testsTemplates: join(ASSETS_DIR, "tests-templates"),
23
27
  ci: join(ASSETS_DIR, "ci"),
28
+ hooks: join(ASSETS_DIR, "hooks"),
24
29
  claudeTemplate: join(ASSETS_DIR, "CLAUDE.template.md"),
25
30
  agentsTemplate: join(ASSETS_DIR, "AGENTS.template.md")
26
31
  };
@@ -66,21 +71,25 @@ var log = {
66
71
  function ensureDir(dir) {
67
72
  mkdirSync(dir, { recursive: true });
68
73
  }
69
- function copyDir(src, dest, opts = {}) {
74
+ function copyDirTracked(src, dest, opts = {}) {
70
75
  const { overwrite = true, label } = opts;
71
76
  if (!existsSync(src)) {
72
77
  log.warn(`source not found, skipping: ${label ?? src}`);
73
- return 0;
78
+ return { count: 0, created: [] };
74
79
  }
75
80
  ensureDir(dest);
76
81
  let count = 0;
82
+ const created = [];
77
83
  function walk(currentSrc, currentDest) {
78
84
  const entries = readdirSync(currentSrc, { withFileTypes: true });
79
85
  for (const entry of entries) {
80
86
  const srcPath = join2(currentSrc, entry.name);
81
87
  const destPath = join2(currentDest, entry.name);
82
88
  if (entry.isDirectory()) {
89
+ const isNew = !existsSync(destPath);
83
90
  ensureDir(destPath);
91
+ if (isNew)
92
+ created.push(destPath);
84
93
  walk(srcPath, destPath);
85
94
  } else {
86
95
  if (!overwrite && existsSync(destPath)) {
@@ -88,17 +97,24 @@ function copyDir(src, dest, opts = {}) {
88
97
  log.dim(`skip ${relPath}`);
89
98
  continue;
90
99
  }
100
+ const isNew = !existsSync(destPath);
91
101
  ensureDir(dirname2(destPath));
92
102
  copyFileSync(srcPath, destPath);
103
+ if (isNew)
104
+ created.push(destPath);
93
105
  count += 1;
94
106
  }
95
107
  }
96
108
  }
97
109
  walk(src, dest);
98
- return count;
110
+ return { count, created };
99
111
  }
100
112
  function copyFile(src, dest, opts = {}) {
101
113
  const { overwrite = true, label } = opts;
114
+ if (!existsSync(src)) {
115
+ log.warn(`source not found, skipping: ${label ?? src}`);
116
+ return false;
117
+ }
102
118
  if (!overwrite && existsSync(dest)) {
103
119
  log.dim(`skip ${label ?? dest}`);
104
120
  return false;
@@ -107,68 +123,258 @@ function copyFile(src, dest, opts = {}) {
107
123
  copyFileSync(src, dest);
108
124
  return true;
109
125
  }
126
+ function copyFileTracked(src, dest, opts = {}) {
127
+ const { overwrite = true, label } = opts;
128
+ if (!existsSync(src)) {
129
+ log.warn(`source not found, skipping: ${label ?? src}`);
130
+ return { written: false, created: false };
131
+ }
132
+ if (!overwrite && existsSync(dest)) {
133
+ log.dim(`skip ${label ?? dest}`);
134
+ return { written: false, created: false };
135
+ }
136
+ const isNew = !existsSync(dest);
137
+ ensureDir(dirname2(dest));
138
+ copyFileSync(src, dest);
139
+ return { written: true, created: isNew };
140
+ }
141
+
142
+ // src/utils/stack-detect.ts
143
+ import { existsSync as existsSync2, readFileSync } from "fs";
144
+ import { join as join3 } from "path";
145
+ function safeExists(filePath) {
146
+ try {
147
+ return existsSync2(filePath);
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+ function safeRead(filePath) {
153
+ try {
154
+ return readFileSync(filePath, "utf8");
155
+ } catch {
156
+ return "";
157
+ }
158
+ }
159
+ function detectPython(repoRoot) {
160
+ if (safeExists(join3(repoRoot, "environment.yml")) || safeExists(join3(repoRoot, "conda-lock.yml")) || safeExists(join3(repoRoot, "meta.yaml"))) {
161
+ return "conda";
162
+ }
163
+ if (safeExists(join3(repoRoot, "pyproject.toml"))) {
164
+ const content = safeRead(join3(repoRoot, "pyproject.toml"));
165
+ if (content.includes("[tool.poetry]")) {
166
+ return "poetry";
167
+ }
168
+ return "uv";
169
+ }
170
+ if (safeExists(join3(repoRoot, "requirements.txt"))) {
171
+ return "pip";
172
+ }
173
+ return null;
174
+ }
175
+ function detectJS(repoRoot) {
176
+ if (!safeExists(join3(repoRoot, "package.json"))) {
177
+ return null;
178
+ }
179
+ if (safeExists(join3(repoRoot, "pnpm-lock.yaml")))
180
+ return "pnpm";
181
+ if (safeExists(join3(repoRoot, "bun.lockb")))
182
+ return "bun";
183
+ if (safeExists(join3(repoRoot, "yarn.lock")))
184
+ return "yarn";
185
+ return "npm";
186
+ }
187
+ function detectGo(repoRoot) {
188
+ return safeExists(join3(repoRoot, "go.mod")) ? "go" : null;
189
+ }
190
+ function detectRust(repoRoot) {
191
+ return safeExists(join3(repoRoot, "Cargo.toml")) ? "rust" : null;
192
+ }
193
+ function detectStack(repoRoot) {
194
+ const candidates = [];
195
+ const python = detectPython(repoRoot);
196
+ if (python)
197
+ candidates.push(python);
198
+ const js = detectJS(repoRoot);
199
+ if (js)
200
+ candidates.push(js);
201
+ const go = detectGo(repoRoot);
202
+ if (go)
203
+ candidates.push(go);
204
+ const rust = detectRust(repoRoot);
205
+ if (rust)
206
+ candidates.push(rust);
207
+ if (candidates.length === 0) {
208
+ return { primary: "unknown", candidates: [], polyglot: false };
209
+ }
210
+ const PYTHON_STACKS = ["conda", "poetry", "uv", "pip"];
211
+ const JS_STACKS = ["pnpm", "bun", "yarn", "npm"];
212
+ const hasPython = candidates.some((c) => PYTHON_STACKS.includes(c));
213
+ const hasJS = candidates.some((c) => JS_STACKS.includes(c));
214
+ const hasGo = candidates.includes("go");
215
+ const hasRust = candidates.includes("rust");
216
+ const languageCount = [hasPython, hasJS, hasGo, hasRust].filter(Boolean).length;
217
+ const polyglot = languageCount > 1;
218
+ return {
219
+ primary: candidates[0],
220
+ candidates,
221
+ polyglot
222
+ };
223
+ }
110
224
 
111
225
  // src/commands/init.ts
226
+ function loadCiTemplate(stack) {
227
+ const templatePath = join4(ASSETS_DIR, "ci-templates", `${stack}.yml`);
228
+ if (!existsSync3(templatePath))
229
+ return null;
230
+ try {
231
+ return readFileSync2(templatePath, "utf8");
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+ function patchFastGateYml(baseYml, fragment, stack) {
237
+ const lines = baseYml.split("\n");
238
+ const PLACEHOLDER_NAME = " - name: Repository-specific fast gate";
239
+ const startIdx = lines.findIndex((l) => l.startsWith(PLACEHOLDER_NAME));
240
+ if (startIdx === -1) {
241
+ return baseYml;
242
+ }
243
+ let endIdx = startIdx + 1;
244
+ while (endIdx < lines.length) {
245
+ const line = lines[endIdx];
246
+ if (line.startsWith(" - ") && endIdx > startIdx || line.match(/^ \S/) && !line.startsWith(" ")) {
247
+ break;
248
+ }
249
+ endIdx++;
250
+ }
251
+ const fragmentLines = fragment.trimEnd().split("\n").map((l) => l === "" ? "" : " " + l);
252
+ const before = lines.slice(0, startIdx);
253
+ const after = lines.slice(endIdx);
254
+ return [...before, ...fragmentLines, ...after].join("\n");
255
+ }
112
256
  async function init(opts) {
113
257
  if (opts.globalOnly && opts.localOnly) {
114
258
  log.error("--global-only and --local-only are mutually exclusive.");
115
259
  process.exit(1);
116
260
  }
117
261
  const cwd = process.cwd();
262
+ const createdPaths = [];
263
+ function track(paths) {
264
+ createdPaths.push(...paths);
265
+ }
266
+ function rollback() {
267
+ log.warn("Rolling back created paths due to error\u2026");
268
+ for (const p of [...createdPaths].reverse()) {
269
+ try {
270
+ rmSync(p, { recursive: true, force: true });
271
+ log.dim(`rolled back: ${p}`);
272
+ } catch {
273
+ log.warn(`could not remove: ${p}`);
274
+ }
275
+ }
276
+ }
118
277
  log.blank();
119
278
  log.info("Initialising contract-driven-delivery kit\u2026");
120
279
  log.blank();
121
- if (!opts.localOnly) {
122
- log.info(`Installing agents \u2192 ${AGENTS_HOME}`);
123
- const agentCount = copyDir(ASSET.agents, AGENTS_HOME, { overwrite: true });
124
- log.ok(`${agentCount} agent file(s) installed.`);
125
- const skillDest = join3(SKILLS_HOME, "contract-driven-delivery");
126
- log.info(`Installing skill \u2192 ${skillDest}`);
127
- const skillCount = copyDir(ASSET.skill, skillDest, { overwrite: true });
128
- log.ok(`${skillCount} skill file(s) installed.`);
129
- log.blank();
130
- }
131
- if (!opts.globalOnly) {
132
- log.info(`Scaffolding project files in ${cwd}`);
133
- const contractsCount = copyDir(
134
- ASSET.contracts,
135
- join3(cwd, "contracts"),
136
- { overwrite: opts.force, label: "contracts" }
137
- );
138
- log.ok(`contracts/ \u2014 ${contractsCount} file(s) written.`);
139
- const specsCount = copyDir(
140
- ASSET.specsTemplates,
141
- join3(cwd, "specs", "templates"),
142
- { overwrite: opts.force, label: "specs/templates" }
143
- );
144
- log.ok(`specs/templates/ \u2014 ${specsCount} file(s) written.`);
145
- const testsCount = copyDir(
146
- ASSET.testsTemplates,
147
- join3(cwd, "tests", "templates"),
148
- { overwrite: opts.force, label: "tests/templates" }
149
- );
150
- log.ok(`tests/templates/ \u2014 ${testsCount} file(s) written.`);
151
- const ciCount = copyDir(
152
- ASSET.ci,
153
- join3(cwd, "ci"),
154
- { overwrite: opts.force, label: "ci" }
155
- );
156
- log.ok(`ci/ \u2014 ${ciCount} file(s) written.`);
157
- const claudeWritten = copyFile(
158
- ASSET.claudeTemplate,
159
- join3(cwd, "CLAUDE.md"),
160
- { overwrite: false, label: "CLAUDE.md" }
161
- );
162
- if (claudeWritten)
163
- log.ok("CLAUDE.md created.");
164
- const agentsWritten = copyFile(
165
- ASSET.agentsTemplate,
166
- join3(cwd, "AGENTS.md"),
167
- { overwrite: false, label: "AGENTS.md" }
168
- );
169
- if (agentsWritten)
170
- log.ok("AGENTS.md created.");
171
- log.blank();
280
+ try {
281
+ if (!opts.localOnly) {
282
+ log.info(`Installing agents \u2192 ${AGENTS_HOME}`);
283
+ const { count: agentCount, created: agentCreated } = copyDirTracked(ASSET.agents, AGENTS_HOME, { overwrite: true });
284
+ track(agentCreated);
285
+ log.ok(`${agentCount} agent file(s) installed.`);
286
+ const skillDest = join4(SKILLS_HOME, "contract-driven-delivery");
287
+ log.info(`Installing skill \u2192 ${skillDest}`);
288
+ const { count: skillCount, created: skillCreated } = copyDirTracked(ASSET.skill, skillDest, { overwrite: true });
289
+ track(skillCreated);
290
+ log.ok(`${skillCount} skill file(s) installed.`);
291
+ log.blank();
292
+ }
293
+ if (!opts.globalOnly) {
294
+ log.info(`Scaffolding project files in ${cwd}`);
295
+ const { count: contractsCount, created: contractsCreated } = copyDirTracked(
296
+ ASSET.contracts,
297
+ join4(cwd, "contracts"),
298
+ { overwrite: opts.force, label: "contracts" }
299
+ );
300
+ track(contractsCreated);
301
+ log.ok(`contracts/ \u2014 ${contractsCount} file(s) written.`);
302
+ const { count: specsCount, created: specsCreated } = copyDirTracked(
303
+ ASSET.specsTemplates,
304
+ join4(cwd, "specs", "templates"),
305
+ { overwrite: opts.force, label: "specs/templates" }
306
+ );
307
+ track(specsCreated);
308
+ log.ok(`specs/templates/ \u2014 ${specsCount} file(s) written.`);
309
+ const { count: testsCount, created: testsCreated } = copyDirTracked(
310
+ ASSET.testsTemplates,
311
+ join4(cwd, "tests", "templates"),
312
+ { overwrite: opts.force, label: "tests/templates" }
313
+ );
314
+ track(testsCreated);
315
+ log.ok(`tests/templates/ \u2014 ${testsCount} file(s) written.`);
316
+ const { count: ciCount, created: ciCreated } = copyDirTracked(
317
+ ASSET.ci,
318
+ join4(cwd, "ci"),
319
+ { overwrite: opts.force, label: "ci" }
320
+ );
321
+ track(ciCreated);
322
+ log.ok(`ci/ \u2014 ${ciCount} file(s) written.`);
323
+ const detection = detectStack(cwd);
324
+ if (detection.polyglot) {
325
+ const PYTHON_STACKS = ["conda", "poetry", "uv", "pip"];
326
+ const JS_STACKS = ["pnpm", "bun", "yarn", "npm"];
327
+ const other = detection.candidates.find((c) => {
328
+ if (PYTHON_STACKS.includes(detection.primary))
329
+ return !PYTHON_STACKS.includes(c);
330
+ if (JS_STACKS.includes(detection.primary))
331
+ return !JS_STACKS.includes(c);
332
+ return c !== detection.primary;
333
+ });
334
+ log.warn(
335
+ `Polyglot detected: ${detection.primary} and ${other ?? detection.candidates[1]}. Generated config for ${detection.primary}.`
336
+ );
337
+ } else if (detection.primary !== "unknown") {
338
+ log.info(`Detected stack: ${detection.primary}`);
339
+ } else {
340
+ log.warn("Could not detect stack \u2014 CI placeholder left in place.");
341
+ }
342
+ const ciYmlDest = join4(cwd, "ci", "github-actions", "contract-driven-gates.yml");
343
+ if (existsSync3(ciYmlDest)) {
344
+ const template = loadCiTemplate(detection.primary);
345
+ if (template) {
346
+ const original = readFileSync2(ciYmlDest, "utf8");
347
+ const patched = patchFastGateYml(original, template, detection.primary);
348
+ if (patched !== original) {
349
+ writeFileSync(ciYmlDest, patched, "utf8");
350
+ log.ok(`CI fast-gate patched for stack: ${detection.primary}`);
351
+ }
352
+ }
353
+ }
354
+ const { written: claudeWritten, created: claudeCreated } = copyFileTracked(
355
+ ASSET.claudeTemplate,
356
+ join4(cwd, "CLAUDE.md"),
357
+ { overwrite: false, label: "CLAUDE.md" }
358
+ );
359
+ if (claudeCreated)
360
+ track([join4(cwd, "CLAUDE.md")]);
361
+ if (claudeWritten)
362
+ log.ok("CLAUDE.md created.");
363
+ const { written: agentsWritten, created: agentsCreated } = copyFileTracked(
364
+ ASSET.agentsTemplate,
365
+ join4(cwd, "AGENTS.md"),
366
+ { overwrite: false, label: "AGENTS.md" }
367
+ );
368
+ if (agentsCreated)
369
+ track([join4(cwd, "AGENTS.md")]);
370
+ if (agentsWritten)
371
+ log.ok("AGENTS.md created.");
372
+ log.blank();
373
+ }
374
+ } catch (err) {
375
+ log.error(`Init failed: ${err instanceof Error ? err.message : String(err)}`);
376
+ rollback();
377
+ process.exit(1);
172
378
  }
173
379
  log.ok("Done.");
174
380
  log.blank();
@@ -177,27 +383,123 @@ async function init(opts) {
177
383
  }
178
384
 
179
385
  // src/commands/update.ts
180
- import { join as join4 } from "path";
181
- async function update() {
386
+ import { join as join5 } from "path";
387
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readdirSync as readdirSync2, copyFileSync as copyFileSync2, readFileSync as readFileSync3 } from "fs";
388
+ import { createHash } from "crypto";
389
+ import { homedir as homedir2 } from "os";
390
+ function fileHash(filePath) {
391
+ const buf = readFileSync3(filePath);
392
+ return createHash("sha256").update(buf).digest("hex");
393
+ }
394
+ function diffDir(src, dest) {
395
+ const entries = [];
396
+ if (!existsSync4(src))
397
+ return entries;
398
+ function walk(currentSrc, currentDest) {
399
+ const items = readdirSync2(currentSrc, { withFileTypes: true });
400
+ for (const item of items) {
401
+ const srcPath = join5(currentSrc, item.name);
402
+ const destPath = join5(currentDest, item.name);
403
+ if (item.isDirectory()) {
404
+ walk(srcPath, destPath);
405
+ } else {
406
+ if (!existsSync4(destPath)) {
407
+ entries.push({ src: srcPath, dest: destPath, action: "add" });
408
+ } else if (fileHash(srcPath) !== fileHash(destPath)) {
409
+ entries.push({ src: srcPath, dest: destPath, action: "overwrite" });
410
+ } else {
411
+ entries.push({ src: srcPath, dest: destPath, action: "skip" });
412
+ }
413
+ }
414
+ }
415
+ }
416
+ walk(src, dest);
417
+ return entries;
418
+ }
419
+ function applyDir(entries) {
420
+ let count = 0;
421
+ for (const e of entries) {
422
+ if (e.action === "skip")
423
+ continue;
424
+ mkdirSync2(join5(e.dest, ".."), { recursive: true });
425
+ copyFileSync2(e.src, e.dest);
426
+ count += 1;
427
+ }
428
+ return count;
429
+ }
430
+ function backupDir(dir, backupDest) {
431
+ if (!existsSync4(dir))
432
+ return;
433
+ mkdirSync2(backupDest, { recursive: true });
434
+ function walk(src, dst) {
435
+ const items = readdirSync2(src, { withFileTypes: true });
436
+ for (const item of items) {
437
+ const s = join5(src, item.name);
438
+ const d = join5(dst, item.name);
439
+ if (item.isDirectory()) {
440
+ mkdirSync2(d, { recursive: true });
441
+ walk(s, d);
442
+ } else
443
+ copyFileSync2(s, d);
444
+ }
445
+ }
446
+ walk(dir, backupDest);
447
+ }
448
+ async function update(opts) {
449
+ log.blank();
450
+ const skillDest = join5(SKILLS_HOME, "contract-driven-delivery");
451
+ const agentDiff = diffDir(ASSET.agents, AGENTS_HOME);
452
+ const skillDiff = diffDir(ASSET.skill, skillDest);
453
+ const toWrite = [...agentDiff, ...skillDiff].filter((e) => e.action !== "skip");
454
+ const toAdd = toWrite.filter((e) => e.action === "add");
455
+ const toOver = toWrite.filter((e) => e.action === "overwrite");
456
+ const toSkip = [...agentDiff, ...skillDiff].filter((e) => e.action === "skip");
457
+ log.info(`Dry-run diff \u2014 agents: ${AGENTS_HOME}`);
458
+ log.info(`Dry-run diff \u2014 skill: ${skillDest}`);
459
+ log.blank();
460
+ if (toAdd.length)
461
+ log.info(` + ${toAdd.length} file(s) would be added`);
462
+ if (toOver.length)
463
+ log.warn(` ~ ${toOver.length} file(s) would be overwritten (user edits lost without backup)`);
464
+ if (toSkip.length)
465
+ log.dim(` ${toSkip.length} file(s) unchanged (skipped)`);
466
+ if (toWrite.length === 0) {
467
+ log.blank();
468
+ log.ok("Already up to date \u2014 nothing to write.");
469
+ log.blank();
470
+ return;
471
+ }
472
+ if (!opts.yes) {
473
+ log.blank();
474
+ log.info("Run with --yes to apply changes. Example:");
475
+ log.dim(" cdd-kit update --yes");
476
+ log.blank();
477
+ return;
478
+ }
479
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
480
+ const backupRoot = join5(homedir2(), ".claude", ".cdd-kit-backup", timestamp);
182
481
  log.blank();
183
- log.info("Updating ~/.claude agents and skill\u2026");
482
+ log.info(`Backing up to ${backupRoot} \u2026`);
483
+ backupDir(AGENTS_HOME, join5(backupRoot, "agents"));
484
+ backupDir(skillDest, join5(backupRoot, "skill"));
485
+ log.ok(`Backup complete: ${backupRoot}`);
184
486
  log.blank();
185
487
  log.info(`Updating agents \u2192 ${AGENTS_HOME}`);
186
- const agentCount = copyDir(ASSET.agents, AGENTS_HOME, { overwrite: true });
488
+ const agentCount = applyDir(agentDiff);
187
489
  log.ok(`${agentCount} agent file(s) updated.`);
188
- const skillDest = join4(SKILLS_HOME, "contract-driven-delivery");
189
490
  log.info(`Updating skill \u2192 ${skillDest}`);
190
- const skillCount = copyDir(ASSET.skill, skillDest, { overwrite: true });
491
+ const skillCount = applyDir(skillDiff);
191
492
  log.ok(`${skillCount} skill file(s) updated.`);
192
493
  log.blank();
193
494
  log.info("Project files (contracts/, specs/, tests/, ci/) were not changed.");
194
495
  log.ok("Update complete.");
496
+ log.info(`Backup saved to: ${backupRoot}`);
195
497
  log.blank();
196
498
  }
197
499
 
198
500
  // src/commands/new-change.ts
199
- import { join as join5 } from "path";
200
- import { existsSync as existsSync2 } from "fs";
501
+ import { join as join6 } from "path";
502
+ import { existsSync as existsSync5, readdirSync as readdirSync3 } from "fs";
201
503
  var REQUIRED_TEMPLATES = [
202
504
  "change-request.md",
203
505
  "change-classification.md",
@@ -205,16 +507,14 @@ var REQUIRED_TEMPLATES = [
205
507
  "ci-gates.md",
206
508
  "tasks.md"
207
509
  ];
208
- var OPTIONAL_TEMPLATES = [
209
- "current-behavior.md",
210
- "proposal.md",
211
- "spec.md",
212
- "design.md",
213
- "contracts.md",
214
- "qa-report.md",
215
- "regression-report.md",
216
- "archive.md"
217
- ];
510
+ function listOptional() {
511
+ try {
512
+ const all = readdirSync3(ASSET.specsTemplates).filter((f) => f.endsWith(".md"));
513
+ return all.filter((f) => !REQUIRED_TEMPLATES.includes(f));
514
+ } catch {
515
+ return [];
516
+ }
517
+ }
218
518
  var SAFE_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
219
519
  async function newChange(name, opts) {
220
520
  if (!SAFE_NAME.test(name)) {
@@ -222,25 +522,30 @@ async function newChange(name, opts) {
222
522
  process.exit(1);
223
523
  }
224
524
  const cwd = process.cwd();
225
- const changeDir = join5(cwd, "specs", "changes", name);
226
- if (existsSync2(changeDir)) {
227
- log.warn(`Change directory already exists: ${changeDir}`);
228
- log.warn("Aborting \u2014 remove or rename the directory to re-scaffold.");
229
- return;
525
+ const changeDir = join6(cwd, "specs", "changes", name);
526
+ if (existsSync5(changeDir)) {
527
+ if (opts.force) {
528
+ log.warn(`Forcing re-scaffold of existing change directory: ${changeDir}`);
529
+ log.warn("Existing files will NOT be deleted; only template files will be overwritten.");
530
+ } else {
531
+ log.warn(`Change directory already exists: ${changeDir}`);
532
+ log.warn("Aborting \u2014 remove or rename the directory to re-scaffold.");
533
+ return;
534
+ }
230
535
  }
231
536
  log.blank();
232
537
  log.info(`Creating change scaffold: specs/changes/${name}`);
233
538
  ensureDir(changeDir);
234
- const templates = opts.all ? [...REQUIRED_TEMPLATES, ...OPTIONAL_TEMPLATES] : [...REQUIRED_TEMPLATES];
539
+ const templates = opts.all ? [...REQUIRED_TEMPLATES, ...listOptional()] : [...REQUIRED_TEMPLATES];
235
540
  let written = 0;
236
541
  for (const tmpl of templates) {
237
- const src = join5(ASSET.specsTemplates, tmpl);
238
- const dest = join5(changeDir, tmpl);
239
- if (!existsSync2(src)) {
542
+ const src = join6(ASSET.specsTemplates, tmpl);
543
+ const dest = join6(changeDir, tmpl);
544
+ if (!existsSync5(src)) {
240
545
  log.warn(`Template not found, skipping: ${tmpl}`);
241
546
  continue;
242
547
  }
243
- copyFile(src, dest, { overwrite: false });
548
+ copyFile(src, dest, { overwrite: opts.force });
244
549
  log.dim(tmpl);
245
550
  written += 1;
246
551
  }
@@ -250,22 +555,29 @@ async function newChange(name, opts) {
250
555
  }
251
556
 
252
557
  // src/commands/validate.ts
253
- import { join as join6 } from "path";
254
- import { existsSync as existsSync3 } from "fs";
255
- import { execSync } from "child_process";
558
+ import { join as join7 } from "path";
559
+ import { existsSync as existsSync6 } from "fs";
560
+ import { spawnSync } from "child_process";
256
561
  var VALIDATORS = [
257
- { flag: "contracts", script: "validate_contracts.py", label: "contracts" },
562
+ {
563
+ flag: "contracts",
564
+ script: "validate_contracts.py",
565
+ label: "contracts",
566
+ chain: [
567
+ { script: "validate_api_semantic.py", label: "API semantic" },
568
+ { script: "validate_env_semantic.py", label: "Env semantic" }
569
+ ]
570
+ },
258
571
  { flag: "env", script: "validate_env_contract.py", label: "env contract" },
259
572
  { flag: "ci", script: "validate_ci_gates.py", label: "CI gates" },
260
- { flag: "spec", script: "validate_spec_traceability.py", label: "spec traceability" }
573
+ { flag: "spec", script: "validate_spec_traceability.py", label: "spec traceability" },
574
+ { flag: "versions", script: "validate_contract_versions.py", label: "contract versions" }
261
575
  ];
262
576
  function resolvePython() {
263
577
  for (const cmd of ["python3", "python"]) {
264
- try {
265
- execSync(`${cmd} --version`, { stdio: "ignore" });
578
+ const r = spawnSync(cmd, ["--version"], { stdio: "ignore" });
579
+ if (r.status === 0)
266
580
  return cmd;
267
- } catch {
268
- }
269
581
  }
270
582
  throw new Error("Python not found. Install Python 3.8+ and ensure it is on PATH.");
271
583
  }
@@ -277,28 +589,47 @@ async function validate(opts) {
277
589
  log.error(e instanceof Error ? e.message : String(e));
278
590
  process.exit(1);
279
591
  }
280
- const scriptsDir = join6(ASSET.skill, "scripts");
281
- const runAll = !opts.contracts && !opts.env && !opts.ci && !opts.spec;
592
+ const scriptsDir = join7(ASSET.skill, "scripts");
593
+ const runAll = !opts.contracts && !opts.env && !opts.ci && !opts.spec && !opts.versions;
282
594
  log.blank();
283
595
  let failed = false;
284
596
  for (const v of VALIDATORS) {
285
597
  if (!runAll && !opts[v.flag])
286
598
  continue;
287
- const scriptPath = join6(scriptsDir, v.script);
288
- if (!existsSync3(scriptPath)) {
599
+ const scriptPath = join7(scriptsDir, v.script);
600
+ if (!existsSync6(scriptPath)) {
289
601
  log.warn(`${v.label}: script not found, skipping (${v.script})`);
290
602
  log.blank();
291
603
  continue;
292
604
  }
293
605
  log.info(`Validating ${v.label}\u2026`);
294
- try {
295
- execSync(`${py} "${scriptPath}"`, { stdio: "inherit", cwd: process.cwd() });
296
- log.ok(`${v.label} passed.`);
297
- } catch {
606
+ const r = spawnSync(py, [scriptPath, ...v.args ?? []], { stdio: "inherit", cwd: process.cwd() });
607
+ if (r.status !== 0) {
298
608
  log.error(`${v.label} validation failed.`);
299
609
  failed = true;
610
+ } else {
611
+ log.ok(`${v.label} passed.`);
300
612
  }
301
613
  log.blank();
614
+ if (v.chain) {
615
+ for (const chained of v.chain) {
616
+ const chainedPath = join7(scriptsDir, chained.script);
617
+ if (!existsSync6(chainedPath)) {
618
+ log.warn(`${chained.label}: script not found, skipping (${chained.script})`);
619
+ log.blank();
620
+ continue;
621
+ }
622
+ log.info(`Validating ${chained.label}\u2026`);
623
+ const cr = spawnSync(py, [chainedPath], { stdio: "inherit", cwd: process.cwd() });
624
+ if (cr.status !== 0) {
625
+ log.error(`${chained.label} validation failed.`);
626
+ failed = true;
627
+ } else {
628
+ log.ok(`${chained.label} passed.`);
629
+ }
630
+ log.blank();
631
+ }
632
+ }
302
633
  }
303
634
  if (failed) {
304
635
  log.error("One or more validations failed.");
@@ -309,9 +640,145 @@ async function validate(opts) {
309
640
  }
310
641
  }
311
642
 
643
+ // src/commands/gate.ts
644
+ import { existsSync as existsSync7, readFileSync as readFileSync4, readdirSync as readdirSync4 } from "fs";
645
+ import { join as join8 } from "path";
646
+ import { spawnSync as spawnSync2 } from "child_process";
647
+ var REQUIRED_FILES = [
648
+ "change-request.md",
649
+ "change-classification.md",
650
+ "test-plan.md",
651
+ "ci-gates.md",
652
+ "tasks.md"
653
+ ];
654
+ var TIER_PATTERN = /\b(tier\s*[0-5]|low|medium|high|critical)\b/i;
655
+ function meaningfulChars(text) {
656
+ return text.split("\n").map((l) => l.trim()).filter((l) => l).filter((l) => !l.startsWith("#")).filter((l) => !/^[|\s\-:]+$/.test(l)).filter((l) => !l.startsWith("<!--")).join("").length;
657
+ }
658
+ async function gate(changeId) {
659
+ const cwd = process.cwd();
660
+ const changeDir = join8(cwd, "specs", "changes", changeId);
661
+ if (!existsSync7(changeDir)) {
662
+ log.error(`change not found: ${changeId} (looked in ${changeDir})`);
663
+ process.exit(1);
664
+ }
665
+ const errors = [];
666
+ for (const f of REQUIRED_FILES) {
667
+ if (!existsSync7(join8(changeDir, f))) {
668
+ errors.push(`missing required artifact: ${f}`);
669
+ }
670
+ }
671
+ if (errors.length === 0) {
672
+ for (const f of REQUIRED_FILES) {
673
+ const content = readFileSync4(join8(changeDir, f), "utf8");
674
+ if (meaningfulChars(content) < 100) {
675
+ errors.push(`${f}: appears to be a stub (< 100 meaningful chars)`);
676
+ }
677
+ }
678
+ const classifPath = join8(changeDir, "change-classification.md");
679
+ if (existsSync7(classifPath)) {
680
+ const text = readFileSync4(classifPath, "utf8");
681
+ if (!TIER_PATTERN.test(text)) {
682
+ errors.push("change-classification.md: missing tier/risk marker (Tier 0-5 or low/medium/high/critical)");
683
+ }
684
+ }
685
+ }
686
+ const agentLogDir = join8(changeDir, "agent-log");
687
+ if (existsSync7(agentLogDir)) {
688
+ const logFiles = readdirSync4(agentLogDir).filter((f) => f.endsWith(".md"));
689
+ for (const f of logFiles) {
690
+ const content = readFileSync4(join8(agentLogDir, f), "utf8");
691
+ const statusMatch = content.match(/^\s*-\s*status:\s*(complete|needs-review|blocked)\s*$/m);
692
+ if (!statusMatch) {
693
+ errors.push(`agent-log/${f}: missing or invalid "status:" line (must be complete | needs-review | blocked)`);
694
+ continue;
695
+ }
696
+ const status = statusMatch[1];
697
+ if (status === "blocked") {
698
+ const nextActionMatch = content.match(/^\s*-\s*next-action:\s*(.+)$/m);
699
+ if (!nextActionMatch || nextActionMatch[1].trim().toLowerCase() === "none" || nextActionMatch[1].trim().length < 10) {
700
+ errors.push(`agent-log/${f}: status=blocked requires concrete "next-action:" line (>= 10 chars, not "none")`);
701
+ }
702
+ }
703
+ }
704
+ }
705
+ if (errors.length > 0) {
706
+ log.error(`gate failed for change: ${changeId}`);
707
+ for (const e of errors) {
708
+ log.error(` ${e}`);
709
+ }
710
+ process.exit(1);
711
+ }
712
+ log.info(`gate: running contract validators for ${changeId}\u2026`);
713
+ const r = spawnSync2(process.execPath, [process.argv[1], "validate"], {
714
+ cwd,
715
+ stdio: "inherit"
716
+ });
717
+ if (r.status !== 0) {
718
+ log.error(`gate failed for change: ${changeId} (validators returned non-zero)`);
719
+ process.exit(1);
720
+ }
721
+ log.ok(`gate passed for change: ${changeId}`);
722
+ }
723
+
724
+ // src/commands/install-hooks.ts
725
+ import { existsSync as existsSync8, readFileSync as readFileSync5, writeFileSync as writeFileSync2, chmodSync, mkdirSync as mkdirSync3 } from "fs";
726
+ import { join as join9 } from "path";
727
+ var START_MARKER = "# cdd-kit-managed-block-start";
728
+ var END_MARKER = "# cdd-kit-managed-block-end";
729
+ async function installHooks() {
730
+ const cwd = process.cwd();
731
+ const gitDir = join9(cwd, ".git");
732
+ if (!existsSync8(gitDir)) {
733
+ log.error("not a git repository (no .git/ found in cwd)");
734
+ process.exit(1);
735
+ }
736
+ const hooksDir = join9(gitDir, "hooks");
737
+ mkdirSync3(hooksDir, { recursive: true });
738
+ const dest = join9(hooksDir, "pre-commit");
739
+ const ourHook = readFileSync5(join9(ASSET.hooks, "pre-commit"), "utf8");
740
+ let final;
741
+ if (!existsSync8(dest)) {
742
+ final = ourHook;
743
+ } else {
744
+ const existing = readFileSync5(dest, "utf8");
745
+ const startIdx = existing.indexOf(START_MARKER);
746
+ const endIdx = existing.indexOf(END_MARKER);
747
+ if (startIdx >= 0 && endIdx > startIdx) {
748
+ const before = existing.slice(0, startIdx);
749
+ const after = existing.slice(endIdx + END_MARKER.length);
750
+ const ourStart = ourHook.indexOf(START_MARKER);
751
+ const ourEnd = ourHook.indexOf(END_MARKER) + END_MARKER.length;
752
+ const ourBlock = ourHook.slice(ourStart, ourEnd);
753
+ final = before + ourBlock + after;
754
+ } else {
755
+ const ourStart = ourHook.indexOf(START_MARKER);
756
+ const ourEnd = ourHook.indexOf(END_MARKER) + END_MARKER.length;
757
+ const ourBlock = ourHook.slice(ourStart, ourEnd);
758
+ if (existing.startsWith("#!")) {
759
+ const firstNewline = existing.indexOf("\n");
760
+ const shebang = existing.slice(0, firstNewline + 1);
761
+ const rest = existing.slice(firstNewline + 1);
762
+ final = shebang + "\n" + ourBlock + "\n" + rest;
763
+ } else {
764
+ final = "#!/bin/sh\n" + ourBlock + "\n" + existing;
765
+ }
766
+ }
767
+ }
768
+ writeFileSync2(dest, final, "utf8");
769
+ try {
770
+ chmodSync(dest, 493);
771
+ } catch {
772
+ }
773
+ log.ok(`pre-commit hook installed at ${dest}`);
774
+ log.info("cdd-kit gate will now run automatically before each commit affecting specs/changes/");
775
+ }
776
+
312
777
  // src/cli/index.ts
778
+ var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
779
+ var pkg = JSON.parse(readFileSync6(join10(__dirname2, "..", "..", "package.json"), "utf8"));
313
780
  var program = new Command();
314
- program.name("cdd").description("Contract-Driven Delivery Kit CLI").version("1.0.0");
781
+ program.name("cdd-kit").description("Contract-Driven Delivery Kit CLI").version(pkg.version);
315
782
  program.command("init").description(
316
783
  "Install agents/skill into ~/.claude and scaffold project files in cwd"
317
784
  ).option("--global-only", "Only install into ~/.claude, skip project files", false).option("--local-only", "Only scaffold project files, skip ~/.claude", false).option("--force", "Overwrite existing project files", false).action(
@@ -321,16 +788,36 @@ program.command("init").description(
321
788
  force: opts.force
322
789
  })
323
790
  );
324
- program.command("update").description("Update ~/.claude agents and skill (does not touch project files)").action(() => update());
325
- 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).action(
326
- (name, opts) => newChange(name, { all: opts.all })
791
+ program.command("update").description("Update ~/.claude agents and skill (does not touch project files)").option("--yes", "Apply changes (default is dry-run)", false).action((opts) => update({ yes: opts.yes }));
792
+ program.command("new <name>").description("Scaffold a new change directory under specs/changes/<name>").option("--all", "Include optional templates in addition to required ones", false).option("--force", "Overwrite existing template files in the change folder", false).action(
793
+ (name, opts) => newChange(name, { all: opts.all, force: opts.force })
327
794
  );
328
- 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).action(
795
+ 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(
329
796
  (opts) => validate({
330
797
  contracts: opts.contracts,
331
798
  env: opts.env,
332
799
  ci: opts.ci,
333
- spec: opts.spec
800
+ spec: opts.spec,
801
+ versions: opts.versions
334
802
  })
335
803
  );
804
+ program.command("gate <change-id>").description("Run full orchestration gate for a change (required artifacts, content, tier, contracts)").action(async (id) => {
805
+ await gate(id);
806
+ });
807
+ program.command("install-hooks").description("Install pre-commit hook that runs cdd-kit gate on staged changes").action(async () => {
808
+ await installHooks();
809
+ });
810
+ program.command("detect-stack").description("Detect the project tech stack and print the result").action(() => {
811
+ const cwd = process.cwd();
812
+ const result = detectStack(cwd);
813
+ console.log(`Detected stack: ${result.primary}`);
814
+ if (result.candidates.length > 1) {
815
+ console.log(`Candidates (in order): ${result.candidates.join(", ")}`);
816
+ }
817
+ if (result.polyglot) {
818
+ console.log(
819
+ `Polyglot: yes (config will be generated for ${result.primary})`
820
+ );
821
+ }
822
+ });
336
823
  program.parse();