@izkac/forgekit 0.2.0 → 0.3.1

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/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) —