@indigoai-us/hq-cli 5.345.152 → 5.345.154

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 (82) hide show
  1. package/CHANGELOG.md +96 -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 +185 -0
  5. package/dist/command-catalog.generated.js +227 -0
  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/cloud.d.ts +17 -0
  11. package/dist/commands/cloud.js +48 -1
  12. package/dist/commands/core-policy.js +4 -4
  13. package/dist/commands/core-session-project-native.js +19 -4
  14. package/dist/commands/decide.d.ts +13 -0
  15. package/dist/commands/decide.js +74 -0
  16. package/dist/commands/integrations-connect.js +24 -1
  17. package/dist/commands/integrations-core.d.ts +14 -0
  18. package/dist/commands/integrations-core.js +19 -1
  19. package/dist/commands/integrations-import.js +22 -1
  20. package/dist/commands/integrations.js +43 -2
  21. package/dist/commands/lanes-message.js +4 -11
  22. package/dist/commands/lanes-questions.js +3 -0
  23. package/dist/commands/lanes-resume.js +4 -11
  24. package/dist/commands/lanes-stories.js +3 -0
  25. package/dist/commands/lanes.js +7 -1
  26. package/dist/commands/run.js +14 -5
  27. package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
  28. package/dist/lib/agent-kit/adopt-identity.js +18 -3
  29. package/dist/lib/agent-kit/services.d.ts +3 -1
  30. package/dist/lib/agent-kit/services.js +5 -1
  31. package/dist/lib/daemon/ops/policy.d.ts +9 -1
  32. package/dist/lib/daemon/ops/policy.js +37 -2
  33. package/dist/lib/decisions/client.d.ts +67 -0
  34. package/dist/lib/decisions/client.js +120 -0
  35. package/dist/lib/decisions/expiry.d.ts +2 -0
  36. package/dist/lib/decisions/expiry.js +12 -0
  37. package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
  38. package/dist/lib/decisions/fixtures/us001.js +21 -0
  39. package/dist/lib/decisions/import-lanes.d.ts +16 -0
  40. package/dist/lib/decisions/import-lanes.js +152 -0
  41. package/dist/lib/decisions/principal.d.ts +11 -0
  42. package/dist/lib/decisions/principal.js +52 -0
  43. package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
  44. package/dist/lib/doctor/checks/claude-wiring.js +21 -10
  45. package/dist/lib/doctor/checks/work-context.js +7 -2
  46. package/dist/lib/doctor/fix/apply.d.ts +3 -2
  47. package/dist/lib/doctor/fix/apply.js +19 -15
  48. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
  49. package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
  50. package/dist/lib/doctor/fix/remediation.js +4 -2
  51. package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
  52. package/dist/lib/doctor/hook-gate-profiles.js +34 -0
  53. package/dist/lib/doctor/types.d.ts +5 -0
  54. package/dist/lib/install/provider-wrappers.js +5 -1
  55. package/dist/lib/lanes/autopilot.js +4 -1
  56. package/dist/lib/lanes/imported-question.d.ts +5 -0
  57. package/dist/lib/lanes/imported-question.js +17 -0
  58. package/dist/lib/lanes/questions.d.ts +0 -5
  59. package/dist/lib/lanes/questions.js +2 -0
  60. package/dist/lib/lanes/reconcile.js +21 -10
  61. package/dist/lib/lanes/store.d.ts +0 -8
  62. package/dist/lib/lanes/store.js +2 -1
  63. package/dist/lib/lanes/stories.d.ts +15 -0
  64. package/dist/lib/lanes/stories.js +25 -0
  65. package/dist/lib/mesh/client.d.ts +8 -0
  66. package/dist/lib/mesh/client.js +44 -3
  67. package/dist/lib/mesh/denial-codes.js +1 -1
  68. package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
  69. package/dist/lib/mesh/live/daemon/run.js +30 -1
  70. package/dist/lib/mesh/live/flush.d.ts +7 -0
  71. package/dist/lib/mesh/live/flush.js +53 -10
  72. package/dist/lib/registry/registry.js +6 -0
  73. package/dist/lib/scan-packages/index.js +7 -3
  74. package/dist/lib/sync/read-only-local-only.d.ts +45 -0
  75. package/dist/lib/sync/read-only-local-only.js +122 -0
  76. package/dist/lib/work-context/organize.d.ts +2 -0
  77. package/dist/lib/work-context/organize.js +22 -4
  78. package/dist/lib/workers-registry/index.d.ts +12 -1
  79. package/dist/lib/workers-registry/index.js +39 -1
  80. package/dist/utils/cognito-session.d.ts +4 -1
  81. package/dist/utils/cognito-session.js +11 -1
  82. package/package.json +7 -7
