cyber-sdd 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude-plugin/plugin.json +1 -2
  2. package/.codex-plugin/plugin.json +1 -2
  3. package/CHANGELOG.md +320 -0
  4. package/agents/sdd-scanner.md +1 -1
  5. package/package.json +11 -12
  6. package/plugin.json +55 -0
  7. package/skills/align-spec/SKILL.md +1 -1
  8. package/skills/align-spec/scripts/align-spec.mjs +6755 -12
  9. package/skills/blast-estimate/README.md +3 -3
  10. package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
  11. package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
  12. package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
  13. package/skills/check-plan-safety/SKILL.md +2 -3
  14. package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
  15. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
  16. package/skills/check-project-specs/SKILL.md +1 -1
  17. package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
  18. package/skills/check-retired-terms/SKILL.md +2 -3
  19. package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
  20. package/skills/check-scenario-overlap/SKILL.md +1 -2
  21. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
  22. package/skills/check-spec-references/SKILL.md +2 -2
  23. package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
  24. package/skills/check-spec-structure/SKILL.md +1 -2
  25. package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
  26. package/skills/collision-ladder/README.md +2 -2
  27. package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
  28. package/skills/combat-log-governance/SKILL.md +1 -1
  29. package/skills/concept-index/SKILL.md +1 -1
  30. package/skills/concept-index/scripts/concept-index.mjs +183 -12
  31. package/skills/discover-plans/README.md +3 -3
  32. package/skills/discover-plans/SKILL.md +1 -1
  33. package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
  34. package/skills/discover-specs/README.md +3 -3
  35. package/skills/discover-specs/SKILL.md +1 -1
  36. package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
  37. package/skills/doctrine-loop/SKILL.md +1 -1
  38. package/skills/gate-validation-governance/SKILL.md +1 -1
  39. package/skills/init/README.md +2 -2
  40. package/skills/init/scripts/wire-statusline.mjs +168 -12
  41. package/skills/manage-ignore/README.md +3 -3
  42. package/skills/manage-ignore/SKILL.md +1 -2
  43. package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
  44. package/skills/manage-scenario-bridge/README.md +3 -3
  45. package/skills/manage-scenario-bridge/SKILL.md +1 -1
  46. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
  47. package/skills/manage-spec-anchors/README.md +3 -3
  48. package/skills/manage-spec-anchors/SKILL.md +1 -1
  49. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
  50. package/skills/mission-graph/README.md +2 -2
  51. package/skills/mission-graph/SKILL.md +1 -1
  52. package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
  53. package/skills/pause-mission/SKILL.md +1 -1
  54. package/skills/place-node/SKILL.md +2 -2
  55. package/skills/place-node/scripts/place-node.mjs +117 -12
  56. package/skills/plan-retirement/README.md +2 -2
  57. package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
  58. package/skills/resolve-governances/README.md +3 -3
  59. package/skills/resolve-governances/SKILL.md +1 -1
  60. package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
  61. package/skills/resolve-tracking/SKILL.md +1 -1
  62. package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
  63. package/skills/scaffold-project-spec/SKILL.md +1 -1
  64. package/skills/spec-gate/README.md +12 -9
  65. package/skills/spec-gate/SKILL.md +2 -2
  66. package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
  67. package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
  68. package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
  69. package/skills/spec-producer-governance/SKILL.md +2 -2
  70. package/skills/start-mission/SKILL.md +1 -1
  71. package/skills/suite-format-governance/README.md +1 -1
  72. package/skills/suite-format-governance/SKILL.md +1 -1
  73. package/skills/touch-set-correction/README.md +5 -5
  74. package/skills/touch-set-correction/SKILL.md +1 -1
  75. package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
  76. package/skills/verify-scenarios/README.md +6 -6
  77. package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
  78. package/.plugin/pins.json +0 -3
  79. package/.plugin/plugin.json +0 -19
  80. package/dist/align-spec/scripts/align-spec.js +0 -102
  81. package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
  82. package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
  83. package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
  84. package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
  85. package/dist/concept-index/scripts/concept-index.js +0 -181
  86. package/dist/discover-specs/scripts/discover-specs.js +0 -318
  87. package/dist/spec-gate/scripts/check-spec-state.js +0 -420
  88. package/dist/spec-gate/scripts/check-suite.js +0 -317
  89. package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
  90. package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
  91. package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
  92. package/skills/align-spec/scripts/align-spec.mts +0 -188
  93. package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
  94. package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
  95. package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
  96. package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
  97. package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
  98. package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
  99. package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
  100. package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
  101. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
  102. package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
  103. package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
  104. package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
  105. package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
  106. package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
  107. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
  108. package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
  109. package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
  110. package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
  111. package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
  112. package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
  113. package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
  114. package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
  115. package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
  116. package/skills/concept-index/scripts/concept-index.mts +0 -246
  117. package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
  118. package/skills/discover-plans/scripts/discover-plans.mts +0 -215
  119. package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
  120. package/skills/discover-specs/scripts/discover-specs.mts +0 -420
  121. package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
  122. package/skills/init/scripts/wire-statusline.mts +0 -279
  123. package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
  124. package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
  125. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
  126. package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
  127. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
  128. package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
  129. package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
  130. package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
  131. package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
  132. package/skills/place-node/scripts/place-node.mts +0 -158
  133. package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
  134. package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
  135. package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
  136. package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
  137. package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
  138. package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
  139. package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
  140. package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
  141. package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
  142. package/skills/spec-gate/scripts/check-suite.mts +0 -538
  143. package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
  144. package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
  145. package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
  146. package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
  147. package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
  148. package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
  149. package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
  150. package/skills/verify-scenarios/scripts/verify-scenarios.mts +0 -470
