@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 +26 -0
- package/dist/commands/core-checkpoint.d.ts +18 -0
- package/dist/commands/core-checkpoint.js +654 -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,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
|
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)) {
|