@druumen/sessions-db 0.1.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +10 -0
  4. package/README.md +250 -0
  5. package/cli/_write-helpers.mjs +99 -0
  6. package/cli/alias.mjs +115 -0
  7. package/cli/argparse.mjs +296 -0
  8. package/cli/close.mjs +116 -0
  9. package/cli/find.mjs +185 -0
  10. package/cli/format.mjs +277 -0
  11. package/cli/link-parent.mjs +133 -0
  12. package/cli/link.mjs +132 -0
  13. package/cli/rebuild.mjs +98 -0
  14. package/cli/sessions-db-session-start-main.mjs +454 -0
  15. package/cli/sessions-db-session-start.mjs +56 -0
  16. package/cli/sessions-db.mjs +119 -0
  17. package/cli/sweep.mjs +171 -0
  18. package/cli/tree.mjs +127 -0
  19. package/lib/git-context.mjs +479 -0
  20. package/lib/identity.mjs +616 -0
  21. package/lib/index.mjs +145 -0
  22. package/lib/init.mjs +185 -0
  23. package/lib/lock.mjs +86 -0
  24. package/lib/operations.mjs +490 -0
  25. package/lib/paths.mjs +199 -0
  26. package/lib/projection.mjs +496 -0
  27. package/lib/sanitize.mjs +131 -0
  28. package/lib/storage.mjs +759 -0
  29. package/lib/sweep.mjs +209 -0
  30. package/lib/transcript.mjs +230 -0
  31. package/lib/types.mjs +276 -0
  32. package/lib/uuid.mjs +116 -0
  33. package/lib/watch.mjs +217 -0
  34. package/package.json +53 -0
  35. package/types/git-context.d.mts +98 -0
  36. package/types/identity.d.mts +658 -0
  37. package/types/index.d.mts +10 -0
  38. package/types/index.d.ts +127 -0
  39. package/types/init.d.mts +53 -0
  40. package/types/lock.d.mts +18 -0
  41. package/types/operations.d.mts +204 -0
  42. package/types/paths.d.mts +54 -0
  43. package/types/projection.d.mts +79 -0
  44. package/types/sanitize.d.mts +39 -0
  45. package/types/storage.d.mts +276 -0
  46. package/types/sweep.d.mts +58 -0
  47. package/types/transcript.d.mts +59 -0
  48. package/types/types.d.mts +255 -0
  49. package/types/uuid.d.mts +17 -0
  50. package/types/watch.d.mts +33 -0
