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