@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.
- package/CHANGELOG.md +249 -0
- package/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +250 -0
- package/cli/_write-helpers.mjs +99 -0
- package/cli/alias.mjs +115 -0
- package/cli/argparse.mjs +296 -0
- package/cli/close.mjs +116 -0
- package/cli/find.mjs +185 -0
- package/cli/format.mjs +277 -0
- package/cli/link-parent.mjs +133 -0
- package/cli/link.mjs +132 -0
- package/cli/rebuild.mjs +98 -0
- package/cli/sessions-db-session-start-main.mjs +454 -0
- package/cli/sessions-db-session-start.mjs +56 -0
- package/cli/sessions-db.mjs +119 -0
- package/cli/sweep.mjs +171 -0
- package/cli/tree.mjs +127 -0
- package/lib/git-context.mjs +479 -0
- package/lib/identity.mjs +616 -0
- package/lib/index.mjs +145 -0
- package/lib/init.mjs +185 -0
- package/lib/lock.mjs +86 -0
- package/lib/operations.mjs +490 -0
- package/lib/paths.mjs +199 -0
- package/lib/projection.mjs +496 -0
- package/lib/sanitize.mjs +131 -0
- package/lib/storage.mjs +759 -0
- package/lib/sweep.mjs +209 -0
- package/lib/transcript.mjs +230 -0
- package/lib/types.mjs +276 -0
- package/lib/uuid.mjs +116 -0
- package/lib/watch.mjs +217 -0
- package/package.json +53 -0
- package/types/git-context.d.mts +98 -0
- package/types/identity.d.mts +658 -0
- package/types/index.d.mts +10 -0
- package/types/index.d.ts +127 -0
- package/types/init.d.mts +53 -0
- package/types/lock.d.mts +18 -0
- package/types/operations.d.mts +204 -0
- package/types/paths.d.mts +54 -0
- package/types/projection.d.mts +79 -0
- package/types/sanitize.d.mts +39 -0
- package/types/storage.d.mts +276 -0
- package/types/sweep.d.mts +58 -0
- package/types/transcript.d.mts +59 -0
- package/types/types.d.mts +255 -0
- package/types/uuid.d.mts +17 -0
- package/types/watch.d.mts +33 -0
package/cli/argparse.mjs
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal hand-rolled argparse for sessions-db CLI subcommands.
|
|
3
|
+
*
|
|
4
|
+
* Why not `node:util.parseArgs`? Three reasons:
|
|
5
|
+
* - We want subcommand-local exit codes (2 for argparse error) with custom
|
|
6
|
+
* error messages that point at the offending flag — `parseArgs` throws
|
|
7
|
+
* generic TypeError which is hard to surface as a CLI banner without
|
|
8
|
+
* adding a wrapping layer anyway.
|
|
9
|
+
* - We want to support both `--flag value` AND `--flag=value` AND repeated
|
|
10
|
+
* flags (collected as arrays) without each handler reimplementing the
|
|
11
|
+
* same plumbing.
|
|
12
|
+
* - Zero-dependency per the P4 ticket constraint, and parseArgs API has
|
|
13
|
+
* differed across Node versions (we're testing on the same major used by
|
|
14
|
+
* hooks/tests). A locally-owned 100-line parser is easier to debug than a
|
|
15
|
+
* wire-up around a stdlib that occasionally changes shape.
|
|
16
|
+
*
|
|
17
|
+
* Spec object shape:
|
|
18
|
+
* {
|
|
19
|
+
* positional: [
|
|
20
|
+
* { name: 'stable_id', required: true },
|
|
21
|
+
* { name: 'alias', required: false },
|
|
22
|
+
* ],
|
|
23
|
+
* // Opt-in: when true, accept any number of extra positionals beyond the
|
|
24
|
+
* // declared slots and stash them in `positionalArray` for the caller.
|
|
25
|
+
* // Default false: extra positionals are an argparse error (exit 2).
|
|
26
|
+
* restPositional: false,
|
|
27
|
+
* flags: {
|
|
28
|
+
* '--task': { type: 'string', alias: '-t' },
|
|
29
|
+
* '--remove': { type: 'boolean' },
|
|
30
|
+
* '--limit': { type: 'number', default: 50 },
|
|
31
|
+
* '--label': { type: 'string', repeatable: true }, // collected as array
|
|
32
|
+
* '--json': { type: 'boolean' },
|
|
33
|
+
* '--root': { type: 'string' },
|
|
34
|
+
* '--dry-run': { type: 'boolean' },
|
|
35
|
+
* '--quiet': { type: 'boolean' },
|
|
36
|
+
* },
|
|
37
|
+
* help: 'subcommand-specific help text',
|
|
38
|
+
* }
|
|
39
|
+
*
|
|
40
|
+
* Boolean flag value semantics (P4 round-1 review fix):
|
|
41
|
+
* `--flag` (no value) → true
|
|
42
|
+
* `--flag=true|1|yes` → true
|
|
43
|
+
* `--flag=false|0|no` → false
|
|
44
|
+
* `--flag value` (spaced) → REJECTED (would silently swallow value as
|
|
45
|
+
* an extra positional, masking user intent).
|
|
46
|
+
* This is consistent with `parseArgs` in node:util and avoids the trap
|
|
47
|
+
* where `link --remove false` was previously parsed as `--remove=true`
|
|
48
|
+
* plus a stray `false` token that got dropped on the floor.
|
|
49
|
+
*
|
|
50
|
+
* Returns:
|
|
51
|
+
* {
|
|
52
|
+
* positional: { stable_id: 'sess_...', alias: 'foo' },
|
|
53
|
+
* positionalArray: ['sess_...', 'foo'], // raw order
|
|
54
|
+
* flags: { '--task': 'T-1', '--limit': 50, ... },
|
|
55
|
+
* helpRequested: false,
|
|
56
|
+
* }
|
|
57
|
+
*
|
|
58
|
+
* Errors throw `ArgparseError` with `.exitCode = 2` and `.message` ready for
|
|
59
|
+
* stderr. The CLI dispatcher catches these and exits 2 — handlers themselves
|
|
60
|
+
* never need to think about exit codes for parse failures.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
const TRUE_BOOL_VALUES = new Set(['true', '1', 'yes']);
|
|
64
|
+
const FALSE_BOOL_VALUES = new Set(['false', '0', 'no']);
|
|
65
|
+
|
|
66
|
+
export class ArgparseError extends Error {
|
|
67
|
+
constructor(message) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = 'ArgparseError';
|
|
70
|
+
this.exitCode = 2;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse argv tokens against `spec`. Throws ArgparseError on any violation.
|
|
76
|
+
*
|
|
77
|
+
* @param {string[]} argv
|
|
78
|
+
* @param {object} spec
|
|
79
|
+
* @returns {{
|
|
80
|
+
* positional: Record<string, string>,
|
|
81
|
+
* positionalArray: string[],
|
|
82
|
+
* flags: Record<string, any>,
|
|
83
|
+
* helpRequested: boolean,
|
|
84
|
+
* }}
|
|
85
|
+
*/
|
|
86
|
+
export function parseArgs(argv, spec = {}) {
|
|
87
|
+
const positionalSpec = Array.isArray(spec.positional) ? spec.positional : [];
|
|
88
|
+
const flagsSpec = (spec.flags && typeof spec.flags === 'object') ? spec.flags : {};
|
|
89
|
+
|
|
90
|
+
// Build alias→canonical map so `-t` resolves to `--task`.
|
|
91
|
+
const aliasMap = {};
|
|
92
|
+
for (const [name, def] of Object.entries(flagsSpec)) {
|
|
93
|
+
if (def && typeof def.alias === 'string' && def.alias.length > 0) {
|
|
94
|
+
aliasMap[def.alias] = name;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const out = {
|
|
99
|
+
positional: {},
|
|
100
|
+
positionalArray: [],
|
|
101
|
+
flags: {},
|
|
102
|
+
helpRequested: false,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Pre-fill defaults for declared flags.
|
|
106
|
+
for (const [name, def] of Object.entries(flagsSpec)) {
|
|
107
|
+
if (def && Object.prototype.hasOwnProperty.call(def, 'default')) {
|
|
108
|
+
out.flags[name] = def.default;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Walk argv. Stop if we see `--` (POSIX double-dash separator) — anything
|
|
113
|
+
// after it is treated as positional even if it looks like a flag. This lets
|
|
114
|
+
// callers pass aliases / IDs that begin with `-` without ambiguity.
|
|
115
|
+
let i = 0;
|
|
116
|
+
let sawDoubleDash = false;
|
|
117
|
+
while (i < argv.length) {
|
|
118
|
+
const tok = argv[i];
|
|
119
|
+
|
|
120
|
+
if (!sawDoubleDash && tok === '--') {
|
|
121
|
+
sawDoubleDash = true;
|
|
122
|
+
i += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!sawDoubleDash && (tok === '-h' || tok === '--help')) {
|
|
127
|
+
out.helpRequested = true;
|
|
128
|
+
i += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Flag form: `--name`, `--name=value`, or short alias `-x`.
|
|
133
|
+
if (!sawDoubleDash && tok.startsWith('-') && tok !== '-') {
|
|
134
|
+
const eqIdx = tok.indexOf('=');
|
|
135
|
+
let rawName;
|
|
136
|
+
let inlineValue;
|
|
137
|
+
if (eqIdx !== -1) {
|
|
138
|
+
rawName = tok.slice(0, eqIdx);
|
|
139
|
+
inlineValue = tok.slice(eqIdx + 1);
|
|
140
|
+
} else {
|
|
141
|
+
rawName = tok;
|
|
142
|
+
inlineValue = undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Resolve short alias to canonical.
|
|
146
|
+
const canonical = aliasMap[rawName] || rawName;
|
|
147
|
+
const def = flagsSpec[canonical];
|
|
148
|
+
if (!def) {
|
|
149
|
+
throw new ArgparseError(`unknown flag: ${rawName}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const type = def.type || 'string';
|
|
153
|
+
|
|
154
|
+
if (type === 'boolean') {
|
|
155
|
+
let value;
|
|
156
|
+
if (inlineValue !== undefined) {
|
|
157
|
+
if (TRUE_BOOL_VALUES.has(inlineValue.toLowerCase())) value = true;
|
|
158
|
+
else if (FALSE_BOOL_VALUES.has(inlineValue.toLowerCase())) value = false;
|
|
159
|
+
else {
|
|
160
|
+
throw new ArgparseError(
|
|
161
|
+
`boolean flag ${canonical} got non-boolean value: ${inlineValue}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
// P4 round-1 review fix: reject `--flag value` (spaced) form for
|
|
166
|
+
// booleans. Previously `--remove false` parsed as `--remove=true`
|
|
167
|
+
// with `false` silently consumed as an extra positional — masking
|
|
168
|
+
// user intent. The acceptable forms are `--flag` and `--flag=value`.
|
|
169
|
+
const next = argv[i + 1];
|
|
170
|
+
if (
|
|
171
|
+
next !== undefined
|
|
172
|
+
&& (TRUE_BOOL_VALUES.has(next.toLowerCase())
|
|
173
|
+
|| FALSE_BOOL_VALUES.has(next.toLowerCase()))
|
|
174
|
+
) {
|
|
175
|
+
throw new ArgparseError(
|
|
176
|
+
`boolean flag ${canonical} does not accept a positional value: ${next} `
|
|
177
|
+
+ `(use ${canonical}=${next} if you meant to set it explicitly)`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
value = true;
|
|
181
|
+
}
|
|
182
|
+
out.flags[canonical] = value;
|
|
183
|
+
i += 1;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// string / number — need a value (either inline or next token).
|
|
188
|
+
let raw;
|
|
189
|
+
if (inlineValue !== undefined) {
|
|
190
|
+
raw = inlineValue;
|
|
191
|
+
i += 1;
|
|
192
|
+
} else {
|
|
193
|
+
if (i + 1 >= argv.length) {
|
|
194
|
+
throw new ArgparseError(`flag ${canonical} requires a value`);
|
|
195
|
+
}
|
|
196
|
+
raw = argv[i + 1];
|
|
197
|
+
// Don't allow the next token to be another flag if it looks like one.
|
|
198
|
+
// This catches typos like `--task --json` where the user forgot the
|
|
199
|
+
// task value — better to fail than silently consume `--json` as the
|
|
200
|
+
// task identifier.
|
|
201
|
+
if (raw.startsWith('-') && raw !== '-' && !/^-?\d/.test(raw)) {
|
|
202
|
+
throw new ArgparseError(
|
|
203
|
+
`flag ${canonical} requires a value (got next flag ${raw})`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
i += 2;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let value;
|
|
210
|
+
if (type === 'number') {
|
|
211
|
+
const n = Number(raw);
|
|
212
|
+
if (!Number.isFinite(n)) {
|
|
213
|
+
throw new ArgparseError(`flag ${canonical} expects a number, got: ${raw}`);
|
|
214
|
+
}
|
|
215
|
+
value = n;
|
|
216
|
+
} else {
|
|
217
|
+
// string (default)
|
|
218
|
+
value = raw;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (def.repeatable === true) {
|
|
222
|
+
const cur = out.flags[canonical];
|
|
223
|
+
if (Array.isArray(cur)) cur.push(value);
|
|
224
|
+
else out.flags[canonical] = [value];
|
|
225
|
+
} else {
|
|
226
|
+
out.flags[canonical] = value;
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Positional.
|
|
232
|
+
out.positionalArray.push(tok);
|
|
233
|
+
i += 1;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Map positionalArray → named positional slots.
|
|
237
|
+
for (let p = 0; p < positionalSpec.length; p++) {
|
|
238
|
+
const ps = positionalSpec[p];
|
|
239
|
+
if (!ps || typeof ps.name !== 'string') continue;
|
|
240
|
+
if (p < out.positionalArray.length) {
|
|
241
|
+
out.positional[ps.name] = out.positionalArray[p];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Validate required positionals (skip if --help was requested — caller
|
|
246
|
+
// should print help and bail before we ever validate).
|
|
247
|
+
if (!out.helpRequested) {
|
|
248
|
+
for (const ps of positionalSpec) {
|
|
249
|
+
if (ps && ps.required === true && !(ps.name in out.positional)) {
|
|
250
|
+
throw new ArgparseError(`missing required positional: ${ps.name}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// P4 round-1 review fix: reject extra positionals unless the spec
|
|
254
|
+
// opts in via `restPositional: true`. Previously `tree <id> garbage`
|
|
255
|
+
// silently dropped the trailing token — an operator typo would not
|
|
256
|
+
// surface and they'd get unexpected output.
|
|
257
|
+
if (spec.restPositional !== true && out.positionalArray.length > positionalSpec.length) {
|
|
258
|
+
const extras = out.positionalArray.slice(positionalSpec.length);
|
|
259
|
+
throw new ArgparseError(
|
|
260
|
+
`unexpected extra positional argument(s): ${extras.join(' ')}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Render a help banner for a subcommand. Composed by the subcommand handler
|
|
270
|
+
* (passes its own usage line + flag descriptions). Returns a string ending
|
|
271
|
+
* with a newline so the caller can `process.stdout.write()` directly.
|
|
272
|
+
*
|
|
273
|
+
* @param {{ usage: string, summary?: string, flags?: Array<{ name: string, desc: string }>, examples?: string[] }} parts
|
|
274
|
+
*/
|
|
275
|
+
export function formatHelp(parts) {
|
|
276
|
+
const lines = [];
|
|
277
|
+
lines.push(`Usage: ${parts.usage}`);
|
|
278
|
+
if (parts.summary) {
|
|
279
|
+
lines.push('');
|
|
280
|
+
lines.push(parts.summary);
|
|
281
|
+
}
|
|
282
|
+
if (Array.isArray(parts.flags) && parts.flags.length > 0) {
|
|
283
|
+
lines.push('');
|
|
284
|
+
lines.push('Flags:');
|
|
285
|
+
const width = parts.flags.reduce((m, f) => Math.max(m, f.name.length), 0);
|
|
286
|
+
for (const f of parts.flags) {
|
|
287
|
+
lines.push(` ${f.name.padEnd(width)} ${f.desc}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (Array.isArray(parts.examples) && parts.examples.length > 0) {
|
|
291
|
+
lines.push('');
|
|
292
|
+
lines.push('Examples:');
|
|
293
|
+
for (const ex of parts.examples) lines.push(` ${ex}`);
|
|
294
|
+
}
|
|
295
|
+
return lines.join('\n') + '\n';
|
|
296
|
+
}
|
package/cli/close.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sessions-db close <stable_id> --outcome X [--reason "..."]` — mark a
|
|
3
|
+
* session closed with a terminal outcome.
|
|
4
|
+
*
|
|
5
|
+
* Day 3 refactor: routes through `lib/operations.closeSession`. The CLI
|
|
6
|
+
* keeps the historical exit-2 path for missing / invalid `--outcome`
|
|
7
|
+
* (an argparse-class error) so the test suite can pin both the message
|
|
8
|
+
* and the code without depending on operations' return shape for those
|
|
9
|
+
* pre-call validations.
|
|
10
|
+
*
|
|
11
|
+
* Outcome enum is enforced (matches projection schema):
|
|
12
|
+
* open | done | blocked | abandoned | merged | superseded
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { closeSession } from '../lib/operations.mjs';
|
|
16
|
+
import { ArgparseError, formatHelp, parseArgs } from './argparse.mjs';
|
|
17
|
+
import { renderDryRun, reportResult, reportStableIdNotFound } from './_write-helpers.mjs';
|
|
18
|
+
|
|
19
|
+
const VALID_OUTCOMES = new Set(['open', 'done', 'blocked', 'abandoned', 'merged', 'superseded']);
|
|
20
|
+
|
|
21
|
+
const SPEC = {
|
|
22
|
+
positional: [{ name: 'stable_id', required: true }],
|
|
23
|
+
flags: {
|
|
24
|
+
'--outcome': { type: 'string' },
|
|
25
|
+
'--reason': { type: 'string' },
|
|
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 close <stable_id> --outcome <outcome> [--reason "..."]',
|
|
35
|
+
summary: 'Close (or reopen) a session with a terminal outcome.',
|
|
36
|
+
flags: [
|
|
37
|
+
{ name: '--outcome <s>', desc: 'open | done | blocked | abandoned | merged | superseded' },
|
|
38
|
+
{ name: '--reason <s>', desc: 'human-readable reason (free text)' },
|
|
39
|
+
{ name: '--dry-run', desc: 'print event but do not write' },
|
|
40
|
+
{ name: '--json', desc: 'JSON output' },
|
|
41
|
+
{ name: '--root <p>', desc: 'override storage root' },
|
|
42
|
+
],
|
|
43
|
+
examples: [
|
|
44
|
+
'sessions-db close sess_01970000-... --outcome done --reason "merged into master"',
|
|
45
|
+
'sessions-db close sess_01970000-... --outcome blocked --reason "waiting on infra"',
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export async function run(argv) {
|
|
50
|
+
let parsed;
|
|
51
|
+
try {
|
|
52
|
+
parsed = parseArgs(argv, SPEC);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (err instanceof ArgparseError) {
|
|
55
|
+
process.stderr.write(`error: ${err.message}\n\n${HELP}`);
|
|
56
|
+
process.exit(err.exitCode);
|
|
57
|
+
}
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (parsed.helpRequested) {
|
|
62
|
+
process.stdout.write(HELP);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stableId = parsed.positional.stable_id;
|
|
67
|
+
const outcome = parsed.flags['--outcome'];
|
|
68
|
+
const reason = parsed.flags['--reason'];
|
|
69
|
+
const root = parsed.flags['--root'];
|
|
70
|
+
const dryRun = parsed.flags['--dry-run'] === true;
|
|
71
|
+
const json = parsed.flags['--json'] === true;
|
|
72
|
+
const quiet = parsed.flags['--quiet'] === true;
|
|
73
|
+
|
|
74
|
+
// Argparse-class checks (exit 2). The library would also reject these,
|
|
75
|
+
// but routing through CLI keeps the historical message + exit code that
|
|
76
|
+
// tests pin against.
|
|
77
|
+
if (!outcome) {
|
|
78
|
+
process.stderr.write(`error: --outcome is required\n`);
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
if (!VALID_OUTCOMES.has(outcome)) {
|
|
82
|
+
process.stderr.write(`error: --outcome must be one of: ${[...VALID_OUTCOMES].join(', ')}\n`);
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (dryRun) {
|
|
87
|
+
const payload = { outcome };
|
|
88
|
+
if (reason !== undefined) payload.closed_reason = reason;
|
|
89
|
+
renderDryRun({ op: 'close', stableId, payload, json });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const opts = root ? { root } : {};
|
|
94
|
+
const result = await closeSession({
|
|
95
|
+
stableId,
|
|
96
|
+
outcome,
|
|
97
|
+
reason,
|
|
98
|
+
...opts,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!result.ok && typeof result.error === 'string'
|
|
102
|
+
&& result.error.startsWith('stable_id not found:')) {
|
|
103
|
+
if (!quiet) {
|
|
104
|
+
const code = reportStableIdNotFound(result.error);
|
|
105
|
+
process.exit(code);
|
|
106
|
+
}
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const extra = { outcome };
|
|
111
|
+
if (reason !== undefined) extra.reason = reason;
|
|
112
|
+
const code = reportResult({
|
|
113
|
+
result, op: 'close', stableId, json, quiet, extra,
|
|
114
|
+
});
|
|
115
|
+
if (code !== 0) process.exit(code);
|
|
116
|
+
}
|
package/cli/find.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sessions-db find` — filter sessions by task / project / alias / branch /
|
|
3
|
+
* cwd / state / outcome. Read-only: never appends to events.jsonl.
|
|
4
|
+
*
|
|
5
|
+
* Filter semantics:
|
|
6
|
+
* - All flags AND together (intersection).
|
|
7
|
+
* - String filters are exact match. cwd uses substring match because the
|
|
8
|
+
* stored cwd is an absolute path and operators usually remember the
|
|
9
|
+
* trailing dir name only.
|
|
10
|
+
* - state / outcome are validated against the projection's enum
|
|
11
|
+
* (active|idle|archived for state, open|done|blocked|abandoned|
|
|
12
|
+
* merged|superseded for outcome). Invalid values exit 2 (argparse-class).
|
|
13
|
+
* - --limit defaults to 50 to keep a `find` with no filters readable. Sort
|
|
14
|
+
* is last_progress_at DESC (most recent first) — matches the UX where
|
|
15
|
+
* ops staff want to see "what's been touched lately" by default.
|
|
16
|
+
*
|
|
17
|
+
* Output:
|
|
18
|
+
* - default: ASCII table (formatSessionTable)
|
|
19
|
+
* - --json: array of full session records
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { loadProjection } from '../lib/storage.mjs';
|
|
23
|
+
import { ArgparseError, formatHelp, parseArgs } from './argparse.mjs';
|
|
24
|
+
import { formatJSON, formatSessionTable, shouldUseColor } from './format.mjs';
|
|
25
|
+
|
|
26
|
+
const VALID_STATES = new Set(['active', 'idle', 'archived']);
|
|
27
|
+
const VALID_OUTCOMES = new Set(['open', 'done', 'blocked', 'abandoned', 'merged', 'superseded']);
|
|
28
|
+
|
|
29
|
+
const SPEC = {
|
|
30
|
+
positional: [],
|
|
31
|
+
flags: {
|
|
32
|
+
'--task': { type: 'string' },
|
|
33
|
+
'--project': { type: 'string' },
|
|
34
|
+
'--alias': { type: 'string' },
|
|
35
|
+
'--branch': { type: 'string' },
|
|
36
|
+
'--cwd': { type: 'string' },
|
|
37
|
+
'--state': { type: 'string' },
|
|
38
|
+
'--outcome': { type: 'string' },
|
|
39
|
+
'--limit': { type: 'number', default: 50 },
|
|
40
|
+
'--json': { type: 'boolean' },
|
|
41
|
+
'--no-color': { type: 'boolean' },
|
|
42
|
+
'--root': { type: 'string' },
|
|
43
|
+
'--quiet': { type: 'boolean' },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const HELP = formatHelp({
|
|
48
|
+
usage: 'sessions-db find [filters] [--limit N] [--json]',
|
|
49
|
+
summary: 'Filter sessions by task / project / alias / branch / cwd / state / outcome.',
|
|
50
|
+
flags: [
|
|
51
|
+
{ name: '--task <id>', desc: 'task filename match (e.g. feat-foo-DDMMYYYY.md)' },
|
|
52
|
+
{ name: '--project <id>', desc: 'project key/dirname match' },
|
|
53
|
+
{ name: '--alias <s>', desc: 'exact alias match' },
|
|
54
|
+
{ name: '--branch <b>', desc: 'branch_current or branch_at_start exact match' },
|
|
55
|
+
{ name: '--cwd <s>', desc: 'cwd / worktree_path substring match' },
|
|
56
|
+
{ name: '--state <s>', desc: 'active | idle | archived' },
|
|
57
|
+
{ name: '--outcome <s>', desc: 'open | done | blocked | abandoned | merged | superseded' },
|
|
58
|
+
{ name: '--limit <N>', desc: 'cap result count (default 50)' },
|
|
59
|
+
{ name: '--json', desc: 'machine-readable JSON output' },
|
|
60
|
+
{ name: '--no-color', desc: 'disable ANSI color' },
|
|
61
|
+
{ name: '--root <path>', desc: 'override storage root (default cwd)' },
|
|
62
|
+
],
|
|
63
|
+
examples: [
|
|
64
|
+
'sessions-db find --task feat-pricing-overhaul-04052026.md',
|
|
65
|
+
'sessions-db find --state active --limit 10',
|
|
66
|
+
'sessions-db find --branch master --outcome open --json',
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Pure filter — exposed so the integration test (and tree-style debugging
|
|
72
|
+
* tools) can drive the same query plane without the CLI shell.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} projection
|
|
75
|
+
* @param {{ task?: string, project?: string, alias?: string, branch?: string,
|
|
76
|
+
* cwd?: string, state?: string, outcome?: string, limit?: number }} filters
|
|
77
|
+
* @returns {Array<object>}
|
|
78
|
+
*/
|
|
79
|
+
export function searchSessions(projection, filters = {}) {
|
|
80
|
+
const sessions = projection && projection.sessions ? projection.sessions : {};
|
|
81
|
+
const limit = typeof filters.limit === 'number' && filters.limit > 0 ? filters.limit : 50;
|
|
82
|
+
|
|
83
|
+
const out = [];
|
|
84
|
+
for (const s of Object.values(sessions)) {
|
|
85
|
+
if (!matches(s, filters)) continue;
|
|
86
|
+
out.push(s);
|
|
87
|
+
}
|
|
88
|
+
// Sort by last_progress_at DESC (string compare on ISO 8601 is lexically
|
|
89
|
+
// correct for descending recency).
|
|
90
|
+
out.sort((a, b) => {
|
|
91
|
+
const la = a.last_progress_at || '';
|
|
92
|
+
const lb = b.last_progress_at || '';
|
|
93
|
+
if (la === lb) return 0;
|
|
94
|
+
return la < lb ? 1 : -1;
|
|
95
|
+
});
|
|
96
|
+
return out.slice(0, limit);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function matches(session, filters) {
|
|
100
|
+
if (!session || typeof session !== 'object') return false;
|
|
101
|
+
|
|
102
|
+
if (filters.task) {
|
|
103
|
+
if (!Array.isArray(session.tasks) || !session.tasks.includes(filters.task)) return false;
|
|
104
|
+
}
|
|
105
|
+
if (filters.project) {
|
|
106
|
+
if (!Array.isArray(session.projects) || !session.projects.includes(filters.project)) return false;
|
|
107
|
+
}
|
|
108
|
+
if (filters.alias) {
|
|
109
|
+
if (session.alias !== filters.alias) return false;
|
|
110
|
+
}
|
|
111
|
+
if (filters.branch) {
|
|
112
|
+
if (session.branch_current !== filters.branch
|
|
113
|
+
&& session.branch_at_start !== filters.branch) return false;
|
|
114
|
+
}
|
|
115
|
+
if (filters.cwd) {
|
|
116
|
+
const candidates = [session.cwd, session.worktree_path_observed, session.worktree_realpath]
|
|
117
|
+
.filter((v) => typeof v === 'string' && v.length > 0);
|
|
118
|
+
if (!candidates.some((v) => v.includes(filters.cwd))) return false;
|
|
119
|
+
}
|
|
120
|
+
if (filters.state) {
|
|
121
|
+
if (session.activity_state !== filters.state) return false;
|
|
122
|
+
}
|
|
123
|
+
if (filters.outcome) {
|
|
124
|
+
if (session.outcome !== filters.outcome) return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function run(argv) {
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = parseArgs(argv, SPEC);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err instanceof ArgparseError) {
|
|
135
|
+
process.stderr.write(`error: ${err.message}\n\n${HELP}`);
|
|
136
|
+
process.exit(err.exitCode);
|
|
137
|
+
}
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (parsed.helpRequested) {
|
|
142
|
+
process.stdout.write(HELP);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Validate enum-typed flags.
|
|
147
|
+
if (parsed.flags['--state'] !== undefined && !VALID_STATES.has(parsed.flags['--state'])) {
|
|
148
|
+
process.stderr.write(`error: --state must be one of: ${[...VALID_STATES].join(', ')}\n`);
|
|
149
|
+
process.exit(2);
|
|
150
|
+
}
|
|
151
|
+
if (parsed.flags['--outcome'] !== undefined && !VALID_OUTCOMES.has(parsed.flags['--outcome'])) {
|
|
152
|
+
process.stderr.write(`error: --outcome must be one of: ${[...VALID_OUTCOMES].join(', ')}\n`);
|
|
153
|
+
process.exit(2);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const root = parsed.flags['--root'];
|
|
157
|
+
const projection = await loadProjection(root ? { root } : {});
|
|
158
|
+
|
|
159
|
+
const filters = {
|
|
160
|
+
task: parsed.flags['--task'],
|
|
161
|
+
project: parsed.flags['--project'],
|
|
162
|
+
alias: parsed.flags['--alias'],
|
|
163
|
+
branch: parsed.flags['--branch'],
|
|
164
|
+
cwd: parsed.flags['--cwd'],
|
|
165
|
+
state: parsed.flags['--state'],
|
|
166
|
+
outcome: parsed.flags['--outcome'],
|
|
167
|
+
limit: parsed.flags['--limit'],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const matched = searchSessions(projection, filters);
|
|
171
|
+
|
|
172
|
+
if (parsed.flags['--quiet']) return;
|
|
173
|
+
|
|
174
|
+
if (parsed.flags['--json']) {
|
|
175
|
+
process.stdout.write(formatJSON(matched));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const useColor = shouldUseColor(
|
|
180
|
+
process.stdout.isTTY,
|
|
181
|
+
process.env,
|
|
182
|
+
parsed.flags['--no-color'] === true,
|
|
183
|
+
);
|
|
184
|
+
process.stdout.write(formatSessionTable(matched, { useColor }));
|
|
185
|
+
}
|