@smartmemory/compose 0.2.8-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 +75 -1
- package/contracts/gsd-state.json +140 -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/build.js +36 -2
- 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-supervisor.js +223 -0
- package/lib/gsd-timing.js +89 -0
- package/lib/gsd.js +446 -45
- 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
package/lib/build.js
CHANGED
|
@@ -58,6 +58,8 @@ import { CrossLayerAudit, loadDebugConfig } from './cross-layer-audit.js';
|
|
|
58
58
|
import { emitCheckpoint } from './bug-checkpoint.js';
|
|
59
59
|
import { appendHypothesisEntry, readHypotheses } from './bug-ledger.js';
|
|
60
60
|
import { tier1CodexReview, tier2FreshAgent } from './bug-escalation.js';
|
|
61
|
+
import { recordTaskStates, writeTimingSidecar } from './gsd-timing.js';
|
|
62
|
+
import { writeGsdTaskDiff } from './gsd-diff-capture.js';
|
|
61
63
|
|
|
62
64
|
// ---------------------------------------------------------------------------
|
|
63
65
|
// COMP-FIX-HARD T5: per-step retries cap parser
|
|
@@ -2958,6 +2960,11 @@ export async function executeParallelDispatchServer(
|
|
|
2958
2960
|
// mode invokes this fn with 6 args (opts={}) so every detector branch below
|
|
2959
2961
|
// is skipped and build behavior stays byte-identical.
|
|
2960
2962
|
const stuckDetector = opts.stuckDetector ?? null;
|
|
2963
|
+
// COMP-GSD-6: optional per-task heartbeat. ONLY the gsd headless path passes
|
|
2964
|
+
// one; build mode (opts={}) leaves it null so behavior stays byte-identical.
|
|
2965
|
+
// Bumped from the push-event callback so a long task sitting in the dispatch
|
|
2966
|
+
// poll loop keeps state.json's heartbeat fresh (else it would look crashed).
|
|
2967
|
+
const onHeartbeat = typeof opts.onHeartbeat === 'function' ? opts.onHeartbeat : null;
|
|
2961
2968
|
const startedTasks = new Set();
|
|
2962
2969
|
|
|
2963
2970
|
if (streamWriter) {
|
|
@@ -2989,11 +2996,15 @@ export async function executeParallelDispatchServer(
|
|
|
2989
2996
|
// state-machine driver. Forward valid events through streamWriter so the
|
|
2990
2997
|
// bridge rebroadcasts them via SSE under the buildStreamEvent wrapper.
|
|
2991
2998
|
let unsubscribePush = null;
|
|
2992
|
-
if (typeof stratum.onEvent === 'function' && (streamWriter || stuckDetector)) {
|
|
2999
|
+
if (typeof stratum.onEvent === 'function' && (streamWriter || stuckDetector || onHeartbeat)) {
|
|
2993
3000
|
unsubscribePush = stratum.onEvent(flowId, stepId, (event) => {
|
|
2994
3001
|
// Accept all KNOWN_VERSIONS (producer emits 0.2.6); pinning '0.2.5' dropped
|
|
2995
3002
|
// every push event from the current producer. Client already validated.
|
|
2996
3003
|
if (!event || !KNOWN_VERSIONS.has(event.schema_version)) return;
|
|
3004
|
+
// COMP-GSD-6: any accepted event proves the run is alive — bump heartbeat.
|
|
3005
|
+
if (onHeartbeat) {
|
|
3006
|
+
try { onHeartbeat(event); } catch (err) { console.error('[build] heartbeat hook failed:', err); }
|
|
3007
|
+
}
|
|
2997
3008
|
// COMP-GSD-5: feed the per-task stuck detector (no-op in build mode —
|
|
2998
3009
|
// stuckDetector is null there). record() ignores all but
|
|
2999
3010
|
// tool_use_summary/tool_result and keys by event.task_id.
|
|
@@ -3009,6 +3020,13 @@ export async function executeParallelDispatchServer(
|
|
|
3009
3020
|
});
|
|
3010
3021
|
}
|
|
3011
3022
|
|
|
3023
|
+
// COMP-GSD-7: capture poll-observed per-task timing (gsd path only). Stratum's
|
|
3024
|
+
// poll response carries no per-task timing, so we stamp first-sight / first-
|
|
3025
|
+
// terminal from our own poll loop and persist a timing.json sidecar for the
|
|
3026
|
+
// milestone report. Gated on context.gsd so build mode is byte-identical.
|
|
3027
|
+
const captureTiming = context?.gsd === true && !!context?.featureCode;
|
|
3028
|
+
const taskTiming = {};
|
|
3029
|
+
|
|
3012
3030
|
try {
|
|
3013
3031
|
// Poll until outcome is present (NOT can_advance — see design §3)
|
|
3014
3032
|
let pollResult;
|
|
@@ -3021,6 +3039,7 @@ export async function executeParallelDispatchServer(
|
|
|
3021
3039
|
`stratum_parallel_poll failed: ${pollResult.error}: ${pollResult.message || ''}`,
|
|
3022
3040
|
);
|
|
3023
3041
|
}
|
|
3042
|
+
if (captureTiming) recordTaskStates(taskTiming, pollResult.tasks, new Date().toISOString());
|
|
3024
3043
|
emitPerTaskProgress(streamWriter, pollResult, emittedStates);
|
|
3025
3044
|
|
|
3026
3045
|
// COMP-GSD-5: real-time stuck detection (gsd path only — null in build).
|
|
@@ -3063,6 +3082,13 @@ export async function executeParallelDispatchServer(
|
|
|
3063
3082
|
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
3064
3083
|
}
|
|
3065
3084
|
|
|
3085
|
+
// COMP-GSD-7: persist the timing sidecar on every terminal path (complete,
|
|
3086
|
+
// stuck, budget) — best-effort; a write failure must never derail dispatch.
|
|
3087
|
+
if (captureTiming) {
|
|
3088
|
+
try { writeTimingSidecar(baseCwd, context.featureCode, taskTiming); }
|
|
3089
|
+
catch (err) { console.error('[build] gsd timing sidecar write failed:', err.message); }
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3066
3092
|
// COMP-GSD-5: short-circuit return on a stuck verdict. The cancel outcome is
|
|
3067
3093
|
// returned verbatim with the verdict attached; the gsd run loop branches on
|
|
3068
3094
|
// `.stuck`. This bypasses the merge/advance bookkeeping below — there is
|
|
@@ -3340,7 +3366,15 @@ function applyServerDispatchDiffsCore(taskList, pollTasks, baseCwd, streamWriter
|
|
|
3340
3366
|
}
|
|
3341
3367
|
continue;
|
|
3342
3368
|
}
|
|
3343
|
-
if (ts?.diff != null)
|
|
3369
|
+
if (ts?.diff != null) {
|
|
3370
|
+
diffMap.set(taskId, ts.diff);
|
|
3371
|
+
// COMP-GSD-7: snapshot the diff before the worktree is cleaned up, so the
|
|
3372
|
+
// milestone report can inline it. gsd path only (context.gsd); best-effort.
|
|
3373
|
+
if (context?.gsd === true && context?.featureCode) {
|
|
3374
|
+
try { writeGsdTaskDiff(baseCwd, context.featureCode, taskId, ts.diff); }
|
|
3375
|
+
catch (err) { console.error('[build] gsd diff snapshot write failed:', err.message); }
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3344
3378
|
}
|
|
3345
3379
|
|
|
3346
3380
|
if (diffMap.size === 0) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// lib/gsd-diff-capture.js
|
|
2
|
+
//
|
|
3
|
+
// COMP-GSD-7 S3: persist per-task diff snapshots for the milestone report.
|
|
4
|
+
//
|
|
5
|
+
// When a GSD parallel task runs in a worktree with capture_diff:true, Stratum
|
|
6
|
+
// returns the unified diff in the poll payload (ts.diff). build.js consumes it
|
|
7
|
+
// at the merge site and then drops it once the worktree is cleaned up. This
|
|
8
|
+
// helper snapshots that diff to .compose/gsd/<feature>/diffs/<taskId>.diff so
|
|
9
|
+
// the report (lib/gsd-milestone-report.js) can inline it. The path helper is the
|
|
10
|
+
// single source of truth shared with the report reader.
|
|
11
|
+
//
|
|
12
|
+
// Atomic write: tmp+rename, mirrors lib/gsd-state.js:44.
|
|
13
|
+
|
|
14
|
+
import { writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export function gsdDiffsDir(cwd, featureCode) {
|
|
18
|
+
return join(cwd, '.compose', 'gsd', featureCode, 'diffs');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function gsdTaskDiffPath(cwd, featureCode, taskId) {
|
|
22
|
+
return join(gsdDiffsDir(cwd, featureCode), `${taskId}.diff`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Atomic write of a task's unified diff. Returns the path. */
|
|
26
|
+
export function writeGsdTaskDiff(cwd, featureCode, taskId, diffText) {
|
|
27
|
+
mkdirSync(gsdDiffsDir(cwd, featureCode), { recursive: true });
|
|
28
|
+
const target = gsdTaskDiffPath(cwd, featureCode, taskId);
|
|
29
|
+
const tmp = `${target}.tmp`;
|
|
30
|
+
if (existsSync(tmp)) { try { unlinkSync(tmp); } catch { /* ignore */ } }
|
|
31
|
+
writeFileSync(tmp, diffText);
|
|
32
|
+
renameSync(tmp, target);
|
|
33
|
+
return target;
|
|
34
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// lib/gsd-events.js
|
|
2
|
+
//
|
|
3
|
+
// COMP-GSD-7-EVENTLOG: append-only GSD run-event log at
|
|
4
|
+
// .compose/gsd/<feature>/events.jsonl — one JSON object per line,
|
|
5
|
+
// { ts, kind, ...detail }. GSD otherwise persists only snapshots, so the
|
|
6
|
+
// milestone report's timeline (COMP-GSD-7) had to reconstruct order from
|
|
7
|
+
// whatever artifacts happened to exist. This log records what happened, in
|
|
8
|
+
// order, across resume sessions; the report consumes it (snapshot fallback).
|
|
9
|
+
//
|
|
10
|
+
// All writes are BEST-EFFORT — an event-log failure must never affect the run.
|
|
11
|
+
// The reader tolerates a torn/corrupt final line (crash mid-append) by skipping
|
|
12
|
+
// unparseable lines.
|
|
13
|
+
//
|
|
14
|
+
// Contract: `detail` must NOT contain `ts` or `kind` keys (the event kind would
|
|
15
|
+
// be clobbered). For a `paused` event the stuck/budget discriminator is
|
|
16
|
+
// `pauseKind`, not `kind`.
|
|
17
|
+
|
|
18
|
+
import { appendFileSync, readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
function gsdDir(cwd, featureCode) {
|
|
22
|
+
return join(cwd, '.compose', 'gsd', featureCode);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function gsdEventsPath(cwd, featureCode) {
|
|
26
|
+
return join(gsdDir(cwd, featureCode), 'events.jsonl');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function appendGsdEvent(cwd, featureCode, kind, detail = {}) {
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(gsdDir(cwd, featureCode), { recursive: true });
|
|
32
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), kind, ...detail }) + '\n';
|
|
33
|
+
appendFileSync(gsdEventsPath(cwd, featureCode), line);
|
|
34
|
+
} catch { /* best-effort — never affect the run */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readGsdEvents(cwd, featureCode) {
|
|
38
|
+
const p = gsdEventsPath(cwd, featureCode);
|
|
39
|
+
if (!existsSync(p)) return [];
|
|
40
|
+
let raw;
|
|
41
|
+
try { raw = readFileSync(p, 'utf-8'); } catch { return []; }
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const line of raw.split('\n')) {
|
|
44
|
+
if (!line.trim()) continue;
|
|
45
|
+
let parsed;
|
|
46
|
+
try { parsed = JSON.parse(line); } catch { continue; /* skip torn/corrupt line */ }
|
|
47
|
+
// Only event OBJECTS are usable — a parseable scalar/array/null is not an
|
|
48
|
+
// event (and would throw in the report's label mapping). Treat as "no event".
|
|
49
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) out.push(parsed);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// COMP-GSD-7-EVENTLOG: fresh-run truncate. Empties the log so a new run's
|
|
55
|
+
// timeline doesn't inherit a prior (abandoned) run's events. Called at the
|
|
56
|
+
// planning checkpoint, AFTER preconditions pass (so a failed fresh invocation
|
|
57
|
+
// doesn't destroy a prior run's history).
|
|
58
|
+
export function clearGsdEvents(cwd, featureCode) {
|
|
59
|
+
const p = gsdEventsPath(cwd, featureCode);
|
|
60
|
+
try { if (existsSync(p)) writeFileSync(p, ''); } catch { /* best-effort */ }
|
|
61
|
+
}
|
|
@@ -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
|
+
}
|