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.
- package/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- 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
|
+
}
|