@indigoai-us/hq-cli 5.345.151 → 5.345.153

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/dist/bin/hq-auth-refresh.d.ts +4 -0
  3. package/dist/bin/hq-auth-refresh.js +9 -1
  4. package/dist/command-catalog.generated.d.ts +188 -3
  5. package/dist/command-catalog.generated.js +230 -3
  6. package/dist/command-registration-plan.d.ts +6 -0
  7. package/dist/command-registration-plan.js +1 -0
  8. package/dist/commands/agent-enroll.d.ts +12 -0
  9. package/dist/commands/agent-enroll.js +37 -4
  10. package/dist/commands/core-policy.js +4 -4
  11. package/dist/commands/core-session-project-native.js +19 -4
  12. package/dist/commands/decide.d.ts +13 -0
  13. package/dist/commands/decide.js +74 -0
  14. package/dist/commands/files.js +23 -7
  15. package/dist/commands/integrations-connect.js +24 -1
  16. package/dist/commands/integrations-core.d.ts +14 -0
  17. package/dist/commands/integrations-core.js +19 -1
  18. package/dist/commands/integrations-import.js +22 -1
  19. package/dist/commands/integrations.js +43 -2
  20. package/dist/commands/lanes-message.js +4 -11
  21. package/dist/commands/lanes-questions.js +3 -0
  22. package/dist/commands/lanes-resume.js +4 -11
  23. package/dist/commands/lanes-stories.js +3 -0
  24. package/dist/commands/rescue.d.ts +7 -0
  25. package/dist/commands/rescue.js +22 -13
  26. package/dist/commands/run.js +14 -5
  27. package/dist/commands/secrets.d.ts +13 -0
  28. package/dist/commands/secrets.js +51 -8
  29. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +186 -186
  30. package/dist/hq-cloud-sync-runner/package.json +1 -1
  31. package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
  32. package/dist/lib/agent-kit/adopt-identity.js +18 -3
  33. package/dist/lib/agent-kit/services.d.ts +3 -1
  34. package/dist/lib/agent-kit/services.js +5 -1
  35. package/dist/lib/daemon/ops/policy.d.ts +9 -1
  36. package/dist/lib/daemon/ops/policy.js +37 -2
  37. package/dist/lib/decisions/client.d.ts +67 -0
  38. package/dist/lib/decisions/client.js +120 -0
  39. package/dist/lib/decisions/expiry.d.ts +2 -0
  40. package/dist/lib/decisions/expiry.js +12 -0
  41. package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
  42. package/dist/lib/decisions/fixtures/us001.js +21 -0
  43. package/dist/lib/decisions/import-lanes.d.ts +16 -0
  44. package/dist/lib/decisions/import-lanes.js +152 -0
  45. package/dist/lib/decisions/principal.d.ts +11 -0
  46. package/dist/lib/decisions/principal.js +52 -0
  47. package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
  48. package/dist/lib/doctor/checks/claude-wiring.js +21 -10
  49. package/dist/lib/doctor/checks/work-context.js +7 -2
  50. package/dist/lib/doctor/fix/apply.d.ts +3 -2
  51. package/dist/lib/doctor/fix/apply.js +19 -15
  52. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
  53. package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
  54. package/dist/lib/doctor/fix/remediation.js +4 -2
  55. package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
  56. package/dist/lib/doctor/hook-gate-profiles.js +34 -0
  57. package/dist/lib/doctor/types.d.ts +5 -0
  58. package/dist/lib/install/provider-wrappers.js +5 -1
  59. package/dist/lib/lanes/autopilot.js +4 -1
  60. package/dist/lib/lanes/imported-question.d.ts +5 -0
  61. package/dist/lib/lanes/imported-question.js +17 -0
  62. package/dist/lib/lanes/questions.d.ts +0 -5
  63. package/dist/lib/lanes/questions.js +2 -0
  64. package/dist/lib/lanes/reconcile.js +21 -10
  65. package/dist/lib/lanes/store.d.ts +0 -8
  66. package/dist/lib/lanes/store.js +2 -1
  67. package/dist/lib/lanes/stories.d.ts +15 -0
  68. package/dist/lib/lanes/stories.js +25 -0
  69. package/dist/lib/mesh/client.d.ts +8 -0
  70. package/dist/lib/mesh/client.js +44 -3
  71. package/dist/lib/mesh/denial-codes.js +1 -1
  72. package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
  73. package/dist/lib/mesh/live/daemon/run.js +30 -1
  74. package/dist/lib/mesh/live/flush.d.ts +7 -0
  75. package/dist/lib/mesh/live/flush.js +53 -10
  76. package/dist/lib/registry/registry.js +6 -0
  77. package/dist/lib/scan-packages/index.js +7 -3
  78. package/dist/lib/work-context/organize.d.ts +2 -0
  79. package/dist/lib/work-context/organize.js +22 -4
  80. package/dist/lib/workers-registry/index.d.ts +12 -1
  81. package/dist/lib/workers-registry/index.js +39 -1
  82. package/dist/utils/cognito-session.d.ts +4 -1
  83. package/dist/utils/cognito-session.js +11 -1
  84. package/dist/utils/hook-trust.d.ts +27 -0
  85. package/dist/utils/hook-trust.js +150 -8
  86. package/dist/utils/shipped-codex-hooks.d.ts +17 -0
  87. package/dist/utils/shipped-codex-hooks.js +86 -0
  88. package/package.json +8 -8
