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,1347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-execution alignment gate — pure logic backing the hierarchy-aware
|
|
3
|
+
* breakdown + corpus-drift surface that runs as the FIRST pre-flight on
|
|
4
|
+
* /hs-next, upstream of conflict resolution ([[pre-execution-alignment-gate]]).
|
|
5
|
+
*
|
|
6
|
+
* The gate exists because **AI agents are good at executing the wrong thing
|
|
7
|
+
* perfectly** ([[heyspex-north-star]]). Two surfaces:
|
|
8
|
+
*
|
|
9
|
+
* - **No children → HARD breakdown gate.** Decomposition brief replaces the
|
|
10
|
+
* task result. The agent must file children before executing.
|
|
11
|
+
* - **Children + corpus drift → SOFT drift questions.** Prepended to the
|
|
12
|
+
* task brief; the agent poses keep/update/drop questions to the user per
|
|
13
|
+
* affected child, then calls `heyspex_record_alignment` to reset the
|
|
14
|
+
* review timestamp. Never blocks execution.
|
|
15
|
+
*
|
|
16
|
+
* "Corpus drift" = the epic's reference corpus (`related:` + `depends_on:` +
|
|
17
|
+
* `milestone:`) has changed (file mtime / `updated:` frontmatter newer than
|
|
18
|
+
* `alignment_reviewed_at`). AGV2 redefined drift away from internal completeness
|
|
19
|
+
* (the v1 detectTaskDrift heuristics produced false positives) toward temporal
|
|
20
|
+
* staleness of *linked plans*. AGV2/DSIG removed signature hashing entirely —
|
|
21
|
+
* it was non-deterministic and produced false signature_changed re-fires.
|
|
22
|
+
*
|
|
23
|
+
* Pure-ish: every vault read is injected via `loadRef(idOrBasename) => spec|null`
|
|
24
|
+
* (same contract as blocker-resolver's `loadTask`); mtime reads via injected
|
|
25
|
+
* `lastModified(idOrBasename) => ms|null`. The module stays hermetic / unit-
|
|
26
|
+
* testable with synthetic loaders.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { execFileSync } from 'node:child_process';
|
|
30
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
31
|
+
import { wikilinkTarget } from './blocker-resolver.mjs';
|
|
32
|
+
import { spexConsoleSettingsCandidates } from './vault-settings.mjs';
|
|
33
|
+
|
|
34
|
+
export const ALIGNMENT_FUSE_DAYS = 7;
|
|
35
|
+
export const DEFAULT_FOCUS_PIN_EXPIRATION_DAYS = 7;
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
// Git-time helpers — SSOT for "when did this spec enter the vault?" The
|
|
39
|
+
// commit timestamp (`%ct`, seconds-since-epoch in committer's local timezone)
|
|
40
|
+
// is hand-edit-immune, unlike frontmatter `updated:` which agents and humans
|
|
41
|
+
// can spoof. Used by computeWorldStateDelta + canSkipAlignment.
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function runGit(vaultRoot, args) {
|
|
45
|
+
try {
|
|
46
|
+
return execFileSync('git', args, {
|
|
47
|
+
cwd: vaultRoot,
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
50
|
+
});
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Unix-ms when `path` was first committed under `vaultRoot`. Returns `null`
|
|
58
|
+
* when git has no history for it (e.g. untracked or never-committed file).
|
|
59
|
+
*/
|
|
60
|
+
export function gitFirstCommitTime(vaultRoot, path) {
|
|
61
|
+
if (!vaultRoot || !path) return null;
|
|
62
|
+
const out = runGit(vaultRoot, [
|
|
63
|
+
'log',
|
|
64
|
+
'--diff-filter=A',
|
|
65
|
+
'--follow',
|
|
66
|
+
'--format=%ct',
|
|
67
|
+
'--reverse',
|
|
68
|
+
'--',
|
|
69
|
+
path,
|
|
70
|
+
]);
|
|
71
|
+
const first = out.split('\n').map((l) => l.trim()).filter(Boolean)[0];
|
|
72
|
+
if (!first) return null;
|
|
73
|
+
const secs = Number(first);
|
|
74
|
+
return Number.isFinite(secs) ? secs * 1000 : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Unix-ms of the most recent commit touching `path`. `null` when untracked. */
|
|
78
|
+
export function gitLastCommitTime(vaultRoot, path) {
|
|
79
|
+
if (!vaultRoot || !path) return null;
|
|
80
|
+
const out = runGit(vaultRoot, ['log', '-1', '--format=%ct', '--', path]);
|
|
81
|
+
const last = out.trim();
|
|
82
|
+
if (!last) return null;
|
|
83
|
+
const secs = Number(last);
|
|
84
|
+
return Number.isFinite(secs) ? secs * 1000 : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sorted unique list of `specs/` paths first committed after `sinceTs` (ms).
|
|
89
|
+
* Backs `computeWorldStateDelta` and the `new_specs_since_breakdown` skip
|
|
90
|
+
* reason. `since` of `0` or NaN returns `[]` (no baseline → no delta).
|
|
91
|
+
*/
|
|
92
|
+
export function gitFilesAddedSince(vaultRoot, sinceTs) {
|
|
93
|
+
if (!vaultRoot) return [];
|
|
94
|
+
if (!Number.isFinite(sinceTs) || sinceTs <= 0) return [];
|
|
95
|
+
const sinceSecs = Math.floor(sinceTs / 1000);
|
|
96
|
+
const out = runGit(vaultRoot, [
|
|
97
|
+
'log',
|
|
98
|
+
`--since=${sinceSecs}`,
|
|
99
|
+
'--diff-filter=A',
|
|
100
|
+
'--name-only',
|
|
101
|
+
'--pretty=format:',
|
|
102
|
+
'--',
|
|
103
|
+
'specs/',
|
|
104
|
+
]);
|
|
105
|
+
return [
|
|
106
|
+
...new Set(out.split('\n').map((l) => l.trim()).filter(Boolean)),
|
|
107
|
+
].sort();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// djb2 — deterministic, dependency-free hash. AGV2/DSIG retired the old
|
|
112
|
+
// signature scheme because it hashed mutable frontmatter; this one hashes
|
|
113
|
+
// only stable inputs (sorted child IDs + first-commit timestamps + git-derived
|
|
114
|
+
// world-state hash), so it can be safely persisted and compared.
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export function djb2(str) {
|
|
118
|
+
const s = String(str);
|
|
119
|
+
let h = 5381;
|
|
120
|
+
for (let i = 0; i < s.length; i++) {
|
|
121
|
+
h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
122
|
+
}
|
|
123
|
+
return (h >>> 0).toString(16);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `{ addedPaths, hash }` snapshot of new spec files added since `sinceTs` (ms).
|
|
128
|
+
* Hash is stable for the same input set and changes when a new spec is added
|
|
129
|
+
* (AC #5 for T1). When `sinceTs` is missing / invalid, returns the empty
|
|
130
|
+
* baseline so callers can treat it as "no delta detected yet."
|
|
131
|
+
*/
|
|
132
|
+
export function computeWorldStateDelta(vaultRoot, sinceTs) {
|
|
133
|
+
const addedPaths = gitFilesAddedSince(vaultRoot, sinceTs);
|
|
134
|
+
return { addedPaths, hash: djb2(addedPaths.join('|')) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Three-component alignment signature. `epicUpdated` is the parent epic's
|
|
139
|
+
* frontmatter `updated:` (string) — its inclusion catches deliberate scope
|
|
140
|
+
* edits even when no child IDs moved. `worldStateHash` ties the signature to
|
|
141
|
+
* the git-derived added-files snapshot.
|
|
142
|
+
*/
|
|
143
|
+
export function computeAlignmentSignature({ childIds, epicUpdated, worldStateHash }) {
|
|
144
|
+
const sortedChildren = (Array.isArray(childIds) ? [...childIds] : [])
|
|
145
|
+
.map((c) => String(c).trim())
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.sort()
|
|
148
|
+
.join('|');
|
|
149
|
+
return djb2(`${sortedChildren}|${epicUpdated || ''}|${worldStateHash || ''}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
153
|
+
// Settings-driven fuse — read spex-console/settings.json (or .heyspex/ mirror) so
|
|
154
|
+
// disabling `focusPinExpirationEnabled` in the UI also disables the alignment
|
|
155
|
+
// fuse. Returns `{ enabled, days }`; when `enabled === false`, callers must
|
|
156
|
+
// drop fuse_expired from the skip-reason ladder.
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
function readJsonSafe(path) {
|
|
160
|
+
try {
|
|
161
|
+
if (!existsSync(path)) return null;
|
|
162
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function readAlignmentFuseSettings(vaultRoot) {
|
|
169
|
+
const fallback = { enabled: true, days: DEFAULT_FOCUS_PIN_EXPIRATION_DAYS };
|
|
170
|
+
for (const p of spexConsoleSettingsCandidates(vaultRoot)) {
|
|
171
|
+
const raw = readJsonSafe(p);
|
|
172
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
173
|
+
const enabled = raw.focusPinExpirationEnabled !== false; // default true
|
|
174
|
+
const daysRaw = raw.focusPinExpirationDays;
|
|
175
|
+
const days =
|
|
176
|
+
typeof daysRaw === 'number' && Number.isFinite(daysRaw) && daysRaw > 0
|
|
177
|
+
? daysRaw
|
|
178
|
+
: DEFAULT_FOCUS_PIN_EXPIRATION_DAYS;
|
|
179
|
+
return { enabled, days };
|
|
180
|
+
}
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
185
|
+
// canSkipAlignment — binary skip rule layered over evaluateCheckpoint. Returns
|
|
186
|
+
// `{ skip: true, reason: "binary_skip_rule" }` only when ALL components agree
|
|
187
|
+
// (no signature drift, no children predating the parent epic, no specs added
|
|
188
|
+
// since the breakdown, fuse not blown). Otherwise the first failing reason.
|
|
189
|
+
//
|
|
190
|
+
// Reasons: 'signature_drift' | 'older_children' | 'new_specs_since_breakdown'
|
|
191
|
+
// | 'fuse_expired' | 'no_checkpoint' | 'binary_skip_rule'
|
|
192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
export function canSkipAlignment(epic, vaultRoot, options = {}) {
|
|
195
|
+
const fm = epic?.frontmatter || {};
|
|
196
|
+
const reviewedAt = fm.alignment_reviewed_at;
|
|
197
|
+
const reviewedMs = reviewedAt ? Date.parse(String(reviewedAt)) : NaN;
|
|
198
|
+
if (!Number.isFinite(reviewedMs)) {
|
|
199
|
+
return { skip: false, reason: 'no_checkpoint' };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const fuse = options.fuse ?? readAlignmentFuseSettings(vaultRoot);
|
|
203
|
+
const now = typeof options.now === 'number' ? options.now : Date.now();
|
|
204
|
+
const fuseDays = options.fuseDays || fuse.days || ALIGNMENT_FUSE_DAYS;
|
|
205
|
+
if (fuse.enabled !== false && now - reviewedMs > fuseDays * 86_400_000) {
|
|
206
|
+
return { skip: false, reason: 'fuse_expired' };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const grain = options.grain || 'epic';
|
|
210
|
+
const childIds = getChildRefs(fm, grain);
|
|
211
|
+
|
|
212
|
+
// older_children: any child first-committed before the parent epic. Catches
|
|
213
|
+
// retroactive structuring (parent filed over pre-existing tasks) — the
|
|
214
|
+
// breakdown decision wasn't taken at the time the children were created, so
|
|
215
|
+
// alignment should be reviewed. Requires `childResolver(id) -> path` and
|
|
216
|
+
// `epicPath` to walk git history; without them this check is silent.
|
|
217
|
+
const epicPath = options.epicPath;
|
|
218
|
+
const childResolver = typeof options.childResolver === 'function' ? options.childResolver : null;
|
|
219
|
+
if (epicPath && childResolver) {
|
|
220
|
+
const epicFirst = gitFirstCommitTime(vaultRoot, epicPath);
|
|
221
|
+
if (Number.isFinite(epicFirst)) {
|
|
222
|
+
for (const cid of childIds) {
|
|
223
|
+
const childPath = childResolver(cid);
|
|
224
|
+
if (!childPath) continue;
|
|
225
|
+
const childFirst = gitFirstCommitTime(vaultRoot, childPath);
|
|
226
|
+
if (Number.isFinite(childFirst) && childFirst < epicFirst) {
|
|
227
|
+
return { skip: false, reason: 'older_children', child_id: cid };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// new_specs_since_breakdown — git-truth, immune to frontmatter spoofing.
|
|
234
|
+
const worldStateDelta =
|
|
235
|
+
options.worldStateDelta ?? computeWorldStateDelta(vaultRoot, reviewedMs);
|
|
236
|
+
if (worldStateDelta.addedPaths.length > 0) {
|
|
237
|
+
return {
|
|
238
|
+
skip: false,
|
|
239
|
+
reason: 'new_specs_since_breakdown',
|
|
240
|
+
world_state_delta: worldStateDelta,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// signature_drift — only fires when the epic stored a signature on its last
|
|
245
|
+
// alignment record AND it disagrees with the current inputs. A fresh epic
|
|
246
|
+
// with no `alignment_signature:` skips this check.
|
|
247
|
+
const currentSignature = computeAlignmentSignature({
|
|
248
|
+
childIds,
|
|
249
|
+
epicUpdated: fm.updated,
|
|
250
|
+
worldStateHash: worldStateDelta.hash,
|
|
251
|
+
});
|
|
252
|
+
if (fm.alignment_signature && fm.alignment_signature !== currentSignature) {
|
|
253
|
+
return {
|
|
254
|
+
skip: false,
|
|
255
|
+
reason: 'signature_drift',
|
|
256
|
+
previous: fm.alignment_signature,
|
|
257
|
+
current: currentSignature,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { skip: true, reason: 'binary_skip_rule', signature: currentSignature };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Epic children live in `children:`; milestone children live in `epics:`. */
|
|
265
|
+
export function childRefsField(grain) {
|
|
266
|
+
return grain === 'milestone' ? 'epics' : 'children';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The child entity kind for a given parent grain. */
|
|
270
|
+
export function childKindFor(grain) {
|
|
271
|
+
return grain === 'milestone' ? 'epic' : 'task';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Pull the raw child-ref array (children: for epics, epics: for milestones). */
|
|
275
|
+
export function getChildRefs(frontmatter, grain) {
|
|
276
|
+
const raw = frontmatter?.[childRefsField(grain)];
|
|
277
|
+
if (!Array.isArray(raw)) return [];
|
|
278
|
+
return raw.map(wikilinkTarget).filter(Boolean);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
282
|
+
// Markdown section helpers (deterministic — no LLM, no network)
|
|
283
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Extract the body text under a `## Heading` (level-2) up to the next level-2
|
|
287
|
+
* heading. `headingRe` matches the heading text (case-insensitive). Returns ''
|
|
288
|
+
* when not found.
|
|
289
|
+
*/
|
|
290
|
+
export function extractSection(body, headingRe) {
|
|
291
|
+
if (typeof body !== 'string') return '';
|
|
292
|
+
const lines = body.split(/\r?\n/);
|
|
293
|
+
let capturing = false;
|
|
294
|
+
const out = [];
|
|
295
|
+
for (const line of lines) {
|
|
296
|
+
const h2 = line.match(/^##\s+(.+?)\s*$/);
|
|
297
|
+
if (h2 && !line.startsWith('###')) {
|
|
298
|
+
if (capturing) break; // next level-2 heading ends the section
|
|
299
|
+
if (headingRe.test(h2[1])) {
|
|
300
|
+
capturing = true;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (capturing) out.push(line);
|
|
305
|
+
}
|
|
306
|
+
return out.join('\n').trim();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Bullet lines (`-`/`*`, checkbox, or numbered `1.` / `1)`) → trimmed text array. */
|
|
310
|
+
export function extractBullets(text) {
|
|
311
|
+
if (typeof text !== 'string') return [];
|
|
312
|
+
const out = [];
|
|
313
|
+
for (const line of text.split(/\r?\n/)) {
|
|
314
|
+
const m = line.match(/^\s*(?:[-*]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)(.+?)\s*$/);
|
|
315
|
+
if (m) out.push(m[1].trim());
|
|
316
|
+
}
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Parse the `## Acceptance criteria` block into decomposition seeds. When the
|
|
322
|
+
* block has `### subheadings` each becomes one seed (the natural one-task-per-
|
|
323
|
+
* section convention used across the vault, e.g. HS-EPIC-MILE → Entity type /
|
|
324
|
+
* Validator + MCP / All Docs filter / Specs filter / Bootstrap consistency).
|
|
325
|
+
* When the block is a flat checklist, returns a single seed carrying the
|
|
326
|
+
* bullets so the agent can split them.
|
|
327
|
+
*
|
|
328
|
+
* Returns `{ sections: [{ title, bullets }], hasSubsections }`.
|
|
329
|
+
*/
|
|
330
|
+
export function parseAcceptanceSubsections(body) {
|
|
331
|
+
// Match both the canonical `## Acceptance criteria` and the bare `## Acceptance`
|
|
332
|
+
// heading variant (~48 vault specs use the latter — and the console parser
|
|
333
|
+
// already accepts it, so anchoring exactly here broke cross-surface parity and
|
|
334
|
+
// wrongly routed those specs to the Tier-3 clarification floor). [[HS-TASK-I2Y8]]
|
|
335
|
+
const section = extractSection(body, /^acceptance(?:\s+criteria)?$/i);
|
|
336
|
+
if (!section) return { sections: [], hasSubsections: false };
|
|
337
|
+
|
|
338
|
+
const lines = section.split(/\r?\n/);
|
|
339
|
+
const subs = [];
|
|
340
|
+
let current = null;
|
|
341
|
+
for (const line of lines) {
|
|
342
|
+
const h3 = line.match(/^###\s+(.+?)\s*$/);
|
|
343
|
+
if (h3) {
|
|
344
|
+
current = { title: h3[1].trim(), bullets: [] };
|
|
345
|
+
subs.push(current);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (current) {
|
|
349
|
+
const b = line.match(/^\s*(?:[-*]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)(.+?)\s*$/);
|
|
350
|
+
if (b) current.bullets.push(b[1].trim());
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (subs.length > 0) {
|
|
355
|
+
return { sections: subs, hasSubsections: true };
|
|
356
|
+
}
|
|
357
|
+
// Flat checklist — one implicit section carrying all bullets.
|
|
358
|
+
const bullets = extractBullets(section);
|
|
359
|
+
if (bullets.length === 0) return { sections: [], hasSubsections: false };
|
|
360
|
+
return {
|
|
361
|
+
sections: [{ title: 'Acceptance criteria', bullets }],
|
|
362
|
+
hasSubsections: false,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* First non-empty paragraph stating the spec's intent: `## Goal`, else
|
|
368
|
+
* `## Problem`, else `## Scope`. Scope is included because it is this vault's
|
|
369
|
+
* dominant task/epic-template section (most specs carry `## Scope`, not
|
|
370
|
+
* `## Goal`), and the readiness floor's "non-trivial goal/scope" check
|
|
371
|
+
* ([[HS-TASK-RW3B]]) depends on it.
|
|
372
|
+
*/
|
|
373
|
+
export function extractGoal(body) {
|
|
374
|
+
const goal =
|
|
375
|
+
extractSection(body, /^goal$/i) ||
|
|
376
|
+
extractSection(body, /^problem$/i) ||
|
|
377
|
+
extractSection(body, /^scope$/i);
|
|
378
|
+
if (!goal) return '';
|
|
379
|
+
const para = goal.split(/\n\s*\n/)[0] || goal;
|
|
380
|
+
return para.replace(/\s+/g, ' ').trim();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Pull the `## Expected Breakage & Consequences` section out of a spec body
|
|
385
|
+
* verbatim ([[spec-hierarchy]] §Expected Breakage & Consequences).
|
|
386
|
+
*/
|
|
387
|
+
function extractBreakageSection(body) {
|
|
388
|
+
if (!body) return null;
|
|
389
|
+
const lines = body.split('\n');
|
|
390
|
+
let start = -1;
|
|
391
|
+
for (let i = 0; i < lines.length; i++) {
|
|
392
|
+
if (/^##\s+Expected Breakage & Consequences\s*$/i.test(lines[i].trim())) {
|
|
393
|
+
start = i;
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (start === -1) return null;
|
|
398
|
+
let end = lines.length;
|
|
399
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
400
|
+
if (/^#{1,2}\s+/.test(lines[i])) {
|
|
401
|
+
end = i;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const section = lines.slice(start, end).join('\n').trim();
|
|
406
|
+
return section.length > 0 ? section : null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
410
|
+
// Hierarchy-aware decomposition context
|
|
411
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
function shapeRef(loadRef, ref) {
|
|
414
|
+
const target = wikilinkTarget(ref);
|
|
415
|
+
if (!target) return null;
|
|
416
|
+
const spec = loadRef(target);
|
|
417
|
+
if (!spec) return { ref: target, resolved: false };
|
|
418
|
+
const fm = spec.frontmatter || {};
|
|
419
|
+
return {
|
|
420
|
+
ref: target,
|
|
421
|
+
resolved: true,
|
|
422
|
+
id: spec.id || fm.id || target,
|
|
423
|
+
name: spec.name || fm.name || target,
|
|
424
|
+
type: spec.type || fm.type || 'spec',
|
|
425
|
+
status: spec.status || fm.status || 'unknown',
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Gather the deterministic context an agent needs to break down (or re-align)
|
|
431
|
+
* a node WITHOUT open-ended search. Resolves the milestone parent + sibling
|
|
432
|
+
* epics so the agent gets the whole chain handed to it.
|
|
433
|
+
*
|
|
434
|
+
* `node` is `{ id, name, frontmatter, body }`. `grain` is 'epic' | 'milestone'.
|
|
435
|
+
*/
|
|
436
|
+
export function extractDecompositionContext(node, loadRef, grain = 'epic') {
|
|
437
|
+
const fm = node.frontmatter || {};
|
|
438
|
+
const body = node.body || '';
|
|
439
|
+
|
|
440
|
+
const acceptance = parseAcceptanceSubsections(body);
|
|
441
|
+
const outOfScope = extractBullets(extractSection(body, /^out of scope/i));
|
|
442
|
+
|
|
443
|
+
const depBullets = extractBullets(extractSection(body, /^dependencies$/i));
|
|
444
|
+
const fmDeps = []
|
|
445
|
+
.concat(Array.isArray(fm.depends_on) ? fm.depends_on : [])
|
|
446
|
+
.concat(Array.isArray(fm.blocked_by) ? fm.blocked_by : [])
|
|
447
|
+
.map(wikilinkTarget)
|
|
448
|
+
.filter(Boolean);
|
|
449
|
+
|
|
450
|
+
const related = (Array.isArray(fm.related) ? fm.related : [])
|
|
451
|
+
.map((r) => shapeRef(loadRef, r))
|
|
452
|
+
.filter(Boolean);
|
|
453
|
+
|
|
454
|
+
// Milestone chain. For an epic, walk up via `milestone:`. For a milestone
|
|
455
|
+
// node itself, there's no parent — siblings are its own epics.
|
|
456
|
+
let milestone = null;
|
|
457
|
+
let milestoneBreakage = null;
|
|
458
|
+
if (grain === 'epic' && fm.milestone) {
|
|
459
|
+
const m = shapeRef(loadRef, fm.milestone);
|
|
460
|
+
if (m && m.resolved) {
|
|
461
|
+
const mSpec = loadRef(m.ref);
|
|
462
|
+
const mFm = mSpec?.frontmatter || {};
|
|
463
|
+
if (mSpec?.body) milestoneBreakage = extractBreakageSection(mSpec.body);
|
|
464
|
+
const selfId = (node.id || fm.id || '').toLowerCase();
|
|
465
|
+
const siblings = (Array.isArray(mFm.epics) ? mFm.epics : [])
|
|
466
|
+
.map((e) => shapeRef(loadRef, e))
|
|
467
|
+
.filter(Boolean)
|
|
468
|
+
.filter((e) => (e.id || e.ref || '').toLowerCase() !== selfId);
|
|
469
|
+
milestone = { ...m, siblings };
|
|
470
|
+
} else if (m) {
|
|
471
|
+
milestone = { ...m, siblings: [] };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
grain,
|
|
477
|
+
childKind: childKindFor(grain),
|
|
478
|
+
goal: extractGoal(body),
|
|
479
|
+
acceptance,
|
|
480
|
+
outOfScope,
|
|
481
|
+
dependencies: { fromBody: depBullets, fromFrontmatter: fmDeps },
|
|
482
|
+
related,
|
|
483
|
+
milestone,
|
|
484
|
+
tags: Array.isArray(fm.tags) ? fm.tags : [],
|
|
485
|
+
expectedBreakage: {
|
|
486
|
+
focus: extractBreakageSection(body),
|
|
487
|
+
milestone: milestoneBreakage,
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
493
|
+
// Corpus-drift detection (temporal staleness of linked plans — AGV2/DCRP)
|
|
494
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Detect temporal corpus-staleness: any doc in the epic's reference corpus
|
|
498
|
+
* (`related:` + `depends_on:` + `milestone:`) whose last-modified time is newer
|
|
499
|
+
* than `alignment_reviewed_at`. Pure with injected loaders so it stays unit-
|
|
500
|
+
* testable; the cli wires `loadRef` to the vault loader and `lastModified` to
|
|
501
|
+
* `fs.statSync(...).mtimeMs` for fallback.
|
|
502
|
+
*
|
|
503
|
+
* epic.frontmatter — the focus spec's frontmatter (read `milestone`, `related`,
|
|
504
|
+
* `depends_on`).
|
|
505
|
+
* reviewedAt — ISO string. If missing or unparseable, returns no drift
|
|
506
|
+
* (you can't detect change without a baseline; the user records alignment
|
|
507
|
+
* once to establish one).
|
|
508
|
+
* loadRef(ref) — corpus-doc loader. Returns a spec with optional
|
|
509
|
+
* `frontmatter.updated` (preferred timestamp source).
|
|
510
|
+
* lastModified(ref)— mtime fallback when `frontmatter.updated` is missing or
|
|
511
|
+
* unparseable. Returns ms epoch.
|
|
512
|
+
*
|
|
513
|
+
* Returns `{ stale, hasDrift }`. `stale` items are
|
|
514
|
+
* `{ ref, source, lastModified }` where source ∈ 'milestone' | 'related' |
|
|
515
|
+
* 'depends_on' and `lastModified` is the resolved newest timestamp (ms).
|
|
516
|
+
*/
|
|
517
|
+
export function detectCorpusDrift({ epic, reviewedAt, loadRef, lastModified }) {
|
|
518
|
+
const stale = [];
|
|
519
|
+
const reviewedMs = reviewedAt ? Date.parse(String(reviewedAt)) : NaN;
|
|
520
|
+
if (!Number.isFinite(reviewedMs)) return { stale, hasDrift: false };
|
|
521
|
+
|
|
522
|
+
const fm = epic?.frontmatter || {};
|
|
523
|
+
const refs = [];
|
|
524
|
+
if (fm.milestone) {
|
|
525
|
+
const t = wikilinkTarget(fm.milestone);
|
|
526
|
+
if (t) refs.push({ ref: t, source: 'milestone' });
|
|
527
|
+
}
|
|
528
|
+
for (const r of Array.isArray(fm.related) ? fm.related : []) {
|
|
529
|
+
const t = wikilinkTarget(r);
|
|
530
|
+
if (t) refs.push({ ref: t, source: 'related' });
|
|
531
|
+
}
|
|
532
|
+
for (const r of Array.isArray(fm.depends_on) ? fm.depends_on : []) {
|
|
533
|
+
const t = wikilinkTarget(r);
|
|
534
|
+
if (t) refs.push({ ref: t, source: 'depends_on' });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// De-duplicate by ref (a doc may appear in multiple lists; keep the first
|
|
538
|
+
// source we saw — milestone > related > depends_on by enumeration order).
|
|
539
|
+
const seen = new Set();
|
|
540
|
+
for (const item of refs) {
|
|
541
|
+
if (seen.has(item.ref)) continue;
|
|
542
|
+
seen.add(item.ref);
|
|
543
|
+
|
|
544
|
+
let ts = null;
|
|
545
|
+
if (loadRef) {
|
|
546
|
+
const spec = loadRef(item.ref);
|
|
547
|
+
const updated = spec?.frontmatter?.updated;
|
|
548
|
+
if (updated) {
|
|
549
|
+
const ms = Date.parse(String(updated));
|
|
550
|
+
if (Number.isFinite(ms)) ts = ms;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (ts === null && typeof lastModified === 'function') {
|
|
554
|
+
const mtime = lastModified(item.ref);
|
|
555
|
+
if (Number.isFinite(mtime)) ts = mtime;
|
|
556
|
+
}
|
|
557
|
+
if (ts !== null && ts > reviewedMs) {
|
|
558
|
+
stale.push({ ref: item.ref, source: item.source, lastModified: ts });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return { stale, hasDrift: stale.length > 0 };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
566
|
+
// Checkpoint state machine (frontmatter-backed, fuse-invalidated)
|
|
567
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Decide whether the gate fires based on the timestamp checkpoint + fuse.
|
|
571
|
+
* Signature hashing was removed in AGV2 (HS-TASK-DSIG) — it was non-deterministic
|
|
572
|
+
* across reads (mutable `frontmatter.updated`) and produced false-positive
|
|
573
|
+
* re-fires of `signature_changed`. "Has the breakdown changed since review?" is
|
|
574
|
+
* now answered by corpus-staleness detection (HS-TASK-DCRP), not by hashing.
|
|
575
|
+
*
|
|
576
|
+
* `now` is injected (ms epoch) so the module stays deterministic in tests /
|
|
577
|
+
* harnesses where Date.now is unavailable.
|
|
578
|
+
* reason: 'no_checkpoint' | 'fuse_expired' | 'ok'
|
|
579
|
+
*/
|
|
580
|
+
export function evaluateCheckpoint({
|
|
581
|
+
frontmatter,
|
|
582
|
+
fuseDays = ALIGNMENT_FUSE_DAYS,
|
|
583
|
+
now,
|
|
584
|
+
}) {
|
|
585
|
+
const fm = frontmatter || {};
|
|
586
|
+
const reviewedAt = fm.alignment_reviewed_at;
|
|
587
|
+
if (!reviewedAt) return { fire: true, reason: 'no_checkpoint' };
|
|
588
|
+
const reviewedMs = Date.parse(reviewedAt);
|
|
589
|
+
const nowMs = typeof now === 'number' ? now : Date.parse(reviewedAt);
|
|
590
|
+
if (Number.isFinite(reviewedMs) && Number.isFinite(nowMs)) {
|
|
591
|
+
if (nowMs - reviewedMs > fuseDays * 24 * 60 * 60 * 1000) {
|
|
592
|
+
return { fire: true, reason: 'fuse_expired' };
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { fire: false, reason: 'ok' };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
599
|
+
// Frontmatter checkpoint upsert (raw-text surgical edit — no lossy reserialize)
|
|
600
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Insert/replace top-level scalar keys inside the first `---…---` frontmatter
|
|
604
|
+
* block of `content`, preserving everything else byte-for-byte. Used by
|
|
605
|
+
* heyspex_record_alignment to stamp the checkpoint without round-tripping the
|
|
606
|
+
* whole YAML through the lossy parseSimpleYaml. Values are written as plain
|
|
607
|
+
* scalars (quote them yourself if they need it).
|
|
608
|
+
*/
|
|
609
|
+
export function upsertFrontmatterKeys(content, kv) {
|
|
610
|
+
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
611
|
+
if (!m) return content; // no frontmatter block — refuse to mangle the file
|
|
612
|
+
let block = m[1];
|
|
613
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
614
|
+
for (const [key, value] of Object.entries(kv)) {
|
|
615
|
+
const line = `${key}: ${value}`;
|
|
616
|
+
const keyRe = new RegExp(`^${key}:.*$`, 'm');
|
|
617
|
+
if (keyRe.test(block)) {
|
|
618
|
+
block = block.replace(keyRe, line);
|
|
619
|
+
} else {
|
|
620
|
+
block = `${block}${eol}${line}`;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return content.replace(m[0], `---${eol}${block}${eol}---`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
627
|
+
// Prompt builders (the agent-facing briefs)
|
|
628
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
629
|
+
|
|
630
|
+
function regroundChecklist(context, idForRecall) {
|
|
631
|
+
const tagList = context.tags.length
|
|
632
|
+
? `[${context.tags.map((t) => `"${t}"`).join(', ')}]`
|
|
633
|
+
: '[]';
|
|
634
|
+
return [
|
|
635
|
+
'### 0 · Re-ground (do this first — cheap, prevents expensive mistakes)',
|
|
636
|
+
'',
|
|
637
|
+
'- Read [[heyspex-north-star]], [[architecture-foundations]], [[entity-model]] if not already fresh in context.',
|
|
638
|
+
`- \`heyspex_recall_memories({ tags: ${tagList}, limit: 10 })\` — surface prior lessons/decisions for this area.`,
|
|
639
|
+
`- \`heyspex_preflight({ intent: "file_${context.childKind}" })\` then \`heyspex_get_procedure\` on each required row (start with \`spec-hierarchy\`, then \`spec-summary-authoring\`).`,
|
|
640
|
+
'',
|
|
641
|
+
];
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function hierarchyContextBlock(id, name, context, opts = {}) {
|
|
645
|
+
const outOfScopeNote = opts.outOfScopeNote || 'do NOT file tasks for these';
|
|
646
|
+
const lines = ['### Hierarchy context (handed to you — no search needed)', ''];
|
|
647
|
+
if (context.milestone) {
|
|
648
|
+
const m = context.milestone;
|
|
649
|
+
lines.push(`- **Milestone:** \`${m.id || m.ref}\` — ${m.name || '(unresolved)'} · ${m.status || '?'}`);
|
|
650
|
+
if (m.siblings && m.siblings.length) {
|
|
651
|
+
lines.push('- **Sibling epics in this milestone:**');
|
|
652
|
+
for (const s of m.siblings.slice(0, 12)) {
|
|
653
|
+
lines.push(` - \`${s.id || s.ref}\`${s.name ? ` — ${s.name}` : ''} · ${s.status || '?'}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
lines.push(`- **Milestone:** _none linked_ — confirm whether \`${id}\` should declare a \`milestone:\` parent.`);
|
|
658
|
+
}
|
|
659
|
+
lines.push(`- **This ${context.grain}:** \`${id}\` — ${name}`);
|
|
660
|
+
if (context.goal) lines.push(`- **Goal:** ${context.goal}`);
|
|
661
|
+
if (context.outOfScope.length) {
|
|
662
|
+
lines.push(`- **Out of scope (${outOfScopeNote}):**`);
|
|
663
|
+
for (const o of context.outOfScope.slice(0, 10)) lines.push(` - ${o}`);
|
|
664
|
+
}
|
|
665
|
+
const deps = [
|
|
666
|
+
...context.dependencies.fromFrontmatter,
|
|
667
|
+
...context.dependencies.fromBody,
|
|
668
|
+
];
|
|
669
|
+
if (deps.length) {
|
|
670
|
+
lines.push(`- **Dependencies:** ${deps.map((d) => `\`${d}\``).join(', ')}`);
|
|
671
|
+
}
|
|
672
|
+
lines.push('');
|
|
673
|
+
return lines;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function readingListBlock(context) {
|
|
677
|
+
const lines = ['### Targeted reading list (read these — they bound the work)', ''];
|
|
678
|
+
if (context.related.length === 0) {
|
|
679
|
+
lines.push('_No `related:` wikilinks on this spec. Add the protocols/specs this work touches before filing._');
|
|
680
|
+
} else {
|
|
681
|
+
for (const r of context.related) {
|
|
682
|
+
if (r.resolved) {
|
|
683
|
+
lines.push(`- [[${r.ref}]] — ${r.name} · ${r.type} · ${r.status}`);
|
|
684
|
+
} else {
|
|
685
|
+
lines.push(`- [[${r.ref}]] — ⚠️ unresolved (fix this wikilink)`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
lines.push('');
|
|
689
|
+
lines.push('Read these FIRST. Avoid open-ended repo search — the list above is the scope.');
|
|
690
|
+
}
|
|
691
|
+
lines.push('');
|
|
692
|
+
return lines;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function parentBreakageContextBlock(id, context) {
|
|
696
|
+
const childKind = context.childKind;
|
|
697
|
+
const eb = context.expectedBreakage || {};
|
|
698
|
+
const lines = [
|
|
699
|
+
'### Expected Breakage — parent context (read before filing children)',
|
|
700
|
+
'',
|
|
701
|
+
`When authoring each child ${childKind}'s \`## Expected Breakage & Consequences\`, **pre-allocate fix-up scope from these parent sections** — drill down to concrete files/fixtures/call sites; do not paste parent prose or invent breakage in isolation ([[spec-hierarchy]] §Expected Breakage cascade).`,
|
|
702
|
+
'',
|
|
703
|
+
];
|
|
704
|
+
|
|
705
|
+
if (childKind === 'task') {
|
|
706
|
+
if (eb.focus) {
|
|
707
|
+
lines.push(`**Epic (\`${id}\`):**`, '', eb.focus, '');
|
|
708
|
+
} else {
|
|
709
|
+
lines.push(
|
|
710
|
+
`**Epic (\`${id}\`):** _No \`## Expected Breakage & Consequences\` section found — fill the epic's breakage before breakdown, or note explicitly in each task why breakage is none._`,
|
|
711
|
+
'',
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
if (context.milestone) {
|
|
715
|
+
const mid = context.milestone.id || context.milestone.ref;
|
|
716
|
+
if (eb.milestone) {
|
|
717
|
+
lines.push(`**Milestone (\`${mid}\`):**`, '', eb.milestone, '');
|
|
718
|
+
} else {
|
|
719
|
+
lines.push(
|
|
720
|
+
`**Milestone (\`${mid}\`):** _No Expected Breakage section found — fill the milestone's cross-epic seam breakage at plan ingest, or note in each task._`,
|
|
721
|
+
'',
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
} else if (childKind === 'epic') {
|
|
726
|
+
if (eb.focus) {
|
|
727
|
+
lines.push(`**Milestone (\`${id}\`):**`, '', eb.focus, '');
|
|
728
|
+
} else {
|
|
729
|
+
lines.push(
|
|
730
|
+
`**Milestone (\`${id}\`):** _No \`## Expected Breakage & Consequences\` section found — author cross-epic seam breakage on this milestone before filing epics._`,
|
|
731
|
+
'',
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return lines;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function seedBlock(_id, context) {
|
|
740
|
+
const lines = [`### Candidate ${context.childKind} seeds (deterministic — adjust with judgment)`, ''];
|
|
741
|
+
if (context.acceptance.sections.length === 0) {
|
|
742
|
+
lines.push(
|
|
743
|
+
`_No \`## Acceptance criteria\` subsections found. Derive ${context.childKind}s from the goal + the reading list, then add an Acceptance criteria section to this ${context.grain}._`,
|
|
744
|
+
);
|
|
745
|
+
lines.push('');
|
|
746
|
+
return lines;
|
|
747
|
+
}
|
|
748
|
+
lines.push(
|
|
749
|
+
`One ${context.childKind} per acceptance area below is the vault convention. These are seeds, not gospel — split, merge, or reorder as the work demands.`,
|
|
750
|
+
'',
|
|
751
|
+
);
|
|
752
|
+
context.acceptance.sections.forEach((s, i) => {
|
|
753
|
+
lines.push(`${i + 1}. **${s.title}**`);
|
|
754
|
+
for (const b of s.bullets.slice(0, 6)) lines.push(` - ${b}`);
|
|
755
|
+
if (s.bullets.length > 6) lines.push(` - …(${s.bullets.length - 6} more)`);
|
|
756
|
+
});
|
|
757
|
+
lines.push('');
|
|
758
|
+
lines.push(
|
|
759
|
+
context.childKind === 'task'
|
|
760
|
+
? `Order them by \`depends_on\` so the foundation lands first. File each task via \`heyspex_file_spec\` per **How to file** below — include \`summary\` in the same call. **Tasks are filed only from this What's Next Launch \`/hs-next\` breakdown** — never during plan ingest and never alongside epics.`
|
|
761
|
+
: `Order them by \`depends_on\` so the foundation lands first. File each epic via \`heyspex_file_spec\` per **How to file** below — include \`summary\` in the same call. **Milestones and epics are filed in the planning phase** — never co-filed with tasks.`,
|
|
762
|
+
);
|
|
763
|
+
lines.push('');
|
|
764
|
+
return lines;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Count BLOCKER + SIBLING findings from a `runPlanReview({ scope: "breakdown" })`
|
|
769
|
+
* return value. Pure — no vault I/O. Callers pass the review result they already
|
|
770
|
+
* hold when composing the decomposition brief ([[HS-TASK-SQWL]]).
|
|
771
|
+
*/
|
|
772
|
+
export function countBreakdownFindings(planReviewResult) {
|
|
773
|
+
if (!planReviewResult || typeof planReviewResult !== 'object') {
|
|
774
|
+
return { blockers: 0, siblings: 0 };
|
|
775
|
+
}
|
|
776
|
+
const blockers = Array.isArray(planReviewResult.code_audit)
|
|
777
|
+
? planReviewResult.code_audit.filter((c) => c?.severity === 'BLOCKER').length
|
|
778
|
+
: 0;
|
|
779
|
+
const siblings = Array.isArray(planReviewResult.in_flight_overlap)
|
|
780
|
+
? planReviewResult.in_flight_overlap.filter((o) => o?.classification === 'SIBLING').length
|
|
781
|
+
: 0;
|
|
782
|
+
return { blockers, siblings };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function formatBreakdownFindingsInline(planReviewResult) {
|
|
786
|
+
const { blockers, siblings } = countBreakdownFindings(planReviewResult);
|
|
787
|
+
if (blockers === 0 && siblings === 0) return '';
|
|
788
|
+
return `⚠ ${blockers} BLOCKER(s), ${siblings} SIBLING(s) — resolve before filing. `;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function filingBlock(id, context, focusSpecPath, breakdownReview) {
|
|
792
|
+
const childKind = context.childKind;
|
|
793
|
+
const milestoneRef = context.milestone ? `[[${context.milestone.id || context.milestone.ref}]]` : '(none)';
|
|
794
|
+
const lines = [
|
|
795
|
+
'### How to file (then the gate clears)',
|
|
796
|
+
'',
|
|
797
|
+
`1. For each ${childKind}: call \`heyspex_file_spec({ type: "${childKind}", name: "<title>", summary: "<plain-language summary per spec-summary-authoring>", frontmatter: { parent: "[[${id}]]"${context.grain === 'epic' ? `, milestone: "${milestoneRef}"` : ''}, depends_on: [/* ordering */] } })\` — the server allocates the \`id\`; author \`summary\` in this call (required — do not defer).${childKind === 'task' ? ' Tasks are never filed during plan ingest.' : ' Epics are never filed alongside tasks.'} Do **not** copy a sibling's frontmatter.`,
|
|
798
|
+
];
|
|
799
|
+
if (childKind === 'task') {
|
|
800
|
+
const findingsInline = formatBreakdownFindingsInline(breakdownReview);
|
|
801
|
+
lines.push(
|
|
802
|
+
`1b. **Breakdown review (advisory — v1)** — once titles/scopes are drafted, call \`heyspex_plan_review({ scope: "breakdown", epic_id: "${id}", proposed_tasks: [{ title: "...", scope: "...", files: ["vendor/..."], symbols: ["fnName"] }, ...] })\`. ${findingsInline}Resolve code-audit BLOCKERs and SIBLING overlaps before filing. Advisory in v1 — does not block \`heyspex_record_alignment\`. At step 3, pass \`skipped_despite_findings: false\` if you resolved all findings before filing, or \`skipped_despite_findings: true\` if you filed despite unresolved BLOCKER/SIBLING findings — this stamps the plan-review marker for skip-rate telemetry ([[HS-TASK-MEN2]]).`,
|
|
803
|
+
`1c. **Expected Breakage (required per task)** — read **Expected Breakage — parent context** above. Each task body must include \`## Expected Breakage & Consequences\` naming the concrete cascade (files/fixtures/call sites) that realizes its slice of the epic's blast radius, allocated from the epic + milestone sections. Include baseline→exit ([[spec-hierarchy]] §Expected Breakage).`,
|
|
804
|
+
`1d. **Builder brief (required per task)** — each task must carry fleet-calibrated dispatch hints so the local Ollama builder gets the right context (not too much, not truncated). Consult \`heyspex_get_procedure({ name: "builder-fleet-capability-reference" })\` or \`node dev-factory/lib/fleet-manifest.mjs --self-test\` + \`buildFleetManifest()\` before filing.`,
|
|
805
|
+
'',
|
|
806
|
+
'**Frontmatter (optional but recommended):** `target:`, `suggested_model:`, `suggested_machine:`, `recommended_num_ctx:` (16384|32768|65536|131072), `estimated_context_tokens:`, `context_files:` (YAML list → builder `--extra`).',
|
|
807
|
+
'',
|
|
808
|
+
'**Body section `## Builder brief` (required):**',
|
|
809
|
+
'',
|
|
810
|
+
'```markdown',
|
|
811
|
+
'## Builder brief',
|
|
812
|
+
'- **Target:** `path/to/file.ts`',
|
|
813
|
+
'- **Context files:** `types/foo.ts`, `lib/bar.ts` (explicit — builder hallucinates without them)',
|
|
814
|
+
'- **Estimated context:** ~45,000 tokens',
|
|
815
|
+
'- **recommended_num_ctx:** 65536',
|
|
816
|
+
'- **suggested_model:** qwen3-coder:30b',
|
|
817
|
+
'- **suggested_machine:** specops-gpu1',
|
|
818
|
+
'- **Do not include:** entire epic body — link [[HS-…-TASK-sibling]] instead',
|
|
819
|
+
'```',
|
|
820
|
+
'',
|
|
821
|
+
'Copy fleet fields from the breakdown JSON draft (`suggested_model`, `suggested_machine`, `estimated_context_tokens`, `recommended_num_ctx`, `context_files`, `fit_assessment`). For epics >~100K estimated context, split tasks — do not assign one monolithic local build.',
|
|
822
|
+
'',
|
|
823
|
+
);
|
|
824
|
+
} else if (childKind === 'epic') {
|
|
825
|
+
lines.push(
|
|
826
|
+
`1b. **Expected Breakage (required per epic)** — read **Expected Breakage — parent context** above. Each epic body must include \`## Expected Breakage & Consequences\` naming subsystem blast radius allocated from the milestone's cross-epic seams ([[spec-hierarchy]] §Expected Breakage cascade).`,
|
|
827
|
+
'',
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
lines.push(
|
|
831
|
+
`2. Update \`${id}\` \`${childRefsField(context.grain)}:\` to list every new ${childKind} as a \`[[wikilink]]\`.`,
|
|
832
|
+
`3. Record the checkpoint — the response carries the next on-deck brief inline (no \`/hs-next\` re-invoke needed):`,
|
|
833
|
+
` \`heyspex_record_alignment({ spec_id: "${id}", summary: "<what you broke it into>", skipped_despite_findings: false })\` — set \`skipped_despite_findings: true\` only when you filed despite unresolved BLOCKER/SIBLING findings from step 1b.`,
|
|
834
|
+
'',
|
|
835
|
+
`_Pinned via \`.heyspex/focus.yaml\` (\`${focusSpecPath || 'n/a'}\`). This ${context.grain} has no ${childKind}s — surfacing a breakdown brief instead of a body excerpt._`,
|
|
836
|
+
);
|
|
837
|
+
return lines;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
841
|
+
// world-state delta sections — surfaced in BOTH the decomposition brief and
|
|
842
|
+
// the drift brief when the canSkipAlignment pre-pass found a non-empty world-
|
|
843
|
+
// state delta (new files or graph-linked changes since alignment_reviewed_at).
|
|
844
|
+
// Deterministic enumeration: agents read this list instead of grepping.
|
|
845
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
846
|
+
|
|
847
|
+
const KIND_FROM_PATH = [
|
|
848
|
+
{ match: /^specs\/research\//i, label: 'research' },
|
|
849
|
+
{ match: /^specs\/memory\/decisions\//i, label: 'decisions' },
|
|
850
|
+
{ match: /^specs\/memory\/lessons\//i, label: 'lessons' },
|
|
851
|
+
{ match: /^specs\/memory\/references\//i, label: 'references' },
|
|
852
|
+
{ match: /^specs\/memory\/ui-patterns\//i, label: 'ui-patterns' },
|
|
853
|
+
{ match: /^specs\/tasks\//i, label: 'sibling Tasks' },
|
|
854
|
+
{ match: /^specs\/epics\//i, label: 'sibling Epics' },
|
|
855
|
+
{ match: /^specs\/milestones?\//i, label: 'sibling Milestones' },
|
|
856
|
+
{ match: /^specs\/protocols\//i, label: 'protocols' },
|
|
857
|
+
{ match: /^specs\/adr\//i, label: 'ADRs' },
|
|
858
|
+
];
|
|
859
|
+
|
|
860
|
+
function kindForPath(p) {
|
|
861
|
+
for (const { match, label } of KIND_FROM_PATH) {
|
|
862
|
+
if (match.test(p)) return label;
|
|
863
|
+
}
|
|
864
|
+
return 'other';
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function groupByKind(paths) {
|
|
868
|
+
const groups = new Map();
|
|
869
|
+
for (const p of paths) {
|
|
870
|
+
const k = kindForPath(p);
|
|
871
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
872
|
+
groups.get(k).push(p);
|
|
873
|
+
}
|
|
874
|
+
return groups;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Body-grep helper: does `body` wikilink any of `targetIds`? Cheap substring
|
|
879
|
+
* match — wikilinks are unique enough that false positives are rare. Used by
|
|
880
|
+
* worldStateDeltaSections to compute the graph_delta subset.
|
|
881
|
+
*/
|
|
882
|
+
function bodyLinksAny(body, targetIds) {
|
|
883
|
+
if (typeof body !== 'string' || body.length === 0) return false;
|
|
884
|
+
for (const id of targetIds) {
|
|
885
|
+
if (!id) continue;
|
|
886
|
+
if (body.includes(`[[${id}]]`) || body.includes(`[[${id}-`)) return true;
|
|
887
|
+
}
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Render the three world-state delta sections (when non-empty) plus the
|
|
893
|
+
* 4-option menu. `delta.addedPaths` is the git-derived list of `specs/` files
|
|
894
|
+
* first committed since `alignment_reviewed_at`. `delta.modifiedPaths` is the
|
|
895
|
+
* (optional) caller-supplied list of files touched since baseline.
|
|
896
|
+
* `loadByPath(path) -> spec|null` enables the graph_delta carve-out: specs
|
|
897
|
+
* whose body wikilinks the focus epic or any of its children.
|
|
898
|
+
*/
|
|
899
|
+
export function buildWorldStateDeltaSections({
|
|
900
|
+
delta,
|
|
901
|
+
focusId,
|
|
902
|
+
childIds = [],
|
|
903
|
+
loadByPath,
|
|
904
|
+
}) {
|
|
905
|
+
if (!delta || (!delta.addedPaths?.length && !delta.modifiedPaths?.length)) {
|
|
906
|
+
return [];
|
|
907
|
+
}
|
|
908
|
+
const addedPaths = Array.isArray(delta.addedPaths) ? delta.addedPaths : [];
|
|
909
|
+
const modifiedPaths = Array.isArray(delta.modifiedPaths) ? delta.modifiedPaths : [];
|
|
910
|
+
const lines = [];
|
|
911
|
+
|
|
912
|
+
if (addedPaths.length) {
|
|
913
|
+
lines.push('### world_state_new (specs added since the breakdown)', '');
|
|
914
|
+
const grouped = groupByKind(addedPaths);
|
|
915
|
+
const labelsInOrder = [
|
|
916
|
+
'research',
|
|
917
|
+
'decisions',
|
|
918
|
+
'lessons',
|
|
919
|
+
'references',
|
|
920
|
+
'ui-patterns',
|
|
921
|
+
'sibling Tasks',
|
|
922
|
+
'sibling Epics',
|
|
923
|
+
'sibling Milestones',
|
|
924
|
+
'protocols',
|
|
925
|
+
'ADRs',
|
|
926
|
+
'other',
|
|
927
|
+
];
|
|
928
|
+
for (const label of labelsInOrder) {
|
|
929
|
+
const arr = grouped.get(label);
|
|
930
|
+
if (!arr || arr.length === 0) continue;
|
|
931
|
+
lines.push(`- **${label}** (${arr.length})`);
|
|
932
|
+
for (const p of arr.slice(0, 12)) lines.push(` - \`${p}\``);
|
|
933
|
+
if (arr.length > 12) lines.push(` - …(${arr.length - 12} more)`);
|
|
934
|
+
}
|
|
935
|
+
lines.push('');
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (modifiedPaths.length) {
|
|
939
|
+
lines.push('### world_state_modified (existing specs touched since the breakdown)', '');
|
|
940
|
+
for (const p of modifiedPaths.slice(0, 20)) lines.push(`- \`${p}\``);
|
|
941
|
+
if (modifiedPaths.length > 20) lines.push(`- …(${modifiedPaths.length - 20} more)`);
|
|
942
|
+
lines.push('');
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// graph_delta — subset of addedPaths whose body wikilinks the focus epic or
|
|
946
|
+
// any of its existing children. Optional (requires a loader); when absent,
|
|
947
|
+
// the section is skipped.
|
|
948
|
+
if (addedPaths.length && typeof loadByPath === 'function') {
|
|
949
|
+
const targets = [focusId, ...childIds].filter(Boolean);
|
|
950
|
+
const graphHits = [];
|
|
951
|
+
for (const p of addedPaths) {
|
|
952
|
+
let spec = null;
|
|
953
|
+
try {
|
|
954
|
+
spec = loadByPath(p);
|
|
955
|
+
} catch {
|
|
956
|
+
spec = null;
|
|
957
|
+
}
|
|
958
|
+
if (spec && bodyLinksAny(spec.body || '', targets)) {
|
|
959
|
+
graphHits.push({ path: p, id: spec.id || null, name: spec.name || null });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (graphHits.length) {
|
|
963
|
+
lines.push(
|
|
964
|
+
`### graph_delta (new specs that wikilink \`${focusId || 'this'}\` or its children)`,
|
|
965
|
+
'',
|
|
966
|
+
);
|
|
967
|
+
for (const h of graphHits.slice(0, 20)) {
|
|
968
|
+
const label = h.id ? `\`${h.id}\`${h.name ? ` — ${h.name}` : ''}` : `\`${h.path}\``;
|
|
969
|
+
lines.push(`- ${label}`);
|
|
970
|
+
}
|
|
971
|
+
if (graphHits.length > 20) lines.push(`- …(${graphHits.length - 20} more)`);
|
|
972
|
+
lines.push('');
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
return lines;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* 4-option menu rendered at the end of the brief — see [[HS-TASK-SQGT]] (skill
|
|
981
|
+
* reply parser). The agent surfaces these options to the user; the user picks
|
|
982
|
+
* one verbatim. Options [2]/[3] dispatch the HSN1 regen tools ([[HS-TASK-0CKW]])
|
|
983
|
+
* once they land; until then the skill returns a stub.
|
|
984
|
+
*/
|
|
985
|
+
export function buildFourOptionMenu({ focusId, claimableId } = {}) {
|
|
986
|
+
const target = claimableId || focusId || 'this spec';
|
|
987
|
+
return [
|
|
988
|
+
'### Your options',
|
|
989
|
+
'',
|
|
990
|
+
`- **[1] proceed** — accept the brief, claim \`${target}\`, start executing. Reply: \`1\` / \`proceed\` / \`yes\`.`,
|
|
991
|
+
'- **[2] regen_task** — re-derive this task from its parent epic + linked plans. Old version is soft-deleted to `.trash/`. Reply: `2` / `refresh task`.',
|
|
992
|
+
'- **[3] regen_tasks_for_epic** — regenerate every task under the focus epic (Stage 1 generate → Stage 2 reconcile → Stage 3 retire). Reply: `3` / `refresh epic`.',
|
|
993
|
+
'- **[4] defer** — close the brief without claiming. Reply: `4` / `defer` / `skip`.',
|
|
994
|
+
'',
|
|
995
|
+
];
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Robust HARD-gate prompt for the no-children case (epic_empty /
|
|
1000
|
+
* milestone_needs_breakdown). Re-ground → hierarchy context → seeds →
|
|
1001
|
+
* reading list → filing.
|
|
1002
|
+
*/
|
|
1003
|
+
export function buildDecompositionPromptMarkdown({
|
|
1004
|
+
grain,
|
|
1005
|
+
id,
|
|
1006
|
+
name,
|
|
1007
|
+
context,
|
|
1008
|
+
roadmapId,
|
|
1009
|
+
focusSpecPath,
|
|
1010
|
+
worldStateDelta,
|
|
1011
|
+
childIds,
|
|
1012
|
+
loadByPath,
|
|
1013
|
+
includeOptionsMenu,
|
|
1014
|
+
breakdownReview,
|
|
1015
|
+
}) {
|
|
1016
|
+
const childKind = context.childKind;
|
|
1017
|
+
const lines = [
|
|
1018
|
+
`## ⛔ needs_breakdown — ${id}`,
|
|
1019
|
+
'',
|
|
1020
|
+
`**${grain[0].toUpperCase()}${grain.slice(1)}:** ${name}`,
|
|
1021
|
+
roadmapId ? `**Roadmap:** ${roadmapId}` : '',
|
|
1022
|
+
'',
|
|
1023
|
+
`> **Execution is blocked.** This ${grain} has **no ${childKind}s**. Do not start implementing — your job this session is to decompose it. _AI agents are good at executing the wrong thing perfectly_ ([[heyspex-north-star]]); a clean breakdown is the cheapest defense.`,
|
|
1024
|
+
'',
|
|
1025
|
+
].filter((l) => l !== '');
|
|
1026
|
+
|
|
1027
|
+
lines.push(...regroundChecklist(context, id));
|
|
1028
|
+
lines.push(...hierarchyContextBlock(id, name, context));
|
|
1029
|
+
lines.push(...parentBreakageContextBlock(id, context));
|
|
1030
|
+
lines.push(...seedBlock(id, context));
|
|
1031
|
+
lines.push(...readingListBlock(context));
|
|
1032
|
+
lines.push(
|
|
1033
|
+
...buildWorldStateDeltaSections({
|
|
1034
|
+
delta: worldStateDelta,
|
|
1035
|
+
focusId: id,
|
|
1036
|
+
childIds,
|
|
1037
|
+
loadByPath,
|
|
1038
|
+
}),
|
|
1039
|
+
);
|
|
1040
|
+
lines.push(...filingBlock(id, context, focusSpecPath, breakdownReview));
|
|
1041
|
+
if (includeOptionsMenu) {
|
|
1042
|
+
lines.push(...buildFourOptionMenu({ focusId: id }));
|
|
1043
|
+
}
|
|
1044
|
+
return lines.join('\n');
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* The acceptance criteria rendered AS THE CONTRACT to satisfy (Tier 1), or the
|
|
1049
|
+
* Tier-2 minimal fallback with an inline gap marker when none exist. Distinct
|
|
1050
|
+
* from `seedBlock`, which frames the same data as "candidates to split" for a
|
|
1051
|
+
* breakdown — here the agent EXECUTES against them.
|
|
1052
|
+
*/
|
|
1053
|
+
function executionContractBlock(context) {
|
|
1054
|
+
const lines = ['### Acceptance criteria — your contract (close-out verifies these)', ''];
|
|
1055
|
+
const sections = context.acceptance?.sections || [];
|
|
1056
|
+
if (sections.length === 0) {
|
|
1057
|
+
// Tier 2 — no acceptance criteria. Mark the gap inline and point at the
|
|
1058
|
+
// goal/reading list (rendered below) as the working contract; never error.
|
|
1059
|
+
lines.push(
|
|
1060
|
+
context.goal
|
|
1061
|
+
? '**[NEEDS CLARIFICATION: no `## Acceptance criteria` on this spec — treat the Goal (in Hierarchy context below) + the reading list as the contract, and add explicit criteria.]**'
|
|
1062
|
+
: '**[NEEDS CLARIFICATION: no `## Acceptance criteria` and no `## Goal` — too thin to derive a contract. Add at least a goal before claiming.]**',
|
|
1063
|
+
'',
|
|
1064
|
+
);
|
|
1065
|
+
return lines;
|
|
1066
|
+
}
|
|
1067
|
+
for (const s of sections) {
|
|
1068
|
+
lines.push(`**${s.title}**`);
|
|
1069
|
+
for (const b of s.bullets) lines.push(`- [ ] ${b}`);
|
|
1070
|
+
lines.push('');
|
|
1071
|
+
}
|
|
1072
|
+
return lines;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1076
|
+
// Tier-3 readiness floor ([[HS-TASK-RW3B]]) — a separable pre-synthesis check
|
|
1077
|
+
// that runs BEFORE buildExecutionBrief. Below a minimum-viable-input threshold,
|
|
1078
|
+
// the caller routes to a recoverable clarification surface naming the specific
|
|
1079
|
+
// missing fields, never a thin brief and never a hard throw. Decoupled from the
|
|
1080
|
+
// composer so it is independently testable (epic §6).
|
|
1081
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Synthesizability floor. A spec is brief-able at Tier 1/2 when it has a title
|
|
1085
|
+
* AND at least one of: an `## Acceptance criteria` section carrying
|
|
1086
|
+
* ≥ `minAcceptanceBullets` bullets, OR a non-trivial goal/scope of
|
|
1087
|
+
* ≥ `minGoalChars` characters. Config-surfaced via `readReadinessFloorSettings`
|
|
1088
|
+
* so it is tunable without a deploy (AC #3).
|
|
1089
|
+
*/
|
|
1090
|
+
export const DEFAULT_READINESS_FLOOR = { minAcceptanceBullets: 1, minGoalChars: 12 };
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Read the (tunable) readiness floor from spex-console/settings.json or its
|
|
1094
|
+
* .heyspex mirror under a `briefReadinessFloor: { minAcceptanceBullets,
|
|
1095
|
+
* minGoalChars }` key. Missing / malformed values fall back per-field to
|
|
1096
|
+
* DEFAULT_READINESS_FLOOR. Mirrors readAlignmentFuseSettings' lookup order.
|
|
1097
|
+
*/
|
|
1098
|
+
export function readReadinessFloorSettings(vaultRoot) {
|
|
1099
|
+
for (const p of spexConsoleSettingsCandidates(vaultRoot)) {
|
|
1100
|
+
const raw = readJsonSafe(p);
|
|
1101
|
+
const floor = raw && typeof raw === 'object' ? raw.briefReadinessFloor : null;
|
|
1102
|
+
if (!floor || typeof floor !== 'object') continue;
|
|
1103
|
+
const pick = (v, fallback) =>
|
|
1104
|
+
typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback;
|
|
1105
|
+
return {
|
|
1106
|
+
minAcceptanceBullets: pick(floor.minAcceptanceBullets, DEFAULT_READINESS_FLOOR.minAcceptanceBullets),
|
|
1107
|
+
minGoalChars: pick(floor.minGoalChars, DEFAULT_READINESS_FLOOR.minGoalChars),
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
return { ...DEFAULT_READINESS_FLOOR };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Assess whether a spec carries enough structured content to derive a reliable
|
|
1115
|
+
* execution brief. Pure: reads only `name` + the `context` produced by
|
|
1116
|
+
* `extractDecompositionContext`. Returns
|
|
1117
|
+
* `{ ready, missing: [{ field, need }], floor, metrics }`
|
|
1118
|
+
* `missing` is empty iff `ready`; each entry names a field and the concrete
|
|
1119
|
+
* thing to add, so the clarification surface can render them verbatim (AC #2).
|
|
1120
|
+
*/
|
|
1121
|
+
export function assessBriefReadiness({ name, context, floor } = {}) {
|
|
1122
|
+
const f = { ...DEFAULT_READINESS_FLOOR, ...(floor || {}) };
|
|
1123
|
+
const missing = [];
|
|
1124
|
+
|
|
1125
|
+
const title = typeof name === 'string' ? name.trim() : '';
|
|
1126
|
+
if (!title) {
|
|
1127
|
+
missing.push({ field: 'title', need: 'a non-empty title/name on the spec' });
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const sections = context?.acceptance?.sections || [];
|
|
1131
|
+
const acceptanceBullets = sections.reduce(
|
|
1132
|
+
(n, s) => n + (Array.isArray(s.bullets) ? s.bullets.length : 0),
|
|
1133
|
+
0,
|
|
1134
|
+
);
|
|
1135
|
+
const goal = typeof context?.goal === 'string' ? context.goal.trim() : '';
|
|
1136
|
+
const goalChars = goal.length;
|
|
1137
|
+
|
|
1138
|
+
const hasAcceptance = acceptanceBullets >= f.minAcceptanceBullets;
|
|
1139
|
+
const hasGoal = goalChars >= f.minGoalChars;
|
|
1140
|
+
if (!hasAcceptance && !hasGoal) {
|
|
1141
|
+
missing.push({
|
|
1142
|
+
field: 'goal_or_acceptance',
|
|
1143
|
+
need:
|
|
1144
|
+
`at least one of: a \`## Acceptance criteria\` section with ≥${f.minAcceptanceBullets} bullet(s), ` +
|
|
1145
|
+
`or a non-trivial \`## Goal\`/scope (≥${f.minGoalChars} characters)`,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
return {
|
|
1150
|
+
ready: missing.length === 0,
|
|
1151
|
+
missing,
|
|
1152
|
+
floor: f,
|
|
1153
|
+
metrics: { acceptanceBullets, goalChars },
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Classify which synthesis tier a brief was served at ([[HS-TASK-68NO]]). Pure —
|
|
1159
|
+
* the observability counterpart records/logs the returned label. The ladder:
|
|
1160
|
+
* 'override' — Tier 0: an authored `## NEXT STEP` override is present.
|
|
1161
|
+
* 'derived' — Tier 1: an `## Acceptance criteria` contract was derived.
|
|
1162
|
+
* 'minimal' — Tier 2: no acceptance criteria; goal-only gap-marked brief.
|
|
1163
|
+
* 'skip' — Tier 3: below the readiness floor → clarification surface.
|
|
1164
|
+
* 'minimal' and 'skip' are the *degraded* tiers (below 'derived').
|
|
1165
|
+
*/
|
|
1166
|
+
export function classifyBriefTier({ override, context, ready } = {}) {
|
|
1167
|
+
if (ready === false) return 'skip';
|
|
1168
|
+
const hasOverride = typeof override === 'string' && override.trim().length > 0;
|
|
1169
|
+
if (hasOverride) return 'override';
|
|
1170
|
+
const sections = context?.acceptance?.sections || [];
|
|
1171
|
+
if (sections.length > 0) return 'derived';
|
|
1172
|
+
return 'minimal';
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** The tiers served below 'derived' — the degraded-mode set for the SLO signal. */
|
|
1176
|
+
export const DEGRADED_BRIEF_TIERS = ['minimal', 'skip'];
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Tier-3 clarification surface. Replaces the brief when `assessBriefReadiness`
|
|
1180
|
+
* fails: a friendly, recoverable note naming exactly what to add (AC #2/#4),
|
|
1181
|
+
* never an error/throw. Same shape as the other prompt builders so callers can
|
|
1182
|
+
* drop it straight into `prompt_markdown`.
|
|
1183
|
+
*/
|
|
1184
|
+
export function buildClarificationPrompt({ id, name, status, roadmapId, missing, focusSpecPath, grain = 'task' } = {}) {
|
|
1185
|
+
const grainLabel =
|
|
1186
|
+
grain === 'milestone' ? 'Milestone' : grain === 'epic' ? 'Epic' : 'Task';
|
|
1187
|
+
const lines = [
|
|
1188
|
+
`## 🟡 needs_clarification — ${id || '(unknown spec)'}`,
|
|
1189
|
+
'',
|
|
1190
|
+
`**${grainLabel}:** ${name || '(untitled)'}`,
|
|
1191
|
+
status ? `**Status:** ${status}` : '',
|
|
1192
|
+
roadmapId ? `**Roadmap:** ${roadmapId}` : '',
|
|
1193
|
+
'',
|
|
1194
|
+
`> **This spec is too thin to derive a reliable execution brief.** Rather than hand you a low-quality brief, here is exactly what to add. Fill these in, then re-run \`/hs-next\` — the brief builds itself once the floor is met.`,
|
|
1195
|
+
'',
|
|
1196
|
+
"### What's missing",
|
|
1197
|
+
'',
|
|
1198
|
+
].filter((l) => l !== '');
|
|
1199
|
+
|
|
1200
|
+
const items = Array.isArray(missing) ? missing : [];
|
|
1201
|
+
if (items.length === 0) {
|
|
1202
|
+
lines.push('- _(no specific fields flagged — re-run `/hs-next` to retry)_');
|
|
1203
|
+
} else {
|
|
1204
|
+
for (const m of items) {
|
|
1205
|
+
lines.push(`- **${m.field}** — ${m.need}`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
lines.push(
|
|
1210
|
+
'',
|
|
1211
|
+
'### How to recover',
|
|
1212
|
+
'',
|
|
1213
|
+
`1. Open the spec on disk (\`${focusSpecPath || 'n/a'}\`) and add the fields above.`,
|
|
1214
|
+
'2. Re-run `/hs-next` (or `heyspex_next_on_deck`) — the readiness floor re-checks and the brief is derived automatically once the threshold is met.',
|
|
1215
|
+
'',
|
|
1216
|
+
);
|
|
1217
|
+
return lines.join('\n');
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Derive an agent EXECUTION brief from a spec's structured content — the
|
|
1222
|
+
* deterministic, on-read replacement for hand-authored NEXT STEP blocks
|
|
1223
|
+
* ([[HS-EPIC-DXBR]] / [[HS-TASK-ULKL]]). Pure: composes only from the passed-in
|
|
1224
|
+
* `context` + `override`; no fs/Neon/index reads, no frontmatter writes. Same
|
|
1225
|
+
* input ⇒ byte-identical output.
|
|
1226
|
+
*
|
|
1227
|
+
* Synthesis chain Tiers 0–2 (the Tier-3 readiness floor lives in the caller):
|
|
1228
|
+
* Tier 0 — `override` (a pre-extracted `## NEXT STEP` block) is PREPENDED under
|
|
1229
|
+
* a labeled heading with the derived contract below it. Never drops,
|
|
1230
|
+
* never replaces — a stale override stays visible beside the contract.
|
|
1231
|
+
* Tier 1 — derive from `context.acceptance` (the executable contract) + goal +
|
|
1232
|
+
* out-of-scope + dependencies + reading list.
|
|
1233
|
+
* Tier 2 — no acceptance criteria ⇒ derive from goal + reading list and mark
|
|
1234
|
+
* the gap inline; no error, no disclaimer.
|
|
1235
|
+
*
|
|
1236
|
+
* `context` is the output of `extractDecompositionContext(node, loadRef, grain)`.
|
|
1237
|
+
* `override` is the verbatim NEXT STEP body, or null/'' when absent.
|
|
1238
|
+
*/
|
|
1239
|
+
export function buildExecutionBrief({ id, name, status, roadmapId, context, override }) {
|
|
1240
|
+
const lines = [
|
|
1241
|
+
`## Execution brief — ${id}`,
|
|
1242
|
+
'',
|
|
1243
|
+
`**Task:** ${name}`,
|
|
1244
|
+
status ? `**Status:** ${status}` : '',
|
|
1245
|
+
roadmapId ? `**Roadmap:** ${roadmapId}` : '',
|
|
1246
|
+
'',
|
|
1247
|
+
].filter((l) => l !== '');
|
|
1248
|
+
|
|
1249
|
+
// Tier 0 — authored override, prepended (never replaces the contract).
|
|
1250
|
+
const overrideText = typeof override === 'string' ? override.trim() : '';
|
|
1251
|
+
if (overrideText) {
|
|
1252
|
+
lines.push(
|
|
1253
|
+
'### Authored override (`## NEXT STEP`)',
|
|
1254
|
+
'',
|
|
1255
|
+
'_A human pinned this prompt. Follow it — the derived contract below stays visible so you can spot any drift between the two._',
|
|
1256
|
+
'',
|
|
1257
|
+
overrideText,
|
|
1258
|
+
'',
|
|
1259
|
+
'---',
|
|
1260
|
+
'',
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
lines.push(...executionContractBlock(context));
|
|
1265
|
+
lines.push(...hierarchyContextBlock(id, name, context, { outOfScopeNote: 'do NOT implement these' }));
|
|
1266
|
+
lines.push(...readingListBlock(context));
|
|
1267
|
+
return lines.join('\n');
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
/**
|
|
1271
|
+
* SOFT corpus-drift prompt (children exist, reference corpus moved). Prepended
|
|
1272
|
+
* to the task brief, NEVER a hard block. The agent reads each stale doc,
|
|
1273
|
+
* identifies what changed, and poses keep/update/drop to the user per affected
|
|
1274
|
+
* child task. Then `heyspex_record_alignment` resets the timestamp so the next
|
|
1275
|
+
* `/hs-next` is quiet.
|
|
1276
|
+
*
|
|
1277
|
+
* stale — `[{ ref, source, lastModified }]` from detectCorpusDrift.
|
|
1278
|
+
*/
|
|
1279
|
+
export function buildDriftResolutionPrompt({
|
|
1280
|
+
grain,
|
|
1281
|
+
id,
|
|
1282
|
+
name,
|
|
1283
|
+
context,
|
|
1284
|
+
existingChildren,
|
|
1285
|
+
stale,
|
|
1286
|
+
roadmapId,
|
|
1287
|
+
focusSpecPath,
|
|
1288
|
+
worldStateDelta,
|
|
1289
|
+
childIds,
|
|
1290
|
+
loadByPath,
|
|
1291
|
+
includeOptionsMenu,
|
|
1292
|
+
}) {
|
|
1293
|
+
const childKind = context.childKind;
|
|
1294
|
+
const lines = [
|
|
1295
|
+
`## ⚠️ corpus_drift — ${id}`,
|
|
1296
|
+
'',
|
|
1297
|
+
`**${grain[0].toUpperCase()}${grain.slice(1)}:** ${name}`,
|
|
1298
|
+
roadmapId ? `**Roadmap:** ${roadmapId}` : '',
|
|
1299
|
+
'',
|
|
1300
|
+
`> **Soft re-ground (not a block).** ${stale.length} doc(s) in this ${grain}'s reference corpus moved since the last alignment review. Read each one, identify what's stale or contradicted in the breakdown, then pose **keep / update / drop** to the user per affected ${childKind}. Apply the answers and reset the checkpoint — then the task brief below is yours to execute.`,
|
|
1301
|
+
'',
|
|
1302
|
+
].filter((l) => l !== '');
|
|
1303
|
+
|
|
1304
|
+
lines.push('### Stale corpus (newer than alignment_reviewed_at)', '');
|
|
1305
|
+
for (const s of stale) {
|
|
1306
|
+
const ts = Number.isFinite(s.lastModified) ? new Date(s.lastModified).toISOString() : 'unknown';
|
|
1307
|
+
lines.push(`- [[${s.ref}]] — ${s.source} · last-modified \`${ts}\``);
|
|
1308
|
+
}
|
|
1309
|
+
lines.push('');
|
|
1310
|
+
|
|
1311
|
+
lines.push(`### Existing ${childKind}s (use these as the resolution surface)`, '');
|
|
1312
|
+
for (const c of existingChildren) {
|
|
1313
|
+
lines.push(`- \`${c.id}\`${c.name ? ` — ${c.name}` : ''} · ${c.status || '?'}`);
|
|
1314
|
+
}
|
|
1315
|
+
lines.push('');
|
|
1316
|
+
|
|
1317
|
+
lines.push(
|
|
1318
|
+
'### Resolution loop (then the drift section clears)',
|
|
1319
|
+
'',
|
|
1320
|
+
`1. Read each stale doc above and identify what changed — direction shifts, new acceptance, retired scope, renamed concepts.`,
|
|
1321
|
+
`2. For each ${childKind} potentially affected, pose to the user: **keep** (no change needed), **update** (open the ${childKind}, restate scope), or **drop** (retire it, add a replacement if needed).`,
|
|
1322
|
+
`3. Apply the user's answers (edit ${childKind}s, file new ones, retire stale ones).`,
|
|
1323
|
+
`4. Record the new baseline: \`heyspex_record_alignment({ spec_id: "${id}", summary: "<one-line resolution>" })\` — this resets \`alignment_reviewed_at\` so \`/hs-next\` is quiet on the next call.`,
|
|
1324
|
+
`5. **False positive?** Audit trail: \`heyspex_record_alignment({ spec_id: "${id}", override_reason: "<why this drift is not real>" })\`.`,
|
|
1325
|
+
'',
|
|
1326
|
+
`_Pinned via \`.heyspex/focus.yaml\` (\`${focusSpecPath || 'n/a'}\`). The task brief follows below — work it once drift questions are resolved._`,
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
const deltaLines = buildWorldStateDeltaSections({
|
|
1330
|
+
delta: worldStateDelta,
|
|
1331
|
+
focusId: id,
|
|
1332
|
+
childIds: childIds || (Array.isArray(existingChildren) ? existingChildren.map((c) => c.id) : []),
|
|
1333
|
+
loadByPath,
|
|
1334
|
+
});
|
|
1335
|
+
if (deltaLines.length) {
|
|
1336
|
+
lines.push('', ...deltaLines);
|
|
1337
|
+
}
|
|
1338
|
+
if (includeOptionsMenu) {
|
|
1339
|
+
lines.push(
|
|
1340
|
+
...buildFourOptionMenu({
|
|
1341
|
+
focusId: id,
|
|
1342
|
+
claimableId: existingChildren?.[0]?.id || id,
|
|
1343
|
+
}),
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
return lines.join('\n');
|
|
1347
|
+
}
|