heyspex 0.1.0

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 (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,548 @@
1
+ /**
2
+ * `heyspex_next_review` resolver — returns the next review-ready spec
3
+ * (parent body + HS-REV doc + prompt markdown) for the reviewer agent.
4
+ *
5
+ * [[HS-TASK-RVDC-heyspex-review-on-deck-mcp-and-hs-review]] · [[HS-RES-CLR4-close-out-review-screen-jtbd-architecture]] §J17
6
+ *
7
+ * Mirrors `resolveNextOnDeckPayload` in cli.mjs, but filters to `status: in_review`
8
+ * specs that have an `HS-REV-<suffix>-*.md` companion. Cli.mjs supplies the
9
+ * shared helpers (`findSpecFile`, `loadSpec`, `loadRoadmap`, `loadTask`) via
10
+ * dependency injection so tests can stub them.
11
+ */
12
+
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { execSync } from 'node:child_process';
16
+ import { readMvpModeState } from './mvp-mode.mjs';
17
+ import { loadReviewFocus } from './review-focus.mjs';
18
+ import { extractSpecSuffix, findReviewDocForSpec } from './spec-id-lib.mjs';
19
+
20
+ const STATUS_IN_REVIEW = 'in_review';
21
+
22
+ /**
23
+ * Active review grain ([[HS-EPIC-7CWZ]]) — the spec kind that crosses the
24
+ * build→review boundary, so the only kind eligible for the Review queue. MVP off
25
+ * (or no `review_grain`) ⇒ `task` (today's behavior); MVP on ⇒ the configured
26
+ * grain (`milestone` / `epic` / `task`). Mirror of the console `resolveNextReview`
27
+ * grain filter so the agent (`heyspex_next_review`) and the console agree.
28
+ */
29
+ function activeReviewGrain(vaultRoot) {
30
+ const state = readMvpModeState(vaultRoot);
31
+ const g = state?.review_grain;
32
+ return g === 'task' || g === 'epic' || g === 'milestone' ? g : 'task';
33
+ }
34
+
35
+ /** Spec kind from a loaded spec/task, lowercased. */
36
+ function specKind(spec) {
37
+ return String(spec?.frontmatter?.type ?? spec?.type ?? '').toLowerCase();
38
+ }
39
+
40
+ /**
41
+ * Resolve the next review-ready spec.
42
+ *
43
+ * Algorithm (spec §1):
44
+ * 1. If `args.spec_id` provided → fetch that spec's review context directly.
45
+ * 2. Else if `.heyspex/review-focus.yaml` exists and `ignore_focus !== true`
46
+ * → honor that pin.
47
+ * 3. Else walk the roadmap manifest phases and collect specs with
48
+ * `status: in_review` + HS-REV companion.
49
+ * 4. If the manifest yields no candidates, scan `specs/tasks/` and
50
+ * `specs/epics/` directly so off-manifest in_review specs still surface.
51
+ * 5. Sort candidates by `updated:` ascending (oldest first, mirrors
52
+ * HS-RES-CLR4 §J2 aging signal) and return the first match.
53
+ *
54
+ * @param {string} vaultRoot
55
+ * @param {{ defaultRoadmapId?: string }} config
56
+ * @param {{ spec_id?: string, roadmap_id?: string, ignore_focus?: boolean }} args
57
+ * @param {{ findSpecFile: Function, loadSpec: Function, loadRoadmap: Function, loadTask: Function }} deps
58
+ * @returns {{ result?: object, error?: object }}
59
+ */
60
+ export function resolveNextReviewPayload(vaultRoot, config, args = {}, deps) {
61
+ if (!deps || !deps.findSpecFile || !deps.loadSpec || !deps.loadTask) {
62
+ return {
63
+ error: {
64
+ error: 'MISSING_DEPS',
65
+ message:
66
+ 'resolveNextReviewPayload requires deps { findSpecFile, loadSpec, loadRoadmap, loadTask }',
67
+ },
68
+ };
69
+ }
70
+
71
+ const roadmapId = args.roadmap_id || config?.defaultRoadmapId || 'HS-ROADMAP-PRODUCT';
72
+ const ignoreFocus = args.ignore_focus === true || args.ignore_focus === 'true';
73
+
74
+ // 1) Direct lookup
75
+ if (args.spec_id) {
76
+ return buildPayloadForSpec(
77
+ vaultRoot,
78
+ args.spec_id,
79
+ roadmapId,
80
+ 'direct_lookup',
81
+ { phase_id: 'direct', phase_name: 'Direct lookup' },
82
+ deps,
83
+ );
84
+ }
85
+
86
+ // 2) review-focus.yaml pin
87
+ if (!ignoreFocus) {
88
+ const focus = loadReviewFocus(vaultRoot);
89
+ if (focus && focus.spec_id) {
90
+ const result = buildPayloadForSpec(
91
+ vaultRoot,
92
+ focus.spec_id,
93
+ roadmapId,
94
+ 'review_focus',
95
+ { phase_id: 'review_focus', phase_name: 'Pinned review' },
96
+ deps,
97
+ );
98
+ if (result.result) {
99
+ if (focus.reason) result.result.focus_reason = focus.reason;
100
+ if (focus.pinned_at) result.result.pinned_at = focus.pinned_at;
101
+ }
102
+ return result;
103
+ }
104
+ }
105
+
106
+ // Active review grain — only units of this kind are eligible for the queue
107
+ // ([[HS-EPIC-7CWZ]] AC#7).
108
+ const grain = activeReviewGrain(vaultRoot);
109
+
110
+ // 3) Manifest traversal
111
+ let candidates = collectInReviewCandidates(vaultRoot, roadmapId, deps);
112
+
113
+ // 4) Folder-scan fallback
114
+ if (candidates.length === 0) {
115
+ const scanned = scanInReviewFromFolders(vaultRoot, deps, grain);
116
+ for (const item of scanned) candidates.push(item);
117
+ }
118
+
119
+ // Grain-filter: keep only units whose kind IS the active review grain.
120
+ candidates = candidates.filter((c) => specKind(c.spec) === grain);
121
+
122
+ if (candidates.length === 0) {
123
+ return {
124
+ error: {
125
+ error: 'NO_REVIEW_READY',
126
+ message: `No status: in_review specs at the active review grain (${grain}) with the required companion found.`,
127
+ },
128
+ };
129
+ }
130
+
131
+ // 5) Sort by updated: ascending (oldest first)
132
+ candidates.sort((a, b) => {
133
+ const ua = String(a.spec.frontmatter?.updated || '');
134
+ const ub = String(b.spec.frontmatter?.updated || '');
135
+ return ua.localeCompare(ub);
136
+ });
137
+
138
+ const pick = candidates[0];
139
+ return buildPayloadForSpec(
140
+ vaultRoot,
141
+ pick.spec.frontmatter.id || pick.spec.id,
142
+ roadmapId,
143
+ 'review_queue',
144
+ { phase_id: pick.phaseId, phase_name: pick.phaseName },
145
+ deps,
146
+ { reviewDocPath: pick.reviewDocPath, specPath: pick.specPath },
147
+ );
148
+ }
149
+
150
+ function collectInReviewCandidates(vaultRoot, roadmapId, deps) {
151
+ const { loadRoadmap, loadTask } = deps;
152
+ if (!loadRoadmap) return [];
153
+ const roadmap = loadRoadmap(vaultRoot, roadmapId);
154
+ if (!roadmap) return [];
155
+ const manifest = roadmap.frontmatter?.roadmap_manifest;
156
+ if (!manifest || !Array.isArray(manifest.phases)) return [];
157
+
158
+ const candidates = [];
159
+ const seen = new Set();
160
+ for (const phase of manifest.phases) {
161
+ for (const taskId of phase.tasks || []) {
162
+ if (seen.has(taskId)) continue;
163
+ seen.add(taskId);
164
+ const task = loadTask?.(vaultRoot, taskId);
165
+ if (!task || task.status !== STATUS_IN_REVIEW) continue;
166
+ const specId = task.frontmatter?.id || taskId;
167
+ const reviewDocPath = findReviewDocForSpec(vaultRoot, specId);
168
+ if (!reviewDocPath) continue;
169
+ candidates.push({
170
+ spec: task,
171
+ phaseId: phase.id,
172
+ phaseName: phase.name,
173
+ reviewDocPath,
174
+ specPath: task.specPath,
175
+ });
176
+ }
177
+ }
178
+ return candidates;
179
+ }
180
+
181
+ function scanInReviewFromFolders(vaultRoot, deps, grain = 'task') {
182
+ const { loadSpec } = deps;
183
+ // Scan the folder for the active grain. A milestone is the MVP review unit and
184
+ // qualifies on its own (its "review doc" is its own acceptance gate) — `HS-MIL-*`
185
+ // ids aren't covered by extractSpecSuffix either, so don't require an HS-REV
186
+ // companion for it (mirrors the console `resolveNextReview` isMilestone branch).
187
+ const FOLDER_BY_GRAIN = {
188
+ task: ['specs/tasks'],
189
+ epic: ['specs/epics'],
190
+ milestone: ['specs/milestones'],
191
+ };
192
+ const folders = FOLDER_BY_GRAIN[grain] ?? ['specs/tasks'];
193
+ const requireReviewDoc = grain !== 'milestone';
194
+ const candidates = [];
195
+ for (const rel of folders) {
196
+ const full = join(vaultRoot, rel);
197
+ if (!existsSync(full)) continue;
198
+ let entries;
199
+ try {
200
+ entries = readdirSync(full);
201
+ } catch {
202
+ continue;
203
+ }
204
+ for (const file of entries) {
205
+ if (!file.endsWith('.md')) continue;
206
+ const specPath = `${rel}/${file}`;
207
+ const spec = loadSpec(vaultRoot, specPath);
208
+ if (!spec || spec.frontmatter?.status !== STATUS_IN_REVIEW) continue;
209
+ const specId = spec.frontmatter?.id || file.replace(/\.md$/i, '');
210
+ const reviewDocPath = findReviewDocForSpec(vaultRoot, specId);
211
+ if (!reviewDocPath && requireReviewDoc) continue;
212
+ candidates.push({
213
+ spec: { ...spec, id: specId, specPath },
214
+ phaseId: 'unscheduled',
215
+ phaseName: 'Unscheduled (off-manifest in_review)',
216
+ reviewDocPath: reviewDocPath ?? null,
217
+ specPath,
218
+ });
219
+ }
220
+ }
221
+ return candidates;
222
+ }
223
+
224
+ function buildPayloadForSpec(
225
+ vaultRoot,
226
+ specId,
227
+ roadmapId,
228
+ sourceKind,
229
+ phaseInfo,
230
+ deps,
231
+ prefetched = {},
232
+ ) {
233
+ const { findSpecFile, loadSpec } = deps;
234
+ const specPath = prefetched.specPath || findSpecFile?.(vaultRoot, specId);
235
+ if (!specPath) {
236
+ return {
237
+ error: {
238
+ error: 'SPEC_NOT_FOUND',
239
+ message: `Spec ${specId} not found in vault`,
240
+ },
241
+ };
242
+ }
243
+ const spec = loadSpec(vaultRoot, specPath);
244
+ if (!spec) {
245
+ return {
246
+ error: {
247
+ error: 'SPEC_LOAD_FAILED',
248
+ message: `Could not load spec at ${specPath}`,
249
+ },
250
+ };
251
+ }
252
+ if (spec.frontmatter?.status && spec.frontmatter.status !== STATUS_IN_REVIEW) {
253
+ return {
254
+ error: {
255
+ error: 'SPEC_NOT_IN_REVIEW',
256
+ message: `Spec ${specId} has status: ${spec.frontmatter.status}, not in_review.`,
257
+ status: spec.frontmatter.status,
258
+ spec_path: specPath,
259
+ },
260
+ };
261
+ }
262
+ // A milestone is reviewed via its own acceptance gate, not an HS-REV companion
263
+ // ([[HS-EPIC-7CWZ]] milestone grain) — don't require one.
264
+ const isMilestone = specKind(spec) === 'milestone';
265
+ const reviewDocPath =
266
+ prefetched.reviewDocPath || findReviewDocForSpec(vaultRoot, specId) || null;
267
+ if (!reviewDocPath && !isMilestone) {
268
+ return {
269
+ error: {
270
+ error: 'NO_REVIEW_DOC',
271
+ message: `Spec ${specId} has no HS-${extractSpecSuffix(specId)}-REV-*.md companion in specs/reviews/.`,
272
+ spec_path: specPath,
273
+ },
274
+ };
275
+ }
276
+ let reviewDocBody = '';
277
+ if (reviewDocPath) {
278
+ try {
279
+ reviewDocBody = readFileSync(join(vaultRoot, reviewDocPath), 'utf-8');
280
+ } catch {
281
+ /* leave empty — prompt will note the missing body */
282
+ }
283
+ }
284
+
285
+ const inReviewChangelogRow = extractInReviewChangelogRow(spec.body);
286
+ const filesChanged = listFilesChangedSinceFlip(vaultRoot, specPath);
287
+ const ageDays = computeAgeDays(spec.frontmatter?.updated);
288
+ const parentBasename = specPath
289
+ .split(/[\\/]/)
290
+ .pop()
291
+ .replace(/\.md$/i, '');
292
+
293
+ const promptMarkdown = buildHsReviewPromptMarkdown({
294
+ specId,
295
+ title: spec.frontmatter?.name || specId,
296
+ status: spec.frontmatter?.status || STATUS_IN_REVIEW,
297
+ sourceKind,
298
+ parentBasename,
299
+ parentSpecBody: spec.body,
300
+ reviewDocPath,
301
+ reviewDocBody: stripFrontmatter(reviewDocBody),
302
+ inReviewChangelogRow,
303
+ filesChanged,
304
+ phaseId: phaseInfo.phase_id,
305
+ phaseName: phaseInfo.phase_name,
306
+ ageDays,
307
+ parentSpecPath: specPath,
308
+ });
309
+
310
+ const result = {
311
+ spec_id: specId,
312
+ title: spec.frontmatter?.name || specId,
313
+ status: STATUS_IN_REVIEW,
314
+ source_kind: sourceKind,
315
+ phase_id: phaseInfo.phase_id,
316
+ roadmap_id: roadmapId,
317
+ parent_spec_path: specPath,
318
+ review_doc_path: reviewDocPath,
319
+ in_review_changelog_row: inReviewChangelogRow,
320
+ files_changed: filesChanged,
321
+ // user_prompt_markdown is the canonical user-facing field after [[HS-TASK-HSNX]]
322
+ // unified the wire shape across agent-dispatch tools. next_review has no
323
+ // agent-dispatch object today (the reviewer is human; no routing needed),
324
+ // so prompt_markdown still mirrors it for back-compat consumers.
325
+ user_prompt_markdown: promptMarkdown,
326
+ prompt_markdown: promptMarkdown,
327
+ resolved_at: new Date().toISOString(),
328
+ };
329
+ if (phaseInfo.phase_name) result.phase_name = phaseInfo.phase_name;
330
+ if (typeof ageDays === 'number') result.age_in_review_days = ageDays;
331
+
332
+ return { result };
333
+ }
334
+
335
+ /**
336
+ * Parse the parent spec's `## Change log` table and return the row that
337
+ * most likely represents the in_review flip.
338
+ *
339
+ * Heuristic (in order):
340
+ * 1. If any row's change-text mentions `in_review` / `in review` /
341
+ * "review", return the most recent such row.
342
+ * 2. Else return the last row of the table — by convention the most
343
+ * recent change-log entry is appended at the bottom, and flipping to
344
+ * `in_review` happens at session close so it is typically the last
345
+ * author-stamped change.
346
+ *
347
+ * Returns the row verbatim (table pipe-syntax preserved) or null if there
348
+ * is no `## Change log` table or no data rows.
349
+ */
350
+ export function extractInReviewChangelogRow(body) {
351
+ if (!body) return null;
352
+ const m = body.match(/##\s+Change log\s*\n([\s\S]*)$/i);
353
+ if (!m) return null;
354
+ const lines = m[1].split(/\r?\n/);
355
+ const matchPattern = /\bin[_ -]?review\b|status:\s*review/i;
356
+ let explicitMatch = null;
357
+ let lastDataRow = null;
358
+ for (const line of lines) {
359
+ const trimmed = line.trim();
360
+ if (!trimmed.startsWith('|')) continue;
361
+ if (trimmed.includes('---')) continue;
362
+ if (/^\|\s*Date\s*\|/i.test(trimmed)) continue;
363
+ lastDataRow = trimmed;
364
+ if (matchPattern.test(trimmed)) {
365
+ explicitMatch = trimmed;
366
+ }
367
+ }
368
+ return explicitMatch || lastDataRow;
369
+ }
370
+
371
+ /**
372
+ * List files the reviewer is likely to need to inspect.
373
+ *
374
+ * Per CLAUDE.md the agent does NOT commit at session close — they flip the
375
+ * spec to `in_review` and leave the working tree dirty for the reviewer.
376
+ * So the most useful file list is:
377
+ *
378
+ * 1. **Working-tree changes** (`git status --porcelain` — staged + unstaged
379
+ * + untracked) when the tree is dirty — this is the agent's just-finished
380
+ * work, exactly what the reviewer needs to walk.
381
+ * 2. **Last commit's files** (`git show --name-only HEAD`) when the tree is
382
+ * clean — likely the close-out / review commit for an older in_review
383
+ * spec that was already committed.
384
+ *
385
+ * Either source is filtered to `interestingPrefixes` (so node_modules / log
386
+ * noise drops out) and capped at `maxFiles` to avoid blasting the prompt.
387
+ * Returns [] gracefully if git isn't available.
388
+ */
389
+ export function listFilesChangedSinceFlip(vaultRoot, specPath, options = {}) {
390
+ const interestingPrefixes = options.interestingPrefixes || [
391
+ 'spex-console/',
392
+ 'vendor/',
393
+ 'scripts/',
394
+ 'specs/',
395
+ 'companion/',
396
+ 'pm-control-center/',
397
+ '.claude/',
398
+ '.cursor/',
399
+ ];
400
+ const maxFiles = options.maxFiles || 50;
401
+ const filterAndCap = (list) =>
402
+ list
403
+ .filter((p) => interestingPrefixes.some((prefix) => p.startsWith(prefix)))
404
+ .slice(0, maxFiles);
405
+
406
+ try {
407
+ const porcelain = execSync('git status --porcelain', {
408
+ cwd: vaultRoot,
409
+ encoding: 'utf-8',
410
+ stdio: ['ignore', 'pipe', 'ignore'],
411
+ });
412
+ const dirty = porcelain
413
+ .split(/\r?\n/)
414
+ .map((line) => {
415
+ const m = line.match(/^[ MADRCU?!]{1,2}\s+(.+?)(?:\s+->\s+(.+))?$/);
416
+ if (!m) return null;
417
+ return (m[2] || m[1]).replace(/^"|"$/g, '');
418
+ })
419
+ .filter(Boolean);
420
+
421
+ if (dirty.length > 0) {
422
+ return filterAndCap(dirty);
423
+ }
424
+
425
+ // Working tree is clean — fall back to the last commit.
426
+ const showOutput = execSync('git show --name-only --pretty=format: HEAD', {
427
+ cwd: vaultRoot,
428
+ encoding: 'utf-8',
429
+ stdio: ['ignore', 'pipe', 'ignore'],
430
+ });
431
+ const committed = showOutput
432
+ .split(/\r?\n/)
433
+ .map((s) => s.trim())
434
+ .filter(Boolean);
435
+ return filterAndCap(committed);
436
+ } catch {
437
+ return [];
438
+ }
439
+ }
440
+
441
+ function computeAgeDays(updatedIso) {
442
+ if (!updatedIso) return null;
443
+ const t = Date.parse(String(updatedIso));
444
+ if (Number.isNaN(t)) return null;
445
+ return Math.max(0, Math.floor((Date.now() - t) / (1000 * 60 * 60 * 24)));
446
+ }
447
+
448
+ function stripFrontmatter(content) {
449
+ if (!content) return '';
450
+ const m = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
451
+ return (m ? m[1] : content).trim();
452
+ }
453
+
454
+ /**
455
+ * Group a flat file list by top-level folder, producing a bulleted markdown
456
+ * block. Returns `(none detected — review the parent spec body directly)`
457
+ * when the list is empty.
458
+ */
459
+ export function renderFilesChangedBlock(files) {
460
+ if (!Array.isArray(files) || files.length === 0) {
461
+ return '(none detected — review the parent spec body directly)';
462
+ }
463
+ const groups = new Map();
464
+ for (const f of files) {
465
+ const top = f.split('/')[0] || '(root)';
466
+ if (!groups.has(top)) groups.set(top, []);
467
+ groups.get(top).push(f);
468
+ }
469
+ const lines = [];
470
+ for (const [top, paths] of [...groups].sort((a, b) => a[0].localeCompare(b[0]))) {
471
+ lines.push(`- **${top}/** (${paths.length})`);
472
+ for (const p of paths.sort()) {
473
+ lines.push(` - \`${p}\``);
474
+ }
475
+ }
476
+ return lines.join('\n');
477
+ }
478
+
479
+ /**
480
+ * Build the verbatim prompt markdown for `/hs-review`. See the template in
481
+ * [[HS-TASK-RVDC-heyspex-review-on-deck-mcp-and-hs-review]] §1 `prompt_markdown`.
482
+ */
483
+ export function buildHsReviewPromptMarkdown(input) {
484
+ const {
485
+ specId,
486
+ title,
487
+ status,
488
+ sourceKind,
489
+ parentBasename,
490
+ parentSpecBody,
491
+ reviewDocPath,
492
+ reviewDocBody,
493
+ inReviewChangelogRow,
494
+ filesChanged,
495
+ phaseId,
496
+ phaseName,
497
+ ageDays,
498
+ parentSpecPath,
499
+ } = input;
500
+
501
+ const suffix = extractSpecSuffix(specId) || 'UNKNOWN';
502
+ const headerKind = sourceKind === 'review_focus' ? 'review_focus' : 'review_queue';
503
+ const ageStr = typeof ageDays === 'number' ? `${ageDays} day${ageDays === 1 ? '' : 's'}` : 'unknown';
504
+ const phaseStr = phaseName ? `${phaseName} (${phaseId})` : phaseId || 'unknown';
505
+ const flippedLine = inReviewChangelogRow
506
+ ? inReviewChangelogRow
507
+ : '_(no `in_review` row found in the parent spec\'s `## Change log` table)_';
508
+
509
+ return `## ${headerKind} — HS-${suffix}-REV
510
+
511
+ **Parent spec:** [[${parentBasename}]] (status: ${status}, age in review: ${ageStr})
512
+ **Title:** ${title}
513
+ **Phase:** ${phaseStr}
514
+ **Review doc:** \`${reviewDocPath}\`
515
+ **Flipped to in_review:** ${flippedLine}
516
+
517
+ ---
518
+
519
+ # Parent spec body
520
+
521
+ ${parentSpecBody.trim()}
522
+
523
+ ---
524
+
525
+ # Review doc — fill-in workspace
526
+
527
+ ${(reviewDocBody || '').trim() || '_(review doc body could not be read — open the file directly)_'}
528
+
529
+ ---
530
+
531
+ # Files changed since in_review flip
532
+
533
+ ${renderFilesChangedBlock(filesChanged)}
534
+
535
+ ---
536
+
537
+ ## What to do
538
+
539
+ Walk the four sections of the review doc in order:
540
+
541
+ 1. **\`## Acceptance verification\`** — tick boxes as you verify each criterion. Add inline deviation notes for anything that doesn't behave as the parent spec describes.
542
+ 2. **\`## Reviewer notes\`** — read the agent's session observations. These are the raw material for memory extraction.
543
+ 3. **\`## Memory candidates\`** — score each candidate row against the **four-axis rubric**: non-derivability / reusability / surprise-cost / concreteness, 0–3 each. **Threshold ≥ 7/12** to accept.
544
+ - **Conflict check first.** Each candidate row must include a \`Conflict check\` cell — \`New\`, \`Refines [[existing-memory]]\`, or \`Conflict [[existing-memory]]\`. Resolve any \`Conflict\` row by either updating the existing memory or rejecting the new candidate; **never silently materialize a contradictory memory**. Use \`heyspex_recall_memories\` with relevant tags to enumerate existing memories before scoring.
545
+ 4. **\`## Decision: close or reopen\`** — record pass/fail with reasoning. On Pass, the reviewer (human) commits + pushes; the commit IS the close action.
546
+
547
+ _Spec path: \`${parentSpecPath}\`_`;
548
+ }