@indigoai-us/hq-cli 5.139.0 → 5.140.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.140.0] — 2026-09-23
6
+
7
+ ### Fixed
8
+
9
+ - `hq lanes monitor-check` now recognizes a live Claude Monitor task launched through the CLI's shell/node process wrapper, so active lanes are not reported as uncovered.
10
+ - A session hint now binds to the best existing project. An exact Board is used as-is. An exact local PRD gets a Board through the same genesis-thread, view, then register path as `project ensure`, and is bound. A single clear match is bound with a recorded reason. When nothing matches, or two candidates are close, the session stays `needs_project` and the start hook lists the candidates. `hq mesh context bind-project` applies the answer. A flush HTTP 404 downgrades that session, holds its events, and logs one line instead of dead-lettering them. `hq mesh doctor` lists `needs_project` sessions.
11
+
5
12
  ## [5.139.0] — 2026-09-23
6
13
 
7
14
  ### Added
@@ -5856,6 +5856,32 @@ export declare const COMMAND_CATALOG: readonly [{
5856
5856
  readonly description: "Print machine-readable JSON";
5857
5857
  }];
5858
5858
  readonly subcommands: readonly [];
5859
+ }, {
5860
+ readonly name: "bind-project";
5861
+ readonly description: "Bind this session to a project id, a numbered candidate, or new <slug>";
5862
+ readonly aliases: readonly [];
5863
+ readonly hidden: false;
5864
+ readonly usage: "[options] <target> [slug]";
5865
+ readonly arguments: readonly [{
5866
+ readonly name: "target";
5867
+ readonly required: true;
5868
+ readonly variadic: false;
5869
+ }, {
5870
+ readonly name: "slug";
5871
+ readonly required: false;
5872
+ readonly variadic: false;
5873
+ }];
5874
+ readonly options: readonly [{
5875
+ readonly flags: "--session <sid>";
5876
+ readonly description: "Canonical session id";
5877
+ }, {
5878
+ readonly flags: "--company <slug|uid>";
5879
+ readonly description: "Company override when creating a Board";
5880
+ }, {
5881
+ readonly flags: "--json";
5882
+ readonly description: "Print machine-readable JSON";
5883
+ }];
5884
+ readonly subcommands: readonly [];
5859
5885
  }, {
5860
5886
  readonly name: "organize";
5861
5887
  readonly description: "List or submit one-time project/task decisions (US-007B / US-005B)";
@@ -7559,6 +7559,40 @@ export const COMMAND_CATALOG = [
7559
7559
  ],
7560
7560
  "subcommands": []
7561
7561
  },
