shapeup-sdlc 1.7.0 → 3.0.1

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 (98) hide show
  1. package/.claude/settings.local.example.json +5 -5
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/AGENTS.md +17 -3
  4. package/README.md +124 -103
  5. package/SECURITY.md +37 -30
  6. package/bin/init.mjs +77 -47
  7. package/bin/lib/grant.mjs +145 -0
  8. package/commands/build.md +7 -6
  9. package/commands/ship.md +29 -16
  10. package/commands/wire.md +1 -1
  11. package/hooks/dispatch-receipt.mjs +195 -0
  12. package/hooks/gate-intake.mjs +3 -3
  13. package/hooks/gate-zerowork.mjs +25 -24
  14. package/hooks/hooks.json +9 -48
  15. package/hooks/lib/decision.mjs +19 -8
  16. package/hooks/safety-spine.mjs +3 -3
  17. package/hooks/sandbox-guard.mjs +84 -40
  18. package/{skills/tech-lead/scripts/compile-order.mjs → kernel/compile.mjs} +283 -41
  19. package/{skills/tech-lead/scripts/gate-answers.mjs → kernel/gate.mjs} +58 -14
  20. package/kernel/harness.mjs +134 -0
  21. package/{skills/tech-lead/scripts/fit-check.mjs → kernel/init/fit.mjs} +15 -12
  22. package/{skills/tech-lead/scripts/init-run.mjs → kernel/init/run.mjs} +152 -31
  23. package/{skills/tech-lead/scripts → kernel}/lib/argv.mjs +52 -31
  24. package/{skills/tech-lead/scripts/lib/contract-md.mjs → kernel/lib/contract.mjs} +251 -7
  25. package/kernel/lib/paths.mjs +491 -0
  26. package/kernel/probe/concurrency.mjs +510 -0
  27. package/{skills/tech-lead/scripts/aegis-digest.mjs → kernel/probe/digest.mjs} +9 -10
  28. package/kernel/probe/eval.mjs +77 -0
  29. package/kernel/probe/leg.mjs +125 -0
  30. package/{skills/tech-lead/scripts/resume-state.mjs → kernel/probe/resume.mjs} +204 -63
  31. package/{skills/tech-lead/scripts → kernel/probe}/stats.mjs +14 -9
  32. package/kernel/probe/t0.mjs +66 -0
  33. package/{skills/ba-pitch-analyzer/scripts/board-derive.mjs → kernel/reduce/board.mjs} +99 -23
  34. package/kernel/reduce/graph.mjs +437 -0
  35. package/{skills/tech-lead/scripts/hill-derive.mjs → kernel/reduce/hill.mjs} +28 -9
  36. package/kernel/reduce/ingest.mjs +633 -0
  37. package/{hooks/slop-cleaner.mjs → kernel/reduce/leftovers.mjs} +40 -57
  38. package/{skills/tech-lead/scripts/ship-report.mjs → kernel/reduce/ship.mjs} +61 -13
  39. package/{skills/tech-lead/scripts/run-snapshot.mjs → kernel/reduce/snapshot.mjs} +21 -15
  40. package/{skills/spec-evaluator/scripts/verdict-ledger.mjs → kernel/reduce/verdict.mjs} +13 -7
  41. package/kernel/report/export.mjs +321 -0
  42. package/kernel/report/facts.mjs +209 -0
  43. package/{skills/tech-lead/scripts/budget-check.mjs → kernel/verify/budget.mjs} +15 -12
  44. package/kernel/verify/dispatch.mjs +114 -0
  45. package/{skills/tech-lead/scripts/validate-envelope.mjs → kernel/verify/envelope.mjs} +20 -15
  46. package/{skills/tech-lead/scripts/lib → kernel/verify}/ratchet-tree.mjs +36 -14
  47. package/kernel/verify/skills.mjs +125 -0
  48. package/kernel/verify/spec.mjs +559 -0
  49. package/{skills/tech-lead/scripts/t0-verify.mjs → kernel/verify/t0.mjs} +134 -24
  50. package/{skills/tech-lead/scripts/trace-lint.mjs → kernel/verify/trace.mjs} +26 -11
  51. package/oracles/http-oracle.mjs +1 -1
  52. package/oracles/process-oracle.mjs +1 -1
  53. package/oracles/snapshot-oracle.mjs +1 -1
  54. package/oracles/test-oracle.mjs +1 -1
  55. package/package.json +11 -9
  56. package/skills/ba-pitch-analyzer/SKILL.md +5 -5
  57. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +1 -1
  58. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +12 -7
  59. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +6 -3
  60. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +5 -4
  61. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +23 -13
  62. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +17 -12
  63. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +17 -38
  64. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +3 -0
  65. package/skills/ba-pitch-analyzer/references/contract-patterns.md +8 -4
  66. package/skills/ba-pitch-analyzer/references/doc-schemas.md +2 -0
  67. package/skills/ba-pitch-analyzer/references/task-generation.md +2 -2
  68. package/skills/qa-edge-hunter/SKILL.md +7 -3
  69. package/skills/scope-architect/SKILL.md +40 -6
  70. package/skills/solution-architect/SKILL.md +19 -4
  71. package/skills/spec-evaluator/SKILL.md +12 -2
  72. package/skills/task-executor/SKILL.md +7 -6
  73. package/skills/tech-lead/SKILL.md +50 -38
  74. package/skills/tech-lead/references/gates.md +71 -20
  75. package/skills/tech-lead/references/protocol.md +832 -0
  76. package/skills/tech-lead/schemas/domain.schema.json +341 -66
  77. package/skills/tech-lead/schemas/work-order.schema.json +11 -2
  78. package/skills/tech-lead/schemas/work-result.schema.json +1 -1
  79. package/skills/tech-lead/workflows/shapeup-run.js +1327 -750
  80. package/hooks/anti-rationalization.mjs +0 -238
  81. package/hooks/compact-snapshot.mjs +0 -47
  82. package/hooks/gate-deadline.mjs +0 -147
  83. package/hooks/gate-l2.mjs +0 -161
  84. package/hooks/session-rehydrate.mjs +0 -108
  85. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +0 -239
  86. package/skills/spec-evaluator/README.md +0 -93
  87. package/skills/tech-lead/README.md +0 -71
  88. package/skills/tech-lead/references/delegation.md +0 -261
  89. package/skills/tech-lead/references/hard-rules.md +0 -34
  90. package/skills/tech-lead/references/invocation.md +0 -45
  91. package/skills/tech-lead/references/ledger-schema.md +0 -213
  92. package/skills/tech-lead/references/round-protocol.md +0 -234
  93. package/skills/tech-lead/references/state-model.md +0 -66
  94. package/skills/tech-lead/scripts/ingest-result.mjs +0 -258
  95. package/skills/tech-lead/scripts/lib/is-main.mjs +0 -81
  96. package/skills/tech-lead/scripts/lib/paths.mjs +0 -280
  97. package/skills/tech-lead/scripts/run-workflow.mjs +0 -381
  98. package/skills/translator/README.md +0 -66
