@indigoai-us/hq-cli 5.108.1 → 5.108.3

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 (109) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/assets/mesh-daemon/README.md +23 -0
  3. package/assets/mesh-daemon/launchd.plist.template +33 -0
  4. package/assets/mesh-daemon/systemd.system.service.template +27 -0
  5. package/assets/mesh-daemon/systemd.user.service.template +18 -0
  6. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +267 -0
  7. package/dist/commands/core.js +5 -4
  8. package/dist/commands/index-cmd.js +13 -0
  9. package/dist/commands/mesh.d.ts +15 -0
  10. package/dist/commands/mesh.js +732 -4
  11. package/dist/commands/search.d.ts +23 -0
  12. package/dist/commands/search.js +42 -1
  13. package/dist/lib/doctor/checks/work-context.d.ts +23 -0
  14. package/dist/lib/doctor/checks/work-context.js +195 -0
  15. package/dist/lib/doctor/registry.js +4 -0
  16. package/dist/lib/mesh/api.d.ts +18 -0
  17. package/dist/lib/mesh/api.js +55 -0
  18. package/dist/lib/mesh/cache.js +35 -4
  19. package/dist/lib/mesh/client.d.ts +118 -0
  20. package/dist/lib/mesh/client.js +268 -0
  21. package/dist/lib/mesh/live/backoff.d.ts +16 -0
  22. package/dist/lib/mesh/live/backoff.js +20 -0
  23. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +49 -0
  24. package/dist/lib/mesh/live/daemon/board-refresh.js +185 -0
  25. package/dist/lib/mesh/live/daemon/credentials.d.ts +71 -0
  26. package/dist/lib/mesh/live/daemon/credentials.js +227 -0
  27. package/dist/lib/mesh/live/daemon/doctor.d.ts +37 -0
  28. package/dist/lib/mesh/live/daemon/doctor.js +139 -0
  29. package/dist/lib/mesh/live/daemon/index.d.ts +24 -0
  30. package/dist/lib/mesh/live/daemon/index.js +14 -0
  31. package/dist/lib/mesh/live/daemon/install.d.ts +83 -0
  32. package/dist/lib/mesh/live/daemon/install.js +330 -0
  33. package/dist/lib/mesh/live/daemon/log.d.ts +11 -0
  34. package/dist/lib/mesh/live/daemon/log.js +79 -0
  35. package/dist/lib/mesh/live/daemon/paths.d.ts +18 -0
  36. package/dist/lib/mesh/live/daemon/paths.js +31 -0
  37. package/dist/lib/mesh/live/daemon/pid-lock.d.ts +48 -0
  38. package/dist/lib/mesh/live/daemon/pid-lock.js +134 -0
  39. package/dist/lib/mesh/live/daemon/presence.d.ts +101 -0
  40. package/dist/lib/mesh/live/daemon/presence.js +250 -0
  41. package/dist/lib/mesh/live/daemon/presign.d.ts +18 -0
  42. package/dist/lib/mesh/live/daemon/presign.js +65 -0
  43. package/dist/lib/mesh/live/daemon/run.d.ts +74 -0
  44. package/dist/lib/mesh/live/daemon/run.js +400 -0
  45. package/dist/lib/mesh/live/daemon/state.d.ts +24 -0
  46. package/dist/lib/mesh/live/daemon/state.js +57 -0
  47. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +170 -0
  48. package/dist/lib/mesh/live/daemon/transcript-watch.js +520 -0
  49. package/dist/lib/mesh/live/enqueue.d.ts +48 -0
  50. package/dist/lib/mesh/live/enqueue.js +100 -0
  51. package/dist/lib/mesh/live/flush.d.ts +56 -0
  52. package/dist/lib/mesh/live/flush.js +360 -0
  53. package/dist/lib/mesh/live/format-spool-line.d.ts +42 -0
  54. package/dist/lib/mesh/live/format-spool-line.js +106 -0
  55. package/dist/lib/mesh/live/index.d.ts +22 -0
  56. package/dist/lib/mesh/live/index.js +16 -0
  57. package/dist/lib/mesh/live/paths.d.ts +13 -0
  58. package/dist/lib/mesh/live/paths.js +30 -0
  59. package/dist/lib/mesh/live/session-events-client.d.ts +58 -0
  60. package/dist/lib/mesh/live/session-events-client.js +120 -0
  61. package/dist/lib/mesh/live/session-identity.d.ts +39 -0
  62. package/dist/lib/mesh/live/session-identity.js +91 -0
  63. package/dist/lib/mesh/live/spool.d.ts +52 -0
  64. package/dist/lib/mesh/live/spool.js +193 -0
  65. package/dist/lib/mesh/live/ulid.d.ts +12 -0
  66. package/dist/lib/mesh/live/ulid.js +39 -0
  67. package/dist/lib/mesh/live/validate-session-event.d.ts +39 -0
  68. package/dist/lib/mesh/live/validate-session-event.js +128 -0
  69. package/dist/lib/search-index/index.d.ts +98 -0
  70. package/dist/lib/search-index/index.js +174 -2
  71. package/dist/lib/work-context/atomic.d.ts +18 -0
  72. package/dist/lib/work-context/atomic.js +132 -0
  73. package/dist/lib/work-context/company.d.ts +103 -0
  74. package/dist/lib/work-context/company.js +274 -0
  75. package/dist/lib/work-context/config.d.ts +59 -0
  76. package/dist/lib/work-context/config.js +172 -0
  77. package/dist/lib/work-context/contract.d.ts +81 -0
  78. package/dist/lib/work-context/contract.js +65 -0
  79. package/dist/lib/work-context/errors.d.ts +27 -0
  80. package/dist/lib/work-context/errors.js +46 -0
  81. package/dist/lib/work-context/index.d.ts +23 -0
  82. package/dist/lib/work-context/index.js +20 -0
  83. package/dist/lib/work-context/migrate.d.ts +84 -0
  84. package/dist/lib/work-context/migrate.js +287 -0
  85. package/dist/lib/work-context/organize.d.ts +130 -0
  86. package/dist/lib/work-context/organize.js +471 -0
  87. package/dist/lib/work-context/outbox.d.ts +97 -0
  88. package/dist/lib/work-context/outbox.js +357 -0
  89. package/dist/lib/work-context/paths.d.ts +14 -0
  90. package/dist/lib/work-context/paths.js +39 -0
  91. package/dist/lib/work-context/project.d.ts +78 -0
  92. package/dist/lib/work-context/project.js +247 -0
  93. package/dist/lib/work-context/reconcile.d.ts +85 -0
  94. package/dist/lib/work-context/reconcile.js +651 -0
  95. package/dist/lib/work-context/repo-remote.d.ts +43 -0
  96. package/dist/lib/work-context/repo-remote.js +200 -0
  97. package/dist/lib/work-context/state.d.ts +112 -0
  98. package/dist/lib/work-context/state.js +162 -0
  99. package/dist/main.js +93 -4
  100. package/dist/utils/cognito-session.d.ts +85 -9
  101. package/dist/utils/cognito-session.js +216 -9
  102. package/dist/utils/qmd-model-download-error.d.ts +27 -0
  103. package/dist/utils/qmd-model-download-error.js +114 -0
  104. package/dist/utils/qmd-query-document-error.d.ts +23 -0
  105. package/dist/utils/qmd-query-document-error.js +117 -0
  106. package/dist/utils/qmd-workdir-missing-error.d.ts +18 -0
  107. package/dist/utils/qmd-workdir-missing-error.js +78 -0
  108. package/dist/utils/sentry-fingerprint.js +7 -0
  109. package/package.json +5 -1
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Deterministic company ownership from the enclosing git remote (US-007A).
3
+ *
4
+ * Walks up from cwd to find a .git dir/file, reads origin's URL from .git/config
5
+ * (no git subprocess), normalises to owner/name, and matches against
6
+ * companies/manifest.yaml repos. Zero or multiple matches → no signal.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { parseUserYaml } from "../../utils/user-yaml-error.js";
11
+ /**
12
+ * Normalise a git remote URL to `owner/name`.
13
+ * Handles https://github.com/owner/name(.git), git@github.com:owner/name(.git),
14
+ * and ssh://git@github.com/owner/name(.git). Returns null when unrecognised.
15
+ */
16
+ export function normalizeRemoteOwnerName(remoteUrl) {
17
+ const raw = remoteUrl.trim();
18
+ if (!raw)
19
+ return null;
20
+ let owner = "";
21
+ let name = "";
22
+ const scp = /^git@([^:]+):(.+)$/i.exec(raw);
23
+ if (scp) {
24
+ const rest = scp[2].replace(/\/+$/, "").replace(/\.git$/i, "");
25
+ const parts = rest.split("/").filter(Boolean);
26
+ if (parts.length >= 2) {
27
+ owner = parts[parts.length - 2];
28
+ name = parts[parts.length - 1];
29
+ }
30
+ }
31
+ else {
32
+ try {
33
+ const withScheme = /^[a-z+]+:\/\//i.test(raw) ? raw : `https://${raw}`;
34
+ const u = new URL(withScheme);
35
+ const parts = u.pathname
36
+ .replace(/\/+$/, "")
37
+ .replace(/\.git$/i, "")
38
+ .split("/")
39
+ .filter(Boolean);
40
+ if (parts.length >= 2) {
41
+ owner = parts[parts.length - 2];
42
+ name = parts[parts.length - 1];
43
+ }
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ if (!owner || !name)
50
+ return null;
51
+ if (!/^[A-Za-z0-9._-]+$/.test(owner) || !/^[A-Za-z0-9._-]+$/.test(name)) {
52
+ return null;
53
+ }
54
+ return `${owner}/${name}`;
55
+ }
56
+ /** Walk up from startDir looking for a `.git` directory or file. */
57
+ export function findEnclosingGitDir(startDir) {
58
+ let cur = path.resolve(startDir);
59
+ for (;;) {
60
+ const candidate = path.join(cur, ".git");
61
+ try {
62
+ if (fs.existsSync(candidate)) {
63
+ const st = fs.lstatSync(candidate);
64
+ if (st.isDirectory())
65
+ return candidate;
66
+ if (st.isFile()) {
67
+ // Linked worktree: `.git` file contains `gitdir: <path>`
68
+ const text = fs.readFileSync(candidate, "utf8");
69
+ const m = /^\s*gitdir:\s*(.+)\s*$/m.exec(text);
70
+ if (m?.[1]) {
71
+ const gitdir = path.resolve(cur, m[1].trim());
72
+ if (fs.existsSync(gitdir))
73
+ return gitdir;
74
+ }
75
+ return null;
76
+ }
77
+ }
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ const parent = path.dirname(cur);
83
+ if (parent === cur)
84
+ return null;
85
+ cur = parent;
86
+ }
87
+ }
88
+ /**
89
+ * Read `remote.origin.url` from a `.git/config` (ini-style). No subprocess.
90
+ */
91
+ export function readOriginRemoteUrl(gitDir) {
92
+ const configPath = path.join(gitDir, "config");
93
+ if (!fs.existsSync(configPath))
94
+ return null;
95
+ let text;
96
+ try {
97
+ text = fs.readFileSync(configPath, "utf8");
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ let inOrigin = false;
103
+ for (const line of text.split(/\r?\n/)) {
104
+ const section = /^\s*\[([^\]]+)\]\s*$/.exec(line);
105
+ if (section) {
106
+ inOrigin = /^remote\s+"origin"$/i.test(section[1].trim());
107
+ continue;
108
+ }
109
+ if (!inOrigin)
110
+ continue;
111
+ const url = /^\s*url\s*=\s*(.+?)\s*$/i.exec(line);
112
+ if (url?.[1])
113
+ return url[1].trim();
114
+ }
115
+ return null;
116
+ }
117
+ /**
118
+ * Match normalised `owner/name` against companies/manifest repos.
119
+ * A repo entry matches if it equals owner/name (case-insensitive) or is a URL
120
+ * that normalises to the same owner/name. Exactly one company → its slug;
121
+ * zero or more than one → null.
122
+ */
123
+ export function matchCompanySlugForRepo(ownerName, companies) {
124
+ const target = ownerName.trim().toLowerCase();
125
+ if (!target || !target.includes("/"))
126
+ return null;
127
+ const matches = [];
128
+ for (const [slug, entry] of Object.entries(companies)) {
129
+ if (!entry || typeof entry !== "object")
130
+ continue;
131
+ const repos = entry.repos;
132
+ if (!Array.isArray(repos))
133
+ continue;
134
+ for (const raw of repos) {
135
+ if (typeof raw !== "string" || !raw.trim())
136
+ continue;
137
+ const trimmed = raw.trim();
138
+ let candidate = null;
139
+ if (trimmed.includes("://") ||
140
+ trimmed.startsWith("git@") ||
141
+ /@[^/]+:/.test(trimmed)) {
142
+ candidate = normalizeRemoteOwnerName(trimmed);
143
+ }
144
+ else if (trimmed.includes("/")) {
145
+ candidate = trimmed.replace(/\.git$/i, "");
146
+ }
147
+ if (candidate && candidate.toLowerCase() === target) {
148
+ matches.push(slug);
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ if (matches.length === 1)
154
+ return matches[0];
155
+ return null;
156
+ }
157
+ export function readCompaniesManifestMap(hqRoot) {
158
+ const mPath = path.join(hqRoot, "companies", "manifest.yaml");
159
+ if (!fs.existsSync(mPath))
160
+ return null;
161
+ try {
162
+ const parsed = parseUserYaml(fs.readFileSync(mPath, "utf8"), mPath);
163
+ if (!parsed ||
164
+ typeof parsed !== "object" ||
165
+ !("companies" in parsed) ||
166
+ typeof parsed.companies !== "object" ||
167
+ parsed.companies === null) {
168
+ return null;
169
+ }
170
+ return parsed.companies;
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ /**
177
+ * Derive the deterministic remote-owner company slug from cwd + HQ manifest.
178
+ * Returns null when there is no unique match.
179
+ */
180
+ export function deriveRemoteOwnerSlug(opts) {
181
+ const cwd = opts.cwd?.trim() ? path.resolve(opts.cwd) : process.cwd();
182
+ const hqRoot = opts.hqRoot?.trim() ? path.resolve(opts.hqRoot) : undefined;
183
+ if (!hqRoot)
184
+ return null;
185
+ const gitDir = findEnclosingGitDir(cwd);
186
+ if (!gitDir)
187
+ return null;
188
+ const origin = readOriginRemoteUrl(gitDir);
189
+ if (!origin)
190
+ return null;
191
+ const ownerName = normalizeRemoteOwnerName(origin);
192
+ if (!ownerName)
193
+ return null;
194
+ const reader = opts.readManifest ?? readCompaniesManifestMap;
195
+ const companies = reader(hqRoot);
196
+ if (!companies)
197
+ return null;
198
+ return matchCompanySlugForRepo(ownerName, companies);
199
+ }
200
+ //# sourceMappingURL=repo-remote.js.map
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Per-session local state file: ~/.hq/work-context/sessions/<sid>.json
3
+ * Written only by hq mesh context. Owner 0600. Metadata-only — never prompts/tokens.
4
+ */
5
+ import type { ClassificationState } from "./contract.js";
6
+ import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
7
+ export type DecisionOptionKind = "bind_existing" | "create_project" | "create_task" | "untracked" | "keep_default" | "override_evidence";
8
+ export interface SessionDecisionOption {
9
+ optionId: string;
10
+ label?: string;
11
+ kind?: DecisionOptionKind;
12
+ projectId?: string;
13
+ taskId?: string;
14
+ }
15
+ export interface SessionDecision {
16
+ decisionId: string;
17
+ options: SessionDecisionOption[];
18
+ expiresAt?: string;
19
+ /**
20
+ * When true, UserPromptSubmit injects a one-time clarification (US-007B/US-010).
21
+ * Set only after work-signal threshold; never re-armed after cancel/timeout.
22
+ */
23
+ askAfter?: boolean;
24
+ /** US-017B correction pointer when company_conflict follows an existing binding. */
25
+ correctionPath?: string;
26
+ }
27
+ /** Local-only origin of the session state writer (never sent on the wire). */
28
+ export type SessionEventSource = "hook" | "transcript";
29
+ /** Local-only harness label when network schema cannot carry it (US-018). */
30
+ export type LocalHarnessLabel = "claude-desktop" | "codex";
31
+ /** Local tracking for an in-flight or completed cross-company migrate (US-017B). */
32
+ export interface SessionMigrationRecord {
33
+ operationId: string;
34
+ digest: string;
35
+ sourceCompanyUid: string;
36
+ destinationCompanyUid: string;
37
+ destinationCompanySlug?: string;
38
+ destination?: {
39
+ projectId?: string;
40
+ taskId?: string;
41
+ };
42
+ submittedAt: string;
43
+ /** Set when the destination receipt arrives. */
44
+ receiptId?: string;
45
+ completedAt?: string;
46
+ }
47
+ export interface SessionStateFile {
48
+ contractVersion: typeof WORK_CONTEXT_CONTRACT_VERSION;
49
+ sessionId: string;
50
+ companyUid?: string;
51
+ companySlug?: string;
52
+ contextStatus: ClassificationState;
53
+ projectId?: string;
54
+ taskId?: string;
55
+ bindingEpisodeId?: string;
56
+ /** Binding / session OCC version from the last server receipt. */
57
+ version?: number;
58
+ decision?: SessionDecision;
59
+ /** Cross-company migrate tracking (pending until receiptId is set). */
60
+ migration?: SessionMigrationRecord;
61
+ /** Local consent receipt for explicit untracked. */
62
+ untrackedReceipt?: {
63
+ origin: "user";
64
+ at: string;
65
+ noBackfill: true;
66
+ };
67
+ /**
68
+ * LOCAL-ONLY. Count of file-writing tools (Edit/Write/MultiEdit/NotebookEdit/
69
+ * file-writing Bash) bumped by PostToolUse (US-010). Used for askAfter.
70
+ */
71
+ toolWrites?: number;
72
+ /** LOCAL-ONLY. First observe/register time for the 10-minute ask threshold. */
73
+ startedAt?: string;
74
+ /** LOCAL-ONLY. True once any turn has been observed for this session. */
75
+ hasTurn?: boolean;
76
+ /**
77
+ * LOCAL-ONLY. How this state was established. Hook path (US-007A/US-010) omits
78
+ * this or sets "hook". Transcript-watch fallback sets "transcript" (US-018).
79
+ * Distinct from the network SessionEvent `source` enum (`hooks` | `transcript`).
80
+ */
81
+ source?: SessionEventSource;
82
+ /**
83
+ * LOCAL-ONLY legacy label. AMEND-001 put `claude-desktop` on the network
84
+ * harness enum; transcript-watch no longer writes this.
85
+ */
86
+ localHarness?: LocalHarnessLabel;
87
+ /**
88
+ * LOCAL-ONLY. Channel-card / Atlas marker for transcript-sourced sessions
89
+ * before SESSION_STATE sync (kept after AMEND-001).
90
+ */
91
+ transcriptMarker?: "from transcript";
92
+ updatedAt: string;
93
+ }
94
+ /** Allowlisted keys that may appear in the durable state projection. */
95
+ export declare const SESSION_STATE_ALLOWLIST: Set<string>;
96
+ export declare function readSessionState(sessionId: string, root: string): SessionStateFile | null;
97
+ export declare function writeSessionState(state: SessionStateFile, root: string): string;
98
+ /** Strip any non-allowlisted fields before durable write. */
99
+ export declare function projectSessionState(state: SessionStateFile): SessionStateFile;
100
+ /**
101
+ * Merge prior local-only counters into a new state write so PostToolUse bumps
102
+ * and reconcile updates do not clobber each other.
103
+ */
104
+ export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null): SessionStateFile;
105
+ /** True when a durable state file was written by the hook / reconcile path. */
106
+ export declare function isHookWrittenSessionState(state: SessionStateFile | null): boolean;
107
+ /** Authoritative prior scope usable for company precedence. */
108
+ export declare function authoritativeCompanyFromState(state: SessionStateFile | null): {
109
+ slug?: string;
110
+ uid?: string;
111
+ } | null;
112
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Per-session local state file: ~/.hq/work-context/sessions/<sid>.json
3
+ * Written only by hq mesh context. Owner 0600. Metadata-only — never prompts/tokens.
4
+ */
5
+ import * as fs from "node:fs";
6
+ import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
7
+ import { atomicWriteJson, ensureOwnerDir } from "./atomic.js";
8
+ import { NotTrackingError } from "./errors.js";
9
+ import { isSafeWorkContextSegment, workContextSessionPath, workContextSessionsDir, } from "./paths.js";
10
+ /** Allowlisted keys that may appear in the durable state projection. */
11
+ export const SESSION_STATE_ALLOWLIST = new Set([
12
+ "contractVersion",
13
+ "sessionId",
14
+ "companyUid",
15
+ "companySlug",
16
+ "contextStatus",
17
+ "projectId",
18
+ "taskId",
19
+ "bindingEpisodeId",
20
+ "version",
21
+ "decision",
22
+ "migration",
23
+ "untrackedReceipt",
24
+ "toolWrites",
25
+ "startedAt",
26
+ "hasTurn",
27
+ "source",
28
+ "localHarness",
29
+ "transcriptMarker",
30
+ "updatedAt",
31
+ ]);
32
+ export function readSessionState(sessionId, root) {
33
+ if (!isSafeWorkContextSegment(sessionId))
34
+ return null;
35
+ const filePath = workContextSessionPath(sessionId, root);
36
+ if (!fs.existsSync(filePath))
37
+ return null;
38
+ try {
39
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
40
+ if (raw.sessionId !== sessionId)
41
+ return null;
42
+ return raw;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ export function writeSessionState(state, root) {
49
+ if (!isSafeWorkContextSegment(state.sessionId)) {
50
+ throw new NotTrackingError(`Unsafe session id for state file: ${state.sessionId}`, "NOT_TRACKING");
51
+ }
52
+ const projected = projectSessionState(state);
53
+ try {
54
+ ensureOwnerDir(workContextSessionsDir(root));
55
+ const dest = workContextSessionPath(state.sessionId, root);
56
+ atomicWriteJson(dest, projected);
57
+ return dest;
58
+ }
59
+ catch (err) {
60
+ throw new NotTrackingError(`Failed to persist session state: ${err instanceof Error ? err.message : String(err)}`, "NOT_TRACKING");
61
+ }
62
+ }
63
+ /** Strip any non-allowlisted fields before durable write. */
64
+ export function projectSessionState(state) {
65
+ const out = {
66
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
67
+ sessionId: state.sessionId,
68
+ contextStatus: state.contextStatus,
69
+ updatedAt: state.updatedAt,
70
+ };
71
+ if (state.companyUid)
72
+ out.companyUid = state.companyUid;
73
+ if (state.companySlug)
74
+ out.companySlug = state.companySlug;
75
+ if (state.projectId)
76
+ out.projectId = state.projectId;
77
+ if (state.taskId)
78
+ out.taskId = state.taskId;
79
+ if (state.bindingEpisodeId)
80
+ out.bindingEpisodeId = state.bindingEpisodeId;
81
+ if (typeof state.version === "number" && Number.isInteger(state.version)) {
82
+ out.version = state.version;
83
+ }
84
+ if (state.decision)
85
+ out.decision = state.decision;
86
+ if (state.migration)
87
+ out.migration = state.migration;
88
+ if (state.untrackedReceipt)
89
+ out.untrackedReceipt = state.untrackedReceipt;
90
+ if (typeof state.toolWrites === "number" && state.toolWrites >= 0) {
91
+ out.toolWrites = Math.floor(state.toolWrites);
92
+ }
93
+ if (state.startedAt)
94
+ out.startedAt = state.startedAt;
95
+ if (state.hasTurn)
96
+ out.hasTurn = true;
97
+ if (state.source)
98
+ out.source = state.source;
99
+ if (state.localHarness)
100
+ out.localHarness = state.localHarness;
101
+ if (state.transcriptMarker)
102
+ out.transcriptMarker = state.transcriptMarker;
103
+ return out;
104
+ }
105
+ /**
106
+ * Merge prior local-only counters into a new state write so PostToolUse bumps
107
+ * and reconcile updates do not clobber each other.
108
+ */
109
+ export function mergeLocalSessionFields(next, prior) {
110
+ if (!prior)
111
+ return next;
112
+ const out = { ...next };
113
+ if (out.toolWrites === undefined && typeof prior.toolWrites === "number") {
114
+ out.toolWrites = prior.toolWrites;
115
+ }
116
+ if (!out.startedAt && prior.startedAt)
117
+ out.startedAt = prior.startedAt;
118
+ if (!out.hasTurn && prior.hasTurn)
119
+ out.hasTurn = true;
120
+ // Preserve an existing one-time decision unless the caller replaces it.
121
+ if (!out.decision && prior.decision)
122
+ out.decision = prior.decision;
123
+ if (!out.untrackedReceipt && prior.untrackedReceipt) {
124
+ out.untrackedReceipt = prior.untrackedReceipt;
125
+ }
126
+ if (!out.source && prior.source)
127
+ out.source = prior.source;
128
+ if (!out.localHarness && prior.localHarness) {
129
+ out.localHarness = prior.localHarness;
130
+ }
131
+ if (!out.transcriptMarker && prior.transcriptMarker) {
132
+ out.transcriptMarker = prior.transcriptMarker;
133
+ }
134
+ if (!out.bindingEpisodeId && prior.bindingEpisodeId) {
135
+ out.bindingEpisodeId = prior.bindingEpisodeId;
136
+ }
137
+ return out;
138
+ }
139
+ /** True when a durable state file was written by the hook / reconcile path. */
140
+ export function isHookWrittenSessionState(state) {
141
+ if (!state)
142
+ return false;
143
+ return state.source !== "transcript";
144
+ }
145
+ /** Authoritative prior scope usable for company precedence. */
146
+ export function authoritativeCompanyFromState(state) {
147
+ if (!state)
148
+ return null;
149
+ const writable = new Set([
150
+ "unresolved",
151
+ "needs_project",
152
+ "needs_task",
153
+ "bound",
154
+ "migration_pending",
155
+ ]);
156
+ if (!writable.has(state.contextStatus))
157
+ return null;
158
+ if (!state.companyUid && !state.companySlug)
159
+ return null;
160
+ return { slug: state.companySlug, uid: state.companyUid };
161
+ }
162
+ //# sourceMappingURL=state.js.map
package/dist/main.js CHANGED
@@ -73,6 +73,9 @@ import { qmdLlmDisabledMessage } from "./utils/qmd-llm-disabled-error.js";
73
73
  import { qmdModuleMissingMessage } from "./utils/qmd-module-missing-error.js";
74
74
  import { qmdStoreMissingMessage } from "./utils/qmd-store-missing-error.js";
75
75
  import { qmdStoreUnopenableMessage } from "./utils/qmd-store-unopenable-error.js";
76
+ import { qmdQueryDocumentMessage } from "./utils/qmd-query-document-error.js";
77
+ import { qmdModelDownloadMessage } from "./utils/qmd-model-download-error.js";
78
+ import { qmdWorkdirMissingMessage } from "./utils/qmd-workdir-missing-error.js";
76
79
  import { hqStateWriteErrorMessage } from "./utils/hq-state-write-error.js";
77
80
  import { isExpectedUserError } from "./utils/expected-cli-error.js";
78
81
  import { isVarlockEnvError } from "./run/env-graph-guard.js";
@@ -377,6 +380,26 @@ const defaultTopLevelErrorDependencies = {
377
380
  process.exitCode = code;
378
381
  },
379
382
  };
383
+ /**
384
+ * Bounded Sentry context for a CAPTURED qmd spawn-level failure (the
385
+ * interpreter-missing and residual QmdBinaryMissingError cases). finishRunQmd
386
+ * stamps the errno code and per-component existence booleans onto the error at
387
+ * failure time; because the reporting host (HQ-CLI-1A) shipped no platform log,
388
+ * this is the evidence the next occurrence carries. Read STRUCTURALLY off the
389
+ * error's own `spawnDiagnostics` field (the boundary reconstructs typed carriers
390
+ * structurally in tests). Only hq-DERIVED paths (the resolved cwd,
391
+ * process.execPath, the bundled launcher) and a bounded errno code appear —
392
+ * never caller argv or query text. Returns undefined when the error carries no
393
+ * such diagnostics, so the generic capture stays a bare captureException(err).
394
+ */
395
+ function qmdSpawnFailureCaptureContext(err) {
396
+ if (err === null || typeof err !== "object")
397
+ return undefined;
398
+ const diagnostics = err.spawnDiagnostics;
399
+ if (diagnostics === null || typeof diagnostics !== "object")
400
+ return undefined;
401
+ return { contexts: { qmd_spawn_failure: diagnostics } };
402
+ }
380
403
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
381
404
  export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
382
405
  // A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
@@ -599,6 +622,50 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
599
622
  const storeUnopenableMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg
600
623
  ? null
601
624
  : qmdStoreUnopenableMessage(err);
625
+ // A qmd `query` (hybrid search) failure whose positional was rejected as a
626
+ // MALFORMED QUERY DOCUMENT — qmd 2.5.3 requires a lex:/vec:/hyde:/intent:/
627
+ // expand: prefix on every line of a multi-line document — is the caller's
628
+ // INPUT SHAPE, not an hq-cli defect. The common case (a fleet agent
629
+ // forwarding a whole multi-line Slack message) is normalised to one line in
630
+ // buildSearchArgs so it never reaches qmd multi-line; this branch is the
631
+ // backstop for a genuinely malformed structured document. finishRunQmd types
632
+ // it QmdQueryDocumentError; print the query-free remedy and skip capture
633
+ // (HQ-CLI-1B / Sentry 7705711476, 28 of 29 events). Scoped to the `query`
634
+ // read. Evaluated AFTER the earlier qmd typed signatures and BEFORE the
635
+ // environmental / transport / generic branches; the wordings are disjoint,
636
+ // so ordering changes no existing branch.
637
+ const queryDocumentMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg
638
+ ? null
639
+ : qmdQueryDocumentMessage(err);
640
+ // A qmd MODEL DOWNLOAD that died with ENOENT renaming its own partial
641
+ // `.ipull` file is a transient LOCAL-CACHE race (another qmd process on the
642
+ // same box finished or cleared the same download first), not an hq-cli
643
+ // defect — hq cannot coordinate qmd children other users start on the host.
644
+ // finishRunQmd types it QmdModelDownloadError (gated on BOTH the ENOENT
645
+ // rename signature AND the .ipull marker, so a durable ENOSPC/EACCES/network
646
+ // failure stays reportable); print the retry remedy and skip capture
647
+ // (HQ-CLI-1B / Sentry 7705711476, the newest event). Scoped to the reads
648
+ // that fetch a model (query/vsearch/embed).
649
+ const modelDownloadMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg
650
+ ? null
651
+ : qmdModelDownloadMessage(err);
652
+ // A qmd spawn-level failure whose ENOENT was a MISSING WORKING DIRECTORY
653
+ // (the resolved hq root the qmd child was handed as `cwd` does not exist).
654
+ // finishRunQmd types it QmdWorkdirMissingError, carrying the hq-derived
655
+ // directory; the boundary prints a self-describing remedy naming that
656
+ // directory and skips capture — a working directory that does not exist is
657
+ // the caller's filesystem, the same disposition as the store-missing /
658
+ // store-unopenable branches. Before this branch it was MISATTRIBUTED to a
659
+ // missing qmd (QmdBinaryMissingError, remedy `Install @tobilu/qmd`) and,
660
+ // with no boundary branch for it, captured as a crash (HQ-CLI-1A, Sentry
661
+ // 7705642434). Evaluated immediately AFTER the store-unopenable /
662
+ // query-document / model-download checks and BEFORE the environmental /
663
+ // transport / generic branches; the carrier's name is disjoint from every
664
+ // neighbour and it carries no `.code` environmentalFsErrorMessage could
665
+ // read, so ordering changes no existing branch.
666
+ const workdirMissingMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg
667
+ ? null
668
+ : qmdWorkdirMissingMessage(err);
602
669
  // An hq STATE-TREE write that failed for an environmental errno
603
670
  // (permission/quota/read-only/parent-gone) is typed at the write site as an
604
671
  // HqStateWriteError carrying the exact hq-DERIVED path it tried to write.
@@ -608,10 +675,10 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
608
675
  // remedy and skip capture. Placed with the environmental-fs family — BEFORE
609
676
  // the generic envMsg computation — so this typed carrier wins even though
610
677
  // it also carries an errno `code` environmentalFsErrorMessage could read.
611
- const stateWriteMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg
678
+ const stateWriteMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg
612
679
  ? null
613
680
  : hqStateWriteErrorMessage(err);
614
- const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || stateWriteMsg
681
+ const envMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg
615
682
  ? null
616
683
  : environmentalFsErrorMessage(err);
617
684
  // A LOCAL sync-state lock failure (@indigoai-us/hq-cloud's
@@ -628,7 +695,7 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
628
695
  // environmental-fs check, before network-transport — is pinned by tests.
629
696
  // The `in-process-async-holder` reason is deliberately NOT suppressed here
630
697
  // (see sync-state-lock-error.ts); it stays captured.
631
- const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || stateWriteMsg || envMsg
698
+ const lockMsg = qmdMsg || collectionMsg || terminatedMsg || llmDisabledMsg || moduleMissingMsg || storeMissingMsg || storeUnopenableMsg || queryDocumentMsg || modelDownloadMsg || workdirMissingMsg || stateWriteMsg || envMsg
632
699
  ? null
633
700
  : syncStateLockMessage(err);
634
701
  // A raw network transport failure (undici's `TypeError: fetch failed`
@@ -648,6 +715,9 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
648
715
  moduleMissingMsg ||
649
716
  storeMissingMsg ||
650
717
  storeUnopenableMsg ||
718
+ queryDocumentMsg ||
719
+ modelDownloadMsg ||
720
+ workdirMissingMsg ||
651
721
  stateWriteMsg ||
652
722
  envMsg ||
653
723
  lockMsg
@@ -674,6 +744,15 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
674
744
  else if (storeUnopenableMsg) {
675
745
  deps.stderr.write(`hq: ${storeUnopenableMsg}\n`);
676
746
  }
747
+ else if (queryDocumentMsg) {
748
+ deps.stderr.write(`hq: ${queryDocumentMsg}\n`);
749
+ }
750
+ else if (modelDownloadMsg) {
751
+ deps.stderr.write(`hq: ${modelDownloadMsg}\n`);
752
+ }
753
+ else if (workdirMissingMsg) {
754
+ deps.stderr.write(`hq: ${workdirMissingMsg}\n`);
755
+ }
677
756
  else if (stateWriteMsg) {
678
757
  deps.stderr.write(`hq: ${stateWriteMsg}\n`);
679
758
  }
@@ -687,7 +766,17 @@ export async function handleTopLevelError(err, deps = defaultTopLevelErrorDepend
687
766
  deps.stderr.write(`hq: ${transportMsg}\n`);
688
767
  }
689
768
  else {
690
- deps.sentry.captureException(err);
769
+ // A genuinely unclassified fault is still captured exactly once. When it
770
+ // is a qmd spawn-level failure the fix could not attribute, attach the
771
+ // bounded existence context so the next occurrence carries the evidence
772
+ // the reported HQ-CLI-1A event lacked; every other error captures bare.
773
+ const spawnContext = qmdSpawnFailureCaptureContext(err);
774
+ if (spawnContext) {
775
+ deps.sentry.captureException(err, spawnContext);
776
+ }
777
+ else {
778
+ deps.sentry.captureException(err);
779
+ }
691
780
  // Always emit something. Printing only when unexpectedCliErrorMessage()
692
781
  // returned a value meant every error class except IntegrationsCliError
693
782
  // exited 1 with zero bytes on stdout AND stderr — a silent failure the