@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.
- package/CHANGELOG.md +17 -0
- package/dist/command-catalog.generated.d.ts +51 -0
- package/dist/command-catalog.generated.js +67 -0
- package/dist/commands/mesh.js +280 -21
- package/dist/commands/report.d.ts +0 -1
- package/dist/commands/report.js +93 -13
- package/dist/lib/lanes/monitor.js +20 -1
- package/dist/lib/mesh/api.d.ts +1 -0
- package/dist/lib/mesh/live/flush.d.ts +1 -1
- package/dist/lib/mesh/live/flush.js +35 -1
- package/dist/lib/mesh/project-board.d.ts +19 -0
- package/dist/lib/mesh/project-board.js +27 -0
- package/dist/lib/work-context/bind-best.d.ts +64 -0
- package/dist/lib/work-context/bind-best.js +299 -0
- package/dist/lib/work-context/contract.d.ts +8 -0
- package/dist/lib/work-context/reconcile.d.ts +9 -0
- package/dist/lib/work-context/reconcile.js +88 -3
- package/dist/lib/work-context/state.d.ts +13 -0
- package/dist/lib/work-context/state.js +9 -0
- package/package.json +1 -1
package/dist/commands/report.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
117
|
+
let scannedChannels = 0;
|
|
39
118
|
for (const row of listed.channels ?? []) {
|
|
40
119
|
if (!row.channelId)
|
|
41
120
|
continue;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
128
|
+
const match = body.reports?.find((report) => report.reportId === reportId);
|
|
129
|
+
if (match)
|
|
130
|
+
return match;
|
|
50
131
|
}
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
package/dist/lib/mesh/api.d.ts
CHANGED
|
@@ -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
|