7562
+ {
7563
+ "name": "bind-project",
7564
+ "description": "Bind this session to a project id, a numbered candidate, or new <slug>",
7565
+ "aliases": [],
7566
+ "hidden": false,
7567
+ "usage": "[options] <target> [slug]",
7568
+ "arguments": [
7569
+ {
7570
+ "name": "target",
7571
+ "required": true,
7572
+ "variadic": false
7573
+ },
7574
+ {
7575
+ "name": "slug",
7576
+ "required": false,
7577
+ "variadic": false
7578
+ }
7579
+ ],
7580
+ "options": [
7581
+ {
7582
+ "flags": "--session <sid>",
7583
+ "description": "Canonical session id"
7584
+ },
7585
+ {
7586
+ "flags": "--company <slug|uid>",
7587
+ "description": "Company override when creating a Board"
7588
+ },
7589
+ {
7590
+ "flags": "--json",
7591
+ "description": "Print machine-readable JSON"
7592
+ }
7593
+ ],
7594
+ "subcommands": []
7595
+ },
7562
7596
  {
7563
7597
  "name": "organize",
7564
7598
  "description": "List or submit one-time project/task decisions (US-007B / US-005B)",
@@ -12,6 +12,7 @@ import chalk from "chalk";
12
12
  import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
13
13
  import * as readline from "node:readline/promises";
14
14
  import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
15
+ import { ensureProjectBoard } from "../lib/mesh/project-board.js";
15
16
  import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
16
17
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
17
18
  import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
@@ -36,7 +37,9 @@ import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, findAncest
36
37
  import { workContextRoot } from "../lib/work-context/paths.js";
37
38
  import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-context/migrate.js";
38
39
  import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
39
- import { readSessionState } from "../lib/work-context/state.js";
40
+ import { readSessionState, updateSessionState } from "../lib/work-context/state.js";
41
+ import { findLocalPrd, listNeedsProjectSessions } from "../lib/work-context/bind-best.js";
42
+ import { WORK_CONTEXT_CONTRACT_VERSION } from "../lib/work-context/contract.js";
40
43
  import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
41
44
  import { resolveCompany } from "../lib/work-context/company.js";
42
45
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../lib/work-context/repo-remote.js";
@@ -345,47 +348,44 @@ async function runProjectSet(projectId, opts) {
345
348
  if (!current && !opts.create) {
346
349
  fail(`Work mesh project ${projectId} has no Board view. Re-run with --create to add one.`);
347
350
  }
348
- if (!current) {
349
- // Register requires a genesis thread and a project view. Create the thread
350
- // before the PUT; register runs after the view exists.
351
- await ensureProjectThread(token, company, projectId, {
352
- summary: opts.name?.trim() || source?.name || projectId,
353
- });
354
- }
355
351
  const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
356
352
  const PROJECT_CHANGED = "The Board changed while you were editing. Re-run to pick up the latest.";
357
353
  let view;
358
- try {
359
- view = await putProjectView(token, company.companyUid, projectId, {
360
- name: opts.name?.trim() || source?.name || current?.name || projectId,
361
- ...(opts.description !== undefined
362
- ? { description: opts.description }
363
- : source?.description !== undefined
364
- ? { description: source.description }
365
- : { description: current?.description ?? "" }),
366
- stories: source?.stories ?? current?.stories ?? [],
367
- repos: explicitRepos ?? source?.repos ?? current?.repos ?? [],
368
- ...(current?.version !== undefined ? { expectedVersion: current.version } : {}),
354
+ if (!current) {
355
+ const ensured = await ensureProjectBoard({
356
+ token,
357
+ company,
358
+ projectId,
359
+ name: opts.name?.trim() || source?.name || projectId,
360
+ description: opts.description !== undefined ? opts.description : source?.description ?? "",
361
+ stories: source?.stories ?? [],
362
+ repos: explicitRepos ?? source?.repos ?? [],
369
363
  });
364
+ view = ensured.view;
370
365
  }
371
- catch (err) {
372
- const conflict = err;
373
- if (conflict.status === 409 && conflict.code === "VERSION_CONFLICT") {
374
- if (opts.json) {
375
- console.log(JSON.stringify({ ok: false, code: "VERSION_CONFLICT", error: PROJECT_CHANGED }));
376
- }
377
- fail(PROJECT_CHANGED);
378
- }
379
- throw err;
380
- }
381
- if (!current) {
382
- const registration = await registerMeshProject(token, company.companyUid, projectId);
383
- if (!registration.threadId || !registration.channelId) {
384
- fail("Work mesh registration did not return a threadId and channelId.");
366
+ else {
367
+ try {
368
+ view = await putProjectView(token, company.companyUid, projectId, {
369
+ name: opts.name?.trim() || source?.name || current.name,
370
+ ...(opts.description !== undefined
371
+ ? { description: opts.description }
372
+ : source?.description !== undefined
373
+ ? { description: source.description }
374
+ : { description: current.description }),
375
+ stories: source?.stories ?? current.stories,
376
+ repos: explicitRepos ?? source?.repos ?? current.repos,
377
+ ...(current.version !== undefined ? { expectedVersion: current.version } : {}),
378
+ });
385
379
  }
386
- const verified = await getProjectView(token, company.companyUid, projectId);
387
- if (verified.projectId !== projectId || verified.companyUid !== company.companyUid) {
388
- fail(`Work mesh project set --create: verified view does not match ${company.companyUid}/${projectId}.`);
380
+ catch (err) {
381
+ const conflict = err;
382
+ if (conflict.status === 409 && conflict.code === "VERSION_CONFLICT") {
383
+ if (opts.json) {
384
+ console.log(JSON.stringify({ ok: false, code: "VERSION_CONFLICT", error: PROJECT_CHANGED }));
385
+ }
386
+ fail(PROJECT_CHANGED);
387
+ }
388
+ throw err;
389
389
  }
390
390
  }
391
391
  if (opts.json) {
@@ -422,44 +422,54 @@ async function runProjectEnsure(projectId, opts) {
422
422
  throw err;
423
423
  }
424
424
  // Genesis thread, then the view, then register. Register returns 403
425
- // PROJECT_NOT_IN_COMPANY until both exist.
426
- const genesis = await ensureProjectThread(token, company, projectId, {
427
- summary: source.name || projectId,
428
- });
429
- if (!genesis.threadId)
430
- fail("Work mesh did not return a threadId.");
425
+ // PROJECT_NOT_IN_COMPANY until both exist. A missing view uses that chain
426
+ // from ensureProjectBoard; an existing view is merged, then registered.
427
+ let registration;
428
+ let verified;
431
429
  if (!current) {
432
- await putProjectView(token, company.companyUid, projectId, {
430
+ const ensured = await ensureProjectBoard({
431
+ token,
432
+ company,
433
+ projectId,
433
434
  name: source.name || projectId,
434
435
  description: source.description ?? "",
435
436
  stories: source.stories,
436
437
  repos: source.repos ?? [],
437
438
  });
439
+ registration = ensured.registration;
440
+ verified = ensured.view;
438
441
  }
439
442
  else {
440
- const stories = mergeProjectStories(current.stories, source.stories);
441
- const repos = mergeProjectRepos(current.repos, source.repos ?? []);
442
- const name = source.name || current.name;
443
- const description = source.description !== undefined ? source.description : current.description;
444
- const changed = name !== current.name ||
445
- description !== current.description ||
446
- JSON.stringify(stories) !== JSON.stringify(current.stories) ||
447
- JSON.stringify(repos) !== JSON.stringify(current.repos);
448
- if (changed) {
449
- await putProjectView(token, company.companyUid, projectId, {
450
- name,
451
- description,
452
- stories,
453
- repos,
454
- ...(current.version !== undefined ? { expectedVersion: current.version } : {}),
455
- });
443
+ const genesis = await ensureProjectThread(token, company, projectId, {
444
+ summary: source.name || projectId,
445
+ });
446
+ if (!genesis.threadId)
447
+ fail("Work mesh did not return a threadId.");
448
+ {
449
+ const stories = mergeProjectStories(current.stories, source.stories);
450
+ const repos = mergeProjectRepos(current.repos, source.repos ?? []);
451
+ const name = source.name || current.name;
452
+ const description = source.description !== undefined ? source.description : current.description;
453
+ const changed = name !== current.name ||
454
+ description !== current.description ||
455
+ JSON.stringify(stories) !== JSON.stringify(current.stories) ||
456
+ JSON.stringify(repos) !== JSON.stringify(current.repos);
457
+ if (changed) {
458
+ await putProjectView(token, company.companyUid, projectId, {
459
+ name,
460
+ description,
461
+ stories,
462
+ repos,
463
+ ...(current.version !== undefined ? { expectedVersion: current.version } : {}),
464
+ });
465
+ }
456
466
  }
467
+ registration = await registerMeshProject(token, company.companyUid, projectId);
468
+ if (!registration.threadId || !registration.channelId) {
469
+ fail("Work mesh registration did not return a threadId and channelId.");
470
+ }
471
+ verified = await getProjectView(token, company.companyUid, projectId);
457
472
  }
458
- const registration = await registerMeshProject(token, company.companyUid, projectId);
459
- if (!registration.threadId || !registration.channelId) {
460
- fail("Work mesh registration did not return a threadId and channelId.");
461
- }
462
- const verified = await getProjectView(token, company.companyUid, projectId);
463
473
  if (verified.companyUid !== company.companyUid) {
464
474
  fail(`Work mesh project ensure: company mismatch (expected ${company.companyUid}, got ${verified.companyUid}).`);
465
475
  }
@@ -503,13 +513,86 @@ async function runDoctor(opts) {
503
513
  fail("Could not resolve the authenticated principal. Sign in again and retry `hq mesh doctor`.");
504
514
  }
505
515
  const warmed = await warmMeshConversationCache(token, principalUid);
516
+ const needsProject = listNeedsProjectSessions(workContextHomeRoot());
506
517
  if (opts.json) {
507
- console.log(JSON.stringify({ ok: true, action: "doctor", ...warmed }, null, 2));
518
+ console.log(JSON.stringify({ ok: true, action: "doctor", ...warmed, needsProject }, null, 2));
508
519
  return;
509
520
  }
510
521
  console.log(`Work mesh cache warmed at ${warmed.cacheRoot}` +
511
522
  ` (directory=${warmed.directory} inbox=${warmed.inbox}` +
512
523
  ` contacts=${warmed.contacts} pair-threads=${warmed.threads})`);
524
+ if (needsProject.length === 0) {
525
+ console.log("needs_project sessions: none");
526
+ return;
527
+ }
528
+ console.log(`needs_project sessions: ${needsProject.length}`);
529
+ for (const row of needsProject) {
530
+ const choices = row.candidates.map((candidate) => candidate.projectId).join(", ") || "(none)";
531
+ console.log(`- ${row.sessionId}: ${row.reason ?? "no reason recorded"}; candidates: ${choices}`);
532
+ }
533
+ }
534
+ async function runBindProject(target, opts) {
535
+ const sessionId = opts.session.trim();
536
+ const root = workContextHomeRoot();
537
+ const current = readSessionState(sessionId, root);
538
+ if (!current)
539
+ fail(`No work-context state for session ${sessionId}.`);
540
+ let projectId = target.trim();
541
+ if (projectId === "new") {
542
+ const slug = opts.slug?.trim();
543
+ if (!slug)
544
+ fail("Usage: hq mesh context bind-project new <slug> --session <sid>");
545
+ const hqRoot = process.env.HQ_ROOT?.trim() || findHqRoot();
546
+ const prdPath = findLocalPrd(hqRoot, current.companySlug, slug);
547
+ if (!prdPath) {
548
+ fail(`No PRD for ${slug}. Run /prd before binding a new project.`);
549
+ }
550
+ if (!current.companyUid)
551
+ fail(`Session ${sessionId} has no company. Bind a company first.`);
552
+ const { token, company } = await withCompany({ company: opts.company ?? current.companySlug });
553
+ const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath);
554
+ await ensureProjectBoard({
555
+ token,
556
+ company: {
557
+ companyUid: current.companyUid,
558
+ companySlug: current.companySlug || company.companySlug,
559
+ },
560
+ projectId: slug,
561
+ name: plan.name || slug,
562
+ description: plan.description ?? "",
563
+ stories: plan.stories,
564
+ repos: plan.repos ?? [],
565
+ });
566
+ projectId = slug;
567
+ }
568
+ else if (/^[1-9][0-9]*$/.test(projectId)) {
569
+ const index = Number(projectId) - 1;
570
+ const chosen = current.projectCandidates?.[index];
571
+ if (!chosen)
572
+ fail(`No candidate ${projectId} on session ${sessionId}.`);
573
+ projectId = chosen.projectId;
574
+ }
575
+ const updated = updateSessionState(sessionId, root, (state) => {
576
+ if (!state)
577
+ return null;
578
+ return {
579
+ ...state,
580
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
581
+ contextStatus: state.taskId ? "bound" : "needs_task",
582
+ projectId,
583
+ projectGate: undefined,
584
+ projectCandidates: undefined,
585
+ projectBindReason: `operator selected ${projectId}`,
586
+ updatedAt: new Date().toISOString(),
587
+ };
588
+ });
589
+ if (!updated)
590
+ fail(`No work-context state for session ${sessionId}.`);
591
+ if (opts.json) {
592
+ console.log(JSON.stringify({ ok: true, action: "context.bind-project", sessionId, projectId, contextStatus: updated.contextStatus }));
593
+ return;
594
+ }
595
+ console.log(`Work mesh: session ${sessionId} bound to project ${projectId}`);
513
596
  }
514
597
  async function runContextReconcile(opts) {
515
598
  if (!opts.observationFile && !opts.observationJson) {
@@ -600,6 +683,20 @@ async function runContextReconcile(opts) {
600
683
  fetchCandidates,
601
684
  offline,
602
685
  validateMembership,
686
+ ensureProjectBoard: token
687
+ ? async ({ companyUid, projectId, prdPath }) => {
688
+ const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath);
689
+ await ensureProjectBoard({
690
+ token,
691
+ company: { companyUid, companySlug: companyUid },
692
+ projectId,
693
+ name: plan.name || projectId,
694
+ description: plan.description ?? "",
695
+ stories: plan.stories,
696
+ repos: plan.repos ?? [],
697
+ });
698
+ }
699
+ : undefined,
603
700
  });
604
701
  for (const line of outcome.diagnostics) {
605
702
  console.error(line);
@@ -1545,6 +1642,15 @@ export function registerMeshCommand(program) {
1545
1642
  .option("--use-device-default", "Opt in to historical repair using the device default (lower-confidence attribution)")
1546
1643
  .option("--json", "Print machine-readable JSON")
1547
1644
  .action((opts) => wrap(() => runContextBackfillHeld(opts))());
1645
+ context
1646
+ .command("bind-project")
1647
+ .description("Bind this session to a project id, a numbered candidate, or new <slug>")
1648
+ .argument("<target>", "Project id, candidate number, or new")
1649
+ .argument("[slug]", "Project slug when target is new")
1650
+ .requiredOption("--session <sid>", "Canonical session id")
1651
+ .option("--company <slug|uid>", "Company override when creating a Board")
1652
+ .option("--json", "Print machine-readable JSON")
1653
+ .action((target, slug, opts) => wrap(() => runBindProject(target, { ...opts, slug }))());
1548
1654
  context
1549
1655
  .command("organize")
1550
1656
  .description("List or submit one-time project/task decisions (US-007B / US-005B)")
@@ -348,7 +348,26 @@ function defaultProcessCommands() {
348
348
  }
349
349
  }
350
350
  function processHasExactCommand(processCommands, command) {
351
- return processCommands.some((line) => line.trim() === command);
351
+ const laneIds = parseCanonicalWatchCommand(command);
352
+ if (!laneIds)
353
+ return false;
354
+ const expectedSuffix = ["lanes", "watch", ...laneIds];
355
+ return processCommands.some((line) => {
356
+ const tokens = line.trim().split(/\s+/u);
357
+ const suffixStart = tokens.length - expectedSuffix.length;
358
+ if (suffixStart < 1)
359
+ return false;
360
+ if (!expectedSuffix.every((token, index) => tokens[suffixStart + index] === token)) {
361
+ return false;
362
+ }
363
+ const hqToken = tokens[suffixStart - 1];
364
+ if (path.basename(hqToken) !== "hq")
365
+ return false;
366
+ const prefix = tokens.slice(0, suffixStart - 1);
367
+ return (prefix.length === 0 ||
368
+ (prefix.length === 1 &&
369
+ ["bun", "deno", "node", "nodejs", "ts-node", "tsx"].includes(path.basename(prefix[0]))));
370
+ });
352
371
  }
353
372
  export function readLiveMonitorEvidence(sessionId, options = {}) {
354
373
  if (!SESSION_ID_PATTERN.test(sessionId)) {
@@ -16,7 +16,7 @@ export declare const HELD_TTL_MS: number;
16
16
  export declare const HELD_RETRY_INTERVAL_MS: number;
17
17
  /** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
18
18
  export declare const HELD_MAX_LINES = 20000;
19
- export type HeldReason = "STATE_ABSENT" | "NEEDS_COMPANY" | "COMPANY_CONFLICT" | "HELD_EXPIRED" | "HELD_OVERFLOW";
19
+ export type HeldReason = "STATE_ABSENT" | "NEEDS_COMPANY" | "COMPANY_CONFLICT" | "NEEDS_PROJECT" | "HELD_EXPIRED" | "HELD_OVERFLOW";
20
20
  export interface HeldLineRecord {
21
21
  /** Original spool event fields. */
22
22
  event: Record<string, unknown>;
@@ -12,7 +12,7 @@ import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { resolveCompany } from "../../work-context/company.js";
14
14
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
15
- import { readSessionState } from "../../work-context/state.js";
15
+ import { readSessionState, updateSessionState } from "../../work-context/state.js";
16
16
  import { repairAckedRegisterStates } from "../../work-context/outbox.js";
17
17
  import { workContextRoot } from "../../work-context/paths.js";
18
18
  import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.js";
@@ -222,6 +222,7 @@ export async function flushSessionEvents(deps) {
222
222
  const markDisposed = (event) => {
223
223
  pendingRestore.delete(event);
224
224
  };
225
+ const loggedProject404 = new Set();
225
226
  for (const [sessionId, items] of bySession) {
226
227
  const state = readSessionState(sessionId, deps.workContextRoot);
227
228
  if (state?.companyConfidence === "device_default" &&
@@ -286,6 +287,36 @@ export async function flushSessionEvents(deps) {
286
287
  accountBatchSuccess(result.body, originalChunk, deps.workMeshRoot, now, summary, markDisposed);
287
288
  continue;
288
289
  }
290
+ if (!result.retryable && result.status === 404) {
291
+ const rejectedProjectId = decision.projectId;
292
+ updateSessionState(sessionId, deps.workContextRoot, (current) => {
293
+ if (!current)
294
+ return current;
295
+ return {
296
+ ...current,
297
+ contextStatus: "needs_project",
298
+ projectGate: "missing_board",
299
+ projectBindReason: "server returned HTTP 404 for this session's project",
300
+ projectCandidates: rejectedProjectId
301
+ ? [{ projectId: rejectedProjectId }]
302
+ : current.projectCandidates,
303
+ projectId: undefined,
304
+ taskId: undefined,
305
+ updatedAt: now().toISOString(),
306
+ };
307
+ });
308
+ if (!loggedProject404.has(sessionId)) {
309
+ loggedProject404.add(sessionId);
310
+ deps.log?.(`work-mesh: session ${sessionId} project missing (HTTP 404); downgraded to needs_project and held events`);
311
+ }
312
+ const heldAt = now().toISOString();
313
+ for (const item of originalChunk) {
314
+ appendHeldLine(heldEnvelope(item.event, "NEEDS_PROJECT", item.heldAt ?? heldAt), deps.workMeshRoot);
315
+ summary.held += 1;
316
+ markDisposed(item.event);
317
+ }
318
+ continue;
319
+ }
289
320
  if (!result.retryable) {
290
321
  const at = now().toISOString();
291
322
  for (const item of originalChunk) {
@@ -484,6 +515,9 @@ function classifySession(state) {
484
515
  if (state.contextStatus === "company_conflict") {
485
516
  return { action: "hold", heldReason: "COMPANY_CONFLICT" };
486
517
  }
518
+ if (state.projectGate === "choose" || state.projectGate === "missing_board") {
519
+ return { action: "hold", heldReason: "NEEDS_PROJECT" };
520
+ }
487
521
  if (!WRITABLE_CONTEXT_STATUSES.has(state.contextStatus)) {
488
522
  return { action: "hold", heldReason: "STATE_ABSENT" };
489
523
  }
@@ -0,0 +1,19 @@
1
+ import { type MeshCompany, type MeshProjectRegistration, type MeshProjectRepo, type MeshProjectStoryInput, type MeshProjectView } from "./api.js";
2
+ /**
3
+ * Create a missing Board. Register returns 403 PROJECT_NOT_IN_COMPANY until
4
+ * the genesis thread and the project view both exist, so the order is fixed:
5
+ * thread, view, register, then a GET to confirm the view.
6
+ */
7
+ export declare function ensureProjectBoard(input: {
8
+ token: string;
9
+ company: MeshCompany;
10
+ projectId: string;
11
+ name: string;
12
+ description?: string;
13
+ stories: MeshProjectStoryInput[];
14
+ repos?: MeshProjectRepo[];
15
+ }): Promise<{
16
+ view: MeshProjectView;
17
+ registration: MeshProjectRegistration;
18
+ }>;
19
+ //# sourceMappingURL=project-board.d.ts.map
@@ -0,0 +1,27 @@
1
+ import { ensureProjectThread, getProjectView, putProjectView, registerMeshProject, } from "./api.js";
2
+ /**
3
+ * Create a missing Board. Register returns 403 PROJECT_NOT_IN_COMPANY until
4
+ * the genesis thread and the project view both exist, so the order is fixed:
5
+ * thread, view, register, then a GET to confirm the view.
6
+ */
7
+ export async function ensureProjectBoard(input) {
8
+ const genesis = await ensureProjectThread(input.token, input.company, input.projectId, {
9
+ summary: input.name || input.projectId,
10
+ });
11
+ if (!genesis.threadId) {
12
+ throw new Error("Work mesh did not return a threadId.");
13
+ }
14
+ await putProjectView(input.token, input.company.companyUid, input.projectId, {
15
+ name: input.name || input.projectId,
16
+ description: input.description ?? "",
17
+ stories: input.stories,
18
+ repos: input.repos ?? [],
19
+ });
20
+ const registration = await registerMeshProject(input.token, input.company.companyUid, input.projectId);
21
+ const view = await getProjectView(input.token, input.company.companyUid, input.projectId);
22
+ if (view.projectId !== input.projectId || view.companyUid !== input.company.companyUid) {
23
+ throw new Error(`Work mesh project board does not match ${input.company.companyUid}/${input.projectId}.`);
24
+ }
25
+ return { view, registration };
26
+ }
27
+ //# sourceMappingURL=project-board.js.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * US-036: pick the best existing project for a session hint.
3
+ * Exact Board, then exact local PRD, then a ranked match.
4
+ * A tie or an empty field stays unbound so the session can ask.
5
+ */
6
+ export interface BindCandidate {
7
+ projectId: string;
8
+ title?: string;
9
+ score: number;
10
+ }
11
+ export interface CatalogProject {
12
+ projectId: string;
13
+ title?: string;
14
+ repos: string[];
15
+ journalMtimeMs?: number;
16
+ hasPrd: boolean;
17
+ hasBoard: boolean;
18
+ prdPath?: string;
19
+ }
20
+ export type RefineAction = {
21
+ action: "keep";
22
+ } | {
23
+ action: "bind";
24
+ projectId: string;
25
+ reason: string;
26
+ } | {
27
+ action: "create";
28
+ projectId: string;
29
+ prdPath: string;
30
+ reason: string;
31
+ } | {
32
+ action: "ask";
33
+ reason: string;
34
+ candidates: BindCandidate[];
35
+ };
36
+ export declare function scoreProject(hint: string, row: CatalogProject, repoPath: string | undefined, nowMs: number): number;
37
+ export declare function discoverProjects(input: {
38
+ hqRoot?: string;
39
+ companySlug?: string;
40
+ companyUid?: string;
41
+ env?: NodeJS.ProcessEnv;
42
+ home?: string;
43
+ }): CatalogProject[];
44
+ export declare function refineProjectBinding(input: {
45
+ hint?: string;
46
+ hqRoot?: string;
47
+ companySlug?: string;
48
+ companyUid?: string;
49
+ repoPath?: string;
50
+ env?: NodeJS.ProcessEnv;
51
+ home?: string;
52
+ nowMs?: number;
53
+ }): RefineAction;
54
+ export declare function findLocalPrd(hqRoot: string | undefined, companySlug: string | undefined, projectId: string): string | null;
55
+ export declare function listNeedsProjectSessions(workContextRoot: string): Array<{
56
+ sessionId: string;
57
+ reason?: string;
58
+ candidates: BindCandidate[];
59
+ }>;
60
+ export declare function formatProjectAsk(candidates: Array<{
61
+ projectId: string;
62
+ title?: string;
63
+ }>): string;
64
+ //# sourceMappingURL=bind-best.d.ts.map
@@ -0,0 +1,299 @@
1
+ /**
2
+ * US-036: pick the best existing project for a session hint.
3
+ * Exact Board, then exact local PRD, then a ranked match.
4
+ * A tie or an empty field stays unbound so the session can ask.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { meshCacheRoot } from "../mesh/cache.js";
10
+ const MARGIN = 15;
11
+ const CLEAR_FLOOR = 25;
12
+ const DAY_MS = 24 * 60 * 60 * 1000;
13
+ function tokens(value) {
14
+ return value
15
+ .toLowerCase()
16
+ .split(/[^a-z0-9]+/)
17
+ .filter((part) => part.length > 2);
18
+ }
19
+ export function scoreProject(hint, row, repoPath, nowMs) {
20
+ const hintL = hint.toLowerCase();
21
+ const idL = row.projectId.toLowerCase();
22
+ let score = 0;
23
+ if (idL === hintL)
24
+ score += 100;
25
+ else if (idL.startsWith(hintL) || hintL.startsWith(idL))
26
+ score += 40;
27
+ const hintTokens = new Set(tokens(hint));
28
+ const titleTokens = new Set(tokens(`${row.projectId} ${row.title ?? ""}`));
29
+ for (const token of hintTokens) {
30
+ if (titleTokens.has(token))
31
+ score += 15;
32
+ }
33
+ if (repoPath) {
34
+ const repoL = repoPath.toLowerCase();
35
+ if (row.repos.some((repo) => {
36
+ const item = repo.toLowerCase();
37
+ return item.length > 0 && (repoL.includes(item) || item.includes(repoL));
38
+ })) {
39
+ score += 50;
40
+ }
41
+ }
42
+ if (row.journalMtimeMs) {
43
+ const age = nowMs - row.journalMtimeMs;
44
+ if (age >= 0 && age < 14 * DAY_MS)
45
+ score += 10;
46
+ else if (age >= 0 && age < 60 * DAY_MS)
47
+ score += 5;
48
+ }
49
+ return score;
50
+ }
51
+ function readJson(filePath) {
52
+ try {
53
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
54
+ return parsed && typeof parsed === "object" ? parsed : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ function reposFromMetadata(metadata) {
61
+ if (!metadata || typeof metadata !== "object")
62
+ return [];
63
+ const repos = metadata.repos;
64
+ if (!Array.isArray(repos))
65
+ return [];
66
+ const out = [];
67
+ for (const repo of repos) {
68
+ if (typeof repo === "string" && repo.trim())
69
+ out.push(repo.trim());
70
+ else if (repo && typeof repo === "object" && typeof repo.path === "string") {
71
+ const value = repo.path.trim();
72
+ if (value)
73
+ out.push(value);
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ function journalMtime(dir) {
79
+ const candidates = ["journal.md", "journal.json", path.join("journal", "index.md")];
80
+ let newest = 0;
81
+ for (const name of candidates) {
82
+ try {
83
+ const stat = fs.statSync(path.join(dir, name));
84
+ if (stat.mtimeMs > newest)
85
+ newest = stat.mtimeMs;
86
+ }
87
+ catch {
88
+ /* absent */
89
+ }
90
+ }
91
+ try {
92
+ const journalDir = path.join(dir, "journal");
93
+ for (const name of fs.readdirSync(journalDir)) {
94
+ const stat = fs.statSync(path.join(journalDir, name));
95
+ if (stat.mtimeMs > newest)
96
+ newest = stat.mtimeMs;
97
+ }
98
+ }
99
+ catch {
100
+ /* absent */
101
+ }
102
+ return newest > 0 ? newest : undefined;
103
+ }
104
+ function absorbDir(map, dir, projectId) {
105
+ const prdPath = path.join(dir, "prd.json");
106
+ const prd = fs.existsSync(prdPath) ? readJson(prdPath) : null;
107
+ const current = map.get(projectId) ?? {
108
+ projectId,
109
+ repos: [],
110
+ hasPrd: false,
111
+ hasBoard: false,
112
+ };
113
+ if (prd) {
114
+ current.hasPrd = true;
115
+ current.prdPath = prdPath;
116
+ const title = typeof prd.name === "string" ? prd.name
117
+ : typeof prd.title === "string" ? prd.title
118
+ : undefined;
119
+ if (title)
120
+ current.title = title;
121
+ const repos = reposFromMetadata(prd.metadata);
122
+ if (repos.length)
123
+ current.repos = repos;
124
+ }
125
+ const journal = journalMtime(dir);
126
+ if (journal)
127
+ current.journalMtimeMs = Math.max(current.journalMtimeMs ?? 0, journal);
128
+ map.set(projectId, current);
129
+ }
130
+ function listProjectDirs(parent, map) {
131
+ if (!fs.existsSync(parent))
132
+ return;
133
+ let names;
134
+ try {
135
+ names = fs.readdirSync(parent);
136
+ }
137
+ catch {
138
+ return;
139
+ }
140
+ for (const name of names) {
141
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
142
+ continue;
143
+ const dir = path.join(parent, name);
144
+ try {
145
+ if (!fs.statSync(dir).isDirectory())
146
+ continue;
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ absorbDir(map, dir, name);
152
+ }
153
+ }
154
+ export function discoverProjects(input) {
155
+ const map = new Map();
156
+ const hqRoot = input.hqRoot?.trim();
157
+ const slug = input.companySlug?.trim();
158
+ if (hqRoot && slug) {
159
+ listProjectDirs(path.join(hqRoot, "companies", slug, "projects"), map);
160
+ listProjectDirs(path.join(hqRoot, "personal", "projects"), map);
161
+ }
162
+ if (input.companyUid) {
163
+ const cache = path.join(meshCacheRoot(input.home ?? os.homedir(), input.env), "projects", input.companyUid);
164
+ if (fs.existsSync(cache)) {
165
+ for (const name of fs.readdirSync(cache)) {
166
+ if (!name.endsWith(".json"))
167
+ continue;
168
+ const projectId = name.slice(0, -".json".length);
169
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(projectId))
170
+ continue;
171
+ const view = readJson(path.join(cache, name));
172
+ const current = map.get(projectId) ?? {
173
+ projectId,
174
+ repos: [],
175
+ hasPrd: false,
176
+ hasBoard: false,
177
+ };
178
+ current.hasBoard = true;
179
+ const title = typeof view?.name === "string" ? view.name : undefined;
180
+ if (title && !current.title)
181
+ current.title = title;
182
+ const repos = Array.isArray(view?.repos) ? reposFromMetadata({ repos: view?.repos }) : [];
183
+ if (repos.length && current.repos.length === 0)
184
+ current.repos = repos;
185
+ map.set(projectId, current);
186
+ }
187
+ }
188
+ }
189
+ return [...map.values()];
190
+ }
191
+ export function refineProjectBinding(input) {
192
+ const hint = input.hint?.trim();
193
+ if (!hint)
194
+ return { action: "keep" };
195
+ const catalog = discoverProjects(input);
196
+ const exact = catalog.find((row) => row.projectId === hint);
197
+ if (exact?.hasBoard) {
198
+ return { action: "bind", projectId: hint, reason: "exact slug has a Board" };
199
+ }
200
+ if (exact?.hasPrd && exact.prdPath) {
201
+ return {
202
+ action: "create",
203
+ projectId: hint,
204
+ prdPath: exact.prdPath,
205
+ reason: "exact slug has a local PRD; Board created from that PRD",
206
+ };
207
+ }
208
+ if (catalog.length === 0)
209
+ return { action: "keep" };
210
+ const nowMs = input.nowMs ?? Date.now();
211
+ const ranked = catalog
212
+ .filter((row) => row.projectId !== hint)
213
+ .map((row) => ({
214
+ projectId: row.projectId,
215
+ title: row.title,
216
+ score: scoreProject(hint, row, input.repoPath, nowMs),
217
+ }))
218
+ .filter((row) => row.score > 0)
219
+ .sort((a, b) => b.score - a.score || a.projectId.localeCompare(b.projectId));
220
+ if (ranked.length === 0) {
221
+ return {
222
+ action: "ask",
223
+ reason: `no project matches ${hint}`,
224
+ candidates: [],
225
+ };
226
+ }
227
+ const top = ranked[0];
228
+ const second = ranked[1];
229
+ const clear = top.score >= CLEAR_FLOOR && (!second || top.score - second.score >= MARGIN);
230
+ if (clear) {
231
+ return {
232
+ action: "bind",
233
+ projectId: top.projectId,
234
+ reason: `best match for ${hint}: ${top.projectId} (score ${top.score})`,
235
+ };
236
+ }
237
+ return {
238
+ action: "ask",
239
+ reason: `unclear match for ${hint}`,
240
+ candidates: ranked.slice(0, 5).map((row) => ({
241
+ projectId: row.projectId,
242
+ ...(row.title ? { title: row.title } : {}),
243
+ score: row.score,
244
+ })),
245
+ };
246
+ }
247
+ export function findLocalPrd(hqRoot, companySlug, projectId) {
248
+ if (!hqRoot || !companySlug)
249
+ return null;
250
+ const candidates = [
251
+ path.join(hqRoot, "companies", companySlug, "projects", projectId, "prd.json"),
252
+ path.join(hqRoot, "personal", "projects", projectId, "prd.json"),
253
+ ];
254
+ return candidates.find((file) => fs.existsSync(file)) ?? null;
255
+ }
256
+ export function listNeedsProjectSessions(workContextRoot) {
257
+ const dir = path.join(workContextRoot, "sessions");
258
+ if (!fs.existsSync(dir))
259
+ return [];
260
+ const out = [];
261
+ for (const name of fs.readdirSync(dir)) {
262
+ if (!name.endsWith(".json"))
263
+ continue;
264
+ const raw = readJson(path.join(dir, name));
265
+ if (!raw || raw.contextStatus !== "needs_project")
266
+ continue;
267
+ const sessionId = typeof raw.sessionId === "string" ? raw.sessionId : name.replace(/\.json$/, "");
268
+ const candidates = Array.isArray(raw.projectCandidates)
269
+ ? raw.projectCandidates.flatMap((item) => {
270
+ if (!item || typeof item !== "object")
271
+ return [];
272
+ const projectId = item.projectId;
273
+ if (typeof projectId !== "string" || !projectId)
274
+ return [];
275
+ const title = item.title;
276
+ const score = item.score;
277
+ return [{
278
+ projectId,
279
+ ...(typeof title === "string" ? { title } : {}),
280
+ score: typeof score === "number" ? score : 0,
281
+ }];
282
+ })
283
+ : [];
284
+ out.push({
285
+ sessionId,
286
+ ...(typeof raw.projectBindReason === "string" ? { reason: raw.projectBindReason } : {}),
287
+ candidates,
288
+ });
289
+ }
290
+ return out.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
291
+ }
292
+ export function formatProjectAsk(candidates) {
293
+ const parts = candidates.map((candidate, index) => {
294
+ const title = candidate.title?.trim() || candidate.projectId;
295
+ return `${index + 1}) ${candidate.projectId} ${title}`;
296
+ });
297
+ return `This session is not bound to a project. Best matches: ${parts.join(" ")}. Reply with a number, a project id, or \`new <slug>\`.`;
298
+ }
299
+ //# sourceMappingURL=bind-best.js.map
@@ -61,6 +61,14 @@ export interface ContextResult {
61
61
  bindingEpisodeId?: string;
62
62
  clientOperationId: string;
63
63
  errorCode?: string;
64
+ /** Why a project was chosen, or why the session is asking. Local consumers only. */
65
+ projectBindReason?: string;
66
+ /** Ranked alternatives when classification is needs_project. */
67
+ projectCandidates?: Array<{
68
+ projectId: string;
69
+ title?: string;
70
+ score?: number;
71
+ }>;
64
72
  }
65
73
  export type NormalizeTaskIdResult = {
66
74
  ok: true;
@@ -69,6 +69,15 @@ export interface ReconcileDeps {
69
69
  * companyUid when local resolution only yielded a slug. Injectable for tests.
70
70
  */
71
71
  validateMembership?: (slug: string) => Promise<MembershipValidationResult> | MembershipValidationResult;
72
+ /**
73
+ * Register a Board and PUT the local PRD. Called only when online and the
74
+ * exact slug already has a local project folder.
75
+ */
76
+ ensureProjectBoard?: (input: {
77
+ companyUid: string;
78
+ projectId: string;
79
+ prdPath: string;
80
+ }) => Promise<void>;
72
81
  }
73
82
  export interface ReconcileOutcome {
74
83
  result: ContextResult;
@@ -9,6 +9,7 @@ import { invalidateDefaultCompanyMemberships, } from "./config.js";
9
9
  import { WORK_CONTEXT_CONTRACT_VERSION, normalizeTaskId, } from "./contract.js";
10
10
  import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOriginError, NotTrackingError, } from "./errors.js";
11
11
  import { decisionFromCandidates, } from "./organize.js";
12
+ import { refineProjectBinding } from "./bind-best.js";
12
13
  import { resolveProjectTask, shouldAskAfter } from "./project.js";
13
14
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
14
15
  import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
@@ -35,6 +36,10 @@ function resultOf(parts) {
35
36
  out.bindingEpisodeId = parts.bindingEpisodeId;
36
37
  if (parts.errorCode)
37
38
  out.errorCode = parts.errorCode;
39
+ if (parts.projectBindReason)
40
+ out.projectBindReason = parts.projectBindReason;
41
+ if (parts.projectCandidates)
42
+ out.projectCandidates = parts.projectCandidates;
38
43
  return out;
39
44
  }
40
45
  function parseUntrackedOrigin(obs) {
@@ -441,8 +446,77 @@ export async function reconcileObservation(obs, deps) {
441
446
  projectResolved = null;
442
447
  }
443
448
  }
444
- const projectId = projectResolved?.projectId;
445
- const taskId = projectResolved?.taskId;
449
+ let projectId = projectResolved?.projectId;
450
+ let taskId = projectResolved?.taskId;
451
+ let projectBindReason;
452
+ let projectCandidates;
453
+ let projectGate;
454
+ if (projectResolved &&
455
+ projectId &&
456
+ projectResolved.source !== "existing_binding" &&
457
+ obs.source !== "transcript" &&
458
+ hqRoot &&
459
+ resolvedCompany.slug) {
460
+ const refined = refineProjectBinding({
461
+ hint: projectId,
462
+ hqRoot,
463
+ companySlug: resolvedCompany.slug,
464
+ companyUid: resolvedCompany.uid,
465
+ repoPath: cwd,
466
+ env,
467
+ nowMs: nowFn().getTime(),
468
+ });
469
+ if (refined.action === "bind") {
470
+ if (refined.projectId !== projectId)
471
+ taskId = undefined;
472
+ projectId = refined.projectId;
473
+ projectBindReason = refined.reason;
474
+ diagnostics.push(`project bind: ${refined.reason}`);
475
+ }
476
+ else if (refined.action === "create") {
477
+ projectBindReason = refined.reason;
478
+ if (!deps.offline && resolvedCompany.uid && deps.ensureProjectBoard) {
479
+ try {
480
+ await deps.ensureProjectBoard({
481
+ companyUid: resolvedCompany.uid,
482
+ projectId,
483
+ prdPath: refined.prdPath,
484
+ });
485
+ diagnostics.push(`project board created for ${projectId}`);
486
+ }
487
+ catch (err) {
488
+ const message = err instanceof Error ? err.message : String(err);
489
+ diagnostics.push(`project board create failed: ${message}`);
490
+ projectBindReason = `Board create failed for ${projectId}: ${message}`;
491
+ projectCandidates = [{ projectId, title: projectId }];
492
+ projectGate = "choose";
493
+ projectId = undefined;
494
+ taskId = undefined;
495
+ projectResolved = null;
496
+ }
497
+ }
498
+ else {
499
+ diagnostics.push(`project bind: local PRD for ${projectId}; Board create waits for online reconcile`);
500
+ }
501
+ }
502
+ else if (refined.action === "ask") {
503
+ projectBindReason = refined.reason;
504
+ projectCandidates = refined.candidates;
505
+ projectGate = "choose";
506
+ projectId = undefined;
507
+ taskId = undefined;
508
+ projectResolved = null;
509
+ diagnostics.push(`project ask: ${refined.reason}`);
510
+ }
511
+ }
512
+ const bindResultFields = {
513
+ ...(projectBindReason ? { projectBindReason } : {}),
514
+ ...(projectCandidates ? { projectCandidates } : {}),
515
+ };
516
+ const bindStateFields = {
517
+ ...bindResultFields,
518
+ ...(projectGate ? { projectGate } : {}),
519
+ };
446
520
  // Trusted / deterministic auto-bind never asks.
447
521
  // Transcript-sourced sessions never ask (US-018).
448
522
  const fromTranscript = obs.source === "transcript";
@@ -457,7 +531,10 @@ export async function reconcileObservation(obs, deps) {
457
531
  (projectResolved.source === "deterministic_cwd" ||
458
532
  projectResolved.source === "deterministic_registry"));
459
533
  let classification;
460
- if (autoBound) {
534
+ if (projectGate === "choose") {
535
+ classification = "needs_project";
536
+ }
537
+ else if (autoBound) {
461
538
  classification = "bound";
462
539
  }
463
540
  else if (projectId && taskId) {
@@ -569,6 +646,7 @@ export async function reconcileObservation(obs, deps) {
569
646
  ...(typeof toolWrites === "number" ? { toolWrites } : {}),
570
647
  ...(hasTurn ? { hasTurn: true } : {}),
571
648
  ...(decision ? { decision } : {}),
649
+ ...bindStateFields,
572
650
  ...transcriptFields,
573
651
  }, prior), deps.root);
574
652
  const kind = registerClassification === "bound"
@@ -591,6 +669,7 @@ export async function reconcileObservation(obs, deps) {
591
669
  projectId,
592
670
  taskId,
593
671
  bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
672
+ ...bindResultFields,
594
673
  }),
595
674
  exitCode: EXIT_OK,
596
675
  diagnostics,
@@ -652,6 +731,7 @@ export async function reconcileObservation(obs, deps) {
652
731
  ...(hasTurn ? { hasTurn: true } : {}),
653
732
  ...(decision ? { decision } : {}),
654
733
  ...(companyConfidence ? { companyConfidence } : {}),
734
+ ...bindStateFields,
655
735
  ...transcriptFields,
656
736
  }, prior);
657
737
  statePath = writeSessionState(stateWrite, deps.root);
@@ -690,6 +770,7 @@ export async function reconcileObservation(obs, deps) {
690
770
  companySlug: resolvedCompany.slug,
691
771
  projectId,
692
772
  taskId,
773
+ ...bindResultFields,
693
774
  }),
694
775
  exitCode: EXIT_OK,
695
776
  diagnostics: [...diagnostics, "offline/local: operation remains queued"],
@@ -736,6 +817,7 @@ export async function reconcileObservation(obs, deps) {
736
817
  startedAt,
737
818
  ...(typeof toolWrites === "number" ? { toolWrites } : {}),
738
819
  ...(hasTurn ? { hasTurn: true } : {}),
820
+ ...bindStateFields,
739
821
  ...transcriptFields,
740
822
  },
741
823
  // Merge against the just-written projection rather than the stale
@@ -761,6 +843,7 @@ export async function reconcileObservation(obs, deps) {
761
843
  projectId,
762
844
  taskId,
763
845
  bindingEpisodeId: delivered.receiptId,
846
+ ...bindResultFields,
764
847
  }),
765
848
  exitCode: EXIT_OK,
766
849
  diagnostics: [...diagnostics, `acked receipt=${delivered.receiptId}`],
@@ -781,6 +864,7 @@ export async function reconcileObservation(obs, deps) {
781
864
  projectId,
782
865
  taskId,
783
866
  errorCode: delivered.code,
867
+ ...bindResultFields,
784
868
  }),
785
869
  exitCode: EXIT_OK,
786
870
  diagnostics: [...diagnostics, `transient ${delivered.code}; remains queued`],
@@ -801,6 +885,7 @@ export async function reconcileObservation(obs, deps) {
801
885
  projectId,
802
886
  taskId,
803
887
  errorCode: delivered.code,
888
+ ...bindResultFields,
804
889
  }),
805
890
  exitCode: EXIT_OK,
806
891
  diagnostics: [...diagnostics, `quarantined: ${delivered.code}`],
@@ -91,6 +91,19 @@ export interface SessionStateFile {
91
91
  transcriptMarker?: "from transcript";
92
92
  /** Historical repair under device-default evidence is intentionally weak. */
93
93
  companyConfidence?: "device_default";
94
+ /** Why the current project was chosen, or why the session is asking. */
95
+ projectBindReason?: string;
96
+ /** Ranked alternatives when the session is waiting for a project choice. */
97
+ projectCandidates?: Array<{
98
+ projectId: string;
99
+ title?: string;
100
+ score?: number;
101
+ }>;
102
+ /**
103
+ * When set, flush holds events instead of posting them.
104
+ * `choose` — the match was unclear. `missing_board` — the server 404'd the project.
105
+ */
106
+ projectGate?: "choose" | "missing_board";
94
107
  updatedAt: string;
95
108
  }
96
109
  /**
@@ -29,6 +29,9 @@ export const SESSION_STATE_ALLOWLIST = new Set([
29
29
  "localHarness",
30
30
  "transcriptMarker",
31
31
  "companyConfidence",
32
+ "projectBindReason",
33
+ "projectCandidates",
34
+ "projectGate",
32
35
  "updatedAt",
33
36
  ]);
34
37
  const SESSION_STATE_LOCK_TIMEOUT_MS = 5_000;
@@ -228,6 +231,12 @@ export function projectSessionState(state) {
228
231
  out.transcriptMarker = state.transcriptMarker;
229
232
  if (state.companyConfidence)
230
233
  out.companyConfidence = state.companyConfidence;
234
+ if (state.projectBindReason)
235
+ out.projectBindReason = state.projectBindReason;
236
+ if (state.projectCandidates?.length)
237
+ out.projectCandidates = state.projectCandidates;
238
+ if (state.projectGate)
239
+ out.projectGate = state.projectGate;
231
240
  return out;
232
241
  }
233
242
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.139.0",
3
+ "version": "5.140.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {