@tekyzinc/gsd-t 4.0.12 → 4.0.15

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.
@@ -0,0 +1,711 @@
1
+ // templates/workflows/gsd-t-scan.workflow.js
2
+ //
3
+ // Runtime: Anthropic native Workflow tool only (not standalone-Node parseable).
4
+ // Globals provided by runtime: agent, parallel, pipeline, log, phase, budget, args.
5
+ // Top-level await + return are runtime-legal in that context.
6
+ //
7
+ // Canonical native-Workflow implementation of the GSD-T scan phase.
8
+ //
9
+ // WHY THIS EXISTS (M66): the legacy commands/gsd-t-scan.md was the ONLY major
10
+ // phase never migrated to a Workflow. It hard-coded exactly 5 teammates (one per
11
+ // DIMENSION: architecture/business-rules/security/quality/contracts) with ZERO
12
+ // volume scaling — a 5-file repo and a 1,809-file repo both got 5 agents. A single
13
+ // `quality` agent asked to cover dead-code+dup+complexity+errors+perf+test-gaps
14
+ // across the whole codebase samples ~5 issues and stops. That produced a cursory
15
+ // 16-item register on a codebase whose deep scan surfaced 117 findings.
16
+ // It also referenced a retired `autoSpawnHeadless()` + `headless-default-contract
17
+ // v2.0.0` that no longer exist post-M61/M65.
18
+ //
19
+ // THE FIX: fan out by codebase VOLUME, not by a fixed dimension count.
20
+ // preflight → volume-probe (derive per-AREA slice list) →
21
+ // pipeline( slice → deep-finder "enumerate, do not sample" → single verify ) →
22
+ // archive prior register → synthesis (dedup/merge/re-rank, continue TD numbering) →
23
+ // deterministic bin/scan-*.js stages (schema / diagrams / HTML report).
24
+ //
25
+ // The number of finders scales with the slice list the probe derives, and slice
26
+ // DEPTH scales with budget.total when a turn target is set. KEEPS the brains:
27
+ // preflight + the deterministic bin/scan-*.js renderers.
28
+ //
29
+ // args shape:
30
+ // {
31
+ // projectDir: ".", // optional — the project to scan
32
+ // scanNumber: 12, // optional — for the register header
33
+ // maxSlicesHint: 40, // optional — soft cap on derived slices
34
+ // verify: "single", // optional — "single" (default) | "none"
35
+ // }
36
+
37
+ export const meta = {
38
+ name: "gsd-t-scan",
39
+ description:
40
+ "GSD-T scan phase: preflight → volume-probe → pipeline(deep-finder per slice → single verify) → archive → synthesis → deep document cross-population (living docs + dimension files) → deterministic schema/diagram/HTML render. Fans out by codebase volume, not a fixed 5-teammate dimension count.",
41
+ phases: [
42
+ { title: "Preflight", detail: "preflight + load prior register" },
43
+ { title: "Probe", detail: "volume probe → derive per-area slice list", model: "haiku" },
44
+ { title: "Deep Scan", detail: "pipeline: per-slice deep finder → single verify" },
45
+ { title: "Synthesis", detail: "dedup / merge / re-rank into techdebt.md", model: "opus" },
46
+ { title: "Document", detail: "deep living-doc + dimension-file cross-population (per-doc fan-out)" },
47
+ { title: "Render", detail: "schema extraction + diagrams + HTML report" },
48
+ ],
49
+ };
50
+
51
+ const lib = require("./_lib.js");
52
+ const { spawnSync } = require("child_process");
53
+ const fs = require("fs");
54
+ const path = require("path");
55
+
56
+ const projectDir = (args && args.projectDir) || ".";
57
+ const scanNumber = (args && args.scanNumber) || null;
58
+ const maxSlicesHint = (args && args.maxSlicesHint) || 40;
59
+ const verifyMode = (args && args.verify) || "single"; // "single" | "none"
60
+
61
+ // ───── Schemas ──────────────────────────────────────────────────────────────
62
+
63
+ // Probe output: a list of slices to fan out over. Each slice is one narrow area
64
+ // of the codebase a single deep-finder agent can exhaustively own.
65
+ const PROBE_SCHEMA = {
66
+ type: "object",
67
+ required: ["totals", "slices"],
68
+ additionalProperties: false,
69
+ properties: {
70
+ totals: {
71
+ type: "object",
72
+ additionalProperties: true,
73
+ description: "Headline counts: files, routes, tables, components, testFiles, topLevelDirs.",
74
+ },
75
+ slices: {
76
+ type: "array",
77
+ minItems: 1,
78
+ items: {
79
+ type: "object",
80
+ required: ["key", "paths", "dimension"],
81
+ additionalProperties: false,
82
+ properties: {
83
+ key: { type: "string", description: "kebab slice id, e.g. 'lib-billing' or 'routes-tenant-scoping'" },
84
+ paths: { type: "array", items: { type: "string" }, description: "globs/dirs this slice exhaustively owns" },
85
+ dimension: {
86
+ type: "string",
87
+ enum: ["architecture", "business-rules", "security", "quality", "contracts", "feature-domain", "data-layer", "api-surface", "testing"],
88
+ },
89
+ why: { type: "string", description: "what makes this slice worth a dedicated deep finder" },
90
+ },
91
+ },
92
+ },
93
+ notes: { type: "string" },
94
+ },
95
+ };
96
+
97
+ const FINDER_SCHEMA = {
98
+ type: "object",
99
+ required: ["slice", "findings"],
100
+ additionalProperties: false,
101
+ properties: {
102
+ slice: { type: "string" },
103
+ findings: {
104
+ type: "array",
105
+ items: {
106
+ type: "object",
107
+ required: ["title", "severity", "area", "files", "detail", "recommendation"],
108
+ additionalProperties: false,
109
+ properties: {
110
+ title: { type: "string" },
111
+ severity: { type: "string", enum: ["CRITICAL", "HIGH", "MEDIUM", "LOW"] },
112
+ area: { type: "string", description: "human area label, e.g. 'Multi-tenant isolation'" },
113
+ files: { type: "array", items: { type: "string" } },
114
+ detail: { type: "string" },
115
+ impact: { type: "string" },
116
+ recommendation: { type: "string" },
117
+ confidence: { type: "string", enum: ["high", "medium", "low"] },
118
+ },
119
+ },
120
+ },
121
+ notes: { type: "string" },
122
+ },
123
+ };
124
+
125
+ const VERIFY_SCHEMA = {
126
+ type: "object",
127
+ required: ["confirmed", "verdict"],
128
+ additionalProperties: false,
129
+ properties: {
130
+ confirmed: { type: "boolean", description: "true if the finding is real after checking the actual code" },
131
+ verdict: { type: "string", enum: ["confirmed", "false-positive", "needs-detail"] },
132
+ note: { type: "string" },
133
+ correctedSeverity: { type: "string", enum: ["CRITICAL", "HIGH", "MEDIUM", "LOW"] },
134
+ },
135
+ };
136
+
137
+ const DOC_RESULT_SCHEMA = {
138
+ type: "object",
139
+ required: ["doc", "status", "path"],
140
+ additionalProperties: false,
141
+ properties: {
142
+ doc: { type: "string", description: "logical doc id, e.g. 'docs/architecture.md'" },
143
+ status: { type: "string", enum: ["written", "merged", "skipped", "failed"] },
144
+ path: { type: "string" },
145
+ bytes: { type: "integer" },
146
+ notes: { type: "string" },
147
+ },
148
+ };
149
+
150
+ const SYNTHESIS_SCHEMA = {
151
+ type: "object",
152
+ required: ["status", "registerPath", "counts"],
153
+ additionalProperties: false,
154
+ properties: {
155
+ status: { type: "string", enum: ["written", "failed"] },
156
+ registerPath: { type: "string" },
157
+ counts: {
158
+ type: "object",
159
+ required: ["critical", "high", "medium", "low"],
160
+ properties: {
161
+ critical: { type: "integer" },
162
+ high: { type: "integer" },
163
+ medium: { type: "integer" },
164
+ low: { type: "integer" },
165
+ total: { type: "integer" },
166
+ },
167
+ },
168
+ archivePath: { type: "string" },
169
+ notes: { type: "string" },
170
+ },
171
+ };
172
+
173
+ // ───── Local-bin resolution for the deterministic bin/scan-*.js renderers ─────
174
+ // Mirrors verify.workflow.js::_runJsonCli — prefer project-local bin/, the scan
175
+ // renderers live in the GSD-T package bin/ which is the project root here.
176
+ function _runNode(scriptRelPath, evalExpr, argv = []) {
177
+ const local = path.join(projectDir, scriptRelPath);
178
+ if (!fs.existsSync(local)) return { ok: false, exitCode: 127, stderr: `missing ${scriptRelPath}` };
179
+ const r = spawnSync(process.execPath, ["-e", evalExpr, ...argv], { cwd: projectDir, stdio: "pipe" });
180
+ return {
181
+ ok: r.status === 0,
182
+ exitCode: r.status,
183
+ stdout: r.stdout && r.stdout.toString(),
184
+ stderr: r.stderr && r.stderr.toString(),
185
+ };
186
+ }
187
+
188
+ // ───── Script body ────────────────────────────────────────────────────────────
189
+
190
+ phase("Preflight");
191
+ const pre = lib.runPreflight({ projectDir });
192
+ if (!pre.ok) {
193
+ log(`preflight FAIL exitCode=${pre.exitCode} — halting scan`);
194
+ return { status: "failed", reason: "preflight-failed", preflight: pre.envelope };
195
+ }
196
+ log("preflight OK");
197
+
198
+ // Load prior register (for dedup + TD-numbering continuation).
199
+ // Red-team fix (CRITICAL-1/CRITICAL-2/MEDIUM-1): the archive + TD-numbering must be
200
+ // DETERMINISTIC JS, not LLM prose — an agent told to "archive then rewrite" can
201
+ // clobber the register without archiving, and an agent told to "continue numbering"
202
+ // with no data hallucinates a start number → colliding TD IDs. So we do the rename
203
+ // here (collision-safe), parse the max TD number, and PASS the prior content into
204
+ // synthesis. This mirrors verify.workflow.js, where every destructive gate is
205
+ // deterministic code, not an agent instruction.
206
+ let priorRegister = null;
207
+ let priorArchivePath = null;
208
+ let tdStart = 1;
209
+ const registerPath = path.join(projectDir, ".gsd-t", "techdebt.md");
210
+
211
+ function _parseMaxTd(text) {
212
+ // Match TD-1, TD-01, TD-001, TD-117 in any "### TD-NNN" or "TD-NNN" form.
213
+ const nums = [];
214
+ const re = /TD-0*(\d+)/g;
215
+ let m;
216
+ while ((m = re.exec(text)) !== null) nums.push(parseInt(m[1], 10));
217
+ return nums.length ? Math.max(...nums) : 0;
218
+ }
219
+ function _archiveDateFrom(text, fallbackDate) {
220
+ // Prefer an explicit YYYY-MM-DD in the header; else use the fallback (mtime-derived).
221
+ const m = text.match(/\b(\d{4}-\d{2}-\d{2})\b/);
222
+ return (m && m[1]) || fallbackDate;
223
+ }
224
+
225
+ if (fs.existsSync(registerPath)) {
226
+ priorRegister = fs.readFileSync(registerPath, "utf8");
227
+ tdStart = _parseMaxTd(priorRegister) + 1;
228
+ // Derive archive date deterministically: header date, else file mtime (NOT Date.now —
229
+ // unavailable in this runtime). Collision-safe suffixing.
230
+ const mtime = fs.statSync(registerPath).mtime;
231
+ const fallbackDate = mtime.toISOString().slice(0, 10);
232
+ const archiveDate = _archiveDateFrom(priorRegister, fallbackDate);
233
+ const scanDir = path.join(projectDir, ".gsd-t");
234
+ let candidate = path.join(scanDir, `techdebt_${archiveDate}.md`);
235
+ let counter = 2;
236
+ while (fs.existsSync(candidate)) {
237
+ candidate = path.join(scanDir, `techdebt_${archiveDate}_${counter}.md`);
238
+ counter += 1;
239
+ }
240
+ fs.renameSync(registerPath, candidate);
241
+ priorArchivePath = candidate;
242
+ log(`prior register archived → ${path.basename(candidate)} (${priorRegister.length} bytes); TD numbering continues at TD-${tdStart}`);
243
+ } else {
244
+ log(`no prior register — TD numbering starts at TD-01`);
245
+ }
246
+
247
+ // ─── Volume probe — derive the slice list (this is the fix for the 5-teammate cap) ───
248
+ phase("Probe");
249
+ const probePrompt = [
250
+ `You are the VOLUME PROBE for a GSD-T deep codebase scan of \`${projectDir}\`.`,
251
+ ``,
252
+ `Your job: measure the codebase's volume, then carve it into NARROW SLICES — one`,
253
+ `slice per area that a single deep-finder agent can EXHAUSTIVELY own. The number`,
254
+ `of slices MUST scale with volume: a tiny repo yields 1-3 slices; a large repo`,
255
+ `(thousands of files, hundreds of routes/tables, many feature domains) yields`,
256
+ `15-40. Do NOT default to a fixed 5 — that under-scaling is exactly the bug M66 fixes.`,
257
+ ``,
258
+ `How to slice (combine these axes — prefer FEATURE-DOMAIN slicing for large apps):`,
259
+ `- By feature domain: each major business area (e.g. billing, scheduling, dispatch,`,
260
+ ` work-orders, LMS, maintenance, integrations) is its own slice, owned end-to-end.`,
261
+ `- By layer where a layer is huge: routes/API surface, data/schema layer, the`,
262
+ ` largest component trees, async jobs/queues.`,
263
+ `- By cross-cutting concern: tenant-isolation, secrets/config, auth, rate-limiting.`,
264
+ `Each slice names the concrete \`paths\` (dirs/globs) it owns and a \`dimension\`.`,
265
+ ``,
266
+ `Use real tooling to measure: count files by extension, count route modules,`,
267
+ `count ORM table definitions (e.g. pgTable/Entity/model), count components, list`,
268
+ `top-level source dirs and their subdirs. Read package.json for the stack. If a`,
269
+ `single dir (e.g. src/lib/) has many independent subdirs, each substantial subdir`,
270
+ `is a candidate slice.`,
271
+ ``,
272
+ `Soft cap: aim for ≤ ${maxSlicesHint} slices. If volume genuinely exceeds that,`,
273
+ `merge the smallest related areas but state in notes what you merged so depth loss`,
274
+ `is visible (no silent truncation).`,
275
+ ``,
276
+ `Return JSON per the schema: totals (headline counts) + slices (the fan-out list).`,
277
+ ].join("\n");
278
+
279
+ // Red-team fix: probe runs on SONNET, not haiku. The probe's whole job is to
280
+ // measure volume and resist under-slicing; a haiku probe that under-counts a large
281
+ // repo (truncated tooling output → guesses) would re-introduce the exact under-scaling
282
+ // M66 fixes. Sonnet is the right tier for this measurement+judgment task.
283
+ const probe = await agent(probePrompt, {
284
+ label: "volume-probe",
285
+ phase: "Probe",
286
+ schema: PROBE_SCHEMA,
287
+ model: "sonnet",
288
+ });
289
+
290
+ const slices = (probe && Array.isArray(probe.slices) && probe.slices) || [];
291
+ if (!slices.length) {
292
+ log("probe returned no slices — halting (cannot scan with an empty slice list)");
293
+ return { status: "failed", reason: "no-slices", probe };
294
+ }
295
+ log(`probe derived ${slices.length} slice(s) from totals=${JSON.stringify(probe.totals)}`);
296
+
297
+ // Red-team fix (HIGH-2): deterministic silent-truncation detection. If the probe hit
298
+ // the soft cap, the operator MUST be told whether coverage was merged away. CLAUDE.md
299
+ // forbids silent caps — so we surface a coverage-risk warning in code, not just prose.
300
+ if (slices.length >= maxSlicesHint) {
301
+ if (probe.notes && /merg/i.test(probe.notes)) {
302
+ log(`⚠ COVERAGE NOTE: probe hit the ${maxSlicesHint}-slice cap and merged areas — see probe.notes: ${probe.notes}`);
303
+ } else {
304
+ log(`⚠ COVERAGE RISK: probe returned ${slices.length} slices (≥ cap ${maxSlicesHint}) with NO documented merges. Some areas may be under-covered. Re-run with a higher maxSlicesHint to confirm full coverage.`);
305
+ }
306
+ }
307
+
308
+ // budget-aware depth hint: with a larger turn target, finders are told to dig deeper.
309
+ // "thorough" and "MAXIMUM" are defined inline in the finder prompt so the hint is actionable.
310
+ const deep = budget && budget.total && budget.total > 300000 ? "MAXIMUM" : "thorough";
311
+
312
+ // ─── Deep scan — pipeline: per-slice deep finder → single verify (no barrier) ───
313
+ // pipeline() runs each slice through both stages independently: slice A can be in
314
+ // verify while slice B is still finding. Wall-clock = slowest single chain.
315
+ phase("Deep Scan");
316
+ const sliceResults = await pipeline(
317
+ slices,
318
+ // Stage 1 — deep finder. One agent OWNS one slice and enumerates exhaustively.
319
+ (slice) => agent(
320
+ [
321
+ `You are a DEEP tech-debt finder for ONE slice of a GSD-T scan: \`${slice.key}\`.`,
322
+ `Dimension: ${slice.dimension}. Owned paths: ${JSON.stringify(slice.paths)}.`,
323
+ slice.why ? `Why this slice matters: ${slice.why}` : ``,
324
+ ``,
325
+ `MANDATE: ENUMERATE, do NOT sample. Walk EVERY file under your owned paths.`,
326
+ `The legacy scan failed because one agent sampled the top ~5 issues across the`,
327
+ `whole repo and stopped. You own only this slice, so go to the bottom of it.`,
328
+ ``,
329
+ `Depth = ${deep}. "thorough" = walk every file in your paths and report every`,
330
+ `non-trivial real defect, high and medium confidence. "MAXIMUM" = additionally`,
331
+ `include lower-confidence and speculative issues worth a human's review.`,
332
+ `Surface every real defect: bugs, security holes, missing validation, broken`,
333
+ `invariants, race conditions, dead/duplicated code, N+1s, untested critical`,
334
+ `paths, contract drift, and domain-specific correctness flaws (e.g. money math,`,
335
+ `state-machine gaps, timezone bugs, idempotency holes).`,
336
+ ``,
337
+ `For each finding give: title, severity (CRITICAL/HIGH/MEDIUM/LOW), a human area`,
338
+ `label, concrete file:line refs, the detail, the impact, and a remediation.`,
339
+ `Set confidence honestly. If this slice is a substantial area (many files), a`,
340
+ `result of only 1-2 findings is suspicious — re-check before concluding it is`,
341
+ `clean. If the slice is genuinely clean, return an empty findings array.`,
342
+ ``,
343
+ `Return JSON per the schema.`,
344
+ ].filter(Boolean).join("\n"),
345
+ { label: `find:${slice.key}`, phase: "Deep Scan", schema: FINDER_SCHEMA, model: "sonnet" }
346
+ ),
347
+ // Stage 2 — single verify pass (per user decision: single, not 3-vote).
348
+ // Confirms each finding against the ACTUAL code; drops false positives.
349
+ // Red-team fix (HIGH-3): pipeline() stage-2 contract is (prevResult, originalItem, index).
350
+ // Defend against an unexpected runtime signature so a missing `slice` arg can never
351
+ // throw `slice.key` and silently drop a whole slice's findings (which would resolve
352
+ // to null at the pipeline level). Recover the key from the finder result if needed.
353
+ async (finderResult, originalItem, _index) => {
354
+ const slice = originalItem || {};
355
+ const sliceKey = slice.key || (finderResult && finderResult.slice) || "unknown-slice";
356
+ if (!finderResult || !Array.isArray(finderResult.findings)) {
357
+ return { slice: sliceKey, findings: [] };
358
+ }
359
+ if (verifyMode === "none" || finderResult.findings.length === 0) {
360
+ return { slice: sliceKey, findings: finderResult.findings || [] };
361
+ }
362
+ const verified = await parallel(
363
+ finderResult.findings.map((f) => async () => {
364
+ try {
365
+ const v = await agent(
366
+ [
367
+ `You are a VERIFIER for one tech-debt finding. Confirm it against the ACTUAL code — do not trust the finder.`,
368
+ `Open the referenced files and check the claim is real and correctly characterized.`,
369
+ ``,
370
+ `Finding: ${JSON.stringify(f)}`,
371
+ ``,
372
+ `Set confirmed=true only if the defect genuinely exists. If the finder`,
373
+ `misread the code, return verdict="false-positive". If real but the`,
374
+ `severity is wrong, set correctedSeverity. If real but underspecified,`,
375
+ `verdict="needs-detail" (still kept). Return JSON per the schema.`,
376
+ ].join("\n"),
377
+ { label: `verify:${sliceKey}`, phase: "Deep Scan", schema: VERIFY_SCHEMA, model: "sonnet" }
378
+ );
379
+ if (!v || v.verdict === "false-positive" || v.confirmed === false) return null;
380
+ return { ...f, severity: v.correctedSeverity || f.severity, _verify: v.verdict };
381
+ } catch (e) {
382
+ // verify errored — keep the finding flagged rather than silently drop it
383
+ return { ...f, _verify: "verify-errored" };
384
+ }
385
+ })
386
+ );
387
+ return { slice: sliceKey, findings: verified.filter(Boolean), notes: finderResult.notes };
388
+ }
389
+ );
390
+
391
+ const allFindings = sliceResults
392
+ .filter(Boolean)
393
+ .flatMap((r) => (r.findings || []).map((f) => ({ ...f, slice: r.slice })));
394
+ log(`deep scan complete: ${allFindings.length} verified findings across ${sliceResults.filter(Boolean).length} slices`);
395
+
396
+ // ─── Synthesis — archive prior register, dedup/merge/re-rank, write fresh register ───
397
+ phase("Synthesis");
398
+ // Red-team fix (CRITICAL-1/CRITICAL-2): the archive already happened in JS above.
399
+ // Synthesis no longer touches the archive — it gets the prior register CONTENT and the
400
+ // deterministically-computed starting TD number, so dedup + numbering can't hallucinate.
401
+ // Red-team fix (HIGH-1): synthesis ALSO writes the .gsd-t/scan/*.md dimension files the
402
+ // deterministic renderer (bin/scan-data-collector.js) reads, so the HTML report reflects
403
+ // real data instead of silently rendering a hollow shell. scanNumber (formerly dead) is
404
+ // threaded into the register header here.
405
+ const synthesisPrompt = [
406
+ `You are the SYNTHESIS agent for a GSD-T deep scan of \`${projectDir}\`.`,
407
+ `${slices.length} slices ran; ${allFindings.length} verified findings came back.`,
408
+ scanNumber ? `This is scan #${scanNumber} — put it in the register header.` : ``,
409
+ ``,
410
+ priorRegister
411
+ ? [
412
+ `The prior register was ALREADY archived (in code) to \`${priorArchivePath ? path.basename(priorArchivePath) : "an archive"}\`.`,
413
+ `Start TD numbering at TD-${tdStart} (computed deterministically from the highest`,
414
+ `prior TD number — do NOT renumber existing items or restart at 1).`,
415
+ `DEDUPLICATE against the prior register below: do not re-add a finding already`,
416
+ `represented there; cross-reference the prior TD id instead.`,
417
+ ``,
418
+ `Prior register content (for dedup + numbering reference):`,
419
+ "```markdown",
420
+ priorRegister.slice(0, 20000),
421
+ "```",
422
+ ].join("\n")
423
+ : `No prior register — start TD numbering at TD-${String(tdStart).padStart(2, "0")}.`,
424
+ ``,
425
+ `Verified findings:`,
426
+ "```json",
427
+ JSON.stringify(allFindings, null, 2),
428
+ "```",
429
+ ``,
430
+ `Write a FRESH \`.gsd-t/techdebt.md\` (use the Write tool). Structure per the GSD-T`,
431
+ `register format: a Summary table (CRITICAL/HIGH/MEDIUM/LOW counts), then sections`,
432
+ `Critical → High → Medium → Low, each finding as \`### TD-NNN — {title}\` with`,
433
+ `Area / Severity / Status: OPEN / Location (file:line) / Description / Impact /`,
434
+ `Remediation / Milestone candidate fields. Re-rank globally by true severity, not`,
435
+ `by slice order. De-duplicate findings that multiple slices surfaced (e.g. a`,
436
+ `cross-cutting tenant-scoping gap) into one item that lists all locations.`,
437
+ ``,
438
+ `Write ONLY the register here. The .gsd-t/scan/*.md analysis files and the living`,
439
+ `docs are produced by the Document phase that runs next (M67) — do not write them.`,
440
+ ``,
441
+ `Do NOT express effort in human-hours/days/sprints — GSD-T units only (domain/`,
442
+ `wave/spawn/token-spend) per the effort-estimates rule. Then commit the new`,
443
+ `register via git (you are on a feature branch; do not push).`,
444
+ `Return JSON per the schema with the final counts and the archivePath`,
445
+ `\`${priorArchivePath || ""}\`.`,
446
+ ].filter(Boolean).join("\n");
447
+
448
+ const synthesis = await agent(synthesisPrompt, {
449
+ label: "synthesis",
450
+ phase: "Synthesis",
451
+ schema: SYNTHESIS_SCHEMA,
452
+ model: "opus",
453
+ });
454
+
455
+ if (!synthesis || synthesis.status !== "written") {
456
+ log("synthesis did not write the register — halting before render");
457
+ return { status: "failed", reason: "synthesis-failed", synthesis, findingCount: allFindings.length, archivePath: priorArchivePath };
458
+ }
459
+ // Deterministic confirmation the register actually landed (don't trust the agent's status alone).
460
+ if (!fs.existsSync(registerPath)) {
461
+ log("synthesis reported written but .gsd-t/techdebt.md is absent — halting");
462
+ return { status: "failed", reason: "register-missing", synthesis, archivePath: priorArchivePath };
463
+ }
464
+ log(`register written: ${JSON.stringify(synthesis.counts)}`);
465
+
466
+ // ─── Document — deep living-doc + dimension-file cross-population (M67) ───
467
+ // M66 made the register deep but left doc cross-population as a non-deterministic
468
+ // "lead agent follow-on" — effectively dropped. M67 fans out one agent PER DOCUMENT,
469
+ // each drawing on the SAME slices + verified findings the finders produced, so the
470
+ // docs are as thorough as the register. Runs BEFORE Render so the HTML report reads
471
+ // the deep .gsd-t/scan/architecture.md (with the parseable file/LOC line) instead of
472
+ // a stub. Per-doc failures are non-fatal (the register is authoritative) but logged.
473
+ phase("Document");
474
+
475
+ // Red-team fix (HIGH-1): the living-doc agents "merge not overwrite" via prose, and a
476
+ // dirty working tree does NOT halt the scan (working-tree-state is a warn, not error).
477
+ // So a doc agent could clobber a user's UNCOMMITTED edits to docs/ or README.md —
478
+ // unrecoverable via git. Deterministic backstop: snapshot every existing living doc to
479
+ // .gsd-t/scan/.doc-backup/ BEFORE the fan-out, mirroring the deterministic archive the
480
+ // register gets. Recovery is then a plain file copy, regardless of git state.
481
+ const LIVING_DOCS = [
482
+ "docs/architecture.md",
483
+ "docs/workflows.md",
484
+ "docs/infrastructure.md",
485
+ "docs/requirements.md",
486
+ "README.md",
487
+ ];
488
+ const docBackupDir = path.join(projectDir, ".gsd-t", "scan", ".doc-backup");
489
+ const backedUp = [];
490
+ fs.mkdirSync(docBackupDir, { recursive: true });
491
+ for (const rel of LIVING_DOCS) {
492
+ const src = path.join(projectDir, rel);
493
+ if (fs.existsSync(src)) {
494
+ const dest = path.join(docBackupDir, rel.replace(/[\/]/g, "__"));
495
+ fs.copyFileSync(src, dest);
496
+ backedUp.push(rel);
497
+ }
498
+ }
499
+ if (backedUp.length) {
500
+ log(`backed up ${backedUp.length} existing living doc(s) to .gsd-t/scan/.doc-backup/ before the document phase (recover by copying back if a merge clobbers content): ${backedUp.join(", ")}`);
501
+ }
502
+
503
+ const sliceSummary = slices.map((s) => `- ${s.key} (${s.dimension}): ${JSON.stringify(s.paths)}`).join("\n");
504
+ const findingsByArea = {};
505
+ for (const f of allFindings) {
506
+ const k = (f.area || "general").toLowerCase();
507
+ (findingsByArea[k] = findingsByArea[k] || []).push(f);
508
+ }
509
+ const findingsJson = JSON.stringify(allFindings, null, 2).slice(0, 40000);
510
+
511
+ // Each entry: { id, label, prompt }. mergeNote is appended to every living-doc prompt.
512
+ const mergeNote =
513
+ `If the file already exists with real content: MERGE, and do it with the Edit tool ` +
514
+ `(targeted section edits/appends) — do NOT call Write on a pre-existing file, because ` +
515
+ `Write is a full overwrite that would destroy the user's structure and any content you ` +
516
+ `did not reproduce. Preserve the user's structure and custom content; update/add sections. ` +
517
+ `If the file is only placeholder/template tokens, you may replace it. If absent, create it ` +
518
+ `with Write. Replace {Project Name}/{Date} tokens with real values (date from the system ` +
519
+ `clock). Do NOT invent facts — derive everything from the slices, findings, and the actual ` +
520
+ `code you read. (A pre-edit backup exists at .gsd-t/scan/.doc-backup/ as a safety net, but ` +
521
+ `do not rely on it — edit carefully.)`;
522
+
523
+ const baseCtx = [
524
+ `Project: \`${projectDir}\`. Probe totals: ${JSON.stringify(probe.totals)}.`,
525
+ ``,
526
+ `Slices the scan covered (your raw material — each names the paths it owns):`,
527
+ sliceSummary,
528
+ ``,
529
+ `Verified findings (truncated):`,
530
+ "```json",
531
+ findingsJson,
532
+ "```",
533
+ ].join("\n");
534
+
535
+ const docTargets = [
536
+ {
537
+ id: "scan-architecture", label: "scan:architecture",
538
+ prompt: `Write \`.gsd-t/scan/architecture.md\` — the architecture dimension analysis. Include stack, structure, patterns, a Components/Domains list (one per feature-domain slice), and data flow. ` +
539
+ `CRITICAL for the HTML renderer: give the file+LOC GRAND TOTAL as a markdown TABLE ROW in EXACTLY this form (the renderer reads this exact shape and stops, so it is NOT double-counted):\n` +
540
+ `\`| Grand Total | <N> files | <LOC> |\` — e.g. \`| Grand Total | 1809 files | 250000 |\`, from the probe totals.\n` +
541
+ `Do NOT also write a bare \`<N> files (~<LOC> LOC)\` GRAND-TOTAL line and do NOT write "Files analyzed: N". ` +
542
+ `Per-directory \`<n> files (~<loc> LOC)\` lines inside a Structure section are fine (they describe individual dirs), but the authoritative TOTAL must be the single table row above.`,
543
+ },
544
+ {
545
+ id: "scan-security", label: "scan:security",
546
+ prompt: `Write \`.gsd-t/scan/security.md\` — the security findings. The renderer parses sections headed \`### SEC-H<n>: <title>\` (HIGH) / \`### SEC-M<n>: <title>\` (MEDIUM), each with \`- **Details**: …\` and \`- **Fix**: …\` bullets. Use that exact form for every security-area finding.`,
547
+ },
548
+ {
549
+ id: "scan-quality", label: "scan:quality",
550
+ prompt: `Write \`.gsd-t/scan/quality.md\` — quality / dead-code / duplication / test-gap findings. The renderer parses sections headed \`### DC-<n>: <title>\` (dead code), \`### TCG-<n>: <title>\` (test gap), or \`### TD-<n>: <title>\`, each with a \`\\\`file:line\\\`\` location line and \`- **Impact**: …\` / \`- **Suggestion**: …\` bullets. Use that exact form.`,
551
+ },
552
+ {
553
+ id: "scan-business-rules", label: "scan:business-rules",
554
+ prompt: `Write \`.gsd-t/scan/business-rules.md\` — embedded business logic discovered across the slices: validation rules, authorization rules, workflow/state-machine rules, calculation rules (pricing/scoring/quotas), integration rules (retry/fallback/timeout). For each: where implemented (file:line) and an assessment. Add an "Undocumented Rules" section for logic with no comments/docs.`,
555
+ },
556
+ {
557
+ id: "scan-contract-drift", label: "scan:contract-drift",
558
+ prompt: `Write \`.gsd-t/scan/contract-drift.md\` — compare \`.gsd-t/contracts/\` (if it exists) to the actual implementation: API endpoints vs api-contract, schema vs schema-contract, undocumented endpoints/tables/components, drift. If no contracts dir exists, write a short note saying so and list the de-facto interfaces worth documenting.`,
559
+ },
560
+ {
561
+ id: "docs-architecture", label: "docs/architecture.md", merge: true,
562
+ prompt: `Update or create \`docs/architecture.md\`: system overview (stack, structure, patterns); component descriptions with locations + dependencies (one section per feature-domain slice); data flow (request → handler → service → data layer → response); data models from schema/ORM; API structure from routes; external integrations; design decisions found in code/configs. Go deep — this should be a real architecture reference, not a stub.`,
563
+ },
564
+ {
565
+ id: "docs-workflows", label: "docs/workflows.md", merge: true,
566
+ prompt: `Update or create \`docs/workflows.md\`: trace USER JOURNEYS per feature-domain slice (each major business area gets its own end-to-end journey from entry point through handlers to data); technical workflows from cron jobs / queue workers / scheduled tasks; API workflows for multi-step operations; integration workflows for external syncing; state machines and approval flows discovered in code. One journey per feature-domain slice minimum.`,
567
+ },
568
+ {
569
+ id: "docs-infrastructure", label: "docs/infrastructure.md", merge: true,
570
+ prompt: `Update or create \`docs/infrastructure.md\` (the most commonly-lost knowledge): Quick Reference commands (package.json scripts, Makefile, CI/CD); local dev setup (README, docker-compose, .env.example); database commands (migrations, seeds, ORM, backups); cloud provisioning (Terraform/CFN/Pulumi/deploy scripts); credentials/secrets (NAMES ONLY from .env.example, never values); deployment (CI/CD, Dockerfiles, platform configs); logging/monitoring.`,
571
+ },
572
+ {
573
+ id: "docs-requirements", label: "docs/requirements.md", merge: true,
574
+ prompt: `Update or create \`docs/requirements.md\`: functional requirements discovered from routes/handlers/UI components; technical requirements from configs/package.json/runtime; non-functional requirements from performance configs, rate limits, caching. Derive from what the code actually does.`,
575
+ },
576
+ {
577
+ id: "readme", label: "README.md", merge: true,
578
+ prompt: `Update or create \`README.md\`: project name + description; tech stack + versions discovered; getting-started/setup (from infrastructure findings); brief architecture overview; link to \`docs/\` for detail. If it exists, MERGE — update tech-stack + setup sections but preserve the user's existing structure and custom content.`,
579
+ },
580
+ ];
581
+
582
+ const docResults = await parallel(
583
+ docTargets.map((d) => async () => {
584
+ const isLiving = !!d.merge;
585
+ const prompt = [
586
+ `You are the documentation agent for ONE document in a GSD-T deep scan.`,
587
+ ``,
588
+ baseCtx,
589
+ ``,
590
+ d.prompt,
591
+ ``,
592
+ isLiving ? mergeNote : `Write the file fresh from the scan data in the format described.`,
593
+ ``,
594
+ `Read the actual code under the relevant slice paths to get specifics right —`,
595
+ `do not summarize only from the findings. Use the Write/Edit tools to write the`,
596
+ `file, then return JSON per the schema (status: "written" new, "merged" if you`,
597
+ `merged into existing content, "skipped" if genuinely nothing to write, "failed"`,
598
+ `on error). Do NOT commit — the workflow handles git.`,
599
+ ].join("\n");
600
+ try {
601
+ return await agent(prompt, {
602
+ label: d.label,
603
+ phase: "Document",
604
+ schema: DOC_RESULT_SCHEMA,
605
+ model: "sonnet",
606
+ });
607
+ } catch (e) {
608
+ return { doc: d.id, status: "failed", path: "", notes: `agent error: ${e && e.message}` };
609
+ }
610
+ })
611
+ );
612
+
613
+ const docsOk = docResults.filter(Boolean).filter((r) => r.status === "written" || r.status === "merged");
614
+ const docsFailed = docResults.filter(Boolean).filter((r) => r.status === "failed");
615
+ log(`document phase: ${docsOk.length}/${docTargets.length} docs written/merged${docsFailed.length ? `, ${docsFailed.length} failed (non-fatal — register is authoritative): ${docsFailed.map((d) => d.doc).join(", ")}` : ""}`);
616
+
617
+ // Deterministic check the renderer's required dimension file actually landed with the
618
+ // parseable file-count line (the render hollow-guard depends on it).
619
+ const archDimPath = path.join(projectDir, ".gsd-t", "scan", "architecture.md");
620
+ if (!fs.existsSync(archDimPath)) {
621
+ log(`⚠ .gsd-t/scan/architecture.md was not written — the HTML report will render hollow. The techdebt.md register and docs/ are unaffected.`);
622
+ }
623
+
624
+ // ─── Render — deterministic schema extraction + diagrams + HTML report ───
625
+ // These bin/scan-*.js renderers are KEPT verbatim (M66 does not touch them).
626
+ // Red-team fix (HIGH-1): guard ALL FOUR required bin files (the eval requires four,
627
+ // not just scan-report.js), and detect a hollow report instead of claiming success.
628
+ phase("Render");
629
+ const requiredBins = [
630
+ "bin/scan-data-collector.js",
631
+ "bin/scan-schema.js",
632
+ "bin/scan-diagrams.js",
633
+ "bin/scan-report.js",
634
+ ];
635
+ const missingBin = requiredBins.find((b) => !fs.existsSync(path.join(projectDir, b)));
636
+ let render;
637
+ if (missingBin) {
638
+ render = { ok: false, exitCode: 127, stderr: `missing renderer ${missingBin}` };
639
+ log(`render skipped — ${missingBin} not found (register is the primary artifact; report is optional)`);
640
+ } else {
641
+ const renderExpr = `
642
+ const {collectScanData}=require('./bin/scan-data-collector.js');
643
+ const {extractSchema}=require('./bin/scan-schema.js');
644
+ const {generateDiagrams}=require('./bin/scan-diagrams.js');
645
+ const {generateReport}=require('./bin/scan-report.js');
646
+ const root=process.argv[1];
647
+ const analysisData=collectScanData(root);
648
+ const schemaData=extractSchema(root);
649
+ const diagrams=generateDiagrams(analysisData, schemaData, {projectRoot:root});
650
+ const r=generateReport(analysisData, schemaData, diagrams, {projectRoot:root});
651
+ if (r.outputPath) console.log(JSON.stringify({outputPath:r.outputPath, diagramsRendered:r.diagramsRendered, filesScanned:analysisData.filesScanned||0, findings:(analysisData.findings||[]).length}));
652
+ else console.error('report-failed:', r.error);
653
+ `;
654
+ render = _runNode("bin/scan-report.js", renderExpr, [projectDir]);
655
+ }
656
+ let reportInfo = null;
657
+ let reportHollow = null;
658
+ if (render.ok) {
659
+ try { reportInfo = JSON.parse((render.stdout || "").trim()); } catch (_) {}
660
+ // A real scan ALWAYS has files. filesScanned===0 alone is a hollow signal —
661
+ // do NOT require findings===0 too (that &&-guard was defeated by any non-empty
662
+ // scan, where the security/quality findings parse but the file count does not).
663
+ reportHollow = !!(reportInfo && reportInfo.filesScanned === 0);
664
+ if (reportHollow) {
665
+ log(`⚠ HTML report rendered but reads HOLLOW (filesScanned=0) — the architecture.md file/LOC line is missing or in an unparsed format. Report at ${reportInfo && reportInfo.outputPath}; the techdebt.md register is the authoritative artifact.`);
666
+ } else {
667
+ log(`HTML report: ${render.stdout && render.stdout.trim()}`);
668
+ }
669
+ } else {
670
+ // Non-fatal — the register is the primary artifact; the report is a nicety.
671
+ log(`render stage non-fatal failure (exitCode=${render.exitCode}): ${render.stderr || render.stdout}`);
672
+ }
673
+
674
+ // Commit the docs + dimension files + HTML report deterministically (doc agents were
675
+ // told NOT to commit, to avoid interleaved concurrent git operations). Best-effort:
676
+ // a commit failure is non-fatal (artifacts are on disk).
677
+ // Red-team fix (LOW): drop `-f` — none of these targets are gitignored, and `-f` would
678
+ // force-add gitignored junk under the pathspecs (e.g. docs/.DS_Store). The .doc-backup
679
+ // dir lives under .gsd-t/scan/ which IS committed; exclude it explicitly so backups
680
+ // aren't versioned.
681
+ const commit = spawnSync(
682
+ "git",
683
+ ["add", "-A", ".gsd-t/scan", "docs", "README.md", ":!.gsd-t/scan/.doc-backup"],
684
+ { cwd: projectDir, stdio: "pipe" }
685
+ );
686
+ if (commit.status === 0) {
687
+ const ci = spawnSync(
688
+ "git",
689
+ ["commit", "-q", "-m", `scan: deep document cross-population (${docsOk.length} docs) + HTML report`],
690
+ { cwd: projectDir, stdio: "pipe" }
691
+ );
692
+ if (ci.status === 0) log("docs + report committed");
693
+ else log(`docs staged but commit skipped (likely nothing to commit or hook): ${ci.stderr && ci.stderr.toString().slice(0, 200)}`);
694
+ } else {
695
+ log(`doc git add non-fatal failure: ${commit.stderr && commit.stderr.toString().slice(0, 200)}`);
696
+ }
697
+
698
+ return {
699
+ status: "complete",
700
+ slices: slices.length,
701
+ findings: allFindings.length,
702
+ counts: synthesis.counts,
703
+ registerPath,
704
+ archivePath: priorArchivePath,
705
+ docs: docResults,
706
+ docsWritten: docsOk.length,
707
+ docsFailed: docsFailed.map((d) => d.doc),
708
+ htmlReport: reportInfo ? reportInfo.outputPath : null,
709
+ reportHollow,
710
+ probeTotals: probe.totals,
711
+ };