@indigoai-us/hq-cli 5.345.28 → 5.345.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/assets/scaffold/core/scripts/hq-delegate-verify.sh +1 -1
- package/assets/scaffold/core/scripts/resolve-company.sh +157 -69
- package/dist/commands/core-checkpoint-fast.d.ts +3 -0
- package/dist/commands/core-checkpoint-fast.js +17 -0
- package/dist/commands/core-checkpoint-native.d.ts +76 -0
- package/dist/commands/core-checkpoint-native.js +1252 -0
- package/dist/commands/core-checkpoint.d.ts +2 -75
- package/dist/commands/core-checkpoint.js +2 -1248
- package/dist/commands/core-resolve-company-command.js +1 -0
- package/dist/commands/core-resolve-company-native.d.ts +2 -2
- package/dist/commands/core-resolve-company-native.js +149 -12
- package/dist/fast-scaffold.js +10 -0
- package/dist/lib/doctor/checks/sync-health.js +9 -1
- package/package.json +1 -1
|
@@ -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
|