@izkac/forgekit 0.1.7 → 0.2.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/bin/forge.mjs CHANGED
@@ -31,6 +31,7 @@ const COMMANDS = {
31
31
  triage: { script: 'triage-prompt.mjs' },
32
32
  change: { script: 'change.mjs' },
33
33
  spine: { script: 'spine.mjs' },
34
+ e2e: { script: 'e2e.mjs' },
34
35
  defer: { script: 'defer.mjs' },
35
36
  'integrity-check': { script: 'integrity-check.mjs', aliases: ['integrity'] },
36
37
  score: { script: 'score-cli.mjs', aliases: ['scorecard'] },
@@ -59,6 +60,7 @@ Commands:
59
60
  triage Classify whether a prompt needs Forge triage
60
61
  change new|archive Specs-engine change scaffold / archive
61
62
  spine init|check Capability→runtime spine matrix (spine.json)
63
+ e2e init|run|check Executable product-loop acceptance (e2e.json)
62
64
  defer add|resolve|list Deferral registry (deferred wiring = tracked debt)
63
65
  integrity-check Mechanical integrity gate (runs at phase done)
64
66
  score [--write] L2 session scorecard (auto-written at phase done)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izkac/forgekit",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
4
4
  "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/e2e.mjs ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forge E2E acceptance — the closed product loop as an executable step list.
4
+ *
5
+ * Usage:
6
+ * forge e2e # show status (file, validity, results freshness)
7
+ * forge e2e init [--force] # scaffold e2e.json for the active change
8
+ * forge e2e run # execute steps, write e2e-results.json (session dir)
9
+ * forge e2e check # gate check: green + current results; exit 1 with problems
10
+ * [--session <id>]
11
+ *
12
+ * e2e.json lives next to spine.json (change dir, falling back to the session
13
+ * dir). Results carry a hash of the steps, so editing e2e.json after a green
14
+ * run invalidates the results. `forge integrity-check` / `forge phase done`
15
+ * run the same gate when the spine has real rows.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import { loadSession, readActive, readJson } from './lib.mjs';
20
+ import {
21
+ checkE2eGate,
22
+ e2ePath,
23
+ e2eResultsPath,
24
+ e2eStepsHash,
25
+ initE2e,
26
+ loadE2eResults,
27
+ runE2eSteps,
28
+ validateE2e,
29
+ writeE2eResults,
30
+ } from './integrity.mjs';
31
+
32
+ const args = process.argv.slice(2);
33
+ const sub = args[0] && !args[0].startsWith('--') ? args[0] : 'status';
34
+
35
+ if (args[0] === '--help' || sub === 'help') {
36
+ process.stdout.write(
37
+ 'Usage: forge e2e [init [--force] | run | check | status] [--session <id>]\n',
38
+ );
39
+ process.exit(0);
40
+ }
41
+
42
+ let sessionId = null;
43
+ let force = false;
44
+ for (let i = 0; i < args.length; i += 1) {
45
+ if (args[i] === '--session' && args[i + 1]) {
46
+ sessionId = args[i + 1];
47
+ i += 1;
48
+ } else if (args[i] === '--force') {
49
+ force = true;
50
+ }
51
+ }
52
+
53
+ if (!sessionId) {
54
+ const active = readActive();
55
+ sessionId = active?.sessionId;
56
+ }
57
+ if (!sessionId) {
58
+ process.stderr.write('No active session. Run forge new first.\n');
59
+ process.exit(1);
60
+ }
61
+
62
+ const { dir, session } = loadSession(sessionId);
63
+ const file = e2ePath({ session, sessionDir: dir });
64
+
65
+ if (sub === 'init') {
66
+ try {
67
+ initE2e({ file, change: session.openspecChange ?? null, force });
68
+ } catch (err) {
69
+ process.stderr.write(`${err instanceof Error ? err.message : err}\n`);
70
+ process.exit(1);
71
+ }
72
+ process.stdout.write(
73
+ `Scaffolded ${file}\nAuthor the product-loop steps (produce → consume → assert domain side effects).\nSteps that would pass against a stubbed handler are invalid.\nRun with: forge e2e run\n`,
74
+ );
75
+ process.exit(0);
76
+ }
77
+
78
+ if (sub === 'run') {
79
+ if (!fs.existsSync(file)) {
80
+ process.stderr.write(`e2e.json not found at ${file} — run forge e2e init\n`);
81
+ process.exit(1);
82
+ }
83
+ let doc;
84
+ try {
85
+ doc = readJson(file);
86
+ } catch (err) {
87
+ process.stderr.write(`e2e.json unreadable: ${err instanceof Error ? err.message : err}\n`);
88
+ process.exit(1);
89
+ }
90
+ const valid = validateE2e(doc);
91
+ if (!valid.ok) {
92
+ process.stderr.write(`e2e.json invalid:\n${valid.problems.map((p) => ` - ${p}`).join('\n')}\n`);
93
+ process.exit(1);
94
+ }
95
+ if (typeof doc.notApplicable === 'string' && doc.notApplicable.trim()) {
96
+ process.stdout.write(`e2e notApplicable: ${doc.notApplicable}\nNothing to run.\n`);
97
+ process.exit(0);
98
+ }
99
+ const results = runE2eSteps(doc, { cwd: process.cwd() });
100
+ const resultsFile = writeE2eResults(dir, results);
101
+ for (const step of results.steps) {
102
+ if (step.skipped) {
103
+ process.stdout.write(` - ${step.name}: skipped (earlier step failed)\n`);
104
+ } else {
105
+ process.stdout.write(
106
+ ` ${step.ok ? '✓' : '✗'} ${step.name}: exit ${step.exitCode ?? 'n/a'}${
107
+ step.expectMatched === false ? ' (expect did not match)' : ''
108
+ }${step.error ? ` [${step.error}]` : ''} (${step.durationMs}ms)\n`,
109
+ );
110
+ if (!step.ok && step.outputTail) {
111
+ process.stdout.write(`${step.outputTail.replace(/^/gm, ' ')}\n`);
112
+ }
113
+ }
114
+ }
115
+ process.stdout.write(`${results.ok ? 'GREEN' : 'FAILED'} — results: ${resultsFile}\n`);
116
+ process.exit(results.ok ? 0 : 1);
117
+ }
118
+
119
+ if (sub === 'check') {
120
+ const gate = checkE2eGate({ e2eFile: file, sessionDir: dir });
121
+ process.stdout.write(
122
+ JSON.stringify(
123
+ { file, ok: gate.problems.length === 0, notApplicable: gate.notApplicable, problems: gate.problems },
124
+ null,
125
+ 2,
126
+ ),
127
+ );
128
+ process.stdout.write('\n');
129
+ process.exit(gate.problems.length === 0 ? 0 : 1);
130
+ }
131
+
132
+ if (sub === 'status') {
133
+ if (!fs.existsSync(file)) {
134
+ process.stdout.write(JSON.stringify({ file, exists: false }, null, 2));
135
+ process.stdout.write('\n');
136
+ process.exit(0);
137
+ }
138
+ let doc = null;
139
+ let valid = { ok: false, problems: ['unreadable'] };
140
+ try {
141
+ doc = readJson(file);
142
+ valid = validateE2e(doc);
143
+ } catch (err) {
144
+ valid = { ok: false, problems: [`unreadable: ${err instanceof Error ? err.message : err}`] };
145
+ }
146
+ const results = loadE2eResults(dir);
147
+ process.stdout.write(
148
+ JSON.stringify(
149
+ {
150
+ file,
151
+ exists: true,
152
+ ok: valid.ok,
153
+ problems: valid.problems,
154
+ results: results
155
+ ? {
156
+ file: e2eResultsPath(dir),
157
+ ok: results.ok === true,
158
+ ranAt: results.ranAt ?? null,
159
+ stale: doc ? results.stepsHash !== e2eStepsHash(doc.steps) : null,
160
+ }
161
+ : null,
162
+ },
163
+ null,
164
+ 2,
165
+ ),
166
+ );
167
+ process.stdout.write('\n');
168
+ process.exit(0);
169
+ }
170
+
171
+ process.stderr.write(`Unknown subcommand: ${sub}\n`);
172
+ process.exit(1);
@@ -7,9 +7,10 @@
7
7
  *
8
8
  * Fails (exit 1) when:
9
9
  * - deferrals are unresolved
10
- * - spine.json is required (jobs/workers in scope) but missing or invalid
11
- * - a spine with rows exists but verify-evidence.md lacks a product-loop
12
- * section, or contains an explicit BLOCKED marker
10
+ * - spine.json is missing or invalid
11
+ * - a spine with rows exists but the executable E2E acceptance is missing,
12
+ * failed, or stale (e2e.json + green, current e2e-results.json), or
13
+ * verify-evidence.md contains an explicit BLOCKED marker
13
14
  */
14
15
 
15
16
  import { loadSession, readActive } from './lib.mjs';
@@ -49,6 +50,7 @@ process.stdout.write(
49
50
  problems: result.problems,
50
51
  spineFile: result.spineFile,
51
52
  spineExists: result.spineExists,
53
+ e2eFile: result.e2eFile,
52
54
  },
53
55
  null,
54
56
  2,
package/src/integrity.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Forge runtime-integrity mechanics: spine matrix, deferral registry,
3
- * and the integrity checks that gate `forge phase done|finish`.
3
+ * executable E2E acceptance, and the integrity checks that gate
4
+ * `forge phase done|finish`.
4
5
  *
5
6
  * Spine matrix — `spine.json` in the change dir (or session dir when the
6
7
  * session has no tracked change). One row per capability/REQ cluster:
@@ -8,13 +9,21 @@
8
9
  * Library-only rows (missing runtime owner / writes / evidence) fail
9
10
  * validation, so "wire later" cannot be checkboxed past `forge phase done`.
10
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
+ *
11
18
  * Deferral registry — `deferrals.json` in the session dir. Reviewers may only
12
19
  * accept "wiring deferred" when a registered deferral names the open task;
13
20
  * unresolved deferrals block done/finish.
14
21
  */
15
22
 
23
+ import crypto from 'node:crypto';
16
24
  import fs from 'node:fs';
17
25
  import path from 'node:path';
26
+ import { spawnSync } from 'node:child_process';
18
27
  import { readJson, writeJson } from './lib.mjs';
19
28
  import { DEFAULT_SPECS_DIR, resolveProjectPlanEngine } from './plan-engine.mjs';
20
29
 
@@ -169,6 +178,292 @@ export function validateSpine(doc) {
169
178
  return { ok: problems.length === 0, problems };
170
179
  }
171
180
 
181
+ /* ------------------------------------------------------------------ */
182
+ /* E2E acceptance — executable product loop */
183
+ /* ------------------------------------------------------------------ */
184
+
185
+ const E2E_FILE = 'e2e.json';
186
+ const E2E_RESULTS_FILE = 'e2e-results.json';
187
+ export const E2E_DEFAULT_TIMEOUT_MS = 300_000;
188
+
189
+ /**
190
+ * Path to e2e.json: change dir when available, else session dir.
191
+ *
192
+ * @param {{ cwd?: string, session?: Record<string, unknown> | null, sessionDir?: string }} opts
193
+ */
194
+ export function e2ePath(opts = {}) {
195
+ const changeDir = resolveChangeDir(opts);
196
+ if (changeDir) return path.join(changeDir, E2E_FILE);
197
+ if (opts.sessionDir) return path.join(opts.sessionDir, E2E_FILE);
198
+ throw new Error('Cannot resolve e2e.json location: no change and no session dir');
199
+ }
200
+
201
+ /**
202
+ * @param {{ change?: string | null }} [opts]
203
+ */
204
+ export function e2eTemplate(opts = {}) {
205
+ return {
206
+ change: opts.change ?? null,
207
+ notApplicable: null,
208
+ steps: [
209
+ {
210
+ name: '<boot>',
211
+ cmd: '<command that starts the system, e.g. docker compose up -d api worker>',
212
+ },
213
+ {
214
+ name: '<produce>',
215
+ cmd: '<command that drives the real production entry point, e.g. node scripts/e2e/enqueue-analyze.mjs>',
216
+ expect: '<regex the combined output must match — delete this field if exit code 0 is enough>',
217
+ },
218
+ {
219
+ name: '<consume-assert>',
220
+ cmd: '<command that proves the domain side effects exist, e.g. node scripts/e2e/assert-ratified.mjs>',
221
+ },
222
+ ],
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Scaffold e2e.json (refuses to overwrite unless force).
228
+ *
229
+ * @param {{ file: string, change?: string | null, force?: boolean }} opts
230
+ */
231
+ export function initE2e(opts) {
232
+ if (fs.existsSync(opts.file) && !opts.force) {
233
+ throw new Error(`e2e.json already exists: ${opts.file} (use --force to overwrite)`);
234
+ }
235
+ writeJson(opts.file, e2eTemplate({ change: opts.change }));
236
+ return opts.file;
237
+ }
238
+
239
+ /**
240
+ * Validate an e2e document.
241
+ *
242
+ * Valid when either:
243
+ * - `notApplicable` is a non-empty string (loop cannot be driven by any
244
+ * command — reviewers police the reason), or
245
+ * - `steps` is a non-empty array where every step has a filled `name` and
246
+ * `cmd` (no scaffold placeholders), `expect` (optional) is a valid regex,
247
+ * and `timeoutMs` (optional) is a positive number.
248
+ *
249
+ * @param {unknown} doc
250
+ * @returns {{ ok: boolean, problems: string[] }}
251
+ */
252
+ export function validateE2e(doc) {
253
+ /** @type {string[]} */
254
+ const problems = [];
255
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
256
+ return { ok: false, problems: ['e2e.json is not an object'] };
257
+ }
258
+ const e2e = /** @type {Record<string, unknown>} */ (doc);
259
+
260
+ if (isNonEmptyString(e2e.notApplicable)) {
261
+ return { ok: true, problems: [] };
262
+ }
263
+
264
+ const steps = e2e.steps;
265
+ if (!Array.isArray(steps) || steps.length === 0) {
266
+ return {
267
+ ok: false,
268
+ problems: [
269
+ 'e2e.steps is empty — add executable product-loop steps, or set notApplicable with a reason',
270
+ ],
271
+ };
272
+ }
273
+
274
+ steps.forEach((step, i) => {
275
+ if (!step || typeof step !== 'object' || Array.isArray(step)) {
276
+ problems.push(`step ${i + 1}: not an object`);
277
+ return;
278
+ }
279
+ const s = /** @type {Record<string, unknown>} */ (step);
280
+ for (const field of ['name', 'cmd']) {
281
+ const value = s[field];
282
+ if (!isNonEmptyString(value)) {
283
+ problems.push(`step ${i + 1} (${s.name ?? '?'}): missing ${field}`);
284
+ } else if (/^<.*>$/.test(value.trim())) {
285
+ problems.push(`step ${i + 1} (${s.name ?? '?'}): ${field} still has scaffold placeholder`);
286
+ }
287
+ }
288
+ if (s.expect !== undefined && s.expect !== null) {
289
+ if (!isNonEmptyString(s.expect)) {
290
+ problems.push(`step ${i + 1} (${s.name ?? '?'}): expect must be a non-empty regex string`);
291
+ } else if (/^<.*>$/.test(s.expect.trim())) {
292
+ problems.push(`step ${i + 1} (${s.name ?? '?'}): expect still has scaffold placeholder`);
293
+ } else {
294
+ try {
295
+ new RegExp(s.expect);
296
+ } catch {
297
+ problems.push(`step ${i + 1} (${s.name ?? '?'}): expect is not a valid regex`);
298
+ }
299
+ }
300
+ }
301
+ if (s.timeoutMs !== undefined && (typeof s.timeoutMs !== 'number' || s.timeoutMs <= 0)) {
302
+ problems.push(`step ${i + 1} (${s.name ?? '?'}): timeoutMs must be a positive number`);
303
+ }
304
+ });
305
+
306
+ return { ok: problems.length === 0, problems };
307
+ }
308
+
309
+ /**
310
+ * Hash of the step list — recorded in results so editing e2e.json after a
311
+ * green run invalidates the results.
312
+ *
313
+ * @param {unknown[]} steps
314
+ */
315
+ export function e2eStepsHash(steps) {
316
+ return crypto.createHash('sha256').update(JSON.stringify(steps ?? [])).digest('hex');
317
+ }
318
+
319
+ /**
320
+ * @param {string} text
321
+ */
322
+ function outputTail(text, lines = 30) {
323
+ if (!text) return '';
324
+ return text.split(/\r?\n/).slice(-lines).join('\n').trim();
325
+ }
326
+
327
+ /**
328
+ * Execute e2e steps sequentially (shell). Stops at the first failure —
329
+ * later steps depend on earlier ones. Exit code must be 0 and `expect`
330
+ * (when present) must match combined stdout+stderr.
331
+ *
332
+ * @param {{ steps?: unknown[] }} doc — a validated e2e document with steps
333
+ * @param {{ cwd?: string }} [opts]
334
+ */
335
+ export function runE2eSteps(doc, opts = {}) {
336
+ const cwd = opts.cwd ?? process.cwd();
337
+ const steps = Array.isArray(doc?.steps) ? doc.steps : [];
338
+ /** @type {Array<Record<string, unknown>>} */
339
+ const results = [];
340
+ let ok = true;
341
+
342
+ for (const step of steps) {
343
+ const s = /** @type {Record<string, any>} */ (step);
344
+ if (!ok) {
345
+ results.push({ name: s.name, cmd: s.cmd, skipped: true });
346
+ continue;
347
+ }
348
+ const started = Date.now();
349
+ const r = spawnSync(s.cmd, {
350
+ shell: true,
351
+ cwd,
352
+ encoding: 'utf8',
353
+ timeout: typeof s.timeoutMs === 'number' ? s.timeoutMs : E2E_DEFAULT_TIMEOUT_MS,
354
+ maxBuffer: 10 * 1024 * 1024,
355
+ });
356
+ const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
357
+ const exitCode = typeof r.status === 'number' ? r.status : null;
358
+ let expectMatched = null;
359
+ let stepOk = exitCode === 0;
360
+ if (stepOk && isNonEmptyString(s.expect)) {
361
+ expectMatched = new RegExp(s.expect).test(output);
362
+ stepOk = expectMatched;
363
+ }
364
+ results.push({
365
+ name: s.name,
366
+ cmd: s.cmd,
367
+ exitCode,
368
+ expectMatched,
369
+ ok: stepOk,
370
+ durationMs: Date.now() - started,
371
+ outputTail: outputTail(output),
372
+ error: r.error ? String(r.error.message ?? r.error) : null,
373
+ });
374
+ if (!stepOk) ok = false;
375
+ }
376
+
377
+ return {
378
+ ok,
379
+ ranAt: new Date().toISOString(),
380
+ stepsHash: e2eStepsHash(steps),
381
+ steps: results,
382
+ };
383
+ }
384
+
385
+ /**
386
+ * @param {string} sessionDir
387
+ */
388
+ export function e2eResultsPath(sessionDir) {
389
+ return path.join(sessionDir, E2E_RESULTS_FILE);
390
+ }
391
+
392
+ /**
393
+ * @param {string} sessionDir
394
+ * @param {ReturnType<typeof runE2eSteps>} results
395
+ */
396
+ export function writeE2eResults(sessionDir, results) {
397
+ writeJson(e2eResultsPath(sessionDir), results);
398
+ return e2eResultsPath(sessionDir);
399
+ }
400
+
401
+ /**
402
+ * @param {string} sessionDir
403
+ * @returns {Record<string, any> | null}
404
+ */
405
+ export function loadE2eResults(sessionDir) {
406
+ const file = e2eResultsPath(sessionDir);
407
+ if (!fs.existsSync(file)) return null;
408
+ try {
409
+ return readJson(file);
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Gate check for the executable E2E acceptance. Returns the problems that
417
+ * block `forge phase done` — empty when the loop was executed green (and the
418
+ * results are current), or when e2e.json honestly opts out via notApplicable.
419
+ *
420
+ * @param {{ e2eFile: string, sessionDir: string }} opts
421
+ * @returns {{ problems: string[], notApplicable: boolean }}
422
+ */
423
+ export function checkE2eGate(opts) {
424
+ /** @type {string[]} */
425
+ const problems = [];
426
+
427
+ if (!fs.existsSync(opts.e2eFile)) {
428
+ problems.push(
429
+ `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.`,
430
+ );
431
+ return { problems, notApplicable: false };
432
+ }
433
+
434
+ let doc;
435
+ try {
436
+ doc = readJson(opts.e2eFile);
437
+ } catch (err) {
438
+ problems.push(`e2e.json unreadable: ${err instanceof Error ? err.message : err}`);
439
+ return { problems, notApplicable: false };
440
+ }
441
+
442
+ const valid = validateE2e(doc);
443
+ if (!valid.ok) {
444
+ problems.push(...valid.problems.map((p) => `e2e: ${p}`));
445
+ return { problems, notApplicable: false };
446
+ }
447
+
448
+ if (isNonEmptyString(doc.notApplicable)) {
449
+ return { problems: [], notApplicable: true };
450
+ }
451
+
452
+ const results = loadE2eResults(opts.sessionDir);
453
+ if (!results) {
454
+ problems.push('e2e-results.json missing — run forge e2e run (a green run is required before done)');
455
+ } else if (results.stepsHash !== e2eStepsHash(doc.steps)) {
456
+ problems.push('e2e-results.json is stale — e2e.json changed since the last run; re-run forge e2e run');
457
+ } else if (!results.ok) {
458
+ const failed = Array.isArray(results.steps) ? results.steps.find((s) => s?.ok === false) : null;
459
+ problems.push(
460
+ `e2e run failed${failed ? ` at step "${failed.name}"` : ''} — fix and re-run forge e2e run`,
461
+ );
462
+ }
463
+
464
+ return { problems, notApplicable: false };
465
+ }
466
+
172
467
  /**
173
468
  * @param {string} sessionDir
174
469
  */
@@ -245,13 +540,15 @@ export function sessionJobsSignalText(session) {
245
540
  * 2. spine.json — **always required** (filled rows, or `notApplicable` with a
246
541
  * reason). Keyword sniffing is not enough to decide; missing spine is how
247
542
  * library-only platforms checkbox past gaps.
248
- * 3. verify-evidence.md — when a spine has real rows (not notApplicable): must
249
- * exist and contain a product-loop section; an explicit BLOCKED marker means
250
- * the change cannot be done. Sync-only work should prefer `notApplicable`
251
- * over inventing a fake loop.
543
+ * 3. E2E acceptance — when a spine has real rows (not notApplicable):
544
+ * e2e.json must exist with filled steps (or its own notApplicable reason)
545
+ * and e2e-results.json must record a green, current run (steps hash must
546
+ * match). Prose in verify-evidence.md does not satisfy this; an explicit
547
+ * BLOCKED marker there still means the change cannot be done. Sync-only
548
+ * work should prefer spine `notApplicable` over inventing a fake loop.
252
549
  *
253
550
  * @param {{ cwd?: string, sessionDir: string, session: Record<string, unknown> }} opts
254
- * @returns {{ ok: boolean, problems: string[], spineFile: string, spineExists: boolean }}
551
+ * @returns {{ ok: boolean, problems: string[], spineFile: string, spineExists: boolean, e2eFile: string | null }}
255
552
  */
256
553
  export function runIntegrityChecks(opts) {
257
554
  /** @type {string[]} */
@@ -295,23 +592,16 @@ export function runIntegrityChecks(opts) {
295
592
  }
296
593
  }
297
594
 
595
+ let e2eFile = null;
298
596
  if (spineExists && spineHasRows) {
597
+ e2eFile = e2ePath({ cwd, session, sessionDir });
598
+ problems.push(...checkE2eGate({ e2eFile, sessionDir }).problems);
599
+
299
600
  const evidenceFile = path.join(sessionDir, 'verify-evidence.md');
300
- if (!fs.existsSync(evidenceFile)) {
301
- problems.push(
302
- 'verify-evidence.md missing — spine rows require product-loop evidence (or use notApplicable for sync-only work)',
303
- );
304
- } else {
305
- const body = fs.readFileSync(evidenceFile, 'utf8');
306
- if (/\bBLOCKED\b/.test(body)) {
307
- problems.push('verify-evidence.md contains BLOCKED — change cannot be marked done while E2E is blocked');
308
- } else if (!/product[- ]loop/i.test(body)) {
309
- problems.push(
310
- 'verify-evidence.md has no "Product loop" section — record the closed producer→consumer loop (or BLOCKED). Sync-only changes should use spine notApplicable instead.',
311
- );
312
- }
601
+ if (fs.existsSync(evidenceFile) && /\bBLOCKED\b/.test(fs.readFileSync(evidenceFile, 'utf8'))) {
602
+ problems.push('verify-evidence.md contains BLOCKED — change cannot be marked done while E2E is blocked');
313
603
  }
314
604
  }
315
605
 
316
- return { ok: problems.length === 0, problems, spineFile, spineExists };
606
+ return { ok: problems.length === 0, problems, spineFile, spineExists, e2eFile };
317
607
  }
@@ -6,16 +6,24 @@ import { tmpdir } from 'node:os';
6
6
  import {
7
7
  JOBS_SIGNAL_RE,
8
8
  addDeferral,
9
+ checkE2eGate,
10
+ e2ePath,
11
+ e2eStepsHash,
12
+ e2eTemplate,
13
+ initE2e,
9
14
  initSpine,
10
15
  loadDeferrals,
11
16
  openDeferrals,
12
17
  resolveChangeDir,
13
18
  resolveDeferral,
19
+ runE2eSteps,
14
20
  runIntegrityChecks,
15
21
  sessionJobsSignalText,
16
22
  spinePath,
17
23
  spineTemplate,
24
+ validateE2e,
18
25
  validateSpine,
26
+ writeE2eResults,
19
27
  } from './integrity.mjs';
20
28
 
21
29
  function tmp(prefix) {
@@ -215,40 +223,184 @@ test('runIntegrityChecks: empty slug without spine still fails', () => {
215
223
  }
216
224
  });
217
225
 
218
- test('runIntegrityChecks: spine rows demand product-loop evidence', () => {
226
+ function greenStep(overrides = {}) {
227
+ return {
228
+ name: 'produce',
229
+ cmd: 'node -e "console.log(\'proposals: 3\')"',
230
+ ...overrides,
231
+ };
232
+ }
233
+
234
+ function writeSpineWithRows(sessionDir) {
235
+ fs.writeFileSync(
236
+ path.join(sessionDir, 'spine.json'),
237
+ `${JSON.stringify({ change: null, notApplicable: null, rows: [validRow()] }, null, 2)}\n`,
238
+ 'utf8',
239
+ );
240
+ }
241
+
242
+ function writeE2eDoc(sessionDir, doc) {
243
+ fs.writeFileSync(path.join(sessionDir, 'e2e.json'), `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
244
+ }
245
+
246
+ test('validateE2e: filled steps pass; template placeholders rejected', () => {
247
+ assert.equal(validateE2e({ notApplicable: null, steps: [greenStep()] }).ok, true);
248
+ const scaffold = validateE2e(e2eTemplate({ change: 'x' }));
249
+ assert.equal(scaffold.ok, false);
250
+ assert.match(scaffold.problems.join('\n'), /scaffold placeholder/);
251
+ });
252
+
253
+ test('validateE2e: missing cmd, bad regex, bad timeout, empty steps', () => {
254
+ assert.match(
255
+ validateE2e({ steps: [{ name: 'x', cmd: '' }] }).problems.join('\n'),
256
+ /missing cmd/,
257
+ );
258
+ assert.match(
259
+ validateE2e({ steps: [greenStep({ expect: '(' })] }).problems.join('\n'),
260
+ /not a valid regex/,
261
+ );
262
+ assert.match(
263
+ validateE2e({ steps: [greenStep({ timeoutMs: -5 })] }).problems.join('\n'),
264
+ /timeoutMs/,
265
+ );
266
+ assert.match(validateE2e({ steps: [] }).problems.join('\n'), /steps is empty/);
267
+ assert.equal(validateE2e({ steps: [], notApplicable: 'no headless env — manual device loop' }).ok, true);
268
+ });
269
+
270
+ test('e2e init writes template and refuses overwrite without force', () => {
271
+ const dir = tmp('forge-e2e-init-');
272
+ try {
273
+ const file = path.join(dir, 'e2e.json');
274
+ initE2e({ file, change: 'my-change' });
275
+ assert.equal(JSON.parse(fs.readFileSync(file, 'utf8')).change, 'my-change');
276
+ assert.throws(() => initE2e({ file, change: 'my-change' }), /already exists/);
277
+ initE2e({ file, change: 'other', force: true });
278
+ assert.equal(JSON.parse(fs.readFileSync(file, 'utf8')).change, 'other');
279
+ assert.equal(
280
+ e2ePath({ cwd: dir, session: { openspecChange: null }, sessionDir: dir }),
281
+ file,
282
+ );
283
+ } finally {
284
+ fs.rmSync(dir, { recursive: true, force: true });
285
+ }
286
+ });
287
+
288
+ test('runE2eSteps: green run with expect match', () => {
289
+ const results = runE2eSteps({ steps: [greenStep({ expect: 'proposals: \\d+' })] });
290
+ assert.equal(results.ok, true);
291
+ assert.equal(results.steps[0].exitCode, 0);
292
+ assert.equal(results.steps[0].expectMatched, true);
293
+ assert.equal(results.stepsHash, e2eStepsHash([greenStep({ expect: 'proposals: \\d+' })]));
294
+ });
295
+
296
+ test('runE2eSteps: non-zero exit fails and skips later steps', () => {
297
+ const results = runE2eSteps({
298
+ steps: [
299
+ { name: 'boom', cmd: 'node -e "process.exit(3)"' },
300
+ greenStep({ name: 'never' }),
301
+ ],
302
+ });
303
+ assert.equal(results.ok, false);
304
+ assert.equal(results.steps[0].exitCode, 3);
305
+ assert.equal(results.steps[1].skipped, true);
306
+ });
307
+
308
+ test('runE2eSteps: exit 0 but expect mismatch fails', () => {
309
+ const results = runE2eSteps({ steps: [greenStep({ expect: 'ratified: \\d+' })] });
310
+ assert.equal(results.ok, false);
311
+ assert.equal(results.steps[0].expectMatched, false);
312
+ });
313
+
314
+ test('checkE2eGate: missing file, missing results, stale hash, failed run, green, notApplicable', () => {
315
+ const dir = tmp('forge-e2e-gate-');
316
+ try {
317
+ const e2eFile = path.join(dir, 'e2e.json');
318
+
319
+ let gate = checkE2eGate({ e2eFile, sessionDir: dir });
320
+ assert.match(gate.problems.join('\n'), /e2e\.json required/);
321
+
322
+ writeE2eDoc(dir, { notApplicable: null, steps: [greenStep()] });
323
+ gate = checkE2eGate({ e2eFile, sessionDir: dir });
324
+ assert.match(gate.problems.join('\n'), /e2e-results\.json missing/);
325
+
326
+ const results = runE2eSteps({ steps: [greenStep()] });
327
+ writeE2eResults(dir, results);
328
+ gate = checkE2eGate({ e2eFile, sessionDir: dir });
329
+ assert.deepEqual(gate.problems, []);
330
+
331
+ writeE2eDoc(dir, { notApplicable: null, steps: [greenStep({ name: 'edited' })] });
332
+ gate = checkE2eGate({ e2eFile, sessionDir: dir });
333
+ assert.match(gate.problems.join('\n'), /stale/);
334
+
335
+ writeE2eDoc(dir, { notApplicable: null, steps: [{ name: 'boom', cmd: 'node -e "process.exit(1)"' }] });
336
+ writeE2eResults(dir, runE2eSteps({ steps: [{ name: 'boom', cmd: 'node -e "process.exit(1)"' }] }));
337
+ gate = checkE2eGate({ e2eFile, sessionDir: dir });
338
+ assert.match(gate.problems.join('\n'), /failed at step "boom"/);
339
+
340
+ writeE2eDoc(dir, { notApplicable: 'loop needs a physical device', steps: [] });
341
+ gate = checkE2eGate({ e2eFile, sessionDir: dir });
342
+ assert.deepEqual(gate.problems, []);
343
+ assert.equal(gate.notApplicable, true);
344
+ } finally {
345
+ fs.rmSync(dir, { recursive: true, force: true });
346
+ }
347
+ });
348
+
349
+ test('runIntegrityChecks: spine rows demand an executed green e2e run', () => {
219
350
  const cwd = tmp('forge-int-loop-');
220
351
  try {
221
352
  const sessionDir = makeSessionDir(cwd);
222
- const spineFile = path.join(sessionDir, 'spine.json');
223
- fs.writeFileSync(
224
- spineFile,
225
- `${JSON.stringify({ change: null, notApplicable: null, rows: [validRow()] }, null, 2)}\n`,
226
- 'utf8',
227
- );
353
+ writeSpineWithRows(sessionDir);
228
354
  const session = { slug: 'wire-worker-jobs', openspecChange: null };
229
355
 
230
356
  let result = runIntegrityChecks({ cwd, sessionDir, session });
231
357
  assert.equal(result.ok, false);
232
- assert.match(result.problems.join('\n'), /verify-evidence\.md missing/);
358
+ assert.match(result.problems.join('\n'), /e2e\.json required/);
233
359
 
234
- const evidenceFile = path.join(sessionDir, 'verify-evidence.md');
235
- fs.writeFileSync(evidenceFile, '# Verify\n\ntier 3 green\n', 'utf8');
360
+ writeE2eDoc(sessionDir, { notApplicable: null, steps: [greenStep()] });
236
361
  result = runIntegrityChecks({ cwd, sessionDir, session });
237
362
  assert.equal(result.ok, false);
238
- assert.match(result.problems.join('\n'), /no "Product loop" section/);
363
+ assert.match(result.problems.join('\n'), /e2e-results\.json missing/);
239
364
 
240
- fs.writeFileSync(evidenceFile, '# Verify\n\n## Product loop\n\nBLOCKED: no compose here\n', 'utf8');
365
+ writeE2eResults(sessionDir, runE2eSteps({ steps: [greenStep()] }));
241
366
  result = runIntegrityChecks({ cwd, sessionDir, session });
242
- assert.equal(result.ok, false);
243
- assert.match(result.problems.join('\n'), /BLOCKED/);
367
+ assert.equal(result.ok, true);
368
+ assert.equal(result.e2eFile, path.join(sessionDir, 'e2e.json'));
244
369
 
370
+ // prose "## Product loop" alone no longer satisfies the gate
371
+ fs.rmSync(path.join(sessionDir, 'e2e-results.json'));
245
372
  fs.writeFileSync(
246
- evidenceFile,
247
- '# Verify\n\n## Product loop\n\ningest x3 -> analyze -> ratify -> run@R: output differs from baseline\n',
373
+ path.join(sessionDir, 'verify-evidence.md'),
374
+ '# Verify\n\n## Product loop\n\ningest -> analyze -> ratify: output differs\n',
248
375
  'utf8',
249
376
  );
250
377
  result = runIntegrityChecks({ cwd, sessionDir, session });
251
- assert.equal(result.ok, true);
378
+ assert.equal(result.ok, false);
379
+ assert.match(result.problems.join('\n'), /e2e-results\.json missing/);
380
+ } finally {
381
+ fs.rmSync(cwd, { recursive: true, force: true });
382
+ }
383
+ });
384
+
385
+ test('runIntegrityChecks: BLOCKED in verify-evidence blocks even with green e2e', () => {
386
+ const cwd = tmp('forge-int-blocked-');
387
+ try {
388
+ const sessionDir = makeSessionDir(cwd);
389
+ writeSpineWithRows(sessionDir);
390
+ writeE2eDoc(sessionDir, { notApplicable: null, steps: [greenStep()] });
391
+ writeE2eResults(sessionDir, runE2eSteps({ steps: [greenStep()] }));
392
+ fs.writeFileSync(
393
+ path.join(sessionDir, 'verify-evidence.md'),
394
+ '# Verify\n\nBLOCKED: ratify UI unreachable in CI\n',
395
+ 'utf8',
396
+ );
397
+ const result = runIntegrityChecks({
398
+ cwd,
399
+ sessionDir,
400
+ session: { slug: 'wire-worker-jobs', openspecChange: null },
401
+ });
402
+ assert.equal(result.ok, false);
403
+ assert.match(result.problems.join('\n'), /BLOCKED/);
252
404
  } finally {
253
405
  fs.rmSync(cwd, { recursive: true, force: true });
254
406
  }
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { execFileSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { runE2eSteps, writeE2eResults } from './integrity.mjs';
8
9
 
9
10
  const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'set-phase.mjs');
10
11
 
@@ -253,7 +254,7 @@ test('phase done refuses any session without spine.json', () => {
253
254
  }
254
255
  });
255
256
 
256
- test('phase done accepts jobs-scoped session with wired spine + product-loop evidence', () => {
257
+ test('phase done accepts jobs-scoped session with wired spine + green e2e run', () => {
257
258
  const dir = tmp('forge-set-phase-spine-ok-');
258
259
  try {
259
260
  const sessionFile = makeForgeFixture(dir, 'sess-spine-ok');
@@ -289,6 +290,13 @@ test('phase done accepts jobs-scoped session with wired spine + product-loop evi
289
290
  '# Verify\n\n## Product loop\n\ningest -> analyze -> ratify -> run: output differs\n',
290
291
  'utf8',
291
292
  );
293
+ const e2eSteps = [{ name: 'loop', cmd: 'node -e "console.log(\'ratified: 1\')"' }];
294
+ fs.writeFileSync(
295
+ path.join(sessionDir, 'e2e.json'),
296
+ `${JSON.stringify({ change: null, notApplicable: null, steps: e2eSteps }, null, 2)}\n`,
297
+ 'utf8',
298
+ );
299
+ writeE2eResults(sessionDir, runE2eSteps({ steps: e2eSteps }));
292
300
 
293
301
  runSetPhase(dir, ['done']);
294
302
  const session = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
@@ -117,7 +117,7 @@ Testing: [references/test-strategy.md](./references/test-strategy.md) — tier 1
117
117
  - Tests required for behavior changes
118
118
  - Trace ecosystem consumers when contracts change
119
119
  - Honor `openspec/config.yaml` prefixes when the project uses them (OpenSpec engine)
120
- - **Runtime integrity** — [references/runtime-integrity.md](./references/runtime-integrity.md): **spine.json mandatory every change** (rows or `notApplicable` — not keyword-gated); no stubs / false success; capability specs beat narrow task wording; every claimed capability needs a named production caller; product-loop when spine has rows (or BLOCKED); deferred wiring only via `forge defer` — `forge phase done` mechanically refuses on `forge integrity-check` failures
120
+ - **Runtime integrity** — [references/runtime-integrity.md](./references/runtime-integrity.md): **spine.json mandatory every change** (rows or `notApplicable` — not keyword-gated); no stubs / false success; capability specs beat narrow task wording; every claimed capability needs a named production caller; when spine has rows the product loop must be **executed** — `e2e.json` steps + green `forge e2e run` (or BLOCKED), prose does not satisfy the gate; deferred wiring only via `forge defer` — `forge phase done` mechanically refuses on `forge integrity-check` failures
121
121
 
122
122
  ## Agent surfaces
123
123
 
@@ -115,7 +115,7 @@ User request
115
115
  └─────────────┬─────────────┘
116
116
 
117
117
  Verify: audit tier 2 + tier 3 (scope from pace)
118
- + ## Product loop (or BLOCKED)
118
+ + forge e2e run (green, or BLOCKED)
119
119
  + forge integrity-check
120
120
 
121
121
 
@@ -130,8 +130,9 @@ User request
130
130
  ```
131
131
 
132
132
  **Jobs / workers / queues:** spine is mandatory for *every* change (`forge spine
133
- init` — rows or `notApplicable`). Async work also needs wiring + product-loop
134
- tasks. See [Runtime integrity](#runtime-integrity).
133
+ init` — rows or `notApplicable`). Spine rows also require executable acceptance
134
+ steps (`forge e2e init` at plan, green `forge e2e run` before done). Async work
135
+ also needs wiring + product-loop tasks. See [Runtime integrity](#runtime-integrity).
135
136
 
136
137
  ### Triage (top of tree)
137
138
 
@@ -178,10 +179,10 @@ See the Forge skill’s [references/plan-routing.md](../references/plan-routing.
178
179
  | ----- | ------------ | ----------------- |
179
180
  | **triage** | Substantial? Skip allowed? Bootstrap session | `forge` skill |
180
181
  | **brainstorm** | Explore intent, approaches, approval | `skills/brainstorming` |
181
- | **plan** | Tracked-change propose; **`forge spine init` every change** (rows or `notApplicable`); wiring + product-loop tasks when async | [plan-routing.md](../references/plan-routing.md) |
182
+ | **plan** | Tracked-change propose; **`forge spine init` every change** (rows or `notApplicable`); rows → `forge e2e init` (steps are a plan deliverable); wiring + product-loop tasks when async | [plan-routing.md](../references/plan-routing.md) |
182
183
  | **implement** | Subagent per task, TDD, tier 2 evidence; update spine rows; `forge defer` for deferred wiring | **`/forge:apply`** (OpenSpec) or `/forge:build` + `skills/subagent-driven-development` + `skills/test-driven-development` + [test-strategy](../references/test-strategy.md) |
183
- | **verify** | Audit tier 2; tier 3; product-loop evidence; `forge integrity-check` | `skills/verification-before-completion` + `verify-evidence.md` |
184
- | **review** | Combined task reviewer (spec + quality) per task; final review (spine + product loop) | `skills/requesting-code-review` |
184
+ | **verify** | Audit tier 2; tier 3; green `forge e2e run`; `forge integrity-check` | `skills/verification-before-completion` + `verify-evidence.md` |
185
+ | **review** | Combined task reviewer (spec + quality) per task; final review (spine + executed e2e) | `skills/requesting-code-review` |
185
186
  | **finish** | Archive (+ ADR if the project uses that); `forge phase done` (integrity gate); cleanup | `/opsx:archive`, `forge cleanup` |
186
187
 
187
188
  **Standalone deep review (outside Forge):** for pre-merge audits with adversarial false-positive filtering, use the **thorough code review** skill — see [thorough-code-review.md](https://github.com/izkac/forgekit/blob/main/docs/thorough-code-review.md). Forge's `requesting-code-review` stays the per-task checkpoint during `/forge:build`.
@@ -206,9 +207,11 @@ Cursor, Claude Code, and Codex without requiring a chat ID.
206
207
  notes.md
207
208
  decisions.md
208
209
  plan.md ← legacy throwaway plans only (deprecated)
209
- verify-evidence.md ← tier 3 + ## Product loop (or BLOCKED)
210
+ verify-evidence.md ← tier 3 + loop narrative (or BLOCKED)
211
+ e2e-results.json ← forge e2e run results (steps hash + per-step outcomes)
210
212
  deferrals.json ← forge defer registry (when used)
211
213
  spine.json ← fallback if no tracked change dir
214
+ e2e.json ← fallback if no tracked change dir
212
215
  scorecard.md / scorecard.json ← L2 session score (written at done/finish)
213
216
  tasks/
214
217
  01-first-task/
@@ -219,8 +222,9 @@ Cursor, Claude Code, and Codex without requiring a chat ID.
219
222
  final-review.md
220
223
  ```
221
224
 
222
- For OpenSpec / specs-engine changes, the canonical **spine matrix** lives next to
223
- the plan: `openspec/changes/<name>/spine.json` (or `<specsDir>/changes/<name>/spine.json`).
225
+ For OpenSpec / specs-engine changes, the canonical **spine matrix** and **e2e
226
+ steps** live next to the plan: `openspec/changes/<name>/spine.json` + `e2e.json`
227
+ (or `<specsDir>/changes/<name>/…`).
224
228
 
225
229
  **Session ID:** `<UTC-compact>-<kebab-slug>-<6-hex>`
226
230
 
@@ -275,8 +279,9 @@ forge prefs --session-set lite # pin active session only
275
279
  forge doctor # plan-engine readiness (OpenSpec or specs layout)
276
280
  forge doctor --install # attempt npm install -g @fission-ai/openspec
277
281
  forge spine init|check # capability→runtime spine matrix (spine.json in change dir)
282
+ forge e2e init|run|check # executable product-loop acceptance (e2e.json + e2e-results.json)
278
283
  forge defer add|resolve|list # deferral registry — deferred wiring is tracked debt
279
- forge integrity-check # mechanical gate: spine + deferrals + product-loop evidence
284
+ forge integrity-check # mechanical gate: spine + deferrals + executed e2e
280
285
  forge score [--write] [--md] # L2 session scorecard (also auto-written at phase done)
281
286
  forge overlay # re-apply OpenSpec vendor overlays in this project
282
287
  forge init […] # wire project commands / hooks / rules
@@ -399,7 +404,7 @@ Integrity upgrades Forge from “no false job success” to **product-loop accep
399
404
  2. **Runtime owner required** — a library alone does not satisfy a capability; name the production caller (job, endpoint, CLI).
400
405
  3. **Tests must fail on a no-op** — asserting “job status became succeeded” is not enough.
401
406
  4. **Specs beat narrow tasks** — capability specs win when they conflict with a thin task reading.
402
- 5. **E2E = product loop** — produce → consume → decision changes output. A single job slice (ingest → Parquet) is **not** platform E2E.
407
+ 5. **E2E = executed product loop** — produce → consume → decision changes output, run as `e2e.json` steps via `forge e2e run` (prose does not count). A single job slice (ingest → Parquet) is **not** platform E2E.
403
408
  6. **Job-kind closure** — every product-surface job kind is wired end-to-end **or deleted** before complete. “Fail closed” is only a temporary `BLOCKED` state.
404
409
  7. **Consumer–producer** — if UI/API reads it, production must write it (proven in evidence).
405
410
  8. **Deferrals are tracked** — “wiring later” only via `forge defer`; unresolved deferrals block `done`.
@@ -409,6 +414,7 @@ Integrity upgrades Forge from “no false job success” to **product-loop accep
409
414
  | Tool | Purpose |
410
415
  |------|---------|
411
416
  | `forge spine init\|check` | **Mandatory every change.** `spine.json`: rows **or** `notApplicable`. Not keyword-gated. |
417
+ | `forge e2e init\|run\|check` | **Mandatory when the spine has rows.** `e2e.json` step list executed by `forge e2e run`; results (`e2e-results.json`) carry a steps hash, so edits after a green run go stale |
412
418
  | `forge defer add\|resolve\|list` | Deferred wiring as tracked debt in the session |
413
419
  | `forge integrity-check` | Combined gate — also run automatically by `forge phase done\|finish` |
414
420
 
@@ -425,9 +431,9 @@ You do **not** paste a long definition-of-done prompt. After
425
431
 
426
432
  | Automatic (CLI / hooks) | Agent-driven (skill phases — required) |
427
433
  | ----------------------- | -------------------------------------- |
428
- | Integrity reminder on every session/prompt hook | Plan: **`forge spine init` every change** — fill rows or `notApplicable` |
434
+ | Integrity reminder on every session/prompt hook | Plan: **`forge spine init` every change** — fill rows or `notApplicable`; rows → also `forge e2e init` |
429
435
  | Pace `auto` fail-closed to **standard**; task-count escalation at ≥15 | Implement: update spine rows; `forge defer add` if wiring is deferred |
430
- | `forge phase done\|finish` requires valid spine + writes L2 scorecard | Verify: `## Product loop` when spine has rows (sync-only → prefer `notApplicable`) |
436
+ | `forge phase done\|finish` requires valid spine + green current e2e run + writes L2 scorecard | Verify: green `forge e2e run` when spine has rows (sync-only → prefer `notApplicable`) |
431
437
  | `forge status` surfaces `integrity.*` defaults | After done: answer L3 ship-check in `scorecard.md` |
432
438
 
433
439
  **Gates are automatic. Filling evidence is part of the normal phase flow.**
@@ -471,35 +477,51 @@ forge spine init
471
477
 
472
478
  Docs-only / no-runtime changes may set `"notApplicable": "docs-only change"` instead of rows.
473
479
 
474
- **If wiring must wait for a later task**
480
+ Spine rows also author the executable acceptance steps:
475
481
 
476
482
  ```bash
477
- forge defer add --task 9.7 --reason "analyze_study handler lands in 9.7"
478
- # when 9.7 is done:
479
- forge defer resolve --task 9.7
483
+ forge e2e init
484
+ # edit openspec/changes/<name>/e2e.json the closed loop as commands
480
485
  ```
481
486
 
482
- **Verify evidence** (required when spine has rows):
487
+ ```json
488
+ {
489
+ "change": "etl-surveydb-pipeline-closure",
490
+ "notApplicable": null,
491
+ "steps": [
492
+ { "name": "ingest", "cmd": "node scripts/e2e/ingest-fixture.mjs OP1086" },
493
+ { "name": "analyze", "cmd": "node scripts/e2e/run-analyze.mjs", "expect": "proposals: [1-9]" },
494
+ { "name": "ratify", "cmd": "node scripts/e2e/ratify-subset.mjs" },
495
+ { "name": "run-assert", "cmd": "node scripts/e2e/assert-output-differs.mjs", "timeoutMs": 600000 }
496
+ ]
497
+ }
498
+ ```
483
499
 
484
- ```markdown
485
- # Verify evidence tier 3
500
+ Steps must assert domain side effects — a list that would pass against a
501
+ stubbed handler is invalid. `"notApplicable": "<reason>"` only when no command
502
+ can drive the loop.
486
503
 
487
- - **Command:** `pytest …` / `npm test …`
488
- - **Exit code:** 0
504
+ **If wiring must wait for a later task**
489
505
 
490
- ## Product loop
506
+ ```bash
507
+ forge defer add --task 9.7 --reason "analyze_study handler lands in 9.7"
508
+ # … when 9.7 is done:
509
+ forge defer resolve --task 9.7
510
+ ```
491
511
 
492
- Fixture: OP1086 three sources
512
+ **Verify** (required when spine has rows):
493
513
 
494
- 1. ingest_source ×3 → study_sources + Parquet
495
- 2. analyze_study study_proposals (match / loop)
496
- 3. ratify subset via API → decisions tip at revision R
497
- 4. harmonization_run @R → .sav + Master QML + BI
498
- 5. Assert: output at R differs from unratified baseline
514
+ ```bash
515
+ forge e2e run # executes the steps, writes e2e-results.json (session dir)
499
516
  ```
500
517
 
501
- Or an explicit `BLOCKED: …` line then `forge phase done` refuses until unblocked
502
- or the user passes `--allow-incomplete`.
518
+ Green run required; results go stale if `e2e.json` changes afterwards (steps
519
+ hash). Keep a short loop narrative under `## Product loop` in
520
+ `verify-evidence.md` as reviewer context — the gate checks the executed
521
+ results, not the heading.
522
+
523
+ Or an explicit `BLOCKED: …` line in `verify-evidence.md` — then `forge phase
524
+ done` refuses until unblocked or the user passes `--allow-incomplete`.
503
525
 
504
526
  **Finish**
505
527
 
@@ -3,7 +3,7 @@
3
3
  Before marking done, integrity must pass (or the user must approve an incomplete finish):
4
4
 
5
5
  ```bash
6
- forge integrity-check # spine + deferrals + product-loop / BLOCKED
6
+ forge integrity-check # spine + deferrals + executed e2e (green, current) / BLOCKED
7
7
  forge score # preview L2 scorecard (optional)
8
8
  forge phase done # runs integrity checks + writes scorecard.md/json
9
9
  # escape hatch only with an honest reason:
@@ -38,6 +38,7 @@ Honor [../references/runtime-integrity.md](../references/runtime-integrity.md) i
38
38
  - Do not mark a section complete if libraries exist but nothing in the production path calls them.
39
39
  - **Deferrals:** if wiring genuinely lands in a later task, register it — `forge defer add --task <id> --reason "…"` — and resolve it when that task lands. Unregistered "later" is a REJECT; unresolved deferrals block `forge phase done`.
40
40
  - **Spine:** when a task wires a capability into production, update its `spine.json` row (runtimeOwner / writes / evidence). `forge spine check` must pass before verify ends.
41
+ - **E2E:** the product-loop acceptance task (last implement task) delivers working `e2e.json` steps and a green `forge e2e run` — steps that would pass against a stubbed handler are invalid.
41
42
 
42
43
  ## Per-task loop
43
44
 
@@ -16,14 +16,20 @@ Thin wrapper around the project **`openspec-propose`** skill (or `/opsx:propose`
16
16
  Sync-only / docs-only: `"notApplicable": "<reason>"`. Capability work: one row
17
17
  per REQ cluster (library → runtime owner → writes → evidence).
18
18
 
19
+ When the spine has real rows, also `forge e2e init` — the executable
20
+ product-loop steps (`e2e.json`) are a **plan deliverable**: author them (or
21
+ task out their authoring) so verify can `forge e2e run` them.
22
+
19
23
  If the change also involves workers, job queues, handlers, or cross-runtime
20
24
  calls, `tasks.md` MUST include:
21
25
 
22
26
  - Explicit **wiring** tasks per job kind / entry point → domain pipeline
23
- - One **product-loop acceptance** task (last implement task, before verify)
27
+ - One **product-loop acceptance** task (last implement task, before
28
+ verify) — its output is a green `forge e2e run`
24
29
 
25
30
  Missing spine = plan **not** ready. (`forge phase done` refuses without a
26
- valid spine keyword sniffing does not decide.)
31
+ valid spine and, when the spine has rows, a green current e2e run —
32
+ keyword sniffing does not decide.)
27
33
 
28
34
  5. Update session:
29
35
  ```bash
@@ -64,14 +64,20 @@ OpenSpec propose flow without the vendor CLI. Change lives under
64
64
  Sync-only / docs-only: `"notApplicable": "<reason>"`. Capability work: one row
65
65
  per REQ cluster (library → runtime owner → writes → evidence).
66
66
 
67
+ When the spine has real rows, also `forge e2e init` — the executable
68
+ product-loop steps (`e2e.json`) are a **plan deliverable**: author them (or
69
+ task out their authoring) so verify can `forge e2e run` them.
70
+
67
71
  If the change also involves workers, job queues, handlers, or cross-runtime
68
72
  calls, `tasks.md` MUST include:
69
73
 
70
74
  - Explicit **wiring** tasks per job kind / entry point → domain pipeline
71
- - One **product-loop acceptance** task (last implement task, before verify)
75
+ - One **product-loop acceptance** task (last implement task, before
76
+ verify) — its output is a green `forge e2e run`
72
77
 
73
78
  Missing spine = plan **not** ready. (`forge phase done` refuses without a
74
- valid spine keyword sniffing does not decide.)
79
+ valid spine and, when the spine has rows, a green current e2e run —
80
+ keyword sniffing does not decide.)
75
81
 
76
82
  5. Update session:
77
83
 
@@ -73,12 +73,15 @@ For each requirement in the change's **capability specs** (not only `tasks.md`):
73
73
 
74
74
  Record the trace in `verify-evidence.md` (a short REQ → caller table is enough).
75
75
 
76
- ### 4. Product-loop E2E or BLOCKED
76
+ ### 4. Product-loop E2E — executed, or BLOCKED
77
77
 
78
78
  Before leaving verify / claiming the change complete:
79
79
 
80
- 1. Run (or document exact commands for) the **closed product loop** — not a single job slice. When the design has a producer/consumer split (analyze vs execute, proposals vs ratify), the loop is: produce artifact → consumer reads it → decision/state change → **next run's output differs from baseline**. Record it under a `## Product loop` heading in `verify-evidence.md` (the done gate greps for it). **Or**
81
- 2. Leave an explicit **`BLOCKED`** list in `verify-evidence.md` explaining why E2E cannot run here the done gate then refuses `done` until unblocked or the user signs `--allow-incomplete`.
80
+ 1. Confirm `e2e.json` (scaffolded at plan time via `forge e2e init`) drives the **closed product loop** — not a single job slice. When the design has a producer/consumer split (analyze vs execute, proposals vs ratify), the loop is: produce artifact → consumer reads it → decision/state change → **next run's output differs from baseline**. Steps must assert domain side effects; a step list that would pass against a stubbed handler is invalid.
81
+ 2. `forge e2e run` executes the steps sequentially and writes `e2e-results.json` (per-step exit codes, output tails, steps hash) into the session dir. A **green run** is required; results go stale if `e2e.json` changes afterwards (re-run). Prose in `verify-evidence.md` no longer satisfies the done gate. **Or**
82
+ 3. Leave an explicit **`BLOCKED`** list in `verify-evidence.md` explaining why E2E cannot run here — the done gate then refuses `done` until unblocked or the user signs `--allow-incomplete`. (`e2e.json` `notApplicable` is only for loops no command can drive — reviewers police the reason.)
83
+
84
+ Keep a short loop narrative under `## Product loop` in `verify-evidence.md` as reviewer context — the gate checks the executed results, not the heading.
82
85
 
83
86
  Also enforce **job-kind closure**: every product-surface job kind is wired end-to-end or deleted from enums/API/UI before complete. And the **consumer–producer rule**: anything the UI/API reads must be proven written by the production path.
84
87
 
@@ -88,6 +91,7 @@ Do **not** mark the change complete or advance to `done` while a critical path i
88
91
 
89
92
  ```bash
90
93
  forge spine check # every capability row wired (library → runtime owner → writes → evidence)
94
+ forge e2e check # green, current e2e-results.json (steps hash matches e2e.json)
91
95
  forge defer list # no unresolved deferrals
92
96
  forge integrity-check # combined; forge phase done runs the same checks
93
97
  ```
@@ -45,12 +45,13 @@ task.
45
45
  - To shrink scope: stop and ask the user. Do not silently checkbox around gaps.
46
46
  - Prefer `DONE_WITH_CONCERNS` / incomplete tasks over green checkboxes.
47
47
 
48
- ## 5. E2E means product loop, not job slice
48
+ ## 5. E2E means an executed product loop, not prose
49
49
 
50
50
  Before claiming the change complete:
51
51
 
52
- 1. Run (or document exact commands for) the **closed product loop** through the
53
- live entry points, **or**
52
+ 1. Author the **closed product loop** as executable steps in `e2e.json`
53
+ (`forge e2e init`), run it against the live entry points
54
+ (`forge e2e run`), and get a **green run** — **or**
54
55
  2. Leave an explicit **`BLOCKED`** list in `verify-evidence.md` — and do **not**
55
56
  mark the change complete / advance to `done`.
56
57
 
@@ -65,8 +66,19 @@ next run's OUTPUT DIFFERS from the baseline
65
66
 
66
67
  Example: ingest→Parquet plus a thin run→`.sav` does **not** verify a
67
68
  governance loop; analyze→proposals→ratify→run-applies-decisions does.
68
- Record it under a `## Product loop` heading in `verify-evidence.md` —
69
- the mechanical gate looks for it.
69
+
70
+ The mechanical gate requires a green, **current** `e2e-results.json` (its
71
+ steps hash must match `e2e.json`) whenever the spine has real rows.
72
+ Describing the loop in `verify-evidence.md` no longer satisfies the gate —
73
+ prose cannot prove wiring. Each step is `{ name, cmd, expect?, timeoutMs? }`:
74
+ exit code 0 required, `expect` (regex) matched against the output. Steps must
75
+ assert **domain side effects** — a step list that would pass against a
76
+ stubbed handler is invalid (rule 3 applies to e2e steps too).
77
+
78
+ `e2e.json` may set `"notApplicable": "<reason>"` only when the loop cannot be
79
+ driven by any command (e.g. requires a physical device). Reviewers police the
80
+ reason; "no time" or "covered by unit tests" is a REJECT. Keep a short loop
81
+ narrative under `## Product loop` in `verify-evidence.md` as reviewer context.
70
82
 
71
83
  ## 6. Job-kind closure
72
84
 
@@ -126,9 +138,9 @@ Then either:
126
138
  (e.g. `"sync HTTP only — no async producer/consumer"`). That is the honest
127
139
  opt-out; missing `spine.json` is not.
128
140
 
129
- Product-loop evidence in `verify-evidence.md` is required when the spine has
130
- real rows. Prefer `notApplicable` for sync-only changes instead of inventing a
131
- fake loop.
141
+ An executed product loop (`forge e2e run`, green + current results) is
142
+ required when the spine has real rows. Prefer `notApplicable` for sync-only
143
+ changes instead of inventing a fake loop.
132
144
 
133
145
  ## Reviewer REJECT checklist (mandatory)
134
146
 
@@ -141,6 +153,8 @@ REJECT the task (or final review → `NOT READY`) if any of:
141
153
  - Spec requirement has a library but no named runtime owner
142
154
  - Deferred wiring without a registered open deferral (`forge defer list`)
143
155
  - Spine row for this capability missing or library-only
156
+ - E2E steps would pass against a stubbed handler (no domain side-effect
157
+ assertions), or `e2e.json` opts out via `notApplicable` without a real reason
144
158
 
145
159
  ## Plan seam (every change)
146
160
 
@@ -148,10 +162,14 @@ Before apply-ready:
148
162
 
149
163
  1. `forge spine init` — **always**. Fill rows for each capability, or set
150
164
  `notApplicable` with a reason (sync-only / docs-only).
151
- 2. If the change involves workers, job queues, handlers, or cross-runtime
165
+ 2. When the spine has real rows, `forge e2e init` the acceptance **steps are
166
+ a plan deliverable**: author (or task out) the `e2e.json` step list that
167
+ drives the loop and asserts its side effects.
168
+ 3. If the change involves workers, job queues, handlers, or cross-runtime
152
169
  calls, `tasks.md` MUST also include:
153
170
  - Explicit **wiring** tasks per job kind / entry point → domain pipeline
154
- - One **product-loop acceptance** task (last implement task, before verify)
171
+ - One **product-loop acceptance** task (last implement task, before
172
+ verify) — its output is a green `forge e2e run`
155
173
 
156
174
  Missing spine = plan not ready. Keyword guesses about “jobs in scope” are not
157
175
  an excuse to skip the spine.
@@ -34,14 +34,17 @@ job, …) that invokes the implementing code. Cross-check against `spine.json`
34
34
  - UI/API reads a collection or artifact nothing in the production path writes → **`NOT READY`**
35
35
  - Missing E2E fixture path with no explicit `BLOCKED` in `verify-evidence.md` → **`NOT READY`**
36
36
 
37
- ## Product-loop evidence (required)
37
+ ## Product-loop acceptance (required — executed, not described)
38
38
 
39
- `verify-evidence.md` must contain a `## Product loop` section proving the
40
- **closed loop** (produce → consume → decision changes output), or an explicit
41
- `BLOCKED` list. A single job slice (e.g. ingest→file) or a library-level E2E
42
- does **not** count as platform E2E.
39
+ `forge e2e check` must be green: `e2e.json` steps drive the **closed loop**
40
+ (produce → consume → decision changes output) and `e2e-results.json` records a
41
+ green, current run (steps hash matches). A single job slice (e.g. ingest→file)
42
+ or a library-level E2E does **not** count as platform E2E. Read the steps —
43
+ would they pass against a stubbed handler? If yes, they prove nothing.
43
44
 
44
- - No product-loop section and no `BLOCKED` → **`NOT READY`**
45
+ - No green, current e2e run and no `BLOCKED` in `verify-evidence.md` → **`NOT READY`**
46
+ - E2E steps assert no domain side effects (would pass on a stub) → **`NOT READY`**
47
+ - `e2e.json` `notApplicable` without a reason no command could overcome → **`NOT READY`**
45
48
  - `BLOCKED` present → **`NOT READY`** (honest, but not READY)
46
49
  - Unresolved deferrals in `forge defer list` → **`NOT READY`**
47
50
 
@@ -43,6 +43,7 @@ Diff range: {DIFF_RANGE} <!-- e.g. `git diff` (uncommitted) or BASE..HEAD -->
43
43
  - Brief authorized a stub / “wire later” for a path this change claims
44
44
  - Wiring is deferred **without a registered open deferral** — the packet must show `forge defer list` output naming this task's deferral; "wiring in §9" with no registry entry is a REJECT
45
45
  - The task claims a capability whose `spine.json` row is missing or library-only (empty runtimeOwner / writes / evidence)
46
+ - The task authored or touched `e2e.json` steps that would pass against a stubbed handler (no domain side-effect assertions), or set `notApplicable` without a real reason
46
47
 
47
48
  **Code quality:**
48
49