@indigoai-us/hq-cli 5.108.1 → 5.108.2

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 (106) hide show
  1. package/CHANGELOG.md +77 -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/mesh.d.ts +15 -0
  9. package/dist/commands/mesh.js +732 -4
  10. package/dist/commands/search.d.ts +23 -0
  11. package/dist/commands/search.js +42 -1
  12. package/dist/lib/doctor/checks/work-context.d.ts +23 -0
  13. package/dist/lib/doctor/checks/work-context.js +195 -0
  14. package/dist/lib/doctor/registry.js +4 -0
  15. package/dist/lib/mesh/api.d.ts +18 -0
  16. package/dist/lib/mesh/api.js +55 -0
  17. package/dist/lib/mesh/cache.js +35 -4
  18. package/dist/lib/mesh/client.d.ts +118 -0
  19. package/dist/lib/mesh/client.js +268 -0
  20. package/dist/lib/mesh/live/backoff.d.ts +16 -0
  21. package/dist/lib/mesh/live/backoff.js +20 -0
  22. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +49 -0
  23. package/dist/lib/mesh/live/daemon/board-refresh.js +185 -0
  24. package/dist/lib/mesh/live/daemon/credentials.d.ts +71 -0
  25. package/dist/lib/mesh/live/daemon/credentials.js +227 -0
  26. package/dist/lib/mesh/live/daemon/doctor.d.ts +37 -0
  27. package/dist/lib/mesh/live/daemon/doctor.js +139 -0
  28. package/dist/lib/mesh/live/daemon/index.d.ts +24 -0
  29. package/dist/lib/mesh/live/daemon/index.js +14 -0
  30. package/dist/lib/mesh/live/daemon/install.d.ts +83 -0
  31. package/dist/lib/mesh/live/daemon/install.js +330 -0
  32. package/dist/lib/mesh/live/daemon/log.d.ts +11 -0
  33. package/dist/lib/mesh/live/daemon/log.js +79 -0
  34. package/dist/lib/mesh/live/daemon/paths.d.ts +18 -0
  35. package/dist/lib/mesh/live/daemon/paths.js +31 -0
  36. package/dist/lib/mesh/live/daemon/pid-lock.d.ts +48 -0
  37. package/dist/lib/mesh/live/daemon/pid-lock.js +134 -0
  38. package/dist/lib/mesh/live/daemon/presence.d.ts +101 -0
  39. package/dist/lib/mesh/live/daemon/presence.js +250 -0
  40. package/dist/lib/mesh/live/daemon/presign.d.ts +18 -0
  41. package/dist/lib/mesh/live/daemon/presign.js +65 -0
  42. package/dist/lib/mesh/live/daemon/run.d.ts +74 -0
  43. package/dist/lib/mesh/live/daemon/run.js +400 -0
  44. package/dist/lib/mesh/live/daemon/state.d.ts +24 -0
  45. package/dist/lib/mesh/live/daemon/state.js +57 -0
  46. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +170 -0
  47. package/dist/lib/mesh/live/daemon/transcript-watch.js +520 -0
  48. package/dist/lib/mesh/live/enqueue.d.ts +48 -0
  49. package/dist/lib/mesh/live/enqueue.js +100 -0
  50. package/dist/lib/mesh/live/flush.d.ts +56 -0
  51. package/dist/lib/mesh/live/flush.js +360 -0
  52. package/dist/lib/mesh/live/format-spool-line.d.ts +42 -0
  53. package/dist/lib/mesh/live/format-spool-line.js +106 -0
  54. package/dist/lib/mesh/live/index.d.ts +22 -0
  55. package/dist/lib/mesh/live/index.js +16 -0
  56. package/dist/lib/mesh/live/paths.d.ts +13 -0
  57. package/dist/lib/mesh/live/paths.js +30 -0
  58. package/dist/lib/mesh/live/session-events-client.d.ts +58 -0
  59. package/dist/lib/mesh/live/session-events-client.js +120 -0
  60. package/dist/lib/mesh/live/session-identity.d.ts +39 -0
  61. package/dist/lib/mesh/live/session-identity.js +91 -0
  62. package/dist/lib/mesh/live/spool.d.ts +52 -0
  63. package/dist/lib/mesh/live/spool.js +193 -0
  64. package/dist/lib/mesh/live/ulid.d.ts +12 -0
  65. package/dist/lib/mesh/live/ulid.js +39 -0
  66. package/dist/lib/mesh/live/validate-session-event.d.ts +39 -0
  67. package/dist/lib/mesh/live/validate-session-event.js +128 -0
  68. package/dist/lib/search-index/index.d.ts +45 -0
  69. package/dist/lib/search-index/index.js +76 -0
  70. package/dist/lib/work-context/atomic.d.ts +18 -0
  71. package/dist/lib/work-context/atomic.js +132 -0
  72. package/dist/lib/work-context/company.d.ts +103 -0
  73. package/dist/lib/work-context/company.js +274 -0
  74. package/dist/lib/work-context/config.d.ts +59 -0
  75. package/dist/lib/work-context/config.js +172 -0
  76. package/dist/lib/work-context/contract.d.ts +81 -0
  77. package/dist/lib/work-context/contract.js +65 -0
  78. package/dist/lib/work-context/errors.d.ts +27 -0
  79. package/dist/lib/work-context/errors.js +46 -0
  80. package/dist/lib/work-context/index.d.ts +23 -0
  81. package/dist/lib/work-context/index.js +20 -0
  82. package/dist/lib/work-context/migrate.d.ts +84 -0
  83. package/dist/lib/work-context/migrate.js +287 -0
  84. package/dist/lib/work-context/organize.d.ts +130 -0
  85. package/dist/lib/work-context/organize.js +471 -0
  86. package/dist/lib/work-context/outbox.d.ts +97 -0
  87. package/dist/lib/work-context/outbox.js +357 -0
  88. package/dist/lib/work-context/paths.d.ts +14 -0
  89. package/dist/lib/work-context/paths.js +39 -0
  90. package/dist/lib/work-context/project.d.ts +78 -0
  91. package/dist/lib/work-context/project.js +247 -0
  92. package/dist/lib/work-context/reconcile.d.ts +85 -0
  93. package/dist/lib/work-context/reconcile.js +651 -0
  94. package/dist/lib/work-context/repo-remote.d.ts +43 -0
  95. package/dist/lib/work-context/repo-remote.js +200 -0
  96. package/dist/lib/work-context/state.d.ts +112 -0
  97. package/dist/lib/work-context/state.js +162 -0
  98. package/dist/main.js +40 -3
  99. package/dist/utils/cognito-session.d.ts +85 -9
  100. package/dist/utils/cognito-session.js +216 -9
  101. package/dist/utils/qmd-model-download-error.d.ts +27 -0
  102. package/dist/utils/qmd-model-download-error.js +114 -0
  103. package/dist/utils/qmd-query-document-error.d.ts +23 -0
  104. package/dist/utils/qmd-query-document-error.js +117 -0
  105. package/dist/utils/sentry-fingerprint.js +2 -0
  106. package/package.json +5 -1
