@primitive.ai/prim 0.1.0-alpha.14 → 0.1.0-alpha.16
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/SKILL.md +85 -1
- package/dist/{chunk-3APLWTLB.js → chunk-6SIEWWUL.js} +26 -4
- package/dist/chunk-BEEGFDGU.js +59 -0
- package/dist/chunk-JZGWQDM5.js +199 -0
- package/dist/chunk-PTLXSXIY.js +111 -0
- package/dist/chunk-S47B4VGC.js +122 -0
- package/dist/chunk-UTKQTZHL.js +88 -0
- package/dist/daemon/server.js +241 -0
- package/dist/hooks/post-tool-use.js +128 -0
- package/dist/hooks/pre-commit.js +69 -11
- package/dist/hooks/pre-tool-use.js +220 -0
- package/dist/hooks/prim-hook.js +51 -0
- package/dist/hooks/session-end.js +61 -0
- package/dist/hooks/session-start.js +61 -0
- package/dist/index.js +1541 -56
- package/package.json +11 -5
package/dist/index.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
color,
|
|
4
|
+
colorForArea,
|
|
5
|
+
stripAnsi
|
|
6
|
+
} from "./chunk-BEEGFDGU.js";
|
|
7
|
+
import {
|
|
8
|
+
checkAffectedDecisions,
|
|
9
|
+
formatDecisionsWarning,
|
|
10
|
+
getGitContext
|
|
11
|
+
} from "./chunk-S47B4VGC.js";
|
|
12
|
+
import {
|
|
13
|
+
HttpError,
|
|
3
14
|
REFRESH_TOKEN_PATH,
|
|
4
15
|
TOKEN_EXPIRES_PATH,
|
|
5
16
|
TOKEN_FILE_PATH,
|
|
@@ -8,12 +19,23 @@ import {
|
|
|
8
19
|
getSiteUrl,
|
|
9
20
|
getTokenExpiresAt,
|
|
10
21
|
saveTokenExpiry
|
|
11
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-6SIEWWUL.js";
|
|
23
|
+
import {
|
|
24
|
+
JOURNAL_DIR,
|
|
25
|
+
SESSIONS_DIR,
|
|
26
|
+
bucketStats,
|
|
27
|
+
listBuckets,
|
|
28
|
+
readMovesFromPath
|
|
29
|
+
} from "./chunk-JZGWQDM5.js";
|
|
30
|
+
import {
|
|
31
|
+
daemonIsLive,
|
|
32
|
+
daemonRequest
|
|
33
|
+
} from "./chunk-UTKQTZHL.js";
|
|
12
34
|
|
|
13
35
|
// src/index.ts
|
|
14
|
-
import { readFileSync as
|
|
15
|
-
import { dirname as
|
|
16
|
-
import { fileURLToPath as
|
|
36
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
37
|
+
import { dirname as dirname5, resolve as resolve4 } from "path";
|
|
38
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
17
39
|
import { Command } from "commander";
|
|
18
40
|
import updateNotifier from "update-notifier";
|
|
19
41
|
|
|
@@ -111,10 +133,10 @@ function registerAuthCommands(program2) {
|
|
|
111
133
|
process.exit(1);
|
|
112
134
|
});
|
|
113
135
|
});
|
|
114
|
-
const port = await new Promise((
|
|
136
|
+
const port = await new Promise((resolve5) => {
|
|
115
137
|
server.listen(CALLBACK_PORT, LOCALHOST, () => {
|
|
116
138
|
const addr = server.address();
|
|
117
|
-
|
|
139
|
+
resolve5(typeof addr === "object" && addr ? addr.port : 0);
|
|
118
140
|
});
|
|
119
141
|
});
|
|
120
142
|
const redirectUri = `http://${LOCALHOST}:${port}/callback`;
|
|
@@ -255,8 +277,263 @@ async function exchangeCode(siteUrl, code, codeVerifier, redirectUri) {
|
|
|
255
277
|
return data.access_token;
|
|
256
278
|
}
|
|
257
279
|
|
|
280
|
+
// src/commands/claude-install.ts
|
|
281
|
+
import {
|
|
282
|
+
closeSync,
|
|
283
|
+
existsSync as existsSync2,
|
|
284
|
+
fsyncSync,
|
|
285
|
+
mkdirSync as mkdirSync2,
|
|
286
|
+
openSync,
|
|
287
|
+
readFileSync as readFileSync2,
|
|
288
|
+
renameSync,
|
|
289
|
+
writeFileSync as writeFileSync2
|
|
290
|
+
} from "fs";
|
|
291
|
+
import { homedir } from "os";
|
|
292
|
+
import { dirname as dirname2, join } from "path";
|
|
293
|
+
var CAPTURE_COMMAND = "prim-hook";
|
|
294
|
+
var GATE_COMMAND = "prim-pre-tool-use";
|
|
295
|
+
var POST_TOOL_USE_COMMAND = "prim-post-tool-use";
|
|
296
|
+
var SESSION_START_COMMAND = "prim-session-start";
|
|
297
|
+
var SESSION_END_COMMAND = "prim-session-end";
|
|
298
|
+
var STATUSLINE_COMMAND = "prim statusline";
|
|
299
|
+
var STATUSLINE_REFRESH_SECONDS = 5;
|
|
300
|
+
var PRIM_COMMANDS = /* @__PURE__ */ new Set([
|
|
301
|
+
CAPTURE_COMMAND,
|
|
302
|
+
GATE_COMMAND,
|
|
303
|
+
POST_TOOL_USE_COMMAND,
|
|
304
|
+
SESSION_START_COMMAND,
|
|
305
|
+
SESSION_END_COMMAND
|
|
306
|
+
]);
|
|
307
|
+
var JSON_INDENT = 2;
|
|
308
|
+
var USER_SCOPE_PATH = join(homedir(), ".claude", "settings.json");
|
|
309
|
+
var PROJECT_SCOPE_PATH = join(process.cwd(), ".claude", "settings.json");
|
|
310
|
+
var CAPTURE_EVENTS = [
|
|
311
|
+
"SessionStart",
|
|
312
|
+
"UserPromptSubmit",
|
|
313
|
+
"PreToolUse",
|
|
314
|
+
"PostToolUse",
|
|
315
|
+
"Stop",
|
|
316
|
+
"SessionEnd",
|
|
317
|
+
"SubagentStop"
|
|
318
|
+
];
|
|
319
|
+
var REGISTRATIONS = [
|
|
320
|
+
...CAPTURE_EVENTS.map((event) => ({ event, matcher: "*", command: CAPTURE_COMMAND })),
|
|
321
|
+
{ event: "PreToolUse", matcher: "Edit|Write|MultiEdit", command: GATE_COMMAND },
|
|
322
|
+
{ event: "PostToolUse", matcher: "Edit|Write|MultiEdit", command: POST_TOOL_USE_COMMAND },
|
|
323
|
+
{ event: "SessionStart", matcher: "*", command: SESSION_START_COMMAND },
|
|
324
|
+
{ event: "SessionEnd", matcher: "*", command: SESSION_END_COMMAND }
|
|
325
|
+
];
|
|
326
|
+
function settingsPathFor(scope) {
|
|
327
|
+
return scope === "user" ? USER_SCOPE_PATH : PROJECT_SCOPE_PATH;
|
|
328
|
+
}
|
|
329
|
+
function readSettings(path) {
|
|
330
|
+
if (!existsSync2(path)) {
|
|
331
|
+
return {};
|
|
332
|
+
}
|
|
333
|
+
const raw = readFileSync2(path, "utf-8");
|
|
334
|
+
try {
|
|
335
|
+
return JSON.parse(raw);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
338
|
+
throw new Error(`${path} is not valid JSON: ${detail}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function entryHasCommand(entry, command) {
|
|
342
|
+
return entry.hooks?.some((h) => h.command === command) ?? false;
|
|
343
|
+
}
|
|
344
|
+
function canonicalEntry(reg) {
|
|
345
|
+
return { matcher: reg.matcher, hooks: [{ type: "command", command: reg.command }] };
|
|
346
|
+
}
|
|
347
|
+
function stripCommand(list, command) {
|
|
348
|
+
const out = [];
|
|
349
|
+
for (const e of list) {
|
|
350
|
+
const hooks = (e.hooks ?? []).filter((h) => h.command !== command);
|
|
351
|
+
if (hooks.length > 0) {
|
|
352
|
+
out.push({ ...e, hooks });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
function ensureRegistration(list, reg, force) {
|
|
358
|
+
const hasCanonical = list.some(
|
|
359
|
+
(e) => e.matcher === reg.matcher && e.hooks?.length === 1 && e.hooks[0].command === reg.command
|
|
360
|
+
);
|
|
361
|
+
if (hasCanonical && !force) {
|
|
362
|
+
return list;
|
|
363
|
+
}
|
|
364
|
+
return [...stripCommand(list, reg.command), canonicalEntry(reg)];
|
|
365
|
+
}
|
|
366
|
+
function isPrimStatusLine(settings) {
|
|
367
|
+
const s = settings.statusLine;
|
|
368
|
+
return s?.type === "command" && s?.command === STATUSLINE_COMMAND;
|
|
369
|
+
}
|
|
370
|
+
function applyStatusLine(settings) {
|
|
371
|
+
if (settings.statusLine && !isPrimStatusLine(settings)) {
|
|
372
|
+
return settings;
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
...settings,
|
|
376
|
+
statusLine: {
|
|
377
|
+
type: "command",
|
|
378
|
+
command: STATUSLINE_COMMAND,
|
|
379
|
+
refreshInterval: STATUSLINE_REFRESH_SECONDS
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function applyInstall(settings, options = {}) {
|
|
384
|
+
const hooks = { ...settings.hooks ?? {} };
|
|
385
|
+
for (const reg of REGISTRATIONS) {
|
|
386
|
+
hooks[reg.event] = ensureRegistration(hooks[reg.event] ?? [], reg, options.force ?? false);
|
|
387
|
+
}
|
|
388
|
+
return applyStatusLine({ ...settings, hooks });
|
|
389
|
+
}
|
|
390
|
+
function applyUninstall(settings) {
|
|
391
|
+
const source = settings.hooks ?? {};
|
|
392
|
+
const hooks = {};
|
|
393
|
+
for (const event of Object.keys(source)) {
|
|
394
|
+
let list = source[event] ?? [];
|
|
395
|
+
for (const command of PRIM_COMMANDS) {
|
|
396
|
+
list = stripCommand(list, command);
|
|
397
|
+
}
|
|
398
|
+
if (list.length > 0) {
|
|
399
|
+
hooks[event] = list;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const next = { ...settings, hooks };
|
|
403
|
+
if (isPrimStatusLine(next)) {
|
|
404
|
+
next.statusLine = void 0;
|
|
405
|
+
}
|
|
406
|
+
return next;
|
|
407
|
+
}
|
|
408
|
+
function captureInstalled(settings) {
|
|
409
|
+
return CAPTURE_EVENTS.some(
|
|
410
|
+
(event) => (settings.hooks?.[event] ?? []).some((e) => entryHasCommand(e, CAPTURE_COMMAND))
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
function statuslineInstalled(settings) {
|
|
414
|
+
return isPrimStatusLine(settings);
|
|
415
|
+
}
|
|
416
|
+
function isGateInstalled(settings) {
|
|
417
|
+
return (settings.hooks?.PreToolUse ?? []).some((e) => entryHasCommand(e, GATE_COMMAND));
|
|
418
|
+
}
|
|
419
|
+
function atomicWrite(path, content) {
|
|
420
|
+
const dir = dirname2(path);
|
|
421
|
+
if (!existsSync2(dir)) {
|
|
422
|
+
mkdirSync2(dir, { recursive: true });
|
|
423
|
+
}
|
|
424
|
+
const tmp = `${path}.tmp.${String(Date.now())}`;
|
|
425
|
+
writeFileSync2(tmp, `${JSON.stringify(content, null, JSON_INDENT)}
|
|
426
|
+
`, "utf-8");
|
|
427
|
+
const fd = openSync(tmp, "r+");
|
|
428
|
+
try {
|
|
429
|
+
fsyncSync(fd);
|
|
430
|
+
} finally {
|
|
431
|
+
closeSync(fd);
|
|
432
|
+
}
|
|
433
|
+
renameSync(tmp, path);
|
|
434
|
+
}
|
|
435
|
+
function performInstall(scope, force) {
|
|
436
|
+
const path = settingsPathFor(scope);
|
|
437
|
+
const before = readSettings(path);
|
|
438
|
+
const after = applyInstall(before, { force });
|
|
439
|
+
const changed = JSON.stringify(before) !== JSON.stringify(after);
|
|
440
|
+
if (changed) {
|
|
441
|
+
atomicWrite(path, after);
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
scope,
|
|
445
|
+
path,
|
|
446
|
+
gate: isGateInstalled(after),
|
|
447
|
+
capture: captureInstalled(after),
|
|
448
|
+
statusline: statuslineInstalled(after),
|
|
449
|
+
changed
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function performUninstall(scope) {
|
|
453
|
+
const path = settingsPathFor(scope);
|
|
454
|
+
const before = readSettings(path);
|
|
455
|
+
const after = applyUninstall(before);
|
|
456
|
+
const changed = JSON.stringify(before) !== JSON.stringify(after);
|
|
457
|
+
if (changed) {
|
|
458
|
+
atomicWrite(path, after);
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
scope,
|
|
462
|
+
path,
|
|
463
|
+
gate: isGateInstalled(after),
|
|
464
|
+
capture: captureInstalled(after),
|
|
465
|
+
statusline: statuslineInstalled(after),
|
|
466
|
+
changed
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
function performStatus() {
|
|
470
|
+
const statusFor = (path) => {
|
|
471
|
+
const settings = readSettings(path);
|
|
472
|
+
return {
|
|
473
|
+
path,
|
|
474
|
+
gate: isGateInstalled(settings),
|
|
475
|
+
capture: captureInstalled(settings),
|
|
476
|
+
statusline: statuslineInstalled(settings)
|
|
477
|
+
};
|
|
478
|
+
};
|
|
479
|
+
return { user: statusFor(USER_SCOPE_PATH), project: statusFor(PROJECT_SCOPE_PATH) };
|
|
480
|
+
}
|
|
481
|
+
function resolveScope(input) {
|
|
482
|
+
if (input === void 0 || input === "user") {
|
|
483
|
+
return "user";
|
|
484
|
+
}
|
|
485
|
+
if (input === "project") {
|
|
486
|
+
return "project";
|
|
487
|
+
}
|
|
488
|
+
console.error(`[prim] unknown --scope "${input}" (expected: user or project)`);
|
|
489
|
+
process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
function registerClaudeCommands(program2) {
|
|
492
|
+
const claude = program2.command("claude").description("Manage the prim Claude Code integration (capture, gate, ingest, presence)");
|
|
493
|
+
claude.command("install").description("Register the prim hooks + statusline in Claude Code's settings.json").option(
|
|
494
|
+
"--scope <scope>",
|
|
495
|
+
"user (default, ~/.claude/settings.json) or project (./.claude/settings.json)"
|
|
496
|
+
).option("--force", "Replace any drifted prim hook entries").action((opts) => {
|
|
497
|
+
const scope = resolveScope(opts.scope);
|
|
498
|
+
const result = performInstall(scope, opts.force ?? false);
|
|
499
|
+
if (result.changed) {
|
|
500
|
+
console.error(
|
|
501
|
+
`[prim] Claude Code integration installed (${scope} scope) at ${result.path}`
|
|
502
|
+
);
|
|
503
|
+
} else {
|
|
504
|
+
console.error(
|
|
505
|
+
`[prim] Claude Code integration already present at ${result.path} (no changes)`
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
console.log(JSON.stringify(result, null, JSON_INDENT));
|
|
509
|
+
});
|
|
510
|
+
claude.command("uninstall").description("Remove all prim hooks + the prim statusline from settings.json").option(
|
|
511
|
+
"--scope <scope>",
|
|
512
|
+
"user (default, ~/.claude/settings.json) or project (./.claude/settings.json)"
|
|
513
|
+
).action((opts) => {
|
|
514
|
+
const scope = resolveScope(opts.scope);
|
|
515
|
+
const result = performUninstall(scope);
|
|
516
|
+
if (result.changed) {
|
|
517
|
+
console.error(`[prim] prim hooks removed from ${result.path}`);
|
|
518
|
+
} else {
|
|
519
|
+
console.error(`[prim] no prim hooks to remove at ${result.path} (nothing changed)`);
|
|
520
|
+
}
|
|
521
|
+
console.log(JSON.stringify(result, null, JSON_INDENT));
|
|
522
|
+
});
|
|
523
|
+
claude.command("status").description(
|
|
524
|
+
"Report whether each prim surface (gate, capture, statusline) is installed per scope"
|
|
525
|
+
).action(() => {
|
|
526
|
+
const result = performStatus();
|
|
527
|
+
const mark = (b) => b ? "\u2713" : "\u2717";
|
|
528
|
+
const line = (label, s) => `[prim] ${label}: gate ${mark(s.gate)} \xB7 capture ${mark(s.capture)} \xB7 statusline ${mark(s.statusline)} (${s.path})`;
|
|
529
|
+
console.error(`${line("user", result.user)}
|
|
530
|
+
${line("project", result.project)}`);
|
|
531
|
+
console.log(JSON.stringify(result, null, JSON_INDENT));
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
258
535
|
// src/commands/context.ts
|
|
259
|
-
import { readFileSync as
|
|
536
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
260
537
|
function registerContextCommands(program2) {
|
|
261
538
|
const context = program2.command("context").description("Manage contexts");
|
|
262
539
|
context.command("list").description("List contexts").option("-s, --scope <scope>", "Filter by scope: project, global, external").option("-t, --project-id <projectId>", "List contexts linked to a specific project").option("--json", "Output as JSON").action(async (opts) => {
|
|
@@ -285,7 +562,7 @@ function registerContextCommands(program2) {
|
|
|
285
562
|
const client = getClient();
|
|
286
563
|
let text = opts.text;
|
|
287
564
|
if (opts.file) {
|
|
288
|
-
text =
|
|
565
|
+
text = readFileSync3(opts.file, "utf-8");
|
|
289
566
|
}
|
|
290
567
|
const taskIds = opts.projectId ? opts.projectId.split(",").map((id) => id.trim()) : void 0;
|
|
291
568
|
const result = await client.post("/api/cli/contexts", {
|
|
@@ -308,7 +585,7 @@ function registerContextCommands(program2) {
|
|
|
308
585
|
const client = getClient();
|
|
309
586
|
let text = opts.text;
|
|
310
587
|
if (opts.file) {
|
|
311
|
-
text =
|
|
588
|
+
text = readFileSync3(opts.file, "utf-8");
|
|
312
589
|
}
|
|
313
590
|
await client.patch(`/api/cli/contexts/${contextId}`, {
|
|
314
591
|
name: opts.name,
|
|
@@ -372,9 +649,772 @@ function printContextList(contexts) {
|
|
|
372
649
|
${contexts.length} context(s)`);
|
|
373
650
|
}
|
|
374
651
|
|
|
652
|
+
// src/commands/daemon.ts
|
|
653
|
+
import { spawn } from "child_process";
|
|
654
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, unlinkSync } from "fs";
|
|
655
|
+
import { homedir as homedir2 } from "os";
|
|
656
|
+
import { join as join2 } from "path";
|
|
657
|
+
var DAEMON_BIN = "prim-daemon-server";
|
|
658
|
+
var PID_PATH = join2(homedir2(), ".config", "prim", "daemon.pid");
|
|
659
|
+
var SOCK_PATH = join2(homedir2(), ".config", "prim", "sock");
|
|
660
|
+
var STOP_TIMEOUT_MS = 5e3;
|
|
661
|
+
var STOP_POLL_MS = 100;
|
|
662
|
+
var STATUS_PROBE_TIMEOUT_MS = 500;
|
|
663
|
+
var POST_START_WAIT_MS = 400;
|
|
664
|
+
var EXIT_NOT_RUNNING = 2;
|
|
665
|
+
function readPidfile() {
|
|
666
|
+
if (!existsSync3(PID_PATH)) {
|
|
667
|
+
return null;
|
|
668
|
+
}
|
|
669
|
+
const raw = readFileSync4(PID_PATH, "utf-8").trim();
|
|
670
|
+
const pid = Number(raw);
|
|
671
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
672
|
+
return null;
|
|
673
|
+
}
|
|
674
|
+
return { pid, alive: processIsAlive(pid) };
|
|
675
|
+
}
|
|
676
|
+
function processIsAlive(pid) {
|
|
677
|
+
try {
|
|
678
|
+
process.kill(pid, 0);
|
|
679
|
+
return true;
|
|
680
|
+
} catch {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
function clearStaleArtifacts() {
|
|
685
|
+
try {
|
|
686
|
+
unlinkSync(PID_PATH);
|
|
687
|
+
} catch {
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
unlinkSync(SOCK_PATH);
|
|
691
|
+
} catch {
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function sleep(ms) {
|
|
695
|
+
return new Promise((resolve5) => {
|
|
696
|
+
const timer = setTimeout(resolve5, ms);
|
|
697
|
+
timer.unref();
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
async function daemonStart(opts) {
|
|
701
|
+
const existing = readPidfile();
|
|
702
|
+
if (existing?.alive) {
|
|
703
|
+
process.stderr.write(`[prim] daemon already running (pid=${existing.pid})
|
|
704
|
+
`);
|
|
705
|
+
console.log(JSON.stringify({ started: false, pid: existing.pid }, null, 2));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (existing && !existing.alive) {
|
|
709
|
+
clearStaleArtifacts();
|
|
710
|
+
}
|
|
711
|
+
if (opts.foreground) {
|
|
712
|
+
const child2 = spawn(DAEMON_BIN, [], { stdio: "inherit" });
|
|
713
|
+
child2.on("exit", (code) => {
|
|
714
|
+
process.exit(code ?? 0);
|
|
715
|
+
});
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const child = spawn(DAEMON_BIN, [], {
|
|
719
|
+
detached: true,
|
|
720
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
721
|
+
});
|
|
722
|
+
child.unref();
|
|
723
|
+
await sleep(POST_START_WAIT_MS);
|
|
724
|
+
const after = readPidfile();
|
|
725
|
+
if (after?.alive) {
|
|
726
|
+
process.stderr.write(`[prim] daemon started (pid=${after.pid}, socket=${SOCK_PATH})
|
|
727
|
+
`);
|
|
728
|
+
console.log(JSON.stringify({ started: true, pid: after.pid }, null, 2));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
process.stderr.write(
|
|
732
|
+
"[prim] daemon start: bin spawned but no pidfile observed (check that `prim-daemon-server` is on PATH)\n"
|
|
733
|
+
);
|
|
734
|
+
console.log(JSON.stringify({ started: false }, null, 2));
|
|
735
|
+
}
|
|
736
|
+
async function daemonStop() {
|
|
737
|
+
const existing = readPidfile();
|
|
738
|
+
if (!existing) {
|
|
739
|
+
process.stderr.write("[prim] daemon not running (no pidfile)\n");
|
|
740
|
+
console.log(JSON.stringify({ stopped: false, wasRunning: false }, null, 2));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (!existing.alive) {
|
|
744
|
+
clearStaleArtifacts();
|
|
745
|
+
process.stderr.write("[prim] daemon not running (cleared stale pidfile)\n");
|
|
746
|
+
console.log(JSON.stringify({ stopped: false, wasRunning: false }, null, 2));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
process.kill(existing.pid, "SIGTERM");
|
|
751
|
+
} catch (err) {
|
|
752
|
+
process.stderr.write(
|
|
753
|
+
`[prim] could not signal pid=${existing.pid}: ${err instanceof Error ? err.message : String(err)}
|
|
754
|
+
`
|
|
755
|
+
);
|
|
756
|
+
console.log(JSON.stringify({ stopped: false, pid: existing.pid }, null, 2));
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS;
|
|
760
|
+
while (Date.now() < deadline) {
|
|
761
|
+
if (!processIsAlive(existing.pid)) {
|
|
762
|
+
clearStaleArtifacts();
|
|
763
|
+
process.stderr.write(`[prim] daemon stopped (pid=${existing.pid})
|
|
764
|
+
`);
|
|
765
|
+
console.log(JSON.stringify({ stopped: true, pid: existing.pid }, null, 2));
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
await sleep(STOP_POLL_MS);
|
|
769
|
+
}
|
|
770
|
+
process.stderr.write(
|
|
771
|
+
`[prim] daemon did not exit within ${STOP_TIMEOUT_MS}ms (pid=${existing.pid} still alive)
|
|
772
|
+
`
|
|
773
|
+
);
|
|
774
|
+
console.log(JSON.stringify({ stopped: false, pid: existing.pid }, null, 2));
|
|
775
|
+
}
|
|
776
|
+
async function daemonStatus() {
|
|
777
|
+
const pid = readPidfile();
|
|
778
|
+
if (!pid?.alive) {
|
|
779
|
+
process.stderr.write("[prim] \u2717 daemon down\n");
|
|
780
|
+
console.log(JSON.stringify({ running: false }, null, 2));
|
|
781
|
+
if (!process.exitCode) {
|
|
782
|
+
process.exitCode = EXIT_NOT_RUNNING;
|
|
783
|
+
}
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const live = await daemonIsLive(STATUS_PROBE_TIMEOUT_MS);
|
|
787
|
+
if (!live) {
|
|
788
|
+
process.stderr.write(`[prim] \u2717 daemon pid=${pid.pid} alive but socket not responding
|
|
789
|
+
`);
|
|
790
|
+
console.log(JSON.stringify({ running: true, responding: false, pid: pid.pid }, null, 2));
|
|
791
|
+
if (!process.exitCode) {
|
|
792
|
+
process.exitCode = EXIT_NOT_RUNNING;
|
|
793
|
+
}
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const snapshot = await daemonRequest(
|
|
797
|
+
"status_snapshot",
|
|
798
|
+
{},
|
|
799
|
+
{ timeoutMs: STATUS_PROBE_TIMEOUT_MS }
|
|
800
|
+
);
|
|
801
|
+
if (!snapshot) {
|
|
802
|
+
process.stderr.write("[prim] \u2713 daemon live (no snapshot)\n");
|
|
803
|
+
console.log(JSON.stringify({ running: true, responding: true }, null, 2));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
process.stderr.write(
|
|
807
|
+
`[prim] \u2713 daemon live \xB7 pid=${snapshot.pid} \xB7 uptime=${Math.round(snapshot.uptimeMs / 1e3)}s \xB7 session=${snapshot.sessionId}
|
|
808
|
+
`
|
|
809
|
+
);
|
|
810
|
+
console.log(JSON.stringify({ running: true, responding: true, ...snapshot }, null, 2));
|
|
811
|
+
}
|
|
812
|
+
async function daemonRestart(opts) {
|
|
813
|
+
await daemonStop();
|
|
814
|
+
await daemonStart(opts);
|
|
815
|
+
}
|
|
816
|
+
function registerDaemonCommands(program2) {
|
|
817
|
+
const daemon = program2.command("daemon").description("Manage the prim companion daemon (latency unlock + presence + broadcast)");
|
|
818
|
+
daemon.command("start").description("Spawn the prim-daemon-server in the background").option("--foreground", "Run in the foreground (inherit stdio); use under launchd / systemd").action(async (opts) => {
|
|
819
|
+
await daemonStart(opts);
|
|
820
|
+
});
|
|
821
|
+
daemon.command("stop").description("Send SIGTERM to the running daemon and clean up the socket").action(async () => {
|
|
822
|
+
await daemonStop();
|
|
823
|
+
});
|
|
824
|
+
daemon.command("status").description("Report daemon liveness + a snapshot if responding").action(async () => {
|
|
825
|
+
await daemonStatus();
|
|
826
|
+
});
|
|
827
|
+
daemon.command("restart").description("Stop, then start (preserves no state today)").option("--foreground", "Restart in the foreground").action(async (opts) => {
|
|
828
|
+
await daemonRestart(opts);
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// src/decisions/cascade-renderer.ts
|
|
833
|
+
var DEPENDENTS_INLINE_LIMIT = 5;
|
|
834
|
+
var KNOWLEDGE_INLINE_LIMIT = 4;
|
|
835
|
+
var ISO_DATE_LENGTH = 10;
|
|
836
|
+
var INTENT_TRUNC = 60;
|
|
837
|
+
var DEFAULT_WIDTH = 80;
|
|
838
|
+
var SOFT_WRAP_INDENT = " ";
|
|
839
|
+
function terminalWidth() {
|
|
840
|
+
return process.stdout.columns ?? DEFAULT_WIDTH;
|
|
841
|
+
}
|
|
842
|
+
function softWrap(line, opts) {
|
|
843
|
+
const width = opts?.width ?? terminalWidth();
|
|
844
|
+
const indent = opts?.indent ?? "";
|
|
845
|
+
if (stripAnsi(line).length <= width) {
|
|
846
|
+
return [line];
|
|
847
|
+
}
|
|
848
|
+
const words = line.split(" ");
|
|
849
|
+
const out = [];
|
|
850
|
+
let current = "";
|
|
851
|
+
for (const w of words) {
|
|
852
|
+
if (current === "") {
|
|
853
|
+
current = w;
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
const tentative = `${current} ${w}`;
|
|
857
|
+
if (stripAnsi(tentative).length > width) {
|
|
858
|
+
out.push(current);
|
|
859
|
+
current = `${indent}${w}`;
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
current = tentative;
|
|
863
|
+
}
|
|
864
|
+
if (current.length > 0) {
|
|
865
|
+
out.push(current);
|
|
866
|
+
}
|
|
867
|
+
return out;
|
|
868
|
+
}
|
|
869
|
+
function formatDate(ms) {
|
|
870
|
+
return new Date(ms).toISOString().slice(0, ISO_DATE_LENGTH);
|
|
871
|
+
}
|
|
872
|
+
function truncate(s, max) {
|
|
873
|
+
if (s.length <= max) {
|
|
874
|
+
return s;
|
|
875
|
+
}
|
|
876
|
+
return `${s.slice(0, max - 1)}\u2026`;
|
|
877
|
+
}
|
|
878
|
+
function bracketed(label) {
|
|
879
|
+
return `[${label}]`;
|
|
880
|
+
}
|
|
881
|
+
function knowledgeRow(files, contexts, triggerFile, triggerContextName) {
|
|
882
|
+
const tokens = [];
|
|
883
|
+
for (const ctx of contexts.slice(0, KNOWLEDGE_INLINE_LIMIT)) {
|
|
884
|
+
const star = ctx.name === triggerContextName ? " *" : "";
|
|
885
|
+
tokens.push(bracketed(`${ctx.name}${star}`));
|
|
886
|
+
}
|
|
887
|
+
for (const f of files.slice(0, Math.max(0, KNOWLEDGE_INLINE_LIMIT - contexts.length))) {
|
|
888
|
+
const star = f === triggerFile ? " *" : "";
|
|
889
|
+
tokens.push(bracketed(`${f}${star}`));
|
|
890
|
+
}
|
|
891
|
+
const overflow = files.length + contexts.length - tokens.length > 0 ? ` (+${String(files.length + contexts.length - tokens.length)} more)` : "";
|
|
892
|
+
if (tokens.length === 0) {
|
|
893
|
+
return [" (no upstream knowledge refs)"];
|
|
894
|
+
}
|
|
895
|
+
return [` ${tokens.join(" ")}${overflow}`];
|
|
896
|
+
}
|
|
897
|
+
function areaChip(area) {
|
|
898
|
+
if (!area) {
|
|
899
|
+
return color("[--]", "gray");
|
|
900
|
+
}
|
|
901
|
+
return color(`[${area}]`, colorForArea(area));
|
|
902
|
+
}
|
|
903
|
+
function countCrossAreaDependents(parentArea, dependents) {
|
|
904
|
+
if (!parentArea) {
|
|
905
|
+
const areaCounts = /* @__PURE__ */ new Map();
|
|
906
|
+
for (const d of dependents) {
|
|
907
|
+
if (d.area) {
|
|
908
|
+
areaCounts.set(d.area, (areaCounts.get(d.area) ?? 0) + 1);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
if (areaCounts.size <= 1) {
|
|
912
|
+
return 0;
|
|
913
|
+
}
|
|
914
|
+
let dominantCount = 0;
|
|
915
|
+
for (const c of areaCounts.values()) {
|
|
916
|
+
if (c > dominantCount) {
|
|
917
|
+
dominantCount = c;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return dependents.filter((d) => d.area).length - dominantCount;
|
|
921
|
+
}
|
|
922
|
+
let count = 0;
|
|
923
|
+
for (const d of dependents) {
|
|
924
|
+
if (d.area && d.area !== parentArea) {
|
|
925
|
+
count++;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return count;
|
|
929
|
+
}
|
|
930
|
+
function dependentsBox(dependents) {
|
|
931
|
+
if (dependents.length === 0) {
|
|
932
|
+
return [" (no downstream dependents)"];
|
|
933
|
+
}
|
|
934
|
+
const inlineCount = Math.min(dependents.length, DEPENDENTS_INLINE_LIMIT);
|
|
935
|
+
const header = `${String(dependents.length)} affected:`;
|
|
936
|
+
const lines = [` ${header}`];
|
|
937
|
+
for (const d of dependents.slice(0, inlineCount)) {
|
|
938
|
+
lines.push(` \u2022 ${areaChip(d.area)} ${truncate(d.intent, INTENT_TRUNC)}`);
|
|
939
|
+
}
|
|
940
|
+
if (dependents.length > inlineCount) {
|
|
941
|
+
lines.push(` + ${String(dependents.length - inlineCount)} more`);
|
|
942
|
+
}
|
|
943
|
+
return lines;
|
|
944
|
+
}
|
|
945
|
+
function triggerHeadline(t) {
|
|
946
|
+
const at = formatDate(t.flaggedAt);
|
|
947
|
+
if (t.type === "file_edit" && t.file) {
|
|
948
|
+
return `trigger: file '${t.file}' was edited; cascade fired at ${at}.`;
|
|
949
|
+
}
|
|
950
|
+
if (t.type === "context_edit" && t.contextName) {
|
|
951
|
+
return `trigger: context '${t.contextName}' was edited; cascade fired at ${at}.`;
|
|
952
|
+
}
|
|
953
|
+
if (t.type === "supersession") {
|
|
954
|
+
return `trigger: an upstream decision was superseded; cascade fired at ${at}.`;
|
|
955
|
+
}
|
|
956
|
+
if (t.type === "invalidation") {
|
|
957
|
+
return `trigger: an upstream decision was invalidated; cascade fired at ${at}.`;
|
|
958
|
+
}
|
|
959
|
+
if (t.type === "confirmation_request") {
|
|
960
|
+
return `trigger: asking-policy confirmation request opened at ${at}.`;
|
|
961
|
+
}
|
|
962
|
+
return `trigger: ${t.type} at ${at}.`;
|
|
963
|
+
}
|
|
964
|
+
function triggerLine(result) {
|
|
965
|
+
const t = result.trigger;
|
|
966
|
+
if (!t) {
|
|
967
|
+
return [];
|
|
968
|
+
}
|
|
969
|
+
const lines = [triggerHeadline(t)];
|
|
970
|
+
if (t.authorName) {
|
|
971
|
+
lines.push(` by ${t.authorName}`);
|
|
972
|
+
}
|
|
973
|
+
if (t.reason) {
|
|
974
|
+
lines.push(` reason: ${t.reason}`);
|
|
975
|
+
}
|
|
976
|
+
return lines;
|
|
977
|
+
}
|
|
978
|
+
function renderCascade(result) {
|
|
979
|
+
const d = result.decision;
|
|
980
|
+
const id = d.shortId ? `dec_${d.shortId}` : d.id;
|
|
981
|
+
const idColored = color(id, "orange");
|
|
982
|
+
const header = `what this would break \xB7 ${String(result.fanOut)} decision(s) \xB7 enforcing`;
|
|
983
|
+
const lines = [header, "", "knowledge"];
|
|
984
|
+
lines.push(
|
|
985
|
+
...knowledgeRow(
|
|
986
|
+
result.upstream.files,
|
|
987
|
+
result.upstream.contexts,
|
|
988
|
+
result.trigger?.file,
|
|
989
|
+
result.trigger?.contextName
|
|
990
|
+
)
|
|
991
|
+
);
|
|
992
|
+
if (result.trigger && (result.trigger.file || result.trigger.contextName)) {
|
|
993
|
+
lines.push(" |");
|
|
994
|
+
lines.push(" | refs (just edited)");
|
|
995
|
+
lines.push(" \u25BC");
|
|
996
|
+
}
|
|
997
|
+
const decisionLine = `\u2022 ${idColored} ${truncate(d.intent, INTENT_TRUNC)}`;
|
|
998
|
+
const fanOutFragment = result.fanOut > 0 ? ` \xB7 ${String(result.fanOut)} decision(s) depend on this` : "";
|
|
999
|
+
const meta = ` ${d.authorName} \xB7 ${formatDate(d.classifiedAt)}${fanOutFragment} \xB7 ${result.reversibility ?? "(unset)"} reversibility`;
|
|
1000
|
+
lines.push("", decisionLine, meta);
|
|
1001
|
+
lines.push("");
|
|
1002
|
+
lines.push("dependents");
|
|
1003
|
+
lines.push(...dependentsBox(result.downstream));
|
|
1004
|
+
const triggered = triggerLine(result);
|
|
1005
|
+
if (triggered.length > 0) {
|
|
1006
|
+
lines.push("");
|
|
1007
|
+
for (const t of triggered) {
|
|
1008
|
+
lines.push(...softWrap(t, { indent: SOFT_WRAP_INDENT }));
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const crossArea = countCrossAreaDependents(d.area, result.downstream);
|
|
1012
|
+
const crossAreaFragment = crossArea > 0 ? ` \xB7 ${String(crossArea)} cross-area dependency` : "";
|
|
1013
|
+
const noEdgesFragment = result.downstream.length === 0 ? " (no edges yet)" : "";
|
|
1014
|
+
lines.push(
|
|
1015
|
+
`impact: ${String(result.fanOut)} decision(s) need review${noEdgesFragment}${crossAreaFragment}.`
|
|
1016
|
+
);
|
|
1017
|
+
if (result.truncated) {
|
|
1018
|
+
lines.push(
|
|
1019
|
+
" \u26A0 blast radius truncated \u2014 more refs/dependents than the server returns per request; not all shown."
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
return lines.join("\n");
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// src/decisions/cascade.ts
|
|
1026
|
+
var NOT_FOUND_RE = /not found/i;
|
|
1027
|
+
var CASCADE_TIMEOUT_MS = 1e4;
|
|
1028
|
+
var defaultDeps = { getClient };
|
|
1029
|
+
var CascadeNotFoundError = class extends Error {
|
|
1030
|
+
constructor(idOrShortId) {
|
|
1031
|
+
super(`Decision not found: ${idOrShortId}`);
|
|
1032
|
+
this.name = "CascadeNotFoundError";
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
async function fetchCascade(idOrShortId, deps = defaultDeps) {
|
|
1036
|
+
const params = new URLSearchParams({ id: idOrShortId });
|
|
1037
|
+
const client = deps.getClient();
|
|
1038
|
+
try {
|
|
1039
|
+
return await client.get(`/api/cli/decisions/cascade?${params.toString()}`, {
|
|
1040
|
+
signal: AbortSignal.timeout(CASCADE_TIMEOUT_MS)
|
|
1041
|
+
});
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
if (err instanceof Error && NOT_FOUND_RE.test(err.message)) {
|
|
1044
|
+
throw new CascadeNotFoundError(idOrShortId);
|
|
1045
|
+
}
|
|
1046
|
+
throw err;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
function formatCascadeJson(result) {
|
|
1050
|
+
return JSON.stringify(result, null, 2);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// src/decisions/recent.ts
|
|
1054
|
+
var RECENT_TIMEOUT_MS = 1e4;
|
|
1055
|
+
var defaultDeps2 = { getClient };
|
|
1056
|
+
async function fetchRecent(args, deps = defaultDeps2) {
|
|
1057
|
+
const params = new URLSearchParams();
|
|
1058
|
+
if (args.limit !== void 0) {
|
|
1059
|
+
params.set("limit", String(args.limit));
|
|
1060
|
+
}
|
|
1061
|
+
if (args.since !== void 0) {
|
|
1062
|
+
params.set("since", args.since);
|
|
1063
|
+
}
|
|
1064
|
+
const client = deps.getClient();
|
|
1065
|
+
try {
|
|
1066
|
+
const res = await client.get(`/api/cli/decisions/recent?${params.toString()}`, {
|
|
1067
|
+
signal: AbortSignal.timeout(RECENT_TIMEOUT_MS)
|
|
1068
|
+
});
|
|
1069
|
+
const result = { decisions: res.decisions };
|
|
1070
|
+
if (res.unavailable !== void 0) {
|
|
1071
|
+
result.unavailable = res.unavailable;
|
|
1072
|
+
}
|
|
1073
|
+
return result;
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1076
|
+
return { decisions: [], unavailable: `recent feed failed: ${detail}` };
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
var SHORT_ID_PREFIX = "dec_";
|
|
1080
|
+
var ZERO_PAD_TWO = 2;
|
|
1081
|
+
function pad2(n) {
|
|
1082
|
+
return n.toString().padStart(ZERO_PAD_TWO, "0");
|
|
1083
|
+
}
|
|
1084
|
+
function formatClock(ms) {
|
|
1085
|
+
const d = new Date(ms);
|
|
1086
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
1087
|
+
}
|
|
1088
|
+
function authorLabel(row) {
|
|
1089
|
+
if (!row.authorIsSelf) {
|
|
1090
|
+
return row.authorName;
|
|
1091
|
+
}
|
|
1092
|
+
switch (row.producerKind) {
|
|
1093
|
+
case "claude_code":
|
|
1094
|
+
return "Your Claude Code";
|
|
1095
|
+
case "chat":
|
|
1096
|
+
return "Your chat";
|
|
1097
|
+
case "spec_edit":
|
|
1098
|
+
return "Your spec edit";
|
|
1099
|
+
case "cli":
|
|
1100
|
+
return "Your CLI";
|
|
1101
|
+
default:
|
|
1102
|
+
return `Your ${row.authorName}`;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
var AUTHOR_WIDTH = 18;
|
|
1106
|
+
function padRight(s, width) {
|
|
1107
|
+
return s.length >= width ? `${s.slice(0, width - 1)} ` : s.padEnd(width, " ");
|
|
1108
|
+
}
|
|
1109
|
+
function formatRecentHuman(result) {
|
|
1110
|
+
if (result.unavailable !== void 0) {
|
|
1111
|
+
return `[prim] recent \xB7 feed not verified \u2014 ${result.unavailable}`;
|
|
1112
|
+
}
|
|
1113
|
+
if (result.decisions.length === 0) {
|
|
1114
|
+
return "[prim] recent \xB7 0 decisions";
|
|
1115
|
+
}
|
|
1116
|
+
const lines = [`[prim] recent \xB7 ${String(result.decisions.length)} decision(s)`];
|
|
1117
|
+
for (const row of result.decisions) {
|
|
1118
|
+
const clock = formatClock(row.classifiedAt);
|
|
1119
|
+
const author = padRight(authorLabel(row), AUTHOR_WIDTH);
|
|
1120
|
+
const areaText = row.area ? `\u2022 ${row.area}` : "\u2022";
|
|
1121
|
+
const areaPlain = padRight(areaText, 12);
|
|
1122
|
+
const areaCol = row.area ? areaPlain.replace("\u2022", color("\u2022", colorForArea(row.area))) : areaPlain;
|
|
1123
|
+
lines.push(` ${clock} ${author}${areaCol}${row.intent}`);
|
|
1124
|
+
}
|
|
1125
|
+
return lines.join("\n");
|
|
1126
|
+
}
|
|
1127
|
+
function formatRecentJson(result) {
|
|
1128
|
+
return JSON.stringify(result, null, 2);
|
|
1129
|
+
}
|
|
1130
|
+
function renderIdentifier(row) {
|
|
1131
|
+
if (row.shortId) {
|
|
1132
|
+
return `${SHORT_ID_PREFIX}${row.shortId}`;
|
|
1133
|
+
}
|
|
1134
|
+
return row.id;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// src/decisions/confirm.ts
|
|
1138
|
+
var CONFIRM_TIMEOUT_MS = 1e4;
|
|
1139
|
+
var defaultDeps3 = { getClient };
|
|
1140
|
+
var NOT_FOUND_RE2 = /not found/i;
|
|
1141
|
+
var AMBIGUOUS_RE = /ambiguous/i;
|
|
1142
|
+
var NOT_AUTHOR_RE = /author/i;
|
|
1143
|
+
var ConfirmNotFoundError = class extends Error {
|
|
1144
|
+
constructor(idOrShortId) {
|
|
1145
|
+
super(`Decision not found: ${idOrShortId}`);
|
|
1146
|
+
this.name = "ConfirmNotFoundError";
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
async function fetchConfirm(idOrShortId, confirmed, deps = defaultDeps3) {
|
|
1150
|
+
const request = { idOrShortId, confirmed };
|
|
1151
|
+
const client = deps.getClient();
|
|
1152
|
+
try {
|
|
1153
|
+
const outcome = await client.post(
|
|
1154
|
+
"/api/cli/decisions/confirm",
|
|
1155
|
+
{ id: idOrShortId, confirmed },
|
|
1156
|
+
{ signal: AbortSignal.timeout(CONFIRM_TIMEOUT_MS) }
|
|
1157
|
+
);
|
|
1158
|
+
return { request, outcome };
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
if (err instanceof Error) {
|
|
1161
|
+
if (NOT_FOUND_RE2.test(err.message)) {
|
|
1162
|
+
throw new ConfirmNotFoundError(idOrShortId);
|
|
1163
|
+
}
|
|
1164
|
+
if (AMBIGUOUS_RE.test(err.message)) {
|
|
1165
|
+
return { request, outcome: { outcome: "ambiguous" } };
|
|
1166
|
+
}
|
|
1167
|
+
if (NOT_AUTHOR_RE.test(err.message)) {
|
|
1168
|
+
return { request, outcome: { outcome: "not_author" } };
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
throw err;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
function intentWord(confirmed) {
|
|
1175
|
+
return confirmed ? "confirmed" : "rejected";
|
|
1176
|
+
}
|
|
1177
|
+
function formatConfirmHuman(result) {
|
|
1178
|
+
const { request, outcome } = result;
|
|
1179
|
+
switch (outcome.outcome) {
|
|
1180
|
+
case "confirmed":
|
|
1181
|
+
case "corrected":
|
|
1182
|
+
case "stale": {
|
|
1183
|
+
const id = renderIdentifier({ shortId: outcome.shortId, id: outcome.decisionId });
|
|
1184
|
+
if (outcome.outcome === "stale") {
|
|
1185
|
+
return `[prim] ${id} ${intentWord(request.confirmed)} \u2014 the prompt had gone stale; recorded against the current decision.`;
|
|
1186
|
+
}
|
|
1187
|
+
if (outcome.outcome === "corrected") {
|
|
1188
|
+
return `[prim] ${id} ${intentWord(request.confirmed)} with a correction.`;
|
|
1189
|
+
}
|
|
1190
|
+
return `[prim] ${id} ${intentWord(request.confirmed)}.`;
|
|
1191
|
+
}
|
|
1192
|
+
case "already_responded": {
|
|
1193
|
+
const id = renderIdentifier({ shortId: outcome.shortId, id: outcome.decisionId });
|
|
1194
|
+
const priorWord = outcome.confirmed === void 0 ? "already answered" : `already ${intentWord(outcome.confirmed)}`;
|
|
1195
|
+
const when = new Date(outcome.respondedAt).toISOString();
|
|
1196
|
+
return `[prim] ${id} was ${priorWord} (responded ${when}); nothing to change.`;
|
|
1197
|
+
}
|
|
1198
|
+
case "no_pending_prompt": {
|
|
1199
|
+
const id = renderIdentifier({ shortId: outcome.shortId, id: outcome.decisionId });
|
|
1200
|
+
return `[prim] ${id} has no pending confirmation request \u2014 nothing to acknowledge.`;
|
|
1201
|
+
}
|
|
1202
|
+
case "ambiguous":
|
|
1203
|
+
return `[prim] shortId "${request.idOrShortId}" is ambiguous in this organization \u2014 retry with the full decision id.`;
|
|
1204
|
+
default:
|
|
1205
|
+
return "[prim] only the decision's author can respond to its confirmation prompt.";
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function formatConfirmJson(result) {
|
|
1209
|
+
return JSON.stringify(result.outcome, null, 2);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// src/decisions/show.ts
|
|
1213
|
+
var NOT_FOUND_RE3 = /not found/i;
|
|
1214
|
+
function colorStatus(status) {
|
|
1215
|
+
if (status === "under_review") {
|
|
1216
|
+
return color(status, "orange");
|
|
1217
|
+
}
|
|
1218
|
+
if (status === "active") {
|
|
1219
|
+
return color(status, "green");
|
|
1220
|
+
}
|
|
1221
|
+
return color(status, "gray");
|
|
1222
|
+
}
|
|
1223
|
+
var SHOW_TIMEOUT_MS = 1e4;
|
|
1224
|
+
var defaultDeps4 = { getClient };
|
|
1225
|
+
var DecisionNotFoundError = class extends Error {
|
|
1226
|
+
constructor(idOrShortId) {
|
|
1227
|
+
super(`Decision not found: ${idOrShortId}`);
|
|
1228
|
+
this.name = "DecisionNotFoundError";
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
async function fetchShow(idOrShortId, deps = defaultDeps4) {
|
|
1232
|
+
const params = new URLSearchParams({ id: idOrShortId });
|
|
1233
|
+
const client = deps.getClient();
|
|
1234
|
+
try {
|
|
1235
|
+
return await client.get(`/api/cli/decisions/show?${params.toString()}`, {
|
|
1236
|
+
signal: AbortSignal.timeout(SHOW_TIMEOUT_MS)
|
|
1237
|
+
});
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
if (err instanceof Error && NOT_FOUND_RE3.test(err.message)) {
|
|
1240
|
+
throw new DecisionNotFoundError(idOrShortId);
|
|
1241
|
+
}
|
|
1242
|
+
throw err;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
var GATED_FLAG_KINDS = /* @__PURE__ */ new Set(["file_edit", "supersession", "context_edit"]);
|
|
1246
|
+
function describeFlag(flag) {
|
|
1247
|
+
const detail = flag.reason ? ` \u2014 ${flag.reason}` : "";
|
|
1248
|
+
if (flag.acknowledgedAt !== void 0) {
|
|
1249
|
+
return `acknowledged ${flag.type}${detail}`;
|
|
1250
|
+
}
|
|
1251
|
+
if (flag.type === "confirmation_request") {
|
|
1252
|
+
return `pending confirmation request${detail}`;
|
|
1253
|
+
}
|
|
1254
|
+
if (GATED_FLAG_KINDS.has(flag.type)) {
|
|
1255
|
+
const verdict = flag.gateVerdict ?? "unknown";
|
|
1256
|
+
return `pending ${flag.type} (verdict: ${verdict})${detail}`;
|
|
1257
|
+
}
|
|
1258
|
+
return `pending ${flag.type}${detail}`;
|
|
1259
|
+
}
|
|
1260
|
+
function describeNode(node) {
|
|
1261
|
+
const id = renderIdentifier({ shortId: node.shortId, id: node.id });
|
|
1262
|
+
const area = node.area ? ` \u2022 ${node.area}` : "";
|
|
1263
|
+
return `${id}${area} ${node.intent} (${node.authorName})`;
|
|
1264
|
+
}
|
|
1265
|
+
function pushFiles(lines, files) {
|
|
1266
|
+
if (files.length === 0) {
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
lines.push(` files (${String(files.length)}):`);
|
|
1270
|
+
for (const file of files) {
|
|
1271
|
+
lines.push(` - ${file}`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
function pushContexts(lines, contexts) {
|
|
1275
|
+
if (contexts.length === 0) {
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
lines.push(` contexts (${String(contexts.length)}):`);
|
|
1279
|
+
for (const ctx of contexts) {
|
|
1280
|
+
lines.push(` - ${ctx.name}`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
function pushEdges(lines, label, arrow, nodes) {
|
|
1284
|
+
if (nodes.length === 0) {
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
lines.push(` ${label} (${String(nodes.length)}):`);
|
|
1288
|
+
for (const node of nodes) {
|
|
1289
|
+
lines.push(` ${arrow} ${describeNode(node)}`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
function formatShowHuman(result) {
|
|
1293
|
+
const d = result.decision;
|
|
1294
|
+
const id = color(renderIdentifier({ shortId: d.shortId, id: d.id }), "orange");
|
|
1295
|
+
const confidence = d.confidence ?? "(unset)";
|
|
1296
|
+
const lines = [
|
|
1297
|
+
`[prim] ${id} \u2014 ${d.intent}`,
|
|
1298
|
+
` status: ${colorStatus(d.status)}${d.confirmed ? " (confirmed)" : ""} \xB7 confidence: ${confidence} \xB7 reversibility: ${d.reversibility ?? "(unset)"}`
|
|
1299
|
+
];
|
|
1300
|
+
if (d.supersededBy) {
|
|
1301
|
+
lines.push(` superseded by: ${d.supersededBy}`);
|
|
1302
|
+
}
|
|
1303
|
+
if (d.area) {
|
|
1304
|
+
lines.push(` area: ${color(d.area, colorForArea(d.area))}`);
|
|
1305
|
+
}
|
|
1306
|
+
if (typeof d.fanOut === "number") {
|
|
1307
|
+
lines.push(` fan-out: ${String(d.fanOut)}`);
|
|
1308
|
+
}
|
|
1309
|
+
if (d.respondedAt !== void 0) {
|
|
1310
|
+
lines.push(` responded at: ${new Date(d.respondedAt).toISOString()}`);
|
|
1311
|
+
}
|
|
1312
|
+
if (d.rationale) {
|
|
1313
|
+
lines.push(` rationale: ${d.rationale}`);
|
|
1314
|
+
}
|
|
1315
|
+
if (d.decided && d.decided.length > 0) {
|
|
1316
|
+
lines.push(` decided (${String(d.decided.length)}):`);
|
|
1317
|
+
for (const point of d.decided) {
|
|
1318
|
+
lines.push(` - ${point}`);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
if (d.alternatives.length > 0) {
|
|
1322
|
+
lines.push(` alternatives: ${d.alternatives.join(" | ")}`);
|
|
1323
|
+
}
|
|
1324
|
+
pushFiles(lines, result.files);
|
|
1325
|
+
pushContexts(lines, result.contexts);
|
|
1326
|
+
pushEdges(lines, "dependents", "\u2192", result.dependents);
|
|
1327
|
+
pushEdges(lines, "depends on", "\u2190", result.dependsOn);
|
|
1328
|
+
if (result.flags.length > 0) {
|
|
1329
|
+
lines.push(` flags (${String(result.flags.length)}):`);
|
|
1330
|
+
for (const flag of result.flags) {
|
|
1331
|
+
lines.push(` \xB7 ${describeFlag(flag)}`);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (result.truncated) {
|
|
1335
|
+
lines.push(" (partial \u2014 some related rows were truncated by a join cap)");
|
|
1336
|
+
}
|
|
1337
|
+
return lines.join("\n");
|
|
1338
|
+
}
|
|
1339
|
+
function formatShowJson(result) {
|
|
1340
|
+
return JSON.stringify(result, null, 2);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// src/commands/decisions.ts
|
|
1344
|
+
var EXIT_NOT_FOUND = 4;
|
|
1345
|
+
function registerDecisionsCommands(program2) {
|
|
1346
|
+
const decisions = program2.command("decisions").description("Inspect the project Decision Graph");
|
|
1347
|
+
decisions.command("check").description("Look up active decisions that reference one or more file paths").requiredOption(
|
|
1348
|
+
"--files <files>",
|
|
1349
|
+
"Comma-separated file paths to check against the Decision Graph"
|
|
1350
|
+
).action(async (opts) => {
|
|
1351
|
+
const filePaths = opts.files.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1352
|
+
const result = await checkAffectedDecisions(filePaths);
|
|
1353
|
+
const warning = formatDecisionsWarning(result);
|
|
1354
|
+
if (warning) {
|
|
1355
|
+
console.error(warning);
|
|
1356
|
+
}
|
|
1357
|
+
printJson(result);
|
|
1358
|
+
});
|
|
1359
|
+
decisions.command("recent").description("Show the team-wide chronological decision feed").option("--limit <n>", "Maximum number of rows to return (default 10)").option(
|
|
1360
|
+
"--since <duration>",
|
|
1361
|
+
"Lookback window \u2014 accepts `Nm`, `Nh`, `Nd` (minutes / hours / days) or absolute epoch ms"
|
|
1362
|
+
).action(async (opts) => {
|
|
1363
|
+
const result = await fetchRecent({
|
|
1364
|
+
limit: opts.limit ? Number.parseInt(opts.limit, 10) : void 0,
|
|
1365
|
+
since: opts.since
|
|
1366
|
+
});
|
|
1367
|
+
console.error(formatRecentHuman(result));
|
|
1368
|
+
console.log(formatRecentJson(result));
|
|
1369
|
+
});
|
|
1370
|
+
decisions.command("show <idOrShortId>").description("Show full detail for one decision (intent, rationale, flags, refs, edges)").action(async (idOrShortId) => {
|
|
1371
|
+
try {
|
|
1372
|
+
const result = await fetchShow(idOrShortId);
|
|
1373
|
+
console.error(formatShowHuman(result));
|
|
1374
|
+
console.log(formatShowJson(result));
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
if (err instanceof DecisionNotFoundError) {
|
|
1377
|
+
console.error(`[prim] ${err.message}`);
|
|
1378
|
+
process.exitCode = EXIT_NOT_FOUND;
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
throw err;
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
decisions.command("cascade <idOrShortId>").description("Render the local cascade subgraph (upstream knowledge + downstream dependents)").action(async (idOrShortId) => {
|
|
1385
|
+
try {
|
|
1386
|
+
const result = await fetchCascade(idOrShortId);
|
|
1387
|
+
console.error(renderCascade(result));
|
|
1388
|
+
console.log(formatCascadeJson(result));
|
|
1389
|
+
} catch (err) {
|
|
1390
|
+
if (err instanceof CascadeNotFoundError) {
|
|
1391
|
+
console.error(`[prim] ${err.message}`);
|
|
1392
|
+
process.exitCode = EXIT_NOT_FOUND;
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
throw err;
|
|
1396
|
+
}
|
|
1397
|
+
});
|
|
1398
|
+
decisions.command("confirm <idOrShortId>").description("Acknowledge a confirmation prompt for the named decision").option("--reject", "Record a rejection (sets the decision's confirmed flag to false)").action(async (idOrShortId, opts) => {
|
|
1399
|
+
const confirmed = !opts.reject;
|
|
1400
|
+
try {
|
|
1401
|
+
const result = await fetchConfirm(idOrShortId, confirmed);
|
|
1402
|
+
console.error(formatConfirmHuman(result));
|
|
1403
|
+
console.log(formatConfirmJson(result));
|
|
1404
|
+
} catch (err) {
|
|
1405
|
+
if (err instanceof ConfirmNotFoundError) {
|
|
1406
|
+
console.error(`[prim] ${err.message}`);
|
|
1407
|
+
process.exitCode = EXIT_NOT_FOUND;
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
throw err;
|
|
1411
|
+
}
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
|
|
375
1415
|
// src/commands/hooks.ts
|
|
376
1416
|
import { execSync } from "child_process";
|
|
377
|
-
import { existsSync as
|
|
1417
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
378
1418
|
import { resolve } from "path";
|
|
379
1419
|
import { Option } from "commander";
|
|
380
1420
|
var HOOK_SCRIPT = `#!/bin/sh
|
|
@@ -408,13 +1448,13 @@ function getGitRoot() {
|
|
|
408
1448
|
}
|
|
409
1449
|
function detectHusky(gitRoot) {
|
|
410
1450
|
const huskyDir = resolve(gitRoot, ".husky");
|
|
411
|
-
if (!
|
|
412
|
-
if (
|
|
413
|
-
if (
|
|
1451
|
+
if (!existsSync4(huskyDir)) return false;
|
|
1452
|
+
if (existsSync4(resolve(huskyDir, "_"))) return true;
|
|
1453
|
+
if (existsSync4(resolve(huskyDir, "pre-commit"))) return true;
|
|
414
1454
|
const pkgPath = resolve(gitRoot, "package.json");
|
|
415
|
-
if (
|
|
1455
|
+
if (existsSync4(pkgPath)) {
|
|
416
1456
|
try {
|
|
417
|
-
const pkg2 = JSON.parse(
|
|
1457
|
+
const pkg2 = JSON.parse(readFileSync5(pkgPath, "utf-8"));
|
|
418
1458
|
const scripts = pkg2.scripts ?? {};
|
|
419
1459
|
if (/husky/i.test(scripts.prepare ?? "") || /husky/i.test(scripts.postinstall ?? "")) {
|
|
420
1460
|
return true;
|
|
@@ -441,20 +1481,20 @@ async function askConfirmation(question) {
|
|
|
441
1481
|
}
|
|
442
1482
|
function installToHusky(gitRoot) {
|
|
443
1483
|
const hookPath = resolve(gitRoot, ".husky", "pre-commit");
|
|
444
|
-
if (
|
|
445
|
-
const existing =
|
|
1484
|
+
if (existsSync4(hookPath)) {
|
|
1485
|
+
const existing = readFileSync5(hookPath, "utf-8");
|
|
446
1486
|
if (containsPrimHook(existing)) {
|
|
447
1487
|
console.log("Prim pre-commit hook is already installed in .husky/pre-commit.");
|
|
448
1488
|
return;
|
|
449
1489
|
}
|
|
450
1490
|
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
451
|
-
|
|
1491
|
+
writeFileSync3(hookPath, `${existing}${separator}${PRIM_HUSKY_BLOCK}
|
|
452
1492
|
`, {
|
|
453
1493
|
mode: 493
|
|
454
1494
|
});
|
|
455
1495
|
console.log("Appended prim hook block to .husky/pre-commit.");
|
|
456
1496
|
} else {
|
|
457
|
-
|
|
1497
|
+
writeFileSync3(hookPath, `#!/bin/sh
|
|
458
1498
|
|
|
459
1499
|
${PRIM_HUSKY_BLOCK}
|
|
460
1500
|
`, {
|
|
@@ -466,11 +1506,11 @@ ${PRIM_HUSKY_BLOCK}
|
|
|
466
1506
|
function installToDotGit(gitRoot) {
|
|
467
1507
|
const hooksDir = resolve(gitRoot, ".git", "hooks");
|
|
468
1508
|
const hookPath = resolve(hooksDir, "pre-commit");
|
|
469
|
-
if (!
|
|
470
|
-
|
|
1509
|
+
if (!existsSync4(hooksDir)) {
|
|
1510
|
+
mkdirSync3(hooksDir, { recursive: true });
|
|
471
1511
|
}
|
|
472
|
-
if (
|
|
473
|
-
const existing =
|
|
1512
|
+
if (existsSync4(hookPath)) {
|
|
1513
|
+
const existing = readFileSync5(hookPath, "utf-8");
|
|
474
1514
|
if (containsPrimHook(existing)) {
|
|
475
1515
|
console.log("Prim pre-commit hook is already installed at .git/hooks/pre-commit.");
|
|
476
1516
|
return;
|
|
@@ -479,7 +1519,7 @@ function installToDotGit(gitRoot) {
|
|
|
479
1519
|
console.log("To replace it, run: prim hooks uninstall && prim hooks install");
|
|
480
1520
|
return;
|
|
481
1521
|
}
|
|
482
|
-
|
|
1522
|
+
writeFileSync3(hookPath, HOOK_SCRIPT, { mode: 493 });
|
|
483
1523
|
console.log(`Installed pre-commit hook at ${hookPath}`);
|
|
484
1524
|
}
|
|
485
1525
|
function registerHooksCommands(program2) {
|
|
@@ -521,15 +1561,147 @@ function registerHooksCommands(program2) {
|
|
|
521
1561
|
hooks.command("uninstall").description("Remove the prim pre-commit hook").action(() => {
|
|
522
1562
|
const gitRoot = getGitRoot();
|
|
523
1563
|
const hookPath = resolve(gitRoot, ".git", "hooks", "pre-commit");
|
|
524
|
-
if (!
|
|
1564
|
+
if (!existsSync4(hookPath)) {
|
|
525
1565
|
console.log("No pre-commit hook found.");
|
|
526
1566
|
return;
|
|
527
1567
|
}
|
|
528
|
-
|
|
1568
|
+
unlinkSync2(hookPath);
|
|
529
1569
|
console.log(`Removed pre-commit hook at ${hookPath}`);
|
|
530
1570
|
});
|
|
531
1571
|
}
|
|
532
1572
|
|
|
1573
|
+
// src/commands/moves.ts
|
|
1574
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, unlinkSync as unlinkSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
1575
|
+
import { join as join3 } from "path";
|
|
1576
|
+
|
|
1577
|
+
// src/flusher.ts
|
|
1578
|
+
import { renameSync as renameSync2, unlinkSync as unlinkSync3 } from "fs";
|
|
1579
|
+
var BATCH_SIZE = 500;
|
|
1580
|
+
var HTTP_TIMEOUT_MS = 1e4;
|
|
1581
|
+
var OPPORTUNISTIC_FLUSH_AFTER_MS = 6e4;
|
|
1582
|
+
async function drainPath(path) {
|
|
1583
|
+
const tmpPath = `${path}.flushing.${String(Date.now())}.${String(process.pid)}`;
|
|
1584
|
+
try {
|
|
1585
|
+
renameSync2(path, tmpPath);
|
|
1586
|
+
} catch (err) {
|
|
1587
|
+
if (err.code === "ENOENT") {
|
|
1588
|
+
return 0;
|
|
1589
|
+
}
|
|
1590
|
+
throw err;
|
|
1591
|
+
}
|
|
1592
|
+
const moves = readMovesFromPath(tmpPath);
|
|
1593
|
+
if (moves.length === 0) {
|
|
1594
|
+
unlinkSync3(tmpPath);
|
|
1595
|
+
return 0;
|
|
1596
|
+
}
|
|
1597
|
+
const client = getClient();
|
|
1598
|
+
for (let i = 0; i < moves.length; i += BATCH_SIZE) {
|
|
1599
|
+
const batch = moves.slice(i, i + BATCH_SIZE);
|
|
1600
|
+
await client.post(
|
|
1601
|
+
"/api/cli/moves/ingest",
|
|
1602
|
+
{ batch },
|
|
1603
|
+
{ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
unlinkSync3(tmpPath);
|
|
1607
|
+
return moves.length;
|
|
1608
|
+
}
|
|
1609
|
+
async function flush() {
|
|
1610
|
+
let total = 0;
|
|
1611
|
+
for (const { path } of listBuckets()) {
|
|
1612
|
+
total += await drainPath(path);
|
|
1613
|
+
}
|
|
1614
|
+
return { flushed: total };
|
|
1615
|
+
}
|
|
1616
|
+
async function flushIfNeeded() {
|
|
1617
|
+
try {
|
|
1618
|
+
const stats = bucketStats();
|
|
1619
|
+
if (stats.length === 0) {
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
const oldest = stats.reduce((min, s) => s.mtimeMs < min ? s.mtimeMs : min, stats[0].mtimeMs);
|
|
1623
|
+
if (Date.now() - oldest > OPPORTUNISTIC_FLUSH_AFTER_MS) {
|
|
1624
|
+
await flush();
|
|
1625
|
+
}
|
|
1626
|
+
} catch {
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// src/commands/moves.ts
|
|
1631
|
+
var MS_PER_SECOND = 1e3;
|
|
1632
|
+
var DEFAULT_TAIL_LINES = "20";
|
|
1633
|
+
var RADIX_DECIMAL = 10;
|
|
1634
|
+
var ID_PREFIX_LEN = 8;
|
|
1635
|
+
var EVENT_COL_WIDTH = 20;
|
|
1636
|
+
var BUCKET_COL_WIDTH = 20;
|
|
1637
|
+
var TAIL_BUCKET_COL_WIDTH = 12;
|
|
1638
|
+
var DIR_MODE = 448;
|
|
1639
|
+
var FILE_MODE2 = 384;
|
|
1640
|
+
var WORKSPACE_FILE = ".prim/workspace.json";
|
|
1641
|
+
function registerMovesCommands(program2) {
|
|
1642
|
+
const moves = program2.command("moves").description("Decision Event Pipeline \u2014 local journal");
|
|
1643
|
+
moves.command("flush").description("Drain all local move journals to the server").action(async () => {
|
|
1644
|
+
const { flushed } = await flush();
|
|
1645
|
+
console.log(`[prim] flushed ${String(flushed)} move${flushed === 1 ? "" : "s"}`);
|
|
1646
|
+
});
|
|
1647
|
+
moves.command("status").description("Show per-bucket pending stats").action(() => {
|
|
1648
|
+
const stats = bucketStats();
|
|
1649
|
+
if (stats.length === 0) {
|
|
1650
|
+
console.log("[prim] journal: empty");
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
console.log(`[prim] root: ${JOURNAL_DIR}`);
|
|
1654
|
+
for (const s of stats) {
|
|
1655
|
+
const ageS = Math.round((Date.now() - s.mtimeMs) / MS_PER_SECOND);
|
|
1656
|
+
console.log(
|
|
1657
|
+
` ${s.bucket.padEnd(BUCKET_COL_WIDTH)} ${String(s.lineCount).padStart(5)} pending, ${String(s.sizeBytes).padStart(8)} bytes, last write ${String(ageS)}s ago`
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
moves.command("tail").description("Pretty-print recent journal entries across all buckets").option("-n, --lines <n>", "number of lines to tail", DEFAULT_TAIL_LINES).action((opts) => {
|
|
1662
|
+
const lines = Number.parseInt(opts.lines, RADIX_DECIMAL);
|
|
1663
|
+
if (!Number.isInteger(lines) || lines < 1) {
|
|
1664
|
+
console.error("[prim] --lines must be a positive integer");
|
|
1665
|
+
process.exitCode = 1;
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
const all = bucketStats().flatMap((s) => readMovesFromPath(s.path).map((m) => ({ bucket: s.bucket, move: m }))).sort((a, b) => a.move.capturedAt - b.move.capturedAt);
|
|
1669
|
+
if (all.length === 0) {
|
|
1670
|
+
console.log("[prim] journal: empty");
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
const tail = all.slice(-lines);
|
|
1674
|
+
for (const { bucket, move: m } of tail) {
|
|
1675
|
+
const t = new Date(m.capturedAt).toISOString();
|
|
1676
|
+
const session = m.sessionId.slice(0, ID_PREFIX_LEN) || "anon";
|
|
1677
|
+
const move = m.moveId.slice(0, ID_PREFIX_LEN);
|
|
1678
|
+
console.log(
|
|
1679
|
+
`${t} ${m.eventType.padEnd(EVENT_COL_WIDTH)} bucket=${bucket.padEnd(TAIL_BUCKET_COL_WIDTH)} session=${session} move=${move}`
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
moves.command("bind").description("Pin the current directory to an org via .prim/workspace.json").requiredOption("--orgId <orgId>", "Convex organization id").action((opts) => {
|
|
1684
|
+
const dir = join3(process.cwd(), ".prim");
|
|
1685
|
+
if (!existsSync5(dir)) {
|
|
1686
|
+
mkdirSync4(dir, { recursive: true, mode: DIR_MODE });
|
|
1687
|
+
}
|
|
1688
|
+
const file = join3(process.cwd(), WORKSPACE_FILE);
|
|
1689
|
+
writeFileSync4(file, JSON.stringify({ orgId: opts.orgId, boundAt: Date.now() }, null, 2), {
|
|
1690
|
+
mode: FILE_MODE2
|
|
1691
|
+
});
|
|
1692
|
+
console.log(`[prim] bound ${process.cwd()} to org ${opts.orgId}`);
|
|
1693
|
+
});
|
|
1694
|
+
moves.command("drop").description("Remove the .prim/workspace.json binding from the cwd").action(() => {
|
|
1695
|
+
const file = join3(process.cwd(), WORKSPACE_FILE);
|
|
1696
|
+
if (!existsSync5(file)) {
|
|
1697
|
+
console.log("[prim] no workspace binding in cwd");
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
unlinkSync4(file);
|
|
1701
|
+
console.log(`[prim] dropped workspace binding from ${process.cwd()}`);
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
|
|
533
1705
|
// src/commands/project.ts
|
|
534
1706
|
function registerProjectCommands(program2) {
|
|
535
1707
|
const project = program2.command("project").description("Manage projects");
|
|
@@ -552,20 +1724,165 @@ function registerProjectCommands(program2) {
|
|
|
552
1724
|
});
|
|
553
1725
|
}
|
|
554
1726
|
|
|
1727
|
+
// src/commands/reconcile.ts
|
|
1728
|
+
var EXIT_OK = 0;
|
|
1729
|
+
var EXIT_USAGE = 2;
|
|
1730
|
+
var EXIT_SERVER = 3;
|
|
1731
|
+
var HTTP_CLIENT_ERROR_MIN = 400;
|
|
1732
|
+
var HTTP_SERVER_ERROR_MIN = 500;
|
|
1733
|
+
function isOk(value) {
|
|
1734
|
+
if (typeof value !== "object" || value === null) {
|
|
1735
|
+
return false;
|
|
1736
|
+
}
|
|
1737
|
+
const v = value;
|
|
1738
|
+
return v.ok === true && typeof v.bypassId === "string" && typeof v.expiresAt === "number";
|
|
1739
|
+
}
|
|
1740
|
+
function formatExpiresIn(expiresAt) {
|
|
1741
|
+
const remainingMs = expiresAt - Date.now();
|
|
1742
|
+
if (remainingMs <= 0) {
|
|
1743
|
+
return "expired";
|
|
1744
|
+
}
|
|
1745
|
+
const SECONDS_PER_MINUTE = 60;
|
|
1746
|
+
const minutes = Math.floor(remainingMs / (SECONDS_PER_MINUTE * 1e3));
|
|
1747
|
+
const seconds = Math.floor(remainingMs / 1e3 % SECONDS_PER_MINUTE);
|
|
1748
|
+
if (minutes === 0) {
|
|
1749
|
+
return `${seconds}s`;
|
|
1750
|
+
}
|
|
1751
|
+
return `${minutes}m${seconds.toString().padStart(2, "0")}s`;
|
|
1752
|
+
}
|
|
1753
|
+
function renderDecisionIdentifier(short, id) {
|
|
1754
|
+
return short ? `dec_${short}` : id;
|
|
1755
|
+
}
|
|
1756
|
+
function isDomainRejection(err) {
|
|
1757
|
+
return err instanceof HttpError && err.status >= HTTP_CLIENT_ERROR_MIN && err.status < HTTP_SERVER_ERROR_MIN;
|
|
1758
|
+
}
|
|
1759
|
+
async function performReconcile(idOrShortId, opts = {}) {
|
|
1760
|
+
const client = getClient();
|
|
1761
|
+
const body = { idOrShortId };
|
|
1762
|
+
if (opts.flag) {
|
|
1763
|
+
body.conflictFlagId = opts.flag;
|
|
1764
|
+
}
|
|
1765
|
+
let response;
|
|
1766
|
+
try {
|
|
1767
|
+
response = await client.post("/api/cli/reconcile/issue", body);
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
if (isDomainRejection(err)) {
|
|
1770
|
+
process.stderr.write(`[prim] reconcile rejected: ${err.message}
|
|
1771
|
+
`);
|
|
1772
|
+
console.log(JSON.stringify({ ok: false, status: err.status, error: err.message }, null, 2));
|
|
1773
|
+
process.exitCode = EXIT_USAGE;
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1777
|
+
process.stderr.write(`[prim] reconcile failed: ${message}
|
|
1778
|
+
`);
|
|
1779
|
+
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
1780
|
+
process.exitCode = EXIT_SERVER;
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
if (isOk(response)) {
|
|
1784
|
+
const ident = renderDecisionIdentifier(response.decisionShortId, response.decisionId);
|
|
1785
|
+
const verb = response.reissued ? "reissued" : "issued";
|
|
1786
|
+
process.stderr.write(
|
|
1787
|
+
`[prim] reconcile bypass ${verb} for ${ident} (expires in ${formatExpiresIn(response.expiresAt)})
|
|
1788
|
+
`
|
|
1789
|
+
);
|
|
1790
|
+
console.log(JSON.stringify(response, null, 2));
|
|
1791
|
+
process.exitCode = EXIT_OK;
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
process.stderr.write("[prim] reconcile: malformed server response\n");
|
|
1795
|
+
console.log(JSON.stringify({ ok: false, response }, null, 2));
|
|
1796
|
+
process.exitCode = EXIT_SERVER;
|
|
1797
|
+
}
|
|
1798
|
+
function registerReconcileCommands(program2) {
|
|
1799
|
+
program2.command("reconcile <idOrShortId>").description(
|
|
1800
|
+
"Issue a single-use bypass for a flagged decision (used by the cooperative reconcile loop)"
|
|
1801
|
+
).option(
|
|
1802
|
+
"--flag <conflictFlagId>",
|
|
1803
|
+
"Specific flag id to bind the bypass to (default: the decision's latest unack'd flag)"
|
|
1804
|
+
).option("--json", "(reserved; STDOUT is always JSON)").action(async (idOrShortId, opts) => {
|
|
1805
|
+
await performReconcile(idOrShortId, opts);
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// src/commands/session.ts
|
|
1810
|
+
import {
|
|
1811
|
+
existsSync as existsSync6,
|
|
1812
|
+
mkdirSync as mkdirSync5,
|
|
1813
|
+
readFileSync as readFileSync6,
|
|
1814
|
+
readdirSync,
|
|
1815
|
+
unlinkSync as unlinkSync5,
|
|
1816
|
+
writeFileSync as writeFileSync5
|
|
1817
|
+
} from "fs";
|
|
1818
|
+
import { join as join4 } from "path";
|
|
1819
|
+
var DIR_MODE2 = 448;
|
|
1820
|
+
var FILE_MODE3 = 384;
|
|
1821
|
+
function ensureDir() {
|
|
1822
|
+
if (!existsSync6(SESSIONS_DIR)) {
|
|
1823
|
+
mkdirSync5(SESSIONS_DIR, { recursive: true, mode: DIR_MODE2 });
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
function markerPath(sessionId) {
|
|
1827
|
+
return join4(SESSIONS_DIR, `${sessionId}.json`);
|
|
1828
|
+
}
|
|
1829
|
+
function registerSessionCommands(program2) {
|
|
1830
|
+
const session = program2.command("session").description("Decision Event Pipeline \u2014 session binding markers");
|
|
1831
|
+
session.command("start <sessionId>").description("Pin a Claude Code session to an org").requiredOption("--orgId <orgId>", "Convex organization id").action((sessionId, opts) => {
|
|
1832
|
+
ensureDir();
|
|
1833
|
+
const marker = {
|
|
1834
|
+
orgId: opts.orgId,
|
|
1835
|
+
startedAt: Date.now()
|
|
1836
|
+
};
|
|
1837
|
+
writeFileSync5(markerPath(sessionId), JSON.stringify(marker, null, 2), {
|
|
1838
|
+
mode: FILE_MODE3
|
|
1839
|
+
});
|
|
1840
|
+
console.log(`[prim] session ${sessionId} bound to org ${opts.orgId}`);
|
|
1841
|
+
});
|
|
1842
|
+
session.command("list").description("List active session markers").action(() => {
|
|
1843
|
+
if (!existsSync6(SESSIONS_DIR)) {
|
|
1844
|
+
console.log("[prim] no session markers");
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
|
|
1848
|
+
if (files.length === 0) {
|
|
1849
|
+
console.log("[prim] no session markers");
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
for (const f of files) {
|
|
1853
|
+
const sessionId = f.replace(/\.json$/, "");
|
|
1854
|
+
try {
|
|
1855
|
+
const m = JSON.parse(readFileSync6(join4(SESSIONS_DIR, f), "utf-8"));
|
|
1856
|
+
console.log(`${sessionId} org=${m.orgId}`);
|
|
1857
|
+
} catch {
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
});
|
|
1861
|
+
session.command("drop <sessionId>").description("Remove a session marker").action((sessionId) => {
|
|
1862
|
+
const p = markerPath(sessionId);
|
|
1863
|
+
if (!existsSync6(p)) {
|
|
1864
|
+
console.log(`[prim] no marker for session ${sessionId}`);
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
unlinkSync5(p);
|
|
1868
|
+
console.log(`[prim] dropped session marker ${sessionId}`);
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
|
|
555
1872
|
// src/commands/skill.ts
|
|
556
1873
|
import {
|
|
557
|
-
closeSync,
|
|
558
|
-
existsSync as
|
|
559
|
-
fsyncSync,
|
|
560
|
-
openSync,
|
|
561
|
-
readFileSync as
|
|
562
|
-
renameSync,
|
|
563
|
-
writeFileSync as
|
|
1874
|
+
closeSync as closeSync2,
|
|
1875
|
+
existsSync as existsSync7,
|
|
1876
|
+
fsyncSync as fsyncSync2,
|
|
1877
|
+
openSync as openSync2,
|
|
1878
|
+
readFileSync as readFileSync7,
|
|
1879
|
+
renameSync as renameSync3,
|
|
1880
|
+
writeFileSync as writeFileSync6
|
|
564
1881
|
} from "fs";
|
|
565
|
-
import { dirname as
|
|
1882
|
+
import { dirname as dirname3, resolve as resolve2 } from "path";
|
|
566
1883
|
import { fileURLToPath } from "url";
|
|
567
1884
|
import { createPatch } from "diff";
|
|
568
|
-
var __dirname =
|
|
1885
|
+
var __dirname = dirname3(fileURLToPath(import.meta.url));
|
|
569
1886
|
var SKILL_BEGIN = "<!-- BEGIN PRIM SKILL v1 -->";
|
|
570
1887
|
var SKILL_END = "<!-- END PRIM SKILL v1 -->";
|
|
571
1888
|
var TARGET_CANDIDATES = [
|
|
@@ -577,15 +1894,15 @@ var TARGET_CANDIDATES = [
|
|
|
577
1894
|
var DEFAULT_TARGET = "CLAUDE.md";
|
|
578
1895
|
function loadSkill() {
|
|
579
1896
|
let dir = __dirname;
|
|
580
|
-
while (dir !==
|
|
1897
|
+
while (dir !== dirname3(dir)) {
|
|
581
1898
|
const p = resolve2(dir, "SKILL.md");
|
|
582
|
-
if (
|
|
583
|
-
dir =
|
|
1899
|
+
if (existsSync7(p)) return readFileSync7(p, "utf-8");
|
|
1900
|
+
dir = dirname3(dir);
|
|
584
1901
|
}
|
|
585
1902
|
throw new Error("SKILL.md not found in package");
|
|
586
1903
|
}
|
|
587
1904
|
function detectTargets(cwd) {
|
|
588
|
-
return TARGET_CANDIDATES.filter((p) =>
|
|
1905
|
+
return TARGET_CANDIDATES.filter((p) => existsSync7(resolve2(cwd, p)));
|
|
589
1906
|
}
|
|
590
1907
|
function detectNewline(content) {
|
|
591
1908
|
return content.includes("\r\n") ? "\r\n" : "\n";
|
|
@@ -611,16 +1928,16 @@ function removeBlock(existing) {
|
|
|
611
1928
|
const out = existing.slice(0, b) + existing.slice(e + SKILL_END.length);
|
|
612
1929
|
return out.replace(/(\r?\n){2,}$/, "$1");
|
|
613
1930
|
}
|
|
614
|
-
function
|
|
1931
|
+
function atomicWrite2(target, content) {
|
|
615
1932
|
const tmp = `${target}.tmp`;
|
|
616
|
-
|
|
617
|
-
const fd =
|
|
1933
|
+
writeFileSync6(tmp, content);
|
|
1934
|
+
const fd = openSync2(tmp, "r+");
|
|
618
1935
|
try {
|
|
619
|
-
|
|
1936
|
+
fsyncSync2(fd);
|
|
620
1937
|
} finally {
|
|
621
|
-
|
|
1938
|
+
closeSync2(fd);
|
|
622
1939
|
}
|
|
623
|
-
|
|
1940
|
+
renameSync3(tmp, target);
|
|
624
1941
|
}
|
|
625
1942
|
function resolveTarget(cwd, override) {
|
|
626
1943
|
if (override) return resolve2(cwd, override);
|
|
@@ -634,7 +1951,7 @@ function resolveTarget(cwd, override) {
|
|
|
634
1951
|
function runInstall(cwd, opts) {
|
|
635
1952
|
const target = resolveTarget(cwd, opts.target);
|
|
636
1953
|
if (target === null) return 1;
|
|
637
|
-
const existing =
|
|
1954
|
+
const existing = existsSync7(target) ? readFileSync7(target, "utf-8") : "";
|
|
638
1955
|
const eol = existing ? detectNewline(existing) : "\n";
|
|
639
1956
|
const block = composeBlock(loadSkill(), eol);
|
|
640
1957
|
const next = applyBlock(existing, block, eol);
|
|
@@ -646,34 +1963,34 @@ function runInstall(cwd, opts) {
|
|
|
646
1963
|
process.stdout.write(createPatch(target, existing, next, "current", "proposed"));
|
|
647
1964
|
return 0;
|
|
648
1965
|
}
|
|
649
|
-
|
|
1966
|
+
atomicWrite2(target, next);
|
|
650
1967
|
console.log(`Wrote ${Buffer.byteLength(next)} bytes to ${target}`);
|
|
651
1968
|
return 0;
|
|
652
1969
|
}
|
|
653
1970
|
function runUninstall(cwd, opts) {
|
|
654
1971
|
const target = resolveTarget(cwd, opts.target);
|
|
655
1972
|
if (target === null) return 1;
|
|
656
|
-
if (!
|
|
1973
|
+
if (!existsSync7(target)) {
|
|
657
1974
|
console.log(`Skill block not present at ${target}`);
|
|
658
1975
|
return 0;
|
|
659
1976
|
}
|
|
660
|
-
const existing =
|
|
1977
|
+
const existing = readFileSync7(target, "utf-8");
|
|
661
1978
|
const next = removeBlock(existing);
|
|
662
1979
|
if (next === null) {
|
|
663
1980
|
console.log(`Skill block not present at ${target}`);
|
|
664
1981
|
return 0;
|
|
665
1982
|
}
|
|
666
|
-
|
|
1983
|
+
atomicWrite2(target, next);
|
|
667
1984
|
console.log(`Removed skill block from ${target}`);
|
|
668
1985
|
return 0;
|
|
669
1986
|
}
|
|
670
1987
|
function runStatus(cwd, opts) {
|
|
671
1988
|
const target = resolveTarget(cwd, opts.target);
|
|
672
1989
|
if (target === null) return 1;
|
|
673
|
-
const fileExists =
|
|
1990
|
+
const fileExists = existsSync7(target);
|
|
674
1991
|
let installed = false;
|
|
675
1992
|
if (fileExists) {
|
|
676
|
-
const content =
|
|
1993
|
+
const content = readFileSync7(target, "utf-8");
|
|
677
1994
|
installed = content.includes(SKILL_BEGIN) && content.includes(SKILL_END);
|
|
678
1995
|
}
|
|
679
1996
|
if (opts.json) {
|
|
@@ -710,7 +2027,7 @@ function registerSkillCommands(program2) {
|
|
|
710
2027
|
}
|
|
711
2028
|
|
|
712
2029
|
// src/commands/spec.ts
|
|
713
|
-
import { readFileSync as
|
|
2030
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
714
2031
|
function registerSpecCommands(program2) {
|
|
715
2032
|
const spec = program2.command("spec").description("Manage spec documents");
|
|
716
2033
|
spec.command("list").description("List spec documents").option("-t, --project-id <projectId>", "List spec for a specific root project").option("--json", "Output as JSON").action(async (opts) => {
|
|
@@ -759,12 +2076,53 @@ ${contexts.length} spec(s)`);
|
|
|
759
2076
|
}
|
|
760
2077
|
printSpec(ctx);
|
|
761
2078
|
});
|
|
2079
|
+
spec.command("create").description("Create a new spec document").requiredOption("-s, --scope <scope>", "Scope: project, global, external").requiredOption("-n, --name <name>", "Spec name").option("-t, --text <text>", "Spec text content").option("-f, --file <path>", "Read text content from file").option("--project-id <projectId>", "Link to project(s), comma-separated").option("--branch <branch>", "Link spec to this branch on the current repo").option("--pr <prNumber>", "Optional PR number to attach to the link").option("--json", "Output as JSON").action(
|
|
2080
|
+
async (opts) => {
|
|
2081
|
+
const client = getClient();
|
|
2082
|
+
let text = opts.text;
|
|
2083
|
+
if (opts.file) {
|
|
2084
|
+
text = readFileSync8(opts.file, "utf-8");
|
|
2085
|
+
}
|
|
2086
|
+
const taskIds = opts.projectId ? opts.projectId.split(",").map((id) => id.trim()) : void 0;
|
|
2087
|
+
let linkedBranch;
|
|
2088
|
+
if (opts.branch) {
|
|
2089
|
+
const { repoFullName } = getGitContext();
|
|
2090
|
+
if (!repoFullName) {
|
|
2091
|
+
console.warn(
|
|
2092
|
+
"[prim] --branch supplied but origin remote is not GitHub; skipping link."
|
|
2093
|
+
);
|
|
2094
|
+
} else {
|
|
2095
|
+
linkedBranch = { repoFullName, branch: opts.branch };
|
|
2096
|
+
if (opts.pr) {
|
|
2097
|
+
const n = Number.parseInt(opts.pr, 10);
|
|
2098
|
+
if (Number.isFinite(n)) linkedBranch.prNumber = n;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
const result = await client.post("/api/cli/contexts", {
|
|
2103
|
+
scope: opts.scope === "project" ? "task" : opts.scope,
|
|
2104
|
+
name: opts.name,
|
|
2105
|
+
text,
|
|
2106
|
+
taskIds,
|
|
2107
|
+
isSpecDocument: true,
|
|
2108
|
+
linkedBranch
|
|
2109
|
+
});
|
|
2110
|
+
if (opts.json) {
|
|
2111
|
+
printJson({ _id: result._id });
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
console.error(
|
|
2115
|
+
`Created spec: ${result._id}${linkedBranch ? ` (linked to ${linkedBranch.branch})` : ""}`
|
|
2116
|
+
);
|
|
2117
|
+
console.log(result._id);
|
|
2118
|
+
}
|
|
2119
|
+
);
|
|
762
2120
|
spec.command("update <contextId>").description("Update a spec document's text content").option("-t, --text <text>", "New text content").option("-f, --file <path>", "Read text content from file").option("-n, --name <name>", "New name").option("--json", "Output as JSON").action(
|
|
763
2121
|
async (contextId, opts) => {
|
|
764
2122
|
const client = getClient();
|
|
765
2123
|
let text = opts.text;
|
|
766
2124
|
if (opts.file) {
|
|
767
|
-
text =
|
|
2125
|
+
text = readFileSync8(opts.file, "utf-8");
|
|
768
2126
|
}
|
|
769
2127
|
if (!(text || opts.name)) {
|
|
770
2128
|
console.error("Provide --text, --file, or --name to update.");
|
|
@@ -806,6 +2164,65 @@ ${contexts.length} spec(s)`);
|
|
|
806
2164
|
}
|
|
807
2165
|
console.log(contextId);
|
|
808
2166
|
});
|
|
2167
|
+
spec.command("review <contextId>").description("Manually trigger the PR Intent Review bot for a spec").requiredOption("--pr <prNumber>", "PR number to review against").option("--sha <headSha>", "Commit SHA the review runs against (defaults to current HEAD)").action(async (contextId, opts) => {
|
|
2168
|
+
const prNumber = Number.parseInt(opts.pr, 10);
|
|
2169
|
+
if (!Number.isFinite(prNumber)) {
|
|
2170
|
+
console.error("--pr must be an integer.");
|
|
2171
|
+
process.exit(1);
|
|
2172
|
+
}
|
|
2173
|
+
const headSha = opts.sha ?? getGitContext().sha;
|
|
2174
|
+
if (!headSha) {
|
|
2175
|
+
console.error("Could not determine head SHA \u2014 pass --sha or run inside a git checkout.");
|
|
2176
|
+
process.exit(1);
|
|
2177
|
+
}
|
|
2178
|
+
const client = getClient();
|
|
2179
|
+
await client.post(`/api/cli/contexts/${contextId}/review`, {
|
|
2180
|
+
prNumber,
|
|
2181
|
+
headSha
|
|
2182
|
+
});
|
|
2183
|
+
console.log(
|
|
2184
|
+
`Scheduled review: ${contextId} against PR #${String(prNumber)} @ ${headSha.slice(0, 7)}`
|
|
2185
|
+
);
|
|
2186
|
+
});
|
|
2187
|
+
spec.command("drift <contextId>").description("Dispatch the Claude Code drift-fix workflow against a PR").requiredOption("--pr <prNumber>", "PR number to dispatch the drift-fix workflow against").action(async (contextId, opts) => {
|
|
2188
|
+
const prNumber = Number.parseInt(opts.pr, 10);
|
|
2189
|
+
if (!Number.isFinite(prNumber)) {
|
|
2190
|
+
console.error("--pr must be an integer.");
|
|
2191
|
+
process.exit(1);
|
|
2192
|
+
}
|
|
2193
|
+
const client = getClient();
|
|
2194
|
+
const result = await client.post(`/api/cli/contexts/${contextId}/drift`, {
|
|
2195
|
+
prNumber
|
|
2196
|
+
});
|
|
2197
|
+
if (result.dispatched) {
|
|
2198
|
+
const ref = result.runUrl ? `: ${result.runUrl}` : "";
|
|
2199
|
+
console.log(`Dispatched drift-fix workflow${ref}`);
|
|
2200
|
+
} else {
|
|
2201
|
+
console.error(
|
|
2202
|
+
"Drift-fix dispatch failed. Likely causes: actions:write App scope not granted, primitive-drift-fix.yml workflow file missing, or no findings on the latest review."
|
|
2203
|
+
);
|
|
2204
|
+
process.exit(1);
|
|
2205
|
+
}
|
|
2206
|
+
});
|
|
2207
|
+
spec.command("status <taskId>").description(
|
|
2208
|
+
"Show task status, auto-complete suppression flag, and the most-recent bot auto-completion"
|
|
2209
|
+
).action(async (taskId) => {
|
|
2210
|
+
const client = getClient();
|
|
2211
|
+
const result = await client.get(`/api/cli/tasks/${taskId}/status`);
|
|
2212
|
+
console.log(`status: ${result.status}`);
|
|
2213
|
+
console.log(`auto-complete suppressed: ${result.autoCompleteSuppressed ? "yes" : "no"}`);
|
|
2214
|
+
const last = result.lastAutoCompleteActivity;
|
|
2215
|
+
if (last) {
|
|
2216
|
+
const when = last.createdAt ? new Date(last.createdAt).toISOString() : "\u2014";
|
|
2217
|
+
const pr = last.prNumber ? `#${String(last.prNumber)}` : "\u2014";
|
|
2218
|
+
console.log(`last auto-complete: ${when} (PR ${pr})`);
|
|
2219
|
+
if (last.explanation) {
|
|
2220
|
+
console.log(` ${last.explanation}`);
|
|
2221
|
+
}
|
|
2222
|
+
} else {
|
|
2223
|
+
console.log("last auto-complete: \u2014");
|
|
2224
|
+
}
|
|
2225
|
+
});
|
|
809
2226
|
spec.command("map <contextId>").description("Map file patterns to a spec (used by pre-commit hook to detect affected specs)").requiredOption(
|
|
810
2227
|
"-p, --pattern <patterns...>",
|
|
811
2228
|
'Glob pattern(s) to associate, e.g. "src/auth/**"'
|
|
@@ -875,9 +2292,64 @@ ${preview}`);
|
|
|
875
2292
|
}
|
|
876
2293
|
}
|
|
877
2294
|
|
|
2295
|
+
// src/commands/statusline.ts
|
|
2296
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
2297
|
+
import { dirname as dirname4, resolve as resolve3 } from "path";
|
|
2298
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2299
|
+
var STATUSLINE_TIMEOUT_MS = 200;
|
|
2300
|
+
function readPackageVersion() {
|
|
2301
|
+
try {
|
|
2302
|
+
const here = dirname4(fileURLToPath2(import.meta.url));
|
|
2303
|
+
const candidates = [resolve3(here, "../../package.json"), resolve3(here, "../package.json")];
|
|
2304
|
+
for (const path of candidates) {
|
|
2305
|
+
try {
|
|
2306
|
+
const pkg2 = JSON.parse(readFileSync9(path, "utf-8"));
|
|
2307
|
+
if (pkg2.version) {
|
|
2308
|
+
return pkg2.version;
|
|
2309
|
+
}
|
|
2310
|
+
} catch {
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
} catch {
|
|
2314
|
+
}
|
|
2315
|
+
return "0.0.0";
|
|
2316
|
+
}
|
|
2317
|
+
function debug(msg) {
|
|
2318
|
+
if (process.env.PRIM_STATUSLINE_DEBUG === "1") {
|
|
2319
|
+
process.stderr.write(`[prim-statusline] ${msg}
|
|
2320
|
+
`);
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
async function renderStatusline() {
|
|
2324
|
+
const version = readPackageVersion();
|
|
2325
|
+
const snapshot = await daemonRequest(
|
|
2326
|
+
"status_snapshot",
|
|
2327
|
+
{},
|
|
2328
|
+
{ timeoutMs: STATUSLINE_TIMEOUT_MS }
|
|
2329
|
+
);
|
|
2330
|
+
if (!snapshot) {
|
|
2331
|
+
debug("daemon snapshot missing");
|
|
2332
|
+
return `primitive ${version} (daemon: down)`;
|
|
2333
|
+
}
|
|
2334
|
+
if (snapshot.presenceStale) {
|
|
2335
|
+
return `primitive ${version} (daemon: live \xB7 presence: stale)`;
|
|
2336
|
+
}
|
|
2337
|
+
const team = typeof snapshot.onlineCount === "number" ? `team: ${String(snapshot.onlineCount)} online` : "team: \u2014";
|
|
2338
|
+
return `primitive ${version} (daemon: live \xB7 ${team})`;
|
|
2339
|
+
}
|
|
2340
|
+
function registerStatuslineCommands(program2) {
|
|
2341
|
+
program2.command("statusline").description("Render the Claude Code statusLine for the prim companion daemon").action(async () => {
|
|
2342
|
+
try {
|
|
2343
|
+
const line = await renderStatusline();
|
|
2344
|
+
process.stdout.write(line);
|
|
2345
|
+
} catch {
|
|
2346
|
+
}
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
|
|
878
2350
|
// src/index.ts
|
|
879
|
-
var __dirname2 =
|
|
880
|
-
var pkg = JSON.parse(
|
|
2351
|
+
var __dirname2 = dirname5(fileURLToPath3(import.meta.url));
|
|
2352
|
+
var pkg = JSON.parse(readFileSync10(resolve4(__dirname2, "../package.json"), "utf-8"));
|
|
881
2353
|
updateNotifier({ pkg }).notify();
|
|
882
2354
|
var program = new Command();
|
|
883
2355
|
program.name("prim").description("CLI for managing Primitive specs and contexts").version(pkg.version).option("-y, --yes", "auto-confirm prompts").option(
|
|
@@ -890,9 +2362,22 @@ registerSpecCommands(program);
|
|
|
890
2362
|
registerProjectCommands(program);
|
|
891
2363
|
registerHooksCommands(program);
|
|
892
2364
|
registerSkillCommands(program);
|
|
2365
|
+
registerMovesCommands(program);
|
|
2366
|
+
registerSessionCommands(program);
|
|
2367
|
+
registerDecisionsCommands(program);
|
|
2368
|
+
registerClaudeCommands(program);
|
|
2369
|
+
registerDaemonCommands(program);
|
|
2370
|
+
registerReconcileCommands(program);
|
|
2371
|
+
registerStatuslineCommands(program);
|
|
893
2372
|
process.on("unhandledRejection", (err) => {
|
|
894
2373
|
const msg = err instanceof Error ? err.message : String(err);
|
|
895
2374
|
console.error(msg);
|
|
896
2375
|
process.exit(1);
|
|
897
2376
|
});
|
|
2377
|
+
var argv = process.argv.slice(2);
|
|
2378
|
+
var isExplicitFlush = argv[0] === "moves" && argv[1] === "flush";
|
|
2379
|
+
if (!isExplicitFlush) {
|
|
2380
|
+
flushIfNeeded().catch(() => {
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
898
2383
|
program.parse();
|