@indigoai-us/hq-cli 5.144.0 → 5.146.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 (34) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/command-catalog.generated.d.ts +24 -6
  3. package/dist/command-catalog.generated.js +29 -6
  4. package/dist/commands/_patterns.d.ts +16 -0
  5. package/dist/commands/_patterns.js +20 -3
  6. package/dist/commands/files-browse.d.ts +21 -10
  7. package/dist/commands/files-browse.js +95 -32
  8. package/dist/commands/files.js +44 -13
  9. package/dist/commands/mesh.js +115 -1
  10. package/dist/commands/report.d.ts +13 -0
  11. package/dist/commands/report.js +35 -13
  12. package/dist/commands/sync-narrow.js +36 -5
  13. package/dist/commands/workers.js +5 -2
  14. package/dist/lib/mesh/live/daemon/credentials.d.ts +37 -0
  15. package/dist/lib/mesh/live/daemon/credentials.js +141 -10
  16. package/dist/lib/mesh/live/daemon/doctor.js +4 -3
  17. package/dist/lib/mesh/live/daemon/presence.d.ts +15 -0
  18. package/dist/lib/mesh/live/daemon/presence.js +69 -1
  19. package/dist/lib/mesh/live/daemon/run.js +3 -1
  20. package/dist/lib/mesh/project-board.d.ts +14 -0
  21. package/dist/lib/mesh/project-board.js +24 -1
  22. package/dist/lib/work-context/bind-best.d.ts +2 -0
  23. package/dist/lib/work-context/bind-best.js +34 -1
  24. package/dist/lib/work-context/bind-calling-session.d.ts +26 -0
  25. package/dist/lib/work-context/bind-calling-session.js +63 -0
  26. package/dist/lib/work-context/prd-sync.d.ts +34 -0
  27. package/dist/lib/work-context/prd-sync.js +120 -0
  28. package/dist/lib/work-context/prompt-rebind.d.ts +21 -0
  29. package/dist/lib/work-context/prompt-rebind.js +76 -0
  30. package/dist/lib/work-context/reconcile.d.ts +2 -0
  31. package/dist/lib/work-context/reconcile.js +31 -0
  32. package/dist/lib/work-context/state.d.ts +2 -0
  33. package/dist/lib/work-context/state.js +8 -0
  34. package/package.json +1 -1
@@ -2,9 +2,13 @@
2
2
  * Contract-3 realtime credential vend + 80%-of-lifetime renewal.
3
3
  * Never logs or persists credential material.
4
4
  */
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
5
7
  import { DEFAULT_VAULT_API_URL } from "../../../../utils/cognito-session.js";
6
8
  import { vaultApiFetch } from "../../../../utils/vault-api.js";
7
9
  import { readAgentIdentityCompanyUid } from "../../../work-context/company.js";
10
+ import { readDeviceConfig } from "../../../work-context/config.js";
11
+ import { workContextSessionsDir } from "../../../work-context/paths.js";
8
12
  export const REALTIME_CREDENTIALS_PATH = "/v1/realtime/credentials";
9
13
  export const MQTT_KEEPALIVE_SECONDS = 15;
10
14
  /** Renew at 80% of credential lifetime. */
@@ -20,6 +24,12 @@ export const REFUSED_RETRY_ENV = "HQ_MESH_DAEMON_REFUSED_RETRY_MS";
20
24
  export const MAX_TIMER_DELAY_MS = 2_147_483_647;
21
25
  /** Cap any single retry so a bogus Retry-After cannot park the daemon. */
22
26
  export const MAX_RETRY_DELAY_MS = 24 * 60 * 60 * 1000;