@@ -6,6 +6,29 @@ export type SearchOptions = {
6
6
  count?: number;
7
7
  json?: boolean;
8
8
  };
9
+ /**
10
+ * Normalise a caller's query into the single argv token qmd is handed.
11
+ *
12
+ * A quoted multi-line query (a fleet agent forwards a whole Slack message as the
13
+ * query) reaches qmd with its newlines intact. qmd's `query` subcommand then
14
+ * reads it as a query DOCUMENT and, unless EVERY non-empty line carries a
15
+ * lex:/vec:/hyde:/intent:/expand: prefix, exits 1 with a syntax error — which
16
+ * hq surfaced as an unclassified crash (Sentry HQ-CLI-1B / indigo-d0 7705711476,
17
+ * 28 of 29 events).
18
+ *
19
+ * The rule is IMPLIED BY qmd's own acceptance rule, so it can never collapse a
20
+ * document qmd would have accepted: qmd requires a prefix on every line of a
21
+ * multi-line document, so a prefixed FIRST non-empty line is a NECESSARY
22
+ * condition of any document qmd accepts. When the caller opened such a document
23
+ * (two or more non-empty lines AND a prefixed first line) the query is returned
24
+ * VERBATIM so structured hybrid search keeps working; otherwise every run of
25
+ * whitespace — the newlines included — collapses to a single space, so plain
26
+ * multi-line free text becomes the single-line query qmd accepts. A half-prefixed
27
+ * document (a user typo whose first line lacks a prefix) is collapsed and still
28
+ * reaches qmd, which returns a real diagnostic rather than a silently-wrong
29
+ * free-text search.
30
+ */
31
+ export declare function normalizeQueryForQmd(query: string): string;
9
32
  export declare function buildSearchArgs(query: string, options?: SearchOptions): string[];
