@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/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
 
@@ -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
- specsRoot = resolveProjectPlanEngine(cwd, { useUserDefault: false }).dir;
122
+ const engine = resolveProjectPlanEngine(cwd, { useUserDefault: false });
123
+ if (engine.engine === 'specs') specsRoot = engine.dir;
118
124
  } catch {
119
125
  // keep default
120
126
  }
@@ -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
- * All registry entries, newest first. Self-heals: entries whose session dir
138
- * vanished (cleanup ran without unregister, project deleted the .forge dir)
139
- * are removed; entries whose whole project path is unreachable (unplugged
140
- * drive) are kept and marked `missing`.
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 const REPO_ROOT = process.cwd();
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 created = new Date(session.createdAt).getTime();
142
- return (Date.now() - created) / (1000 * 60 * 60 * 24);
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
  }