@opengsd/gsd-core 1.5.0-rc.1 → 1.5.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-code-fixer.md +3 -2
  3. package/agents/gsd-debug-session-manager.md +2 -1
  4. package/agents/gsd-debugger.md +4 -3
  5. package/agents/gsd-executor.md +16 -15
  6. package/agents/gsd-intel-updater.md +38 -41
  7. package/agents/gsd-phase-researcher.md +8 -8
  8. package/agents/gsd-plan-checker.md +12 -11
  9. package/agents/gsd-planner.md +21 -181
  10. package/agents/gsd-project-researcher.md +5 -4
  11. package/agents/gsd-research-synthesizer.md +2 -1
  12. package/agents/gsd-ui-researcher.md +2 -1
  13. package/agents/gsd-verifier.md +9 -8
  14. package/bin/install.js +205 -1402
  15. package/gemini-extension.json +1 -1
  16. package/gsd-core/bin/gsd_run +20 -0
  17. package/gsd-core/bin/lib/capability-registry.cjs +1820 -3
  18. package/gsd-core/bin/lib/check-command-router.cjs +133 -2
  19. package/gsd-core/bin/lib/core.cjs +51 -1
  20. package/gsd-core/bin/lib/edge-probe.cjs +173 -0
  21. package/gsd-core/bin/lib/fallow-runner.cjs +63 -25
  22. package/gsd-core/bin/lib/intel.cjs +3 -3
  23. package/gsd-core/bin/lib/io.cjs +61 -6
  24. package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
  25. package/gsd-core/bin/lib/phase.cjs +17 -0
  26. package/gsd-core/bin/lib/probe-core.cjs +257 -0
  27. package/gsd-core/bin/lib/roadmap.cjs +40 -0
  28. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +51 -141
  29. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +67 -29
  30. package/gsd-core/bin/lib/runtime-homes.cjs +137 -101
  31. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
  32. package/gsd-core/bin/lib/runtime-name-policy.cjs +1 -1
  33. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  34. package/gsd-core/bin/lib/state-document.cjs +8 -0
  35. package/gsd-core/bin/lib/uat-predicate.cjs +329 -0
  36. package/gsd-core/bin/lib/update-context.cjs +4 -1
  37. package/gsd-core/bin/lib/verify.cjs +103 -0
  38. package/gsd-core/bin/lib/worktree-base-ref.cjs +33 -8
  39. package/gsd-core/bin/shared/model-catalog.json +11 -6
  40. package/gsd-core/bin/shared/runtime-aliases.manifest.json +2 -1
  41. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
  42. package/gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
  43. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
  44. package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
  45. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
  46. package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
  47. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
  48. package/gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
  49. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
  50. package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
  51. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
  52. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
  53. package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
  54. package/gsd-core/references/edge-probe.md +261 -0
  55. package/gsd-core/references/planner-antipatterns.md +41 -0
  56. package/gsd-core/references/planner-guidance.md +186 -0
  57. package/gsd-core/templates/spec.md +12 -0
  58. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  59. package/gsd-core/workflows/add-backlog.md +1 -1
  60. package/gsd-core/workflows/add-phase.md +1 -1
  61. package/gsd-core/workflows/add-tests.md +1 -1
  62. package/gsd-core/workflows/add-todo.md +1 -1
  63. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  64. package/gsd-core/workflows/audit-fix.md +1 -1
  65. package/gsd-core/workflows/audit-milestone.md +1 -1
  66. package/gsd-core/workflows/audit-uat.md +1 -1
  67. package/gsd-core/workflows/autonomous.md +26 -37
  68. package/gsd-core/workflows/check-todos.md +1 -1
  69. package/gsd-core/workflows/cleanup.md +1 -1
  70. package/gsd-core/workflows/code-review-fix.md +1 -1
  71. package/gsd-core/workflows/code-review.md +51 -16
  72. package/gsd-core/workflows/complete-milestone.md +1 -1
  73. package/gsd-core/workflows/debug.md +1 -1
  74. package/gsd-core/workflows/diagnose-issues.md +1 -1
  75. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  76. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  77. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  78. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  79. package/gsd-core/workflows/discuss-phase.md +1 -1
  80. package/gsd-core/workflows/do.md +1 -1
  81. package/gsd-core/workflows/docs-update.md +1 -1
  82. package/gsd-core/workflows/edit-phase.md +1 -1
  83. package/gsd-core/workflows/eval-review.md +1 -1
  84. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  85. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  86. package/gsd-core/workflows/execute-phase.md +1 -1
  87. package/gsd-core/workflows/execute-plan.md +1 -1
  88. package/gsd-core/workflows/explore.md +1 -1
  89. package/gsd-core/workflows/extract-learnings.md +1 -1
  90. package/gsd-core/workflows/forensics.md +1 -1
  91. package/gsd-core/workflows/graduation.md +1 -1
  92. package/gsd-core/workflows/health.md +1 -1
  93. package/gsd-core/workflows/import.md +1 -1
  94. package/gsd-core/workflows/ingest-docs.md +1 -1
  95. package/gsd-core/workflows/insert-phase.md +1 -1
  96. package/gsd-core/workflows/list-workspaces.md +1 -1
  97. package/gsd-core/workflows/manager.md +1 -1
  98. package/gsd-core/workflows/map-codebase.md +1 -1
  99. package/gsd-core/workflows/milestone-summary.md +1 -1
  100. package/gsd-core/workflows/mvp-phase.md +1 -1
  101. package/gsd-core/workflows/new-milestone.md +9 -1
  102. package/gsd-core/workflows/new-project.md +9 -1
  103. package/gsd-core/workflows/new-workspace.md +1 -1
  104. package/gsd-core/workflows/next.md +1 -1
  105. package/gsd-core/workflows/pause-work.md +1 -1
  106. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  107. package/gsd-core/workflows/plan-phase.md +39 -27
  108. package/gsd-core/workflows/plan-review-convergence.md +1 -1
  109. package/gsd-core/workflows/plant-seed.md +1 -1
  110. package/gsd-core/workflows/profile-user.md +1 -1
  111. package/gsd-core/workflows/progress.md +1 -1
  112. package/gsd-core/workflows/quick.md +1 -1
  113. package/gsd-core/workflows/remove-phase.md +1 -1
  114. package/gsd-core/workflows/remove-workspace.md +1 -1
  115. package/gsd-core/workflows/resume-project.md +1 -1
  116. package/gsd-core/workflows/review.md +1 -1
  117. package/gsd-core/workflows/scan.md +1 -1
  118. package/gsd-core/workflows/secure-phase.md +1 -1
  119. package/gsd-core/workflows/settings-advanced.md +18 -14
  120. package/gsd-core/workflows/settings-integrations.md +1 -1
  121. package/gsd-core/workflows/settings.md +2 -2
  122. package/gsd-core/workflows/ship.md +1 -1
  123. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  124. package/gsd-core/workflows/sketch.md +1 -1
  125. package/gsd-core/workflows/spec-phase.md +130 -1
  126. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  127. package/gsd-core/workflows/spike.md +1 -1
  128. package/gsd-core/workflows/stats.md +1 -1
  129. package/gsd-core/workflows/thread.md +1 -1
  130. package/gsd-core/workflows/transition.md +1 -1
  131. package/gsd-core/workflows/ui-phase.md +1 -1
  132. package/gsd-core/workflows/ui-review.md +1 -1
  133. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  134. package/gsd-core/workflows/update.md +2 -2
  135. package/gsd-core/workflows/validate-phase.md +1 -1
  136. package/gsd-core/workflows/verify-phase.md +1 -1
  137. package/gsd-core/workflows/verify-work.md +1 -1
  138. package/package.json +6 -3
  139. package/scripts/gen-capability-registry.cjs +498 -13
  140. package/scripts/lib/allowlist-ratchet.cjs +101 -1
  141. package/scripts/lint-test-file-count.allowlist.json +15 -2
  142. package/scripts/lint-windows-test-portability.cjs +178 -0
  143. package/scripts/research-profiles.cjs +10 -10
  144. package/scripts/run-tests.cjs +54 -13
  145. package/scripts/sync-next-version.cjs +133 -0
  146. package/scripts/sync-runtime-launcher.cjs +21 -5
  147. package/scripts/update-size-baseline.cjs +68 -0
  148. package/scripts/workflow-policy.cjs +42 -9
  149. package/scripts/workflow-size.cjs +90 -0
