@izkac/forgekit 0.1.7 → 0.3.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.
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Machine-level fleet registry: one JSON file per forge session under
4
+ * `~/.forgekit/fleet/sessions/`, mirrored on every `saveSession` so any
5
+ * project's sessions are visible from a single control terminal
6
+ * (`forge fleet list|watch|view|send`).
7
+ *
8
+ * Standalone on purpose — no import of ../lib.mjs (which binds cwd at import
9
+ * time); everything here takes explicit paths. `FORGEKIT_FLEET_DIR` overrides
10
+ * the registry location (tests point it at a tmp dir).
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ export const PHASE_ORDER = [
18
+ 'triage',
19
+ 'brainstorm',
20
+ 'plan',
21
+ 'implement',
22
+ 'verify',
23
+ 'review',
24
+ 'finish',
25
+ 'done',
26
+ ];
27
+
28
+ export function fleetDir() {
29
+ return (
30
+ process.env.FORGEKIT_FLEET_DIR ||
31
+ path.join(os.homedir(), '.forgekit', 'fleet', 'sessions')
32
+ );
33
+ }
34
+
35
+ /** Same sanitisation Claude Code uses for ~/.claude/projects dir names. */
36
+ export function sanitizePath(p) {
37
+ return String(p).replace(/[^a-zA-Z0-9]/g, '-');
38
+ }
39
+
40
+ export function entryFile(projectRoot, sessionId) {
41
+ return path.join(fleetDir(), `${sanitizePath(projectRoot)}--${sessionId}.json`);
42
+ }
43
+
44
+ /**
45
+ * Best-effort engine detection from env vars set by agent harnesses.
46
+ * ponytail: claude + cursor only; other engines show as null until they
47
+ * grow a detectable env marker.
48
+ */
49
+ export function detectEngine(env = process.env) {
50
+ if (env.CLAUDECODE) return 'claude';
51
+ if (env.CURSOR_TRACE_ID || env.CURSOR_AGENT) return 'cursor';
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Mirror a session into the registry. Never throws — a broken registry must
57
+ * not break session saves.
58
+ *
59
+ * @param {string} projectRoot absolute project path
60
+ * @param {Record<string, any>} session forge session.json contents
61
+ */
62
+ export function registerSession(projectRoot, session) {
63
+ try {
64
+ const file = entryFile(projectRoot, session.id);
65
+ let prev = {};
66
+ try {
67
+ prev = JSON.parse(fs.readFileSync(file, 'utf8'));
68
+ } catch {
69
+ /* first registration */
70
+ }
71
+ const entry = {
72
+ project: projectRoot,
73
+ projectName: path.basename(projectRoot),
74
+ sessionId: session.id,
75
+ slug: session.slug,
76
+ phase: session.phase,
77
+ planType: session.planType ?? null,
78
+ openspecChange: session.openspecChange ?? null,
79
+ tasksTotal: session.tasksTotal ?? 0,
80
+ tasksComplete: session.tasksComplete ?? 0,
81
+ pace: session.resolvedPace ?? session.pace ?? null,
82
+ engine: detectEngine() ?? prev.engine ?? null,
83
+ createdAt: session.createdAt,
84
+ updatedAt: session.updatedAt,
85
+ };
86
+ fs.mkdirSync(fleetDir(), { recursive: true });
87
+ fs.writeFileSync(file, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
88
+ } catch {
89
+ /* registry is advisory */
90
+ }
91
+ }
92
+
93
+ export function unregisterSession(projectRoot, sessionId) {
94
+ try {
95
+ fs.rmSync(entryFile(projectRoot, sessionId), { force: true });
96
+ } catch {
97
+ /* advisory */
98
+ }
99
+ }
100
+
101
+ /**
102
+ * All registry entries, newest first. Self-heals: entries whose session dir
103
+ * vanished (cleanup ran without unregister, project deleted the .forge dir)
104
+ * are removed; entries whose whole project path is unreachable (unplugged
105
+ * drive) are kept and marked `missing`.
106
+ *
107
+ * @returns {Array<Record<string, any>>}
108
+ */
109
+ export function listFleet() {
110
+ const dir = fleetDir();
111
+ if (!fs.existsSync(dir)) return [];
112
+ const entries = [];
113
+ for (const name of fs.readdirSync(dir)) {
114
+ if (!name.endsWith('.json')) continue;
115
+ const file = path.join(dir, name);
116
+ let entry;
117
+ try {
118
+ entry = JSON.parse(fs.readFileSync(file, 'utf8'));
119
+ } catch {
120
+ continue;
121
+ }
122
+ const sessionDir = path.join(entry.project, '.forge', 'sessions', entry.sessionId);
123
+ if (fs.existsSync(sessionDir)) {
124
+ entry.missing = false;
125
+ } else if (fs.existsSync(entry.project)) {
126
+ fs.rmSync(file, { force: true });
127
+ continue;
128
+ } else {
129
+ entry.missing = true;
130
+ }
131
+ entries.push(entry);
132
+ }
133
+ entries.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
134
+ return entries;
135
+ }
136
+
137
+ export function sessionDirFor(entry) {
138
+ return path.join(entry.project, '.forge', 'sessions', entry.sessionId);
139
+ }
140
+
141
+ /**
142
+ * Queue a fleet message for a session; delivered into the agent's context by
143
+ * `forge reminder` (hook) on its next turn.
144
+ */
145
+ export function queueMessage(sessionDir, message, from = 'fleet') {
146
+ const inbox = path.join(sessionDir, 'inbox');
147
+ fs.mkdirSync(inbox, { recursive: true });
148
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
149
+ const file = path.join(inbox, `${stamp}-${from}.md`);
150
+ fs.writeFileSync(file, `${message}\n`, 'utf8');
151
+ return file;
152
+ }
153
+
154
+ /**
155
+ * Read-and-consume pending fleet messages (moved to inbox/delivered/ so each
156
+ * is injected exactly once).
157
+ *
158
+ * @returns {Array<{ file: string, text: string }>}
159
+ */
160
+ export function drainInbox(sessionDir) {
161
+ const inbox = path.join(sessionDir, 'inbox');
162
+ if (!fs.existsSync(inbox)) return [];
163
+ const delivered = path.join(inbox, 'delivered');
164
+ const out = [];
165
+ for (const name of fs.readdirSync(inbox).sort()) {
166
+ const file = path.join(inbox, name);
167
+ if (!fs.statSync(file).isFile()) continue;
168
+ const text = fs.readFileSync(file, 'utf8').trim();
169
+ fs.mkdirSync(delivered, { recursive: true });
170
+ fs.renameSync(file, path.join(delivered, name));
171
+ out.push({ file: name, text });
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /** Pending (undelivered) fleet messages, without consuming them. */
177
+ export function peekInbox(sessionDir) {
178
+ const inbox = path.join(sessionDir, 'inbox');
179
+ if (!fs.existsSync(inbox)) return [];
180
+ return fs
181
+ .readdirSync(inbox)
182
+ .sort()
183
+ .filter((name) => fs.statSync(path.join(inbox, name)).isFile())
184
+ .map((name) => ({
185
+ file: name,
186
+ text: fs.readFileSync(path.join(inbox, name), 'utf8').trim(),
187
+ }));
188
+ }
189
+
190
+ /**
191
+ * Claude Code transcript dir for a project (`~/.claude/projects/<sanitized>`),
192
+ * or null when absent.
193
+ */
194
+ export function claudeTranscriptDir(projectRoot, home = os.homedir()) {
195
+ const dir = path.join(home, '.claude', 'projects', sanitizePath(projectRoot));
196
+ return fs.existsSync(dir) ? dir : null;
197
+ }
198
+
199
+ /** Newest transcript jsonl in a project's Claude dir, or null. */
200
+ export function newestTranscript(projectRoot, home = os.homedir()) {
201
+ const dir = claudeTranscriptDir(projectRoot, home);
202
+ if (!dir) return null;
203
+ const files = fs
204
+ .readdirSync(dir)
205
+ .filter((f) => f.endsWith('.jsonl'))
206
+ .map((f) => {
207
+ const full = path.join(dir, f);
208
+ return { full, mtime: fs.statSync(full).mtimeMs };
209
+ })
210
+ .sort((a, b) => b.mtime - a.mtime);
211
+ return files[0]?.full ?? null;
212
+ }
package/src/lib.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  import crypto from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
+ import { registerSession } from './lib/fleet.mjs';
9
10
 
10
11
  export const REPO_ROOT = process.cwd();
11
12
  export const FORGE_DIR = path.join(REPO_ROOT, '.forge');
@@ -130,6 +131,10 @@ export function saveSession(dir, session) {
130
131
  session.updatedAt = new Date().toISOString();
131
132
  writeJson(path.join(dir, 'session.json'), session);
132
133
  writeJson(path.join(dir, 'status.json'), defaultStatus(session));
134
+ // Mirror into ~/.forgekit/fleet so `forge fleet` sees every project's
135
+ // sessions. Project root derived from dir (<root>/.forge/sessions/<id>),
136
+ // not REPO_ROOT, so callers with explicit dirs mirror correctly too.
137
+ registerSession(path.resolve(dir, '..', '..', '..'), session);
133
138
  }
134
139
 
135
140
  export function sessionAgeDays(session) {
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { FORGE_DIR, loadSession, readActive } from './lib.mjs';
12
+ import { drainInbox } from './lib/fleet.mjs';
12
13
  import { resolveEffectivePreferences } from './preferences.mjs';
13
14
 
14
15
  function getActiveSessionInfo() {
@@ -148,10 +149,20 @@ if (!info) {
148
149
  process.exit(0);
149
150
  }
150
151
 
151
- const message = prompt
152
+ let message = prompt
152
153
  ? buildForgePromptMessage(info, prompt)
153
154
  : buildForgeMessage(info);
154
155
 
156
+ // Deliver queued `forge fleet send` messages exactly once (drain moves them
157
+ // to inbox/delivered/). This is the fleet command bus: control terminal →
158
+ // inbox file → injected into the agent's next turn via this hook.
159
+ const fleetMessages = drainInbox(info.dir);
160
+ if (fleetMessages.length > 0) {
161
+ message += `\n\nFleet messages from the control terminal — acknowledge and act on these first:\n${fleetMessages
162
+ .map((m) => `- ${m.text}`)
163
+ .join('\n')}`;
164
+ }
165
+
155
166
  switch (format) {
156
167
  case 'cursor':
157
168
  emitCursor(message);
package/src/set-phase.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { loadSession, readActive, saveSession } from './lib.mjs';
18
+ import { briefProblem, checkBrief } from './brief.mjs';
18
19
  import { runIntegrityChecks } from './integrity.mjs';
19
20
  import { writeSessionScorecard } from './score.mjs';
20
21
 
@@ -116,6 +117,33 @@ function maybeEscalatePaceForTaskCount() {
116
117
 
117
118
  maybeEscalatePaceForTaskCount();
118
119
 
120
+ /**
121
+ * Hard gate: implementation may not start until the operator brief exists and
122
+ * matches the current specs — the plan-approval checkpoint is only as strong
123
+ * as the human's comprehension of it. `--allow-incomplete "<reason>"` records
124
+ * an honest skip.
125
+ */
126
+ function enforceBriefGate() {
127
+ if (phase !== 'implement') return;
128
+ const result = checkBrief({ session });
129
+ if (result.ok) {
130
+ delete session.briefSkipped;
131
+ return;
132
+ }
133
+ if (allowIncomplete) {
134
+ session.briefSkipped = allowIncomplete;
135
+ return;
136
+ }
137
+ process.stderr.write(
138
+ `Cannot enter phase "implement":\n - ${briefProblem(result)}\n` +
139
+ 'Write the brief (see forge references/operator-brief.md), forge brief stamp, ' +
140
+ 'or pass --allow-incomplete "<reason>".\n',
141
+ );
142
+ process.exit(1);
143
+ }
144
+
145
+ enforceBriefGate();
146
+
119
147
  /**
120
148
  * Refuse finish/done without verify evidence, full task completion, and a
121
149
  * clean integrity check (spine matrix, deferrals, product-loop evidence) —
@@ -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,23 +16,42 @@ 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.)
33
+
34
+ 5. **Operator brief (mandatory)** — see [../references/operator-brief.md](../references/operator-brief.md):
35
+ write `openspec/changes/<change-name>/brief.html` — a plain-language,
36
+ self-contained HTML explanation of what will be built (mermaid diagrams
37
+ where helpful), then:
38
+
39
+ ```bash
40
+ forge brief stamp # records specs hash + opens it in the operator's browser
41
+ ```
42
+
43
+ `forge phase implement` hard-refuses while the brief is missing or stale
44
+ (specs edited after stamping → rewrite affected sections and re-stamp).
27
45
 
28
- 5. Update session:
46
+ 6. Update session:
29
47
  ```bash
30
48
  forge phase plan --plan-type openspec --openspec <change-name>
31
49
  forge phase implement --tasks-total <N>
32
50
  ```
33
51
  Count tasks from `tasks.md` checkboxes.
34
52
 
35
- 6. Get user approval to proceed to implement (unless they already said "go").
53
+ 7. Get user approval to proceed to implement (unless they already said "go").
54
+ The brief (opened by `forge brief stamp`) is what the operator reviews.
36
55
 
37
56
  ## Session tracking
38
57
 
@@ -64,16 +64,34 @@ 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.)
81
+
82
+ 5. **Operator brief (mandatory)** — see [../references/operator-brief.md](../references/operator-brief.md):
83
+ write `<specsDir>/changes/<change-name>/brief.html` — a plain-language,
84
+ self-contained HTML explanation of what will be built (mermaid diagrams
85
+ where helpful), then:
86
+
87
+ ```bash
88
+ forge brief stamp # records specs hash + opens it in the operator's browser
89
+ ```
90
+
91
+ `forge phase implement` hard-refuses while the brief is missing or stale
92
+ (specs edited after stamping → rewrite affected sections and re-stamp).
75
93
 
76
- 5. Update session:
94
+ 6. Update session:
77
95
 
78
96
  ```bash
79
97
  forge phase plan --plan-type specs --openspec <change-name>
@@ -83,7 +101,8 @@ OpenSpec propose flow without the vendor CLI. Change lives under
83
101
  Count tasks from `tasks.md` checkboxes. (`--openspec` carries the change
84
102
  name for both engines.)
85
103
 
86
- 6. Get user approval on the artefacts before implementing (unless they already said "go").
104
+ 7. Get user approval on the artefacts before implementing (unless they already said "go").
105
+ The brief (opened by `forge brief stamp`) is what the operator reviews.
87
106
 
88
107
  ## Compatibility
89
108
 
@@ -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
  ```