@izkac/forgekit 0.2.0 → 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
  *
@@ -35,6 +35,8 @@ const COMMANDS = {
35
35
  defer: { script: 'defer.mjs' },
36
36
  'integrity-check': { script: 'integrity-check.mjs', aliases: ['integrity'] },
37
37
  score: { script: 'score-cli.mjs', aliases: ['scorecard'] },
38
+ fleet: { script: 'fleet.mjs' },
39
+ brief: { script: 'brief-cli.mjs' },
38
40
  };
39
41
 
40
42
  function printHelp() {
@@ -64,6 +66,8 @@ Commands:
64
66
  defer add|resolve|list Deferral registry (deferred wiring = tracked debt)
65
67
  integrity-check Mechanical integrity gate (runs at phase done)
66
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)
67
71
 
68
72
  Prefer \`forgekit install\` to pick multiple skills + agents at once.
69
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izkac/forgekit",
3
- "version": "0.2.0",
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/fleet.mjs ADDED
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fleet control terminal — see and command every forge session on this
4
+ * machine, across projects and engines.
5
+ *
6
+ * Usage:
7
+ * forge fleet list [--json]
8
+ * forge fleet watch [--interval <sec>]
9
+ * forge fleet view <session> [--transcript [N]]
10
+ * forge fleet send <session>|--all <message...>
11
+ *
12
+ * <session> matches by sessionId, slug, or project name; must be unique.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import {
18
+ PHASE_ORDER,
19
+ listFleet,
20
+ newestTranscript,
21
+ peekInbox,
22
+ queueMessage,
23
+ sessionDirFor,
24
+ } from './lib/fleet.mjs';
25
+
26
+ function usage() {
27
+ process.stderr.write(
28
+ `Usage:
29
+ forge fleet list [--json]
30
+ forge fleet watch [--interval <sec>]
31
+ forge fleet view <session> [--transcript [N]]
32
+ forge fleet send <session>|--all <message...>
33
+ `,
34
+ );
35
+ process.exit(1);
36
+ }
37
+
38
+ function relTime(iso) {
39
+ const ms = Date.now() - new Date(iso).getTime();
40
+ if (Number.isNaN(ms)) return '?';
41
+ const min = Math.floor(ms / 60000);
42
+ if (min < 1) return 'now';
43
+ if (min < 60) return `${min}m`;
44
+ const h = Math.floor(min / 60);
45
+ if (h < 24) return `${h}h`;
46
+ return `${Math.floor(h / 24)}d`;
47
+ }
48
+
49
+ function phaseBar(phase) {
50
+ const idx = PHASE_ORDER.indexOf(phase);
51
+ if (phase === 'skipped') return 'skipped';
52
+ if (idx < 0) return phase;
53
+ const total = PHASE_ORDER.length - 1; // done = full bar
54
+ return `${'█'.repeat(idx)}${'░'.repeat(total - idx)} ${phase}`;
55
+ }
56
+
57
+ function tasksCell(entry) {
58
+ const total = Number(entry.tasksTotal) || 0;
59
+ if (total === 0) return '—';
60
+ const complete = Number(entry.tasksComplete) || 0;
61
+ const width = 8;
62
+ const filled = Math.round((complete / total) * width);
63
+ return `${'█'.repeat(filled)}${'░'.repeat(width - filled)} ${complete}/${total}`;
64
+ }
65
+
66
+ function pad(str, len) {
67
+ const s = String(str ?? '');
68
+ return s.length >= len ? s.slice(0, len) : s + ' '.repeat(len - s.length);
69
+ }
70
+
71
+ function renderTable(entries) {
72
+ if (entries.length === 0) return 'No fleet sessions. Start one with `forge new <slug>`.\n';
73
+ const header = `${pad('PROJECT', 18)} ${pad('SESSION', 26)} ${pad('ENGINE', 7)} ${pad('PHASE', 18)} ${pad('TASKS', 13)} ${pad('PACE', 9)} ${pad('AGE', 4)} MSGS`;
74
+ const lines = [header, '-'.repeat(header.length)];
75
+ for (const e of entries) {
76
+ const pending = e.missing ? 0 : peekInbox(sessionDirFor(e)).length;
77
+ lines.push(
78
+ `${pad(e.projectName, 18)} ${pad(e.slug, 26)} ${pad(e.engine ?? '—', 7)} ${pad(
79
+ e.missing ? 'missing' : phaseBar(e.phase),
80
+ 18,
81
+ )} ${pad(tasksCell(e), 13)} ${pad(e.pace ?? '—', 9)} ${pad(relTime(e.updatedAt), 4)} ${
82
+ pending > 0 ? `✉ ${pending}` : ''
83
+ }`,
84
+ );
85
+ }
86
+ return `${lines.join('\n')}\n`;
87
+ }
88
+
89
+ /** Resolve a user-supplied needle to exactly one fleet entry. */
90
+ function findEntry(needle) {
91
+ const entries = listFleet();
92
+ const matches = entries.filter(
93
+ (e) =>
94
+ e.sessionId === needle ||
95
+ e.sessionId.includes(needle) ||
96
+ e.slug === needle ||
97
+ e.projectName === needle,
98
+ );
99
+ if (matches.length === 1) return matches[0];
100
+ if (matches.length === 0) {
101
+ process.stderr.write(`No fleet session matches "${needle}". Try: forge fleet list\n`);
102
+ } else {
103
+ process.stderr.write(
104
+ `Ambiguous "${needle}" — matches:\n${matches
105
+ .map((m) => ` ${m.sessionId} (${m.projectName})`)
106
+ .join('\n')}\n`,
107
+ );
108
+ }
109
+ process.exit(1);
110
+ }
111
+
112
+ function cmdList(args) {
113
+ const entries = listFleet();
114
+ if (args.includes('--json')) {
115
+ process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
116
+ } else {
117
+ process.stdout.write(renderTable(entries));
118
+ }
119
+ }
120
+
121
+ function cmdWatch(args) {
122
+ const i = args.indexOf('--interval');
123
+ const interval = Math.max(1, Number(i >= 0 ? args[i + 1] : 3) || 3) * 1000;
124
+ const render = () => {
125
+ process.stdout.write('\x1b[2J\x1b[H');
126
+ process.stdout.write(`forge fleet — ${new Date().toLocaleTimeString()} (Ctrl+C to exit)\n\n`);
127
+ process.stdout.write(renderTable(listFleet()));
128
+ };
129
+ render();
130
+ setInterval(render, interval);
131
+ }
132
+
133
+ /**
134
+ * ponytail: transcript view is best-effort — newest jsonl in the project's
135
+ * Claude dir, not a verified session link. Upgrade path: SessionStart hook
136
+ * records claudeSessionId in the registry entry.
137
+ */
138
+ function tailTranscript(entry, count) {
139
+ const file = newestTranscript(entry.project);
140
+ if (!file) {
141
+ process.stdout.write('No Claude Code transcript found for this project.\n');
142
+ return;
143
+ }
144
+ process.stdout.write(`Transcript (newest in project): ${file}\n\n`);
145
+ const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
146
+ const turns = [];
147
+ for (const line of lines) {
148
+ let rec;
149
+ try {
150
+ rec = JSON.parse(line);
151
+ } catch {
152
+ continue;
153
+ }
154
+ if (rec.type !== 'user' && rec.type !== 'assistant') continue;
155
+ const content = rec.message?.content;
156
+ let text = '';
157
+ if (typeof content === 'string') text = content;
158
+ else if (Array.isArray(content)) {
159
+ text = content
160
+ .map((c) => (c.type === 'text' ? c.text : c.type === 'tool_use' ? `[tool: ${c.name}]` : ''))
161
+ .filter(Boolean)
162
+ .join(' ');
163
+ }
164
+ text = text.replace(/\s+/g, ' ').trim();
165
+ if (text) turns.push(`${rec.type === 'user' ? '>' : '<'} ${text.slice(0, 300)}`);
166
+ }
167
+ process.stdout.write(`${turns.slice(-count).join('\n\n')}\n`);
168
+ }
169
+
170
+ function cmdView(args) {
171
+ if (!args[0]) usage();
172
+ const entry = findEntry(args[0]);
173
+ const ti = args.indexOf('--transcript');
174
+ const sessionDir = sessionDirFor(entry);
175
+
176
+ process.stdout.write(`${entry.projectName} · ${entry.slug}\n`);
177
+ process.stdout.write(` Session: ${entry.sessionId}\n`);
178
+ process.stdout.write(` Project: ${entry.project}\n`);
179
+ process.stdout.write(` Engine: ${entry.engine ?? 'unknown'}\n`);
180
+ process.stdout.write(` Phase: ${phaseBar(entry.phase)}\n`);
181
+ process.stdout.write(` Tasks: ${tasksCell(entry)}\n`);
182
+ process.stdout.write(` Pace: ${entry.pace ?? '—'}\n`);
183
+ process.stdout.write(` Change: ${entry.openspecChange ?? '—'} (${entry.planType ?? 'pending'})\n`);
184
+ const rel = relTime(entry.updatedAt);
185
+ process.stdout.write(` Updated: ${entry.updatedAt}${rel === 'now' ? '' : ` (${rel} ago)`}\n`);
186
+
187
+ const pending = entry.missing ? [] : peekInbox(sessionDir);
188
+ if (pending.length > 0) {
189
+ process.stdout.write(`\n Pending fleet messages (${pending.length}):\n`);
190
+ for (const m of pending) process.stdout.write(` • ${m.text.split('\n')[0]}\n`);
191
+ }
192
+ const evidence = path.join(sessionDir, 'verify-evidence.md');
193
+ if (fs.existsSync(evidence)) process.stdout.write(` Evidence: ${evidence}\n`);
194
+
195
+ if (ti >= 0) {
196
+ const count = Number(args[ti + 1]) || 20;
197
+ process.stdout.write('\n');
198
+ tailTranscript(entry, count);
199
+ }
200
+ }
201
+
202
+ function cmdSend(args) {
203
+ const all = args[0] === '--all';
204
+ const message = (all ? args.slice(1) : args.slice(1)).join(' ').trim();
205
+ if (!args[0] || !message) usage();
206
+
207
+ const targets = all ? listFleet().filter((e) => !e.missing) : [findEntry(args[0])];
208
+ for (const entry of targets) {
209
+ const file = queueMessage(sessionDirFor(entry), message);
210
+ process.stdout.write(
211
+ `Queued for ${entry.projectName}/${entry.slug} → delivered on its next turn (${file})\n`,
212
+ );
213
+ }
214
+ if (targets.length === 0) process.stdout.write('No reachable fleet sessions.\n');
215
+ }
216
+
217
+ const [cmd, ...rest] = process.argv.slice(2);
218
+ switch (cmd) {
219
+ case 'list':
220
+ cmdList(rest);
221
+ break;
222
+ case 'watch':
223
+ cmdWatch(rest);
224
+ break;
225
+ case 'view':
226
+ cmdView(rest);
227
+ break;
228
+ case 'send':
229
+ cmdSend(rest);
230
+ break;
231
+ default:
232
+ usage();
233
+ }
@@ -0,0 +1,137 @@
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
+ drainInbox,
10
+ entryFile,
11
+ listFleet,
12
+ peekInbox,
13
+ queueMessage,
14
+ registerSession,
15
+ sanitizePath,
16
+ unregisterSession,
17
+ } from './lib/fleet.mjs';
18
+ import { saveSession } from './lib.mjs';
19
+
20
+ const FLEET_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fleet.mjs');
21
+
22
+ function tmp(prefix) {
23
+ return fs.mkdtempSync(path.join(tmpdir(), prefix));
24
+ }
25
+
26
+ function makeSession(id, extra = {}) {
27
+ const now = new Date().toISOString();
28
+ return {
29
+ id,
30
+ slug: 'fixture',
31
+ createdAt: now,
32
+ updatedAt: now,
33
+ phase: 'implement',
34
+ planType: 'specs',
35
+ openspecChange: 'my-change',
36
+ tasksTotal: 10,
37
+ tasksComplete: 4,
38
+ pace: 'auto',
39
+ resolvedPace: 'standard',
40
+ ...extra,
41
+ };
42
+ }
43
+
44
+ /** Scratch project with a session dir so listFleet keeps the entry. */
45
+ function makeProject(root, sessionId) {
46
+ const sessionDir = path.join(root, '.forge', 'sessions', sessionId);
47
+ fs.mkdirSync(sessionDir, { recursive: true });
48
+ return sessionDir;
49
+ }
50
+
51
+ test('register / list / unregister roundtrip', () => {
52
+ process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-reg-'), 'sessions');
53
+ const project = tmp('fleet-proj-');
54
+ makeProject(project, 's1');
55
+
56
+ registerSession(project, makeSession('s1'));
57
+ const entries = listFleet();
58
+ assert.equal(entries.length, 1);
59
+ assert.equal(entries[0].sessionId, 's1');
60
+ assert.equal(entries[0].project, project);
61
+ assert.equal(entries[0].projectName, path.basename(project));
62
+ assert.equal(entries[0].phase, 'implement');
63
+ assert.equal(entries[0].tasksTotal, 10);
64
+ assert.equal(entries[0].tasksComplete, 4);
65
+ assert.equal(entries[0].pace, 'standard');
66
+ assert.equal(entries[0].missing, false);
67
+
68
+ unregisterSession(project, 's1');
69
+ assert.equal(listFleet().length, 0);
70
+ });
71
+
72
+ test('listFleet self-heals entries whose session dir is gone', () => {
73
+ process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-heal-'), 'sessions');
74
+ const project = tmp('fleet-proj-');
75
+ registerSession(project, makeSession('gone')); // no session dir created
76
+ assert.equal(listFleet().length, 0);
77
+ assert.equal(fs.existsSync(entryFile(project, 'gone')), false);
78
+ });
79
+
80
+ test('saveSession mirrors into the fleet registry', () => {
81
+ process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-mirror-'), 'sessions');
82
+ const project = tmp('fleet-proj-');
83
+ const sessionDir = makeProject(project, 's2');
84
+
85
+ saveSession(sessionDir, makeSession('s2', { phase: 'review' }));
86
+ const entries = listFleet();
87
+ assert.equal(entries.length, 1);
88
+ assert.equal(entries[0].sessionId, 's2');
89
+ assert.equal(entries[0].phase, 'review');
90
+ });
91
+
92
+ test('queue → peek → drain delivers each message exactly once', () => {
93
+ const sessionDir = makeProject(tmp('fleet-proj-'), 's3');
94
+ queueMessage(sessionDir, 'pause and report status');
95
+ assert.equal(peekInbox(sessionDir).length, 1);
96
+
97
+ const drained = drainInbox(sessionDir);
98
+ assert.equal(drained.length, 1);
99
+ assert.equal(drained[0].text, 'pause and report status');
100
+ assert.equal(drainInbox(sessionDir).length, 0);
101
+ assert.equal(peekInbox(sessionDir).length, 0);
102
+ });
103
+
104
+ test('fleet CLI: send queues, list --json reports pending-capable entries', () => {
105
+ const fleetDir = path.join(tmp('fleet-cli-'), 'sessions');
106
+ const project = tmp('fleet-proj-');
107
+ const sessionDir = makeProject(project, 's4');
108
+ const env = { ...process.env, FORGEKIT_FLEET_DIR: fleetDir };
109
+
110
+ registerSessionIn(fleetDir, project, makeSession('s4'));
111
+
112
+ const listOut = execFileSync(process.execPath, [FLEET_SCRIPT, 'list', '--json'], { env });
113
+ const parsed = JSON.parse(listOut.toString());
114
+ assert.equal(parsed.length, 1);
115
+ assert.equal(parsed[0].sessionId, 's4');
116
+
117
+ execFileSync(process.execPath, [FLEET_SCRIPT, 'send', 's4', 'ship', 'it'], { env });
118
+ const pending = peekInbox(sessionDir);
119
+ assert.equal(pending.length, 1);
120
+ assert.equal(pending[0].text, 'ship it');
121
+ });
122
+
123
+ /** register via a temp override without clobbering this process's env-based dir */
124
+ function registerSessionIn(fleetDir, project, session) {
125
+ const prev = process.env.FORGEKIT_FLEET_DIR;
126
+ process.env.FORGEKIT_FLEET_DIR = fleetDir;
127
+ try {
128
+ registerSession(project, session);
129
+ } finally {
130
+ if (prev === undefined) delete process.env.FORGEKIT_FLEET_DIR;
131
+ else process.env.FORGEKIT_FLEET_DIR = prev;
132
+ }
133
+ }
134
+
135
+ test('sanitizePath matches Claude Code project-dir naming', () => {
136
+ assert.equal(sanitizePath('S:\\Projects\\forgekit'), 'S--Projects-forgekit');
137
+ });
@@ -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) —
@@ -31,14 +31,27 @@ Thin wrapper around the project **`openspec-propose`** skill (or `/opsx:propose`
31
31
  valid spine and, when the spine has rows, a green current e2e run —
32
32
  keyword sniffing does not decide.)
33
33
 
34
- 5. Update session:
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).
45
+
46
+ 6. Update session:
35
47
  ```bash
