@izkac/forgekit 0.3.11 → 0.3.13
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/bin/forge.mjs +107 -107
- package/bin/forgekit.mjs +83 -83
- package/bin/review.mjs +81 -81
- package/package.json +1 -1
- package/scripts/prepack.mjs +78 -78
- package/scripts/run-tests.mjs +50 -50
- package/src/adr.mjs +236 -236
- package/src/adr.test.mjs +170 -170
- package/src/change.mjs +327 -234
- package/src/change.test.mjs +145 -83
- package/src/cleanup-sessions.mjs +84 -84
- package/src/config.mjs +103 -103
- package/src/defer.mjs +75 -75
- package/src/doctor.mjs +350 -341
- package/src/doctor.test.mjs +114 -114
- package/src/init.mjs +680 -621
- package/src/install.mjs +815 -815
- package/src/install.test.mjs +180 -180
- package/src/integrity-check.mjs +60 -60
- package/src/integrity.mjs +682 -682
- package/src/integrity.test.mjs +566 -566
- package/src/lib.mjs +143 -143
- package/src/models.defaults.json +41 -41
- package/src/new-session.mjs +99 -99
- package/src/openspec-overlays/README.md +19 -19
- package/src/openspec-overlays/openspec-apply-change-footer.md +14 -14
- package/src/openspec-overlays/opsx-apply-completion-step.md +1 -1
- package/src/openspec-overlays/opsx-apply-implement-step.md +11 -11
- package/src/paths.mjs +92 -92
- package/src/plan-engine.mjs +321 -278
- package/src/plan-engine.test.mjs +447 -283
- package/src/preferences.defaults.json +78 -78
- package/src/preferences.mjs +438 -438
- package/src/preferences.test.mjs +174 -174
- package/src/record-evidence.mjs +204 -204
- package/src/resolve-model.mjs +312 -312
- package/src/resolve-model.test.mjs +194 -194
- package/src/review/cli.test.mjs +117 -117
- package/src/review/export.mjs +172 -172
- package/src/review/export.test.mjs +197 -197
- package/src/review/fixtures/valid-review.json +42 -42
- package/src/review/lib.mjs +894 -894
- package/src/review/lib.test.mjs +266 -266
- package/src/review/schema.json +196 -196
- package/src/review/signals.test.mjs +62 -62
- package/src/score-cli.mjs +68 -68
- package/src/score.mjs +568 -566
- package/src/score.test.mjs +366 -340
- package/src/session-reminder.mjs +207 -207
- package/src/session-status.mjs +70 -70
- package/src/set-models.mjs +186 -186
- package/src/set-phase.mjs +205 -205
- package/src/set-prefs.mjs +294 -294
- package/src/specs-sync.mjs +234 -0
- package/src/specs-sync.test.mjs +114 -0
- package/src/spine.mjs +93 -93
- package/src/triage-prompt.mjs +175 -175
- package/src/triage-prompt.test.mjs +50 -50
- package/src/vendor-openspec-overlays.mjs +176 -176
- package/src/vendor-openspec-overlays.test.mjs +62 -62
- package/vendor/skills/archive-to-adr/SKILL.md +149 -149
- package/vendor/skills/forge/SKILL.md +136 -136
- package/vendor/skills/forge/docs/forge.md +650 -647
- package/vendor/skills/forge/phases/brainstorm.md +23 -23
- package/vendor/skills/forge/phases/finish.md +90 -87
- package/vendor/skills/forge/phases/implement.md +77 -77
- package/vendor/skills/forge/phases/plan-openspec.md +60 -60
- package/vendor/skills/forge/phases/plan-specs.md +163 -117
- package/vendor/skills/forge/phases/review.md +25 -25
- package/vendor/skills/forge/phases/verify.md +124 -124
- package/vendor/skills/forge/references/forge-layout.md +85 -85
- package/vendor/skills/forge/references/pace.md +115 -115
- package/vendor/skills/forge/references/plan-routing.md +52 -51
- package/vendor/skills/forge/references/runtime-integrity.md +232 -225
- package/vendor/skills/forge/references/substantial-work.md +37 -37
- package/vendor/skills/forge/references/test-evidence.md +30 -30
- package/vendor/skills/forge/references/test-strategy.md +68 -68
- package/vendor/skills/forge/skills/subagent-driven-development/SKILL.md +87 -87
- package/vendor/skills/forge/subagents/final-reviewer-prompt.md +56 -56
- package/vendor/skills/forge/subagents/implementer-prompt.md +38 -38
- package/vendor/skills/git-resolve-adr-conflict/SKILL.md +132 -132
- package/vendor/skills/thorough-code-review/SKILL.md +290 -290
- package/vendor/skills/thorough-code-review/examples.md +133 -133
- package/vendor/skills/thorough-code-review/reference/accepted-risks.md +26 -26
- package/vendor/skills/thorough-code-review/reference/lenses.md +96 -96
- package/vendor/skills/thorough-code-review/reference/phase1-scout.md +62 -62
- package/vendor/skills/thorough-code-review/reference/phase2-skeptic.md +105 -105
- package/vendor/skills/thorough-code-review/reference/report-schema.json +222 -222
- package/vendor/skills/thorough-code-review/reference/report-template.md +115 -115
- package/vendor/skills/thorough-code-review/reference/severity-rubric.md +49 -49
- package/vendor/skills/thorough-code-review/reference/signals-preflight.md +55 -55
- package/vendor/templates/adr/README.md +7 -7
- package/vendor/templates/adr/decisions.md +141 -141
- package/vendor/templates/adr/hooks/check-pending-adrs.mjs +74 -74
- package/vendor/templates/adr/hooks/check-pending-adrs.sh +3 -3
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.mjs +52 -52
- package/vendor/templates/adr/hooks/openspec-archive-agent-message.sh +3 -3
- package/vendor/templates/project/claude/commands/forge-apply.md +75 -75
- package/vendor/templates/project/claude/commands/forge-brainstorm.md +7 -7
- package/vendor/templates/project/claude/commands/forge-build.md +17 -17
- package/vendor/templates/project/claude/commands/forge-plan.md +12 -12
- package/vendor/templates/project/claude/commands/forge-skip.md +14 -14
- package/vendor/templates/project/claude/commands/forge-status.md +16 -16
- package/vendor/templates/project/claude/commands/forge.md +16 -16
- package/vendor/templates/project/claude/hooks/forge-prompt-hook.mjs +73 -73
- package/vendor/templates/project/claude/hooks/forge-session-start.mjs +19 -19
- package/vendor/templates/project/claude/hooks/forge-triage-hook.mjs +77 -77
- package/vendor/templates/project/cursor/commands/forge-apply.md +75 -75
- package/vendor/templates/project/cursor/commands/forge-brainstorm.md +10 -10
- package/vendor/templates/project/cursor/commands/forge-build.md +17 -17
- package/vendor/templates/project/cursor/commands/forge-plan.md +15 -15
- package/vendor/templates/project/cursor/commands/forge-skip.md +14 -14
- package/vendor/templates/project/cursor/commands/forge-status.md +16 -16
- package/vendor/templates/project/cursor/commands/forge.md +16 -16
- package/vendor/templates/project/cursor/hooks/forge-session-start.mjs +30 -30
- package/vendor/templates/project/cursor/hooks/forge-session-start.sh +3 -3
package/src/integrity.mjs
CHANGED
|
@@ -1,682 +1,682 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Forge runtime-integrity mechanics: spine matrix, deferral registry,
|
|
3
|
-
* executable E2E acceptance, and the integrity checks that gate
|
|
4
|
-
* `forge phase done|finish`.
|
|
5
|
-
*
|
|
6
|
-
* Spine matrix — `spine.json` in the change dir (or session dir when the
|
|
7
|
-
* session has no tracked change). One row per capability/REQ cluster:
|
|
8
|
-
* library → runtime owner → writes → reads → UI consumer → evidence.
|
|
9
|
-
* Library-only rows (missing runtime owner / writes / evidence) fail
|
|
10
|
-
* validation, so "wire later" cannot be checkboxed past `forge phase done`.
|
|
11
|
-
*
|
|
12
|
-
* E2E acceptance — `e2e.json` next to the spine: the closed product loop as
|
|
13
|
-
* an executable step list. `forge e2e run` executes it and records
|
|
14
|
-
* `e2e-results.json` (session dir) with a hash of the steps, so results go
|
|
15
|
-
* stale when steps change. When the spine has real rows, the gate requires a
|
|
16
|
-
* green, current run — prose in verify-evidence.md no longer satisfies it.
|
|
17
|
-
*
|
|
18
|
-
* Deferral registry — `deferrals.json` in the session dir. Reviewers may only
|
|
19
|
-
* accept "wiring deferred" when a registered deferral names the open task;
|
|
20
|
-
* unresolved deferrals block done/finish.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import crypto from 'node:crypto';
|
|
24
|
-
import fs from 'node:fs';
|
|
25
|
-
import path from 'node:path';
|
|
26
|
-
import { spawnSync } from 'node:child_process';
|
|
27
|
-
import { readJson, writeJson } from './lib.mjs';
|
|
28
|
-
import { loadProjectConfig } from './config.mjs';
|
|
29
|
-
import { DEFAULT_SPECS_DIR, resolveProjectPlanEngine } from './plan-engine.mjs';
|
|
30
|
-
|
|
31
|
-
/** Signals that a change involves jobs/workers and therefore needs a spine. */
|
|
32
|
-
export const JOBS_SIGNAL_RE =
|
|
33
|
-
/\b(worker|workers|job|jobs|queue|queues|pipeline|pipelines|etl|orchestration|handler|handlers|cron|scheduler|daemon|ingest|dispatch)\b/i;
|
|
34
|
-
|
|
35
|
-
/** Row fields that must be filled (reads/uiConsumer accept "N/A"). */
|
|
36
|
-
export const SPINE_ROW_REQUIRED = Object.freeze([
|
|
37
|
-
'capability',
|
|
38
|
-
'library',
|
|
39
|
-
'runtimeOwner',
|
|
40
|
-
'writes',
|
|
41
|
-
'reads',
|
|
42
|
-
'uiConsumer',
|
|
43
|
-
'evidence',
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
|
-
const SPINE_FILE = 'spine.json';
|
|
47
|
-
const DEFERRALS_FILE = 'deferrals.json';
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* @param {unknown} value
|
|
51
|
-
*/
|
|
52
|
-
function isNonEmptyString(value) {
|
|
53
|
-
return typeof value === 'string' && value.trim().length > 0;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Resolve the change directory for a session (openspec or specs engine).
|
|
58
|
-
* Returns null when the session has no tracked change.
|
|
59
|
-
*
|
|
60
|
-
* @param {{ cwd?: string, session?: Record<string, unknown> | null }} opts
|
|
61
|
-
*/
|
|
62
|
-
/**
|
|
63
|
-
* The archived copy of a change, when the live dir is gone. Archiving moves
|
|
64
|
-
* `changes/<change>/` to `changes/archive/<YYYY-MM-DD>-<change>/`, so the
|
|
65
|
-
* done-gate integrity check (which runs *after* archive in the finish flow)
|
|
66
|
-
* would otherwise never find spine.json/e2e.json. Returns null if no match.
|
|
67
|
-
*
|
|
68
|
-
* @param {string} changesDir absolute path to `<root>/changes`
|
|
69
|
-
* @param {string} change change name
|
|
70
|
-
*/
|
|
71
|
-
function findArchivedChangeDir(changesDir, change) {
|
|
72
|
-
const archiveDir = path.join(changesDir, 'archive');
|
|
73
|
-
if (!fs.existsSync(archiveDir)) return null;
|
|
74
|
-
let entries;
|
|
75
|
-
try {
|
|
76
|
-
entries = fs.readdirSync(archiveDir, { withFileTypes: true });
|
|
77
|
-
} catch {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
const matches = entries
|
|
81
|
-
.filter((e) => e.isDirectory())
|
|
82
|
-
.map((e) => e.name)
|
|
83
|
-
// CLI + documented manual archive both name dirs `YYYY-MM-DD-<change>`;
|
|
84
|
-
// slice(11) drops the `YYYY-MM-DD-` prefix so the suffix must equal the
|
|
85
|
-
// change exactly (no false match on `…-other-<change>`).
|
|
86
|
-
.filter((name) => name === change || (/^\d{4}-\d{2}-\d{2}-/.test(name) && name.slice(11) === change))
|
|
87
|
-
.sort();
|
|
88
|
-
// Date-prefixed names sort lexically by date — newest archive wins.
|
|
89
|
-
return matches.length ? path.join(archiveDir, matches[matches.length - 1]) : null;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Live change dir if present, else its archived copy, else the live path. */
|
|
93
|
-
function liveOrArchived(changesDir, change) {
|
|
94
|
-
const liveDir = path.join(changesDir, change);
|
|
95
|
-
if (fs.existsSync(liveDir)) return liveDir;
|
|
96
|
-
return findArchivedChangeDir(changesDir, change) ?? liveDir;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function resolveChangeDir(opts = {}) {
|
|
100
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
101
|
-
const session = opts.session ?? null;
|
|
102
|
-
// Write callers (spine/e2e init) must always target the LIVE change dir —
|
|
103
|
-
// scaffolding into an archived record would corrupt frozen history. Only
|
|
104
|
-
// read callers (checks, gates) fall back to the archive.
|
|
105
|
-
const forWrite = opts.forWrite === true;
|
|
106
|
-
const change = session && isNonEmptyString(session.openspecChange) ? session.openspecChange : null;
|
|
107
|
-
if (!change) return null;
|
|
108
|
-
|
|
109
|
-
const openspecChanges = path.join(cwd, 'openspec', 'changes');
|
|
110
|
-
const openspecDir = path.join(openspecChanges, change);
|
|
111
|
-
if (session.planType === 'openspec') {
|
|
112
|
-
return forWrite ? openspecDir : liveOrArchived(openspecChanges, change);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
let specsRoot = DEFAULT_SPECS_DIR;
|
|
116
|
-
try {
|
|
117
|
-
specsRoot = resolveProjectPlanEngine(cwd, { useUserDefault: false }).dir;
|
|
118
|
-
} catch {
|
|
119
|
-
// keep default
|
|
120
|
-
}
|
|
121
|
-
const specsChanges = path.join(cwd, specsRoot, 'changes');
|
|
122
|
-
const specsDir = path.join(specsChanges, change);
|
|
123
|
-
if (session.planType === 'specs') {
|
|
124
|
-
return forWrite ? specsDir : liveOrArchived(specsChanges, change);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// planType unknown — prefer whichever exists (live first, then archived)
|
|
128
|
-
if (fs.existsSync(openspecDir)) return openspecDir;
|
|
129
|
-
if (fs.existsSync(specsDir)) return specsDir;
|
|
130
|
-
if (forWrite) return openspecDir;
|
|
131
|
-
return (
|
|
132
|
-
findArchivedChangeDir(openspecChanges, change) ??
|
|
133
|
-
findArchivedChangeDir(specsChanges, change) ??
|
|
134
|
-
openspecDir
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Path to spine.json: change dir when available, else session dir.
|
|
140
|
-
*
|
|
141
|
-
* @param {{ cwd?: string, session?: Record<string, unknown> | null, sessionDir?: string }} opts
|
|
142
|
-
*/
|
|
143
|
-
export function spinePath(opts = {}) {
|
|
144
|
-
const changeDir = resolveChangeDir(opts);
|
|
145
|
-
if (changeDir) return path.join(changeDir, SPINE_FILE);
|
|
146
|
-
if (opts.sessionDir) return path.join(opts.sessionDir, SPINE_FILE);
|
|
147
|
-
throw new Error('Cannot resolve spine.json location: no change and no session dir');
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* @param {{ change?: string | null }} [opts]
|
|
152
|
-
*/
|
|
153
|
-
export function spineTemplate(opts = {}) {
|
|
154
|
-
return {
|
|
155
|
-
change: opts.change ?? null,
|
|
156
|
-
notApplicable: null,
|
|
157
|
-
rows: [
|
|
158
|
-
{
|
|
159
|
-
capability: '<REQ id or capability cluster, e.g. REQ-GOV-01 matching>',
|
|
160
|
-
library: '<module path, e.g. services/etl-core/matcher.py>',
|
|
161
|
-
runtimeOwner: '<production caller, e.g. worker job analyze_study>',
|
|
162
|
-
writes: '<artifact/collection, e.g. study_proposals>',
|
|
163
|
-
reads: '<consumed inputs, or N/A>',
|
|
164
|
-
uiConsumer: '<UI/API surface reading the writes, or N/A>',
|
|
165
|
-
evidence: '<tier-2/E2E evidence path proving the wired path>',
|
|
166
|
-
},
|
|
167
|
-
],
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Scaffold spine.json (refuses to overwrite unless force).
|
|
173
|
-
*
|
|
174
|
-
* @param {{ file: string, change?: string | null, force?: boolean }} opts
|
|
175
|
-
*/
|
|
176
|
-
export function initSpine(opts) {
|
|
177
|
-
if (fs.existsSync(opts.file) && !opts.force) {
|
|
178
|
-
throw new Error(`spine.json already exists: ${opts.file} (use --force to overwrite)`);
|
|
179
|
-
}
|
|
180
|
-
writeJson(opts.file, spineTemplate({ change: opts.change }));
|
|
181
|
-
return opts.file;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Validate a spine document.
|
|
186
|
-
*
|
|
187
|
-
* Valid when either:
|
|
188
|
-
* - `notApplicable` is a non-empty string (honest opt-out, e.g. docs-only), or
|
|
189
|
-
* - `rows` is a non-empty array where every required cell is filled and no
|
|
190
|
-
* cell still contains scaffold placeholders (`<...>`).
|
|
191
|
-
*
|
|
192
|
-
* @param {unknown} doc
|
|
193
|
-
* @returns {{ ok: boolean, problems: string[] }}
|
|
194
|
-
*/
|
|
195
|
-
export function validateSpine(doc) {
|
|
196
|
-
/** @type {string[]} */
|
|
197
|
-
const problems = [];
|
|
198
|
-
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
199
|
-
return { ok: false, problems: ['spine.json is not an object'] };
|
|
200
|
-
}
|
|
201
|
-
const spine = /** @type {Record<string, unknown>} */ (doc);
|
|
202
|
-
|
|
203
|
-
if (isNonEmptyString(spine.notApplicable)) {
|
|
204
|
-
return { ok: true, problems: [] };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const rows = spine.rows;
|
|
208
|
-
if (!Array.isArray(rows) || rows.length === 0) {
|
|
209
|
-
return {
|
|
210
|
-
ok: false,
|
|
211
|
-
problems: ['spine.rows is empty — add one row per capability, or set notApplicable with a reason'],
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
rows.forEach((row, i) => {
|
|
216
|
-
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
217
|
-
problems.push(`row ${i + 1}: not an object`);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
const r = /** @type {Record<string, unknown>} */ (row);
|
|
221
|
-
for (const field of SPINE_ROW_REQUIRED) {
|
|
222
|
-
const value = r[field];
|
|
223
|
-
if (!isNonEmptyString(value)) {
|
|
224
|
-
problems.push(`row ${i + 1} (${r.capability ?? '?'}): missing ${field}`);
|
|
225
|
-
} else if (/^<.*>$/.test(value.trim())) {
|
|
226
|
-
problems.push(`row ${i + 1} (${r.capability ?? '?'}): ${field} still has scaffold placeholder`);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
return { ok: problems.length === 0, problems };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/* ------------------------------------------------------------------ */
|
|
235
|
-
/* E2E acceptance — executable product loop */
|
|
236
|
-
/* ------------------------------------------------------------------ */
|
|
237
|
-
|
|
238
|
-
const E2E_FILE = 'e2e.json';
|
|
239
|
-
const E2E_RESULTS_FILE = 'e2e-results.json';
|
|
240
|
-
export const E2E_DEFAULT_TIMEOUT_MS = 300_000;
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Path to e2e.json: change dir when available, else session dir.
|
|
244
|
-
*
|
|
245
|
-
* @param {{ cwd?: string, session?: Record<string, unknown> | null, sessionDir?: string }} opts
|
|
246
|
-
*/
|
|
247
|
-
export function e2ePath(opts = {}) {
|
|
248
|
-
const changeDir = resolveChangeDir(opts);
|
|
249
|
-
if (changeDir) return path.join(changeDir, E2E_FILE);
|
|
250
|
-
if (opts.sessionDir) return path.join(opts.sessionDir, E2E_FILE);
|
|
251
|
-
throw new Error('Cannot resolve e2e.json location: no change and no session dir');
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* @param {{ change?: string | null }} [opts]
|
|
256
|
-
*/
|
|
257
|
-
export function e2eTemplate(opts = {}) {
|
|
258
|
-
return {
|
|
259
|
-
change: opts.change ?? null,
|
|
260
|
-
notApplicable: null,
|
|
261
|
-
steps: [
|
|
262
|
-
{
|
|
263
|
-
name: '<boot>',
|
|
264
|
-
cmd: '<command that starts the system, e.g. docker compose up -d api worker>',
|
|
265
|
-
},
|
|
266
|
-
{
|
|
267
|
-
name: '<produce>',
|
|
268
|
-
cmd: '<command that drives the real production entry point, e.g. node scripts/e2e/enqueue-analyze.mjs>',
|
|
269
|
-
expect: '<regex the combined output must match — delete this field if exit code 0 is enough>',
|
|
270
|
-
},
|
|
271
|
-
{
|
|
272
|
-
name: '<consume-assert>',
|
|
273
|
-
cmd: '<command that proves the domain side effects exist, e.g. node scripts/e2e/assert-ratified.mjs>',
|
|
274
|
-
},
|
|
275
|
-
],
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Scaffold e2e.json (refuses to overwrite unless force).
|
|
281
|
-
*
|
|
282
|
-
* @param {{ file: string, change?: string | null, force?: boolean }} opts
|
|
283
|
-
*/
|
|
284
|
-
export function initE2e(opts) {
|
|
285
|
-
if (fs.existsSync(opts.file) && !opts.force) {
|
|
286
|
-
throw new Error(`e2e.json already exists: ${opts.file} (use --force to overwrite)`);
|
|
287
|
-
}
|
|
288
|
-
writeJson(opts.file, e2eTemplate({ change: opts.change }));
|
|
289
|
-
return opts.file;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Validate an e2e document.
|
|
294
|
-
*
|
|
295
|
-
* Valid when either:
|
|
296
|
-
* - `notApplicable` is a non-empty string (loop cannot be driven by any
|
|
297
|
-
* command — reviewers police the reason), or
|
|
298
|
-
* - `steps` is a non-empty array where every step has a filled `name` and
|
|
299
|
-
* `cmd` (no scaffold placeholders), `expect` (optional) is a valid regex,
|
|
300
|
-
* and `timeoutMs` (optional) is a positive number.
|
|
301
|
-
*
|
|
302
|
-
* @param {unknown} doc
|
|
303
|
-
* @returns {{ ok: boolean, problems: string[] }}
|
|
304
|
-
*/
|
|
305
|
-
export function validateE2e(doc) {
|
|
306
|
-
/** @type {string[]} */
|
|
307
|
-
const problems = [];
|
|
308
|
-
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
309
|
-
return { ok: false, problems: ['e2e.json is not an object'] };
|
|
310
|
-
}
|
|
311
|
-
const e2e = /** @type {Record<string, unknown>} */ (doc);
|
|
312
|
-
|
|
313
|
-
if (isNonEmptyString(e2e.notApplicable)) {
|
|
314
|
-
return { ok: true, problems: [] };
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const steps = e2e.steps;
|
|
318
|
-
if (!Array.isArray(steps) || steps.length === 0) {
|
|
319
|
-
return {
|
|
320
|
-
ok: false,
|
|
321
|
-
problems: [
|
|
322
|
-
'e2e.steps is empty — add executable product-loop steps, or set notApplicable with a reason',
|
|
323
|
-
],
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
steps.forEach((step, i) => {
|
|
328
|
-
if (!step || typeof step !== 'object' || Array.isArray(step)) {
|
|
329
|
-
problems.push(`step ${i + 1}: not an object`);
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
const s = /** @type {Record<string, unknown>} */ (step);
|
|
333
|
-
for (const field of ['name', 'cmd']) {
|
|
334
|
-
const value = s[field];
|
|
335
|
-
if (!isNonEmptyString(value)) {
|
|
336
|
-
problems.push(`step ${i + 1} (${s.name ?? '?'}): missing ${field}`);
|
|
337
|
-
} else if (/^<.*>$/.test(value.trim())) {
|
|
338
|
-
problems.push(`step ${i + 1} (${s.name ?? '?'}): ${field} still has scaffold placeholder`);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
if (s.expect !== undefined && s.expect !== null) {
|
|
342
|
-
if (!isNonEmptyString(s.expect)) {
|
|
343
|
-
problems.push(`step ${i + 1} (${s.name ?? '?'}): expect must be a non-empty regex string`);
|
|
344
|
-
} else if (/^<.*>$/.test(s.expect.trim())) {
|
|
345
|
-
problems.push(`step ${i + 1} (${s.name ?? '?'}): expect still has scaffold placeholder`);
|
|
346
|
-
} else {
|
|
347
|
-
try {
|
|
348
|
-
new RegExp(s.expect);
|
|
349
|
-
} catch {
|
|
350
|
-
problems.push(`step ${i + 1} (${s.name ?? '?'}): expect is not a valid regex`);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
if (s.timeoutMs !== undefined && (typeof s.timeoutMs !== 'number' || s.timeoutMs <= 0)) {
|
|
355
|
-
problems.push(`step ${i + 1} (${s.name ?? '?'}): timeoutMs must be a positive number`);
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
return { ok: problems.length === 0, problems };
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Hash of the step list — recorded in results so editing e2e.json after a
|
|
364
|
-
* green run invalidates the results.
|
|
365
|
-
*
|
|
366
|
-
* @param {unknown[]} steps
|
|
367
|
-
*/
|
|
368
|
-
export function e2eStepsHash(steps) {
|
|
369
|
-
return crypto.createHash('sha256').update(JSON.stringify(steps ?? [])).digest('hex');
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* @param {string} text
|
|
374
|
-
*/
|
|
375
|
-
function outputTail(text, lines = 30) {
|
|
376
|
-
if (!text) return '';
|
|
377
|
-
return text.split(/\r?\n/).slice(-lines).join('\n').trim();
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Execute e2e steps sequentially (shell). Stops at the first failure —
|
|
382
|
-
* later steps depend on earlier ones. Exit code must be 0 and `expect`
|
|
383
|
-
* (when present) must match combined stdout+stderr.
|
|
384
|
-
*
|
|
385
|
-
* @param {{ steps?: unknown[] }} doc — a validated e2e document with steps
|
|
386
|
-
* @param {{ cwd?: string }} [opts]
|
|
387
|
-
*/
|
|
388
|
-
export function runE2eSteps(doc, opts = {}) {
|
|
389
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
390
|
-
const steps = Array.isArray(doc?.steps) ? doc.steps : [];
|
|
391
|
-
/** @type {Array<Record<string, unknown>>} */
|
|
392
|
-
const results = [];
|
|
393
|
-
let ok = true;
|
|
394
|
-
|
|
395
|
-
for (const step of steps) {
|
|
396
|
-
const s = /** @type {Record<string, any>} */ (step);
|
|
397
|
-
if (!ok) {
|
|
398
|
-
results.push({ name: s.name, cmd: s.cmd, skipped: true });
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
401
|
-
const started = Date.now();
|
|
402
|
-
const r = spawnSync(s.cmd, {
|
|
403
|
-
shell: true,
|
|
404
|
-
cwd,
|
|
405
|
-
encoding: 'utf8',
|
|
406
|
-
timeout: typeof s.timeoutMs === 'number' ? s.timeoutMs : E2E_DEFAULT_TIMEOUT_MS,
|
|
407
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
408
|
-
});
|
|
409
|
-
const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
|
|
410
|
-
const exitCode = typeof r.status === 'number' ? r.status : null;
|
|
411
|
-
let expectMatched = null;
|
|
412
|
-
let stepOk = exitCode === 0;
|
|
413
|
-
if (stepOk && isNonEmptyString(s.expect)) {
|
|
414
|
-
expectMatched = new RegExp(s.expect).test(output);
|
|
415
|
-
stepOk = expectMatched;
|
|
416
|
-
}
|
|
417
|
-
results.push({
|
|
418
|
-
name: s.name,
|
|
419
|
-
cmd: s.cmd,
|
|
420
|
-
exitCode,
|
|
421
|
-
expectMatched,
|
|
422
|
-
ok: stepOk,
|
|
423
|
-
durationMs: Date.now() - started,
|
|
424
|
-
outputTail: outputTail(output),
|
|
425
|
-
error: r.error ? String(r.error.message ?? r.error) : null,
|
|
426
|
-
});
|
|
427
|
-
if (!stepOk) ok = false;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
return {
|
|
431
|
-
ok,
|
|
432
|
-
ranAt: new Date().toISOString(),
|
|
433
|
-
stepsHash: e2eStepsHash(steps),
|
|
434
|
-
steps: results,
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* @param {string} sessionDir
|
|
440
|
-
*/
|
|
441
|
-
export function e2eResultsPath(sessionDir) {
|
|
442
|
-
return path.join(sessionDir, E2E_RESULTS_FILE);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* @param {string} sessionDir
|
|
447
|
-
* @param {ReturnType<typeof runE2eSteps>} results
|
|
448
|
-
*/
|
|
449
|
-
export function writeE2eResults(sessionDir, results) {
|
|
450
|
-
writeJson(e2eResultsPath(sessionDir), results);
|
|
451
|
-
return e2eResultsPath(sessionDir);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* @param {string} sessionDir
|
|
456
|
-
* @returns {Record<string, any> | null}
|
|
457
|
-
*/
|
|
458
|
-
export function loadE2eResults(sessionDir) {
|
|
459
|
-
const file = e2eResultsPath(sessionDir);
|
|
460
|
-
if (!fs.existsSync(file)) return null;
|
|
461
|
-
try {
|
|
462
|
-
return readJson(file);
|
|
463
|
-
} catch {
|
|
464
|
-
return null;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* Gate check for the executable E2E acceptance. Returns the problems that
|
|
470
|
-
* block `forge phase done` — empty when the loop was executed green (and the
|
|
471
|
-
* results are current), or when e2e.json honestly opts out via notApplicable.
|
|
472
|
-
*
|
|
473
|
-
* @param {{ e2eFile: string, sessionDir: string }} opts
|
|
474
|
-
* @returns {{ problems: string[], notApplicable: boolean }}
|
|
475
|
-
*/
|
|
476
|
-
export function checkE2eGate(opts) {
|
|
477
|
-
/** @type {string[]} */
|
|
478
|
-
const problems = [];
|
|
479
|
-
|
|
480
|
-
if (!fs.existsSync(opts.e2eFile)) {
|
|
481
|
-
problems.push(
|
|
482
|
-
`e2e.json required at ${opts.e2eFile} — run forge e2e init, author the product-loop steps, then forge e2e run. Spine rows mean an async loop exists; it must be executed, not described.`,
|
|
483
|
-
);
|
|
484
|
-
return { problems, notApplicable: false };
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
let doc;
|
|
488
|
-
try {
|
|
489
|
-
doc = readJson(opts.e2eFile);
|
|
490
|
-
} catch (err) {
|
|
491
|
-
problems.push(`e2e.json unreadable: ${err instanceof Error ? err.message : err}`);
|
|
492
|
-
return { problems, notApplicable: false };
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
const valid = validateE2e(doc);
|
|
496
|
-
if (!valid.ok) {
|
|
497
|
-
problems.push(...valid.problems.map((p) => `e2e: ${p}`));
|
|
498
|
-
return { problems, notApplicable: false };
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (isNonEmptyString(doc.notApplicable)) {
|
|
502
|
-
return { problems: [], notApplicable: true };
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
const results = loadE2eResults(opts.sessionDir);
|
|
506
|
-
if (!results) {
|
|
507
|
-
problems.push('e2e-results.json missing — run forge e2e run (a green run is required before done)');
|
|
508
|
-
} else if (results.stepsHash !== e2eStepsHash(doc.steps)) {
|
|
509
|
-
problems.push('e2e-results.json is stale — e2e.json changed since the last run; re-run forge e2e run');
|
|
510
|
-
} else if (!results.ok) {
|
|
511
|
-
const failed = Array.isArray(results.steps) ? results.steps.find((s) => s?.ok === false) : null;
|
|
512
|
-
problems.push(
|
|
513
|
-
`e2e run failed${failed ? ` at step "${failed.name}"` : ''} — fix and re-run forge e2e run`,
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
return { problems, notApplicable: false };
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* @param {string} sessionDir
|
|
522
|
-
*/
|
|
523
|
-
export function deferralsPath(sessionDir) {
|
|
524
|
-
return path.join(sessionDir, DEFERRALS_FILE);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* @param {string} sessionDir
|
|
529
|
-
* @returns {{ deferrals: Array<{ task: string, reason: string, createdAt: string, resolvedAt: string | null }> }}
|
|
530
|
-
*/
|
|
531
|
-
export function loadDeferrals(sessionDir) {
|
|
532
|
-
const file = deferralsPath(sessionDir);
|
|
533
|
-
if (!fs.existsSync(file)) return { deferrals: [] };
|
|
534
|
-
const doc = readJson(file);
|
|
535
|
-
return { deferrals: Array.isArray(doc?.deferrals) ? doc.deferrals : [] };
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* @param {string} sessionDir
|
|
540
|
-
* @param {{ task: string, reason: string }} entry
|
|
541
|
-
*/
|
|
542
|
-
export function addDeferral(sessionDir, entry) {
|
|
543
|
-
if (!isNonEmptyString(entry.task)) throw new Error('Deferral requires --task <id>');
|
|
544
|
-
if (!isNonEmptyString(entry.reason)) throw new Error('Deferral requires --reason "<why>"');
|
|
545
|
-
const doc = loadDeferrals(sessionDir);
|
|
546
|
-
if (doc.deferrals.some((d) => d.task === entry.task && !d.resolvedAt)) {
|
|
547
|
-
throw new Error(`Deferral for task ${entry.task} already open`);
|
|
548
|
-
}
|
|
549
|
-
doc.deferrals.push({
|
|
550
|
-
task: entry.task,
|
|
551
|
-
reason: entry.reason,
|
|
552
|
-
createdAt: new Date().toISOString(),
|
|
553
|
-
resolvedAt: null,
|
|
554
|
-
});
|
|
555
|
-
writeJson(deferralsPath(sessionDir), doc);
|
|
556
|
-
return doc;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* @param {string} sessionDir
|
|
561
|
-
* @param {string} task
|
|
562
|
-
*/
|
|
563
|
-
export function resolveDeferral(sessionDir, task) {
|
|
564
|
-
const doc = loadDeferrals(sessionDir);
|
|
565
|
-
const open = doc.deferrals.find((d) => d.task === task && !d.resolvedAt);
|
|
566
|
-
if (!open) throw new Error(`No open deferral for task ${task}`);
|
|
567
|
-
open.resolvedAt = new Date().toISOString();
|
|
568
|
-
writeJson(deferralsPath(sessionDir), doc);
|
|
569
|
-
return doc;
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* @param {string} sessionDir
|
|
574
|
-
*/
|
|
575
|
-
export function openDeferrals(sessionDir) {
|
|
576
|
-
return loadDeferrals(sessionDir).deferrals.filter((d) => !d.resolvedAt);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/**
|
|
580
|
-
* @param {Record<string, unknown> | null | undefined} session
|
|
581
|
-
*/
|
|
582
|
-
export function sessionJobsSignalText(session) {
|
|
583
|
-
return [session?.paceSignal, session?.slug, session?.openspecChange]
|
|
584
|
-
.filter((v) => isNonEmptyString(v))
|
|
585
|
-
.join(' ');
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
/**
|
|
589
|
-
* Run the mechanical integrity checks for a session.
|
|
590
|
-
*
|
|
591
|
-
* Checks:
|
|
592
|
-
* 1. No unresolved deferrals.
|
|
593
|
-
* 2. spine.json — **always required** (filled rows, or `notApplicable` with a
|
|
594
|
-
* reason). Keyword sniffing is not enough to decide; missing spine is how
|
|
595
|
-
* library-only platforms checkbox past gaps.
|
|
596
|
-
* 3. E2E acceptance — when a spine has real rows (not notApplicable):
|
|
597
|
-
* e2e.json must exist with filled steps (or its own notApplicable reason)
|
|
598
|
-
* and e2e-results.json must record a green, current run (steps hash must
|
|
599
|
-
* match). Prose in verify-evidence.md does not satisfy this; an explicit
|
|
600
|
-
* BLOCKED marker there still means the change cannot be done. Sync-only
|
|
601
|
-
* work should prefer spine `notApplicable` over inventing a fake loop.
|
|
602
|
-
*
|
|
603
|
-
* @param {{ cwd?: string, sessionDir: string, session: Record<string, unknown> }} opts
|
|
604
|
-
* @returns {{ ok: boolean, problems: string[], spineFile: string, spineExists: boolean, e2eFile: string | null }}
|
|
605
|
-
*/
|
|
606
|
-
/**
|
|
607
|
-
* Project-level e2e off switch: `.forge/config.json` → `e2e.disabled` set to a
|
|
608
|
-
* non-empty reason string. Operator-set via `forge e2e disable "<reason>"` —
|
|
609
|
-
* agents must never set it themselves. When set, the integrity gate stops
|
|
610
|
-
* demanding an executed green e2e run (the most time-consuming part of a
|
|
611
|
-
* session); spine, deferrals, evidence, and BLOCKED checks still apply.
|
|
612
|
-
*
|
|
613
|
-
* @param {string} [cwd]
|
|
614
|
-
* @returns {string | null} the reason, or null when e2e is enabled
|
|
615
|
-
*/
|
|
616
|
-
export function e2eDisabledReason(cwd = process.cwd()) {
|
|
617
|
-
try {
|
|
618
|
-
const reason = loadProjectConfig(cwd)?.e2e?.disabled;
|
|
619
|
-
return isNonEmptyString(reason) ? reason : null;
|
|
620
|
-
} catch {
|
|
621
|
-
return null;
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
export function runIntegrityChecks(opts) {
|
|
626
|
-
/** @type {string[]} */
|
|
627
|
-
const problems = [];
|
|
628
|
-
const { sessionDir, session } = opts;
|
|
629
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
630
|
-
|
|
631
|
-
const open = openDeferrals(sessionDir);
|
|
632
|
-
if (open.length > 0) {
|
|
633
|
-
problems.push(
|
|
634
|
-
`unresolved deferrals: ${open.map((d) => `${d.task} (${d.reason})`).join('; ')} — resolve via forge defer resolve --task <id>`,
|
|
635
|
-
);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
const spineFile = spinePath({ cwd, session, sessionDir });
|
|
639
|
-
const spineExists = fs.existsSync(spineFile);
|
|
640
|
-
|
|
641
|
-
/** @type {ReturnType<typeof validateSpine> | null} */
|
|
642
|
-
let spineResult = null;
|
|
643
|
-
let spineHasRows = false;
|
|
644
|
-
if (!spineExists) {
|
|
645
|
-
problems.push(
|
|
646
|
-
`spine.json required at ${spineFile} — run forge spine init, then fill rows (or set notApplicable with a reason). Spine is mandatory for every change so capability→runtime wiring cannot be skipped by accident.`,
|
|
647
|
-
);
|
|
648
|
-
} else {
|
|
649
|
-
try {
|
|
650
|
-
const doc = readJson(spineFile);
|
|
651
|
-
spineResult = validateSpine(doc);
|
|
652
|
-
spineHasRows =
|
|
653
|
-
Array.isArray(doc?.rows) &&
|
|
654
|
-
doc.rows.length > 0 &&
|
|
655
|
-
!isNonEmptyString(doc?.notApplicable);
|
|
656
|
-
} catch (err) {
|
|
657
|
-
spineResult = {
|
|
658
|
-
ok: false,
|
|
659
|
-
problems: [`spine.json unreadable: ${err instanceof Error ? err.message : err}`],
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
if (!spineResult.ok) {
|
|
663
|
-
problems.push(...spineResult.problems.map((p) => `spine: ${p}`));
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
let e2eFile = null;
|
|
668
|
-
const e2eDisabled = e2eDisabledReason(cwd);
|
|
669
|
-
if (spineExists && spineHasRows) {
|
|
670
|
-
if (!e2eDisabled) {
|
|
671
|
-
e2eFile = e2ePath({ cwd, session, sessionDir });
|
|
672
|
-
problems.push(...checkE2eGate({ e2eFile, sessionDir }).problems);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
const evidenceFile = path.join(sessionDir, 'verify-evidence.md');
|
|
676
|
-
if (fs.existsSync(evidenceFile) && /\bBLOCKED\b/.test(fs.readFileSync(evidenceFile, 'utf8'))) {
|
|
677
|
-
problems.push('verify-evidence.md contains BLOCKED — change cannot be marked done while E2E is blocked');
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
return { ok: problems.length === 0, problems, spineFile, spineExists, e2eFile, e2eDisabled };
|
|
682
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Forge runtime-integrity mechanics: spine matrix, deferral registry,
|
|
3
|
+
* executable E2E acceptance, and the integrity checks that gate
|
|
4
|
+
* `forge phase done|finish`.
|
|
5
|
+
*
|
|
6
|
+
* Spine matrix — `spine.json` in the change dir (or session dir when the
|
|
7
|
+
* session has no tracked change). One row per capability/REQ cluster:
|
|
8
|
+
* library → runtime owner → writes → reads → UI consumer → evidence.
|
|
9
|
+
* Library-only rows (missing runtime owner / writes / evidence) fail
|
|
10
|
+
* validation, so "wire later" cannot be checkboxed past `forge phase done`.
|
|
11
|
+
*
|
|
12
|
+
* E2E acceptance — `e2e.json` next to the spine: the closed product loop as
|
|
13
|
+
* an executable step list. `forge e2e run` executes it and records
|
|
14
|
+
* `e2e-results.json` (session dir) with a hash of the steps, so results go
|
|
15
|
+
* stale when steps change. When the spine has real rows, the gate requires a
|
|
16
|
+
* green, current run — prose in verify-evidence.md no longer satisfies it.
|
|
17
|
+
*
|
|
18
|
+
* Deferral registry — `deferrals.json` in the session dir. Reviewers may only
|
|
19
|
+
* accept "wiring deferred" when a registered deferral names the open task;
|
|
20
|
+
* unresolved deferrals block done/finish.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { readJson, writeJson } from './lib.mjs';
|
|
28
|
+
import { loadProjectConfig } from './config.mjs';
|
|
29
|
+
import { DEFAULT_SPECS_DIR, resolveProjectPlanEngine } from './plan-engine.mjs';
|
|
30
|
+
|
|
31
|
+
/** Signals that a change involves jobs/workers and therefore needs a spine. */
|
|
32
|
+
export const JOBS_SIGNAL_RE =
|
|
33
|
+
/\b(worker|workers|job|jobs|queue|queues|pipeline|pipelines|etl|orchestration|handler|handlers|cron|scheduler|daemon|ingest|dispatch)\b/i;
|
|
34
|
+
|
|
35
|
+
/** Row fields that must be filled (reads/uiConsumer accept "N/A"). */
|
|
36
|
+
export const SPINE_ROW_REQUIRED = Object.freeze([
|
|
37
|
+
'capability',
|
|
38
|
+
'library',
|
|
39
|
+
'runtimeOwner',
|
|
40
|
+
'writes',
|
|
41
|
+
'reads',
|
|
42
|
+
'uiConsumer',
|
|
43
|
+
'evidence',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const SPINE_FILE = 'spine.json';
|
|
47
|
+
const DEFERRALS_FILE = 'deferrals.json';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {unknown} value
|
|
51
|
+
*/
|
|
52
|
+
function isNonEmptyString(value) {
|
|
53
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the change directory for a session (openspec or specs engine).
|
|
58
|
+
* Returns null when the session has no tracked change.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ cwd?: string, session?: Record<string, unknown> | null }} opts
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* The archived copy of a change, when the live dir is gone. Archiving moves
|
|
64
|
+
* `changes/<change>/` to `changes/archive/<YYYY-MM-DD>-<change>/`, so the
|
|
65
|
+
* done-gate integrity check (which runs *after* archive in the finish flow)
|
|
66
|
+
* would otherwise never find spine.json/e2e.json. Returns null if no match.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} changesDir absolute path to `<root>/changes`
|
|
69
|
+
* @param {string} change change name
|
|
70
|
+
*/
|
|
71
|
+
function findArchivedChangeDir(changesDir, change) {
|
|
72
|
+
const archiveDir = path.join(changesDir, 'archive');
|
|
73
|
+
if (!fs.existsSync(archiveDir)) return null;
|
|
74
|
+
let entries;
|
|
75
|
+
try {
|
|
76
|
+
entries = fs.readdirSync(archiveDir, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const matches = entries
|
|
81
|
+
.filter((e) => e.isDirectory())
|
|
82
|
+
.map((e) => e.name)
|
|
83
|
+
// CLI + documented manual archive both name dirs `YYYY-MM-DD-<change>`;
|
|
84
|
+
// slice(11) drops the `YYYY-MM-DD-` prefix so the suffix must equal the
|
|
85
|
+
// change exactly (no false match on `…-other-<change>`).
|
|
86
|
+
.filter((name) => name === change || (/^\d{4}-\d{2}-\d{2}-/.test(name) && name.slice(11) === change))
|
|
87
|
+
.sort();
|
|
88
|
+
// Date-prefixed names sort lexically by date — newest archive wins.
|
|
89
|
+
return matches.length ? path.join(archiveDir, matches[matches.length - 1]) : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Live change dir if present, else its archived copy, else the live path. */
|
|
93
|
+
function liveOrArchived(changesDir, change) {
|
|
94
|
+
const liveDir = path.join(changesDir, change);
|
|
95
|
+
if (fs.existsSync(liveDir)) return liveDir;
|
|
96
|
+
return findArchivedChangeDir(changesDir, change) ?? liveDir;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resolveChangeDir(opts = {}) {
|
|
100
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
101
|
+
const session = opts.session ?? null;
|
|
102
|
+
// Write callers (spine/e2e init) must always target the LIVE change dir —
|
|
103
|
+
// scaffolding into an archived record would corrupt frozen history. Only
|
|
104
|
+
// read callers (checks, gates) fall back to the archive.
|
|
105
|
+
const forWrite = opts.forWrite === true;
|
|
106
|
+
const change = session && isNonEmptyString(session.openspecChange) ? session.openspecChange : null;
|
|
107
|
+
if (!change) return null;
|
|
108
|
+
|
|
109
|
+
const openspecChanges = path.join(cwd, 'openspec', 'changes');
|
|
110
|
+
const openspecDir = path.join(openspecChanges, change);
|
|
111
|
+
if (session.planType === 'openspec') {
|
|
112
|
+
return forWrite ? openspecDir : liveOrArchived(openspecChanges, change);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let specsRoot = DEFAULT_SPECS_DIR;
|
|
116
|
+
try {
|
|
117
|
+
specsRoot = resolveProjectPlanEngine(cwd, { useUserDefault: false }).dir;
|
|
118
|
+
} catch {
|
|
119
|
+
// keep default
|
|
120
|
+
}
|
|
121
|
+
const specsChanges = path.join(cwd, specsRoot, 'changes');
|
|
122
|
+
const specsDir = path.join(specsChanges, change);
|
|
123
|
+
if (session.planType === 'specs') {
|
|
124
|
+
return forWrite ? specsDir : liveOrArchived(specsChanges, change);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// planType unknown — prefer whichever exists (live first, then archived)
|
|
128
|
+
if (fs.existsSync(openspecDir)) return openspecDir;
|
|
129
|
+
if (fs.existsSync(specsDir)) return specsDir;
|
|
130
|
+
if (forWrite) return openspecDir;
|
|
131
|
+
return (
|
|
132
|
+
findArchivedChangeDir(openspecChanges, change) ??
|
|
133
|
+
findArchivedChangeDir(specsChanges, change) ??
|
|
134
|
+
openspecDir
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Path to spine.json: change dir when available, else session dir.
|
|
140
|
+
*
|
|
141
|
+
* @param {{ cwd?: string, session?: Record<string, unknown> | null, sessionDir?: string }} opts
|
|
142
|
+
*/
|
|
143
|
+
export function spinePath(opts = {}) {
|
|
144
|
+
const changeDir = resolveChangeDir(opts);
|
|
145
|
+
if (changeDir) return path.join(changeDir, SPINE_FILE);
|
|
146
|
+
if (opts.sessionDir) return path.join(opts.sessionDir, SPINE_FILE);
|
|
147
|
+
throw new Error('Cannot resolve spine.json location: no change and no session dir');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {{ change?: string | null }} [opts]
|
|
152
|
+
*/
|
|
153
|
+
export function spineTemplate(opts = {}) {
|
|
154
|
+
return {
|
|
155
|
+
change: opts.change ?? null,
|
|
156
|
+
notApplicable: null,
|
|
157
|
+
rows: [
|
|
158
|
+
{
|
|
159
|
+
capability: '<REQ id or capability cluster, e.g. REQ-GOV-01 matching>',
|
|
160
|
+
library: '<module path, e.g. services/etl-core/matcher.py>',
|
|
161
|
+
runtimeOwner: '<production caller, e.g. worker job analyze_study>',
|
|
162
|
+
writes: '<artifact/collection, e.g. study_proposals>',
|
|
163
|
+
reads: '<consumed inputs, or N/A>',
|
|
164
|
+
uiConsumer: '<UI/API surface reading the writes, or N/A>',
|
|
165
|
+
evidence: '<tier-2/E2E evidence path proving the wired path>',
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Scaffold spine.json (refuses to overwrite unless force).
|
|
173
|
+
*
|
|
174
|
+
* @param {{ file: string, change?: string | null, force?: boolean }} opts
|
|
175
|
+
*/
|
|
176
|
+
export function initSpine(opts) {
|
|
177
|
+
if (fs.existsSync(opts.file) && !opts.force) {
|
|
178
|
+
throw new Error(`spine.json already exists: ${opts.file} (use --force to overwrite)`);
|
|
179
|
+
}
|
|
180
|
+
writeJson(opts.file, spineTemplate({ change: opts.change }));
|
|
181
|
+
return opts.file;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Validate a spine document.
|
|
186
|
+
*
|
|
187
|
+
* Valid when either:
|
|
188
|
+
* - `notApplicable` is a non-empty string (honest opt-out, e.g. docs-only), or
|
|
189
|
+
* - `rows` is a non-empty array where every required cell is filled and no
|
|
190
|
+
* cell still contains scaffold placeholders (`<...>`).
|
|
191
|
+
*
|
|
192
|
+
* @param {unknown} doc
|
|
193
|
+
* @returns {{ ok: boolean, problems: string[] }}
|
|
194
|
+
*/
|
|
195
|
+
export function validateSpine(doc) {
|
|
196
|
+
/** @type {string[]} */
|
|
197
|
+
const problems = [];
|
|
198
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
199
|
+
return { ok: false, problems: ['spine.json is not an object'] };
|
|
200
|
+
}
|
|
201
|
+
const spine = /** @type {Record<string, unknown>} */ (doc);
|
|
202
|
+
|
|
203
|
+
if (isNonEmptyString(spine.notApplicable)) {
|
|
204
|
+
return { ok: true, problems: [] };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const rows = spine.rows;
|
|
208
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
problems: ['spine.rows is empty — add one row per capability, or set notApplicable with a reason'],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
rows.forEach((row, i) => {
|
|
216
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
217
|
+
problems.push(`row ${i + 1}: not an object`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const r = /** @type {Record<string, unknown>} */ (row);
|
|
221
|
+
for (const field of SPINE_ROW_REQUIRED) {
|
|
222
|
+
const value = r[field];
|
|
223
|
+
if (!isNonEmptyString(value)) {
|
|
224
|
+
problems.push(`row ${i + 1} (${r.capability ?? '?'}): missing ${field}`);
|
|
225
|
+
} else if (/^<.*>$/.test(value.trim())) {
|
|
226
|
+
problems.push(`row ${i + 1} (${r.capability ?? '?'}): ${field} still has scaffold placeholder`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return { ok: problems.length === 0, problems };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* ------------------------------------------------------------------ */
|
|
235
|
+
/* E2E acceptance — executable product loop */
|
|
236
|
+
/* ------------------------------------------------------------------ */
|
|
237
|
+
|
|
238
|
+
const E2E_FILE = 'e2e.json';
|
|
239
|
+
const E2E_RESULTS_FILE = 'e2e-results.json';
|
|
240
|
+
export const E2E_DEFAULT_TIMEOUT_MS = 300_000;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Path to e2e.json: change dir when available, else session dir.
|
|
244
|
+
*
|
|
245
|
+
* @param {{ cwd?: string, session?: Record<string, unknown> | null, sessionDir?: string }} opts
|
|
246
|
+
*/
|
|
247
|
+
export function e2ePath(opts = {}) {
|
|
248
|
+
const changeDir = resolveChangeDir(opts);
|
|
249
|
+
if (changeDir) return path.join(changeDir, E2E_FILE);
|
|
250
|
+
if (opts.sessionDir) return path.join(opts.sessionDir, E2E_FILE);
|
|
251
|
+
throw new Error('Cannot resolve e2e.json location: no change and no session dir');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @param {{ change?: string | null }} [opts]
|
|
256
|
+
*/
|
|
257
|
+
export function e2eTemplate(opts = {}) {
|
|
258
|
+
return {
|
|
259
|
+
change: opts.change ?? null,
|
|
260
|
+
notApplicable: null,
|
|
261
|
+
steps: [
|
|
262
|
+
{
|
|
263
|
+
name: '<boot>',
|
|
264
|
+
cmd: '<command that starts the system, e.g. docker compose up -d api worker>',
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: '<produce>',
|
|
268
|
+
cmd: '<command that drives the real production entry point, e.g. node scripts/e2e/enqueue-analyze.mjs>',
|
|
269
|
+
expect: '<regex the combined output must match — delete this field if exit code 0 is enough>',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: '<consume-assert>',
|
|
273
|
+
cmd: '<command that proves the domain side effects exist, e.g. node scripts/e2e/assert-ratified.mjs>',
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Scaffold e2e.json (refuses to overwrite unless force).
|
|
281
|
+
*
|
|
282
|
+
* @param {{ file: string, change?: string | null, force?: boolean }} opts
|
|
283
|
+
*/
|
|
284
|
+
export function initE2e(opts) {
|
|
285
|
+
if (fs.existsSync(opts.file) && !opts.force) {
|
|
286
|
+
throw new Error(`e2e.json already exists: ${opts.file} (use --force to overwrite)`);
|
|
287
|
+
}
|
|
288
|
+
writeJson(opts.file, e2eTemplate({ change: opts.change }));
|
|
289
|
+
return opts.file;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Validate an e2e document.
|
|
294
|
+
*
|
|
295
|
+
* Valid when either:
|
|
296
|
+
* - `notApplicable` is a non-empty string (loop cannot be driven by any
|
|
297
|
+
* command — reviewers police the reason), or
|
|
298
|
+
* - `steps` is a non-empty array where every step has a filled `name` and
|
|
299
|
+
* `cmd` (no scaffold placeholders), `expect` (optional) is a valid regex,
|
|
300
|
+
* and `timeoutMs` (optional) is a positive number.
|
|
301
|
+
*
|
|
302
|
+
* @param {unknown} doc
|
|
303
|
+
* @returns {{ ok: boolean, problems: string[] }}
|
|
304
|
+
*/
|
|
305
|
+
export function validateE2e(doc) {
|
|
306
|
+
/** @type {string[]} */
|
|
307
|
+
const problems = [];
|
|
308
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
309
|
+
return { ok: false, problems: ['e2e.json is not an object'] };
|
|
310
|
+
}
|
|
311
|
+
const e2e = /** @type {Record<string, unknown>} */ (doc);
|
|
312
|
+
|
|
313
|
+
if (isNonEmptyString(e2e.notApplicable)) {
|
|
314
|
+
return { ok: true, problems: [] };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const steps = e2e.steps;
|
|
318
|
+
if (!Array.isArray(steps) || steps.length === 0) {
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
problems: [
|
|
322
|
+
'e2e.steps is empty — add executable product-loop steps, or set notApplicable with a reason',
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
steps.forEach((step, i) => {
|
|
328
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) {
|
|
329
|
+
problems.push(`step ${i + 1}: not an object`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const s = /** @type {Record<string, unknown>} */ (step);
|
|
333
|
+
for (const field of ['name', 'cmd']) {
|
|
334
|
+
const value = s[field];
|
|
335
|
+
if (!isNonEmptyString(value)) {
|
|
336
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): missing ${field}`);
|
|
337
|
+
} else if (/^<.*>$/.test(value.trim())) {
|
|
338
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): ${field} still has scaffold placeholder`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (s.expect !== undefined && s.expect !== null) {
|
|
342
|
+
if (!isNonEmptyString(s.expect)) {
|
|
343
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): expect must be a non-empty regex string`);
|
|
344
|
+
} else if (/^<.*>$/.test(s.expect.trim())) {
|
|
345
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): expect still has scaffold placeholder`);
|
|
346
|
+
} else {
|
|
347
|
+
try {
|
|
348
|
+
new RegExp(s.expect);
|
|
349
|
+
} catch {
|
|
350
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): expect is not a valid regex`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (s.timeoutMs !== undefined && (typeof s.timeoutMs !== 'number' || s.timeoutMs <= 0)) {
|
|
355
|
+
problems.push(`step ${i + 1} (${s.name ?? '?'}): timeoutMs must be a positive number`);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
return { ok: problems.length === 0, problems };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Hash of the step list — recorded in results so editing e2e.json after a
|
|
364
|
+
* green run invalidates the results.
|
|
365
|
+
*
|
|
366
|
+
* @param {unknown[]} steps
|
|
367
|
+
*/
|
|
368
|
+
export function e2eStepsHash(steps) {
|
|
369
|
+
return crypto.createHash('sha256').update(JSON.stringify(steps ?? [])).digest('hex');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @param {string} text
|
|
374
|
+
*/
|
|
375
|
+
function outputTail(text, lines = 30) {
|
|
376
|
+
if (!text) return '';
|
|
377
|
+
return text.split(/\r?\n/).slice(-lines).join('\n').trim();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Execute e2e steps sequentially (shell). Stops at the first failure —
|
|
382
|
+
* later steps depend on earlier ones. Exit code must be 0 and `expect`
|
|
383
|
+
* (when present) must match combined stdout+stderr.
|
|
384
|
+
*
|
|
385
|
+
* @param {{ steps?: unknown[] }} doc — a validated e2e document with steps
|
|
386
|
+
* @param {{ cwd?: string }} [opts]
|
|
387
|
+
*/
|
|
388
|
+
export function runE2eSteps(doc, opts = {}) {
|
|
389
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
390
|
+
const steps = Array.isArray(doc?.steps) ? doc.steps : [];
|
|
391
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
392
|
+
const results = [];
|
|
393
|
+
let ok = true;
|
|
394
|
+
|
|
395
|
+
for (const step of steps) {
|
|
396
|
+
const s = /** @type {Record<string, any>} */ (step);
|
|
397
|
+
if (!ok) {
|
|
398
|
+
results.push({ name: s.name, cmd: s.cmd, skipped: true });
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const started = Date.now();
|
|
402
|
+
const r = spawnSync(s.cmd, {
|
|
403
|
+
shell: true,
|
|
404
|
+
cwd,
|
|
405
|
+
encoding: 'utf8',
|
|
406
|
+
timeout: typeof s.timeoutMs === 'number' ? s.timeoutMs : E2E_DEFAULT_TIMEOUT_MS,
|
|
407
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
408
|
+
});
|
|
409
|
+
const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
|
|
410
|
+
const exitCode = typeof r.status === 'number' ? r.status : null;
|
|
411
|
+
let expectMatched = null;
|
|
412
|
+
let stepOk = exitCode === 0;
|
|
413
|
+
if (stepOk && isNonEmptyString(s.expect)) {
|
|
414
|
+
expectMatched = new RegExp(s.expect).test(output);
|
|
415
|
+
stepOk = expectMatched;
|
|
416
|
+
}
|
|
417
|
+
results.push({
|
|
418
|
+
name: s.name,
|
|
419
|
+
cmd: s.cmd,
|
|
420
|
+
exitCode,
|
|
421
|
+
expectMatched,
|
|
422
|
+
ok: stepOk,
|
|
423
|
+
durationMs: Date.now() - started,
|
|
424
|
+
outputTail: outputTail(output),
|
|
425
|
+
error: r.error ? String(r.error.message ?? r.error) : null,
|
|
426
|
+
});
|
|
427
|
+
if (!stepOk) ok = false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
ok,
|
|
432
|
+
ranAt: new Date().toISOString(),
|
|
433
|
+
stepsHash: e2eStepsHash(steps),
|
|
434
|
+
steps: results,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* @param {string} sessionDir
|
|
440
|
+
*/
|
|
441
|
+
export function e2eResultsPath(sessionDir) {
|
|
442
|
+
return path.join(sessionDir, E2E_RESULTS_FILE);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* @param {string} sessionDir
|
|
447
|
+
* @param {ReturnType<typeof runE2eSteps>} results
|
|
448
|
+
*/
|
|
449
|
+
export function writeE2eResults(sessionDir, results) {
|
|
450
|
+
writeJson(e2eResultsPath(sessionDir), results);
|
|
451
|
+
return e2eResultsPath(sessionDir);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* @param {string} sessionDir
|
|
456
|
+
* @returns {Record<string, any> | null}
|
|
457
|
+
*/
|
|
458
|
+
export function loadE2eResults(sessionDir) {
|
|
459
|
+
const file = e2eResultsPath(sessionDir);
|
|
460
|
+
if (!fs.existsSync(file)) return null;
|
|
461
|
+
try {
|
|
462
|
+
return readJson(file);
|
|
463
|
+
} catch {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Gate check for the executable E2E acceptance. Returns the problems that
|
|
470
|
+
* block `forge phase done` — empty when the loop was executed green (and the
|
|
471
|
+
* results are current), or when e2e.json honestly opts out via notApplicable.
|
|
472
|
+
*
|
|
473
|
+
* @param {{ e2eFile: string, sessionDir: string }} opts
|
|
474
|
+
* @returns {{ problems: string[], notApplicable: boolean }}
|
|
475
|
+
*/
|
|
476
|
+
export function checkE2eGate(opts) {
|
|
477
|
+
/** @type {string[]} */
|
|
478
|
+
const problems = [];
|
|
479
|
+
|
|
480
|
+
if (!fs.existsSync(opts.e2eFile)) {
|
|
481
|
+
problems.push(
|
|
482
|
+
`e2e.json required at ${opts.e2eFile} — run forge e2e init, author the product-loop steps, then forge e2e run. Spine rows mean an async loop exists; it must be executed, not described.`,
|
|
483
|
+
);
|
|
484
|
+
return { problems, notApplicable: false };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
let doc;
|
|
488
|
+
try {
|
|
489
|
+
doc = readJson(opts.e2eFile);
|
|
490
|
+
} catch (err) {
|
|
491
|
+
problems.push(`e2e.json unreadable: ${err instanceof Error ? err.message : err}`);
|
|
492
|
+
return { problems, notApplicable: false };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const valid = validateE2e(doc);
|
|
496
|
+
if (!valid.ok) {
|
|
497
|
+
problems.push(...valid.problems.map((p) => `e2e: ${p}`));
|
|
498
|
+
return { problems, notApplicable: false };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (isNonEmptyString(doc.notApplicable)) {
|
|
502
|
+
return { problems: [], notApplicable: true };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const results = loadE2eResults(opts.sessionDir);
|
|
506
|
+
if (!results) {
|
|
507
|
+
problems.push('e2e-results.json missing — run forge e2e run (a green run is required before done)');
|
|
508
|
+
} else if (results.stepsHash !== e2eStepsHash(doc.steps)) {
|
|
509
|
+
problems.push('e2e-results.json is stale — e2e.json changed since the last run; re-run forge e2e run');
|
|
510
|
+
} else if (!results.ok) {
|
|
511
|
+
const failed = Array.isArray(results.steps) ? results.steps.find((s) => s?.ok === false) : null;
|
|
512
|
+
problems.push(
|
|
513
|
+
`e2e run failed${failed ? ` at step "${failed.name}"` : ''} — fix and re-run forge e2e run`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return { problems, notApplicable: false };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @param {string} sessionDir
|
|
522
|
+
*/
|
|
523
|
+
export function deferralsPath(sessionDir) {
|
|
524
|
+
return path.join(sessionDir, DEFERRALS_FILE);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* @param {string} sessionDir
|
|
529
|
+
* @returns {{ deferrals: Array<{ task: string, reason: string, createdAt: string, resolvedAt: string | null }> }}
|
|
530
|
+
*/
|
|
531
|
+
export function loadDeferrals(sessionDir) {
|
|
532
|
+
const file = deferralsPath(sessionDir);
|
|
533
|
+
if (!fs.existsSync(file)) return { deferrals: [] };
|
|
534
|
+
const doc = readJson(file);
|
|
535
|
+
return { deferrals: Array.isArray(doc?.deferrals) ? doc.deferrals : [] };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* @param {string} sessionDir
|
|
540
|
+
* @param {{ task: string, reason: string }} entry
|
|
541
|
+
*/
|
|
542
|
+
export function addDeferral(sessionDir, entry) {
|
|
543
|
+
if (!isNonEmptyString(entry.task)) throw new Error('Deferral requires --task <id>');
|
|
544
|
+
if (!isNonEmptyString(entry.reason)) throw new Error('Deferral requires --reason "<why>"');
|
|
545
|
+
const doc = loadDeferrals(sessionDir);
|
|
546
|
+
if (doc.deferrals.some((d) => d.task === entry.task && !d.resolvedAt)) {
|
|
547
|
+
throw new Error(`Deferral for task ${entry.task} already open`);
|
|
548
|
+
}
|
|
549
|
+
doc.deferrals.push({
|
|
550
|
+
task: entry.task,
|
|
551
|
+
reason: entry.reason,
|
|
552
|
+
createdAt: new Date().toISOString(),
|
|
553
|
+
resolvedAt: null,
|
|
554
|
+
});
|
|
555
|
+
writeJson(deferralsPath(sessionDir), doc);
|
|
556
|
+
return doc;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* @param {string} sessionDir
|
|
561
|
+
* @param {string} task
|
|
562
|
+
*/
|
|
563
|
+
export function resolveDeferral(sessionDir, task) {
|
|
564
|
+
const doc = loadDeferrals(sessionDir);
|
|
565
|
+
const open = doc.deferrals.find((d) => d.task === task && !d.resolvedAt);
|
|
566
|
+
if (!open) throw new Error(`No open deferral for task ${task}`);
|
|
567
|
+
open.resolvedAt = new Date().toISOString();
|
|
568
|
+
writeJson(deferralsPath(sessionDir), doc);
|
|
569
|
+
return doc;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* @param {string} sessionDir
|
|
574
|
+
*/
|
|
575
|
+
export function openDeferrals(sessionDir) {
|
|
576
|
+
return loadDeferrals(sessionDir).deferrals.filter((d) => !d.resolvedAt);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* @param {Record<string, unknown> | null | undefined} session
|
|
581
|
+
*/
|
|
582
|
+
export function sessionJobsSignalText(session) {
|
|
583
|
+
return [session?.paceSignal, session?.slug, session?.openspecChange]
|
|
584
|
+
.filter((v) => isNonEmptyString(v))
|
|
585
|
+
.join(' ');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Run the mechanical integrity checks for a session.
|
|
590
|
+
*
|
|
591
|
+
* Checks:
|
|
592
|
+
* 1. No unresolved deferrals.
|
|
593
|
+
* 2. spine.json — **always required** (filled rows, or `notApplicable` with a
|
|
594
|
+
* reason). Keyword sniffing is not enough to decide; missing spine is how
|
|
595
|
+
* library-only platforms checkbox past gaps.
|
|
596
|
+
* 3. E2E acceptance — when a spine has real rows (not notApplicable):
|
|
597
|
+
* e2e.json must exist with filled steps (or its own notApplicable reason)
|
|
598
|
+
* and e2e-results.json must record a green, current run (steps hash must
|
|
599
|
+
* match). Prose in verify-evidence.md does not satisfy this; an explicit
|
|
600
|
+
* BLOCKED marker there still means the change cannot be done. Sync-only
|
|
601
|
+
* work should prefer spine `notApplicable` over inventing a fake loop.
|
|
602
|
+
*
|
|
603
|
+
* @param {{ cwd?: string, sessionDir: string, session: Record<string, unknown> }} opts
|
|
604
|
+
* @returns {{ ok: boolean, problems: string[], spineFile: string, spineExists: boolean, e2eFile: string | null }}
|
|
605
|
+
*/
|
|
606
|
+
/**
|
|
607
|
+
* Project-level e2e off switch: `.forge/config.json` → `e2e.disabled` set to a
|
|
608
|
+
* non-empty reason string. Operator-set via `forge e2e disable "<reason>"` —
|
|
609
|
+
* agents must never set it themselves. When set, the integrity gate stops
|
|
610
|
+
* demanding an executed green e2e run (the most time-consuming part of a
|
|
611
|
+
* session); spine, deferrals, evidence, and BLOCKED checks still apply.
|
|
612
|
+
*
|
|
613
|
+
* @param {string} [cwd]
|
|
614
|
+
* @returns {string | null} the reason, or null when e2e is enabled
|
|
615
|
+
*/
|
|
616
|
+
export function e2eDisabledReason(cwd = process.cwd()) {
|
|
617
|
+
try {
|
|
618
|
+
const reason = loadProjectConfig(cwd)?.e2e?.disabled;
|
|
619
|
+
return isNonEmptyString(reason) ? reason : null;
|
|
620
|
+
} catch {
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function runIntegrityChecks(opts) {
|
|
626
|
+
/** @type {string[]} */
|
|
627
|
+
const problems = [];
|
|
628
|
+
const { sessionDir, session } = opts;
|
|
629
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
630
|
+
|
|
631
|
+
const open = openDeferrals(sessionDir);
|
|
632
|
+
if (open.length > 0) {
|
|
633
|
+
problems.push(
|
|
634
|
+
`unresolved deferrals: ${open.map((d) => `${d.task} (${d.reason})`).join('; ')} — resolve via forge defer resolve --task <id>`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const spineFile = spinePath({ cwd, session, sessionDir });
|
|
639
|
+
const spineExists = fs.existsSync(spineFile);
|
|
640
|
+
|
|
641
|
+
/** @type {ReturnType<typeof validateSpine> | null} */
|
|
642
|
+
let spineResult = null;
|
|
643
|
+
let spineHasRows = false;
|
|
644
|
+
if (!spineExists) {
|
|
645
|
+
problems.push(
|
|
646
|
+
`spine.json required at ${spineFile} — run forge spine init, then fill rows (or set notApplicable with a reason). Spine is mandatory for every change so capability→runtime wiring cannot be skipped by accident.`,
|
|
647
|
+
);
|
|
648
|
+
} else {
|
|
649
|
+
try {
|
|
650
|
+
const doc = readJson(spineFile);
|
|
651
|
+
spineResult = validateSpine(doc);
|
|
652
|
+
spineHasRows =
|
|
653
|
+
Array.isArray(doc?.rows) &&
|
|
654
|
+
doc.rows.length > 0 &&
|
|
655
|
+
!isNonEmptyString(doc?.notApplicable);
|
|
656
|
+
} catch (err) {
|
|
657
|
+
spineResult = {
|
|
658
|
+
ok: false,
|
|
659
|
+
problems: [`spine.json unreadable: ${err instanceof Error ? err.message : err}`],
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
if (!spineResult.ok) {
|
|
663
|
+
problems.push(...spineResult.problems.map((p) => `spine: ${p}`));
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
let e2eFile = null;
|
|
668
|
+
const e2eDisabled = e2eDisabledReason(cwd);
|
|
669
|
+
if (spineExists && spineHasRows) {
|
|
670
|
+
if (!e2eDisabled) {
|
|
671
|
+
e2eFile = e2ePath({ cwd, session, sessionDir });
|
|
672
|
+
problems.push(...checkE2eGate({ e2eFile, sessionDir }).problems);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const evidenceFile = path.join(sessionDir, 'verify-evidence.md');
|
|
676
|
+
if (fs.existsSync(evidenceFile) && /\bBLOCKED\b/.test(fs.readFileSync(evidenceFile, 'utf8'))) {
|
|
677
|
+
problems.push('verify-evidence.md contains BLOCKED — change cannot be marked done while E2E is blocked');
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return { ok: problems.length === 0, problems, spineFile, spineExists, e2eFile, e2eDisabled };
|
|
682
|
+
}
|