@indigoai-us/hq-cli 5.80.0 → 5.81.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 +12 -0
- package/dist/commands/core-checkpoint.d.ts +18 -0
- package/dist/commands/core-checkpoint.js +544 -0
- package/dist/commands/core.js +4 -0
- package/dist/commands/whoami.js +12 -17
- package/dist/utils/id-token.d.ts +8 -0
- package/dist/utils/id-token.js +23 -0
- package/dist/utils/pack-contributions.js +15 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.81.0]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Hidden `hq core checkpoint`: end-of-turn checkpoint + detached background
|
|
10
|
+
maintenance sibling (codex-first, pinned `gpt-5.6-terra` @ high effort;
|
|
11
|
+
claude fallback pinned `claude-opus-5` @ medium). Sibling distills
|
|
12
|
+
learnings into policies/knowledge under a single-flight lock;
|
|
13
|
+
`--gate-probe` caches Stop-gate eligibility (operator rollout domain,
|
|
14
|
+
`HQ_CHECKPOINT_GATE`/`HQ_CHECKPOINT_GATE_DOMAINS` overrides); session id
|
|
15
|
+
inferred from `CLAUDE_CODE_SESSION_ID` when omitted. (#286)
|
|
16
|
+
|
|
5
17
|
## [5.79.0]
|
|
6
18
|
|
|
7
19
|
### 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, you may read\n its tail for context (last ~200 lines); never quote secrets from it.\n2. Verify the checkpoint thread file named in the payload exists and is valid\n JSON; repair or enrich it if needed (keep its shape).\n3. For each entry in \"learnings\": if it is a reusable rule, distill it into a\n policy file under personal/policies/ (or companies/<company>/policies/ when\n the payload names a company and the lesson is company-specific), following\n core/knowledge/public/hq-core/policies-spec.md. Skip duplicates \u2014 search\n existing policies first.\n4. For durable facts (not rules), update knowledge under personal/knowledge/\n or companies/<company>/knowledge/.\n5. Hook or automation improvements go ONLY under personal/hooks/ as proposals.\n You must never write into .claude/, core/, .agents/, .codex/, or repos/.\n6. Write <runDir>/report.md \u2014 full prose: what you read, what you changed\n (paths), what you skipped and why.\n7. If workspace/checkpoints/sibling/pending.jsonl is non-empty when you\n finish, process those payloads the same way, 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,544 @@
|
|
|
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, you may read
|
|
40
|
+
its tail for context (last ~200 lines); never quote secrets from it.
|
|
41
|
+
2. Verify the checkpoint thread file named in the payload exists and is valid
|
|
42
|
+
JSON; repair or enrich it if needed (keep its shape).
|
|
43
|
+
3. For each entry in "learnings": if it is a reusable rule, distill it into a
|
|
44
|
+
policy file under personal/policies/ (or companies/<company>/policies/ when
|
|
45
|
+
the payload names a company and the lesson is company-specific), following
|
|
46
|
+
core/knowledge/public/hq-core/policies-spec.md. Skip duplicates — search
|
|
47
|
+
existing policies first.
|
|
48
|
+
4. For durable facts (not rules), update knowledge under personal/knowledge/
|
|
49
|
+
or companies/<company>/knowledge/.
|
|
50
|
+
5. Hook or automation improvements go ONLY under personal/hooks/ as proposals.
|
|
51
|
+
You must never write into .claude/, core/, .agents/, .codex/, or repos/.
|
|
52
|
+
6. Write <runDir>/report.md — full prose: what you read, what you changed
|
|
53
|
+
(paths), what you skipped and why.
|
|
54
|
+
7. If workspace/checkpoints/sibling/pending.jsonl is non-empty when you
|
|
55
|
+
finish, process those payloads the same way, then truncate the file.
|
|
56
|
+
`;
|
|
57
|
+
export function renderSiblingPrompt(runDir, payloadPath) {
|
|
58
|
+
return SIBLING_PROMPT_TEMPLATE
|
|
59
|
+
.replaceAll("<runDir>", () => runDir)
|
|
60
|
+
.replaceAll("<payloadPath>", () => payloadPath);
|
|
61
|
+
}
|
|
62
|
+
function collect(value, previous) {
|
|
63
|
+
return [...previous, value];
|
|
64
|
+
}
|
|
65
|
+
function usage(message) {
|
|
66
|
+
throw new CheckpointUsageError(message);
|
|
67
|
+
}
|
|
68
|
+
function readPayload(payloadPath) {
|
|
69
|
+
if (!payloadPath)
|
|
70
|
+
return {};
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = payloadPath === "-"
|
|
74
|
+
? fs.readFileSync(0, "utf8")
|
|
75
|
+
: fs.readFileSync(payloadPath, "utf8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
usage(`checkpoint: could not read payload: ${payloadPath}`);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(raw);
|
|
82
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
83
|
+
usage("checkpoint: payload must be a JSON object");
|
|
84
|
+
}
|
|
85
|
+
return parsed;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error instanceof CheckpointUsageError)
|
|
89
|
+
throw error;
|
|
90
|
+
usage("checkpoint: payload is not valid JSON");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function asOptionalString(value, field) {
|
|
94
|
+
if (value === undefined || value === null)
|
|
95
|
+
return undefined;
|
|
96
|
+
if (typeof value !== "string")
|
|
97
|
+
usage(`checkpoint: payload ${field} must be a string`);
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function asStringList(value, field) {
|
|
101
|
+
if (value === undefined || value === null)
|
|
102
|
+
return [];
|
|
103
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
104
|
+
usage(`checkpoint: payload ${field} must be an array of strings`);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
function wasPassed(command, option) {
|
|
109
|
+
return command.getOptionValueSource(option) === "cli";
|
|
110
|
+
}
|
|
111
|
+
function inferredSessionId() {
|
|
112
|
+
// Deliberately do not fall back to the newest transcript under
|
|
113
|
+
// ~/.claude/projects/<munged-cwd>: concurrent sessions make its mtime
|
|
114
|
+
// unreliable and can mis-attribute a checkpoint. Env var or nothing.
|
|
115
|
+
return process.env.CLAUDE_CODE_SESSION_ID?.trim() || undefined;
|
|
116
|
+
}
|
|
117
|
+
function resolveSessionId(options, command, payload) {
|
|
118
|
+
if (wasPassed(command, "sessionId"))
|
|
119
|
+
return options.sessionId;
|
|
120
|
+
return asOptionalString(payload.session_id, "session_id") ?? inferredSessionId();
|
|
121
|
+
}
|
|
122
|
+
function mergeInput(options, command, payload) {
|
|
123
|
+
const summary = wasPassed(command, "summary")
|
|
124
|
+
? options.summary
|
|
125
|
+
: asOptionalString(payload.summary, "summary");
|
|
126
|
+
const files = wasPassed(command, "file")
|
|
127
|
+
? options.file
|
|
128
|
+
: asStringList(payload.files, "files");
|
|
129
|
+
const learnings = wasPassed(command, "learning")
|
|
130
|
+
? options.learning
|
|
131
|
+
: asStringList(payload.learnings, "learnings");
|
|
132
|
+
const decisions = wasPassed(command, "decision")
|
|
133
|
+
? options.decision
|
|
134
|
+
: asStringList(payload.decisions, "decisions");
|
|
135
|
+
const nextSteps = wasPassed(command, "next")
|
|
136
|
+
? options.next
|
|
137
|
+
: asStringList(payload.next_steps, "next_steps");
|
|
138
|
+
const tags = wasPassed(command, "tag")
|
|
139
|
+
? options.tag
|
|
140
|
+
: asStringList(payload.tags, "tags");
|
|
141
|
+
const trigger = (wasPassed(command, "trigger")
|
|
142
|
+
? options.trigger
|
|
143
|
+
: asOptionalString(payload.trigger, "trigger")) ?? DEFAULT_TRIGGER;
|
|
144
|
+
const company = wasPassed(command, "company")
|
|
145
|
+
? options.company
|
|
146
|
+
: asOptionalString(payload.company, "company");
|
|
147
|
+
const sessionId = resolveSessionId(options, command, payload);
|
|
148
|
+
const transcript = wasPassed(command, "transcript")
|
|
149
|
+
? options.transcript
|
|
150
|
+
: asOptionalString(payload.transcript, "transcript");
|
|
151
|
+
return {
|
|
152
|
+
summary,
|
|
153
|
+
files,
|
|
154
|
+
learnings,
|
|
155
|
+
decisions,
|
|
156
|
+
nextSteps,
|
|
157
|
+
tags,
|
|
158
|
+
trigger,
|
|
159
|
+
company,
|
|
160
|
+
sessionId,
|
|
161
|
+
transcript,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function formatTimestamp(date) {
|
|
165
|
+
const part = (value) => value.toString().padStart(2, "0");
|
|
166
|
+
return `${date.getUTCFullYear()}${part(date.getUTCMonth() + 1)}${part(date.getUTCDate())}-${part(date.getUTCHours())}${part(date.getUTCMinutes())}${part(date.getUTCSeconds())}`;
|
|
167
|
+
}
|
|
168
|
+
function summarySlug(summary) {
|
|
169
|
+
const words = summary.toLowerCase().match(/[a-z0-9]+/g)?.slice(0, 4) ?? [];
|
|
170
|
+
if (words.length === 0)
|
|
171
|
+
return "checkpoint-update";
|
|
172
|
+
if (words.length === 1)
|
|
173
|
+
words.push("update");
|
|
174
|
+
const slug = words.join("-").slice(0, 40).replace(/-+$/, "");
|
|
175
|
+
return slug || "checkpoint-update";
|
|
176
|
+
}
|
|
177
|
+
function titleFor(summary) {
|
|
178
|
+
const truncated = summary.length > 60 ? `${summary.slice(0, 57).trimEnd()}...` : summary;
|
|
179
|
+
return `Auto: ${truncated}`;
|
|
180
|
+
}
|
|
181
|
+
function readGitState(repoDir) {
|
|
182
|
+
try {
|
|
183
|
+
const read = (args) => execFileSync("git", ["-C", repoDir, ...args], {
|
|
184
|
+
encoding: "utf8",
|
|
185
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
186
|
+
}).trim();
|
|
187
|
+
if (read(["rev-parse", "--is-inside-work-tree"]) !== "true")
|
|
188
|
+
return null;
|
|
189
|
+
return {
|
|
190
|
+
branch: read(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
191
|
+
current_commit: read(["rev-parse", "--short", "HEAD"]),
|
|
192
|
+
dirty: read(["status", "--porcelain"]) !== "",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function gitStateFor(cwd, liveRoot) {
|
|
200
|
+
return (readGitState(cwd) ??
|
|
201
|
+
readGitState(liveRoot) ?? {
|
|
202
|
+
branch: "unknown",
|
|
203
|
+
current_commit: "unknown",
|
|
204
|
+
dirty: false,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function writeStamps(liveRoot, sessionId) {
|
|
208
|
+
const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
|
|
209
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
210
|
+
const sessionKey = sessionId
|
|
211
|
+
? sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown"
|
|
212
|
+
: "unknown";
|
|
213
|
+
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
214
|
+
const stampPaths = [
|
|
215
|
+
path.join(stateDir, "checkpoint-cli-last"),
|
|
216
|
+
path.join(stateDir, `checkpoint-cli-last-${sessionKey}`),
|
|
217
|
+
];
|
|
218
|
+
for (const stampPath of stampPaths)
|
|
219
|
+
fs.writeFileSync(stampPath, timestamp);
|
|
220
|
+
return stampPaths;
|
|
221
|
+
}
|
|
222
|
+
function gateEligibility() {
|
|
223
|
+
const forced = process.env.HQ_CHECKPOINT_GATE;
|
|
224
|
+
if (forced === "0")
|
|
225
|
+
return false;
|
|
226
|
+
if (forced === "1")
|
|
227
|
+
return true;
|
|
228
|
+
try {
|
|
229
|
+
const tokenPath = path.join(homedir(), ".hq", "cognito-tokens.json");
|
|
230
|
+
const tokens = JSON.parse(fs.readFileSync(tokenPath, "utf8"));
|
|
231
|
+
if (typeof tokens.idToken !== "string")
|
|
232
|
+
return false;
|
|
233
|
+
const email = peekIdToken(tokens.idToken).email;
|
|
234
|
+
if (typeof email !== "string")
|
|
235
|
+
return false;
|
|
236
|
+
const domain = email.split("@").at(-1)?.toLowerCase();
|
|
237
|
+
if (!domain)
|
|
238
|
+
return false;
|
|
239
|
+
const configured = (process.env.HQ_CHECKPOINT_GATE_DOMAINS ?? "")
|
|
240
|
+
.split(",")
|
|
241
|
+
.map((candidate) => candidate.trim().toLowerCase())
|
|
242
|
+
.filter(Boolean);
|
|
243
|
+
return domain === "getindigo.ai" || configured.includes(domain);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function writeGateVerdict(liveRoot) {
|
|
250
|
+
const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
|
|
251
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
252
|
+
const eligible = gateEligibility();
|
|
253
|
+
fs.writeFileSync(path.join(stateDir, "checkpoint-gate-eligible"), eligible ? "1" : "0");
|
|
254
|
+
printResult(eligible ? "eligible" : "ineligible");
|
|
255
|
+
}
|
|
256
|
+
function backendOnPath(name) {
|
|
257
|
+
const pathValue = process.env.PATH;
|
|
258
|
+
if (!pathValue)
|
|
259
|
+
return false;
|
|
260
|
+
return pathValue.split(path.delimiter).some((directory) => {
|
|
261
|
+
if (!directory)
|
|
262
|
+
return false;
|
|
263
|
+
try {
|
|
264
|
+
fs.accessSync(path.join(directory, name), fs.constants.X_OK);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function resolveBackend(requested) {
|
|
273
|
+
const value = requested ?? "auto";
|
|
274
|
+
if (!BACKENDS.has(value))
|
|
275
|
+
usage(`checkpoint: unknown backend: ${value}`);
|
|
276
|
+
if (value === "auto") {
|
|
277
|
+
if (backendOnPath("codex"))
|
|
278
|
+
return "codex";
|
|
279
|
+
if (backendOnPath("claude"))
|
|
280
|
+
return "claude";
|
|
281
|
+
return "none";
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
function siblingPayload(input, threadPath) {
|
|
286
|
+
return {
|
|
287
|
+
summary: input.summary ?? "",
|
|
288
|
+
files: input.files,
|
|
289
|
+
learnings: input.learnings,
|
|
290
|
+
decisions: input.decisions,
|
|
291
|
+
next_steps: input.nextSteps,
|
|
292
|
+
tags: input.tags,
|
|
293
|
+
trigger: input.trigger,
|
|
294
|
+
company: input.company ?? null,
|
|
295
|
+
session_id: input.sessionId ?? null,
|
|
296
|
+
transcript: input.transcript ?? null,
|
|
297
|
+
thread_path: threadPath,
|
|
298
|
+
pending_payloads: [],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function existingSiblingPid(lockPath) {
|
|
302
|
+
try {
|
|
303
|
+
const pid = Number.parseInt(fs.readFileSync(lockPath, "utf8").trim(), 10);
|
|
304
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
305
|
+
return null;
|
|
306
|
+
process.kill(pid, 0);
|
|
307
|
+
return pid;
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (error?.code === "EPERM") {
|
|
311
|
+
try {
|
|
312
|
+
return Number.parseInt(fs.readFileSync(lockPath, "utf8").trim(), 10);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function drainPending(pendingPath) {
|
|
322
|
+
if (!fs.existsSync(pendingPath))
|
|
323
|
+
return [];
|
|
324
|
+
const entries = [];
|
|
325
|
+
for (const line of fs.readFileSync(pendingPath, "utf8").split("\n")) {
|
|
326
|
+
if (!line.trim())
|
|
327
|
+
continue;
|
|
328
|
+
try {
|
|
329
|
+
entries.push(JSON.parse(line));
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// A partial line must not prevent a future sibling from processing the
|
|
333
|
+
// valid queued payloads around it.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
fs.writeFileSync(pendingPath, "");
|
|
337
|
+
return entries;
|
|
338
|
+
}
|
|
339
|
+
function startSibling(liveRoot, input, threadPath, backend) {
|
|
340
|
+
const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
|
|
341
|
+
const siblingRoot = path.join(liveRoot, "workspace", "checkpoints", "sibling");
|
|
342
|
+
const pendingPath = path.join(siblingRoot, "pending.jsonl");
|
|
343
|
+
const lockPath = path.join(stateDir, "checkpoint-sibling.lock");
|
|
344
|
+
const payload = siblingPayload(input, threadPath);
|
|
345
|
+
const activePid = existingSiblingPid(lockPath);
|
|
346
|
+
fs.mkdirSync(siblingRoot, { recursive: true });
|
|
347
|
+
if (activePid !== null) {
|
|
348
|
+
fs.appendFileSync(pendingPath, `${JSON.stringify(payload)}\n`);
|
|
349
|
+
printResult("checkpoint: sibling busy — payload queued");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const runDir = path.join(siblingRoot, `${formatTimestamp(new Date())}-${summarySlug(input.summary ?? "checkpoint")}`);
|
|
353
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
354
|
+
payload.pending_payloads = drainPending(pendingPath);
|
|
355
|
+
const payloadPath = path.join(runDir, "payload.json");
|
|
356
|
+
fs.writeFileSync(payloadPath, `${JSON.stringify(payload, null, 2)}\n`);
|
|
357
|
+
const prompt = renderSiblingPrompt(runDir, payloadPath);
|
|
358
|
+
fs.writeFileSync(path.join(runDir, "prompt.md"), prompt);
|
|
359
|
+
const logPath = path.join(runDir, "run.log");
|
|
360
|
+
const logFd = fs.openSync(logPath, "a");
|
|
361
|
+
const args = backend === "claude"
|
|
362
|
+
? [
|
|
363
|
+
"-p",
|
|
364
|
+
prompt,
|
|
365
|
+
"--model",
|
|
366
|
+
CLAUDE_SIBLING_MODEL,
|
|
367
|
+
"--effort",
|
|
368
|
+
CLAUDE_SIBLING_EFFORT,
|
|
369
|
+
"--permission-mode",
|
|
370
|
+
"acceptEdits",
|
|
371
|
+
]
|
|
372
|
+
: [
|
|
373
|
+
"exec",
|
|
374
|
+
"--skip-git-repo-check",
|
|
375
|
+
"-s",
|
|
376
|
+
"workspace-write",
|
|
377
|
+
"--dangerously-bypass-hook-trust",
|
|
378
|
+
"-m",
|
|
379
|
+
CODEX_SIBLING_MODEL,
|
|
380
|
+
"-c",
|
|
381
|
+
`model_reasoning_effort=${CODEX_SIBLING_REASONING_EFFORT}`,
|
|
382
|
+
prompt,
|
|
383
|
+
];
|
|
384
|
+
let child;
|
|
385
|
+
try {
|
|
386
|
+
child = spawn(backend, args, {
|
|
387
|
+
detached: true,
|
|
388
|
+
cwd: liveRoot,
|
|
389
|
+
stdio: ["ignore", logFd, logFd],
|
|
390
|
+
// This prevents the scaffold Stop hook from recursively launching a
|
|
391
|
+
// sibling when the background agent itself finishes a turn.
|
|
392
|
+
env: {
|
|
393
|
+
...process.env,
|
|
394
|
+
HQ_CHECKPOINT_SIBLING: "1",
|
|
395
|
+
...(backend === "claude" ? { CLAUDE_EFFORT: CLAUDE_SIBLING_EFFORT } : {}),
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
fs.closeSync(logFd);
|
|
401
|
+
}
|
|
402
|
+
child.unref();
|
|
403
|
+
if (!child.pid)
|
|
404
|
+
throw new Error("checkpoint: sibling did not return a PID");
|
|
405
|
+
fs.writeFileSync(lockPath, String(child.pid));
|
|
406
|
+
printResult(`checkpoint: sibling started (pid ${child.pid})`);
|
|
407
|
+
}
|
|
408
|
+
function hasGateProbeConflict(options, command) {
|
|
409
|
+
return [
|
|
410
|
+
"summary",
|
|
411
|
+
"file",
|
|
412
|
+
"learning",
|
|
413
|
+
"decision",
|
|
414
|
+
"next",
|
|
415
|
+
"tag",
|
|
416
|
+
"trigger",
|
|
417
|
+
"company",
|
|
418
|
+
"sessionId",
|
|
419
|
+
"transcript",
|
|
420
|
+
"payload",
|
|
421
|
+
"idle",
|
|
422
|
+
"agent",
|
|
423
|
+
"backend",
|
|
424
|
+
"dryRun",
|
|
425
|
+
].some((option) => wasPassed(command, option));
|
|
426
|
+
}
|
|
427
|
+
function printDryRun(plan) {
|
|
428
|
+
printResult(JSON.stringify(plan, null, 2));
|
|
429
|
+
}
|
|
430
|
+
function runCheckpoint(options, command, group) {
|
|
431
|
+
const hqRoot = options.hqRoot ?? group.opts().hqRoot;
|
|
432
|
+
const liveRoot = resolveLiveRoot({ hqRoot });
|
|
433
|
+
if (options.gateProbe) {
|
|
434
|
+
if (hasGateProbeConflict(options, command)) {
|
|
435
|
+
usage("checkpoint: --gate-probe cannot be combined with checkpoint options");
|
|
436
|
+
}
|
|
437
|
+
writeGateVerdict(liveRoot);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (options.idle) {
|
|
441
|
+
if (options.dryRun) {
|
|
442
|
+
printDryRun({ live_root: liveRoot, idle: true, stamps: [], thread: null, sibling: null });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
writeStamps(liveRoot, resolveSessionId(options, command, readPayload(options.payload)));
|
|
446
|
+
printResult("checkpoint: idle (nothing to record)");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const payload = readPayload(options.payload);
|
|
450
|
+
const input = mergeInput(options, command, payload);
|
|
451
|
+
if (!input.summary?.trim()) {
|
|
452
|
+
usage("checkpoint: --summary is required unless --idle or --gate-probe is used");
|
|
453
|
+
}
|
|
454
|
+
const backend = resolveBackend(options.backend);
|
|
455
|
+
const now = new Date();
|
|
456
|
+
const threadId = `T-${formatTimestamp(now)}-auto-${summarySlug(input.summary)}`;
|
|
457
|
+
const threadPath = path.join(liveRoot, "workspace", "threads", `${threadId}.json`);
|
|
458
|
+
const relativeThreadPath = path.relative(liveRoot, threadPath);
|
|
459
|
+
const stampPaths = [
|
|
460
|
+
path.join(liveRoot, "workspace", "orchestrator", "hook-state", "checkpoint-cli-last"),
|
|
461
|
+
path.join(liveRoot, "workspace", "orchestrator", "hook-state", `checkpoint-cli-last-${input.sessionId ? input.sessionId.replace(/[^A-Za-z0-9._-]/g, "_") || "unknown" : "unknown"}`),
|
|
462
|
+
];
|
|
463
|
+
if (options.dryRun) {
|
|
464
|
+
printDryRun({
|
|
465
|
+
live_root: liveRoot,
|
|
466
|
+
idle: false,
|
|
467
|
+
thread: relativeThreadPath,
|
|
468
|
+
stamps: stampPaths.map((stampPath) => path.relative(liveRoot, stampPath)),
|
|
469
|
+
sibling: options.agent === false ? null : { backend },
|
|
470
|
+
});
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const thread = {
|
|
474
|
+
thread_id: threadId,
|
|
475
|
+
version: 1,
|
|
476
|
+
type: "auto-checkpoint",
|
|
477
|
+
created_at: now.toISOString(),
|
|
478
|
+
updated_at: now.toISOString(),
|
|
479
|
+
workspace_root: liveRoot,
|
|
480
|
+
cwd: process.cwd(),
|
|
481
|
+
git: gitStateFor(process.cwd(), liveRoot),
|
|
482
|
+
conversation_summary: input.summary,
|
|
483
|
+
files_touched: input.files,
|
|
484
|
+
next_steps: input.nextSteps,
|
|
485
|
+
learnings: input.learnings,
|
|
486
|
+
decisions: input.decisions,
|
|
487
|
+
session_id: input.sessionId ?? null,
|
|
488
|
+
metadata: {
|
|
489
|
+
title: titleFor(input.summary),
|
|
490
|
+
tags: ["auto-checkpoint", ...input.tags],
|
|
491
|
+
trigger: input.trigger,
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
fs.mkdirSync(path.dirname(threadPath), { recursive: true });
|
|
495
|
+
fs.writeFileSync(threadPath, `${JSON.stringify(thread, null, 2)}\n`);
|
|
496
|
+
writeStamps(liveRoot, input.sessionId);
|
|
497
|
+
printResult(`checkpoint: ${relativeThreadPath}`);
|
|
498
|
+
if (options.agent === false)
|
|
499
|
+
return;
|
|
500
|
+
if (backend === "none") {
|
|
501
|
+
printResult("checkpoint: sibling disabled (backend none)");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
startSibling(liveRoot, input, threadPath, backend);
|
|
505
|
+
}
|
|
506
|
+
function reportUsage(error) {
|
|
507
|
+
printError("Usage: hq core checkpoint --summary <text> [options]");
|
|
508
|
+
printError(error.message);
|
|
509
|
+
process.exit(2);
|
|
510
|
+
}
|
|
511
|
+
/** Attach the native checkpoint command to the hidden `hq core` group. */
|
|
512
|
+
export function registerCoreCheckpointCommand(core) {
|
|
513
|
+
core
|
|
514
|
+
.command("checkpoint")
|
|
515
|
+
.description("Record an end-of-turn checkpoint and start HQ maintenance")
|
|
516
|
+
.option("--summary <text>", "1–2 sentence outcome summary")
|
|
517
|
+
.option("--file <path>", "file touched this turn", collect, [])
|
|
518
|
+
.option("--learning <text>", "reusable lesson", collect, [])
|
|
519
|
+
.option("--decision <text>", "decision made", collect, [])
|
|
520
|
+
.option("--next <text>", "remaining next step", collect, [])
|
|
521
|
+
.option("--tag <tag>", "checkpoint tag", collect, [])
|
|
522
|
+
.option("--trigger <name>", "checkpoint trigger", DEFAULT_TRIGGER)
|
|
523
|
+
.option("--company <slug>", "active company scope")
|
|
524
|
+
.option("--session-id <id>", "caller session id (default: $CLAUDE_CODE_SESSION_ID when set)")
|
|
525
|
+
.option("--transcript <path>", "session transcript path")
|
|
526
|
+
.option("--payload <file|->", "JSON payload file, or - for stdin")
|
|
527
|
+
.option("--idle", "touch stamps without a checkpoint")
|
|
528
|
+
.option("--no-agent", "do not spawn the maintenance sibling")
|
|
529
|
+
.option("--backend <auto|claude|codex|none>", "sibling backend", "auto")
|
|
530
|
+
.option("--gate-probe", "write the local Stop-hook eligibility verdict")
|
|
531
|
+
.option("--hq-root <path>", "HQ installation to operate on")
|
|
532
|
+
.option("--dry-run", "print the planned checkpoint without writing")
|
|
533
|
+
.action((options, command) => {
|
|
534
|
+
try {
|
|
535
|
+
runCheckpoint(options, command, core);
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
if (error instanceof CheckpointUsageError)
|
|
539
|
+
reportUsage(error);
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
//# sourceMappingURL=core-checkpoint.js.map
|
package/dist/commands/core.js
CHANGED
|
@@ -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
|
package/dist/commands/whoami.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 (
|
|
207
|
+
if (pointsAtDeclaredPayload(link, resolvedTarget)) {
|
|
194
208
|
return fs.existsSync(link.src) ? 'live' : 'broken';
|
|
195
209
|
}
|
|
196
210
|
if (packDir !== undefined && targetUnderPackDir(packDir, resolvedTarget)) {
|