@indigoai-us/hq-cli 5.345.123 → 5.345.125
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 +39 -0
- package/assets/scaffold/core/scripts/work-mesh-project-registration-offer.sh +77 -0
- package/assets/scaffold/core/scripts/workflow-gate.sh +247 -0
- package/dist/command-catalog.generated.d.ts +239 -0
- package/dist/command-catalog.generated.js +291 -0
- package/dist/commands/core-bucket-c-work-mesh-offer-native.d.ts +7 -0
- package/dist/commands/core-bucket-c-work-mesh-offer-native.js +147 -0
- package/dist/commands/core-bucket-c-workflow-gate-fast.d.ts +3 -0
- package/dist/commands/core-bucket-c-workflow-gate-fast.js +17 -0
- package/dist/commands/core-bucket-c-workflow-gate-native.d.ts +7 -0
- package/dist/commands/core-bucket-c-workflow-gate-native.js +250 -0
- package/dist/commands/core.js +12 -0
- package/dist/commands/lanes-accounts.js +22 -3
- package/dist/commands/lanes.js +25 -32
- package/dist/commands/mesh.js +457 -16
- package/dist/fast-scaffold.js +26 -0
- package/dist/lib/lanes/accounts.d.ts +48 -2
- package/dist/lib/lanes/accounts.js +160 -9
- package/dist/lib/lanes/index.d.ts +2 -2
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/message-replay.js +6 -1
- package/dist/lib/lanes/provider-rejection.d.ts +2 -6
- package/dist/lib/lanes/provider-rejection.js +19 -0
- package/dist/lib/lanes/quota-notice.d.ts +24 -0
- package/dist/lib/lanes/quota-notice.js +61 -0
- package/dist/lib/lanes/quota.d.ts +55 -15
- package/dist/lib/lanes/quota.js +231 -49
- package/dist/lib/lanes/reconcile.js +17 -1
- package/dist/lib/lanes/session-relink.d.ts +2 -0
- package/dist/lib/lanes/session-relink.js +98 -4
- package/dist/lib/lanes/spawn.d.ts +1 -1
- package/dist/lib/lanes/spawn.js +58 -12
- package/dist/lib/lanes/types.d.ts +5 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watcher.js +13 -1
- package/dist/lib/mesh/api.d.ts +2 -0
- package/dist/lib/mesh/api.js +3 -0
- package/dist/lib/mesh/client.d.ts +6 -1
- package/dist/lib/mesh/client.js +18 -5
- package/dist/lib/mesh/team-coordination.d.ts +106 -0
- package/dist/lib/mesh/team-coordination.js +297 -0
- package/dist/lib/work-context/auto-bind.d.ts +75 -0
- package/dist/lib/work-context/auto-bind.js +245 -0
- package/package.json +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work Mesh team coordination (WMTC-7 / WMTC-8): story claims, overlap,
|
|
3
|
+
* project reassignment, assignee suggestions, and the presence roster.
|
|
4
|
+
*
|
|
5
|
+
* Every route here except the story PATCH is new on hq-pro. Callers treat a
|
|
6
|
+
* 404/405 without an error code as "this server does not have the route yet"
|
|
7
|
+
* and degrade to one plain line instead of failing.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { meshJson } from "./api.js";
|
|
12
|
+
import { meshCacheRoot } from "./cache.js";
|
|
13
|
+
export const STORY_ASSIGNEE_CONFLICT = "STORY_ASSIGNEE_CONFLICT";
|
|
14
|
+
function asRecord(value) {
|
|
15
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
16
|
+
? value
|
|
17
|
+
: null;
|
|
18
|
+
}
|
|
19
|
+
function str(value) {
|
|
20
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
21
|
+
}
|
|
22
|
+
function strArray(value) {
|
|
23
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* True when the server does not know the route: 405, or a 404 that carries no
|
|
27
|
+
* error code. A coded 404 (for example PROJECT_NOT_FOUND) is a real answer.
|
|
28
|
+
*/
|
|
29
|
+
export function isRouteUnavailable(err) {
|
|
30
|
+
const e = err;
|
|
31
|
+
if (!e || typeof e !== "object")
|
|
32
|
+
return false;
|
|
33
|
+
if (e.status === 405)
|
|
34
|
+
return true;
|
|
35
|
+
return e.status === 404 && !e.code;
|
|
36
|
+
}
|
|
37
|
+
export function unavailableLine(feature) {
|
|
38
|
+
return `${feature} is not available on this server yet`;
|
|
39
|
+
}
|
|
40
|
+
/** Extract a structured conflict from a 409 meshJson failure, if it is one. */
|
|
41
|
+
export function storyConflictFromError(err) {
|
|
42
|
+
const e = err;
|
|
43
|
+
if (!e || typeof e !== "object" || e.status !== 409 || e.code !== STORY_ASSIGNEE_CONFLICT)
|
|
44
|
+
return null;
|
|
45
|
+
const body = asRecord(e.body) ?? {};
|
|
46
|
+
const holderDisplayName = str(body.holderDisplayName);
|
|
47
|
+
return {
|
|
48
|
+
code: STORY_ASSIGNEE_CONFLICT,
|
|
49
|
+
error: str(body.error) ?? e.message,
|
|
50
|
+
holderUid: str(body.holderUid) ?? "",
|
|
51
|
+
...(holderDisplayName ? { holderDisplayName } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function formatStoryConflictLine(storyId, conflict, names) {
|
|
55
|
+
const name = conflict.holderDisplayName || names?.get(conflict.holderUid) || "another teammate";
|
|
56
|
+
const uid = conflict.holderUid ? ` (${conflict.holderUid})` : "";
|
|
57
|
+
return `Story ${storyId} is assigned to ${name}${uid}. Ask them to hand it off, or re-run with --force.`;
|
|
58
|
+
}
|
|
59
|
+
function readJson(file) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function peopleFromPayload(payload) {
|
|
68
|
+
const rec = asRecord(payload);
|
|
69
|
+
const rows = Array.isArray(payload)
|
|
70
|
+
? payload
|
|
71
|
+
: [
|
|
72
|
+
...(Array.isArray(rec?.contacts) ? rec.contacts : []),
|
|
73
|
+
...(Array.isArray(rec?.rows) ? rec.rows : []),
|
|
74
|
+
...(Array.isArray(rec?.members) ? rec.members : []),
|
|
75
|
+
];
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const row of rows) {
|
|
78
|
+
const r = asRecord(row);
|
|
79
|
+
if (!r)
|
|
80
|
+
continue;
|
|
81
|
+
const uid = str(r.personUid) ?? str(r.actorUid) ?? str(r.uid);
|
|
82
|
+
if (!uid || !/^(prs|agt)_/.test(uid))
|
|
83
|
+
continue;
|
|
84
|
+
const displayName = str(r.displayName);
|
|
85
|
+
const email = str(r.email);
|
|
86
|
+
out.push({ uid, ...(displayName ? { displayName } : {}), ...(email ? { email } : {}) });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/** People known from the local contacts/directory cache. Never throws. */
|
|
91
|
+
export function loadCachedPeople(cacheRoot = meshCacheRoot()) {
|
|
92
|
+
const people = new Map();
|
|
93
|
+
for (const dir of ["contacts", "directory"]) {
|
|
94
|
+
let files;
|
|
95
|
+
try {
|
|
96
|
+
files = fs.readdirSync(path.join(cacheRoot, dir)).filter((f) => f.endsWith(".json"));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
for (const person of peopleFromPayload(readJson(path.join(cacheRoot, dir, file)))) {
|
|
103
|
+
const prior = people.get(person.uid);
|
|
104
|
+
const displayName = prior?.displayName ?? person.displayName;
|
|
105
|
+
const email = prior?.email ?? person.email;
|
|
106
|
+
people.set(person.uid, {
|
|
107
|
+
uid: person.uid,
|
|
108
|
+
...(displayName ? { displayName } : {}),
|
|
109
|
+
...(email ? { email } : {}),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...people.values()];
|
|
115
|
+
}
|
|
116
|
+
export function loadNameDirectory(cacheRoot = meshCacheRoot()) {
|
|
117
|
+
const names = new Map();
|
|
118
|
+
for (const person of loadCachedPeople(cacheRoot)) {
|
|
119
|
+
if (person.displayName)
|
|
120
|
+
names.set(person.uid, person.displayName);
|
|
121
|
+
}
|
|
122
|
+
return names;
|
|
123
|
+
}
|
|
124
|
+
export function displayNameFor(uid, serverName, names) {
|
|
125
|
+
return serverName?.trim() || names.get(uid) || uid;
|
|
126
|
+
}
|
|
127
|
+
/** Resolve a uid, email, or display name against a list of people. */
|
|
128
|
+
export function resolvePerson(query, people) {
|
|
129
|
+
const q = query.trim();
|
|
130
|
+
if (/^(prs|agt)_/.test(q)) {
|
|
131
|
+
const known = people.find((p) => p.uid === q);
|
|
132
|
+
return { ok: true, uid: q, ...(known?.displayName ? { displayName: known.displayName } : {}) };
|
|
133
|
+
}
|
|
134
|
+
const lower = q.toLowerCase();
|
|
135
|
+
const exact = people.filter((p) => p.email?.toLowerCase() === lower || p.displayName?.toLowerCase() === lower);
|
|
136
|
+
const matches = exact.length
|
|
137
|
+
? exact
|
|
138
|
+
: people.filter((p) => p.displayName?.toLowerCase().split(/\s+/)[0] === lower);
|
|
139
|
+
if (matches.length === 1) {
|
|
140
|
+
const m = matches[0];
|
|
141
|
+
return { ok: true, uid: m.uid, ...(m.displayName ? { displayName: m.displayName } : {}) };
|
|
142
|
+
}
|
|
143
|
+
return { ok: false, reason: matches.length ? "ambiguous" : "none", candidates: matches };
|
|
144
|
+
}
|
|
145
|
+
export const OVERLAP_MAX_FILES = 200;
|
|
146
|
+
export async function postOverlap(token, req) {
|
|
147
|
+
const body = {
|
|
148
|
+
companyUid: req.companyUid,
|
|
149
|
+
sessionId: req.sessionId,
|
|
150
|
+
repo: req.repo,
|
|
151
|
+
branch: req.branch,
|
|
152
|
+
};
|
|
153
|
+
if (req.projectId)
|
|
154
|
+
body.projectId = req.projectId;
|
|
155
|
+
if (req.storyId)
|
|
156
|
+
body.storyId = req.storyId;
|
|
157
|
+
if (req.files?.length)
|
|
158
|
+
body.files = req.files.slice(0, OVERLAP_MAX_FILES);
|
|
159
|
+
const data = asRecord(await meshJson(token, "/v1/work-mesh/overlap", { method: "POST", body }));
|
|
160
|
+
const rows = Array.isArray(data?.overlaps) ? data.overlaps : [];
|
|
161
|
+
return rows.flatMap((row) => {
|
|
162
|
+
const r = asRecord(row);
|
|
163
|
+
const actorUid = str(r?.actorUid);
|
|
164
|
+
if (!r || !actorUid)
|
|
165
|
+
return [];
|
|
166
|
+
const displayName = str(r.displayName);
|
|
167
|
+
const actorType = str(r.actorType);
|
|
168
|
+
const projectId = str(r.projectId);
|
|
169
|
+
const storyId = str(r.storyId);
|
|
170
|
+
const lastSeenAt = str(r.lastSeenAt);
|
|
171
|
+
return [{
|
|
172
|
+
actorUid,
|
|
173
|
+
...(displayName ? { displayName } : {}),
|
|
174
|
+
...(actorType ? { actorType } : {}),
|
|
175
|
+
sessionId: str(r.sessionId) ?? "",
|
|
176
|
+
repo: str(r.repo) ?? "",
|
|
177
|
+
branch: str(r.branch) ?? "",
|
|
178
|
+
...(projectId ? { projectId } : {}),
|
|
179
|
+
...(storyId ? { storyId } : {}),
|
|
180
|
+
reasons: strArray(r.reasons),
|
|
181
|
+
sharedFiles: strArray(r.sharedFiles),
|
|
182
|
+
...(lastSeenAt ? { lastSeenAt } : {}),
|
|
183
|
+
}];
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
export function formatOverlapLines(overlaps, names) {
|
|
187
|
+
if (overlaps.length === 0)
|
|
188
|
+
return ["No overlapping work found."];
|
|
189
|
+
const lines = [`${overlaps.length} overlapping session${overlaps.length === 1 ? "" : "s"}:`];
|
|
190
|
+
for (const o of overlaps) {
|
|
191
|
+
const who = displayNameFor(o.actorUid, o.displayName, names);
|
|
192
|
+
const where = [
|
|
193
|
+
o.repo ? `${o.repo}@${o.branch}` : "",
|
|
194
|
+
o.projectId ? `project ${o.projectId}` : "",
|
|
195
|
+
o.storyId ? `story ${o.storyId}` : "",
|
|
196
|
+
].filter(Boolean).join(", ");
|
|
197
|
+
lines.push(`- ${who} in session ${o.sessionId}: same ${o.reasons.join(" + ") || "work"}${where ? ` (${where})` : ""}`);
|
|
198
|
+
if (o.sharedFiles.length) {
|
|
199
|
+
const shown = o.sharedFiles.slice(0, 10);
|
|
200
|
+
const more = o.sharedFiles.length - shown.length;
|
|
201
|
+
lines.push(` shared files: ${shown.join(", ")}${more > 0 ? ` (+${more} more)` : ""}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return lines;
|
|
205
|
+
}
|
|
206
|
+
export async function postProjectReassign(token, projectId, body) {
|
|
207
|
+
const payload = { companyUid: body.companyUid, toUid: body.toUid };
|
|
208
|
+
if (body.includeOpenStories)
|
|
209
|
+
payload.includeOpenStories = true;
|
|
210
|
+
if (body.note)
|
|
211
|
+
payload.note = body.note;
|
|
212
|
+
const data = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/reassign`, { method: "POST", body: payload }));
|
|
213
|
+
return {
|
|
214
|
+
projectId: str(data?.projectId) ?? projectId,
|
|
215
|
+
ownerUid: str(data?.ownerUid) ?? body.toUid,
|
|
216
|
+
previousOwnerUid: str(data?.previousOwnerUid) ?? null,
|
|
217
|
+
reassignedStories: strArray(data?.reassignedStories),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
export async function fetchStorySuggestions(token, companyUid, projectId, storyId) {
|
|
221
|
+
const data = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/stories/${encodeURIComponent(storyId)}/suggestions`, { query: { companyUid } }));
|
|
222
|
+
const rows = Array.isArray(data?.candidates) ? data.candidates : [];
|
|
223
|
+
return rows.flatMap((row) => {
|
|
224
|
+
const r = asRecord(row);
|
|
225
|
+
const actorUid = str(r?.actorUid);
|
|
226
|
+
if (!r || !actorUid)
|
|
227
|
+
return [];
|
|
228
|
+
const displayName = str(r.displayName);
|
|
229
|
+
const actorType = str(r.actorType);
|
|
230
|
+
return [{
|
|
231
|
+
actorUid,
|
|
232
|
+
...(displayName ? { displayName } : {}),
|
|
233
|
+
...(actorType ? { actorType } : {}),
|
|
234
|
+
score: typeof r.score === "number" ? r.score : 0,
|
|
235
|
+
idle: r.idle === true,
|
|
236
|
+
reasons: strArray(r.reasons),
|
|
237
|
+
}];
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
export function formatSuggestionLines(storyId, candidates, names) {
|
|
241
|
+
if (candidates.length === 0)
|
|
242
|
+
return [`No suggested assignees for ${storyId}.`];
|
|
243
|
+
const lines = [`Suggested assignees for ${storyId}:`];
|
|
244
|
+
candidates.forEach((c, i) => {
|
|
245
|
+
const who = displayNameFor(c.actorUid, c.displayName, names);
|
|
246
|
+
const why = c.reasons.length ? `: ${c.reasons.join("; ")}` : "";
|
|
247
|
+
lines.push(`${i + 1}. ${who} (${c.actorUid}) score ${c.score}${c.idle ? ", idle" : ""}${why}`);
|
|
248
|
+
});
|
|
249
|
+
return lines;
|
|
250
|
+
}
|
|
251
|
+
export async function fetchRoster(token, companyUid) {
|
|
252
|
+
const data = asRecord(await meshJson(token, "/v1/work-mesh/live/roster", { query: { companyUid } }));
|
|
253
|
+
const rows = Array.isArray(data?.members) ? data.members : [];
|
|
254
|
+
return rows.flatMap((row) => {
|
|
255
|
+
const r = asRecord(row);
|
|
256
|
+
const actorUid = str(r?.actorUid);
|
|
257
|
+
if (!r || !actorUid)
|
|
258
|
+
return [];
|
|
259
|
+
const displayName = str(r.displayName);
|
|
260
|
+
const actorType = str(r.actorType);
|
|
261
|
+
return [{
|
|
262
|
+
actorUid,
|
|
263
|
+
...(displayName ? { displayName } : {}),
|
|
264
|
+
...(actorType ? { actorType } : {}),
|
|
265
|
+
daemonLastSeenAt: str(r.daemonLastSeenAt) ?? null,
|
|
266
|
+
lastSessionAt: str(r.lastSessionAt) ?? null,
|
|
267
|
+
boundSessions: typeof r.boundSessions === "number" ? r.boundSessions : 0,
|
|
268
|
+
unboundSessions: typeof r.unboundSessions === "number" ? r.unboundSessions : 0,
|
|
269
|
+
}];
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
export function rosterFlags(member) {
|
|
273
|
+
const flags = [];
|
|
274
|
+
if (!member.daemonLastSeenAt)
|
|
275
|
+
flags.push("no daemon report");
|
|
276
|
+
if (member.unboundSessions > 0 && member.boundSessions === 0)
|
|
277
|
+
flags.push("only unbound sessions");
|
|
278
|
+
return flags;
|
|
279
|
+
}
|
|
280
|
+
export function formatRosterLines(members, companyLabel, names) {
|
|
281
|
+
const lines = [
|
|
282
|
+
`Work mesh roster: ${companyLabel}`,
|
|
283
|
+
"name\tdaemon last seen\tlast session\tbound\tunbound\tflags",
|
|
284
|
+
];
|
|
285
|
+
for (const m of members) {
|
|
286
|
+
lines.push([
|
|
287
|
+
displayNameFor(m.actorUid, m.displayName, names),
|
|
288
|
+
m.daemonLastSeenAt ?? "never",
|
|
289
|
+
m.lastSessionAt ?? "never",
|
|
290
|
+
String(m.boundSessions),
|
|
291
|
+
String(m.unboundSessions),
|
|
292
|
+
rosterFlags(m).join(", ") || "-",
|
|
293
|
+
].join("\t"));
|
|
294
|
+
}
|
|
295
|
+
return lines;
|
|
296
|
+
}
|
|
297
|
+
//# sourceMappingURL=team-coordination.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WMTC-8 gap 7: resolve the project for an unbound (needs_project) session
|
|
3
|
+
* from local evidence only. No network, no subprocess, so a background hook
|
|
4
|
+
* refresh can call it cheaply. Every failure resolves to "no binding".
|
|
5
|
+
*
|
|
6
|
+
* Order:
|
|
7
|
+
* (a) a prior binding for the same repo+branch recorded locally
|
|
8
|
+
* (b) PRDs in the session's company whose repo AND branch match
|
|
9
|
+
* (c) a PRD matching the repo only, when it is the single candidate
|
|
10
|
+
*/
|
|
11
|
+
import type { SessionStateFile } from "./state.js";
|
|
12
|
+
export interface GitLocation {
|
|
13
|
+
/** Absolute top-level of the checkout containing cwd. */
|
|
14
|
+
topLevel: string;
|
|
15
|
+
/** Main checkout top-level when cwd is a linked worktree. */
|
|
16
|
+
mainTopLevel?: string;
|
|
17
|
+
branch: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface AutoBindCandidate {
|
|
20
|
+
projectId: string;
|
|
21
|
+
prdPath: string;
|
|
22
|
+
matchedBranch: boolean;
|
|
23
|
+
}
|
|
24
|
+
export type AutoBindDecision = {
|
|
25
|
+
action: "noop";
|
|
26
|
+
reason: "already_bound" | "not_needs_project" | "no_session";
|
|
27
|
+
projectId?: string;
|
|
28
|
+
} | {
|
|
29
|
+
action: "bind";
|
|
30
|
+
projectId: string;
|
|
31
|
+
source: "history" | "repo_branch" | "repo_only";
|
|
32
|
+
candidates: AutoBindCandidate[];
|
|
33
|
+
} | {
|
|
34
|
+
action: "skip";
|
|
35
|
+
reason: "no_git" | "no_company" | "no_match" | "ambiguous";
|
|
36
|
+
candidates: AutoBindCandidate[];
|
|
37
|
+
};
|
|
38
|
+
/** Find the git checkout for cwd and its current branch without spawning git. */
|
|
39
|
+
export declare function readGitLocation(cwd: string): GitLocation | null;
|
|
40
|
+
/** Repo identities for the checkout: HQ-relative paths and absolute paths. */
|
|
41
|
+
export declare function repoIdentities(loc: GitLocation, hqRoot: string): string[];
|
|
42
|
+
/** PRDs under companies/<company>/projects/* that name this checkout. */
|
|
43
|
+
export declare function findRepoPrdCandidates(input: {
|
|
44
|
+
hqRoot: string;
|
|
45
|
+
companySlug: string;
|
|
46
|
+
identities: string[];
|
|
47
|
+
branch: string | null;
|
|
48
|
+
}): AutoBindCandidate[];
|
|
49
|
+
export declare function autoBindHistoryPath(workContextRoot: string): string;
|
|
50
|
+
export declare function lookupPriorBinding(input: {
|
|
51
|
+
workContextRoot: string;
|
|
52
|
+
companySlug: string;
|
|
53
|
+
identities: string[];
|
|
54
|
+
branch: string | null;
|
|
55
|
+
}): string | null;
|
|
56
|
+
/** Remember a binding for this repo+branch. Best-effort; never throws. */
|
|
57
|
+
export declare function recordPriorBinding(input: {
|
|
58
|
+
workContextRoot: string;
|
|
59
|
+
companySlug: string;
|
|
60
|
+
identities: string[];
|
|
61
|
+
branch: string | null;
|
|
62
|
+
projectId: string;
|
|
63
|
+
now?: Date;
|
|
64
|
+
}): void;
|
|
65
|
+
export declare function decideAutoBind(input: {
|
|
66
|
+
state: SessionStateFile | null;
|
|
67
|
+
cwd: string;
|
|
68
|
+
hqRoot: string;
|
|
69
|
+
workContextRoot: string;
|
|
70
|
+
companySlug?: string;
|
|
71
|
+
}): AutoBindDecision & {
|
|
72
|
+
git?: GitLocation;
|
|
73
|
+
identities?: string[];
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=auto-bind.d.ts.map
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WMTC-8 gap 7: resolve the project for an unbound (needs_project) session
|
|
3
|
+
* from local evidence only. No network, no subprocess, so a background hook
|
|
4
|
+
* refresh can call it cheaply. Every failure resolves to "no binding".
|
|
5
|
+
*
|
|
6
|
+
* Order:
|
|
7
|
+
* (a) a prior binding for the same repo+branch recorded locally
|
|
8
|
+
* (b) PRDs in the session's company whose repo AND branch match
|
|
9
|
+
* (c) a PRD matching the repo only, when it is the single candidate
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
function readText(file) {
|
|
14
|
+
try {
|
|
15
|
+
return fs.readFileSync(file, "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Find the git checkout for cwd and its current branch without spawning git. */
|
|
22
|
+
export function readGitLocation(cwd) {
|
|
23
|
+
let cur = path.resolve(cwd);
|
|
24
|
+
for (;;) {
|
|
25
|
+
const dotGit = path.join(cur, ".git");
|
|
26
|
+
let st;
|
|
27
|
+
try {
|
|
28
|
+
st = fs.lstatSync(dotGit);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
st = null;
|
|
32
|
+
}
|
|
33
|
+
if (st) {
|
|
34
|
+
let gitDir = dotGit;
|
|
35
|
+
let mainTopLevel;
|
|
36
|
+
if (st.isFile()) {
|
|
37
|
+
const m = /^\s*gitdir:\s*(.+?)\s*$/m.exec(readText(dotGit) ?? "");
|
|
38
|
+
if (!m?.[1])
|
|
39
|
+
return null;
|
|
40
|
+
gitDir = path.resolve(cur, m[1]);
|
|
41
|
+
const common = readText(path.join(gitDir, "commondir"))?.trim();
|
|
42
|
+
if (common)
|
|
43
|
+
mainTopLevel = path.dirname(path.resolve(gitDir, common));
|
|
44
|
+
}
|
|
45
|
+
const head = readText(path.join(gitDir, "HEAD"))?.trim() ?? "";
|
|
46
|
+
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
|
47
|
+
return {
|
|
48
|
+
topLevel: cur,
|
|
49
|
+
...(mainTopLevel && mainTopLevel !== cur ? { mainTopLevel } : {}),
|
|
50
|
+
branch: ref?.[1] ?? null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const parent = path.dirname(cur);
|
|
54
|
+
if (parent === cur)
|
|
55
|
+
return null;
|
|
56
|
+
cur = parent;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function normRepoPath(value) {
|
|
60
|
+
return value.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
61
|
+
}
|
|
62
|
+
/** Repo identities for the checkout: HQ-relative paths and absolute paths. */
|
|
63
|
+
export function repoIdentities(loc, hqRoot) {
|
|
64
|
+
const out = new Set();
|
|
65
|
+
for (const top of [loc.topLevel, loc.mainTopLevel]) {
|
|
66
|
+
if (!top)
|
|
67
|
+
continue;
|
|
68
|
+
out.add(normRepoPath(top));
|
|
69
|
+
const rel = path.relative(hqRoot, top);
|
|
70
|
+
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel))
|
|
71
|
+
out.add(normRepoPath(rel));
|
|
72
|
+
}
|
|
73
|
+
return [...out];
|
|
74
|
+
}
|
|
75
|
+
function asRecord(value) {
|
|
76
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
77
|
+
? value
|
|
78
|
+
: null;
|
|
79
|
+
}
|
|
80
|
+
function prdRepoRefs(prd) {
|
|
81
|
+
const meta = asRecord(prd.metadata) ?? {};
|
|
82
|
+
const refs = [];
|
|
83
|
+
const push = (value) => {
|
|
84
|
+
if (typeof value === "string" && value.trim()) {
|
|
85
|
+
refs.push({ path: normRepoPath(value) });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const r = asRecord(value);
|
|
89
|
+
if (r && typeof r.path === "string" && r.path.trim()) {
|
|
90
|
+
refs.push({
|
|
91
|
+
path: normRepoPath(r.path),
|
|
92
|
+
...(typeof r.branch === "string" && r.branch.trim() ? { branch: r.branch.trim() } : {}),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
for (const list of [meta.repos, meta.relatedRepos, prd.repos]) {
|
|
97
|
+
if (Array.isArray(list))
|
|
98
|
+
list.forEach(push);
|
|
99
|
+
}
|
|
100
|
+
push(meta.repoPath);
|
|
101
|
+
return refs;
|
|
102
|
+
}
|
|
103
|
+
/** PRDs under companies/<company>/projects/* that name this checkout. */
|
|
104
|
+
export function findRepoPrdCandidates(input) {
|
|
105
|
+
const projectsDir = path.join(input.hqRoot, "companies", input.companySlug, "projects");
|
|
106
|
+
let entries;
|
|
107
|
+
try {
|
|
108
|
+
entries = fs.readdirSync(projectsDir);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const ids = new Set(input.identities);
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const entry of entries.sort()) {
|
|
116
|
+
const prdPath = path.join(projectsDir, entry, "prd.json");
|
|
117
|
+
const raw = readText(prdPath);
|
|
118
|
+
if (!raw)
|
|
119
|
+
continue;
|
|
120
|
+
let prd;
|
|
121
|
+
try {
|
|
122
|
+
prd = asRecord(JSON.parse(raw));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!prd)
|
|
128
|
+
continue;
|
|
129
|
+
const refs = prdRepoRefs(prd).filter((ref) => ids.has(ref.path));
|
|
130
|
+
if (refs.length === 0)
|
|
131
|
+
continue;
|
|
132
|
+
const prdBranch = typeof prd.branchName === "string" ? prd.branchName.trim() : "";
|
|
133
|
+
const matchedBranch = Boolean(input.branch && (prdBranch === input.branch || refs.some((ref) => ref.branch === input.branch)));
|
|
134
|
+
const meta = asRecord(prd.metadata);
|
|
135
|
+
const projectId = (typeof prd.projectId === "string" && prd.projectId.trim())
|
|
136
|
+
|| (typeof meta?.projectId === "string" && meta.projectId.trim())
|
|
137
|
+
|| entry;
|
|
138
|
+
out.push({ projectId, prdPath, matchedBranch });
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
export function autoBindHistoryPath(workContextRoot) {
|
|
143
|
+
return path.join(workContextRoot, "auto-bind-history.json");
|
|
144
|
+
}
|
|
145
|
+
function historyKey(companySlug, repo, branch) {
|
|
146
|
+
return `${companySlug}|${repo}@${branch}`;
|
|
147
|
+
}
|
|
148
|
+
function readHistory(workContextRoot) {
|
|
149
|
+
const raw = readText(autoBindHistoryPath(workContextRoot));
|
|
150
|
+
if (!raw)
|
|
151
|
+
return {};
|
|
152
|
+
try {
|
|
153
|
+
return (asRecord(JSON.parse(raw)) ?? {});
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function lookupPriorBinding(input) {
|
|
160
|
+
if (!input.branch)
|
|
161
|
+
return null;
|
|
162
|
+
const history = readHistory(input.workContextRoot);
|
|
163
|
+
for (const repo of input.identities) {
|
|
164
|
+
const hit = history[historyKey(input.companySlug, repo, input.branch)];
|
|
165
|
+
if (hit?.projectId)
|
|
166
|
+
return hit.projectId;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
/** Remember a binding for this repo+branch. Best-effort; never throws. */
|
|
171
|
+
export function recordPriorBinding(input) {
|
|
172
|
+
if (!input.branch || input.identities.length === 0)
|
|
173
|
+
return;
|
|
174
|
+
try {
|
|
175
|
+
const history = readHistory(input.workContextRoot);
|
|
176
|
+
const at = (input.now ?? new Date()).toISOString();
|
|
177
|
+
for (const repo of input.identities) {
|
|
178
|
+
history[historyKey(input.companySlug, repo, input.branch)] = {
|
|
179
|
+
projectId: input.projectId,
|
|
180
|
+
companySlug: input.companySlug,
|
|
181
|
+
at,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const file = autoBindHistoryPath(input.workContextRoot);
|
|
185
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
186
|
+
const tmp = `${file}.tmp.${process.pid}`;
|
|
187
|
+
fs.writeFileSync(tmp, `${JSON.stringify(history, null, 2)}\n`, { mode: 0o600 });
|
|
188
|
+
fs.renameSync(tmp, file);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* history is a hint only */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Decision
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
export function decideAutoBind(input) {
|
|
198
|
+
const { state } = input;
|
|
199
|
+
if (!state)
|
|
200
|
+
return { action: "noop", reason: "no_session" };
|
|
201
|
+
if (state.contextStatus !== "needs_project") {
|
|
202
|
+
if (state.projectId)
|
|
203
|
+
return { action: "noop", reason: "already_bound", projectId: state.projectId };
|
|
204
|
+
return { action: "noop", reason: "not_needs_project" };
|
|
205
|
+
}
|
|
206
|
+
const companySlug = input.companySlug?.trim() || state.companySlug?.trim();
|
|
207
|
+
if (!companySlug)
|
|
208
|
+
return { action: "skip", reason: "no_company", candidates: [] };
|
|
209
|
+
const git = readGitLocation(input.cwd);
|
|
210
|
+
if (!git)
|
|
211
|
+
return { action: "skip", reason: "no_git", candidates: [] };
|
|
212
|
+
const identities = repoIdentities(git, input.hqRoot);
|
|
213
|
+
const extra = { git, identities };
|
|
214
|
+
const prior = lookupPriorBinding({
|
|
215
|
+
workContextRoot: input.workContextRoot,
|
|
216
|
+
companySlug,
|
|
217
|
+
identities,
|
|
218
|
+
branch: git.branch,
|
|
219
|
+
});
|
|
220
|
+
const candidates = findRepoPrdCandidates({
|
|
221
|
+
hqRoot: input.hqRoot,
|
|
222
|
+
companySlug,
|
|
223
|
+
identities,
|
|
224
|
+
branch: git.branch,
|
|
225
|
+
});
|
|
226
|
+
if (prior)
|
|
227
|
+
return { action: "bind", projectId: prior, source: "history", candidates, ...extra };
|
|
228
|
+
const branchMatches = candidates.filter((c) => c.matchedBranch);
|
|
229
|
+
if (branchMatches.length === 1) {
|
|
230
|
+
return { action: "bind", projectId: branchMatches[0].projectId, source: "repo_branch", candidates, ...extra };
|
|
231
|
+
}
|
|
232
|
+
if (branchMatches.length > 1) {
|
|
233
|
+
return { action: "skip", reason: "ambiguous", candidates: branchMatches, ...extra };
|
|
234
|
+
}
|
|
235
|
+
if (candidates.length === 1) {
|
|
236
|
+
return { action: "bind", projectId: candidates[0].projectId, source: "repo_only", candidates, ...extra };
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
action: "skip",
|
|
240
|
+
reason: candidates.length ? "ambiguous" : "no_match",
|
|
241
|
+
candidates,
|
|
242
|
+
...extra,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=auto-bind.js.map
|