@cstart/coldstart 2.2.14 → 2.2.16
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/dist/init.d.ts +16 -3
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +49 -7
- package/dist/init.js.map +1 -1
- package/dist/kb/cli.d.ts.map +1 -1
- package/dist/kb/cli.js +72 -49
- package/dist/kb/cli.js.map +1 -1
- package/dist/kb/durable-worklist.d.ts +20 -0
- package/dist/kb/durable-worklist.d.ts.map +1 -0
- package/dist/kb/durable-worklist.js +175 -0
- package/dist/kb/durable-worklist.js.map +1 -0
- package/dist/kb/store.d.ts.map +1 -1
- package/dist/kb/store.js +24 -3
- package/dist/kb/store.js.map +1 -1
- package/dist/kb/write-batch.d.ts +21 -0
- package/dist/kb/write-batch.d.ts.map +1 -0
- package/dist/kb/write-batch.js +77 -0
- package/dist/kb/write-batch.js.map +1 -0
- package/dist/kb/write-guide.d.ts.map +1 -1
- package/dist/kb/write-guide.js +23 -13
- package/dist/kb/write-guide.js.map +1 -1
- package/dist/kb/write.d.ts +4 -0
- package/dist/kb/write.d.ts.map +1 -1
- package/dist/kb/write.js +10 -1
- package/dist/kb/write.js.map +1 -1
- package/dist/server/mcp.d.ts +12 -1
- package/dist/server/mcp.d.ts.map +1 -1
- package/dist/server/mcp.js +53 -35
- package/dist/server/mcp.js.map +1 -1
- package/hooks/capture-payload.mjs +229 -85
- package/hooks/capture-sentinel.d.mts +4 -0
- package/hooks/capture-sentinel.mjs +10 -0
- package/hooks/codex-kb-elicit.mjs +6 -2
- package/hooks/codex-kb-recall.mjs +54 -5
- package/hooks/cursor-kb-elicit.mjs +8 -4
- package/hooks/cursor-kb-recall.mjs +34 -1
- package/hooks/kb-elicit.mjs +81 -34
- package/package.json +1 -1
- package/dist/kb/session-worklist.d.ts +0 -10
- package/dist/kb/session-worklist.d.ts.map +0 -1
- package/dist/kb/session-worklist.js +0 -92
- package/dist/kb/session-worklist.js.map +0 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* capture-sentinel.mjs — the marker `/capture-notes` command bodies carry so a
|
|
3
|
+
* host's own pre-prompt recall hook can recognize "this submitted prompt IS
|
|
4
|
+
* the capture command" and run `kb-elicit.mjs --manual` itself with the real
|
|
5
|
+
* session id it already has (Cursor/Codex have no way to pass a session id
|
|
6
|
+
* INTO the command the way Claude's `!`-expansion does). Wording in the
|
|
7
|
+
* command body can drift; this string must not — it is grepped verbatim
|
|
8
|
+
* against the raw submitted prompt.
|
|
9
|
+
*/
|
|
10
|
+
export const CAPTURE_SENTINEL = "<!-- coldstart:capture-notes -->";
|
|
@@ -105,7 +105,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
105
105
|
const ignore = loadIgnore(root);
|
|
106
106
|
// Session-cumulative v2 state: which files were already offered, and how
|
|
107
107
|
// far into the rollout the last Stop read (a resumed thread appends).
|
|
108
|
-
|
|
108
|
+
// Unified marker namespace (2026-08-06): manual /capture-notes always ran
|
|
109
|
+
// the SHARED kb-elicit.mjs --manual, which reads coldstart-kb-<sid>-<aid>.json
|
|
110
|
+
// — a per-host prefix here meant it could never see this host's own
|
|
111
|
+
// automatic evidence. One prefix for all three hosts fixes that outright.
|
|
112
|
+
const marker = join(tmpdir(), `coldstart-kb-${sid}-${aid}.json`);
|
|
109
113
|
let state = null;
|
|
110
114
|
try {
|
|
111
115
|
const parsed = JSON.parse(readFileSync(marker, "utf8"));
|
|
@@ -140,7 +144,7 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
140
144
|
|
|
141
145
|
const entries = worklistEntries(CLI, root, fresh, state.files, log);
|
|
142
146
|
const payload = buildCapturePayload({
|
|
143
|
-
root, cli: CLI, sid, entries,
|
|
147
|
+
root, cli: CLI, sid, aid, entries,
|
|
144
148
|
envelope: isSubagent ? "subagent" : "block",
|
|
145
149
|
});
|
|
146
150
|
logCaptureEvent(root, {
|
|
@@ -18,6 +18,17 @@
|
|
|
18
18
|
* arriving via PRs are a prompt-injection surface; the framing line is the
|
|
19
19
|
* cheap mitigation.
|
|
20
20
|
*
|
|
21
|
+
* MANUAL CAPTURE ON-DEMAND (2026-08-06): Codex's `/capture-notes` skill has no
|
|
22
|
+
* way to pass its own real session id into `kb-elicit.mjs --manual` the way
|
|
23
|
+
* Claude's `!`-expansion does — the skill body used to just ask the agent to
|
|
24
|
+
* run the command itself, blind to which session it's in. But THIS hook
|
|
25
|
+
* receives a real `input.session_id` on every prompt. So: when the submitted
|
|
26
|
+
* prompt IS the capture skill (its body carries a stable sentinel,
|
|
27
|
+
* CAPTURE_SENTINEL), run `--manual --session <real sid>` right here and inject
|
|
28
|
+
* its output — deterministic, no LLM has to type a correct `--session <id>`.
|
|
29
|
+
* The skill body keeps "run this in the terminal" as a fallback for when this
|
|
30
|
+
* hook is disabled or fails. See hooks/capture-sentinel.mjs.
|
|
31
|
+
*
|
|
21
32
|
* Self-contained + fail-open: ANY error → exit 0 with no stdout → nothing
|
|
22
33
|
* injected, the prompt proceeds untouched.
|
|
23
34
|
*/
|
|
@@ -29,6 +40,7 @@ import { tmpdir } from "node:os";
|
|
|
29
40
|
import { fileURLToPath } from "node:url";
|
|
30
41
|
// Strip host telemetry wrappers before the query is built — see recall-query.mjs.
|
|
31
42
|
import { recallQuery } from "./recall-query.mjs";
|
|
43
|
+
import { CAPTURE_SENTINEL } from "./capture-sentinel.mjs";
|
|
32
44
|
// Per-session dedup shared with the other recall hooks — see recall-seen.mjs.
|
|
33
45
|
// Hosts with no transcript path never reset; dedup still holds for the session.
|
|
34
46
|
import { readSeen, writeSeen, idsInPage, seenArgs } from "./recall-seen.mjs";
|
|
@@ -78,11 +90,43 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
78
90
|
|
|
79
91
|
const sid = String(input.session_id || "").replace(/[^\w-]/g, "");
|
|
80
92
|
|
|
81
|
-
//
|
|
82
|
-
|
|
93
|
+
// The submitted prompt IS /capture-notes (its skill body carries this
|
|
94
|
+
// sentinel) → run the on-demand capture ourselves with the real session id
|
|
95
|
+
// this hook already has, instead of leaving the agent to guess one.
|
|
96
|
+
let manual = "";
|
|
97
|
+
if (sid && String(input.prompt || "").includes(CAPTURE_SENTINEL)) {
|
|
98
|
+
try {
|
|
99
|
+
const elicit = fileURLToPath(new URL("./kb-elicit.mjs", import.meta.url));
|
|
100
|
+
manual = execFileSync("node", [elicit, "--manual", "--session", sid, "--root", root], {
|
|
101
|
+
encoding: "utf8",
|
|
102
|
+
timeout: 8000,
|
|
103
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
104
|
+
}).trim();
|
|
105
|
+
if (manual) log(`MANUAL-INJECT session=${sid} bytes=${manual.length}`);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
log(`manual-capture-inject failed: ${String(e).split("\n")[0]}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Below this point, every early exit falls back to delivering the manual
|
|
112
|
+
// capture alone (if we have one) instead of exiting silently — a recall
|
|
113
|
+
// miss must never swallow an explicit /capture-notes.
|
|
114
|
+
const deliverManualOnlyAndExit = (why) => {
|
|
115
|
+
if (!manual) process.exit(0);
|
|
116
|
+
log(`INJECT bytes=${manual.length} (manual only, ${why})`);
|
|
117
|
+
process.stdout.write(JSON.stringify({
|
|
118
|
+
hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: manual },
|
|
119
|
+
}));
|
|
120
|
+
process.exit(0);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// No notebook → no recall tax, not even a child process (the manual
|
|
124
|
+
// capture above is independent of the notebook existing — it's what
|
|
125
|
+
// creates the first note).
|
|
126
|
+
if (!existsSync(join(root, ".coldstart", "notebook", ".raw"))) deliverManualOnlyAndExit("no notebook yet");
|
|
83
127
|
|
|
84
128
|
const prompt = recallQuery(input.prompt, MAX_QUERY_CHARS);
|
|
85
|
-
if (!prompt)
|
|
129
|
+
if (!prompt) deliverManualOnlyAndExit("empty query");
|
|
86
130
|
|
|
87
131
|
// Ephemeral Codex runs expose no rollout path; readSeen then never resets,
|
|
88
132
|
// which is the safe direction — dedup simply holds for the whole session.
|
|
@@ -99,12 +143,12 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
99
143
|
});
|
|
100
144
|
} catch (e) {
|
|
101
145
|
log(`search failed/timed out: ${String(e).split("\n")[0]}`);
|
|
102
|
-
|
|
146
|
+
deliverManualOnlyAndExit("search failed");
|
|
103
147
|
}
|
|
104
148
|
|
|
105
149
|
if (!page.trim() || page.startsWith("No notebook notes match") || page.startsWith("No notebook in")) {
|
|
106
150
|
log(`no hits (promptChars=${prompt.length})`);
|
|
107
|
-
|
|
151
|
+
deliverManualOnlyAndExit("no recall hits");
|
|
108
152
|
}
|
|
109
153
|
|
|
110
154
|
// Record what this page hands over before the size guards can trim it — a
|
|
@@ -140,6 +184,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
140
184
|
// never exceed 8.5KB.
|
|
141
185
|
if (block.length > 8500) block = block.slice(0, 8500) + "\n…(truncated)";
|
|
142
186
|
|
|
187
|
+
// An explicit /capture-notes rides FIRST — it's what the user asked for
|
|
188
|
+
// this turn. If the combination would spill past the host's payload cap,
|
|
189
|
+
// recall yields — the capture checklist must arrive whole.
|
|
190
|
+
if (manual) block = manual.length + block.length > 9500 ? manual : `${manual}\n\n---\n\n${block}`;
|
|
191
|
+
|
|
143
192
|
// Arm the PostToolUse nudge detectors (nudge-handler.mjs gates its spiral
|
|
144
193
|
// detectors on seen_find so it never nags sessions that don't use coldstart).
|
|
145
194
|
// An injected session IS coldstart-aware even if it never runs `find` — the
|
|
@@ -121,7 +121,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
121
121
|
if (!transcriptPath || !existsSync(transcriptPath)) { log("SKIP no-transcript"); process.exit(0); }
|
|
122
122
|
|
|
123
123
|
const ignore = loadIgnore(root);
|
|
124
|
-
|
|
124
|
+
// Unified marker namespace (2026-08-06): manual /capture-notes always ran
|
|
125
|
+
// the SHARED kb-elicit.mjs --manual, which reads coldstart-kb-<sid>-<aid>.json
|
|
126
|
+
// — a per-host prefix here meant it could never see this host's own
|
|
127
|
+
// automatic evidence. One prefix for all three hosts fixes that outright.
|
|
128
|
+
const marker = join(tmpdir(), `coldstart-kb-${sid}-${aid}.json`);
|
|
125
129
|
let state = null;
|
|
126
130
|
try {
|
|
127
131
|
const parsed = JSON.parse(readFileSync(marker, "utf8"));
|
|
@@ -152,7 +156,7 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
152
156
|
writeFileSync(marker, JSON.stringify(state));
|
|
153
157
|
if (!fresh.length) { log(`FAST-EXIT subagent no-new-files session=${sid} agent=${aid}`); process.exit(0); }
|
|
154
158
|
const entries = worklistEntries(CLI, root, fresh, Object.fromEntries(fresh.map((rel) => [rel, delta.get(rel)])), log);
|
|
155
|
-
const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "subagent" });
|
|
159
|
+
const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "subagent" });
|
|
156
160
|
logCaptureEvent(root, { event: "fire", reason: "subagent", session: sid, agent: aid, files: fresh.length, host: "cursor" });
|
|
157
161
|
log(`FIRE subagent session=${sid} agent=${aid} files=${fresh.length}`);
|
|
158
162
|
process.stdout.write(JSON.stringify({ followup_message: payload }));
|
|
@@ -188,11 +192,11 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
188
192
|
log(`FIRE ${decision.fire} mode=${decision.mode} session=${sid} score=${decision.score} files=${decision.files.length}`);
|
|
189
193
|
|
|
190
194
|
if (decision.mode === "block") {
|
|
191
|
-
const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "block" });
|
|
195
|
+
const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "block" });
|
|
192
196
|
process.stdout.write(JSON.stringify({ followup_message: payload }));
|
|
193
197
|
} else {
|
|
194
198
|
// Non-blocking: cursor-kb-recall delivers this with the user's next prompt.
|
|
195
|
-
const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "inject" });
|
|
199
|
+
const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "inject" });
|
|
196
200
|
writePendingCapture(sid, decision.fire, payload);
|
|
197
201
|
}
|
|
198
202
|
} catch (e) {
|
|
@@ -16,6 +16,17 @@
|
|
|
16
16
|
* injection channel used here. If a future Cursor build stops honoring it,
|
|
17
17
|
* recall silently no-ops (fail-open) — nav + capture are unaffected.
|
|
18
18
|
*
|
|
19
|
+
* MANUAL CAPTURE ON-DEMAND (2026-08-06): Cursor's `/capture-notes` command has
|
|
20
|
+
* no `!`-expansion (unlike Claude), so it can't pass its own real session id to
|
|
21
|
+
* `kb-elicit.mjs --manual` the way Claude does — the command body used to just
|
|
22
|
+
* ask the agent to run the command itself, blind to which session it's in. But
|
|
23
|
+
* THIS hook receives a real `input.session_id` on every prompt. So: when the
|
|
24
|
+
* submitted prompt IS the capture command (its body carries a stable sentinel,
|
|
25
|
+
* CAPTURE_SENTINEL), run `--manual --session <real sid>` right here and inject
|
|
26
|
+
* its output the same way a pending automatic fire is injected — deterministic,
|
|
27
|
+
* no LLM has to type a correct `--session <id>`. The command body keeps "run
|
|
28
|
+
* this in the terminal" as a fallback for when this hook is disabled/fails.
|
|
29
|
+
*
|
|
19
30
|
* Self-contained + fail-open: ANY error → exit 0, no stdout → nothing injected.
|
|
20
31
|
*/
|
|
21
32
|
|
|
@@ -31,6 +42,7 @@ import { cursorRoot } from "./cursor-input.mjs";
|
|
|
31
42
|
import { takePendingCapture } from "./elicit-core.mjs";
|
|
32
43
|
// Strip host telemetry wrappers before the query is built — see recall-query.mjs.
|
|
33
44
|
import { recallQuery } from "./recall-query.mjs";
|
|
45
|
+
import { CAPTURE_SENTINEL } from "./capture-sentinel.mjs";
|
|
34
46
|
// Per-session dedup shared with the other recall hooks — see recall-seen.mjs.
|
|
35
47
|
// Hosts with no transcript path never reset; dedup still holds for the session.
|
|
36
48
|
import { readSeen, writeSeen, idsInPage, seenArgs } from "./recall-seen.mjs";
|
|
@@ -83,7 +95,28 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
83
95
|
// A pending capture (non-blocking fire at a previous stop) is delivered
|
|
84
96
|
// regardless of recall hits — it must not depend on the notebook existing
|
|
85
97
|
// (the first capture is what creates it).
|
|
86
|
-
|
|
98
|
+
let pending = takePendingCapture(sid);
|
|
99
|
+
|
|
100
|
+
// The submitted prompt IS /capture-notes (its command body carries this
|
|
101
|
+
// sentinel) → run the on-demand capture ourselves with the real session id
|
|
102
|
+
// this hook already has, instead of leaving the agent to guess one. See
|
|
103
|
+
// the file header and hooks/capture-sentinel.mjs.
|
|
104
|
+
if (sid && String(input.prompt || "").includes(CAPTURE_SENTINEL)) {
|
|
105
|
+
try {
|
|
106
|
+
const elicit = fileURLToPath(new URL("./kb-elicit.mjs", import.meta.url));
|
|
107
|
+
const manual = execFileSync("node", [elicit, "--manual", "--session", sid, "--root", root], {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
timeout: 8000,
|
|
110
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
111
|
+
});
|
|
112
|
+
if (manual.trim()) {
|
|
113
|
+
pending = pending ? `${manual.trim()}\n\n---\n\n${pending}` : manual.trim();
|
|
114
|
+
log(`MANUAL-INJECT session=${sid} bytes=${manual.length}`);
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
log(`manual-capture-inject failed: ${String(e).split("\n")[0]}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
87
120
|
|
|
88
121
|
const prompt = recallQuery(input.prompt, MAX_QUERY_CHARS);
|
|
89
122
|
|
package/hooks/kb-elicit.mjs
CHANGED
|
@@ -36,7 +36,7 @@ import { existsSync, writeFileSync, appendFileSync, readFileSync, readdirSync, s
|
|
|
36
36
|
import { extractEvidence, segmentStats } from "./evidence.mjs";
|
|
37
37
|
import { initialState, step } from "./trigger.mjs";
|
|
38
38
|
import { loadIgnore } from "./ignore.mjs";
|
|
39
|
-
import { buildCapturePayload } from "./capture-payload.mjs";
|
|
39
|
+
import { buildCapturePayload, worklistJsonPath } from "./capture-payload.mjs";
|
|
40
40
|
import {
|
|
41
41
|
worklistEntries, freshNotedSet, gitHead, logCaptureEvent, writePendingCapture, MAX_WORKLIST,
|
|
42
42
|
} from "./elicit-core.mjs";
|
|
@@ -129,10 +129,12 @@ process.on("unhandledRejection", (e) => { log(`unhandled ${e?.stack || e}`); pro
|
|
|
129
129
|
|
|
130
130
|
// --- Manual capture (`--manual --root <dir>`) --------------------------------
|
|
131
131
|
// The `/capture-notes` command runs this to fire capture ON DEMAND, bypassing
|
|
132
|
-
// the trigger score gate. There is no hook stdin, so
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
132
|
+
// the trigger score gate. There is no hook stdin, so there is no session id to
|
|
133
|
+
// trust by default. When the host names it (Claude passes `--session
|
|
134
|
+
// ${CLAUDE_SESSION_ID}`), that session's marker is resolved exactly, never
|
|
135
|
+
// guessed among several. Hosts with no session-id variable on their command
|
|
136
|
+
// surface (Cursor, Codex) pass none; soleMarkerUnderRoot below covers the
|
|
137
|
+
// common single-session case for them without ever guessing wrong.
|
|
136
138
|
//
|
|
137
139
|
// It marks the files it LISTED as captured (and nothing else). Without that, a
|
|
138
140
|
// manual capture left every file uncaptured, so the next automatic fire re-asked
|
|
@@ -144,41 +146,77 @@ function argValue(name) {
|
|
|
144
146
|
const i = process.argv.indexOf(name);
|
|
145
147
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
146
148
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
149
|
+
// The main-agent evidence marker for a KNOWN session id. Returns null when the
|
|
150
|
+
// sid is empty or its marker is absent/foreign (files don't resolve under root).
|
|
151
|
+
function markerForSession(root, sid) {
|
|
152
|
+
if (!sid) return null;
|
|
153
|
+
const p = join(tmpdir(), `coldstart-kb-${sid}-main.json`);
|
|
154
|
+
let state;
|
|
155
|
+
try { state = JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
|
|
156
|
+
const files = state && state.files ? Object.keys(state.files) : [];
|
|
157
|
+
if (!files.length || !files.some((rel) => existsSync(join(root, rel)))) return null;
|
|
158
|
+
return { sid, state, path: p };
|
|
159
|
+
}
|
|
160
|
+
// Hosts with no verified session-id variable on their command surface (Cursor,
|
|
161
|
+
// Codex — their commands are plain prompt templates the agent itself runs, with
|
|
162
|
+
// no `!`-style substitution to carry a session id into the invocation) can't name
|
|
163
|
+
// a session at all. Guessing among several is the wrong-session risk `--session`
|
|
164
|
+
// exists to avoid, but when exactly ONE main-agent marker under this root exists
|
|
165
|
+
// there is nothing to disambiguate — restore that single-session case instead of
|
|
166
|
+
// refusing outright. `-main.json` only: subagent markers never own a worklist.
|
|
167
|
+
function soleMarkerUnderRoot(root) {
|
|
168
|
+
const re = /^coldstart-kb-(.+)-main\.json$/;
|
|
169
|
+
let entries;
|
|
170
|
+
try { entries = readdirSync(tmpdir()); } catch { return { marker: null, ambiguous: false }; }
|
|
171
|
+
const candidates = [];
|
|
172
|
+
for (const name of entries) {
|
|
173
|
+
const m = re.exec(name);
|
|
174
|
+
if (!m) continue;
|
|
175
|
+
const found = markerForSession(root, m[1]);
|
|
176
|
+
if (found) candidates.push(found);
|
|
165
177
|
}
|
|
166
|
-
return
|
|
178
|
+
if (candidates.length === 1) return { marker: candidates[0], ambiguous: false };
|
|
179
|
+
if (candidates.length > 1) return { marker: null, ambiguous: true };
|
|
180
|
+
return { marker: null, ambiguous: false };
|
|
167
181
|
}
|
|
168
182
|
if (process.argv.includes("--manual")) {
|
|
169
183
|
try {
|
|
170
184
|
const rootArg = argValue("--root");
|
|
171
185
|
const root = rootArg ? resolve(rootArg) : process.cwd();
|
|
172
186
|
setLogRoot(root);
|
|
173
|
-
const
|
|
174
|
-
|
|
187
|
+
const sidArg = String(argValue("--session") || "").replace(/[^A-Za-z0-9_-]/g, "");
|
|
188
|
+
let found = sidArg ? markerForSession(root, sidArg) : null;
|
|
189
|
+
if (!sidArg) {
|
|
190
|
+
const sole = soleMarkerUnderRoot(root);
|
|
191
|
+
if (sole.ambiguous) {
|
|
192
|
+
process.stdout.write(
|
|
193
|
+
"Multiple sessions have notebook-capture evidence in this repo, and this host can't tell\n" +
|
|
194
|
+
"/capture-notes which one you mean — pass --session <id> if your host exposes one, or close\n" +
|
|
195
|
+
"the other sessions working here so only one remains.\n",
|
|
196
|
+
);
|
|
197
|
+
log(`MANUAL ambiguous-no-session root=${root}`);
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
found = sole.marker;
|
|
201
|
+
if (found) log(`MANUAL sole-marker-fallback sid=${found.sid} root=${root}`);
|
|
202
|
+
}
|
|
203
|
+
if (!sidArg && !found) {
|
|
175
204
|
process.stdout.write(
|
|
176
205
|
"No notebook-capture evidence for this repo yet — it accrues as you read and edit files.\n" +
|
|
177
206
|
"Do a turn or two of real work here, then run /capture-notes again.\n",
|
|
178
207
|
);
|
|
179
|
-
log(`MANUAL no-
|
|
208
|
+
log(`MANUAL no-session root=${root}`);
|
|
209
|
+
process.exit(0);
|
|
210
|
+
}
|
|
211
|
+
if (!found) {
|
|
212
|
+
process.stdout.write(
|
|
213
|
+
"No notebook-capture evidence for THIS session in this repo yet — it accrues as you read\n" +
|
|
214
|
+
"and edit files. Do a turn or two of real work here, then run /capture-notes again.\n",
|
|
215
|
+
);
|
|
216
|
+
log(`MANUAL no-marker-for-session sid=${sidArg} root=${root}`);
|
|
180
217
|
process.exit(0);
|
|
181
218
|
}
|
|
219
|
+
log(`MANUAL session sid=${found.sid} root=${root}`);
|
|
182
220
|
const { sid, state, path: markerPath } = found;
|
|
183
221
|
// A manual /capture-notes is an EXPLICIT request to write up THIS session's
|
|
184
222
|
// work, so its scope is everything worked on — not merely what an automatic
|
|
@@ -189,10 +227,17 @@ if (process.argv.includes("--manual")) {
|
|
|
189
227
|
// note unwritten and a now-stale note unfixed (the Bug-1 report). So:
|
|
190
228
|
// include every edited file regardless of the flag, and drop only READ-ONLY
|
|
191
229
|
// files that were already offered (re-listing those on demand is just
|
|
192
|
-
// noise)
|
|
193
|
-
//
|
|
230
|
+
// noise) AND whose worklist is still live. A captured read-only file whose
|
|
231
|
+
// durable worklist pair is gone (write failure, or hand-deleted since) has
|
|
232
|
+
// no live checklist anywhere naming it — re-including it here is the only
|
|
233
|
+
// way it is ever offered again, and it can't double-nag since there is
|
|
234
|
+
// nothing else currently showing it. Rank most-worked first (edits →
|
|
235
|
+
// retouches → reads) so new/edited files lead; the per-file annotations
|
|
236
|
+
// flag when a fresh note needs nothing.
|
|
237
|
+
const worklistLost = !existsSync(worklistJsonPath(root, sid, "main"));
|
|
238
|
+
if (worklistLost) log(`MANUAL worklist-artifact-missing session=${sid} — recovering captured read-only files too`);
|
|
194
239
|
const files = Object.entries(state.files)
|
|
195
|
-
.filter(([, f]) => (f.reads + f.edits + f.gs) > 0 && (f.edits > 0 || !f.captured))
|
|
240
|
+
.filter(([, f]) => (f.reads + f.edits + f.gs) > 0 && (f.edits > 0 || !f.captured || worklistLost))
|
|
196
241
|
.sort((a, b) => (b[1].edits - a[1].edits)
|
|
197
242
|
|| ((b[1].retouches || 0) - (a[1].retouches || 0))
|
|
198
243
|
|| (b[1].reads - a[1].reads))
|
|
@@ -203,7 +248,9 @@ if (process.argv.includes("--manual")) {
|
|
|
203
248
|
process.exit(0);
|
|
204
249
|
}
|
|
205
250
|
const entries = worklistEntries(CLI, root, files, state.files, log);
|
|
206
|
-
|
|
251
|
+
// Manual capture resolves the invoking session's MAIN marker (markerForSession
|
|
252
|
+
// targets `-main.json`), so its worklist is the main agent's.
|
|
253
|
+
const payload = buildCapturePayload({ root, cli: CLI, sid, aid: "main", entries, envelope: "manual" });
|
|
207
254
|
// Mark ONLY the files actually LISTED (the worklist caps its display to
|
|
208
255
|
// MAX_WORKLIST) captured — any overflow stays uncaptured so it rolls into
|
|
209
256
|
// the next capture rather than being marked done but never shown. Re-read
|
|
@@ -345,7 +392,7 @@ if (process.argv.includes("--manual")) {
|
|
|
345
392
|
writeFileSync(marker, JSON.stringify(state));
|
|
346
393
|
if (!fresh.length) { log(`FAST-EXIT subagent no-new-files session=${sid} agent=${aid}`); process.exit(0); }
|
|
347
394
|
const entries = worklistEntries(CLI, root, fresh, Object.fromEntries(fresh.map((rel) => [rel, delta.get(rel)])), log);
|
|
348
|
-
const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "subagent" });
|
|
395
|
+
const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "subagent" });
|
|
349
396
|
logCaptureEvent(root, { event: "fire", reason: "subagent", session: sid, agent: aid, files: fresh.length });
|
|
350
397
|
log(`FIRE subagent session=${sid} agent=${aid} files=${fresh.length}`);
|
|
351
398
|
process.stdout.write(JSON.stringify({ decision: "block", reason: payload }));
|
|
@@ -381,11 +428,11 @@ if (process.argv.includes("--manual")) {
|
|
|
381
428
|
log(`FIRE ${decision.fire} mode=${decision.mode} session=${sid} score=${decision.score} files=${decision.files.length}`);
|
|
382
429
|
|
|
383
430
|
if (decision.mode === "block") {
|
|
384
|
-
const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "block" });
|
|
431
|
+
const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "block" });
|
|
385
432
|
process.stdout.write(JSON.stringify({ decision: "block", reason: payload }));
|
|
386
433
|
} else {
|
|
387
434
|
// Non-blocking: kb-recall delivers this with the user's next prompt.
|
|
388
|
-
const payload = buildCapturePayload({ root, cli: CLI, sid, entries, envelope: "inject" });
|
|
435
|
+
const payload = buildCapturePayload({ root, cli: CLI, sid, aid, entries, envelope: "inject" });
|
|
389
436
|
writePendingCapture(sid, decision.fire, payload);
|
|
390
437
|
}
|
|
391
438
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/** Paths this spec puts a note on. Flow steps do NOT count: a flow verifies
|
|
2
|
-
* files, it does not give any of them the file note rule 5 asks for. */
|
|
3
|
-
export declare function specPaths(spec: unknown): string[];
|
|
4
|
-
/**
|
|
5
|
-
* Record this write against the session worklist and return the coverage line
|
|
6
|
-
* to print, or null when there is no manifest to compare against (no --session,
|
|
7
|
-
* a manual `kb write` outside capture, a session that never armed).
|
|
8
|
-
*/
|
|
9
|
-
export declare function noteCoverage(sid: string | undefined, spec: unknown): string | null;
|
|
10
|
-
//# sourceMappingURL=session-worklist.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"session-worklist.d.ts","sourceRoot":"","sources":["../../src/kb/session-worklist.ts"],"names":[],"mappings":"AA8CA;yEACyE;AACzE,wBAAgB,SAAS,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,CAIjD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CA8BlF"}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Capture coverage — telling the writing agent how much of its worklist it noted.
|
|
3
|
-
*
|
|
4
|
-
* The gap this closes: the capture checklist hands the agent a worklist of every
|
|
5
|
-
* file it worked on, but `kb write` only ever sees ONE spec, so nothing could
|
|
6
|
-
* report "you wrote 8 notes for 30 worked files". The agent's own count is the
|
|
7
|
-
* one thing it cannot check — it is mid-heredoc, and under-capture is silent by
|
|
8
|
-
* construction. So the hook drops the worklist in tmpdir when it builds the
|
|
9
|
-
* capture prompt, and every `kb write --session <sid>` prints the running ratio.
|
|
10
|
-
*
|
|
11
|
-
* CONTRACT TWIN: hooks/capture-payload.mjs (worklistManifestPath) writes this
|
|
12
|
-
* file; keep the name and shape in step. Everything here is best-effort — a
|
|
13
|
-
* missing, stale or malformed manifest prints nothing and never fails a write.
|
|
14
|
-
*
|
|
15
|
-
* Local only. The manifest lives in tmpdir, never in the repo: it is scratch
|
|
16
|
-
* state for one session, not a record, and nothing about it is transmitted.
|
|
17
|
-
*/
|
|
18
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
-
import { tmpdir } from 'node:os';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
-
/** Manifests older than this are a resumed/unrelated session — ignore them. */
|
|
22
|
-
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
23
|
-
/** Below this share of outstanding files, name the skips. */
|
|
24
|
-
const LOW_COVERAGE = 0.5;
|
|
25
|
-
const LIST_MAX = 6;
|
|
26
|
-
function manifestPath(sid) {
|
|
27
|
-
return join(tmpdir(), `coldstart-kb-worklist-${sid}.json`);
|
|
28
|
-
}
|
|
29
|
-
function load(sid) {
|
|
30
|
-
try {
|
|
31
|
-
const m = JSON.parse(readFileSync(manifestPath(sid), 'utf8'));
|
|
32
|
-
if (!Array.isArray(m?.files) || !m.files.length)
|
|
33
|
-
return null;
|
|
34
|
-
if (typeof m.ts === 'number' && Date.now() - m.ts > MAX_AGE_MS)
|
|
35
|
-
return null;
|
|
36
|
-
m.wrote = Array.isArray(m.wrote) ? m.wrote : [];
|
|
37
|
-
return m;
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
return null;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
/** Paths this spec puts a note on. Flow steps do NOT count: a flow verifies
|
|
44
|
-
* files, it does not give any of them the file note rule 5 asks for. */
|
|
45
|
-
export function specPaths(spec) {
|
|
46
|
-
const s = spec;
|
|
47
|
-
if (!s || typeof s.path !== 'string')
|
|
48
|
-
return [];
|
|
49
|
-
return s.type === 'file-single' || s.type === 'file-hub' ? [s.path] : [];
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Record this write against the session worklist and return the coverage line
|
|
53
|
-
* to print, or null when there is no manifest to compare against (no --session,
|
|
54
|
-
* a manual `kb write` outside capture, a session that never armed).
|
|
55
|
-
*/
|
|
56
|
-
export function noteCoverage(sid, spec) {
|
|
57
|
-
if (!sid)
|
|
58
|
-
return null;
|
|
59
|
-
const m = load(sid);
|
|
60
|
-
if (!m)
|
|
61
|
-
return null;
|
|
62
|
-
for (const p of specPaths(spec))
|
|
63
|
-
if (!m.wrote.includes(p))
|
|
64
|
-
m.wrote.push(p);
|
|
65
|
-
try {
|
|
66
|
-
writeFileSync(manifestPath(sid), JSON.stringify(m));
|
|
67
|
-
}
|
|
68
|
-
catch { /* best-effort */ }
|
|
69
|
-
const outstanding = m.files.filter((f) => f.needsNote);
|
|
70
|
-
if (!outstanding.length)
|
|
71
|
-
return null;
|
|
72
|
-
const wrote = new Set(m.wrote);
|
|
73
|
-
const done = outstanding.filter((f) => wrote.has(f.path));
|
|
74
|
-
const left = outstanding.filter((f) => !wrote.has(f.path));
|
|
75
|
-
const already = m.files.length - outstanding.length;
|
|
76
|
-
const lines = [
|
|
77
|
-
`capture coverage: ${done.length} of ${outstanding.length} worklist files noted` +
|
|
78
|
-
(already ? ` (${already} more already had a fresh note)` : ''),
|
|
79
|
-
];
|
|
80
|
-
if (left.length) {
|
|
81
|
-
const shown = left.slice(0, LIST_MAX).map((f) => `${f.path} [${f.tier}]`).join(', ');
|
|
82
|
-
lines.push(` not noted yet: ${shown}${left.length > LIST_MAX ? `, +${left.length - LIST_MAX} more` : ''}`);
|
|
83
|
-
}
|
|
84
|
-
// The nudge fires only on the low end, and asks for a REASON rather than more
|
|
85
|
-
// notes: a deliberate skip is a fine answer, an invisible one is not. Chained
|
|
86
|
-
// writes each print this, so the last line of the block carries the real tally.
|
|
87
|
-
if (left.length && done.length / outstanding.length < LOW_COVERAGE) {
|
|
88
|
-
lines.push(' if those need no note, say which and why in your reply — an unexplained skip reads as a forgotten one.');
|
|
89
|
-
}
|
|
90
|
-
return lines.join('\n');
|
|
91
|
-
}
|
|
92
|
-
//# sourceMappingURL=session-worklist.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"session-worklist.js","sourceRoot":"","sources":["../../src/kb/session-worklist.ts"],"names":[],"mappings":"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"}
|