10
33
  export declare function buildGetArgs(document: string, options?: Pick<SearchOptions, 'collection'>): string[];
11
34
  export declare function registerSearchCommand(program: Command): void;
@@ -1,7 +1,48 @@
1
1
  import { runQmd } from '../lib/search-index/index.js';
2
+ /**
3
+ * qmd's query-DOCUMENT line prefixes (qmd 2.5.3, dist/cli/qmd.js:2165-2167 and
4
+ * :2210). qmd's `query` subcommand parses a multi-line positional as a query
5
+ * DOCUMENT: with two or more non-empty lines EVERY line must begin with one of
6
+ * these, or qmd throws and exits 1. Mirrored from the EXACT pinned version
7
+ * (package.json pins `@tobilu/qmd` to `2.5.3`, not a range), so the mirror cannot
8
+ * drift without a deliberate dependency bump.
9
+ */
10
+ const QMD_DOCUMENT_PREFIX = /^(?:lex|vec|hyde|intent|expand):\s*/i;
11
+ /**
12
+ * Normalise a caller's query into the single argv token qmd is handed.
13
+ *
14
+ * A quoted multi-line query (a fleet agent forwards a whole Slack message as the
15
+ * query) reaches qmd with its newlines intact. qmd's `query` subcommand then
16
+ * reads it as a query DOCUMENT and, unless EVERY non-empty line carries a
17
+ * lex:/vec:/hyde:/intent:/expand: prefix, exits 1 with a syntax error — which
18
+ * hq surfaced as an unclassified crash (Sentry HQ-CLI-1B / indigo-d0 7705711476,
19
+ * 28 of 29 events).
20
+ *
21
+ * The rule is IMPLIED BY qmd's own acceptance rule, so it can never collapse a
22
+ * document qmd would have accepted: qmd requires a prefix on every line of a
23
+ * multi-line document, so a prefixed FIRST non-empty line is a NECESSARY
24
+ * condition of any document qmd accepts. When the caller opened such a document
25
+ * (two or more non-empty lines AND a prefixed first line) the query is returned
26
+ * VERBATIM so structured hybrid search keeps working; otherwise every run of
27
+ * whitespace — the newlines included — collapses to a single space, so plain
28
+ * multi-line free text becomes the single-line query qmd accepts. A half-prefixed
29
+ * document (a user typo whose first line lacks a prefix) is collapsed and still
30
+ * reaches qmd, which returns a real diagnostic rather than a silently-wrong
31
+ * free-text search.
32
+ */
33
+ export function normalizeQueryForQmd(query) {
34
+ const nonEmptyLines = query
35
+ .split(/\r?\n/)
36
+ .map((line) => line.trim())
37
+ .filter((line) => line.length > 0);
38
+ if (nonEmptyLines.length >= 2 && QMD_DOCUMENT_PREFIX.test(nonEmptyLines[0])) {
39
+ return query;
40
+ }
41
+ return query.replace(/\s+/g, ' ').trim();
42
+ }
2
43
  export function buildSearchArgs(query, options = {}) {
3
44
  const command = { keyword: 'search', semantic: 'vsearch', hybrid: 'query' }[options.mode ?? 'keyword'];
4
- const args = [command, query];
45
+ const args = [command, normalizeQueryForQmd(query)];
5
46
  if (options.collection)
6
47
  args.push('-c', options.collection);
7
48
  if (options.count !== undefined)
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Work-context doctor family (US-007A + US-008): state-file health, outbox
3
+ * depth, quarantined count, default-company mode, and work-mesh spool /
4
+ * held / dead-letter visibility.
5
+ */
6
+ import { type MigrationCapabilitySnapshot } from "../../work-context/config.js";
7
+ import type { CheckContext, CheckFamily, CheckResult } from "../types.js";
8
+ export declare const WORK_CONTEXT_FAMILY_ID = "work-context";
9
+ export declare const WORK_CONTEXT_FAMILY_TITLE = "Work context";
10
+ export interface WorkContextDoctorDeps {
11
+ root: (home?: string) => string;
12
+ home: () => string;
13
+ /** Work-mesh root (spool / held / dead-letter). Defaults to ~/.hq/work-mesh. */
14
+ meshRoot?: (home?: string) => string;
15
+ /**
16
+ * Optional override for the last migration-capability probe (US-017B).
17
+ * When omitted, doctor reads the snapshot from device config.
18
+ */
19
+ migrationCapability?: MigrationCapabilitySnapshot | null;
20
+ }
21
+ export declare const workContextFamily: CheckFamily;
22
+ export declare function checkWorkContext(_context: CheckContext, deps?: WorkContextDoctorDeps): CheckResult[];
23
+ //# sourceMappingURL=work-context.d.ts.map
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Work-context doctor family (US-007A + US-008): state-file health, outbox
3
+ * depth, quarantined count, default-company mode, and work-mesh spool /
4
+ * held / dead-letter visibility.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { readDeviceConfig, getDefaultCompany, } from "../../work-context/config.js";
10
+ import { outboxStats, listOutboxOperations } from "../../work-context/outbox.js";
11
+ import { workContextRoot, workContextSessionsDir } from "../../work-context/paths.js";
12
+ import { countJsonlLines, deadLetterNonEmpty, } from "../../mesh/live/spool.js";
13
+ import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../../mesh/live/paths.js";
14
+ import { collectDaemonDoctor, formatDaemonDoctor, } from "../../mesh/live/daemon/doctor.js";
15
+ export const WORK_CONTEXT_FAMILY_ID = "work-context";
16
+ export const WORK_CONTEXT_FAMILY_TITLE = "Work context";
17
+ const DEFAULT_DEPS = {
18
+ root: (home) => workContextRoot(home ?? os.homedir()),
19
+ home: () => os.homedir(),
20
+ meshRoot: (home) => workMeshRoot(home ?? os.homedir()),
21
+ };
22
+ export const workContextFamily = {
23
+ id: WORK_CONTEXT_FAMILY_ID,
24
+ title: WORK_CONTEXT_FAMILY_TITLE,
25
+ run: (context) => Promise.resolve(checkWorkContext(context)),
26
+ };
27
+ export function checkWorkContext(_context, deps = DEFAULT_DEPS) {
28
+ try {
29
+ return runChecks(deps);
30
+ }
31
+ catch (error) {
32
+ return [
33
+ {
34
+ status: "UNKNOWN",
35
+ checkId: "work-context.error",
36
+ message: `work-context checks could not run: ${error.message}`,
37
+ },
38
+ ];
39
+ }
40
+ }
41
+ function runChecks(deps) {
42
+ const root = deps.root(deps.home());
43
+ const results = [];
44
+ // State-file health
45
+ const sessionsDir = workContextSessionsDir(root);
46
+ if (!fs.existsSync(sessionsDir)) {
47
+ results.push({
48
+ status: "NA",
49
+ checkId: "work-context.state-files",
50
+ target: sessionsDir,
51
+ message: "No work-context sessions directory yet.",
52
+ });
53
+ }
54
+ else {
55
+ const files = fs.readdirSync(sessionsDir).filter((n) => n.endsWith(".json"));
56
+ let corrupt = 0;
57
+ let healthy = 0;
58
+ for (const name of files) {
59
+ const p = path.join(sessionsDir, name);
60
+ try {
61
+ const raw = JSON.parse(fs.readFileSync(p, "utf8"));
62
+ if (!raw.sessionId || !raw.contextStatus || raw.contractVersion !== 1) {
63
+ corrupt += 1;
64
+ }
65
+ else {
66
+ healthy += 1;
67
+ }
68
+ }
69
+ catch {
70
+ corrupt += 1;
71
+ }
72
+ }
73
+ results.push({
74
+ status: corrupt > 0 ? "WARN" : "PASS",
75
+ checkId: "work-context.state-files",
76
+ target: sessionsDir,
77
+ message: corrupt > 0
78
+ ? `${corrupt} corrupt / ${healthy} healthy session state file(s).`
79
+ : `${healthy} healthy session state file(s).`,
80
+ });
81
+ }
82
+ // Outbox depth + quarantined
83
+ const stats = outboxStats(root);
84
+ results.push({
85
+ status: stats.depth > 50 ? "WARN" : "PASS",
86
+ checkId: "work-context.outbox-depth",
87
+ target: path.join(root, "outbox"),
88
+ message: `Outbox queued depth=${stats.depth} (acked=${stats.acked}).`,
89
+ });
90
+ results.push({
91
+ status: stats.quarantined > 0 ? "WARN" : "PASS",
92
+ checkId: "work-context.outbox-quarantined",
93
+ target: path.join(root, "outbox"),
94
+ message: `Outbox quarantined count=${stats.quarantined}.`,
95
+ });
96
+ // Default-company mode + migration capability (US-017B)
97
+ try {
98
+ const cfg = readDeviceConfig({ root });
99
+ const def = getDefaultCompany({ root });
100
+ const mode = def?.enabled
101
+ ? `enabled slug=${def.slug}`
102
+ : "disabled (dark / unset)";
103
+ results.push({
104
+ status: "PASS",
105
+ checkId: "work-context.default-company",
106
+ target: path.join(root, "config.json"),
107
+ message: `Default-company mode: ${mode}; schemaVersion=${cfg.schemaVersion}.`,
108
+ });
109
+ const cap = deps.migrationCapability !== undefined
110
+ ? deps.migrationCapability
111
+ : (cfg.migrationCapability ?? null);
112
+ results.push(formatMigrationCapabilityCheck(cap, root));
113
+ }
114
+ catch (err) {
115
+ results.push({
116
+ status: "WARN",
117
+ checkId: "work-context.default-company",
118
+ target: path.join(root, "config.json"),
119
+ message: `Default-company config unreadable: ${err instanceof Error ? err.message : String(err)}`,
120
+ });
121
+ results.push({
122
+ status: "WARN",
123
+ checkId: "work-context.migration-capability",
124
+ target: path.join(root, "config.json"),
125
+ message: `Migration capability unknown: ${err instanceof Error ? err.message : String(err)}`,
126
+ });
127
+ }
128
+ // Touch listOutboxOperations so depth stays consistent with enumeration.
129
+ void listOutboxOperations(root);
130
+ // Work-mesh spool / held / dead-letter (US-008)
131
+ const meshRoot = (deps.meshRoot ?? DEFAULT_DEPS.meshRoot)(deps.home());
132
+ const spoolPath = workMeshSpoolPath(meshRoot);
133
+ const heldPath = workMeshHeldPath(meshRoot);
134
+ const deadLetterPath = workMeshDeadLetterPath(meshRoot);
135
+ const spoolDepth = countJsonlLines(spoolPath);
136
+ const heldDepth = countJsonlLines(heldPath);
137
+ results.push({
138
+ status: "PASS",
139
+ checkId: "work-context.spool-depth",
140
+ target: spoolPath,
141
+ message: `Work-mesh spool depth=${spoolDepth}; held depth=${heldDepth}.`,
142
+ });
143
+ const deadLetterFull = deadLetterNonEmpty(meshRoot);
144
+ results.push({
145
+ status: deadLetterFull ? "WARN" : "PASS",
146
+ checkId: "work-context.dead-letter",
147
+ target: deadLetterPath,
148
+ message: deadLetterFull
149
+ ? `Work-mesh dead-letter is non-empty (${countJsonlLines(deadLetterPath)} line(s)); inspect and clear after fixing the cause.`
150
+ : "Work-mesh dead-letter is empty.",
151
+ });
152
+ // Daemon doctor (US-009): running / MQTT / companies / flush / unhealthy age
153
+ const daemon = collectDaemonDoctor({
154
+ home: deps.home(),
155
+ meshRoot,
156
+ workContextRoot: root,
157
+ });
158
+ results.push({
159
+ status: daemon.unhealthy ? "WARN" : "PASS",
160
+ checkId: "work-context.daemon",
161
+ target: meshRoot,
162
+ message: formatDaemonDoctor(daemon).join(" "),
163
+ });
164
+ return results;
165
+ }
166
+ function formatMigrationCapabilityCheck(cap, root) {
167
+ const target = path.join(root, "config.json");
168
+ if (!cap) {
169
+ return {
170
+ status: "NA",
171
+ checkId: "work-context.migration-capability",
172
+ target,
173
+ message: "Migration capability: not probed yet (run hq mesh context default set or correct).",
174
+ };
175
+ }
176
+ if (cap.offline) {
177
+ return {
178
+ status: "WARN",
179
+ checkId: "work-context.migration-capability",
180
+ target,
181
+ message: `Migration capability: offline at ${cap.checkedAt}; default-company stays locked.`,
182
+ };
183
+ }
184
+ const falseCount = cap.companies.filter((c) => !c.migration).length;
185
+ const detail = cap.companies.length === 0
186
+ ? "no active memberships"
187
+ : `${cap.companies.filter((c) => c.migration).length}/${cap.companies.length} companies advertise migration`;
188
+ return {
189
+ status: cap.unlocked ? "PASS" : "WARN",
190
+ checkId: "work-context.migration-capability",
191
+ target,
192
+ message: `Migration capability: unlocked=${cap.unlocked} (${detail}; false=${falseCount}); checkedAt=${cap.checkedAt}.`,
193
+ };
194
+ }
195
+ //# sourceMappingURL=work-context.js.map
@@ -21,6 +21,7 @@ import { checkRuntimeProbe } from "./checks/runtime-probe.js";
21
21
  import { runtimeHealthFamily } from "./checks/runtime-health.js";
22
22
  import { integrationsFamily } from "./checks/integrations.js";
23
23
  import { syncHealthFamily } from "./checks/sync-health.js";
24
+ import { workContextFamily } from "./checks/work-context.js";
24
25
  import { fixtureCoverageFamily } from "./fixtures/discover.js";
25
26
  import { checkClaudeWiring } from "./checks/claude-wiring.js";
26
27
  /**
@@ -187,6 +188,9 @@ export function createDefaultRegistry() {
187
188
  // this registry's docblock always anticipated. Local-only reads, so the
188
189
  // doctor's offline contract is preserved.
189
190
  registry.register(syncHealthFamily);
191
+ // Work context (US-007A): session state files, outbox depth/quarantine,
192
+ // and default-company mode. Local-only reads under ~/.hq/work-context.
193
+ registry.register(workContextFamily);
190
194
  return registry;
191
195
  }
192
196
  //# sourceMappingURL=registry.js.map
@@ -29,6 +29,24 @@ export declare function meshJson(token: string, path: string, init?: {
29
29
  query?: Record<string, string>;
30
30
  }): Promise<unknown>;
31
31
  export declare function resolveMeshCompany(token: string, company?: string): Promise<MeshCompany>;
32
+ /**
33
+ * Active memberships for the caller via GET /membership/me.
34
+ * Does not honour HQ_COMPANY_UID / HQ_WORK_MESH_COMPANY_UID env overrides.
35
+ * Throws on transport / auth failures.
36
+ */
37
+ export declare function listActiveMembershipCompanies(token: string): Promise<MeshCompany[]>;
38
+ /**
39
+ * Memberships-backed company lookup for default-company set (US-007A).
40
+ *
41
+ * Lists the caller's active companies via GET /membership/me and matches the
42
+ * requested slug (case-insensitive). Does **not** honour HQ_COMPANY_UID /
43
+ * HQ_WORK_MESH_COMPANY_UID env overrides or treat a raw `cmp_`/`co_` ref as
44
+ * proof of membership without an active membership row.
45
+ *
46
+ * Returns null when the caller is not an active member. Throws on transport /
47
+ * auth failures so the caller can surface DEFAULT_COMPANY_UNAVAILABLE.
48
+ */
49
+ export declare function resolveActiveMembershipCompany(token: string, slug: string): Promise<MeshCompany | null>;
32
50
  export declare function requireToken(): Promise<string>;
33
51
  export declare function listActiveThreads(token: string, companyUid: string, projectId?: string): Promise<MeshThread[]>;
34
52
  export declare function clamp(value: string, max: number): string;
@@ -58,6 +58,61 @@ export async function resolveMeshCompany(token, company) {
58
58
  const uid = await getCompanyUid(token, company);
59
59
  return { companyUid: uid, companySlug: company || uid };
60
60
  }
61
+ /**
62
+ * Active memberships for the caller via GET /membership/me.
63
+ * Does not honour HQ_COMPANY_UID / HQ_WORK_MESH_COMPANY_UID env overrides.
64
+ * Throws on transport / auth failures.
65
+ */
66
+ export async function listActiveMembershipCompanies(token) {
67
+ const data = asRecord(await meshJson(token, "/membership/me"));
68
+ const rows = Array.isArray(data?.memberships) ? data.memberships : [];
69
+ const out = [];
70
+ const seen = new Set();
71
+ for (const row of rows) {
72
+ const rec = asRecord(row);
73
+ if (!rec)
74
+ continue;
75
+ if (String(rec.status || "").toLowerCase() !== "active")
76
+ continue;
77
+ const companyUid = typeof rec.companyUid === "string" ? rec.companyUid : "";
78
+ if (!companyUid || seen.has(companyUid))
79
+ continue;
80
+ const companySlug = typeof rec.companySlug === "string"
81
+ ? rec.companySlug
82
+ : typeof rec.companyFolderSlug === "string"
83
+ ? rec.companyFolderSlug
84
+ : companyUid;
85
+ seen.add(companyUid);
86
+ out.push({ companyUid, companySlug: companySlug || companyUid });
87
+ }
88
+ return out;
89
+ }
90
+ /**
91
+ * Memberships-backed company lookup for default-company set (US-007A).
92
+ *
93
+ * Lists the caller's active companies via GET /membership/me and matches the
94
+ * requested slug (case-insensitive). Does **not** honour HQ_COMPANY_UID /
95
+ * HQ_WORK_MESH_COMPANY_UID env overrides or treat a raw `cmp_`/`co_` ref as
96
+ * proof of membership without an active membership row.
97
+ *
98
+ * Returns null when the caller is not an active member. Throws on transport /
99
+ * auth failures so the caller can surface DEFAULT_COMPANY_UNAVAILABLE.
100
+ */
101
+ export async function resolveActiveMembershipCompany(token, slug) {
102
+ const trimmed = slug.trim();
103
+ if (!trimmed)
104
+ return null;
105
+ const rows = await listActiveMembershipCompanies(token);
106
+ const needle = trimmed.toLowerCase();
107
+ const matches = rows.filter((c) => c.companySlug.toLowerCase() === needle ||
108
+ c.companyUid.toLowerCase() === needle);
109
+ if (matches.length === 1)
110
+ return matches[0];
111
+ if (matches.length === 0)
112
+ return null;
113
+ // Ambiguous slug across memberships — refuse rather than guess.
114
+ return null;
115
+ }
61
116
  export async function requireToken() {
62
117
  return ensureCognitoToken();
63
118
  }
@@ -22,10 +22,41 @@ export function writeMeshCacheFile(segments, value, root = meshCacheRoot()) {
22
22
  if (segments.length === 0 || !segments.every(isSafeCacheSegment))
23
23
  return null;
24
24
  const dest = path.join(root, ...segments);
25
- fs.mkdirSync(path.dirname(dest), { recursive: true });
26
- const tmp = `${dest}.tmp`;
27
- fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
28
- fs.renameSync(tmp, dest);
25
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
26
+ const tmp = `${dest}.tmp.${process.pid}.${Date.now()}`;
27
+ let fd;
28
+ try {
29
+ fd = fs.openSync(tmp, "wx", 0o600);
30
+ const buf = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
31
+ fs.writeSync(fd, buf);
32
+ fs.fsyncSync(fd);
33
+ fs.closeSync(fd);
34
+ fd = undefined;
35
+ fs.renameSync(tmp, dest);
36
+ try {
37
+ fs.chmodSync(dest, 0o600);
38
+ }
39
+ catch {
40
+ // ignore exotic FS
41
+ }
42
+ }
43
+ catch (err) {
44
+ if (fd !== undefined) {
45
+ try {
46
+ fs.closeSync(fd);
47
+ }
48
+ catch {
49
+ /* already closed */
50
+ }
51
+ }
52
+ try {
53
+ fs.unlinkSync(tmp);
54
+ }
55
+ catch {
56
+ /* temp may not exist */
57
+ }
58
+ throw err;
59
+ }
29
60
  return dest;
30
61
  }
31
62
  /** Drop leftover session.json once we know the real prs_/agt_ cache key. */
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Shared work-mesh transport client.
3
+ * Existing `hq mesh` verbs and work-context reconcile both use this for
4
+ * vault HTTP + receipt-shaped responses (US-007A / US-007B).
5
+ */
6
+ import { listActiveMembershipCompanies, meshJson, requireToken, resolveMeshCompany, type MeshCompany } from "./api.js";
7
+ import type { DeliverFn } from "../work-context/outbox.js";
8
+ import type { CandidatesResponse, OrganizeReceipt, OrganizeSubmitDecision, FetchCandidatesFn, PostOrganizeFn } from "../work-context/organize.js";
9
+ import type { MigrateReceipt, PostMigrateFn } from "../work-context/migrate.js";
10
+ export { meshJson, requireToken, resolveMeshCompany, listActiveMembershipCompanies };
11
+ export type { MeshCompany };
12
+ /** Company-wide live read (US-004 / US-007B session status). */
13
+ export interface CompanyLiveSession {
14
+ sessionId: string;
15
+ harness?: string;
16
+ contextStatus?: string;
17
+ projectId?: string;
18
+ taskId?: string;
19
+ status?: string;
20
+ startedAt?: string;
21
+ lastTurnAt?: string;
22
+ turnCount?: number;
23
+ }
24
+ export interface CompanyLiveParticipant {
25
+ actorUid: string;
26
+ actorType?: string;
27
+ displayName?: string;
28
+ presence?: string;
29
+ lastSeenAt?: string;
30
+ sessions?: CompanyLiveSession[];
31
+ }
32
+ export interface CompanyLiveReadResponse {
33
+ contractVersion: 1;
34
+ generatedAt: string;
35
+ participants: CompanyLiveParticipant[];
36
+ nextCursor?: string;
37
+ }
38
+ export interface MeshTransport {
39
+ token: string;
40
+ company: MeshCompany;
41
+ }
42
+ export declare function openMeshTransport(opts: {
43
+ company?: string;
44
+ }): Promise<MeshTransport>;
45
+ /**
46
+ * POST work-session registration for a queued outbox operation.
47
+ * Maps HTTP status to retryable vs quarantine.
48
+ */
49
+ export declare function createWorkSessionDeliverer(opts: {
50
+ token: string;
51
+ /** Override fetch for tests. */
52
+ post?: (path: string, body: Record<string, unknown>) => Promise<{
53
+ status: number;
54
+ body: unknown;
55
+ }>;
56
+ }): DeliverFn;
57
+ /**
58
+ * Probe whether the server advertises migration capability (US-017A).
59
+ * Failures return false (default-company stays locked).
60
+ */
61
+ export declare function fetchMigrationCapability(token: string, companyUid: string): Promise<boolean>;
62
+ export interface MigrationCapabilityCompanyResult {
63
+ companyUid: string;
64
+ companySlug: string;
65
+ migration: boolean;
66
+ }
67
+ export interface MigrationCapabilityProbe {
68
+ /** True only when every active membership reports migration: true. */
69
+ unlocked: boolean;
70
+ /** True when memberships could not be listed (no session / network). */
71
+ offline: boolean;
72
+ checkedAt: string;
73
+ companies: MigrationCapabilityCompanyResult[];
74
+ }
75
+ /**
76
+ * Unlock rule for default-company mode (US-017B): migration must be true for
77
+ * EVERY company the caller belongs to. Offline / empty memberships → locked.
78
+ */
79
+ export declare function probeMigrationCapabilityForMemberships(token: string, opts?: {
80
+ listCompanies?: (token: string) => Promise<MeshCompany[]>;
81
+ fetchOne?: (token: string, companyUid: string) => Promise<boolean>;
82
+ now?: () => Date;
83
+ }): Promise<MigrationCapabilityProbe>;
84
+ /**
85
+ * POST /v1/work-mesh/sessions/{sessionId}/migrate
86
+ */
87
+ export declare function createMigratePoster(opts: {
88
+ token: string;
89
+ post?: (path: string, body: Record<string, unknown>) => Promise<{
90
+ status: number;
91
+ body: unknown;
92
+ }>;
93
+ }): PostMigrateFn;
94
+ /**
95
+ * GET /v1/work-mesh/sessions/{sessionId}/candidates?companyUid=...
96
+ */
97
+ export declare function createCandidatesFetcher(opts: {
98
+ token: string;
99
+ get?: (path: string, query: Record<string, string>) => Promise<unknown>;
100
+ }): FetchCandidatesFn;
101
+ /**
102
+ * POST /v1/work-mesh/sessions/{sessionId}/organize
103
+ */
104
+ export declare function createOrganizePoster(opts: {
105
+ token: string;
106
+ post?: (path: string, body: Record<string, unknown>) => Promise<{
107
+ status: number;
108
+ body: unknown;
109
+ }>;
110
+ }): PostOrganizeFn;
111
+ /** GET /v1/work-mesh/live?companyUid=... */
112
+ export declare function fetchCompanyLive(token: string, companyUid: string, opts?: {
113
+ cursor?: string;
114
+ get?: (path: string, query: Record<string, string>) => Promise<unknown>;
115
+ }): Promise<CompanyLiveReadResponse>;
116
+ export declare function formatCompanyLiveTable(live: CompanyLiveReadResponse, companyLabel: string): string[];
117
+ export type { OrganizeSubmitDecision, OrganizeReceipt, CandidatesResponse, MigrateReceipt, PostMigrateFn, };
118
+ //# sourceMappingURL=client.d.ts.map