@@ -0,0 +1,152 @@
1
+ import { decisionExpiry } from "./expiry.js";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { listQuestionIds, readQuestion, questionRecordPath, withLaneRecordFileLock } from "../lanes/store.js";
6
+ import { questionBodyAbsPath, sha256Text } from "../lanes/questions.js";
7
+ import { DecisionError } from "./client.js";
8
+ import { importedQuestionMarker } from "../lanes/imported-question.js";
9
+ export function legacyDecisionInput(record, body, audience) {
10
+ // Answered/closed/routed questions require contracts beyond release 1.
11
+ if (!record.company?.uid || !/^cmp_[A-Za-z0-9]+$/.test(record.company.uid) || record.status !== "pending" || record.hop > 1 || record.answer || record.answer_history?.length ||
12
+ record.routing_history.at(-1)?.audience.kind !== "user" ||
13
+ record.routing_history.at(-1)?.audience.id !== audience ||
14
+ record.routing_history.some(entry => entry.reason === "escalated") || body.notes || body.loop_stall ||
15
+ !Array.isArray(body.options) || body.options.length < 2 ||
16
+ body.options.some(option => typeof option !== "string" || !option.trim()) ||
17
+ new Set(body.options).size !== body.options.length ||
18
+ typeof body.question !== "string" || !body.question.trim() || typeof body.context !== "string" ||
19
+ (body.recommended !== undefined && !body.options.includes(body.recommended)))
20
+ return undefined;
21
+ return {
22
+ companyUid: record.company.uid, expiresAt: decisionExpiry(),
23
+ audience: { kind: "person", personUid: audience }, statement: body.question,
24
+ options: body.options.map((label, index) => ({ id: String(index + 1), label, recommended: label === body.recommended })),
25
+ context: { links: [], commitmentId: null, brief: `${body.context}\n\nImported lane question ${record.question_id}`.trim() },
26
+ };
27
+ }
28
+ /** Read legacy inputs only. Receipts live separately; legacy lane commands keep their storage. */
29
+ export async function importLaneQuestions(root, audience, client) {
30
+ const journalDir = path.join(root, "workspace", "decisions", "imports", audience);
31
+ if (!/^prs_[A-Za-z0-9_-]+$/.test(audience))
32
+ throw new DecisionError("invalid_audience", "Expected person uid");
33
+ fs.mkdirSync(journalDir, { recursive: true });
34
+ const lockPath = path.join(journalDir, "import.lock");
35
+ let lock;
36
+ try {
37
+ lock = fs.openSync(lockPath, "wx", 0o600);
38
+ }
39
+ catch (error) {
40
+ if (error.code !== "EEXIST")
41
+ throw error;
42
+ throw new DecisionError("import_locked", `An import owns ${lockPath}. Read the PID in that file and verify it is no longer running before deleting only import.lock, then rerun import-lanes. Keep receipts and legacy-read-only markers; they prevent duplicate decisions.`);
43
+ }
44
+ fs.writeFileSync(lock, String(process.pid));
45
+ const counts = { imported: 0, alreadyImported: 0, skipped: 0, skippedQuestions: [] };
46
+ const skip = (id, reason) => { counts.skipped++; counts.skippedQuestions.push({ id, reason }); };
47
+ try {
48
+ for (const id of listQuestionIds(root)) {
49
+ const record = readQuestion(root, id);
50
+ const target = record.routing_history.at(-1)?.audience;
51
+ if (target?.kind === "user" && target.id !== audience) {
52
+ skip(id, "addressed_to_other_user");
53
+ continue;
54
+ }
55
+ const bodyPath = questionBodyAbsPath(root, record);
56
+ let serialized;
57
+ try {
58
+ serialized = fs.readFileSync(bodyPath, "utf8");
59
+ }
60
+ catch (error) {
61
+ if (error.code !== "ENOENT")
62
+ throw error;
63
+ skip(id, "body_unavailable");
64
+ continue;
65
+ }
66
+ if (record.body.digest && sha256Text(serialized) !== record.body.digest) {
67
+ skip(id, "body_digest_mismatch");
68
+ continue;
69
+ }
70
+ let body;
71
+ try {
72
+ body = JSON.parse(serialized);
73
+ }
74
+ catch {
75
+ skip(id, "body_invalid");
76
+ continue;
77
+ }
78
+ if (!body || typeof body !== "object") {
79
+ skip(id, "body_invalid");
80
+ continue;
81
+ }
82
+ if (!record.company?.uid || !/^cmp_[A-Za-z0-9]+$/.test(record.company.uid)) {
83
+ skip(id, "company_unavailable");
84
+ continue;
85
+ }
86
+ const input = legacyDecisionInput(record, body, audience);
87
+ if (!input) {
88
+ skip(id, "advanced_or_closed_question");
89
+ continue;
90
+ }
91
+ const key = createHash("sha256").update(id).digest("hex");
92
+ const receiptPath = path.join(journalDir, `${key}.json`);
93
+ const digest = sha256Text(JSON.stringify({ ...input, expiresAt: undefined }));
94
+ const receipt = fs.existsSync(receiptPath) ? JSON.parse(fs.readFileSync(receiptPath, "utf8")) : undefined;
95
+ if (receipt) {
96
+ if (receipt.sourceDigest !== digest) {
97
+ skip(id, "source_changed_after_import");
98
+ continue;
99
+ }
100
+ // Upgrade receipts written before the read-only marker was introduced.
101
+ withLaneRecordFileLock(questionRecordPath(root, id), () => {
102
+ const markerPath = importedQuestionMarker(root, id);
103
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
104
+ if (!fs.existsSync(markerPath))
105
+ fs.writeFileSync(markerPath, JSON.stringify({ audience, receiptPath }), { flag: "wx", mode: 0o600 });
106
+ });
107
+ if (receipt.state === "imported" && receipt.decisionId) {
108
+ counts.alreadyImported++;
109
+ continue;
110
+ }
111
+ // A crash or lost POST response is ambiguous. Reconcile, never blindly POST again.
112
+ const matches = (await client.list()).filter(decision => decision.companyUid === input.companyUid && decision.audience.kind === "person" && decision.audience.personUid === audience && decision.context.brief === input.context.brief && decision.statement === input.statement && JSON.stringify(decision.options) === JSON.stringify(input.options));
113
+ if (matches.length !== 1)
114
+ throw new DecisionError("import_outcome_unknown", `Question ${id} has an unresolved create; inspect the server before retrying. Receipt: ${receiptPath}`);
115
+ fs.writeFileSync(receiptPath, JSON.stringify({ sourceDigest: digest, state: "imported", decisionId: matches[0].id }), { mode: 0o600 });
116
+ counts.alreadyImported++;
117
+ continue;
118
+ }
119
+ // Persist intent before POST, so even interruption cannot cause an automatic duplicate.
120
+ const markerPath = importedQuestionMarker(root, id);
121
+ const fenced = withLaneRecordFileLock(questionRecordPath(root, id), () => {
122
+ const current = readQuestion(root, id);
123
+ if (current.version !== record.version || fs.readFileSync(bodyPath, "utf8") !== serialized)
124
+ return false;
125
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
126
+ try {
127
+ fs.writeFileSync(markerPath, JSON.stringify({ audience, receiptPath }), { flag: "wx", mode: 0o600 });
128
+ }
129
+ catch (error) {
130
+ if (error.code !== "EEXIST")
131
+ throw error;
132
+ throw new DecisionError("import_outcome_unknown", `Question ${id} already has a read-only transfer marker; inspect its receipt before retrying`);
133
+ }
134
+ fs.writeFileSync(receiptPath, JSON.stringify({ sourceDigest: digest, state: "pending" }), { flag: "wx", mode: 0o600 });
135
+ return true;
136
+ });
137
+ if (!fenced) {
138
+ skip(id, "source_changed_during_import");
139
+ continue;
140
+ }
141
+ const decision = await client.ask(input);
142
+ fs.writeFileSync(receiptPath, JSON.stringify({ sourceDigest: digest, state: "imported", decisionId: decision.id }), { mode: 0o600 });
143
+ counts.imported++;
144
+ }
145
+ return counts;
146
+ }
147
+ finally {
148
+ fs.closeSync(lock);
149
+ fs.unlinkSync(lockPath);
150
+ }
151
+ }
152
+ //# sourceMappingURL=import-lanes.js.map
@@ -0,0 +1,11 @@
1
+ import { type AgentRecord } from "../bot/api.js";
2
+ export declare const ASK_POLICY = "assistant-bound-conductor-release-1-conductor-only";
3
+ /** Session parsing adapted from hq-cli PR 1993; a session reference grants no authority. */
4
+ export declare function desktopSessionRef(env: NodeJS.ProcessEnv): string | undefined;
5
+ /** US-007 seam. No lease authority exists in release 1; env flags never grant it. */
6
+ export declare function hasConductorLease(_session: string | undefined): Promise<boolean>;
7
+ export declare function assertPersonalLocalBot(uid: string, agent: AgentRecord): string;
8
+ export declare function askAudience(token: string): Promise<string>;
9
+ /** Import creates decisions, so the same server-enforced bot gate applies. */
10
+ export declare const importAudience: typeof askAudience;
11
+ //# sourceMappingURL=principal.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { resolveCallerUid, vaultApiFetch } from "../../utils/vault-api.js";
2
+ import { BotApi, BotApiError } from "../bot/api.js";
3
+ import { normalizeSessionIdentity } from "../mesh/live/session-identity.js";
4
+ import { DecisionError } from "./client.js";
5
+ export const ASK_POLICY = "assistant-bound-conductor-release-1-conductor-only";
6
+ /** Session parsing adapted from hq-cli PR 1993; a session reference grants no authority. */
7
+ export function desktopSessionRef(env) {
8
+ try {
9
+ const identity = normalizeSessionIdentity({ env });
10
+ const provider = identity.source.startsWith("CODEX") ? "codex"
11
+ : identity.source.startsWith("CLAUDE") ? "claude"
12
+ : env.HQ_SESSION_PROVIDER?.trim() || (env.CODEX_THREAD_ID || env.CODEX_SESSION_ID ? "codex" : "claude");
13
+ if (!/^[a-z][a-z0-9_-]*$/.test(provider) || !/^[A-Za-z0-9._-]{1,128}$/.test(identity.sessionId) || [".", ".."].includes(identity.sessionId))
14
+ return undefined;
15
+ return `session:${provider}:${identity.sessionId}`;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ /** US-007 seam. No lease authority exists in release 1; env flags never grant it. */
22
+ export async function hasConductorLease(_session) { return false; }
23
+ export function assertPersonalLocalBot(uid, agent) {
24
+ if (agent.uid !== uid || agent.computeMode !== "local" || agent.botKind !== "personal" || !agent.ownerUid?.startsWith("prs_")) {
25
+ throw new DecisionError("asker_not_allowed", `${ASK_POLICY} requires the owner's personal local bot principal`);
26
+ }
27
+ return agent.ownerUid;
28
+ }
29
+ export async function askAudience(token) {
30
+ const caller = await resolveCallerUid(token);
31
+ if (caller.kind === "agent") {
32
+ try {
33
+ const agent = await new BotApi({
34
+ token: async () => token,
35
+ fetchImpl: options => vaultApiFetch({ ...options, signal: AbortSignal.timeout(15_000) }),
36
+ }).getAgent(caller.uid);
37
+ return assertPersonalLocalBot(caller.uid, agent);
38
+ }
39
+ catch (error) {
40
+ if (error instanceof BotApiError && [403, 404].includes(error.status)) {
41
+ throw new DecisionError("asker_not_allowed", `${ASK_POLICY} requires a verified personal local bot principal`);
42
+ }
43
+ throw error;
44
+ }
45
+ }
46
+ if (await hasConductorLease(desktopSessionRef(process.env)))
47
+ return caller.uid;
48
+ throw new DecisionError("asker_not_allowed", `${ASK_POLICY} allows only the personal local bot; desktop conductor leases await US-007`);
49
+ }
50
+ /** Import creates decisions, so the same server-enforced bot gate applies. */
51
+ export const importAudience = askAudience;
52
+ //# sourceMappingURL=principal.js.map
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * "Wiring" is everything short of firing the hook: is every registration backed
5
5
  * by a script that exists and is executable, is every script on disk actually
6
- * registered, is every gated hook id a member of all three `hook-gate.sh`
7
- * profiles, and does any command word-split its own script path. A hook can be
6
+ * registered, is every gated hook id a member of each `hook-gate.sh` profile
7
+ * at or above its declared tier, and does any command word-split its own script path. A hook can be
8
8
  * present, correct, and completely dead — this check catches the ways that
9
9
  * happens without a single error surfacing to the user.
10
10
  *
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * "Wiring" is everything short of firing the hook: is every registration backed
5
5
  * by a script that exists and is executable, is every script on disk actually
6
- * registered, is every gated hook id a member of all three `hook-gate.sh`
7
- * profiles, and does any command word-split its own script path. A hook can be
6
+ * registered, is every gated hook id a member of each `hook-gate.sh` profile
7
+ * at or above its declared tier, and does any command word-split its own script path. A hook can be
8
8
  * present, correct, and completely dead — this check catches the ways that
9
9
  * happens without a single error surfacing to the user.
10
10
  *
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import * as fs from "node:fs";
25
25
  import * as path from "node:path";
26
- import { GATE_PROFILES, gateMembership, parseHookGateProfiles, } from "../hook-gate-profiles.js";
26
+ import { GATE_PROFILES, activeGateProfile, gateMembership, parseHookGateProfiles, requiredMissingProfiles, } from "../hook-gate-profiles.js";
27
27
  import { modeIsExecutable } from "../shared.js";
28
28
  import { countStrayGateEntries } from "../stray-gate-entries.js";
29
29
  /** Common id prefix for every result this check family emits. */
@@ -163,7 +163,11 @@ export function checkClaudeWiring(context) {
163
163
  remediation: `Add ${file} to .claude/hooks/hook-registry.json with the right event and matcher, or delete it.`,
164
164
  });
165
165
  }
