heyspex 0.2.5 → 0.2.7

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 (110) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/bin/hs.mjs +349 -38
  3. package/bin/spexcode.mjs +31 -0
  4. package/dev-factory/lib/dispatch-guard.mjs +75 -0
  5. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  6. package/dev-factory/lib/runner-path.mjs +51 -0
  7. package/dev-factory/lib/seat-context.mjs +20 -4
  8. package/dev-factory/model-catalog.json +222 -222
  9. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  10. package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
  11. package/dist/cli/hs-connect.mjs +40 -3
  12. package/dist/cli/hs-defer.mjs +3817 -3741
  13. package/dist/cli/hs-events.mjs +1296 -0
  14. package/dist/cli/hs-export-events.mjs +1252 -0
  15. package/dist/cli/hs-handoff.mjs +2576 -0
  16. package/dist/cli/hs-index-rebuild.mjs +228 -37
  17. package/dist/cli/hs-index-reconcile.mjs +231 -40
  18. package/dist/cli/hs-init.mjs +22 -3
  19. package/dist/cli/hs-install.mjs +22 -3
  20. package/dist/cli/hs-join.mjs +40 -3
  21. package/dist/cli/hs-next-launch.mjs +2777 -0
  22. package/dist/cli/hs-pin.mjs +1336 -0
  23. package/dist/cli/hs-prereq.mjs +1380 -0
  24. package/dist/cli/hs-revalidate.mjs +3257 -0
  25. package/dist/cli/hs-spawn.mjs +12403 -0
  26. package/dist/cli/hs-test.mjs +1046 -0
  27. package/dist/cli/hs-trash.mjs +362 -0
  28. package/dist/cli/hs-trust.mjs +197 -0
  29. package/dist/cli/hs-vault-pull.mjs +233 -42
  30. package/dist/cli/hs-vault-push.mjs +231 -40
  31. package/dist/cli/hs-vault.mjs +21 -7
  32. package/dist/cli/hs-workspace.mjs +19 -1
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  42. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  43. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  44. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  45. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  46. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  47. package/dist/cli-templates/harness-commands/claude/hs-res.md +83 -63
  48. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  49. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  50. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  51. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  52. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  53. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  54. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  55. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  56. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  57. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  58. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  59. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  60. package/dist/cli-templates/harness-commands/cursor/hs-res.md +78 -58
  61. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  62. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  63. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  64. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  65. package/docs/copilot-plugin-install.md +9 -2
  66. package/docs/mcp-client-config.md +6 -0
  67. package/package.json +266 -257
  68. package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
  69. package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
  70. package/plugins/heyspex/README.md +6 -0
  71. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  72. package/plugins/heyspex/hooks/hooks.json +418 -418
  73. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  74. package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
  75. package/scripts/claude-statusline/statusline-command.mjs +287 -27
  76. package/scripts/hs-companion.mjs +41 -3
  77. package/scripts/install-claude-code-hooks.mjs +104 -3
  78. package/scripts/install-copilot-hooks.mjs +21 -1
  79. package/scripts/install-cursor-hooks.mjs +4 -3
  80. package/scripts/lib/capture-execution-guard.mjs +221 -0
  81. package/scripts/lib/companion-install.mjs +768 -0
  82. package/scripts/lib/customer-kit.mjs +53 -3
  83. package/scripts/lib/hs-companion-settings.mjs +26 -0
  84. package/scripts/lib/hs-source-push.mjs +386 -386
  85. package/scripts/record-grounding.mjs +38 -0
  86. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  87. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  88. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  90. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  91. package/spexharness/src/seat-context.mjs +10 -5
  92. package/spexharness/src/session-bootstrap.mjs +6 -3
  93. package/vendor/heyspex-mcp/cli.mjs +6 -1
  94. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  95. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  96. package/vendor/heyspex-mcp/http-transport.mjs +1 -1
  97. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  98. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  99. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  100. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  101. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  102. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  103. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  104. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  105. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  106. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  107. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  108. package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
  109. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
  110. package/bin/postinstall.mjs +0 -88
