@indigoai-us/hq-cli 5.126.0 → 5.127.0

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 (76) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
  3. package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
  4. package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
  5. package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
  6. package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
  7. package/dist/command-catalog.generated.d.ts +245 -11
  8. package/dist/command-catalog.generated.js +323 -11
  9. package/dist/command-registration-plan.d.ts +1 -0
  10. package/dist/command-registration-plan.js +11 -2
  11. package/dist/commands/agents.d.ts +12 -0
  12. package/dist/commands/agents.js +46 -2
  13. package/dist/commands/lanes.d.ts +55 -16
  14. package/dist/commands/lanes.js +1187 -250
  15. package/dist/commands/mesh.js +254 -19
  16. package/dist/lib/lanes/accounts.d.ts +67 -7
  17. package/dist/lib/lanes/accounts.js +233 -43
  18. package/dist/lib/lanes/compaction.d.ts +96 -0
  19. package/dist/lib/lanes/compaction.js +197 -0
  20. package/dist/lib/lanes/config.d.ts +15 -0
  21. package/dist/lib/lanes/config.js +65 -8
  22. package/dist/lib/lanes/delivery.d.ts +151 -0
  23. package/dist/lib/lanes/delivery.js +354 -0
  24. package/dist/lib/lanes/dropbox.d.ts +96 -0
  25. package/dist/lib/lanes/dropbox.js +408 -0
  26. package/dist/lib/lanes/effort.d.ts +50 -0
  27. package/dist/lib/lanes/effort.js +109 -0
  28. package/dist/lib/lanes/errors.d.ts +2 -2
  29. package/dist/lib/lanes/hooks-install.d.ts +81 -0
  30. package/dist/lib/lanes/hooks-install.js +326 -0
  31. package/dist/lib/lanes/hq-root.d.ts +19 -0
  32. package/dist/lib/lanes/hq-root.js +28 -0
  33. package/dist/lib/lanes/index.d.ts +35 -5
  34. package/dist/lib/lanes/index.js +19 -4
  35. package/dist/lib/lanes/liveness.d.ts +7 -1
  36. package/dist/lib/lanes/liveness.js +64 -5
  37. package/dist/lib/lanes/machine-identity.d.ts +34 -0
  38. package/dist/lib/lanes/machine-identity.js +92 -0
  39. package/dist/lib/lanes/mesh-events.d.ts +109 -0
  40. package/dist/lib/lanes/mesh-events.js +275 -0
  41. package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
  42. package/dist/lib/lanes/outpost-dispatch.js +90 -0
  43. package/dist/lib/lanes/questions.d.ts +75 -0
  44. package/dist/lib/lanes/questions.js +243 -0
  45. package/dist/lib/lanes/quota.d.ts +95 -0
  46. package/dist/lib/lanes/quota.js +320 -0
  47. package/dist/lib/lanes/reconcile-install.d.ts +69 -0
  48. package/dist/lib/lanes/reconcile-install.js +221 -0
  49. package/dist/lib/lanes/reconcile.d.ts +102 -0
  50. package/dist/lib/lanes/reconcile.js +985 -0
  51. package/dist/lib/lanes/registry.d.ts +18 -5
  52. package/dist/lib/lanes/registry.js +293 -19
  53. package/dist/lib/lanes/spawn.d.ts +274 -0
  54. package/dist/lib/lanes/spawn.js +1363 -0
  55. package/dist/lib/lanes/store.d.ts +7 -3
  56. package/dist/lib/lanes/store.js +150 -32
  57. package/dist/lib/lanes/types.d.ts +346 -4
  58. package/dist/lib/lanes/types.js +138 -5
  59. package/dist/lib/lanes/watcher-main.d.ts +9 -0
  60. package/dist/lib/lanes/watcher-main.js +22 -0
  61. package/dist/lib/lanes/watcher.d.ts +216 -0
  62. package/dist/lib/lanes/watcher.js +859 -0
  63. package/dist/lib/mesh/api.d.ts +71 -7
  64. package/dist/lib/mesh/api.js +167 -28
  65. package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
  66. package/dist/lib/mesh/live/backfill-held.js +66 -21
  67. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  68. package/dist/lib/mesh/live/daemon/run.js +111 -54
  69. package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
  70. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
  71. package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
  72. package/dist/lib/scan-packages/index.d.ts +2 -0
  73. package/dist/lib/scan-packages/index.js +1 -1
  74. package/dist/lib/work-context/state.d.ts +12 -0
  75. package/dist/lib/work-context/state.js +131 -3
  76. package/package.json +3 -1
