@indigoai-us/hq-cli 5.138.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.
@@ -1,11 +1,80 @@
1
+ /** `hq report` — safe channel-report lifecycle commands for bot runbooks. */
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
1
5
  import { ensureCognitoToken, } from "../utils/cognito-session.js";
2
6
  import { vaultApiFetch } from "../utils/vault-api.js";
3
7
  import { listReportTemplates } from "../lib/report-templates.js";
4
8
  import { adoptReport, buildReportTarball, conventionReportDir, deployOrgContext, ReportPublishError, plainFailureReason, publishReport, } from "../lib/report-publish.js";
5
9
  const DEPLOY_API_URL = process.env.HQ_DEPLOY_API_URL ?? "https://api.indigo-hq.com";
10
+ const REPORT_CHANNEL_STATE_FILE = "report-channels.json";
11
+ const MAX_REPORT_DISCOVERY_CHANNELS = 50;
6
12
  function reportPath(channelId, reportId) {
7
13
  return `/v1/notify/channels/${encodeURIComponent(channelId)}/reports/${encodeURIComponent(reportId)}`;
8
14
  }
15
+ function reportListPath(channelId) {
16
+ return `/v1/notify/channels/${encodeURIComponent(channelId)}/reports`;
17
+ }
18
+ function reportChannelStatePath() {
19
+ const stateDir = process.env.HQ_STATE_DIR?.trim() || path.join(os.homedir(), ".hq");
20
+ return path.join(stateDir, REPORT_CHANNEL_STATE_FILE);
21
+ }
22
+ function readReportChannelState() {
23
+ const file = reportChannelStatePath();
24
+ let raw;
25
+ try {
26
+ raw = fs.readFileSync(file, "utf8");
27
+ }
28
+ catch (error) {
29
+ if (error.code === "ENOENT") {
30
+ return { version: 1, channels: {} };
31
+ }
32
+ throw new ReportPublishError("Could not read the local report channel state.");
33
+ }
34
+ try {
35
+ const parsed = JSON.parse(raw);
36
+ if (parsed.version !== 1 || !parsed.channels || typeof parsed.channels !== "object")
37
+ throw new Error("invalid state");
38
+ const channels = Object.fromEntries(Object.entries(parsed.channels).filter((entry) => typeof entry[1] === "string" && entry[1].length > 0));
39
+ return { version: 1, channels };
40
+ }
41
+ catch {
42
+ throw new ReportPublishError("Could not read the local report channel state.");
43
+ }
44
+ }
45
+ function rememberReportChannel(reportId, channelId) {
46
+ const file = reportChannelStatePath();
47
+ const state = readReportChannelState();
48
+ state.channels[reportId] = channelId;
49
+ fs.mkdirSync(path.dirname(file), { recursive: true });
50
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
51
+ try {
52
+ fs.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
53
+ fs.renameSync(temporary, file);
54
+ }
55
+ finally {
56
+ if (fs.existsSync(temporary))
57
+ fs.rmSync(temporary, { force: true });
58
+ }
59
+ }
60
+ function rememberReportChannelAfterSuccess(report) {
61
+ try {
62
+ rememberReportChannel(report.reportId, report.channelId);
63
+ }
64
+ catch (error) {
65
+ const reason = error instanceof Error ? error.message : "local state write failed";
66
+ console.warn(`Report succeeded but local channel context was not saved: ${reason}`);
67
+ }
68
+ }
69
+ function locallyRecordedChannel(reportId) {
70
+ return readReportChannelState().channels[reportId];
71
+ }
72
+ function notFoundMessage(scannedChannels) {
73
+ const scope = scannedChannels >= MAX_REPORT_DISCOVERY_CHANNELS
74
+ ? `the first ${MAX_REPORT_DISCOVERY_CHANNELS} channels`
75
+ : `${scannedChannels} channel${scannedChannels === 1 ? "" : "s"}`;
76
+ return `Report not found. Pass --channel or set HQ_REPORT_CHANNEL_ID. Searched ${scope}.`;
77
+ }
9
78
  function errorMessage(error) {
10
79
  if (error instanceof ReportPublishError)
11
80
  return plainFailureReason(error);
@@ -29,30 +98,38 @@ export async function resolveReport(token, reportId, requestedChannelId) {
29
98
  throw new ReportPublishError("Report response was incomplete.");
30
99
  return body.report;
31
100
  }
101
+ const recordedChannel = locallyRecordedChannel(reportId);
102
+ if (recordedChannel) {
103
+ const res = await vaultApiFetch({ token, path: reportPath(recordedChannel, reportId) });
104
+ if (res.ok) {
105
+ const body = await res.json();
106
+ if (body.report)
107
+ return body.report;
108
+ }
109
+ }
32
110
  // The report API is intentionally channel-scoped. Discover only among the
33
- // caller's existing channels rather than inventing an unscoped report route.
111
+ // caller's existing channels, using list endpoints so missing reports never
112
+ // become one server-side 404 per channel.
34
113
  const channels = await vaultApiFetch({ token, path: "/v1/notify/channels" });
35
114
  if (!channels.ok)
36
115
  throw new ReportPublishError("Could not resolve the report channel.");
37
116
  const listed = await channels.json();
38
- const matches = [];
117
+ let scannedChannels = 0;
39
118
  for (const row of listed.channels ?? []) {
40
119
  if (!row.channelId)
41
120
  continue;
42
- const res = await vaultApiFetch({ token, path: reportPath(row.channelId, reportId) });
43
- if (res.status === 404)
44
- continue;
121
+ if (scannedChannels >= MAX_REPORT_DISCOVERY_CHANNELS)
122
+ break;
123
+ scannedChannels += 1;
124
+ const res = await vaultApiFetch({ token, path: reportListPath(row.channelId) });
45
125
  if (!res.ok)
46
126
  continue;
47
127
  const body = await res.json();
48
- if (body.report)
49
- matches.push(body.report);
128
+ const match = body.reports?.find((report) => report.reportId === reportId);
129
+ if (match)
130
+ return match;
50
131
  }
51
- if (matches.length === 1)
52
- return matches[0];
53
- if (matches.length > 1)
54
- throw new ReportPublishError("Report ID is ambiguous; pass --channel or set HQ_REPORT_CHANNEL_ID.");
55
- throw new ReportPublishError("Report not found. Pass --channel or set HQ_REPORT_CHANNEL_ID.");
132
+ throw new ReportPublishError(notFoundMessage(scannedChannels));
56
133
  }
