@izkac/forgekit 0.3.13 → 0.3.15
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 +16 -2
- package/package.json +2 -1
- package/src/checkpoint.mjs +357 -0
- package/src/checkpoint.test.mjs +252 -0
- package/src/fleet.mjs +27 -2
- package/src/fleet.test.mjs +50 -0
- package/src/health.mjs +144 -0
- package/src/health.test.mjs +138 -0
- package/src/integrity.mjs +7 -1
- package/src/integrity.test.mjs +46 -0
- package/src/lib/fleet.mjs +61 -4
- package/src/lib.mjs +20 -3
- package/src/lib.test.mjs +128 -0
- package/src/new-session.mjs +15 -0
- package/src/repo-root.mjs +33 -0
- package/src/session-reminder.mjs +10 -0
- package/src/session-status.mjs +6 -0
- package/src/specs-sync.mjs +2 -4
- package/vendor/skills/forge/SKILL.md +2 -1
- package/vendor/skills/forge/docs/forge.md +79 -2
- package/vendor/skills/forge/phases/implement.md +19 -2
- package/vendor/skills/forge/subagents/task-reviewer-prompt.md +1 -1
package/src/fleet.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
queueMessage,
|
|
25
25
|
sessionDirFor,
|
|
26
26
|
} from './lib/fleet.mjs';
|
|
27
|
+
import { healthCell, sessionHealth } from './health.mjs';
|
|
27
28
|
|
|
28
29
|
function usage() {
|
|
29
30
|
process.stderr.write(
|
|
@@ -70,21 +71,45 @@ function pad(str, len) {
|
|
|
70
71
|
return s.length >= len ? s.slice(0, len) : s + ' '.repeat(len - s.length);
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Health per entry, read from that project's session dir. A row showing a
|
|
76
|
+
* healthy-looking `27/32` while its e2e run is red and nobody has touched it
|
|
77
|
+
* for 14 hours is the failure this column exists to prevent.
|
|
78
|
+
*/
|
|
79
|
+
function healthFor(entry) {
|
|
80
|
+
if (entry.missing) return { state: 'unknown', line: 'missing' };
|
|
81
|
+
try {
|
|
82
|
+
const sessionDir = sessionDirFor(entry);
|
|
83
|
+
const session = JSON.parse(fs.readFileSync(path.join(sessionDir, 'session.json'), 'utf8'));
|
|
84
|
+
return sessionHealth({ cwd: entry.project, sessionDir, session });
|
|
85
|
+
} catch {
|
|
86
|
+
return { state: 'unknown', line: 'unknown' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
function renderTable(entries) {
|
|
74
91
|
if (entries.length === 0) return 'No fleet sessions. Start one with `forge new <slug>`.\n';
|
|
75
|
-
const header = `${pad('PROJECT', 18)} ${pad('SESSION', 26)} ${pad('ENGINE', 7)} ${pad('PHASE', 18)} ${pad('TASKS', 16)} ${pad('PACE', 9)} ${pad('AGE', 4)} MSGS`;
|
|
92
|
+
const header = `${pad('PROJECT', 18)} ${pad('SESSION', 26)} ${pad('ENGINE', 7)} ${pad('PHASE', 18)} ${pad('TASKS', 16)} ${pad('HEALTH', 6)} ${pad('PACE', 9)} ${pad('AGE', 4)} MSGS`;
|
|
76
93
|
const lines = [header, '-'.repeat(header.length)];
|
|
94
|
+
/** @type {string[]} */
|
|
95
|
+
const notes = [];
|
|
77
96
|
for (const e of entries) {
|
|
78
97
|
const pending = e.missing ? 0 : peekInbox(sessionDirFor(e)).length;
|
|
98
|
+
const health = healthFor(e);
|
|
99
|
+
if (health.state === 'red' || health.state === 'stale') {
|
|
100
|
+
notes.push(`${e.projectName}/${e.slug}: ${health.line}`);
|
|
101
|
+
}
|
|
79
102
|
lines.push(
|
|
80
103
|
`${pad(e.projectName, 18)} ${pad(e.slug, 26)} ${pad(e.engine ?? '—', 7)} ${pad(
|
|
81
104
|
e.missing ? 'missing' : phaseBar(e.phase),
|
|
82
105
|
18,
|
|
83
|
-
)} ${pad(tasksCell(e), 16)} ${pad(e.pace ?? '—', 9)} ${pad(relTime(e.lastSeen ?? e.updatedAt), 4)} ${
|
|
106
|
+
)} ${pad(tasksCell(e), 16)} ${pad(healthCell(health.state), 6)} ${pad(e.pace ?? '—', 9)} ${pad(relTime(e.lastSeen ?? e.updatedAt), 4)} ${
|
|
84
107
|
pending > 0 ? `✉ ${pending}` : ''
|
|
85
108
|
}`,
|
|
86
109
|
);
|
|
87
110
|
}
|
|
111
|
+
// The column says which row; the notes say why, without a second command.
|
|
112
|
+
if (notes.length) lines.push('', ...notes.map((n) => ` ! ${n}`));
|
|
88
113
|
return `${lines.join('\n')}\n`;
|
|
89
114
|
}
|
|
90
115
|
|
package/src/fleet.test.mjs
CHANGED
|
@@ -80,6 +80,56 @@ test('listFleet self-heals entries whose session dir is gone', () => {
|
|
|
80
80
|
assert.equal(fs.existsSync(entryFile(project, 'gone')), false);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
test('listFleet reconciles a stale entry against session.json on disk', () => {
|
|
84
|
+
// Regression: the registry is a cache, not a source of truth. A session
|
|
85
|
+
// whose phase advanced without a mirroring write (older CLI, a crash, a
|
|
86
|
+
// hand-edited record) showed its first-registered phase forever — helm's
|
|
87
|
+
// phase-0 sat at `brainstorm` in `forge fleet list` after reaching `done`.
|
|
88
|
+
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-reconcile-'), 'sessions');
|
|
89
|
+
const project = tmp('fleet-proj-');
|
|
90
|
+
const sessionDir = makeProject(project, 's5');
|
|
91
|
+
|
|
92
|
+
registerSession(project, makeSession('s5', { phase: 'brainstorm', tasksComplete: 0 }));
|
|
93
|
+
// Disk moves on without the registry.
|
|
94
|
+
const later = new Date(Date.now() + 60_000).toISOString();
|
|
95
|
+
fs.writeFileSync(
|
|
96
|
+
path.join(sessionDir, 'session.json'),
|
|
97
|
+
`${JSON.stringify(
|
|
98
|
+
makeSession('s5', {
|
|
99
|
+
phase: 'done',
|
|
100
|
+
tasksTotal: 20,
|
|
101
|
+
tasksComplete: 20,
|
|
102
|
+
openspecChange: 'phase-0',
|
|
103
|
+
updatedAt: later,
|
|
104
|
+
}),
|
|
105
|
+
)}\n`,
|
|
106
|
+
'utf8',
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const [entry] = listFleet();
|
|
110
|
+
assert.equal(entry.phase, 'done');
|
|
111
|
+
assert.equal(entry.tasksComplete, 20);
|
|
112
|
+
assert.equal(entry.tasksTotal, 20);
|
|
113
|
+
assert.equal(entry.openspecChange, 'phase-0');
|
|
114
|
+
assert.equal(entry.updatedAt, later);
|
|
115
|
+
// Reconciliation is persisted, so the next read is already correct.
|
|
116
|
+
assert.equal(JSON.parse(fs.readFileSync(entryFile(project, 's5'), 'utf8')).phase, 'done');
|
|
117
|
+
// lastSeen is a heartbeat, not a session field — reconciling must not forge one.
|
|
118
|
+
assert.ok(entry.lastSeen <= new Date().toISOString());
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('listFleet keeps registry-only fields when session.json is unreadable', () => {
|
|
122
|
+
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-unreadable-'), 'sessions');
|
|
123
|
+
const project = tmp('fleet-proj-');
|
|
124
|
+
const sessionDir = makeProject(project, 's6');
|
|
125
|
+
registerSession(project, makeSession('s6', { phase: 'verify' }));
|
|
126
|
+
fs.writeFileSync(path.join(sessionDir, 'session.json'), '{ not json', 'utf8');
|
|
127
|
+
|
|
128
|
+
const [entry] = listFleet();
|
|
129
|
+
assert.equal(entry.phase, 'verify');
|
|
130
|
+
assert.equal(entry.missing, false);
|
|
131
|
+
});
|
|
132
|
+
|
|
83
133
|
test('saveSession mirrors into the fleet registry', () => {
|
|
84
134
|
process.env.FORGEKIT_FLEET_DIR = path.join(tmp('fleet-mirror-'), 'sessions');
|
|
85
135
|
const project = tmp('fleet-proj-');
|
package/src/health.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Session health — a verdict, not a data dump.
|
|
4
|
+
*
|
|
5
|
+
* `forge status` printed every field a session had and said nothing about
|
|
6
|
+
* whether the session was in trouble, so a session could sit at
|
|
7
|
+
* `implement 27/32` with a red e2e run for 14 hours and look exactly like one
|
|
8
|
+
* that was mid-stride. Health answers the one question the operator actually
|
|
9
|
+
* asks: is this session fine, stopped, or broken?
|
|
10
|
+
*
|
|
11
|
+
* Cheap on purpose — file reads only, no subprocesses — because it runs on
|
|
12
|
+
* every `forge status`, every reminder hook, and once per row in
|
|
13
|
+
* `forge fleet list`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { e2ePath, e2eStepsHash, loadE2eResults } from './integrity.mjs';
|
|
19
|
+
import { readJson } from './lib.mjs';
|
|
20
|
+
|
|
21
|
+
/** Hours of no session write after which an unfinished session reads as stopped. */
|
|
22
|
+
export const DEFAULT_IDLE_HOURS = 4;
|
|
23
|
+
|
|
24
|
+
const SEVERITY = { done: 0, healthy: 1, stale: 2, red: 3 };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {number} ms
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
function humanDuration(ms) {
|
|
31
|
+
const min = Math.floor(ms / 60000);
|
|
32
|
+
if (min < 60) return `${Math.max(min, 1)}m`;
|
|
33
|
+
const hours = Math.floor(min / 60);
|
|
34
|
+
if (hours < 48) return `${hours}h`;
|
|
35
|
+
return `${Math.floor(hours / 24)}d`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The failing step of a red run, for a reason line that names the problem
|
|
40
|
+
* instead of pointing at a file.
|
|
41
|
+
*
|
|
42
|
+
* @param {Record<string, any> | null} results
|
|
43
|
+
*/
|
|
44
|
+
function failedStepName(results) {
|
|
45
|
+
if (!results || !Array.isArray(results.steps)) return null;
|
|
46
|
+
const failed = results.steps.find((s) => s && s.ok === false);
|
|
47
|
+
return failed?.name ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {{ cwd?: string, sessionDir: string, session: Record<string, any>, now?: number, idleHours?: number }} opts
|
|
52
|
+
* @returns {{ state: 'healthy'|'stale'|'red'|'done', reasons: string[], line: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function sessionHealth(opts) {
|
|
55
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
56
|
+
const { sessionDir, session } = opts;
|
|
57
|
+
const now = opts.now ?? Date.now();
|
|
58
|
+
const idleHours = Number.isFinite(opts.idleHours) ? Number(opts.idleHours) : DEFAULT_IDLE_HOURS;
|
|
59
|
+
|
|
60
|
+
/** @type {string[]} */
|
|
61
|
+
const reasons = [];
|
|
62
|
+
let state = 'healthy';
|
|
63
|
+
const escalate = (next) => {
|
|
64
|
+
if (SEVERITY[next] > SEVERITY[state]) state = next;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (session.phase === 'done' || session.phase === 'skipped') {
|
|
68
|
+
return {
|
|
69
|
+
state: 'done',
|
|
70
|
+
reasons: [],
|
|
71
|
+
line: `DONE — ${session.slug ?? session.id} (${session.phase})`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- executed product loop: the strongest signal we have on disk ---
|
|
76
|
+
try {
|
|
77
|
+
const results = loadE2eResults(sessionDir);
|
|
78
|
+
if (results) {
|
|
79
|
+
let currentHash = null;
|
|
80
|
+
try {
|
|
81
|
+
const doc = readJson(e2ePath({ cwd, session, sessionDir }));
|
|
82
|
+
currentHash = e2eStepsHash(doc.steps);
|
|
83
|
+
} catch {
|
|
84
|
+
currentHash = null;
|
|
85
|
+
}
|
|
86
|
+
// A failed run outranks a stale one: staleness asks "does this proof
|
|
87
|
+
// still describe the current steps", a failure says the loop is broken
|
|
88
|
+
// either way.
|
|
89
|
+
if (results.ok === false) {
|
|
90
|
+
const step = failedStepName(results);
|
|
91
|
+
const since = results.ranAt ? ` since ${results.ranAt}` : '';
|
|
92
|
+
reasons.push(`e2e failing${since}${step ? ` at step "${step}"` : ''}`);
|
|
93
|
+
escalate('red');
|
|
94
|
+
} else if (currentHash && results.stepsHash !== currentHash) {
|
|
95
|
+
reasons.push('e2e results are stale — e2e.json changed since the last run');
|
|
96
|
+
escalate('stale');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
/* health must never throw — a broken artifact is the caller's problem */
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- an explicit BLOCKED beats any inference we could make ---
|
|
104
|
+
try {
|
|
105
|
+
const verify = path.join(sessionDir, 'verify-evidence.md');
|
|
106
|
+
if (fs.existsSync(verify) && /\bBLOCKED\b/.test(fs.readFileSync(verify, 'utf8'))) {
|
|
107
|
+
reasons.push('verify-evidence records BLOCKED — product loop not proven');
|
|
108
|
+
escalate('red');
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
/* ignore */
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- idle: nobody is driving this session ---
|
|
115
|
+
const updatedAt = new Date(session.updatedAt ?? session.createdAt ?? NaN).getTime();
|
|
116
|
+
if (!Number.isNaN(updatedAt)) {
|
|
117
|
+
const idleMs = now - updatedAt;
|
|
118
|
+
if (idleMs > idleHours * 3600 * 1000) {
|
|
119
|
+
const where = `${session.phase ?? 'unknown'}${
|
|
120
|
+
Number(session.tasksTotal) > 0 ? ` ${session.tasksComplete ?? 0}/${session.tasksTotal}` : ''
|
|
121
|
+
}`;
|
|
122
|
+
reasons.push(`idle ${humanDuration(idleMs)} at ${where}`);
|
|
123
|
+
escalate('stale');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const label = state.toUpperCase();
|
|
128
|
+
const line = reasons.length ? `${label} — ${reasons.join('; ')}` : `${label} — ${session.phase}`;
|
|
129
|
+
return { state, reasons, line };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Short cell for table renderers (`forge fleet list`). */
|
|
133
|
+
export function healthCell(state) {
|
|
134
|
+
switch (state) {
|
|
135
|
+
case 'red':
|
|
136
|
+
return 'RED';
|
|
137
|
+
case 'stale':
|
|
138
|
+
return 'STALE';
|
|
139
|
+
case 'done':
|
|
140
|
+
return 'done';
|
|
141
|
+
default:
|
|
142
|
+
return 'ok';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
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 { DEFAULT_IDLE_HOURS, sessionHealth } from './health.mjs';
|
|
7
|
+
import { e2eStepsHash } from './integrity.mjs';
|
|
8
|
+
|
|
9
|
+
function tmp(prefix) {
|
|
10
|
+
return fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), prefix)));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const HOUR = 3600 * 1000;
|
|
14
|
+
const NOW = new Date('2026-07-25T12:00:00.000Z').getTime();
|
|
15
|
+
const ago = (hours) => new Date(NOW - hours * HOUR).toISOString();
|
|
16
|
+
|
|
17
|
+
/** Project + session dir; session.json is passed separately. */
|
|
18
|
+
function makeProject(overrides = {}) {
|
|
19
|
+
const cwd = tmp('forge-health-');
|
|
20
|
+
const sessionDir = path.join(cwd, '.forge', 'sessions', 's1');
|
|
21
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
22
|
+
const session = {
|
|
23
|
+
id: 's1',
|
|
24
|
+
slug: 'phase-1',
|
|
25
|
+
phase: 'implement',
|
|
26
|
+
planType: 'specs',
|
|
27
|
+
openspecChange: 'phase-1',
|
|
28
|
+
tasksTotal: 32,
|
|
29
|
+
tasksComplete: 27,
|
|
30
|
+
createdAt: ago(30),
|
|
31
|
+
updatedAt: ago(1),
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
return { cwd, sessionDir, session };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** e2e.json in the change dir + a results file in the session dir. */
|
|
38
|
+
function writeE2e({ cwd, sessionDir }, { ok, stale = false, ranAt = ago(2) } = {}) {
|
|
39
|
+
const changeDir = path.join(cwd, 'specs', 'changes', 'phase-1');
|
|
40
|
+
fs.mkdirSync(changeDir, { recursive: true });
|
|
41
|
+
const steps = [{ name: 'bench-check-enforces-the-budget-gate', cmd: 'true' }];
|
|
42
|
+
fs.writeFileSync(path.join(changeDir, 'e2e.json'), `${JSON.stringify({ steps })}\n`, 'utf8');
|
|
43
|
+
// stepsHash is computed by integrity.e2eStepsHash; a wrong hash is what a
|
|
44
|
+
// post-run edit of e2e.json looks like.
|
|
45
|
+
const results = {
|
|
46
|
+
ok,
|
|
47
|
+
ranAt,
|
|
48
|
+
stepsHash: stale ? 'deadbeef' : e2eStepsHash(steps),
|
|
49
|
+
steps: [{ name: 'bench-check-enforces-the-budget-gate', ok, exitCode: ok ? 0 : 1 }],
|
|
50
|
+
};
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
path.join(sessionDir, 'e2e-results.json'),
|
|
53
|
+
`${JSON.stringify(results)}\n`,
|
|
54
|
+
'utf8',
|
|
55
|
+
);
|
|
56
|
+
return changeDir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('a session that ran recently and has no failing proof is healthy', () => {
|
|
60
|
+
const p = makeProject();
|
|
61
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
62
|
+
assert.equal(health.state, 'healthy');
|
|
63
|
+
assert.deepEqual(health.reasons, []);
|
|
64
|
+
assert.match(health.line, /^HEALTHY/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a failing e2e run is RED and names the failing step', () => {
|
|
68
|
+
// helm phase-1: 27/32 with e2e red on the bench budget gate, and nothing
|
|
69
|
+
// anywhere said so.
|
|
70
|
+
const p = makeProject();
|
|
71
|
+
writeE2e(p, { ok: false });
|
|
72
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
73
|
+
|
|
74
|
+
assert.equal(health.state, 'red');
|
|
75
|
+
assert.equal(health.reasons.length, 1);
|
|
76
|
+
assert.match(health.reasons[0], /e2e failing/);
|
|
77
|
+
assert.match(health.reasons[0], /bench-check-enforces-the-budget-gate/);
|
|
78
|
+
assert.match(health.line, /^RED —/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('e2e results that no longer match e2e.json are stale, not green', () => {
|
|
82
|
+
const p = makeProject();
|
|
83
|
+
writeE2e(p, { ok: true, stale: true });
|
|
84
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
85
|
+
|
|
86
|
+
assert.equal(health.state, 'stale');
|
|
87
|
+
assert.match(health.reasons.join(' '), /e2e results.*stale|stale.*e2e/i);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a green current e2e run keeps the session healthy', () => {
|
|
91
|
+
const p = makeProject();
|
|
92
|
+
writeE2e(p, { ok: true });
|
|
93
|
+
assert.equal(sessionHealth({ ...p, now: NOW }).state, 'healthy');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an idle session is STALE and says where it stopped', () => {
|
|
97
|
+
const p = makeProject({ updatedAt: ago(14) });
|
|
98
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
99
|
+
|
|
100
|
+
assert.equal(health.state, 'stale');
|
|
101
|
+
assert.match(health.reasons[0], /idle 14h/);
|
|
102
|
+
assert.match(health.reasons[0], /implement 27\/32/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('idle threshold is configurable and defaults to DEFAULT_IDLE_HOURS', () => {
|
|
106
|
+
const p = makeProject({ updatedAt: ago(DEFAULT_IDLE_HOURS + 1) });
|
|
107
|
+
assert.equal(sessionHealth({ ...p, now: NOW }).state, 'stale');
|
|
108
|
+
assert.equal(sessionHealth({ ...p, now: NOW, idleHours: 48 }).state, 'healthy');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('BLOCKED verify evidence is RED', () => {
|
|
112
|
+
const p = makeProject({ phase: 'verify' });
|
|
113
|
+
fs.writeFileSync(
|
|
114
|
+
path.join(p.sessionDir, 'verify-evidence.md'),
|
|
115
|
+
'# Verify\n\n## Product loop\n\nBLOCKED — the queue worker has no runtime owner yet.\n',
|
|
116
|
+
'utf8',
|
|
117
|
+
);
|
|
118
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
119
|
+
assert.equal(health.state, 'red');
|
|
120
|
+
assert.match(health.reasons.join(' '), /BLOCKED/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('a finished session is neither stale nor red', () => {
|
|
124
|
+
// Idle for a month and carrying an old failing run: done is done.
|
|
125
|
+
const p = makeProject({ phase: 'done', updatedAt: ago(720), tasksComplete: 32 });
|
|
126
|
+
writeE2e(p, { ok: false });
|
|
127
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
128
|
+
assert.equal(health.state, 'done');
|
|
129
|
+
assert.match(health.line, /^DONE/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('red outranks stale when both fire', () => {
|
|
133
|
+
const p = makeProject({ updatedAt: ago(14) });
|
|
134
|
+
writeE2e(p, { ok: false });
|
|
135
|
+
const health = sessionHealth({ ...p, now: NOW });
|
|
136
|
+
assert.equal(health.state, 'red');
|
|
137
|
+
assert.equal(health.reasons.length, 2, 'both reasons are reported, severity picks the state');
|
|
138
|
+
});
|
package/src/integrity.mjs
CHANGED
|
@@ -112,9 +112,15 @@ export function resolveChangeDir(opts = {}) {
|
|
|
112
112
|
return forWrite ? openspecDir : liveOrArchived(openspecChanges, change);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// Only a *specs* engine resolution can name the specs dir. The engine
|
|
116
|
+
// resolver's last resort is {engine:'openspec', dir:'openspec'}, so taking
|
|
117
|
+
// `.dir` unconditionally pointed specs sessions at `openspec/changes/<name>`
|
|
118
|
+
// in every project whose .forge/config.json has no `plan` block — the change
|
|
119
|
+
// dir, its brief.html, spine.json and e2e.json all silently missing.
|
|
115
120
|
let specsRoot = DEFAULT_SPECS_DIR;
|
|
116
121
|
try {
|
|
117
|
-
|
|
122
|
+
const engine = resolveProjectPlanEngine(cwd, { useUserDefault: false });
|
|
123
|
+
if (engine.engine === 'specs') specsRoot = engine.dir;
|
|
118
124
|
} catch {
|
|
119
125
|
// keep default
|
|
120
126
|
}
|
package/src/integrity.test.mjs
CHANGED
|
@@ -146,6 +146,52 @@ test('resolveChangeDir: falls back to the archived copy after archive', () => {
|
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
test('resolveChangeDir: a specs session never falls back to the openspec dir', () => {
|
|
150
|
+
// Regression: resolveProjectPlanEngine's last-resort default is
|
|
151
|
+
// {engine:'openspec', dir:'openspec'}, so a specs-engine session in a
|
|
152
|
+
// project whose .forge/config.json has no `plan` block (ADR-only config,
|
|
153
|
+
// pre-engine config, hand-written) resolved into openspec/changes/<name> —
|
|
154
|
+
// which made `forge phase implement` refuse a brief that was right there.
|
|
155
|
+
const cwd = tmp('forge-specs-dir-');
|
|
156
|
+
try {
|
|
157
|
+
const session = { planType: 'specs', openspecChange: 'my-change' };
|
|
158
|
+
fs.mkdirSync(path.join(cwd, '.forge'), { recursive: true });
|
|
159
|
+
fs.writeFileSync(
|
|
160
|
+
path.join(cwd, '.forge', 'config.json'),
|
|
161
|
+
`${JSON.stringify({ adr: { enabled: true, dir: 'docs/adr' } })}\n`,
|
|
162
|
+
'utf8',
|
|
163
|
+
);
|
|
164
|
+
const specsChange = path.join(cwd, 'specs', 'changes', 'my-change');
|
|
165
|
+
fs.mkdirSync(specsChange, { recursive: true });
|
|
166
|
+
|
|
167
|
+
assert.equal(resolveChangeDir({ cwd, session }), specsChange);
|
|
168
|
+
assert.equal(resolveChangeDir({ cwd, session, forWrite: true }), specsChange);
|
|
169
|
+
} finally {
|
|
170
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('resolveChangeDir: specs engine honors a configured plan.dir', () => {
|
|
175
|
+
// `forge init --no-openspec --plan-dir openspec` — specs engine reusing an
|
|
176
|
+
// existing OpenSpec tree without moving files.
|
|
177
|
+
const cwd = tmp('forge-specs-plandir-');
|
|
178
|
+
try {
|
|
179
|
+
const session = { planType: 'specs', openspecChange: 'my-change' };
|
|
180
|
+
fs.mkdirSync(path.join(cwd, '.forge'), { recursive: true });
|
|
181
|
+
fs.writeFileSync(
|
|
182
|
+
path.join(cwd, '.forge', 'config.json'),
|
|
183
|
+
`${JSON.stringify({ plan: { engine: 'specs', dir: 'openspec' } })}\n`,
|
|
184
|
+
'utf8',
|
|
185
|
+
);
|
|
186
|
+
const changeDir = path.join(cwd, 'openspec', 'changes', 'my-change');
|
|
187
|
+
fs.mkdirSync(changeDir, { recursive: true });
|
|
188
|
+
|
|
189
|
+
assert.equal(resolveChangeDir({ cwd, session }), changeDir);
|
|
190
|
+
} finally {
|
|
191
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
149
195
|
test('spinePath/e2ePath forWrite target the live dir even after archive', () => {
|
|
150
196
|
const cwd = tmp('forge-write-guard-');
|
|
151
197
|
try {
|
package/src/lib/fleet.mjs
CHANGED
|
@@ -134,10 +134,59 @@ export function unregisterSession(projectRoot, sessionId) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
* Fields the registry mirrors from session.json. `session.json` is the source
|
|
138
|
+
* of truth; the entry is only a cache, so reads refresh it.
|
|
139
|
+
*/
|
|
140
|
+
const MIRRORED_FIELDS = [
|
|
141
|
+
'slug',
|
|
142
|
+
'phase',
|
|
143
|
+
'planType',
|
|
144
|
+
'openspecChange',
|
|
145
|
+
'tasksTotal',
|
|
146
|
+
'tasksComplete',
|
|
147
|
+
'createdAt',
|
|
148
|
+
'updatedAt',
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Refresh a cached entry from the session on disk. Returns true when anything
|
|
153
|
+
* changed. A registry write that never happened (older CLI, a crash mid-run,
|
|
154
|
+
* a hand-edited record) otherwise pins the entry at whatever phase it was
|
|
155
|
+
* first registered with, forever.
|
|
156
|
+
*
|
|
157
|
+
* @param {Record<string, any>} entry
|
|
158
|
+
* @param {string} sessionDir
|
|
159
|
+
*/
|
|
160
|
+
function reconcileEntry(entry, sessionDir) {
|
|
161
|
+
let session;
|
|
162
|
+
try {
|
|
163
|
+
session = JSON.parse(fs.readFileSync(path.join(sessionDir, 'session.json'), 'utf8'));
|
|
164
|
+
} catch {
|
|
165
|
+
return false; // unreadable/absent — keep the cached view
|
|
166
|
+
}
|
|
167
|
+
let changed = false;
|
|
168
|
+
for (const key of MIRRORED_FIELDS) {
|
|
169
|
+
const value = session[key];
|
|
170
|
+
if (value === undefined) continue;
|
|
171
|
+
if (entry[key] !== value) {
|
|
172
|
+
entry[key] = value;
|
|
173
|
+
changed = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const pace = session.resolvedPace ?? session.pace ?? null;
|
|
177
|
+
if (pace !== null && entry.pace !== pace) {
|
|
178
|
+
entry.pace = pace;
|
|
179
|
+
changed = true;
|
|
180
|
+
}
|
|
181
|
+
return changed;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* All registry entries, newest first. Self-heals: entries are reconciled
|
|
186
|
+
* against each session.json on disk; entries whose session dir vanished
|
|
187
|
+
* (cleanup ran without unregister, project deleted the .forge dir) are
|
|
188
|
+
* removed; entries whose whole project path is unreachable (unplugged drive)
|
|
189
|
+
* are kept and marked `missing`.
|
|
141
190
|
*
|
|
142
191
|
* @returns {Array<Record<string, any>>}
|
|
143
192
|
*/
|
|
@@ -156,6 +205,14 @@ export function listFleet() {
|
|
|
156
205
|
}
|
|
157
206
|
const sessionDir = path.join(entry.project, '.forge', 'sessions', entry.sessionId);
|
|
158
207
|
if (fs.existsSync(sessionDir)) {
|
|
208
|
+
// Persist before stamping `missing`, which is a view flag, not state.
|
|
209
|
+
if (reconcileEntry(entry, sessionDir)) {
|
|
210
|
+
try {
|
|
211
|
+
fs.writeFileSync(file, `${JSON.stringify(entry, null, 2)}\n`, 'utf8');
|
|
212
|
+
} catch {
|
|
213
|
+
/* registry is advisory — a read-only registry still renders */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
159
216
|
entry.missing = false;
|
|
160
217
|
} else if (fs.existsSync(entry.project)) {
|
|
161
218
|
fs.rmSync(file, { force: true });
|
package/src/lib.mjs
CHANGED
|
@@ -7,8 +7,11 @@ import crypto from 'node:crypto';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { registerSession } from './lib/fleet.mjs';
|
|
10
|
+
import { findRepoRoot } from './repo-root.mjs';
|
|
10
11
|
|
|
11
|
-
export
|
|
12
|
+
export { findRepoRoot };
|
|
13
|
+
|
|
14
|
+
export const REPO_ROOT = findRepoRoot();
|
|
12
15
|
export const FORGE_DIR = path.join(REPO_ROOT, '.forge');
|
|
13
16
|
export const SESSIONS_DIR = path.join(FORGE_DIR, 'sessions');
|
|
14
17
|
export const ACTIVE_FILE = path.join(FORGE_DIR, 'active.json');
|
|
@@ -137,7 +140,21 @@ export function saveSession(dir, session) {
|
|
|
137
140
|
registerSession(path.resolve(dir, '..', '..', '..'), session);
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Age of a session in days, for retention. Legacy and hand-written records
|
|
145
|
+
* carry `startedAt` (or nothing) instead of `createdAt`; an undatable record
|
|
146
|
+
* counts as **infinitely old** rather than brand new — `new Date(undefined)`
|
|
147
|
+
* is NaN and `NaN > RETENTION_DAYS` is false, which let abandoned sessions
|
|
148
|
+
* survive every cleanup run forever.
|
|
149
|
+
*
|
|
150
|
+
* @param {Record<string, any>} session
|
|
151
|
+
* @returns {number} days, or Infinity when no date can be read
|
|
152
|
+
*/
|
|
140
153
|
export function sessionAgeDays(session) {
|
|
141
|
-
const
|
|
142
|
-
|
|
154
|
+
for (const value of [session?.createdAt, session?.startedAt, session?.updatedAt]) {
|
|
155
|
+
if (!value) continue;
|
|
156
|
+
const at = new Date(value).getTime();
|
|
157
|
+
if (!Number.isNaN(at)) return (Date.now() - at) / (1000 * 60 * 60 * 24);
|
|
158
|
+
}
|
|
159
|
+
return Infinity;
|
|
143
160
|
}
|