package/cli/format.mjs ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Output formatting helpers for sessions-db CLI subcommands.
3
+ *
4
+ * Supports three output styles:
5
+ * - `formatSessionTable` — fixed-column ASCII table for `find` (default).
6
+ * - `formatTree` — hub-spoke ASCII tree rooted at a stable_id (depth-capped
7
+ * to defend against circular parent_session_id chains).
8
+ * - `formatJSON` — pretty-printed JSON.stringify with stable key order.
9
+ *
10
+ * No external deps — color is pure ANSI escape codes, gated by a TTY check
11
+ * the CLI entry can override with NO_COLOR=1 / --no-color.
12
+ *
13
+ * The depth cap matters: P3 identity surfaces parent_candidates as hub-spoke
14
+ * hints, but the actual `parent_session_id` is set by `link-parent`. A user
15
+ * could (accidentally or maliciously) create A→B→A. We cap recursion at
16
+ * MAX_TREE_DEPTH and surface a `(circular reference)` marker so the operator
17
+ * can fix it via `link-parent --remove`.
18
+ */
19
+
20
+ const MAX_TREE_DEPTH = 32;
21
+
22
+ // ANSI escape codes (zero-dep). Disabled when NO_COLOR is set or stdout is
23
+ // not a TTY (caller's responsibility — pass useColor=false to bypass).
24
+ const ANSI = Object.freeze({
25
+ reset: '\x1b[0m',
26
+ dim: '\x1b[2m',
27
+ bold: '\x1b[1m',
28
+ red: '\x1b[31m',
29
+ green: '\x1b[32m',
30
+ yellow: '\x1b[33m',
31
+ blue: '\x1b[34m',
32
+ cyan: '\x1b[36m',
33
+ gray: '\x1b[90m',
34
+ });
35
+
36
+ function paint(text, color, useColor) {
37
+ if (!useColor || !color) return text;
38
+ return color + text + ANSI.reset;
39
+ }
40
+
41
+ /**
42
+ * Truncate a stable_id to the first 16 chars for display
43
+ * (sess_<8>-<4>... is enough for visual disambiguation).
44
+ *
45
+ * Exported so tests can verify identical truncation rules across handlers.
46
+ */
47
+ export function truncateStableId(id) {
48
+ if (typeof id !== 'string') return '<invalid>';
49
+ if (id.length <= 22) return id;
50
+ return id.slice(0, 22);
51
+ }
52
+
53
+ /**
54
+ * Human-friendly relative time ("3 hours ago", "2 days ago", "just now").
55
+ *
56
+ * Exported because both find (table cell) and tree (state suffix) want the
57
+ * same relative-time vocabulary so ops staff don't see "3h" in one place and
58
+ * "3 hours ago" in another.
59
+ *
60
+ * @param {string|null|undefined} iso - ISO 8601 timestamp
61
+ * @param {number} [now=Date.now()] - injectable for deterministic tests
62
+ */
63
+ export function relTime(iso, now = Date.now()) {
64
+ if (!iso || typeof iso !== 'string') return '-';
65
+ const t = Date.parse(iso);
66
+ if (!Number.isFinite(t)) return '-';
67
+ const deltaMs = now - t;
68
+ if (deltaMs < 0) return 'in the future';
69
+ const sec = Math.floor(deltaMs / 1000);
70
+ if (sec < 5) return 'just now';
71
+ if (sec < 60) return `${sec}s ago`;
72
+ const min = Math.floor(sec / 60);
73
+ if (min < 60) return `${min}m ago`;
74
+ const hr = Math.floor(min / 60);
75
+ if (hr < 24) return `${hr}h ago`;
76
+ const day = Math.floor(hr / 24);
77
+ if (day < 30) return `${day}d ago`;
78
+ const mo = Math.floor(day / 30);
79
+ if (mo < 12) return `${mo}mo ago`;
80
+ const yr = Math.floor(day / 365);
81
+ return `${yr}y ago`;
82
+ }
83
+
84
+ /**
85
+ * Format a list of session records as a fixed-column ASCII table.
86
+ *
87
+ * @param {Array<object>} sessions
88
+ * @param {{ useColor?: boolean, now?: number }} [opts]
89
+ * @returns {string}
90
+ */
91
+ export function formatSessionTable(sessions, opts = {}) {
92
+ const useColor = opts.useColor === true;
93
+ const now = typeof opts.now === 'number' ? opts.now : Date.now();
94
+
95
+ if (!Array.isArray(sessions) || sessions.length === 0) {
96
+ return '(no sessions matched)\n';
97
+ }
98
+
99
+ const rows = sessions.map((s) => ({
100
+ stable: truncateStableId(s.stable_id || ''),
101
+ alias: s.alias || '-',
102
+ state: s.activity_state || '-',
103
+ outcome: s.outcome || '-',
104
+ last: relTime(s.last_progress_at, now),
105
+ branch: truncBranch(s.branch_current || s.branch_at_start),
106
+ cwd: truncCwd(s.cwd || s.worktree_path_observed),
107
+ }));
108
+
109
+ const headers = {
110
+ stable: 'stable_id',
111
+ alias: 'alias',
112
+ state: 'state',
113
+ outcome: 'outcome',
114
+ last: 'last_progress',
115
+ branch: 'branch',
116
+ cwd: 'cwd',
117
+ };
118
+
119
+ const widths = {
120
+ stable: Math.max(headers.stable.length, ...rows.map((r) => r.stable.length)),
121
+ alias: Math.max(headers.alias.length, ...rows.map((r) => r.alias.length)),
122
+ state: Math.max(headers.state.length, ...rows.map((r) => r.state.length)),
123
+ outcome: Math.max(headers.outcome.length, ...rows.map((r) => r.outcome.length)),
124
+ last: Math.max(headers.last.length, ...rows.map((r) => r.last.length)),
125
+ branch: Math.max(headers.branch.length, ...rows.map((r) => r.branch.length)),
126
+ cwd: Math.max(headers.cwd.length, ...rows.map((r) => r.cwd.length)),
127
+ };
128
+
129
+ const fmt = (r, isHeader = false) => {
130
+ const cells = [
131
+ r.stable.padEnd(widths.stable),
132
+ r.alias.padEnd(widths.alias),
133
+ paintState(r.state, useColor && !isHeader, widths.state),
134
+ paintOutcome(r.outcome, useColor && !isHeader, widths.outcome),
135
+ r.last.padEnd(widths.last),
136
+ r.branch.padEnd(widths.branch),
137
+ r.cwd.padEnd(widths.cwd),
138
+ ];
139
+ return cells.join(' ').trimEnd();
140
+ };
141
+
142
+ const lines = [];
143
+ lines.push(paint(fmt(headers, true), useColor ? ANSI.bold : null, useColor));
144
+ for (const r of rows) lines.push(fmt(r));
145
+ return lines.join('\n') + '\n';
146
+ }
147
+
148
+ function paintState(state, useColor, width) {
149
+ const padded = state.padEnd(width);
150
+ if (!useColor) return padded;
151
+ if (state === 'active') return paint(padded, ANSI.green, true);
152
+ if (state === 'idle') return paint(padded, ANSI.yellow, true);
153
+ if (state === 'archived') return paint(padded, ANSI.gray, true);
154
+ return padded;
155
+ }
156
+
157
+ function paintOutcome(outcome, useColor, width) {
158
+ const padded = outcome.padEnd(width);
159
+ if (!useColor) return padded;
160
+ if (outcome === 'open') return paint(padded, ANSI.cyan, true);
161
+ if (outcome === 'done' || outcome === 'merged') return paint(padded, ANSI.green, true);
162
+ if (outcome === 'blocked') return paint(padded, ANSI.red, true);
163
+ return padded;
164
+ }
165
+
166
+ function truncBranch(branch) {
167
+ if (!branch) return '-';
168
+ if (branch.length <= 32) return branch;
169
+ return branch.slice(0, 29) + '...';
170
+ }
171
+
172
+ function truncCwd(cwd) {
173
+ if (!cwd) return '-';
174
+ if (cwd.length <= 40) return cwd;
175
+ // Keep the tail (most informative — the trailing dir reveals which
176
+ // worktree / project this is) and prefix with `…`.
177
+ return '...' + cwd.slice(-37);
178
+ }
179
+
180
+ /**
181
+ * Format a hub-spoke tree rooted at `rootStableId`.
182
+ *
183
+ * @param {string} rootStableId
184
+ * @param {object} projection
185
+ * @param {{ useColor?: boolean, now?: number }} [opts]
186
+ * @returns {string} ASCII tree text or an error sentinel string when root
187
+ * does not exist (caller decides exit code).
188
+ */
189
+ export function formatTree(rootStableId, projection, opts = {}) {
190
+ const useColor = opts.useColor === true;
191
+ const now = typeof opts.now === 'number' ? opts.now : Date.now();
192
+
193
+ const sessions = projection && projection.sessions ? projection.sessions : {};
194
+ if (!sessions[rootStableId]) {
195
+ return `error: stable_id not found: ${rootStableId}\n`;
196
+ }
197
+
198
+ // Build child index: parent_session_id → [child stable_ids]
199
+ const children = new Map();
200
+ for (const [sid, s] of Object.entries(sessions)) {
201
+ const parent = s && typeof s.parent_session_id === 'string' ? s.parent_session_id : null;
202
+ if (parent && parent !== sid) {
203
+ if (!children.has(parent)) children.set(parent, []);
204
+ children.get(parent).push(sid);
205
+ }
206
+ }
207
+ // Sort children by created_at ASC for stable, deterministic output.
208
+ for (const arr of children.values()) {
209
+ arr.sort((a, b) => {
210
+ const ca = sessions[a] && sessions[a].created_at;
211
+ const cb = sessions[b] && sessions[b].created_at;
212
+ if (!ca && !cb) return 0;
213
+ if (!ca) return 1;
214
+ if (!cb) return -1;
215
+ return ca < cb ? -1 : ca > cb ? 1 : 0;
216
+ });
217
+ }
218
+
219
+ const lines = [];
220
+ const visited = new Set();
221
+
222
+ function nodeLabel(sid) {
223
+ const s = sessions[sid];
224
+ const idShort = truncateStableId(sid);
225
+ const alias = s && s.alias ? ` (${s.alias})` : '';
226
+ const stateLabel = s
227
+ ? `[${s.activity_state || '?'}/${s.outcome || '?'}]`
228
+ : '[?/?]';
229
+ const last = s ? ` ${relTime(s.last_progress_at, now)}` : '';
230
+ return `${paint(idShort, useColor ? ANSI.bold : null, useColor)}${alias} ${paint(stateLabel, useColor ? ANSI.dim : null, useColor)}${last}`;
231
+ }
232
+
233
+ function emit(sid, prefix, isLast, depth) {
234
+ const connector = depth === 0 ? '' : (isLast ? '└── ' : '├── ');
235
+ lines.push(prefix + connector + nodeLabel(sid));
236
+
237
+ if (depth >= MAX_TREE_DEPTH) {
238
+ lines.push(prefix + (isLast ? ' ' : '│ ') + paint('(max depth reached)', useColor ? ANSI.yellow : null, useColor));
239
+ return;
240
+ }
241
+
242
+ if (visited.has(sid)) {
243
+ lines.push(prefix + (isLast ? ' ' : '│ ') + paint('(circular reference)', useColor ? ANSI.red : null, useColor));
244
+ return;
245
+ }
246
+ visited.add(sid);
247
+
248
+ const kids = children.get(sid) || [];
249
+ const childPrefix = prefix + (depth === 0 ? '' : (isLast ? ' ' : '│ '));
250
+ for (let i = 0; i < kids.length; i++) {
251
+ emit(kids[i], childPrefix, i === kids.length - 1, depth + 1);
252
+ }
253
+ }
254
+
255
+ emit(rootStableId, '', true, 0);
256
+ return lines.join('\n') + '\n';
257
+ }
258
+
259
+ /**
260
+ * Format any value as JSON with stable 2-space indentation.
261
+ * @param {any} data
262
+ * @returns {string}
263
+ */
264
+ export function formatJSON(data) {
265
+ return JSON.stringify(data, null, 2) + '\n';
266
+ }
267
+
268
+ /**
269
+ * Decide whether to enable ANSI color: TTY + NO_COLOR not set + --no-color
270
+ * not passed. Exposed so the CLI entry / handlers can call it once at the
271
+ * start and pass the boolean down to formatters.
272
+ */
273
+ export function shouldUseColor(streamIsTTY, env = process.env, noColorFlag = false) {
274
+ if (noColorFlag) return false;
275
+ if (env && env.NO_COLOR && env.NO_COLOR.length > 0) return false;
276
+ return streamIsTTY === true;
277
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `sessions-db link-parent <child> <parent>` — explicitly promote a hub-spoke
3
+ * parent relationship (sets `parent_session_id` on child).
4
+ * `sessions-db link-parent <child> --remove` — clear parent (set null).
5
+ *
6
+ * Day 3 refactor: all child / parent existence checks AND multi-hop cycle
7
+ * detection live in `lib/operations.setParent`. This handler is a thin
8
+ * wrapper that maps argv → operation call → exit code, so the cycle
9
+ * defense exists in exactly one place.
10
+ *
11
+ * Cycle defense semantics (preserved from earlier phases):
12
+ * - direct: child === parent (1-cycle) — rejected
13
+ * - multi-hop: walk the proposed parent's ancestor chain (via the
14
+ * projection's parent_session_id pointers) and refuse if we ever
15
+ * encounter `child` — that would close the loop, e.g. A→B already
16
+ * exists and someone runs `link-parent B A` would form A→B→A.
17
+ * - bound: MAX_PARENT_CHAIN_DEPTH = 50 in operations.mjs to defend
18
+ * against a stale projection cycle.
19
+ */
20
+
21
+ import { setParent } from '../lib/operations.mjs';
22
+ import { ArgparseError, formatHelp, parseArgs } from './argparse.mjs';
23
+ import { renderDryRun, reportResult, reportStableIdNotFound } from './_write-helpers.mjs';
24
+
25
+ const SPEC = {
26
+ positional: [
27
+ { name: 'child', required: true },
28
+ { name: 'parent', required: false },
29
+ ],
30
+ flags: {
31
+ '--remove': { type: 'boolean' },
32
+ '--dry-run': { type: 'boolean' },
33
+ '--json': { type: 'boolean' },
34
+ '--root': { type: 'string' },
35
+ '--quiet': { type: 'boolean' },
36
+ },
37
+ };
38
+
39
+ export const HELP = formatHelp({
40
+ usage: 'sessions-db link-parent <child> <parent> | sessions-db link-parent <child> --remove',
41
+ summary: 'Promote a hub-spoke parent relationship (or clear it).',
42
+ flags: [
43
+ { name: '--remove', desc: 'clear parent_session_id (set null)' },
44
+ { name: '--dry-run', desc: 'print event but do not write' },
45
+ { name: '--json', desc: 'JSON output' },
46
+ { name: '--root <p>', desc: 'override storage root' },
47
+ ],
48
+ examples: [
49
+ 'sessions-db link-parent sess_child-... sess_parent-...',
50
+ 'sessions-db link-parent sess_child-... --remove',
51
+ ],
52
+ });
53
+
54
+ export async function run(argv) {
55
+ let parsed;
56
+ try {
57
+ parsed = parseArgs(argv, SPEC);
58
+ } catch (err) {
59
+ if (err instanceof ArgparseError) {
60
+ process.stderr.write(`error: ${err.message}\n\n${HELP}`);
61
+ process.exit(err.exitCode);
62
+ }
63
+ throw err;
64
+ }
65
+
66
+ if (parsed.helpRequested) {
67
+ process.stdout.write(HELP);
68
+ return;
69
+ }
70
+
71
+ const child = parsed.positional.child;
72
+ const parent = parsed.positional.parent;
73
+ const remove = parsed.flags['--remove'] === true;
74
+ const root = parsed.flags['--root'];
75
+ const dryRun = parsed.flags['--dry-run'] === true;
76
+ const json = parsed.flags['--json'] === true;
77
+ const quiet = parsed.flags['--quiet'] === true;
78
+
79
+ if (remove && parent !== undefined) {
80
+ process.stderr.write(`error: parent positional and --remove are mutually exclusive\n`);
81
+ process.exit(2);
82
+ }
83
+ if (!remove && parent === undefined) {
84
+ process.stderr.write(`error: provide a parent stable_id or --remove\n`);
85
+ process.exit(2);
86
+ }
87
+ if (!remove && parent === child) {
88
+ // Self-cycle would render as "(circular reference)" and serve no
89
+ // purpose. Operations.setParent rejects it too, but the historical
90
+ // CLI message is "cannot be the same stable_id" — preserve it.
91
+ process.stderr.write(`error: parent and child cannot be the same stable_id\n`);
92
+ process.exit(1);
93
+ }
94
+
95
+ if (dryRun) {
96
+ const payload = remove ? { parent_session_id: null } : { parent_session_id: parent };
97
+ renderDryRun({ op: 'parent_set', stableId: child, payload, json });
98
+ return;
99
+ }
100
+
101
+ const opts = root ? { root } : {};
102
+ const result = remove
103
+ ? await setParent({ childId: child, clear: true, ...opts })
104
+ : await setParent({ childId: child, parentId: parent, ...opts });
105
+
106
+ if (!result.ok && typeof result.error === 'string') {
107
+ if (result.error.startsWith('stable_id not found:')) {
108
+ if (!quiet) {
109
+ const code = reportStableIdNotFound(result.error);
110
+ process.exit(code);
111
+ }
112
+ process.exit(1);
113
+ }
114
+ if (result.error.startsWith('setParent: would create a cycle:')) {
115
+ if (!quiet) {
116
+ // Strip the `setParent: ` prefix to keep the historical CLI
117
+ // wording (`error: link-parent would create a cycle: ...`). The
118
+ // operation phrasing is `would create a cycle:` — match the test
119
+ // regex `/would create a cycle/` either way; we re-prefix so
120
+ // the operator-facing message names the CLI subcommand.
121
+ const tail = result.error.slice('setParent: '.length);
122
+ process.stderr.write(`error: link-parent ${tail}\n`);
123
+ }
124
+ process.exit(1);
125
+ }
126
+ }
127
+
128
+ const code = reportResult({
129
+ result, op: 'parent_set', stableId: child, json, quiet,
130
+ extra: remove ? { cleared: true } : { parent: parent },
131
+ });
132
+ if (code !== 0) process.exit(code);
133
+ }
package/cli/link.mjs ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `sessions-db link <stable_id> --task X|--project X` — link a session to a
3
+ * task or project (additive). Pass `--remove` to dispatch a `session_unlink`
4
+ * event that removes the named tasks / projects.
5
+ *
6
+ * Day 3 refactor: routes through `lib/operations.linkTask` /
7
+ * `lib/operations.unlinkTask` — argparse + result-to-exit only.
8
+ *
9
+ * P5 (preserved from earlier phases):
10
+ * - `--remove --task X` writes a `session_unlink` event whose reducer
11
+ * filters tasks[]/projects[] in place (set-based, idempotent).
12
+ * - `--remove` with no `--task` / `--project` rejects with exit 2 +
13
+ * "requires at least one --task or --project".
14
+ */
15
+
16
+ import { linkTask, unlinkTask } from '../lib/operations.mjs';
17
+ import { ArgparseError, formatHelp, parseArgs } from './argparse.mjs';
18
+ import { renderDryRun, reportResult, reportStableIdNotFound } from './_write-helpers.mjs';
19
+
20
+ const SPEC = {
21
+ positional: [{ name: 'stable_id', required: true }],
22
+ flags: {
23
+ '--task': { type: 'string', repeatable: true },
24
+ '--project': { type: 'string', repeatable: true },
25
+ '--remove': { type: 'boolean' },
26
+ '--dry-run': { type: 'boolean' },
27
+ '--json': { type: 'boolean' },
28
+ '--root': { type: 'string' },
29
+ '--quiet': { type: 'boolean' },
30
+ },
31
+ };
32
+
33
+ export const HELP = formatHelp({
34
+ usage: 'sessions-db link <stable_id> [--task T ...] [--project P ...] [--remove]',
35
+ summary: 'Link / unlink a session from one or more tasks / projects.',
36
+ flags: [
37
+ { name: '--task <id>', desc: 'task filename to link (or unlink with --remove); repeatable' },
38
+ { name: '--project <id>', desc: 'project key to link (or unlink with --remove); repeatable' },
39
+ { name: '--remove', desc: 'unlink instead of link — writes session_unlink event' },
40
+ { name: '--dry-run', desc: 'print event but do not write' },
41
+ { name: '--json', desc: 'JSON output' },
42
+ { name: '--root <p>', desc: 'override storage root' },
43
+ ],
44
+ examples: [
45
+ 'sessions-db link sess_01970000-... --task feat-foo.md',
46
+ 'sessions-db link sess_01970000-... --project proj-bar --task feat-foo.md',
47
+ 'sessions-db link sess_01970000-... --remove --task feat-foo.md',
48
+ ],
49
+ });
50
+
51
+ function asArray(v) {
52
+ if (v === undefined) return [];
53
+ return Array.isArray(v) ? v : [v];
54
+ }
55
+
56
+ export async function run(argv) {
57
+ let parsed;
58
+ try {
59
+ parsed = parseArgs(argv, SPEC);
60
+ } catch (err) {
61
+ if (err instanceof ArgparseError) {
62
+ process.stderr.write(`error: ${err.message}\n\n${HELP}`);
63
+ process.exit(err.exitCode);
64
+ }
65
+ throw err;
66
+ }
67
+
68
+ if (parsed.helpRequested) {
69
+ process.stdout.write(HELP);
70
+ return;
71
+ }
72
+
73
+ const stableId = parsed.positional.stable_id;
74
+ const tasks = asArray(parsed.flags['--task']);
75
+ const projects = asArray(parsed.flags['--project']);
76
+ const remove = parsed.flags['--remove'] === true;
77
+ const root = parsed.flags['--root'];
78
+ const dryRun = parsed.flags['--dry-run'] === true;
79
+ const json = parsed.flags['--json'] === true;
80
+ const quiet = parsed.flags['--quiet'] === true;
81
+
82
+ if (tasks.length === 0 && projects.length === 0) {
83
+ // Same message regardless of remove vs add — both modes need targets.
84
+ if (remove) {
85
+ process.stderr.write(
86
+ `error: link --remove requires at least one --task or --project\n`,
87
+ );
88
+ } else {
89
+ process.stderr.write(`error: provide at least one --task or --project\n`);
90
+ }
91
+ process.exit(2);
92
+ }
93
+
94
+ // P5: --remove writes a session_unlink event (set-based filter). Otherwise
95
+ // we keep the P1 session_link path (additive).
96
+ const op = remove ? 'session_unlink' : 'session_link';
97
+
98
+ if (dryRun) {
99
+ const payload = {};
100
+ if (tasks.length > 0) payload.tasks = tasks;
101
+ if (projects.length > 0) payload.projects = projects;
102
+ renderDryRun({ op, stableId, payload, json });
103
+ return;
104
+ }
105
+
106
+ const opts = root ? { root } : {};
107
+ const fn = remove ? unlinkTask : linkTask;
108
+ const result = await fn({
109
+ stableId,
110
+ tasks: tasks.length > 0 ? tasks : undefined,
111
+ projects: projects.length > 0 ? projects : undefined,
112
+ ...opts,
113
+ });
114
+
115
+ if (!result.ok && typeof result.error === 'string'
116
+ && result.error.startsWith('stable_id not found:')) {
117
+ if (!quiet) {
118
+ const code = reportStableIdNotFound(result.error);
119
+ process.exit(code);
120
+ }
121
+ process.exit(1);
122
+ }
123
+
124
+ const extra = {};
125
+ if (tasks.length > 0) extra.tasks = tasks;
126
+ if (projects.length > 0) extra.projects = projects;
127
+ if (remove) extra.removed = true;
128
+ const code = reportResult({
129
+ result, op, stableId, json, quiet, extra,
130
+ });
131
+ if (code !== 0) process.exit(code);
132
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `sessions-db rebuild` — rebuild the projection cache from events.jsonl.
3
+ *
4
+ * Use cases:
5
+ * - Recover after a corrupted projection (loadProjection auto-falls back to
6
+ * rebuild on parse failure, but a manual rebuild gives ops visible
7
+ * confirmation).
8
+ * - After a hand-edit of events.jsonl (rare; e.g. removing a poisoned event).
9
+ * - During schema migration when the reducer changes meaning of an existing
10
+ * op.
11
+ *
12
+ * Output: human-readable summary by default, machine-readable JSON with
13
+ * --json. Tolerated tail-partial corruptions (interrupted writes) are
14
+ * surfaced as a count so ops can correlate against hook failures. Middle-
15
+ * line corruptions throw and exit 1.
16
+ */
17
+
18
+ import { rebuildProjection } from '../lib/storage.mjs';
19
+ import { ArgparseError, formatHelp, parseArgs } from './argparse.mjs';
20
+ import { formatJSON } from './format.mjs';
21
+
22
+ const SPEC = {
23
+ positional: [],
24
+ flags: {
25
+ '--json': { type: 'boolean' },
26
+ '--root': { type: 'string' },
27
+ '--quiet': { type: 'boolean' },
28
+ },
29
+ };
30
+
31
+ export const HELP = formatHelp({
32
+ usage: 'sessions-db rebuild [--json] [--root <p>]',
33
+ summary: 'Rebuild the projection cache from events.jsonl (full fold).',
34
+ flags: [
35
+ { name: '--json', desc: 'JSON output' },
36
+ { name: '--root <p>', desc: 'override storage root' },
37
+ ],
38
+ examples: [
39
+ 'sessions-db rebuild',
40
+ 'sessions-db rebuild --root /tmp/sessions-isolation-test',
41
+ ],
42
+ });
43
+
44
+ export async function run(argv) {
45
+ let parsed;
46
+ try {
47
+ parsed = parseArgs(argv, SPEC);
48
+ } catch (err) {
49
+ if (err instanceof ArgparseError) {
50
+ process.stderr.write(`error: ${err.message}\n\n${HELP}`);
51
+ process.exit(err.exitCode);
52
+ }
53
+ throw err;
54
+ }
55
+
56
+ if (parsed.helpRequested) {
57
+ process.stdout.write(HELP);
58
+ return;
59
+ }
60
+
61
+ const root = parsed.flags['--root'];
62
+ const quiet = parsed.flags['--quiet'] === true;
63
+ const json = parsed.flags['--json'] === true;
64
+
65
+ let result;
66
+ try {
67
+ result = await rebuildProjection(root ? { root } : {});
68
+ } catch (err) {
69
+ // Middle-line corruption (or other rebuild failure) — surface and exit 1.
70
+ process.stderr.write(`error: rebuild failed: ${err && err.message ? err.message : String(err)}\n`);
71
+ if (err && Array.isArray(err.corruptions) && err.corruptions.length > 0) {
72
+ for (const c of err.corruptions.slice(0, 5)) {
73
+ process.stderr.write(` line ${c.lineNumber}: ${c.error}\n`);
74
+ }
75
+ }
76
+ process.exit(1);
77
+ }
78
+
79
+ if (quiet) return;
80
+
81
+ if (json) {
82
+ process.stdout.write(formatJSON({ ok: true, ...result }));
83
+ return;
84
+ }
85
+
86
+ // P5: surface toleratedCorruptions on a SECOND line prefixed with "warning:"
87
+ // so log scrapers can grep for `^warning:` (or the parent token) without
88
+ // reading past the "ok:" header. Format aligned with the P5 ticket §5.
89
+ process.stdout.write(
90
+ `ok: rebuilt projection — ${result.sessionCount} session${result.sessionCount === 1 ? '' : 's'}, ` +
91
+ `${result.eventCount} event${result.eventCount === 1 ? '' : 's'}\n`,
92
+ );
93
+ if (result.toleratedCorruptions > 0) {
94
+ process.stdout.write(
95
+ ` (warning: ${result.toleratedCorruptions} tail-partial event line${result.toleratedCorruptions === 1 ? '' : 's'} tolerated)\n`,
96
+ );
97
+ }
98
+ }