@@ -16,6 +16,10 @@ const node_child_process_1 = require("node:child_process");
16
16
  const core = require("./core.cjs");
17
17
  const { output, error, ERROR_REASON } = core;
18
18
  const decisions_cjs_1 = require("./decisions.cjs");
19
+ const ui_safety_gate_cjs_1 = require("./ui-safety-gate.cjs");
20
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ const roadmapModule = require("./roadmap.cjs");
22
+ const { getRoadmapPhaseWithFallback } = roadmapModule;
19
23
  // ─── Helpers ──────────────────────────────────────────────────────────────────
20
24
  function normalizePhrase(text) {
21
25
  // eslint-disable-next-line @typescript-eslint/no-base-to-string
@@ -320,8 +324,130 @@ function cmdDecisionCoverageVerify(projectDir, args, raw) {
320
324
  message: buildVerifyMessage(notHonored),
321
325
  }, raw, undefined);
322
326
  }
327
+ // ─── ui-plan-gate ─────────────────────────────────────────────────────────────
328
+ /**
329
+ * ui-plan-gate: given a phase number, checks whether the phase has frontend
330
+ * indicators and whether a *-UI-SPEC.md already exists in the phase directory.
331
+ *
332
+ * Returns JSON: { frontend: boolean, hasUiSpec: boolean, block: boolean }
333
+ * block = frontend && !hasUiSpec (gate fires when UI work is detected but no spec exists)
334
+ *
335
+ * Invocable as: gsd_run check ui-plan-gate <phase>
336
+ *
337
+ * Uses checkUiPresence from ui-safety-gate.cjs — does NOT reimplement frontend detection.
338
+ * Uses getRoadmapPhaseInternal + findPhaseInternal from core.cjs for phase data.
339
+ */
340
+ function findUiSpecInDir(phaseDir) {
341
+ if (!phaseDir || !node_fs_1.default.existsSync(phaseDir))
342
+ return '';
343
+ try {
344
+ const files = node_fs_1.default.readdirSync(phaseDir);
345
+ const found = files.find((f) => /-UI-SPEC\.md$/.test(f));
346
+ return found ? node_path_1.default.join(phaseDir, found) : '';
347
+ }
348
+ catch {
349
+ return '';
350
+ }
351
+ }
352
+ /**
353
+ * Pure logic for ui-plan-gate — exposed for direct behavioral testing.
354
+ *
355
+ * Given a projectDir and phase number:
356
+ * (a) Reads the phase section from ROADMAP.md via getRoadmapPhaseWithFallback —
357
+ * same two-pass lookup (current milestone → full roadmap) as `roadmap.get-phase`
358
+ * (cmdRoadmapGetPhase). Cross-milestone / older frontend phases resolve correctly.
359
+ * If ROADMAP.md is missing, phaseSection is '' (ROADMAP.md not present = project
360
+ * has no roadmap = cannot be frontend). If the phase truly can't be found after
361
+ * both passes, phaseSection is '' and phaseLookupFailed is set so callers can
362
+ * surface the miss — we do NOT silently degrade to frontend:false if the roadmap
363
+ * exists but the phase header is absent.
364
+ * (b) Runs checkUiPresence (frontend detection) — no reimplementation.
365
+ * (c) Resolves the phase directory via core.findPhaseInternal; checks for *-UI-SPEC.md.
366
+ *
367
+ * Returns: { frontend, hasUiSpec, block, uiSpecPath, phaseLookupFailed }
368
+ * block = frontend && !hasUiSpec
369
+ * phaseLookupFailed = ROADMAP.md present but phase header not found (surfaced for
370
+ * onError:halt gates so a missing phase doesn't silently bypass)
371
+ */
372
+ function computeUiPlanGate(projectDir, phase) {
373
+ // (a) Read the phase section text using the same two-pass lookup as roadmap.get-phase.
374
+ // getRoadmapPhaseWithFallback: current-milestone first, then stripShippedMilestones
375
+ // fallback — mirrors cmdRoadmapGetPhase exactly.
376
+ let phaseSection = '';
377
+ let phaseLookupFailed;
378
+ try {
379
+ const section = getRoadmapPhaseWithFallback(projectDir, phase);
380
+ if (section === null) {
381
+ // Distinguish: ROADMAP.md missing (no-roadmap project) vs phase not found in ROADMAP.
382
+ // core.planningDir(cwd) resolves the .planning/ root for workstream-aware paths.
383
+ const planDir = typeof core['planningDir'] === 'function'
384
+ ? core['planningDir'](projectDir)
385
+ : node_path_1.default.join(projectDir, '.planning');
386
+ const roadmapPath = node_path_1.default.join(planDir, 'ROADMAP.md');
387
+ if (node_fs_1.default.existsSync(roadmapPath)) {
388
+ // ROADMAP.md exists but phase was not found → surface the miss
389
+ phaseLookupFailed = true;
390
+ }
391
+ // phaseSection stays ''
392
+ }
393
+ else {
394
+ phaseSection = section;
395
+ }
396
+ }
397
+ catch { /* roadmap read failure → treat as empty (non-frontend) */ }
398
+ // (b) Run checkUiPresence (frontend detection) — reuse existing helper; no reimplementation
399
+ const presenceResult = (0, ui_safety_gate_cjs_1.checkUiPresence)(phaseSection);
400
+ const frontend = presenceResult.hasUI;
401
+ // (c) Resolve phase directory via findPhaseInternal and check for *-UI-SPEC.md
402
+ const coreModule = core;
403
+ let phaseDir = '';
404
+ try {
405
+ const findPhase = coreModule['findPhaseInternal'];
406
+ if (typeof findPhase === 'function') {
407
+ const result = findPhase(projectDir, phase);
408
+ if (result && typeof result === 'object') {
409
+ // findPhaseInternal returns { directory: '<relative-posix-path>', ... }
410
+ // directory is relative to cwd — resolve it to absolute.
411
+ const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
412
+ if (relDir) {
413
+ phaseDir = node_path_1.default.resolve(projectDir, relDir);
414
+ }
415
+ }
416
+ else if (typeof result === 'string') {
417
+ phaseDir = result;
418
+ }
419
+ }
420
+ }
421
+ catch { /* phase dir lookup failure → hasUiSpec=false */ }
422
+ const uiSpecPath = findUiSpecInDir(phaseDir);
423
+ const hasUiSpec = uiSpecPath !== '';
424
+ // block = frontend phase with no UI-SPEC
425
+ const block = frontend && !hasUiSpec;
426
+ const result = {
427
+ frontend, hasUiSpec, block, uiSpecPath: hasUiSpec ? uiSpecPath : null,
428
+ };
429
+ if (phaseLookupFailed)
430
+ result.phaseLookupFailed = true;
431
+ return result;
432
+ }
433
+ function cmdUiPlanGate(projectDir, args, raw) {
434
+ // args[0] = 'check', args[1] = 'ui-plan-gate', args[2] = phase
435
+ const phase = args[2] || '';
436
+ if (!phase) {
437
+ error('ui-plan-gate requires a phase argument: check ui-plan-gate <phase>', ERROR_REASON.SDK_MISSING_ARG);
438
+ return;
439
+ }
440
+ output(computeUiPlanGate(projectDir, phase), raw, undefined);
441
+ }
323
442
  function routeCheckCommand({ args, cwd, raw }) {
324
- const subcommand = args[1];
443
+ // Normalize dots to hyphens in the subcommand so both forms are accepted.
444
+ // This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
445
+ // directly runnable as `gsd_run check ui.plan-gate` — the dot is normalized to
446
+ // `ui-plan-gate` before routing. The generic gate-dispatch in §5.6 reads
447
+ // `check.query` from the active gate hook and runs `gsd_run check ${hook.check.query}`,
448
+ // so the declared query must be dispatchable exactly as declared.
449
+ const rawSubcommand = args[1];
450
+ const subcommand = typeof rawSubcommand === 'string' ? rawSubcommand.replace(/\./g, '-') : rawSubcommand;
325
451
  if (subcommand === 'auto-mode') {
326
452
  cmdAutoMode(cwd, raw);
327
453
  return;
@@ -334,10 +460,15 @@ function routeCheckCommand({ args, cwd, raw }) {
334
460
  cmdDecisionCoverageVerify(cwd, args, raw);
335
461
  return;
336
462
  }
337
- error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify', ERROR_REASON.SDK_UNKNOWN_COMMAND);
463
+ if (subcommand === 'ui-plan-gate') {
464
+ cmdUiPlanGate(cwd, args, raw);
465
+ return;
466
+ }
467
+ error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, ui-plan-gate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
338
468
  }
339
469
  module.exports = {
340
470
  routeCheckCommand,
341
471
  decisionMentioned,
342
472
  extractPlanDesignatedSections,
473
+ computeUiPlanGate,
343
474
  };
@@ -159,6 +159,7 @@ function checkAgentsInstalled(runtime) {
159
159
  agents_installed: false,
160
160
  missing_agents: expectedAgents,
161
161
  installed_agents: [],
162
+ incomplete_agents: [],
162
163
  agents_dir: agentsDir,
163
164
  agent_runtime: resolvedRuntime,
164
165
  };
@@ -182,10 +183,59 @@ function checkAgentsInstalled(runtime) {
182
183
  missing.push(agent);
183
184
  }
184
185
  }
