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,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `heyspex_verify_reproduction` — record a reproduction re-run on a diagnose, and
|
|
3
|
+
* close it when the run comes back clean.
|
|
4
|
+
*
|
|
5
|
+
* Criterion 7 of [[HS-ALBV-BFIX-EPIC-breakdown-stage-accepts-a-diagnose]], built in
|
|
6
|
+
* [[HS-09W1]]: a bug write-up that has been broken down closes when its REPRODUCTION
|
|
7
|
+
* stops reproducing — not when its last child task closes. Every one of the seven
|
|
8
|
+
* prior console read-path attempts closed clean while the bug stayed live, which is
|
|
9
|
+
* exactly what a child-rollup close rewards.
|
|
10
|
+
*
|
|
11
|
+
* This is also the first route a diagnose has ever had to `closed` at all. A diagnose
|
|
12
|
+
* is standalone — it has no parent in the milestone → epic → task chain — so
|
|
13
|
+
* `cascadeCloseReviewPending` in the console's status engine never reaches it (that
|
|
14
|
+
* cascade only walks milestone → epic and epic → task). Measured 2026-08-22: 211
|
|
15
|
+
* diagnoses parked at `in_review` / `review_pending` with nothing in the system that
|
|
16
|
+
* would ever move them. Recording a clean re-run here is what moves one.
|
|
17
|
+
*
|
|
18
|
+
* The status flip goes through `PATCH /api/specs/:id/status`, never a hand-edited
|
|
19
|
+
* `status:` — the status API owns coercion, rollup, and spec events ([[HS-DIAG-DOSH]]),
|
|
20
|
+
* and the reproduction gate lives inside it, so a clean record is what unlocks the
|
|
21
|
+
* close rather than this module deciding on its own.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
appendChangeLogRow,
|
|
29
|
+
bumpUpdated,
|
|
30
|
+
commitWrites,
|
|
31
|
+
getFrontmatterBlock,
|
|
32
|
+
getScalarField,
|
|
33
|
+
isoNow,
|
|
34
|
+
parseListFieldBasenames,
|
|
35
|
+
setScalarField,
|
|
36
|
+
todayDate,
|
|
37
|
+
} from './promotion-helpers.mjs';
|
|
38
|
+
import { yamlScalar } from './yaml-scalar.mjs';
|
|
39
|
+
|
|
40
|
+
/** The two verdicts a re-run can produce. Authored by whoever ran it, never inferred. */
|
|
41
|
+
export const REPRODUCTION_VERDICTS = ['no_longer_reproduces', 'still_reproduces'];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Statuses from which a clean re-run closes the diagnose.
|
|
45
|
+
*
|
|
46
|
+
* Build-complete only. An `open` / `in_progress` diagnose has not been built yet, and
|
|
47
|
+
* a re-run that happens to pass on it proves the bug was never reproduced in the first
|
|
48
|
+
* place, not that a fix landed — that one still goes through the normal build→review
|
|
49
|
+
* hand-off. The verdict is recorded either way.
|
|
50
|
+
*/
|
|
51
|
+
export const CLOSEABLE_FROM_STATUSES = new Set(['in_review', 'review_pending']);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Evidence floor. A verdict with no account of what was run is a green light wired to
|
|
55
|
+
* nothing — the same failure mode a vacuous checks-manifest row is refused for
|
|
56
|
+
* ([[checks-manifest-authoring]]). 30 characters is short enough for "ran the repro
|
|
57
|
+
* steps in §Reproduction against localhost:3000, clean" and long enough to refuse "ok".
|
|
58
|
+
*/
|
|
59
|
+
export const EVIDENCE_MIN_LENGTH = 30;
|
|
60
|
+
|
|
61
|
+
/** Mirror of the console's `REPRODUCTION_RERUN_FIELDS` (`@/lib/diagnose-spawned`). */
|
|
62
|
+
export const REPRODUCTION_RERUN_FIELDS = {
|
|
63
|
+
at: 'reproduction_rerun_at',
|
|
64
|
+
by: 'reproduction_rerun_by',
|
|
65
|
+
result: 'reproduction_rerun_result',
|
|
66
|
+
evidence: 'reproduction_rerun_evidence',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Mirror of the console's `SPAWNED_WORK_FIELDS`. */
|
|
70
|
+
export const SPAWNED_WORK_FIELDS = ['spawned_epics', 'spawned_tasks'];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a diagnose reference to a file under `specs/diagnoses/`.
|
|
74
|
+
*
|
|
75
|
+
* Resolution-first, like every other vault resolver here: exact basename, then
|
|
76
|
+
* frontmatter `id:` / `file_id:`, then an unambiguous id-prefix match. No id-shape
|
|
77
|
+
* pattern match — an era-3 file key carries no `DIAG` label
|
|
78
|
+
* ([[HS-B1ZT-QCP2-DIAG-heyspex-promote-research-cannot-resolve]]).
|
|
79
|
+
*/
|
|
80
|
+
export function resolveDiagnoseRef(vaultRoot, rawRef) {
|
|
81
|
+
const ref =
|
|
82
|
+
typeof rawRef === 'string'
|
|
83
|
+
? rawRef
|
|
84
|
+
.trim()
|
|
85
|
+
.replace(/^\[\[|\]\]$/g, '')
|
|
86
|
+
.replace(/^['"]|['"]$/g, '')
|
|
87
|
+
.replace(/\.md$/i, '')
|
|
88
|
+
.trim()
|
|
89
|
+
: '';
|
|
90
|
+
if (!ref) return { error: 'INVALID_ARGS', message: 'spec_id is required' };
|
|
91
|
+
|
|
92
|
+
const dir = join(vaultRoot, 'specs', 'diagnoses');
|
|
93
|
+
const notFound = {
|
|
94
|
+
error: 'DIAGNOSE_NOT_FOUND',
|
|
95
|
+
message: `diagnose ${rawRef} not found under specs/diagnoses/ (tried exact basename, frontmatter id:, and id-prefix match)`,
|
|
96
|
+
};
|
|
97
|
+
if (!existsSync(dir)) return notFound;
|
|
98
|
+
|
|
99
|
+
const load = (file) => {
|
|
100
|
+
const absPath = join(dir, file);
|
|
101
|
+
return {
|
|
102
|
+
basename: file.replace(/\.md$/i, ''),
|
|
103
|
+
absPath,
|
|
104
|
+
relPath: join('specs', 'diagnoses', file),
|
|
105
|
+
raw: readFileSync(absPath, 'utf8'),
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (existsSync(join(dir, `${ref}.md`))) return { record: load(`${ref}.md`) };
|
|
110
|
+
|
|
111
|
+
const files = readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.md'));
|
|
112
|
+
|
|
113
|
+
const idHits = [];
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
let raw;
|
|
116
|
+
try {
|
|
117
|
+
raw = readFileSync(join(dir, file), 'utf8');
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const block = getFrontmatterBlock(raw)?.block;
|
|
122
|
+
if (!block) continue;
|
|
123
|
+
if (getScalarField(block, 'id') === ref || getScalarField(block, 'file_id') === ref) {
|
|
124
|
+
idHits.push(file);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (idHits.length === 1) return { record: load(idHits[0]) };
|
|
128
|
+
if (idHits.length > 1) {
|
|
129
|
+
return {
|
|
130
|
+
error: 'DIAGNOSE_ID_AMBIGUOUS',
|
|
131
|
+
message: `spec_id ${rawRef} matches the frontmatter id of ${idHits.length} files under specs/diagnoses/ — pass the full basename instead`,
|
|
132
|
+
candidates: idHits.map((f) => f.replace(/\.md$/i, '')),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const prefixHits = files.filter((f) => f.startsWith(`${ref}-`));
|
|
137
|
+
if (prefixHits.length === 1) return { record: load(prefixHits[0]) };
|
|
138
|
+
if (prefixHits.length > 1) {
|
|
139
|
+
return {
|
|
140
|
+
error: 'DIAGNOSE_ID_AMBIGUOUS',
|
|
141
|
+
message: `spec_id ${rawRef} is a prefix of ${prefixHits.length} files under specs/diagnoses/ — pass the full basename instead`,
|
|
142
|
+
candidates: prefixHits.map((f) => f.replace(/\.md$/i, '')),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return notFound;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Every spec this diagnose spawned — the link fields, never a parent edge. */
|
|
150
|
+
export function spawnedWorkRefs(block) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const field of SPAWNED_WORK_FIELDS) {
|
|
153
|
+
const list = parseListFieldBasenames(block, field);
|
|
154
|
+
if (Array.isArray(list)) out.push(...list);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Stage the frontmatter mutation for one recorded re-run.
|
|
161
|
+
*
|
|
162
|
+
* `setScalarField` REPLACES an existing line, so a second re-run overwrites the first
|
|
163
|
+
* rather than leaving two verdicts in one file for a reader to pick between. The
|
|
164
|
+
* history of re-runs lives in the change log, where it is append-only and dated.
|
|
165
|
+
*/
|
|
166
|
+
export function buildVerifiedContent(raw, { result, evidence, by, now, today }) {
|
|
167
|
+
const fm = getFrontmatterBlock(raw);
|
|
168
|
+
if (!fm) throw new Error('diagnose has no frontmatter — cannot record a reproduction re-run');
|
|
169
|
+
let block = fm.block;
|
|
170
|
+
block = setScalarField(block, REPRODUCTION_RERUN_FIELDS.at, `"${now}"`);
|
|
171
|
+
block = setScalarField(block, REPRODUCTION_RERUN_FIELDS.by, yamlScalar(by));
|
|
172
|
+
block = setScalarField(block, REPRODUCTION_RERUN_FIELDS.result, result);
|
|
173
|
+
block = setScalarField(block, REPRODUCTION_RERUN_FIELDS.evidence, yamlScalar(evidence));
|
|
174
|
+
block = bumpUpdated(block, now);
|
|
175
|
+
|
|
176
|
+
const verdict =
|
|
177
|
+
result === 'no_longer_reproduces'
|
|
178
|
+
? 'Reproduction re-run: NO LONGER REPRODUCES.'
|
|
179
|
+
: 'Reproduction re-run: STILL REPRODUCES.';
|
|
180
|
+
const body = appendChangeLogRow(fm.body, today, by, `${verdict} ${evidence}`);
|
|
181
|
+
return `${fm.open}${block}${fm.close}${body}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {{
|
|
186
|
+
* vaultRoot: string,
|
|
187
|
+
* consoleFetch: Function,
|
|
188
|
+
* args?: Record<string, unknown>,
|
|
189
|
+
* clock?: () => string,
|
|
190
|
+
* agent?: string,
|
|
191
|
+
* }} ctx
|
|
192
|
+
*/
|
|
193
|
+
export async function executeVerifyReproduction({
|
|
194
|
+
vaultRoot,
|
|
195
|
+
consoleFetch,
|
|
196
|
+
args = {},
|
|
197
|
+
clock = isoNow,
|
|
198
|
+
agent = 'agent',
|
|
199
|
+
}) {
|
|
200
|
+
const result = typeof args.result === 'string' ? args.result.trim().toLowerCase() : '';
|
|
201
|
+
if (!REPRODUCTION_VERDICTS.includes(result)) {
|
|
202
|
+
return {
|
|
203
|
+
error: 'INVALID_ARGS',
|
|
204
|
+
message: `result must be one of: ${REPRODUCTION_VERDICTS.join(' | ')}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const evidence = typeof args.evidence === 'string' ? args.evidence.trim() : '';
|
|
208
|
+
if (evidence.length < EVIDENCE_MIN_LENGTH) {
|
|
209
|
+
return {
|
|
210
|
+
error: 'EVIDENCE_REQUIRED',
|
|
211
|
+
message:
|
|
212
|
+
`evidence must be at least ${EVIDENCE_MIN_LENGTH} characters describing what you re-ran ` +
|
|
213
|
+
'and what happened. A verdict with no account of the run is a green light wired to nothing.',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const resolved = resolveDiagnoseRef(vaultRoot, args.spec_id);
|
|
218
|
+
if (resolved.error) return resolved;
|
|
219
|
+
const diagnose = resolved.record;
|
|
220
|
+
|
|
221
|
+
const fm = getFrontmatterBlock(diagnose.raw);
|
|
222
|
+
if (!fm) {
|
|
223
|
+
return {
|
|
224
|
+
error: 'DIAGNOSE_FRONTMATTER_MISSING',
|
|
225
|
+
message: `${diagnose.basename} has no frontmatter — cannot record a reproduction re-run`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const type = (getScalarField(fm.block, 'type') ?? '').trim().toLowerCase();
|
|
229
|
+
if (type && type !== 'diagnose') {
|
|
230
|
+
return {
|
|
231
|
+
error: 'NOT_A_DIAGNOSE',
|
|
232
|
+
message: `${diagnose.basename} is \`type: ${type}\` — a reproduction re-run is recorded on a diagnose`,
|
|
233
|
+
resolved_type: type,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const specId = getScalarField(fm.block, 'id') ?? diagnose.basename;
|
|
238
|
+
const statusBefore = (getScalarField(fm.block, 'status') ?? 'open').trim().toLowerCase();
|
|
239
|
+
const spawned = spawnedWorkRefs(fm.block);
|
|
240
|
+
|
|
241
|
+
const now = clock();
|
|
242
|
+
const today = todayDate();
|
|
243
|
+
const by = typeof args.by === 'string' && args.by.trim() ? args.by.trim() : agent;
|
|
244
|
+
|
|
245
|
+
const content = buildVerifiedContent(diagnose.raw, { result, evidence, by, now, today });
|
|
246
|
+
const commit = commitWrites([{ kind: 'update', absPath: diagnose.absPath, content }]);
|
|
247
|
+
if (!commit.ok) {
|
|
248
|
+
return {
|
|
249
|
+
error: 'WRITE_FAILED',
|
|
250
|
+
message: commit.error?.message ?? 'unknown write failure',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const recorded = {
|
|
255
|
+
spec_id: specId,
|
|
256
|
+
basename: diagnose.basename,
|
|
257
|
+
path: diagnose.relPath,
|
|
258
|
+
result,
|
|
259
|
+
evidence,
|
|
260
|
+
by,
|
|
261
|
+
at: now,
|
|
262
|
+
spawned_work: spawned,
|
|
263
|
+
status_before: statusBefore,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// A run that still reproduces records and closes nothing — the diagnose is left
|
|
267
|
+
// exactly as it was, and its Resolution row stays open.
|
|
268
|
+
if (result !== 'no_longer_reproduces') {
|
|
269
|
+
return {
|
|
270
|
+
...recorded,
|
|
271
|
+
closed: false,
|
|
272
|
+
close_skipped: 'still_reproduces',
|
|
273
|
+
message:
|
|
274
|
+
'Verdict recorded. The bug still reproduces, so nothing was closed — the write-up stays open.',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!CLOSEABLE_FROM_STATUSES.has(statusBefore)) {
|
|
279
|
+
return {
|
|
280
|
+
...recorded,
|
|
281
|
+
closed: false,
|
|
282
|
+
close_skipped: 'not_build_complete',
|
|
283
|
+
message:
|
|
284
|
+
`Verdict recorded. \`${specId}\` is \`status: ${statusBefore}\`, not build-complete, so it was ` +
|
|
285
|
+
'not closed — hand it off with `heyspex_submit_for_review` first, then the recorded clean run closes it.',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const res = await consoleFetch(`/api/specs/${encodeURIComponent(specId)}/status`, {
|
|
290
|
+
method: 'PATCH',
|
|
291
|
+
// `close_reason`, snake_case — the route's body contract. A camelCase key is
|
|
292
|
+
// silently ignored, so the close would land with no reason recorded.
|
|
293
|
+
body: { status: 'closed', close_reason: `Reproduction re-run ${now}: no longer reproduces.` },
|
|
294
|
+
});
|
|
295
|
+
if (!res?.ok) {
|
|
296
|
+
return {
|
|
297
|
+
...recorded,
|
|
298
|
+
closed: false,
|
|
299
|
+
close_skipped: 'status_api_refused',
|
|
300
|
+
status: res?.status,
|
|
301
|
+
console_response: res?.body,
|
|
302
|
+
message:
|
|
303
|
+
'Verdict recorded on disk, but the status API did not close the spec. The record stands — ' +
|
|
304
|
+
're-run the close once the reason in `console_response` is cleared.',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
...recorded,
|
|
310
|
+
closed: true,
|
|
311
|
+
status_after: res.body?.spec?.status ?? 'closed',
|
|
312
|
+
message: `Reproduction no longer reproduces — \`${specId}\` closed via the status API.`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Why now" one-line legibility builder — vendor mirror of
|
|
3
|
+
* spex-console/src/lib/scheduler/why-now.ts ([[HS-TASK-WYNW]] / [[HS-RES-7J97]]).
|
|
4
|
+
* Keep in lockstep with the console module (heart/star always appear when flagged).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const HEART_REASON_SENTINEL = 'user marked very important (heart)';
|
|
8
|
+
export const STAR_REASON_SENTINEL = 'user marked priority (star)';
|
|
9
|
+
export const TESTING_CLUSTER_REASON_SENTINEL = 'grouped with adjacent testing-flagged specs';
|
|
10
|
+
|
|
11
|
+
const MAX_CLAUSES = 3;
|
|
12
|
+
const SMALL_EFFORT_MAX = 2;
|
|
13
|
+
const HIGH_CRITICALITY_MIN = 3;
|
|
14
|
+
/** Mirrors console AGING_THRESHOLD_DAYS. */
|
|
15
|
+
export const AGING_THRESHOLD_DAYS = 14;
|
|
16
|
+
|
|
17
|
+
function hasReason(trace, sentinel) {
|
|
18
|
+
return Array.isArray(trace?.reasons) && trace.reasons.includes(sentinel);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function collectStageClauses(trace, ctx) {
|
|
22
|
+
const out = [];
|
|
23
|
+
if (trace.topologicalLayer === 0) out.push('no open blockers');
|
|
24
|
+
if (trace.tier === 'infra') out.push('infra work first');
|
|
25
|
+
else if (trace.tier === 'foundation') out.push('foundation work first');
|
|
26
|
+
if (trace.criticality >= HIGH_CRITICALITY_MIN) out.push('blocks the most');
|
|
27
|
+
else if (trace.criticality > 0) out.push(`blocking ${trace.criticality} downstream`);
|
|
28
|
+
if (trace.agedDays >= AGING_THRESHOLD_DAYS) out.push(`waiting ${trace.agedDays}d`);
|
|
29
|
+
if (trace.effort <= SMALL_EFFORT_MAX) out.push('smallest effort');
|
|
30
|
+
if (hasReason(trace, TESTING_CLUSTER_REASON_SENTINEL)) out.push('groups with other testing work');
|
|
31
|
+
if (ctx.hysteresisHeld) out.push('kept its slot (anti-churn)');
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} trace ExplanationTrace from buildExplanation / computeOrder
|
|
37
|
+
* @param {{ pinned?: boolean, pinnedBehindPrereqsCount?: number, hysteresisHeld?: boolean }} [ctx]
|
|
38
|
+
*/
|
|
39
|
+
export function buildWhyNow(trace, ctx = {}) {
|
|
40
|
+
const clauses = [];
|
|
41
|
+
if (ctx.pinned) {
|
|
42
|
+
clauses.push('pinned by you');
|
|
43
|
+
const behind = ctx.pinnedBehindPrereqsCount ?? 0;
|
|
44
|
+
if (behind > 0) clauses.push(`behind ${behind} open prereq${behind === 1 ? '' : 's'}`);
|
|
45
|
+
}
|
|
46
|
+
const wmClause = hasReason(trace, HEART_REASON_SENTINEL)
|
|
47
|
+
? "you ❤️'d it"
|
|
48
|
+
: hasReason(trace, STAR_REASON_SENTINEL)
|
|
49
|
+
? "you ⭐'d it"
|
|
50
|
+
: null;
|
|
51
|
+
if (wmClause) clauses.push(wmClause);
|
|
52
|
+
|
|
53
|
+
const inProgress = trace.status === 'in_progress';
|
|
54
|
+
const footer = inProgress ? 'already in progress' : null;
|
|
55
|
+
const stageClauses = collectStageClauses(trace, ctx);
|
|
56
|
+
const reserveForFooter = footer ? 1 : 0;
|
|
57
|
+
const slotsForStages = Math.max(0, MAX_CLAUSES - clauses.length - reserveForFooter);
|
|
58
|
+
for (let i = 0; i < stageClauses.length && i < slotsForStages; i++) {
|
|
59
|
+
clauses.push(stageClauses[i]);
|
|
60
|
+
}
|
|
61
|
+
if (footer) clauses.push(footer);
|
|
62
|
+
if (clauses.length === 0) return 'default deterministic order';
|
|
63
|
+
return clauses.join(' · ');
|
|
64
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free YAML scalar quoter for the MCP frontmatter writers.
|
|
3
|
+
*
|
|
4
|
+
* The MCP runtime deliberately avoids loading js-yaml (startup cost — see the
|
|
5
|
+
* note in plan-push.mjs and HS-REV-006B), so hand-built frontmatter needs a
|
|
6
|
+
* correct, library-free quoting rule. The prior per-writer rule only quoted a
|
|
7
|
+
* value if it *contained* `:` or `"`, which let values that START with a
|
|
8
|
+
* backtick or a double-quote (and other YAML indicators) through unquoted —
|
|
9
|
+
* the exact corruption that silently dropped specs from the index.
|
|
10
|
+
*
|
|
11
|
+
* Strategy: emit the bare string only when it matches a conservative
|
|
12
|
+
* "plainly safe" pattern; otherwise emit `JSON.stringify(...)`. A JSON
|
|
13
|
+
* double-quoted string is always a valid YAML double-quoted scalar, so this is
|
|
14
|
+
* round-trip safe for any input. This is the seed of the single canonical
|
|
15
|
+
* serializer recommended by [[HS-RES-COOA-deterministic-frontmatter-integrity]].
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Bare-safe iff: starts with an alphanumeric and contains only word chars,
|
|
20
|
+
* spaces, dots, slashes, or hyphens. That charset excludes every YAML
|
|
21
|
+
* indicator (`: # ` " ' & * ! | > % @ , [ ] { }` and leading `-`/space), so
|
|
22
|
+
* the value cannot be misread as structure. Everything else is JSON-quoted.
|
|
23
|
+
*/
|
|
24
|
+
const BARE_SAFE = /^[A-Za-z0-9][\w ./-]*$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {unknown} value
|
|
28
|
+
* @returns {string} a YAML-safe scalar representation of `value`.
|
|
29
|
+
*/
|
|
30
|
+
export function yamlScalar(value) {
|
|
31
|
+
const s = String(value);
|
|
32
|
+
if (BARE_SAFE.test(s)) return s;
|
|
33
|
+
return JSON.stringify(s);
|
|
34
|
+
}
|