@indigoai-us/hq-cli 5.345.152 → 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.
- package/CHANGELOG.md +88 -0
- package/dist/bin/hq-auth-refresh.d.ts +4 -0
- package/dist/bin/hq-auth-refresh.js +9 -1
- package/dist/command-catalog.generated.d.ts +185 -0
- package/dist/command-catalog.generated.js +227 -0
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +12 -0
- package/dist/commands/agent-enroll.js +37 -4
- package/dist/commands/core-policy.js +4 -4
- package/dist/commands/core-session-project-native.js +19 -4
- package/dist/commands/decide.d.ts +13 -0
- package/dist/commands/decide.js +74 -0
- package/dist/commands/integrations-connect.js +24 -1
- package/dist/commands/integrations-core.d.ts +14 -0
- package/dist/commands/integrations-core.js +19 -1
- package/dist/commands/integrations-import.js +22 -1
- package/dist/commands/integrations.js +43 -2
- package/dist/commands/lanes-message.js +4 -11
- package/dist/commands/lanes-questions.js +3 -0
- package/dist/commands/lanes-resume.js +4 -11
- package/dist/commands/lanes-stories.js +3 -0
- package/dist/commands/run.js +14 -5
- package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
- package/dist/lib/agent-kit/adopt-identity.js +18 -3
- package/dist/lib/agent-kit/services.d.ts +3 -1
- package/dist/lib/agent-kit/services.js +5 -1
- package/dist/lib/daemon/ops/policy.d.ts +9 -1
- package/dist/lib/daemon/ops/policy.js +37 -2
- package/dist/lib/decisions/client.d.ts +67 -0
- package/dist/lib/decisions/client.js +120 -0
- package/dist/lib/decisions/expiry.d.ts +2 -0
- package/dist/lib/decisions/expiry.js +12 -0
- package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
- package/dist/lib/decisions/fixtures/us001.js +21 -0
- package/dist/lib/decisions/import-lanes.d.ts +16 -0
- package/dist/lib/decisions/import-lanes.js +152 -0
- package/dist/lib/decisions/principal.d.ts +11 -0
- package/dist/lib/decisions/principal.js +52 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
- package/dist/lib/doctor/checks/claude-wiring.js +21 -10
- package/dist/lib/doctor/checks/work-context.js +7 -2
- package/dist/lib/doctor/fix/apply.d.ts +3 -2
- package/dist/lib/doctor/fix/apply.js +19 -15
- package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
- package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
- package/dist/lib/doctor/fix/remediation.js +4 -2
- package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
- package/dist/lib/doctor/hook-gate-profiles.js +34 -0
- package/dist/lib/doctor/types.d.ts +5 -0
- package/dist/lib/install/provider-wrappers.js +5 -1
- package/dist/lib/lanes/autopilot.js +4 -1
- package/dist/lib/lanes/imported-question.d.ts +5 -0
- package/dist/lib/lanes/imported-question.js +17 -0
- package/dist/lib/lanes/questions.d.ts +0 -5
- package/dist/lib/lanes/questions.js +2 -0
- package/dist/lib/lanes/reconcile.js +21 -10
- package/dist/lib/lanes/store.d.ts +0 -8
- package/dist/lib/lanes/store.js +2 -1
- package/dist/lib/lanes/stories.d.ts +15 -0
- package/dist/lib/lanes/stories.js +25 -0
- package/dist/lib/mesh/client.d.ts +8 -0
- package/dist/lib/mesh/client.js +44 -3
- package/dist/lib/mesh/denial-codes.js +1 -1
- package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
- package/dist/lib/mesh/live/daemon/run.js +30 -1
- package/dist/lib/mesh/live/flush.d.ts +7 -0
- package/dist/lib/mesh/live/flush.js +53 -10
- package/dist/lib/registry/registry.js +6 -0
- package/dist/lib/scan-packages/index.js +7 -3
- package/dist/lib/work-context/organize.d.ts +2 -0
- package/dist/lib/work-context/organize.js +22 -4
- package/dist/lib/workers-registry/index.d.ts +12 -1
- package/dist/lib/workers-registry/index.js +39 -1
- package/dist/utils/cognito-session.d.ts +4 -1
- package/dist/utils/cognito-session.js +11 -1
- package/package.json +7 -7
|
@@ -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
|
|
7
|
-
*
|
|
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
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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${
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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,
|
|
185
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
91
|
-
if (checkId.endsWith(".gate-profiles") &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|