@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 +5 -1
- package/package.json +1 -1
- 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/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
package/scripts/run-tests.mjs
CHANGED
|
@@ -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
|
+
});
|
package/src/cleanup-sessions.mjs
CHANGED
|
@@ -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) {
|
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) —
|
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|