@indigoai-us/hq-cli 5.345.147 → 5.345.149

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 (168) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +2 -0
  3. package/assets/bot-workers/setup/worker.yaml +11 -0
  4. package/dist/command-catalog.generated.d.ts +341 -11
  5. package/dist/command-catalog.generated.js +421 -11
  6. package/dist/command-help.js +2 -1
  7. package/dist/commands/access.d.ts +45 -2
  8. package/dist/commands/access.js +537 -223
  9. package/dist/commands/agent-inbox.js +2 -1
  10. package/dist/commands/billing-usage.d.ts +33 -2
  11. package/dist/commands/billing-usage.js +75 -4
  12. package/dist/commands/bot.js +2 -2
  13. package/dist/commands/channels.d.ts +27 -0
  14. package/dist/commands/channels.js +108 -4
  15. package/dist/commands/cloud.js +31 -1
  16. package/dist/commands/core-refresh-vault-access-native.d.ts +33 -0
  17. package/dist/commands/core-refresh-vault-access-native.js +356 -28
  18. package/dist/commands/core-work-mesh-bind-trusted-native.d.ts +9 -0
  19. package/dist/commands/core-work-mesh-bind-trusted-native.js +26 -19
  20. package/dist/commands/core.js +9 -0
  21. package/dist/commands/daemon.d.ts +7 -0
  22. package/dist/commands/daemon.js +37 -3
  23. package/dist/commands/dm.js +18 -3
  24. package/dist/commands/doctor.d.ts +11 -1
  25. package/dist/commands/doctor.js +56 -1
  26. package/dist/commands/install-global.d.ts +8 -8
  27. package/dist/commands/install-global.js +139 -61
  28. package/dist/commands/lanes-shared.d.ts +3 -1
  29. package/dist/commands/lanes.d.ts +4 -4
  30. package/dist/commands/lanes.js +380 -25
  31. package/dist/commands/library-add.d.ts +31 -1
  32. package/dist/commands/library-add.js +69 -2
  33. package/dist/commands/library.js +37 -3
  34. package/dist/commands/pack-install.d.ts +2 -2
  35. package/dist/commands/pack-install.js +133 -35
  36. package/dist/commands/reindex.js +19 -0
  37. package/dist/commands/sync-manifest.d.ts +2 -9
  38. package/dist/commands/sync-manifest.js +4 -15
  39. package/dist/hook-engine-path.d.ts +4 -0
  40. package/dist/hook-engine-path.js +43 -0
  41. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2783 -2354
  42. package/dist/hq-cloud-sync-runner/package.json +1 -1
  43. package/dist/index.d.ts +10 -0
  44. package/dist/index.js +76 -5
  45. package/dist/lazy-commands.js +2 -0
  46. package/dist/lib/agent-kit/inbox-redact.d.ts +44 -0
  47. package/dist/lib/agent-kit/inbox-redact.js +361 -0
  48. package/dist/lib/agent-kit/inbox-state.js +11 -2
  49. package/dist/lib/agent-kit/mcp/tools.js +2 -1
  50. package/dist/lib/agent-kit/run/inbox.js +36 -7
  51. package/dist/lib/anywhere-runtime-flag-key.d.ts +3 -0
  52. package/dist/lib/anywhere-runtime-flag-key.js +9 -0
  53. package/dist/lib/billing/usage-cost.d.ts +31 -1
  54. package/dist/lib/billing/usage-cost.js +46 -0
  55. package/dist/lib/company-skill-wrapper.js +6 -4
  56. package/dist/lib/daemon/install.d.ts +47 -1
  57. package/dist/lib/daemon/install.js +181 -15
  58. package/dist/lib/daemon/lock.d.ts +32 -0
  59. package/dist/lib/daemon/lock.js +55 -2
  60. package/dist/lib/db/local.d.ts +2 -2
  61. package/dist/lib/db/local.js +2 -2
  62. package/dist/lib/db/sql.js +2 -2
  63. package/dist/lib/db/sqlite-binding.d.ts +22 -0
  64. package/dist/lib/db/sqlite-binding.js +168 -0
  65. package/dist/lib/doctor/__testing__/run-doctor.js +4 -0
  66. package/dist/lib/doctor/checks/anywhere.d.ts +15 -0
  67. package/dist/lib/doctor/checks/anywhere.js +77 -11
  68. package/dist/lib/doctor/checks/cli-path.d.ts +12 -0
  69. package/dist/lib/doctor/checks/cli-path.js +46 -0
  70. package/dist/lib/doctor/checks/hook-engine.d.ts +4 -0
  71. package/dist/lib/doctor/checks/hook-engine.js +17 -0
  72. package/dist/lib/doctor/checks/sync-health.d.ts +7 -0
  73. package/dist/lib/doctor/checks/sync-health.js +38 -2
  74. package/dist/lib/doctor/checks/temp-homes.d.ts +19 -0
  75. package/dist/lib/doctor/checks/temp-homes.js +54 -0
  76. package/dist/lib/doctor/fix/apply.d.ts +90 -1
  77. package/dist/lib/doctor/fix/apply.js +176 -10
  78. package/dist/lib/doctor/fix/json-output.d.ts +4 -1
  79. package/dist/lib/doctor/fix/json-output.js +6 -0
  80. package/dist/lib/doctor/fix/remediation.d.ts +7 -1
  81. package/dist/lib/doctor/fix/remediation.js +23 -1
  82. package/dist/lib/doctor/registry.js +10 -0
  83. package/dist/lib/doctor/temp-home-sweeper.d.ts +149 -0
  84. package/dist/lib/doctor/temp-home-sweeper.js +418 -0
  85. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  86. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  87. package/dist/lib/flag-registry.js +9 -1
  88. package/dist/lib/flag-snapshot-file.js +8 -3
  89. package/dist/lib/hook-engine/reconcile.d.ts +9 -0
  90. package/dist/lib/hook-engine/reconcile.js +134 -0
  91. package/dist/lib/hook-engine/registration.d.ts +17 -0
  92. package/dist/lib/hook-engine/registration.js +50 -0
  93. package/dist/lib/hook-engine/resolve.d.ts +31 -0
  94. package/dist/lib/hook-engine/resolve.js +134 -0
  95. package/dist/lib/install/claude-targets.d.ts +2 -1
  96. package/dist/lib/install/claude-targets.js +8 -3
  97. package/dist/lib/install/codex-targets.d.ts +3 -0
  98. package/dist/lib/install/codex-targets.js +15 -4
  99. package/dist/lib/install/hook-target.d.ts +9 -0
  100. package/dist/lib/install/hook-target.js +39 -0
  101. package/dist/lib/lanes/admission-host.d.ts +1 -0
  102. package/dist/lib/lanes/admission-host.js +8 -1
  103. package/dist/lib/lanes/admission.d.ts +4 -0
  104. package/dist/lib/lanes/admission.js +6 -2
  105. package/dist/lib/lanes/envelope-repair-dispatch.d.ts +5 -0
  106. package/dist/lib/lanes/envelope-repair-dispatch.js +12 -1
  107. package/dist/lib/lanes/index.d.ts +2 -2
  108. package/dist/lib/lanes/index.js +1 -1
  109. package/dist/lib/lanes/keep-alive.js +4 -1
  110. package/dist/lib/lanes/local-host-identity.d.ts +40 -0
  111. package/dist/lib/lanes/local-host-identity.js +314 -0
  112. package/dist/lib/lanes/reaper.d.ts +1 -1
  113. package/dist/lib/lanes/reaper.js +7 -0
  114. package/dist/lib/lanes/reconcile.d.ts +4 -0
  115. package/dist/lib/lanes/reconcile.js +16 -8
  116. package/dist/lib/lanes/spawn.d.ts +20 -0
  117. package/dist/lib/lanes/spawn.js +48 -2
  118. package/dist/lib/lanes/status-fields.js +6 -1
  119. package/dist/lib/lanes/store.js +8 -1
  120. package/dist/lib/lanes/throttle.d.ts +49 -6
  121. package/dist/lib/lanes/throttle.js +154 -17
  122. package/dist/lib/lanes/types.d.ts +22 -1
  123. package/dist/lib/lanes/types.js +27 -3
  124. package/dist/lib/lanes/watcher.js +27 -2
  125. package/dist/lib/lanes/worker-continuity.d.ts +2 -0
  126. package/dist/lib/lanes/worker-continuity.js +18 -11
  127. package/dist/lib/manifest-disabled.d.ts +13 -0
  128. package/dist/lib/manifest-disabled.js +19 -0
  129. package/dist/lib/service-manager/launchd.d.ts +1 -0
  130. package/dist/lib/service-manager/launchd.js +1 -1
  131. package/dist/lib/windows/console-encoding.d.ts +24 -0
  132. package/dist/lib/windows/console-encoding.js +83 -0
  133. package/dist/lib/windows/install-followup.d.ts +35 -0
  134. package/dist/lib/windows/install-followup.js +142 -0
  135. package/dist/lib/windows/user-path.d.ts +125 -0
  136. package/dist/lib/windows/user-path.js +275 -0
  137. package/dist/lib/work-context/auto-bind.d.ts +1 -1
  138. package/dist/lib/work-context/auto-bind.js +22 -3
  139. package/dist/lib/work-context/lane-bind.d.ts +31 -0
  140. package/dist/lib/work-context/lane-bind.js +101 -0
  141. package/dist/lib/work-context/organize.js +1 -0
  142. package/dist/lib/work-context/prd-sync.js +7 -0
  143. package/dist/lib/work-context/project.d.ts +36 -10
  144. package/dist/lib/work-context/project.js +130 -41
  145. package/dist/lib/work-context/reconcile.d.ts +6 -0
  146. package/dist/lib/work-context/reconcile.js +25 -5
  147. package/dist/lib/work-context/state.d.ts +6 -0
  148. package/dist/lib/work-context/state.js +29 -1
  149. package/dist/main.js +2 -1
  150. package/dist/unknown-command.d.ts +18 -0
  151. package/dist/unknown-command.js +88 -0
  152. package/dist/utils/cognito-session.d.ts +8 -0
  153. package/dist/utils/cognito-session.js +14 -2
  154. package/dist/utils/copy-tree.d.ts +61 -0
  155. package/dist/utils/copy-tree.js +249 -0
  156. package/dist/utils/environmental-error.js +7 -3
  157. package/dist/utils/hook-trust.d.ts +9 -0
  158. package/dist/utils/hook-trust.js +30 -2
  159. package/dist/utils/npm-invocation.d.ts +27 -0
  160. package/dist/utils/npm-invocation.js +54 -0
  161. package/dist/utils/qmd-native-binding-error.js +3 -1
  162. package/dist/utils/resolve-executable.d.ts +2 -0
  163. package/dist/utils/resolve-executable.js +6 -2
  164. package/dist/utils/safe-link.d.ts +50 -0
  165. package/dist/utils/safe-link.js +300 -0
  166. package/package.json +17 -5
  167. package/scripts/hook-engine-manifest.mjs +22 -0
  168. package/scripts/install-hook-engine.mjs +10 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Is the npm global shim folder (where `hq.cmd` lives) on the Windows PATH?
3
+ *
4
+ * npm writes `hq.cmd` directly under its prefix (never `<prefix>\bin`). When
5
+ * that folder is not on the user or machine Path, a new terminal cannot find
6
+ * `hq` even though the install succeeded. The supported install adds that
7
+ * folder to the user PATH (HKCU only) and broadcasts the change. It never
8
+ * edits the machine PATH. When the add fails, doctor prints the exact command.
9
+ *
10
+ * Windows PowerShell 5.1 and reg.exe print piped output in the console code
11
+ * page, so a Path holding `C:\Users\José` comes back garbled when read as
12
+ * UTF-8. Reads therefore return the value as base64 of its UTF-16LE bytes, and
13
+ * the append is one PowerShell script that reads, checks, and writes the value
14
+ * without it ever leaving PowerShell.
15
+ */
16
+ import { spawnSync } from "node:child_process";
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ /** The longest user Path HQ will write. Longer values break `setx` and some installers. */
20
+ export const MAX_USER_PATH_LENGTH = 2047;
21
+ const REPLACEMENT_CHAR = "\uFFFD";
22
+ /**
23
+ * Tests on Windows point the scripts at a scratch key with this variable. The
24
+ * scripts accept only keys under HKCU:\Software\HQTest\ and skip the
25
+ * broadcast for them.
26
+ */
27
+ export const USER_PATH_TEST_KEY_ENV = "HQ_USER_PATH_TEST_KEY";
28
+ /** The npm prefix folder above the package's `node_modules`, when `hq.cmd` is there. */
29
+ export function npmShimDir(entry, exists) {
30
+ if (!entry)
31
+ return null;
32
+ const parts = path.win32.normalize(entry).split("\\");
33
+ const nm = parts.map((p) => p.toLowerCase()).lastIndexOf("node_modules");
34
+ if (nm <= 0)
35
+ return null;
36
+ const dir = parts.slice(0, nm).join("\\");
37
+ return exists(path.win32.join(dir, "hq.cmd")) ? dir : null;
38
+ }
39
+ /** Expand `%NAME%` references the way Windows does, matching names case-insensitively. */
40
+ export function expandWindowsEnv(value, env) {
41
+ return value.replace(/%([^%;]+)%/g, (whole, name) => {
42
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
43
+ const found = key === undefined ? undefined : env[key];
44
+ return found === undefined ? whole : found;
45
+ });
46
+ }
47
+ const SELECT_USER_KEY = [
48
+ "$key = 'HKCU:\\Environment'",
49
+ `$test = $env:${USER_PATH_TEST_KEY_ENV}`,
50
+ "if ($test) { if (-not $test.StartsWith('HKCU:\\Software\\HQTest\\', [StringComparison]::OrdinalIgnoreCase)) { exit 2 }; $key = $test }",
51
+ ];
52
+ /**
53
+ * Prints `U:<base64>` for the unexpanded user Path and `M:<base64>` for the
54
+ * machine Path, base64 of the UTF-16LE bytes, or `U-` / `M-` when a value
55
+ * cannot be read. Base64 is ASCII, so the console code page cannot change it.
56
+ */
57
+ export const READ_PATHS_SCRIPT = [
58
+ "$ErrorActionPreference = 'Stop'",
59
+ ...SELECT_USER_KEY,
60
+ "function Emit($tag, $k) { $i = Get-Item -LiteralPath $k -ErrorAction SilentlyContinue; if ($null -eq $i) { return $tag + '-' }; $v = $i.GetValue('Path', $null, 'DoNotExpandEnvironmentNames'); if ($v -isnot [string]) { return $tag + '-' }; return $tag + ':' + [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($v)) }",
61
+ "Write-Output (Emit 'U' $key)",
62
+ "Write-Output (Emit 'M' 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment')",
63
+ ].join("\n");
64
+ /** Decode READ_PATHS_SCRIPT output. Null for a value that could not be read. */
65
+ export function parseReadPathsOutput(stdout) {
66
+ const out = { user: null, machine: null };
67
+ for (const line of stdout.split(/\r?\n/)) {
68
+ const match = /^([UM]):([A-Za-z0-9+/=]*)$/.exec(line.trim());
69
+ if (!match)
70
+ continue;
71
+ const value = Buffer.from(match[2], "base64").toString("utf16le");
72
+ if (match[1] === "U")
73
+ out.user = value;
74
+ else
75
+ out.machine = value;
76
+ }
77
+ return out;
78
+ }
79
+ /**
80
+ * The PATH a new terminal will get: the user Path then the machine Path, as
81
+ * stored in the registry. Falls back to this process's PATH when neither can
82
+ * be read.
83
+ */
84
+ export function readPersistedPath(run, env) {
85
+ let values = [];
86
+ try {
87
+ const result = run(READ_PATHS_SCRIPT, {});
88
+ if (result.status === 0) {
89
+ const read = parseReadPathsOutput(result.stdout);
90
+ values = [read.user, read.machine].filter((v) => v !== null);
91
+ }
92
+ }
93
+ catch {
94
+ // PowerShell missing or refused: treat as unreadable.
95
+ }
96
+ if (values.length === 0)
97
+ return { value: env.PATH ?? env.Path ?? "", source: "process" };
98
+ return { value: values.map((v) => expandWindowsEnv(v, env)).join(";"), source: "registry" };
99
+ }
100
+ function normalizeDir(dir) {
101
+ const unquoted = dir.trim().replace(/^"(.*)"$/, "$1").trim();
102
+ if (!unquoted)
103
+ return "";
104
+ return path.win32.normalize(unquoted).replace(/[\\/]+$/, "").toLowerCase();
105
+ }
106
+ export function isDirOnPath(dir, pathValue) {
107
+ const want = normalizeDir(dir);
108
+ return pathValue.split(";").some((entry) => normalizeDir(entry) === want);
109
+ }
110
+ /** Runs a script through Windows PowerShell. The script travels as -EncodedCommand (UTF-16LE), so quoting cannot change it. */
111
+ function defaultPowerShell(script, extraEnv) {
112
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
113
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
114
+ encoding: "utf8",
115
+ windowsHide: true,
116
+ timeout: 15_000,
117
+ env: { ...process.env, ...extraEnv },
118
+ });
119
+ return { status: result.status, stdout: result.stdout ?? "" };
120
+ }
121
+ /** Null when not on Windows or when hq was not installed as an npm global shim. */
122
+ export function checkShimOnPath(deps = {}) {
123
+ if ((deps.platform ?? process.platform) !== "win32")
124
+ return null;
125
+ const shimDir = npmShimDir(deps.entry ?? process.argv[1], deps.exists ?? fs.existsSync);
126
+ if (!shimDir)
127
+ return null;
128
+ const persisted = readPersistedPath(deps.powerShell ?? defaultPowerShell, deps.env ?? process.env);
129
+ return { shimDir, onPath: isDirOnPath(shimDir, persisted.value), source: persisted.source };
130
+ }
131
+ /** Dummy user variable set and cleared so Windows tells running programs that the environment changed. */
132
+ export const PATH_REFRESH_VAR = "HQ_PATH_REFRESH";
133
+ /**
134
+ * A PowerShell command that appends `dir` to the user Path, keeping the value
135
+ * as REG_EXPAND_SZ so existing `%VAR%` entries still expand. Writing the
136
+ * registry alone does not notify Explorer, so terminals it opens would keep the
137
+ * old PATH until the next sign-in. Setting and clearing a dummy user variable
138
+ * through [Environment]::SetEnvironmentVariable broadcasts WM_SETTINGCHANGE.
139
+ */
140
+ export function pathFixCommand(dir) {
141
+ const quoted = dir.replace(/'/g, "''");
142
+ return ("$p = (Get-Item HKCU:\\Environment).GetValue('Path','','DoNotExpandEnvironmentNames'); " +
143
+ "New-ItemProperty -Path HKCU:\\Environment -Name Path -PropertyType ExpandString -Force " +
144
+ `-Value (($p.TrimEnd(';') + ';${quoted}').TrimStart(';')); ` +
145
+ `[Environment]::SetEnvironmentVariable('${PATH_REFRESH_VAR}','1','User'); ` +
146
+ `[Environment]::SetEnvironmentVariable('${PATH_REFRESH_VAR}',$null,'User')`);
147
+ }
148
+ /** Lines to print when the shim folder is missing from PATH; empty when it is present. */
149
+ export function shimPathWarning(check) {
150
+ if (check.onPath)
151
+ return [];
152
+ return [
153
+ `The folder that holds the hq command (${check.shimDir}) is not on your Windows PATH, so a new terminal will not find hq.`,
154
+ "HQ could not add it automatically. To add it for your user, run this in PowerShell, then open a new terminal:",
155
+ ` ${pathFixCommand(check.shimDir)}`,
156
+ ];
157
+ }
158
+ /**
159
+ * Decide the new unexpanded user Path. Membership is decided on the expanded
160
+ * value so `%APPDATA%\npm` counts. The stored text keeps `%VARS%`. A value
161
+ * that already holds U+FFFD was garbled by an earlier decode and is never
162
+ * written back. The ensure script applies the same rules inside PowerShell.
163
+ */
164
+ export function planUserPathAppend(current, dir, env) {
165
+ if (isDirOnPath(dir, expandWindowsEnv(current, env)))
166
+ return { action: "skip", reason: "present" };
167
+ if (current.includes(REPLACEMENT_CHAR) || dir.includes(REPLACEMENT_CHAR) || dir.includes(";")) {
168
+ return { action: "skip", reason: "unsafe-value" };
169
+ }
170
+ const trimmed = current.trim().replace(/;+$/g, "");
171
+ const value = trimmed ? `${trimmed};${dir}` : dir;
172
+ if (value.length > MAX_USER_PATH_LENGTH)
173
+ return { action: "skip", reason: "too-long" };
174
+ return { action: "write", value };
175
+ }
176
+ /** Exit codes of ENSURE_USER_PATH_SCRIPT. */
177
+ export const ENSURE_EXIT = {
178
+ written: 0,
179
+ badInput: 2,
180
+ unreadable: 3,
181
+ unsafeValue: 4,
182
+ tooLong: 5,
183
+ writtenNoBroadcast: 6,
184
+ present: 10,
185
+ };
186
+ /**
187
+ * Appends $env:HQ_SHIM_DIR to the HKCU Path as REG_EXPAND_SZ, then broadcasts
188
+ * the change. The value is read, checked, and written inside this script, so
189
+ * no console encoding touches it. Mirrors planUserPathAppend.
190
+ */
191
+ export const ENSURE_USER_PATH_SCRIPT = [
192
+ "$ErrorActionPreference = 'Stop'",
193
+ "$dir = $env:HQ_SHIM_DIR",
194
+ `if ([string]::IsNullOrWhiteSpace($dir) -or $dir.Contains(';') -or $dir.Contains([string][char]0xFFFD)) { exit ${ENSURE_EXIT.badInput} }`,
195
+ ...SELECT_USER_KEY,
196
+ "$item = Get-Item -LiteralPath $key -ErrorAction SilentlyContinue",
197
+ `if ($null -eq $item) { exit ${ENSURE_EXIT.unreadable} }`,
198
+ "$p = $item.GetValue('Path', '', 'DoNotExpandEnvironmentNames')",
199
+ `if ($p -isnot [string]) { exit ${ENSURE_EXIT.unsafeValue} }`,
200
+ // Same comparison as normalizeDir: unquote, use backslashes, collapse repeated
201
+ // separators (keeping a UNC prefix), drop trailing ones, ignore case.
202
+ "function Norm($s) { $t = $s.Trim(); if ($t.Length -ge 2 -and $t.StartsWith([string][char]34) -and $t.EndsWith([string][char]34)) { $t = $t.Substring(1, $t.Length - 2).Trim() }; $t = $t.Replace([char]47, [char]92); $bs = [string][char]92; $lead = ''; if ($t.StartsWith($bs + $bs)) { $lead = $bs + $bs; $t = $t.Substring(2) }; $t = $lead + [regex]::Replace($t, ($bs + $bs + '+'), $bs); return $t.TrimEnd([char]92).ToLowerInvariant() }",
203
+ "$want = Norm $dir",
204
+ `foreach ($e in [Environment]::ExpandEnvironmentVariables($p).Split(';')) { if ((Norm $e) -eq $want) { exit ${ENSURE_EXIT.present} } }`,
205
+ `if ($p.Contains([string][char]0xFFFD)) { exit ${ENSURE_EXIT.unsafeValue} }`,
206
+ "$t = $p.Trim().TrimEnd(';')",
207
+ "if ($t) { $new = $t + ';' + $dir } else { $new = $dir }",
208
+ `if ($new.Length -gt ${MAX_USER_PATH_LENGTH}) { exit ${ENSURE_EXIT.tooLong} }`,
209
+ "New-ItemProperty -LiteralPath $key -Name Path -PropertyType ExpandString -Force -Value $new | Out-Null",
210
+ `if (-not $test) { try { [Environment]::SetEnvironmentVariable('${PATH_REFRESH_VAR}','1','User'); [Environment]::SetEnvironmentVariable('${PATH_REFRESH_VAR}',$null,'User') } catch { exit ${ENSURE_EXIT.writtenNoBroadcast} } }`,
211
+ `exit ${ENSURE_EXIT.written}`,
212
+ ].join("\n");
213
+ export function ensureExitReason(status) {
214
+ switch (status) {
215
+ case ENSURE_EXIT.written:
216
+ case ENSURE_EXIT.writtenNoBroadcast:
217
+ return { written: true, reason: "written" };
218
+ case ENSURE_EXIT.present:
219
+ return { written: false, reason: "present" };
220
+ case ENSURE_EXIT.unreadable:
221
+ return { written: false, reason: "unreadable" };
222
+ case ENSURE_EXIT.unsafeValue:
223
+ case ENSURE_EXIT.badInput:
224
+ return { written: false, reason: "unsafe-value" };
225
+ case ENSURE_EXIT.tooLong:
226
+ return { written: false, reason: "too-long" };
227
+ default:
228
+ return { written: false, reason: "write-failed" };
229
+ }
230
+ }
231
+ export function powershellUserPathStore(run = defaultPowerShell, extraEnv = {}) {
232
+ return {
233
+ appendUserPath(dir) {
234
+ try {
235
+ return ensureExitReason(run(ENSURE_USER_PATH_SCRIPT, { ...extraEnv, HQ_SHIM_DIR: dir }).status);
236
+ }
237
+ catch {
238
+ return { written: false, reason: "write-failed" };
239
+ }
240
+ },
241
+ };
242
+ }
243
+ let cachedOnPathDir = null;
244
+ export function resetUserPathEnsureCacheForTests() {
245
+ cachedOnPathDir = null;
246
+ }
247
+ /**
248
+ * Add the npm shim folder to HKCU Path when it is missing. Null off Windows
249
+ * or when this process is not an npm global install. A failed read does not
250
+ * write. Machine Path is never written.
251
+ */
252
+ export function ensureShimOnUserPath(deps = {}) {
253
+ const platform = deps.platform ?? process.platform;
254
+ if (platform !== "win32")
255
+ return null;
256
+ const cacheable = deps.store === undefined && deps.powerShell === undefined && deps.platform === undefined;
257
+ if (cacheable && cachedOnPathDir) {
258
+ return { shimDir: cachedOnPathDir, written: false, onPath: true, source: "registry", reason: "present" };
259
+ }
260
+ const check = checkShimOnPath(deps);
261
+ if (!check)
262
+ return null;
263
+ if (check.onPath) {
264
+ if (cacheable)
265
+ cachedOnPathDir = check.shimDir;
266
+ return { shimDir: check.shimDir, written: false, onPath: true, source: check.source, reason: "present" };
267
+ }
268
+ const store = deps.store ?? powershellUserPathStore(deps.powerShell);
269
+ const applied = store.appendUserPath(check.shimDir);
270
+ const onPath = applied.written || applied.reason === "present";
271
+ if (onPath && cacheable)
272
+ cachedOnPathDir = check.shimDir;
273
+ return { shimDir: check.shimDir, written: applied.written, onPath, source: check.source, reason: applied.reason };
274
+ }
275
+ //# sourceMappingURL=user-path.js.map
@@ -32,7 +32,7 @@ export type AutoBindDecision = {
32
32
  candidates: AutoBindCandidate[];
33
33
  } | {
34
34
  action: "skip";
35
- reason: "no_git" | "no_company" | "no_match" | "ambiguous";
35
+ reason: "no_git" | "no_company" | "no_match" | "ambiguous" | "foreign_lane";
36
36
  candidates: AutoBindCandidate[];
37
37
  };
38
38
  /** Find the git checkout for cwd and its current branch without spawning git. */
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
+ import { foreignSessionOwnsLane } from "./lane-bind.js";
13
14
  function readText(file) {
14
15
  try {
15
16
  return fs.readFileSync(file, "utf8");
@@ -223,17 +224,35 @@ export function decideAutoBind(input) {
223
224
  identities,
224
225
  branch: git.branch,
225
226
  });
226
- if (prior)
227
+ const ownedByOtherSession = (projectId) => foreignSessionOwnsLane({
228
+ hqRoot: input.hqRoot,
229
+ projectId,
230
+ sessionId: state.sessionId,
231
+ companySlug,
232
+ });
233
+ if (prior) {
234
+ if (ownedByOtherSession(prior)) {
235
+ return { action: "skip", reason: "foreign_lane", candidates, ...extra };
236
+ }
227
237
  return { action: "bind", projectId: prior, source: "history", candidates, ...extra };
238
+ }
228
239
  const branchMatches = candidates.filter((c) => c.matchedBranch);
229
240
  if (branchMatches.length === 1) {
230
- return { action: "bind", projectId: branchMatches[0].projectId, source: "repo_branch", candidates, ...extra };
241
+ const projectId = branchMatches[0].projectId;
242
+ if (ownedByOtherSession(projectId)) {
243
+ return { action: "skip", reason: "foreign_lane", candidates, ...extra };
244
+ }
245
+ return { action: "bind", projectId, source: "repo_branch", candidates, ...extra };
231
246
  }
232
247
  if (branchMatches.length > 1) {
233
248
  return { action: "skip", reason: "ambiguous", candidates: branchMatches, ...extra };
234
249
  }
235
250
  if (candidates.length === 1) {
236
- return { action: "bind", projectId: candidates[0].projectId, source: "repo_only", candidates, ...extra };
251
+ const projectId = candidates[0].projectId;
252
+ if (ownedByOtherSession(projectId)) {
253
+ return { action: "skip", reason: "foreign_lane", candidates, ...extra };
254
+ }
255
+ return { action: "bind", projectId, source: "repo_only", candidates, ...extra };
237
256
  }
238
257
  return {
239
258
  action: "skip",
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Lane ownership for automatic project attachment.
3
+ *
4
+ * A session may attach to a project when it is the senior, the provider
5
+ * session, or the creator of an active lane for that project. It must not
6
+ * attach just because a lane exists, the cwd matches, or a binding is recent.
7
+ * Terminal lanes do not count. A missing lane directory does not block.
8
+ */
9
+ export interface ForeignLaneInput {
10
+ hqRoot?: string;
11
+ projectId?: string;
12
+ sessionId?: string;
13
+ /**
14
+ * Company the bind is for. A lane matches only when its company slug
15
+ * matches too. Missing company, or a lane with no company, does not match.
16
+ */
17
+ companySlug?: string;
18
+ /**
19
+ * Lane records already read during this reconcile, keyed by lane directory.
20
+ * Pass the same map to every check in one reconcile so the lane files are
21
+ * read once.
22
+ */
23
+ laneCache?: LaneRecordCache;
24
+ }
25
+ export type LaneRecordCache = Map<string, Record<string, unknown>[] | null>;
26
+ /**
27
+ * True when an active lane for this company and project is owned by a
28
+ * different session and this session does not own or create one.
29
+ */
30
+ export declare function foreignSessionOwnsLane(input: ForeignLaneInput): boolean;
31
+ //# sourceMappingURL=lane-bind.d.ts.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Lane ownership for automatic project attachment.
3
+ *
4
+ * A session may attach to a project when it is the senior, the provider
5
+ * session, or the creator of an active lane for that project. It must not
6
+ * attach just because a lane exists, the cwd matches, or a binding is recent.
7
+ * Terminal lanes do not count. A missing lane directory does not block.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { TERMINAL_LANE_STATES } from "../lanes/types.js";
12
+ const TERMINAL = new Set(TERMINAL_LANE_STATES);
13
+ function asRecord(value) {
14
+ return value && typeof value === "object" && !Array.isArray(value)
15
+ ? value
16
+ : null;
17
+ }
18
+ function sessionOwnsLane(lane, sessionId) {
19
+ const senior = asRecord(lane.senior);
20
+ if (senior?.kind === "session" && senior.id === sessionId)
21
+ return true;
22
+ if (lane.provider_session_id === sessionId)
23
+ return true;
24
+ if (lane.created_by_session === sessionId)
25
+ return true;
26
+ if (lane.created_by === sessionId)
27
+ return true;
28
+ return false;
29
+ }
30
+ function foreignSessionSenior(lane, sessionId) {
31
+ const senior = asRecord(lane.senior);
32
+ return senior?.kind === "session"
33
+ && typeof senior.id === "string"
34
+ && senior.id.length > 0
35
+ && senior.id !== sessionId;
36
+ }
37
+ /** Personal lanes and lanes with no company do not match a company-scoped bind. */
38
+ function laneCompanyMatches(lane, companySlug) {
39
+ const company = asRecord(lane.company);
40
+ const slug = company && typeof company.slug === "string" ? company.slug.trim() : "";
41
+ if (!slug)
42
+ return false;
43
+ return slug.toLowerCase() === companySlug.toLowerCase();
44
+ }
45
+ /**
46
+ * True when an active lane for this company and project is owned by a
47
+ * different session and this session does not own or create one.
48
+ */
49
+ export function foreignSessionOwnsLane(input) {
50
+ const hqRoot = input.hqRoot?.trim();
51
+ const projectId = input.projectId?.trim();
52
+ const sessionId = input.sessionId?.trim();
53
+ const companySlug = input.companySlug?.trim();
54
+ if (!hqRoot || !projectId || !sessionId || !companySlug)
55
+ return false;
56
+ const dir = path.join(hqRoot, "workspace", "lanes", "lanes");
57
+ const lanes = readLaneRecords(dir, input.laneCache);
58
+ if (!lanes)
59
+ return false;
60
+ let foreign = false;
61
+ let mine = false;
62
+ for (const parsed of lanes) {
63
+ if (parsed.project_id !== projectId)
64
+ continue;
65
+ if (!laneCompanyMatches(parsed, companySlug))
66
+ continue;
67
+ if (typeof parsed.state === "string" && TERMINAL.has(parsed.state))
68
+ continue;
69
+ if (sessionOwnsLane(parsed, sessionId))
70
+ mine = true;
71
+ else if (foreignSessionSenior(parsed, sessionId))
72
+ foreign = true;
73
+ }
74
+ return foreign && !mine;
75
+ }
76
+ function readLaneRecords(dir, cache) {
77
+ if (cache?.has(dir))
78
+ return cache.get(dir) ?? null;
79
+ let records;
80
+ try {
81
+ records = [];
82
+ for (const name of fs.readdirSync(dir)) {
83
+ if (!name.endsWith(".json"))
84
+ continue;
85
+ try {
86
+ const parsed = asRecord(JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")));
87
+ if (parsed)
88
+ records.push(parsed);
89
+ }
90
+ catch {
91
+ // An unreadable lane file does not block.
92
+ }
93
+ }
94
+ }
95
+ catch {
96
+ records = null;
97
+ }
98
+ cache?.set(dir, records);
99
+ return records;
100
+ }
101
+ //# sourceMappingURL=lane-bind.js.map
@@ -451,6 +451,7 @@ export async function submitOrganizeDecision(input) {
451
451
  projectId: receipt.projectId,
452
452
  ...(receipt.taskId ? { taskId: receipt.taskId } : {}),
453
453
  bindingEpisodeId: receipt.bindingEpisodeId,
454
+ projectBindAuthority: "trusted",
454
455
  updatedAt: now,
455
456
  // Clear ask — decision resolved.
456
457
  decision: undefined,
@@ -56,6 +56,9 @@ export function rebindSessionToProject(input) {
56
56
  return null;
57
57
  if (current.projectId === input.projectId)
58
58
  return current;
59
+ // An explicit trusted bind stays until the session rebinds explicitly.
60
+ if (current.projectBindAuthority === "trusted")
61
+ return current;
59
62
  return {
60
63
  ...current,
61
64
  projectId: input.projectId,
@@ -64,6 +67,7 @@ export function rebindSessionToProject(input) {
64
67
  ? "needs_task"
65
68
  : current.contextStatus,
66
69
  projectBindReason: `prd-sync project switch to ${input.projectId}`,
70
+ projectBindAuthority: undefined,
67
71
  updatedAt: nowIso,
68
72
  };
69
73
  });
@@ -117,6 +121,9 @@ export function planPrdSync(input) {
117
121
  return empty("different_project");
118
122
  }
119
123
  const targetProjectId = identity.projectId;
124
+ if (state.projectBindAuthority === "trusted" && state.projectId !== targetProjectId) {
125
+ return empty("different_project");
126
+ }
120
127
  const rebindTo = state.projectId !== targetProjectId ? targetProjectId : undefined;
121
128
  let stories;
122
129
  try {
@@ -2,13 +2,21 @@
2
2
  * Project/task resolution for work-context (US-007B).
3
3
  *
4
4
  * Order:
5
- * 1. existing active binding (state bound / needs_task with projectId)
6
- * 2. trusted explicit (HQ_SPAWN_PROJECT/HQ_SPAWN_TASK, observation, meta.yaml)
7
- * 3. exactly one deterministic mapping (cwd under companies/{slug}/projects/{project}/
8
- * or a registry resource that names exactly one project for the repo)
5
+ * 1. explicit trusted bind (observation authority "trusted"; shell alias
6
+ * `project` / `task`). Beats an existing automatic binding and is not
7
+ * refused because another session owns a lane. Refused when the project
8
+ * directory is not under the resolved company.
9
+ * 2. existing active binding (state bound / needs_task with projectId).
10
+ * Ambient HQ_SPAWN_PROJECT and meta.yaml do not override it.
11
+ * 3. trusted explicit (HQ_SPAWN_PROJECT/HQ_SPAWN_TASK, observation projectId,
12
+ * meta.yaml). Meta and the bare `project` alias are refused when another
13
+ * session owns an active lane for that project. Spawn is not.
14
+ * 4. exactly one deterministic mapping (cwd or registry), refused when
15
+ * another session owns an active lane for that project.
9
16
  * Soft evidence never auto-binds — candidates are server-owned (GET .../candidates).
10
17
  */
11
18
  import { type TrustedExplicitContext } from "./contract.js";
19
+ import { type LaneRecordCache } from "./lane-bind.js";
12
20
  import type { SessionStateFile } from "./state.js";
13
21
  export type ProjectResolutionSource = "existing_binding" | "trusted_explicit" | "deterministic_cwd" | "deterministic_registry";
14
22
  export interface ResolvedProjectTask {
@@ -17,16 +25,25 @@ export interface ResolvedProjectTask {
17
25
  source: ProjectResolutionSource;
18
26
  /** True when project+task are both present (trusted or existing bound). */
19
27
  complete: boolean;
28
+ /**
29
+ * Set when the operator or trusted-bind command explicitly chose this
30
+ * project. Later automatic sources must not replace it.
31
+ */
32
+ authority?: "trusted";
20
33
  }
34
+ type TrustedProjectInput = TrustedExplicitContext & {
35
+ companySlug?: string;
36
+ /** Shell and native trusted bind write `project`, not `projectId`. */
37
+ project?: string;
38
+ /** Shell and native trusted bind write `task`, not `taskId`. */
39
+ task?: string;
40
+ /** "trusted" is an explicit bind and beats an existing automatic binding. */
41
+ authority?: string;
42
+ };
21
43
  export interface ProjectResolveInput {
22
44
  sessionId: string;
23
45
  env?: NodeJS.ProcessEnv;
24
- trusted?: TrustedExplicitContext & {
25
- companySlug?: string;
26
- projectId?: string;
27
- taskId?: string;
28
- storyId?: string;
29
- };
46
+ trusted?: TrustedProjectInput;
30
47
  cwd?: string;
31
48
  hqRoot?: string;
32
49
  existingState?: SessionStateFile | null;
@@ -35,6 +52,14 @@ export interface ProjectResolveInput {
35
52
  metaTaskId?: string | null;
36
53
  /** Injectable registry project id (tests). */
37
54
  registryProjectId?: string | null;
55
+ /**
56
+ * Company reconcile already resolved. A trusted project is pinned only when
57
+ * that company's project directory exists. Missing company or root refuses
58
+ * the pin.
59
+ */
60
+ resolvedCompanySlug?: string;
61
+ /** Lane records shared across the lane checks of one reconcile. */
62
+ laneCache?: LaneRecordCache;
38
63
  }
39
64
  /**
40
65
  * Read project / task from workspace/sessions/<sid>/meta.yaml (best-effort).
@@ -75,4 +100,5 @@ export declare function shouldAskAfter(input: {
75
100
  hasTurn?: boolean;
76
101
  now?: Date;
77
102
  }): boolean;
103
+ export {};
78
104
  //# sourceMappingURL=project.d.ts.map