@smartmemory/compose 0.2.7-beta → 0.2.9-beta
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/compose.js +112 -3
- package/contracts/gsd-state.json +140 -0
- package/contracts/gsd-stuck.json +141 -0
- package/dist/assets/{App-D3ehVPvi.js → App-CG-2euMe.js} +164 -164
- package/dist/assets/{arc-Dmf69iHG.js → arc-7QBWoLra.js} +1 -1
- package/dist/assets/{architectureDiagram-3BPJPVTR-xYo993Yw.js → architectureDiagram-3BPJPVTR-CUw-7uLm.js} +1 -1
- package/dist/assets/{blockDiagram-GPEHLZMM-UX4EF98O.js → blockDiagram-GPEHLZMM-COU1vmr7.js} +1 -1
- package/dist/assets/{c4Diagram-AAUBKEIU-DaP9CGWb.js → c4Diagram-AAUBKEIU-XPO9PSJL.js} +1 -1
- package/dist/assets/channel-Bcu04MIK.js +1 -0
- package/dist/assets/{chunk-2J33WTMH-CKk_RN3A.js → chunk-2J33WTMH-zMzVB2a6.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-DboAwYKw.js → chunk-4BX2VUAB-Kke_qcHU.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Dsy9RYvI.js → chunk-55IACEB6-hMeFx5Nh.js} +1 -1
- package/dist/assets/{chunk-727SXJPM-fAH0QO9v.js → chunk-727SXJPM-DesUnrEw.js} +1 -1
- package/dist/assets/{chunk-AQP2D5EJ-DyZYerFP.js → chunk-AQP2D5EJ-1uGGvkxW.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BnboGO5t.js → chunk-FMBD7UC4-DYHv1PcZ.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-Di9tYXme.js → chunk-ND2GUHAM-D0MENOLX.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-zRPRlAIL.js → chunk-QZHKN3VN-8nn3HP-N.js} +1 -1
- package/dist/assets/classDiagram-4FO5ZUOK-DU4yxldU.js +1 -0
- package/dist/assets/classDiagram-v2-Q7XG4LA2-DU4yxldU.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-C7Hqukaf.js → cose-bilkent-S5V4N54A-BoZPVIny.js} +1 -1
- package/dist/assets/{dagre-BM42HDAG-B-cR-BjI.js → dagre-BM42HDAG-BgZzdLG9.js} +1 -1
- package/dist/assets/{diagram-2AECGRRQ-B6-5onDk.js → diagram-2AECGRRQ-CknAnpSu.js} +1 -1
- package/dist/assets/{diagram-5GNKFQAL-DoZZgFAM.js → diagram-5GNKFQAL-CZUEbKim.js} +1 -1
- package/dist/assets/{diagram-KO2AKTUF-77jEGlJh.js → diagram-KO2AKTUF-DCs-pLdH.js} +1 -1
- package/dist/assets/{diagram-LMA3HP47-D3S7XDRD.js → diagram-LMA3HP47-lRaDjIfM.js} +1 -1
- package/dist/assets/{diagram-OG6HWLK6-KbYL9aCY.js → diagram-OG6HWLK6-CIGqmehP.js} +1 -1
- package/dist/assets/{erDiagram-TEJ5UH35-DezFbJP-.js → erDiagram-TEJ5UH35-Lx3c2N6F.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-4x31cK9j.js → flowDiagram-I6XJVG4X-VoluKqSq.js} +1 -1
- package/dist/assets/{ganttDiagram-6RSMTGT7-FopfSTyZ.js → ganttDiagram-6RSMTGT7-D7hETiNZ.js} +1 -1
- package/dist/assets/{gitGraphDiagram-PVQCEYII-DSiQGKbN.js → gitGraphDiagram-PVQCEYII-DenEcUvY.js} +1 -1
- package/dist/assets/{index-ClX6LVAf.js → index-B4dv3acY.js} +2 -2
- package/dist/assets/{infoDiagram-5YYISTIA-DE6BqzK_.js → infoDiagram-5YYISTIA-v7cq9Er9.js} +1 -1
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-Dml8NwQI.js → ishikawaDiagram-YF4QCWOH-CfCCXt2x.js} +1 -1
- package/dist/assets/{journeyDiagram-JHISSGLW-CwWeJgjE.js → journeyDiagram-JHISSGLW-Bbokl_xO.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-DnG956Wh.js → kanban-definition-UN3LZRKU-DhkOZ2hg.js} +1 -1
- package/dist/assets/{linear-CA3N7Rpi.js → linear-bHjluRm2.js} +1 -1
- package/dist/assets/{mindmap-definition-RKZ34NQL-CxfIOjLX.js → mindmap-definition-RKZ34NQL-C1bHpoXH.js} +1 -1
- package/dist/assets/{pieDiagram-4H26LBE5-O7aIwy1x.js → pieDiagram-4H26LBE5-CZb1i55T.js} +1 -1
- package/dist/assets/{quadrantDiagram-W4KKPZXB-CPQ2qq7c.js → quadrantDiagram-W4KKPZXB-o37AwRHB.js} +1 -1
- package/dist/assets/{requirementDiagram-4Y6WPE33-C23horL4.js → requirementDiagram-4Y6WPE33-BVErWDzU.js} +1 -1
- package/dist/assets/{sankeyDiagram-5OEKKPKP-DPY04kOW.js → sankeyDiagram-5OEKKPKP-BhBK8gHQ.js} +1 -1
- package/dist/assets/{sequenceDiagram-3UESZ5HK-BKaTfIvo.js → sequenceDiagram-3UESZ5HK-CsICF23P.js} +1 -1
- package/dist/assets/{stateDiagram-AJRCARHV-B9na_6mY.js → stateDiagram-AJRCARHV-TN1AXwim.js} +1 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-BLR6AkKX.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-BBWPqd7X.js → timeline-definition-PNZ67QCA-DftAajbU.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-tWqiHsOZ.js → vennDiagram-CIIHVFJN-cFTMstT7.js} +1 -1
- package/dist/assets/{wardley-L42UT6IY-DorxG6os.js → wardley-L42UT6IY-DL8CivzO.js} +1 -1
- package/dist/assets/{wardleyDiagram-YWT4CUSO-B49f8GzW.js → wardleyDiagram-YWT4CUSO-BDZT1hQj.js} +1 -1
- package/dist/assets/{xychartDiagram-2RQKCTM6-BgKSj8Qb.js → xychartDiagram-2RQKCTM6-DQQSkfC4.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/budget-ledger.js +84 -0
- package/lib/build-stream-schema.js +5 -3
- package/lib/build.js +122 -2
- package/lib/feature-validator.js +40 -8
- package/lib/gsd-budget.js +205 -0
- package/lib/gsd-diff-capture.js +34 -0
- package/lib/gsd-events.js +61 -0
- package/lib/gsd-headless-config.js +110 -0
- package/lib/gsd-milestone-report.js +323 -0
- package/lib/gsd-state.js +165 -0
- package/lib/gsd-stuck.js +275 -0
- package/lib/gsd-supervisor.js +223 -0
- package/lib/gsd-timing.js +89 -0
- package/lib/gsd.js +908 -16
- package/package.json +1 -1
- package/dist/assets/channel-D_RXsFFT.js +0 -1
- package/dist/assets/classDiagram-4FO5ZUOK-K6wdB4ic.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-K6wdB4ic.js +0 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-Cf84VDiH.js +0 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// lib/gsd-headless-config.js
|
|
2
|
+
//
|
|
3
|
+
// COMP-GSD-6 S05: headless auto-resume policy. Reads `gsd.headless.*` from
|
|
4
|
+
// .compose/compose.json and merges over conservative defaults. Every pause kind
|
|
5
|
+
// is independently overridable (per the product decision): crash and stuck
|
|
6
|
+
// auto-resume by default, budget does NOT (opting in would defeat the GSD-4
|
|
7
|
+
// ceiling), but a user MAY set budget.enabled:true for a fully-unattended burn.
|
|
8
|
+
//
|
|
9
|
+
// Unset config ⇒ defaults ⇒ behavior is a plain run plus supervision.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const HEADLESS_DEFAULTS = Object.freeze({
|
|
15
|
+
autoResume: {
|
|
16
|
+
crash: { enabled: true, maxAttempts: 5 },
|
|
17
|
+
stuck: { enabled: true, maxAttempts: 2 },
|
|
18
|
+
budget: { enabled: false, maxAttempts: 0 },
|
|
19
|
+
// COMP-GSD-6-WATCHDOG: a hung child (heartbeat frozen on a live pid) is
|
|
20
|
+
// killed + resumed like a crash. On by default — that's the whole point of
|
|
21
|
+
// unattended robustness.
|
|
22
|
+
hung: { enabled: true, maxAttempts: 3 },
|
|
23
|
+
},
|
|
24
|
+
backoff: { baseMs: 2000, factor: 2, maxMs: 60000 },
|
|
25
|
+
heartbeatStaleMs: 90000,
|
|
26
|
+
// COMP-GSD-6-WATCHDOG: supervisor poll cadence, SIGTERM→SIGKILL grace, and the
|
|
27
|
+
// child's independent wall-clock heartbeat cadence (must be < heartbeatStaleMs).
|
|
28
|
+
watchdogPollMs: 15000,
|
|
29
|
+
watchdogKillGraceMs: 5000,
|
|
30
|
+
watchdogHeartbeatMs: 30000,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function num(v, fallback) {
|
|
34
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback;
|
|
35
|
+
}
|
|
36
|
+
function bool(v, fallback) {
|
|
37
|
+
return typeof v === 'boolean' ? v : fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mergeKind(override, def) {
|
|
41
|
+
const o = override ?? {};
|
|
42
|
+
return {
|
|
43
|
+
enabled: bool(o.enabled, def.enabled),
|
|
44
|
+
maxAttempts: num(o.maxAttempts, def.maxAttempts),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Merge a raw `gsd.headless` object over HEADLESS_DEFAULTS with per-field type
|
|
49
|
+
// validation (a malformed field falls back to its default, never throws).
|
|
50
|
+
export function resolveHeadlessConfig(raw) {
|
|
51
|
+
const h = raw && typeof raw === 'object' ? raw : {};
|
|
52
|
+
const ar = h.autoResume && typeof h.autoResume === 'object' ? h.autoResume : {};
|
|
53
|
+
const bo = h.backoff && typeof h.backoff === 'object' ? h.backoff : {};
|
|
54
|
+
const d = HEADLESS_DEFAULTS;
|
|
55
|
+
|
|
56
|
+
// COMP-GSD-6-WATCHDOG: enforce the load-bearing invariant
|
|
57
|
+
// `watchdogHeartbeatMs < heartbeatStaleMs`. The child must restamp its heartbeat
|
|
58
|
+
// at least twice within the stale window, or a healthy quiet child trips the
|
|
59
|
+
// watchdog. Two guards:
|
|
60
|
+
// - a degenerate stale window (< 2ms, incl. 0) is unusable — there's no room
|
|
61
|
+
// for a smaller heartbeat — so it falls back to the default.
|
|
62
|
+
// - a heartbeat cadence ≥ the (now positive) stale window is clamped to half
|
|
63
|
+
// it (always strictly < stale for stale ≥ 2), rather than honored.
|
|
64
|
+
let heartbeatStaleMs = num(h.heartbeatStaleMs, d.heartbeatStaleMs);
|
|
65
|
+
if (heartbeatStaleMs < 2) heartbeatStaleMs = d.heartbeatStaleMs;
|
|
66
|
+
let watchdogHeartbeatMs = num(h.watchdogHeartbeatMs, d.watchdogHeartbeatMs);
|
|
67
|
+
if (watchdogHeartbeatMs >= heartbeatStaleMs) {
|
|
68
|
+
watchdogHeartbeatMs = Math.floor(heartbeatStaleMs / 2);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
autoResume: {
|
|
73
|
+
crash: mergeKind(ar.crash, d.autoResume.crash),
|
|
74
|
+
stuck: mergeKind(ar.stuck, d.autoResume.stuck),
|
|
75
|
+
budget: mergeKind(ar.budget, d.autoResume.budget),
|
|
76
|
+
hung: mergeKind(ar.hung, d.autoResume.hung),
|
|
77
|
+
},
|
|
78
|
+
backoff: {
|
|
79
|
+
baseMs: num(bo.baseMs, d.backoff.baseMs),
|
|
80
|
+
factor: num(bo.factor, d.backoff.factor),
|
|
81
|
+
maxMs: num(bo.maxMs, d.backoff.maxMs),
|
|
82
|
+
},
|
|
83
|
+
heartbeatStaleMs,
|
|
84
|
+
watchdogPollMs: num(h.watchdogPollMs, d.watchdogPollMs),
|
|
85
|
+
watchdogKillGraceMs: num(h.watchdogKillGraceMs, d.watchdogKillGraceMs),
|
|
86
|
+
watchdogHeartbeatMs,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Read .compose/compose.json gsd.headless.* and resolve against defaults.
|
|
91
|
+
export function readHeadlessConfig(cwd) {
|
|
92
|
+
const configPath = join(cwd, '.compose', 'compose.json');
|
|
93
|
+
let raw = {};
|
|
94
|
+
if (existsSync(configPath)) {
|
|
95
|
+
try {
|
|
96
|
+
const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
97
|
+
raw = cfg?.gsd?.headless ?? {};
|
|
98
|
+
} catch {
|
|
99
|
+
/* malformed config → defaults */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return resolveHeadlessConfig(raw);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Backoff for attempt N (1-based): base * factor^(N-1), capped at maxMs.
|
|
106
|
+
export function backoffMs(cfg, attempt) {
|
|
107
|
+
const { baseMs, factor, maxMs } = cfg.backoff;
|
|
108
|
+
const raw = baseMs * Math.pow(factor, Math.max(0, attempt - 1));
|
|
109
|
+
return Math.min(raw, maxMs);
|
|
110
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// lib/gsd-milestone-report.js
|
|
2
|
+
//
|
|
3
|
+
// COMP-GSD-7: milestone report generator. On GSD feature completion (and via
|
|
4
|
+
// `compose gsd report <feature>`), assemble a read-only data model from the
|
|
5
|
+
// persisted run artifacts and render a single self-contained HTML report to
|
|
6
|
+
// docs/gsd-reports/<feature>.html — auto-discovered by the cockpit DocsView.
|
|
7
|
+
//
|
|
8
|
+
// Data sources (all read-only):
|
|
9
|
+
// .compose/gsd/<f>/state.json run state + completedAt (gsd-state.js)
|
|
10
|
+
// .compose/gsd/<f>/blackboard.json per-task TaskResults (gsd-blackboard.js)
|
|
11
|
+
// .compose/gsd/<f>/timing.json per-task elapsed (gsd-timing.js sidecar)
|
|
12
|
+
// .compose/gsd/<f>/diffs/<id>.diff per-task diff snapshots (build.js capture)
|
|
13
|
+
// budget-final.json | budget.json budget actuals vs caps (gsd-budget shape)
|
|
14
|
+
//
|
|
15
|
+
// HTML shape mirrors server/graph-export.js: one template literal, inline CSS,
|
|
16
|
+
// no external assets. Atomic write mirrors gsd-state.js:44.
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { readGsdState } from './gsd-state.js';
|
|
21
|
+
import { read as readBlackboard } from './gsd-blackboard.js';
|
|
22
|
+
import { readTimingSidecar } from './gsd-timing.js';
|
|
23
|
+
import { gsdTaskDiffPath } from './gsd-diff-capture.js';
|
|
24
|
+
import { readGsdEvents } from './gsd-events.js';
|
|
25
|
+
|
|
26
|
+
const DIFF_INLINE_CAP_BYTES = 200 * 1024; // 200 KB per task
|
|
27
|
+
|
|
28
|
+
function gsdDir(cwd, featureCode) {
|
|
29
|
+
return join(cwd, '.compose', 'gsd', featureCode);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readJsonOrNull(p) {
|
|
33
|
+
if (!existsSync(p)) return null;
|
|
34
|
+
try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------- Model assembly ----------
|
|
38
|
+
|
|
39
|
+
function resolveBudget(cwd, featureCode, opts) {
|
|
40
|
+
// Precedence: in-process budget_state → budget-final.json → halt budget.json → none.
|
|
41
|
+
const fromOpts = opts?.budgetState;
|
|
42
|
+
const src = fromOpts
|
|
43
|
+
?? readJsonOrNull(join(gsdDir(cwd, featureCode), 'budget-final.json'))
|
|
44
|
+
?? readJsonOrNull(join(gsdDir(cwd, featureCode), 'budget.json'));
|
|
45
|
+
if (!src || (!src.caps && !src.consumed)) return { configured: false, caps: {}, consumed: {}, axis: null };
|
|
46
|
+
return { configured: true, caps: src.caps ?? {}, consumed: src.consumed ?? {}, axis: src.axis ?? null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readDiff(cwd, featureCode, taskId) {
|
|
50
|
+
const p = gsdTaskDiffPath(cwd, featureCode, taskId);
|
|
51
|
+
if (!existsSync(p)) return null;
|
|
52
|
+
try { return readFileSync(p, 'utf-8'); } catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// COMP-GSD-7-EVENTLOG: map a run-event to a timeline row. Unknown future kinds
|
|
56
|
+
// render their kind verbatim rather than dropping out.
|
|
57
|
+
function eventLabel(e) {
|
|
58
|
+
switch (e.kind) {
|
|
59
|
+
case 'run_started': return `Run started (${e.mode ?? 'fresh'})`;
|
|
60
|
+
case 'phase': return `Phase: ${e.phase ?? '?'}`;
|
|
61
|
+
case 'task_completed': return `Task completed: ${e.taskId ?? '?'}`;
|
|
62
|
+
case 'paused': return `Paused (${e.pauseKind ?? '?'})`;
|
|
63
|
+
case 'completed': return 'Run completed';
|
|
64
|
+
case 'failed': return `Run failed${e.reason ? `: ${e.reason}` : ''}`;
|
|
65
|
+
default: return String(e.kind ?? 'event');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildTimeline(state, cwd, featureCode) {
|
|
70
|
+
// COMP-GSD-7-EVENTLOG: prefer the real append-only event stream. Fall back to
|
|
71
|
+
// the snapshot-derived timeline only when there are ZERO usable events (a run
|
|
72
|
+
// that predates the log, or a truncated/torn/corrupt file) — never render an
|
|
73
|
+
// empty timeline because the file happens to exist.
|
|
74
|
+
const events = readGsdEvents(cwd, featureCode);
|
|
75
|
+
if (events.length > 0) {
|
|
76
|
+
return events.map((e) => ({ label: eventLabel(e), ts: e.ts ?? null }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const dir = gsdDir(cwd, featureCode);
|
|
80
|
+
const tl = [];
|
|
81
|
+
if (state.startedAt) tl.push({ label: 'Run started', ts: state.startedAt });
|
|
82
|
+
if (existsSync(join(dir, 'stuck.json'))) {
|
|
83
|
+
const s = readJsonOrNull(join(dir, 'stuck.json'));
|
|
84
|
+
tl.push({ label: `Stuck halt${s?.taskId ? ` (${s.taskId})` : ''}`, ts: s?.ts ?? null });
|
|
85
|
+
}
|
|
86
|
+
if (existsSync(join(dir, 'budget.json'))) {
|
|
87
|
+
const b = readJsonOrNull(join(dir, 'budget.json'));
|
|
88
|
+
tl.push({ label: `Budget halt${b?.axis ? ` (${b.axis})` : ''}`, ts: b?.ts ?? null });
|
|
89
|
+
}
|
|
90
|
+
if (existsSync(join(dir, 'pause.json'))) {
|
|
91
|
+
const p = readJsonOrNull(join(dir, 'pause.json'));
|
|
92
|
+
tl.push({ label: `Paused${p?.kind ? ` (${p.kind})` : ''}`, ts: p?.ts ?? null });
|
|
93
|
+
}
|
|
94
|
+
if (state.completedAt) {
|
|
95
|
+
tl.push({ label: `Run ${state.status === 'complete' ? 'completed' : `ended (${state.status})`}`, ts: state.completedAt });
|
|
96
|
+
}
|
|
97
|
+
return tl;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read all persisted artifacts for a completed GSD feature and return a flat,
|
|
102
|
+
* render-ready model. Returns null if there is no run state at all.
|
|
103
|
+
*/
|
|
104
|
+
export function assembleReportModel(featureCode, cwd, opts = {}) {
|
|
105
|
+
const state = readGsdState(cwd, featureCode);
|
|
106
|
+
if (!state) return null;
|
|
107
|
+
|
|
108
|
+
const blackboard = readBlackboard(featureCode, { cwd });
|
|
109
|
+
const timing = readTimingSidecar(cwd, featureCode);
|
|
110
|
+
|
|
111
|
+
// Order by decomposedTasks; append any blackboard tasks not in the graph.
|
|
112
|
+
const order = Array.isArray(state.decomposedTasks)
|
|
113
|
+
? state.decomposedTasks.map((t) => t.id).filter(Boolean)
|
|
114
|
+
: [];
|
|
115
|
+
const ids = [...order];
|
|
116
|
+
for (const id of Object.keys(blackboard)) if (!ids.includes(id)) ids.push(id);
|
|
117
|
+
|
|
118
|
+
const tasks = ids.map((id) => {
|
|
119
|
+
const tr = blackboard[id] ?? {};
|
|
120
|
+
const tm = timing[id] ?? {};
|
|
121
|
+
const diff = readDiff(cwd, featureCode, id);
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
status: tr.status ?? 'unknown',
|
|
125
|
+
attempts: tr.attempts ?? null,
|
|
126
|
+
filesChanged: Array.isArray(tr.files_changed) ? tr.files_changed : [],
|
|
127
|
+
summary: tr.summary ?? '',
|
|
128
|
+
startedAt: tm.startedAt ?? null,
|
|
129
|
+
completedAt: tm.completedAt ?? null,
|
|
130
|
+
durationMs: typeof tm.durationMs === 'number' ? tm.durationMs : null,
|
|
131
|
+
hasDiff: diff != null,
|
|
132
|
+
diff: diff ?? null,
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const completedSet = new Set(Array.isArray(state.completedTaskIds) ? state.completedTaskIds : []);
|
|
137
|
+
const completed = completedSet.size || tasks.filter((t) => t.status === 'passed').length;
|
|
138
|
+
const taskCount = tasks.length;
|
|
139
|
+
const totalWallClockMs = state.startedAt && state.completedAt
|
|
140
|
+
? Math.max(0, Date.parse(state.completedAt) - Date.parse(state.startedAt))
|
|
141
|
+
: null;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
feature: featureCode,
|
|
145
|
+
status: state.status ?? 'unknown',
|
|
146
|
+
phase: state.phase ?? null,
|
|
147
|
+
startedAt: state.startedAt ?? null,
|
|
148
|
+
completedAt: state.completedAt ?? null,
|
|
149
|
+
flowId: state.flowId ?? null,
|
|
150
|
+
tasks,
|
|
151
|
+
budget: resolveBudget(cwd, featureCode, opts),
|
|
152
|
+
timeline: buildTimeline(state, cwd, featureCode),
|
|
153
|
+
totals: {
|
|
154
|
+
taskCount,
|
|
155
|
+
completed,
|
|
156
|
+
completionRate: taskCount > 0 ? completed / taskCount : 0,
|
|
157
|
+
totalWallClockMs,
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------- HTML render ----------
|
|
163
|
+
|
|
164
|
+
function esc(s) {
|
|
165
|
+
return String(s ?? '')
|
|
166
|
+
.replace(/&/g, '&')
|
|
167
|
+
.replace(/</g, '<')
|
|
168
|
+
.replace(/>/g, '>')
|
|
169
|
+
.replace(/"/g, '"')
|
|
170
|
+
.replace(/'/g, ''');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function fmtMs(ms) {
|
|
174
|
+
if (ms == null) return '—';
|
|
175
|
+
if (ms < 1000) return `${ms} ms`;
|
|
176
|
+
const s = ms / 1000;
|
|
177
|
+
if (s < 60) return `${s.toFixed(1)} s`;
|
|
178
|
+
const m = Math.floor(s / 60);
|
|
179
|
+
return `${m}m ${Math.round(s - m * 60)}s`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const BUDGET_ROWS = [
|
|
183
|
+
{ axis: 'tokens', cap: 'max_tokens', use: 'tokens', fmt: (v) => String(v ?? 0) },
|
|
184
|
+
{ axis: 'agent dispatches', cap: 'max_agent_dispatches', use: 'dispatches', fmt: (v) => String(v ?? 0) },
|
|
185
|
+
{ axis: 'wall-clock (s)', cap: 'ms', use: 'wall_s', fmt: (v) => String(Math.round(v ?? 0)), capFmt: (v) => String(Math.round((v ?? 0) / 1000)) },
|
|
186
|
+
{ axis: 'cost (USD)', cap: 'usd', use: 'dollars', fmt: (v) => Number(v ?? 0).toFixed(4), capFmt: (v) => Number(v ?? 0).toFixed(4) },
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
function renderBudget(budget) {
|
|
190
|
+
if (!budget.configured) {
|
|
191
|
+
return `<p class="muted">Unbudgeted run — no GSD budget caps were enforced.</p>`;
|
|
192
|
+
}
|
|
193
|
+
const rows = BUDGET_ROWS
|
|
194
|
+
.filter((r) => budget.caps[r.cap] != null)
|
|
195
|
+
.map((r) => {
|
|
196
|
+
const cap = r.capFmt ? r.capFmt(budget.caps[r.cap]) : String(budget.caps[r.cap]);
|
|
197
|
+
const used = r.fmt(budget.consumed[r.use]);
|
|
198
|
+
return `<tr><td>${esc(r.axis)}</td><td>${esc(used)}</td><td>${esc(cap)}</td></tr>`;
|
|
199
|
+
})
|
|
200
|
+
.join('\n');
|
|
201
|
+
if (!rows) return `<p class="muted">Budget configured but no enforced axes recorded.</p>`;
|
|
202
|
+
return `<table><thead><tr><th>Axis</th><th>Consumed</th><th>Cap</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function renderTaskDiff(task) {
|
|
206
|
+
if (!task.hasDiff) {
|
|
207
|
+
const files = task.filesChanged.length
|
|
208
|
+
? `<p class="muted">No diff captured. Files changed: ${task.filesChanged.map(esc).join(', ')}</p>`
|
|
209
|
+
: `<p class="muted">No diff captured.</p>`;
|
|
210
|
+
return files;
|
|
211
|
+
}
|
|
212
|
+
let body = task.diff;
|
|
213
|
+
let note = '';
|
|
214
|
+
if (Buffer.byteLength(body, 'utf-8') > DIFF_INLINE_CAP_BYTES) {
|
|
215
|
+
body = body.slice(0, DIFF_INLINE_CAP_BYTES);
|
|
216
|
+
note = `<p class="muted">Diff truncated at ${Math.round(DIFF_INLINE_CAP_BYTES / 1024)} KB — see .compose/gsd/<feature>/diffs/${esc(task.id)}.diff for the full text.</p>`;
|
|
217
|
+
}
|
|
218
|
+
return `<details><summary>diff (${task.filesChanged.length} file${task.filesChanged.length !== 1 ? 's' : ''})</summary>${note}<pre class="diff">${esc(body)}</pre></details>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderTasks(tasks) {
|
|
222
|
+
return tasks.map((t) => `
|
|
223
|
+
<div class="task">
|
|
224
|
+
<h3>${esc(t.id)} <span class="status status-${esc(t.status)}">${esc(t.status)}</span></h3>
|
|
225
|
+
<div class="meta">attempts: ${esc(t.attempts ?? '—')} · files: ${t.filesChanged.length} · elapsed: ${esc(fmtMs(t.durationMs))}</div>
|
|
226
|
+
${t.summary ? `<p class="summary">${esc(t.summary)}</p>` : ''}
|
|
227
|
+
${renderTaskDiff(t)}
|
|
228
|
+
</div>`).join('\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function renderTimeline(timeline) {
|
|
232
|
+
if (!timeline.length) return '';
|
|
233
|
+
const items = timeline.map((e) => `<li><span class="ts">${esc(e.ts ?? '—')}</span> ${esc(e.label)}</li>`).join('\n');
|
|
234
|
+
return `<ul class="timeline">${items}</ul>`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Pure: model → self-contained HTML string. */
|
|
238
|
+
export function renderReportHtml(model) {
|
|
239
|
+
const t = model.totals;
|
|
240
|
+
const pct = `${Math.round(t.completionRate * 100)}%`;
|
|
241
|
+
return `<!DOCTYPE html>
|
|
242
|
+
<html lang="en">
|
|
243
|
+
<head>
|
|
244
|
+
<meta charset="UTF-8">
|
|
245
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
246
|
+
<title>${esc(model.feature)} — GSD Milestone Report</title>
|
|
247
|
+
<style>
|
|
248
|
+
:root { color-scheme: light dark; }
|
|
249
|
+
body { font: 14px/1.5 -apple-system, system-ui, sans-serif; max-width: 980px; margin: 2rem auto; padding: 0 1rem; }
|
|
250
|
+
h1 { margin-bottom: .25rem; } h2 { margin-top: 2rem; border-bottom: 1px solid #8884; padding-bottom: .25rem; }
|
|
251
|
+
.sub { color: #888; margin-top: 0; }
|
|
252
|
+
table { border-collapse: collapse; width: 100%; margin: .5rem 0; }
|
|
253
|
+
th, td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid #8883; }
|
|
254
|
+
.muted { color: #888; }
|
|
255
|
+
.cards { display: flex; gap: 1rem; flex-wrap: wrap; }
|
|
256
|
+
.card { border: 1px solid #8884; border-radius: 8px; padding: .75rem 1rem; min-width: 120px; }
|
|
257
|
+
.card .n { font-size: 1.6rem; font-weight: 600; }
|
|
258
|
+
.task { border: 1px solid #8884; border-radius: 8px; padding: .75rem 1rem; margin: .75rem 0; }
|
|
259
|
+
.task h3 { margin: 0 0 .25rem; } .task .meta { color: #888; font-size: .85rem; }
|
|
260
|
+
.status { font-size: .7rem; padding: .1rem .4rem; border-radius: 4px; background: #8883; vertical-align: middle; }
|
|
261
|
+
.status-passed { background: #2e7d3233; } .status-failed { background: #c6282833; }
|
|
262
|
+
pre.diff { overflow: auto; background: #8881; padding: .6rem; border-radius: 6px; font-size: 12px; }
|
|
263
|
+
ul.timeline { list-style: none; padding-left: 0; } ul.timeline .ts { color: #888; font-variant-numeric: tabular-nums; }
|
|
264
|
+
footer { margin-top: 3rem; color: #888; font-size: .8rem; }
|
|
265
|
+
</style>
|
|
266
|
+
</head>
|
|
267
|
+
<body>
|
|
268
|
+
<h1>${esc(model.feature)}</h1>
|
|
269
|
+
<p class="sub">GSD milestone report · status <strong>${esc(model.status)}</strong>${model.phase ? ` · phase ${esc(model.phase)}` : ''}</p>
|
|
270
|
+
|
|
271
|
+
<div class="cards">
|
|
272
|
+
<div class="card"><div class="n">${t.taskCount}</div>tasks</div>
|
|
273
|
+
<div class="card"><div class="n">${t.completed}</div>completed</div>
|
|
274
|
+
<div class="card"><div class="n">${pct}</div>completion</div>
|
|
275
|
+
<div class="card"><div class="n">${esc(fmtMs(t.totalWallClockMs))}</div>wall-clock</div>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<h2>Budget — actuals vs caps</h2>
|
|
279
|
+
${renderBudget(model.budget)}
|
|
280
|
+
|
|
281
|
+
<h2>Timeline</h2>
|
|
282
|
+
${renderTimeline(model.timeline) || '<p class="muted">No timeline events recorded.</p>'}
|
|
283
|
+
|
|
284
|
+
<h2>Tasks</h2>
|
|
285
|
+
${renderTasks(model.tasks) || '<p class="muted">No tasks recorded.</p>'}
|
|
286
|
+
|
|
287
|
+
<footer>
|
|
288
|
+
Generated by COMP-GSD-7. Per-task elapsed time is poll-granularity-approximate
|
|
289
|
+
(bounded by the dispatch poll interval). Diffs over ${Math.round(DIFF_INLINE_CAP_BYTES / 1024)} KB are truncated.
|
|
290
|
+
</footer>
|
|
291
|
+
</body>
|
|
292
|
+
</html>`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------- Write + orchestrate ----------
|
|
296
|
+
|
|
297
|
+
export function reportPath(cwd, featureCode) {
|
|
298
|
+
return join(cwd, 'docs', 'gsd-reports', `${featureCode}.html`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Atomic write to docs/gsd-reports/<feature>.html. Returns the path. */
|
|
302
|
+
export function writeGsdReport(cwd, featureCode, html) {
|
|
303
|
+
const target = reportPath(cwd, featureCode);
|
|
304
|
+
mkdirSync(join(cwd, 'docs', 'gsd-reports'), { recursive: true });
|
|
305
|
+
const tmp = `${target}.tmp`;
|
|
306
|
+
if (existsSync(tmp)) { try { unlinkSync(tmp); } catch { /* ignore */ } }
|
|
307
|
+
writeFileSync(tmp, html);
|
|
308
|
+
renameSync(tmp, target);
|
|
309
|
+
return target;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Assemble → render → write. Returns { ok, path, model, html } on success or
|
|
314
|
+
* { ok:false, error } when there is no run state to report on. Never throws on
|
|
315
|
+
* a missing-state condition (callers use it best-effort).
|
|
316
|
+
*/
|
|
317
|
+
export function generateGsdMilestoneReport(featureCode, cwd, opts = {}) {
|
|
318
|
+
const model = assembleReportModel(featureCode, cwd, opts);
|
|
319
|
+
if (!model) return { ok: false, error: `no GSD run state for ${featureCode} (no state.json)` };
|
|
320
|
+
const html = renderReportHtml(model);
|
|
321
|
+
const path = writeGsdReport(cwd, featureCode, html);
|
|
322
|
+
return { ok: true, path, model, html };
|
|
323
|
+
}
|
package/lib/gsd-state.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// lib/gsd-state.js
|
|
2
|
+
//
|
|
3
|
+
// COMP-GSD-6 S01: continuous gsd run-state checkpoint (.compose/gsd/<f>/state.json).
|
|
4
|
+
//
|
|
5
|
+
// The load-bearing primitive for headless crash recovery, `compose gsd query`,
|
|
6
|
+
// and the live-run lock. A run flushes this file continuously (init pre-plan,
|
|
7
|
+
// per-task heartbeat, post-decompose, terminal); a hard crash leaves the last
|
|
8
|
+
// checkpoint with status:"running" + a dead pid, which readers derive as
|
|
9
|
+
// "crashed". Plain JSON, atomic tmp+rename — no SQLite (mirrors writeActiveBuild
|
|
10
|
+
// in lib/build.js). `pidAlive` lives here canonically (gsd.js imports it) to
|
|
11
|
+
// keep the gsd.js <-> gsd-state.js dependency one-directional.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_STALE_MS = 90000;
|
|
17
|
+
|
|
18
|
+
function gsdDir(cwd, featureCode) {
|
|
19
|
+
return join(cwd, '.compose', 'gsd', featureCode);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function gsdStatePath(cwd, featureCode) {
|
|
23
|
+
return join(gsdDir(cwd, featureCode), 'state.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// COMP-GSD-6-WATCHDOG: best-effort removal of pause.json. After the supervisor
|
|
27
|
+
// kills a hung child, clearing pause.json lets loadResumeTaskGraph's crash-bridge
|
|
28
|
+
// recover from the current state.json (it prefers pause.json when present). A
|
|
29
|
+
// path+rm helper lives here (gsd-state owns the gsd dir layout) so the supervisor
|
|
30
|
+
// needn't import the heavy gsd.js.
|
|
31
|
+
export function clearGsdPause(cwd, featureCode) {
|
|
32
|
+
const p = join(gsdDir(cwd, featureCode), 'pause.json');
|
|
33
|
+
try { if (existsSync(p)) rmSync(p, { force: true }); } catch { /* best-effort */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// COMP-GSD-7-EVENTLOG: best-effort removal of a prior run's halt artifacts
|
|
37
|
+
// (stuck.{json,md}, budget.{json,md}). A fresh run clears these at its planning
|
|
38
|
+
// checkpoint so the milestone report's timeline — and its snapshot fallback,
|
|
39
|
+
// which reads these files — reflects only the current run, not a stale earlier
|
|
40
|
+
// halt. A clean complete clears only pause.json, so these can otherwise linger.
|
|
41
|
+
export function clearGsdHaltArtifacts(cwd, featureCode) {
|
|
42
|
+
const dir = gsdDir(cwd, featureCode);
|
|
43
|
+
for (const name of ['stuck.json', 'stuck.md', 'budget.json', 'budget.md']) {
|
|
44
|
+
const p = join(dir, name);
|
|
45
|
+
try { if (existsSync(p)) rmSync(p, { force: true }); } catch { /* best-effort */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Authoritative liveness probe. Signal 0 checks existence without delivering a
|
|
50
|
+
// signal. EPERM => the process exists but isn't ours (still alive) — this is the
|
|
51
|
+
// semantics crash detection needs (cf. build.js isProcessAlive, which returns
|
|
52
|
+
// false on EPERM and is therefore wrong for this purpose).
|
|
53
|
+
export function pidAlive(pid) {
|
|
54
|
+
if (!pid || typeof pid !== 'number') return false;
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return err.code === 'EPERM';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Atomic write: stamp heartbeatAt, write to a tmp sibling, rename into place.
|
|
64
|
+
// Returns the object as persisted (with heartbeatAt). The caller owns `pid`
|
|
65
|
+
// (the runtime sets process.pid; tests inject a synthetic pid) — unlike
|
|
66
|
+
// writeActiveBuild, we do NOT force process.pid here.
|
|
67
|
+
export function writeGsdState(cwd, featureCode, state) {
|
|
68
|
+
const dir = gsdDir(cwd, featureCode);
|
|
69
|
+
mkdirSync(dir, { recursive: true });
|
|
70
|
+
const persisted = { ...state, heartbeatAt: new Date().toISOString() };
|
|
71
|
+
const target = gsdStatePath(cwd, featureCode);
|
|
72
|
+
const tmp = `${target}.tmp`;
|
|
73
|
+
writeFileSync(tmp, JSON.stringify(persisted, null, 2));
|
|
74
|
+
renameSync(tmp, target);
|
|
75
|
+
return persisted;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readGsdState(cwd, featureCode) {
|
|
79
|
+
const p = gsdStatePath(cwd, featureCode);
|
|
80
|
+
if (!existsSync(p)) return null;
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Reader-side status derivation. Dead pid is the ONLY crash signal; a stale
|
|
89
|
+
// heartbeat on a live pid is advisory (heartbeatStale) — never a crash verdict,
|
|
90
|
+
// because a healthy long task legitimately sits in the dispatch poll loop.
|
|
91
|
+
//
|
|
92
|
+
// Returns { status, heartbeatStale }.
|
|
93
|
+
// running + live pid + fresh hb -> { running, false }
|
|
94
|
+
// running + live pid + stale hb -> { running, true }
|
|
95
|
+
// running + dead pid -> { crashed, false }
|
|
96
|
+
// <terminal> -> { <terminal>, false } (complete|stuck|budget|failed)
|
|
97
|
+
// null/no status -> { absent, false }
|
|
98
|
+
export function deriveRunStatus(state, { staleMs = DEFAULT_STALE_MS, now = Date.now() } = {}) {
|
|
99
|
+
if (!state || !state.status) return { status: 'absent', heartbeatStale: false };
|
|
100
|
+
if (state.status !== 'running') {
|
|
101
|
+
return { status: state.status, heartbeatStale: false };
|
|
102
|
+
}
|
|
103
|
+
if (!pidAlive(state.pid)) {
|
|
104
|
+
return { status: 'crashed', heartbeatStale: false };
|
|
105
|
+
}
|
|
106
|
+
const hb = state.heartbeatAt ? Date.parse(state.heartbeatAt) : null;
|
|
107
|
+
const heartbeatStale = hb != null && !Number.isNaN(hb) && now - hb > staleMs;
|
|
108
|
+
return { status: 'running', heartbeatStale };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// COMP-GSD-6: synthesize the `compose gsd query` snapshot (contracts/
|
|
112
|
+
// gsd-state.json#/definitions/query). Fixed source precedence so the
|
|
113
|
+
// pre-dispatch cumulative-budget refusal (budget.json, no state.json) isn't
|
|
114
|
+
// mislabeled 'absent': state.json -> pause.json -> budget.json -> absent.
|
|
115
|
+
// Pure synchronous reads — no LLM/server/Stratum (~ms).
|
|
116
|
+
export function buildGsdQuery(cwd, featureCode, { staleMs = DEFAULT_STALE_MS, now = Date.now() } = {}) {
|
|
117
|
+
const dir = gsdDir(cwd, featureCode);
|
|
118
|
+
|
|
119
|
+
// 1. state.json
|
|
120
|
+
const state = readGsdState(cwd, featureCode);
|
|
121
|
+
if (state) {
|
|
122
|
+
const { status, heartbeatStale } = deriveRunStatus(state, { staleMs, now });
|
|
123
|
+
const total = Array.isArray(state.decomposedTasks) ? state.decomposedTasks.length : 0;
|
|
124
|
+
const completed = Array.isArray(state.completedTaskIds) ? state.completedTaskIds.length : 0;
|
|
125
|
+
return {
|
|
126
|
+
feature: featureCode,
|
|
127
|
+
status,
|
|
128
|
+
phase: state.phase ?? null,
|
|
129
|
+
heartbeatStale,
|
|
130
|
+
progress: { completed, total },
|
|
131
|
+
resumeReady: !!state.resumeReady,
|
|
132
|
+
pid: state.pid ?? null,
|
|
133
|
+
flowId: state.flowId ?? null,
|
|
134
|
+
heartbeatAt: state.heartbeatAt ?? null,
|
|
135
|
+
budget: state.budget ?? null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. pause.json (paused, no live state.json — e.g. a pre-GSD-6 halt)
|
|
140
|
+
const pausePath = join(dir, 'pause.json');
|
|
141
|
+
if (existsSync(pausePath)) {
|
|
142
|
+
try {
|
|
143
|
+
const p = JSON.parse(readFileSync(pausePath, 'utf-8'));
|
|
144
|
+
const kind = p.kind === 'budget' ? 'budget' : 'stuck';
|
|
145
|
+
return {
|
|
146
|
+
feature: featureCode,
|
|
147
|
+
status: kind,
|
|
148
|
+
phase: 'execute',
|
|
149
|
+
pause: { kind, detail: p.detail ?? null, stuckTaskId: p.stuckTaskId ?? null },
|
|
150
|
+
};
|
|
151
|
+
} catch { /* fall through */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 3. budget.json (pre-dispatch cumulative refusal — writes budget.json, no state)
|
|
155
|
+
const budgetPath = join(dir, 'budget.json');
|
|
156
|
+
if (existsSync(budgetPath)) {
|
|
157
|
+
try {
|
|
158
|
+
const b = JSON.parse(readFileSync(budgetPath, 'utf-8'));
|
|
159
|
+
return { feature: featureCode, status: 'budget', budget: b };
|
|
160
|
+
} catch { /* fall through */ }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 4. absent
|
|
164
|
+
return { feature: featureCode, status: 'absent' };
|
|
165
|
+
}
|