@tekyzinc/gsd-t 4.7.11 → 4.9.11
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 +46 -0
- package/README.md +1 -1
- package/bin/gsd-t-architectural-trigger.cjs +80 -5
- 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-jargon-lint.cjs +363 -0
- package/bin/gsd-t-milestone-state.cjs +236 -0
- package/bin/gsd-t-rule-consume.cjs +141 -0
- package/bin/gsd-t-shrink-metric.cjs +255 -0
- package/bin/gsd-t-traceability-gate.cjs +321 -24
- package/bin/gsd-t.js +61 -0
- package/commands/gsd-t-doc-ripple.md +1 -1
- package/commands/gsd-t-milestone.md +39 -1
- package/commands/gsd-t-quick.md +13 -6
- package/package.json +1 -1
- package/scripts/gsd-t-brevity-guard.js +340 -0
- package/templates/CLAUDE-global.md +18 -0
- package/templates/PseudoCode-spec.md +194 -0
- package/templates/prompts/blind-adversary-subagent.md +4 -0
- package/templates/prompts/keep-or-supersede-subagent.md +61 -0
- package/templates/prompts/pre-mortem-subagent.md +4 -0
- package/templates/prompts/qa-subagent.md +14 -0
- package/templates/prompts/red-team-subagent.md +16 -0
- package/templates/workflows/gsd-t-execute.workflow.js +7 -1
- package/templates/workflows/gsd-t-phase.workflow.js +22 -2
- package/templates/workflows/gsd-t-quick.workflow.js +16 -2
- package/templates/workflows/gsd-t-verify.workflow.js +183 -2
|
@@ -33,10 +33,21 @@
|
|
|
33
33
|
*
|
|
34
34
|
* Input: --milestone Mxx --project-dir PATH (reads .gsd-t/domains/* /tasks.md).
|
|
35
35
|
* OR --tasks <file> to check a single tasks.md (used by tests).
|
|
36
|
+
* OR --domains a,b,c to scope to EXACTLY those domain dirs (M87 D2-T4).
|
|
36
37
|
* Output: JSON envelope { ok, exitCode, milestone, tasks:[...], violations:[...] }.
|
|
37
38
|
* Exit: 0 all tasks traceable · 4 ≥1 violation (blocks execute) · 64 bad input.
|
|
38
39
|
*
|
|
39
40
|
* Hard rules: zero deps, never throws, pure/read-only.
|
|
41
|
+
*
|
|
42
|
+
* ── M87 D2 EXTENSION (section-citation coverage; pseudocode-source-of-truth §3) ──
|
|
43
|
+
* In ADDITION to the M83 AC→(path+test) binding, when a PseudoCode-[Title].md doc
|
|
44
|
+
* is in scope (present in the pseudocode dir AND cited by ≥1 task), every one of
|
|
45
|
+
* its `##` sections (§3.1: level-2 headings OUTSIDE Appendix code fences) must be
|
|
46
|
+
* cited by ≥1 task via a `**PseudoCode-Section**: <Title>#<anchor>` field. A
|
|
47
|
+
* section with zero citing tasks is an uncovered structural gap; a cited anchor
|
|
48
|
+
* that resolves to NO `##`-heading slug (§3.2 GitHub-style, slug-as-slug never
|
|
49
|
+
* substring) is an unresolvable-citation FAILURE. The M83 behavior + exit codes
|
|
50
|
+
* are preserved untouched.
|
|
40
51
|
*/
|
|
41
52
|
|
|
42
53
|
const fs = require("node:fs");
|
|
@@ -160,6 +171,90 @@ function _acBulletText(lines) {
|
|
|
160
171
|
return out.join("\n");
|
|
161
172
|
}
|
|
162
173
|
|
|
174
|
+
// ─── M87 D2: section-citation grammar (pseudocode-source-of-truth §3) ─────
|
|
175
|
+
|
|
176
|
+
// `**PseudoCode-Section**: <Title>#<anchor>` — matched emphasis-agnostically on
|
|
177
|
+
// the BARED line, then split path-as-path into Title + anchor segments. The
|
|
178
|
+
// raw line is bared via _bare (emphasis stripped) so the colon-inside/outside
|
|
179
|
+
// bold forms both match, exactly like the M83 field scan.
|
|
180
|
+
const PSEUDOCODE_SECTION_FIELD_RE = /^\s*[-*]?\s*pseudocode-section\s*:/i;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parse the `**PseudoCode-Section**: <Title>#<anchor>` citation from a task's
|
|
184
|
+
* lines. Returns { title, anchor } structured segments, or null if absent.
|
|
185
|
+
* Path-as-path: the value is split on the FIRST `#`; everything left of it is
|
|
186
|
+
* the doc Title, everything right is the anchor slug. Never substring-matched
|
|
187
|
+
* against doc text.
|
|
188
|
+
*
|
|
189
|
+
* Title normalization: the corpus cites with the full filename stem
|
|
190
|
+
* (`PseudoCode-PayPal#…`) while the doc loader keys by the bare subject
|
|
191
|
+
* (`PayPal`, the stem minus the `PseudoCode-` prefix). A leading `PseudoCode-`
|
|
192
|
+
* in the title segment is therefore stripped so both forms resolve to one key.
|
|
193
|
+
*/
|
|
194
|
+
function _normalizeDocTitle(t) {
|
|
195
|
+
const s = String(t == null ? "" : t).trim();
|
|
196
|
+
const m = s.match(/^PseudoCode-(.+)$/);
|
|
197
|
+
return m ? m[1] : s;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseSectionCitation(lines) {
|
|
201
|
+
for (const ln of lines) {
|
|
202
|
+
const bare = _bare(ln);
|
|
203
|
+
if (!PSEUDOCODE_SECTION_FIELD_RE.test(bare)) continue;
|
|
204
|
+
const idx = bare.indexOf(":");
|
|
205
|
+
if (idx < 0) continue;
|
|
206
|
+
const val = bare.slice(idx + 1).trim();
|
|
207
|
+
const hash = val.indexOf("#");
|
|
208
|
+
if (hash < 0) return { title: _normalizeDocTitle(val), anchor: "", raw: val };
|
|
209
|
+
return { title: _normalizeDocTitle(val.slice(0, hash)), anchor: val.slice(hash + 1).trim(), raw: val };
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* GitHub-style heading→slug (contract §3.2, deterministic/pure):
|
|
216
|
+
* lowercase → drop every char that is NOT ascii-alnum / space / hyphen →
|
|
217
|
+
* spaces to hyphens → collapse hyphen runs. slug-as-slug, never substring.
|
|
218
|
+
*/
|
|
219
|
+
function slugifyHeading(text) {
|
|
220
|
+
return String(text == null ? "" : text)
|
|
221
|
+
.trim()
|
|
222
|
+
.toLowerCase()
|
|
223
|
+
.replace(/[^a-z0-9 -]/g, "")
|
|
224
|
+
.replace(/ /g, "-")
|
|
225
|
+
.replace(/-+/g, "-");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Enumerate the citable `##` sections of a PseudoCode doc (contract §3.1):
|
|
230
|
+
* every level-2 heading OUTSIDE fenced code blocks (so the `# 0.` banner lines
|
|
231
|
+
* inside the Appendix raw-pseudocode fences are EXCLUDED). Returns an ordered
|
|
232
|
+
* list of { title, slug }. Pure/read-only over the doc text.
|
|
233
|
+
*/
|
|
234
|
+
function enumerateSections(md) {
|
|
235
|
+
const lines = (md || "").split(/\r?\n/);
|
|
236
|
+
const out = [];
|
|
237
|
+
let inFence = false;
|
|
238
|
+
for (const line of lines) {
|
|
239
|
+
// Fence delimiters: ``` or ~~~ (3+), optionally indented, with an info string.
|
|
240
|
+
if (/^\s*(```+|~~~+)/.test(line)) { inFence = !inFence; continue; }
|
|
241
|
+
if (inFence) continue;
|
|
242
|
+
const m = line.match(/^##\s+(.*\S.*)$/);
|
|
243
|
+
if (m) out.push({ title: m[1].trim(), slug: slugifyHeading(m[1].trim()) });
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Derive the doc <Title> from a PseudoCode-[Title].md filename (basename minus
|
|
250
|
+
* the `PseudoCode-` prefix and `.md` suffix). Path-as-path.
|
|
251
|
+
*/
|
|
252
|
+
function docTitleFromFilename(filename) {
|
|
253
|
+
const base = path.basename(String(filename || ""));
|
|
254
|
+
const m = base.match(/^PseudoCode-(.+)\.md$/);
|
|
255
|
+
return m ? m[1] : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
163
258
|
/**
|
|
164
259
|
* A task is "behavioral" (subject to the gate) if it declares acceptance
|
|
165
260
|
* criteria — i.e. it promises an observable behavior. Pure-scaffolding tasks
|
|
@@ -167,9 +262,12 @@ function _acBulletText(lines) {
|
|
|
167
262
|
*/
|
|
168
263
|
function assessTask(task) {
|
|
169
264
|
const lines = task.lines;
|
|
265
|
+
// M87 D2: every task may carry a section citation, AC-bearing or not — capture
|
|
266
|
+
// it regardless so non-behavioral coverage tasks still count toward citations.
|
|
267
|
+
const sectionCitation = parseSectionCitation(lines);
|
|
170
268
|
const hasAc = hasMultiField(lines, AC_FIELD_RE);
|
|
171
269
|
if (!hasAc) {
|
|
172
|
-
return { title: task.title, behavioral: false, violations: [] };
|
|
270
|
+
return { title: task.title, behavioral: false, violations: [], sectionCitation };
|
|
173
271
|
}
|
|
174
272
|
|
|
175
273
|
// Underscore-preserving values for path/runner scans (Red Team recheck HIGH).
|
|
@@ -218,42 +316,203 @@ function assessTask(task) {
|
|
|
218
316
|
behavioral: true,
|
|
219
317
|
isHeadline,
|
|
220
318
|
hasFiles, hasTest, hasImplPath,
|
|
319
|
+
sectionCitation,
|
|
221
320
|
violations,
|
|
222
321
|
};
|
|
223
322
|
}
|
|
224
323
|
|
|
225
324
|
// ─── driver ──────────────────────────────────────────────────────────────
|
|
226
325
|
|
|
227
|
-
|
|
326
|
+
/**
|
|
327
|
+
* Resolve the set of tasks.md files to gate.
|
|
328
|
+
*
|
|
329
|
+
* Returns { files: [...] } on success, or { error: { reason, ... } } when an
|
|
330
|
+
* explicit scope cannot be honored (M87 D2-T4 — never silently fall back to all
|
|
331
|
+
* historical domains).
|
|
332
|
+
*
|
|
333
|
+
* Scoping precedence:
|
|
334
|
+
* 1. `domains` (explicit list) → EXACTLY those dirs; each must exist + carry a
|
|
335
|
+
* tasks.md, else a structured error (a named-but-missing domain is never a
|
|
336
|
+
* silent drop).
|
|
337
|
+
* 2. `milestone` prefix-match → domains whose name starts with the mNN prefix
|
|
338
|
+
* (M83 path, preserved for prefix-named milestones).
|
|
339
|
+
* 3. `milestone` set but prefix-match yields zero AND no `domains` →
|
|
340
|
+
* structured `milestone-scope-unresolved` error (exit 64) instructing
|
|
341
|
+
* `--domains`, REPLACING the M83 fall-back-to-all.
|
|
342
|
+
* 4. No milestone, no domains → all domains (single-milestone-repo default).
|
|
343
|
+
*/
|
|
344
|
+
/**
|
|
345
|
+
* Containment guard (per feedback_destructive_path_ops_containment): a child path
|
|
346
|
+
* is valid only if it resolves strictly INSIDE root (root + separator prefix) —
|
|
347
|
+
* never outside, never equal to root. Refuses `../`-escapes, absolute paths, and
|
|
348
|
+
* any name resolving to a sibling/ancestor. Returns the resolved path or null.
|
|
349
|
+
*/
|
|
350
|
+
function containedChild(root, name) {
|
|
351
|
+
const resolvedRoot = path.resolve(root);
|
|
352
|
+
const candidate = path.resolve(resolvedRoot, name);
|
|
353
|
+
if (candidate === resolvedRoot) return null; // equal-to-root is not a child
|
|
354
|
+
if (!candidate.startsWith(resolvedRoot + path.sep)) return null; // outside-root
|
|
355
|
+
return candidate;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function listTasksFiles(projectDir, milestone, domains = null) {
|
|
228
359
|
const domainsDir = path.join(projectDir, ".gsd-t", "domains");
|
|
360
|
+
|
|
361
|
+
// (1) Explicit domain list — scope to EXACTLY those, error on any miss.
|
|
362
|
+
if (domains && domains.length) {
|
|
363
|
+
const files = [];
|
|
364
|
+
const missing = [];
|
|
365
|
+
const escaped = [];
|
|
366
|
+
for (const name of domains) {
|
|
367
|
+
// Containment: a domain name must resolve strictly inside the domains dir.
|
|
368
|
+
// A `../`-escape / absolute path / sibling reference is REFUSED, never
|
|
369
|
+
// silently gated against an out-of-tree file (read-only today, but the
|
|
370
|
+
// gate becomes attacker-reachable the moment --domains is doc-derived).
|
|
371
|
+
const domainDir = containedChild(domainsDir, name);
|
|
372
|
+
if (!domainDir) { escaped.push(name); continue; }
|
|
373
|
+
const tasksPath = path.join(domainDir, "tasks.md");
|
|
374
|
+
let stat = null;
|
|
375
|
+
try { stat = fs.statSync(domainDir); } catch {/* missing */}
|
|
376
|
+
if (!stat || !stat.isDirectory() || !fs.existsSync(tasksPath)) {
|
|
377
|
+
missing.push(name);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
files.push({ domain: name, tasksPath });
|
|
381
|
+
}
|
|
382
|
+
if (escaped.length) {
|
|
383
|
+
return { error: { reason: "domain-path-escape", escapedDomains: escaped, requestedDomains: domains } };
|
|
384
|
+
}
|
|
385
|
+
if (missing.length) {
|
|
386
|
+
return { error: { reason: "domain-not-found", missingDomains: missing, requestedDomains: domains } };
|
|
387
|
+
}
|
|
388
|
+
return { files };
|
|
389
|
+
}
|
|
390
|
+
|
|
229
391
|
let entries = [];
|
|
230
392
|
try {
|
|
231
393
|
entries = fs.readdirSync(domainsDir, { withFileTypes: true });
|
|
232
394
|
} catch {
|
|
233
|
-
return [];
|
|
395
|
+
return { files: [] };
|
|
234
396
|
}
|
|
235
|
-
const
|
|
236
|
-
const mPrefix = milestone ? milestone.toLowerCase() : null;
|
|
397
|
+
const all = [];
|
|
237
398
|
for (const e of entries) {
|
|
238
399
|
if (!e.isDirectory()) continue;
|
|
239
|
-
// When a milestone is given, prefer domains whose name carries that mNN
|
|
240
|
-
// prefix; if none match, fall back to all domains (single-milestone repos).
|
|
241
400
|
const tasksPath = path.join(domainsDir, e.name, "tasks.md");
|
|
242
|
-
if (fs.existsSync(tasksPath))
|
|
401
|
+
if (fs.existsSync(tasksPath)) all.push({ domain: e.name, tasksPath });
|
|
243
402
|
}
|
|
403
|
+
|
|
404
|
+
const mPrefix = milestone ? milestone.toLowerCase() : null;
|
|
244
405
|
if (mPrefix) {
|
|
245
|
-
const matched =
|
|
246
|
-
if (matched.length) return matched;
|
|
406
|
+
const matched = all.filter((d) => d.domain.toLowerCase().startsWith(mPrefix));
|
|
407
|
+
if (matched.length) return { files: matched };
|
|
408
|
+
// Zero prefix-match + no explicit --domains → DO NOT fall back to all.
|
|
409
|
+
return { error: { reason: "milestone-scope-unresolved", milestone } };
|
|
247
410
|
}
|
|
248
|
-
return
|
|
411
|
+
return { files: all };
|
|
249
412
|
}
|
|
250
413
|
|
|
251
|
-
|
|
414
|
+
// ─── M87 D2: section-coverage over the in-scope PseudoCode docs ───────────
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Enumerate the PseudoCode-*.md docs available for coverage. Default location is
|
|
418
|
+
* `.gsd-t/pseudocode/` (contract §7), overridable via pseudocodeDir (the D2 test
|
|
419
|
+
* points it at D1's byte-verbatim fixtures). Returns a Map<Title, {path, md,
|
|
420
|
+
* sections}>; a missing dir yields an empty Map (logged skip, never a throw).
|
|
421
|
+
*/
|
|
422
|
+
function loadPseudocodeDocs(pseudocodeDir) {
|
|
423
|
+
const docs = new Map();
|
|
424
|
+
let entries = [];
|
|
425
|
+
try { entries = fs.readdirSync(pseudocodeDir, { withFileTypes: true }); }
|
|
426
|
+
catch { return docs; }
|
|
427
|
+
for (const e of entries) {
|
|
428
|
+
if (!e.isFile()) continue;
|
|
429
|
+
const title = docTitleFromFilename(e.name);
|
|
430
|
+
if (!title) continue;
|
|
431
|
+
const full = path.join(pseudocodeDir, e.name);
|
|
432
|
+
let md;
|
|
433
|
+
try { md = fs.readFileSync(full, "utf8"); } catch { continue; }
|
|
434
|
+
docs.set(title, { path: full, md, sections: enumerateSections(md) });
|
|
435
|
+
}
|
|
436
|
+
return docs;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Run section-citation coverage over the in-scope PseudoCode docs.
|
|
441
|
+
*
|
|
442
|
+
* A doc is "in scope" when it exists in the pseudocode dir AND is cited by ≥1
|
|
443
|
+
* task. For each in-scope doc: (a) every cited anchor MUST resolve to a real
|
|
444
|
+
* `##`-heading slug (else unresolvable-citation FAILURE); (b) every `##` section
|
|
445
|
+
* MUST be cited by ≥1 task (else uncovered-section gap). All slug-as-slug, never
|
|
446
|
+
* substring.
|
|
447
|
+
*
|
|
448
|
+
* @returns {{ violations, docs }}
|
|
449
|
+
*/
|
|
450
|
+
function assessSectionCoverage(taskResults, pseudocodeDocs) {
|
|
451
|
+
const violations = [];
|
|
452
|
+
const docReports = [];
|
|
453
|
+
|
|
454
|
+
// citationsByTitle: Map<Title, Array<{ anchor, domain, task }>>
|
|
455
|
+
const citationsByTitle = new Map();
|
|
456
|
+
for (const r of taskResults) {
|
|
457
|
+
const c = r.sectionCitation;
|
|
458
|
+
if (!c || !c.title) continue;
|
|
459
|
+
if (!citationsByTitle.has(c.title)) citationsByTitle.set(c.title, []);
|
|
460
|
+
citationsByTitle.get(c.title).push({ anchor: c.anchor, domain: r.domain, task: r.title });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// A doc is in scope when present in the dir AND cited by ≥1 task.
|
|
464
|
+
for (const [title, citations] of citationsByTitle) {
|
|
465
|
+
const doc = pseudocodeDocs.get(title);
|
|
466
|
+
if (!doc) continue; // cited but no doc available → out of section-coverage scope
|
|
467
|
+
const slugSet = new Set(doc.sections.map((s) => s.slug));
|
|
468
|
+
const citedSlugs = new Set();
|
|
469
|
+
|
|
470
|
+
// (a) Unresolvable-citation FAILURE: a cited anchor matching no `##` slug.
|
|
471
|
+
for (const cit of citations) {
|
|
472
|
+
if (cit.anchor && slugSet.has(cit.anchor)) {
|
|
473
|
+
citedSlugs.add(cit.anchor);
|
|
474
|
+
} else {
|
|
475
|
+
violations.push({
|
|
476
|
+
domain: cit.domain, task: cit.task,
|
|
477
|
+
kind: "unresolvable-section-citation",
|
|
478
|
+
detail: `PseudoCode-Section cites "${title}#${cit.anchor}" but no \`##\` heading in PseudoCode-${title}.md slugifies to that anchor (§3.2 slug-as-slug).`,
|
|
479
|
+
doc: title, anchor: cit.anchor,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// (b) Uncovered-section gap: a `##` section with zero citing tasks.
|
|
485
|
+
for (const s of doc.sections) {
|
|
486
|
+
if (!citedSlugs.has(s.slug)) {
|
|
487
|
+
violations.push({
|
|
488
|
+
domain: null, task: null,
|
|
489
|
+
kind: "uncovered-section",
|
|
490
|
+
detail: `PseudoCode-${title}.md section "${s.title}" (#${s.slug}) has zero citing tasks — structural coverage gap (§3.1).`,
|
|
491
|
+
doc: title, anchor: s.slug, section: s.title,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
docReports.push({
|
|
497
|
+
doc: title,
|
|
498
|
+
sectionsTotal: doc.sections.length,
|
|
499
|
+
sectionsCovered: citedSlugs.size,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return { violations, docs: docReports };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function runGate({ projectDir = process.cwd(), milestone = null, tasksFile = null, domains = null, pseudocodeDir = null } = {}) {
|
|
252
507
|
let files;
|
|
253
508
|
if (tasksFile) {
|
|
254
509
|
files = [{ domain: path.basename(path.dirname(tasksFile)), tasksPath: tasksFile }];
|
|
255
510
|
} else {
|
|
256
|
-
|
|
511
|
+
const resolved = listTasksFiles(projectDir, milestone, domains);
|
|
512
|
+
if (resolved.error) {
|
|
513
|
+
return { ok: false, exitCode: 64, milestone, ...resolved.error, tasks: [], violations: [] };
|
|
514
|
+
}
|
|
515
|
+
files = resolved.files;
|
|
257
516
|
}
|
|
258
517
|
if (!files.length) {
|
|
259
518
|
return { ok: false, exitCode: 64, milestone, reason: "no-tasks-files", tasks: [], violations: [] };
|
|
@@ -276,6 +535,26 @@ function runGate({ projectDir = process.cwd(), milestone = null, tasksFile = nul
|
|
|
276
535
|
}
|
|
277
536
|
}
|
|
278
537
|
|
|
538
|
+
// M87 D2: section-citation coverage over the in-scope PseudoCode docs.
|
|
539
|
+
// Containment: an explicit --pseudocode-dir must resolve INSIDE the project
|
|
540
|
+
// tree (the D2 test legitimately points it at test/fixtures/, also inside the
|
|
541
|
+
// project; an out-of-tree `../../evildocs` is refused). The default
|
|
542
|
+
// `.gsd-t/pseudocode/` is always in-tree. A refused dir → empty doc set
|
|
543
|
+
// (logged skip, never an out-of-tree read), same fail-closed shape as a missing dir.
|
|
544
|
+
let resolvedPcDir = pseudocodeDir || path.join(projectDir, ".gsd-t", "pseudocode");
|
|
545
|
+
if (pseudocodeDir) {
|
|
546
|
+
const resolvedProject = path.resolve(projectDir);
|
|
547
|
+
const candidate = path.resolve(resolvedProject, pseudocodeDir);
|
|
548
|
+
if (candidate !== resolvedProject && !candidate.startsWith(resolvedProject + path.sep)) {
|
|
549
|
+
resolvedPcDir = null; // out-of-tree → no docs loaded (fail-closed)
|
|
550
|
+
} else {
|
|
551
|
+
resolvedPcDir = candidate;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const pseudocodeDocs = resolvedPcDir ? loadPseudocodeDocs(resolvedPcDir) : new Map();
|
|
555
|
+
const coverage = assessSectionCoverage(taskResults, pseudocodeDocs);
|
|
556
|
+
for (const v of coverage.violations) violations.push(v);
|
|
557
|
+
|
|
279
558
|
const ok = violations.length === 0;
|
|
280
559
|
return {
|
|
281
560
|
ok,
|
|
@@ -285,6 +564,7 @@ function runGate({ projectDir = process.cwd(), milestone = null, tasksFile = nul
|
|
|
285
564
|
tasksTotal: taskResults.length,
|
|
286
565
|
behavioral: behavioralCount,
|
|
287
566
|
violations: violations.length,
|
|
567
|
+
sectionCoverageDocs: coverage.docs,
|
|
288
568
|
},
|
|
289
569
|
tasks: taskResults,
|
|
290
570
|
violations,
|
|
@@ -295,30 +575,42 @@ function runGate({ projectDir = process.cwd(), milestone = null, tasksFile = nul
|
|
|
295
575
|
// ─── CLI ─────────────────────────────────────────────────────────────────
|
|
296
576
|
|
|
297
577
|
function parseArgs(argv) {
|
|
298
|
-
const o = { projectDir: process.cwd(), milestone: null, tasksFile: null, help: false };
|
|
578
|
+
const o = { projectDir: process.cwd(), milestone: null, tasksFile: null, domains: null, pseudocodeDir: null, help: false };
|
|
299
579
|
for (let i = 0; i < argv.length; i++) {
|
|
300
580
|
const a = argv[i];
|
|
301
581
|
if (a === "--help" || a === "-h") o.help = true;
|
|
302
582
|
else if (a === "--project-dir") o.projectDir = argv[++i];
|
|
303
583
|
else if (a === "--milestone") o.milestone = argv[++i];
|
|
304
584
|
else if (a === "--tasks") o.tasksFile = argv[++i];
|
|
585
|
+
else if (a === "--domains") {
|
|
586
|
+
o.domains = String(argv[++i] || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
587
|
+
}
|
|
588
|
+
else if (a === "--pseudocode-dir") o.pseudocodeDir = argv[++i];
|
|
305
589
|
else if (a === "--json") {/* default */}
|
|
306
590
|
}
|
|
307
591
|
return o;
|
|
308
592
|
}
|
|
309
593
|
|
|
310
|
-
const HELP = `Usage: gsd-t traceability-gate [--milestone Mxx] [--project-dir PATH] [--tasks FILE]
|
|
594
|
+
const HELP = `Usage: gsd-t traceability-gate [--milestone Mxx] [--project-dir PATH] [--tasks FILE] [--domains a,b,c] [--pseudocode-dir PATH]
|
|
311
595
|
|
|
312
|
-
Plan-phase acceptance-traceability gate (M83)
|
|
313
|
-
the milestone's .gsd-t/domains/* /tasks.md binds
|
|
314
|
-
implementing **Files** path AND a named test.
|
|
315
|
-
real implementation path and a test.
|
|
596
|
+
Plan-phase acceptance-traceability gate (M83) + section-citation coverage (M87 D2).
|
|
597
|
+
Asserts every behavioral task in the milestone's .gsd-t/domains/* /tasks.md binds
|
|
598
|
+
its acceptance criteria to an implementing **Files** path AND a named test.
|
|
599
|
+
Headline tasks must have BOTH a real implementation path and a test. Additionally,
|
|
600
|
+
when a PseudoCode-[Title].md doc is in scope, every \`##\` section must be cited by
|
|
601
|
+
≥1 task (\`**PseudoCode-Section**: <Title>#<anchor>\`) and every citation must
|
|
602
|
+
resolve to a real heading slug. Blocks execute on any violation.
|
|
316
603
|
|
|
317
|
-
--milestone Mxx
|
|
318
|
-
|
|
319
|
-
--
|
|
604
|
+
--milestone Mxx Scope by mNN-prefix domains (M83). Zero match + no --domains
|
|
605
|
+
→ exit 64 milestone-scope-unresolved (never fall-back-to-all).
|
|
606
|
+
--domains a,b,c Scope to EXACTLY these domain dirs (each must exist + carry a
|
|
607
|
+
tasks.md; a missing named domain is an error, not a drop).
|
|
608
|
+
--project-dir P Project root (default: cwd).
|
|
609
|
+
--tasks FILE Check a single tasks.md (overrides domain discovery).
|
|
610
|
+
--pseudocode-dir P PseudoCode-*.md dir for section coverage (default
|
|
611
|
+
.gsd-t/pseudocode).
|
|
320
612
|
|
|
321
|
-
Exit: 0 all traceable · 4 ≥1 violation · 64
|
|
613
|
+
Exit: 0 all traceable · 4 ≥1 violation · 64 bad input / unresolved scope.`;
|
|
322
614
|
|
|
323
615
|
function main() {
|
|
324
616
|
const o = parseArgs(process.argv.slice(2));
|
|
@@ -335,4 +627,9 @@ function main() {
|
|
|
335
627
|
|
|
336
628
|
if (require.main === module) main();
|
|
337
629
|
|
|
338
|
-
module.exports = {
|
|
630
|
+
module.exports = {
|
|
631
|
+
runGate, parseTasks, assessTask, listTasksFiles,
|
|
632
|
+
parseSectionCitation, slugifyHeading, enumerateSections, docTitleFromFilename,
|
|
633
|
+
loadPseudocodeDocs, assessSectionCoverage,
|
|
634
|
+
_internal: { fieldValue, TEST_PATH_RE },
|
|
635
|
+
};
|
package/bin/gsd-t.js
CHANGED
|
@@ -395,6 +395,53 @@ function addHeartbeatHook(hooks, event, cmd) {
|
|
|
395
395
|
return true;
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
// ─── Brevity Guard (M93) ────────────────────────────────────────────────────
|
|
399
|
+
// A BLOCKING Stop hook (not async — it must run synchronously to block a verbose
|
|
400
|
+
// answer-mode reply). Enforces the Reader Contract: answer-first, no preamble,
|
|
401
|
+
// jargon glossed. Fail-open by design (the script never blocks on error).
|
|
402
|
+
|
|
403
|
+
const BREVITY_GUARD_SCRIPT = "gsd-t-brevity-guard.js";
|
|
404
|
+
|
|
405
|
+
function installBrevityGuard() {
|
|
406
|
+
ensureDir(SCRIPTS_DIR);
|
|
407
|
+
const src = path.join(PKG_SCRIPTS, BREVITY_GUARD_SCRIPT);
|
|
408
|
+
const dest = path.join(SCRIPTS_DIR, BREVITY_GUARD_SCRIPT);
|
|
409
|
+
if (!fs.existsSync(src)) { warn("Brevity-guard script not found in package — skipping"); return; }
|
|
410
|
+
|
|
411
|
+
const srcContent = fs.readFileSync(src, "utf8");
|
|
412
|
+
const destContent = fs.existsSync(dest) ? fs.readFileSync(dest, "utf8") : "";
|
|
413
|
+
if (normalizeEol(srcContent) !== normalizeEol(destContent)) {
|
|
414
|
+
copyFile(src, dest, BREVITY_GUARD_SCRIPT);
|
|
415
|
+
} else {
|
|
416
|
+
info("Brevity-guard script unchanged");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const parsed = readSettingsJson();
|
|
420
|
+
if (parsed === null && fs.existsSync(SETTINGS_JSON)) {
|
|
421
|
+
warn("settings.json has invalid JSON — cannot configure brevity-guard hook");
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const settings = parsed || {};
|
|
425
|
+
if (!settings.hooks) settings.hooks = {};
|
|
426
|
+
if (!settings.hooks.Stop) settings.hooks.Stop = [];
|
|
427
|
+
|
|
428
|
+
const already = settings.hooks.Stop.some((entry) =>
|
|
429
|
+
entry.hooks && entry.hooks.some((h) => h.command && h.command.includes(BREVITY_GUARD_SCRIPT))
|
|
430
|
+
);
|
|
431
|
+
if (already) { info("Brevity-guard hook already configured"); return; }
|
|
432
|
+
|
|
433
|
+
// Blocking (no async) so the Stop-hook block decision is honored.
|
|
434
|
+
const cmd = `node "${dest.replace(/\\/g, "\\\\")}"`;
|
|
435
|
+
settings.hooks.Stop.push({ matcher: "", hooks: [{ type: "command", command: cmd }] });
|
|
436
|
+
|
|
437
|
+
if (!isSymlink(SETTINGS_JSON)) {
|
|
438
|
+
fs.writeFileSync(SETTINGS_JSON, JSON.stringify(settings, null, 2));
|
|
439
|
+
success("Brevity-guard Stop hook configured in settings.json");
|
|
440
|
+
} else {
|
|
441
|
+
warn("Skipping settings.json write — target is a symlink");
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
398
445
|
// ─── Context Meter ──────────────────────────────────────────────────────────
|
|
399
446
|
|
|
400
447
|
const CONTEXT_METER_SCRIPT = "gsd-t-context-meter.js";
|
|
@@ -1613,6 +1660,9 @@ async function doInstall(opts = {}) {
|
|
|
1613
1660
|
heading("Heartbeat (Real-time Events)");
|
|
1614
1661
|
installHeartbeat();
|
|
1615
1662
|
|
|
1663
|
+
heading("Brevity Guard (M93 — concise replies)");
|
|
1664
|
+
installBrevityGuard();
|
|
1665
|
+
|
|
1616
1666
|
heading("Update Check (Session Start)");
|
|
1617
1667
|
installUpdateCheck();
|
|
1618
1668
|
|
|
@@ -2584,6 +2634,17 @@ const PROJECT_BIN_TOOLS = [
|
|
|
2584
2634
|
// Backlog #40 — deterministic archive+sweep of a completed milestone's domain dirs
|
|
2585
2635
|
// (complete-milestone Step 7). Propagated so complete-milestone can invoke it project-local.
|
|
2586
2636
|
"gsd-t-archive-domains.cjs",
|
|
2637
|
+
// M91 (merged M87+M88) — PseudoCode source-of-truth deterministic gates, propagated so
|
|
2638
|
+
// project-local runCli helpers (gsd-t-verify / gsd-t-milestone workflows) resolve them
|
|
2639
|
+
// without the global binary. guard-map = the [RULE] guard-bridge verify gate (D1);
|
|
2640
|
+
// guard-map-derive = build→map derivation seam (G2); milestone-state = sign-off /
|
|
2641
|
+
// isDefined predicate (G1); rule-consume = triad rule-set consumer (G3);
|
|
2642
|
+
// divergence-grammar = §4 parse/format round-trip (G4).
|
|
2643
|
+
"gsd-t-guard-map.cjs", "gsd-t-guard-map-derive.cjs", "gsd-t-milestone-state.cjs",
|
|
2644
|
+
"gsd-t-rule-consume.cjs", "gsd-t-divergence-grammar.cjs",
|
|
2645
|
+
// M93 — jargon-gloss lint for written docs (the file surface the brevity-guard
|
|
2646
|
+
// Stop hook can't reach). Propagated so a project's doc checks can invoke it.
|
|
2647
|
+
"gsd-t-jargon-lint.cjs",
|
|
2587
2648
|
];
|
|
2588
2649
|
|
|
2589
2650
|
// Files that older versions of this installer copied into project bin/ but
|
|
@@ -8,7 +8,7 @@ You are the doc-ripple agent. You identify and update all downstream documents a
|
|
|
8
8
|
preflight → brief (kind=doc-ripple) → doc-ripple agent (opus, with phase protocol)
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
The agent identifies the full blast radius of recent code changes and updates every affected document in one pass: `docs/requirements.md`, `docs/architecture.md`, `docs/workflows.md`, `.gsd-t/contracts/`, owning `scope.md`, `README.md`, and (for this repo) the 4 command-reference files when a command interface changed.
|
|
11
|
+
The agent identifies the full blast radius of recent code changes and updates every affected document in one pass: `docs/requirements.md`, `docs/architecture.md`, `docs/workflows.md`, `.gsd-t/contracts/`, `PseudoCode-[Title].md` (the intention-first behavior map — when the implemented behavior diverges from its pseudocode, ripple the change or write a `⚠ Divergence` flag per the source-of-truth contract §4), owning `scope.md`, `README.md`, and (for this repo) the 4 command-reference files when a command interface changed.
|
|
12
12
|
|
|
13
13
|
## Step 1: Determine the change set
|
|
14
14
|
|
|
@@ -2,14 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
You are the lead agent. Define a new milestone by invoking the generic upper-stage Workflow at `templates/workflows/gsd-t-phase.workflow.js` with `phase: "milestone"`. A milestone is a significant deliverable (e.g., "User Authentication", "Payment Integration").
|
|
4
4
|
|
|
5
|
+
## Default altitude: smallest change that hits the crux (M92)
|
|
6
|
+
|
|
7
|
+
**Before reaching for milestone ceremony, ask whether the ask even needs a milestone.** The default recommendation is the SMALLEST change that hits the crux — if the work is a one-file change or a focused fix, `/gsd-t-quick` (do it directly) is the answer, not a milestone with partition + plan→execute + competition.
|
|
8
|
+
|
|
9
|
+
Milestone ceremony — definition, partition into domains, plan→execute waves, Competition Mode — is the **opt-in escalation**, justified when the crux genuinely needs cross-domain coordination or carries real uncertainty (multiple coupled deliverables, several domains, a decomposition decision worth competing). It is never the implied-default "bigger is more rigorous." If you cannot name why the crux needs a milestone, the smaller path IS the recommendation.
|
|
10
|
+
|
|
5
11
|
## What this command does
|
|
6
12
|
|
|
7
13
|
```
|
|
8
14
|
preflight → brief (kind=milestone) → milestone agent (opus, with phase protocol)
|
|
15
|
+
→ TWO-ALTITUDE intention-first authoring flow (default-ON):
|
|
16
|
+
1. HIGH-LEVEL APPROACH altitude (what/why/when, actors, one-breath summary)
|
|
17
|
+
→ user signs off the APPROACH
|
|
18
|
+
2. DETAILED altitude — author `PseudoCode-[Title].md` at exemplar granularity
|
|
9
19
|
```
|
|
10
20
|
|
|
11
21
|
The agent defines the milestone — origin, goal, falsifiable success criteria — and appends it to `.gsd-t/progress.md`. Partition is deferred (the Next Up successor). Effort/scope is expressed in GSD-T-native units (domain count, wave count, spawn count) — never developer-hours.
|
|
12
22
|
|
|
23
|
+
## Two-Altitude Intention-First Flow (default-ON, M87)
|
|
24
|
+
|
|
25
|
+
A milestone is authored at **two altitudes, in order** — the high-level approach is signed off BEFORE the detailed decomposition is written. This is the intention-first source-of-truth flow (contract `.gsd-t/contracts/pseudocode-source-of-truth-contract.md` §1). It is a **prose flow** — the command DESCRIBES the sign-off checkpoint below; it does NOT bind a machine-checkable "DEFINED only after sign-off" predicate (that deterministic `isDefined(milestone)` gate is M88).
|
|
26
|
+
|
|
27
|
+
### Altitude 1 — High-Level Approach (signed off FIRST)
|
|
28
|
+
|
|
29
|
+
Before any field-level detail, the milestone agent emits a **high-level approach pseudocode** covering:
|
|
30
|
+
|
|
31
|
+
- **What / Why / When** — the directive the milestone serves, in the user's own intention (never agent reasoning).
|
|
32
|
+
- **Actors** — the parties/components/realms the approach touches.
|
|
33
|
+
- **One-breath summary** — "one call in one breath": the whole approach stated as a single coherent thought.
|
|
34
|
+
|
|
35
|
+
The agent then **presents this approach to the user for SIGN-OFF**. The user signs off the APPROACH before the detailed doc is written. This sign-off checkpoint is the gate between the two altitudes — the detailed `PseudoCode-[Title].md` is only authored AFTER the approach is approved.
|
|
36
|
+
|
|
37
|
+
### Altitude 2 — Detailed `PseudoCode-[Title].md`
|
|
38
|
+
|
|
39
|
+
Only after the approach is signed off, the agent authors the detailed `PseudoCode-[Title].md` at exemplar granularity (the five section elements of contract §1: Intention, Mechanism, one-breath summary table, Guard map, Divergence flags, Appendix). `[Title]` is the SUBJECT the doc represents (e.g. `PseudoCode-PayPal.md`), never a milestone id; a milestone may produce several. Per-milestone docs live at `.gsd-t/pseudocode/PseudoCode-[Title].md` (contract §7).
|
|
40
|
+
|
|
41
|
+
### Default-ON; skip is a LOGGED decision
|
|
42
|
+
|
|
43
|
+
The two-altitude flow is **default-ON**. Skipping the detailed-doc altitude is a **LOGGED decision** in `.gsd-t/progress.md` (Decision Log entry naming WHY it was skipped) — **never a silent default-off** (`feedback_no_silent_degradation`). A skip that is not logged is a process failure.
|
|
44
|
+
|
|
45
|
+
### Keep-or-supersede on inherited shipped code
|
|
46
|
+
|
|
47
|
+
When the milestone inherits a model from shipped code, the agent runs the **keep-or-supersede** protocol (`templates/prompts/keep-or-supersede-subagent.md`): per inherited model, ASK keep or supersede. Each **supersede** WRITES a `⚠ Divergence` flag into the doc (contract §4 grammar shape). Keep = no flag. The deterministic divergence-grammar round-trip is M88; the ASK + flag-writing is the M87 prose protocol.
|
|
48
|
+
|
|
13
49
|
## Step 1: Load context
|
|
14
50
|
|
|
15
51
|
Read `.gsd-t/progress.md` (current version + completed milestones), `docs/requirements.md`, and `docs/architecture.md` so the new milestone is framed against existing state.
|
|
@@ -75,7 +111,9 @@ The Workflow returns `{ status, artifacts, summary, decisions }` (plus `competit
|
|
|
75
111
|
|
|
76
112
|
## Document Ripple
|
|
77
113
|
|
|
78
|
-
The milestone agent appends the milestone definition + a Decision Log entry to `.gsd-t/progress.md
|
|
114
|
+
The milestone agent appends the milestone definition + a Decision Log entry to `.gsd-t/progress.md`, and:
|
|
115
|
+
|
|
116
|
+
- **`PseudoCode-[Title].md`** (at `.gsd-t/pseudocode/PseudoCode-[Title].md`) — the detailed intention-first source-of-truth doc authored at Altitude 2 (one per coherent subject; a milestone may produce several). Written only after the Altitude-1 approach is signed off. A skip is a LOGGED Decision Log entry, never a silent omission.
|
|
79
117
|
|
|
80
118
|
## Next Up
|
|
81
119
|
|
package/commands/gsd-t-quick.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
You are executing a small, focused task that doesn't need full phase planning. This is for bug fixes, config changes, small features, and ad-hoc work.
|
|
4
4
|
|
|
5
|
+
## Default altitude: smallest change that hits the crux (M92)
|
|
6
|
+
|
|
7
|
+
**The default recommendation is the SMALLEST change that hits the crux — do it directly.** Before choosing scope: state the crux in one line, grep/read what already exists, then make the smallest one-file change that hits it — editing inward at the source, not outward at the N consumers.
|
|
8
|
+
|
|
9
|
+
Ceremony — the full execute workflow, partition, plan→execute, competition — is the **opt-in escalation**, reached for ONLY when the crux genuinely needs cross-domain coordination or real uncertainty (see Step 2's boundary check). It is never the implied-default "Recommended." If you cannot name why the crux needs ceremony, the smallest change IS the answer.
|
|
10
|
+
|
|
5
11
|
## Argument Parsing
|
|
6
12
|
|
|
7
13
|
Parse `$ARGUMENTS`. The first positional arg is the quick task description (`$TASK`). M43 D4 removed the `--watch` opt-out; `--in-session`/`--headless` were never shipped. Under `.gsd-t/contracts/headless-default-contract.md` **v2.0.0** the inner subagent spawn (Step 0.1 fresh-dispatch) and all validation spawns (Design Verification Step 5.25, Red Team Step 5.5, doc-ripple Step 6) go headless unconditionally. A legacy `--watch` token is accepted but ignored (stderr deprecation line).
|
|
@@ -192,13 +198,14 @@ Based on $ARGUMENTS, determine:
|
|
|
192
198
|
- Does it cross a domain boundary?
|
|
193
199
|
- Does it affect any existing contract?
|
|
194
200
|
|
|
195
|
-
###
|
|
196
|
-
|
|
197
|
-
"This change touches {domain-1} and {domain-2} and may affect {contract}.
|
|
198
|
-
Should I proceed with quick mode or use the full execute workflow?"
|
|
201
|
+
### Default — within a single domain or pre-partition:
|
|
202
|
+
The smallest change that hits the crux is the recommendation. Proceed directly.
|
|
199
203
|
|
|
200
|
-
###
|
|
201
|
-
|
|
204
|
+
### Escalate to ceremony ONLY when the crux needs it:
|
|
205
|
+
If — and only if — the change genuinely crosses domain boundaries or affects a contract (real cross-domain coordination / real uncertainty), warn the user:
|
|
206
|
+
"This change touches {domain-1} and {domain-2} and may affect {contract}.
|
|
207
|
+
The smallest direct change does not contain the crux — escalate to the full execute workflow?"
|
|
208
|
+
This is the opt-in escalation, justified by the crux — not a default.
|
|
202
209
|
|
|
203
210
|
## Step 3: Execute
|
|
204
211
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.11",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|