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,327 +1,327 @@
1
- // heyspex_list_procedures, get_procedure, preflight handlers (disk and tier-aware
2
- // variants; the procedure store itself is procedure-tools.mjs). Extracted from cli.mjs under
3
- // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
4
-
5
- import { executeGetProcedure, executeGetProcedureViaTier, executeListProcedures, executeListProceduresViaTier, executeRunPreflight, executeRunPreflightViaTier } from './procedure-tools.mjs';
6
- import { detectAgentIdentity, effectiveConsoleUrl } from './mcp-config.mjs';
7
- import { preflightMarkerNotice, writePreflightMarker } from './preflight-gate.mjs';
8
-
9
- /**
10
- * [[HS-K0ZP]]: reading research-capture-protocol directly is the same proof that
11
- * running heyspex_preflight({ intent: "research" }) gives, so a successful
12
- * heyspex_get_procedure on it writes the `research` marker too. Only a real body
13
- * counts -- a NOT_FOUND response taught the agent nothing. Never throws: the
14
- * marker write is fail-open by contract, and a read must never break on it.
15
- */
16
- export const RESEARCH_PROTOCOL_NAME = 'research-capture-protocol';
17
- export function recordResearchProtocolRead(vaultRoot, args, result) {
18
- try {
19
- if (String(args?.name ?? '').trim() !== RESEARCH_PROTOCOL_NAME) return false;
20
- if (!result || result.error || !result.content) return false;
21
- const identity = detectAgentIdentity(args.agent, args.session_id);
22
- return writePreflightMarker(vaultRoot, 'research', {
23
- sessionId: identity.sessionId,
24
- agent: identity.agent,
25
- procedures: [RESEARCH_PROTOCOL_NAME],
26
- degradedSource: result.degraded ? 'disk' : undefined,
27
- });
28
- } catch {
29
- return false;
30
- }
31
- }
32
- import { matchingRecipes } from './recipe-tools.mjs';
33
- import { wrapDesignResult } from './tool-helpers.mjs';
34
- import { MEMORY_BASELINE_TAGS, MEMORY_DESCRIPTION_TRUNCATE, MEMORY_INJECTION_LIMIT, detectMemoryAmendment, scoreRelevantMemories } from './on-deck-brief.mjs';
35
- import { consoleReadErrorFrom, readViaSelector, resolveReadTier } from './read-path.mjs';
36
- import { consoleFetch } from './console-transport.mjs';
37
- import { extractSummary } from './memory-tools.mjs';
38
-
39
- export function handleListProcedures(vaultRoot, args) {
40
- return wrapDesignResult(executeListProcedures(vaultRoot, args));
41
- }
42
-
43
- export function handleGetProcedure(vaultRoot, args) {
44
- const result = executeGetProcedure(vaultRoot, args);
45
- recordResearchProtocolRead(vaultRoot, args, result);
46
- return wrapDesignResult(result);
47
- }
48
-
49
- export function handleRunPreflight(vaultRoot, args) {
50
- const result = executeRunPreflight(vaultRoot, args);
51
-
52
- // Proof-of-use ([[preflight-gate]]): record that the questions were actually
53
- // asked, so the tools those questions govern can refuse when they were not.
54
- // Only a real answer counts — an UNKNOWN_INTENT / CATALOG_NOT_FOUND response
55
- // taught the agent nothing and must not clear a gate.
56
- if (result && !result.error) {
57
- const identity = detectAgentIdentity(args.agent, args.session_id);
58
- const wrote = writePreflightMarker(vaultRoot, String(args.intent ?? '').trim(), {
59
- sessionId: identity.sessionId,
60
- agent: identity.agent,
61
- threadType: args.thread_type ?? null,
62
- procedures: Array.isArray(result.procedures)
63
- ? result.procedures.map((p) => p?.procedure).filter(Boolean)
64
- : [],
65
- });
66
- // A marker we could not write used to be swallowed silently, so preflight
67
- // reported success while the gated tools refused forever and told the caller
68
- // to "call preflight, then retry" — a loop no retry escaped
69
- // ([[HS-N71O-P3XQ-DIAG-six-new-qc-gates-fail-cold-verification]] item 3).
70
- // Preflight still succeeds (fail-open); it just stops claiming it recorded
71
- // something it did not.
72
- const notice = preflightMarkerNotice(wrote);
73
- if (notice) Object.assign(result, notice);
74
- }
75
- // Match workflow-recipes against the intent so recipes are discoverable the
76
- // same way procedures are ([[HS-RQ9K-TASK]] / [[HS-5G0P-EPIC]]). Never throws.
77
- let recipes = [];
78
- try {
79
- recipes = matchingRecipes(vaultRoot, args.intent);
80
- } catch {
81
- recipes = [];
82
- }
83
-
84
- // Recipe-only intent (e.g. "bug_fix"): a valid recipe signature that is NOT a
85
- // procedure intent returns UNKNOWN_INTENT from the procedure map. Rather than
86
- // hard-error, surface the matching recipes so the caller still gets a useful
87
- // answer — recipe intents live in a different namespace than procedure intents.
88
- if (result && result.error === 'UNKNOWN_INTENT' && recipes.length > 0) {
89
- return wrapDesignResult({
90
- intent: String(args.intent ?? '').trim(),
91
- procedures: [],
92
- recipes,
93
- note:
94
- 'No procedure checklist for this intent, but it matches one or more workflow-recipes. Start one with heyspex_start_recipe, or call heyspex_preflight with a procedure intent (e.g. file_task, execute_spec).',
95
- });
96
- }
97
-
98
- // Attach recalled_memories so high-stakes decision points carry the same
99
- // memory load /hs-next does. Skipped on error responses ([[HS-TASK-IMMF]]).
100
- if (result && !result.error) {
101
- try {
102
- const recalled = scoreRelevantMemories(vaultRoot, {
103
- // Use the intent name as a primary signal so an intent like
104
- // recommend_location surfaces template/saas/architecture memories
105
- // strongly. The baseline pool still applies on top.
106
- primaryTags: typeof args.intent === 'string' ? [args.intent] : [],
107
- });
108
- if (recalled.items.length > 0) {
109
- result.recalled_memories = recalled;
110
- }
111
- } catch {
112
- // Recall failure must never break preflight — surface the procedures
113
- // without memories rather than 500ing the response.
114
- }
115
- if (recipes.length > 0) {
116
- result.recipes = recipes;
117
- }
118
- }
119
- return wrapDesignResult(result);
120
- }
121
-
122
- /**
123
- * Cloud-reliant sibling of `handleListProcedures` ([[HS-DJWP-MCPC-TASK]]
124
- * "Procedures" family, backing `heyspex_list_procedures`). `handleListProcedures`
125
- * stays untouched (its `diskRead` branch).
126
- */
127
- export async function handleListProceduresViaTier(vaultRoot, args) {
128
- const tier = await resolveReadTier();
129
- const result = await executeListProceduresViaTier({
130
- vaultRoot,
131
- args,
132
- tier,
133
- consoleFetch,
134
- consoleUrl: effectiveConsoleUrl(),
135
- });
136
- return wrapDesignResult(result);
137
- }
138
-
139
- /**
140
- * Cloud-reliant sibling of `handleGetProcedure` ([[HS-DJWP-MCPC-TASK]]
141
- * "Procedures" family, backing `heyspex_get_procedure`). `handleGetProcedure`
142
- * stays untouched (its `diskRead` branch).
143
- */
144
- export async function handleGetProcedureViaTier(vaultRoot, args) {
145
- const tier = await resolveReadTier();
146
- const result = await executeGetProcedureViaTier({
147
- vaultRoot,
148
- args,
149
- tier,
150
- consoleFetch,
151
- consoleUrl: effectiveConsoleUrl(),
152
- });
153
- recordResearchProtocolRead(vaultRoot, args, result);
154
- return wrapDesignResult(result);
155
- }
156
-
157
- /**
158
- * Cloud-reliant sibling of `handleRunPreflight` ([[HS-DJWP-MCPC-TASK]]
159
- * "Procedures" + "Memories" families, backing `heyspex_preflight`).
160
- * `handleRunPreflight` is left completely untouched — it is imported and
161
- * called directly (synchronously) by `preflight-gate.test.mjs`, so it must
162
- * keep returning a plain object, never a Promise. This is a SEPARATE function,
163
- * used only by the live tool dispatch (`case 'heyspex_preflight'` below),
164
- * which duplicates the marker-write / recipe-match / memory-recall tail
165
- * (`writePreflightMarker`, `preflightMarkerNotice`, `matchingRecipes` are
166
- * reused unchanged — only the tier-aware catalog + memory fetch differ).
167
- */
168
- export async function handleRunPreflightViaTier(vaultRoot, args) {
169
- const tier = await resolveReadTier();
170
- const consoleUrl = effectiveConsoleUrl();
171
- const result = await executeRunPreflightViaTier({ vaultRoot, args, tier, consoleFetch, consoleUrl });
172
-
173
- if (result && !result.error) {
174
- const identity = detectAgentIdentity(args.agent, args.session_id);
175
- const wrote = writePreflightMarker(vaultRoot, String(args.intent ?? '').trim(), {
176
- sessionId: identity.sessionId,
177
- agent: identity.agent,
178
- threadType: args.thread_type ?? null,
179
- procedures: Array.isArray(result.procedures)
180
- ? result.procedures.map((p) => p?.procedure).filter(Boolean)
181
- : [],
182
- degradedSource: result.degraded ? 'disk' : undefined,
183
- });
184
- const notice = preflightMarkerNotice(wrote);
185
- if (notice) Object.assign(result, notice);
186
- }
187
-
188
- let recipes = [];
189
- try {
190
- recipes = matchingRecipes(vaultRoot, args.intent);
191
- } catch {
192
- recipes = [];
193
- }
194
-
195
- if (result && result.error === 'UNKNOWN_INTENT' && recipes.length > 0) {
196
- return wrapDesignResult({
197
- intent: String(args.intent ?? '').trim(),
198
- procedures: [],
199
- recipes,
200
- note:
201
- 'No procedure checklist for this intent, but it matches one or more workflow-recipes. Start one with heyspex_start_recipe, or call heyspex_preflight with a procedure intent (e.g. file_task, execute_spec).',
202
- });
203
- }
204
-
205
- if (result && !result.error) {
206
- try {
207
- const recalled = await scoreRelevantMemoriesViaTier(
208
- vaultRoot,
209
- { primaryTags: typeof args.intent === 'string' ? [args.intent] : [] },
210
- { tier, consoleUrl },
211
- );
212
- if (recalled.items.length > 0) {
213
- result.recalled_memories = recalled;
214
- }
215
- } catch {
216
- // Recall failure must never break preflight.
217
- }
218
- if (recipes.length > 0) {
219
- result.recipes = recipes;
220
- }
221
- }
222
- return wrapDesignResult(result);
223
- }
224
-
225
- /**
226
- * Cloud-reliant sibling of `scoreRelevantMemories` ([[HS-DJWP-MCPC-TASK]]
227
- * "Memories" family, backing the preflight memory recall). `scoreRelevantMemories`
228
- * is left completely untouched (its callers — `handleRunPreflight`,
229
- * `enrichOnDeckWithRecalledMemories` — keep the exact disk-only behavior they
230
- * have today); this is a NEW, separate cloud path used only by
231
- * `handleRunPreflightViaTier` below. Scoring logic (tag-overlap, amendment
232
- * detection, truncation, sort) is duplicated rather than shared with
233
- * `scoreRelevantMemories` deliberately — the two operate on differently-shaped
234
- * inputs (disk: `{frontmatter, body}` per file; cloud: the console's flat
235
- * `MemoryDoc` rows) and duplicating ~25 lines is a smaller risk than
236
- * refactoring the already-tested disk function to share a helper.
237
- *
238
- * Unlike `handleRecallMemoriesViaTier`, no fidelity gap here — `MemoryDoc`
239
- * already carries everything this scorer reads (`tags`, `body`, `name`, `id`).
240
- *
241
- * Split into a tag-only ranking pass ({@link rankMemoryCandidatesByTags}) and a
242
- * per-item body-dependent builder ({@link buildScoredMemoryItem}) so the
243
- * caller ({@link scoreRelevantMemoriesViaTier}) can rank against a
244
- * summary-only listing (score is pure tag-overlap, never touches body) and
245
- * only fetch body text for the top `limit` survivors —
246
- * [[HS-LKV0-FUU4-DIAG-cloud-mode-recall-times-out-the]]: the console's
247
- * `GET /api/memories` used to always ship a body per doc, which is what made
248
- * a 896-doc vault blow the 30s fetch timeout on every one of these read paths.
249
- */
250
- export function rankMemoryCandidatesByTags(docs, options = {}) {
251
- const primaryTags = Array.isArray(options.primaryTags) ? options.primaryTags : [];
252
- const baselineTags = Array.isArray(options.baselineTags) ? options.baselineTags : MEMORY_BASELINE_TAGS;
253
- const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : MEMORY_INJECTION_LIMIT;
254
-
255
- const querySet = new Set([...primaryTags, ...baselineTags].map((t) => String(t).toLowerCase()));
256
- if (querySet.size === 0) return [];
257
-
258
- const scored = [];
259
- for (const doc of docs) {
260
- const tags = Array.isArray(doc.tags) ? doc.tags : [];
261
- if (tags.length === 0) continue;
262
- const tagSet = new Set(tags.map((t) => String(t).toLowerCase()));
263
- let score = 0;
264
- for (const q of querySet) if (tagSet.has(q)) score += 1;
265
- if (score === 0) continue;
266
- scored.push({ doc, score });
267
- }
268
-
269
- scored.sort((a, b) => {
270
- if (b.score !== a.score) return b.score - a.score;
271
- const aName = a.doc.name || a.doc.id || '';
272
- const bName = b.doc.name || b.doc.id || '';
273
- return String(aName).localeCompare(String(bName));
274
- });
275
-
276
- return scored.slice(0, limit);
277
- }
278
-
279
- /** Builds one scored-memory item from a ranked candidate + its (separately fetched) body. */
280
- export function buildScoredMemoryItem(doc, score, body) {
281
- const slug = doc.id;
282
- const name = doc.name || doc.id || slug;
283
- let description = extractSummary(body || '');
284
- if (description.length > MEMORY_DESCRIPTION_TRUNCATE) {
285
- description = description.slice(0, MEMORY_DESCRIPTION_TRUNCATE - 1) + '…';
286
- }
287
- const amended = detectMemoryAmendment(body || '', description);
288
- if (amended) {
289
- description = `${description} ⚠ ${amended} — this summary may not reflect the current ruling; READ THE FULL BODY before applying.`;
290
- }
291
- return { source: 'vault', name, link: `[[${slug}]]`, description, amended: amended || undefined, score };
292
- }
293
-
294
- export async function scoreRelevantMemoriesViaTier(vaultRoot, options, { tier, consoleUrl }) {
295
- return readViaSelector({
296
- tier,
297
- diskRead: () => scoreRelevantMemories(vaultRoot, options),
298
- neonRead: async () => {
299
- // Summary-only listing — scoring is pure tag-overlap and never needs body.
300
- const res = await consoleFetch('/api/memories');
301
- if (!res.ok) {
302
- throw consoleReadErrorFrom(res, `GET /api/memories returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
303
- }
304
- const docs = Array.isArray(res.body?.memories) ? res.body.memories : [];
305
- const ranked = rankMemoryCandidatesByTags(docs, options);
306
- if (ranked.length === 0) return { header: null, items: [] };
307
-
308
- // Fetch bodies ONLY for the survivors that ranking already narrowed to
309
- // (`limit`, default 5) — never one fetch per candidate across the whole set.
310
- const idsQs = new URLSearchParams({ ids: ranked.map(({ doc }) => doc.id).join(','), fields: 'body' });
311
- const bodyPath = `/api/memories?${idsQs}`;
312
- const bodyRes = await consoleFetch(bodyPath);
313
- if (!bodyRes.ok) {
314
- throw consoleReadErrorFrom(bodyRes, `GET ${bodyPath} returned ${bodyRes.status}: ${bodyRes.error || JSON.stringify(bodyRes.body ?? {})}`, null);
315
- }
316
- const bodyDocs = Array.isArray(bodyRes.body?.memories) ? bodyRes.body.memories : [];
317
- const bodyById = new Map(bodyDocs.map((d) => [d.id, typeof d.body === 'string' ? d.body : '']));
318
-
319
- return {
320
- header: 'Retrieved from prior sessions. Apply at decision points; do not echo verbatim.',
321
- items: ranked.map(({ doc, score }) => buildScoredMemoryItem(doc, score, bodyById.get(doc.id) || '')),
322
- };
323
- },
324
- routeLabel: 'GET /api/memories',
325
- consoleUrl,
326
- });
327
- }
1
+ // heyspex_list_procedures, get_procedure, preflight handlers (disk and tier-aware
2
+ // variants; the procedure store itself is procedure-tools.mjs). Extracted from cli.mjs under
3
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
4
+
5
+ import { executeGetProcedure, executeGetProcedureViaTier, executeListProcedures, executeListProceduresViaTier, executeRunPreflight, executeRunPreflightViaTier } from './procedure-tools.mjs';
6
+ import { detectAgentIdentity, effectiveConsoleUrl } from './mcp-config.mjs';
7
+ import { preflightMarkerNotice, writePreflightMarker } from './preflight-gate.mjs';
8
+
9
+ /**
10
+ * [[HS-K0ZP]]: reading research-capture-protocol directly is the same proof that
11
+ * running heyspex_preflight({ intent: "research" }) gives, so a successful
12
+ * heyspex_get_procedure on it writes the `research` marker too. Only a real body
13
+ * counts -- a NOT_FOUND response taught the agent nothing. Never throws: the
14
+ * marker write is fail-open by contract, and a read must never break on it.
15
+ */
16
+ export const RESEARCH_PROTOCOL_NAME = 'research-capture-protocol';
17
+ export function recordResearchProtocolRead(vaultRoot, args, result) {
18
+ try {
19
+ if (String(args?.name ?? '').trim() !== RESEARCH_PROTOCOL_NAME) return false;
20
+ if (!result || result.error || !result.content) return false;
21
+ const identity = detectAgentIdentity(args.agent, args.session_id);
22
+ return writePreflightMarker(vaultRoot, 'research', {
23
+ sessionId: identity.sessionId,
24
+ agent: identity.agent,
25
+ procedures: [RESEARCH_PROTOCOL_NAME],
26
+ degradedSource: result.degraded ? 'disk' : undefined,
27
+ });
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+ import { matchingRecipes } from './recipe-tools.mjs';
33
+ import { wrapDesignResult } from './tool-helpers.mjs';
34
+ import { MEMORY_BASELINE_TAGS, MEMORY_DESCRIPTION_TRUNCATE, MEMORY_INJECTION_LIMIT, detectMemoryAmendment, scoreRelevantMemories } from './on-deck-brief.mjs';
35
+ import { consoleReadErrorFrom, readViaSelector, resolveReadTier } from './read-path.mjs';
36
+ import { consoleFetch } from './console-transport.mjs';
37
+ import { extractSummary } from './memory-tools.mjs';
38
+
39
+ export function handleListProcedures(vaultRoot, args) {
40
+ return wrapDesignResult(executeListProcedures(vaultRoot, args));
41
+ }
42
+
43
+ export function handleGetProcedure(vaultRoot, args) {
44
+ const result = executeGetProcedure(vaultRoot, args);
45
+ recordResearchProtocolRead(vaultRoot, args, result);
46
+ return wrapDesignResult(result);
47
+ }
48
+
49
+ export function handleRunPreflight(vaultRoot, args) {
50
+ const result = executeRunPreflight(vaultRoot, args);
51
+
52
+ // Proof-of-use ([[preflight-gate]]): record that the questions were actually
53
+ // asked, so the tools those questions govern can refuse when they were not.
54
+ // Only a real answer counts — an UNKNOWN_INTENT / CATALOG_NOT_FOUND response
55
+ // taught the agent nothing and must not clear a gate.
56
+ if (result && !result.error) {
57
+ const identity = detectAgentIdentity(args.agent, args.session_id);
58
+ const wrote = writePreflightMarker(vaultRoot, String(args.intent ?? '').trim(), {
59
+ sessionId: identity.sessionId,
60
+ agent: identity.agent,
61
+ threadType: args.thread_type ?? null,
62
+ procedures: Array.isArray(result.procedures)
63
+ ? result.procedures.map((p) => p?.procedure).filter(Boolean)
64
+ : [],
65
+ });
66
+ // A marker we could not write used to be swallowed silently, so preflight
67
+ // reported success while the gated tools refused forever and told the caller
68
+ // to "call preflight, then retry" — a loop no retry escaped
69
+ // ([[HS-N71O-P3XQ-DIAG-six-new-qc-gates-fail-cold-verification]] item 3).
70
+ // Preflight still succeeds (fail-open); it just stops claiming it recorded
71
+ // something it did not.
72
+ const notice = preflightMarkerNotice(wrote);
73
+ if (notice) Object.assign(result, notice);
74
+ }
75
+ // Match workflow-recipes against the intent so recipes are discoverable the
76
+ // same way procedures are ([[HS-RQ9K-TASK]] / [[HS-5G0P-EPIC]]). Never throws.
77
+ let recipes = [];
78
+ try {
79
+ recipes = matchingRecipes(vaultRoot, args.intent);
80
+ } catch {
81
+ recipes = [];
82
+ }
83
+
84
+ // Recipe-only intent (e.g. "bug_fix"): a valid recipe signature that is NOT a
85
+ // procedure intent returns UNKNOWN_INTENT from the procedure map. Rather than
86
+ // hard-error, surface the matching recipes so the caller still gets a useful
87
+ // answer — recipe intents live in a different namespace than procedure intents.
88
+ if (result && result.error === 'UNKNOWN_INTENT' && recipes.length > 0) {
89
+ return wrapDesignResult({
90
+ intent: String(args.intent ?? '').trim(),
91
+ procedures: [],
92
+ recipes,
93
+ note:
94
+ 'No procedure checklist for this intent, but it matches one or more workflow-recipes. Start one with heyspex_start_recipe, or call heyspex_preflight with a procedure intent (e.g. file_task, execute_spec).',
95
+ });
96
+ }
97
+
98
+ // Attach recalled_memories so high-stakes decision points carry the same
99
+ // memory load /hs-next does. Skipped on error responses ([[HS-TASK-IMMF]]).
100
+ if (result && !result.error) {
101
+ try {
102
+ const recalled = scoreRelevantMemories(vaultRoot, {
103
+ // Use the intent name as a primary signal so an intent like
104
+ // recommend_location surfaces template/saas/architecture memories
105
+ // strongly. The baseline pool still applies on top.
106
+ primaryTags: typeof args.intent === 'string' ? [args.intent] : [],
107
+ });
108
+ if (recalled.items.length > 0) {
109
+ result.recalled_memories = recalled;
110
+ }
111
+ } catch {
112
+ // Recall failure must never break preflight — surface the procedures
113
+ // without memories rather than 500ing the response.
114
+ }
115
+ if (recipes.length > 0) {
116
+ result.recipes = recipes;
117
+ }
118
+ }
119
+ return wrapDesignResult(result);
120
+ }
121
+
122
+ /**
123
+ * Cloud-reliant sibling of `handleListProcedures` ([[HS-DJWP-MCPC-TASK]]
124
+ * "Procedures" family, backing `heyspex_list_procedures`). `handleListProcedures`
125
+ * stays untouched (its `diskRead` branch).
126
+ */
127
+ export async function handleListProceduresViaTier(vaultRoot, args) {
128
+ const tier = await resolveReadTier();
129
+ const result = await executeListProceduresViaTier({
130
+ vaultRoot,
131
+ args,
132
+ tier,
133
+ consoleFetch,
134
+ consoleUrl: effectiveConsoleUrl(),
135
+ });
136
+ return wrapDesignResult(result);
137
+ }
138
+
139
+ /**
140
+ * Cloud-reliant sibling of `handleGetProcedure` ([[HS-DJWP-MCPC-TASK]]
141
+ * "Procedures" family, backing `heyspex_get_procedure`). `handleGetProcedure`
142
+ * stays untouched (its `diskRead` branch).
143
+ */
144
+ export async function handleGetProcedureViaTier(vaultRoot, args) {
145
+ const tier = await resolveReadTier();
146
+ const result = await executeGetProcedureViaTier({
147
+ vaultRoot,
148
+ args,
149
+ tier,
150
+ consoleFetch,
151
+ consoleUrl: effectiveConsoleUrl(),
152
+ });
153
+ recordResearchProtocolRead(vaultRoot, args, result);
154
+ return wrapDesignResult(result);
155
+ }
156
+
157
+ /**
158
+ * Cloud-reliant sibling of `handleRunPreflight` ([[HS-DJWP-MCPC-TASK]]
159
+ * "Procedures" + "Memories" families, backing `heyspex_preflight`).
160
+ * `handleRunPreflight` is left completely untouched — it is imported and
161
+ * called directly (synchronously) by `preflight-gate.test.mjs`, so it must
162
+ * keep returning a plain object, never a Promise. This is a SEPARATE function,
163
+ * used only by the live tool dispatch (`case 'heyspex_preflight'` below),
164
+ * which duplicates the marker-write / recipe-match / memory-recall tail
165
+ * (`writePreflightMarker`, `preflightMarkerNotice`, `matchingRecipes` are
166
+ * reused unchanged — only the tier-aware catalog + memory fetch differ).
167
+ */
168
+ export async function handleRunPreflightViaTier(vaultRoot, args) {
169
+ const tier = await resolveReadTier();
170
+ const consoleUrl = effectiveConsoleUrl();
171
+ const result = await executeRunPreflightViaTier({ vaultRoot, args, tier, consoleFetch, consoleUrl });
172
+
173
+ if (result && !result.error) {
174
+ const identity = detectAgentIdentity(args.agent, args.session_id);
175
+ const wrote = writePreflightMarker(vaultRoot, String(args.intent ?? '').trim(), {
176
+ sessionId: identity.sessionId,
177
+ agent: identity.agent,
178
+ threadType: args.thread_type ?? null,
179
+ procedures: Array.isArray(result.procedures)
180
+ ? result.procedures.map((p) => p?.procedure).filter(Boolean)
181
+ : [],
182
+ degradedSource: result.degraded ? 'disk' : undefined,
183
+ });
184
+ const notice = preflightMarkerNotice(wrote);
185
+ if (notice) Object.assign(result, notice);
186
+ }
187
+
188
+ let recipes = [];
189
+ try {
190
+ recipes = matchingRecipes(vaultRoot, args.intent);
191
+ } catch {
192
+ recipes = [];
193
+ }
194
+
195
+ if (result && result.error === 'UNKNOWN_INTENT' && recipes.length > 0) {
196
+ return wrapDesignResult({
197
+ intent: String(args.intent ?? '').trim(),
198
+ procedures: [],
199
+ recipes,
200
+ note:
201
+ 'No procedure checklist for this intent, but it matches one or more workflow-recipes. Start one with heyspex_start_recipe, or call heyspex_preflight with a procedure intent (e.g. file_task, execute_spec).',
202
+ });
203
+ }
204
+
205
+ if (result && !result.error) {
206
+ try {
207
+ const recalled = await scoreRelevantMemoriesViaTier(
208
+ vaultRoot,
209
+ { primaryTags: typeof args.intent === 'string' ? [args.intent] : [] },
210
+ { tier, consoleUrl },
211
+ );
212
+ if (recalled.items.length > 0) {
213
+ result.recalled_memories = recalled;
214
+ }
215
+ } catch {
216
+ // Recall failure must never break preflight.
217
+ }
218
+ if (recipes.length > 0) {
219
+ result.recipes = recipes;
220
+ }
221
+ }
222
+ return wrapDesignResult(result);
223
+ }
224
+
225
+ /**
226
+ * Cloud-reliant sibling of `scoreRelevantMemories` ([[HS-DJWP-MCPC-TASK]]
227
+ * "Memories" family, backing the preflight memory recall). `scoreRelevantMemories`
228
+ * is left completely untouched (its callers — `handleRunPreflight`,
229
+ * `enrichOnDeckWithRecalledMemories` — keep the exact disk-only behavior they
230
+ * have today); this is a NEW, separate cloud path used only by
231
+ * `handleRunPreflightViaTier` below. Scoring logic (tag-overlap, amendment
232
+ * detection, truncation, sort) is duplicated rather than shared with
233
+ * `scoreRelevantMemories` deliberately — the two operate on differently-shaped
234
+ * inputs (disk: `{frontmatter, body}` per file; cloud: the console's flat
235
+ * `MemoryDoc` rows) and duplicating ~25 lines is a smaller risk than
236
+ * refactoring the already-tested disk function to share a helper.
237
+ *
238
+ * Unlike `handleRecallMemoriesViaTier`, no fidelity gap here — `MemoryDoc`
239
+ * already carries everything this scorer reads (`tags`, `body`, `name`, `id`).
240
+ *
241
+ * Split into a tag-only ranking pass ({@link rankMemoryCandidatesByTags}) and a
242
+ * per-item body-dependent builder ({@link buildScoredMemoryItem}) so the
243
+ * caller ({@link scoreRelevantMemoriesViaTier}) can rank against a
244
+ * summary-only listing (score is pure tag-overlap, never touches body) and
245
+ * only fetch body text for the top `limit` survivors —
246
+ * [[HS-LKV0-FUU4-DIAG-cloud-mode-recall-times-out-the]]: the console's
247
+ * `GET /api/memories` used to always ship a body per doc, which is what made
248
+ * a 896-doc vault blow the 30s fetch timeout on every one of these read paths.
249
+ */
250
+ export function rankMemoryCandidatesByTags(docs, options = {}) {
251
+ const primaryTags = Array.isArray(options.primaryTags) ? options.primaryTags : [];
252
+ const baselineTags = Array.isArray(options.baselineTags) ? options.baselineTags : MEMORY_BASELINE_TAGS;
253
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : MEMORY_INJECTION_LIMIT;
254
+
255
+ const querySet = new Set([...primaryTags, ...baselineTags].map((t) => String(t).toLowerCase()));
256
+ if (querySet.size === 0) return [];
257
+
258
+ const scored = [];
259
+ for (const doc of docs) {
260
+ const tags = Array.isArray(doc.tags) ? doc.tags : [];
261
+ if (tags.length === 0) continue;
262
+ const tagSet = new Set(tags.map((t) => String(t).toLowerCase()));
263
+ let score = 0;
264
+ for (const q of querySet) if (tagSet.has(q)) score += 1;
265
+ if (score === 0) continue;
266
+ scored.push({ doc, score });
267
+ }
268
+
269
+ scored.sort((a, b) => {
270
+ if (b.score !== a.score) return b.score - a.score;
271
+ const aName = a.doc.name || a.doc.id || '';
272
+ const bName = b.doc.name || b.doc.id || '';
273
+ return String(aName).localeCompare(String(bName));
274
+ });
275
+
276
+ return scored.slice(0, limit);
277
+ }
278
+
279
+ /** Builds one scored-memory item from a ranked candidate + its (separately fetched) body. */
280
+ export function buildScoredMemoryItem(doc, score, body) {
281
+ const slug = doc.id;
282
+ const name = doc.name || doc.id || slug;
283
+ let description = extractSummary(body || '');
284
+ if (description.length > MEMORY_DESCRIPTION_TRUNCATE) {
285
+ description = description.slice(0, MEMORY_DESCRIPTION_TRUNCATE - 1) + '…';
286
+ }
287
+ const amended = detectMemoryAmendment(body || '', description);
288
+ if (amended) {
289
+ description = `${description} ⚠ ${amended} — this summary may not reflect the current ruling; READ THE FULL BODY before applying.`;
290
+ }
291
+ return { source: 'vault', name, link: `[[${slug}]]`, description, amended: amended || undefined, score };
292
+ }
293
+
294
+ export async function scoreRelevantMemoriesViaTier(vaultRoot, options, { tier, consoleUrl }) {
295
+ return readViaSelector({
296
+ tier,
297
+ diskRead: () => scoreRelevantMemories(vaultRoot, options),
298
+ neonRead: async () => {
299
+ // Summary-only listing — scoring is pure tag-overlap and never needs body.
300
+ const res = await consoleFetch('/api/memories');
301
+ if (!res.ok) {
302
+ throw consoleReadErrorFrom(res, `GET /api/memories returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
303
+ }
304
+ const docs = Array.isArray(res.body?.memories) ? res.body.memories : [];
305
+ const ranked = rankMemoryCandidatesByTags(docs, options);
306
+ if (ranked.length === 0) return { header: null, items: [] };
307
+
308
+ // Fetch bodies ONLY for the survivors that ranking already narrowed to
309
+ // (`limit`, default 5) — never one fetch per candidate across the whole set.
310
+ const idsQs = new URLSearchParams({ ids: ranked.map(({ doc }) => doc.id).join(','), fields: 'body' });
311
+ const bodyPath = `/api/memories?${idsQs}`;
312
+ const bodyRes = await consoleFetch(bodyPath);
313
+ if (!bodyRes.ok) {
314
+ throw consoleReadErrorFrom(bodyRes, `GET ${bodyPath} returned ${bodyRes.status}: ${bodyRes.error || JSON.stringify(bodyRes.body ?? {})}`, null);
315
+ }
316
+ const bodyDocs = Array.isArray(bodyRes.body?.memories) ? bodyRes.body.memories : [];
317
+ const bodyById = new Map(bodyDocs.map((d) => [d.id, typeof d.body === 'string' ? d.body : '']));
318
+
319
+ return {
320
+ header: 'Retrieved from prior sessions. Apply at decision points; do not echo verbatim.',
321
+ items: ranked.map(({ doc, score }) => buildScoredMemoryItem(doc, score, bodyById.get(doc.id) || '')),
322
+ };
323
+ },
324
+ routeLabel: 'GET /api/memories',
325
+ consoleUrl,
326
+ });
327
+ }