57
134
  export async function patchReport(token, report, patch) {
58
135
  const res = await vaultApiFetch({ token, path: reportPath(report.channelId, report.reportId), method: "PATCH", body: patch });
@@ -146,7 +223,7 @@ export async function deployReportStatic(token, input) {
146
223
  return { appId: app.id, url, deployId };
147
224
  }
148
225
  async function publishWithOrg(token, report, directory, draft) {
149
- return publishReport(report, directory, draft, {
226
+ const published = await publishReport(report, directory, draft, {
150
227
  patch: (current, patch) => patchReport(token, current, patch),
151
228
  deploy: async (input) => {
152
229
  const org = await orgFor(token, report);
@@ -154,6 +231,8 @@ async function publishWithOrg(token, report, directory, draft) {
154
231
  },
155
232
  now: () => new Date().toISOString(),
156
233
  });
234
+ rememberReportChannelAfterSuccess(published);
235
+ return published;
157
236
  }
158
237
  async function publishAction(reportId, options) {
159
238
  const token = await ensureCognitoToken();
@@ -246,6 +325,7 @@ async function adoptAction(reportId, options) {
246
325
  },
247
326
  patch: (current, patch) => patchReport(token, current, patch),
248
327
  });
328
+ rememberReportChannelAfterSuccess(updated);
249
329
  print(updated, options.json === true);
250
330
  }
251
331
  async function decommissionAction(reportId, options) {
@@ -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)) {
@@ -31,6 +31,7 @@ export interface MeshProjectStoryInput {
31
31
  status?: string;
32
32
  passes?: boolean;
33
33
  priority?: number;
34
+ assigneeUid?: string;
34
35
  }
35
36
  export interface MeshProjectRepo {
36
37
  path: string;
@@ -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