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.
- package/.claude/settings.local.example.json +20 -0
- package/.claude-plugin/marketplace.json +16 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.env.shapeup.example +14 -0
- package/AGENTS.md +133 -0
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/SECURITY.md +72 -0
- package/bin/init.mjs +329 -0
- package/commands/build.md +14 -0
- package/commands/eval.md +15 -0
- package/commands/hammer.md +12 -0
- package/commands/orient.md +11 -0
- package/commands/qa.md +12 -0
- package/commands/retro.md +13 -0
- package/commands/scopes.md +14 -0
- package/commands/shape.md +12 -0
- package/commands/ship.md +53 -0
- package/commands/wire.md +11 -0
- package/hooks/anti-rationalization.mjs +244 -0
- package/hooks/compact-snapshot.mjs +47 -0
- package/hooks/gate-deadline.mjs +151 -0
- package/hooks/gate-intake.mjs +110 -0
- package/hooks/gate-l2.mjs +161 -0
- package/hooks/gate-zerowork.mjs +264 -0
- package/hooks/hooks.json +118 -0
- package/hooks/lib/decision.mjs +183 -0
- package/hooks/safety-spine.mjs +296 -0
- package/hooks/sandbox-guard.mjs +172 -0
- package/hooks/session-rehydrate.mjs +109 -0
- package/hooks/slop-cleaner.mjs +176 -0
- package/oracles/_shared.mjs +46 -0
- package/oracles/http-oracle.mjs +155 -0
- package/oracles/index.mjs +36 -0
- package/oracles/process-oracle.mjs +146 -0
- package/oracles/snapshot-oracle.mjs +119 -0
- package/oracles/test-oracle.mjs +138 -0
- package/package.json +49 -0
- package/skills/advisor-protocol/SKILL.md +171 -0
- package/skills/ba-pitch-analyzer/SKILL.md +175 -0
- package/skills/ba-pitch-analyzer/assets/templates/_index.tmpl.md +84 -0
- package/skills/ba-pitch-analyzer/assets/templates/api-feasibility.tmpl.md +65 -0
- package/skills/ba-pitch-analyzer/assets/templates/assess-report.tmpl.md +127 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/be-service.contract.tmpl.md +62 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/offline-storage.contract.tmpl.md +92 -0
- package/skills/ba-pitch-analyzer/assets/templates/contracts/third-party-api.contract.tmpl.md +66 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/context-map.tmpl.md +64 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/event-choreography.tmpl.md +77 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/migration-plan.tmpl.md +104 -0
- package/skills/ba-pitch-analyzer/assets/templates/cross-context/team-handoff.tmpl.md +79 -0
- package/skills/ba-pitch-analyzer/assets/templates/domain-model.tmpl.md +79 -0
- package/skills/ba-pitch-analyzer/assets/templates/feedback.tmpl.md +87 -0
- package/skills/ba-pitch-analyzer/assets/templates/integration.tmpl.md +64 -0
- package/skills/ba-pitch-analyzer/assets/templates/run-state.tmpl.md +77 -0
- package/skills/ba-pitch-analyzer/assets/templates/scope-summary.tmpl.md +105 -0
- package/skills/ba-pitch-analyzer/assets/templates/synthesis.tmpl.md +215 -0
- package/skills/ba-pitch-analyzer/assets/templates/task-board.tmpl.md +35 -0
- package/skills/ba-pitch-analyzer/assets/templates/task-spike.tmpl.md +86 -0
- package/skills/ba-pitch-analyzer/assets/templates/task.tmpl.md +84 -0
- package/skills/ba-pitch-analyzer/assets/templates/usecase.tmpl.md +105 -0
- package/skills/ba-pitch-analyzer/assets/templates/ux-behavior.tmpl.md +65 -0
- package/skills/ba-pitch-analyzer/references/contract-patterns.md +152 -0
- package/skills/ba-pitch-analyzer/references/ddd-patterns.md +173 -0
- package/skills/ba-pitch-analyzer/references/doc-schemas.md +275 -0
- package/skills/ba-pitch-analyzer/references/integration-analysis.md +152 -0
- package/skills/ba-pitch-analyzer/references/task-generation.md +623 -0
- package/skills/ba-pitch-analyzer/references/test-surface.md +102 -0
- package/skills/ba-pitch-analyzer/references/ux-behavior-patterns.md +269 -0
- package/skills/ba-pitch-analyzer/scripts/board-derive.mjs +241 -0
- package/skills/ba-pitch-analyzer/scripts/spec-lint.mjs +239 -0
- package/skills/coach/SKILL.md +202 -0
- package/skills/orient/SKILL.md +262 -0
- package/skills/qa-edge-hunter/SKILL.md +369 -0
- package/skills/scope-architect/SKILL.md +111 -0
- package/skills/scope-hammer/SKILL.md +186 -0
- package/skills/shapeup/SKILL.md +403 -0
- package/skills/shapeup/resources/breadboard-reflection.md +88 -0
- package/skills/shapeup/resources/breadboarding.md +334 -0
- package/skills/shapeup/resources/context-compaction.md +240 -0
- package/skills/shapeup/resources/framing-doc.md +92 -0
- package/skills/shapeup/resources/kickoff-doc.md +95 -0
- package/skills/shapeup/resources/shaping.md +194 -0
- package/skills/shapeup/resources/spike.md +86 -0
- package/skills/solution-architect/SKILL.md +129 -0
- package/skills/spec-evaluator/README.md +93 -0
- package/skills/spec-evaluator/SKILL.md +213 -0
- package/skills/spec-evaluator/references/anti-leniency.md +50 -0
- package/skills/spec-evaluator/references/dimension-contract.md +126 -0
- package/skills/spec-evaluator/references/dimensions/_registry.md +40 -0
- package/skills/spec-evaluator/references/dimensions/completeness.md +102 -0
- package/skills/spec-evaluator/references/dimensions/integration.md +129 -0
- package/skills/spec-evaluator/references/dimensions/performance.md +48 -0
- package/skills/spec-evaluator/references/dimensions/security.md +60 -0
- package/skills/spec-evaluator/references/dimensions/spec-conformance.md +134 -0
- package/skills/spec-evaluator/references/dimensions/tdd-surface.md +110 -0
- package/skills/spec-evaluator/references/dimensions/test-surface-conformance.md +100 -0
- package/skills/spec-evaluator/references/probing.md +177 -0
- package/skills/spec-evaluator/references/report-schema.md +101 -0
- package/skills/spec-evaluator/references/verdict-ledger.md +92 -0
- package/skills/spec-evaluator/scripts/verdict-ledger.mjs +166 -0
- package/skills/task-executor/SKILL.md +194 -0
- package/skills/tech-lead/README.md +71 -0
- package/skills/tech-lead/SKILL.md +459 -0
- package/skills/tech-lead/references/delegation.md +254 -0
- package/skills/tech-lead/references/gates.md +379 -0
- package/skills/tech-lead/references/invocation.md +45 -0
- package/skills/tech-lead/references/ledger-schema.md +214 -0
- package/skills/tech-lead/references/round-protocol.md +184 -0
- package/skills/tech-lead/references/state-model.md +66 -0
- package/skills/tech-lead/references/tiny-lane.md +52 -0
- package/skills/tech-lead/schemas/domain.schema.json +2294 -0
- package/skills/tech-lead/schemas/gate-answers.schema.json +92 -0
- package/skills/tech-lead/schemas/work-order.schema.json +21 -0
- package/skills/tech-lead/schemas/work-result.schema.json +40 -0
- package/skills/tech-lead/scripts/aegis-digest.mjs +124 -0
- package/skills/tech-lead/scripts/budget-check.mjs +156 -0
- package/skills/tech-lead/scripts/compile-order.mjs +518 -0
- package/skills/tech-lead/scripts/fit-check.mjs +196 -0
- package/skills/tech-lead/scripts/gate-answers.mjs +338 -0
- package/skills/tech-lead/scripts/ingest-result.mjs +270 -0
- package/skills/tech-lead/scripts/init-run.mjs +326 -0
- package/skills/tech-lead/scripts/lib/argv.mjs +224 -0
- package/skills/tech-lead/scripts/lib/contract-md.mjs +481 -0
- package/skills/tech-lead/scripts/lib/is-main.mjs +82 -0
- package/skills/tech-lead/scripts/lib/paths.mjs +278 -0
- package/skills/tech-lead/scripts/lib/ratchet-tree.mjs +112 -0
- package/skills/tech-lead/scripts/run-snapshot.mjs +269 -0
- package/skills/tech-lead/scripts/ship-report.mjs +275 -0
- package/skills/tech-lead/scripts/stats.mjs +423 -0
- package/skills/tech-lead/scripts/t0-verify.mjs +470 -0
- package/skills/tech-lead/scripts/trace-lint.mjs +367 -0
- package/skills/tech-lead/scripts/validate-envelope.mjs +283 -0
- package/skills/translator/README.md +66 -0
- package/skills/translator/SKILL.md +258 -0
- 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
|
+
}
|