@indigoai-us/hq-cli 5.345.151 → 5.345.153

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 (88) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/dist/bin/hq-auth-refresh.d.ts +4 -0
  3. package/dist/bin/hq-auth-refresh.js +9 -1
  4. package/dist/command-catalog.generated.d.ts +188 -3
  5. package/dist/command-catalog.generated.js +230 -3
  6. package/dist/command-registration-plan.d.ts +6 -0
  7. package/dist/command-registration-plan.js +1 -0
  8. package/dist/commands/agent-enroll.d.ts +12 -0
  9. package/dist/commands/agent-enroll.js +37 -4
  10. package/dist/commands/core-policy.js +4 -4
  11. package/dist/commands/core-session-project-native.js +19 -4
  12. package/dist/commands/decide.d.ts +13 -0
  13. package/dist/commands/decide.js +74 -0
  14. package/dist/commands/files.js +23 -7
  15. package/dist/commands/integrations-connect.js +24 -1
  16. package/dist/commands/integrations-core.d.ts +14 -0
  17. package/dist/commands/integrations-core.js +19 -1
  18. package/dist/commands/integrations-import.js +22 -1
  19. package/dist/commands/integrations.js +43 -2
  20. package/dist/commands/lanes-message.js +4 -11
  21. package/dist/commands/lanes-questions.js +3 -0
  22. package/dist/commands/lanes-resume.js +4 -11
  23. package/dist/commands/lanes-stories.js +3 -0
  24. package/dist/commands/rescue.d.ts +7 -0
  25. package/dist/commands/rescue.js +22 -13
  26. package/dist/commands/run.js +14 -5
  27. package/dist/commands/secrets.d.ts +13 -0
  28. package/dist/commands/secrets.js +51 -8
  29. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +186 -186
  30. package/dist/hq-cloud-sync-runner/package.json +1 -1
  31. package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
  32. package/dist/lib/agent-kit/adopt-identity.js +18 -3
  33. package/dist/lib/agent-kit/services.d.ts +3 -1
  34. package/dist/lib/agent-kit/services.js +5 -1
  35. package/dist/lib/daemon/ops/policy.d.ts +9 -1
  36. package/dist/lib/daemon/ops/policy.js +37 -2
  37. package/dist/lib/decisions/client.d.ts +67 -0
  38. package/dist/lib/decisions/client.js +120 -0
  39. package/dist/lib/decisions/expiry.d.ts +2 -0
  40. package/dist/lib/decisions/expiry.js +12 -0
  41. package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
  42. package/dist/lib/decisions/fixtures/us001.js +21 -0
  43. package/dist/lib/decisions/import-lanes.d.ts +16 -0
  44. package/dist/lib/decisions/import-lanes.js +152 -0
  45. package/dist/lib/decisions/principal.d.ts +11 -0
  46. package/dist/lib/decisions/principal.js +52 -0
  47. package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
  48. package/dist/lib/doctor/checks/claude-wiring.js +21 -10
  49. package/dist/lib/doctor/checks/work-context.js +7 -2
  50. package/dist/lib/doctor/fix/apply.d.ts +3 -2
  51. package/dist/lib/doctor/fix/apply.js +19 -15
  52. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
  53. package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
  54. package/dist/lib/doctor/fix/remediation.js +4 -2
  55. package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
  56. package/dist/lib/doctor/hook-gate-profiles.js +34 -0
  57. package/dist/lib/doctor/types.d.ts +5 -0
  58. package/dist/lib/install/provider-wrappers.js +5 -1
  59. package/dist/lib/lanes/autopilot.js +4 -1
  60. package/dist/lib/lanes/imported-question.d.ts +5 -0
  61. package/dist/lib/lanes/imported-question.js +17 -0
  62. package/dist/lib/lanes/questions.d.ts +0 -5
  63. package/dist/lib/lanes/questions.js +2 -0
  64. package/dist/lib/lanes/reconcile.js +21 -10
  65. package/dist/lib/lanes/store.d.ts +0 -8
  66. package/dist/lib/lanes/store.js +2 -1
  67. package/dist/lib/lanes/stories.d.ts +15 -0
  68. package/dist/lib/lanes/stories.js +25 -0
  69. package/dist/lib/mesh/client.d.ts +8 -0
  70. package/dist/lib/mesh/client.js +44 -3
  71. package/dist/lib/mesh/denial-codes.js +1 -1
  72. package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
  73. package/dist/lib/mesh/live/daemon/run.js +30 -1
  74. package/dist/lib/mesh/live/flush.d.ts +7 -0
  75. package/dist/lib/mesh/live/flush.js +53 -10
  76. package/dist/lib/registry/registry.js +6 -0
  77. package/dist/lib/scan-packages/index.js +7 -3
  78. package/dist/lib/work-context/organize.d.ts +2 -0
  79. package/dist/lib/work-context/organize.js +22 -4
  80. package/dist/lib/workers-registry/index.d.ts +12 -1
  81. package/dist/lib/workers-registry/index.js +39 -1
  82. package/dist/utils/cognito-session.d.ts +4 -1
  83. package/dist/utils/cognito-session.js +11 -1
  84. package/dist/utils/hook-trust.d.ts +27 -0
  85. package/dist/utils/hook-trust.js +150 -8
  86. package/dist/utils/shipped-codex-hooks.d.ts +17 -0
  87. package/dist/utils/shipped-codex-hooks.js +86 -0
  88. package/package.json +8 -8