@@ -1,308 +0,0 @@
1
- #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { pathToFileURL } from "node:url";
5
- //#region skills/manage-spec-anchors/scripts/manage-spec-anchors.mts
6
- const ANCHORS_CONFIG = ".agents/sdd/spec-anchors.toml";
7
- const LIFECYCLE_STATUSES = /* @__PURE__ */ new Set([
8
- "draft",
9
- "approved",
10
- "implemented",
11
- "deprecated"
12
- ]);
13
- const SKIP_DIRS = /* @__PURE__ */ new Set([
14
- "node_modules",
15
- ".git",
16
- "dist",
17
- ".turbo",
18
- ".next",
19
- "coverage"
20
- ]);
21
- const FIXED_ANCHORS = [
22
- {
23
- pattern: ".agents/spec/",
24
- kind: "fixed",
25
- explanation: "the repo-root single-project spec"
26
- },
27
- {
28
- pattern: ".agents/specs/<project>/",
29
- kind: "fixed",
30
- explanation: "each repo-root multi-project spec (the folder names the project)"
31
- },
32
- {
33
- pattern: "<project-path>/.agents/spec/",
34
- kind: "fixed",
35
- explanation: "a nested project's hoisted spec, at any depth"
36
- }
37
- ];
38
- function parseAnchorsToml(text) {
39
- const m = /(^|\n)\s*anchors\s*=\s*\[([\s\S]*?)\]/.exec(text);
40
- if (!m) return [];
41
- const out = [];
42
- for (const q of m[2].matchAll(/"([^"]*)"|'([^']*)'/g)) out.push((q[1] ?? q[2]).trim());
43
- return out.filter((s) => s !== "");
44
- }
45
- function readCustomAnchors(root) {
46
- const file = join(root, ANCHORS_CONFIG);
47
- if (!existsSync(file)) return [];
48
- try {
49
- return parseAnchorsToml(readFileSync(file, "utf8"));
50
- } catch {
51
- return [];
52
- }
53
- }
54
- function serializeAnchors(patterns) {
55
- if (patterns.length === 0) return "anchors = []\n";
56
- return `anchors = [\n${patterns.map((p) => ` "${p}",`).join("\n")}\n]\n`;
57
- }
58
- function writeCustomAnchors(root, patterns) {
59
- const file = join(root, ANCHORS_CONFIG);
60
- mkdirSync(dirname(file), { recursive: true });
61
- writeFileSync(file, serializeAnchors(patterns));
62
- }
63
- function isValidPattern(pattern) {
64
- const p = pattern.trim().replace(/\\/g, "/");
65
- if (p === "" || p.startsWith("/") || /(^|\/)\.\.(\/|$)/.test(p)) return false;
66
- const segs = p.replace(/^\/+|\/+$/g, "").split("/");
67
- if (segs.some((s) => s === "")) return false;
68
- return segs.every((s) => s === "*" || s === "**" || s === "<project>" || !/[<>]/.test(s));
69
- }
70
- function isFixedConvention(pattern) {
71
- const p = pattern.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
72
- return p === ".agents/spec" || p === ".agents/specs/<project>" || p === ".agents/specs/*" || p.endsWith("/.agents/spec") || p.endsWith("/.agents/specs/<project>");
73
- }
74
- function listAnchors(root) {
75
- const custom = readCustomAnchors(root).map((pattern) => ({
76
- pattern,
77
- kind: "custom",
78
- explanation: "a declared extra anchor"
79
- }));
80
- return [...FIXED_ANCHORS, ...custom];
81
- }
82
- function addAnchor(root, pattern) {
83
- const p = pattern.trim();
84
- if (isFixedConvention(p)) return {
85
- ok: false,
86
- reason: "that is a fixed convention — always scanned, never curated"
87
- };
88
- if (!isValidPattern(p)) return {
89
- ok: false,
90
- reason: "malformed anchor pattern"
91
- };
92
- const current = readCustomAnchors(root);
93
- if (current.includes(p)) return {
94
- ok: true,
95
- changed: false,
96
- anchors: current,
97
- message: "already present"
98
- };
99
- const next = [...current, p];
100
- writeCustomAnchors(root, next);
101
- return {
102
- ok: true,
103
- changed: true,
104
- anchors: next,
105
- message: `added ${p}`
106
- };
107
- }
108
- function removeAnchor(root, pattern) {
109
- const p = pattern.trim();
110
- const current = readCustomAnchors(root);
111
- if (!current.includes(p)) return {
112
- ok: true,
113
- changed: false,
114
- anchors: current,
115
- message: "nothing was removed"
116
- };
117
- const next = current.filter((a) => a !== p);
118
- writeCustomAnchors(root, next);
119
- return {
120
- ok: true,
121
- changed: true,
122
- anchors: next,
123
- message: `removed ${p}`
124
- };
125
- }
126
- function editAnchor(root, oldPattern, newPattern) {
127
- const oldP = oldPattern.trim();
128
- const newP = newPattern.trim();
129
- if (isFixedConvention(oldP)) return {
130
- ok: false,
131
- reason: "cannot edit a fixed convention"
132
- };
133
- if (!isValidPattern(newP)) return {
134
- ok: false,
135
- reason: "malformed replacement pattern"
136
- };
137
- if (isFixedConvention(newP)) return {
138
- ok: false,
139
- reason: "replacement is a fixed convention"
140
- };
141
- const current = readCustomAnchors(root);
142
- if (!current.includes(oldP)) return {
143
- ok: false,
144
- reason: `no such custom anchor: ${oldP}`
145
- };
146
- const next = current.map((a) => a === oldP ? newP : a);
147
- writeCustomAnchors(root, next);
148
- return {
149
- ok: true,
150
- changed: true,
151
- anchors: next,
152
- message: `edited ${oldP} -> ${newP}`
153
- };
154
- }
155
- function inducePatterns(root, samplePath) {
156
- const rel = samplePath.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
157
- if (rel === "" || /(^|\/)\.\.(\/|$)/.test(rel)) return {
158
- ok: false,
159
- reason: "path is not repo-relative"
160
- };
161
- let ok = false;
162
- try {
163
- ok = statSync(join(root, rel)).isDirectory();
164
- } catch {
165
- ok = false;
166
- }
167
- if (!ok) return {
168
- ok: false,
169
- reason: `sample path does not resolve to a directory under the repo: ${rel}`
170
- };
171
- const generalized = [...rel.split("/").slice(0, -1), "<project>"].join("/");
172
- return {
173
- ok: true,
174
- candidates: generalized === rel ? [rel] : [rel, generalized]
175
- };
176
- }
177
- function collectDescendants(root, startDir) {
178
- const out = [startDir];
179
- let entries;
180
- try {
181
- entries = readdirSync(join(root, startDir), { withFileTypes: true });
182
- } catch {
183
- return out;
184
- }
185
- for (const e of entries) {
186
- if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
187
- out.push(...collectDescendants(root, startDir ? `${startDir}/${e.name}` : e.name));
188
- }
189
- return out;
190
- }
191
- function previewPattern(root, pattern) {
192
- const p = pattern.trim();
193
- if (!isValidPattern(p)) return {
194
- ok: false,
195
- reason: "malformed candidate pattern"
196
- };
197
- const segs = p.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
198
- let frontier = [{ dir: "" }];
199
- for (const seg of segs) {
200
- const next = [];
201
- if (seg === "**") {
202
- for (const node of frontier) for (const dir of collectDescendants(root, node.dir)) next.push({
203
- dir,
204
- capturedName: node.capturedName
205
- });
206
- frontier = next;
207
- continue;
208
- }
209
- const isGlob = seg === "*" || seg === "<project>";
210
- for (const node of frontier) if (isGlob) {
211
- let entries;
212
- try {
213
- entries = readdirSync(join(root, node.dir), { withFileTypes: true });
214
- } catch {
215
- continue;
216
- }
217
- for (const e of entries) {
218
- if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
219
- next.push({
220
- dir: node.dir ? `${node.dir}/${e.name}` : e.name,
221
- capturedName: seg === "<project>" ? e.name : node.capturedName
222
- });
223
- }
224
- } else next.push({
225
- dir: node.dir ? `${node.dir}/${seg}` : seg,
226
- capturedName: node.capturedName
227
- });
228
- frontier = next;
229
- }
230
- const matches = [];
231
- for (const node of frontier) {
232
- const rel = node.dir ? `${node.dir}/spec.md` : "spec.md";
233
- let status;
234
- try {
235
- status = /(^|\n)status:\s*["']?([a-z]+)/.exec(readFileSync(join(root, rel), "utf8"))?.[2];
236
- } catch {
237
- continue;
238
- }
239
- if (!status || !LIFECYCLE_STATUSES.has(status)) continue;
240
- matches.push({
241
- path: node.dir,
242
- name: node.capturedName ?? node.dir.split("/").pop() ?? node.dir
243
- });
244
- }
245
- matches.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
246
- return {
247
- ok: true,
248
- matches
249
- };
250
- }
251
- function flag(argv, name) {
252
- const i = argv.indexOf(name);
253
- return i === -1 ? void 0 : argv[i + 1];
254
- }
255
- function main(argv) {
256
- const root = flag(argv, "--root") ?? ".";
257
- const w = (s) => process.stdout.write(`${s}\n`);
258
- if (argv.includes("--list")) {
259
- w("Spec anchors discovery scans (fixed conventions are always scanned):");
260
- for (const a of listAnchors(root)) w(` [${a.kind}] ${a.pattern} — ${a.explanation}`);
261
- return 0;
262
- }
263
- if (argv.includes("--add")) {
264
- const r = addAnchor(root, flag(argv, "--add") ?? "");
265
- w(r.ok ? r.message : `refused: ${r.reason}`);
266
- return r.ok ? 0 : 1;
267
- }
268
- if (argv.includes("--remove")) {
269
- const r = removeAnchor(root, flag(argv, "--remove") ?? "");
270
- w(r.ok ? r.message : `refused: ${r.reason}`);
271
- return r.ok ? 0 : 1;
272
- }
273
- if (argv.includes("--edit")) {
274
- const i = argv.indexOf("--edit");
275
- const r = editAnchor(root, argv[i + 1] ?? "", argv[i + 2] ?? "");
276
- w(r.ok ? r.message : `refused: ${r.reason}`);
277
- return r.ok ? 0 : 1;
278
- }
279
- if (argv.includes("--induce")) {
280
- const r = inducePatterns(root, flag(argv, "--induce") ?? "");
281
- if (!r.ok) {
282
- w(`unusable: ${r.reason}`);
283
- return 1;
284
- }
285
- w("Candidate anchor patterns:");
286
- for (const c of r.candidates) w(` ${c}`);
287
- return 0;
288
- }
289
- if (argv.includes("--preview")) {
290
- const r = previewPattern(root, flag(argv, "--preview") ?? "");
291
- if (!r.ok) {
292
- w(`invalid: ${r.reason}`);
293
- return 1;
294
- }
295
- if (r.matches.length === 0) {
296
- w("This pattern matches no project.");
297
- return 0;
298
- }
299
- w("This pattern would discover:");
300
- for (const m of r.matches) w(` ${m.path} (${m.name})`);
301
- return 0;
302
- }
303
- w("usage: manage-spec-anchors --list | --add <p> | --remove <p> | --edit <old> <new> | --induce <path> | --preview <p>");
304
- return 0;
305
- }
306
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
307
- //#endregion
308
- export { FIXED_ANCHORS, addAnchor, editAnchor, inducePatterns, isFixedConvention, isValidPattern, listAnchors, main, parseAnchorsToml, previewPattern, readCustomAnchors, removeAnchor, serializeAnchors };
@@ -1,331 +0,0 @@
1
- #!/usr/bin/env node
2
- // manage-spec-anchors — corpus/spec-anchors' concrete curation engine. Declares and curates the
3
- // OPT-IN extra spec anchors that discover-specs scans on top of the three fixed conventions
4
- // (ADR-0019). It reads/writes ONLY `.agents/sdd/spec-anchors.toml` — never a spec.md, status,
5
- // approval, or freeze.
6
- //
7
- // Operations:
8
- // --list list the three fixed anchors (each explained) + every custom anchor
9
- // --add <pattern> validate + append a custom anchor (creates the config if absent)
10
- // --remove <pattern> drop a custom anchor (no-op + unchanged when absent)
11
- // --edit <old> <new> replace one custom anchor (rejects a malformed <new>)
12
- // --induce <path> candidate patterns for a sample spec dir (literal + <project> capture)
13
- // --preview <pattern> the project(s) a candidate pattern would discover, without persisting
14
- //
15
- // A pattern is a repo-relative directory pattern; `*` globs one segment, `**` globs zero or more
16
- // segments (any depth), `<project>` globs AND captures a segment as the spec name. Fixed conventions
17
- // are implicit and cannot be curated here.
18
- // Pure functions are exported for node:test; running the file directly drives the CLI. No deps.
19
-
20
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
21
- import { dirname, join } from 'node:path'
22
- import { pathToFileURL } from 'node:url'
23
-
24
- const ANCHORS_CONFIG = '.agents/sdd/spec-anchors.toml'
25
- const LIFECYCLE_STATUSES = new Set(['draft', 'approved', 'implemented', 'deprecated'])
26
- const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.turbo', '.next', 'coverage'])
27
-
28
- export interface AnchorEntry {
29
- pattern: string
30
- kind: 'fixed' | 'custom'
31
- explanation: string
32
- }
33
-
34
- // The three fixed conventions, always scanned by discovery and never curated here.
35
- export const FIXED_ANCHORS: AnchorEntry[] = [
36
- { pattern: '.agents/spec/', kind: 'fixed', explanation: 'the repo-root single-project spec' },
37
- {
38
- pattern: '.agents/specs/<project>/',
39
- kind: 'fixed',
40
- explanation: 'each repo-root multi-project spec (the folder names the project)',
41
- },
42
- {
43
- pattern: '<project-path>/.agents/spec/',
44
- kind: 'fixed',
45
- explanation: "a nested project's hoisted spec, at any depth",
46
- },
47
- ]
48
-
49
- // ── Config read/write (a minimal TOML: the `anchors = [ … ]` string array) ──
50
-
51
- export function parseAnchorsToml(text: string): string[] {
52
- const m = /(^|\n)\s*anchors\s*=\s*\[([\s\S]*?)\]/.exec(text)
53
- if (!m) return []
54
- const out: string[] = []
55
- for (const q of m[2].matchAll(/"([^"]*)"|'([^']*)'/g)) out.push((q[1] ?? q[2]).trim())
56
- return out.filter((s) => s !== '')
57
- }
58
-
59
- export function readCustomAnchors(root: string): string[] {
60
- const file = join(root, ANCHORS_CONFIG)
61
- if (!existsSync(file)) return []
62
- try {
63
- return parseAnchorsToml(readFileSync(file, 'utf8'))
64
- } catch {
65
- return []
66
- }
67
- }
68
-
69
- export function serializeAnchors(patterns: string[]): string {
70
- if (patterns.length === 0) return 'anchors = []\n'
71
- return `anchors = [\n${patterns.map((p) => ` "${p}",`).join('\n')}\n]\n`
72
- }
73
-
74
- function writeCustomAnchors(root: string, patterns: string[]): void {
75
- const file = join(root, ANCHORS_CONFIG)
76
- mkdirSync(dirname(file), { recursive: true })
77
- writeFileSync(file, serializeAnchors(patterns))
78
- }
79
-
80
- // ── Validation ──
81
-
82
- // A pattern is well-formed iff it is repo-relative (no leading slash, no `..`) and every segment is
83
- // a literal, `*`, `**`, or the `<project>` capture token (no other `<…>` token, no empty segment).
84
- export function isValidPattern(pattern: string): boolean {
85
- const p = pattern.trim().replace(/\\/g, '/')
86
- if (p === '' || p.startsWith('/') || /(^|\/)\.\.(\/|$)/.test(p)) return false
87
- const segs = p.replace(/^\/+|\/+$/g, '').split('/')
88
- if (segs.some((s) => s === '')) return false
89
- return segs.every((s) => s === '*' || s === '**' || s === '<project>' || !/[<>]/.test(s))
90
- }
91
-
92
- // True when a pattern names one of the three fixed conventions (which are implicit, not curated).
93
- export function isFixedConvention(pattern: string): boolean {
94
- const p = pattern
95
- .trim()
96
- .replace(/\\/g, '/')
97
- .replace(/^\/+|\/+$/g, '')
98
- return (
99
- p === '.agents/spec' ||
100
- p === '.agents/specs/<project>' ||
101
- p === '.agents/specs/*' ||
102
- p.endsWith('/.agents/spec') ||
103
- p.endsWith('/.agents/specs/<project>')
104
- )
105
- }
106
-
107
- // ── list ──
108
-
109
- export function listAnchors(root: string): AnchorEntry[] {
110
- const custom = readCustomAnchors(root).map(
111
- (pattern): AnchorEntry => ({ pattern, kind: 'custom', explanation: 'a declared extra anchor' }),
112
- )
113
- return [...FIXED_ANCHORS, ...custom]
114
- }
115
-
116
- // ── add / remove / edit ──
117
-
118
- export type CrudResult =
119
- | { ok: true; changed: boolean; anchors: string[]; message: string }
120
- | { ok: false; reason: string }
121
-
122
- export function addAnchor(root: string, pattern: string): CrudResult {
123
- const p = pattern.trim()
124
- if (isFixedConvention(p)) return { ok: false, reason: 'that is a fixed convention — always scanned, never curated' }
125
- if (!isValidPattern(p)) return { ok: false, reason: 'malformed anchor pattern' }
126
- const current = readCustomAnchors(root)
127
- if (current.includes(p)) return { ok: true, changed: false, anchors: current, message: 'already present' }
128
- const next = [...current, p]
129
- writeCustomAnchors(root, next)
130
- return { ok: true, changed: true, anchors: next, message: `added ${p}` }
131
- }
132
-
133
- export function removeAnchor(root: string, pattern: string): CrudResult {
134
- const p = pattern.trim()
135
- const current = readCustomAnchors(root)
136
- if (!current.includes(p)) return { ok: true, changed: false, anchors: current, message: 'nothing was removed' }
137
- const next = current.filter((a) => a !== p)
138
- writeCustomAnchors(root, next)
139
- return { ok: true, changed: true, anchors: next, message: `removed ${p}` }
140
- }
141
-
142
- export function editAnchor(root: string, oldPattern: string, newPattern: string): CrudResult {
143
- const oldP = oldPattern.trim()
144
- const newP = newPattern.trim()
145
- if (isFixedConvention(oldP)) return { ok: false, reason: 'cannot edit a fixed convention' }
146
- if (!isValidPattern(newP)) return { ok: false, reason: 'malformed replacement pattern' }
147
- if (isFixedConvention(newP)) return { ok: false, reason: 'replacement is a fixed convention' }
148
- const current = readCustomAnchors(root)
149
- if (!current.includes(oldP)) return { ok: false, reason: `no such custom anchor: ${oldP}` }
150
- const next = current.map((a) => (a === oldP ? newP : a))
151
- writeCustomAnchors(root, next)
152
- return { ok: true, changed: true, anchors: next, message: `edited ${oldP} -> ${newP}` }
153
- }
154
-
155
- // ── induce ──
156
-
157
- export type InduceResult = { ok: true; candidates: string[] } | { ok: false; reason: string }
158
-
159
- // From a sample path to a spec's directory, offer a literal-directory candidate and a <project>-token
160
- // generalization (deepest segment replaced with the capture). Rejects a path that is not a directory
161
- // under the repo root.
162
- export function inducePatterns(root: string, samplePath: string): InduceResult {
163
- const rel = samplePath
164
- .trim()
165
- .replace(/\\/g, '/')
166
- .replace(/^\/+|\/+$/g, '')
167
- if (rel === '' || /(^|\/)\.\.(\/|$)/.test(rel)) return { ok: false, reason: 'path is not repo-relative' }
168
- let ok = false
169
- try {
170
- ok = statSync(join(root, rel)).isDirectory()
171
- } catch {
172
- ok = false
173
- }
174
- if (!ok) return { ok: false, reason: `sample path does not resolve to a directory under the repo: ${rel}` }
175
- const segs = rel.split('/')
176
- const generalized = [...segs.slice(0, -1), '<project>'].join('/')
177
- const candidates = generalized === rel ? [rel] : [rel, generalized]
178
- return { ok: true, candidates }
179
- }
180
-
181
- // ── preview ──
182
-
183
- interface PreviewMatch {
184
- path: string
185
- name: string
186
- }
187
-
188
- export type PreviewResult = { ok: true; matches: PreviewMatch[] } | { ok: false; reason: string }
189
-
190
- // Every dir reachable from `startDir` by descending zero or more levels (startDir itself included),
191
- // skipping SKIP_DIRS. Backs the `**` segment (any-depth glob) below.
192
- function collectDescendants(root: string, startDir: string): string[] {
193
- const out = [startDir]
194
- let entries: import('node:fs').Dirent[]
195
- try {
196
- entries = readdirSync(join(root, startDir), { withFileTypes: true })
197
- } catch {
198
- return out
199
- }
200
- for (const e of entries) {
201
- if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
202
- out.push(...collectDescendants(root, startDir ? `${startDir}/${e.name}` : e.name))
203
- }
204
- return out
205
- }
206
-
207
- // Expand a pattern against the filesystem (mirrors discover-specs' expandAnchor), then keep only the
208
- // dirs holding a spec.md whose frontmatter status is in the lifecycle enum — the specs discovery
209
- // would actually surface. Does NOT read or persist anything to the config.
210
- export function previewPattern(root: string, pattern: string): PreviewResult {
211
- const p = pattern.trim()
212
- if (!isValidPattern(p)) return { ok: false, reason: 'malformed candidate pattern' }
213
- const segs = p
214
- .replace(/\\/g, '/')
215
- .replace(/^\/+|\/+$/g, '')
216
- .split('/')
217
- .filter(Boolean)
218
- let frontier: { dir: string; capturedName?: string }[] = [{ dir: '' }]
219
- for (const seg of segs) {
220
- const next: { dir: string; capturedName?: string }[] = []
221
- if (seg === '**') {
222
- for (const node of frontier) {
223
- for (const dir of collectDescendants(root, node.dir)) next.push({ dir, capturedName: node.capturedName })
224
- }
225
- frontier = next
226
- continue
227
- }
228
- const isGlob = seg === '*' || seg === '<project>'
229
- for (const node of frontier) {
230
- if (isGlob) {
231
- let entries: import('node:fs').Dirent[]
232
- try {
233
- entries = readdirSync(join(root, node.dir), { withFileTypes: true })
234
- } catch {
235
- continue
236
- }
237
- for (const e of entries) {
238
- if (!e.isDirectory() || SKIP_DIRS.has(e.name)) continue
239
- next.push({
240
- dir: node.dir ? `${node.dir}/${e.name}` : e.name,
241
- capturedName: seg === '<project>' ? e.name : node.capturedName,
242
- })
243
- }
244
- } else {
245
- next.push({ dir: node.dir ? `${node.dir}/${seg}` : seg, capturedName: node.capturedName })
246
- }
247
- }
248
- frontier = next
249
- }
250
- const matches: PreviewMatch[] = []
251
- for (const node of frontier) {
252
- const rel = node.dir ? `${node.dir}/spec.md` : 'spec.md'
253
- let status: string | undefined
254
- try {
255
- status = /(^|\n)status:\s*["']?([a-z]+)/.exec(readFileSync(join(root, rel), 'utf8'))?.[2]
256
- } catch {
257
- continue
258
- }
259
- if (!status || !LIFECYCLE_STATUSES.has(status)) continue
260
- matches.push({ path: node.dir, name: node.capturedName ?? node.dir.split('/').pop() ?? node.dir })
261
- }
262
- matches.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
263
- return { ok: true, matches }
264
- }
265
-
266
- // ── CLI ──
267
-
268
- function flag(argv: string[], name: string): string | undefined {
269
- const i = argv.indexOf(name)
270
- return i === -1 ? undefined : argv[i + 1]
271
- }
272
-
273
- export function main(argv: string[]): number {
274
- const root = flag(argv, '--root') ?? '.'
275
- const w = (s: string) => process.stdout.write(`${s}\n`)
276
-
277
- if (argv.includes('--list')) {
278
- w('Spec anchors discovery scans (fixed conventions are always scanned):')
279
- for (const a of listAnchors(root)) w(` [${a.kind}] ${a.pattern} — ${a.explanation}`)
280
- return 0
281
- }
282
- if (argv.includes('--add')) {
283
- const r = addAnchor(root, flag(argv, '--add') ?? '')
284
- w(r.ok ? r.message : `refused: ${r.reason}`)
285
- return r.ok ? 0 : 1
286
- }
287
- if (argv.includes('--remove')) {
288
- const r = removeAnchor(root, flag(argv, '--remove') ?? '')
289
- w(r.ok ? r.message : `refused: ${r.reason}`)
290
- return r.ok ? 0 : 1
291
- }
292
- if (argv.includes('--edit')) {
293
- const i = argv.indexOf('--edit')
294
- const r = editAnchor(root, argv[i + 1] ?? '', argv[i + 2] ?? '')
295
- w(r.ok ? r.message : `refused: ${r.reason}`)
296
- return r.ok ? 0 : 1
297
- }
298
- if (argv.includes('--induce')) {
299
- const r = inducePatterns(root, flag(argv, '--induce') ?? '')
300
- if (!r.ok) {
301
- w(`unusable: ${r.reason}`)
302
- return 1
303
- }
304
- w('Candidate anchor patterns:')
305
- for (const c of r.candidates) w(` ${c}`)
306
- return 0
307
- }
308
- if (argv.includes('--preview')) {
309
- const r = previewPattern(root, flag(argv, '--preview') ?? '')
310
- if (!r.ok) {
311
- w(`invalid: ${r.reason}`)
312
- return 1
313
- }
314
- if (r.matches.length === 0) {
315
- w('This pattern matches no project.')
316
- return 0
317
- }
318
- w('This pattern would discover:')
319
- for (const m of r.matches) w(` ${m.path} (${m.name})`)
320
- return 0
321
- }
322
-
323
- w(
324
- 'usage: manage-spec-anchors --list | --add <p> | --remove <p> | --edit <old> <new> | --induce <path> | --preview <p>',
325
- )
326
- return 0
327
- }
328
-
329
- if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
330
- process.exit(main(process.argv.slice(2)))
331
- }