@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.
package/bin/forge.mjs CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * Forge CLI — session orchestration, prefs, models, project init.
4
4
  *
@@ -31,9 +31,12 @@ 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'] },
38
+ fleet: { script: 'fleet.mjs' },
39
+ brief: { script: 'brief-cli.mjs' },
37
40
  };
38
41
 
39
42
  function printHelp() {
@@ -59,9 +62,12 @@ Commands:
59
62
  triage Classify whether a prompt needs Forge triage
60
63
  change new|archive Specs-engine change scaffold / archive
61
64
  spine init|check Capability→runtime spine matrix (spine.json)
65
+ e2e init|run|check Executable product-loop acceptance (e2e.json)
62
66
  defer add|resolve|list Deferral registry (deferred wiring = tracked debt)
63
67
  integrity-check Mechanical integrity gate (runs at phase done)
64
68
  score [--write] L2 session scorecard (auto-written at phase done)
69
+ fleet list|watch|view|send Cross-project session control terminal
70
+ brief stamp|check|open Operator brief (plain-language HTML, gates implement)
65
71
 
66
72
  Prefer \`forgekit install\` to pick multiple skills + agents at once.
67
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izkac/forgekit",
3
- "version": "0.1.7",
3
+ "version": "0.3.0",
4
4
  "description": "Forgekit CLIs — forgekit (install), forge (workflow), review (code review)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import fs from 'node:fs';
10
+ import os from 'node:os';
10
11
  import path from 'node:path';
11
12
  import { spawnSync } from 'node:child_process';
12
13
  import { fileURLToPath } from 'node:url';
@@ -39,5 +40,11 @@ if (files.length === 0) {
39
40
  const result = spawnSync(process.execPath, ['--test', ...files], {
40
41
  cwd: cliRoot,
41
42
  stdio: 'inherit',
43
+ env: {
44
+ ...process.env,
45
+ // Keep saveSession's fleet-registry mirroring away from ~/.forgekit
46
+ // when tests exercise session saves with scratch projects.
47
+ FORGEKIT_FLEET_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'forgekit-test-fleet-')),
48
+ },
42
49
  });
43
50
  process.exit(result.status ?? 1);
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Operator-brief CLI (core in brief.mjs).
4
+ *
5
+ * Usage:
6
+ * forge brief stamp [--session <id>] [--no-open] stamp freshness hash + open in browser
7
+ * forge brief check [--session <id>] exit 1 when missing/stale/unstamped
8
+ * forge brief open [--session <id>] open in default browser
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import { loadSession, readActive } from './lib.mjs';
13
+ import { resolveChangeDir } from './integrity.mjs';
14
+ import { BRIEF_FILE, briefPath, checkBrief, openInBrowser, stampBrief } from './brief.mjs';
15
+
16
+ const [cmd, ...rest] = process.argv.slice(2);
17
+ if (!cmd || !['stamp', 'check', 'open'].includes(cmd)) {
18
+ process.stderr.write(
19
+ 'Usage: forge brief stamp [--session <id>] [--no-open] | check [--session <id>] | open [--session <id>]\n',
20
+ );
21
+ process.exit(1);
22
+ }
23
+
24
+ let sessionId = null;
25
+ const si = rest.indexOf('--session');
26
+ if (si >= 0 && rest[si + 1]) sessionId = rest[si + 1];
27
+ if (!sessionId) sessionId = readActive()?.sessionId ?? null;
28
+ if (!sessionId) {
29
+ process.stderr.write('No active session. Run forge new first.\n');
30
+ process.exit(1);
31
+ }
32
+
33
+ const { session } = loadSession(sessionId);
34
+ const changeDir = resolveChangeDir({ session });
35
+
36
+ if (cmd === 'check') {
37
+ const result = checkBrief({ session });
38
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
39
+ process.exit(result.ok ? 0 : 1);
40
+ }
41
+
42
+ if (!changeDir) {
43
+ process.stderr.write('Session has no tracked change — nothing to brief.\n');
44
+ process.exit(1);
45
+ }
46
+
47
+ if (cmd === 'stamp') {
48
+ const hash = stampBrief(changeDir);
49
+ process.stdout.write(`Stamped ${briefPath(changeDir)} (specs hash ${hash})\n`);
50
+ if (!rest.includes('--no-open')) {
51
+ openInBrowser(briefPath(changeDir));
52
+ process.stdout.write('Opened in default browser.\n');
53
+ }
54
+ } else {
55
+ const file = briefPath(changeDir);
56
+ if (!fs.existsSync(file)) {
57
+ process.stderr.write(`No ${BRIEF_FILE} in ${changeDir}.\n`);
58
+ process.exit(1);
59
+ }
60
+ openInBrowser(file);
61
+ process.stdout.write(`Opened ${file}\n`);
62
+ }
package/src/brief.mjs ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Operator brief core — a plain-language, self-contained HTML explanation of
4
+ * what will be built, written by the agent at the end of the plan phase into
5
+ * `<changeDir>/brief.html`. CLI in brief-cli.mjs (`forge brief`).
6
+ *
7
+ * Freshness: `stampBrief` records a hash of proposal.md/design.md/tasks.md
8
+ * inside brief.html. When the specs change afterwards the brief goes stale and
9
+ * the hard gate in `forge phase implement` refuses until it is rewritten and
10
+ * re-stamped — same executed-evidence philosophy as the e2e gate.
11
+ */
12
+
13
+ import crypto from 'node:crypto';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { spawn } from 'node:child_process';
17
+ import { resolveChangeDir } from './integrity.mjs';
18
+
19
+ export const BRIEF_FILE = 'brief.html';
20
+ const HASH_MARKER = /<!--\s*forge-brief-specs-hash:([a-f0-9]+)\s*-->/;
21
+ const SPEC_FILES = ['proposal.md', 'design.md', 'tasks.md'];
22
+
23
+ /** Hash of the spec sources a brief must reflect (missing files hash as absent). */
24
+ export function specsHash(changeDir) {
25
+ const h = crypto.createHash('sha256');
26
+ for (const name of SPEC_FILES) {
27
+ const file = path.join(changeDir, name);
28
+ h.update(name);
29
+ h.update(fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '<absent>');
30
+ }
31
+ return h.digest('hex').slice(0, 16);
32
+ }
33
+
34
+ export function briefPath(changeDir) {
35
+ return path.join(changeDir, BRIEF_FILE);
36
+ }
37
+
38
+ export function readBriefHash(file) {
39
+ if (!fs.existsSync(file)) return null;
40
+ const match = fs.readFileSync(file, 'utf8').match(HASH_MARKER);
41
+ return match ? match[1] : null;
42
+ }
43
+
44
+ /** Write/replace the freshness marker in brief.html. Returns the stamped hash. */
45
+ export function stampBrief(changeDir) {
46
+ const file = briefPath(changeDir);
47
+ if (!fs.existsSync(file)) {
48
+ throw new Error(`No ${BRIEF_FILE} in ${changeDir} — write the operator brief first.`);
49
+ }
50
+ const hash = specsHash(changeDir);
51
+ let html = fs.readFileSync(file, 'utf8');
52
+ const marker = `<!-- forge-brief-specs-hash:${hash} -->`;
53
+ html = HASH_MARKER.test(html) ? html.replace(HASH_MARKER, marker) : `${marker}\n${html}`;
54
+ fs.writeFileSync(file, html, 'utf8');
55
+ return hash;
56
+ }
57
+
58
+ /**
59
+ * Brief status for a session.
60
+ *
61
+ * @param {{ cwd?: string, session: Record<string, any> }} opts
62
+ * @returns {{ ok: boolean, reason: 'not-applicable'|'fresh'|'missing'|'unstamped'|'stale', path: string | null }}
63
+ */
64
+ export function checkBrief(opts) {
65
+ const { session } = opts;
66
+ const changeDir = resolveChangeDir(opts);
67
+ // Brief only applies to tracked-change plans; direct/throwaway have no specs
68
+ // for an operator to review.
69
+ if (!changeDir || session.planType === 'direct' || session.planType === 'throwaway') {
70
+ return { ok: true, reason: 'not-applicable', path: null };
71
+ }
72
+ const file = briefPath(changeDir);
73
+ if (!fs.existsSync(file)) return { ok: false, reason: 'missing', path: file };
74
+ const stamped = readBriefHash(file);
75
+ if (!stamped) return { ok: false, reason: 'unstamped', path: file };
76
+ if (stamped !== specsHash(changeDir)) return { ok: false, reason: 'stale', path: file };
77
+ return { ok: true, reason: 'fresh', path: file };
78
+ }
79
+
80
+ /** Human-readable problem line for a failed check, null when ok. */
81
+ export function briefProblem(result) {
82
+ switch (result.reason) {
83
+ case 'missing':
84
+ return `operator brief missing — write ${result.path} (plain-language HTML, see forge references/operator-brief.md), then run forge brief stamp`;
85
+ case 'unstamped':
86
+ return 'operator brief not stamped — run forge brief stamp (records specs hash + opens it for the operator)';
87
+ case 'stale':
88
+ return `operator brief is stale — specs changed after it was written; update ${result.path} and re-run forge brief stamp`;
89
+ default:
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /** Open a file with the OS default handler (fire-and-forget). */
95
+ export function openInBrowser(file) {
96
+ const target = path.resolve(file);
97
+ const [cmd, args] =
98
+ process.platform === 'win32'
99
+ ? ['cmd', ['/c', 'start', '', target]]
100
+ : process.platform === 'darwin'
101
+ ? ['open', [target]]
102
+ : ['xdg-open', [target]];
103
+ spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
104
+ }
@@ -0,0 +1,186 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import {
9
+ briefPath,
10
+ checkBrief,
11
+ readBriefHash,
12
+ specsHash,
13
+ stampBrief,
14
+ } from './brief.mjs';
15
+
16
+ const SET_PHASE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'set-phase.mjs');
17
+
18
+ function tmp(prefix) {
19
+ return fs.mkdtempSync(path.join(tmpdir(), prefix));
20
+ }
21
+
22
+ /** Project with a specs-engine change dir containing proposal + tasks. */
23
+ function makeChange(root, change) {
24
+ const changeDir = path.join(root, 'specs', 'changes', change);
25
+ fs.mkdirSync(changeDir, { recursive: true });
26
+ fs.writeFileSync(path.join(changeDir, 'proposal.md'), '# Why\nBecause.\n', 'utf8');
27
+ fs.writeFileSync(path.join(changeDir, 'tasks.md'), '# Tasks\n- [ ] 1.1 do it\n', 'utf8');
28
+ return changeDir;
29
+ }
30
+
31
+ function writeBrief(changeDir) {
32
+ fs.writeFileSync(
33
+ briefPath(changeDir),
34
+ '<html><head><title>Brief</title></head><body><h1>TL;DR</h1></body></html>\n',
35
+ 'utf8',
36
+ );
37
+ }
38
+
39
+ function makeSession(overrides = {}) {
40
+ return {
41
+ id: 's1',
42
+ slug: 'fixture',
43
+ planType: 'specs',
44
+ openspecChange: 'my-change',
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ test('specsHash changes when a spec file changes', () => {
50
+ const changeDir = makeChange(tmp('brief-'), 'my-change');
51
+ const before = specsHash(changeDir);
52
+ fs.appendFileSync(path.join(changeDir, 'proposal.md'), 'More.\n');
53
+ assert.notEqual(specsHash(changeDir), before);
54
+ });
55
+
56
+ test('stampBrief injects the marker once and restamp replaces it', () => {
57
+ const changeDir = makeChange(tmp('brief-'), 'my-change');
58
+ writeBrief(changeDir);
59
+ const hash = stampBrief(changeDir);
60
+ assert.equal(readBriefHash(briefPath(changeDir)), hash);
61
+
62
+ fs.appendFileSync(path.join(changeDir, 'tasks.md'), '- [ ] 1.2 more\n');
63
+ const hash2 = stampBrief(changeDir);
64
+ assert.notEqual(hash2, hash);
65
+ const html = fs.readFileSync(briefPath(changeDir), 'utf8');
66
+ assert.equal(html.match(/forge-brief-specs-hash/g).length, 1);
67
+ });
68
+
69
+ test('stampBrief throws when brief.html is missing', () => {
70
+ const changeDir = makeChange(tmp('brief-'), 'my-change');
71
+ assert.throws(() => stampBrief(changeDir), /write the operator brief first/);
72
+ });
73
+
74
+ test('checkBrief lifecycle: missing → unstamped → fresh → stale', () => {
75
+ const root = tmp('brief-');
76
+ const changeDir = makeChange(root, 'my-change');
77
+ const session = makeSession();
78
+
79
+ assert.equal(checkBrief({ cwd: root, session }).reason, 'missing');
80
+
81
+ writeBrief(changeDir);
82
+ assert.equal(checkBrief({ cwd: root, session }).reason, 'unstamped');
83
+
84
+ stampBrief(changeDir);
85
+ const fresh = checkBrief({ cwd: root, session });
86
+ assert.equal(fresh.reason, 'fresh');
87
+ assert.equal(fresh.ok, true);
88
+
89
+ fs.appendFileSync(path.join(changeDir, 'proposal.md'), 'Edited after stamp.\n');
90
+ const stale = checkBrief({ cwd: root, session });
91
+ assert.equal(stale.reason, 'stale');
92
+ assert.equal(stale.ok, false);
93
+ });
94
+
95
+ test('checkBrief not-applicable without a change or for direct/throwaway plans', () => {
96
+ const root = tmp('brief-');
97
+ assert.equal(
98
+ checkBrief({ cwd: root, session: makeSession({ openspecChange: null }) }).reason,
99
+ 'not-applicable',
100
+ );
101
+ makeChange(root, 'my-change');
102
+ assert.equal(
103
+ checkBrief({ cwd: root, session: makeSession({ planType: 'direct' }) }).reason,
104
+ 'not-applicable',
105
+ );
106
+ });
107
+
108
+ /** Full .forge fixture so set-phase.mjs can run as a child in `root`. */
109
+ function makePhaseFixture(root, overrides = {}) {
110
+ const sessionDir = path.join(root, '.forge', 'sessions', 's1');
111
+ fs.mkdirSync(sessionDir, { recursive: true });
112
+ const now = new Date().toISOString();
113
+ fs.writeFileSync(
114
+ path.join(sessionDir, 'session.json'),
115
+ `${JSON.stringify({
116
+ id: 's1',
117
+ slug: 'fixture',
118
+ createdAt: now,
119
+ updatedAt: now,
120
+ phase: 'plan',
121
+ planType: 'specs',
122
+ openspecChange: 'my-change',
123
+ forgeSkipped: false,
124
+ tasksTotal: 0,
125
+ tasksComplete: 0,
126
+ ...overrides,
127
+ })}\n`,
128
+ 'utf8',
129
+ );
130
+ fs.writeFileSync(
131
+ path.join(root, '.forge', 'active.json'),
132
+ `${JSON.stringify({ sessionId: 's1' })}\n`,
133
+ 'utf8',
134
+ );
135
+ return sessionDir;
136
+ }
137
+
138
+ function runSetPhase(cwd, args) {
139
+ try {
140
+ const stdout = execFileSync(process.execPath, [SET_PHASE, ...args], {
141
+ cwd,
142
+ env: { ...process.env, FORGEKIT_FLEET_DIR: path.join(tmp('brief-fleet-'), 's') },
143
+ });
144
+ return { status: 0, stdout: stdout.toString(), stderr: '' };
145
+ } catch (err) {
146
+ return { status: err.status, stdout: String(err.stdout), stderr: String(err.stderr) };
147
+ }
148
+ }
149
+
150
+ test('phase implement hard-refuses without a fresh brief, passes with one', () => {
151
+ const root = tmp('brief-gate-');
152
+ const changeDir = makeChange(root, 'my-change');
153
+ makePhaseFixture(root);
154
+
155
+ const refused = runSetPhase(root, ['implement', '--tasks-total', '3']);
156
+ assert.notEqual(refused.status, 0);
157
+ assert.match(refused.stderr, /operator brief missing/);
158
+
159
+ writeBrief(changeDir);
160
+ stampBrief(changeDir);
161
+ const ok = runSetPhase(root, ['implement', '--tasks-total', '3']);
162
+ assert.equal(ok.status, 0);
163
+
164
+ fs.appendFileSync(path.join(changeDir, 'tasks.md'), '- [ ] 1.9 late edit\n');
165
+ const stale = runSetPhase(root, ['implement']);
166
+ assert.notEqual(stale.status, 0);
167
+ assert.match(stale.stderr, /stale/);
168
+ });
169
+
170
+ test('phase implement --allow-incomplete records briefSkipped', () => {
171
+ const root = tmp('brief-gate-');
172
+ makeChange(root, 'my-change');
173
+ const sessionDir = makePhaseFixture(root);
174
+
175
+ const ok = runSetPhase(root, ['implement', '--allow-incomplete', 'operator waived brief']);
176
+ assert.equal(ok.status, 0);
177
+ const session = JSON.parse(fs.readFileSync(path.join(sessionDir, 'session.json'), 'utf8'));
178
+ assert.equal(session.briefSkipped, 'operator waived brief');
179
+ });
180
+
181
+ test('phase implement unaffected for sessions without a tracked change', () => {
182
+ const root = tmp('brief-gate-');
183
+ makePhaseFixture(root, { planType: null, openspecChange: null });
184
+ const ok = runSetPhase(root, ['implement', '--tasks-total', '2']);
185
+ assert.equal(ok.status, 0);
186
+ });
@@ -18,6 +18,7 @@ import {
18
18
  SESSIONS_DIR,
19
19
  sessionAgeDays,
20
20
  } from './lib.mjs';
21
+ import { unregisterSession } from './lib/fleet.mjs';
21
22
 
22
23
  const args = new Set(process.argv.slice(2));
23
24
  const dryRun = args.has('--dry-run');
@@ -59,6 +60,7 @@ for (const entry of fs.readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
59
60
  if (!dryRun) {
60
61
  fs.rmSync(dir, { recursive: true, force: true });
61
62
  if (isActive) clearActive();
63
+ unregisterSession(process.cwd(), sessionId);
62
64
  }
63
65
  removed.push({ sessionId, reason: tooOld ? 'retention' : 'finished' });
64
66
  } else {
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);