@indigoai-us/hq-cli 5.79.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/cloud.d.ts +9 -0
- package/dist/commands/cloud.js +53 -1
- 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/packs.js +1 -1
- package/dist/commands/whoami.js +12 -17
- package/dist/index.js +6 -2
- package/dist/utils/id-token.d.ts +8 -0
- package/dist/utils/id-token.js +23 -0
- package/dist/utils/pack-contributions.d.ts +6 -1
- package/dist/utils/pack-contributions.js +63 -7
- package/package.json +2 -2
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
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ import { type BannerLevel } from "../lib/narrow-hint-banner.js";
|
|
|
22
22
|
* succeed when files were dropped" guarantee unit-testable.
|
|
23
23
|
*/
|
|
24
24
|
export declare function scopeExcludedWarning(count: number): string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Optional detail line listing the effective write prefixes when a push
|
|
27
|
+
* dropped files — helps debug ACL vs scope-excluded mismatches (feedback_7e9c535e).
|
|
28
|
+
*/
|
|
29
|
+
export declare function scopeExcludedPrefixDetail(prefixSet: readonly string[] | undefined): string | null;
|
|
25
30
|
/**
|
|
26
31
|
* Bound foreground waits for a watcher/manual sync that currently owns the
|
|
27
32
|
* per-root operation lock. The cloud engine reads this environment value on
|
|
@@ -159,6 +164,8 @@ export interface ShareCallOptions {
|
|
|
159
164
|
skipUnchanged?: boolean;
|
|
160
165
|
propagateDeletes?: boolean;
|
|
161
166
|
propagateDeletePolicy?: "owned-only" | "currency-gated" | "all";
|
|
167
|
+
/** DEV-1768 push parity: filter upload plan to granted ACL prefixes. */
|
|
168
|
+
prefixSet?: PullScope["prefixSet"];
|
|
162
169
|
}
|
|
163
170
|
export interface ShareCallResult {
|
|
164
171
|
filesUploaded: number;
|
|
@@ -172,6 +179,8 @@ export interface ShareCallResult {
|
|
|
172
179
|
export interface PushAllDeps {
|
|
173
180
|
vaultClient: PullAllVaultClient;
|
|
174
181
|
share: (options: ShareCallOptions) => Promise<ShareCallResult>;
|
|
182
|
+
/** DEV-1768: resolve membership push scope (same as pull). */
|
|
183
|
+
resolveScope?: (companyUid: string, slug: string) => Promise<PullScope>;
|
|
175
184
|
}
|
|
176
185
|
export interface PushAllOptions {
|
|
177
186
|
hqRoot: string;
|
package/dist/commands/cloud.js
CHANGED
|
@@ -32,6 +32,16 @@ export function scopeExcludedWarning(count) {
|
|
|
32
32
|
`They were skipped, not synced. Re-run with --json to list them, or ` +
|
|
33
33
|
`ask an admin to grant you write on those paths.`);
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Optional detail line listing the effective write prefixes when a push
|
|
37
|
+
* dropped files — helps debug ACL vs scope-excluded mismatches (feedback_7e9c535e).
|
|
38
|
+
*/
|
|
39
|
+
export function scopeExcludedPrefixDetail(prefixSet) {
|
|
40
|
+
if (!prefixSet || prefixSet.length === 0) {
|
|
41
|
+
return " Granted write prefixes: (none — shared-mode membership with no explicit grants)";
|
|
42
|
+
}
|
|
43
|
+
return ` Granted write prefixes: ${prefixSet.join(", ")}`;
|
|
44
|
+
}
|
|
35
45
|
/**
|
|
36
46
|
* Resolve the `propagateDeletePolicy` for share() calls.
|
|
37
47
|
*
|
|
@@ -268,6 +278,7 @@ export async function pushAll(options, deps) {
|
|
|
268
278
|
}
|
|
269
279
|
plan.push({
|
|
270
280
|
slug,
|
|
281
|
+
companyUid: m.companyUid,
|
|
271
282
|
shareOptions: {
|
|
272
283
|
company: m.companyUid,
|
|
273
284
|
hqRoot: options.hqRoot,
|
|
@@ -284,6 +295,7 @@ export async function pushAll(options, deps) {
|
|
|
284
295
|
if (personal) {
|
|
285
296
|
plan.push({
|
|
286
297
|
slug: "personal",
|
|
298
|
+
companyUid: personal.uid,
|
|
287
299
|
shareOptions: {
|
|
288
300
|
company: personal.uid,
|
|
289
301
|
hqRoot: options.hqRoot,
|
|
@@ -309,6 +321,17 @@ export async function pushAll(options, deps) {
|
|
|
309
321
|
};
|
|
310
322
|
for (const entry of plan) {
|
|
311
323
|
result.attempted += 1;
|
|
324
|
+
if (entry.slug !== "personal" && deps.resolveScope) {
|
|
325
|
+
try {
|
|
326
|
+
const scope = await deps.resolveScope(entry.companyUid, entry.slug);
|
|
327
|
+
if (scope.prefixSet !== undefined) {
|
|
328
|
+
entry.shareOptions.prefixSet = scope.prefixSet;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
// Degrade to no explicit scope filter — share() treats undefined as full access.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
312
335
|
try {
|
|
313
336
|
const r = await deps.share(entry.shareOptions);
|
|
314
337
|
result.filesUploaded += r.filesUploaded;
|
|
@@ -582,6 +605,8 @@ export function registerCloudCommands(program) {
|
|
|
582
605
|
// 2. default: vend via cached Cognito session (the human CLI path).
|
|
583
606
|
let entityContext;
|
|
584
607
|
let vaultConfig;
|
|
608
|
+
/** Vault API config for membership/grant scope lookups. Set even on the pre-vended `--creds-from-stdin` path — share() accepts entityContext OR vaultConfig, not both. */
|
|
609
|
+
let scopeVaultConfig;
|
|
585
610
|
if (options.credsFromStdin) {
|
|
586
611
|
if (process.stdin.isTTY) {
|
|
587
612
|
throw new Error("--creds-from-stdin requires JSON on stdin, but stdin is a " +
|
|
@@ -595,17 +620,22 @@ export function registerCloudCommands(program) {
|
|
|
595
620
|
catch (e) {
|
|
596
621
|
throw new Error(`--creds-from-stdin: failed to parse stdin as JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
597
622
|
}
|
|
623
|
+
// Upload creds are pre-vended in entityContext, but prefix filtering
|
|
624
|
+
// still needs vault-service membership/grant resolution.
|
|
625
|
+
const accessToken = await ensureCognitoToken();
|
|
626
|
+
scopeVaultConfig = buildVaultConfig(accessToken);
|
|
598
627
|
}
|
|
599
628
|
else {
|
|
600
629
|
const accessToken = await ensureCognitoToken();
|
|
601
630
|
vaultConfig = buildVaultConfig(accessToken);
|
|
631
|
+
scopeVaultConfig = vaultConfig;
|
|
602
632
|
}
|
|
603
633
|
// Resolve the target. For `--personal`, look up the caller's
|
|
604
634
|
// canonical person entity and force personalMode + journalSlug so
|
|
605
635
|
// share() lands files at hqRoot directly (no companies/<slug>/
|
|
606
636
|
// prefix). For everything else, the company is whatever the user
|
|
607
637
|
// passed or the active company from .hq/config.json.
|
|
608
|
-
let targetCompany = options.company;
|
|
638
|
+
let targetCompany = options.company ?? entityContext?.uid;
|
|
609
639
|
let personalMode = false;
|
|
610
640
|
let journalSlug;
|
|
611
641
|
if (options.personal) {
|
|
@@ -646,6 +676,12 @@ export function registerCloudCommands(program) {
|
|
|
646
676
|
// idToken — pre-vended `--creds-from-stdin` paths still get author
|
|
647
677
|
// attribution as long as the caller is logged in locally.
|
|
648
678
|
const author = resolveUploadAuthorFromCache();
|
|
679
|
+
let pushPrefixSet;
|
|
680
|
+
if (!personalMode && scopeVaultConfig) {
|
|
681
|
+
const scopeClient = new VaultClient(scopeVaultConfig);
|
|
682
|
+
const scope = await resolveCliPullScope(scopeClient, targetCompany, options.hqRoot);
|
|
683
|
+
pushPrefixSet = scope?.prefixSet;
|
|
684
|
+
}
|
|
649
685
|
const result = await share({
|
|
650
686
|
paths: targetPaths,
|
|
651
687
|
company: targetCompany,
|
|
@@ -658,6 +694,7 @@ export function registerCloudCommands(program) {
|
|
|
658
694
|
...(personalMode ? { personalMode: true } : {}),
|
|
659
695
|
...(journalSlug !== undefined ? { journalSlug } : {}),
|
|
660
696
|
...(author ? { author } : {}),
|
|
697
|
+
...(pushPrefixSet !== undefined ? { prefixSet: pushPrefixSet } : {}),
|
|
661
698
|
});
|
|
662
699
|
if (jsonMode) {
|
|
663
700
|
// Synthetic terminal event so subprocess consumers can read final
|
|
@@ -681,6 +718,9 @@ export function registerCloudCommands(program) {
|
|
|
681
718
|
log(chalk.yellow(`\n⚠ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ` +
|
|
682
719
|
`${result.filesSkipped} skipped, ${result.filesExcludedByScope} scope-excluded)`));
|
|
683
720
|
log(chalk.yellow(scopeExcludedWarning(result.filesExcludedByScope)));
|
|
721
|
+
const prefixDetail = scopeExcludedPrefixDetail(pushPrefixSet);
|
|
722
|
+
if (prefixDetail)
|
|
723
|
+
log(chalk.yellow(prefixDetail));
|
|
684
724
|
}
|
|
685
725
|
else {
|
|
686
726
|
log(chalk.green(`\n✓ Pushed ${result.filesUploaded} file(s) (${formatBytes(result.bytesUploaded)}, ${result.filesSkipped} skipped)`));
|
|
@@ -1091,6 +1131,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
|
1091
1131
|
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
1092
1132
|
}, {
|
|
1093
1133
|
vaultClient: adapter,
|
|
1134
|
+
resolveScope: (companyUid, slug) => resolvePullScope(realClient, companyUid, slug, hqRoot),
|
|
1094
1135
|
share: (opts) => share({
|
|
1095
1136
|
paths: opts.paths,
|
|
1096
1137
|
company: opts.company,
|
|
@@ -1113,6 +1154,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
|
1113
1154
|
...(opts.propagateDeletePolicy !== undefined
|
|
1114
1155
|
? { propagateDeletePolicy: opts.propagateDeletePolicy }
|
|
1115
1156
|
: {}),
|
|
1157
|
+
...(opts.prefixSet !== undefined ? { prefixSet: opts.prefixSet } : {}),
|
|
1116
1158
|
...(author ? { author } : {}),
|
|
1117
1159
|
}),
|
|
1118
1160
|
});
|
|
@@ -1195,6 +1237,12 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1195
1237
|
// Push first so the subsequent pull doesn't redownload files we were
|
|
1196
1238
|
// about to broadcast (matches hq-sync-runner ordering).
|
|
1197
1239
|
console.log(chalk.dim(" → push leg"));
|
|
1240
|
+
let nowPushPrefixSet;
|
|
1241
|
+
if (!personalMode) {
|
|
1242
|
+
const scopeClient = new VaultClient(vaultConfig);
|
|
1243
|
+
const pushScope = await resolveCliPullScope(scopeClient, targetCompany, hqRoot);
|
|
1244
|
+
nowPushPrefixSet = pushScope?.prefixSet;
|
|
1245
|
+
}
|
|
1198
1246
|
const pushResult = await share({
|
|
1199
1247
|
paths: pushPaths,
|
|
1200
1248
|
company: targetCompany,
|
|
@@ -1208,6 +1256,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1208
1256
|
...(personalMode ? { personalMode: true } : {}),
|
|
1209
1257
|
...(journalSlug !== undefined ? { journalSlug } : {}),
|
|
1210
1258
|
...(author ? { author } : {}),
|
|
1259
|
+
...(nowPushPrefixSet !== undefined ? { prefixSet: nowPushPrefixSet } : {}),
|
|
1211
1260
|
});
|
|
1212
1261
|
const pushStatus = pushResult.aborted || pushResult.filesExcludedByScope > 0
|
|
1213
1262
|
? chalk.yellow("⚠")
|
|
@@ -1218,6 +1267,9 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1218
1267
|
(pushResult.aborted ? " — aborted" : ""));
|
|
1219
1268
|
if (pushResult.filesExcludedByScope > 0) {
|
|
1220
1269
|
console.log(chalk.yellow(scopeExcludedWarning(pushResult.filesExcludedByScope)));
|
|
1270
|
+
const prefixDetail = scopeExcludedPrefixDetail(nowPushPrefixSet);
|
|
1271
|
+
if (prefixDetail)
|
|
1272
|
+
console.log(chalk.yellow(prefixDetail));
|
|
1221
1273
|
}
|
|
1222
1274
|
if (pushResult.aborted) {
|
|
1223
1275
|
console.log(chalk.yellow("\n⚠ Sync now aborted on push leg; pull skipped."));
|
|
@@ -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/packs.js
CHANGED
|
@@ -78,7 +78,7 @@ export function buildInstalledView(hqRoot, hqVersion, pack, installedSources, ch
|
|
|
78
78
|
const counts = { live: 0, broken: 0, missing: 0, foreign: 0 };
|
|
79
79
|
const brokenLinks = [];
|
|
80
80
|
for (const link of links) {
|
|
81
|
-
const st = linkStatus(link);
|
|
81
|
+
const st = linkStatus(link, pack.dir);
|
|
82
82
|
counts[st]++;
|
|
83
83
|
if (st === 'broken')
|
|
84
84
|
brokenLinks.push({ key: link.key, item: link.item, dst: link.dst });
|
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
|
package/dist/index.js
CHANGED
|
@@ -11,8 +11,12 @@ if (isVersionRequest(process.argv)) {
|
|
|
11
11
|
process.stdout.write(`${CLI_VERSION}\n`);
|
|
12
12
|
}
|
|
13
13
|
else {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Keep the command lifecycle detached from module evaluation, as it was
|
|
15
|
+
// before the fast --version split. Some best-effort teardown work uses
|
|
16
|
+
// unref'd handles; top-level-awaiting runCli() makes Node turn an otherwise
|
|
17
|
+
// successful command into exit 13 when that teardown promise remains pending.
|
|
18
|
+
// Rejections still become unhandled and preserve a genuine non-zero failure.
|
|
19
|
+
void import("./main.js").then(({ runCli }) => runCli());
|
|
16
20
|
}
|
|
17
21
|
export const __test__ = { isVersionRequest };
|
|
18
22
|
//# sourceMappingURL=index.js.map
|
|
@@ -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
|
|
@@ -75,8 +75,13 @@ export type LinkStatus = 'live' | 'broken' | 'missing' | 'foreign';
|
|
|
75
75
|
* effect (a config merge) is handled separately (US-004/US-005).
|
|
76
76
|
*/
|
|
77
77
|
export declare function contributionLinks(hqRoot: string, packDir: string, contributes: Partial<Record<PackContributeKey, string[]>>): WiredLink[];
|
|
78
|
+
/**
|
|
79
|
+
* Canonical absolute path — resolves symlinks and platform aliases such as
|
|
80
|
+
* macOS `/var` ↔ `/private/var` so equivalent paths compare equal.
|
|
81
|
+
*/
|
|
82
|
+
export declare function canonicalPath(p: string): string;
|
|
78
83
|
/** Classify a host path against the link that should own it. */
|
|
79
|
-
export declare function linkStatus(link: WiredLink): LinkStatus;
|
|
84
|
+
export declare function linkStatus(link: WiredLink, packDir?: string): LinkStatus;
|
|
80
85
|
/** A content pack's manifest plus the install-time stamped source. */
|
|
81
86
|
export interface InstalledPackManifest extends PackManifest {
|
|
82
87
|
source?: string;
|
|
@@ -132,8 +132,56 @@ export function contributionLinks(hqRoot, packDir, contributes) {
|
|
|
132
132
|
}
|
|
133
133
|
return links;
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Canonical absolute path — resolves symlinks and platform aliases such as
|
|
137
|
+
* macOS `/var` ↔ `/private/var` so equivalent paths compare equal.
|
|
138
|
+
*/
|
|
139
|
+
export function canonicalPath(p) {
|
|
140
|
+
try {
|
|
141
|
+
return fs.realpathSync.native(p);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return path.resolve(p);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** True when `candidate` resolves under `packDir` (pack directory containment). */
|
|
148
|
+
function targetUnderPackDir(packDir, candidate) {
|
|
149
|
+
const root = canonicalPath(packDir);
|
|
150
|
+
const resolved = canonicalPath(candidate);
|
|
151
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
152
|
+
return resolved === root || resolved.startsWith(prefix);
|
|
153
|
+
}
|
|
154
|
+
/** True when `a` and `b` resolve to the same on-disk file (canonical path or inode). */
|
|
155
|
+
function samePayloadFile(a, b) {
|
|
156
|
+
const canonA = canonicalPath(a);
|
|
157
|
+
const canonB = canonicalPath(b);
|
|
158
|
+
if (canonA === canonB)
|
|
159
|
+
return true;
|
|
160
|
+
try {
|
|
161
|
+
const stA = fs.statSync(a);
|
|
162
|
+
const stB = fs.statSync(b);
|
|
163
|
+
return stA.dev === stB.dev && stA.ino === stB.ino;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
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
|
+
}
|
|
135
183
|
/** Classify a host path against the link that should own it. */
|
|
136
|
-
export function linkStatus(link) {
|
|
184
|
+
export function linkStatus(link, packDir) {
|
|
137
185
|
let st;
|
|
138
186
|
try {
|
|
139
187
|
st = fs.lstatSync(link.dst);
|
|
@@ -151,13 +199,21 @@ export function linkStatus(link) {
|
|
|
151
199
|
catch {
|
|
152
200
|
return 'foreign';
|
|
153
201
|
}
|
|
154
|
-
// scan-packages.sh writes the symlink target as the absolute `src` path
|
|
155
|
-
//
|
|
202
|
+
// scan-packages.sh writes the symlink target as the absolute `src` path.
|
|
203
|
+
// Canonicalize both sides so `/var` vs `/private/var` (macOS) and symlink
|
|
204
|
+
// aliases classify as ours; optionally accept stale targets that still resolve
|
|
205
|
+
// inside this pack's directory.
|
|
156
206
|
const resolvedTarget = path.resolve(path.dirname(link.dst), target);
|
|
157
|
-
if (
|
|
158
|
-
return '
|
|
207
|
+
if (pointsAtDeclaredPayload(link, resolvedTarget)) {
|
|
208
|
+
return fs.existsSync(link.src) ? 'live' : 'broken';
|
|
209
|
+
}
|
|
210
|
+
if (packDir !== undefined && targetUnderPackDir(packDir, resolvedTarget)) {
|
|
211
|
+
// Stale or corrupted: symlink resolves inside this pack but not to the
|
|
212
|
+
// declared payload. Treat as broken (never live) so list does not report a
|
|
213
|
+
// healthy link; unwirePack still removes it on uninstall.
|
|
214
|
+
return 'broken';
|
|
159
215
|
}
|
|
160
|
-
return
|
|
216
|
+
return 'foreign'; // points at another pack / somewhere else
|
|
161
217
|
}
|
|
162
218
|
/** Absolute path to `<hqRoot>/core/packages`. */
|
|
163
219
|
export function packagesDir(hqRoot) {
|
|
@@ -230,7 +286,7 @@ export function findDependentPacks(installed, name) {
|
|
|
230
286
|
export function unwirePack(hqRoot, packDir, contributes) {
|
|
231
287
|
const result = { unlinked: [], skipped: [] };
|
|
232
288
|
for (const link of contributionLinks(hqRoot, packDir, contributes)) {
|
|
233
|
-
const status = linkStatus(link);
|
|
289
|
+
const status = linkStatus(link, packDir);
|
|
234
290
|
if (status === 'live' || status === 'broken') {
|
|
235
291
|
try {
|
|
236
292
|
fs.unlinkSync(link.dst);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.81.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
31
31
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
32
|
-
"@indigoai-us/hq-cloud": "^6.14.
|
|
32
|
+
"@indigoai-us/hq-cloud": "^6.14.37",
|
|
33
33
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
34
34
|
"@sentry/node": "^10.49.0",
|
|
35
35
|
"better-sqlite3": "^12.11.1",
|