@vincemakes/kiso-code 0.39.2 → 0.40.0

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.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * 0.40.0 — the one-time move of the legacy `KISO_HOME/sessions` into the
3
+ * per-project folders (see projects.ts). The lead's conditions, in order:
4
+ *
5
+ * 1. the manifest is written FIRST: every session's old folder, new
6
+ * folder, reason and evidence, before any file moves;
7
+ * 2. a session whose lock a live process holds is skipped, and moved on a
8
+ * later start;
9
+ * 3. a recorded workspace places a session; otherwise the paths its tools
10
+ * touched, mapped to their repository on disk, place it when one root
11
+ * has at least 3 mentions and 80% of them; anything else goes to
12
+ * `_unknown`. A delegated child follows its parent;
13
+ * 4. the move is announced once, naming the reverse command.
14
+ *
15
+ * Each session moves trace → sidecar → lock → log (projects.ts), so a crash
16
+ * leaves the log in the legacy folder, where the next start finds it again.
17
+ * Nothing is deleted: files the move does not own (orphan sidecars, the
18
+ * delegation artifacts, whose paths the parents' logs record) stay where
19
+ * they are.
20
+ */
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+ import { readProfile } from "@vincemakes/kiso-runtime/internal";
25
+ import { canonicalPath, claimProjectDir, hasSession, LEGACY_SESSIONS_DIR, lockHeldLive, moveSessionFiles, parentOfChild, projectDirFor, PROJECTS_DIR, REVERSED_MARKER, sessionFiles, unknownDir, } from "./projects.js";
26
+ /** The inference's two thresholds (the lead's ruling). */
27
+ export const INFER_MIN_MENTIONS = 3;
28
+ export const INFER_MIN_SHARE = 0.8;
29
+ /** The legacy sessions still to move: non-empty logs, sorted. */
30
+ export function pendingLegacyIds(home) {
31
+ const dir = join(home, LEGACY_SESSIONS_DIR);
32
+ let entries;
33
+ try {
34
+ entries = readdirSync(dir);
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ return entries
40
+ .filter((e) => e.endsWith(".jsonl"))
41
+ .map((e) => e.slice(0, -".jsonl".length))
42
+ .filter((id) => hasSession(dir, id))
43
+ .sort();
44
+ }
45
+ /** An absolute path inside a tool's input or output. The leading guard
46
+ * keeps a URL's path (`https://x/y`) and a relative path's tail out. */
47
+ const ABS_PATH = /(?<![\w.:/~-])\/[^\s'"`;|&<>(){}[\],*?$\\]+/g;
48
+ /**
49
+ * The repository a path belongs to: the nearest ancestor holding `.git`.
50
+ * A linked worktree (`.git` is a file naming `<repo>/.git/worktrees/x`)
51
+ * belongs to its main repository. Walks stop at the home directory, and
52
+ * paths inside KISO_HOME belong to no project.
53
+ */
54
+ export function repoRootOf(path, cache, stops) {
55
+ if (path === stops.kisoHome || path.startsWith(`${stops.kisoHome}/`))
56
+ return null;
57
+ const walked = [];
58
+ let dir = path;
59
+ let root = null;
60
+ for (;;) {
61
+ const known = cache.get(dir);
62
+ if (known !== undefined) {
63
+ root = known;
64
+ break;
65
+ }
66
+ walked.push(dir);
67
+ if (dir === "/" || dir === stops.home)
68
+ break;
69
+ const git = join(dir, ".git");
70
+ if (existsSync(git)) {
71
+ root = dir;
72
+ try {
73
+ if (statSync(git).isFile()) {
74
+ const m = /^gitdir:\s*(.+?)\/\.git\/worktrees\/[^/]+\s*$/m.exec(readFileSync(git, "utf8"));
75
+ if (m !== null)
76
+ root = m[1];
77
+ }
78
+ }
79
+ catch {
80
+ // unreadable `.git` file — the directory itself is the root
81
+ }
82
+ // as the disk spells it: a log that wrote `~/desktop/x` and one
83
+ // that wrote `~/Desktop/x` name one repository
84
+ root = canonicalPath(root);
85
+ break;
86
+ }
87
+ dir = dirname(dir);
88
+ }
89
+ for (const d of walked)
90
+ cache.set(d, root);
91
+ return root;
92
+ }
93
+ /** The absolute paths one log's tool calls and tool results name. */
94
+ export function toolPaths(logText) {
95
+ const out = [];
96
+ for (const line of logText.split("\n")) {
97
+ if (line === "")
98
+ continue;
99
+ let event;
100
+ try {
101
+ event = JSON.parse(line).event ?? {};
102
+ }
103
+ catch {
104
+ continue; // a torn line
105
+ }
106
+ if (event.type !== "tool_call_end" && event.type !== "tool_result")
107
+ continue;
108
+ const text = JSON.stringify(event.type === "tool_call_end" ? event.input : event.content) ?? "";
109
+ for (const m of text.replaceAll("\\n", "\n").replaceAll("\\t", "\t").matchAll(ABS_PATH))
110
+ out.push(m[0]);
111
+ }
112
+ return out;
113
+ }
114
+ /** The inference over one log: the dominant repository and its counts. */
115
+ export function inferWorkspace(logText, cache, stops) {
116
+ const counts = new Map();
117
+ for (const path of toolPaths(logText)) {
118
+ const root = repoRootOf(path, cache, stops);
119
+ if (root !== null)
120
+ counts.set(root, (counts.get(root) ?? 0) + 1);
121
+ }
122
+ let best = null;
123
+ let total = 0;
124
+ for (const entry of counts) {
125
+ total += entry[1];
126
+ if (best === null || entry[1] > best[1])
127
+ best = entry;
128
+ }
129
+ if (best === null)
130
+ return null;
131
+ return { root: best[0], mentions: best[1], total, placed: best[1] >= INFER_MIN_MENTIONS && best[1] / total >= INFER_MIN_SHARE };
132
+ }
133
+ /**
134
+ * The plan: where every pending legacy session goes, and why. A pure read
135
+ * of the legacy folder and the repositories on disk — nothing is written,
136
+ * so the owner's dry run is this function over a copy.
137
+ */
138
+ export function planMigration(home, opts = {}) {
139
+ const legacy = join(home, LEGACY_SESSIONS_DIR);
140
+ const ids = pendingLegacyIds(home);
141
+ const stops = { home: opts.userHome ?? homedir(), kisoHome: home };
142
+ const cache = new Map();
143
+ // an earlier run's decision stands: that run may have moved the sidecar
144
+ // (which holds the recorded workspace) and crashed before the log, and a
145
+ // re-decision from what is left would send the log somewhere else
146
+ const earlier = earlierDecisions(home);
147
+ const decided = new Map();
148
+ const decide = (id) => {
149
+ const done = decided.get(id);
150
+ if (done !== undefined)
151
+ return done;
152
+ let placement;
153
+ const parent = parentOfChild(id);
154
+ const profile = readProfile(legacy, id);
155
+ const recorded = profile.kind === "ok" ? profile.profile.workspace : null;
156
+ const prior = earlier.get(id);
157
+ if (lockHeldLive(legacy, id)) {
158
+ placement = { id, reason: "skipped-live", workspace: null, from: legacy };
159
+ }
160
+ else if (prior !== undefined) {
161
+ placement = { ...prior, from: legacy };
162
+ }
163
+ else if (parent !== null && ids.includes(parent)) {
164
+ // a child follows its parent — unless the parent is open, and then
165
+ // the child waits with it
166
+ const p = decide(parent);
167
+ placement = { id, reason: p.reason, workspace: p.workspace, from: legacy, parent, ...(p.evidence !== undefined ? { evidence: p.evidence } : {}) };
168
+ }
169
+ else if (recorded !== null) {
170
+ placement = { id, reason: "recorded", workspace: canonicalPath(recorded), from: legacy };
171
+ }
172
+ else {
173
+ let text = "";
174
+ try {
175
+ text = readFileSync(join(legacy, `${id}.jsonl`), "utf8");
176
+ }
177
+ catch {
178
+ // unreadable — no evidence, so no project
179
+ }
180
+ const inferred = inferWorkspace(text, cache, stops);
181
+ placement =
182
+ inferred !== null && inferred.placed
183
+ ? { id, reason: "inferred", workspace: inferred.root, from: legacy, evidence: { root: inferred.root, mentions: inferred.mentions, total: inferred.total } }
184
+ : { id, reason: "unknown", workspace: null, from: legacy, ...(inferred !== null ? { evidence: { root: inferred.root, mentions: inferred.mentions, total: inferred.total } } : {}) };
185
+ }
186
+ decided.set(id, placement);
187
+ return placement;
188
+ };
189
+ // the target folders, assigned in one pass so two workspaces that encode
190
+ // alike get distinct folders before either exists on disk
191
+ const taken = new Map();
192
+ const dirOf = new Map();
193
+ const target = (workspace) => {
194
+ if (workspace === null)
195
+ return unknownDir(home);
196
+ const known = dirOf.get(workspace);
197
+ if (known !== undefined)
198
+ return known;
199
+ const dir = projectDirFor(home, workspace, taken);
200
+ taken.set(dir, workspace);
201
+ dirOf.set(workspace, dir);
202
+ return dir;
203
+ };
204
+ const plan = ids.map((id) => {
205
+ const p = decide(id);
206
+ return { ...p, to: p.reason === "skipped-live" ? null : (p.to ?? target(p.workspace)) };
207
+ });
208
+ // children move before their parent: a crash between them leaves the
209
+ // parent — whose log is the one a person resumes — still listed
210
+ return [...plan.filter((p) => p.parent !== undefined), ...plan.filter((p) => p.parent === undefined)];
211
+ }
212
+ /** Every earlier manifest's placements that were not skips, the latest
213
+ * first-come: id → where it was going. */
214
+ function earlierDecisions(home) {
215
+ const out = new Map();
216
+ const dir = join(home, PROJECTS_DIR);
217
+ let names;
218
+ try {
219
+ names = readdirSync(dir).filter((n) => n.startsWith("migration-") && n.endsWith(".json"));
220
+ }
221
+ catch {
222
+ return out;
223
+ }
224
+ for (const name of names.sort().reverse()) {
225
+ try {
226
+ const parsed = JSON.parse(readFileSync(join(dir, name), "utf8"));
227
+ for (const e of parsed.entries ?? []) {
228
+ if (e.to === null || e.reason === "skipped-live" || out.has(e.id))
229
+ continue;
230
+ out.set(e.id, e);
231
+ }
232
+ }
233
+ catch {
234
+ // an unreadable manifest decides nothing
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+ export function countPlan(plan) {
240
+ const n = (r) => plan.filter((p) => p.reason === r).length;
241
+ return { recorded: n("recorded"), inferred: n("inferred"), unknown: n("unknown"), skippedLive: n("skipped-live") };
242
+ }
243
+ /**
244
+ * Run the plan. The manifest lands before the first move; after the moves
245
+ * it is rewritten with each entry's outcome. Returns null when there was
246
+ * nothing to move (all skipped, or nothing pending).
247
+ */
248
+ export function runMigration(home, plan, now = new Date()) {
249
+ const movable = plan.filter((p) => p.to !== null);
250
+ if (movable.length === 0)
251
+ return null;
252
+ const root = join(home, PROJECTS_DIR);
253
+ mkdirSync(root, { recursive: true, mode: 0o700 });
254
+ const manifest = join(root, `migration-${now.toISOString().replace(/[:.]/g, "-")}.json`);
255
+ const write = (entries, completedAt) => {
256
+ const tmp = `${manifest}.${process.pid}.tmp`;
257
+ writeFileSync(tmp, `${JSON.stringify({ version: 1, startedAt: now.toISOString(), completedAt, entries }, null, 1)}\n`, { mode: 0o600 });
258
+ renameSync(tmp, manifest);
259
+ };
260
+ write(plan, null);
261
+ let moved = 0;
262
+ const outcomes = plan.map((p) => {
263
+ if (p.to === null)
264
+ return { ...p, moved: false };
265
+ claimProjectDir(p.to, p.workspace);
266
+ // a session that became open since the plan was made waits too
267
+ if (lockHeldLive(p.from, p.id))
268
+ return { ...p, moved: false, skipped: "opened during the move" };
269
+ moveSessionFiles(p.from, p.to, p.id);
270
+ const ok = hasSession(p.to, p.id) && !existsSync(join(p.from, `${p.id}.jsonl`));
271
+ if (ok)
272
+ moved += 1;
273
+ return { ...p, moved: ok };
274
+ });
275
+ write(outcomes, new Date().toISOString());
276
+ return { manifest, counts: countPlan(plan), moved };
277
+ }
278
+ /** The announcement, once per migration that moved something. */
279
+ export function migrationNotice(result) {
280
+ const c = result.counts;
281
+ const parts = [`recorded ${c.recorded}`, `inferred ${c.inferred}`, `unknown ${c.unknown}`, ...(c.skippedLive > 0 ? [`still open ${c.skippedLive}, moved later`] : [])];
282
+ return `sessions now live in one folder per project — moved ${result.moved} (${parts.join(" · ")}); undo: kiso sessions --reverse-migration ${result.manifest}`;
283
+ }
284
+ /**
285
+ * `kiso sessions --reverse-migration <manifest>`: every entry the manifest
286
+ * moved goes back to the legacy folder, the log FIRST (so a crash leaves it
287
+ * listed from its old place), and the marker switches the per-project
288
+ * layout off. Sessions created in project folders after the migration are
289
+ * not in the manifest and stay; the count says so.
290
+ */
291
+ export function reverseMigration(home, manifestPath) {
292
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
293
+ const entries = (parsed.entries ?? []).filter((e) => e.to !== null);
294
+ let restored = 0;
295
+ // parents first, then children: the reverse of the forward order
296
+ for (const e of [...entries.filter((x) => x.parent === undefined), ...entries.filter((x) => x.parent !== undefined)]) {
297
+ if (e.to === null || !existsSync(join(e.to, `${e.id}.jsonl`)))
298
+ continue;
299
+ moveSessionFiles(e.to, e.from, e.id, [...sessionFiles(e.id)].reverse());
300
+ if (hasSession(e.from, e.id))
301
+ restored += 1;
302
+ }
303
+ mkdirSync(join(home, PROJECTS_DIR), { recursive: true, mode: 0o700 });
304
+ writeFileSync(join(home, PROJECTS_DIR, REVERSED_MARKER), `${new Date().toISOString()} ${manifestPath}\n`, { mode: 0o600 });
305
+ let left = 0;
306
+ const root = join(home, PROJECTS_DIR);
307
+ for (const name of readdirSync(root)) {
308
+ const dir = join(root, name);
309
+ try {
310
+ if (!statSync(dir).isDirectory())
311
+ continue;
312
+ left += readdirSync(dir).filter((f) => f.endsWith(".jsonl") && hasSession(dir, f.slice(0, -".jsonl".length))).length;
313
+ }
314
+ catch {
315
+ // unreadable folder — not counted
316
+ }
317
+ }
318
+ return { restored, left };
319
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The shell command line, READ — never run — for the approval chain
3
+ * (0.40.0: the read-only allow now, the catastrophe floor next).
4
+ *
5
+ * This is not a shell. It is a lexer for the subset of POSIX sh whose
6
+ * meaning can be known WITHOUT running anything, and it refuses the rest:
7
+ * a command line that uses expansion (`$`, backticks, globs, a leading
8
+ * `~`, braces), grouping or subshells, background jobs, heredocs, or a
9
+ * leading variable assignment does not parse, and a caller that cannot
10
+ * parse a line must treat it as unknown. The refusals are the design —
11
+ * every construct accepted here is one whose words are exactly the words
12
+ * the shell will pass to the program.
13
+ *
14
+ * What it produces is a LIST of pipelines joined by `;`, `&&`, `||` (a
15
+ * newline counts as `;`), each pipeline a series of simple commands joined
16
+ * by `|`, each command an argv plus its redirections.
17
+ */
18
+ export interface Redirect {
19
+ /** The descriptor written before the operator (`2>`), null when none
20
+ * was written, or "&" for `&>` (stdout and stderr together). */
21
+ readonly fd: number | "&" | null;
22
+ readonly op: ">" | ">>" | "<" | ">&";
23
+ readonly target: string;
24
+ }
25
+ export interface SimpleCommand {
26
+ readonly argv: readonly string[];
27
+ readonly redirects: readonly Redirect[];
28
+ }
29
+ export interface Pipeline {
30
+ /** How this pipeline joins the one before it; null for the first. */
31
+ readonly joinedBy: ";" | "&&" | "||" | null;
32
+ readonly stages: readonly SimpleCommand[];
33
+ }
34
+ export type ParseResult = {
35
+ readonly ok: true;
36
+ readonly list: readonly Pipeline[];
37
+ } | {
38
+ readonly ok: false;
39
+ readonly why: string;
40
+ };
41
+ /**
42
+ * Parse a command line, or say why not. Pure: no filesystem, no
43
+ * environment.
44
+ */
45
+ export declare function parseShell(src: string): ParseResult;
46
+ /**
47
+ * A path argument, resolved the way read_file resolves one: against the
48
+ * directory the command runs in, with symlinks in the deepest EXISTING
49
+ * ancestor followed, and the not-yet-existing tail re-appended. `inside`
50
+ * is whether that canonical path is the workspace root or under it. One
51
+ * resolver for every consumer — the read-only allow asks "inside?", the
52
+ * floor asks "what is this?" — so the two can never disagree about where
53
+ * a path goes.
54
+ */
55
+ export interface ResolvedPath {
56
+ readonly canonical: string;
57
+ readonly inside: boolean;
58
+ }
59
+ /** The well-known home subtrees that hold credentials or configuration
60
+ * that runs — one list for both chain members: the read-only allow
61
+ * never reads or lists under them, the floor never deletes them. */
62
+ export declare const HOME_SUBTREES: readonly string[];
63
+ /** A real path in the case the DISK holds it. The JS realpath keeps the
64
+ * case it was given, so on a case-insensitive disk `.ENV` stayed `.ENV`
65
+ * and walked past every name-based predicate (the lead's review, B2). */
66
+ export declare function realCase(p: string): string;
67
+ export declare function resolveShellPath(workspaceRoot: string, cwd: string, word: string): ResolvedPath;
68
+ /**
69
+ * The FLOOR's reading of a command line (0.40.0 item 4) — the opposite
70
+ * contract to `parseShell`. The read-only allow must refuse whatever it
71
+ * cannot read exactly; the floor must never refuse to read, because a
72
+ * line it gave up on is a line it let through. So this reader accepts
73
+ * everything, approximately, and marks where each word stops being known:
74
+ *
75
+ * - every simple command the line would run is returned in order,
76
+ * including the ones inside `$( )`, backticks and `( )` groups;
77
+ * - a word records the index where its first unknown character sits (a
78
+ * variable, a substitution, an unquoted glob), whether it began with
79
+ * an unquoted `~`, and whether it is ONLY a variable (`$DIR/`,
80
+ * `"${X}"/*`: nothing literal but slashes, dots and globs);
81
+ * - redirection targets and leading assignments are not arguments, and
82
+ * are dropped.
83
+ */
84
+ export interface LooseWord {
85
+ readonly text: string;
86
+ /** Index of the first unknown character, or -1 when all is literal. */
87
+ readonly unknownAt: number;
88
+ /** Whether that first unknown is a glob (`*`), not a variable or a
89
+ * substitution: a glob ranges over a directory's entries, a variable
90
+ * can be anything at all. */
91
+ readonly unknownIsGlob: boolean;
92
+ readonly tilde: boolean;
93
+ readonly variableOnly: boolean;
94
+ }
95
+ /** One command, and how it joins the one before it: after `cd x &&` it
96
+ * runs in x, after `cd x ||` in the old directory, after `;` in either. */
97
+ export interface LooseCommand {
98
+ readonly kind: "cmd";
99
+ readonly argv: readonly LooseWord[];
100
+ readonly joinedBy: "&&" | "||" | ";" | "|";
101
+ /** The words the reader drops — redirection targets and leading
102
+ * assignments — kept only when the caller asks (`keepDropped`): the
103
+ * floor judges what a command DOES, the protected-file check what a
104
+ * line NAMES, and `< file` names a file. */
105
+ readonly dropped?: readonly LooseWord[];
106
+ }
107
+ /** A subshell: a `( … )` group, or the command line inside `$( … )` or
108
+ * backticks (which runs BEFORE the command that holds it). A `cd` inside
109
+ * one does not move the shell around it. */
110
+ export interface LooseGroup {
111
+ readonly kind: "group";
112
+ readonly items: readonly LooseNode[];
113
+ readonly joinedBy: "&&" | "||" | ";" | "|";
114
+ }
115
+ export type LooseNode = LooseCommand | LooseGroup;
116
+ /** B6 (the lead's review): how deep the reader follows nested subshells.
117
+ * A 9 KB `$(` nest overflowed the stack; the runtime turned the throw into
118
+ * an ask, bypass's allow won, and /bin/sh ran the line. Past this depth
119
+ * the word is taken as only a variable — which, as a target, is refused —
120
+ * and nothing deeper is read. */
121
+ export declare const LOOSE_MAX_DEPTH = 64;
122
+ /** Every command the line would run, in order, flattened — for a caller
123
+ * that asks the same question of each one. */
124
+ export declare function looseCommands(nodes: readonly LooseNode[]): LooseCommand[];
125
+ export declare function parseShellLoose(src: string): LooseNode[];
126
+ /** The same, saying whether the reader stopped short: a nest past
127
+ * LOOSE_MAX_DEPTH is not read, and a caller that must see every command
128
+ * (the floor) treats an unread line as one it could not read. */
129
+ export declare function parseShellLooseChecked(src: string, opts?: {
130
+ readonly keepDropped?: boolean;
131
+ }): {
132
+ readonly nodes: LooseNode[];
133
+ readonly truncated: boolean;
134
+ };