@izkac/forgekit 0.3.5 → 0.3.7
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 +1 -1
- package/package.json +1 -1
- package/src/e2e-cli.test.mjs +75 -0
- package/src/e2e.mjs +54 -2
- package/src/fleet.mjs +11 -5
- package/src/fleet.test.mjs +44 -0
- package/src/lib/fleet.mjs +35 -0
- package/src/new-session.mjs +33 -16
- package/src/score.mjs +47 -1
- package/src/score.test.mjs +11 -0
- package/src/session-reminder.mjs +30 -2
- package/vendor/skills/forge/docs/forge.md +4 -0
- package/vendor/skills/forge/references/runtime-integrity.md +12 -0
- package/vendor/templates/project/claude/commands/forge-analyze.md +42 -0
- package/vendor/templates/project/claude/commands/forge-harness.md +41 -0
- package/vendor/templates/project/claude/commands/forge.md +1 -1
- package/vendor/templates/project/cursor/commands/forge-analyze.md +42 -0
- package/vendor/templates/project/cursor/commands/forge-harness.md +41 -0
- package/vendor/templates/project/cursor/commands/forge.md +1 -1
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Running agents across three IDEs and two terminals? `forge fleet` is a single co
|
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
forge fleet list # every session: phase, task progress, engine, activity
|
|
35
|
-
forge fleet watch # live-refreshing dashboard
|
|
35
|
+
forge fleet watch # live-refreshing dashboard (active sessions only; --all shows everything)
|
|
36
36
|
forge fleet view <s> # detail + live transcript tail (Claude Code)
|
|
37
37
|
forge fleet send <s> "pause and report" # message any session; --all broadcasts
|
|
38
38
|
```
|
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
|
|
9
|
+
const E2E = path.join(path.dirname(fileURLToPath(import.meta.url)), 'e2e.mjs');
|
|
10
|
+
|
|
11
|
+
function tmp(prefix) {
|
|
12
|
+
return fs.mkdtempSync(path.join(tmpdir(), prefix));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function run(cwd, args) {
|
|
16
|
+
return execFileSync(process.execPath, [E2E, ...args], {
|
|
17
|
+
cwd,
|
|
18
|
+
env: { ...process.env, FORGEKIT_FLEET_DIR: path.join(tmp('e2e-fleet-'), 's') },
|
|
19
|
+
}).toString();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** .forge fixture with an active session tracking a specs change. */
|
|
23
|
+
function makeFixture(root) {
|
|
24
|
+
const sessionDir = path.join(root, '.forge', 'sessions', 's1');
|
|
25
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
26
|
+
fs.writeFileSync(
|
|
27
|
+
path.join(sessionDir, 'session.json'),
|
|
28
|
+
`${JSON.stringify({ id: 's1', slug: 'fixture', planType: 'specs', openspecChange: 'my-change' })}\n`,
|
|
29
|
+
'utf8',
|
|
30
|
+
);
|
|
31
|
+
fs.writeFileSync(
|
|
32
|
+
path.join(root, '.forge', 'active.json'),
|
|
33
|
+
`${JSON.stringify({ sessionId: 's1' })}\n`,
|
|
34
|
+
'utf8',
|
|
35
|
+
);
|
|
36
|
+
fs.mkdirSync(path.join(root, 'specs', 'changes', 'my-change'), { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('e2e harness: record → show → surfaced by init; config keys preserved', () => {
|
|
40
|
+
const root = tmp('e2e-harness-');
|
|
41
|
+
makeFixture(root);
|
|
42
|
+
// Pre-existing config keys must survive the harness merge-write.
|
|
43
|
+
fs.writeFileSync(
|
|
44
|
+
path.join(root, '.forge', 'config.json'),
|
|
45
|
+
`${JSON.stringify({ plan: { engine: 'specs', dir: 'specs' } }, null, 2)}\n`,
|
|
46
|
+
'utf8',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
assert.match(run(root, ['harness']), /No harness recorded/);
|
|
50
|
+
|
|
51
|
+
run(root, [
|
|
52
|
+
'harness',
|
|
53
|
+
'--set',
|
|
54
|
+
'compose test stack: server + scratch mongo on isolated ports',
|
|
55
|
+
'--start',
|
|
56
|
+
'npm run e2e:stack',
|
|
57
|
+
'--dir',
|
|
58
|
+
'scripts/e2e',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(root, '.forge', 'config.json'), 'utf8'));
|
|
62
|
+
assert.equal(cfg.plan.engine, 'specs');
|
|
63
|
+
assert.equal(cfg.e2e.harness.start, 'npm run e2e:stack');
|
|
64
|
+
assert.match(cfg.e2e.harness.description, /compose test stack/);
|
|
65
|
+
|
|
66
|
+
assert.match(run(root, ['harness']), /REUSE it — do not build/);
|
|
67
|
+
assert.match(run(root, ['init']), /REUSE it — do not build/);
|
|
68
|
+
assert.equal(JSON.parse(run(root, ['status'])).harness.dir, 'scripts/e2e');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('e2e harness --set requires a description', () => {
|
|
72
|
+
const root = tmp('e2e-harness-req-');
|
|
73
|
+
makeFixture(root);
|
|
74
|
+
assert.throws(() => run(root, ['harness', '--set']), /Usage: forge e2e harness --set/);
|
|
75
|
+
});
|
package/src/e2e.mjs
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* forge e2e init [--force] # scaffold e2e.json for the active change
|
|
8
8
|
* forge e2e run # execute steps, write e2e-results.json (session dir)
|
|
9
9
|
* forge e2e check # gate check: green + current results; exit 1 with problems
|
|
10
|
+
* forge e2e harness # show recorded project harness (reuse it!)
|
|
11
|
+
* forge e2e harness --set "<desc>" [--start "<cmd>"] [--dir <path>]
|
|
10
12
|
* [--session <id>]
|
|
11
13
|
*
|
|
12
14
|
* e2e.json lives next to spine.json (change dir, falling back to the session
|
|
@@ -17,6 +19,7 @@
|
|
|
17
19
|
|
|
18
20
|
import fs from 'node:fs';
|
|
19
21
|
import { loadSession, readActive, readJson } from './lib.mjs';
|
|
22
|
+
import { loadProjectConfig, saveProjectConfig } from './config.mjs';
|
|
20
23
|
import {
|
|
21
24
|
checkE2eGate,
|
|
22
25
|
e2ePath,
|
|
@@ -34,11 +37,57 @@ const sub = args[0] && !args[0].startsWith('--') ? args[0] : 'status';
|
|
|
34
37
|
|
|
35
38
|
if (args[0] === '--help' || sub === 'help') {
|
|
36
39
|
process.stdout.write(
|
|
37
|
-
'Usage: forge e2e [init [--force] | run | check | status] [--session <id>]\n',
|
|
40
|
+
'Usage: forge e2e [init [--force] | run | check | status | harness [--set <desc> --start <cmd> --dir <path>]] [--session <id>]\n',
|
|
38
41
|
);
|
|
39
42
|
process.exit(0);
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
/** Recorded project harness (committed in .forge/config.json → e2e.harness). */
|
|
46
|
+
function loadHarness() {
|
|
47
|
+
const cfg = loadProjectConfig(process.cwd());
|
|
48
|
+
const h = cfg?.e2e?.harness;
|
|
49
|
+
return h && typeof h === 'object' ? h : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function harnessLines(h) {
|
|
53
|
+
const lines = [`Existing e2e harness (REUSE it — do not build or ask for a new one):`];
|
|
54
|
+
lines.push(` ${h.description}`);
|
|
55
|
+
if (h.start) lines.push(` Start: ${h.start}`);
|
|
56
|
+
if (h.dir) lines.push(` Location: ${h.dir}`);
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Project-level, no session needed.
|
|
61
|
+
if (sub === 'harness') {
|
|
62
|
+
const si = args.indexOf('--set');
|
|
63
|
+
if (si >= 0) {
|
|
64
|
+
const description = args[si + 1];
|
|
65
|
+
if (!description || description.startsWith('--')) {
|
|
66
|
+
process.stderr.write('Usage: forge e2e harness --set "<description>" [--start "<cmd>"] [--dir <path>]\n');
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const harness = { description, recordedAt: new Date().toISOString() };
|
|
70
|
+
const st = args.indexOf('--start');
|
|
71
|
+
if (st >= 0 && args[st + 1]) harness.start = args[st + 1];
|
|
72
|
+
const di = args.indexOf('--dir');
|
|
73
|
+
if (di >= 0 && args[di + 1]) harness.dir = args[di + 1];
|
|
74
|
+
saveProjectConfig(process.cwd(), { e2e: { harness } }, { mergeKeys: ['adr', 'plan', 'e2e'] });
|
|
75
|
+
process.stdout.write(
|
|
76
|
+
`Recorded harness in .forge/config.json (commit it). Future sessions will see it on forge e2e init.\n`,
|
|
77
|
+
);
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
const h = loadHarness();
|
|
81
|
+
if (!h) {
|
|
82
|
+
process.stdout.write(
|
|
83
|
+
'No harness recorded. After building one (with operator approval), record it:\n forge e2e harness --set "<what/where>" --start "<command>" [--dir <path>]\n',
|
|
84
|
+
);
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
process.stdout.write(`${harnessLines(h)}\n`);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
42
91
|
let sessionId = null;
|
|
43
92
|
let force = false;
|
|
44
93
|
for (let i = 0; i < args.length; i += 1) {
|
|
@@ -74,6 +123,8 @@ if (sub === 'init') {
|
|
|
74
123
|
process.stdout.write(
|
|
75
124
|
`Scaffolded ${file}\nAuthor the product-loop steps (produce → consume → assert domain side effects).\nSteps that would pass against a stubbed handler are invalid.\nRun with: forge e2e run\n`,
|
|
76
125
|
);
|
|
126
|
+
const harness = loadHarness();
|
|
127
|
+
if (harness) process.stdout.write(`\n${harnessLines(harness)}\n`);
|
|
77
128
|
process.exit(0);
|
|
78
129
|
}
|
|
79
130
|
|
|
@@ -133,7 +184,7 @@ if (sub === 'check') {
|
|
|
133
184
|
|
|
134
185
|
if (sub === 'status') {
|
|
135
186
|
if (!fs.existsSync(file)) {
|
|
136
|
-
process.stdout.write(JSON.stringify({ file, exists: false }, null, 2));
|
|
187
|
+
process.stdout.write(JSON.stringify({ file, exists: false, harness: loadHarness() }, null, 2));
|
|
137
188
|
process.stdout.write('\n');
|
|
138
189
|
process.exit(0);
|
|
139
190
|
}
|
|
@@ -153,6 +204,7 @@ if (sub === 'status') {
|
|
|
153
204
|
exists: true,
|
|
154
205
|
ok: valid.ok,
|
|
155
206
|
problems: valid.problems,
|
|
207
|
+
harness: loadHarness(),
|
|
156
208
|
results: results
|
|
157
209
|
? {
|
|
158
210
|
file: e2eResultsPath(dir),
|
package/src/fleet.mjs
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
7
|
* forge fleet list [--json]
|
|
8
|
-
* forge fleet watch [--interval <sec>]
|
|
8
|
+
* forge fleet watch [--interval <sec>] [--all]
|
|
9
9
|
* forge fleet view <session> [--transcript [N]]
|
|
10
10
|
* forge fleet send <session>|--all <message...>
|
|
11
11
|
*
|
|
12
12
|
* <session> matches by sessionId, slug, or project name; must be unique.
|
|
13
|
+
* watch shows only active sessions (not done, session dir present); --all
|
|
14
|
+
* includes done/missing ones. list always shows everything.
|
|
13
15
|
*/
|
|
14
16
|
|
|
15
17
|
import fs from 'node:fs';
|
|
@@ -27,7 +29,7 @@ function usage() {
|
|
|
27
29
|
process.stderr.write(
|
|
28
30
|
`Usage:
|
|
29
31
|
forge fleet list [--json]
|
|
30
|
-
forge fleet watch [--interval <sec>]
|
|
32
|
+
forge fleet watch [--interval <sec>] [--all]
|
|
31
33
|
forge fleet view <session> [--transcript [N]]
|
|
32
34
|
forge fleet send <session>|--all <message...>
|
|
33
35
|
`,
|
|
@@ -78,7 +80,7 @@ function renderTable(entries) {
|
|
|
78
80
|
`${pad(e.projectName, 18)} ${pad(e.slug, 26)} ${pad(e.engine ?? '—', 7)} ${pad(
|
|
79
81
|
e.missing ? 'missing' : phaseBar(e.phase),
|
|
80
82
|
18,
|
|
81
|
-
)} ${pad(tasksCell(e), 13)} ${pad(e.pace ?? '—', 9)} ${pad(relTime(e.updatedAt), 4)} ${
|
|
83
|
+
)} ${pad(tasksCell(e), 13)} ${pad(e.pace ?? '—', 9)} ${pad(relTime(e.lastSeen ?? e.updatedAt), 4)} ${
|
|
82
84
|
pending > 0 ? `✉ ${pending}` : ''
|
|
83
85
|
}`,
|
|
84
86
|
);
|
|
@@ -121,10 +123,14 @@ function cmdList(args) {
|
|
|
121
123
|
function cmdWatch(args) {
|
|
122
124
|
const i = args.indexOf('--interval');
|
|
123
125
|
const interval = Math.max(1, Number(i >= 0 ? args[i + 1] : 3) || 3) * 1000;
|
|
126
|
+
const all = args.includes('--all');
|
|
124
127
|
const render = () => {
|
|
128
|
+
const entries = listFleet().filter((e) => all || (!e.missing && e.phase !== 'done'));
|
|
125
129
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
126
|
-
process.stdout.write(
|
|
127
|
-
|
|
130
|
+
process.stdout.write(
|
|
131
|
+
`forge fleet — ${new Date().toLocaleTimeString()} (Ctrl+C to exit${all ? '' : ', --all for done/missing'})\n\n`,
|
|
132
|
+
);
|
|
133
|
+
process.stdout.write(renderTable(entries));
|
|
128
134
|
};
|
|
129
135
|
render();
|
|
130
136
|
setInterval(render, interval);
|
package/src/fleet.test.mjs
CHANGED
|
@@ -9,10 +9,13 @@ import {
|
|
|
9
9
|
drainInbox,
|
|
10
10
|
entryFile,
|
|
11
11
|
listFleet,
|
|
12
|
+
LIVE_WINDOW_MS,
|
|
13
|
+
liveOverlaps,
|
|
12
14
|
peekInbox,
|
|
13
15
|
queueMessage,
|
|
14
16
|
registerSession,
|
|
15
17
|
sanitizePath,
|
|
18
|
+
touchSession,
|
|
16
19
|
unregisterSession,
|
|
17
20
|
} from './lib/fleet.mjs';
|
|
18
21
|
import { saveSession } from './lib.mjs';
|
|
@@ -132,6 +135,47 @@ function registerSessionIn(fleetDir, project, session) {
|
|
|
132
135
|
}
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
test('registerSession stamps lastSeen; touchSession refreshes it', () => {
|
|
139
|
+
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-hb-'), 'sessions');
|
|
140
|
+
const project = tmp('fleet-proj-');
|
|
141
|
+
makeProject(project, 's5');
|
|
142
|
+
|
|
143
|
+
registerSession(project, makeSession('s5'));
|
|
144
|
+
const before = listFleet()[0].lastSeen;
|
|
145
|
+
assert.ok(before);
|
|
146
|
+
|
|
147
|
+
const file = entryFile(project, 's5');
|
|
148
|
+
const entry = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
149
|
+
entry.lastSeen = '2000-01-01T00:00:00.000Z';
|
|
150
|
+
fs.writeFileSync(file, JSON.stringify(entry));
|
|
151
|
+
|
|
152
|
+
touchSession(project, 's5');
|
|
153
|
+
const after = listFleet()[0].lastSeen;
|
|
154
|
+
assert.ok(after > '2000-01-01T00:00:00.000Z');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('liveOverlaps flags only live sessions in the same project', () => {
|
|
158
|
+
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-ovl-'), 'sessions');
|
|
159
|
+
const project = tmp('fleet-proj-');
|
|
160
|
+
const other = tmp('fleet-proj2-');
|
|
161
|
+
for (const id of ['me', 'peer', 'finished']) makeProject(project, id);
|
|
162
|
+
makeProject(other, 'elsewhere');
|
|
163
|
+
|
|
164
|
+
registerSession(project, makeSession('me'));
|
|
165
|
+
registerSession(project, makeSession('peer'));
|
|
166
|
+
registerSession(project, makeSession('finished', { phase: 'done' }));
|
|
167
|
+
registerSession(other, makeSession('elsewhere'));
|
|
168
|
+
|
|
169
|
+
const overlaps = liveOverlaps(project, 'me');
|
|
170
|
+
assert.deepEqual(
|
|
171
|
+
overlaps.map((e) => e.sessionId),
|
|
172
|
+
['peer'],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Stale heartbeat falls outside the liveness window.
|
|
176
|
+
assert.equal(liveOverlaps(project, 'me', Date.now() + LIVE_WINDOW_MS + 1000).length, 0);
|
|
177
|
+
});
|
|
178
|
+
|
|
135
179
|
test('sanitizePath matches Claude Code project-dir naming', () => {
|
|
136
180
|
assert.equal(sanitizePath('S:\\Projects\\forgekit'), 'S--Projects-forgekit');
|
|
137
181
|
});
|
package/src/lib/fleet.mjs
CHANGED
|
@@ -82,6 +82,7 @@ export function registerSession(projectRoot, session) {
|
|
|
82
82
|
engine: detectEngine() ?? prev.engine ?? null,
|
|
83
83
|
createdAt: session.createdAt,
|
|
84
84
|
updatedAt: session.updatedAt,
|
|
85
|
+
lastSeen: new Date().toISOString(),
|
|
85
86
|
};
|
|
86
87
|
fs.mkdirSync(fleetDir(), { recursive: true });
|
|
87
88
|
fs.writeFileSync(file, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
|
|
@@ -90,6 +91,40 @@ export function registerSession(projectRoot, session) {
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Heartbeat: refresh lastSeen on an existing registry entry. Called from the
|
|
96
|
+
* reminder hook, which fires on every agent turn — so lastSeen ≈ "agent is
|
|
97
|
+
* actually running", unlike updatedAt which only moves on saveSession.
|
|
98
|
+
*/
|
|
99
|
+
export function touchSession(projectRoot, sessionId) {
|
|
100
|
+
try {
|
|
101
|
+
const file = entryFile(projectRoot, sessionId);
|
|
102
|
+
const entry = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
103
|
+
entry.lastSeen = new Date().toISOString();
|
|
104
|
+
fs.writeFileSync(file, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
|
|
105
|
+
} catch {
|
|
106
|
+
/* advisory */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ponytail: fixed 30-min liveness window; make configurable if hooks ever fire slower.
|
|
111
|
+
export const LIVE_WINDOW_MS = 30 * 60 * 1000;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Other live sessions in the same project — the overlap signal for "two
|
|
115
|
+
* agents editing one working tree". Live = not done, session dir present,
|
|
116
|
+
* heartbeat (lastSeen, falling back to updatedAt) within LIVE_WINDOW_MS.
|
|
117
|
+
*/
|
|
118
|
+
export function liveOverlaps(projectRoot, sessionId, now = Date.now()) {
|
|
119
|
+
const root = path.resolve(projectRoot);
|
|
120
|
+
return listFleet().filter((e) => {
|
|
121
|
+
if (e.sessionId === sessionId || e.missing || e.phase === 'done') return false;
|
|
122
|
+
if (path.resolve(e.project) !== root) return false;
|
|
123
|
+
const seen = new Date(e.lastSeen ?? e.updatedAt).getTime();
|
|
124
|
+
return !Number.isNaN(seen) && now - seen < LIVE_WINDOW_MS;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
93
128
|
export function unregisterSession(projectRoot, sessionId) {
|
|
94
129
|
try {
|
|
95
130
|
fs.rmSync(entryFile(projectRoot, sessionId), { force: true });
|
package/src/new-session.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from './lib.mjs';
|
|
21
21
|
import { resolveSessionPaceFields } from './preferences.mjs';
|
|
22
22
|
import { warnIfDoctorFails } from './doctor.mjs';
|
|
23
|
+
import { liveOverlaps, queueMessage, sessionDirFor } from './lib/fleet.mjs';
|
|
23
24
|
|
|
24
25
|
function usage() {
|
|
25
26
|
process.stderr.write(
|
|
@@ -64,19 +65,35 @@ Object.assign(session, paceFields);
|
|
|
64
65
|
saveSession(dir, session);
|
|
65
66
|
writeActive(sessionId);
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
68
|
+
// Fleet coordination: another live session in this working tree risks
|
|
69
|
+
// conflicting edits — surface it here and notify the other sessions' inboxes.
|
|
70
|
+
const overlaps = liveOverlaps(process.cwd(), sessionId);
|
|
71
|
+
for (const o of overlaps) {
|
|
72
|
+
queueMessage(
|
|
73
|
+
sessionDirFor(o),
|
|
74
|
+
`Fleet overlap: session "${session.slug}" (${sessionId}) just started in this project. Coordinate with the user to avoid conflicting edits.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const out = {
|
|
79
|
+
sessionId,
|
|
80
|
+
dir,
|
|
81
|
+
session: defaultStatus(session),
|
|
82
|
+
pace: {
|
|
83
|
+
requested: session.pace,
|
|
84
|
+
resolved: session.resolvedPace,
|
|
85
|
+
reason: session.paceReason,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
if (overlaps.length > 0) {
|
|
89
|
+
out.overlaps = overlaps.map((o) => ({
|
|
90
|
+
sessionId: o.sessionId,
|
|
91
|
+
slug: o.slug,
|
|
92
|
+
phase: o.phase,
|
|
93
|
+
engine: o.engine,
|
|
94
|
+
lastSeen: o.lastSeen ?? o.updatedAt,
|
|
95
|
+
}));
|
|
96
|
+
out.overlapAdvice =
|
|
97
|
+
'Other live sessions are working in this project. Tell the user and ask: continue anyway, use a git worktree, or pause one session.';
|
|
98
|
+
}
|
|
99
|
+
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
package/src/score.mjs
CHANGED
|
@@ -475,7 +475,52 @@ export function formatScorecardMarkdown(card) {
|
|
|
475
475
|
}
|
|
476
476
|
|
|
477
477
|
/**
|
|
478
|
-
*
|
|
478
|
+
* Durable one-line-per-session ledger at `.forge/scorecards.jsonl`. Sessions
|
|
479
|
+
* are pruned after RETENTION_DAYS and scorecards die with them; the ledger
|
|
480
|
+
* survives — it is the history `/forge:analyze` reads for trends. Re-scoring
|
|
481
|
+
* a session replaces its line (latest score wins). Never throws.
|
|
482
|
+
*
|
|
483
|
+
* @param {string} sessionDir
|
|
484
|
+
* @param {ReturnType<typeof scoreSession>} card
|
|
485
|
+
* @param {Record<string, unknown>} session
|
|
486
|
+
*/
|
|
487
|
+
export function appendScorecardLedger(sessionDir, card, session = {}) {
|
|
488
|
+
try {
|
|
489
|
+
const file = path.join(path.resolve(sessionDir, '..', '..'), 'scorecards.jsonl');
|
|
490
|
+
const line = {
|
|
491
|
+
scoredAt: card.scoredAt,
|
|
492
|
+
sessionId: card.sessionId,
|
|
493
|
+
slug: card.slug,
|
|
494
|
+
change: card.openspecChange,
|
|
495
|
+
score: card.score,
|
|
496
|
+
grade: card.grade,
|
|
497
|
+
integrityOk: card.integrityOk,
|
|
498
|
+
pace: session.resolvedPace ?? null,
|
|
499
|
+
incompleteReason: session.incompleteReason ?? null,
|
|
500
|
+
caps: card.caps,
|
|
501
|
+
deductions: card.checks
|
|
502
|
+
.filter((c) => c.points < c.max)
|
|
503
|
+
.map((c) => ({ id: c.id, points: c.points, max: c.max, notes: c.notes })),
|
|
504
|
+
};
|
|
505
|
+
const kept = (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n') : [])
|
|
506
|
+
.filter(Boolean)
|
|
507
|
+
.filter((l) => {
|
|
508
|
+
try {
|
|
509
|
+
return JSON.parse(l).sessionId !== card.sessionId;
|
|
510
|
+
} catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
kept.push(JSON.stringify(line));
|
|
515
|
+
fs.writeFileSync(file, `${kept.join('\n')}\n`, 'utf8');
|
|
516
|
+
} catch {
|
|
517
|
+
/* ledger is advisory — never block a scorecard write */
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Write scorecard.json + scorecard.md into the session dir, and mirror a
|
|
523
|
+
* summary line into the durable `.forge/scorecards.jsonl` ledger.
|
|
479
524
|
*
|
|
480
525
|
* @param {{ cwd?: string, sessionDir: string, session: Record<string, unknown> }} opts
|
|
481
526
|
*/
|
|
@@ -485,5 +530,6 @@ export function writeSessionScorecard(opts) {
|
|
|
485
530
|
const mdPath = path.join(opts.sessionDir, 'scorecard.md');
|
|
486
531
|
writeJson(jsonPath, card);
|
|
487
532
|
fs.writeFileSync(mdPath, formatScorecardMarkdown(card), 'utf8');
|
|
533
|
+
appendScorecardLedger(opts.sessionDir, card, opts.session);
|
|
488
534
|
return { card, jsonPath, mdPath };
|
|
489
535
|
}
|
package/src/score.test.mjs
CHANGED
|
@@ -191,6 +191,17 @@ test('writeSessionScorecard writes json and md', () => {
|
|
|
191
191
|
assert.equal(fs.existsSync(jsonPath), true);
|
|
192
192
|
assert.equal(fs.existsSync(mdPath), true);
|
|
193
193
|
assert.equal(JSON.parse(fs.readFileSync(jsonPath, 'utf8')).grade, card.grade);
|
|
194
|
+
|
|
195
|
+
// Durable ledger: one line per session, re-scoring replaces (not appends).
|
|
196
|
+
const ledger = path.join(root, '.forge', 'scorecards.jsonl');
|
|
197
|
+
assert.equal(fs.existsSync(ledger), true);
|
|
198
|
+
writeSessionScorecard({ cwd: root, sessionDir, session });
|
|
199
|
+
const lines = fs.readFileSync(ledger, 'utf8').split('\n').filter(Boolean);
|
|
200
|
+
assert.equal(lines.length, 1);
|
|
201
|
+
const entry = JSON.parse(lines[0]);
|
|
202
|
+
assert.equal(entry.sessionId, session.id);
|
|
203
|
+
assert.equal(entry.grade, card.grade);
|
|
204
|
+
assert.ok(Array.isArray(entry.deductions));
|
|
194
205
|
} finally {
|
|
195
206
|
fs.rmSync(root, { recursive: true, force: true });
|
|
196
207
|
}
|
package/src/session-reminder.mjs
CHANGED
|
@@ -8,8 +8,14 @@
|
|
|
8
8
|
* forge reminder --format plain
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { FORGE_DIR, loadSession, readActive } from './lib.mjs';
|
|
12
|
-
import {
|
|
11
|
+
import { FORGE_DIR, loadSession, readActive, REPO_ROOT } from './lib.mjs';
|
|
12
|
+
import {
|
|
13
|
+
drainInbox,
|
|
14
|
+
liveOverlaps,
|
|
15
|
+
queueMessage,
|
|
16
|
+
sessionDirFor,
|
|
17
|
+
touchSession,
|
|
18
|
+
} from './lib/fleet.mjs';
|
|
13
19
|
import { resolveEffectivePreferences } from './preferences.mjs';
|
|
14
20
|
|
|
15
21
|
function getActiveSessionInfo() {
|
|
@@ -149,10 +155,32 @@ if (!info) {
|
|
|
149
155
|
process.exit(0);
|
|
150
156
|
}
|
|
151
157
|
|
|
158
|
+
// Fleet heartbeat: this hook fires on every agent turn, so lastSeen in the
|
|
159
|
+
// registry tracks "agent actually running", not just the last saveSession.
|
|
160
|
+
touchSession(REPO_ROOT, info.session.id);
|
|
161
|
+
|
|
152
162
|
let message = prompt
|
|
153
163
|
? buildForgePromptMessage(info, prompt)
|
|
154
164
|
: buildForgeMessage(info);
|
|
155
165
|
|
|
166
|
+
// Overlap check on resume: another live session in this same working tree
|
|
167
|
+
// means potentially conflicting edits. Warn this agent and drop a note in the
|
|
168
|
+
// other sessions' inboxes. Session-start only — per-turn would spam.
|
|
169
|
+
if (format === 'claude-session-start') {
|
|
170
|
+
const overlaps = liveOverlaps(REPO_ROOT, info.session.id);
|
|
171
|
+
if (overlaps.length > 0) {
|
|
172
|
+
message += `\n\nFleet overlap — other live session(s) in this project:\n${overlaps
|
|
173
|
+
.map((o) => `- ${o.slug} (${o.sessionId}) · ${o.engine ?? 'unknown'} · phase ${o.phase}`)
|
|
174
|
+
.join('\n')}\nTell the user and ask how to proceed: continue anyway, move this work to a git worktree, or pause one session.`;
|
|
175
|
+
for (const o of overlaps) {
|
|
176
|
+
queueMessage(
|
|
177
|
+
sessionDirFor(o),
|
|
178
|
+
`Fleet overlap: session "${info.session.slug}" (${info.session.id}) just resumed in this project. Coordinate with the user to avoid conflicting edits.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
156
184
|
// Deliver queued `forge fleet send` messages exactly once (drain moves them
|
|
157
185
|
// to inbox/delivered/). This is the fleet command bus: control terminal →
|
|
158
186
|
// inbox file → injected into the agent's next turn via this hook.
|
|
@@ -246,6 +246,8 @@ required for correctness.
|
|
|
246
246
|
| `/forge:apply` | **Tracked-change implement** — subagent TDD + verify + review (preferred over `/opsx:apply`) |
|
|
247
247
|
| `/forge:build` | Implement phase (`tasks.md` from either engine) |
|
|
248
248
|
| `/forge:status` | Show active session progress |
|
|
249
|
+
| `/forge:harness` | Ensure a working, recorded project e2e harness (build proactively) |
|
|
250
|
+
| `/forge:analyze` | Agent-written improvement report over recent sessions |
|
|
249
251
|
| `/forge:skip` | **Explicit** opt-out of Forge for this task |
|
|
250
252
|
|
|
251
253
|
OpenSpec commands remain available standalone (OpenSpec-engine projects):
|
|
@@ -624,6 +626,8 @@ per machine with `forgekit install`; wire project commands/hooks with `forge ini
|
|
|
624
626
|
| `/forge:apply` | Tracked-change implement + verify + review (preferred) |
|
|
625
627
|
| `/forge:build` | Implement phase (`tasks.md` from either engine) |
|
|
626
628
|
| `/forge:status` | Session progress |
|
|
629
|
+
| `/forge:harness` | Ensure a working, recorded project e2e harness |
|
|
630
|
+
| `/forge:analyze` | Improvement report over recent sessions |
|
|
627
631
|
| `/forge:skip` | Explicit skip for this task |
|
|
628
632
|
|
|
629
633
|
### Codex CLI
|
|
@@ -98,6 +98,18 @@ cost instead:
|
|
|
98
98
|
fixture-level work is over-asking; skipping the loop because the rig is
|
|
99
99
|
missing is under-testing — the ask is exactly the moment the operator can
|
|
100
100
|
say "build it once" and make every future loop on the project cheap.
|
|
101
|
+
- **A built harness is permanent project infrastructure — record it, reuse
|
|
102
|
+
it.** When the operator approves building one: build it as committed
|
|
103
|
+
project code (never session scratch), then record it:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
forge e2e harness --set "<what it is / where>" --start "<start command>" [--dir <path>]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
This lands in `.forge/config.json` (committed), and every later session
|
|
110
|
+
sees it — `forge e2e init` and `forge e2e status` print the recorded
|
|
111
|
+
harness. Check `forge e2e harness` before proposing to build or asking the
|
|
112
|
+
operator; proposing a new rig while one is recorded is a REJECT.
|
|
101
113
|
- **One executed loop per capability, not per assertion.** Push edge cases
|
|
102
114
|
into unit tests; e2e proves the wiring exists.
|
|
103
115
|
- **Set `timeoutMs` from the step count up front.** A 10-step loop is never a
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: /forge:analyze
|
|
3
|
+
description: Forge — analyze recent sessions and write an improvement report
|
|
4
|
+
category: Workflow
|
|
5
|
+
tags: [workflow, forge, retrospective]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
**Forge-owned command.** Read the evidence recent Forge sessions left behind and write an honest improvement report — what went well, what keeps going wrong, and what to change. The analysis is yours: look for patterns, not single events.
|
|
9
|
+
|
|
10
|
+
## 1. Gather
|
|
11
|
+
|
|
12
|
+
- `.forge/scorecards.jsonl` — durable one-line-per-session ledger (score, grade, deductions, caps, pace, incomplete reasons). Survives session cleanup; this is your history.
|
|
13
|
+
- `.forge/sessions/*/` for sessions still on disk: `scorecard.md`, `verify-evidence.md`, `reviews/final-review.md`, `deferrals.json`, `session.json`.
|
|
14
|
+
|
|
15
|
+
If both are empty, tell the user there is nothing to analyze yet and stop.
|
|
16
|
+
|
|
17
|
+
## 2. Analyze
|
|
18
|
+
|
|
19
|
+
Patterns worth hunting (not a checklist — follow what the data shows):
|
|
20
|
+
|
|
21
|
+
- **Recurring deductions** — the same check losing points across sessions is a process problem, not a session problem.
|
|
22
|
+
- **`--allow-incomplete` usage** — legitimate deferrals, or the gate being routinely dodged? Read the reasons.
|
|
23
|
+
- **Pace vs outcome** — do brisk/lite sessions score worse here? Are task-count escalations firing when they should?
|
|
24
|
+
- **Evidence honesty** — ceremony-only tests, evidence with non-zero exits, verify phases that re-ran nothing.
|
|
25
|
+
- **Deferrals** — raised vs resolved; anything raised repeatedly for the same area?
|
|
26
|
+
- **Grade trend** — improving, flat, or decaying over time?
|
|
27
|
+
|
|
28
|
+
For each pattern found: name the root cause and one concrete fix — a pace pref, a missing harness, a rule, a habit. No generic advice.
|
|
29
|
+
|
|
30
|
+
## 3. Report
|
|
31
|
+
|
|
32
|
+
Write `.forge/reports/analysis-<YYYY-MM-DD>.md`:
|
|
33
|
+
|
|
34
|
+
- **TL;DR** — 3 bullets max
|
|
35
|
+
- **Trend table** — session · date · grade · top deduction
|
|
36
|
+
- **What's working** — keep doing
|
|
37
|
+
- **What's broken** — each item with its concrete fix
|
|
38
|
+
- **Next actions** — ranked, smallest-effective first
|
|
39
|
+
|
|
40
|
+
Then summarize the TL;DR to the user in chat.
|
|
41
|
+
|
|
42
|
+
Reference: `~/.claude/skills/forge/docs/forge.md`
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: /forge:harness
|
|
3
|
+
description: Forge — ensure the project has a working, recorded e2e harness
|
|
4
|
+
category: Workflow
|
|
5
|
+
tags: [workflow, forge, e2e]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
**Forge-owned command.** Ensure this project has a working, recorded e2e harness — the environment `forge e2e run` steps execute against, and the base for the project's own end-to-end testing. Building it proactively here means later sessions never stall at the integrity gate waiting for one.
|
|
9
|
+
|
|
10
|
+
## 1. Check what's recorded
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
forge e2e harness
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **Harness shown** → verify it still works: run its start command, then one real probe against it. Working → report to the user and stop. Broken → continue to step 2, treating the existing harness as the starting point (fix, don't rebuild).
|
|
17
|
+
- **"No harness recorded"** → step 2.
|
|
18
|
+
|
|
19
|
+
## 2. Design it with the operator
|
|
20
|
+
|
|
21
|
+
Explore the project first: how the app starts, what backing services it needs, and what a real user-visible probe looks like (HTTP endpoint, CLI invocation, UI route). Then propose to the user:
|
|
22
|
+
|
|
23
|
+
- what the harness starts (app + backing services, isolated ports/data so it can't touch dev state)
|
|
24
|
+
- how a test asserts *through the product* (the probe `forge e2e` steps will use — not internal function calls)
|
|
25
|
+
- where it lives (e.g. `scripts/e2e/`, a compose file, a test config)
|
|
26
|
+
|
|
27
|
+
**Get explicit approval before building.** A harness is committed project infrastructure, not session scratch.
|
|
28
|
+
|
|
29
|
+
## 3. Build and prove
|
|
30
|
+
|
|
31
|
+
Build the approved harness. Prove it end-to-end: start it, run one real probe, show the user the output. A harness that has never gone green is not done.
|
|
32
|
+
|
|
33
|
+
## 4. Record it
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
forge e2e harness --set "<what/where>" --start "<command>" [--dir <path>]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then commit `.forge/config.json`. Every future session sees the harness on `forge e2e init` and reuses it instead of rebuilding or asking again.
|
|
40
|
+
|
|
41
|
+
Reference: `~/.claude/skills/forge/docs/forge.md`
|
|
@@ -13,4 +13,4 @@ Read and follow the Forge skill (`~/.claude/skills/forge/SKILL.md`) and `~/.clau
|
|
|
13
13
|
2. Resume from `.forge/active.json` or `forge new <slug>`
|
|
14
14
|
3. Continue from current `phase` in `session.json`
|
|
15
15
|
|
|
16
|
-
Subcommands: `/forge:brainstorm`, `/forge:plan`, `/forge:apply`, `/forge:build`, `/forge:status`, `/forge:skip`
|
|
16
|
+
Subcommands: `/forge:brainstorm`, `/forge:plan`, `/forge:apply`, `/forge:build`, `/forge:status`, `/forge:harness`, `/forge:analyze`, `/forge:skip`
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: /forge:analyze
|
|
3
|
+
id: forge-analyze
|
|
4
|
+
category: Workflow
|
|
5
|
+
description: Forge — analyze recent sessions and write an improvement report
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
**Forge-owned command.** Read the evidence recent Forge sessions left behind and write an honest improvement report — what went well, what keeps going wrong, and what to change. The analysis is yours: look for patterns, not single events.
|
|
9
|
+
|
|
10
|
+
## 1. Gather
|
|
11
|
+
|
|
12
|
+
- `.forge/scorecards.jsonl` — durable one-line-per-session ledger (score, grade, deductions, caps, pace, incomplete reasons). Survives session cleanup; this is your history.
|
|
13
|
+
- `.forge/sessions/*/` for sessions still on disk: `scorecard.md`, `verify-evidence.md`, `reviews/final-review.md`, `deferrals.json`, `session.json`.
|
|
14
|
+
|
|
15
|
+
If both are empty, tell the user there is nothing to analyze yet and stop.
|
|
16
|
+
|
|
17
|
+
## 2. Analyze
|
|
18
|
+
|
|
19
|
+
Patterns worth hunting (not a checklist — follow what the data shows):
|
|
20
|
+
|
|
21
|
+
- **Recurring deductions** — the same check losing points across sessions is a process problem, not a session problem.
|
|
22
|
+
- **`--allow-incomplete` usage** — legitimate deferrals, or the gate being routinely dodged? Read the reasons.
|
|
23
|
+
- **Pace vs outcome** — do brisk/lite sessions score worse here? Are task-count escalations firing when they should?
|
|
24
|
+
- **Evidence honesty** — ceremony-only tests, evidence with non-zero exits, verify phases that re-ran nothing.
|
|
25
|
+
- **Deferrals** — raised vs resolved; anything raised repeatedly for the same area?
|
|
26
|
+
- **Grade trend** — improving, flat, or decaying over time?
|
|
27
|
+
|
|
28
|
+
For each pattern found: name the root cause and one concrete fix — a pace pref, a missing harness, a rule, a habit. No generic advice.
|
|
29
|
+
|
|
30
|
+
## 3. Report
|
|
31
|
+
|
|
32
|
+
Write `.forge/reports/analysis-<YYYY-MM-DD>.md`:
|
|
33
|
+
|
|
34
|
+
- **TL;DR** — 3 bullets max
|
|
35
|
+
- **Trend table** — session · date · grade · top deduction
|
|
36
|
+
- **What's working** — keep doing
|
|
37
|
+
- **What's broken** — each item with its concrete fix
|
|
38
|
+
- **Next actions** — ranked, smallest-effective first
|
|
39
|
+
|
|
40
|
+
Then summarize the TL;DR to the user in chat.
|
|
41
|
+
|
|
42
|
+
Reference: `~/.cursor/skills/forge/docs/forge.md`
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: /forge:harness
|
|
3
|
+
id: forge-harness
|
|
4
|
+
category: Workflow
|
|
5
|
+
description: Forge — ensure the project has a working, recorded e2e harness
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
**Forge-owned command.** Ensure this project has a working, recorded e2e harness — the environment `forge e2e run` steps execute against, and the base for the project's own end-to-end testing. Building it proactively here means later sessions never stall at the integrity gate waiting for one.
|
|
9
|
+
|
|
10
|
+
## 1. Check what's recorded
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
forge e2e harness
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- **Harness shown** → verify it still works: run its start command, then one real probe against it. Working → report to the user and stop. Broken → continue to step 2, treating the existing harness as the starting point (fix, don't rebuild).
|
|
17
|
+
- **"No harness recorded"** → step 2.
|
|
18
|
+
|
|
19
|
+
## 2. Design it with the operator
|
|
20
|
+
|
|
21
|
+
Explore the project first: how the app starts, what backing services it needs, and what a real user-visible probe looks like (HTTP endpoint, CLI invocation, UI route). Then propose to the user:
|
|
22
|
+
|
|
23
|
+
- what the harness starts (app + backing services, isolated ports/data so it can't touch dev state)
|
|
24
|
+
- how a test asserts *through the product* (the probe `forge e2e` steps will use — not internal function calls)
|
|
25
|
+
- where it lives (e.g. `scripts/e2e/`, a compose file, a test config)
|
|
26
|
+
|
|
27
|
+
**Get explicit approval before building.** A harness is committed project infrastructure, not session scratch.
|
|
28
|
+
|
|
29
|
+
## 3. Build and prove
|
|
30
|
+
|
|
31
|
+
Build the approved harness. Prove it end-to-end: start it, run one real probe, show the user the output. A harness that has never gone green is not done.
|
|
32
|
+
|
|
33
|
+
## 4. Record it
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
forge e2e harness --set "<what/where>" --start "<command>" [--dir <path>]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then commit `.forge/config.json`. Every future session sees the harness on `forge e2e init` and reuses it instead of rebuilding or asking again.
|
|
40
|
+
|
|
41
|
+
Reference: `~/.cursor/skills/forge/docs/forge.md`
|
|
@@ -13,4 +13,4 @@ Read and follow the Forge skill (`~/.cursor/skills/forge/SKILL.md`) and `~/.curs
|
|
|
13
13
|
2. Resume from `.forge/active.json` or `forge new <slug>`
|
|
14
14
|
3. Continue from current `phase` in `session.json`
|
|
15
15
|
|
|
16
|
-
Subcommands: `/forge:brainstorm`, `/forge:plan`, `/forge:apply`, `/forge:build`, `/forge:status`, `/forge:skip`
|
|
16
|
+
Subcommands: `/forge:brainstorm`, `/forge:plan`, `/forge:apply`, `/forge:build`, `/forge:status`, `/forge:harness`, `/forge:analyze`, `/forge:skip`
|