@indigoai-us/hq-cli 5.322.0 → 5.324.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/assets/scaffold/core/scripts/derive-trigger-facts.sh +263 -0
- package/assets/scaffold/core/scripts/lib/transcript-tail.sh +98 -0
- package/assets/scaffold/core/scripts/lib/trigger-fact-text.awk +86 -0
- package/dist/bin/hq-auth-refresh.d.ts +5 -1
- package/dist/bin/hq-auth-refresh.js +33 -1
- package/dist/command-catalog.generated.d.ts +138 -6
- package/dist/command-catalog.generated.js +178 -6
- package/dist/command-registration-plan.d.ts +24 -0
- package/dist/command-registration-plan.js +4 -0
- package/dist/commands/anywhere-runtime-flag.d.ts +9 -0
- package/dist/commands/anywhere-runtime-flag.js +22 -0
- package/dist/commands/cloud.js +23 -4
- package/dist/commands/core-derive-trigger-facts-command.d.ts +3 -0
- package/dist/commands/core-derive-trigger-facts-command.js +47 -0
- package/dist/commands/core-derive-trigger-facts-native.d.ts +21 -0
- package/dist/commands/core-derive-trigger-facts-native.js +492 -0
- package/dist/commands/core.js +6 -0
- package/dist/commands/daemon.js +39 -2
- package/dist/commands/db-provision.js +6 -1
- package/dist/commands/doctor.js +8 -4
- package/dist/commands/feedback.d.ts +2 -0
- package/dist/commands/feedback.js +10 -0
- package/dist/commands/install-global.d.ts +46 -0
- package/dist/commands/install-global.js +674 -0
- package/dist/commands/link.d.ts +30 -0
- package/dist/commands/link.js +184 -0
- package/dist/commands/mcp-status.d.ts +6 -0
- package/dist/commands/mcp-status.js +21 -4
- package/dist/commands/pkg-install.js +20 -1
- package/dist/commands/update.d.ts +19 -0
- package/dist/commands/update.js +52 -6
- package/dist/fast-core.d.ts +2 -1
- package/dist/fast-core.js +12 -8
- package/dist/fast-scaffold.js +13 -0
- package/dist/index.js +9 -7
- package/dist/lib/cli-invite-nudge.d.ts +25 -0
- package/dist/lib/cli-invite-nudge.js +152 -0
- package/dist/lib/core-utils/timeout-guard-native-flags.d.ts +6 -0
- package/dist/lib/core-utils/timeout-guard-native-flags.js +30 -0
- package/dist/lib/core-utils/timeout-guard-native.d.ts +0 -3
- package/dist/lib/core-utils/timeout-guard-native.js +4 -30
- package/dist/lib/core-utils/timeout-guard.d.ts +2 -0
- package/dist/lib/core-utils/timeout-guard.js +2 -1
- package/dist/lib/daemon/convergence.d.ts +42 -0
- package/dist/lib/daemon/convergence.js +66 -0
- package/dist/lib/daemon/heartbeat.d.ts +43 -0
- package/dist/lib/daemon/heartbeat.js +70 -0
- package/dist/lib/daemon/ops/journal.d.ts +29 -0
- package/dist/lib/daemon/ops/journal.js +141 -0
- package/dist/lib/daemon/ops/memory.d.ts +20 -0
- package/dist/lib/daemon/ops/memory.js +65 -0
- package/dist/lib/daemon/ops/mesh.d.ts +38 -0
- package/dist/lib/daemon/ops/mesh.js +70 -0
- package/dist/lib/daemon/ops/policy.d.ts +78 -0
- package/dist/lib/daemon/ops/policy.js +283 -0
- package/dist/lib/daemon/protocol.d.ts +68 -0
- package/dist/lib/daemon/protocol.js +154 -0
- package/dist/lib/daemon/run.d.ts +10 -0
- package/dist/lib/daemon/run.js +85 -3
- package/dist/lib/daemon/server.d.ts +36 -0
- package/dist/lib/daemon/server.js +306 -0
- package/dist/lib/daemon/session-registry.d.ts +30 -0
- package/dist/lib/daemon/session-registry.js +42 -0
- package/dist/lib/doctor/checks/anywhere.d.ts +38 -0
- package/dist/lib/doctor/checks/anywhere.js +182 -0
- package/dist/lib/doctor/registry.js +6 -0
- package/dist/lib/first-sync-next-step.d.ts +10 -1
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/install/claude-targets.d.ts +47 -0
- package/dist/lib/install/claude-targets.js +203 -0
- package/dist/lib/install/codex-targets.d.ts +57 -0
- package/dist/lib/install/codex-targets.js +187 -0
- package/dist/lib/mesh/live/session-seq.js +62 -9
- package/dist/lib/registry/normalize-remote.d.ts +21 -0
- package/dist/lib/registry/normalize-remote.js +68 -0
- package/dist/lib/registry/registry.d.ts +75 -0
- package/dist/lib/registry/registry.js +261 -0
- package/dist/main.js +3 -1
- package/dist/utils/cli-telemetry.d.ts +11 -0
- package/dist/utils/cli-telemetry.js +76 -0
- package/dist/utils/cognito-session.d.ts +5 -1
- package/dist/utils/cognito-session.js +8 -12
- package/dist/utils/feedback-diagnostics.d.ts +2 -0
- package/dist/utils/run-bundled-script.d.ts +7 -4
- package/dist/utils/run-bundled-script.js +47 -12
- package/dist/utils/version-check.d.ts +2 -0
- package/dist/utils/version-check.js +7 -0
- package/package.json +2 -2
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { isExpiring, isMachineIdentity, loadCachedTokens, } from "../utils/cognito-session.js";
|
|
7
|
+
import { queueCliInviteNudgeEvent } from "../utils/cli-telemetry.js";
|
|
8
|
+
import { resolveCompanyFlagGate } from "./flag-registry.js";
|
|
9
|
+
import { isHumanInteractiveCliSession } from "./first-sync-next-step.js";
|
|
10
|
+
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
11
|
+
export const CLI_INVITE_NUDGE_FLAG = "cli.first-sync-invite-teammate-v1";
|
|
12
|
+
function reportFailure(operation, error) {
|
|
13
|
+
const name = error instanceof Error ? error.name : "unknown";
|
|
14
|
+
const code = /^[A-Za-z][A-Za-z0-9]{0,39}$/.test(name) ? name : "unknown";
|
|
15
|
+
console.error(`hq: Could not ${operation} (${code}); invite prompt stays hidden.`);
|
|
16
|
+
}
|
|
17
|
+
/** One eligible, successful work-bearing company sync can show one prompt per company. */
|
|
18
|
+
export async function runCliInviteNudge(input) {
|
|
19
|
+
if (!input.interactive || !input.canPrompt || input.alreadyShown || !/^cmp_[A-Za-z0-9_-]{1,128}$/.test(input.companyUid)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
let enabled;
|
|
23
|
+
try {
|
|
24
|
+
enabled = await input.flagEnabled();
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
(input.reportFailure ?? reportFailure)("resolve the invite flag", error);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (!enabled)
|
|
31
|
+
return false;
|
|
32
|
+
let humanMemberCount;
|
|
33
|
+
try {
|
|
34
|
+
humanMemberCount = await input.readHumanMemberCount();
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
(input.reportFailure ?? reportFailure)("check company membership", error);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (humanMemberCount !== 1 || !input.claimShown())
|
|
41
|
+
return false;
|
|
42
|
+
input.write("Your company has one member. Invite a teammate to share the workspace.");
|
|
43
|
+
input.record(input.companyUid, "shown");
|
|
44
|
+
let clicked;
|
|
45
|
+
try {
|
|
46
|
+
clicked = await input.ask("Show the existing invite command? [y/N] ");
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
(input.reportFailure ?? reportFailure)("read the invite choice", error);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
input.record(input.companyUid, clicked ? "clicked" : "dismissed");
|
|
53
|
+
if (clicked) {
|
|
54
|
+
input.write(`Run: hq members invite <email> --company ${input.companyRef} --role member`);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
function inviteNudgeMarkerPath(companyUid, home = process.env.HOME?.trim() || os.homedir()) {
|
|
59
|
+
const digest = createHash("sha256").update(companyUid).digest("hex");
|
|
60
|
+
return path.join(home, ".hq", "cli-invite-nudge", `${digest}.shown`);
|
|
61
|
+
}
|
|
62
|
+
function markerExists(companyUid) {
|
|
63
|
+
try {
|
|
64
|
+
return fs.existsSync(inviteNudgeMarkerPath(companyUid));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
reportFailure("read invite prompt state", error);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function claimMarker(companyUid) {
|
|
72
|
+
const marker = inviteNudgeMarkerPath(companyUid);
|
|
73
|
+
const homeHq = path.join(process.env.HOME?.trim() || os.homedir(), ".hq");
|
|
74
|
+
try {
|
|
75
|
+
// Do not create ~/.hq for a machine without a signed-in person profile.
|
|
76
|
+
if (!fs.existsSync(homeHq))
|
|
77
|
+
return false;
|
|
78
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true, mode: 0o700 });
|
|
79
|
+
fs.writeFileSync(marker, `${new Date().toISOString()}\n`, { flag: "wx", mode: 0o600 });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
reportFailure("write invite prompt state", error);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function readActiveHumanMemberCount(accessToken, companyUid) {
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const timeout = setTimeout(() => controller.abort(), 1_200);
|
|
93
|
+
try {
|
|
94
|
+
const response = await vaultApiFetch({
|
|
95
|
+
token: accessToken,
|
|
96
|
+
path: `/membership/company/${encodeURIComponent(companyUid)}`,
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
throw new Error(`http_${response.status}`);
|
|
101
|
+
const body = await response.json();
|
|
102
|
+
if (!Array.isArray(body.members))
|
|
103
|
+
throw new Error("invalid_roster_shape");
|
|
104
|
+
return body.members.filter((member) => typeof member === "object" && member !== null &&
|
|
105
|
+
member.status === "active" &&
|
|
106
|
+
typeof member.personUid === "string" &&
|
|
107
|
+
member.personUid.startsWith("prs_")).length;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function askInviteChoice(prompt) {
|
|
114
|
+
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
115
|
+
try {
|
|
116
|
+
const answer = await terminal.question(prompt);
|
|
117
|
+
return answer.trim().toLowerCase() === "y";
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
terminal.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Called after a successful work-bearing company sync; every dependency remains fail-closed. */
|
|
124
|
+
export async function maybeShowCliInviteNudge(input) {
|
|
125
|
+
const companyUid = input.companyUid?.trim() ?? "";
|
|
126
|
+
const companyRef = input.companyRef?.trim() ?? "";
|
|
127
|
+
if (!companyUid || !companyRef)
|
|
128
|
+
return false;
|
|
129
|
+
const cached = loadCachedTokens();
|
|
130
|
+
if (!cached || isExpiring(cached, 120) ||
|
|
131
|
+
!isHumanInteractiveCliSession({
|
|
132
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
133
|
+
argv: process.argv,
|
|
134
|
+
env: process.env,
|
|
135
|
+
machineIdentity: isMachineIdentity(),
|
|
136
|
+
}))
|
|
137
|
+
return false;
|
|
138
|
+
return runCliInviteNudge({
|
|
139
|
+
companyUid,
|
|
140
|
+
companyRef,
|
|
141
|
+
interactive: true,
|
|
142
|
+
canPrompt: process.stdin.isTTY === true,
|
|
143
|
+
alreadyShown: markerExists(companyUid),
|
|
144
|
+
flagEnabled: () => resolveCompanyFlagGate(companyUid, cached.idToken ?? "", CLI_INVITE_NUDGE_FLAG, { fallback: false }, () => false),
|
|
145
|
+
readHumanMemberCount: () => readActiveHumanMemberCount(input.accessToken, companyUid),
|
|
146
|
+
claimShown: () => claimMarker(companyUid),
|
|
147
|
+
ask: askInviteChoice,
|
|
148
|
+
write: (line) => console.log(line),
|
|
149
|
+
record: queueCliInviteNudgeEvent,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=cli-invite-nudge.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Flag snapshot reader loaded only when the timeout guard would block. */
|
|
2
|
+
import type { FlagReader } from "../flag-registry.js";
|
|
3
|
+
import { type PersistedFlagSnapshot } from "../flag-snapshot-file.js";
|
|
4
|
+
export declare function createNativeSnapshotFlagReader(persistedSnapshot: PersistedFlagSnapshot | null, companyIdentifiers: readonly string[], env: NodeJS.ProcessEnv): FlagReader;
|
|
5
|
+
export declare function snapshotFlagReader(env: NodeJS.ProcessEnv): FlagReader;
|
|
6
|
+
//# sourceMappingURL=timeout-guard-native-flags.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Flag snapshot reader loaded only when the timeout guard would block. */
|
|
2
|
+
import { readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
|
|
3
|
+
import { resolveProcessFlagCompanyContext } from "../process-flag-company-context.js";
|
|
4
|
+
import { readFlagSnapshotFile, } from "../flag-snapshot-file.js";
|
|
5
|
+
export function createNativeSnapshotFlagReader(persistedSnapshot, companyIdentifiers, env) {
|
|
6
|
+
const snapshot = persistedSnapshot
|
|
7
|
+
? { version: 0, flags: persistedSnapshot.flags }
|
|
8
|
+
: null;
|
|
9
|
+
return {
|
|
10
|
+
isEnabled(flagKey, lookup = {}) {
|
|
11
|
+
if (snapshot) {
|
|
12
|
+
return resolveFlag({
|
|
13
|
+
flagKey,
|
|
14
|
+
env,
|
|
15
|
+
snapshot,
|
|
16
|
+
companyIdentifiers,
|
|
17
|
+
lookup,
|
|
18
|
+
}).value;
|
|
19
|
+
}
|
|
20
|
+
return readLocalOverride(flagKey, env) ?? (lookup.fallback ?? false);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function snapshotFlagReader(env) {
|
|
25
|
+
const company = resolveProcessFlagCompanyContext(env);
|
|
26
|
+
const persistedSnapshot = company.companyUid ? readFlagSnapshotFile(company.companyUid, { env }) : null;
|
|
27
|
+
const companyIdentifiers = [company.companyUid, company.companySlug].filter((value) => Boolean(value));
|
|
28
|
+
return createNativeSnapshotFlagReader(persistedSnapshot, companyIdentifiers, env);
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=timeout-guard-native-flags.js.map
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
/** Native fast-route adapter for the shared timeout-guard command. */
|
|
2
|
-
import type { FlagReader } from "../flag-registry.js";
|
|
3
|
-
import { type PersistedFlagSnapshot } from "../flag-snapshot-file.js";
|
|
4
2
|
import { type TimeoutGuardDeps } from "./timeout-guard.js";
|
|
5
|
-
export declare function createNativeSnapshotFlagReader(persistedSnapshot: PersistedFlagSnapshot | null, companyIdentifiers: readonly string[], env: NodeJS.ProcessEnv): FlagReader;
|
|
6
3
|
export declare function runTimeoutGuardFast(_argv: readonly string[], stdin: string, _cwd: string, _hqRoot: string | undefined, dependencies?: TimeoutGuardDeps): Promise<number>;
|
|
7
4
|
//# sourceMappingURL=timeout-guard-native.d.ts.map
|
|
@@ -1,40 +1,14 @@
|
|
|
1
1
|
/** Native fast-route adapter for the shared timeout-guard command. */
|
|
2
|
-
import { readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
|
|
3
|
-
import { resolveProcessFlagCompanyContext } from "../process-flag-company-context.js";
|
|
4
|
-
import { readFlagSnapshotFile, } from "../flag-snapshot-file.js";
|
|
5
2
|
import { timeoutGuardCommand } from "./timeout-guard.js";
|
|
6
|
-
export function createNativeSnapshotFlagReader(persistedSnapshot, companyIdentifiers, env) {
|
|
7
|
-
const snapshot = persistedSnapshot
|
|
8
|
-
? { version: 0, flags: persistedSnapshot.flags }
|
|
9
|
-
: null;
|
|
10
|
-
return {
|
|
11
|
-
isEnabled(flagKey, lookup = {}) {
|
|
12
|
-
if (snapshot) {
|
|
13
|
-
return resolveFlag({
|
|
14
|
-
flagKey,
|
|
15
|
-
env,
|
|
16
|
-
snapshot,
|
|
17
|
-
companyIdentifiers,
|
|
18
|
-
lookup,
|
|
19
|
-
}).value;
|
|
20
|
-
}
|
|
21
|
-
return readLocalOverride(flagKey, env) ?? (lookup.fallback ?? false);
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
function snapshotFlagReader(companyUid, companySlug, env) {
|
|
26
|
-
const persistedSnapshot = companyUid ? readFlagSnapshotFile(companyUid, { env }) : null;
|
|
27
|
-
const companyIdentifiers = [companyUid, companySlug].filter((value) => Boolean(value));
|
|
28
|
-
return createNativeSnapshotFlagReader(persistedSnapshot, companyIdentifiers, env);
|
|
29
|
-
}
|
|
30
3
|
export async function runTimeoutGuardFast(_argv, stdin, _cwd, _hqRoot, dependencies = {}) {
|
|
31
4
|
const env = dependencies.env ?? process.env;
|
|
32
|
-
const company = resolveProcessFlagCompanyContext(env);
|
|
33
|
-
const flagReader = dependencies.flagReader ?? snapshotFlagReader(company.companyUid, company.companySlug, env);
|
|
34
5
|
return timeoutGuardCommand(stdin, {
|
|
35
6
|
...dependencies,
|
|
36
7
|
env,
|
|
37
|
-
|
|
8
|
+
getFlagReader: dependencies.getFlagReader ?? (async () => {
|
|
9
|
+
const { snapshotFlagReader } = await import("./timeout-guard-native-flags.js");
|
|
10
|
+
return snapshotFlagReader(env);
|
|
11
|
+
}),
|
|
38
12
|
});
|
|
39
13
|
}
|
|
40
14
|
//# sourceMappingURL=timeout-guard-native.js.map
|
|
@@ -49,6 +49,8 @@ export declare function evaluateTimeoutGuard(input: TimeoutGuardInput): GuardVer
|
|
|
49
49
|
export interface TimeoutGuardDeps {
|
|
50
50
|
/** Resolve the current HQ user email for the rollout gate. Default: cached id token. */
|
|
51
51
|
getEmail?: () => string | undefined;
|
|
52
|
+
/** Resolve the flag reader only after an input would otherwise be blocked. */
|
|
53
|
+
getFlagReader?: () => Promise<FlagReader>;
|
|
52
54
|
env?: NodeJS.ProcessEnv;
|
|
53
55
|
stderr?: NodeJS.WritableStream;
|
|
54
56
|
/** Test seam: held registry snapshot reader. */
|
|
@@ -222,8 +222,9 @@ export async function timeoutGuardCommand(stdin, deps = {}) {
|
|
|
222
222
|
if (!verdict.block)
|
|
223
223
|
return 0;
|
|
224
224
|
// Rollout gate: only act for @getindigo.ai identities.
|
|
225
|
+
const flagReader = deps.flagReader ?? await deps.getFlagReader?.();
|
|
225
226
|
const getEmail = deps.getEmail ?? defaultEmail;
|
|
226
|
-
if (!await isGatedUser(await getEmail(),
|
|
227
|
+
if (!await isGatedUser(await getEmail(), flagReader))
|
|
227
228
|
return 0;
|
|
228
229
|
err.write(`${BLOCK_MESSAGE(verdict.reason ?? "declared timeout exceeds the harness ceiling")}\n`);
|
|
229
230
|
return 2;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon version convergence (hq-anywhere US-012).
|
|
3
|
+
*
|
|
4
|
+
* After `hq update` (or /update-hq) installs a new hq-cli, the resident
|
|
5
|
+
* daemon still runs the old code until it restarts. This module asks the
|
|
6
|
+
* running hqd for its version and compares it with the version now installed
|
|
7
|
+
* on disk. Policy hq-self-updater-must-verify-convergence-after-install: the
|
|
8
|
+
* caller reports a mismatch as a failure; it is never swallowed.
|
|
9
|
+
*/
|
|
10
|
+
export declare const CONVERGENCE_TIMEOUT_MS = 10000;
|
|
11
|
+
export declare const CONVERGENCE_POLL_MS = 500;
|
|
12
|
+
export interface ConvergenceResult {
|
|
13
|
+
converged: boolean;
|
|
14
|
+
expected: string;
|
|
15
|
+
/** The version hqd reported last, or null when it never answered. */
|
|
16
|
+
running: string | null;
|
|
17
|
+
/** The last ping error when hqd never answered. */
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ConvergenceDeps {
|
|
21
|
+
/** Ask hqd for its version; rejects when hqd is unreachable. */
|
|
22
|
+
ping?: () => Promise<string>;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
pollMs?: number;
|
|
25
|
+
sleep?: (ms: number) => Promise<void>;
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}
|
|
28
|
+
/** Ping hqd over its socket and return the version it reports. */
|
|
29
|
+
export declare function pingHqdVersion(socketPath?: string, timeoutMs?: number): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* The hq-cli version installed on PATH. The process running `hq update` is
|
|
32
|
+
* the old CLI, so its own CLI_VERSION is not the installed version.
|
|
33
|
+
*/
|
|
34
|
+
export declare function installedCliVersion(run?: (cmd: string, args: string[]) => {
|
|
35
|
+
status: number | null;
|
|
36
|
+
stdout: string;
|
|
37
|
+
}): string | null;
|
|
38
|
+
/** Poll hqd until it reports `expected` or the budget runs out. */
|
|
39
|
+
export declare function verifyDaemonConvergence(expected: string, deps?: ConvergenceDeps): Promise<ConvergenceResult>;
|
|
40
|
+
/** One-line description of a non-converged result, with the remediation. */
|
|
41
|
+
export declare function describeNonConvergence(result: ConvergenceResult): string;
|
|
42
|
+
//# sourceMappingURL=convergence.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon version convergence (hq-anywhere US-012).
|
|
3
|
+
*
|
|
4
|
+
* After `hq update` (or /update-hq) installs a new hq-cli, the resident
|
|
5
|
+
* daemon still runs the old code until it restarts. This module asks the
|
|
6
|
+
* running hqd for its version and compares it with the version now installed
|
|
7
|
+
* on disk. Policy hq-self-updater-must-verify-convergence-after-install: the
|
|
8
|
+
* caller reports a mismatch as a failure; it is never swallowed.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { hqdRequest } from "./protocol.js";
|
|
12
|
+
export const CONVERGENCE_TIMEOUT_MS = 10_000;
|
|
13
|
+
export const CONVERGENCE_POLL_MS = 500;
|
|
14
|
+
/** Ping hqd over its socket and return the version it reports. */
|
|
15
|
+
export async function pingHqdVersion(socketPath, timeoutMs = 2_000) {
|
|
16
|
+
const result = (await hqdRequest("ping", {}, { socketPath, timeoutMs }));
|
|
17
|
+
if (typeof result?.version !== "string")
|
|
18
|
+
throw new Error("hqd ping did not return a version");
|
|
19
|
+
return result.version;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The hq-cli version installed on PATH. The process running `hq update` is
|
|
23
|
+
* the old CLI, so its own CLI_VERSION is not the installed version.
|
|
24
|
+
*/
|
|
25
|
+
export function installedCliVersion(run = defaultRun) {
|
|
26
|
+
const out = run("hq", ["--version"]);
|
|
27
|
+
if (out.status !== 0)
|
|
28
|
+
return null;
|
|
29
|
+
const match = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/.exec(out.stdout);
|
|
30
|
+
return match ? match[0] : null;
|
|
31
|
+
}
|
|
32
|
+
function defaultRun(cmd, args) {
|
|
33
|
+
const result = spawnSync(cmd, args, { encoding: "utf8", timeout: 10_000 });
|
|
34
|
+
return { status: result.status, stdout: typeof result.stdout === "string" ? result.stdout : "" };
|
|
35
|
+
}
|
|
36
|
+
/** Poll hqd until it reports `expected` or the budget runs out. */
|
|
37
|
+
export async function verifyDaemonConvergence(expected, deps = {}) {
|
|
38
|
+
const ping = deps.ping ?? (() => pingHqdVersion());
|
|
39
|
+
const timeoutMs = deps.timeoutMs ?? CONVERGENCE_TIMEOUT_MS;
|
|
40
|
+
const pollMs = deps.pollMs ?? CONVERGENCE_POLL_MS;
|
|
41
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
42
|
+
const now = deps.now ?? Date.now;
|
|
43
|
+
const deadline = now() + timeoutMs;
|
|
44
|
+
let running = null;
|
|
45
|
+
let error;
|
|
46
|
+
for (;;) {
|
|
47
|
+
try {
|
|
48
|
+
running = await ping();
|
|
49
|
+
error = undefined;
|
|
50
|
+
if (running === expected)
|
|
51
|
+
return { converged: true, expected, running };
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
error = err instanceof Error ? err.message : String(err);
|
|
55
|
+
}
|
|
56
|
+
if (now() >= deadline)
|
|
57
|
+
return { converged: false, expected, running, ...(error ? { error } : {}) };
|
|
58
|
+
await sleep(pollMs);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** One-line description of a non-converged result, with the remediation. */
|
|
62
|
+
export function describeNonConvergence(result) {
|
|
63
|
+
const seen = result.running ? `hqd is still running ${result.running}` : `hqd did not answer (${result.error ?? "no response"})`;
|
|
64
|
+
return `Daemon did not converge on hq-cli ${result.expected}: ${seen}. Run: hq daemon restart && hq doctor --only anywhere`;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=convergence.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hqd heartbeat (hq-anywhere US-012).
|
|
3
|
+
*
|
|
4
|
+
* Every 60s the daemon records a host-local beat in ~/.hq/hqd-heartbeat.json so `hq doctor` can tell a live daemon
|
|
5
|
+
* from a stale one offline. A device with no beat for 5 minutes is
|
|
6
|
+
* "daemon stale".
|
|
7
|
+
*/
|
|
8
|
+
export declare const HEARTBEAT_INTERVAL_MS = 60000;
|
|
9
|
+
export declare const HEARTBEAT_STALE_MS: number;
|
|
10
|
+
export declare const HEARTBEAT_FILE = "hqd-heartbeat.json";
|
|
11
|
+
export declare const DAEMON_STALE_LABEL = "daemon stale";
|
|
12
|
+
export interface HeartbeatState {
|
|
13
|
+
at: string;
|
|
14
|
+
version: string;
|
|
15
|
+
sessions: number;
|
|
16
|
+
device: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function heartbeatFile(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
19
|
+
/** Mesh session id for this device's daemon; only characters the spool accepts. */
|
|
20
|
+
export declare function heartbeatSessionId(hostname?: string): string;
|
|
21
|
+
export declare function readHeartbeat(file: string): HeartbeatState | null;
|
|
22
|
+
/** Presence label for a device given its last beat: "daemon stale" after 5 minutes of silence. */
|
|
23
|
+
export declare function daemonPresenceLabel(state: HeartbeatState | null, now?: Date): "daemon live" | typeof DAEMON_STALE_LABEL;
|
|
24
|
+
export interface HeartbeatOptions {
|
|
25
|
+
version: string;
|
|
26
|
+
sessionCount: () => number;
|
|
27
|
+
file?: string;
|
|
28
|
+
hostname?: string;
|
|
29
|
+
now?: () => Date;
|
|
30
|
+
intervalMs?: number;
|
|
31
|
+
/** Optional host event publisher. The daemon does not publish to Work Mesh without an explicit company-bound session. */
|
|
32
|
+
publish?: (state: HeartbeatState, sessionId: string) => void;
|
|
33
|
+
log?: (level: "info" | "warn" | "error", message: string) => void;
|
|
34
|
+
}
|
|
35
|
+
/** Publish one heartbeat. Errors are logged and returned, never thrown into the daemon loop. */
|
|
36
|
+
export declare function beatOnce(opts: HeartbeatOptions): {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
state: HeartbeatState;
|
|
39
|
+
error?: string;
|
|
40
|
+
};
|
|
41
|
+
/** Beat now and every interval until stopped. The timer does not hold the process open. */
|
|
42
|
+
export declare function startHeartbeat(opts: HeartbeatOptions): () => void;
|
|
43
|
+
//# sourceMappingURL=heartbeat.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hqd heartbeat (hq-anywhere US-012).
|
|
3
|
+
*
|
|
4
|
+
* Every 60s the daemon records a host-local beat in ~/.hq/hqd-heartbeat.json so `hq doctor` can tell a live daemon
|
|
5
|
+
* from a stale one offline. A device with no beat for 5 minutes is
|
|
6
|
+
* "daemon stale".
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
export const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
12
|
+
export const HEARTBEAT_STALE_MS = 5 * 60_000;
|
|
13
|
+
export const HEARTBEAT_FILE = "hqd-heartbeat.json";
|
|
14
|
+
export const DAEMON_STALE_LABEL = "daemon stale";
|
|
15
|
+
export function heartbeatFile(home = os.homedir(), env = process.env) {
|
|
16
|
+
const dir = env.HQ_REGISTRY_DIR?.trim() ? path.resolve(env.HQ_REGISTRY_DIR.trim()) : path.join(home, ".hq");
|
|
17
|
+
return path.join(dir, HEARTBEAT_FILE);
|
|
18
|
+
}
|
|
19
|
+
/** Mesh session id for this device's daemon; only characters the spool accepts. */
|
|
20
|
+
export function heartbeatSessionId(hostname = os.hostname()) {
|
|
21
|
+
return `hqd-${hostname.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").slice(0, 48) || "device"}`;
|
|
22
|
+
}
|
|
23
|
+
export function readHeartbeat(file) {
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = fs.readFileSync(file, "utf8");
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err.code === "ENOENT")
|
|
30
|
+
return null;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (typeof parsed.at !== "string" || typeof parsed.version !== "string")
|
|
35
|
+
throw new Error(`${file} is not a heartbeat record`);
|
|
36
|
+
return { at: parsed.at, version: parsed.version, sessions: Number(parsed.sessions ?? 0), device: String(parsed.device ?? "") };
|
|
37
|
+
}
|
|
38
|
+
/** Presence label for a device given its last beat: "daemon stale" after 5 minutes of silence. */
|
|
39
|
+
export function daemonPresenceLabel(state, now = new Date()) {
|
|
40
|
+
if (!state)
|
|
41
|
+
return DAEMON_STALE_LABEL;
|
|
42
|
+
const age = now.getTime() - Date.parse(state.at);
|
|
43
|
+
return Number.isFinite(age) && age <= HEARTBEAT_STALE_MS ? "daemon live" : DAEMON_STALE_LABEL;
|
|
44
|
+
}
|
|
45
|
+
/** Publish one heartbeat. Errors are logged and returned, never thrown into the daemon loop. */
|
|
46
|
+
export function beatOnce(opts) {
|
|
47
|
+
const now = (opts.now ?? (() => new Date()))();
|
|
48
|
+
const sessionId = heartbeatSessionId(opts.hostname);
|
|
49
|
+
const state = { at: now.toISOString(), version: opts.version, sessions: opts.sessionCount(), device: sessionId };
|
|
50
|
+
try {
|
|
51
|
+
const file = opts.file ?? heartbeatFile();
|
|
52
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
53
|
+
fs.writeFileSync(file, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
54
|
+
opts.publish?.(state, sessionId);
|
|
55
|
+
return { ok: true, state };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
59
|
+
opts.log?.("warn", `hqd heartbeat failed: ${error}`);
|
|
60
|
+
return { ok: false, state, error };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Beat now and every interval until stopped. The timer does not hold the process open. */
|
|
64
|
+
export function startHeartbeat(opts) {
|
|
65
|
+
beatOnce(opts);
|
|
66
|
+
const timer = setInterval(() => beatOnce(opts), opts.intervalMs ?? HEARTBEAT_INTERVAL_MS);
|
|
67
|
+
timer.unref();
|
|
68
|
+
return () => clearInterval(timer);
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=heartbeat.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* journal.append op (hq-anywhere US-009).
|
|
3
|
+
*
|
|
4
|
+
* Follows `.claude/skills/_shared/journal.sh append` semantics: the entry goes
|
|
5
|
+
* into the project's active journal (frontmatter `status: active`, `project:`
|
|
6
|
+
* = the project folder) as "- <ISO8601> <text>" on the line after the section
|
|
7
|
+
* header, or under a new header at EOF when the header is missing. A daemon
|
|
8
|
+
* has no per-session pointer, so the newest active journal in
|
|
9
|
+
* <project>/journal/ is used, and one is opened (same template as
|
|
10
|
+
* `journal.sh open`) when none exists.
|
|
11
|
+
*/
|
|
12
|
+
export declare const JOURNAL_SECTIONS: Record<string, string>;
|
|
13
|
+
export declare class JournalOpError extends Error {
|
|
14
|
+
readonly code: string;
|
|
15
|
+
constructor(code: string, message: string);
|
|
16
|
+
}
|
|
17
|
+
export interface JournalAppendArgs {
|
|
18
|
+
company: string;
|
|
19
|
+
project: string;
|
|
20
|
+
section: string;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
export interface JournalAppendResult {
|
|
24
|
+
path: string;
|
|
25
|
+
line: string;
|
|
26
|
+
opened: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare function journalAppend(args: JournalAppendArgs, hqRoot: string, now?: Date): JournalAppendResult;
|
|
29
|
+
//# sourceMappingURL=journal.d.ts.map
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* journal.append op (hq-anywhere US-009).
|
|
3
|
+
*
|
|
4
|
+
* Follows `.claude/skills/_shared/journal.sh append` semantics: the entry goes
|
|
5
|
+
* into the project's active journal (frontmatter `status: active`, `project:`
|
|
6
|
+
* = the project folder) as "- <ISO8601> <text>" on the line after the section
|
|
7
|
+
* header, or under a new header at EOF when the header is missing. A daemon
|
|
8
|
+
* has no per-session pointer, so the newest active journal in
|
|
9
|
+
* <project>/journal/ is used, and one is opened (same template as
|
|
10
|
+
* `journal.sh open`) when none exists.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
const SLUG_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
15
|
+
export const JOURNAL_SECTIONS = {
|
|
16
|
+
decisions: "## Decisions",
|
|
17
|
+
open: "## Open threads",
|
|
18
|
+
findings: "## Findings",
|
|
19
|
+
rejected: "## Rejected",
|
|
20
|
+
};
|
|
21
|
+
export class JournalOpError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
constructor(code, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.name = "JournalOpError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function isoSeconds(d) {
|
|
30
|
+
return `${d.toISOString().slice(0, 19)}Z`;
|
|
31
|
+
}
|
|
32
|
+
function frontmatterValue(text, key) {
|
|
33
|
+
const m = new RegExp(`^${key}: (.*)$`, "m").exec(text);
|
|
34
|
+
return m ? m[1] : null;
|
|
35
|
+
}
|
|
36
|
+
function findActiveJournal(projectDir) {
|
|
37
|
+
const dir = path.join(projectDir, "journal");
|
|
38
|
+
let names;
|
|
39
|
+
try {
|
|
40
|
+
names = fs.readdirSync(dir);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err.code === "ENOENT")
|
|
44
|
+
return null;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
for (const name of names.filter((n) => n.endsWith(".md")).sort().reverse()) {
|
|
48
|
+
const file = path.join(dir, name);
|
|
49
|
+
const text = fs.readFileSync(file, "utf8");
|
|
50
|
+
if (frontmatterValue(text, "status") !== "active")
|
|
51
|
+
continue;
|
|
52
|
+
const project = frontmatterValue(text, "project");
|
|
53
|
+
if (!project)
|
|
54
|
+
continue;
|
|
55
|
+
let real;
|
|
56
|
+
try {
|
|
57
|
+
real = fs.realpathSync(project);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (real === projectDir)
|
|
63
|
+
return file;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function openJournal(projectDir, now) {
|
|
68
|
+
const dir = path.join(projectDir, "journal");
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
70
|
+
const iso = now.toISOString();
|
|
71
|
+
const stamp = `${iso.slice(0, 10)}-${iso.slice(11, 13)}${iso.slice(14, 16)}`;
|
|
72
|
+
const base = `${stamp}-hqd-adhoc`;
|
|
73
|
+
let file = path.join(dir, `${base}.md`);
|
|
74
|
+
let suffix = 2;
|
|
75
|
+
while (fs.existsSync(file))
|
|
76
|
+
file = path.join(dir, `${base}-${suffix++}.md`);
|
|
77
|
+
fs.writeFileSync(file, [
|
|
78
|
+
"---",
|
|
79
|
+
"skill: hqd",
|
|
80
|
+
`started_at: ${isoSeconds(now)}`,
|
|
81
|
+
"thread_id: adhoc",
|
|
82
|
+
`project: ${projectDir}`,
|
|
83
|
+
"status: active",
|
|
84
|
+
"auto_capture: true",
|
|
85
|
+
'summary: ""',
|
|
86
|
+
"---",
|
|
87
|
+
"",
|
|
88
|
+
"## Decisions",
|
|
89
|
+
"## Open threads",
|
|
90
|
+
"## Findings",
|
|
91
|
+
"## Rejected",
|
|
92
|
+
"## Auto-capture",
|
|
93
|
+
"",
|
|
94
|
+
].join("\n"));
|
|
95
|
+
return file;
|
|
96
|
+
}
|
|
97
|
+
export function journalAppend(args, hqRoot, now = new Date()) {
|
|
98
|
+
if (!SLUG_RE.test(args.company))
|
|
99
|
+
throw new JournalOpError("bad_args", "company must be a company slug");
|
|
100
|
+
if (!SLUG_RE.test(args.project))
|
|
101
|
+
throw new JournalOpError("bad_args", "project must be a project folder name");
|
|
102
|
+
const header = JOURNAL_SECTIONS[args.section];
|
|
103
|
+
if (!header)
|
|
104
|
+
throw new JournalOpError("bad_args", `section must be one of ${Object.keys(JOURNAL_SECTIONS).join(", ")}`);
|
|
105
|
+
const text = args.text.replace(/\s*[\r\n]+\s*/g, " ").trim();
|
|
106
|
+
if (!text)
|
|
107
|
+
throw new JournalOpError("bad_args", "text must be non-empty");
|
|
108
|
+
const rawDir = path.join(hqRoot, "companies", args.company, "projects", args.project);
|
|
109
|
+
let projectDir;
|
|
110
|
+
try {
|
|
111
|
+
projectDir = fs.realpathSync(rawDir);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
throw new JournalOpError("unknown_project", `no project folder at companies/${args.company}/projects/${args.project}`);
|
|
115
|
+
}
|
|
116
|
+
const companyRoot = fs.realpathSync(path.join(hqRoot, "companies", args.company));
|
|
117
|
+
if (!projectDir.startsWith(companyRoot + path.sep)) {
|
|
118
|
+
throw new JournalOpError("bad_args", "project folder resolves outside its company");
|
|
119
|
+
}
|
|
120
|
+
let file = findActiveJournal(projectDir);
|
|
121
|
+
const opened = file === null;
|
|
122
|
+
if (!file)
|
|
123
|
+
file = openJournal(projectDir, now);
|
|
124
|
+
const line = `- ${isoSeconds(now)} ${text}`;
|
|
125
|
+
const body = fs.readFileSync(file, "utf8");
|
|
126
|
+
const lines = body.split("\n");
|
|
127
|
+
const at = lines.indexOf(header);
|
|
128
|
+
let next;
|
|
129
|
+
if (at !== -1) {
|
|
130
|
+
lines.splice(at + 1, 0, line);
|
|
131
|
+
next = lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
next = `${body}${body.endsWith("\n") || body === "" ? "" : "\n"}\n${header}\n${line}\n`;
|
|
135
|
+
}
|
|
136
|
+
const tmp = `${file}.tmp`;
|
|
137
|
+
fs.writeFileSync(tmp, next);
|
|
138
|
+
fs.renameSync(tmp, file);
|
|
139
|
+
return { path: file, line, opened };
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=journal.js.map
|