@@ -0,0 +1,559 @@
1
+ #!/usr/bin/env node
2
+ // Spec lint — the checks that must hold before a board becomes work.
3
+ //
4
+ // The mechanical half of the old ba-pitch-analyzer Phase 7a self-audit + Phase 7c parse steps
5
+ // + Phase 6b PA1/PA2 lints — checkbox walking and glob checks a model should never grade on
6
+ // its own output (a worker grading itself was always a judge-purity smell):
7
+ //
8
+ // PA1 a scope substrate aligned 1:1 with a single top-level directory (directory-thinking)
9
+ // PA2 a scope substrate resolving to more than the size cap (~15 files)
10
+ // DISJOINT a path matched by two scopes' allowed_file_substrate without BOTH declaring it
11
+ // in shared_substrate
12
+ // STRUCTURE spec tree completeness (usecases/ ≥1 UC, domain-model, UC ## Steps),
13
+ // unresolved wikilinks, task frontmatter completeness, unlocks edge-symmetry,
14
+ // depends_on referencing unknown tasks
15
+ // TIER-DIRECTION ANY committed (SHARED) file referencing the LOCAL tier: a `TASK-` id in
16
+ // prose, a table cell or frontmatter, or a path into .shapeup/. Scans the whole
17
+ // shapeup/<slug>/ tree, because the leak was never confined to the two corners the
18
+ // narrower checks watched — measured across nine runs it was 264 ids in
19
+ // spec/synthesis.md, 183 in scope-summary.md, 136 in scope-board.md, all in cells
20
+ // and sentences no wikilink check can see. Persisted links flow LOCAL→SHARED only:
21
+ // board ids renumber per machine and .shapeup/ is gitignored, so a committed
22
+ // reference resolves for its author and nobody else. Cite the UC or scope_id.
23
+ // (shapeup/knowledge-base/ is a sibling of the slug tree, so it is outside this
24
+ // walk by construction — those files instruct workers, they do not cite artifacts.)
25
+ // UC-ANCHOR a task whose use_case_refs is empty or names a UC with no usecases/UC-*.md —
26
+ // the LOCAL→SHARED anchor must be complete (single-anchor rule; SPIKE/CHORE/
27
+ // DOCS/MIGRATION tasks anchor elsewhere and are exempt)
28
+ // SCOPE-ANCHOR the same rule for the other direction's artifact: a scope contract whose
29
+ // use_cases is empty or names a UC with no usecases/UC-*.md. The contract is
30
+ // committed, so this anchor is what the scope↔task join is re-derived through
31
+ // SCOPE-DEPS a contract's depends_on naming itself, a scope not in this run, or a CYCLE —
32
+ // build ORDER lives in the committed tier now, and the scheduler answers a cycle by
33
+ // dumping every remaining scope into one unordered wave without reporting it
34
+ // SCOPE-COVERS a contract's covers entry that is not a REQ-id (warn), or names a REQ that
35
+ // is not in requirements.md (red, when a registry exists) — shape alone let a scope
36
+ // claim coverage of a requirement that does not exist
37
+ // SCOPE-PARTITION a task claimed by more than one scope. The UC anchor is a SPEC link, not an
38
+ // assignment: one use case is routinely implemented by several scopes, so on a
39
+ // four-scope/one-UC cut every scope claimed every task and would build all of them.
40
+ // Resolved by a `scope_id:` on the task (LOCAL→SHARED) or by re-cutting
41
+ // INV-FLOOR the raw idea (intake.md) names explicit constraints (a No-gos/Constraints/
42
+ // Edge-cases heading with real content under it) but no usecases/UC-*.md declares
43
+ // a single [INV-NN] anywhere — a criteria-count check can't tell a healthy small
44
+ // tree from one that silently derived nothing from the pitch
45
+ //
46
+ // Zero dependencies (glob matcher inlined from hooks/sandbox-guard.mjs). Judgment stays in the skill
47
+ // (gap severity, lens choice); this script only reports facts.
48
+ //
49
+ // Usage: node kernel/harness.mjs verify spec --slug <slug> [--cwd <dir>]
50
+ // Prints a JSON report. Exit 0 = no red findings, 1 = at least one red.
51
+
52
+ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
53
+ import { resolve, join, relative } from "node:path";
54
+ import { parseBoard, deriveUnlocks } from "../reduce/board.mjs";
55
+ import { runArgs } from "../lib/argv.mjs";
56
+ import { LOCAL } from "../lib/paths.mjs";
57
+ import { specDir, scopesDir, tasksDir, intake, sharedRoot, requirements } from "../lib/paths.mjs";
58
+ import { readAllContracts, unreadableReason, ucId, scopePartitionConflicts, SCOPE_CONTRACT } from "../lib/contract.mjs";
59
+
60
+ // Inlined from hooks/sandbox-guard.mjs so this skill ships self-contained (a skill's scripts
61
+ // must not reach outside its own folder — channels that copy only skills/ would dangle).
62
+ /**
63
+ * Compile a substrate glob into an anchored RegExp (inlined from sandbox-guard so the skill ships
64
+ * self-contained). Supports single-star, double-star, and double-star-slash segment wildcards.
65
+ * @param {string} glob - The glob pattern.
66
+ * @returns {RegExp} A full-string (`^…$`) matcher for repo-relative paths.
67
+ */
68
+ export function globToRegExp(glob) {
69
+ let re = "";
70
+ for (let i = 0; i < glob.length; i++) {
71
+ const c = glob[i];
72
+ if (c === "*") {
73
+ if (glob[i + 1] === "*") {
74
+ re += glob[i + 2] === "/" ? "(?:[^/]+/)*" : ".*";
75
+ i += glob[i + 2] === "/" ? 2 : 1;
76
+ } else re += "[^/]*";
77
+ } else if ("\\^$.|?+()[]{}".includes(c)) re += "\\" + c;
78
+ else re += c;
79
+ }
80
+ return new RegExp(`^${re}$`);
81
+ }
82
+
83
+ const SIZE_CAP = 15;
84
+
85
+ /**
86
+ * Recursively list repo-relative file paths under a root, skipping .git/node_modules/.shapeup.
87
+ * @param {string} root - The base the results are made relative to.
88
+ * @param {string} [dir=root] - Current directory being walked (callers omit it).
89
+ * @param {string[]} [acc=[]] - Accumulator (callers omit it).
90
+ * @returns {string[]} Repo-relative paths of every file found.
91
+ */
92
+ function walkFiles(root, dir = root, acc = []) {
93
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
94
+ if (e.name === ".git" || e.name === "node_modules" || e.name === LOCAL) continue;
95
+ const p = join(dir, e.name);
96
+ if (e.isDirectory()) walkFiles(root, p, acc);
97
+ else acc.push(relative(root, p));
98
+ }
99
+ return acc;
100
+ }
101
+
102
+ /**
103
+ * Lint scope contracts for PA1 (directory-thinking), PA2 (size cap), and DISJOINT substrate overlap.
104
+ * @param {Array<{scope_id:string, topology_type?:string, allowed_file_substrate?:string[],
105
+ * shared_substrate?:string[]}>} scopes - The scope contracts.
106
+ * @param {string[]} repoFiles - Repo-relative file list the substrate globs resolve against.
107
+ * @returns {Array<{rule:string, level:("red"|"warn"), scope:string, detail:string}>} Findings; [] when clean.
108
+ */
109
+ export function lintScopes(scopes, repoFiles) {
110
+ const findings = [];
111
+ /**
112
+ * Resolve a list of substrate globs to the repo files they match.
113
+ * @param {(string[]|undefined)} globs - Glob patterns (undefined → none).
114
+ * @returns {string[]} The repo-relative files matched by any glob (deduplication is the caller's).
115
+ */
116
+ const resolveGlobs = (globs) => {
117
+ const res = (globs || []).map((g) => globToRegExp(g));
118
+ return repoFiles.filter((f) => res.some((r) => r.test(f)));
119
+ };
120
+ for (const s of scopes) {
121
+ const allowed = s.allowed_file_substrate || [];
122
+ // PA1 — directory-thinking: every glob confined to ONE layer directory (e.g. all of it
123
+ // under apps/web/). A flow slice crosses layers (apps/web/cart + apps/api/cart passes).
124
+ const layers = new Set(allowed.map((g) => g.split("/").slice(0, 2).join("/")));
125
+ if (allowed.length && layers.size === 1 && s.topology_type !== "CHOWDER") {
126
+ findings.push({ rule: "PA1", level: "red", scope: s.scope_id, detail: `substrate aligns 1:1 with '${[...layers][0]}/' — slice by flow, not by directory` });
127
+ }
128
+ // PA2 — resolved file count over the cap (chowder absorbs true strays).
129
+ const files = resolveGlobs(allowed);
130
+ if (files.length > SIZE_CAP && s.topology_type !== "CHOWDER") {
131
+ findings.push({ rule: "PA2", level: "warn", scope: s.scope_id, detail: `substrate resolves to ${files.length} files (cap ~${SIZE_CAP}) — consider splitting` });
132
+ }
133
+ // T0-UNVERIFIABLE — a scope with no fixtures the parser can see.
134
+ //
135
+ // RED, not warn, and it is the most important line in this file. `runFixtures` used to answer
136
+ // `pass: true` for an empty list (`[].every(…)` is `true`), so a scope whose fixtures did not
137
+ // PARSE was certified T0-green having executed nothing — "measured, not claimed" inverted into
138
+ // "nothing measured, therefore green", in the one layer the evaluator must cite.
139
+ //
140
+ // It is not hypothetical and it is not an authoring slip in the usual sense: the architect wrote
141
+ // GOOD fixtures (`node --test test/…`) as a markdown `## e2e_verification_fixtures` section
142
+ // instead of a frontmatter key. The substrate list beside it, written as a frontmatter block
143
+ // list, parsed perfectly. One field silently reached the scorer as `undefined` and the scope
144
+ // went green on zero evidence.
145
+ //
146
+ // `verify t0` now refuses to call an unrun scope green, so the failure is loud either way. This
147
+ // catches it one gate earlier, where the fix is editing a contract rather than burning a round.
148
+ if (!(s.e2e_verification_fixtures || []).length) {
149
+ findings.push({
150
+ rule: "T0-UNVERIFIABLE", level: "red", scope: s.scope_id,
151
+ detail: "no e2e_verification_fixtures the parser can see — T0 has nothing to run, so this scope " +
152
+ "cannot be verified. Declare them as a FRONTMATTER key (a `- ` block list or a [a, b] inline " +
153
+ "list); a `## e2e_verification_fixtures` markdown section is not read, and a scope whose " +
154
+ "fixtures silently vanish is a scope certified on no evidence.",
155
+ });
156
+ }
157
+ // T0-UNPASSABLE — a fixture whose own comment declares a non-zero exit.
158
+ //
159
+ // `verify t0` scores a fixture as passing iff it exits 0, and that rule lived only in the
160
+ // scorer. Handed a contract that said "commands that drive this scope end-to-end", an architect
161
+ // wrote the error paths as bare invocations — `todo done abc # E_INVALID_INDEX, exit 1` — which
162
+ // cannot pass by construction. Four of six scopes then burned their attempt budget on code that
163
+ // was already correct, and the run reported them as hard scopes.
164
+ //
165
+ // Caught here because the cost of finding it late is measured in whole attempts: this is the
166
+ // last gate before BUILD spends anything. Matched on the author's OWN declaration of a non-zero
167
+ // exit, not on guessing what a command does — a fixture that merely mentions "exit" in another
168
+ // sense does not match, and a warn never blocks a run.
169
+ for (const fx of s.e2e_verification_fixtures || []) {
170
+ const m = String(fx).match(/#[^#]*\bexit\s+([1-9]\d*)\b/i);
171
+ if (m) {
172
+ findings.push({
173
+ rule: "T0-UNPASSABLE", level: "warn", scope: s.scope_id,
174
+ detail: `fixture declares "exit ${m[1]}" — T0 passes a fixture only on exit 0, so this scope cannot go green. ` +
175
+ `Assert the error path inside a test file that itself exits 0: ${String(fx).slice(0, 60)}`,
176
+ });
177
+ }
178
+ }
179
+ }
180
+ // DISJOINT — pairwise overlap not covered by BOTH scopes' shared_substrate.
181
+ for (let i = 0; i < scopes.length; i++) {
182
+ for (let j = i + 1; j < scopes.length; j++) {
183
+ const a = scopes[i], b = scopes[j];
184
+ const filesA = new Set(resolveGlobs(a.allowed_file_substrate));
185
+ const overlap = resolveGlobs(b.allowed_file_substrate).filter((f) => filesA.has(f));
186
+ const sharedA = (a.shared_substrate || []).map(globToRegExp);
187
+ const sharedB = (b.shared_substrate || []).map(globToRegExp);
188
+ for (const f of overlap) {
189
+ const declared = sharedA.some((r) => r.test(f)) && sharedB.some((r) => r.test(f));
190
+ if (!declared) findings.push({ rule: "DISJOINT", level: "red", scope: `${a.scope_id}+${b.scope_id}`, detail: `${f} is in both substrates but not in both shared_substrate lists — PA3 waiting to happen` });
191
+ // DECLARING IT SHARED MAKES IT LEGAL, NOT SAFE, and those are different claims.
192
+ //
193
+ // `shared` is the escape hatch from DISJOINT: two scopes may both write an entry point when
194
+ // both say so. That is right for the WRITE PERMISSION — `sandbox-guard` permits a path any
195
+ // live order covers — and it says nothing about the two scopes running at the SAME TIME.
196
+ // Measured on a shared entry point with three concurrent writers: every trial lost work,
197
+ // because an edit is read-modify-write and the last writer wins. Every layer was individually
198
+ // correct — the lint permitted the overlap, the waves co-scheduled the scopes, the guard
199
+ // allowed both writes — and the join silently dropped a scope's registration.
200
+ //
201
+ // Reported rather than blocked: refusing a legal contract would break the case the escape
202
+ // hatch exists for. What the scheduler needs is the FACT, so it can put two scopes sharing a
203
+ // writable path in different waves instead of discovering the collision in the file.
204
+ else findings.push({ rule: "SHARED-CONCURRENT", level: "warn", scope: `${a.scope_id}+${b.scope_id}`, detail: `${f} is writable by both scopes — legal, but they must not build concurrently: an edit is read-modify-write and the later writer silently drops the earlier one` });
205
+ }
206
+ }
207
+ }
208
+ return findings;
209
+ }
210
+
211
+ /** Text forms worth scanning; anything else in a committed tree is not a reference carrier. */
212
+ const SCANNED = /\.(md|markdown|yml|yaml|json|txt)$/i;
213
+
214
+ /** A machine-local board id. Strict on purpose: a committed tree has no reason to carry one at all. */
215
+ const TASK_ID = /\bTASK-[A-Za-z0-9][\w.-]*/;
216
+
217
+ /**
218
+ * Lint the WHOLE committed tree for references into the gitignored tier.
219
+ *
220
+ * WHY THIS IS NOT THE SAME RULE AS TIER-DIRECTION ABOVE. That one walks wikilinks inside `spec/`
221
+ * and one frontmatter key in `scopes/`. Neither of those is the form the violation actually takes.
222
+ * Measured across nine completed runs, the leak is a bare `TASK-004` in a table cell or a sentence,
223
+ * in seven committed artifact types — 264 of them in `spec/synthesis.md` alone, 183 in
224
+ * `scope-summary.md`, 136 in `scope-board.md` — plus paths into `.shapeup/` in nine more files.
225
+ * A rule that inspects two corners of the tree for two syntactic forms reported all of it clean.
226
+ *
227
+ * The template that motivates the strictness states the rule and then breaks it: `synthesis.tmpl.md`
228
+ * says "Record only the count + status — never task ids … spec-lint flags [[tasks/...]] here as a
229
+ * red TIER-DIRECTION finding" and then prints a dependency chain, a wave table and a critical path
230
+ * entirely in `TASK-NNN` ids, 110 lines later, in cells no wikilink check can see.
231
+ *
232
+ * SCOPE IS THE SLUG'S TREE. `shapeup/knowledge-base/` is a sibling of `shapeup/<slug>/`, not a
233
+ * child, so it is outside this walk by construction — which is right: those files are instructions
234
+ * telling a worker what to do at runtime, not references a reader is expected to resolve.
235
+ *
236
+ * @param {{cwd:string, slug:string}} opts - Working root and feature slug.
237
+ * @returns {Array<{rule:string, level:"red", detail:string}>} One finding per offending line; [] when clean.
238
+ */
239
+ export function lintCommittedTier({ cwd, slug }) {
240
+ const root = sharedRoot(cwd, slug);
241
+ if (!existsSync(root)) return [];
242
+ // Built from the LOCAL constant, never a literal — the storage roots have exactly one home.
243
+ const localPath = new RegExp(`${LOCAL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/\\S`);
244
+ const findings = [];
245
+ for (const rel of walkFiles(root)) {
246
+ if (!SCANNED.test(rel)) continue;
247
+ let lines;
248
+ try { lines = readFileSync(join(root, rel), "utf8").split(/\r?\n/); } catch { continue; }
249
+ lines.forEach((line, i) => {
250
+ const at = `${relative(cwd, join(root, rel))}:${i + 1}`;
251
+ const task = line.match(TASK_ID);
252
+ if (task) {
253
+ findings.push({ rule: "TIER-DIRECTION", level: "red", detail:
254
+ `${at} names ${task[0]} — a committed file cannot carry a board id. Boards live in ${LOCAL}/ ` +
255
+ "(gitignored) and renumber on every regeneration, so this resolves on the machine that wrote it " +
256
+ "and nowhere else. Cite the use case or the scope_id, which are stable." });
257
+ }
258
+ if (localPath.test(line)) {
259
+ findings.push({ rule: "TIER-DIRECTION", level: "red", detail:
260
+ `${at} points into ${LOCAL}/ — a committed file cannot reference the gitignored tier; the path ` +
261
+ "dangles on every other clone. Name the committed artifact, or describe the tier without a path." });
262
+ }
263
+ });
264
+ }
265
+ return findings;
266
+ }
267
+
268
+ /**
269
+ * Lint the scope contract's anchor into the COMMITTED spec — the direction persisted links flow in.
270
+ *
271
+ * THE MIRROR OF UC-ANCHOR, for the other artifact that has to name what it builds. UC-ANCHOR makes
272
+ * every LOCAL task name a committed use case; nothing made the COMMITTED scope contract name one,
273
+ * and the field it carried instead was a list of LOCAL task ids. That is the exact shape
274
+ * TIER-DIRECTION reds a spec doc for — except TIER-DIRECTION walks wikilinks inside `spec/`, and a
275
+ * contract lives in `scopes/` and holds its pointer in frontmatter, so neither half of the existing
276
+ * rule could see it. Measured before this rule existed: a contract naming `TASK-004`, with no board
277
+ * anywhere in the tree, linted 0 red / 0 warn, and `compile` then wrote a build order carrying no
278
+ * tasks at all and exited 0.
279
+ *
280
+ * `tasks` is red rather than a warn because there is no reading of it that is safe to carry: on the
281
+ * machine that authored it the ids resolve and the contract looks correct, and on every other one
282
+ * they resolve to nothing without a single check going red. A field that is only wrong somewhere
283
+ * else is the kind this repo has been bitten by twice.
284
+ *
285
+ * @param {{scopes:Array<object>, specDir:string}} input - The parsed contracts and the SHARED spec dir.
286
+ * @returns {Array<{rule:string, level:("red"|"warn"), scope:string, detail:string}>} Findings; [] when clean.
287
+ */
288
+ export function lintScopeAnchors({ scopes, specDir: specRoot, reqIds = null, tasks = [] }) {
289
+ const findings = [];
290
+ // SCOPE-PARTITION — dispatch has to assign each task to exactly ONE scope.
291
+ //
292
+ // The UC anchor is a spec link, not an assignment: a use case is routinely implemented by several
293
+ // scopes, which is what a vertical slice IS. On the corpus's four-scope / one-use-case cut every
294
+ // scope claimed every task, so each would build all four and be denied by the sandbox on three of
295
+ // them. Red rather than warn: a dispatch that is not a partition burns the attempt budget of every
296
+ // scope in the cut. The fix is a `scope_id:` on the task (LOCAL naming SHARED, the sanctioned
297
+ // direction) or a re-cut that gives each scope its own use cases.
298
+ for (const c of scopePartitionConflicts(tasks, scopes)) {
299
+ findings.push({ rule: "SCOPE-PARTITION", level: "red", scope: c.scopes.join("+"), detail:
300
+ `${c.task_id} is claimed by ${c.scopes.length} scopes (${c.scopes.join(", ")}) — they share a use case, so the ` +
301
+ "UC anchor cannot say who builds it. Stamp `scope_id:` on the task, or re-cut so each scope owns its own use cases." });
302
+ }
303
+ const ucDir = join(specRoot, "usecases");
304
+ const ucIds = new Set(
305
+ (existsSync(ucDir) ? readdirSync(ucDir) : []).filter((f) => /^UC-.*\.md$/.test(f)).map((f) => f.replace(/\.md$/, "")),
306
+ );
307
+ const ids = new Set(scopes.map((s) => s.scope_id).filter(Boolean));
308
+ for (const s of scopes) {
309
+ const where = s.scope_id || "(unnamed scope)";
310
+ if (Array.isArray(s.tasks) && s.tasks.length) {
311
+ findings.push({
312
+ rule: "TIER-DIRECTION", level: "red", scope: where,
313
+ detail: `contract names LOCAL task ids [${s.tasks.join(", ")}] — a committed contract cannot point into ` +
314
+ `.shapeup/ (gitignored, and boards renumber per machine), so these dangle on every other clone. ` +
315
+ "Anchor with use_cases: [UC-…] instead; the scope's tasks are re-derived from the board's own use_case_refs.",
316
+ });
317
+ }
318
+ const anchors = (s.use_cases || []).map(ucId).filter(Boolean);
319
+ if (!anchors.length) {
320
+ findings.push({
321
+ rule: "SCOPE-ANCHOR", level: "red", scope: where,
322
+ detail: "empty use_cases — every scope must anchor into the committed spec (LOCAL→SHARED, the same " +
323
+ "single-anchor rule tasks follow). Without it nothing can say which tasks, requirements or " +
324
+ "affordances this scope is answerable for.",
325
+ });
326
+ }
327
+ for (const uc of anchors) {
328
+ if (!ucIds.has(uc)) findings.push({ rule: "SCOPE-ANCHOR", level: "red", scope: where, detail: `use_cases "${uc}" does not resolve to usecases/${uc}.md` });
329
+ }
330
+ // `depends_on` carries the build ORDER now that task ids no longer do, so a dangling id is a
331
+ // silently-dropped edge in the scheduler (which fails open by design) — reported here instead.
332
+ for (const d of s.depends_on || []) {
333
+ const id = String(d).trim();
334
+ if (id === s.scope_id) findings.push({ rule: "SCOPE-DEPS", level: "red", scope: where, detail: `depends_on names itself — a scope cannot wait for its own completion` });
335
+ else if (id && !ids.has(id)) findings.push({ rule: "SCOPE-DEPS", level: "red", scope: where, detail: `depends_on "${id}" is not a scope in this run — the scheduler drops the edge, so this scope may build before its dependency` });
336
+ }
337
+ for (const r of s.covers || []) {
338
+ const req = String(r).trim();
339
+ if (!/^REQ-[A-Z0-9-]+$/i.test(req)) {
340
+ findings.push({ rule: "SCOPE-COVERS", level: "warn", scope: where, detail: `covers "${r}" is not a REQ-id — the requirement edge will not resolve` });
341
+ continue;
342
+ }
343
+ // CLOSURE, not just shape. Validating the format alone let a scope claim a requirement that
344
+ // does not exist — the field read as traceability while tracing to nothing. Checked only when
345
+ // a registry is on disk, so a pre-spine spec is unaffected (absent artifact ⇒ arm skipped).
346
+ if (reqIds && !reqIds.has(req.toUpperCase())) {
347
+ findings.push({ rule: "SCOPE-COVERS", level: "red", scope: where, detail: `covers "${req}" is not in requirements.md — a covers: link must resolve to a registered REQ, or the scope claims coverage of nothing` });
348
+ }
349
+ }
350
+ }
351
+
352
+ // SCOPE-DEPS cycles. `scopeWaves` guards a cycle by dumping the remainder into ONE wave and
353
+ // reporting nothing, so a cyclic cut silently degrades to the unscheduled fan-out the scheduler
354
+ // exists to replace. Now that build order lives on the contract, the cycle has to be reported
355
+ // where it can still be fixed.
356
+ for (const cyc of depCycles(scopes)) {
357
+ findings.push({ rule: "SCOPE-DEPS", level: "red", scope: cyc[0], detail:
358
+ `depends_on cycle: ${cyc.join(" → ")} → ${cyc[0]} — no build order satisfies it, so the scheduler drops to a single unordered wave` });
359
+ }
360
+ return findings;
361
+ }
362
+
363
+ /**
364
+ * Every dependency cycle among the scopes, each reported once from its lowest-sorting member.
365
+ * @param {Array<{scope_id:string, depends_on?:string[]}>} scopes - The contracts.
366
+ * @returns {string[][]} One id path per distinct cycle; [] when the relation is acyclic.
367
+ */
368
+ function depCycles(scopes) {
369
+ const deps = new Map(scopes.map((s) => [s.scope_id, (s.depends_on || []).map((d) => String(d).trim())]));
370
+ const seen = new Set();
371
+ const cycles = [];
372
+ for (const start of deps.keys()) {
373
+ const stack = [];
374
+ /**
375
+ * Depth-first walk recording any cycle reached from `start`.
376
+ * @param {string} id - The scope currently being entered.
377
+ * @returns {void}
378
+ */
379
+ const walk = (id) => {
380
+ const at = stack.indexOf(id);
381
+ if (at !== -1) {
382
+ const cyc = stack.slice(at);
383
+ const key = [...cyc].sort().join("|");
384
+ if (!seen.has(key)) { seen.add(key); cycles.push(cyc); }
385
+ return;
386
+ }
387
+ if (!deps.has(id)) return;
388
+ stack.push(id);
389
+ for (const d of deps.get(id)) walk(d);
390
+ stack.pop();
391
+ };
392
+ walk(start);
393
+ }
394
+ return cycles;
395
+ }
396
+
397
+ // Match a heading-like line naming No-gos/Constraints/Edge-cases anywhere in the free-form raw
398
+ // idea, any markdown heading level, case-insensitive.
399
+ const CONSTRAINT_HEADING = /^#{1,6}\s*(no-?gos|constraints|edge[\s-]?cases)\b/im;
400
+
401
+ /**
402
+ * Does the raw idea (intake.md, verbatim and free-form) name an explicit constraints section
403
+ * with real content under it — as opposed to naming the heading and leaving it empty? Only a
404
+ * pitch that actually named constraints obligates the derived spec tree to have derived at
405
+ * least one invariant from them (INV-FLOOR below); a pitch that never raised the topic is not
406
+ * evidence of a thinned tree.
407
+ * @param {string} intakeContent - The raw idea, verbatim (may be "" when no intake.md exists).
408
+ * @returns {boolean} True when a No-gos/Constraints/Edge-cases heading is followed by non-blank,
409
+ * non-comment content before the next heading (or end of file).
410
+ */
411
+ export function intakeNamesConstraints(intakeContent) {
412
+ if (!intakeContent) return false;
413
+ const lines = intakeContent.split(/\r?\n/);
414
+ for (let i = 0; i < lines.length; i++) {
415
+ if (!CONSTRAINT_HEADING.test(lines[i])) continue;
416
+ let body = "";
417
+ for (let j = i + 1; j < lines.length && !/^#{1,6}\s/.test(lines[j]); j++) body += lines[j] + "\n";
418
+ if (body.replace(/<!--[\s\S]*?-->/g, "").trim().length > 0) return true;
419
+ }
420
+ return false;
421
+ }
422
+
423
+ /**
424
+ * Lint spec-tree completeness, wikilink resolution, tier-direction, task frontmatter/graph
425
+ * integrity (edge symmetry, dependency existence), UC-anchor completeness, and the invariant
426
+ * floor against a pitch that named constraints.
427
+ * @param {{specDir:string, tasks:Array<object>, intakeContent?:string}} input - The SHARED spec
428
+ * dir, the parsed board, and the raw idea's verbatim text (intake.md; "" when absent — a run
429
+ * with no intake on disk cannot be checked against it, so INV-FLOOR simply cannot fire).
430
+ * @returns {Array<{rule:string, level:("red"|"warn"), detail:string}>} Findings; [] when clean.
431
+ */
432
+ export function lintStructure({ specDir, tasks, intakeContent = "" }) {
433
+ const findings = [];
434
+ const ucDir = join(specDir, "usecases");
435
+ if (!existsSync(join(specDir, "domain-model.md"))) findings.push({ rule: "STRUCTURE", level: "red", detail: "domain-model.md missing" });
436
+ const ucs = existsSync(ucDir) ? readdirSync(ucDir).filter((f) => /^UC-.*\.md$/.test(f)) : [];
437
+ if (!ucs.length) findings.push({ rule: "STRUCTURE", level: "red", detail: "usecases/ has no UC-*.md — nothing to build or grade against" });
438
+ let anyInvariant = false;
439
+ for (const f of ucs) {
440
+ const body = readFileSync(join(ucDir, f), "utf8");
441
+ if (!/^##\s+Steps/m.test(body)) findings.push({ rule: "STRUCTURE", level: "warn", detail: `${f} has no ## Steps section` });
442
+ if (/\[INV-\d+\]/.test(body)) anyInvariant = true;
443
+ }
444
+ // INV-FLOOR — a criteria-count check can't tell a healthy small tree from one that derived
445
+ // nothing from the pitch. Only fire when the pitch itself named constraints: a pitch that
446
+ // never raised the topic is not evidence of a thinned tree.
447
+ if (!anyInvariant && intakeNamesConstraints(intakeContent)) {
448
+ findings.push({ rule: "INV-FLOOR", level: "red", detail: "intake.md names explicit constraints (a No-gos/Constraints/Edge-cases section with content) but no usecases/UC-*.md declares a single [INV-NN] anywhere — the spec tree derived nothing from the pitch's own constraints" });
449
+ }
450
+ // Wikilinks in spec docs must resolve within the spec dir — and never cross the tier
451
+ // boundary: a SHARED doc linking the LOCAL board is the wrong direction by construction.
452
+ const specFiles = existsSync(specDir) ? walkFiles(specDir) : [];
453
+ const names = new Set(specFiles.map((f) => f.replace(/\.md$/, "")));
454
+ for (const f of specFiles.filter((x) => x.endsWith(".md"))) {
455
+ const body = readFileSync(join(specDir, f), "utf8");
456
+ for (const m of body.matchAll(/\[\[([^\]#|]+)/g)) {
457
+ const target = m[1].trim().replace(/\.md$/, "");
458
+ if (target.startsWith("tasks/")) {
459
+ findings.push({ rule: "TIER-DIRECTION", level: "red", detail: `${f} → [[${m[1].trim()}]] links the LOCAL board from a committed doc — links flow LOCAL→SHARED only; cite the UC or scope_id instead (task ids renumber per machine)` });
460
+ continue;
461
+ }
462
+ if (!names.has(target) && ![...names].some((n) => n.endsWith(`/${target}`) || n === target)) {
463
+ findings.push({ rule: "WIKILINK", level: "warn", detail: `${f} → [[${m[1].trim()}]] unresolved in spec dir` });
464
+ }
465
+ }
466
+ }
467
+ // Task frontmatter + graph integrity (edge symmetry — the hand-authored-unlocks drift, mechanized).
468
+ const ids = new Set(tasks.map((t) => t.id));
469
+ const derived = deriveUnlocks(tasks);
470
+ for (const t of tasks) {
471
+ for (const k of ["id", "status"]) if (!t[k] || t[k] === "unknown") findings.push({ rule: "TASK", level: "red", detail: `${t.file} missing frontmatter ${k}` });
472
+ for (const d of t.depends_on) if (!ids.has(d)) findings.push({ rule: "TASK", level: "red", detail: `${t.id} depends_on ${d} which does not exist` });
473
+ if (JSON.stringify([...t.unlocks].sort()) !== JSON.stringify(derived[t.id] || [])) {
474
+ findings.push({ rule: "EDGE-SYMMETRY", level: "red", detail: `${t.id} unlocks ${JSON.stringify(t.unlocks)} ≠ derived inverse ${JSON.stringify(derived[t.id])} — run harness reduce board --write` });
475
+ }
476
+ }
477
+ // UC-ANCHOR — the LOCAL→SHARED anchor must be complete: every implementation task names
478
+ // ≥1 UC (single-anchor rule, task-generation.md) and each named UC exists on disk.
479
+ // SPIKE/CHORE/DOCS/MIGRATION tasks anchor elsewhere (api_ref / linked_docs) — exempt.
480
+ const ucIds = new Set(ucs.map((f) => f.replace(/\.md$/, "")));
481
+ const anchorExempt = new Set(["spike", "chore", "docs", "migration"]);
482
+ for (const t of tasks) {
483
+ if (anchorExempt.has((t.type || "").toLowerCase())) continue;
484
+ const refs = t.use_case_refs || [];
485
+ if (!refs.length) {
486
+ findings.push({ rule: "UC-ANCHOR", level: "red", detail: `${t.id} has empty use_case_refs — every task must anchor into the committed spec (LOCAL→SHARED, single-anchor rule)` });
487
+ continue;
488
+ }
489
+ for (const r of refs) {
490
+ const uc = ucId(r);
491
+ if (!ucIds.has(uc)) findings.push({ rule: "UC-ANCHOR", level: "red", detail: `${t.id} use_case_refs "${r}" does not resolve to usecases/${uc}.md` });
492
+ }
493
+ }
494
+ return findings;
495
+ }
496
+
497
+ /**
498
+ * Run the full spec lint (scopes + structure) for a slug.
499
+ * @param {{cwd:string, slug:string}} opts - Working root and feature slug.
500
+ * @returns {{slug:string, scopes:number, tasks:number, red:number, warn:number,
501
+ * findings:Array<object>}} Counts and the combined findings from {@link lintScopes} and
502
+ * {@link lintStructure}.
503
+ */
504
+ export function lint({ cwd, slug }) {
505
+ const specRoot = specDir(cwd, slug);
506
+ const contracts = readAllContracts(scopesDir(cwd, slug), SCOPE_CONTRACT);
507
+ const scopes = contracts.map((c) => c.contract);
508
+ const tasks = parseBoard(tasksDir(cwd, slug));
509
+ const intakePath = intake(cwd, slug);
510
+ const intakeContent = existsSync(intakePath) ? readFileSync(intakePath, "utf8") : "";
511
+ // The REQ registry, when the tree has one — absent means covers-closure simply cannot apply.
512
+ const reqFile = requirements(cwd, slug);
513
+ const reqIds = existsSync(reqFile)
514
+ ? new Set([...readFileSync(reqFile, "utf8").matchAll(/\bREQ-[A-Z0-9-]+/gi)].map((m) => m[0].toUpperCase()))
515
+ : null;
516
+ const repoFiles = walkFiles(cwd);
517
+ const findings = [
518
+ // A contract whose table this parser cannot see reads as a contract that declared no
519
+ // table, and every rule below then passes for the part it could not read. Loud, not empty.
520
+ ...contracts
521
+ .map(({ contract, path }) => ({ reason: unreadableReason(contract), scope: contract.scope_id || path }))
522
+ .filter((x) => x.reason)
523
+ .map((x) => ({ rule: "CONTRACT-UNREADABLE", level: "red", scope: x.scope, detail: `${x.reason} — the rules below could not check what they could not read` })),
524
+ ...lintScopes(scopes, repoFiles),
525
+ ...lintScopeAnchors({ scopes, specDir: specRoot, reqIds, tasks }),
526
+ ...lintCommittedTier({ cwd, slug }),
527
+ ...lintStructure({ specDir: specRoot, tasks, intakeContent }),
528
+ ];
529
+ return {
530
+ slug,
531
+ scopes: scopes.length,
532
+ tasks: tasks.length,
533
+ red: findings.filter((f) => f.level === "red").length,
534
+ warn: findings.filter((f) => f.level === "warn").length,
535
+ findings,
536
+ };
537
+ }
538
+
539
+ /** The typed argv contract (see `kernel/lib/argv.mjs`). */
540
+ export const ARGV_SPEC = {
541
+ usage: "harness.mjs verify spec --slug <slug> [--cwd <dir>]",
542
+ _: { arity: 0, max: 0, name: "(no positional operands)" },
543
+ slug: { type: "str", required: true },
544
+ cwd: { type: "path" },
545
+ };
546
+
547
+ /**
548
+ * Lint the committed spec tree against the board it must stay in step with.
549
+ *
550
+ * @param {string[]} rawArgv - The subcommand's own arguments (harness.mjs strips the verb words).
551
+ * @returns {(Promise<void>|void)} Settles when the subcommand has written its output; most paths
552
+ * call `process.exit()` with the subcommand's documented code rather than returning.
553
+ */
554
+ export async function cli(rawArgv) {
555
+ const args = runArgs(ARGV_SPEC, rawArgv);
556
+ const report = lint({ cwd: resolve(args.cwd || process.cwd()), slug: args.slug });
557
+ console.log(JSON.stringify(report, null, 2));
558
+ process.exit(report.red > 0 ? 1 : 0);
559
+ }