@@ -0,0 +1,17 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { LanesError } from "./errors.js";
5
+ export function importedQuestionMarker(root, id) {
6
+ return path.join(root, "workspace", "decisions", "legacy-read-only", `${createHash("sha256").update(id).digest("hex")}.json`);
7
+ }
8
+ /** A pending transfer is also read-only: its remote POST may already have succeeded. */
9
+ export function assertQuestionNotImported(root, id) {
10
+ if (isQuestionImported(root, id)) {
11
+ throw new LanesError("question_imported_read_only", "This question was transferred to hq decide and is read-only in hq lanes. Use hq decide list to find and answer the decision.", { question_id: id });
12
+ }
13
+ }
14
+ export function isQuestionImported(root, id) {
15
+ return fs.existsSync(importedQuestionMarker(root, id));
16
+ }
17
+ //# sourceMappingURL=imported-question.js.map
@@ -1,8 +1,3 @@
1
- /**
2
- * Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
3
- * path and for envelope `decision: "ask"` ingestion. Two writers here would
4
- * drift on hop, origin, raised_via, body store, and routing_history.
5
- */
6
1
  import { type LaneEnvelope, type LaneRecord, type LaneCompanyRef, type QuestionBodyStore, type QuestionKind, type QuestionPriority, type QuestionRecord, type RaisedVia } from "./types.js";
