shapeup-sdlc 1.6.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 (135) hide show
  1. package/.claude/settings.local.example.json +20 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +18 -0
  4. package/.env.shapeup.example +14 -0
  5. package/AGENTS.md +133 -0
  6. package/LICENSE +21 -0
  7. package/README.md +362 -0
  8. package/SECURITY.md +72 -0
  9. package/bin/init.mjs +329 -0
  10. package/commands/build.md +14 -0
  11. package/commands/eval.md +15 -0
  12. package/commands/hammer.md +12 -0
  13. package/commands/orient.md +11 -0
  14. package/commands/qa.md +12 -0
  15. package/commands/retro.md +13 -0
  16. package/commands/scopes.md +14 -0
  17. package/commands/shape.md +12 -0
  18. package/commands/ship.md +53 -0
  19. package/commands/wire.md +11 -0
  20. package/hooks/anti-rationalization.mjs +244 -0
  21. package/hooks/compact-snapshot.mjs +47 -0
  22. package/hooks/gate-deadline.mjs +151 -0
  23. package/hooks/gate-intake.mjs +110 -0
  24. package/hooks/gate-l2.mjs +161 -0
  25. package/hooks/gate-zerowork.mjs +264 -0
  26. package/hooks/hooks.json +118 -0
  27. package/hooks/lib/decision.mjs +183 -0
  28. package/hooks/safety-spine.mjs +296 -0
  29. package/hooks/sandbox-guard.mjs +172 -0
  30. package/hooks/session-rehydrate.mjs +109 -0
  31. package/hooks/slop-cleaner.mjs +176 -0
  32. package/oracles/_shared.mjs +46 -0
  33. package/oracles/http-oracle.mjs +155 -0
  34. package/oracles/index.mjs +36 -0
  35. package/oracles/process-oracle.mjs +146 -0
  36. package/oracles/snapshot-oracle.mjs +119 -0
  37. package/oracles/test-oracle.mjs +138 -0
  38. package/package.json +49 -0
  39. package/skills/advisor-protocol/SKILL.md +171 -0
  40. package/skills/ba-pitch-analyzer/SKILL.md +175 -0
  41. package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +84 -0
  42. package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +65 -0
  43. package/skills/ba-pitch-analyzer/assets/templates/assess-report.tmpl.md +127 -0
  44. package/skills/ba-pitch-analyzer/assets/templates/contracts/be-service.contract.tmpl.md +62 -0
  45. package/skills/ba-pitch-analyzer/assets/templates/contracts/offline-storage.contract.tmpl.md +92 -0
  46. package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +66 -0
  47. package/skills/ba-pitch-analyzer/assets/templates/cross-context/context-map.tmpl.md +64 -0
  48. package/skills/ba-pitch-analyzer/assets/templates/cross-context/event-choreography.tmpl.md +77 -0
  49. package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +104 -0
  50. package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +79 -0
  51. package/skills/ba-pitch-analyzer/assets/templates/domain-model.tmpl.md +79 -0
  52. package/skills/ba-pitch-analyzer/assets/templates/feedback.tmpl.md +87 -0
  53. package/skills/ba-pitch-analyzer/assets/templates/integration.tmpl.md +64 -0
  54. package/skills/ba-pitch-analyzer/assets/templates/run-state.tmpl.md +77 -0
  55. package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +105 -0
  56. package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +215 -0
  57. package/skills/ba-pitch-analyzer/assets/templates/task-board.tmpl.md +35 -0
  58. package/skills/ba-pitch-analyzer/assets/templates/task-spike.tmpl.md +86 -0
  59. package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +84 -0
  60. package/skills/ba-pitch-analyzer/assets/templates/usecase.tmpl.md +105 -0
  61. package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +65 -0
  62. package/skills/ba-pitch-analyzer/references/contract-patterns.md +152 -0
  63. package/skills/ba-pitch-analyzer/references/ddd-patterns.md +173 -0
  64. package/skills/ba-pitch-analyzer/references/doc-schemas.md +275 -0
  65. package/skills/ba-pitch-analyzer/references/integration-analysis.md +152 -0
  66. package/skills/ba-pitch-analyzer/references/task-generation.md +623 -0
  67. package/skills/ba-pitch-analyzer/references/test-surface.md +102 -0
  68. package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +269 -0
  69. package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +241 -0
  70. package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +239 -0
  71. package/skills/coach/SKILL.md +202 -0
  72. package/skills/orient/SKILL.md +262 -0
  73. package/skills/qa-edge-hunter/SKILL.md +369 -0
  74. package/skills/scope-architect/SKILL.md +111 -0
  75. package/skills/scope-hammer/SKILL.md +186 -0
  76. package/skills/shapeup/SKILL.md +403 -0
  77. package/skills/shapeup/resources/breadboard-reflection.md +88 -0
  78. package/skills/shapeup/resources/breadboarding.md +334 -0
  79. package/skills/shapeup/resources/context-compaction.md +240 -0
  80. package/skills/shapeup/resources/framing-doc.md +92 -0
  81. package/skills/shapeup/resources/kickoff-doc.md +95 -0
  82. package/skills/shapeup/resources/shaping.md +194 -0
  83. package/skills/shapeup/resources/spike.md +86 -0
  84. package/skills/solution-architect/SKILL.md +129 -0
  85. package/skills/spec-evaluator/README.md +93 -0
  86. package/skills/spec-evaluator/SKILL.md +213 -0
  87. package/skills/spec-evaluator/references/anti-leniency.md +50 -0
  88. package/skills/spec-evaluator/references/dimension-contract.md +126 -0
  89. package/skills/spec-evaluator/references/dimensions/_registry.md +40 -0
  90. package/skills/spec-evaluator/references/dimensions/completeness.md +102 -0
  91. package/skills/spec-evaluator/references/dimensions/integration.md +129 -0
  92. package/skills/spec-evaluator/references/dimensions/performance.md +48 -0
  93. package/skills/spec-evaluator/references/dimensions/security.md +60 -0
  94. package/skills/spec-evaluator/references/dimensions/spec-conformance.md +134 -0
  95. package/skills/spec-evaluator/references/dimensions/tdd-surface.md +110 -0
  96. package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +100 -0
  97. package/skills/spec-evaluator/references/probing.md +177 -0
  98. package/skills/spec-evaluator/references/report-schema.md +101 -0
  99. package/skills/spec-evaluator/references/verdict-ledger.md +92 -0
  100. package/skills/spec-evaluator/scripts/verdict-ledger.mjs +166 -0
  101. package/skills/task-executor/SKILL.md +194 -0
  102. package/skills/tech-lead/README.md +71 -0
  103. package/skills/tech-lead/SKILL.md +459 -0
  104. package/skills/tech-lead/references/delegation.md +254 -0
  105. package/skills/tech-lead/references/gates.md +379 -0
  106. package/skills/tech-lead/references/invocation.md +45 -0
  107. package/skills/tech-lead/references/ledger-schema.md +214 -0
  108. package/skills/tech-lead/references/round-protocol.md +184 -0
  109. package/skills/tech-lead/references/state-model.md +66 -0
  110. package/skills/tech-lead/references/tiny-lane.md +52 -0
  111. package/skills/tech-lead/schemas/domain.schema.json +2294 -0
  112. package/skills/tech-lead/schemas/gate-answers.schema.json +92 -0
  113. package/skills/tech-lead/schemas/work-order.schema.json +21 -0
  114. package/skills/tech-lead/schemas/work-result.schema.json +40 -0
  115. package/skills/tech-lead/scripts/aegis-digest.mjs +124 -0
  116. package/skills/tech-lead/scripts/budget-check.mjs +156 -0
  117. package/skills/tech-lead/scripts/compile-order.mjs +518 -0
  118. package/skills/tech-lead/scripts/fit-check.mjs +196 -0
  119. package/skills/tech-lead/scripts/gate-answers.mjs +338 -0
  120. package/skills/tech-lead/scripts/ingest-result.mjs +270 -0
  121. package/skills/tech-lead/scripts/init-run.mjs +326 -0
  122. package/skills/tech-lead/scripts/lib/argv.mjs +224 -0
  123. package/skills/tech-lead/scripts/lib/contract-md.mjs +481 -0
  124. package/skills/tech-lead/scripts/lib/is-main.mjs +82 -0
  125. package/skills/tech-lead/scripts/lib/paths.mjs +278 -0
  126. package/skills/tech-lead/scripts/lib/ratchet-tree.mjs +112 -0
  127. package/skills/tech-lead/scripts/run-snapshot.mjs +269 -0
  128. package/skills/tech-lead/scripts/ship-report.mjs +275 -0
  129. package/skills/tech-lead/scripts/stats.mjs +423 -0
  130. package/skills/tech-lead/scripts/t0-verify.mjs +470 -0
  131. package/skills/tech-lead/scripts/trace-lint.mjs +367 -0
  132. package/skills/tech-lead/scripts/validate-envelope.mjs +283 -0
  133. package/skills/translator/README.md +66 -0
  134. package/skills/translator/SKILL.md +258 -0
  135. package/skills/translator/references/preservation-rules.md +102 -0