@@ -0,0 +1,137 @@
1
+ /** Producer-service fixtures verified against hq-pro PR #5060 at 7c234efa (in-memory service output; handler wrappers). */
2
+ import type { Decision } from "../client.js";
3
+ export declare const openDecision: Decision;
4
+ export declare const decisionFixtures: {
5
+ create: {
6
+ decision: Decision;
7
+ };
8
+ list: {
9
+ decisions: Decision[];
10
+ };
11
+ answer: {
12
+ decision: {
13
+ state: string;
14
+ updatedAt: string;
15
+ answer: {
16
+ choiceId: string;
17
+ by: string;
18
+ at: string;
19
+ source: string;
20
+ };
21
+ id: string;
22
+ askerPrincipal: string;
23
+ companyUid: string;
24
+ audience: {
25
+ kind: "person";
26
+ personUid: string;
27
+ } | {
28
+ kind: "company_admins";
29
+ };
30
+ statement: string;
31
+ options: import("../client.js").DecisionOption[];
32
+ context: {
33
+ links: string[];
34
+ brief: string;
35
+ commitmentId: string | null;
36
+ };
37
+ talkThreadId: string | null;
38
+ createdAt: string;
39
+ expiresAt: string;
40
+ };
41
+ };
42
+ talk: {
43
+ decision: {
44
+ state: string;
45
+ updatedAt: string;
46
+ talkThreadId: null;
47
+ id: string;
48
+ askerPrincipal: string;
49
+ companyUid: string;
50
+ audience: {
51
+ kind: "person";
52
+ personUid: string;
53
+ } | {
54
+ kind: "company_admins";
55
+ };
56
+ statement: string;
57
+ options: import("../client.js").DecisionOption[];
58
+ context: {
59
+ links: string[];
60
+ brief: string;
61
+ commitmentId: string | null;
62
+ };
63
+ answer: {
64
+ choiceId: string;
65
+ by: string;
66
+ at: string;
67
+ source: "tap" | "thread" | "desktop";
68
+ } | null;
69
+ createdAt: string;
70
+ expiresAt: string;
71
+ };
72
+ };
73
+ withdraw: {
74
+ decision: {
75
+ state: string;
76
+ updatedAt: string;
77
+ id: string;
78
+ askerPrincipal: string;
79
+ companyUid: string;
80
+ audience: {
81
+ kind: "person";
82
+ personUid: string;
83
+ } | {
84
+ kind: "company_admins";
85
+ };
86
+ statement: string;
87
+ options: import("../client.js").DecisionOption[];
88
+ context: {
89
+ links: string[];
90
+ brief: string;
91
+ commitmentId: string | null;
92
+ };
93
+ answer: {
94
+ choiceId: string;
95
+ by: string;
96
+ at: string;
97
+ source: "tap" | "thread" | "desktop";
98
+ } | null;
99
+ talkThreadId: string | null;
100
+ createdAt: string;
101
+ expiresAt: string;
102
+ };
103
+ };
104
+ expired: {
105
+ decision: {
106
+ state: string;
107
+ updatedAt: string;
108
+ id: string;
109
+ askerPrincipal: string;
110
+ companyUid: string;
111
+ audience: {
112
+ kind: "person";
113
+ personUid: string;
114
+ } | {
115
+ kind: "company_admins";
116
+ };
117
+ statement: string;
118
+ options: import("../client.js").DecisionOption[];
119
+ context: {
120
+ links: string[];
121
+ brief: string;
122
+ commitmentId: string | null;
123
+ };
124
+ answer: {
125
+ choiceId: string;
126
+ by: string;
127
+ at: string;
128
+ source: "tap" | "thread" | "desktop";
129
+ } | null;
130
+ talkThreadId: string | null;
131
+ createdAt: string;
132
+ expiresAt: string;
133
+ };
134
+ };
135
+ };
136
+ export declare const decisionRoutes: string[][];
137
+ //# sourceMappingURL=us001.d.ts.map
@@ -0,0 +1,21 @@
1
+ export const openDecision = {
2
+ id: "f3ccdf93-9b7e-4564-a3ad-566067900aee", companyUid: "cmp_indigo", askerPrincipal: "agt_personal", audience: { kind: "person", personUid: "prs_owner" },
3
+ statement: "Release the update?",
4
+ options: [{ id: "A", label: "Release", recommended: true }, { id: "B", label: "Wait", recommended: false }],
5
+ context: { links: ["https://example.com/review"], brief: "Checks passed", commitmentId: "cm_1" },
6
+ state: "open", answer: null, talkThreadId: null,
7
+ createdAt: "2026-10-18T02:00:00.000Z", expiresAt: "2026-10-19T02:00:00.000Z", updatedAt: "2026-10-18T02:00:00.000Z",
8
+ };
9
+ export const decisionFixtures = {
10
+ create: { decision: openDecision },
11
+ list: { decisions: [openDecision] },
12
+ answer: { decision: { ...openDecision, state: "answered", updatedAt: "2026-10-18T03:00:00.000Z", answer: { choiceId: "A", by: "prs_owner", at: "2026-10-18T03:00:00.000Z", source: "desktop" } } },
13
+ talk: { decision: { ...openDecision, state: "talking", updatedAt: "2026-10-18T03:00:00.000Z", talkThreadId: null } },
14
+ withdraw: { decision: { ...openDecision, state: "withdrawn", updatedAt: "2026-10-18T03:00:00.000Z" } },
15
+ expired: { decision: { ...openDecision, state: "expired", updatedAt: "2026-10-19T03:00:00.000Z" } },
16
+ };
17
+ export const decisionRoutes = [
18
+ ["POST", "/v1/decisions"], ["GET", "/v1/decisions"],
19
+ ["POST", "/v1/decisions/{id}/answer"], ["POST", "/v1/decisions/{id}/talk"], ["POST", "/v1/decisions/{id}/withdraw"],
20
+ ];
21
+ //# sourceMappingURL=us001.js.map
@@ -0,0 +1,16 @@
1
+ import { type QuestionBody } from "../lanes/questions.js";
2
+ import type { QuestionRecord } from "../lanes/types.js";
3
+ import { DecisionsClient, type AskDecision } from "./client.js";
4
+ export declare function legacyDecisionInput(record: QuestionRecord, body: QuestionBody, audience: string): AskDecision | undefined;
5
+ export interface ImportCounts {
6
+ imported: number;
7
+ alreadyImported: number;
8
+ skipped: number;
9
+ skippedQuestions: Array<{
10
+ id: string;
11
+ reason: string;
12
+ }>;
13
+ }
14
+ /** Read legacy inputs only. Receipts live separately; legacy lane commands keep their storage. */
15
+ export declare function importLaneQuestions(root: string, audience: string, client: Pick<DecisionsClient, "ask" | "list">): Promise<ImportCounts>;
16
+ //# sourceMappingURL=import-lanes.d.ts.map
@@ -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. */