27
+ /** Contract-3 vend accepts at most this many preferred company uids. */
28
+ export const PREFERRED_COMPANY_LIMIT = 5;
29
+ /** Session files older than this do not count as companies in use. */
30
+ export const PREFERRED_SESSION_WINDOW_MS = 24 * 60 * 60 * 1000;
31
+ /** Preferred-set changes re-vend at most this often. */
32
+ export const PREFERRED_REVEND_MIN_MS = 60_000;
23
33
  /**
24
34
  * Typed vend failure. `refused` = server will not vend right now
25
35
  * (FEATURE_DISABLED / REALTIME_CONTRACT_UNSUPPORTED / 403); daemon should
@@ -241,8 +251,14 @@ export function normalizeContract3Bundle(raw) {
241
251
  const companyUid = typeof row.companyUid === "string" ? row.companyUid.trim() : "";
242
252
  if (!companyUid)
243
253
  continue;
254
+ const slug = typeof row.companySlug === "string"
255
+ ? row.companySlug.trim()
256
+ : typeof row.slug === "string"
257
+ ? row.slug.trim()
258
+ : "";
244
259
  droppedCompanies.push({
245
260
  companyUid,
261
+ ...(slug ? { label: slug } : {}),
246
262
  reason: typeof row.reason === "string" && row.reason.trim()
247
263
  ? row.reason.trim()
248
264
  : "not included",
@@ -282,6 +298,101 @@ export function scopeBundleToAgentIdentity(bundle, env = process.env) {
282
298
  }
283
299
  return { ...bundle, companies: matched };
284
300
  }
301
+ function pushDistinct(out, seen, uid) {
302
+ const trimmed = uid?.trim();
303
+ if (!trimmed || seen.has(trimmed) || out.length >= PREFERRED_COMPANY_LIMIT)
304
+ return;
305
+ seen.add(trimmed);
306
+ out.push(trimmed);
307
+ }
308
+ /**
309
+ * Device default first, then distinct company uids from session files updated
310
+ * in the last 24 hours, newest first, max 5. Any read error returns undefined
311
+ * so the vend body omits the field.
312
+ */
313
+ export function readPreferredCompanyUids(input) {
314
+ try {
315
+ const nowMs = input.nowMs ?? Date.now();
316
+ const config = readDeviceConfig({ root: input.workContextRoot });
317
+ const out = [];
318
+ const seen = new Set();
319
+ if (config.defaultCompany?.enabled && config.defaultCompany.uid) {
320
+ pushDistinct(out, seen, config.defaultCompany.uid);
321
+ }
322
+ const dir = workContextSessionsDir(input.workContextRoot);
323
+ let names = [];
324
+ try {
325
+ names = fs.readdirSync(dir);
326
+ }
327
+ catch (err) {
328
+ const code = err.code;
329
+ if (code !== "ENOENT")
330
+ throw err;
331
+ }
332
+ const recent = [];
333
+ for (const name of names) {
334
+ if (!name.endsWith(".json"))
335
+ continue;
336
+ const full = path.join(dir, name);
337
+ const stat = fs.statSync(full);
338
+ if (!stat.isFile())
339
+ continue;
340
+ const raw = JSON.parse(fs.readFileSync(full, "utf8"));
341
+ const parsedAt = typeof raw.updatedAt === "string" ? Date.parse(raw.updatedAt) : Number.NaN;
342
+ const at = Number.isFinite(parsedAt) ? parsedAt : stat.mtimeMs;
343
+ if (!Number.isFinite(at) || nowMs - at > PREFERRED_SESSION_WINDOW_MS)
344
+ continue;
345
+ if (nowMs < at)
346
+ continue;
347
+ if (typeof raw.companyUid !== "string")
348
+ continue;
349
+ recent.push({ uid: raw.companyUid, at });
350
+ }
351
+ recent.sort((a, b) => b.at - a.at || a.uid.localeCompare(b.uid));
352
+ for (const row of recent)
353
+ pushDistinct(out, seen, row.uid);
354
+ return out;
355
+ }
356
+ catch {
357
+ return undefined;
358
+ }
359
+ }
360
+ export function preferredCompanyKey(uids) {
361
+ return (uids ?? []).join("\n");
362
+ }
363
+ /** True when a new preferred set should force a vend, at most once per minute. */
364
+ export function shouldRevendPreferredCompanies(input) {
365
+ if (!input.next)
366
+ return false;
367
+ if (preferredCompanyKey(input.previous) === preferredCompanyKey(input.next)) {
368
+ return false;
369
+ }
370
+ const min = input.minIntervalMs ?? PREFERRED_REVEND_MIN_MS;
371
+ return input.nowMs - input.lastVendMs >= min;
372
+ }
373
+ export function contract3VendBody(preferredCompanyUids) {
374
+ const body = {
375
+ contractVersion: 3,
376
+ };
377
+ if (preferredCompanyUids && preferredCompanyUids.length > 0) {
378
+ body.preferredCompanyUids = preferredCompanyUids.slice(0, PREFERRED_COMPANY_LIMIT);
379
+ }
380
+ return body;
381
+ }
382
+ /** Plain doctor line for one company the vend left out. */
383
+ export function formatDroppedPresenceCompany(company) {
384
+ const name = company.label?.trim() || company.companyUid;
385
+ if (company.reason === "gate_disabled") {
386
+ return `${name}: not included (presence is turned off for this company)`;
387
+ }
388
+ if (company.reason === "policy_budget") {
389
+ return `${name}: not included (too many companies for one credential)`;
390
+ }
391
+ if (!company.reason || company.reason === "unknown" || company.reason === "not included") {
392
+ return `${name}: not included`;
393
+ }
394
+ return `${name}: not included (${company.reason})`;
395
+ }
285
396
  function headerGet(headers, name) {
286
397
  if (!headers)
287
398
  return null;
@@ -294,11 +405,19 @@ function headerGet(headers, name) {
294
405
  }
295
406
  export function createContract3Fetcher(opts) {
296
407
  return async () => {
408
+ let preferred;
409
+ if (opts.readPreferredCompanyUids) {
410
+ try {
411
+ preferred = opts.readPreferredCompanyUids();
412
+ }
413
+ catch {
414
+ preferred = undefined;
415
+ }
416
+ }
417
+ const body = contract3VendBody(preferred);
297
418
  let bundle;
298
419
  if (opts.post) {
299
- const res = await opts.post(REALTIME_CREDENTIALS_PATH, {
300
- contractVersion: 3,
301
- });
420
+ const res = await opts.post(REALTIME_CREDENTIALS_PATH, body);
302
421
  if (res.status < 200 || res.status >= 300) {
303
422
  throw classifyCredentialVendFailure(res.status, res.body, headerGet(res.headers, "Retry-After"));
304
423
  }
@@ -309,23 +428,23 @@ export function createContract3Fetcher(opts) {
309
428
  token: opts.token,
310
429
  path: REALTIME_CREDENTIALS_PATH,
311
430
  method: "POST",
312
- body: { contractVersion: 3 },
431
+ body,
313
432
  baseUrl: opts.baseUrl ?? DEFAULT_VAULT_API_URL,
314
433
  });
315
434
  const text = await res.text();
316
- let body = {};
435
+ let parsed = {};
317
436
  if (text) {
318
437
  try {
319
- body = JSON.parse(text);
438
+ parsed = JSON.parse(text);
320
439
  }
321
440
  catch {
322
- body = { raw: text.slice(0, 200) };
441
+ parsed = { raw: text.slice(0, 200) };
323
442
  }
324
443
  }
325
444
  if (!res.ok) {
326
- throw classifyCredentialVendFailure(res.status, body, res.headers.get("Retry-After"));
445
+ throw classifyCredentialVendFailure(res.status, parsed, res.headers.get("Retry-After"));
327
446
  }
328
- bundle = normalizeContract3Bundle(body);
447
+ bundle = normalizeContract3Bundle(parsed);
329
448
  }
330
449
  return scopeBundleToAgentIdentity(bundle, opts.env ?? process.env);
331
450
  };
@@ -344,6 +463,7 @@ export class CredentialRenewalManager {
344
463
  random;
345
464
  handle = null;
346
465
  stopped = false;
466
+ inflight = false;
347
467
  constructor(fetchCredentials, onRenewed, onError = () => { }, timers = realTimerHost, retryDelayMs = 30_000, refusedRetryMs = DEFAULT_REFUSED_RETRY_MS, random = Math.random) {
348
468
  this.fetchCredentials = fetchCredentials;
349
469
  this.onRenewed = onRenewed;
@@ -360,9 +480,17 @@ export class CredentialRenewalManager {
360
480
  const delay = renewalDelayMs(this.timers.now(), bundle.expiresAt);
361
481
  this.handle = scheduleBoundedTimeout(this.timers, () => void this.renew(), delay);
362
482
  }
483
+ /** Vend now (preferred companies changed). No-op while a vend is in flight. */
484
+ renewNow() {
485
+ if (this.stopped || this.inflight)
486
+ return;
487
+ this.clear();
488
+ void this.renew();
489
+ }
363
490
  async renew() {
364
- if (this.stopped)
491
+ if (this.stopped || this.inflight)
365
492
  return;
493
+ this.inflight = true;
366
494
  try {
367
495
  const bundle = await this.fetchCredentials();
368
496
  if (this.stopped)
@@ -389,6 +517,9 @@ export class CredentialRenewalManager {
389
517
  return;
390
518
  this.handle = scheduleBoundedTimeout(this.timers, () => void this.renew(), delay);
391
519
  }
520
+ finally {
521
+ this.inflight = false;
522
+ }
392
523
  }
393
524
  stop() {
394
525
  this.stopped = true;
@@ -9,6 +9,7 @@ import { describeCognitoTokenSource, formatCognitoSubject, loadMachineCachedToke
9
9
  import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
10
10
  import { readSessionState } from "../../../work-context/state.js";
11
11
  import { workContextRoot } from "../../../work-context/paths.js";
12
+ import { formatDroppedPresenceCompany } from "./credentials.js";
12
13
  import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
13
14
  import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
14
15
  import { daemonDir, daemonPidPath } from "./paths.js";
@@ -239,9 +240,9 @@ export function formatDaemonDoctor(report) {
239
240
  if (report.presenceDroppedCompanies.length > 0) {
240
241
  lines.push(`presence not published for ${report.presenceDroppedCompanies.length} companies: ` +
241
242
  report.presenceDroppedCompanies
242
- .map((company) => `${company.companyUid} (${company.reason})`)
243
- .join(", ") +
244
- `; presence credential holds ${report.companiesOnline.length} companies; these were not included`);
243
+ .map((company) => formatDroppedPresenceCompany(company))
244
+ .join("; ") +
245
+ `; presence credential holds ${report.companiesOnline.length} companies`);
245
246
  }
246
247
  if (report.unhealthy) {
247
248
  lines.push(`health: UNHEALTHY`);
@@ -84,6 +84,13 @@ export interface PresenceClientOptions {
84
84
  onRefusal?: (refusal: PresenceRefusal | null) => void;
85
85
  now?: () => Date;
86
86
  env?: NodeJS.ProcessEnv;
87
+ /**
88
+ * Companies the daemon is working in. When the set changes, presence
89
+ * re-vends instead of waiting for credential expiry (at most once a minute).
90
+ * Undefined means a read failed — do not re-vend.
91
+ */
92
+ readPreferredCompanyUids?: () => string[] | undefined;
93
+ preferredRevendMinMs?: number;
87
94
  }
88
95
  /**
89
96
  * Full-jitter capped exponential backoff (1s base → 60s cap by default),
@@ -123,6 +130,9 @@ export declare class PresenceClient {
123
130
  /** Last refusal code we logged via onError (episode dedupe). */
124
131
  private loggedRefusalKey;
125
132
  private refusal;
133
+ private preferredHandle;
134
+ private preferredSnapshot;
135
+ private lastPreferredVendMs;
126
136
  private readonly mqttConnect;
127
137
  private readonly timers;
128
138
  private readonly random;
@@ -137,6 +147,11 @@ export declare class PresenceClient {
137
147
  getRefusal(): PresenceRefusal | null;
138
148
  /** Topics this client has published to (for doctor / tests). */
139
149
  getPublishedTopics(): readonly string[];
150
+ private preferredMinMs;
151
+ private notePreferredVend;
152
+ private clearPreferredWatch;
153
+ private armPreferredWatch;
154
+ private maybeRevendPreferred;
140
155
  private setState;
141
156
  start(): Promise<void>;
142
157
  /**
@@ -12,7 +12,7 @@
12
12
  * - Server credential refusals use a long retry (default 10m ±20%)
13
13
  */
14
14
  import mqtt from "mqtt";
15
- import { CredentialVendError, CredentialRenewalManager, MQTT_KEEPALIVE_SECONDS, clampRetryDelayMs, defaultRefusedRetryMs, realTimerHost, refusedRetryDelayMs, scheduleBoundedTimeout, } from "./credentials.js";
15
+ import { CredentialVendError, CredentialRenewalManager, MQTT_KEEPALIVE_SECONDS, PREFERRED_REVEND_MIN_MS, clampRetryDelayMs, defaultRefusedRetryMs, realTimerHost, refusedRetryDelayMs, scheduleBoundedTimeout, shouldRevendPreferredCompanies, } from "./credentials.js";
16
16
  import { presignIotWssUrl } from "./presign.js";
17
17
  /**
18
18
  * A connection that drops before this grace window after `connect` is treated
@@ -93,6 +93,9 @@ export class PresenceClient {
93
93
  /** Last refusal code we logged via onError (episode dedupe). */
94
94
  loggedRefusalKey = null;
95
95
  refusal = null;
96
+ preferredHandle = null;
97
+ preferredSnapshot = [];
98
+ lastPreferredVendMs = 0;
96
99
  mqttConnect;
97
100
  timers;
98
101
  random;
@@ -128,6 +131,67 @@ export class PresenceClient {
128
131
  getPublishedTopics() {
129
132
  return this.publishedTopics;
130
133
  }
134
+ preferredMinMs() {
135
+ return this.options.preferredRevendMinMs ?? PREFERRED_REVEND_MIN_MS;
136
+ }
137
+ notePreferredVend() {
138
+ const read = this.options.readPreferredCompanyUids;
139
+ if (!read)
140
+ return;
141
+ let next;
142
+ try {
143
+ next = read();
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ if (!next)
149
+ return;
150
+ this.preferredSnapshot = next;
151
+ this.lastPreferredVendMs = this.timers.now();
152
+ }
153
+ clearPreferredWatch() {
154
+ if (this.preferredHandle) {
155
+ this.preferredHandle.clear();
156
+ this.preferredHandle = null;
157
+ }
158
+ }
159
+ armPreferredWatch() {
160
+ if (!this.options.readPreferredCompanyUids || this.stopped)
161
+ return;
162
+ this.clearPreferredWatch();
163
+ this.preferredHandle = scheduleBoundedTimeout(this.timers, () => {
164
+ this.preferredHandle = null;
165
+ if (this.stopped)
166
+ return;
167
+ this.maybeRevendPreferred();
168
+ this.armPreferredWatch();
169
+ }, this.preferredMinMs());
170
+ }
171
+ maybeRevendPreferred() {
172
+ const read = this.options.readPreferredCompanyUids;
173
+ if (!read || this.stopped || this.refusal)
174
+ return;
175
+ let next;
176
+ try {
177
+ next = read();
178
+ }
179
+ catch {
180
+ return;
181
+ }
182
+ if (!shouldRevendPreferredCompanies({
183
+ previous: this.preferredSnapshot,
184
+ next,
185
+ lastVendMs: this.lastPreferredVendMs,
186
+ nowMs: this.timers.now(),
187
+ minIntervalMs: this.preferredMinMs(),
188
+ })) {
189
+ return;
190
+ }
191
+ this.lastPreferredVendMs = this.timers.now();
192
+ this.preferredSnapshot = next;
193
+ this.renewal.renewNow();
194
+ }
131
195
  setState(state) {
132
196
  if (this.state === state)
133
197
  return;
@@ -149,6 +213,7 @@ export class PresenceClient {
149
213
  this.stopped = true;
150
214
  this.generation += 1;
151
215
  this.renewal.stop();
216
+ this.clearPreferredWatch();
152
217
  this.clearReconnect();
153
218
  this.clearStableTimer();
154
219
  this.connectedAtMs = null;
@@ -190,6 +255,7 @@ export class PresenceClient {
190
255
  }
191
256
  async onCredentialsRenewed(bundle) {
192
257
  this.bundle = bundle;
258
+ this.notePreferredVend();
193
259
  // Successful vend ends a refusal episode (renewal skips connectOnce's vend path).
194
260
  this.clearRefusal(true);
195
261
  if (this.stopped)
@@ -249,6 +315,8 @@ export class PresenceClient {
249
315
  // Successful vend ends a refusal episode (even before MQTT connects).
250
316
  this.clearRefusal(true);
251
317
  this.renewal.schedule(this.bundle);
318
+ this.notePreferredVend();
319
+ this.armPreferredWatch();
252
320
  }
253
321
  const bundle = this.bundle;
254
322
  const url = presignIotWssUrl(bundle.credentials, bundle.iotEndpoint, bundle.region, this.now());
@@ -26,7 +26,7 @@ import { backfillHeldSessions, } from "../backfill-held.js";
26
26
  import { reconcileObservation } from "../../../work-context/reconcile.js";
27
27
  import { workMeshRoot, workMeshSpoolPath } from "../paths.js";
28
28
  import { createVaultBoardReader, refreshBoundSessionBoards, BOARD_REFRESH_INTERVAL_MS, } from "./board-refresh.js";
29
- import { createContract3Fetcher, createPersonalRealtimeFetcher, realTimerHost, } from "./credentials.js";
29
+ import { createContract3Fetcher, createPersonalRealtimeFetcher, readPreferredCompanyUids, realTimerHost, } from "./credentials.js";
30
30
  import { appendDaemonLog, daemonLogLine, ensureDaemonLog } from "./log.js";
31
31
  import { acquirePidLock, defaultPidLockDeps, releasePidLock, } from "./pid-lock.js";
32
32
  import { daemonDir } from "./paths.js";
@@ -392,6 +392,7 @@ export async function runMeshDaemon(deps = {}) {
392
392
  token: t,
393
393
  baseUrl: resolveVaultApiBase(env),
394
394
  env,
395
+ readPreferredCompanyUids: () => readPreferredCompanyUids({ workContextRoot: ctxRoot }),
395
396
  })();
396
397
  });
397
398
  const fetchCredentials = async () => {
@@ -418,6 +419,7 @@ export async function runMeshDaemon(deps = {}) {
418
419
  }, now);
419
420
  log(dir, `presence online companies=${companies.length}`);
420
421
  },
422
+ readPreferredCompanyUids: () => readPreferredCompanyUids({ workContextRoot: ctxRoot }),
421
423
  onRefusal: (refusal) => {
422
424
  patchDaemonState(dir, {
423
425
  presenceRefusal: refusal,
@@ -1,4 +1,18 @@
1
1
  import { type MeshCompany, type MeshProjectRegistration, type MeshProjectRepo, type MeshProjectStoryInput, type MeshProjectView } from "./api.js";
2
+ /**
3
+ * POST /v1/work-mesh/projects/{id}/register always binds the server-derived
4
+ * proj- thread (projectGenesisThreadId). It does not accept a client threadId.
5
+ * Close a thread only when this same ensure created it and register returned
6
+ * a different canonical id. An already-open thread stays open on a re-run.
7
+ * The Board view reads the registered thread.
8
+ */
9
+ export declare function closeSupersededProjectThread(input: {
10
+ token: string;
11
+ companyUid: string;
12
+ openedThreadId: string;
13
+ canonicalThreadId: string;
14
+ created: boolean;
15
+ }): Promise<void>;
2
16
  /**
3
17
  * Create a missing Board. Register returns 403 PROJECT_NOT_IN_COMPANY until
4
18
  * the genesis thread and the project view both exist, so the order is fixed:
@@ -1,4 +1,20 @@
1
- import { ensureProjectThread, getProjectView, putProjectView, registerMeshProject, } from "./api.js";
1
+ import { appendThreadEvent, ensureProjectThread, getProjectView, putProjectView, registerMeshProject, } from "./api.js";
2
+ /**
3
+ * POST /v1/work-mesh/projects/{id}/register always binds the server-derived
4
+ * proj- thread (projectGenesisThreadId). It does not accept a client threadId.
5
+ * Close a thread only when this same ensure created it and register returned
6
+ * a different canonical id. An already-open thread stays open on a re-run.
7
+ * The Board view reads the registered thread.
8
+ */
9
+ export async function closeSupersededProjectThread(input) {
10
+ if (!input.created)
11
+ return;
12
+ if (!input.openedThreadId || input.openedThreadId === input.canonicalThreadId)
13
+ return;
14
+ await appendThreadEvent(input.token, input.companyUid, input.openedThreadId, "done", {
15
+ summary: "Superseded by the registered project thread.",
16
+ });
17
+ }
2
18
  /**
3
19
  * Create a missing Board. Register returns 403 PROJECT_NOT_IN_COMPANY until
4
20
  * the genesis thread and the project view both exist, so the order is fixed:
@@ -18,6 +34,13 @@ export async function ensureProjectBoard(input) {
18
34
  repos: input.repos ?? [],
19
35
  });
20
36
  const registration = await registerMeshProject(input.token, input.company.companyUid, input.projectId);
37
+ await closeSupersededProjectThread({
38
+ token: input.token,
39
+ companyUid: input.company.companyUid,
40
+ openedThreadId: genesis.threadId,
41
+ canonicalThreadId: registration.threadId,
42
+ created: genesis.created,
43
+ });
21
44
  const view = await getProjectView(input.token, input.company.companyUid, input.projectId);
22
45
  if (view.projectId !== input.projectId || view.companyUid !== input.company.companyUid) {
23
46
  throw new Error(`Work mesh project board does not match ${input.company.companyUid}/${input.projectId}.`);
@@ -41,6 +41,8 @@ export declare function discoverProjects(input: {
41
41
  env?: NodeJS.ProcessEnv;
42
42
  home?: string;
43
43
  }): CatalogProject[];
44
+ /** discoverProjects memoized by directory mtime. A cache hit does not rescan. */
45
+ export declare function discoverProjectsCached(input: Parameters<typeof discoverProjects>[0], nowMs?: number): CatalogProject[];
44
46
  export declare function refineProjectBinding(input: {
45
47
  hint?: string;
46
48
  hqRoot?: string;
@@ -188,11 +188,44 @@ export function discoverProjects(input) {
188
188
  }
189
189
  return [...map.values()];
190
190
  }
191
+ const CATALOG_CACHE_TTL_MS = 5000;
192
+ let catalogCache = null;
193
+ function catalogStamp(input) {
194
+ const hqRoot = input.hqRoot?.trim();
195
+ const slug = input.companySlug?.trim();
196
+ const dirs = [
197
+ hqRoot && slug ? path.join(hqRoot, "companies", slug, "projects") : "",
198
+ hqRoot ? path.join(hqRoot, "personal", "projects") : "",
199
+ input.companyUid
200
+ ? path.join(meshCacheRoot(input.home ?? os.homedir(), input.env), "projects", input.companyUid)
201
+ : "",
202
+ ];
203
+ return dirs.map((dir) => {
204
+ if (!dir)
205
+ return "0";
206
+ try {
207
+ return String(fs.statSync(dir).mtimeMs);
208
+ }
209
+ catch {
210
+ return "0";
211
+ }
212
+ }).join("|");
213
+ }
214
+ /** discoverProjects memoized by directory mtime. A cache hit does not rescan. */
215
+ export function discoverProjectsCached(input, nowMs = Date.now()) {
216
+ const key = `${input.hqRoot ?? ""}|${input.companySlug ?? ""}|${input.companyUid ?? ""}|${input.home ?? ""}|${catalogStamp(input)}`;
217
+ if (catalogCache && catalogCache.key === key && nowMs - catalogCache.at < CATALOG_CACHE_TTL_MS) {
218
+ return catalogCache.rows;
219
+ }
220
+ const rows = discoverProjects(input);
221
+ catalogCache = { key, at: nowMs, rows };
222
+ return rows;
223
+ }
191
224
  export function refineProjectBinding(input) {
192
225
  const hint = input.hint?.trim();
193
226
  if (!hint)
194
227
  return { action: "keep" };
195
- const catalog = discoverProjects(input);
228
+ const catalog = discoverProjectsCached(input, input.nowMs ?? Date.now());
196
229
  const exact = catalog.find((row) => row.projectId === hint);
197
230
  if (exact?.hasBoard) {
198
231
  return { action: "bind", projectId: hint, reason: "exact slug has a Board" };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * US-040: when this process ensures a project, bind the calling session
3
+ * if it is still unresolved in the same company. Never move a session
4
+ * that is already on a different project.
5
+ */
6
+ export interface CallingCompany {
7
+ companyUid: string;
8
+ companySlug: string;
9
+ }
10
+ export type BindCallingResult = {
11
+ bound: true;
12
+ sessionId: string;
13
+ projectId: string;
14
+ } | {
15
+ bound: false;
16
+ reason: "no_session" | "no_state" | "company_mismatch" | "different_project" | "already_bound" | "not_unresolved";
17
+ };
18
+ export declare function bindCallingSession(input: {
19
+ root: string;
20
+ env?: NodeJS.ProcessEnv;
21
+ projectId: string;
22
+ company: CallingCompany;
23
+ now?: () => Date;
24
+ }): BindCallingResult;
25
+ export declare function formatSessionBoundLine(sessionId: string, projectId: string): string;
26
+ //# sourceMappingURL=bind-calling-session.d.ts.map
@@ -0,0 +1,63 @@
1
+ /**
2
+ * US-040: when this process ensures a project, bind the calling session
3
+ * if it is still unresolved in the same company. Never move a session
4
+ * that is already on a different project.
5
+ */
6
+ import { sessionIdFromEnv } from "../mesh/live/session-identity.js";
7
+ import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
8
+ import { readSessionState, updateSessionState } from "./state.js";
9
+ function sameCompany(state, company) {
10
+ if (state.companyUid && state.companyUid === company.companyUid)
11
+ return true;
12
+ if (state.companySlug &&
13
+ company.companySlug &&
14
+ state.companySlug.toLowerCase() === company.companySlug.toLowerCase()) {
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+ export function bindCallingSession(input) {
20
+ const sessionId = sessionIdFromEnv(input.env ?? process.env);
21
+ if (!sessionId)
22
+ return { bound: false, reason: "no_session" };
23
+ const current = readSessionState(sessionId, input.root);
24
+ if (!current)
25
+ return { bound: false, reason: "no_state" };
26
+ if (!sameCompany(current, input.company))
27
+ return { bound: false, reason: "company_mismatch" };
28
+ if (current.projectId && current.projectId !== input.projectId) {
29
+ return { bound: false, reason: "different_project" };
30
+ }
31
+ if (current.projectId === input.projectId && (current.contextStatus === "bound" || current.contextStatus === "needs_task")) {
32
+ return { bound: false, reason: "already_bound" };
33
+ }
34
+ if (current.contextStatus !== "needs_project" && current.contextStatus !== "unresolved") {
35
+ return { bound: false, reason: "not_unresolved" };
36
+ }
37
+ const nowIso = (input.now ?? (() => new Date()))().toISOString();
38
+ const updated = updateSessionState(sessionId, input.root, (state) => {
39
+ if (!state)
40
+ return null;
41
+ if (state.projectId && state.projectId !== input.projectId)
42
+ return state;
43
+ return {
44
+ ...state,
45
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
46
+ contextStatus: state.taskId ? "bound" : "needs_task",
47
+ projectId: input.projectId,
48
+ projectGate: undefined,
49
+ projectCandidates: undefined,
50
+ projectHint: undefined,
51
+ projectBindReason: `bound by project ensure to ${input.projectId}`,
52
+ updatedAt: nowIso,
53
+ };
54
+ });
55
+ if (!updated || updated.projectId !== input.projectId) {
56
+ return { bound: false, reason: "different_project" };
57
+ }
58
+ return { bound: true, sessionId, projectId: input.projectId };
59
+ }
60
+ export function formatSessionBoundLine(sessionId, projectId) {
61
+ return `Work mesh: session ${sessionId} bound to project ${projectId}`;
62
+ }
63
+ //# sourceMappingURL=bind-calling-session.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * US-040: diff a bound project's prd.json and describe task_status events.
3
+ * Planning never touches the network. The snapshot is written only after
4
+ * the caller accepts the plan (commit), so a failed emit can retry.
5
+ */
6
+ export interface PrdStatusEvent {
7
+ taskId: string;
8
+ status: "done" | "in_progress";
9
+ }
10
+ export interface PrdSnapshot {
11
+ stories: Record<string, {
12
+ passes: boolean;
13
+ status?: string;
14
+ }>;
15
+ seq: number;
16
+ }
17
+ export declare function prdSnapshotPath(sessionId: string, root: string): string;
18
+ export declare function readPrdSnapshot(sessionId: string, root: string): PrdSnapshot | null;
19
+ export declare function prdBelongsToProject(file: string, projectId: string, companySlug: string | undefined): boolean;
20
+ export interface PrdSyncPlan {
21
+ changed: boolean;
22
+ skip?: "no_state" | "not_bound" | "different_project" | "unreadable";
23
+ events: PrdStatusEvent[];
24
+ /** Story most recently moved to in_progress, if any. */
25
+ taskId?: string;
26
+ seqStart: number;
27
+ commit: () => void;
28
+ }
29
+ export declare function planPrdSync(input: {
30
+ sessionId: string;
31
+ file: string;
32
+ root: string;
33
+ }): PrdSyncPlan;
34
+ //# sourceMappingURL=prd-sync.d.ts.map