@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/README.md +83 -36
- package/bin/forge.mjs +5 -1
- package/package.json +4 -4
- package/scripts/run-tests.mjs +7 -0
- package/src/brief-cli.mjs +62 -0
- package/src/brief.mjs +104 -0
- package/src/brief.test.mjs +186 -0
- package/src/cleanup-sessions.mjs +2 -0
- package/src/fleet.mjs +233 -0
- package/src/fleet.test.mjs +137 -0
- package/src/lib/fleet.mjs +212 -0
- package/src/lib.mjs +5 -0
- package/src/session-reminder.mjs +12 -1
- package/src/set-phase.mjs +28 -0
- package/vendor/skills/forge/phases/plan-openspec.md +15 -2
- package/vendor/skills/forge/phases/plan-specs.md +15 -2
- package/vendor/skills/forge/references/operator-brief.md +74 -0
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) {
|
package/src/session-reminder.mjs
CHANGED
|
@@ -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
|
-
|
|
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) —
|