@indigoai-us/hq-cli 5.80.0 → 5.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.82.0]
6
+
7
+ ### Added
8
+
9
+ - `hq core checkpoint` reaches full `/checkpoint`-skill parity: new
10
+ `--insight`, `--commit`, `--initial-commit`, and `--worker*` options
11
+ (payload equivalents included), automatic caller-repo `remote_url`
12
+ capture, `commits_made` derivation from `--initial-commit`, and a
13
+ sibling prompt that executes the complete checkpoint flow — in-place
14
+ thread upgrade/rename, knowledge-repo scan, journal close, legacy
15
+ checkpoint, thread index refresh, document-release best-effort,
16
+ transcript-derived learnings — under explicit allow-list write
17
+ bounds. (#289)
18
+
19
+ ## [5.81.0]
20
+
21
+ ### Added
22
+
23
+ - Hidden `hq core checkpoint`: end-of-turn checkpoint + detached background
24
+ maintenance sibling (codex-first, pinned `gpt-5.6-terra` @ high effort;
25
+ claude fallback pinned `claude-opus-5` @ medium). Sibling distills
26
+ learnings into policies/knowledge under a single-flight lock;
27
+ `--gate-probe` caches Stop-gate eligibility (operator rollout domain,
28
+ `HQ_CHECKPOINT_GATE`/`HQ_CHECKPOINT_GATE_DOMAINS` overrides); session id
29
+ inferred from `CLAUDE_CODE_SESSION_ID` when omitted. (#286)
30
+
5
31
  ## [5.79.0]
6
32
 
7
33
  ### Added
@@ -0,0 +1,18 @@
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 { Command } from "commander";
10
+ /**
11
+ * Kept in TypeScript rather than in a bundled asset: it is an instruction to
12
+ * a locally-installed agent, not a scaffold script that should be packaged.
13
+ */
14
+ export declare const SIBLING_PROMPT_TEMPLATE = "You are the HQ checkpoint sibling \u2014 a background maintenance agent for this\nHQ install. Your parent session's state is in <payloadPath>. Work\nquietly and do not ask questions; if something is ambiguous, record it in the\nreport instead of guessing.\n\n1. Read the payload. If it lists a transcript path that exists, read its tail (~400 lines)\n both for session context and to extract additional reusable learnings/insights\n the parent did not pass explicitly. Never quote secrets\n or tokens from the transcript. If .claude/skills/checkpoint/SKILL.md exists\n under this HQ root, read it and follow it wherever it goes beyond these instructions;\n the write bounds below always win over the skill text.\n2. Upgrade the thread file named in the payload IN PLACE: verify/repair its\n JSON; fill git.remote_url, git.initial_commit, git.commits_made, and\n git.knowledge_repos by scanning core/knowledge/public/*,\n core/knowledge/private/*, personal/knowledge/*, and companies/*/knowledge\n for symlinks or directories containing .git, recording dirty repositories\n as {\"<name>\": {\"commit\": \"<short>\", \"dirty\": true}}. Fill worker,\n next_steps, and insights; set type to \"checkpoint\"; then rename the file to\n drop -auto- from its filename. Use the renamed path in every reference you\n write afterwards.\n3. For every explicit or transcript-derived learning that is a reusable rule,\n distill a non-duplicate policy file under personal/policies/ or, only when\n the payload names a company and the rule is company-specific,\n companies/<company>/policies/, following\n core/knowledge/public/hq-core/policies-spec.md. Store up to two explicit or\n transcript-derived insights per core/knowledge/public/hq-core/insights-spec.md\n when present, otherwise workspace/insights/. Durable facts (not rules) may\n go under personal/knowledge/ or companies/<company>/knowledge/ only.\n4. Close an active session journal fail-soft with\n bash .claude/skills/_shared/journal.sh close \"<project_dir>\" \"<one-line synthesis>\".\n Write a legacy checkpoint JSON under workspace/checkpoints/<id>.json with\n id, created_at, summary, files, and next_steps for backward compatibility.\n5. Update workspace/threads/recent.md and regenerate\n workspace/threads/INDEX.md. For each company whose knowledge path appears\n in files_touched, regenerate companies/<company>/knowledge/INDEX.md under\n core/knowledge/public/hq-core/index-md-spec.md. Mechanical index generation\n is allowed for those companies, but knowledge/policy content writes remain\n restricted to the payload's named company.\n6. Run .claude/skills/document-release/SKILL.md best-effort when it exists;\n skip silently on any failure. Hook or automation improvements go ONLY under\n personal/hooks/ as proposals.\n7. 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.\n8. Write <runDir>/report.md \u2014 full prose: what you read, what you changed\n (paths), and what you skipped and why. If\n workspace/checkpoints/sibling/pending.jsonl is non-empty when you finish,\n process those payloads with this same flow, then truncate the file.\n";
15
+ export declare function renderSiblingPrompt(runDir: string, payloadPath: string): string;
16
+ /** Attach the native checkpoint command to the hidden `hq core` group. */
17
+ export declare function registerCoreCheckpointCommand(core: Command): void;
18
+ //# sourceMappingURL=core-checkpoint.d.ts.map
@@ -0,0 +1,654 @@
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 { execFileSync, spawn } from "node:child_process";
12
+ import { homedir } from "node:os";
13
+ import { resolveLiveRoot } from "../utils/hq-roots.js";
14
+ import { peekIdToken } from "../utils/id-token.js";
15
+ const DEFAULT_TRIGGER = "stop-gate";
16
+ const BACKENDS = new Set(["auto", "claude", "codex", "none"]);
17
+ // Pinned by operator directive 2026-07-31; change defaults here deliberately.
18
+ const CODEX_SIBLING_MODEL = "gpt-5.6-terra";
19
+ const CODEX_SIBLING_REASONING_EFFORT = "high";
20
+ const CLAUDE_SIBLING_MODEL = "claude-opus-5";
21
+ const CLAUDE_SIBLING_EFFORT = "medium";
22
+ class CheckpointUsageError extends Error {
23
+ }
24
+ function printResult(line) {
25
+ process.stdout.write(`${line}\n`);
26
+ }
27
+ function printError(line) {
28
+ process.stderr.write(`${line}\n`);
29
+ }
30
+ /**
31
+ * Kept in TypeScript rather than in a bundled asset: it is an instruction to
32
+ * a locally-installed agent, not a scaffold script that should be packaged.
33
+ */
34
+ export const SIBLING_PROMPT_TEMPLATE = `You are the HQ checkpoint sibling — a background maintenance agent for this
35
+ HQ install. Your parent session's state is in <payloadPath>. Work
36
+ quietly and do not ask questions; if something is ambiguous, record it in the
37
+ report instead of guessing.
38
+
39
+ 1. Read the payload. If it lists a transcript path that exists, read its tail (~400 lines)
40
+ both for session context and to extract additional reusable learnings/insights
41
+ the parent did not pass explicitly. Never quote secrets
42
+ or tokens from the transcript. If .claude/skills/checkpoint/SKILL.md exists
43
+ under this HQ root, read it and follow it wherever it goes beyond these instructions;
44
+ the write bounds below always win over the skill text.
45
+ 2. Upgrade the thread file named in the payload IN PLACE: verify/repair its
46
+ JSON; fill git.remote_url, git.initial_commit, git.commits_made, and
47
+ git.knowledge_repos by scanning core/knowledge/public/*,
48
+ core/knowledge/private/*, personal/knowledge/*, and companies/*/knowledge
49
+ for symlinks or directories containing .git, recording dirty repositories
50
+ as {"<name>": {"commit": "<short>", "dirty": true}}. Fill worker,
51
+ next_steps, and insights; set type to "checkpoint"; then rename the file to
52
+ drop -auto- from its filename. Use the renamed path in every reference you
53
+ write afterwards.
54
+ 3. For every explicit or transcript-derived learning that is a reusable rule,
55
+ distill a non-duplicate policy file under personal/policies/ or, only when
56
+ the payload names a company and the rule is company-specific,
57
+ companies/<company>/policies/, following
58
+ core/knowledge/public/hq-core/policies-spec.md. Store up to two explicit or
59
+ transcript-derived insights per core/knowledge/public/hq-core/insights-spec.md
60
+ when present, otherwise workspace/insights/. Durable facts (not rules) may
61
+ go under personal/knowledge/ or companies/<company>/knowledge/ only.
62
+ 4. Close an active session journal fail-soft with
63
+ bash .claude/skills/_shared/journal.sh close "<project_dir>" "<one-line synthesis>".
64
+ Write a legacy checkpoint JSON under workspace/checkpoints/<id>.json with
65
+ id, created_at, summary, files, and next_steps for backward compatibility.
66
+ 5. Update workspace/threads/recent.md and regenerate
67
+ workspace/threads/INDEX.md. For each company whose knowledge path appears
68
+ in files_touched, regenerate companies/<company>/knowledge/INDEX.md under
69
+ core/knowledge/public/hq-core/index-md-spec.md. Mechanical index generation
70
+ is allowed for those companies, but knowledge/policy content writes remain
71
+ restricted to the payload's named company.
72
+ 6. Run .claude/skills/document-release/SKILL.md best-effort when it exists;
73
+ skip silently on any failure. Hook or automation improvements go ONLY under
74
+ personal/hooks/ as proposals.
75
+ 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.
76
+ 8. Write <runDir>/report.md — full prose: what you read, what you changed
77
+ (paths), and what you skipped and why. If
78
+ workspace/checkpoints/sibling/pending.jsonl is non-empty when you finish,
79
+ process those payloads with this same flow, then truncate the file.
80
+ `;
81
+ export function renderSiblingPrompt(runDir, payloadPath) {
82
+ return SIBLING_PROMPT_TEMPLATE
83
+ .replaceAll("<runDir>", () => runDir)
84
+ .replaceAll("<payloadPath>", () => payloadPath);
85
+ }
86
+ function collect(value, previous) {
87
+ return [...previous, value];
88
+ }
89
+ function usage(message) {
90
+ throw new CheckpointUsageError(message);
91
+ }
92
+ function readPayload(payloadPath) {
93
+ if (!payloadPath)
94
+ return {};
95
+ let raw;
96
+ try {
97
+ raw = payloadPath === "-"
98
+ ? fs.readFileSync(0, "utf8")
99
+ : fs.readFileSync(payloadPath, "utf8");
100
+ }
101
+ catch {
102
+ usage(`checkpoint: could not read payload: ${payloadPath}`);
103
+ }
104
+ try {
105
+ const parsed = JSON.parse(raw);
106
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
107
+ usage("checkpoint: payload must be a JSON object");
108
+ }
109
+ return parsed;
110
+ }
111
+ catch (error) {
112
+ if (error instanceof CheckpointUsageError)
113
+ throw error;
114
+ usage("checkpoint: payload is not valid JSON");
115
+ }
116
+ }
117
+ function asOptionalString(value, field) {
118
+ if (value === undefined || value === null)
119
+ return undefined;
120
+ if (typeof value !== "string")
121
+ usage(`checkpoint: payload ${field} must be a string`);
122
+ return value;
123
+ }
124
+ function asStringList(value, field) {
125
+ if (value === undefined || value === null)
126
+ return [];
127
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
128
+ usage(`checkpoint: payload ${field} must be an array of strings`);
129
+ }
130
+ return value;
131
+ }
132
+ function asWorker(value) {
133
+ if (value === undefined || value === null)
134
+ return {};
135
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
136
+ usage("checkpoint: payload worker must be an object");
137
+ }
138
+ const worker = value;
139
+ return {
140
+ id: asOptionalString(worker.id, "worker.id"),
141
+ skill: asOptionalString(worker.skill, "worker.skill"),
142
+ state: asOptionalString(worker.state, "worker.state"),
143
+ };
144
+ }
145
+ function resolveWorker(options, command, payload) {
146
+ const payloadWorker = asWorker(payload.worker);
147
+ const id = wasPassed(command, "worker") ? options.worker : payloadWorker.id;
148
+ const skill = wasPassed(command, "workerSkill") ? options.workerSkill : payloadWorker.skill;
149
+ const suppliedState = wasPassed(command, "workerState") ? options.workerState : payloadWorker.state;
150
+ const state = suppliedState ?? (id ? "completed" : null);
151
+ return { id: id ?? null, skill: skill ?? null, state };
152
+ }
153
+ function wasPassed(command, option) {
154
+ return command.getOptionValueSource(option) === "cli";
155
+ }
156
+ function inferredSessionId() {
157
+ // Deliberately do not fall back to the newest transcript under
158
+ // ~/.claude/projects/<munged-cwd>: concurrent sessions make its mtime
159
+ // unreliable and can mis-attribute a checkpoint. Env var or nothing.
160
+ return process.env.CLAUDE_CODE_SESSION_ID?.trim() || undefined;
161
+ }
162
+ function resolveSessionId(options, command, payload) {
163
+ if (wasPassed(command, "sessionId"))
164
+ return options.sessionId;
165
+ return asOptionalString(payload.session_id, "session_id") ?? inferredSessionId();
166
+ }
167
+ function mergeInput(options, command, payload) {
168
+ const summary = wasPassed(command, "summary")
169
+ ? options.summary
170
+ : asOptionalString(payload.summary, "summary");
171
+ const files = wasPassed(command, "file")
172
+ ? options.file
173
+ : asStringList(payload.files, "files");
174
+ const learnings = wasPassed(command, "learning")
175
+ ? options.learning
176
+ : asStringList(payload.learnings, "learnings");
177
+ const insights = wasPassed(command, "insight")
178
+ ? options.insight
179
+ : asStringList(payload.insights, "insights");
180
+ const decisions = wasPassed(command, "decision")
181
+ ? options.decision
182
+ : asStringList(payload.decisions, "decisions");
183
+ const nextSteps = wasPassed(command, "next")
184
+ ? options.next
185
+ : asStringList(payload.next_steps, "next_steps");
186
+ const tags = wasPassed(command, "tag")
187
+ ? options.tag
188
+ : asStringList(payload.tags, "tags");
189
+ const commits = wasPassed(command, "commit")
190
+ ? options.commit
191
+ : asStringList(payload.commits, "commits");
192
+ const initialCommit = wasPassed(command, "initialCommit")
193
+ ? options.initialCommit
194
+ : asOptionalString(payload.initial_commit, "initial_commit");
195
+ const trigger = (wasPassed(command, "trigger")
196
+ ? options.trigger
197
+ : asOptionalString(payload.trigger, "trigger")) ?? DEFAULT_TRIGGER;
198
+ const company = wasPassed(command, "company")
199
+ ? options.company
200
+ : asOptionalString(payload.company, "company");
201
+ const sessionId = resolveSessionId(options, command, payload);
202
+ const transcript = wasPassed(command, "transcript")
203
+ ? options.transcript
204
+ : asOptionalString(payload.transcript, "transcript");
205
+ return {
206
+ summary,
207
+ files,
208
+ learnings,
209
+ insights,
210
+ decisions,
211
+ nextSteps,
212
+ tags,
213
+ commits,
214
+ initialCommit,
215
+ worker: resolveWorker(options, command, payload),
216
+ trigger,
217
+ company,
218
+ sessionId,
219
+ transcript,
220
+ };
221
+ }
222
+ function formatTimestamp(date) {
223
+ const part = (value) => value.toString().padStart(2, "0");
224
+ return `${date.getUTCFullYear()}${part(date.getUTCMonth() + 1)}${part(date.getUTCDate())}-${part(date.getUTCHours())}${part(date.getUTCMinutes())}${part(date.getUTCSeconds())}`;
225
+ }
226
+ function summarySlug(summary) {
227
+ const words = summary.toLowerCase().match(/[a-z0-9]+/g)?.slice(0, 4) ?? [];
228
+ if (words.length === 0)
229
+ return "checkpoint-update";
230
+ if (words.length === 1)
231
+ words.push("update");
232
+ const slug = words.join("-").slice(0, 40).replace(/-+$/, "");
233
+ return slug || "checkpoint-update";
234
+ }
235
+ function titleFor(summary) {
236
+ const truncated = summary.length > 60 ? `${summary.slice(0, 57).trimEnd()}...` : summary;
237
+ return `Auto: ${truncated}`;
238
+ }
239
+ function readGitState(repoDir) {
240
+ try {
241
+ const read = (args) => execFileSync("git", ["-C", repoDir, ...args], {
242
+ encoding: "utf8",
243
+ stdio: ["ignore", "pipe", "ignore"],
244
+ }).trim();
245
+ if (read(["rev-parse", "--is-inside-work-tree"]) !== "true")
246
+ return null;
247
+ return {
248
+ branch: read(["rev-parse", "--abbrev-ref", "HEAD"]),
249
+ current_commit: read(["rev-parse", "--short", "HEAD"]),
250
+ dirty: read(["status", "--porcelain"]) !== "",
251
+ remote_url: (() => {
252
+ try {
253
+ return read(["remote", "get-url", "origin"]);
254
+ }
255
+ catch {
256
+ return null;
257
+ }
258
+ })(),
259
+ };
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }
265
+ function commitsSince(repoDir, initialCommit) {
266
+ try {
267
+ const output = execFileSync("git", ["-C", repoDir, "log", "--no-decorate", "--pretty=format:%h: %s", `${initialCommit}..HEAD`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
268
+ return output ? output.split("\n") : [];
269
+ }
270
+ catch {
271
+ return [];
272
+ }
273
+ }
274
+ function gitStateFor(cwd, liveRoot, initialCommit, explicitCommits) {
275
+ const callerState = readGitState(cwd);
276
+ const state = callerState ??
277
+ readGitState(liveRoot) ?? {
278
+ branch: "unknown",
279
+ current_commit: "unknown",
280
+ dirty: false,
281
+ remote_url: null,
282
+ };
283
+ return {
284
+ ...state,
285
+ // Remote URLs and derived history describe the caller's repository only.
286
+ remote_url: callerState?.remote_url ?? null,
287
+ initial_commit: initialCommit ?? null,
288
+ commits_made: explicitCommits.length > 0
289
+ ? explicitCommits
290
+ : initialCommit && callerState
291
+ ? commitsSince(cwd, initialCommit)
292
+ : [],
293
+ };
294
+ }
295
+ function writeStamps(liveRoot, sessionId) {
296
+ const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
297
+ fs.mkdirSync(stateDir, { recursive: true });
298
+ const sessionKey = sessionId
299
+ ? sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown"
300
+ : "unknown";
301
+ const timestamp = Math.floor(Date.now() / 1000).toString();
302
+ const stampPaths = [
303
+ path.join(stateDir, "checkpoint-cli-last"),
304
+ path.join(stateDir, `checkpoint-cli-last-${sessionKey}`),
305
+ ];
306
+ for (const stampPath of stampPaths)
307
+ fs.writeFileSync(stampPath, timestamp);
308
+ return stampPaths;
309
+ }
310
+ function gateEligibility() {
311
+ const forced = process.env.HQ_CHECKPOINT_GATE;
312
+ if (forced === "0")
313
+ return false;
314
+ if (forced === "1")
315
+ return true;
316
+ try {
317
+ const tokenPath = path.join(homedir(), ".hq", "cognito-tokens.json");
318
+ const tokens = JSON.parse(fs.readFileSync(tokenPath, "utf8"));
319
+ if (typeof tokens.idToken !== "string")
320
+ return false;
321
+ const email = peekIdToken(tokens.idToken).email;
322
+ if (typeof email !== "string")
323
+ return false;
324
+ const domain = email.split("@").at(-1)?.toLowerCase();
325
+ if (!domain)
326
+ return false;
327
+ const configured = (process.env.HQ_CHECKPOINT_GATE_DOMAINS ?? "")
328
+ .split(",")
329
+ .map((candidate) => candidate.trim().toLowerCase())
330
+ .filter(Boolean);
331
+ return domain === "getindigo.ai" || configured.includes(domain);
332
+ }
333
+ catch {
334
+ return false;
335
+ }
336
+ }
337
+ function writeGateVerdict(liveRoot) {
338
+ const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
339
+ fs.mkdirSync(stateDir, { recursive: true });
340
+ const eligible = gateEligibility();
341
+ fs.writeFileSync(path.join(stateDir, "checkpoint-gate-eligible"), eligible ? "1" : "0");
342
+ printResult(eligible ? "eligible" : "ineligible");
343
+ }
344
+ function backendOnPath(name) {
345
+ const pathValue = process.env.PATH;
346
+ if (!pathValue)
347
+ return false;
348
+ return pathValue.split(path.delimiter).some((directory) => {
349
+ if (!directory)
350
+ return false;
351
+ try {
352
+ fs.accessSync(path.join(directory, name), fs.constants.X_OK);
353
+ return true;
354
+ }
355
+ catch {
356
+ return false;
357
+ }
358
+ });
359
+ }
360
+ function resolveBackend(requested) {
361
+ const value = requested ?? "auto";
362
+ if (!BACKENDS.has(value))
363
+ usage(`checkpoint: unknown backend: ${value}`);
364
+ if (value === "auto") {
365
+ if (backendOnPath("codex"))
366
+ return "codex";
367
+ if (backendOnPath("claude"))
368
+ return "claude";
369
+ return "none";
370
+ }
371
+ return value;
372
+ }
373
+ function siblingPayload(input, threadPath) {
374
+ return {
375
+ summary: input.summary ?? "",
376
+ files: input.files,
377
+ learnings: input.learnings,
378
+ insights: input.insights,
379
+ decisions: input.decisions,
380
+ next_steps: input.nextSteps,
381
+ tags: input.tags,
382
+ trigger: input.trigger,
383
+ company: input.company ?? null,
384
+ session_id: input.sessionId ?? null,
385
+ transcript: input.transcript ?? null,
386
+ worker: input.worker,
387
+ thread_path: threadPath,
388
+ pending_payloads: [],
389
+ };
390
+ }
391
+ function existingSiblingPid(lockPath) {
392
+ try {
393
+ const pid = Number.parseInt(fs.readFileSync(lockPath, "utf8").trim(), 10);
394
+ if (!Number.isSafeInteger(pid) || pid <= 0)
395
+ return null;
396
+ process.kill(pid, 0);
397
+ return pid;
398
+ }
399
+ catch (error) {
400
+ if (error?.code === "EPERM") {
401
+ try {
402
+ return Number.parseInt(fs.readFileSync(lockPath, "utf8").trim(), 10);
403
+ }
404
+ catch {
405
+ return null;
406
+ }
407
+ }
408
+ return null;
409
+ }
410
+ }
411
+ function drainPending(pendingPath) {
412
+ if (!fs.existsSync(pendingPath))
413
+ return [];
414
+ const entries = [];
415
+ for (const line of fs.readFileSync(pendingPath, "utf8").split("\n")) {
416
+ if (!line.trim())
417
+ continue;
418
+ try {
419
+ entries.push(JSON.parse(line));
420
+ }
421
+ catch {
422
+ // A partial line must not prevent a future sibling from processing the
423
+ // valid queued payloads around it.
424
+ }
425
+ }
426
+ fs.writeFileSync(pendingPath, "");
427
+ return entries;
428
+ }
429
+ function startSibling(liveRoot, input, threadPath, backend) {
430
+ const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
431
+ const siblingRoot = path.join(liveRoot, "workspace", "checkpoints", "sibling");
432
+ const pendingPath = path.join(siblingRoot, "pending.jsonl");
433
+ const lockPath = path.join(stateDir, "checkpoint-sibling.lock");
434
+ const payload = siblingPayload(input, threadPath);
435
+ const activePid = existingSiblingPid(lockPath);
436
+ fs.mkdirSync(siblingRoot, { recursive: true });
437
+ if (activePid !== null) {
438
+ fs.appendFileSync(pendingPath, `${JSON.stringify(payload)}\n`);
439
+ printResult("checkpoint: sibling busy — payload queued");
440
+ return;
441
+ }
442
+ const runDir = path.join(siblingRoot, `${formatTimestamp(new Date())}-${summarySlug(input.summary ?? "checkpoint")}`);
443
+ fs.mkdirSync(runDir, { recursive: true });
444
+ payload.pending_payloads = drainPending(pendingPath);
445
+ const payloadPath = path.join(runDir, "payload.json");
446
+ fs.writeFileSync(payloadPath, `${JSON.stringify(payload, null, 2)}\n`);
447
+ const prompt = renderSiblingPrompt(runDir, payloadPath);
448
+ fs.writeFileSync(path.join(runDir, "prompt.md"), prompt);
449
+ const logPath = path.join(runDir, "run.log");
450
+ const logFd = fs.openSync(logPath, "a");
451
+ const args = backend === "claude"
452
+ ? [
453
+ "-p",
454
+ prompt,
455
+ "--model",
456
+ CLAUDE_SIBLING_MODEL,
457
+ "--effort",
458
+ CLAUDE_SIBLING_EFFORT,
459
+ "--permission-mode",
460
+ "acceptEdits",
461
+ ]
462
+ : [
463
+ "exec",
464
+ "--skip-git-repo-check",
465
+ "-s",
466
+ "workspace-write",
467
+ "--dangerously-bypass-hook-trust",
468
+ "-m",
469
+ CODEX_SIBLING_MODEL,
470
+ "-c",
471
+ `model_reasoning_effort=${CODEX_SIBLING_REASONING_EFFORT}`,
472
+ prompt,
473
+ ];
474
+ let child;
475
+ try {
476
+ child = spawn(backend, args, {
477
+ detached: true,
478
+ cwd: liveRoot,
479
+ stdio: ["ignore", logFd, logFd],
480
+ // This prevents the scaffold Stop hook from recursively launching a
481
+ // sibling when the background agent itself finishes a turn.
482
+ env: {
483
+ ...process.env,
484
+ HQ_CHECKPOINT_SIBLING: "1",
485
+ ...(backend === "claude" ? { CLAUDE_EFFORT: CLAUDE_SIBLING_EFFORT } : {}),
486
+ },
487
+ });
488
+ }
489
+ finally {
490
+ fs.closeSync(logFd);
491
+ }
492
+ child.unref();
493
+ if (!child.pid)
494
+ throw new Error("checkpoint: sibling did not return a PID");
495
+ fs.writeFileSync(lockPath, String(child.pid));
496
+ printResult(`checkpoint: sibling started (pid ${child.pid})`);
497
+ }
498
+ function hasGateProbeConflict(options, command) {
499
+ return [
500
+ "summary",
501
+ "file",
502
+ "learning",
503
+ "insight",
504
+ "decision",
505
+ "next",
506
+ "tag",
507
+ "commit",
508
+ "initialCommit",
509
+ "worker",
510
+ "workerSkill",
511
+ "workerState",
512
+ "trigger",
513
+ "company",
514
+ "sessionId",
515
+ "transcript",
516
+ "payload",
517
+ "idle",
518
+ "agent",
519
+ "backend",
520
+ "dryRun",
521
+ ].some((option) => wasPassed(command, option));
522
+ }
523
+ function printDryRun(plan) {
524
+ printResult(JSON.stringify(plan, null, 2));
525
+ }
526
+ function runCheckpoint(options, command, group) {
527
+ const hqRoot = options.hqRoot ?? group.opts().hqRoot;
528
+ const liveRoot = resolveLiveRoot({ hqRoot });
529
+ if (options.gateProbe) {
530
+ if (hasGateProbeConflict(options, command)) {
531
+ usage("checkpoint: --gate-probe cannot be combined with checkpoint options");
532
+ }
533
+ writeGateVerdict(liveRoot);
534
+ return;
535
+ }
536
+ if (options.idle) {
537
+ if (options.dryRun) {
538
+ printDryRun({ live_root: liveRoot, idle: true, stamps: [], thread: null, sibling: null });
539
+ return;
540
+ }
541
+ writeStamps(liveRoot, resolveSessionId(options, command, readPayload(options.payload)));
542
+ printResult("checkpoint: idle (nothing to record)");
543
+ return;
544
+ }
545
+ const payload = readPayload(options.payload);
546
+ const input = mergeInput(options, command, payload);
547
+ if (!input.summary?.trim()) {
548
+ usage("checkpoint: --summary is required unless --idle or --gate-probe is used");
549
+ }
550
+ const backend = resolveBackend(options.backend);
551
+ const now = new Date();
552
+ const threadId = `T-${formatTimestamp(now)}-auto-${summarySlug(input.summary)}`;
553
+ const threadPath = path.join(liveRoot, "workspace", "threads", `${threadId}.json`);
554
+ const relativeThreadPath = path.relative(liveRoot, threadPath);
555
+ const git = gitStateFor(process.cwd(), liveRoot, input.initialCommit, input.commits);
556
+ const stampPaths = [
557
+ path.join(liveRoot, "workspace", "orchestrator", "hook-state", "checkpoint-cli-last"),
558
+ path.join(liveRoot, "workspace", "orchestrator", "hook-state", `checkpoint-cli-last-${input.sessionId ? input.sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown" : "unknown"}`),
559
+ ];
560
+ if (options.dryRun) {
561
+ printDryRun({
562
+ live_root: liveRoot,
563
+ idle: false,
564
+ thread: relativeThreadPath,
565
+ checkpoint: {
566
+ git,
567
+ insights: input.insights,
568
+ worker: input.worker,
569
+ },
570
+ stamps: stampPaths.map((stampPath) => path.relative(liveRoot, stampPath)),
571
+ sibling: options.agent === false ? null : { backend },
572
+ });
573
+ return;
574
+ }
575
+ const thread = {
576
+ thread_id: threadId,
577
+ version: 1,
578
+ type: "auto-checkpoint",
579
+ created_at: now.toISOString(),
580
+ updated_at: now.toISOString(),
581
+ workspace_root: liveRoot,
582
+ cwd: process.cwd(),
583
+ git,
584
+ conversation_summary: input.summary,
585
+ files_touched: input.files,
586
+ next_steps: input.nextSteps,
587
+ learnings: input.learnings,
588
+ insights: input.insights,
589
+ decisions: input.decisions,
590
+ worker: input.worker,
591
+ session_id: input.sessionId ?? null,
592
+ metadata: {
593
+ title: titleFor(input.summary),
594
+ tags: ["auto-checkpoint", ...input.tags],
595
+ trigger: input.trigger,
596
+ },
597
+ };
598
+ fs.mkdirSync(path.dirname(threadPath), { recursive: true });
599
+ fs.writeFileSync(threadPath, `${JSON.stringify(thread, null, 2)}\n`);
600
+ writeStamps(liveRoot, input.sessionId);
601
+ printResult(`checkpoint: ${relativeThreadPath}`);
602
+ if (options.agent === false)
603
+ return;
604
+ if (backend === "none") {
605
+ printResult("checkpoint: sibling disabled (backend none)");
606
+ return;
607
+ }
608
+ startSibling(liveRoot, input, threadPath, backend);
609
+ }
610
+ function reportUsage(error) {
611
+ printError("Usage: hq core checkpoint --summary <text> [options]");
612
+ printError(error.message);
613
+ process.exit(2);
614
+ }
615
+ /** Attach the native checkpoint command to the hidden `hq core` group. */
616
+ export function registerCoreCheckpointCommand(core) {
617
+ core
618
+ .command("checkpoint")
619
+ .description("Record an end-of-turn checkpoint and start HQ maintenance")
620
+ .option("--summary <text>", "1–2 sentence outcome summary")
621
+ .option("--file <path>", "file touched this turn", collect, [])
622
+ .option("--learning <text>", "reusable lesson", collect, [])
623
+ .option("--insight <text>", "session insight", collect, [])
624
+ .option("--decision <text>", "decision made", collect, [])
625
+ .option("--next <text>", "remaining next step", collect, [])
626
+ .option("--tag <tag>", "checkpoint tag", collect, [])
627
+ .option("--commit <entry>", "session commit (<hash>: <message>)", collect, [])
628
+ .option("--initial-commit <sha>", "commit at the start of this session")
629
+ .option("--worker <id>", "active worker identifier")
630
+ .option("--worker-skill <name>", "active worker skill")
631
+ .option("--worker-state <state>", "active worker state")
632
+ .option("--trigger <name>", "checkpoint trigger", DEFAULT_TRIGGER)
633
+ .option("--company <slug>", "active company scope")
634
+ .option("--session-id <id>", "caller session id (default: $CLAUDE_CODE_SESSION_ID when set)")
635
+ .option("--transcript <path>", "session transcript path")
636
+ .option("--payload <file|->", "JSON payload file, or - for stdin")
637
+ .option("--idle", "touch stamps without a checkpoint")
638
+ .option("--no-agent", "do not spawn the maintenance sibling")
639
+ .option("--backend <auto|claude|codex|none>", "sibling backend", "auto")
640
+ .option("--gate-probe", "write the local Stop-hook eligibility verdict")
641
+ .option("--hq-root <path>", "HQ installation to operate on")
642
+ .option("--dry-run", "print the planned checkpoint without writing")
643
+ .action((options, command) => {
644
+ try {
645
+ runCheckpoint(options, command, core);
646
+ }
647
+ catch (error) {
648
+ if (error instanceof CheckpointUsageError)
649
+ reportUsage(error);
650
+ throw error;
651
+ }
652
+ });
653
+ }
654
+ //# sourceMappingURL=core-checkpoint.js.map
@@ -29,6 +29,7 @@
29
29
  * `ScaffoldRoot`.
30
30
  */
31
31
  import { Option } from "commander";
32
+ import { registerCoreCheckpointCommand } from "./core-checkpoint.js";
32
33
  import { resolveLiveRoot } from "../utils/hq-roots.js";
33
34
  import { runBundledScript } from "../utils/run-bundled-script.js";
34
35
  /**
@@ -233,6 +234,9 @@ export function registerCoreCommands(program) {
233
234
  // collide with a wrapped script's own flags — everything after the
234
235
  // subcommand name is passed through untouched.
235
236
  .addOption(new Option("--hq-root <path>", "HQ installation to operate on (live-root commands)").hideHelp());
237
+ // This group primarily hosts manifest-driven bundled assets, but it also
238
+ // hosts native TypeScript plumbing when a scaffold contract needs it.
239
+ registerCoreCheckpointCommand(core);
236
240
  const runCommand = (entry, args = [], cmd, target) => {
237
241
  const scope = core.opts();
238
242
  // `cmd.args` is the authoritative operand list: with
@@ -3,24 +3,19 @@
3
3
  */
4
4
  import chalk from 'chalk';
5
5
  import { loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, } from '@indigoai-us/hq-cloud';
6
+ import { peekIdToken as decodeIdToken } from "../utils/id-token.js";
6
7
  function peekIdToken(idToken) {
7
- try {
8
- const payload = idToken.split('.')[1];
9
- if (!payload)
10
- return {};
11
- const pad = payload.length % 4 === 0 ? '' : '='.repeat(4 - (payload.length % 4));
12
- const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + pad;
13
- const decoded = JSON.parse(Buffer.from(normalized, 'base64').toString('utf-8'));
14
- return {
15
- email: decoded.email,
16
- sub: decoded.sub,
17
- entityType: decoded['custom:entityType'],
18
- entityUid: decoded['custom:entityUid'],
19
- };
20
- }
21
- catch {
22
- return {};
23
- }
8
+ const decoded = decodeIdToken(idToken);
9
+ return {
10
+ email: typeof decoded.email === "string" ? decoded.email : undefined,
11
+ sub: typeof decoded.sub === "string" ? decoded.sub : undefined,
12
+ entityType: typeof decoded["custom:entityType"] === "string"
13
+ ? decoded["custom:entityType"]
14
+ : undefined,
15
+ entityUid: typeof decoded["custom:entityUid"] === "string"
16
+ ? decoded["custom:entityUid"]
17
+ : undefined,
18
+ };
24
19
  }
25
20
  export function registerWhoamiCommand(program) {
26
21
  program
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Decode the payload of a locally cached JWT without verifying its signature.
3
+ *
4
+ * Callers use this only for data already trusted locally (display labels and
5
+ * local eligibility checks), never to authorize a remote request.
6
+ */
7
+ export declare function peekIdToken(idToken: string): Record<string, unknown>;
8
+ //# sourceMappingURL=id-token.d.ts.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Decode the payload of a locally cached JWT without verifying its signature.
3
+ *
4
+ * Callers use this only for data already trusted locally (display labels and
5
+ * local eligibility checks), never to authorize a remote request.
6
+ */
7
+ export function peekIdToken(idToken) {
8
+ try {
9
+ const payload = idToken.split(".")[1];
10
+ if (!payload)
11
+ return {};
12
+ const pad = payload.length % 4 === 0 ? "" : "=".repeat(4 - (payload.length % 4));
13
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/") + pad;
14
+ const decoded = JSON.parse(Buffer.from(normalized, "base64").toString("utf-8"));
15
+ return decoded && typeof decoded === "object" && !Array.isArray(decoded)
16
+ ? decoded
17
+ : {};
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ }
23
+ //# sourceMappingURL=id-token.js.map
@@ -166,6 +166,20 @@ function samePayloadFile(a, b) {
166
166
  return false;
167
167
  }
168
168
  }
169
+ /** True when the symlink target resolves to the declared payload (dir or file inside it). */
170
+ function pointsAtDeclaredPayload(link, resolvedTarget) {
171
+ if (samePayloadFile(link.src, resolvedTarget))
172
+ return true;
173
+ try {
174
+ const root = canonicalPath(link.src);
175
+ const resolved = canonicalPath(resolvedTarget);
176
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
177
+ return resolved === root || resolved.startsWith(prefix);
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ }
169
183
  /** Classify a host path against the link that should own it. */
170
184
  export function linkStatus(link, packDir) {
171
185
  let st;
@@ -190,7 +204,7 @@ export function linkStatus(link, packDir) {
190
204
  // aliases classify as ours; optionally accept stale targets that still resolve
191
205
  // inside this pack's directory.
192
206
  const resolvedTarget = path.resolve(path.dirname(link.dst), target);
193
- if (samePayloadFile(link.src, resolvedTarget)) {
207
+ if (pointsAtDeclaredPayload(link, resolvedTarget)) {
194
208
  return fs.existsSync(link.src) ? 'live' : 'broken';
195
209
  }
196
210
  if (packDir !== undefined && targetUnderPackDir(packDir, resolvedTarget)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.80.0",
3
+ "version": "5.82.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {