@cstart/coldstart 2.2.6 → 2.2.8
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/LICENSE +21 -0
- package/README.md +5 -1
- package/dist/constants.d.ts +9 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +12 -0
- package/dist/constants.js.map +1 -1
- package/dist/daemon-lock.d.ts +13 -0
- package/dist/daemon-lock.d.ts.map +1 -1
- package/dist/daemon-lock.js +31 -0
- package/dist/daemon-lock.js.map +1 -1
- package/dist/index.js +68 -4
- package/dist/index.js.map +1 -1
- package/dist/indexer/patch.d.ts.map +1 -1
- package/dist/indexer/patch.js +2 -3
- package/dist/indexer/patch.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +20 -6
- package/dist/init.js.map +1 -1
- package/dist/kb/cli.d.ts.map +1 -1
- package/dist/kb/cli.js +29 -0
- package/dist/kb/cli.js.map +1 -1
- package/dist/kb/view.d.ts +9 -1
- package/dist/kb/view.d.ts.map +1 -1
- package/dist/kb/view.js +16 -2
- package/dist/kb/view.js.map +1 -1
- package/dist/restart.d.ts +3 -2
- package/dist/restart.d.ts.map +1 -1
- package/dist/restart.js +6 -3
- package/dist/restart.js.map +1 -1
- package/dist/server/mcp.d.ts.map +1 -1
- package/dist/server/mcp.js +33 -3
- package/dist/server/mcp.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +11 -1
- package/dist/watcher.js.map +1 -1
- package/hooks/capture-payload.mjs +18 -10
- package/hooks/kb-elicit.mjs +57 -7
- package/hooks/trigger.mjs +33 -7
- package/package.json +4 -3
|
@@ -127,25 +127,27 @@ agent; there is no "next".
|
|
|
127
127
|
11. Never copy secret VALUES (env contents, tokens, keys) into a note — notes are committed \
|
|
128
128
|
to git.
|
|
129
129
|
|
|
130
|
-
FLOWS —
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
about how one file works? File-level → it belongs in that file's note. Product-level → flow.
|
|
136
|
-
Examples that can qualify:
|
|
130
|
+
FLOWS — the product-level layer. A flow note records knowledge no single file owns: how the \
|
|
131
|
+
system behaves as a whole. The test, for every candidate: is this about how the CODEBASE \
|
|
132
|
+
works, or about how one file works? File-level → it belongs in that file's note. \
|
|
133
|
+
Product-level → flow.
|
|
134
|
+
Examples that qualify:
|
|
137
135
|
- how authentication works end-to-end
|
|
138
136
|
- how UI components get rendered under different conditions
|
|
139
137
|
- how a file is consumed along a path when its imports don't reveal it and the gotcha spans \
|
|
140
138
|
the path (a single non-obvious consumer is NOT a flow — it goes in that file's own note, rule 6)
|
|
141
139
|
The summary's FIRST sentence states the product-level fact — the thing a reader of all the \
|
|
142
|
-
file notes would still be missing.
|
|
140
|
+
file notes would still be missing. If you cannot write that sentence, there is no flow.
|
|
143
141
|
Steps are the minimal chain, each with its role. kb write WARNS when fewer than two steps \
|
|
144
142
|
are files you read this session — a flow assembled from grep hits is the classic bad flow; \
|
|
145
143
|
take that warning seriously.
|
|
146
144
|
Never a flow: a feature's parts-list, a relationship the import graph already shows, a \
|
|
147
145
|
mechanism living in one file.
|
|
148
|
-
kb search first
|
|
146
|
+
kb search first. UPDATE an existing flow only when your missing-fact sentence is THE SAME \
|
|
147
|
+
fact it already leads with — new detail about a mechanism it already tells. A DIFFERENT \
|
|
148
|
+
missing fact is a NEW flow, even in the same subsystem, even across the same files. Folding \
|
|
149
|
+
an unrelated fact into a nearby flow buries it: nobody searching for your fact will find that \
|
|
150
|
+
title.
|
|
149
151
|
|
|
150
152
|
WRITE — one Bash block total: specs as heredocs, writes chained with &&.
|
|
151
153
|
{"type":"file-single","path":"src/x.py","summary":"1-3 sentences","aliases":["symptom words"]}
|
|
@@ -156,5 +158,11 @@ with several of its symbols. file-hub is ONLY for grab-bag files that have no si
|
|
|
156
158
|
not make a file a hub; having no one purpose does.
|
|
157
159
|
Update = the same spec plus "id":"<id from the worklist>".
|
|
158
160
|
node ${cli} kb write /tmp/spec1.json --root ${root} --session ${sid} --force
|
|
159
|
-
Flow/lesson shapes: run \`node ${cli} kb write --root ${root}\` with no spec — it prints the full guide
|
|
161
|
+
Flow/lesson shapes: run \`node ${cli} kb write --root ${root}\` with no spec — it prints the full guide.
|
|
162
|
+
|
|
163
|
+
FLOW DECISION — run this EVERY time, as its own command. It stands apart from the WRITE block \
|
|
164
|
+
above: run it even when you decided no note was warranted and emitted no Bash block at all. It \
|
|
165
|
+
records only what you decided about FLOWS (created a new one, folded into an existing one, or \
|
|
166
|
+
none) so the flow gate can be measured.
|
|
167
|
+
node ${cli} kb flow-decision --decision <none|new|update> [--id <flow id>] --why "<one clause>" --root ${root} --session ${sid}${tail}`;
|
|
160
168
|
}
|
package/hooks/kb-elicit.mjs
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { tmpdir } from "node:os";
|
|
32
|
-
import { join, resolve } from "node:path";
|
|
32
|
+
import { join, resolve, dirname } from "node:path";
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
34
|
import { existsSync, writeFileSync, appendFileSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
35
35
|
|
|
@@ -51,6 +51,31 @@ function log(msg) {
|
|
|
51
51
|
try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] elicit: ${msg}\n`); } catch { /* never fail logging */ }
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// --- Repo-root resolution ----------------------------------------------------
|
|
55
|
+
/**
|
|
56
|
+
* The Stop payload's `cwd` tracks the session's shell, which a `cd` inside a
|
|
57
|
+
* Bash call moves — sometimes clean OUT of the repo (e.g. into ~/.claude/…/memory
|
|
58
|
+
* to edit private files). Root was taken raw from that cwd, so normRel would then
|
|
59
|
+
* strip the WRONG prefix and admit a foreign absolute path into the worklist as if
|
|
60
|
+
* it were repo-local (observed 2026-07-25: MEMORY.md / drive.mjs entered a marker).
|
|
61
|
+
* A notebook-inited repo carries a `.coldstart/notebook/` dir, so walk up from cwd
|
|
62
|
+
* to the nearest ancestor that has one — that is the true root regardless of shell
|
|
63
|
+
* drift. `.coldstart/notebook` (not bare `.coldstart`) is the marker on purpose: the
|
|
64
|
+
* GLOBAL `~/.coldstart` dir holds daemon/searcher state and no notebook, so a drift
|
|
65
|
+
* into ~ must not resolve to the home directory.
|
|
66
|
+
* Returns "" when cwd is not inside any notebook-inited repo.
|
|
67
|
+
*/
|
|
68
|
+
function findRepoRoot(startDir) {
|
|
69
|
+
let dir = resolve(startDir || ".");
|
|
70
|
+
for (let i = 0; i < 40; i++) {
|
|
71
|
+
if (existsSync(join(dir, ".coldstart", "notebook"))) return dir;
|
|
72
|
+
const parent = dirname(dir);
|
|
73
|
+
if (parent === dir) break;
|
|
74
|
+
dir = parent;
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
// --- Subagent transcript resolution ------------------------------------------
|
|
55
80
|
/**
|
|
56
81
|
* Resolve a subagent's transcript, supporting both flat and nested layouts.
|
|
@@ -108,7 +133,13 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
108
133
|
// session: the freshest marker in tmpdir whose recorded (relative) files still
|
|
109
134
|
// resolve under <root> is this repo's active session. We then emit the SAME
|
|
110
135
|
// capture payload an automatic fire would, built from accumulated evidence.
|
|
111
|
-
//
|
|
136
|
+
//
|
|
137
|
+
// It marks the files it LISTED as captured (and nothing else). Without that, a
|
|
138
|
+
// manual capture left every file uncaptured, so the next automatic fire re-asked
|
|
139
|
+
// for notes on the exact same worklist the user had just written up. The trigger's
|
|
140
|
+
// own timing counters (armed / activeStops / stopsSinceFire / quietRun) are left
|
|
141
|
+
// untouched, which is what preserves the real guarantee: an on-demand capture
|
|
142
|
+
// cannot change WHEN automatic capture fires, only what is still outstanding.
|
|
112
143
|
function argValue(name) {
|
|
113
144
|
const i = process.argv.indexOf(name);
|
|
114
145
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
@@ -129,7 +160,7 @@ function freshestMarkerUnderRoot(root) {
|
|
|
129
160
|
if (!files.length) continue;
|
|
130
161
|
// Belongs to THIS repo iff a recorded file still resolves under root.
|
|
131
162
|
if (files.some((rel) => existsSync(join(root, rel)))) {
|
|
132
|
-
return { sid: m.n.slice("coldstart-kb-".length, -"-main.json".length), state };
|
|
163
|
+
return { sid: m.n.slice("coldstart-kb-".length, -"-main.json".length), state, path: m.p };
|
|
133
164
|
}
|
|
134
165
|
}
|
|
135
166
|
return null;
|
|
@@ -148,7 +179,7 @@ if (process.argv.includes("--manual")) {
|
|
|
148
179
|
log(`MANUAL no-marker root=${root}`);
|
|
149
180
|
process.exit(0);
|
|
150
181
|
}
|
|
151
|
-
const { sid, state } = found;
|
|
182
|
+
const { sid, state, path: markerPath } = found;
|
|
152
183
|
const files = Object.keys(state.files).filter((rel) => !state.files[rel].captured);
|
|
153
184
|
if (!files.length) {
|
|
154
185
|
process.stdout.write("Everything worked on so far is already captured — nothing new to write right now.\n");
|
|
@@ -157,6 +188,17 @@ if (process.argv.includes("--manual")) {
|
|
|
157
188
|
}
|
|
158
189
|
const entries = worklistEntries(CLI, root, files, state.files, log);
|
|
159
190
|
const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "manual" });
|
|
191
|
+
// Mark ONLY the listed files captured. Re-read first: a Stop hook may have
|
|
192
|
+
// written the marker since we loaded it, and we must not clobber its
|
|
193
|
+
// counters. Best-effort — a failure here costs a duplicate ask, never the
|
|
194
|
+
// payload the user asked for.
|
|
195
|
+
try {
|
|
196
|
+
const live = JSON.parse(readFileSync(markerPath, "utf8"));
|
|
197
|
+
if (live && live.files) {
|
|
198
|
+
for (const rel of files) if (live.files[rel]) live.files[rel].captured = true;
|
|
199
|
+
writeFileSync(markerPath, JSON.stringify(live));
|
|
200
|
+
}
|
|
201
|
+
} catch (e) { log(`MANUAL mark-captured skipped ${e}`); }
|
|
160
202
|
logCaptureEvent(root, { event: "fire", reason: "manual", session: sid, files: files.length });
|
|
161
203
|
log(`FIRE manual session=${sid} files=${files.length}`);
|
|
162
204
|
process.stdout.write(payload + "\n");
|
|
@@ -176,8 +218,8 @@ if (process.argv.includes("--manual")) {
|
|
|
176
218
|
} catch (e) { log(`bad stdin ${e}`); }
|
|
177
219
|
|
|
178
220
|
try {
|
|
179
|
-
const
|
|
180
|
-
setLogRoot(
|
|
221
|
+
const cwd = String(input.cwd || "");
|
|
222
|
+
setLogRoot(cwd);
|
|
181
223
|
|
|
182
224
|
// Guard 1: already inside a hook-induced continuation → let it stop.
|
|
183
225
|
if (input.stop_hook_active === true) { log("SKIP stop_hook_active"); process.exit(0); }
|
|
@@ -212,7 +254,6 @@ if (process.argv.includes("--manual")) {
|
|
|
212
254
|
}
|
|
213
255
|
if (!transcriptPath || !existsSync(transcriptPath)) { log("SKIP no-transcript"); process.exit(0); }
|
|
214
256
|
|
|
215
|
-
const ignore = loadIgnore(root);
|
|
216
257
|
const marker = join(tmpdir(), `coldstart-kb-${sid}-${aid}.json`);
|
|
217
258
|
let state = null;
|
|
218
259
|
try {
|
|
@@ -222,6 +263,15 @@ if (process.argv.includes("--manual")) {
|
|
|
222
263
|
const freshMarker = !state; // no valid prior marker: we're attaching, not resuming our own place
|
|
223
264
|
if (!state) state = initialState();
|
|
224
265
|
|
|
266
|
+
// Freeze the repo root in the marker: derive it once (walk-up from cwd), then
|
|
267
|
+
// reuse the stored value on every later stop so a mid-session `cd` out of the
|
|
268
|
+
// repo can never re-point root at a foreign tree. Fall back to the raw cwd only
|
|
269
|
+
// when neither a stored root nor a `.coldstart` ancestor exists.
|
|
270
|
+
const root = (typeof state.root === "string" && state.root) ? state.root : (findRepoRoot(cwd) || cwd);
|
|
271
|
+
state.root = root;
|
|
272
|
+
setLogRoot(root);
|
|
273
|
+
const ignore = loadIgnore(root);
|
|
274
|
+
|
|
225
275
|
// This stop's transcript slice (everything since the last processed line).
|
|
226
276
|
const text = readFileSync(transcriptPath, "utf8");
|
|
227
277
|
const lines = text.split("\n");
|
package/hooks/trigger.mjs
CHANGED
|
@@ -3,18 +3,34 @@
|
|
|
3
3
|
* clock, no client specifics. The hook feeds it one stop's observations and
|
|
4
4
|
* it returns the updated state + a fire decision.
|
|
5
5
|
*
|
|
6
|
-
* FROZEN SPEC (2026-07-15, replay + wave-lab validated; 2026-07-21 descent/synthesis update
|
|
6
|
+
* FROZEN SPEC (2026-07-15, replay + wave-lab validated; 2026-07-21 descent/synthesis update;
|
|
7
|
+
* 2026-07-24 "option C" — see the REGRESSION note below):
|
|
7
8
|
* score = uncaptured contentRead files ×1
|
|
8
9
|
* + settled edited files ×2 (settled = 3 active stops w/o re-edit)
|
|
9
|
-
* +
|
|
10
|
+
* + ALL stops since last fire (every stop is engagement; see below)
|
|
10
11
|
* fresh-noted files contribute NOTHING — genuinely new knowledge drives firing.
|
|
11
|
-
* arm at score ≥ T(10), requiring ≥
|
|
12
|
+
* arm at score ≥ T(10), requiring ≥1 active stop AND ≥1 uncaptured file.
|
|
12
13
|
* fire armed + descent (1 quiet stop) → non-blocking (inject)
|
|
13
|
-
* score ≥ CAP(20)
|
|
14
|
+
* score ≥ CAP(20), ≥2 uncaptured files → non-blocking (backlog
|
|
14
15
|
* rescue — replay showed dense sessions starve descent and hit cap
|
|
15
16
|
* repeatedly; blocking each cap re-created the v4 agitation)
|
|
16
17
|
* .git/HEAD drift, ≥2 uncaptured files → BLOCKING (instant —
|
|
17
18
|
* the one boundary where waiting for a next prompt loses the moment)
|
|
19
|
+
*
|
|
20
|
+
* REGRESSION FIXED 2026-07-24 (why the stop term is ALL stops, not active ones):
|
|
21
|
+
* The 2026-07-21 change made synthesis turns count as QUIET so they could feed
|
|
22
|
+
* descent. Correct for descent — but the score's stop term read `activeStops`,
|
|
23
|
+
* so the same edit silently DELETED the only score-growth term a discussion
|
|
24
|
+
* session has. Measured consequence: descent fired ZERO times in this repo's
|
|
25
|
+
* entire history (104 stops / 24 fires — all head-drift or cap). A session that
|
|
26
|
+
* reads 3 files then discusses them for 8 turns sat at score 4 forever.
|
|
27
|
+
* The two questions are now asked of different counters, which is the point:
|
|
28
|
+
* "has enough happened?" → score, counting EVERY stop (engagement)
|
|
29
|
+
* "has it wound down?" → quietRun, counting only non-active stops
|
|
30
|
+
* The ≥2-active-stops arming gate went with it: a discussion session bursts its
|
|
31
|
+
* file reads in ONE stop, so that gate alone would have kept the fix inert.
|
|
32
|
+
* Blocking paths (head-drift) keep MIN_FILES=2 — relaxing those to 1 re-creates
|
|
33
|
+
* the v4 single-file agitation this design exists to kill.
|
|
18
34
|
* NEVER: first-stop fire, wall-clock, gap/resume, conversation classification.
|
|
19
35
|
* (surge removed 2026-07-21: with descent=1 a quiet stop fires descent before
|
|
20
36
|
* surge could ever apply — the two were redundant. cap + head-drift remain the
|
|
@@ -29,13 +45,19 @@ export const T_ARM = 10;
|
|
|
29
45
|
export const T_CAP = 20;
|
|
30
46
|
export const SETTLE_ACTIVE_STOPS = 3;
|
|
31
47
|
export const DESCENT_QUIET = 1;
|
|
48
|
+
/** Uncaptured-file floor for the BLOCKING/backlog paths (head-drift, cap).
|
|
49
|
+
* Stays at 2 deliberately: a one-file blocking prompt is the v4 agitation. */
|
|
32
50
|
export const MIN_FILES = 2;
|
|
51
|
+
/** Floor for the non-blocking paths (arm → descent). One real file is enough to
|
|
52
|
+
* be worth a note, and descent only ever injects. */
|
|
53
|
+
export const MIN_FILES_ARM = 1;
|
|
33
54
|
|
|
34
55
|
export function initialState() {
|
|
35
56
|
return {
|
|
36
57
|
v: 2,
|
|
37
58
|
stop: 0, // stops processed
|
|
38
|
-
activeStops: 0, // ACTIVE stops since last fire
|
|
59
|
+
activeStops: 0, // ACTIVE stops since last fire (settle clock + arming floor)
|
|
60
|
+
stopsSinceFire: 0, // ALL stops since last fire (score's engagement term)
|
|
39
61
|
quietRun: 0, // consecutive quiet stops
|
|
40
62
|
armed: false,
|
|
41
63
|
fires: 0,
|
|
@@ -99,6 +121,9 @@ export function step(state, obs) {
|
|
|
99
121
|
// so it counts as quiet, not active.
|
|
100
122
|
const active = newFiles > 0 || editsThisStop > 0;
|
|
101
123
|
if (active) { s.activeStops++; s.quietRun = 0; } else { s.quietRun++; }
|
|
124
|
+
// Engagement, counted separately from "work happened". `|| 0` tolerates
|
|
125
|
+
// markers written before this field existed (a live session mid-upgrade).
|
|
126
|
+
s.stopsSinceFire = (s.stopsSinceFire || 0) + 1;
|
|
102
127
|
for (const f of Object.values(s.files)) {
|
|
103
128
|
if (f.lastEditActive === -2) f.lastEditActive = s.activeStops; // edit stamped at current active count
|
|
104
129
|
}
|
|
@@ -109,10 +134,10 @@ export function step(state, obs) {
|
|
|
109
134
|
const settledEdits = uncaptured.filter(([, f]) =>
|
|
110
135
|
f.edits > 0 && f.lastEditActive >= 0 && s.activeStops - f.lastEditActive >= SETTLE_ACTIVE_STOPS,
|
|
111
136
|
).length;
|
|
112
|
-
const score = readPts + settledEdits * 2 + s.
|
|
137
|
+
const score = readPts + settledEdits * 2 + s.stopsSinceFire;
|
|
113
138
|
|
|
114
139
|
// ---- arm / fire ----------------------------------------------------------------
|
|
115
|
-
if (!s.armed && score >= T_ARM && s.activeStops >=
|
|
140
|
+
if (!s.armed && score >= T_ARM && s.activeStops >= 1 && uncaptured.length >= MIN_FILES_ARM) {
|
|
116
141
|
s.armed = true;
|
|
117
142
|
}
|
|
118
143
|
|
|
@@ -134,6 +159,7 @@ export function step(state, obs) {
|
|
|
134
159
|
for (const [, f] of uncaptured) f.captured = true;
|
|
135
160
|
s.armed = false;
|
|
136
161
|
s.activeStops = 0;
|
|
162
|
+
s.stopsSinceFire = 0;
|
|
137
163
|
s.quietRun = 0;
|
|
138
164
|
s.fires++;
|
|
139
165
|
decision = { ...fire, files, score };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cstart/coldstart",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.8",
|
|
4
4
|
"mcpName": "io.github.AkashGoenka/coldstart",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
"tree-sitter"
|
|
40
40
|
],
|
|
41
41
|
"license": "MIT",
|
|
42
|
+
"author": "Akash Goenka (https://github.com/AkashGoenka)",
|
|
42
43
|
"homepage": "https://akashgoenka.github.io/coldstart/",
|
|
43
44
|
"repository": {
|
|
44
45
|
"type": "git",
|
|
45
|
-
"url": "https://github.com/AkashGoenka/coldstart"
|
|
46
|
+
"url": "git+https://github.com/AkashGoenka/coldstart.git"
|
|
46
47
|
},
|
|
47
48
|
"bugs": {
|
|
48
49
|
"url": "https://github.com/AkashGoenka/coldstart/issues"
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
"test:watch": "vitest"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
57
|
-
"@modelcontextprotocol/sdk": "
|
|
58
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
58
59
|
"@vscode/ripgrep": "^1.18.0",
|
|
59
60
|
"web-tree-sitter": "^0.26.10"
|
|
60
61
|
},
|