@smartmemory/compose 0.2.2-beta → 0.2.4-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/contracts/checkpoint.schema.json +85 -0
- package/dist/assets/{App-QGVt8tH2.js → App-j8fWZcGr.js} +5 -5
- package/dist/assets/{arc-yX1Dy9Ls.js → arc-BFqOo_jJ.js} +1 -1
- package/dist/assets/{architectureDiagram-3BPJPVTR-BhtVN7Go.js → architectureDiagram-3BPJPVTR-D722w0RE.js} +1 -1
- package/dist/assets/{blockDiagram-GPEHLZMM-Do_uWvAL.js → blockDiagram-GPEHLZMM-B4w0mOAJ.js} +1 -1
- package/dist/assets/{c4Diagram-AAUBKEIU-DhjfNEZ_.js → c4Diagram-AAUBKEIU-D6LE8-j8.js} +1 -1
- package/dist/assets/channel-BD-5_hPW.js +1 -0
- package/dist/assets/{chunk-2J33WTMH-ZLuzLSr5.js → chunk-2J33WTMH-CrazA7xu.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-BkfYx42O.js → chunk-4BX2VUAB-Cp90GiCM.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-UGWHui37.js → chunk-55IACEB6-Bnais1SK.js} +1 -1
- package/dist/assets/{chunk-727SXJPM-DENLKVEd.js → chunk-727SXJPM-kD07Sqp5.js} +1 -1
- package/dist/assets/{chunk-AQP2D5EJ-BV-AIq0h.js → chunk-AQP2D5EJ-DmIxhJc8.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BO5q1BN_.js → chunk-FMBD7UC4-Jti_und8.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-rAAtIsqf.js → chunk-ND2GUHAM-Ipx3noKz.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DmZo3sCU.js → chunk-QZHKN3VN-CeblRnPF.js} +1 -1
- package/dist/assets/classDiagram-4FO5ZUOK-mSW5R7DY.js +1 -0
- package/dist/assets/classDiagram-v2-Q7XG4LA2-mSW5R7DY.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BlBShGhI.js → cose-bilkent-S5V4N54A-fNQlSmHt.js} +1 -1
- package/dist/assets/{dagre-BM42HDAG-urcL7_B8.js → dagre-BM42HDAG-D27D6YAL.js} +1 -1
- package/dist/assets/{diagram-2AECGRRQ-DlfCvqLR.js → diagram-2AECGRRQ-CtXeohzN.js} +1 -1
- package/dist/assets/{diagram-5GNKFQAL-h5gTsYDo.js → diagram-5GNKFQAL-C_BqZkx0.js} +1 -1
- package/dist/assets/{diagram-KO2AKTUF-BbEbRrVo.js → diagram-KO2AKTUF-B29ynQz4.js} +1 -1
- package/dist/assets/{diagram-LMA3HP47-jog00Zl2.js → diagram-LMA3HP47-DAYJMc2I.js} +1 -1
- package/dist/assets/{diagram-OG6HWLK6-B0JVsR6S.js → diagram-OG6HWLK6-CBJMis3l.js} +1 -1
- package/dist/assets/{erDiagram-TEJ5UH35-DkUnalKg.js → erDiagram-TEJ5UH35-nd3GWiPn.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-DewNd_kM.js → flowDiagram-I6XJVG4X-HFUno_nV.js} +1 -1
- package/dist/assets/{ganttDiagram-6RSMTGT7-DzDBcVj5.js → ganttDiagram-6RSMTGT7-CPPAAjwR.js} +1 -1
- package/dist/assets/{gitGraphDiagram-PVQCEYII-D0CX5WgP.js → gitGraphDiagram-PVQCEYII-NBq1F6K2.js} +1 -1
- package/dist/assets/{index-D4GJb_6L.js → index-uHKnp74B.js} +2 -2
- package/dist/assets/{infoDiagram-5YYISTIA-B1zzuW9l.js → infoDiagram-5YYISTIA-D-TOBtCq.js} +1 -1
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-3hFmuv1F.js → ishikawaDiagram-YF4QCWOH-nXOztZiZ.js} +1 -1
- package/dist/assets/{journeyDiagram-JHISSGLW-w9c-l95A.js → journeyDiagram-JHISSGLW-Bko3tTdh.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-9cL90JL0.js → kanban-definition-UN3LZRKU-1e-7i8st.js} +1 -1
- package/dist/assets/{linear-DyDb5wz8.js → linear-Dx5ZJB7F.js} +1 -1
- package/dist/assets/{mindmap-definition-RKZ34NQL-DBQqsZiD.js → mindmap-definition-RKZ34NQL-CNwNkDqN.js} +1 -1
- package/dist/assets/{pieDiagram-4H26LBE5-BbIHZku5.js → pieDiagram-4H26LBE5-C5fvCej-.js} +1 -1
- package/dist/assets/{quadrantDiagram-W4KKPZXB-DEQSG_lM.js → quadrantDiagram-W4KKPZXB-4NoQsF61.js} +1 -1
- package/dist/assets/{requirementDiagram-4Y6WPE33-BeVnwIwF.js → requirementDiagram-4Y6WPE33-q5WxB9LO.js} +1 -1
- package/dist/assets/{sankeyDiagram-5OEKKPKP-Be-ROw_I.js → sankeyDiagram-5OEKKPKP-DlQNB367.js} +1 -1
- package/dist/assets/{sequenceDiagram-3UESZ5HK-E-tnxahu.js → sequenceDiagram-3UESZ5HK-BzHclOKt.js} +1 -1
- package/dist/assets/{stateDiagram-AJRCARHV-3rgFN7hL.js → stateDiagram-AJRCARHV-BvWRI9zK.js} +1 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-CDlF0VA8.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-Dcs4QFbE.js → timeline-definition-PNZ67QCA-j2wKjAti.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-BstUQ900.js → vennDiagram-CIIHVFJN-B77g7htC.js} +1 -1
- package/dist/assets/{wardley-L42UT6IY-CO77hXwj.js → wardley-L42UT6IY-83Im2mo2.js} +1 -1
- package/dist/assets/{wardleyDiagram-YWT4CUSO-BvrP3shF.js → wardleyDiagram-YWT4CUSO-CK-XB-bO.js} +1 -1
- package/dist/assets/{xychartDiagram-2RQKCTM6-Btu4JcQO.js → xychartDiagram-2RQKCTM6-D42FcVOY.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/checkpoint/anchor.js +66 -0
- package/lib/checkpoint/atomic.js +83 -0
- package/lib/checkpoint/checkpoint-writer.js +131 -0
- package/lib/checkpoint/fingerprint.js +145 -0
- package/lib/checkpoint/git.js +58 -0
- package/lib/checkpoint/prompts.js +206 -0
- package/lib/checkpoint/reconciler.js +207 -0
- package/lib/checkpoint/render.js +107 -0
- package/lib/checkpoint/store/index.js +67 -0
- package/lib/checkpoint/store/jsonl.js +80 -0
- package/package.json +1 -1
- package/server/compose-mcp-tools.js +30 -0
- package/server/compose-mcp.js +40 -0
- package/server/lifecycle-guard.js +225 -0
- package/server/session-routes.js +65 -0
- package/server/stratum-client.js +140 -0
- package/server/vision-routes.js +68 -20
- package/server/vision-server.js +2 -0
- package/dist/assets/channel-D6hNrRZ2.js +0 -1
- package/dist/assets/classDiagram-4FO5ZUOK-DvsVLUph.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-DvsVLUph.js +0 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-DY_OtnIg.js +0 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/checkpoint/atomic.js — small fs helpers for the checkpoint stores.
|
|
3
|
+
*
|
|
4
|
+
* COMP-RESUME slice S2 (correction C6): existing stores copy-paste the
|
|
5
|
+
* temp-file + rename atomic-write idiom (see server/vision-store.js:132-143)
|
|
6
|
+
* and the JSONL append/idempotent-read idiom (see server/gate-log-store.js:46-102).
|
|
7
|
+
* This module is the one shared helper the new JSONL checkpoint backend uses,
|
|
8
|
+
* so we don't churn the existing stores.
|
|
9
|
+
*
|
|
10
|
+
* No external dependencies; Node built-ins only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
mkdirSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
appendFileSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { dirname } from 'node:path';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Atomically write `str` to `file`: write to a unique temp sibling, then rename.
|
|
25
|
+
* The rename is atomic on POSIX, so readers never observe a half-written file.
|
|
26
|
+
* Creates the parent directory recursively.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} file — absolute or relative target path
|
|
29
|
+
* @param {string} str — exact bytes to write (caller controls trailing newline)
|
|
30
|
+
*/
|
|
31
|
+
export function writeAtomic(file, str) {
|
|
32
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
33
|
+
const tmp = `${file}.tmp.${Date.now()}`;
|
|
34
|
+
writeFileSync(tmp, str, 'utf8');
|
|
35
|
+
renameSync(tmp, file);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Append one object as a single JSON line to `file` (creating parent dirs).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} file
|
|
42
|
+
* @param {object} obj
|
|
43
|
+
*/
|
|
44
|
+
export function appendJsonl(file, obj) {
|
|
45
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
46
|
+
appendFileSync(file, JSON.stringify(obj) + '\n', 'utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read all valid JSON objects from a JSONL `file`.
|
|
51
|
+
* Returns `[]` if the file is absent. Blank and malformed lines are skipped
|
|
52
|
+
* (tolerant read — a torn final line never poisons the whole log).
|
|
53
|
+
*
|
|
54
|
+
* @param {string} file
|
|
55
|
+
* @returns {object[]}
|
|
56
|
+
*/
|
|
57
|
+
export function readJsonl(file) {
|
|
58
|
+
if (!existsSync(file)) return [];
|
|
59
|
+
const raw = readFileSync(file, 'utf8');
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const line of raw.split('\n')) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (!trimmed) continue;
|
|
64
|
+
try {
|
|
65
|
+
out.push(JSON.parse(trimmed));
|
|
66
|
+
} catch {
|
|
67
|
+
// malformed line — skip
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Return the last valid JSON object in `file`, or `null` if there is none
|
|
75
|
+
* (absent file, empty file, or only blank/malformed lines).
|
|
76
|
+
*
|
|
77
|
+
* @param {string} file
|
|
78
|
+
* @returns {object|null}
|
|
79
|
+
*/
|
|
80
|
+
export function readLastJsonl(file) {
|
|
81
|
+
const all = readJsonl(file);
|
|
82
|
+
return all.length ? all[all.length - 1] : null;
|
|
83
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkpoint-writer.js — COMP-RESUME S9 (lib side of the MCP `write_checkpoint` tool).
|
|
3
|
+
*
|
|
4
|
+
* Reads directly from disk (no server dependency) so a checkpoint can be written
|
|
5
|
+
* even when the Compose server is not running — same stance as compose-mcp-tools.js.
|
|
6
|
+
*
|
|
7
|
+
* The resume path (`compose_resume`) is NOT here: it HTTP-delegates to the server
|
|
8
|
+
* route POST /api/session/bind/reconcile, because reconcile must run server-side
|
|
9
|
+
* where the live vision item / lifecycle state and broadcasts exist.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { captureFingerprint } from './fingerprint.js';
|
|
16
|
+
import { captureAnchor } from './anchor.js';
|
|
17
|
+
import { scribePrompt } from './prompts.js';
|
|
18
|
+
import { createCheckpointStore } from './store/index.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the checkpoint config block from .compose/compose.json with defaults
|
|
22
|
+
* applied EXPLICITLY (loadProjectConfig does not merge defaults — Codex #4).
|
|
23
|
+
*/
|
|
24
|
+
export function checkpointConfig(targetRoot) {
|
|
25
|
+
let raw = {};
|
|
26
|
+
try {
|
|
27
|
+
raw = JSON.parse(readFileSync(path.join(targetRoot, '.compose', 'compose.json'), 'utf-8'));
|
|
28
|
+
} catch {
|
|
29
|
+
raw = {};
|
|
30
|
+
}
|
|
31
|
+
const c = raw.checkpoint ?? {};
|
|
32
|
+
return {
|
|
33
|
+
enabled: c.enabled !== false,
|
|
34
|
+
backend: c.backend ?? 'jsonl',
|
|
35
|
+
confidenceThreshold: typeof c.confidenceThreshold === 'number' ? c.confidenceThreshold : 0.6,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Best-effort read of a feature's lifecycle phase label from feature.json. */
|
|
40
|
+
function readFeaturePhase(featureDir) {
|
|
41
|
+
try {
|
|
42
|
+
const fj = JSON.parse(readFileSync(path.join(featureDir, 'feature.json'), 'utf-8'));
|
|
43
|
+
return fj.phase || fj.status || null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Write a checkpoint to the configured backend.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} targetRoot Project root (git repo / cwd to fingerprint).
|
|
53
|
+
* @param {object} args
|
|
54
|
+
* @param {string} args.featureCode
|
|
55
|
+
* @param {string} [args.phase] Lifecycle phase label; falls back to feature.json then 'unknown'.
|
|
56
|
+
* @param {string} [args.trigger] Schema enum; default 'manual'.
|
|
57
|
+
* @param {object|null} [args.soft] {goal,nextStep,risks} for a narrative checkpoint; null → anchor.
|
|
58
|
+
* @param {string|null} [args.flowId]
|
|
59
|
+
* @param {number} [args.confidence] Present only on resume-sync checkpoints.
|
|
60
|
+
* @returns {{ checkpoint: object, scribePrompt: string|null }} the written
|
|
61
|
+
* Checkpoint, plus — when `soft` was NOT provided (an anchor write at a
|
|
62
|
+
* boundary) — a `scribePrompt` the orchestrator can answer and re-submit as a
|
|
63
|
+
* narrative checkpoint (the hybrid "anchor now, narrative on-demand" flow).
|
|
64
|
+
* `scribePrompt` is null when `soft` was supplied (already a narrative cp).
|
|
65
|
+
*/
|
|
66
|
+
export function writeCheckpoint(targetRoot, {
|
|
67
|
+
featureCode,
|
|
68
|
+
phase = null,
|
|
69
|
+
trigger = 'manual',
|
|
70
|
+
soft = null,
|
|
71
|
+
flowId = null,
|
|
72
|
+
confidence = null,
|
|
73
|
+
} = {}) {
|
|
74
|
+
if (!featureCode) throw new Error('write_checkpoint: featureCode is required');
|
|
75
|
+
const cfg = checkpointConfig(targetRoot);
|
|
76
|
+
const dataDir = path.join(targetRoot, '.compose', 'data');
|
|
77
|
+
const composeDir = path.join(targetRoot, '.compose');
|
|
78
|
+
const featureDir = path.join(targetRoot, 'docs', 'features', featureCode);
|
|
79
|
+
|
|
80
|
+
const store = createCheckpointStore(cfg.backend, { dataDir });
|
|
81
|
+
// The prior checkpoint (read before writing) seeds the scribe prompt's context.
|
|
82
|
+
const priorCheckpoint = soft ? null : store.readLatest(featureCode);
|
|
83
|
+
|
|
84
|
+
const cp = {
|
|
85
|
+
id: randomUUID(),
|
|
86
|
+
featureCode,
|
|
87
|
+
phase: phase ?? readFeaturePhase(featureDir) ?? 'unknown',
|
|
88
|
+
createdAt: new Date().toISOString(),
|
|
89
|
+
trigger,
|
|
90
|
+
fingerprint: captureFingerprint(targetRoot, { featureDir, composeDir, dataDir, flowId }),
|
|
91
|
+
soft: soft ?? null,
|
|
92
|
+
artifactIds: [],
|
|
93
|
+
};
|
|
94
|
+
if (typeof confidence === 'number') cp.confidence = confidence;
|
|
95
|
+
store.write(cp);
|
|
96
|
+
|
|
97
|
+
// No soft → this is an anchor; hand back the scribe prompt so the orchestrator
|
|
98
|
+
// can generate {goal,nextStep,risks} (anchored to the fingerprint) and write a
|
|
99
|
+
// narrative checkpoint. Wires scribePrompt into the production path (impl #3).
|
|
100
|
+
const prompt = soft
|
|
101
|
+
? null
|
|
102
|
+
: scribePrompt({ fingerprint: cp.fingerprint, journalTail: '', priorCheckpoint });
|
|
103
|
+
|
|
104
|
+
return { checkpoint: cp, scribePrompt: prompt };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Server-side boundary convenience: write an anchor checkpoint for a live vision
|
|
109
|
+
* `item` at a lifecycle boundary. Resolves config/store/paths and delegates to
|
|
110
|
+
* captureAnchor. Gated on `checkpoint.enabled`. BEST-EFFORT — never throws (so a
|
|
111
|
+
* checkpoint failure can never break a route handler); returns the cp or null.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} targetRoot
|
|
114
|
+
* @param {{ item: object, trigger: string, flowId?: string|null }} opts
|
|
115
|
+
*/
|
|
116
|
+
export function anchorBoundary(targetRoot, { item, trigger, flowId = null } = {}) {
|
|
117
|
+
try {
|
|
118
|
+
const cfg = checkpointConfig(targetRoot);
|
|
119
|
+
if (!cfg.enabled) return null;
|
|
120
|
+
const featureCode = item?.lifecycle?.featureCode;
|
|
121
|
+
if (!featureCode) return null;
|
|
122
|
+
const dataDir = path.join(targetRoot, '.compose', 'data');
|
|
123
|
+
const composeDir = path.join(targetRoot, '.compose');
|
|
124
|
+
const featureDir = path.join(targetRoot, 'docs', 'features', featureCode);
|
|
125
|
+
const store = createCheckpointStore(cfg.backend, { dataDir });
|
|
126
|
+
return captureAnchor({ item, trigger, cwd: targetRoot, featureDir, composeDir, dataDir, store, flowId });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.warn('[checkpoint] anchorBoundary failed:', err?.message ?? err);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COMP-RESUME S3 — environment fingerprinting + drift classification.
|
|
3
|
+
*
|
|
4
|
+
* captureFingerprint() produces an EnvFingerprint (contracts/checkpoint.schema.json
|
|
5
|
+
* $defs/EnvFingerprint): a deterministic snapshot of the environment that
|
|
6
|
+
* records what exists and never interprets it (no pass/fail verdicts).
|
|
7
|
+
*
|
|
8
|
+
* classify() is a pure function comparing two fingerprints to decide whether
|
|
9
|
+
* the environment is unchanged ('clean'), moved forward cleanly ('advanced'),
|
|
10
|
+
* or drifted in a way that needs reconciliation ('diverged').
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join, dirname } from 'node:path';
|
|
15
|
+
import { head, branch, porcelain, dirtyHash } from './git.js';
|
|
16
|
+
|
|
17
|
+
// Phase artifacts whose presence is part of the build signature. Order is the
|
|
18
|
+
// canonical phase order: design → blueprint → plan.
|
|
19
|
+
const PHASE_ARTIFACT_FILES = {
|
|
20
|
+
design: 'design.md',
|
|
21
|
+
blueprint: 'blueprint.md',
|
|
22
|
+
plan: 'plan.md',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a phase artifact to its absolute path if it exists under featureDir,
|
|
27
|
+
* else null.
|
|
28
|
+
*/
|
|
29
|
+
function artifactPath(featureDir, fileName) {
|
|
30
|
+
if (!featureDir) return null;
|
|
31
|
+
const p = join(featureDir, fileName);
|
|
32
|
+
return existsSync(p) ? p : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read the `_seq` of the last VALID JSON line of <composeDir>/build-stream.jsonl.
|
|
37
|
+
* NOTE: the build stream is written to `.compose/build-stream.jsonl` (composeDir),
|
|
38
|
+
* NOT `.compose/data/` — see lib/build-stream-writer.js:28 (`join(composeDir, ...)`).
|
|
39
|
+
*
|
|
40
|
+
* Crash tolerance (Codex impl review #2): a crash can leave a torn final line, so
|
|
41
|
+
* we scan BACKWARD from the end and return the _seq of the first line that parses
|
|
42
|
+
* with a numeric _seq — rather than giving up if only the very last line is
|
|
43
|
+
* corrupt. Returns null when the file is absent, empty, or has no parseable _seq.
|
|
44
|
+
*/
|
|
45
|
+
function lastBuildStreamSeq(composeDir) {
|
|
46
|
+
if (!composeDir) return null;
|
|
47
|
+
const file = join(composeDir, 'build-stream.jsonl');
|
|
48
|
+
if (!existsSync(file)) return null;
|
|
49
|
+
let content;
|
|
50
|
+
try {
|
|
51
|
+
content = readFileSync(file, 'utf-8').trimEnd();
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!content) return null;
|
|
56
|
+
const lines = content.split('\n');
|
|
57
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
58
|
+
const line = lines[i].trim();
|
|
59
|
+
if (!line) continue;
|
|
60
|
+
try {
|
|
61
|
+
const obj = JSON.parse(line);
|
|
62
|
+
if (typeof obj._seq === 'number') return obj._seq;
|
|
63
|
+
// a valid line without a numeric _seq → keep scanning backward
|
|
64
|
+
} catch {
|
|
65
|
+
// torn / malformed line (e.g. interrupted final write) → keep scanning
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Capture a deterministic snapshot of the environment.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} cwd Repo / working directory to fingerprint.
|
|
75
|
+
* @param {object} [opts]
|
|
76
|
+
* @param {string} [opts.featureDir] Dir holding phase artifacts (design/blueprint/plan).
|
|
77
|
+
* @param {string|null} [opts.flowId] Stratum flow id, passed through.
|
|
78
|
+
* @param {string|null} [opts.composeDir] `.compose` dir for build-stream lookup. Defaults to dirname(dataDir).
|
|
79
|
+
* @param {string|null} [opts.dataDir] `.compose/data` dir; used only to derive composeDir when composeDir is absent.
|
|
80
|
+
* @returns {object} EnvFingerprint
|
|
81
|
+
*/
|
|
82
|
+
export function captureFingerprint(cwd, { featureDir, flowId = null, composeDir = null, dataDir = null } = {}) {
|
|
83
|
+
const streamDir = composeDir ?? (dataDir ? dirname(dataDir) : null);
|
|
84
|
+
const status = porcelain(cwd); // '' clean, null no-repo, non-empty dirty
|
|
85
|
+
return {
|
|
86
|
+
capturedAt: new Date().toISOString(),
|
|
87
|
+
git: {
|
|
88
|
+
head: head(cwd),
|
|
89
|
+
branch: branch(cwd),
|
|
90
|
+
dirty: typeof status === 'string' && status.length > 0,
|
|
91
|
+
dirtyHash: dirtyHash(cwd),
|
|
92
|
+
},
|
|
93
|
+
phaseArtifacts: {
|
|
94
|
+
design: artifactPath(featureDir, PHASE_ARTIFACT_FILES.design),
|
|
95
|
+
blueprint: artifactPath(featureDir, PHASE_ARTIFACT_FILES.blueprint),
|
|
96
|
+
plan: artifactPath(featureDir, PHASE_ARTIFACT_FILES.plan),
|
|
97
|
+
implementFiles: [],
|
|
98
|
+
contracts: [],
|
|
99
|
+
},
|
|
100
|
+
testRef: null,
|
|
101
|
+
buildStreamSeq: lastBuildStreamSeq(streamDir),
|
|
102
|
+
flowId,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Names of phase artifacts that are present (non-null) in a fingerprint's
|
|
108
|
+
* phaseArtifacts. Used to detect artifact removal between captures.
|
|
109
|
+
*/
|
|
110
|
+
function presentArtifacts(fp) {
|
|
111
|
+
const pa = fp.phaseArtifacts ?? {};
|
|
112
|
+
return Object.keys(PHASE_ARTIFACT_FILES).filter((k) => pa[k] != null);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pure drift classifier.
|
|
117
|
+
*
|
|
118
|
+
* @param {object|null} prev Prior fingerprint (null if none).
|
|
119
|
+
* @param {object} curr Live fingerprint.
|
|
120
|
+
* @returns {'clean'|'advanced'|'diverged'}
|
|
121
|
+
*
|
|
122
|
+
* Rules:
|
|
123
|
+
* - !prev → 'clean' (nothing to drift from).
|
|
124
|
+
* - same head AND same dirtyHash → 'clean'.
|
|
125
|
+
* - else if clean tree AND head moved AND no prior artifact was removed → 'advanced'.
|
|
126
|
+
* - else → 'diverged'.
|
|
127
|
+
*/
|
|
128
|
+
export function classify(prev, curr) {
|
|
129
|
+
if (!prev) return 'clean';
|
|
130
|
+
|
|
131
|
+
const p = prev.git ?? {};
|
|
132
|
+
const c = curr.git ?? {};
|
|
133
|
+
|
|
134
|
+
if (p.head === c.head && p.dirtyHash === c.dirtyHash) return 'clean';
|
|
135
|
+
|
|
136
|
+
if (c.dirty === false && c.head !== p.head) {
|
|
137
|
+
// Every artifact that existed in prev must still exist in curr.
|
|
138
|
+
const prevPresent = presentArtifacts(prev);
|
|
139
|
+
const currPresent = new Set(presentArtifacts(curr));
|
|
140
|
+
const allRetained = prevPresent.every((name) => currPresent.has(name));
|
|
141
|
+
if (allRetained) return 'advanced';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return 'diverged';
|
|
145
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COMP-RESUME S2 — thin git wrapper for environment fingerprinting.
|
|
3
|
+
*
|
|
4
|
+
* A deliberately small spawnSync wrapper (pattern: lib/bug-bisect.js `git()`).
|
|
5
|
+
* Every helper returns trimmed stdout or null on failure, so callers outside a
|
|
6
|
+
* git repo (or with git unavailable) degrade gracefully rather than throwing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run a git command, return trimmed stdout. Returns null on any non-zero exit
|
|
14
|
+
* or other failure (e.g. not a git repo, git missing).
|
|
15
|
+
* @param {string} cwd
|
|
16
|
+
* @param {string[]} args
|
|
17
|
+
* @returns {string|null}
|
|
18
|
+
*/
|
|
19
|
+
export function git(cwd, args) {
|
|
20
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
21
|
+
if (r.status !== 0) return null;
|
|
22
|
+
return (r.stdout ?? '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Current HEAD sha, or null outside a repo. */
|
|
26
|
+
export function head(cwd) {
|
|
27
|
+
return git(cwd, ['rev-parse', 'HEAD']);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Current branch name (or 'HEAD' when detached), null outside a repo. */
|
|
31
|
+
export function branch(cwd) {
|
|
32
|
+
return git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Working-tree status in porcelain form. '' when the tree is clean, null when
|
|
37
|
+
* not a git repo. (Note: empty string is "clean", null is "no repo" — callers
|
|
38
|
+
* must distinguish the two.)
|
|
39
|
+
*/
|
|
40
|
+
export function porcelain(cwd) {
|
|
41
|
+
return git(cwd, ['status', '--porcelain']);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Deterministic hash of the working-tree state: sha256 of
|
|
46
|
+
* `git status --porcelain` concatenated with `git diff`.
|
|
47
|
+
*
|
|
48
|
+
* Returns null when (a) not a git repo, or (b) the tree is clean (porcelain is
|
|
49
|
+
* the empty string) — there is no drift to fingerprint.
|
|
50
|
+
* @returns {string|null}
|
|
51
|
+
*/
|
|
52
|
+
export function dirtyHash(cwd) {
|
|
53
|
+
const status = porcelain(cwd);
|
|
54
|
+
// null → not a repo; '' → clean tree. Either way, no dirty hash.
|
|
55
|
+
if (status === null || status === '') return null;
|
|
56
|
+
const diff = git(cwd, ['diff']) ?? '';
|
|
57
|
+
return createHash('sha256').update(status).update(diff).digest('hex');
|
|
58
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COMP-RESUME S6 — agent prompt builders (pure string functions).
|
|
3
|
+
*
|
|
4
|
+
* These build the prompts handed to the scribe and reconciliation agents.
|
|
5
|
+
* They are PURE: no fs, no git, no network, no imports from other checkpoint
|
|
6
|
+
* modules. The caller captures the fingerprint and passes it in; these
|
|
7
|
+
* functions only format it into instructions.
|
|
8
|
+
*
|
|
9
|
+
* Design anchors:
|
|
10
|
+
* - Decision 1/4: the scribe writes ONLY the soft layer {goal,nextStep,risks},
|
|
11
|
+
* merged onto a fresh anchor by the caller.
|
|
12
|
+
* - Decision 2/4: the fingerprint records, never interprets. The scribe must
|
|
13
|
+
* NOT assert verdicts ("tests pass"); every factual claim must reference an
|
|
14
|
+
* anchor in the provided fingerprint (e.g. point at testRef).
|
|
15
|
+
* - Decision 5: the reconciliation agent treats the live ENVIRONMENT as ground
|
|
16
|
+
* truth; the stale checkpoint is advisory and may be wrong. It emits a synced
|
|
17
|
+
* checkpoint {soft, confidence, resumeAction} and lowers confidence when
|
|
18
|
+
* uncertain.
|
|
19
|
+
*
|
|
20
|
+
* @see docs/features/COMP-RESUME/blueprint.md (slice S6)
|
|
21
|
+
* @see docs/features/COMP-RESUME/design.md (Decisions 1, 2, 4, 5)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pretty-print a value as a fenced JSON block for embedding in a prompt.
|
|
26
|
+
* Falls back to String(value) if the value isn't serializable.
|
|
27
|
+
* @param {unknown} value
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
function jsonBlock(value) {
|
|
31
|
+
let body;
|
|
32
|
+
try {
|
|
33
|
+
body = JSON.stringify(value ?? null, null, 2);
|
|
34
|
+
} catch {
|
|
35
|
+
body = String(value);
|
|
36
|
+
}
|
|
37
|
+
return '```json\n' + body + '\n```';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Render the git portion of a fingerprint as a compact human-readable line.
|
|
42
|
+
* @param {object} fp - EnvFingerprint
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function gitSummary(fp) {
|
|
46
|
+
const git = (fp && fp.git) || {};
|
|
47
|
+
const head = git.head == null ? '(no repo)' : git.head;
|
|
48
|
+
const branch = git.branch == null ? '(detached/none)' : git.branch;
|
|
49
|
+
const dirty = git.dirty ? 'dirty' : 'clean';
|
|
50
|
+
return `head=${head} branch=${branch} tree=${dirty}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the scribe prompt.
|
|
55
|
+
*
|
|
56
|
+
* The scribe is invoked at major boundaries to author the soft layer. It must
|
|
57
|
+
* return ONLY a JSON object `{ "goal": string, "nextStep": string, "risks":
|
|
58
|
+
* string[] }` and nothing else, with every factual claim anchored to the
|
|
59
|
+
* provided fingerprint (never a remembered verdict).
|
|
60
|
+
*
|
|
61
|
+
* @param {object} args
|
|
62
|
+
* @param {object} args.fingerprint - EnvFingerprint captured at this boundary (ground truth).
|
|
63
|
+
* @param {string} [args.journalTail] - Recent journal/build-stream tail for context.
|
|
64
|
+
* @param {object|null} [args.priorCheckpoint] - The previous narrative checkpoint, if any.
|
|
65
|
+
* @returns {string} prompt text
|
|
66
|
+
*/
|
|
67
|
+
export function scribePrompt({ fingerprint, journalTail = '', priorCheckpoint = null }) {
|
|
68
|
+
const fp = fingerprint || {};
|
|
69
|
+
const sections = [];
|
|
70
|
+
|
|
71
|
+
sections.push(
|
|
72
|
+
'You are the Compose **scribe**. Your sole job is to record the *intent* of the current build state — what the goal is, what the single next step is, and what risks are live right now.',
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
sections.push(
|
|
76
|
+
[
|
|
77
|
+
'CRITICAL RULES:',
|
|
78
|
+
'1. Return ONLY a single JSON object and nothing else — no prose, no markdown, no code fences. The object MUST be exactly:',
|
|
79
|
+
jsonBlock({ goal: 'string', nextStep: 'string', risks: ['string'] }),
|
|
80
|
+
'2. The ENVIRONMENT FINGERPRINT below is ground truth. Every factual claim you make MUST reference an anchor that appears in the fingerprint (a git head/branch, an artifact path, the testRef). Do NOT invent state that is not anchored there.',
|
|
81
|
+
"3. Do NOT claim results or verdicts. Specifically: do NOT claim tests pass or fail — reference the fingerprint's testRef (the raw test-output path) instead and let the reader inspect it. The fingerprint records what exists; it never interprets.",
|
|
82
|
+
'4. `goal` and `nextStep` are required and must be non-empty. `risks` is an array (use [] if none).',
|
|
83
|
+
].join('\n'),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
sections.push(
|
|
87
|
+
[
|
|
88
|
+
'ENVIRONMENT FINGERPRINT (ground truth — anchor every claim to this):',
|
|
89
|
+
`Git: ${gitSummary(fp)}`,
|
|
90
|
+
jsonBlock(fp),
|
|
91
|
+
].join('\n'),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (journalTail && String(journalTail).trim()) {
|
|
95
|
+
sections.push(
|
|
96
|
+
['RECENT JOURNAL / BUILD-STREAM TAIL (context only — not authoritative):', String(journalTail).trim()].join('\n'),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (priorCheckpoint && priorCheckpoint.soft) {
|
|
101
|
+
const soft = priorCheckpoint.soft;
|
|
102
|
+
sections.push(
|
|
103
|
+
[
|
|
104
|
+
'PRIOR NARRATIVE CHECKPOINT (the last recorded intent — may be stale; reconcile against the fingerprint):',
|
|
105
|
+
`- goal: ${soft.goal ?? ''}`,
|
|
106
|
+
`- nextStep: ${soft.nextStep ?? ''}`,
|
|
107
|
+
Array.isArray(soft.risks) && soft.risks.length
|
|
108
|
+
? `- risks: ${soft.risks.join('; ')}`
|
|
109
|
+
: '- risks: (none recorded)',
|
|
110
|
+
].join('\n'),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
sections.push('Now emit the JSON object describing the current intent, and nothing else.');
|
|
115
|
+
|
|
116
|
+
return sections.join('\n\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Build the reconciliation prompt.
|
|
121
|
+
*
|
|
122
|
+
* Used on resume only when the environment has DIVERGED from the stale
|
|
123
|
+
* checkpoint. The agent treats the live environment as ground truth, reconciles
|
|
124
|
+
* the stale checkpoint's intent against what the environment shows now, and
|
|
125
|
+
* returns ONLY a JSON object `{ "soft": {goal,nextStep,risks}, "confidence":
|
|
126
|
+
* number 0..1, "resumeAction": string }`.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} args
|
|
129
|
+
* @param {object} args.staleCheckpoint - The last stored checkpoint (advisory, may be wrong).
|
|
130
|
+
* @param {object} args.liveFingerprint - The freshly captured EnvFingerprint (ground truth).
|
|
131
|
+
* @param {string} [args.envScan] - Extra environment scan text (diffs, file listings, test output excerpt).
|
|
132
|
+
* @returns {string} prompt text
|
|
133
|
+
*/
|
|
134
|
+
export function reconcilePrompt({ staleCheckpoint, liveFingerprint, envScan = '' }) {
|
|
135
|
+
const stale = staleCheckpoint || {};
|
|
136
|
+
const staleSoft = stale.soft || {};
|
|
137
|
+
const live = liveFingerprint || {};
|
|
138
|
+
const sections = [];
|
|
139
|
+
|
|
140
|
+
sections.push(
|
|
141
|
+
'You are the Compose **reconciliation agent**. A build is being resumed after an interruption. Your job is to reconcile the recorded intent against the current environment and produce a corrected, synced checkpoint.',
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
sections.push(
|
|
145
|
+
[
|
|
146
|
+
'GROUND TRUTH RULE:',
|
|
147
|
+
'The live ENVIRONMENT (git state + on-disk artifacts + logs) is GROUND TRUTH. The stale checkpoint below is ADVISORY ONLY and may be wrong, out of date, or contradicted by the environment. When they disagree, the environment wins — every time.',
|
|
148
|
+
].join('\n'),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
sections.push(
|
|
152
|
+
[
|
|
153
|
+
'OUTPUT RULES:',
|
|
154
|
+
'Return ONLY a single JSON object and nothing else — no prose, no markdown, no code fences. The object MUST be exactly:',
|
|
155
|
+
jsonBlock({
|
|
156
|
+
soft: { goal: 'string', nextStep: 'string', risks: ['string'] },
|
|
157
|
+
confidence: 'number between 0 and 1',
|
|
158
|
+
resumeAction: 'string',
|
|
159
|
+
}),
|
|
160
|
+
'- `confidence` is a number from 0 to 1 expressing how sure you are that the synced intent matches the environment. Lower confidence when the environment is ambiguous, the divergence is large, or you cannot tell what was in progress.',
|
|
161
|
+
'- `resumeAction` is a short imperative describing the concrete next action to take to resume the build.',
|
|
162
|
+
'- `soft.goal` and `soft.nextStep` are required and must be non-empty; `soft.risks` is an array (use [] if none).',
|
|
163
|
+
].join('\n'),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
sections.push(
|
|
167
|
+
[
|
|
168
|
+
'LIVE ENVIRONMENT FINGERPRINT (ground truth):',
|
|
169
|
+
`Git: ${gitSummary(live)}`,
|
|
170
|
+
jsonBlock(live),
|
|
171
|
+
].join('\n'),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
sections.push(
|
|
175
|
+
[
|
|
176
|
+
'STALE CHECKPOINT (advisory — what the build *expected*; may be wrong):',
|
|
177
|
+
`- phase: ${stale.phase ?? '(unknown)'}`,
|
|
178
|
+
`- goal: ${staleSoft.goal ?? '(none recorded)'}`,
|
|
179
|
+
`- nextStep: ${staleSoft.nextStep ?? '(none recorded)'}`,
|
|
180
|
+
Array.isArray(staleSoft.risks) && staleSoft.risks.length
|
|
181
|
+
? `- risks: ${staleSoft.risks.join('; ')}`
|
|
182
|
+
: '- risks: (none recorded)',
|
|
183
|
+
'',
|
|
184
|
+
'Its captured fingerprint (what the environment looked like when this was recorded):',
|
|
185
|
+
jsonBlock(stale.fingerprint ?? null),
|
|
186
|
+
].join('\n'),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
if (envScan && String(envScan).trim()) {
|
|
190
|
+
sections.push(
|
|
191
|
+
['ADDITIONAL ENVIRONMENT SCAN (ground truth — diffs / listings / raw test output):', String(envScan).trim()].join(
|
|
192
|
+
'\n',
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
sections.push(
|
|
198
|
+
[
|
|
199
|
+
'TASK:',
|
|
200
|
+
'Compare what the environment shows NOW against what the checkpoint expected. Reconcile any divergence: where they conflict, follow the environment. Produce the corrected `soft` intent reflecting the current reality, set `resumeAction` to the next concrete step, and set `confidence` honestly — lower the confidence whenever you are uncertain or the divergence cannot be cleanly explained.',
|
|
201
|
+
'Now emit the JSON object, and nothing else.',
|
|
202
|
+
].join('\n'),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return sections.join('\n\n');
|
|
206
|
+
}
|