@@ -6,11 +6,12 @@
6
6
  * Distinct from `hq doctor` (hook guardrails).
7
7
  */
8
8
  import * as os from "node:os";
9
+ import { readFileSync } from "node:fs";
9
10
  import { findHqRoot } from "../utils/manifest.js";
10
11
  import chalk from "chalk";
11
12
  import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
12
13
  import * as readline from "node:readline/promises";
13
- import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, listActiveMembershipCompanies, listActiveThreads, patchStoryStatus, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
14
+ import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
14
15
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
15
16
  import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
16
17
  import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
@@ -88,14 +89,19 @@ async function runCheck(opts) {
88
89
  async function runEvent(verb, opts) {
89
90
  const { token, company } = await withCompany(opts);
90
91
  const projectId = requireProject(opts);
92
+ const eventKind = verb === "start" ? "claim" : verb;
93
+ let claimant = "";
94
+ if (eventKind === "claim") {
95
+ const resolvedClaimant = await resolveMeshPrincipalUid(token, loadCachedTokens());
96
+ if (!resolvedClaimant) {
97
+ fail("Could not resolve the authenticated claimant. Sign in again and retry `hq mesh start`.");
98
+ }
99
+ claimant = resolvedClaimant;
100
+ }
91
101
  const ensured = await ensureProjectThread(token, company, projectId, {
92
102
  threadId: opts.threadId,
93
103
  summary: opts.summary,
94
104
  });
95
- const eventKind = verb === "start" ? "claim" : verb;
96
- const claimant = eventKind === "claim"
97
- ? await resolveMeshPrincipalUid(token, loadCachedTokens())
98
- : "";
99
105
  const event = await appendThreadEvent(token, company.companyUid, ensured.threadId, eventKind, eventPayload(eventKind, opts, claimant));
100
106
  if (opts.json) {
101
107
  console.log(JSON.stringify({
@@ -115,17 +121,215 @@ async function runEvent(verb, opts) {
115
121
  async function runStory(opts) {
116
122
  const storyId = opts.story?.trim();
117
123
  const status = opts.status?.trim();
118
- if (!storyId || !status || !STORY_STATUSES.has(status)) {
119
- fail("story requires --story <id> and --status queued|in_progress|review|done");
124
+ const assignee = opts.assignee?.trim();
125
+ if (!storyId) {
126
+ fail("story requires --story <id>");
127
+ }
128
+ if (!status && !assignee) {
129
+ fail("story requires --status queued|in_progress|review|done or --assignee <uid|me|none>");
130
+ }
131
+ if (status && !STORY_STATUSES.has(status)) {
132
+ fail("story --status must be queued|in_progress|review|done");
120
133
  }
121
134
  const { token, company } = await withCompany(opts);
122
135
  const projectId = requireProject(opts);
123
- const patched = await patchStoryStatus(token, company.companyUid, projectId, storyId, status);
136
+ let assigneeUid;
137
+ let clearAssignee = false;
138
+ if (assignee === "none") {
139
+ clearAssignee = true;
140
+ }
141
+ else if (assignee === "me") {
142
+ const resolved = await resolveMeshPrincipalUid(token, loadCachedTokens());
143
+ if (!resolved || resolved === "session") {
144
+ fail("`--assignee me` needs the signed-in person or agent. The synthetic session identity cannot be assigned. Sign in again and retry `hq mesh story`.");
145
+ }
146
+ assigneeUid = resolved;
147
+ }
148
+ else if (assignee) {
149
+ if (!/^(prs|agt)_/.test(assignee)) {
150
+ fail("`--assignee` must be a person or agent uid, `me`, or `none`.");
151
+ }
152
+ assigneeUid = assignee;
153
+ }
154
+ const patched = await patchStory(token, company.companyUid, projectId, storyId, {
155
+ ...(status ? { status } : {}),
156
+ ...(assigneeUid ? { assigneeUid } : {}),
157
+ ...(clearAssignee ? { clearAssignee: true } : {}),
158
+ });
124
159
  if (opts.json) {
125
160
  console.log(JSON.stringify({ ok: true, action: "story", company, ...patched, storyId }, null, 2));
126
161
  return;
127
162
  }
128
- console.log(`Work mesh: story ${storyId} → ${patched.status} (v${patched.version ?? "?"})`);
163
+ const assignment = assignee === "none"
164
+ ? "assignee cleared"
165
+ : assignee
166
+ ? `assignee ${patched.assigneeUid ?? assigneeUid}`
167
+ : "";
168
+ const statusPart = status ? `${storyId} → ${patched.status}` : storyId;
169
+ console.log(`Work mesh: story ${statusPart}${assignment ? ` ${assignment}` : ""} (v${patched.version ?? "?"})`);
170
+ }
171
+ function record(value) {
172
+ return value && typeof value === "object" && !Array.isArray(value)
173
+ ? value
174
+ : null;
175
+ }
176
+ function parseProjectRepoSpec(value, source) {
177
+ const raw = value.trim();
178
+ const separator = raw.lastIndexOf("@");
179
+ const path = (separator > 0 ? raw.slice(0, separator) : raw).trim();
180
+ const branch = (separator > 0 ? raw.slice(separator + 1) : "").trim();
181
+ if (!path)
182
+ fail(`${source} requires a non-empty repository path.`);
183
+ if (separator > 0 && !branch)
184
+ fail(`${source} has an empty branch after @.`);
185
+ return { path, branch };
186
+ }
187
+ function parseProjectRepos(value, source) {
188
+ if (!Array.isArray(value))
189
+ fail(`${source} must be a JSON repository array.`);
190
+ return value.map((raw, index) => {
191
+ if (typeof raw === "string")
192
+ return parseProjectRepoSpec(raw, `${source}[${index}]`);
193
+ const repo = record(raw);
194
+ const path = typeof repo?.path === "string"
195
+ ? repo.path.trim()
196
+ : typeof repo?.repoPath === "string"
197
+ ? repo.repoPath.trim()
198
+ : typeof repo?.repo === "string"
199
+ ? repo.repo.trim()
200
+ : "";
201
+ const branchValue = repo?.branch ?? repo?.branchName;
202
+ const branch = branchValue === undefined ? "" : typeof branchValue === "string" ? branchValue.trim() : null;
203
+ if (!repo || !path)
204
+ fail(`${source}[${index}].path requires a string.`);
205
+ if (branch === null)
206
+ fail(`${source}[${index}].branch requires a string.`);
207
+ return { path, branch };
208
+ });
209
+ }
210
+ function parseProjectReposJson(raw, source) {
211
+ try {
212
+ return parseProjectRepos(JSON.parse(raw), source);
213
+ }
214
+ catch (err) {
215
+ if (err instanceof SyntaxError)
216
+ fail(`${source} must contain valid JSON.`);
217
+ throw err;
218
+ }
219
+ }
220
+ function projectStoriesFromJson(value, source) {
221
+ const plan = Array.isArray(value) ? null : record(value);
222
+ const rawStories = Array.isArray(value)
223
+ ? value
224
+ : Array.isArray(plan?.userStories)
225
+ ? plan.userStories
226
+ : Array.isArray(plan?.stories)
227
+ ? plan.stories
228
+ : null;
229
+ if (!rawStories) {
230
+ fail(`${source} must be a JSON story array or an object with userStories.`);
231
+ }
232
+ const stories = rawStories.map((raw, index) => {
233
+ const story = record(raw);
234
+ const id = typeof story?.id === "string" ? story.id.trim() : "";
235
+ const title = typeof story?.title === "string" ? story.title.trim() : "";
236
+ if (!story || !id || !title) {
237
+ fail(`${source} userStories[${index}] requires string id and title.`);
238
+ }
239
+ const status = typeof story.status === "string" ? story.status.trim() : undefined;
240
+ if (status && !STORY_STATUSES.has(status)) {
241
+ fail(`${source} userStories[${index}].status must be queued|in_progress|review|done.`);
242
+ }
243
+ const resolvedStatus = status || (story.passes === true ? "done" : undefined);
244
+ return {
245
+ id,
246
+ title,
247
+ ...(typeof story.description === "string" ? { description: story.description } : {}),
248
+ ...(Array.isArray(story.acceptanceCriteria)
249
+ ? {
250
+ acceptanceCriteria: story.acceptanceCriteria.filter((item) => typeof item === "string"),
251
+ }
252
+ : {}),
253
+ ...(resolvedStatus ? { status: resolvedStatus } : {}),
254
+ // hq-pro makes passes mirror the Board status. A completed PRD story
255
+ // without an explicit status must therefore become done.
256
+ passes: resolvedStatus === "done",
257
+ ...(typeof story.priority === "number" && Number.isFinite(story.priority)
258
+ ? { priority: story.priority }
259
+ : {}),
260
+ };
261
+ });
262
+ const metadata = record(plan?.metadata);
263
+ const repos = metadata && Object.hasOwn(metadata, "repos")
264
+ ? parseProjectRepos(metadata.repos, `${source} metadata.repos`)
265
+ : undefined;
266
+ return {
267
+ ...(typeof plan?.name === "string" ? { name: plan.name } : {}),
268
+ ...(typeof plan?.description === "string" ? { description: plan.description } : {}),
269
+ stories,
270
+ ...(repos ? { repos } : {}),
271
+ };
272
+ }
273
+ function parseProjectPlan(raw, source) {
274
+ try {
275
+ return projectStoriesFromJson(JSON.parse(raw), source);
276
+ }
277
+ catch (err) {
278
+ if (err instanceof SyntaxError)
279
+ fail(`${source} must contain valid JSON.`);
280
+ throw err;
281
+ }
282
+ }
283
+ async function runProjectSet(projectId, opts) {
284
+ if (opts.storiesFile && opts.storiesJson) {
285
+ fail("Pass only one of --stories-file or --stories-json.");
286
+ }
287
+ if (opts.reposJson && opts.repo?.length) {
288
+ fail("Pass only one of --repos-json or --repo.");
289
+ }
290
+ const source = opts.storiesFile
291
+ ? parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile)
292
+ : opts.storiesJson
293
+ ? parseProjectPlan(opts.storiesJson, "--stories-json")
294
+ : undefined;
295
+ const explicitRepos = opts.reposJson
296
+ ? parseProjectReposJson(opts.reposJson, "--repos-json")
297
+ : opts.repo?.length
298
+ ? opts.repo.map((repo, index) => parseProjectRepoSpec(repo, `--repo[${index}]`))
299
+ : undefined;
300
+ const { token, company } = await withCompany(opts);
301
+ const current = await getProjectView(token, company.companyUid, projectId);
302
+ const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
303
+ const view = await putProjectView(token, company.companyUid, projectId, {
304
+ name: opts.name?.trim() || source?.name || current.name,
305
+ ...(opts.description !== undefined
306
+ ? { description: opts.description }
307
+ : source?.description !== undefined
308
+ ? { description: source.description }
309
+ : { description: current.description }),
310
+ stories: source?.stories ?? current.stories,
311
+ repos: explicitRepos ?? source?.repos ?? current.repos,
312
+ ...(current.version !== undefined ? { version: current.version } : {}),
313
+ });
314
+ if (opts.json) {
315
+ console.log(JSON.stringify({ ok: true, action: "project.set", principalUid, ...view }, null, 2));
316
+ return;
317
+ }
318
+ console.log(`Work mesh: project ${view.projectId} set (${view.name}; ${view.stories.length} stories)`);
319
+ }
320
+ async function runProjectRegister(projectId, opts) {
321
+ if (opts.channel?.trim()) {
322
+ fail("--channel is not supported by the current Work Mesh registration API; omit it to let the server create or bind the project channel.");
323
+ }
324
+ const { token, company } = await withCompany(opts);
325
+ const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
326
+ const registration = await registerMeshProject(token, company.companyUid, projectId);
327
+ const boardState = "registered";
328
+ if (opts.json) {
329
+ console.log(JSON.stringify({ ok: true, action: "project.register", principalUid, boardState, ...registration }, null, 2));
330
+ return;
331
+ }
332
+ console.log(`Work mesh: project ${registration.projectId} registered (${boardState}; channel ${registration.channelId} ${registration.channelCreated ? "created" : "bound"})`);
129
333
  }
130
334
  async function runDoctor(opts) {
131
335
  if (opts.apply) {
@@ -134,6 +338,9 @@ async function runDoctor(opts) {
134
338
  }
135
339
  const token = await requireToken();
136
340
  const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
341
+ if (!principalUid) {
342
+ fail("Could not resolve the authenticated principal. Sign in again and retry `hq mesh doctor`.");
343
+ }
137
344
  const warmed = await warmMeshConversationCache(token, principalUid);
138
345
  if (opts.json) {
139
346
  console.log(JSON.stringify({ ok: true, action: "doctor", ...warmed }, null, 2));
@@ -349,19 +556,20 @@ async function runContextBackfillHeld(opts) {
349
556
  const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 0;
350
557
  const companyHint = opts.company?.trim() || undefined;
351
558
  const useDeviceDefault = opts.useDeviceDefault === true || readDeviceConfig({ root }).repairHeldWithDefault === true;
352
- // Build the reconcile network seam exactly like `hq mesh context reconcile`.
353
- // Not needed in --dry-run (reconcile is never invoked then). Explicit types
354
- // because these are captured in the reconcile closure below (no control-flow
355
- // narrowing, unlike the inline reconcile handler).
559
+ // Build the membership seam for both modes; dry-run is read-only but must
560
+ // validate a locally resolved slug just as live reconciliation does.
561
+ // The remaining seams are captured in the reconcile closure below.
356
562
  let deliver;
357
563
  let fetchCandidates;
358
564
  let validateMembership;
359
565
  let offline = Boolean(opts.offline);
360
- if (!dryRun && !offline) {
566
+ if (!offline) {
361
567
  try {
362
568
  const token = await requireToken();
363
- deliver = createWorkSessionDeliverer({ token });
364
- fetchCandidates = createCandidatesFetcher({ token });
569
+ if (!dryRun) {
570
+ deliver = createWorkSessionDeliverer({ token });
571
+ fetchCandidates = createCandidatesFetcher({ token });
572
+ }
365
573
  validateMembership = async (candidate) => {
366
574
  const membership = await resolveActiveMembershipCompany(token, candidate);
367
575
  if (!membership)
@@ -386,6 +594,7 @@ async function runContextBackfillHeld(opts) {
386
594
  // so `--dry-run` and the real run report the same counts.
387
595
  remoteOwnerHint: companyHint || undefined,
388
596
  useDeviceDefault,
597
+ validateMembership,
389
598
  reconcile: (obs) => reconcileObservation(
390
599
  // Pass --company as a non-forcing hint (remoteOwnerSlug feeds
391
600
  // deterministic resolution BELOW the identity-file / device default,
@@ -413,6 +622,9 @@ async function runContextBackfillHeld(opts) {
413
622
  `${result.unresolved} unresolved, ` +
414
623
  `${result.errors} error(s).` +
415
624
  (dryRun ? " (dry-run: nothing written)" : ""));
625
+ for (const { sessionId, reason } of result.unresolvedReasons) {
626
+ console.log(`Unresolved ${sessionId}: ${reason}`);
627
+ }
416
628
  if (!dryRun && result.reconciled > 0) {
417
629
  console.log("Reconciled session state files were rewritten with a resolved company; " +
418
630
  "the mesh daemon's held retry will re-attribute and post those events.");
@@ -1078,9 +1290,32 @@ export function registerMeshCommand(program) {
1078
1290
  .option("--summary <text>", "Note text")).action((opts) => wrap(() => runEvent("note", opts))());
1079
1291
  addSharedFlags(mesh
1080
1292
  .command("story")
1081
- .description("PATCH one Board story status")
1293
+ .description("PATCH one Board story status and/or assignee")
1082
1294
  .requiredOption("--story <id>", "Story id, e.g. US-001")
1083
- .requiredOption("--status <status>", "queued|in_progress|review|done")).action((opts) => wrap(() => runStory(opts))());
1295
+ .option("--status <status>", "queued|in_progress|review|done")
1296
+ .option("--assignee <uid|me|none>", "Assign a person or agent uid, yourself, or clear")).action((opts) => wrap(() => runStory(opts))());
1297
+ const project = mesh
1298
+ .command("project")
1299
+ .description("Set a project Board view or register its project channel");
1300
+ project
1301
+ .command("set <projectId>")
1302
+ .description("PUT the project Board view from flags or a PRD JSON file")
1303
+ .requiredOption("--company <slug|uid>", "Company slug or cloud uid")
1304
+ .option("--name <name>", "Project name")
1305
+ .option("--description <text>", "Project description")
1306
+ .option("--stories-file <path>", "PRD JSON file with userStories")
1307
+ .option("--stories-json <json>", "Story array or JSON object with userStories")
1308
+ .option("--repos-json <json>", "Repository array, such as [{path,branch}]")
1309
+ .option("--repo <path[@branch]>", "Repository binding (repeatable)", collectRepeatable)
1310
+ .option("--json", "Print machine-readable JSON")
1311
+ .action((projectId, opts) => wrap(() => runProjectSet(projectId, opts))());
1312
+ project
1313
+ .command("register <projectId>")
1314
+ .description("POST project registration to create or bind its project channel and Board")
1315
+ .requiredOption("--company <slug|uid>", "Company slug or cloud uid")
1316
+ .option("--channel <channelId>", "Reserved: the current server chooses the project channel")
1317
+ .option("--json", "Print machine-readable JSON")
1318
+ .action((projectId, opts) => wrap(() => runProjectRegister(projectId, opts))());
1084
1319
  mesh
1085
1320
  .command("doctor")
1086
1321
  .description("Warm ~/.hq/work-mesh/cache from hq-pro (directory, inbox, pair DMs). Not `hq doctor`.")
@@ -1110,7 +1345,7 @@ export function registerMeshCommand(program) {
1110
1345
  context
1111
1346
  .command("backfill-held")
1112
1347
  .description("Reconcile ENDED sessions whose held events lack a company so the daemon re-attributes the backlog (explicit, opt-in; no fleet fan-out)")
1113
- .option("--dry-run", "Report what would be reconciled; write nothing, no network")
1348
+ .option("--dry-run", "Report what would be reconciled; write nothing or deliver events")
1114
1349
  .option("--offline", "Reconcile outbox-only (no candidates/register network)")
1115
1350
  .option("--limit <n>", "Cap sessions processed (default 0 = no cap)")
1116
1351
  .option("--company <slug>", "Optional non-forcing company hint")
@@ -2,17 +2,37 @@
2
2
  * Provider account resolution (spec §5.5).
3
3
  *
4
4
  * Naming an account and omitting one mean different things: an explicit
5
- * disabled id is refused; omitting rotates past a disabled default.
5
+ * disabled id is refused; omitting picks the least-loaded eligible account.
6
6
  * Status reports `configured` and `logged_in` separately. logged_in is
7
- * derived from whether <home>/auth.json EXISTS — this module never reads,
8
- * prints, copies, or hashes that file.
7
+ * derived from whether a provider credential file EXISTS — this module
8
+ * never reads, prints, copies, or hashes that file.
9
9
  */
10
- import type { LaneProvider, LanesConfig } from "./types.js";
10
+ import { type LaneRecord, type LaneProvider } from "./types.js";
11
+ import type { LanesConfig } from "./types.js";
11
12
  export declare const PROVIDER_HOME_ENV: Record<LaneProvider, string>;
12
13
  export declare const AUTH_JSON_NAME = "auth.json";
14
+ export declare const CLAUDE_CREDENTIALS_NAME = ".credentials.json";
15
+ /**
16
+ * Credential filenames are a property of the provider. Codex and Grok write
17
+ * auth.json. Claude Code writes .credentials.json (never auth.json) inside
18
+ * the config dir, and can also authenticate via the OS keychain with no
19
+ * file here at all.
20
+ */
21
+ export declare const PROVIDER_CREDENTIAL_FILES: Record<LaneProvider, readonly string[]>;
22
+ export type AuthState = "file" | "absent" | "unknown";
13
23
  export interface AccountDeps {
14
24
  existsSync: (p: string) => boolean;
15
25
  }
26
+ export interface AccountResolveContext {
27
+ hqRoot?: string;
28
+ existsSync?: (p: string) => boolean;
29
+ loadLanes?: (hqRoot: string) => LaneRecord[];
30
+ /** Already-resolved model for this lane. Beats cliModel and account defaults. */
31
+ model?: string;
32
+ /** `--model` from create, applied to every candidate account. */
33
+ cliModel?: string;
34
+ now?: Date;
35
+ }
16
36
  export interface ResolvedAccount {
17
37
  provider: LaneProvider;
18
38
  id: string;
@@ -29,12 +49,52 @@ export interface AccountStatus {
29
49
  /** Present in personal/lanes/config.json. */
30
50
  configured: boolean;
31
51
  /**
32
- * True iff `<home>/auth.json` exists. Never inspects the file contents.
52
+ * True when a provider credential file exists. False only when every
53
+ * candidate is absent AND absence is conclusive for that provider
54
+ * (Codex, Grok). Null for Claude when no candidate exists: Claude can
55
+ * authenticate via keychain, so a missing file is not logged-out.
56
+ * Never inspects file contents.
33
57
  */
34
- logged_in: boolean;
58
+ logged_in: boolean | null;
59
+ auth_state: AuthState;
35
60
  envVar: string;
36
61
  }
37
- export declare function resolveAccount(provider: LaneProvider, requestedId: string | undefined, config: LanesConfig): ResolvedAccount;
62
+ export declare function credentialPaths(provider: LaneProvider, home: string): string[];
63
+ /**
64
+ * Existence-only probe. Callers must not read the files this returns paths to.
65
+ */
66
+ export declare function hasCredentialFile(provider: LaneProvider, home: string, existsSync: (p: string) => boolean): boolean;
67
+ /**
68
+ * Claude with no file in the config dir is unknown, not logged out: the
69
+ * credential may live in the OS keychain or an API-key helper. Codex and
70
+ * Grok write auth.json when logged in, so absence is logged out.
71
+ */
72
+ export declare function authStateFor(provider: LaneProvider, present: boolean): AuthState;
73
+ export declare function loggedInFromAuthState(state: AuthState): boolean | null;
74
+ /** Admission and listing share this: refuse only a conclusive logged-out. */
75
+ export declare function isLoggedOut(status: Pick<AccountStatus, "logged_in">): boolean;
76
+ /** Auto-select requires a proven credential file, not Claude-unknown. */
77
+ export declare function isCredentialProven(status: Pick<AccountStatus, "logged_in">): boolean;
78
+ /**
79
+ * Occupying states hold a slot: running / awaiting_input / blocked have a
80
+ * live (or paused-live) worker; queued holds a queue slot that will take a
81
+ * running slot. Terminal states do not count.
82
+ */
83
+ export declare function isOccupyingLaneState(state: LaneRecord["state"]): boolean;
84
+ export declare function occupyingLaneCount(lanes: readonly LaneRecord[], provider: LaneProvider, accountId: string): number;
85
+ export declare function runningLaneCount(lanes: readonly LaneRecord[], provider: LaneProvider, accountId: string): number;
86
+ /** Most recent created_at on this account+provider, any state. Null if none. */
87
+ export declare function lastStartedAt(lanes: readonly LaneRecord[], provider: LaneProvider, accountId: string): string | null;
88
+ /**
89
+ * Pick among enabled, credential-proven accounts by occupying count
90
+ * (lowest wins). Ties break to least-recently-started (oldest created_at),
91
+ * then account id ascending. Accounts at max_running are excluded from
92
+ * start-eligible; if every proven account is at cap the pick still returns
93
+ * one so admission can queue rather than overflow.
94
+ */
95
+ export declare function selectBalancedAccount(provider: LaneProvider, config: LanesConfig, ctx?: AccountResolveContext): ResolvedAccount;
96
+ export declare function resolveAccount(provider: LaneProvider, requestedId: string | undefined, config: LanesConfig, ctx?: AccountResolveContext): ResolvedAccount;
97
+ /** Codex/Grok path. Kept for callers that still name auth.json. */
38
98
  export declare function authJsonPath(home: string): string;
39
99
  export declare function accountStatus(provider: LaneProvider, accountId: string, config: LanesConfig, deps?: AccountDeps): AccountStatus;
40
100
  export declare function listAccountStatuses(provider: LaneProvider, config: LanesConfig, deps?: AccountDeps): AccountStatus[];