7
2
  export interface QuestionBody {
8
3
  question: string;
@@ -1,3 +1,4 @@
1
+ import { assertQuestionNotImported } from "./imported-question.js";
1
2
  /**
2
3
  * Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
3
4
  * path and for envelope `decision: "ask"` ingestion. Two writers here would
@@ -143,6 +144,7 @@ export function questionBodyAbsPath(hqRoot, question) {
143
144
  return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
144
145
  }
145
146
  export function writeQuestionBody(hqRoot, question, body) {
147
+ assertQuestionNotImported(hqRoot, question.question_id);
146
148
  assertQuestionBodySecretSafe(body);
147
149
  const store = parseQuestionBodyStore(question.body.store);
148
150
  const filePath = questionBodyAbsPath(hqRoot, question);
@@ -17,6 +17,7 @@ import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
17
17
  import { MAX_LANE_DEPTH } from "./constants.js";
18
18
  import { laneDepthCap } from "./depth-cap.js";
19
19
  import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
20
+ import { isQuestionImported } from "./imported-question.js";
20
21
  import { loadWorkerProfile } from "./profile.js";
21
22
  import { terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./grants.js";
22
23
  import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
@@ -650,16 +651,26 @@ async function closeInterruptedDoneStoryWorkers(hqRoot, lanes, questions, summar
650
651
  for (const question of questions) {
651
652
  if (question.origin.id !== lane.lane_id || question.status !== "pending")
652
653
  continue;
653
- casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending", (current) => ({
654
- ...current,
655
- status: "cancelled",
656
- routing_history: appendCappedHistory(current.routing_history, {
657
- hop: current.hop,
658
- audience: { kind: "user" },
659
- at,
660
- reason: "cancelled",
661
- }),
662
- }), false);
654
+ if (isQuestionImported(hqRoot, question.question_id))
655
+ continue;
656
+ try {
657
+ casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending", (current) => ({
658
+ ...current,
659
+ status: "cancelled",
660
+ routing_history: appendCappedHistory(current.routing_history, {
661
+ hop: current.hop,
662
+ audience: { kind: "user" },
663
+ at,
664
+ reason: "cancelled",
665
+ }),
666
+ }), false);
667
+ }
668
+ catch (error) {
669
+ // Import may acquire the record lock after our initial check. The
670
+ // decision service owns that question; continue closing this lane.
671
+ if (!(error instanceof LanesError) || error.error !== "question_imported_read_only")
672
+ throw error;
673
+ }
663
674
  }
664
675
  appendWatcherEvent(watcherEventsPath(hqRoot, lane.lane_id), {
665
676
  event: "story_closed",
@@ -1,11 +1,3 @@
1
- /**
2
- * File-backed lane / question record store (spec §10, §10.1).
3
- *
4
- * Root: workspace/lanes/ under the HQ root. One stable path per record;
5
- * `status` is the pending/answered discriminator — files are never moved
6
- * between directories. Every mutation is temp-file-plus-rename and a
7
- * compare-and-set on `version`. Histories are capped at 50 at the append site.
8
- */
9
1
  import * as fs from "node:fs";
10
2
  import { type OutpostIdentity } from "./admission-host.js";
11
3
  import { type AttributionHistoryEntry, type LaneRecord, type LaneWriter, type QuestionRecord, type RoutingHistoryEntry, type SeniorHistoryEntry } from "./types.js";
@@ -1,3 +1,4 @@
1
+ import { assertQuestionNotImported } from "./imported-question.js";
1
2
  /**
2
3
  * File-backed lane / question record store (spec §10, §10.1).
3
4
  *
@@ -2013,7 +2014,7 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
2013
2014
  return created;
2014
2015
  }
2015
2016
  export function updateQuestion(hqRoot, questionId, expectedVersion, updater, deps = defaultStoreDeps()) {
2016
- const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, updater, deps);
2017
+ const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, (current) => { assertQuestionNotImported(hqRoot, questionId); return updater(current); }, deps);
2017
2018
  syncPendingMarker(hqRoot, next, deps);
2018
2019
  syncLaneQuestionReference(hqRoot, next, deps);
2019
2020
  return next;
@@ -20,4 +20,19 @@ export declare function storyStatusForEnvelope(status: string): "in_progress" |
20
20
  * and explicit story commands own those transitions.
21
21
  */
22
22
  export declare function terminalEnvelopeStoryStatus(lane: LaneRecord, decision: "done" | "blocked" | "ask", envelopeStatus?: string): LaneStoryStatus | undefined;
23
+ /**
24
+ * The stories array after attributing a lane to projectId/storyId.
25
+ *
26
+ * A lane with no story (empty story_id plus no_story_reason) carries no story
27
+ * rows, so attributing it to another project without a story keeps the rows
28
+ * unchanged. This is the single write site for attribution-driven appends:
29
+ * it never produces a row with an empty story_id, which the lane schema
30
+ * rejects (and which would roll back the whole write).
31
+ */
32
+ export declare function storiesAfterAttribution(lane: LaneRecord, projectId: string, storyId: string, at: string): LaneStory[];
33
+ /**
34
+ * The row attribution would append, or undefined when nothing is appended
35
+ * (the story is already carried, or there is no story id).
36
+ */
37
+ export declare function attributedStoryRow(lane: LaneRecord, projectId: string, storyId: string, at: string): LaneStory | undefined;
23
38
  //# sourceMappingURL=stories.d.ts.map
@@ -89,4 +89,29 @@ export function terminalEnvelopeStoryStatus(lane, decision, envelopeStatus = "in
89
89
  return "review";
90
90
  return storyStatusForEnvelope(envelopeStatus);
91
91
  }
92
+ /**
93
+ * The stories array after attributing a lane to projectId/storyId.
94
+ *
95
+ * A lane with no story (empty story_id plus no_story_reason) carries no story
96
+ * rows, so attributing it to another project without a story keeps the rows
97
+ * unchanged. This is the single write site for attribution-driven appends:
98
+ * it never produces a row with an empty story_id, which the lane schema
99
+ * rejects (and which would roll back the whole write).
100
+ */
101
+ export function storiesAfterAttribution(lane, projectId, storyId, at) {
102
+ const current = laneStories(lane);
103
+ const added = attributedStoryRow(lane, projectId, storyId, at);
104
+ return added ? [...current, added] : current;
105
+ }
106
+ /**
107
+ * The row attribution would append, or undefined when nothing is appended
108
+ * (the story is already carried, or there is no story id).
109
+ */
110
+ export function attributedStoryRow(lane, projectId, storyId, at) {
111
+ if (!storyId.trim())
112
+ return undefined;
113
+ if (laneStory(lane, projectId, storyId))
114
+ return undefined;
115
+ return { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at };
116
+ }
92
117
  //# sourceMappingURL=stories.js.map
@@ -54,6 +54,14 @@ export declare function createWorkSessionDeliverer(opts: {
54
54
  body: unknown;
55
55
  }>;
56
56
  }): DeliverFn;
