@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
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Work Mesh Live — session-event validator (US-001).
3
+ *
4
+ * Uses Ajv draft 2020-12 against the verbatim schema copy. Prohibited content
5
+ * classes are rejected (additionalProperties: false). Error messages never
6
+ * echo prohibited marker values (redaction assertion surface).
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { dirname, isAbsolute, join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import Ajv2020 from "ajv/dist/2020.js";
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ export const SESSION_EVENT_SCHEMA_PATH = join(HERE, "session-event.schema.json");
15
+ /** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
16
+ export const SESSION_EVENT_SCHEMA_SHA256 = "be300120b4793a5c6f1e713bf9f72b2fd22f6ac082bef8eedfa507b684b09ccb";
17
+ export const PROHIBITED_CONTENT_CLASSES = [
18
+ "prompts",
19
+ "model_output",
20
+ "transcripts",
21
+ "message_bodies",
22
+ "tokens",
23
+ "credentials",
24
+ ];
25
+ /** Fixture field name → prohibited class. */
26
+ export const PROHIBITED_FIELD_BY_CLASS = {
27
+ prompts: "prompt",
28
+ model_output: "modelOutput",
29
+ transcripts: "transcript",
30
+ message_bodies: "messageBody",
31
+ tokens: "token",
32
+ credentials: "credentials",
33
+ };
34
+ const REDACT_MARKERS = [
35
+ "REDACT_MARKER_PROMPT_",
36
+ "REDACT_MARKER_MODEL_OUTPUT_",
37
+ "REDACT_MARKER_TRANSCRIPT_",
38
+ "REDACT_MARKER_MESSAGE_BODY_",
39
+ "REDACT_MARKER_TOKEN_",
40
+ "REDACT_MARKER_CREDENTIALS_",
41
+ ];
42
+ let cachedValidate;
43
+ function loadSchema() {
44
+ return JSON.parse(readFileSync(SESSION_EVENT_SCHEMA_PATH, "utf8"));
45
+ }
46
+ export function getSessionEventValidator() {
47
+ if (cachedValidate)
48
+ return cachedValidate;
49
+ const ajv = new Ajv2020({
50
+ allErrors: true,
51
+ strict: false,
52
+ validateFormats: false,
53
+ });
54
+ cachedValidate = ajv.compile(loadSchema());
55
+ return cachedValidate;
56
+ }
57
+ /**
58
+ * Redact known prohibited markers from Ajv error text so diagnostics never
59
+ * leak prompt/token/credential material.
60
+ */
61
+ export function redactValidationMessage(message) {
62
+ let out = message;
63
+ for (const marker of REDACT_MARKERS) {
64
+ if (out.includes(marker)) {
65
+ out = out.split(marker)[0] + "[REDACTED]";
66
+ }
67
+ }
68
+ // Also strip any bearer-shaped or op:// fragments that slipped through.
69
+ out = out.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]");
70
+ out = out.replace(/op:\/\/[^\s"]+/g, "op://[REDACTED]");
71
+ return out;
72
+ }
73
+ function formatErrors(errors) {
74
+ if (!errors || errors.length === 0)
75
+ return ["validation_failed"];
76
+ return errors.map((e) => {
77
+ const path = e.instancePath || "/";
78
+ const msg = e.message ?? "invalid";
79
+ return redactValidationMessage(`${path}: ${msg}`);
80
+ });
81
+ }
82
+ export function validateSessionEvent(candidate) {
83
+ const validate = getSessionEventValidator();
84
+ const ok = validate(candidate);
85
+ if (ok)
86
+ return { ok: true, errors: [] };
87
+ return { ok: false, errors: formatErrors(validate.errors) };
88
+ }
89
+ export function sha256File(path) {
90
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
91
+ }
92
+ export function sha256SessionEventSchema() {
93
+ return sha256File(SESSION_EVENT_SCHEMA_PATH);
94
+ }
95
+ const HQ_PRO_SCHEMA_RELATIVE = "src/vault-service/work-mesh/live/session-event.schema.json";
96
+ /** Walk up from `start` until a directory containing package.json is found. */
97
+ function findRepoRoot(start) {
98
+ let dir = start;
99
+ for (;;) {
100
+ if (existsSync(join(dir, "package.json")))
101
+ return dir;
102
+ const parent = dirname(dir);
103
+ if (parent === dir)
104
+ return null;
105
+ dir = parent;
106
+ }
107
+ }
108
+ /**
109
+ * Resolve the hq-pro canonical schema path when available.
110
+ *
111
+ * Order: `HQ_PRO_SCHEMA_PATH` env override (absolute path to the hq-pro
112
+ * schema file) → sibling checkout
113
+ * `../hq-pro/src/vault-service/work-mesh/live/session-event.schema.json`
114
+ * from the hq-cli repo root → null.
115
+ * Shipped code must not hardcode machine-specific worktree paths.
116
+ */
117
+ export function resolveHqProSchemaPath() {
118
+ const envPath = process.env.HQ_PRO_SCHEMA_PATH?.trim();
119
+ if (envPath) {
120
+ return isAbsolute(envPath) ? envPath : resolve(envPath);
121
+ }
122
+ const repoRoot = findRepoRoot(HERE);
123
+ if (!repoRoot)
124
+ return null;
125
+ const sibling = resolve(repoRoot, "..", "hq-pro", HQ_PRO_SCHEMA_RELATIVE);
126
+ return existsSync(sibling) ? sibling : null;
127
+ }
128
+ //# sourceMappingURL=validate-session-event.js.map
@@ -200,6 +200,51 @@ export declare class QmdStoreUnopenableError extends QmdExitError {
200
200
  name: string;
201
201
  constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, storeDir?: string | undefined, probeReason?: string | undefined);
202
202
  }
203
+ /**
204
+ * qmd's `query` subcommand (hybrid search) rejected the positional as a
205
+ * MALFORMED QUERY DOCUMENT: qmd 2.5.3 parses a multi-line positional as a query
206
+ * document and requires EVERY non-empty line to carry a lex:/vec:/hyde:/intent:/
207
+ * expand: prefix (dist/cli/qmd.js:2157-2217), throwing otherwise. That is the
208
+ * caller's INPUT SHAPE — a multi-line free-text query, most often a fleet agent
209
+ * forwarding a whole Slack message — not a bug HQ can fix in code.
210
+ *
211
+ * HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, 28 of 29 events): `hq search
212
+ * --mode hybrid` mapped to `qmd query` and handed the caller's multi-line query
213
+ * through unchanged; qmd exited 1 with `Line 1 is missing a lex:/vec:/hyde:/
214
+ * intent: prefix. …`. With no classifier the generic template raised a plain
215
+ * QmdExitError that reached the boundary's final else and was captured.
216
+ * buildSearchArgs now normalises free text to a single line so the common case
217
+ * never reaches qmd multi-line; this typed subclass lets the boundary classify a
218
+ * genuinely malformed structured document as the caller's input and stop
219
+ * fingerprinting on the query text: it is raised only from qmd's OWN captured
220
+ * streams and its message names the SUBCOMMAND only.
221
+ */
222
+ export declare class QmdQueryDocumentError extends QmdExitError {
223
+ name: string;
224
+ }
225
+ /**
226
+ * A qmd MODEL DOWNLOAD did not finish: qmd crashed with ENOENT while renaming its
227
+ * own partial `.ipull` download file into place. The partial file was gone by the
228
+ * time the rename ran — the signature of ANOTHER qmd process on the same box
229
+ * finishing (or clearing) the same download first. That is a transient, retryable
230
+ * LOCAL-CACHE race, not a bug HQ can fix in code: hq cannot coordinate qmd
231
+ * children other users start on the same host, and reaching into qmd's model
232
+ * cache would be hq owning upstream state.
233
+ *
234
+ * HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, the newest event, on release
235
+ * 5.107.1): `qmd query` reached its reranking stage and its reranker-model
236
+ * downloader died uncaught with `[Error: ENOENT: no such file or directory,
237
+ * rename '<cache>/…-reranker-….gguf.ipull' -> '<cache>/…-reranker-….gguf']`.
238
+ * With no classifier it took the identical fall-through to capture as the
239
+ * query-document crash — and because both share the (QmdExitError, qmd:query,
240
+ * exit:1) fingerprint, a query-document-only fix would leave this issue alive.
241
+ * This typed subclass lets the boundary classify it and stop the capture: it is
242
+ * raised only from qmd's OWN captured streams and its message names the
243
+ * SUBCOMMAND only.
244
+ */
245
+ export declare class QmdModelDownloadError extends QmdExitError {
246
+ name: string;
247
+ }
203
248
  export type ResolveQmdBinOptions = {
204
249
  env?: Record<string, string | undefined>;
205
250
  isExecutable?: (candidate: string) => boolean;
@@ -4,8 +4,10 @@ import { createRequire } from 'node:module';
4
4
  import * as os from 'node:os';
5
5
  import * as path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { isQmdModelDownloadError } from '../../utils/qmd-model-download-error.js';
7
8
  import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
8
9
  import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
10
+ import { isQmdQueryDocumentError } from '../../utils/qmd-query-document-error.js';
9
11
  import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
10
12
  import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-error.js';
11
13
  import { redactErrorText } from '../../utils/redact-error-text.js';
@@ -189,6 +191,51 @@ export class QmdStoreUnopenableError extends QmdExitError {
189
191
  this.probeReason = probeReason;
190
192
  }
191
193
  }
194
+ /**
195
+ * qmd's `query` subcommand (hybrid search) rejected the positional as a
196
+ * MALFORMED QUERY DOCUMENT: qmd 2.5.3 parses a multi-line positional as a query
197
+ * document and requires EVERY non-empty line to carry a lex:/vec:/hyde:/intent:/
198
+ * expand: prefix (dist/cli/qmd.js:2157-2217), throwing otherwise. That is the
199
+ * caller's INPUT SHAPE — a multi-line free-text query, most often a fleet agent
200
+ * forwarding a whole Slack message — not a bug HQ can fix in code.
201
+ *
202
+ * HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, 28 of 29 events): `hq search
203
+ * --mode hybrid` mapped to `qmd query` and handed the caller's multi-line query
204
+ * through unchanged; qmd exited 1 with `Line 1 is missing a lex:/vec:/hyde:/
205
+ * intent: prefix. …`. With no classifier the generic template raised a plain
206
+ * QmdExitError that reached the boundary's final else and was captured.
207
+ * buildSearchArgs now normalises free text to a single line so the common case
208
+ * never reaches qmd multi-line; this typed subclass lets the boundary classify a
209
+ * genuinely malformed structured document as the caller's input and stop
210
+ * fingerprinting on the query text: it is raised only from qmd's OWN captured
211
+ * streams and its message names the SUBCOMMAND only.
212
+ */
213
+ export class QmdQueryDocumentError extends QmdExitError {
214
+ name = 'QmdQueryDocumentError';
215
+ }
216
+ /**
217
+ * A qmd MODEL DOWNLOAD did not finish: qmd crashed with ENOENT while renaming its
218
+ * own partial `.ipull` download file into place. The partial file was gone by the
219
+ * time the rename ran — the signature of ANOTHER qmd process on the same box
220
+ * finishing (or clearing) the same download first. That is a transient, retryable
221
+ * LOCAL-CACHE race, not a bug HQ can fix in code: hq cannot coordinate qmd
222
+ * children other users start on the same host, and reaching into qmd's model
223
+ * cache would be hq owning upstream state.
224
+ *
225
+ * HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, the newest event, on release
226
+ * 5.107.1): `qmd query` reached its reranking stage and its reranker-model
227
+ * downloader died uncaught with `[Error: ENOENT: no such file or directory,
228
+ * rename '<cache>/…-reranker-….gguf.ipull' -> '<cache>/…-reranker-….gguf']`.
229
+ * With no classifier it took the identical fall-through to capture as the
230
+ * query-document crash — and because both share the (QmdExitError, qmd:query,
231
+ * exit:1) fingerprint, a query-document-only fix would leave this issue alive.
232
+ * This typed subclass lets the boundary classify it and stop the capture: it is
233
+ * raised only from qmd's OWN captured streams and its message names the
234
+ * SUBCOMMAND only.
235
+ */
236
+ export class QmdModelDownloadError extends QmdExitError {
237
+ name = 'QmdModelDownloadError';
238
+ }
192
239
  function isExecutable(candidate) {
193
240
  try {
194
241
  fs.accessSync(candidate, fs.constants.X_OK);
@@ -1036,6 +1083,35 @@ function finishRunQmd(result, bin, args) {
1036
1083
  const ensure = lastQmdStoreEnsure;
1037
1084
  throw new QmdStoreUnopenableError(`qmd ${subcommand} could not run: its local search store could not be opened`, args, normalized.status, normalized.stdout, normalized.stderr, ensure?.dir, ensure && !ensure.writable ? ensure.reason : undefined);
1038
1085
  }
1086
+ // qmd's `query` subcommand rejected the positional as a MALFORMED QUERY
1087
+ // DOCUMENT: qmd 2.5.3 parses a multi-line positional as a document and requires
1088
+ // every non-empty line to carry a lex:/vec:/hyde:/intent:/expand: prefix, so a
1089
+ // multi-line free-text query (a fleet agent forwarding a whole Slack message)
1090
+ // exits 1 with a syntax diagnostic. That is the caller's INPUT SHAPE, not an
1091
+ // hq-cli defect. Read qmd's OWN captured streams only, never the synthesized
1092
+ // message, so a user query that merely contains the wording can never trip it.
1093
+ // hq-cli side: buildSearchArgs now normalises free text to one line so this no
1094
+ // longer fires for the common case; this branch types a genuinely malformed
1095
+ // structured document so the boundary prints a query-free remedy and skips
1096
+ // capture. Placed AFTER the store checks and BEFORE the collection-missing
1097
+ // regex; the wordings are disjoint (that regex needs collection/qmd:// adjacent
1098
+ // to a not-found token, absent here), so no existing branch changes behaviour
1099
+ // (HQ-CLI-1B / Sentry 7705711476).
1100
+ if (isQmdQueryDocumentError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
1101
+ throw new QmdQueryDocumentError(`qmd ${subcommand} could not run: the query was read as a malformed query document`, args, normalized.status, normalized.stdout, normalized.stderr);
1102
+ }
1103
+ // A qmd MODEL DOWNLOAD died with ENOENT renaming its own partial `.ipull` file
1104
+ // — the signature of another qmd process on the same box finishing or clearing
1105
+ // the same download first. That is a transient, retryable LOCAL-CACHE race, not
1106
+ // an hq-cli defect (hq cannot coordinate qmd children other users start on the
1107
+ // host). Require BOTH the ENOENT-rename signature AND the `.ipull` marker so a
1108
+ // durable failure (ENOSPC/EACCES/network) keeps its reportable path. Read qmd's
1109
+ // OWN captured streams only. Placed with the query-document check, before the
1110
+ // collection-missing regex; the signatures are disjoint, so no existing branch
1111
+ // changes behaviour (HQ-CLI-1B / Sentry 7705711476, the newest event).
1112
+ if (isQmdModelDownloadError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
1113
+ throw new QmdModelDownloadError(`qmd ${subcommand} could not run: a model download did not finish (its partial file disappeared)`, args, normalized.status, normalized.stdout, normalized.stderr);
1114
+ }
1039
1115
  if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(classifyText)) {
1040
1116
  throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
1041
1117
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Atomic owner-only file writes with symlink and path guards for work-context.
3
+ */
4
+ /**
5
+ * Resolve symlinks on an existing path; for a missing leaf, resolve the parent
6
+ * and re-append the basename so we never rename onto a symlink.
7
+ */
8
+ export declare function resolveRealTarget(target: string): string;
9
+ /**
10
+ * Ensure parent dir exists as a real directory (not a symlink), mode 0700.
11
+ */
12
+ export declare function ensureOwnerDir(dir: string): string;
13
+ /**
14
+ * Atomically replace target with data at mode 0600 (same-dir tmp + rename).
15
+ */
16
+ export declare function atomicWriteFile(target: string, data: string | Buffer): void;
17
+ export declare function atomicWriteJson(target: string, value: unknown): void;
18
+ //# sourceMappingURL=atomic.d.ts.map
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Atomic owner-only file writes with symlink and path guards for work-context.
3
+ */
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { UnsafeConfigPathError } from "./errors.js";
7
+ function fsyncDir(dir) {
8
+ try {
9
+ const fd = fs.openSync(dir, "r");
10
+ try {
11
+ fs.fsyncSync(fd);
12
+ }
13
+ finally {
14
+ fs.closeSync(fd);
15
+ }
16
+ }
17
+ catch {
18
+ // Best-effort: some platforms reject directory fsync.
19
+ }
20
+ }
21
+ /**
22
+ * Resolve symlinks on an existing path; for a missing leaf, resolve the parent
23
+ * and re-append the basename so we never rename onto a symlink.
24
+ */
25
+ export function resolveRealTarget(target) {
26
+ try {
27
+ return fs.realpathSync(target);
28
+ }
29
+ catch {
30
+ // Leaf may not exist yet.
31
+ }
32
+ let cur = path.dirname(target);
33
+ const base = path.basename(target);
34
+ for (let i = 0; i < 40; i += 1) {
35
+ try {
36
+ const lst = fs.lstatSync(cur);
37
+ if (lst.isSymbolicLink()) {
38
+ throw new UnsafeConfigPathError(`Refusing write through symlink: ${cur}`);
39
+ }
40
+ const real = fs.realpathSync(cur);
41
+ return path.join(real, base);
42
+ }
43
+ catch (err) {
44
+ if (err instanceof UnsafeConfigPathError)
45
+ throw err;
46
+ const parent = path.dirname(cur);
47
+ if (parent === cur)
48
+ break;
49
+ cur = parent;
50
+ }
51
+ }
52
+ return target;
53
+ }
54
+ /**
55
+ * Ensure parent dir exists as a real directory (not a symlink), mode 0700.
56
+ */
57
+ export function ensureOwnerDir(dir) {
58
+ const resolved = (() => {
59
+ try {
60
+ if (fs.existsSync(dir)) {
61
+ const lst = fs.lstatSync(dir);
62
+ if (lst.isSymbolicLink()) {
63
+ throw new UnsafeConfigPathError(`Refusing directory that is a symlink: ${dir}`);
64
+ }
65
+ return fs.realpathSync(dir);
66
+ }
67
+ }
68
+ catch (err) {
69
+ if (err instanceof UnsafeConfigPathError)
70
+ throw err;
71
+ }
72
+ return dir;
73
+ })();
74
+ fs.mkdirSync(resolved, { recursive: true, mode: 0o700 });
75
+ try {
76
+ fs.chmodSync(resolved, 0o700);
77
+ }
78
+ catch {
79
+ // ignore chmod failures on exotic FS
80
+ }
81
+ const lst = fs.lstatSync(resolved);
82
+ if (lst.isSymbolicLink()) {
83
+ throw new UnsafeConfigPathError(`Refusing directory that is a symlink: ${resolved}`);
84
+ }
85
+ return fs.realpathSync(resolved);
86
+ }
87
+ /**
88
+ * Atomically replace target with data at mode 0600 (same-dir tmp + rename).
89
+ */
90
+ export function atomicWriteFile(target, data) {
91
+ const realTarget = resolveRealTarget(target);
92
+ const dir = ensureOwnerDir(path.dirname(realTarget));
93
+ const finalPath = path.join(dir, path.basename(realTarget));
94
+ const tmp = path.join(dir, `.${path.basename(finalPath)}.tmp.${process.pid}.${Date.now()}`);
95
+ let fd;
96
+ try {
97
+ fd = fs.openSync(tmp, "wx", 0o600);
98
+ fs.writeSync(fd, typeof data === "string" ? Buffer.from(data, "utf8") : data);
99
+ fs.fsyncSync(fd);
100
+ fs.closeSync(fd);
101
+ fd = undefined;
102
+ fs.renameSync(tmp, finalPath);
103
+ fsyncDir(dir);
104
+ try {
105
+ fs.chmodSync(finalPath, 0o600);
106
+ }
107
+ catch {
108
+ // ignore
109
+ }
110
+ }
111
+ catch (err) {
112
+ if (fd !== undefined) {
113
+ try {
114
+ fs.closeSync(fd);
115
+ }
116
+ catch {
117
+ /* already closed */
118
+ }
119
+ }
120
+ try {
121
+ fs.unlinkSync(tmp);
122
+ }
123
+ catch {
124
+ /* temp may not exist */
125
+ }
126
+ throw err;
127
+ }
128
+ }
129
+ export function atomicWriteJson(target, value) {
130
+ atomicWriteFile(target, `${JSON.stringify(value, null, 2)}\n`);
131
+ }
132
+ //# sourceMappingURL=atomic.js.map
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Company precedence for work-context reconcile (US-007A).
3
+ *
4
+ * Order:
5
+ * 1. trusted explicit (HQ_SPAWN_COMPANY env or observation.trustedContext/explicit)
6
+ * 2. existing authoritative session scope
7
+ * 3. session meta.yaml company_slug (/startwork)
8
+ * 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
9
+ * trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
10
+ * above device default)
11
+ * 5. enabled device default
12
+ * 6. exactly one deterministic mapping (cwd under companies/{slug}/ or repo remote)
13
+ *
14
+ * Prompt-text matching is never used.
15
+ * Device default + conflicting deterministic evidence → company_conflict.
16
+ */
17
+ import type { TrustedExplicitContext } from "./contract.js";
18
+ import { type SessionStateFile } from "./state.js";
19
+ export type CompanyResolutionSource = "trusted_explicit" | "existing_scope" | "session_meta" | "device_default" | "deterministic_cwd" | "deterministic_remote";
20
+ export interface ResolvedCompany {
21
+ slug?: string;
22
+ uid?: string;
23
+ source: CompanyResolutionSource;
24
+ }
25
+ /** Pointer to the US-017B correction client (never auto-switches). */
26
+ export declare const COMPANY_CORRECTION_PATH_PREFIX = "hq mesh context correct --session";
27
+ export declare function companyCorrectionPath(sessionId: string): string;
28
+ export type CompanyResolution = {
29
+ status: "resolved";
30
+ company: ResolvedCompany;
31
+ } | {
32
+ status: "needs_company";
33
+ } | {
34
+ status: "company_conflict";
35
+ defaultCompany: ResolvedCompany;
36
+ evidenceCompany: ResolvedCompany;
37
+ /** Present when a prior binding exists and evidence names another company. */
38
+ correctionPath?: string;
39
+ /** True when conflict is after an existing binding (cannot auto-switch). */
40
+ afterBinding?: boolean;
41
+ };
42
+ export interface CompanyResolveInput {
43
+ root: string;
44
+ sessionId: string;
45
+ env?: NodeJS.ProcessEnv;
46
+ /** Trusted explicit from observation (trustedContext or explicit). */
47
+ trusted?: TrustedExplicitContext & {
48
+ companySlug?: string;
49
+ };
50
+ cwd?: string;
51
+ hqRoot?: string;
52
+ /** Optional preloaded state (tests). */
53
+ existingState?: SessionStateFile | null;
54
+ /** Optional injected meta company_slug. */
55
+ metaCompanySlug?: string | null;
56
+ /** Optional injected deterministic remote ownership slug. */
57
+ remoteOwnerSlug?: string | null;
58
+ }
59
+ /** Env naming the on-box identity.json (fleet agent boxes). */
60
+ export declare const HQ_AGENT_IDENTITY_FILE_ENV = "HQ_AGENT_IDENTITY_FILE";
61
+ /**
62
+ * Read `companyUid` from the agent identity file when
63
+ * `HQ_AGENT_IDENTITY_FILE` is set. Missing/unreadable/malformed → undefined
64
+ * (never throws; never logs file contents).
65
+ */
66
+ export declare function readAgentIdentityCompanyUid(env?: NodeJS.ProcessEnv): string | undefined;
67
+ /**
68
+ * Compare companies: UIDs when both sides have them; otherwise normalized
69
+ * slugs when both have slugs. Never treat uid+slug vs slug-only as a
70
+ * conflict when the slugs match (verified scope under companies/{slug}/).
71
+ */
72
+ export declare function sameCompany(a: {
73
+ slug?: string;
74
+ uid?: string;
75
+ }, b: {
76
+ slug?: string;
77
+ uid?: string;
78
+ }): boolean;
79
+ /**
80
+ * Extract company slug from a cwd path containing `companies/{slug}/`.
81
+ */
82
+ export declare function companySlugFromCwd(cwd: string | undefined, hqRoot?: string): string | undefined;
83
+ /**
84
+ * Extract project id from cwd under `companies/{slug}/projects/{project}/`
85
+ * (US-007B deterministic mapping). Never invents a project.
86
+ */
87
+ export declare function projectIdFromCwd(cwd: string | undefined, hqRoot?: string): string | undefined;
88
+ /**
89
+ * Read company_slug from workspace/sessions/<sid>/meta.yaml (best-effort, no yaml dep).
90
+ */
91
+ export declare function readMetaCompanySlug(sessionId: string, hqRoot: string | undefined): string | undefined;
92
+ /**
93
+ * Deterministic mapping: exactly one of cwd companies/{slug} or remote owner.
94
+ * Multiple distinct signals that disagree → no deterministic mapping (needs_company
95
+ * unless a higher-precedence source exists). Matching signals collapse to one.
96
+ */
97
+ export declare function resolveDeterministicCompany(input: {
98
+ cwd?: string;
99
+ hqRoot?: string;
100
+ remoteOwnerSlug?: string | null;
101
+ }): ResolvedCompany | null;
102
+ export declare function resolveCompany(input: CompanyResolveInput): CompanyResolution;
103
+ //# sourceMappingURL=company.d.ts.map