@@ -1,1181 +1,1209 @@
1
- /**
2
- * HeySpex procedure-discovery MCP tools.
3
- *
4
- * Pure-logic module backing the `heyspex_list_procedures`,
5
- * `heyspex_get_procedure`, and `heyspex_preflight` MCP tools mounted in
6
- * cli.mjs. Reads the committed catalog at specs/protocols/.catalog.json
7
- * (generated by scripts/build-procedure-catalog.mjs) and the underlying
8
- * markdown bodies under specs/protocols/.
9
- *
10
- * Spec: [[HS-EPIC-3QS8-agent-procedure-discovery-enforcement-la]]
11
- * Pattern source: vendor/heyspex-mcp/design-tools.mjs (the
12
- * heyspex_design_* family is the canonical template this module mirrors).
13
- */
14
-
15
- import { existsSync, readFileSync } from "node:fs";
16
- import { dirname, join, resolve } from "node:path";
17
- import { fileURLToPath } from "node:url";
18
-
19
- import { consoleReadErrorFrom, mergeDegradation, readViaSelector, withDegradationScope } from "./read-path.mjs";
20
- import { pageListToolResult } from "./list-tools.mjs";
21
-
22
- const CATALOG_FILE = ["specs", "protocols", ".catalog.json"];
23
- const PROTOCOLS_DIR = ["specs", "protocols"];
24
-
25
- function normalize(value) {
26
- return String(value ?? "").toLowerCase();
27
- }
28
-
29
- export function loadCatalog(vaultRoot) {
30
- const path = join(vaultRoot, ...CATALOG_FILE);
31
- if (!existsSync(path)) return null;
32
- try {
33
- return JSON.parse(readFileSync(path, "utf8"));
34
- } catch {
35
- return null;
36
- }
37
- }
38
-
39
- function summarize(entry) {
40
- return {
41
- name: entry.name,
42
- id: entry.id,
43
- title: entry.title,
44
- tags: Array.isArray(entry.tags) ? entry.tags : [],
45
- triggers: Array.isArray(entry.triggers) ? entry.triggers : [],
46
- summary: entry.summary ?? "",
47
- path: entry.path,
48
- };
49
- }
50
-
51
- /**
52
- * `injectedCatalog` (HS-DJWP-MCPC-TASK read-path wiring): when provided, used
53
- * verbatim INSTEAD of `loadCatalog(vaultRoot)` — the cloud-reliant selector
54
- * wrapper below fetches the catalog once over HTTP and injects it here so
55
- * this function never touches disk in that tier. `undefined` (the default)
56
- * preserves today's disk-only behavior byte-for-byte.
57
- */
58
- export function executeListProcedures(vaultRoot, args = {}, injectedCatalog) {
59
- const catalog =
60
- injectedCatalog !== undefined ? injectedCatalog : loadCatalog(vaultRoot);
61
- if (!catalog) {
62
- return {
63
- error: "CATALOG_NOT_FOUND",
64
- message:
65
- "specs/protocols/.catalog.json not found. Run `node scripts/build-procedure-catalog.mjs`.",
66
- };
67
- }
68
- const procedures = Array.isArray(catalog.procedures)
69
- ? catalog.procedures
70
- : [];
71
- const tagFilter = args.tag ? normalize(args.tag) : null;
72
- const triggerFilter = args.trigger ? normalize(args.trigger) : null;
73
- const queryFilter = args.query ? normalize(args.query) : null;
74
-
75
- const matches = [];
76
- for (const entry of procedures) {
77
- if (tagFilter) {
78
- const tags = (entry.tags ?? []).map(normalize);
79
- if (!tags.includes(tagFilter)) continue;
80
- }
81
- if (triggerFilter) {
82
- const triggers = (entry.triggers ?? []).map(normalize);
83
- if (!triggers.includes(triggerFilter)) continue;
84
- }
85
- if (queryFilter) {
86
- const haystack = [entry.name, entry.title, entry.summary]
87
- .map(normalize)
88
- .join(" ");
89
- if (!haystack.includes(queryFilter)) continue;
90
- }
91
- matches.push(summarize(entry));
92
- }
93
-
94
- // One page on a (name, id) keyset cursor, fitted to the byte budget of the
95
- // tool — never the whole catalog, never a total count
96
- // ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]]).
97
- const paged = pageListToolResult("heyspex_list_procedures", matches, args, {
98
- keyOf: (p) => p.name,
99
- idOf: (p) => p.id ?? p.name,
100
- extra: {
101
- filters_applied: {
102
- tag: args.tag ?? null,
103
- trigger: args.trigger ?? null,
104
- query: args.query ?? null,
105
- },
106
- },
107
- });
108
- if (paged.error) return paged.error;
109
- return paged.result;
110
- }
111
-
112
- function findEntry(catalog, name) {
113
- const procedures = Array.isArray(catalog.procedures)
114
- ? catalog.procedures
115
- : [];
116
- return procedures.find((p) => p.name === name) || null;
117
- }
118
-
119
- export function executeGetProcedure(vaultRoot, args = {}) {
120
- const name = String(args.name ?? "").trim();
121
- if (!name) {
122
- return {
123
- error: "MISSING_NAME",
124
- message: 'name is required (e.g. "spec-hierarchy")',
125
- };
126
- }
127
- const catalog = loadCatalog(vaultRoot);
128
- if (!catalog) {
129
- return {
130
- error: "CATALOG_NOT_FOUND",
131
- message:
132
- "specs/protocols/.catalog.json not found. Run `node scripts/build-procedure-catalog.mjs`.",
133
- };
134
- }
135
- const entry = findEntry(catalog, name);
136
- if (!entry) {
137
- return {
138
- error: "PROCEDURE_NOT_FOUND",
139
- message: `No procedure named "${name}" in catalog. Call heyspex_list_procedures to browse.`,
140
- };
141
- }
142
- const bodyPath = join(vaultRoot, ...PROTOCOLS_DIR, `${name}.md`);
143
- if (!existsSync(bodyPath)) {
144
- return {
145
- error: "BODY_FILE_MISSING",
146
- message: `Catalog entry exists but ${entry.path} not found on disk. Regenerate the catalog.`,
147
- };
148
- }
149
- const content = readFileSync(bodyPath, "utf8");
150
- return {
151
- procedure: {
152
- name: entry.name,
153
- id: entry.id,
154
- title: entry.title,
155
- tags: Array.isArray(entry.tags) ? entry.tags : [],
156
- triggers: Array.isArray(entry.triggers) ? entry.triggers : [],
157
- summary: entry.summary ?? "",
158
- path: entry.path,
159
- status: entry.status ?? null,
160
- created: entry.created ?? null,
161
- lastModified: entry.lastModified ?? null,
162
- byteSize: entry.byteSize ?? null,
163
- },
164
- content,
165
- source: entry.path,
166
- };
167
- }
168
-
169
- /**
170
- * Procedures every customer vault ships (the universal template protocols; a
171
- * drift test pins this list to the template manifest's protocol dests). Every
172
- * other PREFLIGHT_INTENT_MAP procedure is platform-only: a vault whose runtime
173
- * catalog lacks it gets the row DEMOTED to advisory by {@link resolveRow}, never
174
- * deleted. Promoting more procedures to universal is a later template version.
175
- */
176
- export const UNIVERSAL_PROCEDURES = Object.freeze([
177
- "canonical-frontmatter-templates",
178
- "spec-hierarchy",
179
- "memory-protocol",
180
- "session-close-protocol",
181
- "spec-summary-authoring",
182
- "destructive-action-checklist",
183
- ]);
184
-
185
- /** Audience of a preflight row, by procedure name. */
186
- export function procedureAudience(name) {
187
- return UNIVERSAL_PROCEDURES.includes(name) ? "universal" : "platform";
188
- }
189
-
190
- const DEMOTED_REASON = "not in this vault's catalog (platform-only procedure)";
191
-
192
- /**
193
- * Resolve one preflight row against the supplied (runtime) catalog. Shared by
194
- * the static intent path AND the close_session thread-type path. A row whose
195
- * procedure is present is returned exactly as before; an absent one is demoted
196
- * to `advisory` (original severity kept in `demoted_from`) with
197
- * `missing_from_catalog: true` still set for consumers.
198
- */
199
- export function resolveRow(catalog, row) {
200
- const entry = findEntry(catalog, row.procedure);
201
- const base = {
202
- procedure: row.procedure,
203
- ...(row.why !== undefined ? { why: row.why } : {}),
204
- severity: row.severity,
205
- path: entry?.path ?? null,
206
- title: entry?.title ?? null,
207
- missing_from_catalog: entry ? undefined : true,
208
- };
209
- if (entry) return base;
210
- return {
211
- ...base,
212
- severity: "advisory",
213
- demoted_from: row.severity,
214
- reason: DEMOTED_REASON,
215
- };
216
- }
217
-
218
- // Bootstrap intent → procedure map (eight intents per HS-EPIC-3QS8 §Open
219
- // questions "Trigger taxonomy"). Each row names the procedure slug (matches
220
- // specs/protocols/<slug>.md), a `why` blurb the agent can quote, and a
221
- // severity. Extension rule: add a new intent when a third unique use case
222
- // appears — new intents land in their own tasks, not edits to this map.
223
- const PREFLIGHT_INTENT_MAP = {
224
- // [[HS-K0ZP]]: "about to do research" is a governed trigger. Running this
225
- // preflight writes the proof-of-use marker that unlocks WebSearch / WebFetch /
226
- // heyspex_save_research for the session (spexharness/src/gates/research-protocol-gate.mjs
227
- // on every PreToolUse front, preflight-gate.mjs inside the MCP server). Before
228
- // it, those calls are refused -- the protocol was prose an agent could skip.
229
- research: [
230
- {
231
- procedure: "research-capture-protocol",
232
- why: "Method triage FIRST (known-target-retrieval vs the confidence ladder), ground-first (heyspex_recall_memories + /hs-ground) before any external pass, and the offer-before-save step: a web search offers to save findings, it does not auto-file a research doc.",
233
- severity: "required",
234
- },
235
- {
236
- procedure: "capture-guardrails",
237
- why: "A known-target lookup never enters the research-doc pipeline; the exit for a quick retrieval is a memory-reference or a fold into the consuming spec.",
238
- severity: "recommended",
239
- },
240
- ],
241
- promote_research: [
242
- {
243
- procedure: "research-capture-protocol",
244
- why: "Confirm the research doc is complete (prerequisites, conclusion, sources) before promotion.",
245
- severity: "required",
246
- },
247
- {
248
- procedure: "spec-hierarchy",
249
- why: "Mandatory default: target_milestone NEW. Existing-milestone attach only when status: open and thesis clearly extends; in_progress requires explicit owner override.",
250
- severity: "required",
251
- },
252
- {
253
- procedure: "pillar-assignment-protocol",
254
- why: "Every spawned epic must name a pillar before execution breakdown.",
255
- severity: "recommended",
256
- },
257
- ],
258
- file_iteration: [
259
- {
260
- procedure: "supersession-protocol",
261
- why: "§Iterates-on: choose the right tier; a re-attempt is neither supersession nor amends.",
262
- severity: "required",
263
- },
264
- {
265
- procedure: "research-capture-protocol",
266
- why: 'Promote flow asks "is this a re-attempt?" and populates iterates_on: — confirm iteration lineage is recorded.',
267
- severity: "required",
268
- },
269
- {
270
- procedure: "internal-linking-protocol",
271
- why: "Iteration structure and tier-based lineage tracking; see entity-model §Iteration lineage for tier semantics.",
272
- severity: "recommended",
273
- },
274
- ],
275
- file_milestone: [
276
- {
277
- procedure: "spec-hierarchy",
278
- why: "Defines which level a new spec belongs at and the milestone frontmatter shape.",
279
- severity: "required",
280
- },
281
- {
282
- procedure: "spec-summary-authoring",
283
- why: "Every milestone needs a plain-language summary: at filing time — pass summary to heyspex_file_spec / POST /api/specs.",
284
- severity: "required",
285
- },
286
- {
287
- procedure: "vault-id-assignment-protocol",
288
- why: "Allocates a unique HS-MIL-<suffix> id and prevents duplicates.",
289
- severity: "required",
290
- },
291
- ],
292
- file_epic: [
293
- {
294
- procedure: "spec-hierarchy",
295
- why: "Defines epic frontmatter, required parent-milestone link, and child layout.",
296
- severity: "required",
297
- },
298
- {
299
- procedure: "spec-summary-authoring",
300
- why: "Every epic needs a plain-language summary at plan-ingest filing time — pass summary to heyspex_push_plan / heyspex_file_spec / POST /api/specs. Do not file tasks in the same pass.",
301
- severity: "required",
302
- },
303
- {
304
- procedure: "pillar-assignment-protocol",
305
- why: "Every epic must name a pillar (hard-blocked at filing). Covers picking an existing HS-PILR theme or authoring a net-new one.",
306
- severity: "required",
307
- },
308
- {
309
- procedure: "vault-id-assignment-protocol",
310
- why: "Allocates a unique HS-EPIC-<suffix> id and prevents duplicates.",
311
- severity: "required",
312
- },
313
- {
314
- procedure: "plan-ingest-agent-workflow",
315
- why: "Linkage + manifest registration steps when ingesting an external plan into the epic.",
316
- severity: "recommended",
317
- },
318
- {
319
- procedure: "builder-fleet-capability-reference",
320
- why: "Optional builder_profile + ## Builder fleet notes at epic filing — anticipate local builder model class and context tier.",
321
- severity: "recommended",
322
- },
323
- ],
324
- file_task: [
325
- {
326
- procedure: "spec-hierarchy",
327
- why: "Defines task frontmatter, required parent-epic link, and child-task fan-out rules.",
328
- severity: "required",
329
- },
330
- {
331
- procedure: "builder-fleet-capability-reference",
332
- why: "Required ## Builder brief + dispatch frontmatter (suggested_model, recommended_num_ctx, context_files) for fleet-calibrated tasks.",
333
- severity: "required",
334
- },
335
- {
336
- procedure: "spec-summary-authoring",
337
- why: "Every task needs a plain-language summary at the /hs-next breakdown moment — pass summary to heyspex_file_spec / POST /api/specs. Tasks are never filed during plan ingest.",
338
- severity: "required",
339
- },
340
- {
341
- procedure: "vault-id-assignment-protocol",
342
- why: "Allocates a unique HS-TASK-<suffix> id and prevents duplicates.",
343
- severity: "required",
344
- },
345
- {
346
- procedure: "checks-manifest-authoring",
347
- why: "Every task ships a FROZEN specs/checks/<task-id>.checks.json — each AC classified machine|property|human with named runnable checks, authored at breakdown before any builder sees the brief. A task without one is not ready to dispatch.",
348
- severity: "required",
349
- },
350
- {
351
- procedure: "unit-test-authoring-protocol",
352
- why: "How the tests behind the manifest checks are written: pure-core/thin-shell, colocation, the five case families, and the mutation gate as the quality oracle.",
353
- severity: "recommended",
354
- },
355
- ],
356
- file_diagnose: [
357
- {
358
- procedure: "spec-hierarchy",
359
- why: "Defines diagnose frontmatter, the standalone-default / optional epic-or-milestone parent rule, and severity.",
360
- severity: "required",
361
- },
362
- {
363
- procedure: "vault-id-assignment-protocol",
364
- why: "Allocates a unique HS-DIAG-<suffix> id and prevents duplicates.",
365
- severity: "required",
366
- },
367
- {
368
- procedure: "resolution-diagnose-workflow",
369
- why: "The end-to-end bug→diagnose procedure: intake, §Step 2 symptom-vs-core classification (if fixing exactly this leaves the same class of failure possible, you are still on the symptom), severity ranking, §Step 6 sibling sweep — which now owes a receipt in the body's ## Sweep section — and close-out.",
370
- severity: "required",
371
- },
372
- {
373
- procedure: "checks-manifest-authoring",
374
- why: 'A bug is the case where a regression check is worth the most and costs the least — the reproduction is already written. Freeze one runnable check per acceptance criterion via `checks:` on heyspex_file_spec, and make at least one of them FAIL when the fix is reverted; "the test passes now" is also what a symptom-level fix produces. REQUIRED WITH AN OUT: a trivial bug may file with no manifest — filing still succeeds and returns a `no_checks_manifest` warning — but skipping is a decision to state, not a step to forget.',
375
- severity: "required",
376
- },
377
- ],
378
- file_research: [
379
- {
380
- procedure: "capture-guardrails",
381
- why: "A brand-new idea surfacing in conversation (no existing HS-CAP-*/HS-RES-* id already in play) goes through Capture → Ground=Expand → Promote-to-research first via heyspex_active_capture — never straight to file_research/research dispatch tooling. Skip only when the research target already has an existing capture or research id.",
382
- severity: "recommended",
383
- },
384
- {
385
- procedure: "spec-hierarchy",
386
- why: "Confirms research is a standalone entity (HS-RES-<id>), how it links back to consumers, and that promotion defaults to a NEW milestone.",
387
- severity: "required",
388
- },
389
- {
390
- procedure: "research-capture-protocol",
391
- why: "Required template (Executive Summary + Sources) and the back-wikilink + maintenance rules for a research doc.",
392
- severity: "required",
393
- },
394
- {
395
- procedure: "vault-id-assignment-protocol",
396
- why: "Allocates a unique HS-RES-<suffix> id and prevents duplicates.",
397
- severity: "required",
398
- },
399
- ],
400
- file_memory_lesson: [
401
- {
402
- procedure: "memory-protocol",
403
- why: "How to file a memory-lesson and link it back to the originating spec; the four-axis capture rubric.",
404
- severity: "required",
405
- },
406
- {
407
- procedure: "vault-id-assignment-protocol",
408
- why: "Allocates a unique memory slug/id and prevents duplicates.",
409
- severity: "required",
410
- },
411
- ],
412
- file_memory_decision: [
413
- {
414
- procedure: "memory-protocol",
415
- why: "How to file a memory-decision and link it back to the originating spec; the four-axis capture rubric.",
416
- severity: "required",
417
- },
418
- {
419
- procedure: "vault-id-assignment-protocol",
420
- why: "Allocates a unique memory slug/id and prevents duplicates.",
421
- severity: "required",
422
- },
423
- ],
424
- file_memory_reference: [
425
- {
426
- procedure: "memory-protocol",
427
- why: "How to file a memory-reference and link it back to the originating spec; the four-axis capture rubric.",
428
- severity: "required",
429
- },
430
- {
431
- procedure: "vault-id-assignment-protocol",
432
- why: "Allocates a unique memory slug/id and prevents duplicates.",
433
- severity: "required",
434
- },
435
- ],
436
- file_memory_ui_pattern: [
437
- {
438
- procedure: "memory-protocol",
439
- why: "How to file a memory-ui-pattern and link it back to the originating spec; the four-axis capture rubric.",
440
- severity: "required",
441
- },
442
- {
443
- procedure: "vault-id-assignment-protocol",
444
- why: "Allocates a unique memory slug/id and prevents duplicates.",
445
- severity: "required",
446
- },
447
- ],
448
- file_review: [
449
- {
450
- procedure: "session-close-protocol",
451
- why: "The HS-REV companion doc shape (four required sections) and the close-out flow it belongs to.",
452
- severity: "required",
453
- },
454
- {
455
- procedure: "spec-hierarchy",
456
- why: 'Confirms the review parents the spec under review (parent: "[[<spec>]]" is required).',
457
- severity: "recommended",
458
- },
459
- ],
460
- file_capture: [
461
- {
462
- procedure: "capture-guardrails",
463
- why: "Captures are session-based and NOT research — human words stay verbatim in `## Notes`; AI authors separate layers on an explicit pass. Drive them via heyspex_active_capture, not heyspex_file_spec.",
464
- severity: "required",
465
- },
466
- ],
467
- destructive_action: [
468
- {
469
- procedure: "destructive-action-checklist",
470
- why: "Run before any PR that deletes, archives, or routes data-destroying actions.",
471
- severity: "required",
472
- },
473
- {
474
- procedure: "dev-server-lifecycle-and-db-safety",
475
- why: "If the action is a DB migration / Neon branch reset / .next delete, stop the running dev server first — check ground-truth status via heyspex_dev_server_status.",
476
- severity: "recommended",
477
- },
478
- ],
479
- protocol_edit: [
480
- {
481
- procedure: "spec-hierarchy",
482
- why: '§Agent autonomy row "Modify a protocol doc" — protocol edits require explicit user approval.',
483
- severity: "required",
484
- },
485
- ],
486
- memory_creation: [
487
- {
488
- procedure: "memory-protocol",
489
- why: "How to file a lesson/decision/reference/ui-pattern memory and link it back to the originating spec.",
490
- severity: "required",
491
- },
492
- ],
493
- close_session: [
494
- {
495
- procedure: "session-close-protocol",
496
- why: "Required agent close-out checklist (status flips, review-doc creation, hand-off). " + "Hand-off: if the human asks for a hand-off, or work remains for a next session, run the /hs-handoff create flow — `hs handoff record --new-code --spec <next> --phase … --link … --blocker …` (minted code) and hand back `/hs-handoff <CODE>` plus `claude \"/hs-next <id>\"`; never a freehand prompt.",
497
- severity: "required",
498
- },
499
- {
500
- procedure: "memory-protocol",
501
- why: "Self-check for learnings is step 0 of close-out — capture any non-obvious lesson/decision as a memory spec.",
502
- severity: "recommended",
503
- },
504
- ],
505
- context_rollover: [
506
- {
507
- procedure: "context-rollover-checkpoint",
508
- why: "Lightweight mid-flight checkpoint when compaction interrupts implementation — record WIP + handoff, then resume. Do NOT flip status / write HS-REV (that is session close, not rollover).",
509
- severity: "required",
510
- },
511
- ],
512
- claim_spec: [
513
- {
514
- procedure: "spec-hierarchy",
515
- why: "A spec is claimable iff it is its own current ClaimTarget — read §Claim eligibility before claiming an Epic/Milestone with open descendants; the console endpoint returns 409 claim_blocked_by_descendants otherwise ([[HS-TASK-0RVD]]).",
516
- severity: "required",
517
- },
518
- {
519
- procedure: "pre-execution-alignment-gate",
520
- why: "The /hs-next surface routes the claim through this gate; the brief sections (world_state_new / world_state_modified / graph_delta) and the 4-option menu are documented here.",
521
- severity: "recommended",
522
- },
523
- ],
524
- execute_spec: [
525
- {
526
- procedure: "dev-factory-delegation-routing",
527
- why: "The COO operating contract: delegation is table-driven. Before delegating build/breakdown/critic work, consult dev-factory/routing-table.json — build → free local fleet, breakdown/critic → DeepSeek V4, cloud/Opus escalation-only. NEVER spawn a Claude subagent on your own (expensive) tier for mechanical, gate-verified build.",
528
- severity: "required",
529
- },
530
- {
531
- procedure: "pre-execution-alignment-gate",
532
- why: "The first pre-flight before executing an epic/milestone — break down a childless node or revisit a drifted breakdown; hard gate with override-with-reason.",
533
- severity: "required",
534
- },
535
- {
536
- procedure: "spec-hierarchy",
537
- why: "Confirms the level you are executing and the parent/child linkage the breakdown must respect.",
538
- severity: "recommended",
539
- },
540
- {
541
- procedure: "builder-fleet-capability-reference",
542
- why: "Context tier ladder + task Builder brief contract when breaking down or dispatching to the local fleet.",
543
- severity: "recommended",
544
- },
545
- {
546
- procedure: "unit-test-authoring-protocol",
547
- why: "Before writing any test: pure-core/thin-shell structure, colocated <module>.test.ts, the five case families every pure module covers, and the mutation gate as the test-quality oracle.",
548
- severity: "recommended",
549
- },
550
- {
551
- procedure: "checks-manifest-authoring",
552
- why: "The frozen specs/checks/<task-id>.checks.json is the external completion gate — builders satisfy its named checks and never edit it; hs check run <task-id> is the per-criterion verdict.",
553
- severity: "recommended",
554
- },
555
- {
556
- procedure: "dev-server-lifecycle-and-db-safety",
557
- why: "Execution often involves DB migrations / dependency installs / .next deletes that break a running dev server — check ground-truth status (heyspex_dev_server_status) and stop the server first.",
558
- severity: "recommended",
559
- },
560
- ],
561
- exit_plan_mode: [
562
- {
563
- procedure: "plan-quality-canonical-check",
564
- why: "Run dual overlap+code audit and canonical/supersession check before exit; revise plan to V2.",
565
- severity: "required",
566
- },
567
- {
568
- procedure: "plan-ingest-agent-workflow",
569
- why: "After the plan is approved, do NOT start coding. First ingest the approved plan into the vault as an epic (file it with children:[]; tasks fan out later via /hs-next) AND claim it via heyspex_claim_spec before executing. ExitPlanMode is the plan→execute seam — without this step agents skip filing+claiming and go straight to code.",
570
- severity: "required",
571
- },
572
- {
573
- procedure: "supersession-protocol",
574
- why: "If the plan deprecates an existing spec, file the supersession edge per protocol.",
575
- severity: "recommended",
576
- },
577
- ],
578
- recommend_location: [
579
- {
580
- procedure: "rule-placement-protocol",
581
- why: "Cost ranking for where a new rule belongs: procedure ≥ memory > spec > CLAUDE.md. CLAUDE.md is the most expensive surface (always-loaded, customer-inherited) — never the default.",
582
- severity: "required",
583
- },
584
- {
585
- procedure: "memory-protocol",
586
- why: "When the right home is a memory (rank 2 in rule-placement-protocol), this defines the lesson/decision/reference/ui-pattern shape and tagging conventions.",
587
- severity: "recommended",
588
- },
589
- {
590
- procedure: "foundations-placement-protocol",
591
- why: "When the thing to place is a high-level vision/strategy concept (a principle, a north-star idea, a business claim) rather than an agent-behavior rule: ranks the foundations homes (North Star > Operating Model > Principles > Architecture > Positioning > Research) and gates a foundational edit behind user sign-off.",
592
- severity: "recommended",
593
- },
594
- ],
595
- focus_pivot: [
596
- {
597
- procedure: "focus-intent-routing",
598
- why: 'How to detect a roadmap focus-pivot utterance and route it through heyspex_set_focus_areas — covers negation handling ("stop working on X" must NOT add X), the closed-tag-vocabulary constraint (heyspex_list_known_tags first), and the confirmation beat before applying.',
599
- severity: "required",
600
- },
601
- ],
602
- server_impacting_action: [
603
- {
604
- procedure: "dev-server-lifecycle-and-db-safety",
605
- why: "Before a DB migration, Neon branch reset, dependency install, .next delete, or starting a second dev server: check ground-truth status (heyspex_dev_server_status / `hs server status`) and stop a live server first — running a migration under a live server strands its in-memory schema and forces a restart.",
606
- severity: "required",
607
- },
608
- ],
609
- restructure_content: [
610
- {
611
- procedure: "internal-linking-protocol",
612
- why: "Before re-parenting an epic, editing a milestone epics[] roster, or moving trackable work to a new canonical home — leave a summary + wikilink stub at the old home so wikilinks and lexical retrieval signal are preserved.",
613
- severity: "required",
614
- },
615
- {
616
- procedure: "section-supersede-protocol",
617
- why: "Before editing or deleting a section that has gone stale: the three-way test for edit-in-place vs supersede-the-section vs retire-the-whole-document, the stub properties a reviewer checks, and the rule that the repair stays a proposal a person approves — a linked heading is never silently rewritten.",
618
- severity: "required",
619
- },
620
- {
621
- procedure: "spec-hierarchy",
622
- why: "Confirms bidirectional linkage shape (epics: ⇄ milestone:, children: ⇄ parent:) that re-parent edits must maintain.",
623
- severity: "recommended",
624
- },
625
- {
626
- procedure: "supersession-protocol",
627
- why: "If the move replaces dead guidance (not merely relocates live work), use supersession instead of a cross-reference stub.",
628
- severity: "recommended",
629
- },
630
- {
631
- procedure: "doc-canonicalization-protocol",
632
- why: "If the restructure is an evergreen-doc split/merge rather than plan-side re-parent, use doc-canonicalization instead.",
633
- severity: "recommended",
634
- },
635
- ],
636
- };
637
-
638
- const VALID_INTENTS = Object.keys(PREFLIGHT_INTENT_MAP);
639
-
640
- // Maps a file_* intent to the canonical filing tool the agent should call.
641
- // This is the core of [[HS-EPIC-WDP3]]: surface the deterministic tool inline
642
- // in the preflight response so an agent that reaches the procedure layer is
643
- // routed at the right answer — instead of pattern-matching a sibling spec's
644
- // frontmatter. Every non-capture kind files via heyspex_file_spec (the server
645
- // generates frontmatter from the canonical template); captures are
646
- // session-based and route at heyspex_active_capture. Intents not listed here
647
- // (destructive_action, protocol_edit, close_session, claim_spec, execute_spec,
648
- // recommend_location) govern actions, not spec creation — close_session carries
649
- // close_tool; others carry no filing_tool block.
650
- const CLOSE_TOOL_MAP = {
651
- close_session: {
652
- tool: "heyspex_submit_for_review",
653
- minimal_call: { spec_id: "<completed-review-unit-or-task-id>" },
654
- note: "Status SSOT is PATCH /api/specs/:id/status — not frontmatter edits. This tool applies review-grain coercion, parent rollup, and releases the claim. Still run session-close-protocol steps 2–7 (change-log, HS-REV or review seed, WIP, export).",
655
- },
656
- };
657
-
658
- // Build the close_tool block for action intents that flip status at session end.
659
- function buildCloseTool(intent) {
660
- return CLOSE_TOOL_MAP[intent] ?? null;
661
- }
662
-
663
- const FILE_SPEC_NOTE =
664
- "Use this. The server generates frontmatter from the canonical template. Do not compose frontmatter from a sibling spec.";
665
- const FILING_TOOL_MAP = {
666
- file_milestone: { type: "milestone" },
667
- file_epic: {
668
- type: "epic",
669
- parentKey: "milestone",
670
- parentExample: "[[HS-MIL-...]]",
671
- },
672
- file_task: {
673
- type: "task",
674
- parentKey: "parent",
675
- parentExample: "[[HS-EPIC-...]]",
676
- },
677
- file_diagnose: {
678
- type: "diagnose",
679
- note: `${FILE_SPEC_NOTE} By default a diagnose is filed standalone — it has no parent and does not nest under any epic or milestone, so it lives on its own in the Resolution lane rather than inside a planned work item. Keep it standalone for a cross-cutting bug, or one that isn't part of any single epic's or milestone's scope (the common case). Only set parent: "[[HS-EPIC-...]]" or "[[HS-MIL-...]]" when the bug clearly belongs to that epic's or milestone's work and you want it tracked under it.`,
680
- },
681
- file_research: { type: "research" },
682
- file_memory_lesson: { type: "memory-lesson" },
683
- file_memory_decision: { type: "memory-decision" },
684
- file_memory_reference: { type: "memory-reference" },
685
- file_memory_ui_pattern: { type: "memory-ui-pattern" },
686
- file_review: {
687
- type: "review",
688
- parentKey: "parent",
689
- parentExample: "[[<spec-under-review>]]",
690
- note: `${FILE_SPEC_NOTE} A review's parent is the spec it reviews — parent is required.`,
691
- },
692
- file_capture: {
693
- tool: "heyspex_active_capture",
694
- minimal_call: { spec_id: "<HS-CAP-...>" },
695
- note: "Captures are session-based; use heyspex_active_capture, NOT heyspex_file_spec.",
696
- },
697
- };
698
-
699
- // Build the filing_tool block for a given intent, or null when the intent does
700
- // not create a spec.
701
- function buildFilingTool(intent) {
702
- const spec = FILING_TOOL_MAP[intent];
703
- if (!spec) return null;
704
- // Captures (and any future bespoke tool) carry an explicit shape.
705
- if (spec.tool) {
706
- return {
707
- tool: spec.tool,
708
- minimal_call: spec.minimal_call,
709
- note: spec.note,
710
- };
711
- }
712
- const minimal_call = { type: spec.type };
713
- if (spec.parentKey) minimal_call[spec.parentKey] = spec.parentExample;
714
- minimal_call.name = "<title>";
715
- return {
716
- tool: "heyspex_file_spec",
717
- minimal_call,
718
- note: spec.note ?? FILE_SPEC_NOTE,
719
- };
720
- }
721
-
722
- // ─────────────────────────────────────────────────────────────────────────────
723
- // Thread-type-aware session close-out ([[HS-RES-32MJ]]). Close-out is NOT
724
- // one-size-fits-all: the thread type decides whether there is a close at all and
725
- // which steps run; the grain ladder (session-close-protocol) decides which spec
726
- // is the review unit on the execution paths. Thread-type is DECLARED, not
727
- // inferred — two of the five candidate signals (specs-filed-by-type,
728
- // research-saved) are not session-scoped on either tier, so a positive
729
- // declaration is the robust path. Mirrors the prose decision table in
730
- // session-close-protocol §"Thread-type close-out".
731
- // ─────────────────────────────────────────────────────────────────────────────
732
- const CLOSE_SESSION_THREAD_PATHS = {
733
- capture_only: {
734
- label: "Capture-only — brain-offload via /hs-capture; no claim, no code",
735
- is_close: false,
736
- close_tool: false,
737
- procedures: ["research-capture-protocol"],
738
- steps: [
739
- "No session close-out is owed — a capture is not a review unit.",
740
- "Exit = promote to research (heyspex_promote_capture) or leave the capture pinned.",
741
- "Do NOT submit_for_review, mint an HS-REV, record a WIP ledger, or flush an audit export.",
742
- ],
743
- },
744
- research_only: {
745
- label: "Research-only — saved an HS-RES; no claimed spec",
746
- is_close: false,
747
- close_tool: false,
748
- procedures: ["research-capture-protocol"],
749
- steps: [
750
- "Save findings via heyspex_save_research / heyspex_complete_research (external scope needs >=1 cited URL).",
751
- "Research is evergreen: do NOT submit_for_review or flip status, and do NOT mint an HS-REV.",
752
- "No execution WIP ledger is owed. Audit export (heyspex_export_events) is optional.",
753
- ],
754
- },
755
- plan_ingest_only: {
756
- label: "Plan/ingest-only — filed milestone+epics; no task execution",
757
- is_close: false,
758
- close_tool: false,
759
- procedures: ["plan-ingest-agent-workflow", "spec-hierarchy"],
760
- steps: [
761
- "Confirm every new spec is registered + linked: heyspex_validate_linkage (heyspex_register_spec for any orphan).",
762
- "Confirm each milestone/epic carries an authored summary and a re-derived ## Intended outcome.",
763
- "No task close, no HS-REV, no execution WIP ledger — epics are filed with children: [].",
764
- ],
765
- },
766
- one_task: {
767
- label: "One-task execution — claimed and completed a single task",
768
- is_close: true,
769
- close_tool: true,
770
- procedures: ["session-close-protocol", "memory-protocol"],
771
- steps: [
772
- "heyspex_submit_for_review({ spec_id }) on the completed unit (status API SSOT — never hand-edit status:). Grain-coercion, parent rollup, and claim release happen server-side.",
773
- "Append a change-log roll-up / review seed to the task.",
774
- "Create an HS-REV companion + parent ## Review back-link ONLY if the active review grain is task; otherwise the change-log seed is enough.",
775
- "heyspex_record_wip for the worked epic (skip only if the epic closed this session).",
776
- "heyspex_export_events to flush the audit log. Do NOT git commit / push.",
777
- "Hand-off: if the human asks for a hand-off, or work remains for a next session, run the /hs-handoff create flow — `hs handoff record --new-code --spec <next> --phase … --link … --blocker …` (minted code) and hand back `/hs-handoff <CODE>` plus `claude \"/hs-next <id>\"`; never a freehand prompt.",
778
- ],
779
- },
780
- multi_task_epic: {
781
- label: "Multi-task / epic push — completed several tasks under an epic",
782
- is_close: true,
783
- close_tool: true,
784
- procedures: ["session-close-protocol", "memory-protocol"],
785
- steps: [
786
- "heyspex_submit_for_review on each completed unit; parent rollup is automatic when the last child lands.",
787
- "One consolidated change-log + the HS-REV companion at the ACTIVE review grain (epic in steady-state, milestone in MVP).",
788
- "heyspex_record_wip once for the worked epic; heyspex_export_events; do NOT git commit / push.",
789
- "Hand-off: if the human asks for a hand-off, or work remains for a next session, run the /hs-handoff create flow — `hs handoff record --new-code --spec <next> --phase … --link … --blocker …` (minted code) and hand back `/hs-handoff <CODE>` plus `claude \"/hs-next <id>\"`; never a freehand prompt.",
790
- ],
791
- },
792
- resolve_diagnose: {
793
- label: "Resolve / diagnose — worked an HS-DIAG or a Resolution item",
794
- is_close: true,
795
- close_tool: true,
796
- procedures: ["session-close-protocol", "resolution-diagnose-workflow"],
797
- steps: [
798
- "If the flag was a stale-cache false positive (the file on disk contradicts it): re-stamp via heyspex_record_alignment — do NOT re-file work that already exists.",
799
- "If a real diagnose/fix completed: heyspex_submit_for_review on it + change-log; HS-REV per active grain.",
800
- "heyspex_record_wip if anything remains open; heyspex_export_events.",
801
- "Hand-off: if the human asks for a hand-off, or work remains for a next session, run the /hs-handoff create flow — `hs handoff record --new-code --spec <next> --phase … --link … --blocker …` (minted code) and hand back `/hs-handoff <CODE>` plus `claude \"/hs-next <id>\"`; never a freehand prompt.",
802
- ],
803
- },
804
- mid_rollover: {
805
- label: "Mid-implementation rollover — claimed but not finished",
806
- is_close: false,
807
- close_tool: false,
808
- procedures: ["context-rollover-checkpoint"],
809
- steps: [
810
- "This is NOT a session close. Do NOT flip status, mint an HS-REV, or submit_for_review.",
811
- "Record a lightweight WIP checkpoint (heyspex_record_wip) so the next session resumes; leave the spec in_progress.",
812
- "Hand-off: if the human asks for a hand-off, or work remains for a next session, run the /hs-handoff create flow — `hs handoff record --new-code --spec <next> --phase … --link … --blocker …` (minted code) and hand back `/hs-handoff <CODE>` plus `claude \"/hs-next <id>\"`; never a freehand prompt.",
813
- 'See heyspex_preflight({ intent: "context_rollover" }) / the context-rollover-checkpoint procedure for the full checkpoint shape.',
814
- ],
815
- },
816
- };
817
-
818
- const CLOSE_SESSION_THREAD_TYPES = Object.keys(CLOSE_SESSION_THREAD_PATHS);
819
-
820
- const CLOSE_SESSION_MIXED_RULE =
821
- "If the session spanned MORE THAN ONE thread type (e.g. filed a plan AND finished a task), request each applicable type and run the UNION of their steps — ordered, idempotent (re-running submit_for_review / export_events is safe), and surface the ambiguity. Never drop an owed obligation; never duplicate one.";
822
-
823
- function buildCloseSessionPreflight(vaultRoot, threadType, injectedCatalog) {
824
- const path = CLOSE_SESSION_THREAD_PATHS[threadType];
825
- if (!path) {
826
- return {
827
- error: "UNKNOWN_THREAD_TYPE",
828
- message: `Unknown thread_type "${threadType}" for close_session. Valid thread types: ${CLOSE_SESSION_THREAD_TYPES.join(", ")}.`,
829
- };
830
- }
831
- const catalog =
832
- injectedCatalog !== undefined ? injectedCatalog : loadCatalog(vaultRoot);
833
- if (!catalog) {
834
- return {
835
- error: "CATALOG_NOT_FOUND",
836
- message:
837
- "specs/protocols/.catalog.json not found. Run `node scripts/build-procedure-catalog.mjs`.",
838
- };
839
- }
840
- const procedures = path.procedures.map((name) =>
841
- resolveRow(catalog, { procedure: name, severity: "required" }),
842
- );
843
- const close_tool = path.close_tool ? buildCloseTool("close_session") : null;
844
- return {
845
- intent: "close_session",
846
- thread_type: threadType,
847
- thread_label: path.label,
848
- is_close: path.is_close,
849
- steps: path.steps,
850
- procedures,
851
- mixed_thread_rule: CLOSE_SESSION_MIXED_RULE,
852
- ...(close_tool ? { close_tool } : {}),
853
- };
854
- }
855
-
856
- /**
857
- * `injectedCatalog` — see {@link executeListProcedures}; same contract here
858
- * (threaded through to `buildCloseSessionPreflight` for the close_session
859
- * thread-type path too, so a single HTTP fetch covers both branches).
860
- */
861
- export function executeRunPreflight(vaultRoot, args = {}, injectedCatalog) {
862
- const intent = String(args.intent ?? "").trim();
863
- if (!intent) {
864
- return {
865
- error: "MISSING_INTENT",
866
- message: `intent is required. Valid intents: ${VALID_INTENTS.join(", ")}.`,
867
- };
868
- }
869
- // Thread-type-aware close-out: when a thread_type is declared, return that
870
- // type's CONDITIONAL close path instead of the static checklist ([[HS-RES-32MJ]]).
871
- const threadType = String(args.thread_type ?? "").trim();
872
- if (intent === "close_session" && threadType) {
873
- return buildCloseSessionPreflight(vaultRoot, threadType, injectedCatalog);
874
- }
875
- const recommendations = PREFLIGHT_INTENT_MAP[intent];
876
- if (!recommendations) {
877
- return {
878
- error: "UNKNOWN_INTENT",
879
- message: `Unknown intent "${intent}". Valid intents: ${VALID_INTENTS.join(", ")}.`,
880
- };
881
- }
882
- const catalog =
883
- injectedCatalog !== undefined ? injectedCatalog : loadCatalog(vaultRoot);
884
- if (!catalog) {
885
- return {
886
- error: "CATALOG_NOT_FOUND",
887
- message:
888
- "specs/protocols/.catalog.json not found. Run `node scripts/build-procedure-catalog.mjs`.",
889
- };
890
- }
891
-
892
- const procedures = recommendations.map((row) => resolveRow(catalog, row));
893
-
894
- const filing_tool = buildFilingTool(intent);
895
- const close_tool = buildCloseTool(intent);
896
-
897
- const result = {
898
- intent,
899
- procedures,
900
- ...(filing_tool ? { filing_tool } : {}),
901
- ...(close_tool ? { close_tool } : {}),
902
- };
903
-
904
- // Family-overlap advisory for filing intents: warn agents to check whether
905
- // a closed family already covered this goal with no iterates_on edge.
906
- const FILING_ADVISORY =
907
- "Family-overlap check (warn-only): before filing, check whether a CLOSED family already shipped against this goal with no iterates_on: edge — compare both TITLE and JTBD (job-to-be-done) statement, not title alone, since two differently-titled specs can still be the same job (owner-approved 2026-08-23 job-based-duplicate-detection extension) — if so this may be round 2; see entity-model §Iteration lineage. Never blocks filing.";
908
- const FILING_INTENTS_WITH_ADVISORY = [
909
- "file_epic",
910
- "file_task",
911
- "promote_research",
912
- ];
913
- if (FILING_INTENTS_WITH_ADVISORY.includes(intent)) {
914
- result.advisory = FILING_ADVISORY;
915
- }
916
-
917
- // Static close_session call (no thread_type declared): nudge toward the
918
- // conditional path so the agent runs the right minimal-complete close-out.
919
- if (intent === "close_session") {
920
- result.thread_type_hint = {
921
- note: 'Close-out is thread-type-aware. Re-call heyspex_preflight({ intent: "close_session", thread_type: "<type>" }) to get the conditional close path for what this session actually did.',
922
- thread_types: CLOSE_SESSION_THREAD_TYPES.map((t) => ({
923
- thread_type: t,
924
- label: CLOSE_SESSION_THREAD_PATHS[t].label,
925
- is_close: CLOSE_SESSION_THREAD_PATHS[t].is_close,
926
- })),
927
- mixed: CLOSE_SESSION_MIXED_RULE,
928
- };
929
- }
930
-
931
- // [[HS-EN4F]]: research intents carry the absolute hs-res route.
932
- if (RESEARCH_TOOLING_INTENTS.includes(intent)) {
933
- const research_tooling = buildResearchTooling();
934
- if (research_tooling) result.research_tooling = research_tooling;
935
- }
936
-
937
- return result;
938
- }
939
-
940
- // ─────────────────────────────────────────────────────────────────────────────
941
- // [[HS-EN4F]] — the hs-res route, in absolute paths. The /hs-res command and
942
- // its SKILL.md spell the gather/synthesis CLIs repo-relative
943
- // (`node dev-factory/spexcrawl/...`), which only resolves inside the platform
944
- // checkout. An agent in a customer repo (sales-coach, 2026-10-02) looked for
945
- // hs-res there, did not find it, concluded "the tooling isn't installed" and
946
- // fanned out Sonnet subagents on WebSearch. The research preflight is the one
947
- // call every research path must make (the HS-K0ZP gate refuses WebSearch /
948
- // WebFetch until it runs), so it carries the route: absolute commands derived
949
- // from where THIS module sits — the MCP server serving the customer repo runs
950
- // out of the platform checkout, so these paths resolve from any working
951
- // directory. Null when the platform does not ship the CLIs (published package).
952
- // ─────────────────────────────────────────────────────────────────────────────
953
- const RESEARCH_TOOLING_INTENTS = [
954
- "research",
955
- "file_research",
956
- "promote_research",
957
- ];
958
- const DEFAULT_PLATFORM_ROOT = resolve(
959
- dirname(fileURLToPath(import.meta.url)),
960
- "..",
961
- "..",
962
- );
963
-
964
- export function buildResearchTooling(
965
- platformRoot = DEFAULT_PLATFORM_ROOT,
966
- exists = existsSync,
967
- ) {
968
- const abs = (...parts) => join(platformRoot, ...parts).replace(/\\/g, "/");
969
- const spexcrawl = abs("dev-factory", "spexcrawl", "spexcrawl-cli.mjs");
970
- const firecrawl = abs("dev-factory", "spexcrawl", "firecrawl-cli.mjs");
971
- const synthesize = abs("dev-factory", "spexcrawl", "synthesize-cli.mjs");
972
- const skill = abs(".commandcode", "skills", "hs-res", "SKILL.md");
973
- if (![spexcrawl, firecrawl, synthesize].every((p) => exists(p))) return null;
974
- return {
975
- method:
976
- "hs-res (the confidence ladder) — URL DISCOVERY defaults to self-hosted Firecrawl search; FULL-PAGE fetch of each cited URL defaults to SpexCrawl scrape (Firecrawl scrape is a per-URL retry only); DeepSeek synthesizes. Zero Claude/Anthropic sub-agents. Owner ruling 2026-10-10: SpexCrawl search is blocked on all four engines, so it is no longer the default discovery step. Hosted api.firecrawl.dev stays ask-before-spend and is never a default.",
977
- note: "These commands run from ANY repo. A project with no /hs-res command or skill of its own does NOT mean the tooling is missing — use these absolute paths. Firecrawl search returns titles/URLs/snippets only: snippets are discovery, never evidence — scrape the full page of every URL you cite. Native WebSearch/WebFetch is the last resort only when both backends report unavailable; never fan out Sonnet sub-agents for research.",
978
- skill_path: exists(skill) ? skill : null,
979
- // Owner ruling 2026-10-10 (HS-5PG6): discovery = Firecrawl search, fetch = SpexCrawl scrape.
980
- discover_primary: `node "${firecrawl}" search "<query>" --json`,
981
- fetch_primary: `node "${spexcrawl}" scrape "<url>" --json`,
982
- fetch_retry: `node "${firecrawl}" scrape "<url>" --json`,
983
- optional_combined: `node "${spexcrawl}" search "<query>" --json`,
984
- // Legacy field names kept for existing consumers. They now mirror the new
985
- // order: gather_* = discovery, scrape_* = fetch. `gather_primary` is NO
986
- // LONGER SpexCrawl search (that moved to optional_combined).
987
- gather_primary: `node "${firecrawl}" search "<query>" --json`,
988
- gather_fallback: `node "${spexcrawl}" search "<query>" --json`,
989
- scrape_primary: `node "${spexcrawl}" scrape "<url>" --json`,
990
- scrape_fallback: `node "${firecrawl}" scrape "<url>" --json`,
991
- synthesize: `node "${synthesize}" --prompt-file <path> --json`,
992
- grounding_first:
993
- "Before any gather: method triage (known-target-retrieval vs the ladder), ground-first (heyspex_recall_memories), and check for a working reference the owner already runs (e.g. `docker ps` for a local Firecrawl on :3002). Record the four-part grounding result (already built / already decided / owner reference / residual delta).",
994
- };
995
- }
996
-
997
- export const PREFLIGHT_INTENTS = VALID_INTENTS;
998
- export { CLOSE_SESSION_THREAD_TYPES };
999
-
1000
- // ─────────────────────────────────────────────────────────────────────────────
1001
- // Read-path selector wiring ([[HS-DJWP-MCPC-TASK-wire-the-mcp-server-s-vault-reads]]
1002
- // of [[HS-T6GS-MCPC-EPIC-mcp-server-reads-the-vault-through-the]]).
1003
- //
1004
- // The three functions below are the cloud-reliant siblings of
1005
- // `executeListProcedures` / `executeGetProcedure` / `executeRunPreflight`.
1006
- // They never call `loadCatalog(vaultRoot)` in the `neon` tier — the catalog
1007
- // (or, for `executeGetProcedureViaTier`, the full procedure body) is fetched
1008
- // once over HTTP via the caller-supplied `consoleFetch` and handed to the
1009
- // existing pure functions as an `injectedCatalog`, so the filtering /
1010
- // preflight-mapping logic itself is never duplicated. In `disk` tier each
1011
- // wrapper calls the untouched, byte-for-byte-unchanged pure function — same
1012
- // disk-only code path as before this task.
1013
- //
1014
- // `consoleFetch` is passed in (not imported) because this module is
1015
- // deliberately transport-free "pure-logic" (see file header) — cli.mjs owns
1016
- // the bound `makeConsoleFetch` client and threads it through here, mirroring
1017
- // how `readViaSelector` itself never imports console-fetch.mjs.
1018
- // ─────────────────────────────────────────────────────────────────────────────
1019
-
1020
- /** Row shape from `GET /api/procedures` — mirrors `summarize()` above (see that route's docstring: "do not invent a new shape here"). */
1021
- function catalogFromProceduresRoute(body) {
1022
- const rows = Array.isArray(body?.procedures) ? body.procedures : [];
1023
- return { procedures: rows };
1024
- }
1025
-
1026
- /**
1027
- * Fetch the procedure catalog via `GET /api/procedures` (disk: `loadCatalog`).
1028
- * Exported for callers that need the raw catalog object outside the three
1029
- * higher-level wrappers below (none today; kept for symmetry / testability).
1030
- */
1031
- export async function loadCatalogViaTier({
1032
- vaultRoot,
1033
- tier,
1034
- consoleFetch,
1035
- consoleUrl,
1036
- }) {
1037
- return readViaSelector({
1038
- tier,
1039
- diskRead: () => loadCatalog(vaultRoot),
1040
- neonRead: async () => {
1041
- const res = await consoleFetch("/api/procedures");
1042
- if (!res.ok) {
1043
- throw consoleReadErrorFrom(res, `GET /api/procedures returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
1044
- }
1045
- return catalogFromProceduresRoute(res.body);
1046
- },
1047
- routeLabel: "GET /api/procedures",
1048
- consoleUrl,
1049
- });
1050
- }
1051
-
1052
- /** Cloud-reliant sibling of {@link executeListProcedures}. */
1053
- export async function executeListProceduresViaTier(opts) {
1054
- const { value, degraded } = await withDegradationScope(() => executeListProceduresViaTierInner(opts));
1055
- return mergeDegradation(value, degraded);
1056
- }
1057
-
1058
- async function executeListProceduresViaTierInner({
1059
- vaultRoot,
1060
- args,
1061
- tier,
1062
- consoleFetch,
1063
- consoleUrl,
1064
- }) {
1065
- return readViaSelector({
1066
- tier,
1067
- diskRead: () => executeListProcedures(vaultRoot, args),
1068
- neonRead: async () => {
1069
- const catalog = await loadCatalogViaTier({
1070
- vaultRoot,
1071
- tier: "neon",
1072
- consoleFetch,
1073
- consoleUrl,
1074
- });
1075
- if (!catalog) {
1076
- return {
1077
- error: "CATALOG_NOT_FOUND",
1078
- message:
1079
- "GET /api/procedures returned no catalog (console reports it missing).",
1080
- };
1081
- }
1082
- // Filtering happens HERE, client-side — the route has no tag/trigger/query
1083
- // params (it always answers the full unfiltered set), unlike the disk path
1084
- // which filters inside executeListProcedures itself. Passing the fetched
1085
- // catalog as `injectedCatalog` runs the SAME filter logic either way.
1086
- return executeListProcedures(vaultRoot, args, catalog);
1087
- },
1088
- routeLabel: "GET /api/procedures",
1089
- consoleUrl,
1090
- });
1091
- }
1092
-
1093
- /** Cloud-reliant sibling of {@link executeGetProcedure}. Single round trip: `GET /api/procedures/<name>` returns catalog entry + full body together. */
1094
- export async function executeGetProcedureViaTier({
1095
- vaultRoot,
1096
- args,
1097
- tier,
1098
- consoleFetch,
1099
- consoleUrl,
1100
- }) {
1101
- return readViaSelector({
1102
- tier,
1103
- diskRead: () => executeGetProcedure(vaultRoot, args),
1104
- neonRead: async () => {
1105
- const name = String(args?.name ?? "").trim();
1106
- if (!name) {
1107
- return {
1108
- error: "MISSING_NAME",
1109
- message: 'name is required (e.g. "spec-hierarchy")',
1110
- };
1111
- }
1112
- const res = await consoleFetch(
1113
- `/api/procedures/${encodeURIComponent(name)}`,
1114
- );
1115
- if (res.ok) {
1116
- return {
1117
- procedure: res.body?.procedure,
1118
- content: res.body?.content,
1119
- source: res.body?.source ?? null,
1120
- };
1121
- }
1122
- // Aligned with the sibling *ViaTier functions ([[HS-DJWP-MCPC-TASK]]
1123
- // round 3 fix — a 500 {error:'boom'} was previously swallowed as a real
1124
- // answer regardless of status code): ONLY a 404 PROCEDURE_NOT_FOUND is a
1125
- // real "no such procedure" answer, matching the route's own contract
1126
- // (its docstring: "an unknown name is a 404 error code, never an empty
1127
- // 200 success"). Everything else — a 500 CATALOG_NOT_FOUND included, a
1128
- // reachable console reporting a server-side config break — throws so the
1129
- // selector surfaces CONSOLE_UNREACHABLE_CLOUD_MODE rather than silently
1130
- // degrading; the epic's no-silent-degrade rule treats "console answered,
1131
- // but the answer means something is broken" the same as unreachable.
1132
- if (res.status === 404 && res.body?.error === "PROCEDURE_NOT_FOUND") {
1133
- return { error: res.body.error, message: res.body.message };
1134
- }
1135
- throw consoleReadErrorFrom(
1136
- res,
1137
- `GET /api/procedures/${name} returned ${res.status}: ${res.body?.error ? `${res.body.error}${res.body.message ? ` — ${res.body.message}` : ""}` : res.error || JSON.stringify(res.body ?? {})}`,
1138
- null,
1139
- );
1140
- },
1141
- routeLabel: "GET /api/procedures/[name]",
1142
- consoleUrl,
1143
- });
1144
- }
1145
-
1146
- /** Cloud-reliant sibling of {@link executeRunPreflight}. One catalog fetch covers both the static intent map AND the close_session thread-type path. */
1147
- export async function executeRunPreflightViaTier(opts) {
1148
- const { value, degraded } = await withDegradationScope(() => executeRunPreflightViaTierInner(opts));
1149
- return mergeDegradation(value, degraded);
1150
- }
1151
-
1152
- async function executeRunPreflightViaTierInner({
1153
- vaultRoot,
1154
- args,
1155
- tier,
1156
- consoleFetch,
1157
- consoleUrl,
1158
- }) {
1159
- return readViaSelector({
1160
- tier,
1161
- diskRead: () => executeRunPreflight(vaultRoot, args),
1162
- neonRead: async () => {
1163
- const catalog = await loadCatalogViaTier({
1164
- vaultRoot,
1165
- tier: "neon",
1166
- consoleFetch,
1167
- consoleUrl,
1168
- });
1169
- if (!catalog) {
1170
- return {
1171
- error: "CATALOG_NOT_FOUND",
1172
- message:
1173
- "GET /api/procedures returned no catalog (console reports it missing).",
1174
- };
1175
- }
1176
- return executeRunPreflight(vaultRoot, args, catalog);
1177
- },
1178
- routeLabel: "GET /api/procedures",
1179
- consoleUrl,
1180
- });
1181
- }
1
+ /**
2
+ * HeySpex procedure-discovery MCP tools.
3
+ *
4
+ * Pure-logic module backing the `heyspex_list_procedures`,
5
+ * `heyspex_get_procedure`, and `heyspex_preflight` MCP tools mounted in
6
+ * cli.mjs. Reads the committed catalog at specs/protocols/.catalog.json
7
+ * (generated by scripts/build-procedure-catalog.mjs) and the underlying
8
+ * markdown bodies under specs/protocols/.
9
+ *
10
+ * Spec: [[HS-EPIC-3QS8-agent-procedure-discovery-enforcement-la]]
11
+ * Pattern source: vendor/heyspex-mcp/design-tools.mjs (the
12
+ * heyspex_design_* family is the canonical template this module mirrors).
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { consoleReadErrorFrom, mergeDegradation, readViaSelector, withDegradationScope } from "./read-path.mjs";
20
+ import { pageListToolResult } from "./list-tools.mjs";
21
+ import { isDevFactoryOverlayOpen } from "./dev-factory-overlay.mjs";
22
+
23
+ const CATALOG_FILE = ["specs", "protocols", ".catalog.json"];
24
+ const PROTOCOLS_DIR = ["specs", "protocols"];
25
+
26
+ function normalize(value) {
27
+ return String(value ?? "").toLowerCase();
28
+ }
29
+
30
+ export function loadCatalog(vaultRoot) {
31
+ const path = join(vaultRoot, ...CATALOG_FILE);
32
+ if (!existsSync(path)) return null;
33
+ try {
34
+ return JSON.parse(readFileSync(path, "utf8"));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function summarize(entry) {
41
+ return {
42
+ name: entry.name,
43
+ id: entry.id,
44
+ title: entry.title,
45
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
46
+ triggers: Array.isArray(entry.triggers) ? entry.triggers : [],
47
+ summary: entry.summary ?? "",
48
+ path: entry.path,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * `injectedCatalog` (HS-DJWP-MCPC-TASK read-path wiring): when provided, used
54
+ * verbatim INSTEAD of `loadCatalog(vaultRoot)` — the cloud-reliant selector
55
+ * wrapper below fetches the catalog once over HTTP and injects it here so
56
+ * this function never touches disk in that tier. `undefined` (the default)
57
+ * preserves today's disk-only behavior byte-for-byte.
58
+ */
59
+ export function executeListProcedures(vaultRoot, args = {}, injectedCatalog) {
60
+ const catalog =
61
+ injectedCatalog !== undefined ? injectedCatalog : loadCatalog(vaultRoot);
62
+ if (!catalog) {
63
+ return {
64
+ error: "CATALOG_NOT_FOUND",
65
+ message:
66
+ "specs/protocols/.catalog.json not found. Run `node scripts/build-procedure-catalog.mjs`.",
67
+ };
68
+ }
69
+ const procedures = Array.isArray(catalog.procedures)
70
+ ? catalog.procedures
71
+ : [];
72
+ const tagFilter = args.tag ? normalize(args.tag) : null;
73
+ const triggerFilter = args.trigger ? normalize(args.trigger) : null;
74
+ const queryFilter = args.query ? normalize(args.query) : null;
75
+
76
+ const matches = [];
77
+ for (const entry of procedures) {
78
+ if (tagFilter) {
79
+ const tags = (entry.tags ?? []).map(normalize);
80
+ if (!tags.includes(tagFilter)) continue;
81
+ }
82
+ if (triggerFilter) {
83
+ const triggers = (entry.triggers ?? []).map(normalize);
84
+ if (!triggers.includes(triggerFilter)) continue;
85
+ }
86
+ if (queryFilter) {
87
+ const haystack = [entry.name, entry.title, entry.summary]
88
+ .map(normalize)
89
+ .join(" ");
90
+ if (!haystack.includes(queryFilter)) continue;
91
+ }
92
+ matches.push(summarize(entry));
93
+ }
94
+
95
+ // One page on a (name, id) keyset cursor, fitted to the byte budget of the
96
+ // tool — never the whole catalog, never a total count
97
+ // ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]]).
98
+ const paged = pageListToolResult("heyspex_list_procedures", matches, args, {
99
+ keyOf: (p) => p.name,
100
+ idOf: (p) => p.id ?? p.name,
101
+ extra: {
102
+ filters_applied: {
103
+ tag: args.tag ?? null,
104
+ trigger: args.trigger ?? null,
105
+ query: args.query ?? null,
106
+ },
107
+ },
108
+ });
109
+ if (paged.error) return paged.error;
110
+ return paged.result;
111
+ }
112
+
113
+ function findEntry(catalog, name) {
114
+ const procedures = Array.isArray(catalog.procedures)
115
+ ? catalog.procedures
116
+ : [];
117
+ return procedures.find((p) => p.name === name) || null;
118
+ }
119
+
120
+ export function executeGetProcedure(vaultRoot, args = {}) {
121
+ const name = String(args.name ?? "").trim();
122
+ if (!name) {
123
+ return {
124
+ error: "MISSING_NAME",
125
+ message: 'name is required (e.g. "spec-hierarchy")',
126
+ };
127
+ }
128
+ const catalog = loadCatalog(vaultRoot);
129
+ if (!catalog) {
130
+ return {
131
+ error: "CATALOG_NOT_FOUND",
132
+ message:
133
+ "specs/protocols/.catalog.json not found. Run `node scripts/build-procedure-catalog.mjs`.",
134
+ };
135
+ }
136
+ const entry = findEntry(catalog, name);
137
+ if (!entry) {
138
+ return {
139
+ error: "PROCEDURE_NOT_FOUND",
140
+ message: `No procedure named "${name}" in catalog. Call heyspex_list_procedures to browse.`,
141
+ };
142
+ }
143
+ const bodyPath = join(vaultRoot, ...PROTOCOLS_DIR, `${name}.md`);
144
+ if (!existsSync(bodyPath)) {
145
+ return {
146
+ error: "BODY_FILE_MISSING",
147
+ message: `Catalog entry exists but ${entry.path} not found on disk. Regenerate the catalog.`,
148
+ };
149
+ }
150
+ const content = readFileSync(bodyPath, "utf8");
151
+ return {
152
+ procedure: {
153
+ name: entry.name,
154
+ id: entry.id,
155
+ title: entry.title,
156
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
157
+ triggers: Array.isArray(entry.triggers) ? entry.triggers : [],
158
+ summary: entry.summary ?? "",
159
+ path: entry.path,
160
+ status: entry.status ?? null,
161
+ created: entry.created ?? null,
162
+ lastModified: entry.lastModified ?? null,
163
+ byteSize: entry.byteSize ?? null,
164
+ },
165
+ content,
166
+ source: entry.path,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Procedures every customer vault ships (the universal template protocols; a
172
+ * drift test pins this list to the template manifest's protocol dests). Every
173
+ * other PREFLIGHT_INTENT_MAP procedure is platform-only: a vault whose runtime
174
+ * catalog lacks it gets the row DEMOTED to advisory by {@link resolveRow}, never
175
+ * deleted. Promoting more procedures to universal is a later template version.
176
+ */
177
+ export const UNIVERSAL_PROCEDURES = Object.freeze([
178
+ "canonical-frontmatter-templates",
179
+ "spec-hierarchy",
180
+ "memory-protocol",
181
+ "session-close-protocol",
182
+ "spec-summary-authoring",
183
+ "destructive-action-checklist",
184
+ ]);
185
+
186
+ /** Audience of a preflight row, by procedure name. */
187
+ export function procedureAudience(name) {
188
+ return UNIVERSAL_PROCEDURES.includes(name) ? "universal" : "platform";
189
+ }
190
+
191
+ const DEMOTED_REASON = "not in this vault's catalog (platform-only procedure)";
192
+
193
+ /**
194
+ * Resolve one preflight row against the supplied (runtime) catalog. Shared by
195
+ * the static intent path AND the close_session thread-type path. A row whose
196
+ * procedure is present is returned exactly as before; an absent one is demoted
197
+ * to `advisory` (original severity kept in `demoted_from`) with
198
+ * `missing_from_catalog: true` still set for consumers.
199
+ */
200
+ export function resolveRow(catalog, row) {
201
+ const entry = findEntry(catalog, row.procedure);
202
+ const base = {
203
+ procedure: row.procedure,
204
+ ...(row.why !== undefined ? { why: row.why } : {}),
205
+ severity: row.severity,
206
+ path: entry?.path ?? null,
207
+ title: entry?.title ?? null,
208
+ missing_from_catalog: entry ? undefined : true,
209
+ };
210
+ if (entry) return base;
211
+ return {
212
+ ...base,
213
+ severity: "advisory",
214
+ demoted_from: row.severity,
215
+ reason: DEMOTED_REASON,
216
+ };
217
+ }
218
+
219
+ // Bootstrap intent → procedure map (eight intents per HS-EPIC-3QS8 §Open
220
+ // questions "Trigger taxonomy"). Each row names the procedure slug (matches
221
+ // specs/protocols/<slug>.md), a `why` blurb the agent can quote, and a
222
+ // severity. Extension rule: add a new intent when a third unique use case
223
+ // appears — new intents land in their own tasks, not edits to this map.
224
+ const PREFLIGHT_INTENT_MAP = {
225
+ // [[HS-K0ZP]]: "about to do research" is a governed trigger. Running this
226
+ // preflight writes the proof-of-use marker that unlocks WebSearch / WebFetch /
227
+ // heyspex_save_research for the session (spexharness/src/gates/research-protocol-gate.mjs
228
+ // on every PreToolUse front, preflight-gate.mjs inside the MCP server). Before
229
+ // it, those calls are refused -- the protocol was prose an agent could skip.
230
+ research: [
231
+ {
232
+ procedure: "research-capture-protocol",
233
+ why: "Method triage FIRST (known-target-retrieval vs the confidence ladder), ground-first (heyspex_recall_memories + /hs-ground) before any external pass, and the offer-before-save step: a web search offers to save findings, it does not auto-file a research doc.",
234
+ severity: "required",
235
+ },
236
+ {
237
+ procedure: "capture-guardrails",
238
+ why: "A known-target lookup never enters the research-doc pipeline; the exit for a quick retrieval is a memory-reference or a fold into the consuming spec.",
239
+ severity: "recommended",
240
+ },
241
+ ],
242
+ promote_research: [
243
+ {
244
+ procedure: "research-capture-protocol",
245
+ why: "Confirm the research doc is complete (prerequisites, conclusion, sources) before promotion.",
246
+ severity: "required",
247
+ },
248
+ {
249
+ procedure: "spec-hierarchy",
250
+ why: "Mandatory default: target_milestone NEW. Existing-milestone attach only when status: open and thesis clearly extends; in_progress requires explicit owner override.",
251
+ severity: "required",
252
+ },
253
+ {
254
+ procedure: "pillar-assignment-protocol",
255
+ why: "Every spawned epic must name a pillar before execution breakdown.",
256
+ severity: "recommended",
257
+ },
258
+ ],
259
+ file_iteration: [
260
+ {
261
+ procedure: "supersession-protocol",
262
+ why: "§Iterates-on: choose the right tier; a re-attempt is neither supersession nor amends.",
263
+ severity: "required",
264
+ },
265
+ {
266
+ procedure: "research-capture-protocol",
267
+ why: 'Promote flow asks "is this a re-attempt?" and populates iterates_on: — confirm iteration lineage is recorded.',
268
+ severity: "required",
269
+ },
270
+ {
271
+ procedure: "internal-linking-protocol",
272
+ why: "Iteration structure and tier-based lineage tracking; see entity-model §Iteration lineage for tier semantics.",
273
+ severity: "recommended",
274
+ },
275
+ ],
276
+ file_milestone: [
277
+ {
278
+ procedure: "spec-hierarchy",
279
+ why: "Defines which level a new spec belongs at and the milestone frontmatter shape.",
280
+ severity: "required",
281
+ },
282
+ {
283
+ procedure: "spec-summary-authoring",
284
+ why: "Every milestone needs a plain-language summary: at filing time — pass summary to heyspex_file_spec / POST /api/specs.",
285
+ severity: "required",
286
+ },
287
+ {
288
+ procedure: "vault-id-assignment-protocol",
289
+ why: "Allocates a unique HS-MIL-<suffix> id and prevents duplicates.",
290
+ severity: "required",
291
+ },
292
+ ],
293
+ file_epic: [
294
+ {
295
+ procedure: "spec-hierarchy",
296
+ why: "Defines epic frontmatter, required parent-milestone link, and child layout.",
297
+ severity: "required",
298
+ },
299
+ {
300
+ procedure: "spec-summary-authoring",
301
+ why: "Every epic needs a plain-language summary at plan-ingest filing time — pass summary to heyspex_push_plan / heyspex_file_spec / POST /api/specs. Do not file tasks in the same pass.",
302
+ severity: "required",
303
+ },
304
+ {
305
+ procedure: "pillar-assignment-protocol",
306
+ why: "Every epic must name a pillar (hard-blocked at filing). Covers picking an existing HS-PILR theme or authoring a net-new one.",
307
+ severity: "required",
308
+ },
309
+ {
310
+ procedure: "vault-id-assignment-protocol",
311
+ why: "Allocates a unique HS-EPIC-<suffix> id and prevents duplicates.",
312
+ severity: "required",
313
+ },
314
+ {
315
+ procedure: "plan-ingest-agent-workflow",
316
+ why: "Linkage + manifest registration steps when ingesting an external plan into the epic.",
317
+ severity: "recommended",
318
+ },
319
+ {
320
+ procedure: "builder-fleet-capability-reference",
321
+ why: "Optional builder_profile + ## Builder fleet notes at epic filing — anticipate local builder model class and context tier.",
322
+ severity: "recommended",
323
+ },
324
+ ],
325
+ file_task: [
326
+ {
327
+ procedure: "spec-hierarchy",
328
+ why: "Defines task frontmatter, required parent-epic link, and child-task fan-out rules.",
329
+ severity: "required",
330
+ },
331
+ {
332
+ procedure: "builder-fleet-capability-reference",
333
+ why: "Required ## Builder brief + dispatch frontmatter (suggested_model, recommended_num_ctx, context_files) for fleet-calibrated tasks.",
334
+ severity: "required",
335
+ },
336
+ {
337
+ procedure: "spec-summary-authoring",
338
+ why: "Every task needs a plain-language summary at the /hs-next breakdown moment — pass summary to heyspex_file_spec / POST /api/specs. Tasks are never filed during plan ingest.",
339
+ severity: "required",
340
+ },
341
+ {
342
+ procedure: "vault-id-assignment-protocol",
343
+ why: "Allocates a unique HS-TASK-<suffix> id and prevents duplicates.",
344
+ severity: "required",
345
+ },
346
+ {
347
+ procedure: "checks-manifest-authoring",
348
+ why: "Every task ships a FROZEN specs/checks/<task-id>.checks.json — each AC classified machine|property|human with named runnable checks, authored at breakdown before any builder sees the brief. A task without one is not ready to dispatch.",
349
+ severity: "required",
350
+ },
351
+ {
352
+ procedure: "unit-test-authoring-protocol",
353
+ why: "How the tests behind the manifest checks are written: pure-core/thin-shell, colocation, the five case families, and the mutation gate as the quality oracle.",
354
+ severity: "recommended",
355
+ },
356
+ ],
357
+ file_diagnose: [
358
+ {
359
+ procedure: "spec-hierarchy",
360
+ why: "Defines diagnose frontmatter, the standalone-default / optional epic-or-milestone parent rule, and severity.",
361
+ severity: "required",
362
+ },
363
+ {
364
+ procedure: "vault-id-assignment-protocol",
365
+ why: "Allocates a unique HS-DIAG-<suffix> id and prevents duplicates.",
366
+ severity: "required",
367
+ },
368
+ {
369
+ procedure: "resolution-diagnose-workflow",
370
+ why: "The end-to-end bug→diagnose procedure: intake, §Step 2 symptom-vs-core classification (if fixing exactly this leaves the same class of failure possible, you are still on the symptom), severity ranking, §Step 6 sibling sweep — which now owes a receipt in the body's ## Sweep section — and close-out.",
371
+ severity: "required",
372
+ },
373
+ {
374
+ procedure: "checks-manifest-authoring",
375
+ why: 'A bug is the case where a regression check is worth the most and costs the least — the reproduction is already written. Freeze one runnable check per acceptance criterion via `checks:` on heyspex_file_spec, and make at least one of them FAIL when the fix is reverted; "the test passes now" is also what a symptom-level fix produces. REQUIRED WITH AN OUT: a trivial bug may file with no manifest — filing still succeeds and returns a `no_checks_manifest` warning — but skipping is a decision to state, not a step to forget.',
376
+ severity: "required",
377
+ },
378
+ ],
379
+ file_research: [
380
+ {
381
+ procedure: "capture-guardrails",
382
+ why: "A brand-new idea surfacing in conversation (no existing HS-CAP-*/HS-RES-* id already in play) goes through Capture → Ground=Expand → Promote-to-research first via heyspex_active_capture — never straight to file_research/research dispatch tooling. Skip only when the research target already has an existing capture or research id.",
383
+ severity: "recommended",
384
+ },
385
+ {
386
+ procedure: "spec-hierarchy",
387
+ why: "Confirms research is a standalone entity (HS-RES-<id>), how it links back to consumers, and that promotion defaults to a NEW milestone.",
388
+ severity: "required",
389
+ },
390
+ {
391
+ procedure: "research-capture-protocol",
392
+ why: "Required template (Executive Summary + Sources) and the back-wikilink + maintenance rules for a research doc.",
393
+ severity: "required",
394
+ },
395
+ {
396
+ procedure: "vault-id-assignment-protocol",
397
+ why: "Allocates a unique HS-RES-<suffix> id and prevents duplicates.",
398
+ severity: "required",
399
+ },
400
+ ],
401
+ file_memory_lesson: [
402
+ {
403
+ procedure: "memory-protocol",
404
+ why: "How to file a memory-lesson and link it back to the originating spec; the four-axis capture rubric.",
405
+ severity: "required",
406
+ },
407
+ {
408
+ procedure: "vault-id-assignment-protocol",
409
+ why: "Allocates a unique memory slug/id and prevents duplicates.",
410
+ severity: "required",
411
+ },
412
+ ],
413
+ file_memory_decision: [
414
+ {
415
+ procedure: "memory-protocol",
416
+ why: "How to file a memory-decision and link it back to the originating spec; the four-axis capture rubric.",
417
+ severity: "required",
418
+ },
419
+ {
420
+ procedure: "vault-id-assignment-protocol",
421
+ why: "Allocates a unique memory slug/id and prevents duplicates.",
422
+ severity: "required",
423
+ },
424
+ ],
425
+ file_memory_reference: [
426
+ {
427
+ procedure: "memory-protocol",
428
+ why: "How to file a memory-reference and link it back to the originating spec; the four-axis capture rubric.",
429
+ severity: "required",
430
+ },
431
+ {
432
+ procedure: "vault-id-assignment-protocol",
433
+ why: "Allocates a unique memory slug/id and prevents duplicates.",
434
+ severity: "required",
435
+ },
436
+ ],
437
+ file_memory_ui_pattern: [
438
+ {
439
+ procedure: "memory-protocol",
440
+ why: "How to file a memory-ui-pattern and link it back to the originating spec; the four-axis capture rubric.",
441
+ severity: "required",
442
+ },
443
+ {
444
+ procedure: "vault-id-assignment-protocol",
445
+ why: "Allocates a unique memory slug/id and prevents duplicates.",
446
+ severity: "required",
447
+ },
448
+ ],
449
+ file_review: [
450
+ {
451
+ procedure: "session-close-protocol",
452
+ why: "The HS-REV companion doc shape (four required sections) and the close-out flow it belongs to.",
453
+ severity: "required",
454
+ },
455
+ {
456
+ procedure: "spec-hierarchy",
457
+ why: 'Confirms the review parents the spec under review (parent: "[[<spec>]]" is required).',
458
+ severity: "recommended",
459
+ },
460
+ ],
461
+ file_capture: [
462
+ {
463
+ procedure: "capture-guardrails",
464
+ why: "Captures are session-based and NOT research — human words stay verbatim in `## Notes`; AI authors separate layers on an explicit pass. Drive them via heyspex_active_capture, not heyspex_file_spec.",
465
+ severity: "required",
466
+ },
467
+ ],
468
+ destructive_action: [
469
+ {
470
+ procedure: "destructive-action-checklist",
471
+ why: "Run before any PR that deletes, archives, or routes data-destroying actions.",
472
+ severity: "required",
473
+ },
474
+ {
475
+ procedure: "dev-server-lifecycle-and-db-safety",
476
+ why: "If the action is a DB migration / Neon branch reset / .next delete, stop the running dev server first — check ground-truth status via heyspex_dev_server_status.",
477
+ severity: "recommended",
478
+ },
479
+ ],
480
+ protocol_edit: [
481
+ {
482
+ procedure: "spec-hierarchy",
483
+ why: '§Agent autonomy row "Modify a protocol doc" — protocol edits require explicit user approval.',
484
+ severity: "required",
485
+ },
486
+ ],
487
+ memory_creation: [
488
+ {
489
+ procedure: "memory-protocol",
490
+ why: "How to file a lesson/decision/reference/ui-pattern memory and link it back to the originating spec.",
491
+ severity: "required",
492
+ },
493
+ ],
494
+ close_session: [
495
+ {
496
+ procedure: "session-close-protocol",
497
+ why: "Required agent close-out checklist (status flips, review-doc creation, hand-off). " + "Hand-off: if the human asks for a hand-off, or work remains for a next session, run the /hs-handoff create flow — `hs handoff record --new-code --spec <next> --phase … --link … --blocker …` (minted code) and hand back `/hs-handoff <CODE>` plus `claude \"/hs-next <id>\"`; never a freehand prompt.",
498
+ severity: "required",
499
+ },
500
+ {
501
+ procedure: "memory-protocol",
502
+ why: "Self-check for learnings is step 0 of close-out — capture any non-obvious lesson/decision as a memory spec.",
503
+ severity: "recommended",
504
+ },
505
+ ],
506
+ context_rollover: [
507
+ {
508
+ procedure: "context-rollover-checkpoint",
509
+ why: "Lightweight mid-flight checkpoint when compaction interrupts implementation — record WIP + handoff, then resume. Do NOT flip status / write HS-REV (that is session close, not rollover).",
510
+ severity: "required",
511
+ },
512
+ ],
513
+ claim_spec: [
514
+ {
515
+ procedure: "spec-hierarchy",
516
+ why: "A spec is claimable iff it is its own current ClaimTarget — read §Claim eligibility before claiming an Epic/Milestone with open descendants; the console endpoint returns 409 claim_blocked_by_descendants otherwise ([[HS-TASK-0RVD]]).",
517
+ severity: "required",
518
+ },
519
+ {
520
+ procedure: "pre-execution-alignment-gate",
521
+ why: "The /hs-next surface routes the claim through this gate; the brief sections (world_state_new / world_state_modified / graph_delta) and the 4-option menu are documented here.",
522
+ severity: "recommended",
523
+ },
524
+ ],
525
+ execute_spec: [
526
+ {
527
+ procedure: "dev-factory-delegation-routing",
528
+ why: "The COO operating contract: delegation is table-driven. Before delegating build/breakdown/critic work, consult dev-factory/routing-table.json — build → free local fleet, breakdown/critic → DeepSeek V4, cloud/Opus escalation-only. NEVER spawn a Claude subagent on your own (expensive) tier for mechanical, gate-verified build.",
529
+ severity: "required",
530
+ },
531
+ {
532
+ procedure: "pre-execution-alignment-gate",
533
+ why: "The first pre-flight before executing an epic/milestone — break down a childless node or revisit a drifted breakdown; hard gate with override-with-reason.",
534
+ severity: "required",
535
+ },
536
+ {
537
+ procedure: "spec-hierarchy",
538
+ why: "Confirms the level you are executing and the parent/child linkage the breakdown must respect.",
539
+ severity: "recommended",
540
+ },
541
+ {
542
+ procedure: "builder-fleet-capability-reference",
543
+ why: "Context tier ladder + task Builder brief contract when breaking down or dispatching to the local fleet.",
544
+ severity: "recommended",
545
+ },
546
+ {
547
+ procedure: "unit-test-authoring-protocol",
548
+ why: "Before writing any test: pure-core/thin-shell structure, colocated <module>.test.ts, the five case families every pure module covers, and the mutation gate as the test-quality oracle.",
549
+ severity: "recommended",
550
+ },
551
+ {
552
+ procedure: "checks-manifest-authoring",
553
+ why: "The frozen specs/checks/<task-id>.checks.json is the external completion gate — builders satisfy its named checks and never edit it; hs check run <task-id> is the per-criterion verdict.",
554
+ severity: "recommended",
555
+ },
556
+ {
557
+ procedure: "dev-server-lifecycle-and-db-safety",
558
+ why: "Execution often involves DB migrations / dependency installs / .next deletes that break a running dev server — check ground-truth status (heyspex_dev_server_status) and stop the server first.",
559
+ severity: "recommended",
560
+ },
561
+ ],
562
+ exit_plan_mode: [
563
+ {
564
+ procedure: "plan-quality-canonical-check",
565
+ why: "Run dual overlap+code audit and canonical/supersession check before exit; revise plan to V2.",
566
+ severity: "required",
567
+ },
568
+ {
569
+ procedure: "plan-ingest-agent-workflow",
570
+ why: "After the plan is approved, do NOT start coding. First ingest the approved plan into the vault as an epic (file it with children:[]; tasks fan out later via /hs-next) AND claim it via heyspex_claim_spec before executing. ExitPlanMode is the plan→execute seam — without this step agents skip filing+claiming and go straight to code.",
571
+ severity: "required",
572
+ },
573
+ {
574
+ procedure: "supersession-protocol",
575
+ why: "If the plan deprecates an existing spec, file the supersession edge per protocol.",
576
+ severity: "recommended",
577
+ },
578
+ ],
579
+ recommend_location: [
580
+ {
581
+ procedure: "rule-placement-protocol",
582
+ why: "Cost ranking for where a new rule belongs: procedure ≥ memory > spec > CLAUDE.md. CLAUDE.md is the most expensive surface (always-loaded, customer-inherited) — never the default.",
583
+ severity: "required",
584
+ },
585
+ {
586
+ procedure: "memory-protocol",
587
+ why: "When the right home is a memory (rank 2 in rule-placement-protocol), this defines the lesson/decision/reference/ui-pattern shape and tagging conventions.",
588
+ severity: "recommended",
589
+ },
590
+ {
591
+ procedure: "foundations-placement-protocol",
592
+ why: "When the thing to place is a high-level vision/strategy concept (a principle, a north-star idea, a business claim) rather than an agent-behavior rule: ranks the foundations homes (North Star > Operating Model > Principles > Architecture > Positioning > Research) and gates a foundational edit behind user sign-off.",
593
+ severity: "recommended",
594
+ },
595
+ ],
596
+ focus_pivot: [
597
+ {
598
+ procedure: "focus-intent-routing",
599
+ why: 'How to detect a roadmap focus-pivot utterance and route it through heyspex_set_focus_areas — covers negation handling ("stop working on X" must NOT add X), the closed-tag-vocabulary constraint (heyspex_list_known_tags first), and the confirmation beat before applying.',
600
+ severity: "required",
601
+ },
602
+ ],
603
+ server_impacting_action: [
604
+ {
605
+ procedure: "dev-server-lifecycle-and-db-safety",
606
+ why: "Before a DB migration, Neon branch reset, dependency install, .next delete, or starting a second dev server: check ground-truth status (heyspex_dev_server_status / `hs server status`) and stop a live server first — running a migration under a live server strands its in-memory schema and forces a restart.",
607
+ severity: "required",
608
+ },
609
+ ],
610
+ restructure_content: [
611
+ {
612
+ procedure: "internal-linking-protocol",
613
+ why: "Before re-parenting an epic, editing a milestone epics[] roster, or moving trackable work to a new canonical home — leave a summary + wikilink stub at the old home so wikilinks and lexical retrieval signal are preserved.",
614
+ severity: "required",
615
+ },
616
+ {
617
+ procedure: "section-supersede-protocol",
618
+ why: "Before editing or deleting a section that has gone stale: the three-way test for edit-in-place vs supersede-the-section vs retire-the-whole-document, the stub properties a reviewer checks, and the rule that the repair stays a proposal a person approves — a linked heading is never silently rewritten.",
619
+ severity: "required",
620
+ },
621
+ {
622
+ procedure: "spec-hierarchy",
623
+ why: "Confirms bidirectional linkage shape (epics: ⇄ milestone:, children: ⇄ parent:) that re-parent edits must maintain.",
624
+ severity: "recommended",
625
+ },
626
+ {
627
+ procedure: "supersession-protocol",
628
+ why: "If the move replaces dead guidance (not merely relocates live work), use supersession instead of a cross-reference stub.",
629
+ severity: "recommended",
630
+ },
631
+ {
632
+ procedure: "doc-canonicalization-protocol",
633
+ why: "If the restructure is an evergreen-doc split/merge rather than plan-side re-parent, use doc-canonicalization instead.",
634
+ severity: "recommended",
635
+ },
636
+ ],
637
+ };
638
+
639
+ const VALID_INTENTS = Object.keys(PREFLIGHT_INTENT_MAP);
640
+
641
+ // Maps a file_* intent to the canonical filing tool the agent should call.
642
+ // This is the core of [[HS-EPIC-WDP3]]: surface the deterministic tool inline
643
+ // in the preflight response so an agent that reaches the procedure layer is
644
+ // routed at the right answer — instead of pattern-matching a sibling spec's
645
+ // frontmatter. Every non-capture kind files via heyspex_file_spec (the server
646
+ // generates frontmatter from the canonical template); captures are
647
+ // session-based and route at heyspex_active_capture. Intents not listed here
648
+ // (destructive_action, protocol_edit, close_session, claim_spec, execute_spec,
649
+ // recommend_location) govern actions, not spec creation — close_session carries
650
+ // close_tool; others carry no filing_tool block.
651
+ const CLOSE_TOOL_MAP = {
652
+ close_session: {
653
+ tool: "heyspex_submit_for_review",
654
+ minimal_call: { spec_id: "<completed-review-unit-or-task-id>" },
655
+ note: "Status SSOT is PATCH /api/specs/:id/status — not frontmatter edits. This tool applies review-grain coercion, parent rollup, and releases the claim. Still run session-close-protocol steps 2–7 (change-log, HS-REV or review seed, WIP, export).",
656
+ },
657
+ };
658
+
659
+ // Build the close_tool block for action intents that flip status at session end.
660
+ function buildCloseTool(intent) {
661
+ return CLOSE_TOOL_MAP[intent] ?? null;
662
+ }
663
+
664
+ const FILE_SPEC_NOTE =
665
+ "Use this. The server generates frontmatter from the canonical template. Do not compose frontmatter from a sibling spec.";
666
+ const FILING_TOOL_MAP = {
667
+ file_milestone: { type: "milestone" },
668
+ file_epic: {
669
+ type: "epic",
670
+ parentKey: "milestone",
671
+ parentExample: "[[HS-MIL-...]]",
672
+ },
673
+ file_task: {
674
+ type: "task",
675
+ parentKey: "parent",
676
+ parentExample: "[[HS-EPIC-...]]",
677
+ },
678
+ file_diagnose: {
679
+ type: "diagnose",
680
+ note: `${FILE_SPEC_NOTE} By default a diagnose is filed standalone — it has no parent and does not nest under any epic or milestone, so it lives on its own in the Resolution lane rather than inside a planned work item. Keep it standalone for a cross-cutting bug, or one that isn't part of any single epic's or milestone's scope (the common case). Only set parent: "[[HS-EPIC-...]]" or "[[HS-MIL-...]]" when the bug clearly belongs to that epic's or milestone's work and you want it tracked under it.`,
681
+ },
682
+ file_research: { type: "research" },
683
+ file_memory_lesson: { type: "memory-lesson" },
684
+ file_memory_decision: { type: "memory-decision" },
685
+ file_memory_reference: { type: "memory-reference" },
686
+ file_memory_ui_pattern: { type: "memory-ui-pattern" },
687
+ file_review: {
688
+ type: "review",
689
+ parentKey: "parent",
690
+ parentExample: "[[<spec-under-review>]]",
691
+ note: `${FILE_SPEC_NOTE} A review's parent is the spec it reviews — parent is required.`,
692
+ },
693
+ file_capture: {
694
+ tool: "heyspex_active_capture",
695
+ minimal_call: { spec_id: "<HS-CAP-...>" },
696
+ note: "Captures are session-based; use heyspex_active_capture, NOT heyspex_file_spec.",
697
+ },
698
+ };
699
+
700
+ // Build the filing_tool block for a given intent, or null when the intent does
701
+ // not create a spec.
702
+ function buildFilingTool(intent) {
703
+ const spec = FILING_TOOL_MAP[intent];
704
+ if (!spec) return null;
705
+ // Captures (and any future bespoke tool) carry an explicit shape.
706
+ if (spec.tool) {
707
+ return {
708
+ tool: spec.tool,
709
+ minimal_call: spec.minimal_call,
710
+ note: spec.note,
711
+ };
712
+ }
713
+ const minimal_call = { type: spec.type };
714
+ if (spec.parentKey) minimal_call[spec.parentKey] = spec.parentExample;
715
+ minimal_call.name = "<title>";
716
+ return {
717
+ tool: "heyspex_file_spec",
718
+ minimal_call,
719
+ note: spec.note ?? FILE_SPEC_NOTE,
720
+ };
721
+ }
722
+
723
+ // ─────────────────────────────────────────────────────────────────────────────
724
+ // Thread-type-aware session close-out ([[HS-RES-32MJ]]). Close-out is NOT
725
+ // one-size-fits-all: the thread type decides whether there is a close at all and
726
+ // which steps run; the grain ladder (session-close-protocol) decides which spec
727
+ // is the review unit on the execution paths. Thread-type is DECLARED, not
728
+ // inferred — two of the five candidate signals (specs-filed-by-type,
729
+ // research-saved) are not session-scoped on either tier, so a positive
730
+ // declaration is the robust path. Mirrors the prose decision table in
731
+ // session-close-protocol §"Thread-type close-out".
732
+ // ─────────────────────────────────────────────────────────────────────────────
733
+ const CLOSE_SESSION_THREAD_PATHS = {
734
+ capture_only: {
735
+ label: "Capture-only — brain-offload via /hs-capture; no claim, no code",
736
+ is_close: false,
737
+ close_tool: false,
738
+ procedures: ["research-capture-protocol"],
739
+ steps: [
740
+ "No session close-out is owed — a capture is not a review unit.",
741
+ "Exit = promote to research (heyspex_promote_capture) or leave the capture pinned.",
742
+ "Do NOT submit_for_review, mint an HS-REV, record a WIP ledger, or flush an audit export.",
743
+ ],
744
+ },
745
+ research_only: {
746
+ label: "Research-only — saved an HS-RES; no claimed spec",
747
+ is_close: false,
748
+ close_tool: false,
749
+ procedures: ["research-capture-protocol"],
750
+ steps: [
751
+ "Save findings via heyspex_save_research / heyspex_complete_research (external scope needs >=1 cited URL).",
752
+ "Research is evergreen: do NOT submit_for_review or flip status, and do NOT mint an HS-REV.",
753
+ "No execution WIP ledger is owed. Audit export (heyspex_export_events) is optional.",
754
+ ],
755
+ },
756
+ plan_ingest_only: {
757
+ label: "Plan/ingest-only — filed milestone+epics; no task execution",
758
+ is_close: false,
759
+ close_tool: false,
760
+ procedures: ["plan-ingest-agent-workflow", "spec-hierarchy"],
761
+ steps: [
762
+ "Confirm every new spec is registered + linked: heyspex_validate_linkage (heyspex_register_spec for any orphan).",
763
+ "Confirm each milestone/epic carries an authored summary and a re-derived ## Intended outcome.",
764
+ "No task close, no HS-REV, no execution WIP ledger — epics are filed with children: [].",
765
+ ],
766
+ },
767
+ one_task: {
768
+ label: "One-task execution — claimed and completed a single task",
769
+ is_close: true,
770
+ close_tool: true,
771
+ procedures: ["session-close-protocol", "memory-protocol"],
772
+ steps: [
773
+ "heyspex_submit_for_review({ spec_id }) on the completed unit (status API SSOT — never hand-edit status:). Grain-coercion, parent rollup, and claim release happen server-side.",
774
+ "Append a change-log roll-up / review seed to the task.",
775
+ "Create an HS-REV companion + parent ## Review back-link ONLY if the active review grain is task; otherwise the change-log seed is enough.",
776
+ "heyspex_record_wip for the worked epic (skip only if the epic closed this session).",
777
+ "heyspex_export_events to flush the audit log. Do NOT git commit / push.",
778
+ "Hand-off: if the human asks for a hand-off, or work remains for a next session, run the /hs-handoff create flow — `hs handoff record --new-code --spec <next> --phase … --link … --blocker …` (minted code) and hand back `/hs-handoff <CODE>` plus `claude \"/hs-next <id>\"`; never a freehand prompt.",
779
+ ],
780
+ },
781
+ multi_task_epic: {
782
+ label: "Multi-task / epic push — completed several tasks under an epic",
783
+ is_close: true,
784
+ close_tool: true,
785
+ procedures: ["session-close-protocol", "memory-protocol"],
786
+ steps: [
787
+ "heyspex_submit_for_review on each completed unit; parent rollup is automatic when the last child lands.",
788
+ "One consolidated change-log + the HS-REV companion at the ACTIVE review grain (epic in steady-state, milestone in MVP).",
789
+ "heyspex_record_wip once for the worked epic; heyspex_export_events; do NOT git commit / push.",
790
+ "Hand-off: if the human asks for a hand-off, or work remains for a next session, run the /hs-handoff create flow — `hs handoff record --new-code --spec <next> --phase … --link … --blocker …` (minted code) and hand back `/hs-handoff <CODE>` plus `claude \"/hs-next <id>\"`; never a freehand prompt.",
791
+ ],
792
+ },
793
+ resolve_diagnose: {
794
+ label: "Resolve / diagnose — worked an HS-DIAG or a Resolution item",
795
+ is_close: true,
796
+ close_tool: true,
797
+ procedures: ["session-close-protocol", "resolution-diagnose-workflow"],
798
+ steps: [
799
+ "If the flag was a stale-cache false positive (the file on disk contradicts it): re-stamp via heyspex_record_alignment — do NOT re-file work that already exists.",
800
+ "If a real diagnose/fix completed: heyspex_submit_for_review on it + change-log; HS-REV per active grain.",
801
+ "heyspex_record_wip if anything remains open; heyspex_export_events.",
802
+ "Hand-off: if the human asks for a hand-off, or work remains for a next session, run the /hs-handoff create flow — `hs handoff record --new-code --spec <next> --phase … --link … --blocker …` (minted code) and hand back `/hs-handoff <CODE>` plus `claude \"/hs-next <id>\"`; never a freehand prompt.",
803
+ ],
804
+ },
805
+ mid_rollover: {
806
+ label: "Mid-implementation rollover — claimed but not finished",
807
+ is_close: false,
808
+ close_tool: false,
809
+ procedures: ["context-rollover-checkpoint"],
810
+ steps: [
811
+ "This is NOT a session close. Do NOT flip status, mint an HS-REV, or submit_for_review.",
812
+ "Record a lightweight WIP checkpoint (heyspex_record_wip) so the next session resumes; leave the spec in_progress.",
813
+ "Hand-off: if the human asks for a hand-off, or work remains for a next session, run the /hs-handoff create flow — `hs handoff record --new-code --spec <next> --phase … --link … --blocker …` (minted code) and hand back `/hs-handoff <CODE>` plus `claude \"/hs-next <id>\"`; never a freehand prompt.",
814
+ 'See heyspex_preflight({ intent: "context_rollover" }) / the context-rollover-checkpoint procedure for the full checkpoint shape.',
815
+ ],
816
+ },
817
+ };
818
+
819
+ const CLOSE_SESSION_THREAD_TYPES = Object.keys(CLOSE_SESSION_THREAD_PATHS);
820
+
821
+ const CLOSE_SESSION_MIXED_RULE =
822
+ "If the session spanned MORE THAN ONE thread type (e.g. filed a plan AND finished a task), request each applicable type and run the UNION of their steps — ordered, idempotent (re-running submit_for_review / export_events is safe), and surface the ambiguity. Never drop an owed obligation; never duplicate one.";
823
+
824
+ function buildCloseSessionPreflight(vaultRoot, threadType, injectedCatalog) {
825
+ const path = CLOSE_SESSION_THREAD_PATHS[threadType];
826
+ if (!path) {
827
+ return {
828
+ error: "UNKNOWN_THREAD_TYPE",
829
+ message: `Unknown thread_type "${threadType}" for close_session. Valid thread types: ${CLOSE_SESSION_THREAD_TYPES.join(", ")}.`,
830
+ };
831
+ }
832
+ const catalog =
833
+ injectedCatalog !== undefined ? injectedCatalog : loadCatalog(vaultRoot);
834
+ if (!catalog) {
835
+ return {
836
+ error: "CATALOG_NOT_FOUND",
837
+ message:
838
+ "specs/protocols/.catalog.json not found. Run `node scripts/build-procedure-catalog.mjs`.",
839
+ };
840
+ }
841
+ const procedures = path.procedures.map((name) =>
842
+ resolveRow(catalog, { procedure: name, severity: "required" }),
843
+ );
844
+ const close_tool = path.close_tool ? buildCloseTool("close_session") : null;
845
+ return {
846
+ intent: "close_session",
847
+ thread_type: threadType,
848
+ thread_label: path.label,
849
+ is_close: path.is_close,
850
+ steps: path.steps,
851
+ procedures,
852
+ mixed_thread_rule: CLOSE_SESSION_MIXED_RULE,
853
+ ...(close_tool ? { close_tool } : {}),
854
+ };
855
+ }
856
+
857
+ /**
858
+ * `injectedCatalog` — see {@link executeListProcedures}; same contract here
859
+ * (threaded through to `buildCloseSessionPreflight` for the close_session
860
+ * thread-type path too, so a single HTTP fetch covers both branches).
861
+ */
862
+ // [[HS-J3NY-C86Z-TASK-replace-hard-coded-owner-defaults-on]]: the execute_spec delegation row used
863
+ // to tell EVERY agent "build -> free local fleet, breakdown/critic -> DeepSeek V4" from the owner's
864
+ // dev-factory/routing-table.json, a file a customer install does not have. That wording is now the
865
+ // owner's only: it shows when the HS-9JMD dev-factory overlay gate is open (heyspex-app source
866
+ // checkout + owner account + HEYSPEX_DEV_FACTORY not 0). Everyone else is told the harness defaults.
867
+ export const CUSTOMER_DELEGATION_WHY =
868
+ "Delegation follows your SpexFleet seats, which default to the models your harness provides: under Claude Code every seat runs a Claude model by its tier (judgement/planning -> opus, build/execution -> sonnet, mechanical -> haiku, in-session sub-agents inherit your session), under GitHub Copilot every seat runs auto. `hs seat list` shows each seat's model and any override you set with `hs seat set <seat> <model>`. Hand mechanical, gate-verified build work to the build seat instead of doing it on your own top-tier session.";
869
+
870
+ /** True when the owner's dev-factory overlay may load for this package checkout. Never throws. */
871
+ export function ownerRoutingOverlayOpen(root = DEFAULT_PLATFORM_ROOT) {
872
+ try {
873
+ return isDevFactoryOverlayOpen(root);
874
+ } catch {
875
+ return false;
876
+ }
877
+ }
878
+
879
+ /** Swap owner-only routing text for the harness-default text when the overlay is closed. Pure. */
880
+ export function harnessConditionalRow(row, { overlayOpen = false } = {}) {
881
+ if (row?.procedure !== "dev-factory-delegation-routing" || overlayOpen) return row;
882
+ return { ...row, why: CUSTOMER_DELEGATION_WHY };
883
+ }
884
+
885
+ export function executeRunPreflight(vaultRoot, args = {}, injectedCatalog) {
886
+ const intent = String(args.intent ?? "").trim();
887
+ if (!intent) {
888
+ return {
889
+ error: "MISSING_INTENT",
890
+ message: `intent is required. Valid intents: ${VALID_INTENTS.join(", ")}.`,
891
+ };
892
+ }
893
+ // Thread-type-aware close-out: when a thread_type is declared, return that
894
+ // type's CONDITIONAL close path instead of the static checklist ([[HS-RES-32MJ]]).
895
+ const threadType = String(args.thread_type ?? "").trim();
896
+ if (intent === "close_session" && threadType) {
897
+ return buildCloseSessionPreflight(vaultRoot, threadType, injectedCatalog);
898
+ }
899
+ const recommendations = PREFLIGHT_INTENT_MAP[intent];
900
+ if (!recommendations) {
901
+ return {
902
+ error: "UNKNOWN_INTENT",
903
+ message: `Unknown intent "${intent}". Valid intents: ${VALID_INTENTS.join(", ")}.`,
904
+ };
905
+ }
906
+ const catalog =
907
+ injectedCatalog !== undefined ? injectedCatalog : loadCatalog(vaultRoot);
908
+ if (!catalog) {
909
+ return {
910
+ error: "CATALOG_NOT_FOUND",
911
+ message:
912
+ "specs/protocols/.catalog.json not found. Run `node scripts/build-procedure-catalog.mjs`.",
913
+ };
914
+ }
915
+
916
+ const overlayOpen =
917
+ typeof args.overlay_open === "boolean" ? args.overlay_open : ownerRoutingOverlayOpen();
918
+ const procedures = recommendations
919
+ .map((row) => harnessConditionalRow(row, { overlayOpen }))
920
+ .map((row) => resolveRow(catalog, row));
921
+
922
+ const filing_tool = buildFilingTool(intent);
923
+ const close_tool = buildCloseTool(intent);
924
+
925
+ const result = {
926
+ intent,
927
+ procedures,
928
+ ...(filing_tool ? { filing_tool } : {}),
929
+ ...(close_tool ? { close_tool } : {}),
930
+ };
931
+
932
+ // Family-overlap advisory for filing intents: warn agents to check whether
933
+ // a closed family already covered this goal with no iterates_on edge.
934
+ const FILING_ADVISORY =
935
+ "Family-overlap check (warn-only): before filing, check whether a CLOSED family already shipped against this goal with no iterates_on: edge — compare both TITLE and JTBD (job-to-be-done) statement, not title alone, since two differently-titled specs can still be the same job (owner-approved 2026-08-23 job-based-duplicate-detection extension) — if so this may be round 2; see entity-model §Iteration lineage. Never blocks filing.";
936
+ const FILING_INTENTS_WITH_ADVISORY = [
937
+ "file_epic",
938
+ "file_task",
939
+ "promote_research",
940
+ ];
941
+ if (FILING_INTENTS_WITH_ADVISORY.includes(intent)) {
942
+ result.advisory = FILING_ADVISORY;
943
+ }
944
+
945
+ // Static close_session call (no thread_type declared): nudge toward the
946
+ // conditional path so the agent runs the right minimal-complete close-out.
947
+ if (intent === "close_session") {
948
+ result.thread_type_hint = {
949
+ note: 'Close-out is thread-type-aware. Re-call heyspex_preflight({ intent: "close_session", thread_type: "<type>" }) to get the conditional close path for what this session actually did.',
950
+ thread_types: CLOSE_SESSION_THREAD_TYPES.map((t) => ({
951
+ thread_type: t,
952
+ label: CLOSE_SESSION_THREAD_PATHS[t].label,
953
+ is_close: CLOSE_SESSION_THREAD_PATHS[t].is_close,
954
+ })),
955
+ mixed: CLOSE_SESSION_MIXED_RULE,
956
+ };
957
+ }
958
+
959
+ // [[HS-EN4F]]: research intents carry the absolute hs-res route.
960
+ if (RESEARCH_TOOLING_INTENTS.includes(intent)) {
961
+ const research_tooling = buildResearchTooling();
962
+ if (research_tooling) result.research_tooling = research_tooling;
963
+ }
964
+
965
+ return result;
966
+ }
967
+
968
+ // ─────────────────────────────────────────────────────────────────────────────
969
+ // [[HS-EN4F]] — the hs-res route, in absolute paths. The /hs-res command and
970
+ // its SKILL.md spell the gather/synthesis CLIs repo-relative
971
+ // (`node dev-factory/spexcrawl/...`), which only resolves inside the platform
972
+ // checkout. An agent in a customer repo (sales-coach, 2026-10-02) looked for
973
+ // hs-res there, did not find it, concluded "the tooling isn't installed" and
974
+ // fanned out Sonnet subagents on WebSearch. The research preflight is the one
975
+ // call every research path must make (the HS-K0ZP gate refuses WebSearch /
976
+ // WebFetch until it runs), so it carries the route: absolute commands derived
977
+ // from where THIS module sits — the MCP server serving the customer repo runs
978
+ // out of the platform checkout, so these paths resolve from any working
979
+ // directory. Null when the platform does not ship the CLIs (published package).
980
+ // ─────────────────────────────────────────────────────────────────────────────
981
+ const RESEARCH_TOOLING_INTENTS = [
982
+ "research",
983
+ "file_research",
984
+ "promote_research",
985
+ ];
986
+ const DEFAULT_PLATFORM_ROOT = resolve(
987
+ dirname(fileURLToPath(import.meta.url)),
988
+ "..",
989
+ "..",
990
+ );
991
+
992
+ export function buildResearchTooling(
993
+ platformRoot = DEFAULT_PLATFORM_ROOT,
994
+ exists = existsSync,
995
+ ) {
996
+ const abs = (...parts) => join(platformRoot, ...parts).replace(/\\/g, "/");
997
+ const spexcrawl = abs("dev-factory", "spexcrawl", "spexcrawl-cli.mjs");
998
+ const firecrawl = abs("dev-factory", "spexcrawl", "firecrawl-cli.mjs");
999
+ const synthesize = abs("dev-factory", "spexcrawl", "synthesize-cli.mjs");
1000
+ const skill = abs(".commandcode", "skills", "hs-res", "SKILL.md");
1001
+ if (![spexcrawl, firecrawl, synthesize].every((p) => exists(p))) return null;
1002
+ return {
1003
+ method:
1004
+ "hs-res (the confidence ladder) — URL DISCOVERY defaults to self-hosted Firecrawl search; FULL-PAGE fetch of each cited URL defaults to SpexCrawl scrape (Firecrawl scrape is a per-URL retry only); DeepSeek synthesizes. Zero Claude/Anthropic sub-agents. Owner ruling 2026-10-10: SpexCrawl search is blocked on all four engines, so it is no longer the default discovery step. Hosted api.firecrawl.dev stays ask-before-spend and is never a default.",
1005
+ note: "These commands run from ANY repo. A project with no /hs-res command or skill of its own does NOT mean the tooling is missing — use these absolute paths. Firecrawl search returns titles/URLs/snippets only: snippets are discovery, never evidence — scrape the full page of every URL you cite. Native WebSearch/WebFetch is the last resort only when both backends report unavailable; never fan out Sonnet sub-agents for research.",
1006
+ skill_path: exists(skill) ? skill : null,
1007
+ // Owner ruling 2026-10-10 (HS-5PG6): discovery = Firecrawl search, fetch = SpexCrawl scrape.
1008
+ discover_primary: `node "${firecrawl}" search "<query>" --json`,
1009
+ fetch_primary: `node "${spexcrawl}" scrape "<url>" --json`,
1010
+ fetch_retry: `node "${firecrawl}" scrape "<url>" --json`,
1011
+ optional_combined: `node "${spexcrawl}" search "<query>" --json`,
1012
+ // Legacy field names kept for existing consumers. They now mirror the new
1013
+ // order: gather_* = discovery, scrape_* = fetch. `gather_primary` is NO
1014
+ // LONGER SpexCrawl search (that moved to optional_combined).
1015
+ gather_primary: `node "${firecrawl}" search "<query>" --json`,
1016
+ gather_fallback: `node "${spexcrawl}" search "<query>" --json`,
1017
+ scrape_primary: `node "${spexcrawl}" scrape "<url>" --json`,
1018
+ scrape_fallback: `node "${firecrawl}" scrape "<url>" --json`,
1019
+ synthesize: `node "${synthesize}" --prompt-file <path> --json`,
1020
+ grounding_first:
1021
+ "Before any gather: method triage (known-target-retrieval vs the ladder), ground-first (heyspex_recall_memories), and check for a working reference the owner already runs (e.g. `docker ps` for a local Firecrawl on :3002). Record the four-part grounding result (already built / already decided / owner reference / residual delta).",
1022
+ };
1023
+ }
1024
+
1025
+ export const PREFLIGHT_INTENTS = VALID_INTENTS;
1026
+ export { CLOSE_SESSION_THREAD_TYPES };
1027
+
1028
+ // ─────────────────────────────────────────────────────────────────────────────
1029
+ // Read-path selector wiring ([[HS-DJWP-MCPC-TASK-wire-the-mcp-server-s-vault-reads]]
1030
+ // of [[HS-T6GS-MCPC-EPIC-mcp-server-reads-the-vault-through-the]]).
1031
+ //
1032
+ // The three functions below are the cloud-reliant siblings of
1033
+ // `executeListProcedures` / `executeGetProcedure` / `executeRunPreflight`.
1034
+ // They never call `loadCatalog(vaultRoot)` in the `neon` tier — the catalog
1035
+ // (or, for `executeGetProcedureViaTier`, the full procedure body) is fetched
1036
+ // once over HTTP via the caller-supplied `consoleFetch` and handed to the
1037
+ // existing pure functions as an `injectedCatalog`, so the filtering /
1038
+ // preflight-mapping logic itself is never duplicated. In `disk` tier each
1039
+ // wrapper calls the untouched, byte-for-byte-unchanged pure function — same
1040
+ // disk-only code path as before this task.
1041
+ //
1042
+ // `consoleFetch` is passed in (not imported) because this module is
1043
+ // deliberately transport-free "pure-logic" (see file header) — cli.mjs owns
1044
+ // the bound `makeConsoleFetch` client and threads it through here, mirroring
1045
+ // how `readViaSelector` itself never imports console-fetch.mjs.
1046
+ // ─────────────────────────────────────────────────────────────────────────────
1047
+
1048
+ /** Row shape from `GET /api/procedures` — mirrors `summarize()` above (see that route's docstring: "do not invent a new shape here"). */
1049
+ function catalogFromProceduresRoute(body) {
1050
+ const rows = Array.isArray(body?.procedures) ? body.procedures : [];
1051
+ return { procedures: rows };
1052
+ }
1053
+
1054
+ /**
1055
+ * Fetch the procedure catalog via `GET /api/procedures` (disk: `loadCatalog`).
1056
+ * Exported for callers that need the raw catalog object outside the three
1057
+ * higher-level wrappers below (none today; kept for symmetry / testability).
1058
+ */
1059
+ export async function loadCatalogViaTier({
1060
+ vaultRoot,
1061
+ tier,
1062
+ consoleFetch,
1063
+ consoleUrl,
1064
+ }) {
1065
+ return readViaSelector({
1066
+ tier,
1067
+ diskRead: () => loadCatalog(vaultRoot),
1068
+ neonRead: async () => {
1069
+ const res = await consoleFetch("/api/procedures");
1070
+ if (!res.ok) {
1071
+ throw consoleReadErrorFrom(res, `GET /api/procedures returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
1072
+ }
1073
+ return catalogFromProceduresRoute(res.body);
1074
+ },
1075
+ routeLabel: "GET /api/procedures",
1076
+ consoleUrl,
1077
+ });
1078
+ }
1079
+
1080
+ /** Cloud-reliant sibling of {@link executeListProcedures}. */
1081
+ export async function executeListProceduresViaTier(opts) {
1082
+ const { value, degraded } = await withDegradationScope(() => executeListProceduresViaTierInner(opts));
1083
+ return mergeDegradation(value, degraded);
1084
+ }
1085
+
1086
+ async function executeListProceduresViaTierInner({
1087
+ vaultRoot,
1088
+ args,
1089
+ tier,
1090
+ consoleFetch,
1091
+ consoleUrl,
1092
+ }) {
1093
+ return readViaSelector({
1094
+ tier,
1095
+ diskRead: () => executeListProcedures(vaultRoot, args),
1096
+ neonRead: async () => {
1097
+ const catalog = await loadCatalogViaTier({
1098
+ vaultRoot,
1099
+ tier: "neon",
1100
+ consoleFetch,
1101
+ consoleUrl,
1102
+ });
1103
+ if (!catalog) {
1104
+ return {
1105
+ error: "CATALOG_NOT_FOUND",
1106
+ message:
1107
+ "GET /api/procedures returned no catalog (console reports it missing).",
1108
+ };
1109
+ }
1110
+ // Filtering happens HERE, client-side — the route has no tag/trigger/query
1111
+ // params (it always answers the full unfiltered set), unlike the disk path
1112
+ // which filters inside executeListProcedures itself. Passing the fetched
1113
+ // catalog as `injectedCatalog` runs the SAME filter logic either way.
1114
+ return executeListProcedures(vaultRoot, args, catalog);
1115
+ },
1116
+ routeLabel: "GET /api/procedures",
1117
+ consoleUrl,
1118
+ });
1119
+ }
1120
+
1121
+ /** Cloud-reliant sibling of {@link executeGetProcedure}. Single round trip: `GET /api/procedures/<name>` returns catalog entry + full body together. */
1122
+ export async function executeGetProcedureViaTier({
1123
+ vaultRoot,
1124
+ args,
1125
+ tier,
1126
+ consoleFetch,
1127
+ consoleUrl,
1128
+ }) {
1129
+ return readViaSelector({
1130
+ tier,
1131
+ diskRead: () => executeGetProcedure(vaultRoot, args),
1132
+ neonRead: async () => {
1133
+ const name = String(args?.name ?? "").trim();
1134
+ if (!name) {
1135
+ return {
1136
+ error: "MISSING_NAME",
1137
+ message: 'name is required (e.g. "spec-hierarchy")',
1138
+ };
1139
+ }
1140
+ const res = await consoleFetch(
1141
+ `/api/procedures/${encodeURIComponent(name)}`,
1142
+ );
1143
+ if (res.ok) {
1144
+ return {
1145
+ procedure: res.body?.procedure,
1146
+ content: res.body?.content,
1147
+ source: res.body?.source ?? null,
1148
+ };
1149
+ }
1150
+ // Aligned with the sibling *ViaTier functions ([[HS-DJWP-MCPC-TASK]]
1151
+ // round 3 fix — a 500 {error:'boom'} was previously swallowed as a real
1152
+ // answer regardless of status code): ONLY a 404 PROCEDURE_NOT_FOUND is a
1153
+ // real "no such procedure" answer, matching the route's own contract
1154
+ // (its docstring: "an unknown name is a 404 error code, never an empty
1155
+ // 200 success"). Everything else — a 500 CATALOG_NOT_FOUND included, a
1156
+ // reachable console reporting a server-side config break — throws so the
1157
+ // selector surfaces CONSOLE_UNREACHABLE_CLOUD_MODE rather than silently
1158
+ // degrading; the epic's no-silent-degrade rule treats "console answered,
1159
+ // but the answer means something is broken" the same as unreachable.
1160
+ if (res.status === 404 && res.body?.error === "PROCEDURE_NOT_FOUND") {
1161
+ return { error: res.body.error, message: res.body.message };
1162
+ }
1163
+ throw consoleReadErrorFrom(
1164
+ res,
1165
+ `GET /api/procedures/${name} returned ${res.status}: ${res.body?.error ? `${res.body.error}${res.body.message ? ` — ${res.body.message}` : ""}` : res.error || JSON.stringify(res.body ?? {})}`,
1166
+ null,
1167
+ );
1168
+ },
1169
+ routeLabel: "GET /api/procedures/[name]",
1170
+ consoleUrl,
1171
+ });
1172
+ }
1173
+
1174
+ /** Cloud-reliant sibling of {@link executeRunPreflight}. One catalog fetch covers both the static intent map AND the close_session thread-type path. */
1175
+ export async function executeRunPreflightViaTier(opts) {
1176
+ const { value, degraded } = await withDegradationScope(() => executeRunPreflightViaTierInner(opts));
1177
+ return mergeDegradation(value, degraded);
1178
+ }
1179
+
1180
+ async function executeRunPreflightViaTierInner({
1181
+ vaultRoot,
1182
+ args,
1183
+ tier,
1184
+ consoleFetch,
1185
+ consoleUrl,
1186
+ }) {
1187
+ return readViaSelector({
1188
+ tier,
1189
+ diskRead: () => executeRunPreflight(vaultRoot, args),
1190
+ neonRead: async () => {
1191
+ const catalog = await loadCatalogViaTier({
1192
+ vaultRoot,
1193
+ tier: "neon",
1194
+ consoleFetch,
1195
+ consoleUrl,
1196
+ });
1197
+ if (!catalog) {
1198
+ return {
1199
+ error: "CATALOG_NOT_FOUND",
1200
+ message:
1201
+ "GET /api/procedures returned no catalog (console reports it missing).",
1202
+ };
1203
+ }
1204
+ return executeRunPreflight(vaultRoot, args, catalog);
1205
+ },
1206
+ routeLabel: "GET /api/procedures",
1207
+ consoleUrl,
1208
+ });
1209
+ }