@indigoai-us/hq-cli 5.345.28 → 5.345.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1252 @@
1
+ /**
2
+ * `hq core checkpoint` — synchronous end-of-turn state capture plus an
3
+ * optional detached HQ-maintenance sibling.
4
+ *
5
+ * The foreground command intentionally performs only local filesystem and git
6
+ * reads/writes. It never waits for the sibling, and it never performs network
7
+ * I/O itself.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { randomUUID } from "node:crypto";
12
+ import { execFileSync, spawn } from "node:child_process";
13
+ import { homedir } from "node:os";
14
+ import { toHqStateWriteError } from "../utils/hq-state-write-error.js";
15
+ import { checkpointStampWriteErrorFor, isCheckpointStampWriteError, } from "../utils/checkpoint-stamp-write-error.js";
16
+ import { describeCoreCommand } from "./core-command-catalog.js";
17
+ const DEFAULT_TRIGGER = "stop-gate";
18
+ const BACKENDS = new Set(["auto", "claude", "codex", "grok", "none"]);
19
+ // Pinned by operator directive 2026-09-15; change defaults here deliberately.
20
+ const CODEX_SIBLING_MODEL = "gpt-5.6-terra";
21
+ const CODEX_SIBLING_REASONING_EFFORT = "medium";
22
+ const CLAUDE_SIBLING_MODEL = "claude-sonnet-5";
23
+ const CLAUDE_SIBLING_EFFORT = "medium";
24
+ const GROK_SIBLING_MODEL = "grok-4.6";
25
+ const GROK_SIBLING_EFFORT = "medium";
26
+ /**
27
+ * `grok -p` is single-turn by default: without a turn budget it answers with a
28
+ * plan and exits, having done nothing. The maintenance flow needs many turns.
29
+ */
30
+ const GROK_SIBLING_MAX_TURNS = "100";
31
+ /**
32
+ * Fallback priority for `--backend auto` once the caller's own backend is
33
+ * unavailable, unresponsive, out of credits (surfaces as a crashed run), or
34
+ * simply unknown. First healthy candidate wins.
35
+ */
36
+ /**
37
+ * Durable opt-out for the maintenance sibling, read from the HQ root's
38
+ * `.claude/settings.json` (overlaid by `settings.local.json`, the same order
39
+ * Claude Code merges them):
40
+ *
41
+ * { "env": { "HQ_CHECKPOINT_AGENT": "0" } }
42
+ *
43
+ * `--no-agent` and `--backend none` already turn the sibling off, but only for
44
+ * one invocation — no help when the checkpoint is fired by the Stop gate,
45
+ * where the user has no command line to edit. The sibling is the expensive
46
+ * half of a checkpoint (an unattended agent turn per Stop), so the off switch
47
+ * has to be somewhere a user can set it once. The `env` block is that place:
48
+ * Claude Code already exports it into hook processes, and reading the file
49
+ * directly means the setting also holds for the Codex and grok gates, whose
50
+ * hosts never read it.
51
+ */
52
+ const SIBLING_SETTING = "HQ_CHECKPOINT_AGENT";
53
+ /**
54
+ * The Stop gate's own switch. Resolved through the same settings file as the
55
+ * sibling switch so it reaches Codex and grok, whose hosts never export the
56
+ * `.claude/settings.json` `env` block into a hook process.
57
+ */
58
+ const GATE_SETTING = "HQ_CHECKPOINT_GATE";
59
+ const SIBLING_SETTING_OFF = new Set(["0", "false", "off", "no"]);
60
+ const SIBLING_SETTING_ON = new Set(["1", "true", "on", "yes"]);
61
+ const FALLBACK_BACKEND_ORDER = ["claude", "codex", "grok"];
62
+ /** A backend that cannot answer `--version` this fast is treated as broken. */
63
+ const BACKEND_PROBE_TIMEOUT_MS = 10_000;
64
+ /** A sibling still holding the lock after this long is treated as abandoned. */
65
+ const SIBLING_LOCK_TTL_MS = 60 * 60 * 1000;
66
+ /** Newest-wins cap so a wedged sibling cannot grow an unbounded queue. */
67
+ const MAX_PENDING_PAYLOADS = 50;
68
+ class CheckpointUsageError extends Error {
69
+ }
70
+ /**
71
+ * Printed after every non-dry-run checkpoint, real or --idle. Agents were
72
+ * treating the checkpoint call as the end of the turn, leaving their actual
73
+ * findings only in the checkpoint payload — which the user never sees. This
74
+ * line rides the command output (the one channel guaranteed to reach the
75
+ * calling agent) to force the user-facing reply. Deliberately conditional on
76
+ * whether the reply was already delivered this turn: an unconditional
77
+ * "restate in full" made agents repeat a reply the user had already seen
78
+ * (both the CLI and the desktop app render pre-tool-call text), while an
79
+ * unconditional "no commentary after" hid the reply from agents that had not
80
+ * yet written one. The Stop gate makes the same distinction from the
81
+ * transcript — and backstops this reminder mechanically: a satisfying
82
+ * checkpoint on a work turn whose genuine reply is still missing is blocked
83
+ * once with a dedicated reply demand. This line covers proactive checkpoints
84
+ * the gate never sees. Mirrors the hq-core policies
85
+ * `checkpoint-is-bookkeeping-not-user-communication` and
86
+ * `checkpoint-is-not-the-user-report`.
87
+ */
88
+ export const CHECKPOINT_REPLY_REMINDER = "checkpoint: REMINDER — this checkpoint is invisible bookkeeping; the user never sees it " +
89
+ "and it does NOT count as your reply. If this turn's substance (results, findings, " +
90
+ "decisions, state changes, anything awaiting their input) has NOT yet been delivered to " +
91
+ "the user, write your user-facing reply now as the FINAL text of the turn. If you " +
92
+ "already delivered it earlier this turn, end the turn now WITHOUT repeating it — the " +
93
+ "user has already seen that message, and repeating it double-messages them.";
94
+ /**
95
+ * The --idle variant: an idle-only turn (nothing ran, nothing to report) is
96
+ * exempt at the Stop gate, so this text must never order a reply into
97
+ * existence — but an --idle after read-only work is NOT exempt (the gate
98
+ * demands the findings), so the conditional half still points there.
99
+ */
100
+ export const CHECKPOINT_IDLE_REPLY_REMINDER = "checkpoint: idle recorded — invisible bookkeeping; the user sees none of it. If this " +
101
+ "turn surfaced findings or answers the user has not been told, deliver them now as the " +
102
+ "turn's final text. If the turn genuinely produced nothing to report, end it.";
103
+ function printResult(line) {
104
+ process.stdout.write(`${line}\n`);
105
+ }
106
+ function printError(line) {
107
+ process.stderr.write(`${line}\n`);
108
+ }
109
+ /**
110
+ * Kept in TypeScript rather than in a bundled asset: it is an instruction to
111
+ * a locally-installed agent, not a scaffold script that should be packaged.
112
+ */
113
+ const SIBLING_PROMPT_TEMPLATE = `You are the HQ checkpoint sibling — a background maintenance agent for this
114
+ HQ install. Your parent session's state is in <payloadPath>. Work
115
+ quietly and do not ask questions; if something is ambiguous, record it in the
116
+ report instead of guessing.
117
+
118
+ 1. Read the payload. If it lists a transcript path that exists, read its tail (~400 lines)
119
+ both for session context and to extract additional reusable learnings/insights
120
+ the parent did not pass explicitly. Never quote secrets
121
+ or tokens from the transcript. If .claude/skills/checkpoint/SKILL.md exists
122
+ under this HQ root, read it and follow it wherever it goes beyond these instructions;
123
+ the write bounds below always win over the skill text.
124
+ 2. Upgrade the thread file named in the payload IN PLACE: verify/repair its
125
+ JSON; fill git.remote_url, git.initial_commit, git.commits_made, and
126
+ git.knowledge_repos by scanning core/knowledge/public/*,
127
+ core/knowledge/private/*, personal/knowledge/*, and companies/*/knowledge
128
+ for symlinks or directories containing .git, recording dirty repositories
129
+ as {"<name>": {"commit": "<short>", "dirty": true}}. Fill worker,
130
+ next_steps, and insights; set type to "checkpoint"; then rename the file to
131
+ drop -auto- from its filename. Use the renamed path in every reference you
132
+ write afterwards.
133
+ 3. For every explicit or transcript-derived learning that is a reusable rule,
134
+ FIRST search the existing policies for one the learning refines, contradicts
135
+ or duplicates. Then take exactly one of these actions and name it in the
136
+ report — the policy set is curated, not append-only:
137
+ - AMEND an existing policy in place when the learning sharpens it, narrows
138
+ its scope, or adds a case, and the rule as written is still correct.
139
+ - SUPERSEDE it when the learning contradicts it: rewrite the rule to what is
140
+ now true and record inside the file what changed and why.
141
+ - MERGE near-duplicates into the single best-named file, then delete the
142
+ files you merged away.
143
+ - CREATE a new policy only when no existing policy covers the rule.
144
+ Follow core/knowledge/public/hq-core/policies-spec.md. Write under
145
+ personal/policies/ or, only when the payload names a company and the rule is
146
+ company-specific, companies/<company>/policies/.
147
+ DELETION BOUNDS: delete a policy only as the MERGE or SUPERSEDE step above,
148
+ only inside those two directories, and never one whose body marks it HARD —
149
+ if a hard policy now looks wrong, leave it untouched and flag it in the
150
+ report for a human to decide. Never delete a file you have not read.
151
+ Apply the same curation to durable facts (not rules) under
152
+ personal/knowledge/ or companies/<company>/knowledge/: correct a stale fact
153
+ in place rather than appending a second, contradictory copy of it.
154
+ Store up to two explicit or transcript-derived insights per
155
+ core/knowledge/public/hq-core/insights-spec.md when present, otherwise
156
+ workspace/insights/.
157
+ 4. Close an active session journal fail-soft with
158
+ bash .claude/skills/_shared/journal.sh close "<project_dir>" "<one-line synthesis>".
159
+ Write a legacy checkpoint JSON under workspace/checkpoints/<id>.json with
160
+ id, created_at, summary, files, and next_steps for backward compatibility.
161
+ 5. Update workspace/threads/recent.md and regenerate
162
+ workspace/threads/INDEX.md. For each company whose knowledge path appears
163
+ in files_touched, regenerate companies/<company>/knowledge/INDEX.md under
164
+ core/knowledge/public/hq-core/index-md-spec.md. Mechanical index generation
165
+ is allowed for those companies, but knowledge/policy content writes remain
166
+ restricted to the payload's named company.
167
+ <ontologyStep>6. Run .claude/skills/document-release/SKILL.md best-effort when it exists;
168
+ skip silently on any failure. Hook or automation improvements go ONLY under
169
+ personal/hooks/ as proposals.
170
+ 7. WRITE BOUNDS: you may write only under personal/, workspace/, and companies/<company>/ as constrained above. You must NEVER write into .claude/, core/, .agents/, .codex/, repos/, or anywhere outside the HQ root.
171
+ 8. Write <runDir>/report.md — full prose: what you read, what you changed
172
+ (paths), and what you skipped and why. List every policy or knowledge file
173
+ you amended, superseded, merged or deleted with the reason, so a human can
174
+ audit and reverse it; a deletion you do not name in the report is a defect.
175
+ Then drain the queue: while
176
+ workspace/checkpoints/sibling/pending.jsonl exists and is non-empty, claim
177
+ it atomically by renaming it aside —
178
+ mv workspace/checkpoints/sibling/pending.jsonl <runDir>/pending-claimed-N.jsonl
179
+ (N counting up from 2) — and process the claimed payloads with this same
180
+ flow. Repeat until a claim finds nothing left, then update the report.
181
+ NEVER read the queue and truncate it in place: a payload appended between
182
+ your read and the truncate is lost, and its checkpoint is never enriched.
183
+ `;
184
+ /**
185
+ * Rendered into the sibling prompt only when the payload names a company. The
186
+ * flag check happens inside `hq core ontology plan`, in the sibling, because
187
+ * the checkpoint parent performs no network I/O. Only this run's own payload
188
+ * gets the step. A queued payload's company files are recorded as queued
189
+ * touches when it is queued (CO-59), and a later plan for that company takes
190
+ * them while checkpoint.ontology.queued-touches is on; a run with no company
191
+ * of its own gets the step for the company whose touches waited longest.
192
+ */
193
+ const SIBLING_ONTOLOGY_STEP = `5b. ONTOLOGY (this run's own payload only; skip it for claimed queue payloads).
194
+ Run: hq core ontology plan --run-dir "<runDir>" --hq-root "<hqRoot>"
195
+ If it prints "ontology: skipped", do nothing more for this step. Otherwise
196
+ read <runDir>/ontology-plan.json, follow its "instructions" exactly, and
197
+ write <runDir>/ontology-candidates.json. Read only the files and line
198
+ ranges the plan lists. Then run:
199
+ hq core ontology apply --run-dir "<runDir>" --hq-root "<hqRoot>"
200
+ Never write under the company's ontology/ folder yourself, and never edit
201
+ payload.json or ontology-plan.json. Copy the command's one-line output into
202
+ the report. If either command fails, record the error and continue.
203
+ `;
204
+ export function renderSiblingPrompt(runDir, payloadPath, options = {}) {
205
+ const ontologyStep = options.company && options.hqRoot ? SIBLING_ONTOLOGY_STEP : "";
206
+ return SIBLING_PROMPT_TEMPLATE
207
+ .replaceAll("<ontologyStep>", () => ontologyStep)
208
+ .replaceAll("<runDir>", () => runDir)
209
+ .replaceAll("<payloadPath>", () => payloadPath)
210
+ .replaceAll("<hqRoot>", () => options.hqRoot ?? "");
211
+ }
212
+ function collect(value, previous) {
213
+ return [...previous, value];
214
+ }
215
+ function usage(message) {
216
+ throw new CheckpointUsageError(message);
217
+ }
218
+ function readPayload(payloadPath) {
219
+ if (!payloadPath)
220
+ return {};
221
+ let raw;
222
+ try {
223
+ raw = payloadPath === "-"
224
+ ? fs.readFileSync(0, "utf8")
225
+ : fs.readFileSync(payloadPath, "utf8");
226
+ }
227
+ catch {
228
+ usage(`checkpoint: could not read payload: ${payloadPath}`);
229
+ }
230
+ try {
231
+ const parsed = JSON.parse(raw);
232
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
233
+ usage("checkpoint: payload must be a JSON object");
234
+ }
235
+ return parsed;
236
+ }
237
+ catch (error) {
238
+ if (error instanceof CheckpointUsageError)
239
+ throw error;
240
+ usage("checkpoint: payload is not valid JSON");
241
+ }
242
+ }
243
+ function asOptionalString(value, field) {
244
+ if (value === undefined || value === null)
245
+ return undefined;
246
+ if (typeof value !== "string")
247
+ usage(`checkpoint: payload ${field} must be a string`);
248
+ return value;
249
+ }
250
+ function asStringList(value, field) {
251
+ if (value === undefined || value === null)
252
+ return [];
253
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
254
+ usage(`checkpoint: payload ${field} must be an array of strings`);
255
+ }
256
+ return value;
257
+ }
258
+ function asWorker(value) {
259
+ if (value === undefined || value === null)
260
+ return {};
261
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
262
+ usage("checkpoint: payload worker must be an object");
263
+ }
264
+ const worker = value;
265
+ return {
266
+ id: asOptionalString(worker.id, "worker.id"),
267
+ skill: asOptionalString(worker.skill, "worker.skill"),
268
+ state: asOptionalString(worker.state, "worker.state"),
269
+ };
270
+ }
271
+ function resolveWorker(options, command, payload) {
272
+ const payloadWorker = asWorker(payload.worker);
273
+ const id = wasPassed(command, "worker") ? options.worker : payloadWorker.id;
274
+ const skill = wasPassed(command, "workerSkill") ? options.workerSkill : payloadWorker.skill;
275
+ const suppliedState = wasPassed(command, "workerState") ? options.workerState : payloadWorker.state;
276
+ const state = suppliedState ?? (id ? "completed" : null);
277
+ return { id: id ?? null, skill: skill ?? null, state };
278
+ }
279
+ function wasPassed(command, option) {
280
+ return command.getOptionValueSource(option) === "cli";
281
+ }
282
+ function inferredSessionId() {
283
+ // Deliberately do not fall back to the newest transcript under
284
+ // ~/.claude/projects/<munged-cwd>: concurrent sessions make its mtime
285
+ // unreliable and can mis-attribute a checkpoint. Env var or nothing.
286
+ return process.env.CLAUDE_CODE_SESSION_ID?.trim() || undefined;
287
+ }
288
+ function resolveSessionId(options, command, payload) {
289
+ if (wasPassed(command, "sessionId"))
290
+ return options.sessionId;
291
+ return asOptionalString(payload.session_id, "session_id") ?? inferredSessionId();
292
+ }
293
+ function mergeInput(options, command, payload) {
294
+ const summary = wasPassed(command, "summary")
295
+ ? options.summary
296
+ : asOptionalString(payload.summary, "summary");
297
+ const files = wasPassed(command, "file")
298
+ ? options.file
299
+ : asStringList(payload.files, "files");
300
+ const learnings = wasPassed(command, "learning")
301
+ ? options.learning
302
+ : asStringList(payload.learnings, "learnings");
303
+ const insights = wasPassed(command, "insight")
304
+ ? options.insight
305
+ : asStringList(payload.insights, "insights");
306
+ const decisions = wasPassed(command, "decision")
307
+ ? options.decision
308
+ : asStringList(payload.decisions, "decisions");
309
+ const nextSteps = wasPassed(command, "next")
310
+ ? options.next
311
+ : asStringList(payload.next_steps, "next_steps");
312
+ const tags = wasPassed(command, "tag")
313
+ ? options.tag
314
+ : asStringList(payload.tags, "tags");
315
+ const commits = wasPassed(command, "commit")
316
+ ? options.commit
317
+ : asStringList(payload.commits, "commits");
318
+ const initialCommit = wasPassed(command, "initialCommit")
319
+ ? options.initialCommit
320
+ : asOptionalString(payload.initial_commit, "initial_commit");
321
+ const trigger = (wasPassed(command, "trigger")
322
+ ? options.trigger
323
+ : asOptionalString(payload.trigger, "trigger")) ?? DEFAULT_TRIGGER;
324
+ const company = wasPassed(command, "company")
325
+ ? options.company
326
+ : asOptionalString(payload.company, "company");
327
+ const sessionId = resolveSessionId(options, command, payload);
328
+ const transcript = wasPassed(command, "transcript")
329
+ ? options.transcript
330
+ : asOptionalString(payload.transcript, "transcript");
331
+ return {
332
+ summary,
333
+ files,
334
+ learnings,
335
+ insights,
336
+ decisions,
337
+ nextSteps,
338
+ tags,
339
+ commits,
340
+ initialCommit,
341
+ worker: resolveWorker(options, command, payload),
342
+ trigger,
343
+ company,
344
+ sessionId,
345
+ transcript,
346
+ };
347
+ }
348
+ function formatTimestamp(date) {
349
+ const part = (value) => value.toString().padStart(2, "0");
350
+ return `${date.getUTCFullYear()}${part(date.getUTCMonth() + 1)}${part(date.getUTCDate())}-${part(date.getUTCHours())}${part(date.getUTCMinutes())}${part(date.getUTCSeconds())}`;
351
+ }
352
+ function summarySlug(summary) {
353
+ const words = summary.toLowerCase().match(/[a-z0-9]+/g)?.slice(0, 4) ?? [];
354
+ if (words.length === 0)
355
+ return "checkpoint-update";
356
+ if (words.length === 1)
357
+ words.push("update");
358
+ const slug = words.join("-").slice(0, 40).replace(/-+$/, "");
359
+ return slug || "checkpoint-update";
360
+ }
361
+ function titleFor(summary) {
362
+ const truncated = summary.length > 60 ? `${summary.slice(0, 57).trimEnd()}...` : summary;
363
+ return `Auto: ${truncated}`;
364
+ }
365
+ function readGitState(repoDir) {
366
+ try {
367
+ const read = (args) => execFileSync("git", ["-C", repoDir, ...args], {
368
+ encoding: "utf8",
369
+ stdio: ["ignore", "pipe", "ignore"],
370
+ }).trim();
371
+ if (read(["rev-parse", "--is-inside-work-tree"]) !== "true")
372
+ return null;
373
+ return {
374
+ branch: read(["rev-parse", "--abbrev-ref", "HEAD"]),
375
+ current_commit: read(["rev-parse", "--short", "HEAD"]),
376
+ dirty: read(["status", "--porcelain"]) !== "",
377
+ remote_url: (() => {
378
+ try {
379
+ return read(["remote", "get-url", "origin"]);
380
+ }
381
+ catch {
382
+ return null;
383
+ }
384
+ })(),
385
+ };
386
+ }
387
+ catch {
388
+ return null;
389
+ }
390
+ }
391
+ function commitsSince(repoDir, initialCommit) {
392
+ try {
393
+ const output = execFileSync("git", ["-C", repoDir, "log", "--no-decorate", "--pretty=format:%h: %s", `${initialCommit}..HEAD`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
394
+ return output ? output.split("\n") : [];
395
+ }
396
+ catch {
397
+ return [];
398
+ }
399
+ }
400
+ function gitStateFor(cwd, liveRoot, initialCommit, explicitCommits) {
401
+ const callerState = readGitState(cwd);
402
+ const state = callerState ??
403
+ readGitState(liveRoot) ?? {
404
+ branch: "unknown",
405
+ current_commit: "unknown",
406
+ dirty: false,
407
+ remote_url: null,
408
+ };
409
+ return {
410
+ ...state,
411
+ // Remote URLs and derived history describe the caller's repository only.
412
+ remote_url: callerState?.remote_url ?? null,
413
+ initial_commit: initialCommit ?? null,
414
+ commits_made: explicitCommits.length > 0
415
+ ? explicitCommits
416
+ : initialCommit && callerState
417
+ ? commitsSince(cwd, initialCommit)
418
+ : [],
419
+ };
420
+ }
421
+ /**
422
+ * Perform a write into HQ's OWN state tree, converting a filesystem errno into a
423
+ * typed, path-naming {@link HqStateWriteError} (the boundary prints that and does
424
+ * NOT capture it — the fault is the user's machine, not an hq bug) while leaving
425
+ * any NON-errno failure — a genuine defect — to propagate unchanged and stay
426
+ * reportable. Scoped by the caller to the checkpoint state paths this command
427
+ * derives. The checkpoint still FAILS (exits non-zero) on such an error; only
428
+ * the classification and the message change (HQ-CLI-19, Sentry 7702730566).
429
+ */
430
+ function writeHqState(targetPath, operation, perform) {
431
+ try {
432
+ perform();
433
+ }
434
+ catch (error) {
435
+ if (isCheckpointStampWriteError(error))
436
+ throw error;
437
+ const typed = toHqStateWriteError(error, targetPath, operation);
438
+ if (typed)
439
+ throw typed;
440
+ throw error;
441
+ }
442
+ }
443
+ const WINDOWS_CHECKPOINT_WRITE_RETRY_CODES = new Set(["EBUSY", "EPERM", "UNKNOWN"]);
444
+ const WINDOWS_CHECKPOINT_WRITE_RETRY_DELAYS_MS = [25, 50, 100];
445
+ function waitForCheckpointWriteRetry(delayMs) {
446
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT)), 0, 0, delayMs);
447
+ }
448
+ function removeCheckpointStampTemp(tempPath, fsImpl, waitForRetry) {
449
+ for (let attempt = 0;; attempt += 1) {
450
+ try {
451
+ fsImpl.unlinkSync(tempPath);
452
+ return;
453
+ }
454
+ catch (error) {
455
+ const code = error?.code;
456
+ if (code === "ENOENT")
457
+ return;
458
+ if (typeof code === "string" &&
459
+ WINDOWS_CHECKPOINT_WRITE_RETRY_CODES.has(code) &&
460
+ attempt < WINDOWS_CHECKPOINT_WRITE_RETRY_DELAYS_MS.length) {
461
+ waitForRetry(WINDOWS_CHECKPOINT_WRITE_RETRY_DELAYS_MS[attempt]);
462
+ continue;
463
+ }
464
+ throw checkpointStampWriteErrorFor(error, "unlink") ?? error;
465
+ }
466
+ }
467
+ }
468
+ function writeCheckpointStampAtomically(targetPath, contents, fsImpl, waitForRetry) {
469
+ for (let attempt = 0;; attempt += 1) {
470
+ const tempPath = path.join(path.dirname(targetPath), `.hq-checkpoint-stamp.tmp.${process.pid}.${randomUUID()}`);
471
+ let failedOperation = "open";
472
+ try {
473
+ fsImpl.writeFileSync(tempPath, contents, { flag: "wx" });
474
+ failedOperation = "rename";
475
+ fsImpl.renameSync(tempPath, targetPath);
476
+ return;
477
+ }
478
+ catch (error) {
479
+ removeCheckpointStampTemp(tempPath, fsImpl, waitForRetry);
480
+ const code = error?.code;
481
+ if (typeof code !== "string" ||
482
+ !WINDOWS_CHECKPOINT_WRITE_RETRY_CODES.has(code) ||
483
+ attempt >= WINDOWS_CHECKPOINT_WRITE_RETRY_DELAYS_MS.length) {
484
+ throw checkpointStampWriteErrorFor(error, failedOperation) ?? error;
485
+ }
486
+ waitForRetry(WINDOWS_CHECKPOINT_WRITE_RETRY_DELAYS_MS[attempt]);
487
+ }
488
+ }
489
+ }
490
+ export function writeStamps(liveRoot, sessionId, options = {}) {
491
+ const fsImpl = options.fs ?? fs;
492
+ const platform = options.platform ?? process.platform;
493
+ const waitForRetry = options.waitForRetry ?? waitForCheckpointWriteRetry;
494
+ const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
495
+ writeHqState(stateDir, "create the checkpoint state directory", () => fsImpl.mkdirSync(stateDir, { recursive: true }));
496
+ const sessionKey = sessionId
497
+ ? sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown"
498
+ : "unknown";
499
+ const timestamp = Math.floor(Date.now() / 1000).toString();
500
+ const stampPaths = [
501
+ path.join(stateDir, "checkpoint-cli-last"),
502
+ path.join(stateDir, `checkpoint-cli-last-${sessionKey}`),
503
+ ];
504
+ for (const stampPath of stampPaths) {
505
+ writeHqState(stampPath, "write its checkpoint state file", () => platform === "win32"
506
+ ? writeCheckpointStampAtomically(stampPath, timestamp, fsImpl, waitForRetry)
507
+ : fsImpl.writeFileSync(stampPath, timestamp));
508
+ }
509
+ return stampPaths;
510
+ }
511
+ /**
512
+ * Codex Stop-gate rollout scope: every HQ user on the operator domain, rather
513
+ * than the single account that dogfooded it first. Eligibility is decided on
514
+ * the email domain alone — a candidate must have exactly one `@` and the part
515
+ * after it must equal this domain, so lookalikes never pass (`xgetindigo.ai`,
516
+ * `getindigo.ai.evil.test`, `a@b@getindigo.ai`).
517
+ */
518
+ const CODEX_CHECKPOINT_DOMAIN = "getindigo.ai";
519
+ function hasCodexCheckpointDomain(candidate) {
520
+ if (typeof candidate !== "string")
521
+ return false;
522
+ const parts = candidate.trim().toLowerCase().split("@");
523
+ return parts.length === 2 && parts[0].length > 0 && parts[1] === CODEX_CHECKPOINT_DOMAIN;
524
+ }
525
+ function checkpointGateRuntime() {
526
+ const runtime = (process.env.HQ_CHECKPOINT_RUNTIME ?? "claude").trim().toLowerCase();
527
+ if (runtime === "claude" || runtime === "codex")
528
+ return runtime;
529
+ return "other";
530
+ }
531
+ async function gateEligibility(runtime, liveRoot) {
532
+ // An operator switch precedes runtime and identity eligibility, and is read
533
+ // from the settings file as well as the environment: under Codex and grok the
534
+ // environment is the one place the operator cannot reach.
535
+ const forced = resolveSwitchSetting(liveRoot, GATE_SETTING);
536
+ if (forced !== undefined)
537
+ return forced;
538
+ if (runtime === "claude")
539
+ return true;
540
+ if (runtime !== "codex")
541
+ return false;
542
+ try {
543
+ const tokenPath = path.join(homedir(), ".hq", "cognito-tokens.json");
544
+ const tokens = JSON.parse(fs.readFileSync(tokenPath, "utf8"));
545
+ if (typeof tokens.idToken !== "string")
546
+ return false;
547
+ const { peekIdToken } = await import("../utils/id-token.js");
548
+ const claims = peekIdToken(tokens.idToken);
549
+ const candidateEmails = [claims.email, claims["custom:delegatedEmail"]];
550
+ return candidateEmails.some(hasCodexCheckpointDomain);
551
+ }
552
+ catch {
553
+ return false;
554
+ }
555
+ }
556
+ async function writeGateVerdict(liveRoot) {
557
+ const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
558
+ fs.mkdirSync(stateDir, { recursive: true });
559
+ const runtime = checkpointGateRuntime();
560
+ const eligible = await gateEligibility(runtime, liveRoot);
561
+ fs.writeFileSync(path.join(stateDir, `checkpoint-gate-eligible-${runtime}`), eligible ? "1" : "0");
562
+ printResult(eligible ? "eligible" : "ineligible");
563
+ }
564
+ function backendOnPath(name) {
565
+ const pathValue = process.env.PATH;
566
+ if (!pathValue)
567
+ return false;
568
+ return pathValue.split(path.delimiter).some((directory) => {
569
+ if (!directory)
570
+ return false;
571
+ try {
572
+ fs.accessSync(path.join(directory, name), fs.constants.X_OK);
573
+ return true;
574
+ }
575
+ catch {
576
+ return false;
577
+ }
578
+ });
579
+ }
580
+ /**
581
+ * An installed binary is not necessarily a working one: a partial install, a
582
+ * broken wrapper or a missing runtime all leave an executable on PATH that
583
+ * fails the moment it is asked to do anything. Ask it for its version — cheap,
584
+ * bounded, and enough to reject a backend before handing it a checkpoint.
585
+ */
586
+ function backendResponds(name) {
587
+ try {
588
+ execFileSync(name, ["--version"], {
589
+ timeout: BACKEND_PROBE_TIMEOUT_MS,
590
+ stdio: "ignore",
591
+ });
592
+ return true;
593
+ }
594
+ catch {
595
+ return false;
596
+ }
597
+ }
598
+ function processAlive(pid) {
599
+ try {
600
+ process.kill(pid, 0);
601
+ return true;
602
+ }
603
+ catch {
604
+ return false;
605
+ }
606
+ }
607
+ /**
608
+ * Backends whose most recent run died without producing a report.
609
+ *
610
+ * A version probe cannot catch a backend that starts cleanly and then fails at
611
+ * runtime — an expired login, a sandbox it cannot write in, a model it is not
612
+ * entitled to. The evidence for that is a finished run directory with no
613
+ * report.md in it, so prefer a different backend for the next checkpoint. Only
614
+ * the newest run per backend counts: one that has since succeeded is healthy.
615
+ */
616
+ function crashedBackends(siblingRoot) {
617
+ const crashed = new Set();
618
+ let runDirs;
619
+ try {
620
+ runDirs = fs
621
+ .readdirSync(siblingRoot)
622
+ .filter((entry) => entry !== "pending.jsonl")
623
+ .sort()
624
+ .reverse();
625
+ }
626
+ catch {
627
+ return crashed;
628
+ }
629
+ const seen = new Set();
630
+ for (const runDir of runDirs) {
631
+ const absolute = path.join(siblingRoot, runDir);
632
+ let backend;
633
+ try {
634
+ const payload = JSON.parse(fs.readFileSync(path.join(absolute, "payload.json"), "utf8"));
635
+ backend = payload.backend;
636
+ }
637
+ catch {
638
+ continue;
639
+ }
640
+ if (typeof backend !== "string" || !backend || seen.has(backend))
641
+ continue;
642
+ seen.add(backend);
643
+ if (fs.existsSync(path.join(absolute, "report.md")))
644
+ continue;
645
+ // A missing report only means a crash once the process itself is gone.
646
+ let pid = Number.NaN;
647
+ try {
648
+ pid = Number.parseInt(fs.readFileSync(path.join(absolute, "sibling.pid"), "utf8").trim(), 10);
649
+ }
650
+ catch {
651
+ // A run that never recorded a PID cannot still be running.
652
+ }
653
+ if (Number.isSafeInteger(pid) && pid > 0 && processAlive(pid))
654
+ continue;
655
+ crashed.add(backend);
656
+ }
657
+ return crashed;
658
+ }
659
+ /**
660
+ * The backend of the agent that invoked this checkpoint, when known.
661
+ *
662
+ * Reuses the same runtime signal the Stop gate reads (`HQ_CHECKPOINT_RUNTIME`,
663
+ * set to `codex` by the codex hook adapter and defaulted to `claude` by the
664
+ * claude Stop hook). Grok runs no HQ hooks, so it never auto-invokes a
665
+ * checkpoint and has no reliable caller signal — an `other`/unknown runtime
666
+ * returns null and falls through to the fallback order.
667
+ */
668
+ function callerBackend() {
669
+ const runtime = checkpointGateRuntime();
670
+ return runtime === "claude" || runtime === "codex" ? runtime : null;
671
+ }
672
+ /**
673
+ * Preference order for `--backend auto`: the caller's own backend first, so a
674
+ * claude session keeps using claude and a codex session keeps using codex
675
+ * (same-engine continuity), then the fallback priority claude -> codex -> grok
676
+ * for whatever the caller is not. Deterministic and env-only, so it is unit
677
+ * tested directly. `preferGrok` (the company's grok-backend flag) puts grok
678
+ * ahead of that order and leaves the rest as the fallback.
679
+ */
680
+ export function autoBackendPreference(preferGrok = false) {
681
+ const caller = callerBackend();
682
+ const order = caller
683
+ ? [caller, ...FALLBACK_BACKEND_ORDER.filter((name) => name !== caller)]
684
+ : [...FALLBACK_BACKEND_ORDER];
685
+ return preferGrok ? ["grok", ...order.filter((name) => name !== "grok")] : order;
686
+ }
687
+ /**
688
+ * Whether the payload company's last ontology plan recorded
689
+ * checkpoint.ontology.grok-backend as on. Reads only the local state file the
690
+ * detached sibling writes, so a foreground checkpoint never waits on the flag
691
+ * registry; a flag change takes effect from the checkpoint after the next plan.
692
+ */
693
+ export async function companyPrefersGrok(liveRoot, companySlug) {
694
+ const [{ readBackendPreference }, { resolveOntologyCompany }] = await Promise.all([
695
+ import("../lib/checkpoint-ontology/plan.js"),
696
+ import("../lib/checkpoint-ontology/company.js"),
697
+ ]);
698
+ const resolved = resolveOntologyCompany(liveRoot, companySlug);
699
+ if (!resolved.ok)
700
+ return false;
701
+ try {
702
+ return readBackendPreference(liveRoot, resolved.company);
703
+ }
704
+ catch (error) {
705
+ // A damaged state file must not stop the checkpoint; the usual order applies.
706
+ printError(`checkpoint: ignoring unreadable backend preference for ${resolved.company.slug}: ${error.message}`);
707
+ return false;
708
+ }
709
+ }
710
+ function settingsEnvValue(liveRoot, key) {
711
+ // settings.local.json wins, matching Claude Code's own merge order.
712
+ for (const file of ["settings.local.json", "settings.json"]) {
713
+ const settingsPath = path.join(liveRoot, ".claude", file);
714
+ let contents;
715
+ try {
716
+ contents = fs.readFileSync(settingsPath, "utf8");
717
+ }
718
+ catch {
719
+ continue; // absent overlay or no .claude directory at all
720
+ }
721
+ let env;
722
+ try {
723
+ env = JSON.parse(contents)?.env;
724
+ }
725
+ catch {
726
+ printError(`checkpoint: ignoring ${settingsPath} (not valid JSON)`);
727
+ continue;
728
+ }
729
+ if (!env || typeof env !== "object" || !(key in env))
730
+ continue;
731
+ const raw = env[key];
732
+ if (typeof raw === "string") {
733
+ const trimmed = raw.trim();
734
+ // An empty string is not a deferral to the next file: the key is here and
735
+ // says nothing, which is a mistake worth naming.
736
+ return trimmed
737
+ ? { kind: "value", value: trimmed, source: settingsPath }
738
+ : { kind: "invalid", source: settingsPath, raw };
739
+ }
740
+ // Claude Code's env block takes strings, but JSON's own `false`/`0` is what
741
+ // a user reaches for first. Honour it rather than ignoring the intent.
742
+ if (typeof raw === "boolean" || typeof raw === "number") {
743
+ return { kind: "value", value: String(raw), source: settingsPath };
744
+ }
745
+ return { kind: "invalid", source: settingsPath, raw };
746
+ }
747
+ return { kind: "absent" };
748
+ }
749
+ /** Maps one resolved value onto the switch, reporting a vocabulary it cannot read. */
750
+ function parseSwitchSetting(key, raw, source) {
751
+ const value = raw.toLowerCase();
752
+ if (SIBLING_SETTING_OFF.has(value))
753
+ return false;
754
+ if (SIBLING_SETTING_ON.has(value))
755
+ return true;
756
+ printError(`checkpoint: ignoring ${key}=${raw} from ${source} ` +
757
+ "(expected 0/1, false/true, off/on or no/yes)");
758
+ return undefined;
759
+ }
760
+ /**
761
+ * One operator switch: the process environment first, then the HQ root's
762
+ * Claude settings. `undefined` means nobody set it — distinct from a setting
763
+ * that says "off", and distinct from one this cannot read (reported, then
764
+ * treated as unset so a typo never silently flips a default).
765
+ */
766
+ function resolveSwitchSetting(liveRoot, key) {
767
+ const fromEnv = process.env[key]?.trim();
768
+ if (fromEnv)
769
+ return parseSwitchSetting(key, fromEnv, "the environment");
770
+ const lookup = settingsEnvValue(liveRoot, key);
771
+ if (lookup.kind === "absent")
772
+ return undefined;
773
+ if (lookup.kind === "invalid") {
774
+ printError(`checkpoint: ignoring ${key} in ${lookup.source}: ` +
775
+ `${JSON.stringify(lookup.raw) ?? String(lookup.raw)} is not a value it can read ` +
776
+ '(expected a string such as "0" or "1")');
777
+ return undefined;
778
+ }
779
+ return parseSwitchSetting(key, lookup.value, lookup.source);
780
+ }
781
+ /**
782
+ * Whether the maintenance sibling may run at all. The process environment wins
783
+ * over the settings file, so a one-off `HQ_CHECKPOINT_AGENT=0 hq core
784
+ * checkpoint …` still works and a host that exports the settings `env` block
785
+ * (Claude Code does) agrees with a host that does not.
786
+ */
787
+ function siblingEnabledBySetting(liveRoot) {
788
+ return resolveSwitchSetting(liveRoot, SIBLING_SETTING) ?? true;
789
+ }
790
+ /** Rejects an unknown `--backend` without paying for the probe that resolves one. */
791
+ function assertKnownBackend(requested) {
792
+ const value = requested ?? "auto";
793
+ if (!BACKENDS.has(value))
794
+ usage(`checkpoint: unknown backend: ${value}`);
795
+ return value;
796
+ }
797
+ async function resolveBackend(requested, liveRoot, company) {
798
+ const value = assertKnownBackend(requested);
799
+ // An explicitly named backend is honoured as given; only `auto` shops around.
800
+ if (value !== "auto")
801
+ return value;
802
+ const available = autoBackendPreference(await companyPrefersGrok(liveRoot, company)).filter((name) => backendOnPath(name) && backendResponds(name));
803
+ if (available.length === 0)
804
+ return "none";
805
+ const crashed = crashedBackends(path.join(liveRoot, "workspace", "checkpoints", "sibling"));
806
+ // If every candidate failed last time, still try the preferred one: a stale
807
+ // failure is a weaker signal than running no maintenance sibling at all.
808
+ return available.find((name) => !crashed.has(name)) ?? available[0];
809
+ }
810
+ /**
811
+ * Lane identity a checkpoint caller may carry. The sibling is HQ maintenance,
812
+ * not the lane: with HQ_LANE_ID set, the lane drop-box hook hands the lane's
813
+ * operator messages to the sibling, and the lane never sees them.
814
+ */
815
+ const LANE_ENV_KEYS = ["HQ_LANE_ID", "HQ_PROJECT_ID", "HQ_STORY_ID", "HQ_POLICY_WORKER_DIR"];
816
+ /** The sibling's environment: the caller's, minus lane identity. */
817
+ function siblingEnv(base, backend) {
818
+ const vars = { ...base };
819
+ for (const name of LANE_ENV_KEYS)
820
+ delete vars[name];
821
+ // This prevents the scaffold Stop hook from recursively launching a
822
+ // sibling when the background agent itself finishes a turn.
823
+ vars.HQ_CHECKPOINT_SIBLING = "1";
824
+ if (backend === "claude")
825
+ vars.CLAUDE_EFFORT = CLAUDE_SIBLING_EFFORT;
826
+ return vars;
827
+ }
828
+ export function siblingArgs(backend, prompt) {
829
+ switch (backend) {
830
+ case "claude":
831
+ return [
832
+ "-p",
833
+ prompt,
834
+ "--model",
835
+ CLAUDE_SIBLING_MODEL,
836
+ "--effort",
837
+ CLAUDE_SIBLING_EFFORT,
838
+ // `acceptEdits` auto-approves edits only. At the HQ root the scaffold
839
+ // settings make Bash "ask", so a terminal command — the git scan, the
840
+ // journal helper, the queue-draining `mv` — hits an approval gate with
841
+ // no human attached; headless `claude -p` then denies it and the run
842
+ // stalls having written no report. `bypassPermissions` is the same
843
+ // unattended posture codex gets from `-s danger-full-access` and grok from
844
+ // `--always-approve --sandbox workspace`. Hooks still fire natively —
845
+ // this only removes the interactive approval gate, not the hook chain.
846
+ "--permission-mode",
847
+ "bypassPermissions",
848
+ ];
849
+ case "grok":
850
+ return [
851
+ "-p",
852
+ prompt,
853
+ "-m",
854
+ GROK_SIBLING_MODEL,
855
+ "--reasoning-effort",
856
+ GROK_SIBLING_EFFORT,
857
+ // `acceptEdits` auto-approves edits only. A terminal command — the git
858
+ // scan, the journal helper — then hits an approval gate with no human
859
+ // attached, and grok cancels the whole run: `stopReason: cancelled,
860
+ // cancellationCategory: PermissionCancelled`. Unattended approval plus
861
+ // a sandbox is the same posture codex gets from `-s danger-full-access`.
862
+ "--always-approve",
863
+ "--sandbox",
864
+ "workspace",
865
+ "--max-turns",
866
+ GROK_SIBLING_MAX_TURNS,
867
+ ];
868
+ case "codex":
869
+ return [
870
+ "exec",
871
+ "--skip-git-repo-check",
872
+ // danger-full-access, NOT workspace-write. `-s workspace-write` force-
873
+ // enables Codex's restrictive sandbox, which denies the temp/cache/socket
874
+ // writes the maintenance sibling needs on macOS (git/Xcode cache, zsh/asdf
875
+ // heredocs) and cannot even initialize bubblewrap on some Linux hosts
876
+ // (`bwrap: loopback: Failed RTM_NEWADDR`) — HQ harness finding 2.3, which
877
+ // wedged the sibling before it wrote any report. HQ's safety boundary is
878
+ // its hooks, which still fire here via --dangerously-bypass-hook-trust;
879
+ // the sandbox is not what guards the HQ root.
880
+ "-s",
881
+ "danger-full-access",
882
+ "--dangerously-bypass-hook-trust",
883
+ "-m",
884
+ CODEX_SIBLING_MODEL,
885
+ "-c",
886
+ `model_reasoning_effort=${CODEX_SIBLING_REASONING_EFFORT}`,
887
+ prompt,
888
+ ];
889
+ }
890
+ }
891
+ function siblingPayload(input, threadPath, backend) {
892
+ return {
893
+ backend,
894
+ summary: input.summary ?? "",
895
+ files: input.files,
896
+ learnings: input.learnings,
897
+ insights: input.insights,
898
+ decisions: input.decisions,
899
+ next_steps: input.nextSteps,
900
+ tags: input.tags,
901
+ trigger: input.trigger,
902
+ company: input.company ?? null,
903
+ session_id: input.sessionId ?? null,
904
+ transcript: input.transcript ?? null,
905
+ worker: input.worker,
906
+ thread_path: threadPath,
907
+ pending_payloads: [],
908
+ };
909
+ }
910
+ /**
911
+ * PID of a sibling that is genuinely still working, or null when the lock is
912
+ * stale and this process should take it over.
913
+ *
914
+ * A lock is stale when the PID is gone, when it cannot be signalled, or when
915
+ * the run has outlived {@link SIBLING_LOCK_TTL_MS}. Siblings are spawned as
916
+ * this user, so an EPERM means the PID was recycled by somebody else's
917
+ * process — not that our sibling is alive. Treating an unsignalable PID as
918
+ * alive wedges the queue permanently: on 2026-08-03 a lock landed on a
919
+ * root-owned kernel thread and every checkpoint for the next two days queued
920
+ * behind a sibling that had never existed.
921
+ */
922
+ function existingSiblingPid(lockPath, now) {
923
+ let raw;
924
+ let writtenAtMs;
925
+ try {
926
+ raw = fs.readFileSync(lockPath, "utf8").trim();
927
+ writtenAtMs = fs.statSync(lockPath).mtimeMs;
928
+ }
929
+ catch {
930
+ return null;
931
+ }
932
+ const pid = Number.parseInt(raw, 10);
933
+ if (!Number.isSafeInteger(pid) || pid <= 0)
934
+ return null;
935
+ if (now - writtenAtMs >= SIBLING_LOCK_TTL_MS)
936
+ return null;
937
+ try {
938
+ process.kill(pid, 0);
939
+ return pid;
940
+ }
941
+ catch {
942
+ return null;
943
+ }
944
+ }
945
+ /**
946
+ * Queue a payload for the next sibling, keeping only the newest entries. An
947
+ * unbounded queue hides a wedged sibling instead of surfacing it. Returns the
948
+ * resulting queue depth so the caller can report it.
949
+ */
950
+ function appendPending(pendingPath, payload) {
951
+ fs.appendFileSync(pendingPath, `${JSON.stringify(payload)}\n`);
952
+ const lines = fs
953
+ .readFileSync(pendingPath, "utf8")
954
+ .split("\n")
955
+ .filter((line) => line.trim());
956
+ if (lines.length <= MAX_PENDING_PAYLOADS)
957
+ return lines.length;
958
+ // Best-effort trim: this rewrite can race a concurrent append, but it only
959
+ // runs once the queue is already over cap, where newest-wins is the point.
960
+ const kept = lines.slice(-MAX_PENDING_PAYLOADS);
961
+ fs.writeFileSync(pendingPath, `${kept.join("\n")}\n`);
962
+ return kept.length;
963
+ }
964
+ /**
965
+ * Take ownership of the queued payloads by renaming the queue aside.
966
+ *
967
+ * The queue must never be claimed by read-then-truncate: a payload appended
968
+ * between the read and the truncate is discarded unread, and the checkpoint it
969
+ * describes is never enriched. Renaming is atomic, so this run receives exactly
970
+ * the file that existed at the moment of the claim while concurrent callers go
971
+ * on appending to a fresh queue. The claimed file is left in the run directory
972
+ * as an audit trail and as a recovery point if this sibling dies.
973
+ */
974
+ function claimPending(pendingPath, claimPath) {
975
+ try {
976
+ fs.renameSync(pendingPath, claimPath);
977
+ }
978
+ catch (error) {
979
+ if (error?.code === "ENOENT")
980
+ return [];
981
+ throw error;
982
+ }
983
+ const entries = [];
984
+ for (const line of fs.readFileSync(claimPath, "utf8").split("\n")) {
985
+ if (!line.trim())
986
+ continue;
987
+ try {
988
+ entries.push(JSON.parse(line));
989
+ }
990
+ catch {
991
+ // A partial line must not prevent this run from processing the valid
992
+ // queued payloads around it.
993
+ }
994
+ }
995
+ return entries;
996
+ }
997
+ async function startSibling(liveRoot, input, threadPath, backend) {
998
+ const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
999
+ const siblingRoot = path.join(liveRoot, "workspace", "checkpoints", "sibling");
1000
+ const pendingPath = path.join(siblingRoot, "pending.jsonl");
1001
+ const lockPath = path.join(stateDir, "checkpoint-sibling.lock");
1002
+ const payload = siblingPayload(input, threadPath, backend);
1003
+ const activePid = existingSiblingPid(lockPath, Date.now());
1004
+ fs.mkdirSync(siblingRoot, { recursive: true });
1005
+ if (activePid !== null) {
1006
+ const depth = appendPending(pendingPath, payload);
1007
+ try {
1008
+ const { recordQueuedTouches } = await import("../lib/checkpoint-ontology/queued-touches.js");
1009
+ recordQueuedTouches(liveRoot, payload.company, payload.files, new Date());
1010
+ }
1011
+ catch {
1012
+ // Queued touches only help a later ontology plan; they must never fail
1013
+ // the checkpoint itself.
1014
+ }
1015
+ printResult(`checkpoint: sibling busy — payload queued (${depth} pending)`);
1016
+ return;
1017
+ }
1018
+ const runDir = path.join(siblingRoot, `${formatTimestamp(new Date())}-${summarySlug(input.summary ?? "checkpoint")}`);
1019
+ fs.mkdirSync(runDir, { recursive: true });
1020
+ payload.pending_payloads = claimPending(pendingPath, path.join(runDir, "pending-claimed.jsonl"));
1021
+ if (!payload.company) {
1022
+ let queued;
1023
+ try {
1024
+ const { oldestQueuedCompany } = await import("../lib/checkpoint-ontology/queued-touches.js");
1025
+ queued = oldestQueuedCompany(liveRoot);
1026
+ }
1027
+ catch {
1028
+ queued = null;
1029
+ }
1030
+ if (queued) {
1031
+ payload.ontology_company = queued;
1032
+ try {
1033
+ const { markBorrowed } = await import("../lib/checkpoint-ontology/queued-touches.js");
1034
+ markBorrowed(liveRoot, queued, new Date());
1035
+ }
1036
+ catch {
1037
+ // A borrowed-company marker must never fail the checkpoint.
1038
+ }
1039
+ }
1040
+ }
1041
+ const payloadPath = path.join(runDir, "payload.json");
1042
+ fs.writeFileSync(payloadPath, `${JSON.stringify(payload, null, 2)}\n`);
1043
+ const prompt = renderSiblingPrompt(runDir, payloadPath, {
1044
+ company: payload.company ?? payload.ontology_company ?? null,
1045
+ hqRoot: liveRoot,
1046
+ });
1047
+ fs.writeFileSync(path.join(runDir, "prompt.md"), prompt);
1048
+ const logPath = path.join(runDir, "run.log");
1049
+ const logFd = fs.openSync(logPath, "a");
1050
+ const args = siblingArgs(backend, prompt);
1051
+ let child;
1052
+ try {
1053
+ child = spawn(backend, args, {
1054
+ detached: true,
1055
+ windowsHide: true,
1056
+ cwd: liveRoot,
1057
+ stdio: ["ignore", logFd, logFd],
1058
+ env: siblingEnv(process.env, backend),
1059
+ });
1060
+ }
1061
+ finally {
1062
+ fs.closeSync(logFd);
1063
+ }
1064
+ child.unref();
1065
+ if (!child.pid)
1066
+ throw new Error("checkpoint: sibling did not return a PID");
1067
+ fs.writeFileSync(lockPath, String(child.pid));
1068
+ // Recorded per run so a later checkpoint can tell "died without a report"
1069
+ // apart from "still working" without consulting the shared lock.
1070
+ fs.writeFileSync(path.join(runDir, "sibling.pid"), String(child.pid));
1071
+ printResult(`checkpoint: sibling started (${backend}, pid ${child.pid})`);
1072
+ }
1073
+ function hasGateProbeConflict(options, command) {
1074
+ return [
1075
+ "summary",
1076
+ "file",
1077
+ "learning",
1078
+ "insight",
1079
+ "decision",
1080
+ "next",
1081
+ "tag",
1082
+ "commit",
1083
+ "initialCommit",
1084
+ "worker",
1085
+ "workerSkill",
1086
+ "workerState",
1087
+ "trigger",
1088
+ "company",
1089
+ "sessionId",
1090
+ "transcript",
1091
+ "payload",
1092
+ "idle",
1093
+ "agent",
1094
+ "backend",
1095
+ "dryRun",
1096
+ ].some((option) => wasPassed(command, option));
1097
+ }
1098
+ function printDryRun(plan) {
1099
+ printResult(JSON.stringify(plan, null, 2));
1100
+ }
1101
+ async function runCheckpoint(options, command, group, preResolvedHqRoot) {
1102
+ const hqRoot = options.hqRoot ?? group.opts().hqRoot;
1103
+ const liveRoot = preResolvedHqRoot ?? (await import("../utils/hq-roots.js")).resolveLiveRoot({ hqRoot });
1104
+ if (options.gateProbe) {
1105
+ if (hasGateProbeConflict(options, command)) {
1106
+ usage("checkpoint: --gate-probe cannot be combined with checkpoint options");
1107
+ }
1108
+ await writeGateVerdict(liveRoot);
1109
+ return;
1110
+ }
1111
+ if (options.idle) {
1112
+ if (options.dryRun) {
1113
+ printDryRun({ live_root: liveRoot, idle: true, stamps: [], thread: null, sibling: null });
1114
+ return;
1115
+ }
1116
+ writeStamps(liveRoot, resolveSessionId(options, command, readPayload(options.payload)));
1117
+ printResult("checkpoint: idle (nothing to record)");
1118
+ // An idle turn persisted nothing, but a read-only turn can still have
1119
+ // produced substantive findings the user has not been told about. The
1120
+ // conditional idle variant matches the Stop gate, which exempts an
1121
+ // idle-only turn from the reply demand.
1122
+ printResult(CHECKPOINT_IDLE_REPLY_REMINDER);
1123
+ return;
1124
+ }
1125
+ const payload = readPayload(options.payload);
1126
+ const input = mergeInput(options, command, payload);
1127
+ if (!input.summary?.trim()) {
1128
+ usage("checkpoint: --summary is required unless --idle or --gate-probe is used");
1129
+ }
1130
+ // Resolved before resolveBackend so an opted-out run pays for no backend
1131
+ // probe: `--backend auto` shells out to every installed CLI for a version.
1132
+ const siblingOptOut = options.agent === false || !siblingEnabledBySetting(liveRoot);
1133
+ // Validated even when nothing will spawn, so `--backend bogus` is still an error.
1134
+ const requestedBackend = assertKnownBackend(options.backend);
1135
+ const backend = siblingOptOut ? "none" : await resolveBackend(requestedBackend, liveRoot, input.company);
1136
+ const now = new Date();
1137
+ const threadId = `T-${formatTimestamp(now)}-auto-${summarySlug(input.summary)}`;
1138
+ const threadPath = path.join(liveRoot, "workspace", "threads", `${threadId}.json`);
1139
+ const relativeThreadPath = path.relative(liveRoot, threadPath);
1140
+ const git = gitStateFor(process.cwd(), liveRoot, input.initialCommit, input.commits);
1141
+ const stampPaths = [
1142
+ path.join(liveRoot, "workspace", "orchestrator", "hook-state", "checkpoint-cli-last"),
1143
+ path.join(liveRoot, "workspace", "orchestrator", "hook-state", `checkpoint-cli-last-${input.sessionId ? input.sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown" : "unknown"}`),
1144
+ ];
1145
+ if (options.dryRun) {
1146
+ printDryRun({
1147
+ live_root: liveRoot,
1148
+ idle: false,
1149
+ thread: relativeThreadPath,
1150
+ checkpoint: {
1151
+ git,
1152
+ insights: input.insights,
1153
+ worker: input.worker,
1154
+ },
1155
+ stamps: stampPaths.map((stampPath) => path.relative(liveRoot, stampPath)),
1156
+ sibling: siblingOptOut ? null : { backend },
1157
+ });
1158
+ return;
1159
+ }
1160
+ const thread = {
1161
+ thread_id: threadId,
1162
+ version: 1,
1163
+ type: "auto-checkpoint",
1164
+ created_at: now.toISOString(),
1165
+ updated_at: now.toISOString(),
1166
+ workspace_root: liveRoot,
1167
+ cwd: process.cwd(),
1168
+ git,
1169
+ conversation_summary: input.summary,
1170
+ files_touched: input.files,
1171
+ next_steps: input.nextSteps,
1172
+ learnings: input.learnings,
1173
+ insights: input.insights,
1174
+ decisions: input.decisions,
1175
+ worker: input.worker,
1176
+ session_id: input.sessionId ?? null,
1177
+ metadata: {
1178
+ title: titleFor(input.summary),
1179
+ tags: ["auto-checkpoint", ...input.tags],
1180
+ trigger: input.trigger,
1181
+ },
1182
+ };
1183
+ writeHqState(path.dirname(threadPath), "create the checkpoint thread directory", () => fs.mkdirSync(path.dirname(threadPath), { recursive: true }));
1184
+ writeHqState(threadPath, "write the checkpoint thread file", () => fs.writeFileSync(threadPath, `${JSON.stringify(thread, null, 2)}\n`));
1185
+ writeStamps(liveRoot, input.sessionId);
1186
+ printResult(`checkpoint: ${relativeThreadPath}`);
1187
+ // finally: the thread is already written at this point, so the agent must
1188
+ // get the reminder even when sibling startup throws (e.g. an explicitly
1189
+ // requested backend that is not installed).
1190
+ try {
1191
+ if (options.agent !== false) {
1192
+ if (siblingOptOut) {
1193
+ printResult(`checkpoint: sibling disabled (${SIBLING_SETTING})`);
1194
+ }
1195
+ else if (backend === "none") {
1196
+ printResult("checkpoint: sibling disabled (backend none)");
1197
+ }
1198
+ else {
1199
+ await startSibling(liveRoot, input, threadPath, backend);
1200
+ }
1201
+ }
1202
+ }
1203
+ finally {
1204
+ printResult(CHECKPOINT_REPLY_REMINDER);
1205
+ }
1206
+ }
1207
+ function reportUsage(error) {
1208
+ printError("Usage: hq core checkpoint --summary <text> [options]");
1209
+ printError(error.message);
1210
+ process.exit(2);
1211
+ }
1212
+ /** Attach the native checkpoint command to the hidden `hq core` group. */
1213
+ export function registerCoreCheckpointCommand(core, preResolvedHqRoot) {
1214
+ const checkpoint = core
1215
+ .command("checkpoint")
1216
+ .description("Record an end-of-turn checkpoint and start HQ maintenance")
1217
+ .option("--summary <text>", "1–2 sentence outcome summary")
1218
+ .option("--file <path>", "file touched this turn", collect, [])
1219
+ .option("--learning <text>", "reusable lesson", collect, [])
1220
+ .option("--insight <text>", "session insight", collect, [])
1221
+ .option("--decision <text>", "decision made", collect, [])
1222
+ .option("--next <text>", "remaining next step", collect, [])
1223
+ .option("--tag <tag>", "checkpoint tag", collect, [])
1224
+ .option("--commit <entry>", "session commit (<hash>: <message>)", collect, [])
1225
+ .option("--initial-commit <sha>", "commit at the start of this session")
1226
+ .option("--worker <id>", "active worker identifier")
1227
+ .option("--worker-skill <name>", "active worker skill")
1228
+ .option("--worker-state <state>", "active worker state")
1229
+ .option("--trigger <name>", "checkpoint trigger", DEFAULT_TRIGGER)
1230
+ .option("--company <slug>", "active company scope")
1231
+ .option("--session-id <id>", "caller session id (default: $CLAUDE_CODE_SESSION_ID when set)")
1232
+ .option("--transcript <path>", "session transcript path")
1233
+ .option("--payload <file|->", "JSON payload file, or - for stdin")
1234
+ .option("--idle", "touch stamps without a checkpoint")
1235
+ .option("--no-agent", `do not spawn the maintenance sibling (set it off for good with "env": {"${SIBLING_SETTING}": "0"} in .claude/settings.json)`)
1236
+ .option("--backend <auto|claude|codex|grok|none>", "sibling backend", "auto")
1237
+ .option("--gate-probe", "write the local Stop-hook eligibility verdict")
1238
+ .option("--hq-root <path>", "HQ installation to operate on")
1239
+ .option("--dry-run", "print the planned checkpoint without writing")
1240
+ .action(async (options, command) => {
1241
+ try {
1242
+ await runCheckpoint(options, command, core, preResolvedHqRoot);
1243
+ }
1244
+ catch (error) {
1245
+ if (error instanceof CheckpointUsageError)
1246
+ reportUsage(error);
1247
+ throw error;
1248
+ }
1249
+ });
1250
+ describeCoreCommand(checkpoint, { execution: "native", root: "live" });
1251
+ }
1252
+ //# sourceMappingURL=core-checkpoint-native.js.map