@cstart/coldstart 2.1.1 → 2.2.1
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/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +32 -0
- package/dist/cli.js.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +1 -6
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +45 -1
- package/dist/init.js.map +1 -1
- package/dist/kb/cli.d.ts.map +1 -1
- package/dist/kb/cli.js +11 -4
- package/dist/kb/cli.js.map +1 -1
- package/dist/kb/lint.d.ts +6 -1
- package/dist/kb/lint.d.ts.map +1 -1
- package/dist/kb/lint.js +28 -0
- package/dist/kb/lint.js.map +1 -1
- package/dist/kb/write-guide.d.ts +15 -0
- package/dist/kb/write-guide.d.ts.map +1 -0
- package/dist/kb/write-guide.js +112 -0
- package/dist/kb/write-guide.js.map +1 -0
- package/dist/server/mcp.d.ts +5 -1
- package/dist/server/mcp.d.ts.map +1 -1
- package/dist/server/mcp.js +15 -4
- package/dist/server/mcp.js.map +1 -1
- package/dist/unwire.d.ts.map +1 -1
- package/dist/unwire.js +2 -0
- package/dist/unwire.js.map +1 -1
- package/hooks/capture-payload.mjs +158 -0
- package/hooks/codex-kb-elicit.mjs +77 -272
- package/hooks/cursor-kb-elicit.mjs +121 -266
- package/hooks/cursor-kb-recall.mjs +39 -15
- package/hooks/elicit-core.mjs +126 -0
- package/hooks/evidence.mjs +318 -0
- package/hooks/ignore.mjs +84 -0
- package/hooks/kb-elicit.mjs +114 -288
- package/hooks/kb-recall.mjs +43 -17
- package/hooks/trigger.mjs +140 -0
- package/package.json +1 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* evidence.mjs — per-file evidence records from a session transcript.
|
|
3
|
+
*
|
|
4
|
+
* The primitive under both the capture worklist and the (pending) trigger
|
|
5
|
+
* score: for every repo file the session touched, WHAT KIND of contact was it?
|
|
6
|
+
*
|
|
7
|
+
* edit — Edit/Write/NotebookEdit (Edit's old_string proves content
|
|
8
|
+
* knowledge), or `sed -i`-style in-place bash edits
|
|
9
|
+
* read — Read tool (any window), or a bash command whose JOB is to
|
|
10
|
+
* print file content (cat/head/tail/sed -n/awk/…)
|
|
11
|
+
* gs — `coldstart gs <path>` (structure summary; skims count —
|
|
12
|
+
* the aim is file summaries)
|
|
13
|
+
* mention — the path appeared anywhere else: grep/rg output, ls, mv,
|
|
14
|
+
* an argument to a script, a token in some command. NOT a read.
|
|
15
|
+
*
|
|
16
|
+
* DEFAULT-DENY: an unknown bash verb contributes at most a mention. A missed
|
|
17
|
+
* read only drops a file from ONE worklist — it returns next session — while
|
|
18
|
+
* a mention promoted to "read" pollutes every worklist. The asymmetry is why
|
|
19
|
+
* this is winnable where the old deep-read gate (which FAST-EXITed whole
|
|
20
|
+
* sessions on its false negatives) was not.
|
|
21
|
+
*
|
|
22
|
+
* Bash-derived evidence is confirmed against the tool_result: a command whose
|
|
23
|
+
* result never arrived or errored (file not found, interrupt) contributes
|
|
24
|
+
* nothing. Read/Edit tool calls are confirmed the same way.
|
|
25
|
+
*
|
|
26
|
+
* Pure parser: no fs writes, no coldstart CLI calls. Disk existence checks
|
|
27
|
+
* for bash-derived path guesses are the only I/O.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { statSync } from "node:fs";
|
|
32
|
+
|
|
33
|
+
// Path-like tokens inside a shell command (same shape kb-elicit used):
|
|
34
|
+
// anything with an extension. Existence on disk is verified before a bash
|
|
35
|
+
// token becomes evidence — shell tokens are guesses.
|
|
36
|
+
const BASH_PATH_RE = /(?:^|[\s"'`=(:;|])((?:\.{1,2}\/|\/)?[A-Za-z0-9_][A-Za-z0-9_.\/-]*\.[A-Za-z0-9]{1,8})(?=$|[\s"'`):;,|>])/g;
|
|
37
|
+
|
|
38
|
+
// Verbs whose job is printing file content. Everything not listed here is a
|
|
39
|
+
// mention — including grep/rg (matched LINES are not the file) and
|
|
40
|
+
// interpreter invocations (`node x.js` runs a file, it doesn't read one).
|
|
41
|
+
const READ_VERBS = new Set(["cat", "head", "tail", "bat", "less", "more", "nl", "tac"]);
|
|
42
|
+
|
|
43
|
+
const TIER = { mention: 0, gs: 1, read: 2, edit: 3 };
|
|
44
|
+
|
|
45
|
+
function normRel(root, p) {
|
|
46
|
+
let s = String(p || "").trim();
|
|
47
|
+
if (!s) return "";
|
|
48
|
+
if (s.startsWith("/")) {
|
|
49
|
+
if (root && s.startsWith(root + "/")) return s.slice(root.length + 1);
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
return s.replace(/^\.\//, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Split a compound command into simple segments and classify each segment's
|
|
56
|
+
// path tokens by its leading verb. Best-effort shell reading — anything the
|
|
57
|
+
// parse can't place stays a mention.
|
|
58
|
+
function classifyBash(cmd) {
|
|
59
|
+
const out = []; // [{rel-candidate, tier}]
|
|
60
|
+
// coldstart gs is cross-segment-safe to grab globally
|
|
61
|
+
for (const g of String(cmd).matchAll(/coldstart\s+gs\s+(\S+)/g)) {
|
|
62
|
+
out.push({ token: g[1], tier: TIER.gs });
|
|
63
|
+
}
|
|
64
|
+
const segments = String(cmd).split(/\|\||&&|;|\|/);
|
|
65
|
+
for (const seg of segments) {
|
|
66
|
+
// strip leading env assignments (FOO=1 cmd …) and sudo/command wrappers
|
|
67
|
+
const words = seg.trim().split(/\s+/);
|
|
68
|
+
let vi = 0;
|
|
69
|
+
while (vi < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[vi]) || words[vi] === "sudo" || words[vi] === "command")) vi++;
|
|
70
|
+
const verb = (words[vi] || "").replace(/^.*\//, ""); // basename of the verb
|
|
71
|
+
let tier = TIER.mention;
|
|
72
|
+
if (READ_VERBS.has(verb)) tier = TIER.read;
|
|
73
|
+
else if (verb === "sed") tier = /(^|\s)-i\b/.test(seg) ? TIER.edit : TIER.read; // sed -n '1,80p' = windowed read; sed -i = in-place edit
|
|
74
|
+
else if (verb === "awk") tier = TIER.read;
|
|
75
|
+
let n = 0;
|
|
76
|
+
for (const m of seg.matchAll(BASH_PATH_RE)) {
|
|
77
|
+
if (++n > 12) break; // a single huge command must not dominate
|
|
78
|
+
out.push({ token: m[1], tier });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Shared per-file record collector — the same record shape for every host walker.
|
|
85
|
+
function makeCollector(root) {
|
|
86
|
+
const evidence = new Map();
|
|
87
|
+
const state = { ordinal: 0 };
|
|
88
|
+
const commit = (rel, tier, mustExist) => {
|
|
89
|
+
if (!rel || rel.startsWith(".coldstart/") || rel.includes("..")) return;
|
|
90
|
+
if (mustExist) {
|
|
91
|
+
try { if (!statSync(join(root, rel)).isFile()) return; } catch { return; }
|
|
92
|
+
}
|
|
93
|
+
let rec = evidence.get(rel);
|
|
94
|
+
if (!rec) {
|
|
95
|
+
rec = { reads: 0, edits: 0, gs: 0, mentions: 0, events: 0, firstEvent: state.ordinal, lastEvent: state.ordinal };
|
|
96
|
+
evidence.set(rel, rec);
|
|
97
|
+
}
|
|
98
|
+
if (tier === TIER.edit) rec.edits++;
|
|
99
|
+
else if (tier === TIER.read) rec.reads++;
|
|
100
|
+
else if (tier === TIER.gs) rec.gs++;
|
|
101
|
+
else rec.mentions++;
|
|
102
|
+
rec.events++;
|
|
103
|
+
rec.lastEvent = state.ordinal;
|
|
104
|
+
};
|
|
105
|
+
return { evidence, state, commit };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* extractEvidence(transcriptText, root) → Map<relPath, record>
|
|
110
|
+
* record = { reads, edits, gs, mentions, events, firstEvent, lastEvent }
|
|
111
|
+
* events/firstEvent/lastEvent are tool-call ordinals (for retouch ranking).
|
|
112
|
+
* Worklist eligibility = reads + edits + gs > 0 (contentRead); mentions never qualify.
|
|
113
|
+
*/
|
|
114
|
+
export function extractEvidence(transcriptText, root) {
|
|
115
|
+
const { evidence, state, commit } = makeCollector(root);
|
|
116
|
+
const pending = new Map(); // tool_use_id → [{rel, tier, mustExist}]
|
|
117
|
+
|
|
118
|
+
for (const line of transcriptText.split("\n")) {
|
|
119
|
+
if (!line.trim() || line[0] !== "{") continue;
|
|
120
|
+
let rec;
|
|
121
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
122
|
+
|
|
123
|
+
if (rec.type === "assistant") {
|
|
124
|
+
const content = rec.message?.content;
|
|
125
|
+
if (!Array.isArray(content)) continue;
|
|
126
|
+
for (const b of content) {
|
|
127
|
+
if (!b || b.type !== "tool_use") continue;
|
|
128
|
+
const inp = b.input || {};
|
|
129
|
+
const claims = [];
|
|
130
|
+
if (b.name === "Read") {
|
|
131
|
+
claims.push({ rel: normRel(root, inp.file_path), tier: TIER.read, mustExist: false });
|
|
132
|
+
} else if (b.name === "Edit" || b.name === "Write" || b.name === "NotebookEdit" || b.name === "MultiEdit") {
|
|
133
|
+
claims.push({ rel: normRel(root, inp.file_path || inp.notebook_path), tier: TIER.edit, mustExist: false });
|
|
134
|
+
} else if (b.name === "Bash") {
|
|
135
|
+
for (const c of classifyBash(String(inp.command || ""))) {
|
|
136
|
+
claims.push({ rel: normRel(root, c.token), tier: c.tier, mustExist: true });
|
|
137
|
+
}
|
|
138
|
+
} else if (b.name === "Grep" || b.name === "Glob") {
|
|
139
|
+
// native search tools: results are matches, not reads
|
|
140
|
+
const p = inp.path ? normRel(root, inp.path) : "";
|
|
141
|
+
if (p) claims.push({ rel: p, tier: TIER.mention, mustExist: true });
|
|
142
|
+
}
|
|
143
|
+
const kept = claims.filter((c) => c.rel);
|
|
144
|
+
if (kept.length) pending.set(b.id, kept);
|
|
145
|
+
}
|
|
146
|
+
} else if (rec.type === "user") {
|
|
147
|
+
const content = rec.message?.content;
|
|
148
|
+
if (!Array.isArray(content)) continue;
|
|
149
|
+
for (const b of content) {
|
|
150
|
+
if (!b || b.type !== "tool_result" || !pending.has(b.tool_use_id)) continue;
|
|
151
|
+
const claims = pending.get(b.tool_use_id);
|
|
152
|
+
pending.delete(b.tool_use_id);
|
|
153
|
+
if (b.is_error === true) continue; // errored call: the content never arrived
|
|
154
|
+
state.ordinal++;
|
|
155
|
+
for (const c of claims) commit(c.rel, c.tier, c.mustExist);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return evidence;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* extractCursorEvidence(transcriptText, root) — Cursor conversation JSONL.
|
|
164
|
+
*
|
|
165
|
+
* Records: {role:"assistant", message:{content:[{type:"tool_use", name, input}]}}
|
|
166
|
+
* with Claude-shaped tool names (Read/Shell/Grep/Glob/Write/Edit — verified on
|
|
167
|
+
* real transcripts 2026-07-17). Cursor's transcript carries NO tool_result
|
|
168
|
+
* records, so result confirmation is impossible on this host; the compensating
|
|
169
|
+
* control is a mustExist stat-check on EVERY claim (not just bash tokens) —
|
|
170
|
+
* a Read of a path that isn't a file on disk contributes nothing.
|
|
171
|
+
*/
|
|
172
|
+
export function extractCursorEvidence(transcriptText, root) {
|
|
173
|
+
const { evidence, state, commit } = makeCollector(root);
|
|
174
|
+
for (const line of transcriptText.split("\n")) {
|
|
175
|
+
if (!line.trim() || line[0] !== "{") continue;
|
|
176
|
+
let rec;
|
|
177
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
178
|
+
const content = rec.message?.content;
|
|
179
|
+
if (!Array.isArray(content)) continue;
|
|
180
|
+
for (const b of content) {
|
|
181
|
+
if (!b || b.type !== "tool_use") continue;
|
|
182
|
+
const inp = b.input || {};
|
|
183
|
+
state.ordinal++;
|
|
184
|
+
if (b.name === "Read") {
|
|
185
|
+
commit(normRel(root, inp.path || inp.file_path), TIER.read, true);
|
|
186
|
+
} else if (b.name === "Edit" || b.name === "Write" || b.name === "MultiEdit" || b.name === "SearchReplace") {
|
|
187
|
+
// On edits mustExist stays true: Cursor supplies no results, and a
|
|
188
|
+
// Write that never landed should not anchor a note. A genuinely new
|
|
189
|
+
// file exists on disk by the time the Stop hook runs.
|
|
190
|
+
commit(normRel(root, inp.path || inp.file_path), TIER.edit, true);
|
|
191
|
+
} else if (b.name === "Shell" || b.name === "Bash") {
|
|
192
|
+
for (const c of classifyBash(String(inp.command || ""))) {
|
|
193
|
+
commit(normRel(root, c.token), c.tier, true);
|
|
194
|
+
}
|
|
195
|
+
} else if (b.name === "Grep" || b.name === "Glob") {
|
|
196
|
+
const p = inp.path ? normRel(root, inp.path) : "";
|
|
197
|
+
if (p) commit(p, TIER.mention, true); // search hits are not reads
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return evidence;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Codex embeds shell invocations inside a JS tool script:
|
|
205
|
+
// tools.exec_command({"cmd":"<shell>", ...})
|
|
206
|
+
// The script is agent-authored JS, so the key appears both quoted ("cmd":) and
|
|
207
|
+
// unquoted (cmd:) across rollouts — accept either. Values are JSON-escaped.
|
|
208
|
+
function codexCmdStrings(input) {
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const m of String(input).matchAll(/(?:"cmd"|\bcmd)\s*:\s*"((?:[^"\\]|\\.)*)"/g)) {
|
|
211
|
+
try { out.push(JSON.parse(`"${m[1]}"`)); } catch { /* bad escape: skip */ }
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* extractCodexEvidence(transcriptText, root) — Codex rollout JSONL.
|
|
218
|
+
*
|
|
219
|
+
* Records: {type:"response_item", payload:{type:"custom_tool_call"|"function_call",
|
|
220
|
+
* name, call_id, input|arguments}} paired with *_output payloads by call_id —
|
|
221
|
+
* so Codex evidence IS result-confirmed, like Claude's. Tool surface (verified
|
|
222
|
+
* on real rollouts 2026-07-17): name "exec" wraps shell commands in a JS
|
|
223
|
+
* script (classifyBash over each extracted "cmd"); "apply_patch" carries
|
|
224
|
+
* `*** Update/Add File:` headers (edit tier). Anything else: default-deny —
|
|
225
|
+
* its path tokens are at most mentions.
|
|
226
|
+
*/
|
|
227
|
+
export function extractCodexEvidence(transcriptText, root) {
|
|
228
|
+
const { evidence, state, commit } = makeCollector(root);
|
|
229
|
+
const pending = new Map(); // call_id → [{rel, tier, mustExist}]
|
|
230
|
+
for (const line of transcriptText.split("\n")) {
|
|
231
|
+
if (!line.trim() || line[0] !== "{") continue;
|
|
232
|
+
let rec;
|
|
233
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
234
|
+
if (rec.type !== "response_item") continue;
|
|
235
|
+
const p = rec.payload || {};
|
|
236
|
+
if (p.type === "custom_tool_call" || p.type === "function_call") {
|
|
237
|
+
const input = typeof p.input === "string" ? p.input
|
|
238
|
+
: typeof p.arguments === "string" ? p.arguments
|
|
239
|
+
: JSON.stringify(p.input ?? p.arguments ?? "");
|
|
240
|
+
const claims = [];
|
|
241
|
+
if (p.name === "apply_patch" || /^\s*\*\*\* (?:Begin Patch|Update File|Add File)/m.test(input)) {
|
|
242
|
+
for (const m of input.matchAll(/\*\*\* (?:Update|Add) File:\s*([^\n\\"]+)/g)) {
|
|
243
|
+
claims.push({ rel: normRel(root, m[1].trim()), tier: TIER.edit, mustExist: true });
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
const cmds = codexCmdStrings(input);
|
|
247
|
+
if (cmds.length) {
|
|
248
|
+
for (const cmd of cmds) {
|
|
249
|
+
for (const c of classifyBash(cmd)) claims.push({ rel: normRel(root, c.token), tier: c.tier, mustExist: true });
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
// Unknown tool: default-deny — a `coldstart gs` stays gs (explicit
|
|
253
|
+
// signature), every other path token is a mention at most.
|
|
254
|
+
for (const c of classifyBash(input)) claims.push({ rel: normRel(root, c.token), tier: c.tier === TIER.gs ? TIER.gs : TIER.mention, mustExist: true });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const kept = claims.filter((c) => c.rel);
|
|
258
|
+
if (kept.length && p.call_id) pending.set(p.call_id, kept);
|
|
259
|
+
} else if (p.type === "custom_tool_call_output" || p.type === "function_call_output") {
|
|
260
|
+
const claims = pending.get(p.call_id);
|
|
261
|
+
if (!claims) continue;
|
|
262
|
+
pending.delete(p.call_id);
|
|
263
|
+
state.ordinal++;
|
|
264
|
+
for (const c of claims) commit(c.rel, c.tier, c.mustExist);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return evidence;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Files eligible for the capture worklist: actual content contact only. */
|
|
271
|
+
export function contentReadFiles(evidence) {
|
|
272
|
+
return [...evidence.entries()]
|
|
273
|
+
.filter(([, r]) => r.reads + r.edits + r.gs > 0)
|
|
274
|
+
.sort((a, b) => (b[1].edits - a[1].edits) || (b[1].events - a[1].events))
|
|
275
|
+
.map(([rel]) => rel);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* segmentStats(text) — synthesis detection for a transcript slice (the lines
|
|
280
|
+
* since the previous Stop). A synthesis turn is prose-heavy and tool-light:
|
|
281
|
+
* the agent is explaining/summarizing, which counts as an ACTIVE stop for the
|
|
282
|
+
* trigger even though it touched no new files.
|
|
283
|
+
*/
|
|
284
|
+
export function segmentStats(text) {
|
|
285
|
+
let toolCalls = 0;
|
|
286
|
+
let textBytes = 0;
|
|
287
|
+
for (const line of text.split("\n")) {
|
|
288
|
+
if (!line.trim() || line[0] !== "{") continue;
|
|
289
|
+
let rec;
|
|
290
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
291
|
+
if (rec.type !== "assistant") continue;
|
|
292
|
+
for (const b of rec.message?.content || []) {
|
|
293
|
+
if (!b) continue;
|
|
294
|
+
if (b.type === "tool_use") toolCalls++;
|
|
295
|
+
else if (b.type === "text") textBytes += String(b.text || "").length;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return { toolCalls, textBytes, synthesis: textBytes >= 1500 && toolCalls <= 2 };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** segmentStats for a Cursor transcript slice (assistant text vs tool_use items). */
|
|
302
|
+
export function segmentStatsCursor(text) {
|
|
303
|
+
let toolCalls = 0;
|
|
304
|
+
let textBytes = 0;
|
|
305
|
+
for (const line of text.split("\n")) {
|
|
306
|
+
if (!line.trim() || line[0] !== "{") continue;
|
|
307
|
+
let rec;
|
|
308
|
+
try { rec = JSON.parse(line); } catch { continue; }
|
|
309
|
+
if (rec.role !== "assistant") continue;
|
|
310
|
+
for (const b of rec.message?.content || []) {
|
|
311
|
+
if (!b) continue;
|
|
312
|
+
if (b.type === "tool_use") toolCalls++;
|
|
313
|
+
else if (b.type === "text") textBytes += String(b.text || "").length;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { toolCalls, textBytes, synthesis: textBytes >= 1500 && toolCalls <= 2 };
|
|
317
|
+
}
|
|
318
|
+
|
package/hooks/ignore.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ignore.mjs — .coldstartignore: which files never get notes.
|
|
3
|
+
*
|
|
4
|
+
* Enforced AT THE ROOT (the evidence/worklist layer): ignored files never
|
|
5
|
+
* enter evidence records, so they never reach a worklist, never arm the
|
|
6
|
+
* trigger, and the agent is told "unlisted files are out of scope". No
|
|
7
|
+
* kb-write validation layer — attack the roots and the other steps aren't
|
|
8
|
+
* needed. `kb lint` may REPORT ignored-anchor notes later (observability),
|
|
9
|
+
* but nothing here blocks a write.
|
|
10
|
+
*
|
|
11
|
+
* Syntax: gitignore subset — one pattern per line, `#` comments, blank lines
|
|
12
|
+
* skipped, `!` negation (later lines win), `dir/` matches the whole subtree,
|
|
13
|
+
* `*` never crosses `/`, `**` does, a pattern without `/` matches at any
|
|
14
|
+
* depth. Shipped DEFAULTS cover only the uncontroversial data-shaped set;
|
|
15
|
+
* logic-bearing configs (vite.config.ts, workflow YAML, tsconfig via `!`)
|
|
16
|
+
* are deliberately NOT defaulted — users add/negate in .coldstartignore.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_IGNORES = [
|
|
23
|
+
// pure data / machine-managed
|
|
24
|
+
"*.json",
|
|
25
|
+
"yarn.lock", "pnpm-lock.yaml", "Gemfile.lock", "Cargo.lock",
|
|
26
|
+
"poetry.lock", "composer.lock", "go.sum", "*.lock",
|
|
27
|
+
// generated / build output
|
|
28
|
+
"dist/", "build/", "out/", "coverage/", "node_modules/", "vendor/",
|
|
29
|
+
".next/", "__snapshots__/",
|
|
30
|
+
"*.min.js", "*.min.css", "*.map", "*.snap",
|
|
31
|
+
// secrets — and notes must never quote env VALUES either (checklist rule)
|
|
32
|
+
".env", ".env.*",
|
|
33
|
+
// binary / media
|
|
34
|
+
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.svg", "*.ico", "*.webp",
|
|
35
|
+
"*.woff", "*.woff2", "*.ttf", "*.eot", "*.otf",
|
|
36
|
+
"*.pdf", "*.zip", "*.gz", "*.tar", "*.wasm", "*.mo", "*.po",
|
|
37
|
+
"*.pyc", "*.class", "*.jar", "*.o", "*.dylib", "*.so", "*.dll",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// gitignore-style pattern → RegExp over repo-relative paths (no leading /).
|
|
41
|
+
function patternToRegex(pattern) {
|
|
42
|
+
let p = pattern;
|
|
43
|
+
let dirOnly = false;
|
|
44
|
+
if (p.endsWith("/")) { dirOnly = true; p = p.slice(0, -1); }
|
|
45
|
+
const anchored = p.includes("/") && !p.startsWith("**/");
|
|
46
|
+
p = p.replace(/^\//, "");
|
|
47
|
+
let re = "";
|
|
48
|
+
for (let i = 0; i < p.length; i++) {
|
|
49
|
+
const c = p[i];
|
|
50
|
+
if (c === "*") {
|
|
51
|
+
if (p[i + 1] === "*") { re += "(?:[^/]+(?:/[^/]+)*)?"; i++; if (p[i + 1] === "/") { re += "/?"; i++; } }
|
|
52
|
+
else re += "[^/]*";
|
|
53
|
+
} else if (c === "?") re += "[^/]";
|
|
54
|
+
else if (".+^${}()|[]\\".includes(c)) re += "\\" + c;
|
|
55
|
+
else re += c;
|
|
56
|
+
}
|
|
57
|
+
const body = anchored ? re : `(?:.*/)?${re}`;
|
|
58
|
+
return new RegExp(`^${body}${dirOnly ? "(?:/.*)?" : ""}$`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function compileIgnore(lines) {
|
|
62
|
+
const rules = [];
|
|
63
|
+
for (const raw of lines) {
|
|
64
|
+
const line = String(raw).trim();
|
|
65
|
+
if (!line || line.startsWith("#")) continue;
|
|
66
|
+
const negated = line.startsWith("!");
|
|
67
|
+
const pattern = negated ? line.slice(1) : line;
|
|
68
|
+
try { rules.push({ negated, re: patternToRegex(pattern) }); } catch { /* bad pattern: skip */ }
|
|
69
|
+
}
|
|
70
|
+
return (rel) => {
|
|
71
|
+
let ignored = false;
|
|
72
|
+
for (const r of rules) if (r.re.test(rel)) ignored = !r.negated; // last match wins
|
|
73
|
+
return ignored;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Load .coldstart/.coldstartignore layered over the shipped defaults.
|
|
78
|
+
* The file is personal (gitignored by init's scaffold) — defaults ship in
|
|
79
|
+
* code, so every collaborator gets the same baseline without the file. */
|
|
80
|
+
export function loadIgnore(root) {
|
|
81
|
+
let userLines = [];
|
|
82
|
+
try { userLines = readFileSync(join(root, ".coldstart", ".coldstartignore"), "utf8").split("\n"); } catch { /* none: defaults only */ }
|
|
83
|
+
return compileIgnore([...DEFAULT_IGNORES, ...userLines]);
|
|
84
|
+
}
|