57
+ /**
58
+ * Outbox error code for a 401/403 register refusal. Only real auth / identity
59
+ * refusals are AUTH_DENIED. A company whose work registry is off
60
+ * (WORK_REGISTRY_DISABLED) or an unknown company (INVALID_COMPANY) is not an
61
+ * auth problem, so those keep their own code and doctor does not point at
62
+ * login (Shepherd issue 2726, feedback_6a792ab4).
63
+ */
64
+ export declare function registerDenialOutboxCode(serverCode: string | undefined): string;
57
65
  /**
58
66
  * Probe whether the server advertises migration capability (US-017A).
59
67
  * Failures return false (default-company stays locked).
@@ -4,7 +4,7 @@
4
4
  * vault HTTP + receipt-shaped responses (US-007A / US-007B).
5
5
  */
6
6
  import { listActiveMembershipCompanies, meshJson, requireToken, resolveMeshCompany, } from "./api.js";
7
- import { isWorkSessionDenialCode } from "./denial-codes.js";
7
+ import { isWorkSessionDenialCode, WORK_SESSION_DENIAL_CODES, } from "./denial-codes.js";
8
8
  export { meshJson, requireToken, resolveMeshCompany, listActiveMembershipCompanies };
9
9
  export async function openMeshTransport(opts) {
10
10
  const token = await requireToken();
@@ -63,11 +63,11 @@ export function createWorkSessionDeliverer(opts) {
63
63
  if (status === 401 || status === 403) {
64
64
  const serverCode = isWorkSessionDenialCode(errorFields.code)
65
65
  ? errorFields.code
66
- : undefined;
66
+ : denialCodeFromMessage(message);
67
67
  return {
68
68
  ok: false,
69
69
  retryable: false,
70
- code: "AUTH_DENIED",
70
+ code: registerDenialOutboxCode(serverCode),
71
71
  httpStatus: status,
72
72
  ...(serverCode ? { serverCode } : {}),
73
73
  };
@@ -98,6 +98,15 @@ export function createWorkSessionDeliverer(opts) {
98
98
  return { ok: false, retryable: false, code: `HTTP_${status}` };
99
99
  }
100
100
  }
101
+ const messageCode = denialCodeFromMessage(message);
102
+ if (messageCode && registerDenialOutboxCode(messageCode) !== "AUTH_DENIED") {
103
+ return {
104
+ ok: false,
105
+ retryable: false,
106
+ code: registerDenialOutboxCode(messageCode),
107
+ serverCode: messageCode,
108
+ };
109
+ }
101
110
  if (/unauthorized|forbidden|auth/i.test(message)) {
102
111
  return { ok: false, retryable: false, code: "AUTH_DENIED" };
103
112
  }
@@ -112,6 +121,27 @@ export function createWorkSessionDeliverer(opts) {
112
121
  }
113
122
  };
114
123
  }
