@yeison.restrepo.r/code-conductor 1.23.1 → 1.23.2
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/bin/code-conductor.mjs +1 -1
- package/lib/installer/deploy.mjs +3 -0
- package/package.json +2 -1
- package/scripts/conductor-db.mjs +447 -0
- package/scripts/detect-stack.mjs +679 -0
- package/scripts/resume-read.mjs +157 -0
- package/scripts/session-id.mjs +64 -0
- package/scripts/snap-build.mjs +90 -0
- package/scripts/snap-validate.mjs +32 -0
package/bin/code-conductor.mjs
CHANGED
|
@@ -87,7 +87,7 @@ export function run(argv, env = process.env, { cwd = process.cwd(), log } = {})
|
|
|
87
87
|
// and the environment codes (EROFS/EACCES/EPERM), which are exit 1 regardless.
|
|
88
88
|
let writing = false;
|
|
89
89
|
try {
|
|
90
|
-
assertAssets(assetRoot, ['global', 'skills', 'project-template']); // pre-flight
|
|
90
|
+
assertAssets(assetRoot, ['global', 'skills', 'scripts', 'project-template']); // pre-flight
|
|
91
91
|
writing = true; // copy phase begins
|
|
92
92
|
const claudeDir = deployGlobal(assetRoot, home);
|
|
93
93
|
chmodHooks(claudeDir);
|
package/lib/installer/deploy.mjs
CHANGED
|
@@ -67,6 +67,9 @@ export function deployProject(assetRoot, cwd) {
|
|
|
67
67
|
if (name === '.claude') continue;
|
|
68
68
|
cpSync(join(templateRoot, name), join(cwd, name), CP_OPTS);
|
|
69
69
|
}
|
|
70
|
+
// scripts/ is a sibling of project-template/ under assetRoot (not nested inside
|
|
71
|
+
// it), since it's shared source the CLI itself doesn't template-copy per file.
|
|
72
|
+
cpSync(join(assetRoot, 'scripts'), join(cwd, 'scripts'), CP_OPTS);
|
|
70
73
|
return target;
|
|
71
74
|
}
|
|
72
75
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yeison.restrepo.r/code-conductor",
|
|
3
|
-
"version": "1.23.
|
|
3
|
+
"version": "1.23.2",
|
|
4
4
|
"description": "A spec-first, token-efficient Claude Code configuration and installer CLI.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"lib/",
|
|
16
16
|
"global/",
|
|
17
17
|
"skills/",
|
|
18
|
+
"scripts/",
|
|
18
19
|
"project-template/"
|
|
19
20
|
],
|
|
20
21
|
"engines": {
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// scripts/conductor-db.mjs
|
|
2
|
+
//
|
|
3
|
+
// Local task-state cache writer for Code Conductor (FEAT-005).
|
|
4
|
+
// Wraps Node's built-in node:sqlite (DatabaseSync) to own <repo-root>/.conductor/cache.db.
|
|
5
|
+
//
|
|
6
|
+
// REQUIRES Node >= 22.5 with node:sqlite available. On older Node the dynamic
|
|
7
|
+
// import('node:sqlite') fails and the script degrades gracefully (warn, exit 0).
|
|
8
|
+
// The caller (cc-implement Step 6 hook / test helper) passes --experimental-sqlite
|
|
9
|
+
// only when Node >= 22.5. engines.node stays >=20; this cache self-disables below 22.5.
|
|
10
|
+
//
|
|
11
|
+
// All failures are NON-FATAL: one stderr line prefixed "CONDUCTOR_DB:" then exit 0.
|
|
12
|
+
// The plan markdown remains the authoritative task-state record.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import { mkdirSync, existsSync, renameSync, unlinkSync, statSync, readSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, resolve, relative, sep } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const PREFIX = 'CONDUCTOR_DB:';
|
|
20
|
+
const warn = (m) => process.stderr.write(`${PREFIX} ${m}\n`);
|
|
21
|
+
|
|
22
|
+
const VALID_STATES = new Set([' ', '>', 'X', '!']);
|
|
23
|
+
const MAX_KEY_LEN = 512;
|
|
24
|
+
const WALK_CAP = 40;
|
|
25
|
+
const USAGE = "usage: conductor-db.mjs record <plan_file> <task_id> <state> | init";
|
|
26
|
+
const SCHEMA_VERSION = 2;
|
|
27
|
+
const MAX_SNAP_BYTES = 10 * 1024 * 1024; // 10 MiB
|
|
28
|
+
const MAX_CONTENT_BYTES = 1024 * 1024; // 1 MiB
|
|
29
|
+
const STDIN_CHUNK = 65536;
|
|
30
|
+
const U_SESSION = 'usage: conductor-db.mjs session <session_id> <phase> <spec> <git_commit_hash>';
|
|
31
|
+
const U_GET_SESSION = 'usage: conductor-db.mjs get-session <session_id>';
|
|
32
|
+
const U_SNAPSHOT = 'usage: conductor-db.mjs snapshot <git_commit_hash>';
|
|
33
|
+
const U_GET_SNAPSHOT = 'usage: conductor-db.mjs get-snapshot <git_commit_hash>';
|
|
34
|
+
const U_HISTORY = 'usage: conductor-db.mjs history <session_id> <kind>';
|
|
35
|
+
|
|
36
|
+
function validateKey(name, value, usage = USAGE) {
|
|
37
|
+
const v = String(value ?? '').trim(); // 1. trim FIRST (whitespace never counts)
|
|
38
|
+
if (!v) { warn(`${name} is empty; ${usage}`); return null; } // 2. empty-check on trimmed
|
|
39
|
+
if (v.length > MAX_KEY_LEN) { warn(`${name} exceeds ${MAX_KEY_LEN} chars; rejected`); return null; } // 3. length-check on trimmed
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Optional metadata: empty allowed, NOT trimmed, capped at MAX_KEY_LEN. Returns
|
|
44
|
+
// the raw string (possibly '') on success, or null ONLY when over the cap. Callers
|
|
45
|
+
// must test `=== null` — '' is a valid accepted value, not a rejection.
|
|
46
|
+
function validateOptional(name, value) {
|
|
47
|
+
const v = String(value ?? '');
|
|
48
|
+
if (v.length > MAX_KEY_LEN) { warn(`${name} exceeds ${MAX_KEY_LEN} chars; rejected`); return null; }
|
|
49
|
+
return v;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function resolveRoot() {
|
|
53
|
+
// 1. Primary: git.
|
|
54
|
+
try {
|
|
55
|
+
const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
56
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
57
|
+
}).trim();
|
|
58
|
+
if (out) return resolve(out);
|
|
59
|
+
} catch { /* fall through */ }
|
|
60
|
+
// 2. Fallback A: bounded walk up from cwd looking for a .git entry.
|
|
61
|
+
let dir = resolve(process.cwd());
|
|
62
|
+
for (let i = 0; i < WALK_CAP; i++) {
|
|
63
|
+
if (existsSync(join(dir, '.git'))) return dir;
|
|
64
|
+
const parent = dirname(dir);
|
|
65
|
+
if (parent === dir) break; // filesystem root
|
|
66
|
+
dir = parent;
|
|
67
|
+
}
|
|
68
|
+
// 3. Fallback B: this script lives at <root>/scripts/conductor-db.mjs.
|
|
69
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizePlanFile(planFile, root) {
|
|
73
|
+
// Repo-relative, then force POSIX separators so a Windows `\` key never
|
|
74
|
+
// duplicates the same plan's POSIX `/` key. `sep` is `\` on Windows, `/` on POSIX.
|
|
75
|
+
// `resolve`/`relative` already collapse redundant separators (`a//b` -> `a/b`);
|
|
76
|
+
// the trailing replace is a defensive guarantee that no `//` ever persists.
|
|
77
|
+
return relative(root, resolve(process.cwd(), planFile))
|
|
78
|
+
.split(sep).join('/')
|
|
79
|
+
.replace(/\/{2,}/g, '/');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function applySchema(db) {
|
|
83
|
+
// WAL is best-effort: on a network share / virtualized FS that lacks the
|
|
84
|
+
// shared-memory + POSIX locking WAL needs, this either silently returns a
|
|
85
|
+
// rollback journal (e.g. 'delete') or throws. Either way we proceed — the
|
|
86
|
+
// engine works correctly under the default rollback journal; WAL is only an
|
|
87
|
+
// optimization. A hard I/O failure surfaces later and degrades in withDb.
|
|
88
|
+
try { db.exec('PRAGMA journal_mode = WAL;'); } catch { /* WAL unsupported: use default journal */ }
|
|
89
|
+
db.exec('BEGIN IMMEDIATE;');
|
|
90
|
+
try {
|
|
91
|
+
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
|
|
92
|
+
db.exec(
|
|
93
|
+
"CREATE TABLE IF NOT EXISTS task_state (" +
|
|
94
|
+
"plan_file TEXT NOT NULL, task_id TEXT NOT NULL, " +
|
|
95
|
+
"state TEXT NOT NULL CHECK (state IN (' ', '>', 'X', '!')), " +
|
|
96
|
+
"updated_at TEXT NOT NULL, " +
|
|
97
|
+
"PRIMARY KEY (plan_file, task_id)) WITHOUT ROWID;"
|
|
98
|
+
);
|
|
99
|
+
db.exec(
|
|
100
|
+
"CREATE TABLE IF NOT EXISTS sessions (" +
|
|
101
|
+
"session_id TEXT NOT NULL, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, " +
|
|
102
|
+
"phase TEXT NOT NULL DEFAULT '', spec TEXT NOT NULL DEFAULT '', " +
|
|
103
|
+
"git_commit_hash TEXT NOT NULL DEFAULT '', " +
|
|
104
|
+
"PRIMARY KEY (session_id)) WITHOUT ROWID;"
|
|
105
|
+
);
|
|
106
|
+
db.exec(
|
|
107
|
+
"CREATE TABLE IF NOT EXISTS snapshots (" +
|
|
108
|
+
"id INTEGER PRIMARY KEY, git_commit_hash TEXT NOT NULL, " +
|
|
109
|
+
"created_at TEXT NOT NULL, snap_json TEXT NOT NULL);"
|
|
110
|
+
);
|
|
111
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_snapshots_hash ON snapshots (git_commit_hash);");
|
|
112
|
+
db.exec(
|
|
113
|
+
"CREATE TABLE IF NOT EXISTS raw_history (" +
|
|
114
|
+
"id INTEGER PRIMARY KEY, session_id TEXT NOT NULL DEFAULT '', " +
|
|
115
|
+
"created_at TEXT NOT NULL, kind TEXT NOT NULL DEFAULT '', content TEXT NOT NULL);"
|
|
116
|
+
);
|
|
117
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_raw_history_session ON raw_history (session_id);");
|
|
118
|
+
db.exec('COMMIT;');
|
|
119
|
+
} catch (e) {
|
|
120
|
+
try { db.exec('ROLLBACK;'); } catch { /* ignore */ }
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// True iff the task_state table is physically present, independent of the
|
|
126
|
+
// user_version header. Guards the case where an interrupted run left a version
|
|
127
|
+
// header without the table (or vice-versa): we re-apply the idempotent schema.
|
|
128
|
+
function tableExists(db) {
|
|
129
|
+
return !!db.prepare(
|
|
130
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'task_state'"
|
|
131
|
+
).get();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Open the db and immediately arm a busy timeout so rapid back-to-back hook
|
|
135
|
+
// invocations wait up to 2s for a transient lock instead of failing on the first
|
|
136
|
+
// contention. Every connection in this script is opened through here.
|
|
137
|
+
//
|
|
138
|
+
// The busy_timeout pragma is an optimization, not a correctness requirement: if
|
|
139
|
+
// it throws after a successful open, that is a NON-FATAL boundary — we log one
|
|
140
|
+
// CONDUCTOR_DB line and return the (still fully usable) handle rather than
|
|
141
|
+
// aborting or leaking it. Without the timeout the connection simply falls back
|
|
142
|
+
// to the SQLITE_BUSY graceful-degradation path on contention.
|
|
143
|
+
function openConn(DatabaseSync, dbPath) {
|
|
144
|
+
const db = new DatabaseSync(dbPath);
|
|
145
|
+
try { db.exec('PRAGMA busy_timeout = 2000;'); }
|
|
146
|
+
catch { warn('busy_timeout pragma failed, continuing without it'); }
|
|
147
|
+
return db;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Bounded, memory-safe stdin reader. Loops readSync(0,...) into a fixed reusable
|
|
151
|
+
// chunk buffer and stops the instant the running total EXCEEDS `max` (returning an
|
|
152
|
+
// over-cap signal without draining a hostile stream), so peak memory is ~max+chunk.
|
|
153
|
+
// Never calls process.exit; a read error propagates to the caller's try/catch.
|
|
154
|
+
function readStdinCapped(max) {
|
|
155
|
+
const chunk = Buffer.allocUnsafe(STDIN_CHUNK);
|
|
156
|
+
const parts = [];
|
|
157
|
+
let total = 0;
|
|
158
|
+
for (;;) {
|
|
159
|
+
let n;
|
|
160
|
+
try { n = readSync(0, chunk, 0, STDIN_CHUNK, null); }
|
|
161
|
+
catch (e) {
|
|
162
|
+
if (e && e.code === 'EAGAIN') continue; // non-blocking stdin not ready: retry
|
|
163
|
+
if (e && e.code === 'EOF') break; // some platforms signal EOF as throw
|
|
164
|
+
throw e;
|
|
165
|
+
}
|
|
166
|
+
if (n === 0) break; // clean EOF
|
|
167
|
+
total += n;
|
|
168
|
+
if (total > max) return { buf: null, overCap: true };
|
|
169
|
+
parts.push(Buffer.from(chunk.subarray(0, n)));
|
|
170
|
+
}
|
|
171
|
+
return { buf: Buffer.concat(parts), overCap: false };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const compactStamp = () => new Date().toISOString().replace(/[-:.]/g, '');
|
|
175
|
+
|
|
176
|
+
// Best-effort removal of the WAL/SHM sidecars that belong to a db file we are
|
|
177
|
+
// replacing. A stale `-wal`/`-shm` whose header no longer matches the fresh db
|
|
178
|
+
// can panic the next connection open, so they must go with the main file.
|
|
179
|
+
function clearSidecars(dbPath) {
|
|
180
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
181
|
+
// The common case is that the sidecar does not exist -> unlinkSync throws
|
|
182
|
+
// ENOENT; that is expected and swallowed. Any other error (EACCES, locked
|
|
183
|
+
// file) is likewise non-fatal here. The empty catch suppresses ALL of them.
|
|
184
|
+
try { unlinkSync(`${dbPath}${suffix}`); } catch { /* ENOENT (absent) or locked: best-effort, ignore */ }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Move a corrupt/non-regular file aside; never recursively delete. Returns true
|
|
189
|
+
// if the path was cleared (renamed or removed) so a fresh db can be created.
|
|
190
|
+
function backupAside(path) {
|
|
191
|
+
const base = `${path}.corrupt.${compactStamp()}`;
|
|
192
|
+
let target = base;
|
|
193
|
+
for (let i = 1; i <= 100 && existsSync(target); i++) target = `${base}.${i}`;
|
|
194
|
+
|
|
195
|
+
const cleared = (() => {
|
|
196
|
+
// Preferred: rename aside — atomic and content-preserving for files AND dirs.
|
|
197
|
+
try { renameSync(path, target); return true; }
|
|
198
|
+
catch { /* rename failed (locked / cross-device / perms): type-aware fallback */ }
|
|
199
|
+
|
|
200
|
+
// Rename failed. The unlink fallback is valid ONLY for a regular file:
|
|
201
|
+
// - unlinkSync on a directory throws EISDIR (POSIX) / EPERM (Windows);
|
|
202
|
+
// - rmdirSync only removes an *empty* dir (ours may hold files) and a
|
|
203
|
+
// recursive delete is explicitly forbidden.
|
|
204
|
+
// So: regular file -> unlinkSync; directory -> give up (warn, no write),
|
|
205
|
+
// never unlink/rmdir/rm -r it. This is why we branch on statSync here rather
|
|
206
|
+
// than blindly calling unlinkSync.
|
|
207
|
+
let isDir = false;
|
|
208
|
+
try { isDir = statSync(path).isDirectory(); }
|
|
209
|
+
catch { return true; } // path vanished between attempts: treat as cleared
|
|
210
|
+
if (isDir) {
|
|
211
|
+
warn(`cannot move aside directory at ${path} (rename failed); skipping cache write`);
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
try { unlinkSync(path); return true; }
|
|
215
|
+
catch { warn(`cannot move aside file at ${path}; skipping cache write`); return false; }
|
|
216
|
+
})();
|
|
217
|
+
|
|
218
|
+
// Only after the main file is cleared: drop its now-orphaned sidecars so the
|
|
219
|
+
// fresh db opens against a clean slate (no mismatched journal headers).
|
|
220
|
+
if (cleared) clearSidecars(path);
|
|
221
|
+
return cleared;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isCorruptionError(e) {
|
|
225
|
+
const s = `${(e && e.code) || ''} ${(e && e.message) || ''}`.toLowerCase();
|
|
226
|
+
return s.includes('not a database') || s.includes('malformed') ||
|
|
227
|
+
s.includes('file is encrypted') || s.includes('notadb');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function openReady(DatabaseSync, dbPath) {
|
|
231
|
+
// A directory or other non-regular file squatting the db path: move it aside
|
|
232
|
+
// (its contents travel with the rename) — never recursively delete it.
|
|
233
|
+
try {
|
|
234
|
+
if (!statSync(dbPath).isFile()) {
|
|
235
|
+
if (!backupAside(dbPath)) return null;
|
|
236
|
+
}
|
|
237
|
+
} catch { /* ENOENT: nothing there yet — normal first run */ }
|
|
238
|
+
|
|
239
|
+
let db;
|
|
240
|
+
try {
|
|
241
|
+
db = openConn(DatabaseSync, dbPath);
|
|
242
|
+
const ver = db.prepare('PRAGMA user_version').get().user_version; // first I/O
|
|
243
|
+
if (ver > SCHEMA_VERSION) {
|
|
244
|
+
warn(`db schema v${ver} newer than supported v${SCHEMA_VERSION}, skipping cache write`);
|
|
245
|
+
db.close();
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
if (ver < SCHEMA_VERSION || !tableExists(db)) applySchema(db);
|
|
249
|
+
return db;
|
|
250
|
+
} catch (e) {
|
|
251
|
+
// Close the (possibly half-usable) handle BEFORE any rethrow or return so
|
|
252
|
+
// no path — corruption OR an unexpected non-corruption error — leaks it.
|
|
253
|
+
try { if (db) db.close(); } catch { /* ignore */ }
|
|
254
|
+
if (!isCorruptionError(e)) throw e;
|
|
255
|
+
if (!backupAside(dbPath)) return null;
|
|
256
|
+
db = openConn(DatabaseSync, dbPath); // retry once on a fresh file
|
|
257
|
+
applySchema(db);
|
|
258
|
+
return db;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function upsert(db, planFile, taskId, state) {
|
|
263
|
+
db.prepare(
|
|
264
|
+
'INSERT INTO task_state (plan_file, task_id, state, updated_at) VALUES (?, ?, ?, ?) ' +
|
|
265
|
+
'ON CONFLICT (plan_file, task_id) DO UPDATE SET state = excluded.state, updated_at = excluded.updated_at'
|
|
266
|
+
).run(planFile, taskId, state, new Date().toISOString());
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function upsertSession(db, sessionId, phase, spec, gitHash) {
|
|
270
|
+
const now = new Date().toISOString();
|
|
271
|
+
db.prepare(
|
|
272
|
+
'INSERT INTO sessions (session_id, started_at, updated_at, phase, spec, git_commit_hash) ' +
|
|
273
|
+
'VALUES ($session_id, $started_at, $updated_at, $phase, $spec, $git_commit_hash) ' +
|
|
274
|
+
'ON CONFLICT(session_id) DO UPDATE SET ' +
|
|
275
|
+
'updated_at = excluded.updated_at, phase = excluded.phase, ' +
|
|
276
|
+
'spec = excluded.spec, git_commit_hash = excluded.git_commit_hash'
|
|
277
|
+
).run({ $session_id: sessionId, $started_at: now, $updated_at: now, $phase: phase, $spec: spec, $git_commit_hash: gitHash });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function withDb(root, fn) {
|
|
281
|
+
let DatabaseSync;
|
|
282
|
+
try {
|
|
283
|
+
({ DatabaseSync } = await import('node:sqlite'));
|
|
284
|
+
} catch {
|
|
285
|
+
warn('node:sqlite unavailable, skipping cache write');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const dir = join(root, '.conductor');
|
|
289
|
+
const dbPath = join(dir, 'cache.db');
|
|
290
|
+
// If `.conductor` already exists but as a regular FILE (not a directory),
|
|
291
|
+
// mkdirSync would throw EEXIST/ENOTDIR. Move the squatting file aside with the
|
|
292
|
+
// same non-destructive rename used for a bad db, then create the directory.
|
|
293
|
+
try {
|
|
294
|
+
if (!statSync(dir).isDirectory()) { if (!backupAside(dir)) return; }
|
|
295
|
+
} catch { /* ENOENT: nothing at the path yet — mkdir will create it */ }
|
|
296
|
+
try {
|
|
297
|
+
mkdirSync(dir, { recursive: true });
|
|
298
|
+
} catch (e) {
|
|
299
|
+
warn(`cannot create ${dir}: ${(e && e.code) || (e && e.message)}, skipping cache write`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
let db = null;
|
|
303
|
+
try {
|
|
304
|
+
db = openReady(DatabaseSync, dbPath);
|
|
305
|
+
if (!db) return; // skipped (unrecoverable or newer schema — Task 9)
|
|
306
|
+
return fn(db);
|
|
307
|
+
} catch (e) {
|
|
308
|
+
warn(`${(e && e.code) || 'error'} writing ${dbPath}: ${e && e.message}, skipping cache write`);
|
|
309
|
+
} finally {
|
|
310
|
+
if (db) {
|
|
311
|
+
// Force a full WAL flush + truncate before closing so the -wal/-shm
|
|
312
|
+
// sidecars are collapsed into the main db on every filesystem (some
|
|
313
|
+
// network/oddball FS skip the implicit close-time checkpoint). Best-effort:
|
|
314
|
+
// a checkpoint failure must never mask the operation's own outcome.
|
|
315
|
+
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE);'); } catch { /* best-effort flush */ }
|
|
316
|
+
try { db.close(); } catch { /* ignore */ }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function cmdRecord(args) {
|
|
322
|
+
if (args.length !== 3) { warn(USAGE); return; }
|
|
323
|
+
const [rawPlan, rawTask, rawState] = args;
|
|
324
|
+
const root = resolveRoot();
|
|
325
|
+
const planFile = validateKey('plan_file', normalizePlanFile(String(rawPlan ?? ''), root));
|
|
326
|
+
if (planFile === null) return;
|
|
327
|
+
const taskId = validateKey('task_id', rawTask);
|
|
328
|
+
if (taskId === null) return;
|
|
329
|
+
const state = String(rawState ?? '');
|
|
330
|
+
if (!VALID_STATES.has(state)) { warn(`invalid state ${JSON.stringify(state)}; must be one of ' ' '>' 'X' '!'`); return; }
|
|
331
|
+
await withDb(root, (db) => upsert(db, planFile, taskId, state));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function cmdInit(args) {
|
|
335
|
+
if (args.length !== 0) { warn(USAGE); return; }
|
|
336
|
+
const root = resolveRoot();
|
|
337
|
+
await withDb(root, () => { /* create/verify only */ });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function cmdSession(args) {
|
|
341
|
+
if (args.length !== 4) { warn(U_SESSION); return; }
|
|
342
|
+
const [rawId, rawPhase, rawSpec, rawHash] = args;
|
|
343
|
+
const sessionId = validateKey('session_id', rawId, U_SESSION);
|
|
344
|
+
if (sessionId === null) return;
|
|
345
|
+
const phase = validateOptional('phase', rawPhase);
|
|
346
|
+
if (phase === null) return;
|
|
347
|
+
const spec = validateOptional('spec', rawSpec);
|
|
348
|
+
if (spec === null) return;
|
|
349
|
+
const gitHash = validateKey('git_commit_hash', rawHash, U_SESSION);
|
|
350
|
+
if (gitHash === null) return;
|
|
351
|
+
const root = resolveRoot();
|
|
352
|
+
await withDb(root, (db) => upsertSession(db, sessionId, phase, spec, gitHash));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function cmdGetSession(args) {
|
|
356
|
+
if (args.length !== 1) { warn(U_GET_SESSION); return; }
|
|
357
|
+
const sessionId = validateKey('session_id', args[0], U_GET_SESSION);
|
|
358
|
+
if (sessionId === null) return;
|
|
359
|
+
const root = resolveRoot();
|
|
360
|
+
const row = await withDb(root, (db) =>
|
|
361
|
+
db.prepare(
|
|
362
|
+
'SELECT session_id, started_at, updated_at, phase, spec, git_commit_hash FROM sessions WHERE session_id = $id'
|
|
363
|
+
).get({ $id: sessionId })
|
|
364
|
+
);
|
|
365
|
+
if (row) {
|
|
366
|
+
const out = {
|
|
367
|
+
session_id: row.session_id, started_at: row.started_at, updated_at: row.updated_at,
|
|
368
|
+
phase: row.phase, spec: row.spec, git_commit_hash: row.git_commit_hash,
|
|
369
|
+
};
|
|
370
|
+
process.stdout.write(JSON.stringify(out) + '\n');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function cmdSnapshot(args) {
|
|
375
|
+
if (args.length !== 1) { warn(U_SNAPSHOT); return; }
|
|
376
|
+
const gitHash = validateKey('git_commit_hash', args[0], U_SNAPSHOT);
|
|
377
|
+
if (gitHash === null) return;
|
|
378
|
+
if (process.stdin.isTTY) { warn(`snap_json is empty; ${U_SNAPSHOT}`); return; } // no-hang guard
|
|
379
|
+
let buf, overCap;
|
|
380
|
+
try { ({ buf, overCap } = readStdinCapped(MAX_SNAP_BYTES)); }
|
|
381
|
+
catch (e) { warn(`error reading stdin: ${(e && e.code) || (e && e.message)}, skipping cache write`); return; }
|
|
382
|
+
if (overCap) { warn(`snap_json exceeds ${MAX_SNAP_BYTES} bytes (10 MiB); rejected`); return; }
|
|
383
|
+
if (buf.length === 0) { warn(`snap_json is empty; ${U_SNAPSHOT}`); return; }
|
|
384
|
+
let snapJson;
|
|
385
|
+
try { snapJson = new TextDecoder('utf-8', { fatal: true }).decode(buf); }
|
|
386
|
+
catch { warn('snap_json is not valid UTF-8; rejected'); return; }
|
|
387
|
+
const root = resolveRoot();
|
|
388
|
+
await withDb(root, (db) => {
|
|
389
|
+
db.prepare('INSERT INTO snapshots (git_commit_hash, created_at, snap_json) VALUES ($h, $c, $j)')
|
|
390
|
+
.run({ $h: gitHash, $c: new Date().toISOString(), $j: snapJson });
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function cmdGetSnapshot(args) {
|
|
395
|
+
if (args.length !== 1) { warn(U_GET_SNAPSHOT); return; }
|
|
396
|
+
const gitHash = validateKey('git_commit_hash', args[0], U_GET_SNAPSHOT);
|
|
397
|
+
if (gitHash === null) return;
|
|
398
|
+
const root = resolveRoot();
|
|
399
|
+
const row = await withDb(root, (db) =>
|
|
400
|
+
db.prepare('SELECT snap_json FROM snapshots WHERE git_commit_hash = $h ORDER BY id DESC LIMIT 1').get({ $h: gitHash })
|
|
401
|
+
);
|
|
402
|
+
if (row) { process.stdout.write(row.snap_json); process.stdout.write('\n'); }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function cmdHistory(args) {
|
|
406
|
+
if (args.length !== 2) { warn(U_HISTORY); return; }
|
|
407
|
+
const sessionId = validateKey('session_id', args[0], U_HISTORY);
|
|
408
|
+
if (sessionId === null) return;
|
|
409
|
+
const kind = validateOptional('kind', args[1]);
|
|
410
|
+
if (kind === null) return;
|
|
411
|
+
if (process.stdin.isTTY) { warn(`content is empty; ${U_HISTORY}`); return; } // no-hang guard
|
|
412
|
+
let buf, overCap;
|
|
413
|
+
try { ({ buf, overCap } = readStdinCapped(MAX_CONTENT_BYTES)); }
|
|
414
|
+
catch (e) { warn(`error reading stdin: ${(e && e.code) || (e && e.message)}, skipping cache write`); return; }
|
|
415
|
+
if (overCap) { warn(`content exceeds ${MAX_CONTENT_BYTES} bytes (1 MiB); rejected`); return; }
|
|
416
|
+
if (buf.length === 0) { warn(`content is empty; ${U_HISTORY}`); return; }
|
|
417
|
+
let content;
|
|
418
|
+
try { content = new TextDecoder('utf-8', { fatal: true }).decode(buf); }
|
|
419
|
+
catch { warn('content is not valid UTF-8; rejected'); return; }
|
|
420
|
+
const root = resolveRoot();
|
|
421
|
+
await withDb(root, (db) => {
|
|
422
|
+
db.prepare('INSERT INTO raw_history (session_id, created_at, kind, content) VALUES ($s, $c, $k, $ct)')
|
|
423
|
+
.run({ $s: sessionId, $c: new Date().toISOString(), $k: kind, $ct: content });
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function main() {
|
|
428
|
+
const [sub, ...rest] = process.argv.slice(2);
|
|
429
|
+
if (sub === 'record') return cmdRecord(rest);
|
|
430
|
+
if (sub === 'init') return cmdInit(rest);
|
|
431
|
+
if (sub === 'session') return cmdSession(rest);
|
|
432
|
+
if (sub === 'get-session') return cmdGetSession(rest);
|
|
433
|
+
if (sub === 'snapshot') return cmdSnapshot(rest);
|
|
434
|
+
if (sub === 'get-snapshot') return cmdGetSnapshot(rest);
|
|
435
|
+
if (sub === 'history') return cmdHistory(rest);
|
|
436
|
+
warn(`unknown subcommand ${JSON.stringify(sub ?? '')}; ${USAGE}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Set exitCode rather than calling process.exit(0): a large query payload
|
|
440
|
+
// (> the ~64 KiB pipe buffer) is written asynchronously, and a synchronous
|
|
441
|
+
// process.exit would truncate the unflushed tail. The db is already closed in
|
|
442
|
+
// withDb's finally and no timers/handles linger, so the event loop drains
|
|
443
|
+
// stdout and exits 0 on its own.
|
|
444
|
+
main().then(() => { process.exitCode = 0; }).catch((e) => {
|
|
445
|
+
warn(`unexpected: ${e && e.message}`);
|
|
446
|
+
process.exitCode = 0;
|
|
447
|
+
});
|