@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.
@@ -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
- function listTasksFiles(projectDir, milestone) {
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 out = [];
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)) out.push({ domain: e.name, 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 = out.filter((d) => d.domain.toLowerCase().startsWith(mPrefix));
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 out;
411
+ return { files: all };
249
412
  }
250
413
 
251
- function runGate({ projectDir = process.cwd(), milestone = null, tasksFile = null } = {}) {
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
- files = listTasksFiles(projectDir, milestone);
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). Asserts every behavioral task in
313
- the milestone's .gsd-t/domains/* /tasks.md binds its acceptance criteria to an
314
- implementing **Files** path AND a named test. Headline tasks must have BOTH a
315
- real implementation path and a test. Blocks execute on any violation.
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 Limit to domains whose name carries the mNN prefix.
318
- --project-dir P Project root (default: cwd).
319
- --tasks FILE Check a single tasks.md (overrides domain discovery).
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 no tasks files / bad input.`;
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 = { runGate, parseTasks, assessTask, _internal: { fieldValue, TEST_PATH_RE } };
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
@@ -2584,6 +2584,14 @@ const PROJECT_BIN_TOOLS = [
2584
2584
  // Backlog #40 — deterministic archive+sweep of a completed milestone's domain dirs
2585
2585
  // (complete-milestone Step 7). Propagated so complete-milestone can invoke it project-local.
2586
2586
  "gsd-t-archive-domains.cjs",
2587
+ // M91 (merged M87+M88) — PseudoCode source-of-truth deterministic gates, propagated so
2588
+ // project-local runCli helpers (gsd-t-verify / gsd-t-milestone workflows) resolve them
2589
+ // without the global binary. guard-map = the [RULE] guard-bridge verify gate (D1);
2590
+ // guard-map-derive = build→map derivation seam (G2); milestone-state = sign-off /
2591
+ // isDefined predicate (G1); rule-consume = triad rule-set consumer (G3);
2592
+ // divergence-grammar = §4 parse/format round-trip (G4).
2593
+ "gsd-t-guard-map.cjs", "gsd-t-guard-map-derive.cjs", "gsd-t-milestone-state.cjs",
2594
+ "gsd-t-rule-consume.cjs", "gsd-t-divergence-grammar.cjs",
2587
2595
  ];
2588
2596
 
2589
2597
  // 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
 
@@ -6,10 +6,40 @@ You are the lead agent. Define a new milestone by invoking the generic upper-sta
6
6
 
7
7
  ```
8
8
  preflight → brief (kind=milestone) → milestone agent (opus, with phase protocol)
9
+ → TWO-ALTITUDE intention-first authoring flow (default-ON):
10
+ 1. HIGH-LEVEL APPROACH altitude (what/why/when, actors, one-breath summary)
11
+ → user signs off the APPROACH
12
+ 2. DETAILED altitude — author `PseudoCode-[Title].md` at exemplar granularity
9
13
  ```
10
14
 
11
15
  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
16
 
17
+ ## Two-Altitude Intention-First Flow (default-ON, M87)
18
+
19
+ 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).
20
+
21
+ ### Altitude 1 — High-Level Approach (signed off FIRST)
22
+
23
+ Before any field-level detail, the milestone agent emits a **high-level approach pseudocode** covering:
24
+
25
+ - **What / Why / When** — the directive the milestone serves, in the user's own intention (never agent reasoning).
26
+ - **Actors** — the parties/components/realms the approach touches.
27
+ - **One-breath summary** — "one call in one breath": the whole approach stated as a single coherent thought.
28
+
29
+ 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.
30
+
31
+ ### Altitude 2 — Detailed `PseudoCode-[Title].md`
32
+
33
+ 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).
34
+
35
+ ### Default-ON; skip is a LOGGED decision
36
+
37
+ 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.
38
+
39
+ ### Keep-or-supersede on inherited shipped code
40
+
41
+ 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.
42
+
13
43
  ## Step 1: Load context
14
44
 
15
45
  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 +105,9 @@ The Workflow returns `{ status, artifacts, summary, decisions }` (plus `competit
75
105
 
76
106
  ## Document Ripple
77
107
 
78
- The milestone agent appends the milestone definition + a Decision Log entry to `.gsd-t/progress.md`.
108
+ The milestone agent appends the milestone definition + a Decision Log entry to `.gsd-t/progress.md`, and:
109
+
110
+ - **`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
111
 
80
112
  ## Next Up
81
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "4.7.11",
3
+ "version": "4.8.10",
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",
@@ -66,6 +66,7 @@ These documents MUST be maintained and referenced throughout development:
66
66
  | **README** | `README.md` | Project overview, setup, features |
67
67
  | **Progress** | `.gsd-t/progress.md` | Current milestone/phase state + version |
68
68
  | **Contracts** | `.gsd-t/contracts/` | Interfaces between domains |
69
+ | **PseudoCode** | `.gsd-t/pseudocode/PseudoCode-[Title].md` | Intention-first behavior map — the milestone source-of-truth (authored before the build; rippled when code/contract/schema changes) |
69
70
  | **Tech Debt** | `.gsd-t/techdebt.md` | Debt register from scans |
70
71
 
71
72
  ## The "No Re-Research" Rule
@@ -278,6 +279,7 @@ NEVER commit without this checklist. For each trigger that fired, do the action
278
279
  - **UI component interface changed** → `.gsd-t/contracts/component-contract.md`.
279
280
  - **New files/dirs** → owning domain's `scope.md`.
280
281
  - **Requirement implemented/changed** → `docs/requirements.md`.
282
+ - **Behavior/intention changed vs. the signed-off pseudocode** → update the owning `PseudoCode-[Title].md` (the milestone source-of-truth) in the same pass; a supersede writes a `⚠ Divergence` flag.
281
283
  - **Component or data-flow changed** → `docs/architecture.md`.
282
284
  - **ANY document/script/code file modified** → timestamped `.gsd-t/progress.md` Decision Log entry (`- YYYY-MM-DD HH:MM: {what} — {result}`); covers every workflow command AND manual edits. Architectural decision → include rationale in that entry.
283
285
  - **Tech debt found/fixed** → `.gsd-t/techdebt.md`.