186
+ // ── Manifest-backed completeness check ──────────────────────────────────────
187
+ // If a gsd-file-manifest.json exists alongside the agents dir (parent dir),
188
+ // verify that every manifest-tracked file for each expected agent is present
189
+ // on disk. Missing manifest-tracked files indicate an incomplete install even
190
+ // when the plain presence check above passed (e.g. .md present, .toml absent).
191
+ // If no manifest is found the check is a no-op (graceful for claude/bundled).
192
+ const incomplete = [];
193
+ const manifestPath = node_path_1.default.join(node_path_1.default.dirname(agentsDir), 'gsd-file-manifest.json');
194
+ let manifestFiles = {};
195
+ try {
196
+ const raw = node_fs_1.default.readFileSync(manifestPath, 'utf8');
197
+ const parsed = JSON.parse(raw);
198
+ if (parsed !== null &&
199
+ typeof parsed === 'object' &&
200
+ 'files' in parsed &&
201
+ typeof parsed['files'] === 'object' &&
202
+ parsed['files'] !== null) {
203
+ manifestFiles = parsed['files'];
204
+ }
205
+ }
206
+ catch {
207
+ // No manifest or unreadable — completeness check is skipped
208
+ }
209
+ if (Object.keys(manifestFiles).length > 0) {
210
+ for (const agent of expectedAgents) {
211
+ // Find all manifest keys that belong to this agent:
212
+ // key must be "agents/<agentName>.<ext>" with no further path segments.
213
+ const agentPrefix = `agents/${agent}.`;
214
+ const agentManifestKeys = Object.keys(manifestFiles).filter(key => {
215
+ if (!key.startsWith(agentPrefix))
216
+ return false;
217
+ const rest = key.slice(agentPrefix.length);
218
+ // rest must be a bare extension (no slashes, non-empty)
219
+ return rest.length > 0 && !rest.includes('/');
220
+ });
221
+ if (agentManifestKeys.length === 0) {
222
+ // Agent not tracked in manifest — skip completeness check for this agent
223
+ continue;
224
+ }
225
+ const allPresent = agentManifestKeys.every(key => {
226
+ const basename = key.slice('agents/'.length);
227
+ return node_fs_1.default.existsSync(node_path_1.default.join(agentsDir, basename));
228
+ });
229
+ if (!allPresent) {
230
+ incomplete.push(agent);
231
+ }
232
+ }
233
+ }
185
234
  return {
186
- agents_installed: installed.length > 0 && missing.length === 0,
235
+ agents_installed: installed.length > 0 && missing.length === 0 && incomplete.length === 0,
187
236
  missing_agents: missing,
188
237
  installed_agents: installed,
238
+ incomplete_agents: incomplete,
189
239
  agents_dir: agentsDir,
190
240
  agent_runtime: resolvedRuntime,
191
241
  };
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ /**
3
+ * Spec-completeness edge-probe — the FIRST adapter of the probe-core resolution model
4
+ * (ADR-457 build model; ADR-550 Decision 7 seam).
5
+ *
6
+ * The generic resolution lifecycle, the status×verification re-cut, `validateResolution`,
7
+ * `validateRequirement`, the `analyzeCoverage` merge/rollup/orphan-reject engine, and the
8
+ * `runProbeCli` scaffold all live in `src/probe-core.cts`. This module keeps ONLY the
9
+ * edge-specific cluster: the five data/behavior shapes, the closed 8-category edge taxonomy,
10
+ * shape classification, edge proposal, and the `{ explicit, backstop }` verification validators.
11
+ *
12
+ * Authored as strict TypeScript (`src/edge-probe.cts`) and compiled by
13
+ * `tsc -p tsconfig.build.json` to the gitignored runtime artifact
14
+ * `gsd-core/bin/lib/edge-probe.cjs`. Do NOT hand-write the `.cjs`; it is emitted. Tests
15
+ * `require()` the built artifact; `pretest` runs `build:lib` first.
16
+ *
17
+ * Pure and dependency-free: it classifies each requirement's data/behavior shape, filters
18
+ * the closed 8-category edge taxonomy to applicable categories, proposes concrete candidate
19
+ * edges, and (via probe-core) merges author resolutions into a coverage report.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.EDGE_VALIDATORS = exports.TAXONOMY = exports.VALID_SHAPES = exports.SHAPE_CUES = void 0;
23
+ exports.classifyShape = classifyShape;
24
+ exports.applicableCategories = applicableCategories;
25
+ exports.validateRequirement = validateRequirement;
26
+ exports.validateResolution = validateResolution;
27
+ exports.proposeEdges = proposeEdges;
28
+ exports.analyzeCoverage = analyzeCoverage;
29
+ const probe_core_cjs_1 = require("./probe-core.cjs");
30
+ /**
31
+ * Word-boundary cues mapping requirement prose -> data/behavior shape.
32
+ * Heuristic and intentionally lossy; an authored `shapes` array overrides it.
33
+ */
34
+ exports.SHAPE_CUES = {
35
+ 'numeric-range': /\b(round(ing|ed)?|threshold|max(imum)?|min(imum)?|limit|bound(ary)?|between|cap|percent|amount|price|count|number|score|rate|decimal)\b/i,
36
+ 'collection': /\b(lists?|arrays?|sets?|items?|collections?|each|every|all|sort(ed|ing)?|merge|dedupe|group|ranges?|intervals?|overlap(ping)?)\b/i,
37
+ 'text': /\b(string|text|names?|labels?|truncate|substring|char(acter)?s?|length|slug|message|unicode)\b/i,
38
+ 'stateful': /\b(save|persist|store|update|toggle|create|delete|remove|submit|retry|apply|register|insert)\b/i,
39
+ 'io': /\b(files?|requests?|fetch|upload|download|network|api|endpoints?|connections?|sockets?)\b/i,
40
+ };
41
+ /** The locked shape vocabulary — exactly the keys of SHAPE_CUES (single source of truth). */
42
+ exports.VALID_SHAPES = new Set(Object.keys(exports.SHAPE_CUES));
43
+ /** Detect which shapes a requirement's prose matches (heuristic). */
44
+ function classifyShape(text) {
45
+ const shapes = [];
46
+ const subject = String(text == null ? '' : text);
47
+ for (const shape of Object.keys(exports.SHAPE_CUES)) {
48
+ if (exports.SHAPE_CUES[shape].test(subject))
49
+ shapes.push(shape);
50
+ }
51
+ return shapes;
52
+ }
53
+ /**
54
+ * Closed taxonomy of 8 domain-boundary edge categories (established QA names).
55
+ * `shapes` lists which requirement shapes make the category relevant.
56
+ */
57
+ exports.TAXONOMY = [
58
+ { id: 'boundary', name: 'Boundary values', shapes: ['numeric-range'], probe: 'What happens exactly at each min/max/threshold — and one step either side?' },
59
+ { id: 'adjacency', name: 'Adjacency / touching', shapes: ['collection'], probe: 'When two things are exactly equal or just touch, do they merge, collide, or separate?' },
60
+ { id: 'empty', name: 'Empty / degenerate', shapes: ['collection', 'text'], probe: 'What is the result for empty, single-element, or null input?' },
61
+ { id: 'encoding', name: 'Encoding / representation', shapes: ['text'], probe: 'Whose definition of length/equality applies — bytes, code points, grapheme clusters, or normalized form?' },
62
+ { id: 'ordering', name: 'Ordering / stability', shapes: ['collection'], probe: 'When elements compare equal, is output order specified and stable?' },
63
+ { id: 'precision', name: 'Precision / overflow', shapes: ['numeric-range'], probe: 'Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?' },
64
+ { id: 'idempotency', name: 'Idempotency / repetition', shapes: ['stateful'], probe: 'What happens if this runs twice on the same input?' },
65
+ { id: 'concurrency', name: 'Concurrency / effect ordering', shapes: ['stateful', 'io'], probe: 'If interrupted or run in parallel, what is guaranteed?' },
66
+ ];
67
+ /** Return taxonomy category ids whose applicable shapes intersect the input set. */
68
+ function applicableCategories(shapes) {
69
+ const set = new Set(shapes);
70
+ return exports.TAXONOMY.filter((c) => c.shapes.some((s) => set.has(s))).map((c) => c.id);
71
+ }
72
+ /**
73
+ * The edge adapter's injected runtime validators (ADR-550 #5). `categories` is the closed
74
+ * taxonomy; both verification tiers require a non-empty `resolution` (an explicit AC's text
75
+ * or a backstop note) so plan-phase has a criterion to lift.
76
+ */
77
+ exports.EDGE_VALIDATORS = {
78
+ categories: exports.TAXONOMY.map((c) => c.id),
79
+ verification: ['explicit', 'backstop'],
80
+ requiredFieldsByVerification: { explicit: ['resolution'], backstop: ['resolution'] },
81
+ };
82
+ /**
83
+ * Validate a single requirement — the generic id/text checks (probe-core) plus the edge's
84
+ * `shapes`-must-be-an-array check. A bare string like `shapes:"numeric-range"` would otherwise
85
+ * fall through to prose classification, silently ignoring the authored override.
86
+ *
87
+ * The edge adapter's `text` is REQUIRED (the prose is the classification signal), so reject a
88
+ * missing/empty `text` when no authored `shapes` override is present. Without this, a `{ id }`
89
+ * requirement classifies to zero shapes → zero edges → it is silently DROPPED from coverage
90
+ * with no signal — the exact fail-open this feature exists to eliminate. An explicit `shapes`
91
+ * array (including `[]` for "no applicable categories") is the legitimate way to opt out of
92
+ * prose classification, so `text` is only required when `shapes` is absent.
93
+ */
94
+ function validateRequirement(requirement) {
95
+ (0, probe_core_cjs_1.validateRequirement)(requirement);
96
+ const r = requirement;
97
+ if (r.shapes != null && !Array.isArray(r.shapes)) {
98
+ throw new Error(`requirement ${requirement.id} shapes must be an array when present`);
99
+ }
100
+ if (r.shapes == null && !(typeof r.text === 'string' && r.text.trim())) {
101
+ throw new Error(`requirement ${requirement.id} text must be a non-empty string when no shapes override is provided`);
102
+ }
103
+ }
104
+ /** Validate an edge resolution against the edge verification vocabulary. */
105
+ function validateResolution(resolution) {
106
+ return (0, probe_core_cjs_1.validateResolution)(resolution, exports.EDGE_VALIDATORS);
107
+ }
108
+ /**
109
+ * Propose candidate edges for a requirement. Uses authored `shapes` when present, else
110
+ * classifies from prose. Every proposed edge starts unresolved (verification null).
111
+ */
112
+ function proposeEdges(requirement) {
113
+ validateRequirement(requirement);
114
+ let shapes;
115
+ if (Array.isArray(requirement.shapes)) {
116
+ // Fail closed: an authored array must contain only locked shape values. A non-empty
117
+ // but invalid array (e.g. ['numeric'], a typo for 'numeric-range') would otherwise
118
+ // intersect no category and silently suppress every probe — the gate reads green while
119
+ // nothing was checked. An empty array stays a valid "no applicable categories" override.
120
+ for (const s of requirement.shapes) {
121
+ if (typeof s !== 'string' || !exports.VALID_SHAPES.has(s)) {
122
+ throw new Error(`invalid shape ${JSON.stringify(s)} for requirement ${requirement.id} — must be one of: ${[...exports.VALID_SHAPES].join(', ')}`);
123
+ }
124
+ }
125
+ shapes = requirement.shapes;
126
+ }
127
+ else {
128
+ shapes = classifyShape(requirement.text);
129
+ }
130
+ return applicableCategories(shapes).map((catId) => {
131
+ const cat = exports.TAXONOMY.find((c) => c.id === catId);
132
+ return {
133
+ requirement_id: requirement.id,
134
+ category: catId,
135
+ status: 'unresolved',
136
+ verification: null,
137
+ resolution: null,
138
+ reason: null,
139
+ probe: cat ? cat.probe : '',
140
+ };
141
+ });
142
+ }
143
+ /**
144
+ * Propose edges for every requirement (deterministic propose), then delegate the
145
+ * merge/rollup/orphan-reject to probe-core. Edge-specific pre-checks: requirements must be an
146
+ * array, requirement ids must be unique. Throws on any invalid resolution.
147
+ */
148
+ function analyzeCoverage(requirements, resolutions = []) {
149
+ if (!Array.isArray(requirements)) {
150
+ throw new Error('requirements must be an array');
151
+ }
152
+ const items = [];
153
+ const seenReqIds = new Set();
154
+ for (const req of requirements) {
155
+ validateRequirement(req);
156
+ if (seenReqIds.has(req.id)) {
157
+ throw new Error(`duplicate requirement id ${JSON.stringify(req.id)}`);
158
+ }
159
+ seenReqIds.add(req.id);
160
+ for (const edge of proposeEdges(req))
161
+ items.push(edge);
162
+ }
163
+ return (0, probe_core_cjs_1.analyzeCoverage)(items, resolutions, exports.EDGE_VALIDATORS);
164
+ }
165
+ /*
166
+ * CLI entry (EP-06 invokable surface): `edge-probe.cjs <requirements.json> [resolutions.json]`.
167
+ * The generic I/O plumbing (parse, fail-closed exit 2, pretty-JSON out) lives in probe-core's
168
+ * `runProbeCli`; the edge adapter supplies its `analyzeCoverage`. Guarded by
169
+ * `require.main === module` so it runs only when the compiled `.cjs` is executed directly.
170
+ */
171
+ if (require.main === module) {
172
+ (0, probe_core_cjs_1.runProbeCli)((requirements, resolutions) => analyzeCoverage(requirements, resolutions), { usage: 'edge-probe.cjs <requirements.json> [resolutions.json]' });
173
+ }
@@ -5,6 +5,9 @@
5
5
  * ADR-457 build-at-publish: the hand-written bin/lib/fallow-runner.cjs
