@izkac/forgekit 0.3.14 → 0.3.16

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.
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `forge finding` — file, list and close findings (see findings.mjs).
4
+ *
5
+ * Usage:
6
+ * forge finding add "<text>" [--change <slug>] [--severity blocker|major|minor|note]
7
+ * forge finding list [--json] [--all]
8
+ * forge finding resolve <id> [--note "<text>"]
9
+ */
10
+
11
+ import { FORGE_DIR, loadSession, readActive } from './lib.mjs';
12
+ import { addFinding, readFindings, resolveFinding } from './findings.mjs';
13
+
14
+ function usage() {
15
+ process.stderr.write(
16
+ `Usage:
17
+ forge finding add "<text>" [--change <slug>] [--severity blocker|major|minor|note]
18
+ forge finding list [--json] [--all]
19
+ forge finding resolve <id> [--note "<text>"]
20
+ `,
21
+ );
22
+ }
23
+
24
+ function fail(message) {
25
+ process.stderr.write(`${message}\n`);
26
+ process.exit(1);
27
+ }
28
+
29
+ function emit(payload) {
30
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
31
+ }
32
+
33
+ /** Active session, or nulls — findings are often filed between sessions. */
34
+ function activeSessionInfo() {
35
+ try {
36
+ const active = readActive();
37
+ if (!active?.sessionId) return { sessionId: null, slug: null };
38
+ const { session } = loadSession(active.sessionId);
39
+ return { sessionId: session.id ?? null, slug: session.slug ?? null };
40
+ } catch {
41
+ return { sessionId: null, slug: null };
42
+ }
43
+ }
44
+
45
+ const argv = process.argv.slice(2);
46
+ const cmd = argv[0];
47
+ if (!cmd || cmd === '--help' || cmd === '-h') {
48
+ usage();
49
+ process.exit(cmd ? 0 : 1);
50
+ }
51
+
52
+ if (cmd === 'add') {
53
+ const text = argv[1] ?? '';
54
+ if (!text.trim() || text.startsWith('--')) {
55
+ fail('A finding needs text: forge finding add "<text>"');
56
+ }
57
+ let change = null;
58
+ let severity = 'major';
59
+ for (let i = 2; i < argv.length; i += 1) {
60
+ if (argv[i] === '--change' && argv[i + 1]) change = argv[(i += 1)];
61
+ else if (argv[i] === '--severity' && argv[i + 1]) severity = argv[(i += 1)];
62
+ else fail(`Unknown argument: ${argv[i]}`);
63
+ }
64
+ try {
65
+ const entry = addFinding({
66
+ forgeDir: FORGE_DIR,
67
+ text,
68
+ severity,
69
+ change,
70
+ session: activeSessionInfo(),
71
+ });
72
+ emit({
73
+ ok: true,
74
+ ...entry,
75
+ next: change
76
+ ? `Open its home: forge change new ${change}`
77
+ : 'Give it a home: forge change new <slug>, forge defer add, or fix it now — otherwise label it a note (--severity note).',
78
+ });
79
+ } catch (err) {
80
+ fail(err instanceof Error ? err.message : String(err));
81
+ }
82
+ process.exit(0);
83
+ }
84
+
85
+ if (cmd === 'list') {
86
+ const all = argv.includes('--all');
87
+ const entries = readFindings(FORGE_DIR);
88
+ const open = entries.filter((e) => e.status === 'open');
89
+ const resolved = entries.filter((e) => e.status !== 'open');
90
+ if (argv.includes('--json')) {
91
+ emit({ open, resolved: all ? resolved : resolved.slice(-10), total: entries.length });
92
+ process.exit(0);
93
+ }
94
+ if (entries.length === 0) {
95
+ process.stdout.write('No findings recorded. File one: forge finding add "<text>"\n');
96
+ process.exit(0);
97
+ }
98
+ const rows = (all ? entries : open).map(
99
+ (e) =>
100
+ `${e.id} ${String(e.severity ?? 'major').padEnd(7)} ${e.status === 'open' ? ' ' : '✓'} ${e.text}${
101
+ e.change ? ` → ${e.change}` : ''
102
+ }`,
103
+ );
104
+ process.stdout.write(
105
+ `${rows.join('\n')}\n\n${open.length} open, ${resolved.length} resolved${
106
+ all ? '' : ' (--all to see resolved)'
107
+ }\n`,
108
+ );
109
+ process.exit(0);
110
+ }
111
+
112
+ if (cmd === 'resolve') {
113
+ const id = argv[1];
114
+ if (!id) fail('Usage: forge finding resolve <id> [--note "<text>"]');
115
+ let note = null;
116
+ for (let i = 2; i < argv.length; i += 1) {
117
+ if (argv[i] === '--note' && argv[i + 1]) note = argv[(i += 1)];
118
+ else fail(`Unknown argument: ${argv[i]}`);
119
+ }
120
+ try {
121
+ emit({ ok: true, ...resolveFinding({ forgeDir: FORGE_DIR, id, note }) });
122
+ } catch (err) {
123
+ fail(err instanceof Error ? err.message : String(err));
124
+ }
125
+ process.exit(0);
126
+ }
127
+
128
+ usage();
129
+ fail(`Unknown subcommand: ${cmd}`);
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Findings ledger — give an observation a home the day it is written.
4
+ *
5
+ * Analysis reports kept re-listing the same unactioned items ("untick 6.2",
6
+ * "the e2e parallel race", "grouping.ts D1 extraction") because nothing turned
7
+ * a report line into tracked work: prose in a gitignored report is not a
8
+ * queue. A finding either becomes a change, a deferral, or an edit made on the
9
+ * spot — or it is a note, and should be labelled one.
10
+ *
11
+ * Library half (`findings-cli.mjs` is the command). Ledger:
12
+ * `.forge/findings.jsonl`, durable across session cleanup.
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { readLedger } from './ledger.mjs';
18
+
19
+ export const SEVERITIES = ['blocker', 'major', 'minor', 'note'];
20
+
21
+ /** @param {string} forgeDir */
22
+ export function findingsPath(forgeDir) {
23
+ return path.join(forgeDir, 'findings.jsonl');
24
+ }
25
+
26
+ /** @param {string} forgeDir */
27
+ export function readFindings(forgeDir) {
28
+ return readLedger(findingsPath(forgeDir));
29
+ }
30
+
31
+ /** @param {string} forgeDir */
32
+ export function openFindings(forgeDir) {
33
+ return readFindings(forgeDir).filter((f) => f && f.status === 'open');
34
+ }
35
+
36
+ /**
37
+ * Next id: F1, F2, … Stable across resolves so a report can cite one.
38
+ * @param {Record<string, any>[]} entries
39
+ */
40
+ export function nextFindingId(entries) {
41
+ let max = 0;
42
+ for (const e of entries) {
43
+ const m = /^F(\d+)$/.exec(String(e?.id ?? ''));
44
+ if (m) max = Math.max(max, Number(m[1]));
45
+ }
46
+ return `F${max + 1}`;
47
+ }
48
+
49
+ /**
50
+ * @param {string} forgeDir
51
+ * @param {Record<string, any>[]} entries
52
+ */
53
+ function writeAll(forgeDir, entries) {
54
+ const file = findingsPath(forgeDir);
55
+ fs.mkdirSync(path.dirname(file), { recursive: true });
56
+ fs.writeFileSync(file, `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`, 'utf8');
57
+ }
58
+
59
+ /**
60
+ * @param {{ forgeDir: string, text: string, severity?: string, change?: string | null,
61
+ * session?: { sessionId?: string | null, slug?: string | null }, now?: () => Date }} opts
62
+ */
63
+ export function addFinding(opts) {
64
+ const text = String(opts.text ?? '').trim();
65
+ if (!text) throw new Error('A finding needs text: forge finding add "<text>"');
66
+ const severity = opts.severity ?? 'major';
67
+ if (!SEVERITIES.includes(severity)) {
68
+ throw new Error(`Unknown severity "${severity}" (expected ${SEVERITIES.join(' | ')}).`);
69
+ }
70
+ const entries = readFindings(opts.forgeDir);
71
+ const entry = {
72
+ id: nextFindingId(entries),
73
+ text,
74
+ severity,
75
+ status: 'open',
76
+ change: opts.change ?? null,
77
+ sessionId: opts.session?.sessionId ?? null,
78
+ slug: opts.session?.slug ?? null,
79
+ createdAt: (opts.now?.() ?? new Date()).toISOString(),
80
+ };
81
+ writeAll(opts.forgeDir, [...entries, entry]);
82
+ return entry;
83
+ }
84
+
85
+ /**
86
+ * @param {{ forgeDir: string, id: string, note?: string | null, now?: () => Date }} opts
87
+ */
88
+ export function resolveFinding(opts) {
89
+ const entries = readFindings(opts.forgeDir);
90
+ const idx = entries.findIndex((e) => e.id === opts.id);
91
+ if (idx < 0) throw new Error(`No finding with id ${opts.id}. See: forge finding list --all`);
92
+ if (entries[idx].status !== 'open') {
93
+ throw new Error(`Finding ${opts.id} is already ${entries[idx].status}.`);
94
+ }
95
+ entries[idx] = {
96
+ ...entries[idx],
97
+ status: 'resolved',
98
+ note: opts.note ?? entries[idx].note ?? null,
99
+ resolvedAt: (opts.now?.() ?? new Date()).toISOString(),
100
+ };
101
+ writeAll(opts.forgeDir, entries);
102
+ return entries[idx];
103
+ }
@@ -0,0 +1,140 @@
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 FINDINGS = path.join(path.dirname(fileURLToPath(import.meta.url)), 'findings-cli.mjs');
10
+
11
+ function tmp(prefix) {
12
+ return fs.realpathSync(fs.mkdtempSync(path.join(tmpdir(), prefix)));
13
+ }
14
+
15
+ function makeProject() {
16
+ const cwd = tmp('forge-finding-');
17
+ const sessionDir = path.join(cwd, '.forge', 'sessions', 's1');
18
+ fs.mkdirSync(sessionDir, { recursive: true });
19
+ fs.writeFileSync(
20
+ path.join(sessionDir, 'session.json'),
21
+ `${JSON.stringify({
22
+ id: 's1',
23
+ slug: 'phase-1',
24
+ openspecChange: 'phase-1',
25
+ phase: 'verify',
26
+ createdAt: new Date().toISOString(),
27
+ updatedAt: new Date().toISOString(),
28
+ })}\n`,
29
+ 'utf8',
30
+ );
31
+ fs.writeFileSync(
32
+ path.join(cwd, '.forge', 'active.json'),
33
+ `${JSON.stringify({ sessionId: 's1' })}\n`,
34
+ 'utf8',
35
+ );
36
+ return cwd;
37
+ }
38
+
39
+ function run(cwd, args) {
40
+ const env = { ...process.env, FORGEKIT_FLEET_DIR: path.join(tmp('finding-fleet-'), 's') };
41
+ try {
42
+ const stdout = execFileSync(process.execPath, [FINDINGS, ...args], { cwd, env });
43
+ const text = stdout.toString();
44
+ try {
45
+ return { status: 0, out: JSON.parse(text), text };
46
+ } catch {
47
+ return { status: 0, out: null, text };
48
+ }
49
+ } catch (err) {
50
+ return { status: err.status, out: null, text: String(err.stdout), stderr: String(err.stderr) };
51
+ }
52
+ }
53
+
54
+ test('a finding lands in a durable ledger with the session that raised it', () => {
55
+ // "A finding either gets a home the day it is written, or it is not a
56
+ // finding — it is a note." Three analysis reports in a row carried the same
57
+ // unactioned items because nothing converted a line into tracked work.
58
+ const cwd = makeProject();
59
+ const { status, out } = run(cwd, [
60
+ 'add',
61
+ 'smoke suite pinned --workers=1 in 6/6 changes; the shared-state race is never fixed',
62
+ ]);
63
+
64
+ assert.equal(status, 0);
65
+ assert.equal(out.ok, true);
66
+ assert.match(out.id, /^F\d+$/);
67
+
68
+ const ledger = path.join(cwd, '.forge', 'findings.jsonl');
69
+ const [entry] = fs
70
+ .readFileSync(ledger, 'utf8')
71
+ .split('\n')
72
+ .filter(Boolean)
73
+ .map((l) => JSON.parse(l));
74
+ assert.equal(entry.status, 'open');
75
+ assert.equal(entry.sessionId, 's1');
76
+ assert.match(entry.text, /workers=1/);
77
+ });
78
+
79
+ test('a finding can name the change that will carry it', () => {
80
+ const cwd = makeProject();
81
+ const { out } = run(cwd, ['add', 'grouping.ts D1 extraction', '--change', 'fix-grouping-d1']);
82
+ assert.equal(out.change, 'fix-grouping-d1');
83
+ // The command tells you how to open that change rather than creating it
84
+ // behind your back.
85
+ assert.match(out.next, /forge change new fix-grouping-d1/);
86
+ });
87
+
88
+ test('list shows open findings and resolve closes one by id', () => {
89
+ const cwd = makeProject();
90
+ const first = run(cwd, ['add', 'server suite baseline is 1-4 varying failures']);
91
+ run(cwd, ['add', 'untick 6.2 in the archived tasks.md']);
92
+
93
+ const open = run(cwd, ['list', '--json']);
94
+ assert.equal(open.out.open.length, 2);
95
+
96
+ const resolved = run(cwd, ['resolve', first.out.id, '--note', 'quarantined the flaky names']);
97
+ assert.equal(resolved.status, 0);
98
+ assert.equal(resolved.out.status, 'resolved');
99
+
100
+ const after = run(cwd, ['list', '--json']);
101
+ assert.equal(after.out.open.length, 1);
102
+ assert.equal(after.out.resolved.length, 1);
103
+ assert.match(after.out.resolved[0].note, /quarantined/);
104
+ });
105
+
106
+ test('resolving an unknown id fails loudly instead of silently succeeding', () => {
107
+ const cwd = makeProject();
108
+ const { status, stderr } = run(cwd, ['resolve', 'F999']);
109
+ assert.equal(status, 1);
110
+ assert.match(stderr, /F999/);
111
+ });
112
+
113
+ test('add refuses an empty finding', () => {
114
+ const cwd = makeProject();
115
+ const { status, stderr } = run(cwd, ['add', ' ']);
116
+ assert.equal(status, 1);
117
+ assert.match(stderr, /text/i);
118
+ });
119
+
120
+ test('findings work without an active session', () => {
121
+ // Reports are often written between sessions; that must not block filing.
122
+ const cwd = tmp('forge-finding-nosession-');
123
+ fs.mkdirSync(path.join(cwd, '.forge'), { recursive: true });
124
+ const { status, out } = run(cwd, ['add', 'pace auto never selects brisk or lite']);
125
+ assert.equal(status, 0);
126
+ assert.equal(out.sessionId, null);
127
+ });
128
+
129
+ test('forge status reports open findings so they cannot be forgotten', () => {
130
+ const cwd = makeProject();
131
+ run(cwd, ['add', 'e2e parallel race unfixed']);
132
+ const statusScript = path.join(path.dirname(FINDINGS), 'session-status.mjs');
133
+ const stdout = execFileSync(process.execPath, [statusScript], {
134
+ cwd,
135
+ env: { ...process.env, FORGEKIT_FLEET_DIR: path.join(tmp('finding-fleet-'), 's') },
136
+ }).toString();
137
+ const status = JSON.parse(stdout);
138
+ assert.equal(status.openFindings.count, 1);
139
+ assert.match(status.openFindings.latest[0].text, /parallel race/);
140
+ });
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/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
+ }