@tekyzinc/gsd-t 4.7.11 → 4.8.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/bin/gsd-t-divergence-grammar.cjs +213 -0
- package/bin/gsd-t-guard-map-derive.cjs +210 -0
- package/bin/gsd-t-guard-map.cjs +279 -0
- package/bin/gsd-t-milestone-state.cjs +236 -0
- package/bin/gsd-t-rule-consume.cjs +141 -0
- package/bin/gsd-t-traceability-gate.cjs +321 -24
- package/bin/gsd-t.js +8 -0
- package/commands/gsd-t-doc-ripple.md +1 -1
- package/commands/gsd-t-milestone.md +33 -1
- package/package.json +1 -1
- package/templates/CLAUDE-global.md +2 -0
- package/templates/PseudoCode-spec.md +194 -0
- package/templates/prompts/keep-or-supersede-subagent.md +61 -0
- package/templates/prompts/qa-subagent.md +10 -0
- package/templates/prompts/red-team-subagent.md +12 -0
- package/templates/workflows/gsd-t-phase.workflow.js +22 -2
- package/templates/workflows/gsd-t-verify.workflow.js +101 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gsd-t-guard-map — M87 D1 (guard-bridge-spike)
|
|
5
|
+
*
|
|
6
|
+
* The A1 kill-criterion gate. Turns a prose `[RULE]` guard map in a
|
|
7
|
+
* `PseudoCode-[Title].md` doc into a machine-checkable verify gate where a
|
|
8
|
+
* divergence (an UNBACKED or CONTRADICTED rule) is a DETERMINISTIC,
|
|
9
|
+
* non-vacuous FAILURE — zero LLM judgment in the pass/fail decision.
|
|
10
|
+
*
|
|
11
|
+
* Contract: .gsd-t/contracts/pseudocode-source-of-truth-contract.md §2 (the
|
|
12
|
+
* `[RULE]` guard-map grammar + RULE-ID derivation) + §7 (discovery convention).
|
|
13
|
+
*
|
|
14
|
+
* Grammar (§2, reconciled to the real binvoice corpus):
|
|
15
|
+
* A rule = any line CONTAINING the `[RULE …]` marker (matched ANYWHERE on the
|
|
16
|
+
* line, NOT `^`-anchored — the corpus places guard prose LEFT of the marker).
|
|
17
|
+
* Three accepted marker forms:
|
|
18
|
+
* ... [RULE] <RULE-ID>: <invariant> # explicit id, invariant RIGHT
|
|
19
|
+
* GATE: ... → 409 [RULE] <invariant> # loose, invariant RIGHT (PayPal style)
|
|
20
|
+
* <invariant prose> ... [RULE — <tag>] # tagged, invariant LEFT (Extension style)
|
|
21
|
+
* Invariant capture is SIDE-AGNOSTIC (§2 v1.1.3): RIGHT-of-`]` if non-empty,
|
|
22
|
+
* ELSE the LEFT-of-marker prose. An EMPTY resolved invariant = parse FAILURE.
|
|
23
|
+
* Rule id: explicit `<RULE-ID>:` if present, else DERIVE `R-<DOC-SLUG>-<NN>`
|
|
24
|
+
* by 1-based appearance order (pure — same doc bytes → same ids).
|
|
25
|
+
*
|
|
26
|
+
* Gate (DOC-keyed, §2 "Non-vacuity on the MAP side"): iterate the DOC's derived
|
|
27
|
+
* RULE-ID set as the source of truth. For each doc rule:
|
|
28
|
+
* - map entry present + backedBy non-empty + !contradicted → backed (passes)
|
|
29
|
+
* - map entry unbacked (backedBy []) OR contradicted → FAIL (exit 4)
|
|
30
|
+
* - map key ABSENT entirely → UNBACKED → FAIL (exit 4)
|
|
31
|
+
* An incomplete map that simply omits a doc rule can NEVER pass vacuously.
|
|
32
|
+
*
|
|
33
|
+
* Hard engineering bar (mirror M83 traceability-gate): zero deps (Node built-ins
|
|
34
|
+
* only), never throws (bad input → exitCode 64, never an uncaught throw),
|
|
35
|
+
* pure/read-only (writes nothing). Deterministic pass/fail — ZERO LLM judgment.
|
|
36
|
+
*
|
|
37
|
+
* Input: --doc <path> --map <path> [--json]
|
|
38
|
+
* Output: JSON envelope { ok, exitCode, ... }.
|
|
39
|
+
* Exit: 0 every rule backed, none contradicted · 4 ≥1 unbacked/contradicted
|
|
40
|
+
* (names the RULE-ID) · 64 bad input.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const fs = require("node:fs");
|
|
44
|
+
const path = require("node:path");
|
|
45
|
+
|
|
46
|
+
// ─── RULE marker grammar (§2) ─────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
// The marker is `[RULE]` or `[RULE — <tag>]`. Matched ANYWHERE on the line
|
|
49
|
+
// (not `^`-anchored). Path-as-marker, never a bare "RULE" substring.
|
|
50
|
+
const MARKER_RE = /\[RULE(\s*—\s*[^\]]*)?\]/;
|
|
51
|
+
|
|
52
|
+
// An explicit id form: directly after `]`, `<RULE-ID>: <invariant>` where the
|
|
53
|
+
// id is an R-prefixed token (e.g. `R-PAYPAL-01`). Captured for id resolution.
|
|
54
|
+
const EXPLICIT_ID_RE = /^\s*(R-[A-Z0-9][A-Z0-9-]*)\s*:\s*(.*)$/i;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Derive the DOC-SLUG from a doc filename: `PseudoCode-[Title].md` → uppercased
|
|
58
|
+
* `[Title]` with non-alphanumerics collapsed (PAYPAL, EXTENSION). Pure.
|
|
59
|
+
* @param {string} docPath
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function docSlug(docPath) {
|
|
63
|
+
const base = path.basename(String(docPath || ""), ".md");
|
|
64
|
+
// Strip a leading "PseudoCode-" (case-insensitive) if present, else use whole base.
|
|
65
|
+
let title = base.replace(/^pseudocode-/i, "");
|
|
66
|
+
// Strip a trailing harness-variant suffix (e.g. `-doctored`) so a doctored COPY of a
|
|
67
|
+
// subject derives the SAME DOC-SLUG as the faithful subject — the derived RULE-IDs must
|
|
68
|
+
// stay STABLE between faithful and doctored runs (§2; the divergence lives only in the
|
|
69
|
+
// map, never the doc text or its ids). `-doctored` is a test-harness variant of the same
|
|
70
|
+
// subject (PayPal), not a distinct subject Title.
|
|
71
|
+
title = title.replace(/-doctored$/i, "");
|
|
72
|
+
return title.replace(/[^a-z0-9]+/gi, "").toUpperCase() || "DOC";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse every `[RULE]` from a doc's text per §2.
|
|
77
|
+
* Side-agnostic invariant capture, deterministic id resolution.
|
|
78
|
+
* @param {string} md - the doc text
|
|
79
|
+
* @param {string} docPath - used to derive the DOC-SLUG
|
|
80
|
+
* @returns {{ rules: Array<{id, invariant, tag, explicit, line}>, parseErrors: Array<{line, raw}> }}
|
|
81
|
+
*/
|
|
82
|
+
function parseRules(md, docPath) {
|
|
83
|
+
const slug = docSlug(docPath);
|
|
84
|
+
const lines = String(md == null ? "" : md).split(/\r?\n/);
|
|
85
|
+
const rules = [];
|
|
86
|
+
const parseErrors = [];
|
|
87
|
+
let n = 0; // 1-based appearance order, only incremented for real markers
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < lines.length; i++) {
|
|
90
|
+
const line = lines[i];
|
|
91
|
+
const m = line.match(MARKER_RE);
|
|
92
|
+
if (!m) continue;
|
|
93
|
+
|
|
94
|
+
n += 1;
|
|
95
|
+
const markerStart = m.index;
|
|
96
|
+
const markerEnd = m.index + m[0].length;
|
|
97
|
+
const tag = m[1] ? m[1].replace(/^\s*—\s*/, "").trim() : null;
|
|
98
|
+
|
|
99
|
+
const leftText = line.slice(0, markerStart).trim();
|
|
100
|
+
const rightRaw = line.slice(markerEnd).trim();
|
|
101
|
+
|
|
102
|
+
// Explicit id form: the RIGHT side begins `<RULE-ID>: <invariant>`.
|
|
103
|
+
let id = `R-${slug}-${String(n).padStart(2, "0")}`;
|
|
104
|
+
let explicit = false;
|
|
105
|
+
let rightInvariant = rightRaw;
|
|
106
|
+
const idm = rightRaw.match(EXPLICIT_ID_RE);
|
|
107
|
+
if (idm) {
|
|
108
|
+
id = idm[1].toUpperCase();
|
|
109
|
+
explicit = true;
|
|
110
|
+
rightInvariant = idm[2].trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Side-agnostic capture: RIGHT-of-`]` if non-empty, else LEFT-of-marker prose.
|
|
114
|
+
const invariant = (rightInvariant && rightInvariant.length > 0) ? rightInvariant : leftText;
|
|
115
|
+
|
|
116
|
+
if (!invariant || invariant.length === 0) {
|
|
117
|
+
// A rule whose resolved invariant is empty is a PARSE FAILURE (§2).
|
|
118
|
+
parseErrors.push({ line: i + 1, raw: line.trim() });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
rules.push({ id, invariant, tag, explicit, line: i + 1 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { rules, parseErrors };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── gate (DOC-keyed; §2 non-vacuity on the map side) ──────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Gate a parsed doc against a build→rule map. Iterates the DOC's derived
|
|
132
|
+
* RULE-ID set (NOT the map's keyset). A doc-derived id absent from the map is
|
|
133
|
+
* treated as UNBACKED.
|
|
134
|
+
* @param {Array} rules - parseRules().rules
|
|
135
|
+
* @param {object} map - { rules: { <id>: { backedBy:[...], contradicted:bool } } }
|
|
136
|
+
* @returns {{ violations: Array<{id, kind, detail}>, checked: number }}
|
|
137
|
+
*/
|
|
138
|
+
function gateRules(rules, map) {
|
|
139
|
+
const mapRules = (map && map.rules && typeof map.rules === "object") ? map.rules : {};
|
|
140
|
+
const violations = [];
|
|
141
|
+
for (const r of rules) {
|
|
142
|
+
const entry = mapRules[r.id];
|
|
143
|
+
if (entry === undefined || entry === null) {
|
|
144
|
+
// Key absent entirely → UNBACKED (doc-keyed iteration; no map-side vacuous pass).
|
|
145
|
+
violations.push({
|
|
146
|
+
id: r.id,
|
|
147
|
+
kind: "unbacked",
|
|
148
|
+
detail: `RULE ${r.id} ("${r.invariant}") has NO entry in the build→rule map — UNBACKED (a doc-derived rule absent from the map is unbacked, never a vacuous pass).`,
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const backedBy = Array.isArray(entry.backedBy) ? entry.backedBy : [];
|
|
153
|
+
const contradicted = entry.contradicted === true;
|
|
154
|
+
if (contradicted) {
|
|
155
|
+
violations.push({
|
|
156
|
+
id: r.id,
|
|
157
|
+
kind: "contradicted",
|
|
158
|
+
detail: `RULE ${r.id} ("${r.invariant}") is CONTRADICTED — build/test evidence asserts the negation of the invariant (contract-breach divergence).`,
|
|
159
|
+
});
|
|
160
|
+
} else if (backedBy.length === 0) {
|
|
161
|
+
violations.push({
|
|
162
|
+
id: r.id,
|
|
163
|
+
kind: "unbacked",
|
|
164
|
+
detail: `RULE ${r.id} ("${r.invariant}") is UNBACKED — no test assertion references it (backedBy is empty).`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { violations, checked: rules.length };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── driver ────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
function readJson(p) {
|
|
174
|
+
const txt = fs.readFileSync(p, "utf8");
|
|
175
|
+
return JSON.parse(txt);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Run the gate over a doc + map. Never throws — bad input returns exitCode 64.
|
|
180
|
+
* @param {{ doc, map }} o
|
|
181
|
+
* @returns {{ ok, exitCode, ... }}
|
|
182
|
+
*/
|
|
183
|
+
function runGate(o) {
|
|
184
|
+
const { doc, map } = (o && typeof o === "object") ? o : {};
|
|
185
|
+
if (!doc || !map || typeof doc !== "string" || typeof map !== "string") {
|
|
186
|
+
return { ok: false, exitCode: 64, reason: "missing --doc and/or --map", doc: doc || null, map: map || null };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let md;
|
|
190
|
+
try {
|
|
191
|
+
md = fs.readFileSync(doc, "utf8");
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return { ok: false, exitCode: 64, reason: `cannot read doc: ${e && e.message}`, doc };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let mapObj;
|
|
197
|
+
try {
|
|
198
|
+
mapObj = readJson(map);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
return { ok: false, exitCode: 64, reason: `cannot read/parse map JSON: ${e && e.message}`, map };
|
|
201
|
+
}
|
|
202
|
+
if (!mapObj || typeof mapObj !== "object" || mapObj.rules === undefined || typeof mapObj.rules !== "object" || mapObj.rules === null) {
|
|
203
|
+
return { ok: false, exitCode: 64, reason: "map JSON has no `rules` object", map };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const { rules, parseErrors } = parseRules(md, doc);
|
|
207
|
+
|
|
208
|
+
// A parse failure (empty-invariant rule) is bad input (the doc violates the grammar).
|
|
209
|
+
if (parseErrors.length > 0) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
exitCode: 64,
|
|
213
|
+
reason: `parse failure: ${parseErrors.length} rule line(s) yielded an empty invariant`,
|
|
214
|
+
parseErrors,
|
|
215
|
+
doc,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const derivedIds = rules.map((r) => r.id);
|
|
220
|
+
const { violations, checked } = gateRules(rules, mapObj);
|
|
221
|
+
|
|
222
|
+
const ok = violations.length === 0;
|
|
223
|
+
return {
|
|
224
|
+
ok,
|
|
225
|
+
exitCode: ok ? 0 : 4,
|
|
226
|
+
doc,
|
|
227
|
+
map,
|
|
228
|
+
ruleCount: checked,
|
|
229
|
+
derivedIds,
|
|
230
|
+
rules: rules.map((r) => ({ id: r.id, invariant: r.invariant, tag: r.tag, explicit: r.explicit, line: r.line })),
|
|
231
|
+
violations,
|
|
232
|
+
...(ok ? {} : { reason: `${violations.length} unbacked/contradicted rule(s): ${violations.map((v) => v.id).join(", ")}` }),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
function parseArgs(argv) {
|
|
239
|
+
const o = { doc: null, map: null, help: false };
|
|
240
|
+
for (let i = 0; i < argv.length; i++) {
|
|
241
|
+
const a = argv[i];
|
|
242
|
+
if (a === "--help" || a === "-h") o.help = true;
|
|
243
|
+
else if (a === "--doc") o.doc = argv[++i];
|
|
244
|
+
else if (a === "--map") o.map = argv[++i];
|
|
245
|
+
else if (a === "--json") {/* default output is JSON */}
|
|
246
|
+
}
|
|
247
|
+
return o;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const HELP = `Usage: gsd-t guard-map --doc <PseudoCode-[Title].md> --map <build-map.json> [--json]
|
|
251
|
+
|
|
252
|
+
The M87 guard-map gate (A1). Enumerates every [RULE] from a PseudoCode doc per
|
|
253
|
+
the source-of-truth contract §2, then gates a build→rule map: exit non-zero when
|
|
254
|
+
a doc-derived RULE-ID is UNBACKED (backedBy empty, or key absent from the map) or
|
|
255
|
+
CONTRADICTED, naming the violated RULE-ID. Deterministic, zero LLM judgment.
|
|
256
|
+
|
|
257
|
+
--doc PATH the PseudoCode-[Title].md guard-map doc.
|
|
258
|
+
--map PATH the build→rule map JSON ({ "rules": { "<id>": { backedBy, contradicted } } }).
|
|
259
|
+
|
|
260
|
+
Exit: 0 all backed/none contradicted · 4 ≥1 unbacked/contradicted · 64 bad input.`;
|
|
261
|
+
|
|
262
|
+
function main() {
|
|
263
|
+
const o = parseArgs(process.argv.slice(2));
|
|
264
|
+
if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
|
|
265
|
+
let res;
|
|
266
|
+
try {
|
|
267
|
+
res = runGate(o);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// Defense in depth — runGate is written never to throw, but the contract
|
|
270
|
+
// mandates the module never throws, so bad input maps to 64 even here.
|
|
271
|
+
res = { ok: false, exitCode: 64, reason: `gate-error: ${e && e.message}` };
|
|
272
|
+
}
|
|
273
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
274
|
+
process.exit(res.exitCode);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (require.main === module) main();
|
|
278
|
+
|
|
279
|
+
module.exports = { runGate, parseRules, gateRules, docSlug, _internal: { MARKER_RE, EXPLICIT_ID_RE } };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gsd-t-milestone-state — M88 G1 (m88-signoff-state)
|
|
5
|
+
*
|
|
6
|
+
* A machine-checkable milestone sign-off STATE + the `isDefined(milestone)`
|
|
7
|
+
* predicate. Today "DEFINED" is prose an LLM writes into `.gsd-t/progress.md` —
|
|
8
|
+
* NOT a code-readable marker. This module reads a CONCRETE, structured sign-off
|
|
9
|
+
* marker at the head of the detailed `PseudoCode-[Title].md` and decides, with
|
|
10
|
+
* ZERO LLM judgment, whether a milestone's pseudocode set is signed off.
|
|
11
|
+
*
|
|
12
|
+
* The marker is a STRUCTURED front-matter HTML comment at the doc head:
|
|
13
|
+
*
|
|
14
|
+
* <!-- signed-off: <ISO-date> by <author> -->
|
|
15
|
+
*
|
|
16
|
+
* It is PARSED by its structural form (regex on the marker shape), never by a
|
|
17
|
+
* prose substring scan (`feedback_coverage_check_structural_not_substring`).
|
|
18
|
+
* An absent OR malformed marker is FAIL-CLOSED → `signed:false` (never a crash,
|
|
19
|
+
* never a vacuous pass). A skip is NEVER a silent default-off — `recordSkip`
|
|
20
|
+
* emits an ASSERTABLE, greppable logged decision (`feedback_no_silent_degradation`).
|
|
21
|
+
*
|
|
22
|
+
* Contract: .gsd-t/contracts/pseudocode-source-of-truth-contract.md §1
|
|
23
|
+
* (doc anatomy — the two altitudes; High-Level signed off FIRST, then Detailed).
|
|
24
|
+
*
|
|
25
|
+
* ── INTEGRATION NOTE (for a later doc-ripple pass — NOT done here) ──────────
|
|
26
|
+
* This domain ships the predicate ONLY. It does NOT edit `commands/gsd-t-milestone.md`
|
|
27
|
+
* (D3-owned). When that doc-ripple pass runs, the milestone command's "DEFINED"
|
|
28
|
+
* emission should call `isDefined(<the milestone's detailed PseudoCode doc paths>)`
|
|
29
|
+
* and refuse to flip a milestone to DEFINED until the predicate returns true —
|
|
30
|
+
* replacing the prose "DEFINED" an LLM writes into progress.md with this
|
|
31
|
+
* code-readable gate. A deliberate skip MUST route through `recordSkip` so the
|
|
32
|
+
* decision is logged + greppable, never a silent default-off.
|
|
33
|
+
*
|
|
34
|
+
* Hard engineering bar (mirror M87 D1 guard-map): zero deps (Node built-ins
|
|
35
|
+
* only), never throws (bad input → exitCode 64, never an uncaught throw),
|
|
36
|
+
* pure/read-only (writes nothing). Deterministic — ZERO LLM judgment.
|
|
37
|
+
*
|
|
38
|
+
* CLI:
|
|
39
|
+
* --doc <path> [--json] → readSignoff over one detailed doc
|
|
40
|
+
* --docs <comma-list> [--json] → isDefined over a doc set
|
|
41
|
+
* --skip <milestone> --reason <text> → recordSkip (assertable decision)
|
|
42
|
+
*
|
|
43
|
+
* Exit: 0 signed / defined / skip-recorded · 4 unsigned / NOT-defined · 64 bad input.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const fs = require("node:fs");
|
|
47
|
+
|
|
48
|
+
// ─── sign-off marker grammar (structural, NOT prose substring) ─────────────
|
|
49
|
+
//
|
|
50
|
+
// `<!-- signed-off: <ISO-date> by <author> -->`
|
|
51
|
+
// <ISO-date> : YYYY-MM-DD, optionally with a time/zone tail (ISO 8601-ish).
|
|
52
|
+
// <author> : a non-empty author token/phrase (everything up to the `-->`).
|
|
53
|
+
//
|
|
54
|
+
// Anchored to the marker FORM, not to a "signed-off" substring anywhere in
|
|
55
|
+
// prose: the keyword must sit inside an `<!-- ... -->` HTML comment AND be
|
|
56
|
+
// followed by a structurally-valid date + ` by ` + non-empty author. A bare
|
|
57
|
+
// `<!-- signed-off: -->` (no date) does NOT match → fail-closed (unsigned).
|
|
58
|
+
const SIGNOFF_RE =
|
|
59
|
+
/<!--\s*signed-off:\s*(\d{4}-\d{2}-\d{2}(?:[T ][0-9:.+\-Z]*)?)\s+by\s+(\S.*?)\s*-->/i;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse the sign-off marker from a detailed doc's text. Structural-only.
|
|
63
|
+
* @param {string} md - the doc text (may be null/undefined → unsigned).
|
|
64
|
+
* @returns {{signed: boolean, date: (string|null), author: (string|null)}}
|
|
65
|
+
*/
|
|
66
|
+
function parseSignoff(md) {
|
|
67
|
+
const text = md == null ? "" : String(md);
|
|
68
|
+
const m = text.match(SIGNOFF_RE);
|
|
69
|
+
if (!m) return { signed: false, date: null, author: null };
|
|
70
|
+
const date = (m[1] || "").trim();
|
|
71
|
+
const author = (m[2] || "").trim();
|
|
72
|
+
// Defense in depth: both date AND a non-empty author are required. An empty
|
|
73
|
+
// author (regex can't capture one) means malformed → fail-closed.
|
|
74
|
+
if (!date || !author) return { signed: false, date: null, author: null };
|
|
75
|
+
return { signed: true, date, author };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read the sign-off marker from a detailed PseudoCode doc on disk.
|
|
80
|
+
* Never throws — an unreadable/missing path is fail-closed (unsigned).
|
|
81
|
+
* @param {string} docPath
|
|
82
|
+
* @returns {{signed: boolean, date: (string|null), author: (string|null), doc: (string|null), error?: string}}
|
|
83
|
+
*/
|
|
84
|
+
function readSignoff(docPath) {
|
|
85
|
+
if (!docPath || typeof docPath !== "string") {
|
|
86
|
+
return { signed: false, date: null, author: null, doc: null, error: "no doc path" };
|
|
87
|
+
}
|
|
88
|
+
let md;
|
|
89
|
+
try {
|
|
90
|
+
md = fs.readFileSync(docPath, "utf8");
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// Missing / unreadable doc = NOT signed (fail-closed), never a throw.
|
|
93
|
+
return { signed: false, date: null, author: null, doc: docPath, error: `cannot read doc: ${e && e.message}` };
|
|
94
|
+
}
|
|
95
|
+
const parsed = parseSignoff(md);
|
|
96
|
+
return { ...parsed, doc: docPath };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The `isDefined` predicate. A milestone's pseudocode is DEFINED IFF EVERY
|
|
101
|
+
* detailed doc in the set carries a valid sign-off marker. ANY unsigned (or
|
|
102
|
+
* absent/malformed) doc → NOT-DEFINED (false). An EMPTY set is NOT defined
|
|
103
|
+
* (there is nothing signed off to define it — fail-closed, never vacuous-true).
|
|
104
|
+
* @param {string[]} milestoneDocs - detailed PseudoCode doc paths.
|
|
105
|
+
* @returns {{defined: boolean, total: number, signed: number, docs: Array, unsigned: string[]}}
|
|
106
|
+
*/
|
|
107
|
+
function isDefined(milestoneDocs) {
|
|
108
|
+
const docs = Array.isArray(milestoneDocs) ? milestoneDocs : [];
|
|
109
|
+
const results = docs.map((d) => readSignoff(d));
|
|
110
|
+
const unsigned = results.filter((r) => !r.signed).map((r) => r.doc);
|
|
111
|
+
const signedCount = results.length - unsigned.length;
|
|
112
|
+
// Empty set → NOT defined (no signed docs ⇒ nothing defines the milestone).
|
|
113
|
+
const defined = results.length > 0 && unsigned.length === 0;
|
|
114
|
+
return {
|
|
115
|
+
defined,
|
|
116
|
+
total: results.length,
|
|
117
|
+
signed: signedCount,
|
|
118
|
+
docs: results.map((r) => ({ doc: r.doc, signed: r.signed, date: r.date, author: r.author })),
|
|
119
|
+
unsigned,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Record a deliberate skip of the sign-off gate as an ASSERTABLE, greppable
|
|
125
|
+
* logged decision — NEVER a silent default-off. Returns a structured object
|
|
126
|
+
* whose `decision` line is a single greppable string containing the milestone
|
|
127
|
+
* AND the reason (so a test / grep can prove the skip is observable).
|
|
128
|
+
* @param {string} milestone - the milestone id being skipped.
|
|
129
|
+
* @param {string} reason - the human-supplied justification (required).
|
|
130
|
+
* @returns {{ok: boolean, kind: "milestone-signoff-skip", milestone: (string|null), reason: (string|null), decision: string}}
|
|
131
|
+
*/
|
|
132
|
+
function recordSkip(milestone, reason) {
|
|
133
|
+
const ms = milestone == null ? "" : String(milestone).trim();
|
|
134
|
+
const rs = reason == null ? "" : String(reason).trim();
|
|
135
|
+
// A skip with no reason is itself fail-closed: still emitted + assertable, but
|
|
136
|
+
// flagged ok:false so the gate cannot treat a reasonless skip as a clean pass.
|
|
137
|
+
const ok = ms.length > 0 && rs.length > 0;
|
|
138
|
+
const decision = `[GSD-T SIGNOFF-SKIP] milestone=${ms || "<none>"} reason=${rs || "<none>"}`;
|
|
139
|
+
return { ok, kind: "milestone-signoff-skip", milestone: ms || null, reason: rs || null, decision };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── CLI ────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
function parseArgs(argv) {
|
|
145
|
+
const o = { doc: null, docs: null, skip: null, reason: null, help: false };
|
|
146
|
+
for (let i = 0; i < argv.length; i++) {
|
|
147
|
+
const a = argv[i];
|
|
148
|
+
if (a === "--help" || a === "-h") o.help = true;
|
|
149
|
+
else if (a === "--doc") o.doc = argv[++i];
|
|
150
|
+
else if (a === "--docs") o.docs = argv[++i];
|
|
151
|
+
else if (a === "--skip") o.skip = argv[++i];
|
|
152
|
+
else if (a === "--reason") o.reason = argv[++i];
|
|
153
|
+
else if (a === "--json") {/* default output is JSON */}
|
|
154
|
+
}
|
|
155
|
+
return o;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const HELP = `Usage:
|
|
159
|
+
gsd-t milestone-state --doc <PseudoCode-[Title].md> [--json] read one doc's sign-off
|
|
160
|
+
gsd-t milestone-state --docs <p1,p2,...> [--json] isDefined over a set
|
|
161
|
+
gsd-t milestone-state --skip <milestone> --reason <text> record an assertable skip
|
|
162
|
+
|
|
163
|
+
The M88 milestone sign-off state. A milestone is NOT DEFINED until every detailed
|
|
164
|
+
PseudoCode-[Title].md carries a structured sign-off marker:
|
|
165
|
+
|
|
166
|
+
<!-- signed-off: <ISO-date> by <author> -->
|
|
167
|
+
|
|
168
|
+
Parsing is structural (marker form), not a prose substring scan. An absent or
|
|
169
|
+
malformed marker is fail-closed (unsigned). A skip is a LOGGED, greppable
|
|
170
|
+
decision, never a silent default-off.
|
|
171
|
+
|
|
172
|
+
Exit: 0 signed/defined/skip-recorded · 4 unsigned/NOT-defined · 64 bad input.`;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Run the CLI for one parsed arg set. Never throws — bad input → exitCode 64.
|
|
176
|
+
* @param {object} o - parsed args.
|
|
177
|
+
* @returns {{ok: boolean, exitCode: number, ...}}
|
|
178
|
+
*/
|
|
179
|
+
function runCli(o) {
|
|
180
|
+
const opts = o && typeof o === "object" ? o : {};
|
|
181
|
+
|
|
182
|
+
// recordSkip mode
|
|
183
|
+
if (opts.skip != null) {
|
|
184
|
+
const res = recordSkip(opts.skip, opts.reason);
|
|
185
|
+
// A reasonless skip is bad input (64) — the decision is still emitted so it
|
|
186
|
+
// stays assertable, but the gate must not treat it as a clean exit.
|
|
187
|
+
return { ...res, exitCode: res.ok ? 0 : 64 };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// isDefined mode (a set of docs)
|
|
191
|
+
if (opts.docs != null) {
|
|
192
|
+
const list = String(opts.docs)
|
|
193
|
+
.split(",")
|
|
194
|
+
.map((s) => s.trim())
|
|
195
|
+
.filter((s) => s.length > 0);
|
|
196
|
+
if (list.length === 0) {
|
|
197
|
+
return { ok: false, exitCode: 64, reason: "--docs given but resolved to an empty list" };
|
|
198
|
+
}
|
|
199
|
+
const res = isDefined(list);
|
|
200
|
+
return { ok: res.defined, exitCode: res.defined ? 0 : 4, ...res };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// readSignoff mode (one doc)
|
|
204
|
+
if (opts.doc != null) {
|
|
205
|
+
const res = readSignoff(opts.doc);
|
|
206
|
+
return { ok: res.signed, exitCode: res.signed ? 0 : 4, ...res };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { ok: false, exitCode: 64, reason: "no mode: pass --doc, --docs, or --skip" };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function main() {
|
|
213
|
+
const o = parseArgs(process.argv.slice(2));
|
|
214
|
+
if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
|
|
215
|
+
let res;
|
|
216
|
+
try {
|
|
217
|
+
res = runCli(o);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
// Defense in depth — runCli is written never to throw; the contract mandates
|
|
220
|
+
// the module never throws, so any unexpected error maps to 64.
|
|
221
|
+
res = { ok: false, exitCode: 64, reason: `state-error: ${e && e.message}` };
|
|
222
|
+
}
|
|
223
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
224
|
+
process.exit(res.exitCode);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (require.main === module) main();
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
readSignoff,
|
|
231
|
+
parseSignoff,
|
|
232
|
+
isDefined,
|
|
233
|
+
recordSkip,
|
|
234
|
+
runCli,
|
|
235
|
+
_internal: { SIGNOFF_RE },
|
|
236
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gsd-t-rule-consume — M88 G3 (triad-consumption seam)
|
|
5
|
+
*
|
|
6
|
+
* The DETERMINISTIC half of the A5 reframe. A5 once read "the `[RULE]` set is
|
|
7
|
+
* consumed by verify's QA + Red Team frames" — a non-deterministic LIVE-triad
|
|
8
|
+
* observation. This module reframes the CONSUMER side as a pure, deterministic
|
|
9
|
+
* seam: given the guard-map JSON (`{rules:{<id>:...}}` from gsd-t-guard-map.cjs),
|
|
10
|
+
* it surfaces the RULE-ID set that each verify frame must ingest.
|
|
11
|
+
*
|
|
12
|
+
* The seam invariant (A5): EVERY rule id in the map surfaces in BOTH frames —
|
|
13
|
+
* - `qa` → the QA contract-compliance frame (each rule = a compliance assertion)
|
|
14
|
+
* - `redTeam` → the Red Team attack-surface frame (each rule = an invariant to attack)
|
|
15
|
+
* A rule that reaches the map but is DROPPED from either frame breaks the seam
|
|
16
|
+
* (the rule set failed to reach the whole triad). The killing test asserts this.
|
|
17
|
+
*
|
|
18
|
+
* Hard engineering bar (mirror gsd-t-guard-map.cjs): zero deps (Node built-ins
|
|
19
|
+
* only), NEVER throws, pure/read-only (writes nothing). Fail-closed: an
|
|
20
|
+
* empty/malformed map yields EMPTY frame sets (`{qa:[], redTeam:[]}`), never a
|
|
21
|
+
* throw and never a phantom id.
|
|
22
|
+
*
|
|
23
|
+
* Input: --map <path> [--json]
|
|
24
|
+
* Output: JSON envelope { ok, exitCode, qa, redTeam, ... }.
|
|
25
|
+
* Exit: 0 ok (sets surfaced, possibly empty) · 64 bad input (cannot read map).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require("node:fs");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract the ordered, de-duplicated RULE-ID set from a guard-map object.
|
|
32
|
+
* Pure. Never throws. A non-object, a missing/non-object `rules`, or a
|
|
33
|
+
* malformed shape → empty array (fail-closed — no phantom ids).
|
|
34
|
+
* @param {*} map - parsed guard-map object ({ rules: { <id>: {...} } })
|
|
35
|
+
* @returns {string[]} the rule ids in stable insertion order, de-duplicated
|
|
36
|
+
*/
|
|
37
|
+
function ruleIds(map) {
|
|
38
|
+
if (!map || typeof map !== "object") return [];
|
|
39
|
+
const rules = map.rules;
|
|
40
|
+
if (!rules || typeof rules !== "object") return [];
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const ids = [];
|
|
43
|
+
for (const key of Object.keys(rules)) {
|
|
44
|
+
const id = String(key);
|
|
45
|
+
if (id.length === 0 || seen.has(id)) continue;
|
|
46
|
+
seen.add(id);
|
|
47
|
+
ids.push(id);
|
|
48
|
+
}
|
|
49
|
+
return ids;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Surface the rule-id set into BOTH verify frames. Pure. The seam: every id
|
|
54
|
+
* appears in `qa` AND `redTeam` — same set, two frame views. A dropped id in
|
|
55
|
+
* either frame is the failure the seam-check catches.
|
|
56
|
+
* @param {*} map - parsed guard-map object
|
|
57
|
+
* @returns {{ qa: string[], redTeam: string[] }}
|
|
58
|
+
*/
|
|
59
|
+
function consume(map) {
|
|
60
|
+
const ids = ruleIds(map);
|
|
61
|
+
// Independent copies so a mutation of one frame can never silently alias the other.
|
|
62
|
+
return { qa: ids.slice(), redTeam: ids.slice() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── driver ────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run the consumer over a map path. Never throws — bad input → exitCode 64
|
|
69
|
+
* with EMPTY frame sets (fail-closed).
|
|
70
|
+
* @param {{ map: string|null }} o
|
|
71
|
+
* @returns {{ ok, exitCode, qa, redTeam, ... }}
|
|
72
|
+
*/
|
|
73
|
+
function runConsume(o) {
|
|
74
|
+
const { map } = (o && typeof o === "object") ? o : {};
|
|
75
|
+
if (!map || typeof map !== "string") {
|
|
76
|
+
return { ok: false, exitCode: 64, reason: "missing --map", qa: [], redTeam: [], map: map || null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let mapObj;
|
|
80
|
+
try {
|
|
81
|
+
mapObj = JSON.parse(fs.readFileSync(map, "utf8"));
|
|
82
|
+
} catch (e) {
|
|
83
|
+
// Fail-closed: unreadable / unparseable map → empty sets, no throw.
|
|
84
|
+
return { ok: false, exitCode: 64, reason: `cannot read/parse map JSON: ${e && e.message}`, qa: [], redTeam: [], map };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const frames = consume(mapObj);
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
exitCode: 0,
|
|
91
|
+
map,
|
|
92
|
+
ruleCount: frames.qa.length,
|
|
93
|
+
qa: frames.qa,
|
|
94
|
+
redTeam: frames.redTeam,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
function parseArgs(argv) {
|
|
101
|
+
const o = { map: null, help: false };
|
|
102
|
+
for (let i = 0; i < argv.length; i++) {
|
|
103
|
+
const a = argv[i];
|
|
104
|
+
if (a === "--help" || a === "-h") o.help = true;
|
|
105
|
+
else if (a === "--map") o.map = argv[++i];
|
|
106
|
+
else if (a === "--json") {/* default output is JSON */}
|
|
107
|
+
}
|
|
108
|
+
return o;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const HELP = `Usage: gsd-t rule-consume --map <guard-map.json> [--json]
|
|
112
|
+
|
|
113
|
+
The M88 G3 triad-consumption seam (A5). Reads the guard-map JSON and surfaces
|
|
114
|
+
the RULE-ID set into both verify frames — every id appears in BOTH the QA
|
|
115
|
+
contract-compliance frame and the Red Team attack-surface frame. A dropped id
|
|
116
|
+
breaks the seam (caught by test/m88-triad-consumption-seam.test.js).
|
|
117
|
+
Deterministic, zero LLM judgment, fail-closed (empty/malformed map → empty sets).
|
|
118
|
+
|
|
119
|
+
--map PATH the guard-map JSON ({ "rules": { "<id>": {...} } }).
|
|
120
|
+
|
|
121
|
+
Output: { "ok", "exitCode", "qa": [...ids], "redTeam": [...ids] }.
|
|
122
|
+
Exit: 0 ok (sets surfaced) · 64 bad input (map unreadable → empty sets).`;
|
|
123
|
+
|
|
124
|
+
function main() {
|
|
125
|
+
const o = parseArgs(process.argv.slice(2));
|
|
126
|
+
if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
|
|
127
|
+
let res;
|
|
128
|
+
try {
|
|
129
|
+
res = runConsume(o);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
// Defense in depth — runConsume never throws, but the contract mandates the
|
|
132
|
+
// module never throws, so any escape maps to 64 with empty sets.
|
|
133
|
+
res = { ok: false, exitCode: 64, reason: `consume-error: ${e && e.message}`, qa: [], redTeam: [] };
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
136
|
+
process.exit(res.exitCode);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (require.main === module) main();
|
|
140
|
+
|
|
141
|
+
module.exports = { runConsume, consume, ruleIds };
|