124
+ /**
125
+ * Outbox error code for a 401/403 register refusal. Only real auth / identity
126
+ * refusals are AUTH_DENIED. A company whose work registry is off
127
+ * (WORK_REGISTRY_DISABLED) or an unknown company (INVALID_COMPANY) is not an
128
+ * auth problem, so those keep their own code and doctor does not point at
129
+ * login (Shepherd issue 2726, feedback_6a792ab4).
130
+ */
131
+ export function registerDenialOutboxCode(serverCode) {
132
+ if (serverCode === "WORK_REGISTRY_DISABLED" || serverCode === "INVALID_COMPANY") {
133
+ return serverCode;
134
+ }
135
+ return "AUTH_DENIED";
136
+ }
137
+ /** Find an allowlisted denial code in an error message when the error has no code field. */
138
+ function denialCodeFromMessage(message) {
139
+ for (const code of WORK_SESSION_DENIAL_CODES) {
140
+ if (new RegExp(`\\b${code}\\b`).test(message))
141
+ return code;
142
+ }
143
+ return undefined;
144
+ }
115
145
  function mapRegisterResponse(status, body, operationId) {
116
146
  if (status >= 200 && status < 300) {
117
147
  const rec = body && typeof body === "object" ? body : {};
@@ -124,6 +154,17 @@ function mapRegisterResponse(status, body, operationId) {
124
154
  if (status >= 500 || status === 429) {
125
155
  return { ok: false, retryable: true, code: `HTTP_${status}` };
126
156
  }
157
+ if (status === 401 || status === 403) {
158
+ const rec = body && typeof body === "object" ? body : {};
159
+ const serverCode = isWorkSessionDenialCode(rec.code) ? rec.code : undefined;
160
+ return {
161
+ ok: false,
162
+ retryable: false,
163
+ code: registerDenialOutboxCode(serverCode),
164
+ httpStatus: status,
165
+ ...(serverCode ? { serverCode } : {}),
166
+ };
167
+ }
127
168
  if (status >= 400 && status < 500) {
128
169
  return { ok: false, retryable: false, code: `HTTP_${status}` };
129
170
  }
@@ -26,7 +26,7 @@ export function isWorkSessionDenialCode(value) {
26
26
  }
27
27
  /** Plain-language reasons for denial codes the user can act on. */
28
28
  const WORK_SESSION_DENIAL_REASONS = {
29
- WORK_REGISTRY_DISABLED: "Work tracking is turned off for this company. The company owner can turn it on with: hq company --company <slug> settings set --work-registry-enabled true",
29
+ WORK_REGISTRY_DISABLED: "Work registry is disabled for this company; ask a company admin to enable it with: hq company --company <slug> settings set --work-registry-enabled true. This is not a login problem.",
30
30
  FEATURE_DISABLED: "Live work tracking is turned off for this company. The company owner can turn it on with: hq company --company <slug> settings set --work-mesh-live-enabled true",
31
31
  };
32
32
  /** Plain reason for a server denial code, or undefined when there is none. */
@@ -24,6 +24,12 @@ export declare const SPOOL_DEBOUNCE_MS = 2000;
24
24
  export declare const FLUSH_INTERVAL_MS = 10000;
25
25
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
26
26
  export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
27
+ /**
28
+ * A long-running daemon re-runs the held backfill this often while held
29
+ * events remain, so a backlog larger than one batch keeps draining after a
30
+ * reinstall instead of waiting for the next daemon start (Shepherd 2361).
31
+ */
32
+ export declare const HELD_BACKFILL_INTERVAL_MS: number;
27
33
  export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
28
34
  /** Skip further membership probes after a transport failure so a network drop
29
35
  * cannot fan out one hanging lookup per held session. */
@@ -60,10 +66,12 @@ export interface DaemonRunDeps {
60
66
  quarantined: number;
61
67
  skipped?: number;
62
68
  }>;
63
- /** Injected held-session recovery (tests). Runs once before the startup flush. */
69
+ /** Injected held-session recovery (tests). Runs before the startup flush and then periodically while held events remain. */
64
70
  backfillHeld?: () => Promise<BackfillHeldResult>;
65
71
  /** Maximum distinct held sessions reconciled during one daemon startup. */
66
72
  heldBackfillBatchSize?: number;
73
+ /** Minimum gap between periodic held backfills. Default HELD_BACKFILL_INTERVAL_MS. */
74
+ heldBackfillIntervalMs?: number;
67
75
  /** Injected board refresh (tests). */
68
76
  refreshBoards?: () => Promise<{
69
77
  refreshed: number;
@@ -46,6 +46,12 @@ export const SPOOL_DEBOUNCE_MS = 2_000;
46
46
  export const FLUSH_INTERVAL_MS = 10_000;
47
47
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
48
48
  export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
49
+ /**
50
+ * A long-running daemon re-runs the held backfill this often while held
51
+ * events remain, so a backlog larger than one batch keeps draining after a
52
+ * reinstall instead of waiting for the next daemon start (Shepherd 2361).
53
+ */
54
+ export const HELD_BACKFILL_INTERVAL_MS = 15 * 60 * 1_000;
49
55
  export const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2_000;
50
56
  /** Skip further membership probes after a transport failure so a network drop
51
57
  * cannot fan out one hanging lookup per held session. */
@@ -350,6 +356,19 @@ export async function runMeshDaemon(deps = {}) {
350
356
  };
351
357
  /** In-memory held re-attempt throttle (survives watch-triggered flushes). */
352
358
  let lastHeldRetryAtMs = 0;
359
+ /** Epoch ms of the last held backfill (startup or periodic). */
360
+ let lastHeldBackfillAtMs = 0;
361
+ const heldBackfillIntervalMs = deps.heldBackfillIntervalMs ?? HELD_BACKFILL_INTERVAL_MS;
362
+ const heldBackfillDue = (atMs) => {
363
+ if (atMs - lastHeldBackfillAtMs < heldBackfillIntervalMs)
364
+ return false;
365
+ try {
366
+ return fs.statSync(workMeshHeldPath(meshRoot)).size > 0;
367
+ }
368
+ catch {
369
+ return false;
370
+ }
371
+ };
353
372
  const shutdownController = new AbortController();
354
373
  const flushFn = deps.flush ??
355
374
  (async (signal) => {
@@ -380,6 +399,12 @@ export async function runMeshDaemon(deps = {}) {
380
399
  return "unavailable";
381
400
  return authorization.status === "authorized";
382
401
  },
402
+ // Held sessions whose local evidence only names a slug need the
403
+ // membership slug -> uid lookup, or they stay NEEDS_COMPANY forever.
404
+ resolveCompanyUid: async (slug) => {
405
+ const authorization = await tenantGate.authorize({ companySlug: slug, deviceDefault: false });
406
+ return authorization.status === "authorized" ? authorization.uid : null;
407
+ },
383
408
  });
384
409
  });
385
410
  const replayFn = deps.replayOutbox ??
@@ -674,7 +699,11 @@ export async function runMeshDaemon(deps = {}) {
674
699
  // A held session has already ended, so no later hook will naturally
675
700
  // revisit its context. Repair a bounded batch before the first flush;
676
701
  // recovered state is then retried immediately in this same cycle.
677
- if (reason === "start") {
702
+ // A long-running daemon repeats the bounded batch on an interval
703
+ // while held events remain, so the cursor keeps rotating through a
704
+ // backlog larger than one batch.
705
+ if (reason === "start" || heldBackfillDue(now().getTime())) {
706
+ lastHeldBackfillAtMs = now().getTime();
678
707
  try {
679
708
  const recovered = await backfillFn();
680
709
  if (recovered.reconciled > 0 ||
@@ -58,6 +58,13 @@ export interface FlushDeps {
58
58
  * stripped. Defaults to a per-flush cached git resolver (tests inject).
59
59
  */
60
60
  resolveWorkLocation?: WorkLocationResolver;
61
+ /**
62
+ * Membership-validated slug -> companyUid lookup for held sessions whose
63
+ * local evidence names a company slug but no uid. Return the uid, or null
64
+ * when the caller is not a member or the lookup is unavailable (the events
65
+ * stay held and are retried). Called at most once per slug per flush.
66
+ */
67
+ resolveCompanyUid?: (slug: string) => Promise<string | null>;
61
68
  }
62
69
  export interface FlushSummary {
63
70
  claimed: number;
@@ -262,6 +262,7 @@ export async function flushSessionEvents(inputDeps) {
262
262
  pendingRestore.delete(event);
263
263
  };
264
264
  const loggedProject404 = new Set();
265
+ const heldUidCache = new Map();
265
266
  sessionLoop: for (const [sessionId, items] of bySession) {
266
267
  if (deps.signal?.aborted)
267
268
  break sessionLoop;
@@ -283,7 +284,8 @@ export async function flushSessionEvents(inputDeps) {
283
284
  if (leaveResult.abort)
284
285
  continue;
285
286
  const remainingItems = leaveResult.items;
286
- state = bindHeldCompanyIfResolved(sessionId, remainingItems, state, deps, now);
287
+ const heldBinding = bindHeldCompanyIfResolved(sessionId, remainingItems, state, deps, now, heldUidCache);
288
+ state = heldBinding instanceof Promise ? await heldBinding : heldBinding;
287
289
  if (state?.companyConfidence === "device_default" &&
288
290
  deps.authorizeDeviceDefaultTenant) {
289
291
  const allowed = await deps.authorizeDeviceDefaultTenant(state);
@@ -646,12 +648,12 @@ const SESSION_EVENT_HARNESSES = new Set([
646
648
  "agent-box",
647
649
  "agents-v2",
648
650
  ]);
649
- function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
651
+ function resolveHeldCompany(sessionId, items, state, deps) {
650
652
  if (state?.companyUid)
651
- return state;
653
+ return null;
652
654
  const sample = items[0]?.event;
653
655
  if (!sample)
654
- return state;
656
+ return null;
655
657
  const cwd = eventText(sample, "cwd");
656
658
  const hqRoot = eventText(sample, "hqRoot");
657
659
  const remoteOwnerSlug = cwd
@@ -675,14 +677,16 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
675
677
  remoteOwnerSlug,
676
678
  repoIdentityKey,
677
679
  });
678
- if (resolution.status !== "resolved" || !resolution.company.uid)
679
- return state;
680
+ if (resolution.status !== "resolved")
681
+ return null;
680
682
  if (resolution.company.source === "trusted_explicit" &&
681
683
  !eventText(sample, "companySlug")) {
682
- return state;
684
+ return null;
683
685
  }
684
- const uid = resolution.company.uid;
685
- const slug = resolution.company.slug;
686
+ return resolution.company;
687
+ }
688
+ function writeHeldCompanyBinding(sessionId, state, company, deps, now) {
689
+ const { uid, slug } = company;
686
690
  const nowIso = now().toISOString();
687
691
  const next = updateSessionState(sessionId, deps.workContextRoot, (current) => {
688
692
  const base = current ?? state;
@@ -703,7 +707,7 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
703
707
  contractVersion: base.contractVersion ?? 1,
704
708
  companyUid: uid,
705
709
  ...(slug ? { companySlug: slug } : {}),
706
- ...(resolution.company.source === "device_default"
710
+ ...(company.source === "device_default"
707
711
  ? { companyConfidence: "device_default" }
708
712
  : {}),
709
713
  contextStatus,
@@ -712,6 +716,45 @@ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now) {
712
716
  });
713
717
  return next ?? readSessionState(sessionId, deps.workContextRoot) ?? state;
714
718
  }
719
+ /**
720
+ * Bind a held session to a company. Local evidence with a uid binds directly.
721
+ * Local evidence that only names a slug (session meta, cwd, git remote) needs
722
+ * a membership-validated slug -> uid lookup; without it the session would stay
723
+ * NEEDS_COMPANY on every retry (Shepherd 2361).
724
+ */
725
+ function bindHeldCompanyIfResolved(sessionId, items, state, deps, now, uidCache) {
726
+ // Stay synchronous unless a uid lookup is needed: the flush loop's
727
+ // microtask ordering is relied on by retry/back-pressure timing.
728
+ const company = resolveHeldCompany(sessionId, items, state, deps);
729
+ if (!company)
730
+ return state;
731
+ if (company.uid) {
732
+ return writeHeldCompanyBinding(sessionId, state, { uid: company.uid, slug: company.slug, source: company.source }, deps, now);
733
+ }
734
+ const slug = company.slug?.trim().toLowerCase();
735
+ if (!slug || !deps.resolveCompanyUid || company.source === "device_default")
736
+ return state;
737
+ let pending = uidCache.get(slug);
738
+ if (!pending) {
739
+ const resolver = deps.resolveCompanyUid;
740
+ pending = (async () => {
741
+ try {
742
+ const uid = await resolver(slug);
743
+ return typeof uid === "string" && uid.trim() ? uid.trim() : null;
744
+ }
745
+ catch (err) {
746
+ deps.log?.(`held company uid lookup failed for slug=${slug}: ${(err instanceof Error ? err.message : String(err)).slice(0, 120)}`);
747
+ return null;
748
+ }
749
+ })();
750
+ uidCache.set(slug, pending);
751
+ }
752
+ return pending.then((uid) => {
753
+ if (!uid)
754
+ return state;
755
+ return writeHeldCompanyBinding(sessionId, state, { uid, slug: company.slug, source: company.source }, deps, now);
756
+ });
757
+ }
715
758
  function applySessionMetaCompanyRebind(sessionId, items, state, deps, now) {
716
759
  if (!state)
717
760
  return state;
@@ -90,6 +90,12 @@ export function pathKey(dir) {
90
90
  export function canonicalDir(dir) {
91
91
  const abs = path.resolve(dir);
92
92
  try {
93
+ // macOS volumes are case-insensitive by default: /x/CaseRepo and
94
+ // /x/caserepo are one folder, but the JS realpath keeps the caller's
95
+ // spelling, so the same repo produced two registry keys. realpath(3)
96
+ // returns the on-disk spelling. Other platforms keep the JS realpath.
97
+ if (process.platform === "darwin")
98
+ return fs.realpathSync.native(abs);
93
99
  return fs.realpathSync(abs);
94
100
  }
95
101
  catch {
@@ -4,7 +4,7 @@ import * as path from 'node:path';
4
4
  import * as yaml from 'js-yaml';
5
5
  import { contributionLinks } from '../../utils/pack-contribution-links.js';
6
6
  import { CONTRIBUTION_TABLE } from '../../utils/contribution-table.js';
7
- import { isExcludedWorkerDirectory } from '../workers-registry/index.js';
7
+ import { isExcludedWorkerDirectory, isNestedWorkerCheckout } from '../workers-registry/index.js';
8
8
  function isDirectory(target) { try {
9
9
  return fs.statSync(target).isDirectory();
10
10
  }
@@ -77,8 +77,9 @@ function allWorkerYamlFiles(root, relative) {
77
77
  const output = [];
78
78
  const walk = (next, seen) => {
79
79
  // Prune exactly what the worker registry prunes (tenant repos/ checkouts,
80
- // _template, _overrides). A worker.yaml the registry never indexes cannot
81
- // collide with a pack worker, and walking source checkouts is slow.
80
+ // nested git checkouts, _template, _overrides). A worker.yaml the registry
81
+ // never indexes cannot collide with a pack worker, and walking source
82
+ // checkouts is slow.
82
83
  if (isExcludedWorkerDirectory(next))
83
84
  return;
84
85
  const absolute = path.join(root, next);
@@ -101,7 +102,10 @@ function allWorkerYamlFiles(root, relative) {
101
102
  catch {
102
103
  return;
103
104
  }
105
+ const checkout = isNestedWorkerCheckout(relative, next, names);
104
106
  for (const name of names) {
107
+ if (checkout && name !== 'worker.yaml')
108
+ continue;
105
109
  const child = path.join(next, name);
106
110
  const childAbsolute = path.join(root, child);
107
111
  if (name === 'worker.yaml' && (() => { try {
@@ -92,6 +92,8 @@ export declare function prepareOrganizeDecision(input: {
92
92
  /** Skip network; leave unresolved without candidates. */
93
93
  offline?: boolean;
94
94
  }): Promise<OrganizeListResult>;
95
+ /** True when a local decision's server window has passed (or is unparsable). */
96
+ export declare function isLocalDecisionExpired(expiresAt: string | undefined, now?: Date): boolean;
95
97
  export declare function formatOrganizeList(result: OrganizeListResult): string[];
96
98
  /**
97
99
  * Mark that the ask was cancelled / timed out / surface incapable.
@@ -114,8 +114,17 @@ export async function prepareOrganizeDecision(input) {
114
114
  reason: state.contextStatus,
115
115
  };
116
116
  }
117
- // Ask-once: an existing decision is never replaced or re-armed.
118
- if (state.decision?.decisionId) {
117
+ // Ask-once: an existing decision is never replaced or re-armed — unless its
118
+ // server decision window has expired. The server stamps a fresh window only
119
+ // when candidates are listed, so returning the stale decision would make
120
+ // every later organize fail with DECISION_EXPIRED / DECISION_WINDOW_MISSING.
121
+ const refreshExpired = Boolean(state.decision?.decisionId) &&
122
+ state.decision?.askAfter === true &&
123
+ isLocalDecisionExpired(state.decision?.expiresAt, nowFn()) &&
124
+ Boolean(state.companyUid) &&
125
+ !input.offline &&
126
+ Boolean(input.fetchCandidates);
127
+ if (state.decision?.decisionId && !refreshExpired) {
119
128
  return {
120
129
  sessionId: input.sessionId,
121
130
  contextStatus: state.contextStatus,
@@ -124,7 +133,7 @@ export async function prepareOrganizeDecision(input) {
124
133
  reason: state.decision.askAfter ? "pending_ask" : "ask_already_settled",
125
134
  };
126
135
  }
127
- const ready = shouldAskAfter({
136
+ const ready = refreshExpired || shouldAskAfter({
128
137
  toolWrites: state.toolWrites,
129
138
  startedAt: state.startedAt,
130
139
  hasTurn: state.hasTurn,
@@ -168,9 +177,18 @@ export async function prepareOrganizeDecision(input) {
168
177
  contextStatus: next.contextStatus,
169
178
  pending: true,
170
179
  decision,
171
- reason: "ask_prepared",
180
+ reason: refreshExpired ? "ask_refreshed" : "ask_prepared",
172
181
  };
173
182
  }
183
+ /** True when a local decision's server window has passed (or is unparsable). */
184
+ export function isLocalDecisionExpired(expiresAt, now = new Date()) {
185
+ if (!expiresAt)
186
+ return false;
187
+ const ms = Date.parse(expiresAt);
188
+ if (!Number.isFinite(ms))
189
+ return true;
190
+ return now.getTime() >= ms;
191
+ }
174
192
  export function formatOrganizeList(result) {
175
193
  const lines = [
176
194
  `session ${result.sessionId} status=${result.contextStatus}` +
@@ -28,9 +28,20 @@ export interface GenerateWorkersRegistryResult {
28
28
  * (`.codex/worktrees/<name>/`) are the same trap in a different folder: a
29
29
  * nested HQ root under a company project. Those copies land in the registry as
30
30
  * `companies/<slug>/…/.claude/worktrees/…` or `.codex/worktrees/…` paths, so
31
- * `hq lanes create` then fails with `worker_tenancy_unresolved`.
31
+ * `hq lanes create` then fails with `worker_tenancy_unresolved`. HQ's own
32
+ * `workspace/worktrees/<name>/` is the same shape.
33
+ *
34
+ * `node_modules` and `.git` never hold workers and are large, so they are
35
+ * skipped wherever they appear. Any other checkout (a directory holding `.git`)
36
+ * is caught by the walk itself, because only the filesystem can tell.
32
37
  */
33
38
  export declare function isExcludedWorkerDirectory(relativeDirectory: string): boolean;
39
+ /**
40
+ * True when `relativeDirectory`, found while walking `scanRoot`, is a separate
41
+ * git checkout (its listing `names` holds `.git`). The caller may still take a
42
+ * `worker.yaml` directly inside it, but must not descend any further.
43
+ */
44
+ export declare function isNestedWorkerCheckout(scanRoot: string, relativeDirectory: string, names: readonly string[]): boolean;
34
45
  /**
35
46
  * Generate core/workers/registry.yaml from worker.yaml files. Invalid workers
36
47
  * are quarantined but do not prevent all valid workers from being registered.