166
- // --- AC4: every gated hook id is in ALL THREE profiles -----------------------
166
+ // --- AC4: every gated hook id is in each profile its tier requires ---------
167
+ // Profiles are nested tiers (minimal within standard within strict). A hook
168
+ // absent from a profile BELOW its lowest listed one is excluded on purpose
169
+ // and passes. A required profile missing the hook fails only when it is the
170
+ // active runtime profile; otherwise the hook still runs, so it warns.
167
171
  const gatedIds = new Set();
168
172
  for (const { scan } of scans) {
169
173
  if (scan.gatedHookId)
@@ -181,27 +185,34 @@ export function checkClaudeWiring(context) {
181
185
  }
182
186
  else {
183
187
  const profiles = parseHookGateProfiles(gateSource);
188
+ const active = activeGateProfile(context.hookProfile ?? process.env.HQ_HOOK_PROFILE);
184
189
  for (const id of [...gatedIds].sort()) {
185
- const { present, missing } = gateMembership(id, profiles);
190
+ const { present } = gateMembership(id, profiles);
191
+ const missing = requiredMissingProfiles(id, profiles);
186
192
  if (missing.length === 0) {
187
193
  results.push({
188
194
  status: "PASS",
189
195
  checkId: `${CLAUDE_WIRING_PREFIX}.gate-profiles`,
190
196
  target: id,
191
- message: `Gated hook ${id} is allowlisted in all three hook-gate.sh profiles.`,
197
+ message: `Gated hook ${id} is allowlisted in every hook-gate.sh profile from ${present[0]} up.`,
192
198
  });
193
199
  continue;
194
200
  }
195
201
  const missingList = formatProfiles(missing);
196
202
  const presentDesc = present.length > 0 ? formatProfiles(present) : "no profile";
203
+ const inActive = missing.includes(active);
204
+ const plural = missing.length === 1 ? "" : "s";
197
205
  results.push({
198
- status: "FAIL",
206
+ status: inActive ? "FAIL" : "WARN",
199
207
  checkId: `${CLAUDE_WIRING_PREFIX}.gate-profiles`,
200
208
  target: id,
201
209
  message: `Gated hook ${id} is missing from the ${missingList} hook-gate.sh ` +
202
- `profile${missing.length === 1 ? "" : "s"} (present in ${presentDesc}); ` +
203
- `it silently no-ops under ${missing.length === 1 ? "that profile" : "those profiles"}.`,
204
- remediation: `Add ${id} to the ${missingList} allowlist${missing.length === 1 ? "" : "s"} in .claude/hooks/hook-gate.sh.`,
210
+ `profile${plural} (present in ${presentDesc}); ` +
211
+ (inActive
212
+ ? `it does not run under the active ${active} profile.`
213
+ : `it runs under the active ${active} profile but would not run under ${missing.length === 1 ? "that profile" : "those profiles"}.`),
214
+ remediation: `Run \`hq doctor --fix\`, or add ${id} to the ${missingList} ` +
215
+ `allowlist${plural} in .claude/hooks/hook-gate.sh.`,
205
216
  });
206
217
  }
207
218
  }
@@ -13,7 +13,7 @@ import { workContextRoot, workContextSessionsDir } from "../../work-context/path
13
13
  import { countDistinctHeldEvents, countJsonlLines, deadLetterNonEmpty, } from "../../mesh/live/spool.js";
14
14
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../../mesh/live/paths.js";
15
15
  import { collectDaemonDoctor, formatDaemonDoctor, } from "../../mesh/live/daemon/doctor.js";
16
- import { workSessionDenialReason } from "../../mesh/denial-codes.js";
16
+ import { WORK_TRACKING_OFF_DENIAL_CODES, workSessionDenialReason, } from "../../mesh/denial-codes.js";
17
17
  export const WORK_CONTEXT_FAMILY_ID = "work-context";
18
18
  export const WORK_CONTEXT_FAMILY_TITLE = "Work context";
19
19
  const DEFAULT_DEPS = {
@@ -98,7 +98,12 @@ function runChecks(deps) {
98
98
  ]
99
99
  .filter(Boolean)
100
100
  .join(" ");
101
- const quarantineDenialReason = showQuarantineReason
101
+ // A refusal because work tracking is off for the company is shown whether
102
+ // or not the detail flag is on: without it doctor only shows a count and the
103
+ // user is left guessing at auth (Shepherd issue 2726).
104
+ const workTrackingOff = stats.quarantined > 0 &&
105
+ WORK_TRACKING_OFF_DENIAL_CODES.includes(stats.lastQuarantineServerCode ?? "");
106
+ const quarantineDenialReason = showQuarantineReason || workTrackingOff
102
107
  ? workSessionDenialReason(stats.lastQuarantineServerCode)
103
108
  : undefined;
104
109
  results.push({
@@ -190,8 +190,9 @@ export interface AppliedFixBackup {
190
190
  export declare const MAX_FIX_PASSES = 3;
191
191
  /**
192
192
  * Two 90s network repairs leave 120s for startup and check overhead under the
193
- * hook's 300s ceiling. Journal pulls and manifest uploads share this budget;
194
- * pulls go first and uploads take any slot left.
193
+ * hook's 300s ceiling. Journal pulls and manifest uploads share this budget.
194
+ * When both kinds are pending and no upload has run yet, one slot is held for
195
+ * a manifest upload so stale journals cannot starve every manifest.
195
196
  */
196
197
  export declare const MAX_REFRESH_SYNC_PLANS_PER_RUN = 2;
197
198
  /** The outcome of an {@link applyFixes} run. */
@@ -40,7 +40,7 @@ import * as path from "node:path";
40
40
  import { isPersonalJournalSlug } from "../checks/sync-health.js";
41
41
  import { createDefaultRegistry } from "../registry.js";
42
42
  import { flattenFamilies } from "../report.js";
43
- import { gateMembership, parseHookGateProfiles, } from "../hook-gate-profiles.js";
43
+ import { parseHookGateProfiles, requiredMissingProfiles, } from "../hook-gate-profiles.js";
44
44
  import { createBackup } from "./backup.js";
45
45
  import { removeStrayGateEntries } from "../stray-gate-entries.js";
46
46
  import { deriveRemediation } from "./remediation.js";
@@ -48,7 +48,7 @@ import { botDir } from "../../bot/paths.js";
48
48
  import { readBotConfig } from "../../bot/config.js";
49
49
  import { buildBotDaemonPaths, installedDaemonHqBinary, repairBotDaemonIfStale, rewriteBotDaemonNodePath, } from "../../bot/daemon.js";
50
50
  import { classifyRefreshSyncOutcome, } from "./refresh-sync-outcome.js";
51
- import { readRefreshSyncProgress, writeRefreshSyncCursor, writeRefreshSyncLastRun, } from "./refresh-sync-progress.js";
51
+ import { readRefreshManifestCursor, readRefreshSyncProgress, writeRefreshManifestCursor, writeRefreshSyncCursor, writeRefreshSyncLastRun, } from "./refresh-sync-progress.js";
52
52
  import { clientHealthShowManualOnlyEnabled } from "../client-health-reason-codes-flag.js";
53
53
  /** The tree subtrees whose uncommitted changes block a `--fix` run. */
54
54
  export const HOOK_CONFIG_DIRS = [".claude", ".codex", ".grok"];
@@ -58,8 +58,9 @@ export const REFRESH_SYNC_TIMEOUT_MS = 90_000;
58
58
  export const MAX_FIX_PASSES = 3;
59
59
  /**
60
60
  * Two 90s network repairs leave 120s for startup and check overhead under the
61
- * hook's 300s ceiling. Journal pulls and manifest uploads share this budget;
62
- * pulls go first and uploads take any slot left.
61
+ * hook's 300s ceiling. Journal pulls and manifest uploads share this budget.
62
+ * When both kinds are pending and no upload has run yet, one slot is held for
63
+ * a manifest upload so stale journals cannot starve every manifest.
63
64
  */
64
65
  export const MAX_REFRESH_SYNC_PLANS_PER_RUN = 2;
65
66
  function rotateRefreshSyncPlans(plans, afterSlug) {
@@ -148,6 +149,7 @@ export async function applyFixes(options) {
148
149
  let lastAttemptedRefreshSyncSlug = readRefreshSyncProgress(hqRoot)?.lastAttemptedSlug ?? null;
149
150
  let refreshSyncAppliedThisRun = 0;
150
151
  let refreshManifestAppliedThisRun = 0;
152
+ let lastAttemptedRefreshManifestScope = readRefreshManifestCursor(hqRoot);
151
153
  const appliedKeys = new Set();
152
154
  const planned = [];
153
155
  const backups = [];
@@ -175,21 +177,20 @@ export async function applyFixes(options) {
175
177
  keysThisPass.add(key);
176
178
  plans.push(plan);
177
179
  }
180
+ const networkSlotsLeft = Math.max(0, MAX_REFRESH_SYNC_PLANS_PER_RUN - refreshSyncAppliedThisRun - refreshManifestAppliedThisRun);
181
+ // Both kinds start after the company attempted last, so a scope that
182
+ // never clears cannot hold the front of the line on every run.
183
+ const manifestCandidates = rotateRefreshSyncPlans(plans.filter((plan) => plan.fixClass === "refresh-manifest"), lastAttemptedRefreshManifestScope);
184
+ // Hold one slot for an upload until one has run this invocation.
185
+ const manifestReserve = manifestCandidates.length > 0 && refreshManifestAppliedThisRun === 0 ? 1 : 0;
178
186
  const seenRefreshTargets = new Set();
179
187
  const refreshPlans = rotateRefreshSyncPlans(plans.filter((plan) => plan.fixClass === "refresh-sync"), lastAttemptedRefreshSyncSlug).filter((plan) => {
180
188
  if (seenRefreshTargets.has(plan.target))
181
189
  return false;
182
190
  seenRefreshTargets.add(plan.target);
183
191
  return true;
184
- }).slice(0, Math.max(0, MAX_REFRESH_SYNC_PLANS_PER_RUN - refreshSyncAppliedThisRun));
185
- // Manifest uploads take whatever network budget the pulls left.
186
- const manifestPlans = plans
187
- .filter((plan) => plan.fixClass === "refresh-manifest")
188
- .sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
189
- .slice(0, Math.max(0, MAX_REFRESH_SYNC_PLANS_PER_RUN -
190
- refreshSyncAppliedThisRun -
191
- refreshManifestAppliedThisRun -
192
- refreshPlans.length));
192
+ }).slice(0, Math.max(0, networkSlotsLeft - manifestReserve));
193
+ const manifestPlans = manifestCandidates.slice(0, networkSlotsLeft - refreshPlans.length);
193
194
  return [
194
195
  ...plans.filter((plan) => plan.fixClass !== "refresh-sync" && plan.fixClass !== "refresh-manifest"),
195
196
  ...refreshPlans,
@@ -255,8 +256,11 @@ export async function applyFixes(options) {
255
256
  lastAttemptedRefreshSyncSlug = plan.target;
256
257
  refreshSyncAppliedThisRun += 1;
257
258
  }
258
- if (plan.fixClass === "refresh-manifest")
259
+ if (plan.fixClass === "refresh-manifest") {
260
+ writeRefreshManifestCursor(hqRoot, plan.target);
261
+ lastAttemptedRefreshManifestScope = plan.target;
259
262
  refreshManifestAppliedThisRun += 1;
263
+ }
260
264
  appliedKeys.add(JSON.stringify([plan.checkId, plan.target]));
261
265
  planned.push({ plan, pass });
262
266
  await plan.apply();
@@ -761,7 +765,7 @@ function planGateProfile(hqRoot, result, rem) {
761
765
  catch {
762
766
  return null;
763
767
  }
764
- const missing = gateMembership(hookId, parseHookGateProfiles(source)).missing;
768
+ const missing = requiredMissingProfiles(hookId, parseHookGateProfiles(source));
765
769
  if (missing.length === 0)
766
770
  return null;
767
771
  const relpath = toRelpath(hqRoot, gatePath);
@@ -19,6 +19,20 @@ export interface RefreshSyncProgress {
19
19
  lastRunAtMs?: number;
20
20
  }
21
21
  export declare function readRefreshSyncProgress(hqRoot: string): RefreshSyncProgress | null;
22
+ /**
23
+ * `--fix` manifest-upload cursor. Kept in its own file so the refresh-sync
24
+ * progress format, which doctor JSON reads, does not change. Written before
25
+ * each upload, like the pull cursor, so the next run starts after the last
26
+ * company attempted and one failing company cannot hold the front of the line.
27
+ */
28
+ export declare const REFRESH_MANIFEST_PROGRESS_RELATIVE_PATH: string;
29
+ export interface RefreshManifestProgress {
30
+ version: 1;
31
+ lastAttemptedScope: string;
32
+ }
33
+ /** The last manifest scope `--fix` attempted, or null when absent or unreadable. */
34
+ export declare function readRefreshManifestCursor(hqRoot: string): string | null;
35
+ export declare function writeRefreshManifestCursor(hqRoot: string, scope: string): void;
22
36
  /** Persist the resume cursor without claiming this run finished. */
23
37
  export declare function writeRefreshSyncCursor(hqRoot: string, slug: string): void;
24
38
  /** Record the check ids this completed `--fix` pass actually tried to refresh. */
@@ -49,13 +49,41 @@ export function readRefreshSyncProgress(hqRoot) {
49
49
  }
50
50
  return record;
51
51
  }
52
- function writeProgressFile(hqRoot, progress) {
53
- const target = progressPath(hqRoot);
52
+ function writeProgressFile(hqRoot, progress, relativePath = REFRESH_SYNC_PROGRESS_RELATIVE_PATH) {
53
+ const target = path.join(hqRoot, relativePath);
54
54
  fs.mkdirSync(path.dirname(target), { recursive: true });
55
55
  const temporaryPath = `${target}.${process.pid}.tmp`;
56
56
  fs.writeFileSync(temporaryPath, `${JSON.stringify(progress)}\n`, { mode: 0o600 });
57
57
  fs.renameSync(temporaryPath, target);
58
58
  }
59
+ /**
60
+ * `--fix` manifest-upload cursor. Kept in its own file so the refresh-sync
61
+ * progress format, which doctor JSON reads, does not change. Written before
62
+ * each upload, like the pull cursor, so the next run starts after the last
63
+ * company attempted and one failing company cannot hold the front of the line.
64
+ */
65
+ export const REFRESH_MANIFEST_PROGRESS_RELATIVE_PATH = path.join("workspace", "doctor", "refresh-manifest-progress.json");
66
+ /** The last manifest scope `--fix` attempted, or null when absent or unreadable. */
67
+ export function readRefreshManifestCursor(hqRoot) {
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(fs.readFileSync(path.join(hqRoot, REFRESH_MANIFEST_PROGRESS_RELATIVE_PATH), "utf8"));
71
+ }
72
+ catch {
73
+ // The cursor only orders retries. A missing or damaged file restarts the
74
+ // rotation from the first scope instead of failing the repair run.
75
+ return null;
76
+ }
77
+ if (typeof parsed !== "object" || parsed === null)
78
+ return null;
79
+ const record = parsed;
80
+ if (record.version !== 1 || typeof record.lastAttemptedScope !== "string")
81
+ return null;
82
+ return record.lastAttemptedScope;
83
+ }
84
+ export function writeRefreshManifestCursor(hqRoot, scope) {
85
+ writeProgressFile(hqRoot, { version: 1, lastAttemptedScope: scope }, REFRESH_MANIFEST_PROGRESS_RELATIVE_PATH);
86
+ }
59
87
  /** Persist the resume cursor without claiming this run finished. */
60
88
  export function writeRefreshSyncCursor(hqRoot, slug) {
61
89
  const existing = readRefreshSyncProgress(hqRoot);
@@ -87,8 +87,10 @@ export function deriveRemediation(result) {
87
87
  command: `chmod +x "${chmodPath}"`,
88
88
  };
89
89
  }
90
- // gate-profile — a gated hook id present in some but not all three profiles.
91
- if (checkId.endsWith(".gate-profiles") && result.status === "FAIL" && target) {
90
+ // gate-profile — a gated hook id missing from a profile its tier requires.
91
+ if (checkId.endsWith(".gate-profiles") &&
92
+ (result.status === "FAIL" || result.status === "WARN") &&
93
+ target) {
92
94
  return {
93
95
  autoFixable: true,
94
96
  fixClass: "gate-profile",
@@ -52,4 +52,23 @@ export declare function parseHookGateProfiles(source: string): HookGateProfiles;
52
52
  * profile allowlists key on the hook id, not the script filename.
53
53
  */
54
54
  export declare function gateMembership(hookId: string, profiles: HookGateProfiles): GateMembership;
55
+ /**
56
+ * The profiles `hookId` is REQUIRED in but missing from.
57
+ *
58
+ * The shipped gate declares the profiles as nested tiers: minimal is
59
+ * "critical safety hooks only", standard is "all minimal + ...", strict is
60
+ * "all standard + ...". A hook's lowest listed profile is its declared tier,
61
+ * and it must be listed in every profile from that tier up. Absence from a
62
+ * LOWER profile is an intentional exclusion, not a defect (feedback 299: a
63
+ * standard hook absent from minimal is by design). The tier is capped at
64
+ * standard, so an id listed only in strict is still required in standard. An
65
+ * id listed in no profile is required everywhere.
66
+ */
67
+ export declare function requiredMissingProfiles(hookId: string, profiles: HookGateProfiles): GateProfile[];
68
+ /**
69
+ * Resolve the runtime gate profile the way hook-gate.sh does:
70
+ * `HQ_HOOK_PROFILE`, default `standard`. An unrecognised value falls back to
71
+ * `standard` here (the gate itself errors on it).
72
+ */
73
+ export declare function activeGateProfile(value?: string | null): GateProfile;
55
74
  //# sourceMappingURL=hook-gate-profiles.d.ts.map
@@ -104,4 +104,38 @@ function profileFunctionBody(source, profile) {
104
104
  const close = rest.search(/\n\s*\}/);
105
105
  return close >= 0 ? rest.slice(0, close) : rest;
106
106
  }
107
+ /**
108
+ * The profiles `hookId` is REQUIRED in but missing from.
109
+ *
110
+ * The shipped gate declares the profiles as nested tiers: minimal is
111
+ * "critical safety hooks only", standard is "all minimal + ...", strict is
112
+ * "all standard + ...". A hook's lowest listed profile is its declared tier,
113
+ * and it must be listed in every profile from that tier up. Absence from a
114
+ * LOWER profile is an intentional exclusion, not a defect (feedback 299: a
115
+ * standard hook absent from minimal is by design). The tier is capped at
116
+ * standard, so an id listed only in strict is still required in standard. An
117
+ * id listed in no profile is required everywhere.
118
+ */
119
+ export function requiredMissingProfiles(hookId, profiles) {
120
+ const { present, missing } = gateMembership(hookId, profiles);
121
+ if (present.length === 0)
122
+ return missing;
123
+ // The inferred tier is capped at standard, the default runtime profile. The
124
+ // strict list is reserved for future hooks and currently equals standard, so
125
+ // a strict-only id is a missed allowlist edit, not a strict tier: it never
126
+ // runs under the default profile and must still be required in standard.
127
+ const lowest = Math.min(GATE_PROFILES.indexOf(present[0]), GATE_PROFILES.indexOf("standard"));
128
+ return missing.filter((p) => GATE_PROFILES.indexOf(p) >= lowest);
129
+ }
130
+ /**
131
+ * Resolve the runtime gate profile the way hook-gate.sh does:
132
+ * `HQ_HOOK_PROFILE`, default `standard`. An unrecognised value falls back to
133
+ * `standard` here (the gate itself errors on it).
134
+ */
135
+ export function activeGateProfile(value) {
136
+ const v = (value ?? "").trim();
137
+ return GATE_PROFILES.includes(v)
138
+ ? v
139
+ : "standard";
140
+ }
107
141
  //# sourceMappingURL=hook-gate-profiles.js.map
@@ -79,6 +79,11 @@ export interface CheckContext {
79
79
  company?: string;
80
80
  /** Internal test seam; production `hq doctor` always enables integrations. */
81
81
  integrations?: boolean;
82
+ /**
83
+ * The runtime hook-gate profile (minimal|standard|strict) to judge gate
84
+ * membership against. Absent reads `HQ_HOOK_PROFILE`, default `standard`.
85
+ */
86
+ hookProfile?: string;
82
87
  }
83
88
  /**
84
89
  * A check family: an id, a human title, and an async `run` returning per-item
@@ -94,7 +94,11 @@ function wrapperSource(provider) {
94
94
  'log_file="$log_dir/provider-auth.log"',
95
95
  'tmp=$(mktemp -d "${TMPDIR:-/tmp}/hq-provider.XXXXXX") || exit 1',
96
96
  'trap \'rm -rf "$tmp"\' EXIT HUP INT TERM',
97
- `if command -v timeout >/dev/null 2>&1; then timeout 5 "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; elif command -v perl >/dev/null 2>&1; then perl -e 'alarm 5; exec @ARGV' "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; else auth_status=124; fi`,
97
+ // macOS ships no timeout(1), so the perl alarm fallback runs there. When the
98
+ // alarm fires the shell itself reports "Alarm clock: 14 perl ..." on its own
99
+ // stderr (the command's 2>&1 does not cover it); the brace group's 2>/dev/null
100
+ // keeps that report off the user's terminal. The exit status (142) is unchanged.
101
+ `if command -v timeout >/dev/null 2>&1; then timeout 5 "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; elif command -v perl >/dev/null 2>&1; then { perl -e 'alarm 5; exec @ARGV' "$real" ${statusArgs} >"$tmp/status" 2>&1; } 2>/dev/null; auth_status=$?; else auth_status=124; fi`,
98
102
  'case "$auth_status" in 124|142) : >"$tmp/status-timeout" ;; esac',
99
103
  'if [ "$auth_status" -ne 0 ]; then cat "$tmp/status" >>"$log_file" 2>/dev/null || :; fi',
100
104
  `if [ ! -e "$tmp/status-timeout" ] && grep -Eiq '${authFailure}' "$tmp/status"; then`,
@@ -1265,7 +1265,10 @@ export async function runLeadAutopilotPass(input) {
1265
1265
  for (let attempt = 0; attempt < 2; attempt += 1) {
1266
1266
  try {
1267
1267
  lockFd = fs.openSync(lockPath, "wx", 0o600);
1268
- fs.writeFileSync(lockFd, `${process.pid}\n${input.now.toISOString()}\n${processStartIdentity(process.pid) ?? ""}\n`, "utf8");
1268
+ // The created-at stamp is compared with Date.now() by reclaimStaleLock
1269
+ // (the only liveness check where /proc is absent, e.g. macOS), so it must
1270
+ // be wall-clock time, not the caller's injected logical `now`.
1271
+ fs.writeFileSync(lockFd, `${process.pid}\n${new Date().toISOString()}\n${processStartIdentity(process.pid) ?? ""}\n`, "utf8");
1269
1272
  break;
1270
1273
  }
1271
1274
  catch (error) {
@@ -0,0 +1,5 @@
1
+ export declare function importedQuestionMarker(root: string, id: string): string;
2
+ /** A pending transfer is also read-only: its remote POST may already have succeeded. */
3
+ export declare function assertQuestionNotImported(root: string, id: string): void;
4
+ export declare function isQuestionImported(root: string, id: string): boolean;
5
+ //# sourceMappingURL=imported-question.d.ts.map