@@ -0,0 +1,481 @@
1
+ // contract-md — read and write the committed contracts as markdown.
2
+ //
3
+ // WHY (ADR-0001). Scope contracts, the wiring map and the project profile are *low-level design*:
4
+ // which files a slice may touch, which seam each use case attaches to, where the app starts. A
5
+ // teammate should be able to read them in a pull request. As `.json` they were machine artifacts
6
+ // sitting in the tier meant for prose, and nobody read them.
7
+ //
8
+ // THE FORMAT, and why it is not YAML. Every script in this repo holds a zero-dependency rule, and
9
+ // a real YAML parser is a dependency. But the shapes barely need one: `ProjectProfile` is four
10
+ // scalars, and `ScopeContract`/`WiringMap` are scalars and string arrays plus exactly ONE
11
+ // array-of-objects each. So:
12
+ //
13
+ // • scalars and string arrays → frontmatter, in the `key: value` / `key: [a, b]` dialect the
14
+ // repo already parses in `compile-order.frontmatter`;
15
+ // • arrays of objects → markdown TABLES, which `trace-lint.parseRequirements` and
16
+ // `compile-order.ledgerDecisions` already demonstrate reading;
17
+ // • everything else in the file is prose, and is preserved on round-trip.
18
+ //
19
+ // Both parsers existed before this file; what is new is putting them behind one contract so a
20
+ // reader and a writer cannot disagree about the dialect.
21
+ //
22
+ // THE BOUNDARY THAT MATTERS: **markdown is the on-disk format, JSON is the wire format.**
23
+ // `compile-order` parses a contract off disk and embeds the resulting OBJECT in
24
+ // `payload.scope_contract`, so the WorkOrder envelope, its schema and `validate-envelope` are
25
+ // completely unchanged. Nothing downstream of the parse knows the file was markdown.
26
+ //
27
+ // A CONSEQUENCE WORTH STATING: these files are now hand-editable, where they used to be
28
+ // machine-written and schema-validated on every write. `spec-lint` re-validates every parsed
29
+ // contract against `domain.schema.json`, so a hand-edit that breaks the shape fails loudly
30
+ // instead of silently widening a sandbox.
31
+ //
32
+ // Zero dependencies, zero network.
33
+
34
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
35
+ import { join } from "node:path";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Per-type layout: which array-of-objects field lives under which heading.
39
+ //
40
+ // `signatures` names the columns that identify a table as THAT field's, and it exists because of
41
+ // HD-001: the heading match is exact, so a table written under `# Wiring map — <slug>` instead of
42
+ // `## Wiring` parsed as ABSENT, and every reader downstream treats absent as "none declared". The
43
+ // observed consequence was `trace-lint` reporting `🟢 green · 0/0 engines reach src/cli/main.js`
44
+ // for a committed map holding six correct rows — the gate whose whole purpose is that no engine
45
+ // ships orphaned, failing OPEN.
46
+ //
47
+ // A signature makes the difference detectable without loosening the format: a table carrying
48
+ // `use_case` AND `engine` columns is the wiring table wherever it was put, so finding one under a
49
+ // heading this spec does not claim is a PARSE FAILURE, not an empty field. Prose tables are
50
+ // unaffected — a comparison table under "Why this slice" does not carry these columns.
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** `ScopeContract` — the substrate whitelist and fixtures for one vertical slice. */
54
+ export const SCOPE_CONTRACT = {
55
+ tables: { affordance_manifest: "Affordances" },
56
+ signatures: { affordance_manifest: ["test_id", "role"] },
57
+ };
58
+
59
+ /** `WiringMap` — per use case: engine → seam → entry-point call site → affordance. */
60
+ export const WIRING_MAP = {
61
+ tables: { entries: "Wiring" },
62
+ signatures: { entries: ["use_case", "engine"] },
63
+ };
64
+
65
+ /** `ProjectProfile` — archetype + entry point. All scalars; no tables. */
66
+ export const PROJECT_PROFILE = { tables: {} };
67
+
68
+ /**
69
+ * The key an unreadable-table diagnostic is attached under.
70
+ *
71
+ * Non-enumerable would be tidier, but the object is JSON-serialised into the WorkOrder envelope and
72
+ * a non-enumerable property would vanish there — silently, which is the failure mode being fixed.
73
+ * The `$` prefix keeps it out of collision with any schema field; readers strip it before validating.
74
+ */
75
+ export const UNREADABLE = "$unreadable_tables";
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Scalars
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Coerce one frontmatter or table-cell value.
83
+ *
84
+ * ONE RULE IN BOTH PLACES, deliberately: a value wrapped in `[...]` is a list of strings,
85
+ * anything else is a scalar. `required_states` inside an affordance row and
86
+ * `allowed_file_substrate` in the frontmatter are then written the same way, so an author never
87
+ * has to remember which context they are in.
88
+ *
89
+ * @param {string} raw - The raw text.
90
+ * @returns {(string|number|boolean|string[])} The coerced value.
91
+ */
92
+ export function coerce(raw) {
93
+ let trimmed = String(raw ?? "").trim();
94
+ // HD-005 — A MARKDOWN CODE SPAN IS FORMATTING, NOT PART OF THE VALUE.
95
+ //
96
+ // These contracts are MARKDOWN on disk, and a code span is the idiomatic way to write a path or
97
+ // an identifier in one; this repo's own prose backticks every path it names. Read literally,
98
+ // `` `src/capture/add.js` `` is a filename with two backticks in it, which is on no disk
99
+ // anywhere — so `trace-lint` reported "engine file not on disk" and then "reachability is not
100
+ // demonstrated" for a wiring map whose engines all resolve AND all reach the entry point. The
101
+ // gate that exists so no engine ships orphaned failing CLOSED, on a correct map, is the same
102
+ // silent-format family as HD-001..HD-004 arriving from the other direction.
103
+ //
104
+ // Stripped ONLY when the whole value is a single span: a cell like
105
+ // "Registered as the `add` entry in `TABLE`" is prose that happens to contain spans, and its
106
+ // value is the prose. The inner text may not itself contain a backtick, so `` `a` and `b` ``
107
+ // is left alone rather than being spliced into one nonsense token.
108
+ const span = trimmed.match(/^(`{1,3})([\s\S]+)\1$/);
109
+ if (span && !span[2].includes("`")) trimmed = span[2].trim();
110
+ // A LIST IS TESTED BEFORE THE QUOTES ARE STRIPPED. `"[a, b]"` is a quoted STRING; stripping first
111
+ // would turn it into a list and change its type on a round-trip.
112
+ if (/^\[.*\]$/.test(trimmed)) return splitList(trimmed.slice(1, -1));
113
+ const v = trimmed.replace(/^["']|["']$/g, "");
114
+ if (v === "true") return true;
115
+ if (v === "false") return false;
116
+ if (v === "~" || v === "null" || v === "") return null;
117
+ if (/^-?\d+$/.test(v)) return Number(v);
118
+ return v;
119
+ }
120
+
121
+ /**
122
+ * Render a value back into the frontmatter/cell dialect `coerce` reads.
123
+ * @param {*} v - The value.
124
+ * @returns {string} Its textual form; `null`/`undefined` become `~`.
125
+ */
126
+ export function uncoerce(v) {
127
+ if (v === null || v === undefined) return "~";
128
+ // HD-002's other half. A member containing the delimiter must go back out QUOTED, or the round
129
+ // trip that wrote it re-reads as several members — the same shredding, arriving from the writer's
130
+ // side instead of the reader's.
131
+ if (Array.isArray(v)) return `[${v.map((x) => (/[,"]/.test(String(x)) ? JSON.stringify(String(x)) : String(x))).join(", ")}]`;
132
+ return String(v);
133
+ }
134
+
135
+ /**
136
+ * Split a `[a, b]` list body on commas that are NOT inside quotes.
137
+ *
138
+ * HD-002. The old implementation was `body.split(",")`, and it shredded any member carrying a
139
+ * comma — even a correctly quoted one. Measured: a `scope-architect` run probed the running CLI,
140
+ * confirmed `tag` was unimplemented, and wrote the honest entry its own SKILL.md asks for —
141
+ * ["TBD — `tag` is not in dispatch.js's TABLE (exits 1, confirmed against the running CLI). A
142
+ * fixture asserting the spec'd behaviour (attach/remove a tag, idempotent double-tag) can only
143
+ * be written once the command exists."]
144
+ * — and the parser turned that one string into FOUR list members. Three of them are prose, and
145
+ * `t0-verify` executes this field, so the run would have tried to spawn `idempotent double-tag)`.
146
+ * A worker doing exactly what its contract asks, mangled on the way in.
147
+ *
148
+ * @param {string} body - The text between the brackets.
149
+ * @returns {string[]} Members, unquoted and trimmed; empty members are dropped.
150
+ */
151
+ function splitList(body) {
152
+ const out = [];
153
+ let cur = "", q = null;
154
+ for (let i = 0; i < body.length; i++) {
155
+ const c = body[i];
156
+ if (q) {
157
+ if (c === "\\" && body[i + 1] === q) { cur += body[++i]; continue; }
158
+ if (c === q) { q = null; continue; }
159
+ cur += c;
160
+ } else if (c === '"' || c === "'") {
161
+ q = c;
162
+ } else if (c === ",") {
163
+ out.push(cur.trim()); cur = "";
164
+ } else cur += c;
165
+ }
166
+ out.push(cur.trim());
167
+ return out.filter(Boolean);
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Frontmatter
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Split a document into its frontmatter map and the body after it.
176
+ * @param {string} md - Full markdown text.
177
+ * @returns {{meta:Object<string,*>, body:string}} Parsed scalars/lists and the remaining body
178
+ * ({}/whole document when there is no leading `---` block).
179
+ */
180
+ export function splitFrontmatter(md) {
181
+ const m = String(md ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
182
+ if (!m) return { meta: {}, body: String(md ?? "") };
183
+ const meta = {};
184
+ const unreadable = [];
185
+ const lines = m[1].split(/\r?\n/);
186
+ for (let i = 0; i < lines.length; i++) {
187
+ const line = lines[i];
188
+ if (/^\s/.test(line) || !line.trim()) continue; // continuation lines are consumed below
189
+ const c = line.indexOf(":");
190
+ if (c === -1) continue;
191
+ const key = line.slice(0, c).trim();
192
+ const inline = line.slice(c + 1).trim();
193
+
194
+ // HD-003. An indented run beneath a key is a YAML BLOCK SEQUENCE, and it used to be skipped
195
+ // entirely — so `e2e_verification_fixtures:` followed by two `- "node …"` lines parsed to
196
+ // null, the members vanished, and no reader could tell "declared nothing" from "declared
197
+ // something I discarded". Measured: a scope-architect run wrote three scopes of researched
198
+ // fixtures in this form and every one evaporated. It is ACCEPTED now, because it is the form a
199
+ // model reaches for by default and the one that carries prose members without HD-002's quoting
200
+ // problem — and anything indented that is NOT a block sequence is reported rather than dropped.
201
+ const block = [];
202
+ let stray = 0;
203
+ let j = i + 1;
204
+ for (; j < lines.length && (/^\s/.test(lines[j]) || !lines[j].trim()); j++) {
205
+ const t = lines[j].trim();
206
+ if (!t) continue;
207
+ if (t.startsWith("- ") || t === "-") block.push(t.replace(/^-\s*/, ""));
208
+ else stray++;
209
+ }
210
+ i = j - 1;
211
+
212
+ if (inline) {
213
+ meta[key] = coerce(inline);
214
+ if (block.length || stray) unreadable.push({ field: key, expected_heading: "an inline value OR an indented block, not both", found_under: "both", rows: block.length + stray });
215
+ continue;
216
+ }
217
+ if (block.length) {
218
+ meta[key] = block.map((v) => String(coerce(v)));
219
+ } else if (stray) {
220
+ meta[key] = null;
221
+ unreadable.push({ field: key, expected_heading: `${key}: [a, b] (or an indented \`- item\` list)`, found_under: "an indented block this dialect cannot read", rows: stray });
222
+ } else {
223
+ meta[key] = coerce(inline);
224
+ }
225
+ }
226
+ if (unreadable.length) meta[UNREADABLE] = unreadable;
227
+ return { meta, body: m[2] };
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Tables
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /**
235
+ * Split one table row into cells, honouring `\|` as a literal pipe.
236
+ *
237
+ * `renderTable` escapes a `|` inside a value because an unescaped one would end the cell and shift
238
+ * every column after it. A splitter that ignored the escape made the write side lossy in exactly
239
+ * the case the escape existed for — the value came back truncated at the backslash, silently, with
240
+ * the row still the right width. Escaping and unescaping have to be the same commit.
241
+ *
242
+ * @param {string} line - A trimmed row beginning with `|`.
243
+ * @returns {string[]} The trimmed cells, outer delimiters dropped, `\|` restored to `|`.
244
+ */
245
+ export function splitRow(line) {
246
+ const cells = [];
247
+ let cur = "";
248
+ for (let i = 0; i < line.length; i++) {
249
+ const c = line[i];
250
+ if (c === "\\" && line[i + 1] === "|") { cur += "|"; i++; continue; }
251
+ if (c === "|") { cells.push(cur); cur = ""; continue; }
252
+ cur += c;
253
+ }
254
+ cells.push(cur);
255
+ return cells.slice(1, -1).map((c) => c.trim()); // drop the empties outside the outer pipes
256
+ }
257
+
258
+ /**
259
+ * Read every markdown table in a body, keyed by the heading immediately above it.
260
+ * @param {string} body - Markdown after the frontmatter.
261
+ * @returns {Object<string, Array<Object<string,*>>>} heading text → row objects, columns named by
262
+ * the table's own header row (so adding a schema field needs no parser change).
263
+ */
264
+ export function parseTables(body) {
265
+ const out = {};
266
+ let heading = null;
267
+ let header = null;
268
+ for (const raw of String(body ?? "").split(/\r?\n/)) {
269
+ const line = raw.trim();
270
+ const h = line.match(/^#{1,6}\s+(.*)$/);
271
+ if (h) { heading = h[1].trim(); header = null; continue; }
272
+ if (!line.startsWith("|")) { if (!line) header = null; continue; }
273
+
274
+ const cells = splitRow(line);
275
+ if (!cells.length) continue;
276
+ if (cells.every((c) => /^:?-{2,}:?$/.test(c))) continue; // separator row
277
+ if (!header) { header = cells.map((c) => c.toLowerCase().replace(/\s+/g, "_")); continue; }
278
+ if (!heading) continue;
279
+
280
+ const row = {};
281
+ header.forEach((k, i) => {
282
+ const v = coerce(cells[i] ?? "");
283
+ if (v !== null && v !== "") row[k] = v;
284
+ });
285
+ (out[heading] ||= []).push(row);
286
+ }
287
+ return out;
288
+ }
289
+
290
+ /**
291
+ * Render rows as a markdown table.
292
+ * @param {Array<Object>} rows - Row objects.
293
+ * @param {string[]} [columns] - Column order; defaults to the union of keys in first-seen order.
294
+ * @returns {string} The table, or "" when there are no rows.
295
+ */
296
+ export function renderTable(rows, columns) {
297
+ if (!rows?.length) return "";
298
+ const cols = columns?.length ? columns : [...new Set(rows.flatMap((r) => Object.keys(r)))];
299
+ // A literal `|` inside a cell would end it; escaping is the only way a value survives.
300
+ const cell = (v) => uncoerce(v).replace(/\|/g, "\\|");
301
+ return [
302
+ `| ${cols.join(" | ")} |`,
303
+ `|${cols.map(() => "---").join("|")}|`,
304
+ ...rows.map((r) => `| ${cols.map((c) => (c in r ? cell(r[c]) : "")).join(" | ")} |`),
305
+ ].join("\n");
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // The contract API
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /**
313
+ * Parse a markdown contract into the object the envelope carries.
314
+ * @param {string} md - The contract file's text.
315
+ * @param {{tables:Object<string,string>}} [spec=SCOPE_CONTRACT] - Field → heading map for the
316
+ * arrays of objects (see SCOPE_CONTRACT / WIRING_MAP / PROJECT_PROFILE).
317
+ * @returns {Object} The contract object: frontmatter scalars/lists plus one array per table.
318
+ * A declared table with no matching heading is simply absent, never `[]` — an empty array and
319
+ * an undeclared field mean different things to the schema.
320
+ */
321
+ export function parseContract(md, spec = SCOPE_CONTRACT) {
322
+ const { meta, body } = splitFrontmatter(md);
323
+ const tables = parseTables(body);
324
+ const out = { ...meta };
325
+ // Frontmatter-level diagnostics (HD-003) and table-level ones (HD-001) share one channel, so a
326
+ // reader asks `unreadableReason()` once and cannot check for one while missing the other.
327
+ const unreadable = [...(meta[UNREADABLE] || [])];
328
+ delete out[UNREADABLE];
329
+ for (const [field, heading] of Object.entries(spec.tables || {})) {
330
+ if (tables[heading]) { out[field] = tables[heading]; continue; }
331
+ // HD-001. The field is absent — but is it absent because nobody declared it, or because the
332
+ // author declared it somewhere this parser does not look? Those are opposite facts and the
333
+ // old code returned the same thing for both. A table carrying this field's signature columns,
334
+ // under a heading the spec does not claim, is the second case.
335
+ const sig = (spec.signatures || {})[field];
336
+ if (!sig) continue;
337
+ for (const [seen, rows] of Object.entries(tables)) {
338
+ if (seen === heading || !rows.length) continue;
339
+ const cols = new Set(Object.keys(rows[0]));
340
+ if (sig.every((c) => cols.has(c))) {
341
+ unreadable.push({ field, expected_heading: heading, found_under: seen, rows: rows.length });
342
+ break;
343
+ }
344
+ }
345
+ }
346
+ if (unreadable.length) out[UNREADABLE] = unreadable;
347
+ return out;
348
+ }
349
+
350
+ /**
351
+ * The one-line reason a contract could not be read, or null when it read cleanly.
352
+ *
353
+ * Exported so every consumer asks the same question the same way. A reader that skips this is
354
+ * back to treating "I could not see your table" as "you declared no table", which is HD-001.
355
+ * @param {Object|null} contract - A parsed contract (or a `readContract()` result's `.contract`).
356
+ * @returns {string|null} A human-readable failure, or null if the contract parsed cleanly.
357
+ */
358
+ export function unreadableReason(contract) {
359
+ const u = contract && contract[UNREADABLE];
360
+ if (!u || !u.length) return null;
361
+ return u
362
+ .map((x) => (x.found_under === "an indented block this dialect cannot read" || x.found_under === "both"
363
+ ? `\`${x.field}\` was written as \`${x.expected_heading}\` but ${x.rows} indented line(s) beneath it could not be read, so the value parsed as ABSENT`
364
+ : `\`${x.field}\` must be a table under a \`## ${x.expected_heading}\` heading; found ${x.rows} matching row(s) under "${x.found_under}" instead, so the field parsed as ABSENT`))
365
+ .join("; ");
366
+ }
367
+
368
+ /**
369
+ * Render a contract object as markdown.
370
+ *
371
+ * Prose is preserved: pass the previous file's text as `existing` and any narrative under a
372
+ * heading this spec does not own survives the round-trip. A generator that silently deleted a
373
+ * teammate's "Why this slice" paragraph on every regeneration would make the format worse than
374
+ * the JSON it replaced.
375
+ *
376
+ * @param {Object} obj - The contract object.
377
+ * @param {{tables:Object<string,string>}} [spec=SCOPE_CONTRACT] - Field → heading map.
378
+ * @param {{existing?:string, title?:string}} [opts] - existing: prior file text to keep prose from;
379
+ * title: an `# ` heading for a new file.
380
+ * @returns {string} The markdown document, newline-terminated.
381
+ */
382
+ export function renderContract(obj, spec = SCOPE_CONTRACT, opts = {}) {
383
+ const tableFields = new Set(Object.keys(spec.tables || {}));
384
+ const fm = Object.entries(obj)
385
+ .filter(([k, v]) => !tableFields.has(k) && v !== undefined)
386
+ .map(([k, v]) => `${k}: ${uncoerce(v)}`);
387
+
388
+ const kept = opts.existing ? keepProse(opts.existing, spec) : "";
389
+ const parts = ["---", ...fm, "---", ""];
390
+ if (opts.title && !kept.includes("# ")) parts.push(`# ${opts.title}`, "");
391
+ if (kept) parts.push(kept.trimEnd(), "");
392
+
393
+ for (const [field, heading] of Object.entries(spec.tables || {})) {
394
+ const rows = obj[field];
395
+ if (!rows?.length) continue;
396
+ parts.push(`## ${heading}`, "", renderTable(rows), "");
397
+ }
398
+ return parts.join("\n").replace(/\n{3,}/g, "\n\n");
399
+ }
400
+
401
+ /**
402
+ * Read a contract from disk, markdown first, legacy JSON second.
403
+ *
404
+ * THE FALLBACK IS NOT PERMANENT KINDNESS — it is what makes the format change safe to roll out.
405
+ * A project upgrades its plugin and its data at different moments: `migrate.sh` replaces the code
406
+ * in step 1 and applies migrations in step 2, and a run started between them would otherwise find
407
+ * every contract "missing" and take a fail-open branch. Reading the `.json` a beat longer turns
408
+ * that window from a silently degraded run into a non-event.
409
+ *
410
+ * @param {string} path - Path to the contract, with or without an extension.
411
+ * @param {{tables:Object<string,string>}} [spec=SCOPE_CONTRACT] - Field → heading map.
412
+ * @returns {{contract:Object, path:string, format:("markdown"|"json")}|null} The parsed contract
413
+ * with the path and format it actually came from, or null when neither form exists.
414
+ * @throws {SyntaxError} If a legacy `.json` exists but does not parse — a corrupt contract must
415
+ * be loud, and only the ABSENT case is allowed to be quiet.
416
+ */
417
+ export function readContract(path, spec = SCOPE_CONTRACT) {
418
+ const base = String(path).replace(/\.(md|json)$/, "");
419
+ const md = `${base}.md`;
420
+ if (existsSync(md)) {
421
+ return { contract: parseContract(readFileSync(md, "utf8"), spec), path: md, format: "markdown" };
422
+ }
423
+ const json = `${base}.json`;
424
+ if (existsSync(json)) {
425
+ return { contract: JSON.parse(readFileSync(json, "utf8")), path: json, format: "json" };
426
+ }
427
+ return null;
428
+ }
429
+
430
+ /**
431
+ * Read every contract in a directory, markdown and legacy JSON alike.
432
+ *
433
+ * A `.md` and a `.json` for the same id is a half-finished migration, not two scopes — the
434
+ * markdown wins and the JSON is ignored, so a stale leftover cannot resurrect an old substrate.
435
+ *
436
+ * @param {string} dir - Directory of contracts (typically `scopes/`).
437
+ * @param {{tables:Object<string,string>}} [spec=SCOPE_CONTRACT] - Field → heading map.
438
+ * @returns {Array<{contract:Object, path:string, format:string, id:string}>} One entry per
439
+ * contract, sorted by id; [] when the directory is absent. Unparseable files are skipped.
440
+ */
441
+ export function readAllContracts(dir, spec = SCOPE_CONTRACT) {
442
+ if (!existsSync(dir)) return [];
443
+ let names;
444
+ try { names = readdirSync(dir); } catch { return []; }
445
+ const ids = new Map();
446
+ for (const f of names) {
447
+ const m = f.match(/^(.+)\.(md|json)$/);
448
+ if (!m) continue;
449
+ // markdown wins over a legacy sibling of the same id
450
+ if (m[2] === "json" && ids.has(m[1])) continue;
451
+ if (m[2] === "md" || !ids.has(m[1])) ids.set(m[1], f);
452
+ }
453
+ const out = [];
454
+ for (const [id, file] of [...ids].sort(([a], [b]) => a.localeCompare(b))) {
455
+ try {
456
+ const found = readContract(join(dir, file), spec);
457
+ if (found) out.push({ ...found, id });
458
+ } catch { /* an unparseable contract is reported by spec-lint, not here */ }
459
+ }
460
+ return out;
461
+ }
462
+
463
+ /**
464
+ * Extract the prose a regeneration must not destroy — everything in the body except the sections
465
+ * this spec owns.
466
+ * @param {string} md - The prior file text.
467
+ * @param {{tables:Object<string,string>}} spec - Field → heading map; those headings are dropped.
468
+ * @returns {string} The surviving prose.
469
+ */
470
+ export function keepProse(md, spec) {
471
+ const owned = new Set(Object.values(spec.tables || {}));
472
+ const { body } = splitFrontmatter(md);
473
+ const out = [];
474
+ let skipping = false;
475
+ for (const line of String(body ?? "").split(/\r?\n/)) {
476
+ const h = line.trim().match(/^#{1,6}\s+(.*)$/);
477
+ if (h) skipping = owned.has(h[1].trim());
478
+ if (!skipping) out.push(line);
479
+ }
480
+ return out.join("\n").trim();
481
+ }
@@ -0,0 +1,82 @@
1
+ // isMain — "was this module executed directly, or imported?"
2
+ //
3
+ // WHY THIS FILE EXISTS (measured on the SDD harness benchmark, not theorized).
4
+ //
5
+ // Eighteen of this plugin's scripts and hooks decided whether to do anything at all with:
6
+ //
7
+ // if (import.meta.url === `file://${process.argv[1]}`) main();
8
+ //
9
+ // That comparison is FALSE — and the whole body silently does nothing, exit 0, no output —
10
+ // whenever the path the process was invoked with is not byte-identical to the resolved module
11
+ // URL. Two extremely common cases make it false:
12
+ //
13
+ // 1. A SYMLINKED DIRECTORY ANYWHERE IN THE PATH. Node resolves `import.meta.url` through
14
+ // symlinks; `process.argv[1]` is the string as typed. On macOS `/var` is a symlink to
15
+ // `/private/var`, so EVERY path under the system temp directory mismatches — which is how
16
+ // the benchmark installs this plugin (`/var/folders/…/package`). nvm, pnpm's content store,
17
+ // Homebrew and any symlinked checkout do the same thing on every platform.
18
+ //
19
+ // 2. A SPACE OR OTHER URL-RESERVED CHARACTER IN THE PATH. `import.meta.url` is percent-encoded
20
+ // (`My%20Plugins`); the template literal is not (`My Plugins`). So a plugin installed under
21
+ // `~/Library/Application Support/…` or any directory with a space in its name mismatches too.
22
+ //
23
+ // WHAT THAT COST, measured. `init-run.mjs` is GATE L0.1 — the orchestrator's mandatory first tool
24
+ // call, the script that writes the run receipt everything else is derived from. Under a `/var`
25
+ // path it exited 0 with empty stdout and wrote no receipt. The orchestrator could not distinguish
26
+ // "the run opened" from "nothing happened", and in the benchmark's F4 handoff rows it spent
27
+ // 82–120 turns before its first write doing forensics on its own bootstrap — retrying the script
28
+ // six ways, hitting five separate permission refusals trying to capture an exit code, and finally
29
+ // running `find /` to look for its own skill. Session B cost $4.57–$10.36 and recovered 0/3 of
30
+ // the gap while the artifact it needed sat on disk the entire time.
31
+ //
32
+ // The same guard sits in seven hooks, including `gate-zerowork`, `safety-spine` and
33
+ // `sandbox-guard`. This project's stated organising idea is that "every invariant that matters
34
+ // lives in the runtime, not in a prompt" — and under a symlinked install the runtime half was
35
+ // inert, while every gate still reported success. A silent no-op is the single worst failure mode
36
+ // an enforcement layer can have, because it is indistinguishable from working.
37
+ //
38
+ // THE FIX. Compare resolved URL to resolved URL, using `pathToFileURL` so encoding matches and
39
+ // `realpathSync` so symlinks match. `tests/structural/11-is-main.mjs` asserts that no file
40
+ // reintroduces the fragile form, and actually executes each entry point through a symlinked
41
+ // directory AND through a directory with a space in its name to prove the guard holds — because
42
+ // this defect was invisible to every test that invoked scripts by their real path.
43
+
44
+ import { realpathSync } from "node:fs";
45
+ import { pathToFileURL } from "node:url";
46
+
47
+ /**
48
+ * True when `moduleUrl` belongs to the module Node was asked to execute.
49
+ *
50
+ * Usage, at the bottom of an entry point:
51
+ *
52
+ * import { isMain } from "./lib/is-main.mjs";
53
+ * if (isMain(import.meta.url)) main();
54
+ *
55
+ * @param {string} moduleUrl - The caller's `import.meta.url`.
56
+ * @returns {boolean} true if executed directly, false if imported (or if there is no entry point,
57
+ * e.g. `node --eval`, where nothing should auto-run).
58
+ */
59
+ export function isMain(moduleUrl) {
60
+ const entry = process.argv[1];
61
+ if (!entry || !moduleUrl) return false;
62
+
63
+ // Cheap path first: correct encoding, no filesystem access. Handles spaces and unicode.
64
+ let entryUrl;
65
+ try { entryUrl = pathToFileURL(entry).href; } catch { return false; }
66
+ if (entryUrl === moduleUrl) return true;
67
+
68
+ // Then resolve symlinks on both sides. `import.meta.url` is already realpath-resolved by Node,
69
+ // but resolving it again is harmless and covers the reverse case (a caller passing an
70
+ // unresolved URL) rather than assuming Node's behaviour never changes.
71
+ try {
72
+ const realEntry = pathToFileURL(realpathSync(entry)).href;
73
+ if (realEntry === moduleUrl) return true;
74
+ return realEntry === pathToFileURL(realpathSync(new URL(moduleUrl))).href;
75
+ } catch {
76
+ // An unreadable or deleted entry point is not this function's problem to report. Returning
77
+ // false means "do not auto-run", which is the safe direction for an imported module and is
78
+ // never the direction that silently skips a gate — a directly-invoked script whose own path
79
+ // cannot be stat'd has larger problems that will surface immediately.
80
+ return false;
81
+ }
82
+ }