6
6
  * collapsed to a TypeScript source of truth. Behaviour is preserved
7
7
  * byte-for-behaviour from the prior hand-written .cjs; only types are added.
8
+ *
9
+ * Parses the real fallow `audit --format json` schema (schema_version 3
10
+ * envelope, nested dead_code/duplication sections). See fallow 2.70.0+.
8
11
  */
9
12
  var __importDefault = (this && this.__importDefault) || function (mod) {
10
13
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -13,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
13
16
  exports.resolveFallowBinary = resolveFallowBinary;
14
17
  exports.requireFallowBinary = requireFallowBinary;
15
18
  exports.normalizeFallowReport = normalizeFallowReport;
19
+ exports.normalizeFallowReportFile = normalizeFallowReportFile;
16
20
  const node_fs_1 = __importDefault(require("node:fs"));
17
21
  const node_path_1 = __importDefault(require("node:path"));
18
22
  function candidateNames() {
@@ -68,48 +72,82 @@ function requireFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }) {
68
72
  throw new Error('Fallow is enabled but no binary was found. Please install fallow via `npm install -D fallow` or `cargo install fallow`.');
69
73
  }
70
74
  function normalizeFallowReport(report) {
71
- const unused = Array.isArray(report?.unusedExports)
72
- ? report.unusedExports
73
- : [];
74
- const duplicates = Array.isArray(report?.duplicates)
75
- ? report.duplicates
76
- : [];
77
- const circular = Array.isArray(report?.circularDependencies)
78
- ? report.circularDependencies
79
- : [];
75
+ const deadCodeRaw = report?.dead_code;
76
+ const duplicationRaw = report?.duplication;
77
+ const unusedExports = (Array.isArray(deadCodeRaw?.unused_exports)
78
+ ? (deadCodeRaw?.unused_exports ?? [])
79
+ : []).filter((x) => x !== null && typeof x === 'object');
80
+ const unusedFiles = (Array.isArray(deadCodeRaw?.unused_files)
81
+ ? (deadCodeRaw?.unused_files ?? [])
82
+ : []).filter((x) => x !== null && typeof x === 'object');
83
+ const circularDeps = (Array.isArray(deadCodeRaw?.circular_dependencies)
84
+ ? (deadCodeRaw?.circular_dependencies ?? [])
85
+ : []).filter((x) => x !== null && typeof x === 'object');
86
+ const cloneGroups = (Array.isArray(duplicationRaw?.clone_groups)
87
+ ? (duplicationRaw?.clone_groups ?? [])
88
+ : []).filter((x) => x !== null && typeof x === 'object');
80
89
  const findings = [];
81
- for (const item of unused) {
90
+ for (const item of unusedExports) {
91
+ if (!item || typeof item !== 'object')
92
+ continue;
82
93
  findings.push({
83
94
  type: 'unused_export',
84
- message: `Unused export ${item.symbol ?? '<unknown>'}`,
85
- file: item.file ?? '',
95
+ message: `Unused export ${item.export_name ?? '<unknown>'}`,
96
+ file: item.path ?? '',
86
97
  line: item.line ?? null,
87
98
  });
88
99
  }
89
- for (const item of duplicates) {
100
+ for (const item of unusedFiles) {
101
+ if (!item || typeof item !== 'object')
102
+ continue;
90
103
  findings.push({
91
- type: 'duplicate_block',
92
- message: `Duplicate block (${Math.round((item.similarity ?? 0) * 100)}% similarity)`,
93
- file: item.left?.file ?? '',
94
- line: item.left?.start ?? null,
95
- related_file: item.right?.file ?? '',
104
+ type: 'unused_file',
105
+ message: `Unused file ${item.path ?? '<unknown>'}`,
106
+ file: item.path ?? '',
107
+ line: null,
96
108
  });
97
109
  }
98
- for (const item of circular) {
110
+ for (const item of circularDeps) {
111
+ if (!item || typeof item !== 'object')
112
+ continue;
113
+ const files = Array.isArray(item.files) ? item.files : [];
99
114
  findings.push({
100
115
  type: 'circular_dependency',
101
- message: `Circular dependency: ${(item.cycle ?? []).join(' -> ')}`,
102
- file: Array.isArray(item.cycle) && item.cycle.length > 0 ? item.cycle[0] : '',
103
- line: null,
116
+ message: `Circular dependency: ${files.join(' -> ')}`,
117
+ file: files.length > 0 ? files[0] : '',
118
+ line: item.line ?? null,
119
+ });
120
+ }
121
+ for (const group of cloneGroups) {
122
+ if (!group || typeof group !== 'object')
123
+ continue;
124
+ const instances = Array.isArray(group.instances) ? group.instances : [];
125
+ findings.push({
126
+ type: 'duplicate_block',
127
+ message: `Duplicate block (${instances.length} instances)`,
128
+ file: instances[0]?.file ?? '',
129
+ line: instances[0]?.start_line ?? null,
130
+ related_file: instances[1]?.file ?? '',
104
131
  });
105
132
  }
106
133
  return {
107
134
  summary: {
108
- unused_exports: unused.length,
109
- duplicates: duplicates.length,
110
- circular_dependencies: circular.length,
135
+ unused_exports: unusedExports.length,
136
+ unused_files: unusedFiles.length,
137
+ duplicates: cloneGroups.length,
138
+ circular_dependencies: circularDeps.length,
111
139
  total: findings.length,
112
140
  },
113
141
  findings,
114
142
  };
115
143
  }
144
+ function normalizeFallowReportFile(filePath) {
145
+ try {
146
+ const raw = node_fs_1.default.readFileSync(filePath, 'utf8');
147
+ const parsed = JSON.parse(raw);
148
+ return normalizeFallowReport(parsed);
149
+ }
150
+ catch {
151
+ return normalizeFallowReport(null);
152
+ }
153
+ }
@@ -149,7 +149,7 @@ function matchesInValue(value, lowerTerm) {
149
149
  }
150
150
  /**
151
151
  * Query intel files for a search term.
152
- * Searches across all JSON intel files (keys and values) and arch.md (text lines).
152
+ * Searches across all JSON intel files in INTEL_FILES (keys and values), including arch-decisions.json (parsed as JSON, not as text).
153
153
  */
154
154
  function intelQuery(term, planningDir) {
155
155
  if (!isIntelEnabled(planningDir))
@@ -328,7 +328,7 @@ function intelValidate(planningDir) {
328
328
  }
329
329
  // Validate entries are objects with expected fields
330
330
  if (data.entries && typeof data.entries === 'object') {
331
- // files.json: check exports are actual symbol names (no spaces)
331
+ // file-roles.json (INTEL_FILES key 'files'): check exports are actual symbol names (no spaces)
332
332
  if (key === 'files') {
333
333
  for (const [entryPath, entry] of Object.entries(data.entries)) {
334
334
  const entryObj = entry;
@@ -348,7 +348,7 @@ function intelValidate(planningDir) {
348
348
  }
349
349
  }
350
350
  }
351
- // deps.json: check entries have version, type, used_by
351
+ // dependency-graph.json (INTEL_FILES key 'deps'): check entries have version, type, used_by
352
352
  if (key === 'deps') {
353
353
  for (const [depName, entry] of Object.entries(data.entries)) {
354
354
  const entryObj = entry;
@@ -65,6 +65,61 @@ function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnl
65
65
  }
66
66
  }
67
67
  // ─── Output helpers ───────────────────────────────────────────────────────────
68
+ /**
69
+ * Transient write errnos. When stdout/stderr is a NON-BLOCKING pipe — as it is
70
+ * under the parallel `node --test` runner on Linux CI — a full pipe buffer makes
71
+ * `fs.writeSync` throw EAGAIN, and a signal can interrupt it with EINTR. Both
72
+ * clear on retry once the reader drains. This is the same transient class the
73
+ * STATE.md lock path already retries (ACQUIRE_LOCK_RETRY_ERRNOS, #3776); #1008.
74
+ */
75
+ const WRITE_RETRY_ERRNOS = new Set(['EAGAIN', 'EINTR']);
76
+ // Bounded so a pathological never-draining fd cannot spin forever. Each retry
77
+ // yields the thread for ~1ms via Atomics.wait (the project's sync-sleep idiom —
78
+ // see clock.cts realClock.sleep), so the cap is ~1s of total back-pressure wait.
79
+ const WRITE_MAX_RETRIES = 1000;
80
+ const WRITE_RETRY_BACKOFF_MS = 1;
81
+ // Sleep buffer is lazily allocated on the FIRST back-pressure retry (rare — only
82
+ // when a non-blocking pipe is full) and then reused. Keeping it out of module
83
+ // load costs nothing on the overwhelmingly common no-retry path and avoids
84
+ // perturbing SharedArrayBuffer-allocation accounting in other modules (perf-316).
85
+ let _writeSleepBuf = null;
86
+ function backoffOnce() {
87
+ if (_writeSleepBuf === null)
88
+ _writeSleepBuf = new Int32Array(new SharedArrayBuffer(4));
89
+ Atomics.wait(_writeSleepBuf, 0, 0, WRITE_RETRY_BACKOFF_MS);
90
+ }
91
+ /**
92
+ * Write the entire payload to `fd`, tolerating non-blocking-pipe back-pressure.
93
+ *
94
+ * `fs.writeSync` does NOT block on a non-blocking pipe: a full buffer throws
95
+ * EAGAIN, and a partially-drained buffer returns a SHORT count (fewer bytes than
96
+ * requested). The previous bare `fs.writeSync(fd, string)` call assumed it always
97
+ * blocked until the kernel accepted every byte — false under load, which both
98
+ * threw spurious errors and risked silently truncating output (#1008).
99
+ *
100
+ * This loops on short counts (advancing the offset) and retries EAGAIN/EINTR with
101
+ * a brief Atomics.wait backoff that yields the thread so the reader can drain.
102
+ * Non-transient errors (e.g. EPIPE) propagate unchanged.
103
+ */
104
+ function writeAllSync(fd, data) {
105
+ const buf = Buffer.from(data, 'utf8');
106
+ let offset = 0;
107
+ let retries = 0;
108
+ while (offset < buf.length) {
109
+ try {
110
+ offset += node_fs_1.default.writeSync(fd, buf, offset, buf.length - offset);
111
+ }
112
+ catch (err) {
113
+ const code = err.code ?? '';
114
+ if (WRITE_RETRY_ERRNOS.has(code) && retries < WRITE_MAX_RETRIES) {
115
+ retries += 1;
116
+ backoffOnce();
117
+ continue;
118
+ }
119
+ throw err;
120
+ }
121
+ }
122
+ }
68
123
  function output(result, raw, rawValue) {
69
124
  let data;
70
125
  if (raw && rawValue !== undefined) {
@@ -87,10 +142,10 @@ function output(result, raw, rawValue) {
87
142
  }
88
143
  }
89
144
  // process.stdout.write() is async when stdout is a pipe — process.exit()
90
- // can tear down the process before the reader consumes the buffer.
91
- // fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and
92
- // skipping process.exit() lets the event loop drain naturally.
93
- node_fs_1.default.writeSync(1, data);
145
+ // can tear down the process before the reader consumes the buffer. writeAllSync
146
+ // pushes every byte synchronously (looping short counts, retrying EAGAIN/EINTR),
147
+ // and skipping process.exit() lets the event loop drain naturally.
148
+ writeAllSync(1, data);
94
149
  }
95
150
  /**
96
151
  * Frozen enum of typed reason codes used by error() for structured errors.
@@ -148,10 +203,10 @@ function getJsonErrorMode() { return _jsonErrorMode; }
148
203
  function error(message, reason = ERROR_REASON.UNKNOWN) {
149
204
  if (_jsonErrorMode) {
150
205
  const payload = JSON.stringify({ ok: false, reason, message }) + '\n';
151
- node_fs_1.default.writeSync(2, payload);
206
+ writeAllSync(2, payload);
152
207
  }
153
208
  else {
154
- node_fs_1.default.writeSync(2, 'Error: ' + message + '\n');
209
+ writeAllSync(2, 'Error: ' + message + '\n');
155
210
  }
156
211
  process.exit(1);
157
212
  }