36
48
  forge phase plan --plan-type openspec --openspec <change-name>
37
49
  forge phase implement --tasks-total <N>
38
50
  ```
39
51
  Count tasks from `tasks.md` checkboxes.
40
52
 
41
- 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.
42
55
 
43
56
  ## Session tracking
44
57
 
@@ -79,7 +79,19 @@ OpenSpec propose flow without the vendor CLI. Change lives under
79
79
  valid spine and, when the spine has rows, a green current e2e run —
80
80
  keyword sniffing does not decide.)
81
81
 
82
- 5. Update session:
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).
93
+
94
+ 6. Update session:
83
95
 
84
96
  ```bash
85
97
  forge phase plan --plan-type specs --openspec <change-name>
@@ -89,7 +101,8 @@ OpenSpec propose flow without the vendor CLI. Change lives under
89
101
  Count tasks from `tasks.md` checkboxes. (`--openspec` carries the change
90
102
  name for both engines.)
91
103
 
92
- 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.
93
106
 
94
107
  ## Compatibility
95
108
 
@@ -0,0 +1,74 @@
1
+ # Operator brief — `brief.html`
2
+
3
+ Every tracked change (openspec or specs engine) ends its plan phase with an
4
+ **operator brief**: one self-contained HTML file at
5
+ `<changeDir>/brief.html` that a human can read in two minutes and understand
6
+ *what will be built* — without reading proposal.md, design.md, or tasks.md.
7
+
8
+ The plan-approval checkpoint is only as strong as the operator's comprehension.
9
+ The specs are written for agents; the brief is the translation for the human
10
+ who approves them. `forge phase implement` **refuses** while the brief is
11
+ missing, unstamped, or stale.
12
+
13
+ ## Who writes it
14
+
15
+ You (the agent), at the end of the plan phase — after the specs are final,
16
+ before `forge phase implement`. The CLI never generates prose; it only stamps
17
+ freshness and opens the file.
18
+
19
+ ## Workflow
20
+
21
+ ```bash
22
+ # 1. specs are final (proposal.md / design.md / tasks.md)
23
+ # 2. write <changeDir>/brief.html (structure below)
24
+ forge brief stamp # records specs hash + opens it in the operator's browser
25
+ forge phase implement --tasks-total <N> # hard-gated on a fresh stamped brief
26
+ ```
27
+
28
+ If any spec file changes later, the brief goes **stale** — update the affected
29
+ sections and `forge brief stamp` again. `forge brief check` reports status.
30
+ Genuine edge case (operator explicitly waives it):
31
+ `forge phase implement --allow-incomplete "<reason>"`.
32
+
33
+ ## Writing rules
34
+
35
+ Audience: a smart human who has NOT read the specs and doesn't want to.
36
+ Plain language. No spec-speak, no requirement IDs, no file paths in headings.
37
+ If a sentence would not make sense to a project stakeholder outside this
38
+ session, rewrite it.
39
+
40
+ Structure (sections, in order — skip a section only when truly empty):
41
+
42
+ 1. **TL;DR** — 2-3 sentences: what will exist after this change that doesn't
43
+ today, and why it's worth doing.
44
+ 2. **What you'll get** — the observable outcomes, bulleted. Behavior, not
45
+ implementation ("you can send a message to any session from one terminal",
46
+ not "a registry module under lib/").
47
+ 3. **How it works** — the shape of the solution in one short narrative plus a
48
+ **mermaid diagram** where a picture beats prose (flow, sequence, or state —
49
+ whichever fits; skip the diagram for trivial changes rather than forcing one).
50
+ 4. **What changes for you** — new commands/screens/steps the operator will
51
+ use; anything that behaves differently than before.
52
+ 5. **Risks and open questions** — honest, short. What could go wrong, what was
53
+ deliberately deferred, what the operator should keep an eye on.
54
+ 6. **Out of scope** — what this change deliberately does NOT do.
55
+ 7. **Work overview** — task groups from tasks.md compressed to one line each,
56
+ with rough size (n tasks). Not the full task list.
57
+
58
+ ## File rules
59
+
60
+ - One file, self-contained: inline CSS, no build step, no local assets.
61
+ - Mermaid via CDN is fine (`<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js">`
62
+ + `mermaid.initialize({ startOnLoad: true })`; diagrams in `<pre class="mermaid">` blocks).
63
+ Trade-off accepted: diagrams need internet to render; the text must stand on
64
+ its own without them.
65
+ - Readable defaults: max-width ~48rem/prose, system font stack, dark-on-light.
66
+ Keep styling minimal — this is a memo, not a product page.
67
+ - Do not remove the `<!-- forge-brief-specs-hash:… -->` comment `forge brief
68
+ stamp` inserts; re-stamping updates it.
69
+
70
+ ## Archive
71
+
72
+ `brief.html` lives in the change dir, so it archives with the change
73
+ (`changes/archive/…`) and remains the human-readable record of what was
74
+ approved — useful input for archive→ADR.