@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.
@@ -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 — rare. While investigating you will see flows everywhere: every file connects to \
131
- something, every feature has a chain. Those connections are not flow notes at that rate, \
132
- everything would be a flow.
133
- A flow note records PRODUCT-level knowledge: how the system behaves as a whole, knowledge no \
134
- single file owns. The test, for every candidate: is this about how the CODEBASE works, or \
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 update the existing flow, never a near-duplicate.
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.${tail}`;
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
  }
@@ -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
- // READ-ONLY — never mutates the marker, so it cannot perturb automatic firing.
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 root = String(input.cwd || "");
180
- setLogRoot(root);
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
- * + active stops since last fire (new files or edits only; synthesis no longer counts as active)
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 ≥2 active stops AND ≥2 uncaptured files.
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) → non-blocking (backlog
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.activeStops;
137
+ const score = readPts + settledEdits * 2 + s.stopsSinceFire;
113
138
 
114
139
  // ---- arm / fire ----------------------------------------------------------------
115
- if (!s.armed && score >= T_ARM && s.activeStops >= 2 && uncaptured.length >= MIN_FILES) {
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.6",
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": "latest",
58
+ "@modelcontextprotocol/sdk": "^1.29.0",
58
59
  "@vscode/ripgrep": "^1.18.0",
59
60
  "web-tree-sitter": "^0.26.10"
60
61
  },