@indigoai-us/hq-cli 5.126.0 → 5.128.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 +218 -0
- package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
- package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
- package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
- package/dist/command-catalog.generated.d.ts +245 -11
- package/dist/command-catalog.generated.js +323 -11
- package/dist/command-registration-plan.d.ts +1 -0
- package/dist/command-registration-plan.js +11 -2
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/lanes.d.ts +55 -16
- package/dist/commands/lanes.js +1187 -250
- package/dist/commands/mesh.js +254 -19
- package/dist/lib/core-utils/sentry-report.d.ts +2 -0
- package/dist/lib/core-utils/sentry-report.js +47 -7
- package/dist/lib/lanes/accounts.d.ts +67 -7
- package/dist/lib/lanes/accounts.js +233 -43
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +15 -0
- package/dist/lib/lanes/config.js +65 -8
- package/dist/lib/lanes/delivery.d.ts +151 -0
- package/dist/lib/lanes/delivery.js +354 -0
- package/dist/lib/lanes/dropbox.d.ts +96 -0
- package/dist/lib/lanes/dropbox.js +408 -0
- package/dist/lib/lanes/effort.d.ts +50 -0
- package/dist/lib/lanes/effort.js +109 -0
- package/dist/lib/lanes/errors.d.ts +2 -2
- package/dist/lib/lanes/hooks-install.d.ts +81 -0
- package/dist/lib/lanes/hooks-install.js +326 -0
- package/dist/lib/lanes/hq-root.d.ts +19 -0
- package/dist/lib/lanes/hq-root.js +28 -0
- package/dist/lib/lanes/index.d.ts +35 -5
- package/dist/lib/lanes/index.js +19 -4
- package/dist/lib/lanes/liveness.d.ts +7 -1
- package/dist/lib/lanes/liveness.js +64 -5
- package/dist/lib/lanes/machine-identity.d.ts +34 -0
- package/dist/lib/lanes/machine-identity.js +92 -0
- package/dist/lib/lanes/mesh-events.d.ts +109 -0
- package/dist/lib/lanes/mesh-events.js +275 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
- package/dist/lib/lanes/outpost-dispatch.js +90 -0
- package/dist/lib/lanes/questions.d.ts +75 -0
- package/dist/lib/lanes/questions.js +243 -0
- package/dist/lib/lanes/quota.d.ts +95 -0
- package/dist/lib/lanes/quota.js +320 -0
- package/dist/lib/lanes/reconcile-install.d.ts +69 -0
- package/dist/lib/lanes/reconcile-install.js +221 -0
- package/dist/lib/lanes/reconcile.d.ts +102 -0
- package/dist/lib/lanes/reconcile.js +985 -0
- package/dist/lib/lanes/registry.d.ts +18 -5
- package/dist/lib/lanes/registry.js +293 -19
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +7 -3
- package/dist/lib/lanes/store.js +150 -32
- package/dist/lib/lanes/types.d.ts +346 -4
- package/dist/lib/lanes/types.js +138 -5
- package/dist/lib/lanes/watcher-main.d.ts +9 -0
- package/dist/lib/lanes/watcher-main.js +22 -0
- package/dist/lib/lanes/watcher.d.ts +216 -0
- package/dist/lib/lanes/watcher.js +859 -0
- package/dist/lib/mesh/api.d.ts +71 -7
- package/dist/lib/mesh/api.js +167 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
- package/dist/lib/mesh/live/backfill-held.js +66 -21
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +111 -54
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
- package/dist/lib/scan-packages/index.d.ts +2 -0
- package/dist/lib/scan-packages/index.js +1 -1
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/package.json +3 -1
package/dist/commands/mesh.js
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
* Distinct from `hq doctor` (hook guardrails).
|
|
7
7
|
*/
|
|
8
8
|
import * as os from "node:os";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
9
10
|
import { findHqRoot } from "../utils/manifest.js";
|
|
10
11
|
import chalk from "chalk";
|
|
11
12
|
import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
|
|
12
13
|
import * as readline from "node:readline/promises";
|
|
13
|
-
import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, listActiveMembershipCompanies, listActiveThreads,
|
|
14
|
+
import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
|
|
14
15
|
import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
|
|
15
16
|
import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
|
|
16
17
|
import { ensureDefaultCompany } from "../lib/work-context/default-company.js";
|
|
@@ -88,14 +89,19 @@ async function runCheck(opts) {
|
|
|
88
89
|
async function runEvent(verb, opts) {
|
|
89
90
|
const { token, company } = await withCompany(opts);
|
|
90
91
|
const projectId = requireProject(opts);
|
|
92
|
+
const eventKind = verb === "start" ? "claim" : verb;
|
|
93
|
+
let claimant = "";
|
|
94
|
+
if (eventKind === "claim") {
|
|
95
|
+
const resolvedClaimant = await resolveMeshPrincipalUid(token, loadCachedTokens());
|
|
96
|
+
if (!resolvedClaimant) {
|
|
97
|
+
fail("Could not resolve the authenticated claimant. Sign in again and retry `hq mesh start`.");
|
|
98
|
+
}
|
|
99
|
+
claimant = resolvedClaimant;
|
|
100
|
+
}
|
|
91
101
|
const ensured = await ensureProjectThread(token, company, projectId, {
|
|
92
102
|
threadId: opts.threadId,
|
|
93
103
|
summary: opts.summary,
|
|
94
104
|
});
|
|
95
|
-
const eventKind = verb === "start" ? "claim" : verb;
|
|
96
|
-
const claimant = eventKind === "claim"
|
|
97
|
-
? await resolveMeshPrincipalUid(token, loadCachedTokens())
|
|
98
|
-
: "";
|
|
99
105
|
const event = await appendThreadEvent(token, company.companyUid, ensured.threadId, eventKind, eventPayload(eventKind, opts, claimant));
|
|
100
106
|
if (opts.json) {
|
|
101
107
|
console.log(JSON.stringify({
|
|
@@ -115,17 +121,215 @@ async function runEvent(verb, opts) {
|
|
|
115
121
|
async function runStory(opts) {
|
|
116
122
|
const storyId = opts.story?.trim();
|
|
117
123
|
const status = opts.status?.trim();
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
const assignee = opts.assignee?.trim();
|
|
125
|
+
if (!storyId) {
|
|
126
|
+
fail("story requires --story <id>");
|
|
127
|
+
}
|
|
128
|
+
if (!status && !assignee) {
|
|
129
|
+
fail("story requires --status queued|in_progress|review|done or --assignee <uid|me|none>");
|
|
130
|
+
}
|
|
131
|
+
if (status && !STORY_STATUSES.has(status)) {
|
|
132
|
+
fail("story --status must be queued|in_progress|review|done");
|
|
120
133
|
}
|
|
121
134
|
const { token, company } = await withCompany(opts);
|
|
122
135
|
const projectId = requireProject(opts);
|
|
123
|
-
|
|
136
|
+
let assigneeUid;
|
|
137
|
+
let clearAssignee = false;
|
|
138
|
+
if (assignee === "none") {
|
|
139
|
+
clearAssignee = true;
|
|
140
|
+
}
|
|
141
|
+
else if (assignee === "me") {
|
|
142
|
+
const resolved = await resolveMeshPrincipalUid(token, loadCachedTokens());
|
|
143
|
+
if (!resolved || resolved === "session") {
|
|
144
|
+
fail("`--assignee me` needs the signed-in person or agent. The synthetic session identity cannot be assigned. Sign in again and retry `hq mesh story`.");
|
|
145
|
+
}
|
|
146
|
+
assigneeUid = resolved;
|
|
147
|
+
}
|
|
148
|
+
else if (assignee) {
|
|
149
|
+
if (!/^(prs|agt)_/.test(assignee)) {
|
|
150
|
+
fail("`--assignee` must be a person or agent uid, `me`, or `none`.");
|
|
151
|
+
}
|
|
152
|
+
assigneeUid = assignee;
|
|
153
|
+
}
|
|
154
|
+
const patched = await patchStory(token, company.companyUid, projectId, storyId, {
|
|
155
|
+
...(status ? { status } : {}),
|
|
156
|
+
...(assigneeUid ? { assigneeUid } : {}),
|
|
157
|
+
...(clearAssignee ? { clearAssignee: true } : {}),
|
|
158
|
+
});
|
|
124
159
|
if (opts.json) {
|
|
125
160
|
console.log(JSON.stringify({ ok: true, action: "story", company, ...patched, storyId }, null, 2));
|
|
126
161
|
return;
|
|
127
162
|
}
|
|
128
|
-
|
|
163
|
+
const assignment = assignee === "none"
|
|
164
|
+
? "assignee cleared"
|
|
165
|
+
: assignee
|
|
166
|
+
? `assignee ${patched.assigneeUid ?? assigneeUid}`
|
|
167
|
+
: "";
|
|
168
|
+
const statusPart = status ? `${storyId} → ${patched.status}` : storyId;
|
|
169
|
+
console.log(`Work mesh: story ${statusPart}${assignment ? ` ${assignment}` : ""} (v${patched.version ?? "?"})`);
|
|
170
|
+
}
|
|
171
|
+
function record(value) {
|
|
172
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
173
|
+
? value
|
|
174
|
+
: null;
|
|
175
|
+
}
|
|
176
|
+
function parseProjectRepoSpec(value, source) {
|
|
177
|
+
const raw = value.trim();
|
|
178
|
+
const separator = raw.lastIndexOf("@");
|
|
179
|
+
const path = (separator > 0 ? raw.slice(0, separator) : raw).trim();
|
|
180
|
+
const branch = (separator > 0 ? raw.slice(separator + 1) : "").trim();
|
|
181
|
+
if (!path)
|
|
182
|
+
fail(`${source} requires a non-empty repository path.`);
|
|
183
|
+
if (separator > 0 && !branch)
|
|
184
|
+
fail(`${source} has an empty branch after @.`);
|
|
185
|
+
return { path, branch };
|
|
186
|
+
}
|
|
187
|
+
function parseProjectRepos(value, source) {
|
|
188
|
+
if (!Array.isArray(value))
|
|
189
|
+
fail(`${source} must be a JSON repository array.`);
|
|
190
|
+
return value.map((raw, index) => {
|
|
191
|
+
if (typeof raw === "string")
|
|
192
|
+
return parseProjectRepoSpec(raw, `${source}[${index}]`);
|
|
193
|
+
const repo = record(raw);
|
|
194
|
+
const path = typeof repo?.path === "string"
|
|
195
|
+
? repo.path.trim()
|
|
196
|
+
: typeof repo?.repoPath === "string"
|
|
197
|
+
? repo.repoPath.trim()
|
|
198
|
+
: typeof repo?.repo === "string"
|
|
199
|
+
? repo.repo.trim()
|
|
200
|
+
: "";
|
|
201
|
+
const branchValue = repo?.branch ?? repo?.branchName;
|
|
202
|
+
const branch = branchValue === undefined ? "" : typeof branchValue === "string" ? branchValue.trim() : null;
|
|
203
|
+
if (!repo || !path)
|
|
204
|
+
fail(`${source}[${index}].path requires a string.`);
|
|
205
|
+
if (branch === null)
|
|
206
|
+
fail(`${source}[${index}].branch requires a string.`);
|
|
207
|
+
return { path, branch };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
function parseProjectReposJson(raw, source) {
|
|
211
|
+
try {
|
|
212
|
+
return parseProjectRepos(JSON.parse(raw), source);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
if (err instanceof SyntaxError)
|
|
216
|
+
fail(`${source} must contain valid JSON.`);
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function projectStoriesFromJson(value, source) {
|
|
221
|
+
const plan = Array.isArray(value) ? null : record(value);
|
|
222
|
+
const rawStories = Array.isArray(value)
|
|
223
|
+
? value
|
|
224
|
+
: Array.isArray(plan?.userStories)
|
|
225
|
+
? plan.userStories
|
|
226
|
+
: Array.isArray(plan?.stories)
|
|
227
|
+
? plan.stories
|
|
228
|
+
: null;
|
|
229
|
+
if (!rawStories) {
|
|
230
|
+
fail(`${source} must be a JSON story array or an object with userStories.`);
|
|
231
|
+
}
|
|
232
|
+
const stories = rawStories.map((raw, index) => {
|
|
233
|
+
const story = record(raw);
|
|
234
|
+
const id = typeof story?.id === "string" ? story.id.trim() : "";
|
|
235
|
+
const title = typeof story?.title === "string" ? story.title.trim() : "";
|
|
236
|
+
if (!story || !id || !title) {
|
|
237
|
+
fail(`${source} userStories[${index}] requires string id and title.`);
|
|
238
|
+
}
|
|
239
|
+
const status = typeof story.status === "string" ? story.status.trim() : undefined;
|
|
240
|
+
if (status && !STORY_STATUSES.has(status)) {
|
|
241
|
+
fail(`${source} userStories[${index}].status must be queued|in_progress|review|done.`);
|
|
242
|
+
}
|
|
243
|
+
const resolvedStatus = status || (story.passes === true ? "done" : undefined);
|
|
244
|
+
return {
|
|
245
|
+
id,
|
|
246
|
+
title,
|
|
247
|
+
...(typeof story.description === "string" ? { description: story.description } : {}),
|
|
248
|
+
...(Array.isArray(story.acceptanceCriteria)
|
|
249
|
+
? {
|
|
250
|
+
acceptanceCriteria: story.acceptanceCriteria.filter((item) => typeof item === "string"),
|
|
251
|
+
}
|
|
252
|
+
: {}),
|
|
253
|
+
...(resolvedStatus ? { status: resolvedStatus } : {}),
|
|
254
|
+
// hq-pro makes passes mirror the Board status. A completed PRD story
|
|
255
|
+
// without an explicit status must therefore become done.
|
|
256
|
+
passes: resolvedStatus === "done",
|
|
257
|
+
...(typeof story.priority === "number" && Number.isFinite(story.priority)
|
|
258
|
+
? { priority: story.priority }
|
|
259
|
+
: {}),
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
const metadata = record(plan?.metadata);
|
|
263
|
+
const repos = metadata && Object.hasOwn(metadata, "repos")
|
|
264
|
+
? parseProjectRepos(metadata.repos, `${source} metadata.repos`)
|
|
265
|
+
: undefined;
|
|
266
|
+
return {
|
|
267
|
+
...(typeof plan?.name === "string" ? { name: plan.name } : {}),
|
|
268
|
+
...(typeof plan?.description === "string" ? { description: plan.description } : {}),
|
|
269
|
+
stories,
|
|
270
|
+
...(repos ? { repos } : {}),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function parseProjectPlan(raw, source) {
|
|
274
|
+
try {
|
|
275
|
+
return projectStoriesFromJson(JSON.parse(raw), source);
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
if (err instanceof SyntaxError)
|
|
279
|
+
fail(`${source} must contain valid JSON.`);
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function runProjectSet(projectId, opts) {
|
|
284
|
+
if (opts.storiesFile && opts.storiesJson) {
|
|
285
|
+
fail("Pass only one of --stories-file or --stories-json.");
|
|
286
|
+
}
|
|
287
|
+
if (opts.reposJson && opts.repo?.length) {
|
|
288
|
+
fail("Pass only one of --repos-json or --repo.");
|
|
289
|
+
}
|
|
290
|
+
const source = opts.storiesFile
|
|
291
|
+
? parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile)
|
|
292
|
+
: opts.storiesJson
|
|
293
|
+
? parseProjectPlan(opts.storiesJson, "--stories-json")
|
|
294
|
+
: undefined;
|
|
295
|
+
const explicitRepos = opts.reposJson
|
|
296
|
+
? parseProjectReposJson(opts.reposJson, "--repos-json")
|
|
297
|
+
: opts.repo?.length
|
|
298
|
+
? opts.repo.map((repo, index) => parseProjectRepoSpec(repo, `--repo[${index}]`))
|
|
299
|
+
: undefined;
|
|
300
|
+
const { token, company } = await withCompany(opts);
|
|
301
|
+
const current = await getProjectView(token, company.companyUid, projectId);
|
|
302
|
+
const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
|
|
303
|
+
const view = await putProjectView(token, company.companyUid, projectId, {
|
|
304
|
+
name: opts.name?.trim() || source?.name || current.name,
|
|
305
|
+
...(opts.description !== undefined
|
|
306
|
+
? { description: opts.description }
|
|
307
|
+
: source?.description !== undefined
|
|
308
|
+
? { description: source.description }
|
|
309
|
+
: { description: current.description }),
|
|
310
|
+
stories: source?.stories ?? current.stories,
|
|
311
|
+
repos: explicitRepos ?? source?.repos ?? current.repos,
|
|
312
|
+
...(current.version !== undefined ? { version: current.version } : {}),
|
|
313
|
+
});
|
|
314
|
+
if (opts.json) {
|
|
315
|
+
console.log(JSON.stringify({ ok: true, action: "project.set", principalUid, ...view }, null, 2));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
console.log(`Work mesh: project ${view.projectId} set (${view.name}; ${view.stories.length} stories)`);
|
|
319
|
+
}
|
|
320
|
+
async function runProjectRegister(projectId, opts) {
|
|
321
|
+
if (opts.channel?.trim()) {
|
|
322
|
+
fail("--channel is not supported by the current Work Mesh registration API; omit it to let the server create or bind the project channel.");
|
|
323
|
+
}
|
|
324
|
+
const { token, company } = await withCompany(opts);
|
|
325
|
+
const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
|
|
326
|
+
const registration = await registerMeshProject(token, company.companyUid, projectId);
|
|
327
|
+
const boardState = "registered";
|
|
328
|
+
if (opts.json) {
|
|
329
|
+
console.log(JSON.stringify({ ok: true, action: "project.register", principalUid, boardState, ...registration }, null, 2));
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
console.log(`Work mesh: project ${registration.projectId} registered (${boardState}; channel ${registration.channelId} ${registration.channelCreated ? "created" : "bound"})`);
|
|
129
333
|
}
|
|
130
334
|
async function runDoctor(opts) {
|
|
131
335
|
if (opts.apply) {
|
|
@@ -134,6 +338,9 @@ async function runDoctor(opts) {
|
|
|
134
338
|
}
|
|
135
339
|
const token = await requireToken();
|
|
136
340
|
const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
|
|
341
|
+
if (!principalUid) {
|
|
342
|
+
fail("Could not resolve the authenticated principal. Sign in again and retry `hq mesh doctor`.");
|
|
343
|
+
}
|
|
137
344
|
const warmed = await warmMeshConversationCache(token, principalUid);
|
|
138
345
|
if (opts.json) {
|
|
139
346
|
console.log(JSON.stringify({ ok: true, action: "doctor", ...warmed }, null, 2));
|
|
@@ -349,19 +556,20 @@ async function runContextBackfillHeld(opts) {
|
|
|
349
556
|
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 0;
|
|
350
557
|
const companyHint = opts.company?.trim() || undefined;
|
|
351
558
|
const useDeviceDefault = opts.useDeviceDefault === true || readDeviceConfig({ root }).repairHeldWithDefault === true;
|
|
352
|
-
// Build the
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
// narrowing, unlike the inline reconcile handler).
|
|
559
|
+
// Build the membership seam for both modes; dry-run is read-only but must
|
|
560
|
+
// validate a locally resolved slug just as live reconciliation does.
|
|
561
|
+
// The remaining seams are captured in the reconcile closure below.
|
|
356
562
|
let deliver;
|
|
357
563
|
let fetchCandidates;
|
|
358
564
|
let validateMembership;
|
|
359
565
|
let offline = Boolean(opts.offline);
|
|
360
|
-
if (!
|
|
566
|
+
if (!offline) {
|
|
361
567
|
try {
|
|
362
568
|
const token = await requireToken();
|
|
363
|
-
|
|
364
|
-
|
|
569
|
+
if (!dryRun) {
|
|
570
|
+
deliver = createWorkSessionDeliverer({ token });
|
|
571
|
+
fetchCandidates = createCandidatesFetcher({ token });
|
|
572
|
+
}
|
|
365
573
|
validateMembership = async (candidate) => {
|
|
366
574
|
const membership = await resolveActiveMembershipCompany(token, candidate);
|
|
367
575
|
if (!membership)
|
|
@@ -386,6 +594,7 @@ async function runContextBackfillHeld(opts) {
|
|
|
386
594
|
// so `--dry-run` and the real run report the same counts.
|
|
387
595
|
remoteOwnerHint: companyHint || undefined,
|
|
388
596
|
useDeviceDefault,
|
|
597
|
+
validateMembership,
|
|
389
598
|
reconcile: (obs) => reconcileObservation(
|
|
390
599
|
// Pass --company as a non-forcing hint (remoteOwnerSlug feeds
|
|
391
600
|
// deterministic resolution BELOW the identity-file / device default,
|
|
@@ -413,6 +622,9 @@ async function runContextBackfillHeld(opts) {
|
|
|
413
622
|
`${result.unresolved} unresolved, ` +
|
|
414
623
|
`${result.errors} error(s).` +
|
|
415
624
|
(dryRun ? " (dry-run: nothing written)" : ""));
|
|
625
|
+
for (const { sessionId, reason } of result.unresolvedReasons) {
|
|
626
|
+
console.log(`Unresolved ${sessionId}: ${reason}`);
|
|
627
|
+
}
|
|
416
628
|
if (!dryRun && result.reconciled > 0) {
|
|
417
629
|
console.log("Reconciled session state files were rewritten with a resolved company; " +
|
|
418
630
|
"the mesh daemon's held retry will re-attribute and post those events.");
|
|
@@ -1078,9 +1290,32 @@ export function registerMeshCommand(program) {
|
|
|
1078
1290
|
.option("--summary <text>", "Note text")).action((opts) => wrap(() => runEvent("note", opts))());
|
|
1079
1291
|
addSharedFlags(mesh
|
|
1080
1292
|
.command("story")
|
|
1081
|
-
.description("PATCH one Board story status")
|
|
1293
|
+
.description("PATCH one Board story status and/or assignee")
|
|
1082
1294
|
.requiredOption("--story <id>", "Story id, e.g. US-001")
|
|
1083
|
-
.
|
|
1295
|
+
.option("--status <status>", "queued|in_progress|review|done")
|
|
1296
|
+
.option("--assignee <uid|me|none>", "Assign a person or agent uid, yourself, or clear")).action((opts) => wrap(() => runStory(opts))());
|
|
1297
|
+
const project = mesh
|
|
1298
|
+
.command("project")
|
|
1299
|
+
.description("Set a project Board view or register its project channel");
|
|
1300
|
+
project
|
|
1301
|
+
.command("set <projectId>")
|
|
1302
|
+
.description("PUT the project Board view from flags or a PRD JSON file")
|
|
1303
|
+
.requiredOption("--company <slug|uid>", "Company slug or cloud uid")
|
|
1304
|
+
.option("--name <name>", "Project name")
|
|
1305
|
+
.option("--description <text>", "Project description")
|
|
1306
|
+
.option("--stories-file <path>", "PRD JSON file with userStories")
|
|
1307
|
+
.option("--stories-json <json>", "Story array or JSON object with userStories")
|
|
1308
|
+
.option("--repos-json <json>", "Repository array, such as [{path,branch}]")
|
|
1309
|
+
.option("--repo <path[@branch]>", "Repository binding (repeatable)", collectRepeatable)
|
|
1310
|
+
.option("--json", "Print machine-readable JSON")
|
|
1311
|
+
.action((projectId, opts) => wrap(() => runProjectSet(projectId, opts))());
|
|
1312
|
+
project
|
|
1313
|
+
.command("register <projectId>")
|
|
1314
|
+
.description("POST project registration to create or bind its project channel and Board")
|
|
1315
|
+
.requiredOption("--company <slug|uid>", "Company slug or cloud uid")
|
|
1316
|
+
.option("--channel <channelId>", "Reserved: the current server chooses the project channel")
|
|
1317
|
+
.option("--json", "Print machine-readable JSON")
|
|
1318
|
+
.action((projectId, opts) => wrap(() => runProjectRegister(projectId, opts))());
|
|
1084
1319
|
mesh
|
|
1085
1320
|
.command("doctor")
|
|
1086
1321
|
.description("Warm ~/.hq/work-mesh/cache from hq-pro (directory, inbox, pair DMs). Not `hq doctor`.")
|
|
@@ -1110,7 +1345,7 @@ export function registerMeshCommand(program) {
|
|
|
1110
1345
|
context
|
|
1111
1346
|
.command("backfill-held")
|
|
1112
1347
|
.description("Reconcile ENDED sessions whose held events lack a company so the daemon re-attributes the backlog (explicit, opt-in; no fleet fan-out)")
|
|
1113
|
-
.option("--dry-run", "Report what would be reconciled; write nothing
|
|
1348
|
+
.option("--dry-run", "Report what would be reconciled; write nothing or deliver events")
|
|
1114
1349
|
.option("--offline", "Reconcile outbox-only (no candidates/register network)")
|
|
1115
1350
|
.option("--limit <n>", "Cap sessions processed (default 0 = no cap)")
|
|
1116
1351
|
.option("--company <slug>", "Optional non-forcing company hint")
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* than truncated. Unknown scalar metadata is dropped instead: the allowlist,
|
|
13
13
|
* not a caller version match, determines what may leave the machine.
|
|
14
14
|
*/
|
|
15
|
+
import * as Sentry from "@sentry/node";
|
|
15
16
|
import type { CaptureContext, SeverityLevel } from "@sentry/node";
|
|
16
17
|
export declare const DEFAULT_SENTRY_REPORT_TIMEOUT_MS = 750;
|
|
17
18
|
export declare const MAX_SENTRY_REPORT_TIMEOUT_MS = 5000;
|
|
@@ -41,6 +42,7 @@ export type SentryReportOptions = {
|
|
|
41
42
|
export type SentryReportDependencies = {
|
|
42
43
|
sentry: {
|
|
43
44
|
captureMessage: (message: string, context?: CaptureContext | SeverityLevel) => string;
|
|
45
|
+
metrics: Pick<typeof Sentry.metrics, "count">;
|
|
44
46
|
flush: (timeout?: number) => PromiseLike<boolean>;
|
|
45
47
|
};
|
|
46
48
|
stdout: Pick<NodeJS.WriteStream, "write">;
|
|
@@ -29,6 +29,9 @@ const SENTRY_REPORT_LEVELS = new Set([
|
|
|
29
29
|
"info",
|
|
30
30
|
"debug",
|
|
31
31
|
]);
|
|
32
|
+
const HOOK_TIMEOUT_WARNING_TYPE = "hook_timeout_warning";
|
|
33
|
+
const HOOK_TIMEOUT_WARNING_METRIC = "hq.hook_timeout_warning";
|
|
34
|
+
const HOOK_TIMEOUT_EXCEEDED_FINGERPRINT = "hook-timeout-exceeded";
|
|
32
35
|
/**
|
|
33
36
|
* The only hook diagnostics that can be attached as metadata. The list omits
|
|
34
37
|
* command text, prompts, payloads, company names, and repositories. `hook_path`
|
|
@@ -182,21 +185,43 @@ function resolveTimeout(timeoutMs) {
|
|
|
182
185
|
return null;
|
|
183
186
|
return timeout;
|
|
184
187
|
}
|
|
185
|
-
function sentryEnvelope(event) {
|
|
188
|
+
function sentryEnvelope(event, overrides) {
|
|
186
189
|
return {
|
|
187
|
-
level: event.level,
|
|
188
|
-
fingerprint: [event.fingerprint],
|
|
190
|
+
level: overrides?.level ?? event.level,
|
|
191
|
+
fingerprint: overrides?.fingerprint ?? [event.fingerprint],
|
|
189
192
|
tags: { sentry_event_type: event.type },
|
|
190
193
|
extra: event.metadata,
|
|
191
194
|
};
|
|
192
195
|
}
|
|
193
196
|
/** Build the same scrubbed event projection that the live SDK path uses. */
|
|
194
|
-
function scrubbedSentryEnvelope(event) {
|
|
197
|
+
function scrubbedSentryEnvelope(event, overrides) {
|
|
195
198
|
return beforeSend({
|
|
196
199
|
message: event.message,
|
|
197
|
-
...sentryEnvelope(event),
|
|
200
|
+
...sentryEnvelope(event, overrides),
|
|
198
201
|
}, {});
|
|
199
202
|
}
|
|
203
|
+
/**
|
|
204
|
+
* The watchdog's relative threshold fires before the declared timeout and
|
|
205
|
+
* clamps remaining_ms at zero, so its real warnings stay on the metric path.
|
|
206
|
+
* Keep a defensive branch for a genuine exceeded payload from another
|
|
207
|
+
* producer without restoring per-hook grouping.
|
|
208
|
+
*/
|
|
209
|
+
function hookTimeoutExceededOverrides(event) {
|
|
210
|
+
if (event.type !== HOOK_TIMEOUT_WARNING_TYPE)
|
|
211
|
+
return undefined;
|
|
212
|
+
const elapsedMs = event.metadata.elapsed_ms;
|
|
213
|
+
const declaredTimeoutMs = event.metadata.declared_timeout_ms;
|
|
214
|
+
if (typeof elapsedMs !== "number" ||
|
|
215
|
+
typeof declaredTimeoutMs !== "number" ||
|
|
216
|
+
elapsedMs < declaredTimeoutMs) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
const hookEvent = typeof event.metadata.hook_event === "string" ? event.metadata.hook_event : "unknown";
|
|
220
|
+
return {
|
|
221
|
+
level: "warning",
|
|
222
|
+
fingerprint: [HOOK_TIMEOUT_EXCEEDED_FINGERPRINT, hookEvent],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
200
225
|
/**
|
|
201
226
|
* Resolve true only when the local transport queue drains within the budget.
|
|
202
227
|
* This does not confirm that the ingest service accepted the event.
|
|
@@ -234,13 +259,28 @@ export async function reportSentryEvent(stdin, options = {}, dependencies = defa
|
|
|
234
259
|
const noun = result.droppedMetadataKeys === 1 ? "key" : "keys";
|
|
235
260
|
dependencies.stderr.write(`Dropped ${result.droppedMetadataKeys} unsupported Sentry report metadata ${noun}.\n`);
|
|
236
261
|
}
|
|
237
|
-
const
|
|
262
|
+
const overrides = hookTimeoutExceededOverrides(result.event);
|
|
238
263
|
if (options.dryRun) {
|
|
239
|
-
const scrubbed = scrubbedSentryEnvelope(result.event);
|
|
264
|
+
const scrubbed = scrubbedSentryEnvelope(result.event, overrides);
|
|
240
265
|
if (scrubbed)
|
|
241
266
|
dependencies.stdout.write(`${JSON.stringify(scrubbed)}\n`);
|
|
242
267
|
return result;
|
|
243
268
|
}
|
|
269
|
+
if (result.event.type === HOOK_TIMEOUT_WARNING_TYPE && !overrides) {
|
|
270
|
+
try {
|
|
271
|
+
dependencies.sentry.metrics.count(HOOK_TIMEOUT_WARNING_METRIC, 1, {
|
|
272
|
+
attributes: result.event.metadata,
|
|
273
|
+
});
|
|
274
|
+
if (await flushWithin(dependencies.sentry, timeoutMs))
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Report failure below without exposing event fields or transport details.
|
|
279
|
+
}
|
|
280
|
+
dependencies.stderr.write("Sentry report failed.\n");
|
|
281
|
+
return invalidInput();
|
|
282
|
+
}
|
|
283
|
+
const envelope = sentryEnvelope(result.event, overrides);
|
|
244
284
|
try {
|
|
245
285
|
dependencies.sentry.captureMessage(result.event.message, envelope);
|
|
246
286
|
if (await flushWithin(dependencies.sentry, timeoutMs))
|
|
@@ -2,17 +2,37 @@
|
|
|
2
2
|
* Provider account resolution (spec §5.5).
|
|
3
3
|
*
|
|
4
4
|
* Naming an account and omitting one mean different things: an explicit
|
|
5
|
-
* disabled id is refused; omitting
|
|
5
|
+
* disabled id is refused; omitting picks the least-loaded eligible account.
|
|
6
6
|
* Status reports `configured` and `logged_in` separately. logged_in is
|
|
7
|
-
* derived from whether
|
|
8
|
-
* prints, copies, or hashes that file.
|
|
7
|
+
* derived from whether a provider credential file EXISTS — this module
|
|
8
|
+
* never reads, prints, copies, or hashes that file.
|
|
9
9
|
*/
|
|
10
|
-
import type
|
|
10
|
+
import { type LaneRecord, type LaneProvider } from "./types.js";
|
|
11
|
+
import type { LanesConfig } from "./types.js";
|
|
11
12
|
export declare const PROVIDER_HOME_ENV: Record<LaneProvider, string>;
|
|
12
13
|
export declare const AUTH_JSON_NAME = "auth.json";
|
|
14
|
+
export declare const CLAUDE_CREDENTIALS_NAME = ".credentials.json";
|
|
15
|
+
/**
|
|
16
|
+
* Credential filenames are a property of the provider. Codex and Grok write
|
|
17
|
+
* auth.json. Claude Code writes .credentials.json (never auth.json) inside
|
|
18
|
+
* the config dir, and can also authenticate via the OS keychain with no
|
|
19
|
+
* file here at all.
|
|
20
|
+
*/
|
|
21
|
+
export declare const PROVIDER_CREDENTIAL_FILES: Record<LaneProvider, readonly string[]>;
|
|
22
|
+
export type AuthState = "file" | "absent" | "unknown";
|
|
13
23
|
export interface AccountDeps {
|
|
14
24
|
existsSync: (p: string) => boolean;
|
|
15
25
|
}
|
|
26
|
+
export interface AccountResolveContext {
|
|
27
|
+
hqRoot?: string;
|
|
28
|
+
existsSync?: (p: string) => boolean;
|
|
29
|
+
loadLanes?: (hqRoot: string) => LaneRecord[];
|
|
30
|
+
/** Already-resolved model for this lane. Beats cliModel and account defaults. */
|
|
31
|
+
model?: string;
|
|
32
|
+
/** `--model` from create, applied to every candidate account. */
|
|
33
|
+
cliModel?: string;
|
|
34
|
+
now?: Date;
|
|
35
|
+
}
|
|
16
36
|
export interface ResolvedAccount {
|
|
17
37
|
provider: LaneProvider;
|
|
18
38
|
id: string;
|
|
@@ -29,12 +49,52 @@ export interface AccountStatus {
|
|
|
29
49
|
/** Present in personal/lanes/config.json. */
|
|
30
50
|
configured: boolean;
|
|
31
51
|
/**
|
|
32
|
-
* True
|
|
52
|
+
* True when a provider credential file exists. False only when every
|
|
53
|
+
* candidate is absent AND absence is conclusive for that provider
|
|
54
|
+
* (Codex, Grok). Null for Claude when no candidate exists: Claude can
|
|
55
|
+
* authenticate via keychain, so a missing file is not logged-out.
|
|
56
|
+
* Never inspects file contents.
|
|
33
57
|
*/
|
|
34
|
-
logged_in: boolean;
|
|
58
|
+
logged_in: boolean | null;
|
|
59
|
+
auth_state: AuthState;
|
|
35
60
|
envVar: string;
|
|
36
61
|
}
|
|
37
|
-
export declare function
|
|
62
|
+
export declare function credentialPaths(provider: LaneProvider, home: string): string[];
|
|
63
|
+
/**
|
|
64
|
+
* Existence-only probe. Callers must not read the files this returns paths to.
|
|
65
|
+
*/
|
|
66
|
+
export declare function hasCredentialFile(provider: LaneProvider, home: string, existsSync: (p: string) => boolean): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Claude with no file in the config dir is unknown, not logged out: the
|
|
69
|
+
* credential may live in the OS keychain or an API-key helper. Codex and
|
|
70
|
+
* Grok write auth.json when logged in, so absence is logged out.
|
|
71
|
+
*/
|
|
72
|
+
export declare function authStateFor(provider: LaneProvider, present: boolean): AuthState;
|
|
73
|
+
export declare function loggedInFromAuthState(state: AuthState): boolean | null;
|
|
74
|
+
/** Admission and listing share this: refuse only a conclusive logged-out. */
|
|
75
|
+
export declare function isLoggedOut(status: Pick<AccountStatus, "logged_in">): boolean;
|
|
76
|
+
/** Auto-select requires a proven credential file, not Claude-unknown. */
|
|
77
|
+
export declare function isCredentialProven(status: Pick<AccountStatus, "logged_in">): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Occupying states hold a slot: running / awaiting_input / blocked have a
|
|
80
|
+
* live (or paused-live) worker; queued holds a queue slot that will take a
|
|
81
|
+
* running slot. Terminal states do not count.
|
|
82
|
+
*/
|
|
83
|
+
export declare function isOccupyingLaneState(state: LaneRecord["state"]): boolean;
|
|
84
|
+
export declare function occupyingLaneCount(lanes: readonly LaneRecord[], provider: LaneProvider, accountId: string): number;
|
|
85
|
+
export declare function runningLaneCount(lanes: readonly LaneRecord[], provider: LaneProvider, accountId: string): number;
|
|
86
|
+
/** Most recent created_at on this account+provider, any state. Null if none. */
|
|
87
|
+
export declare function lastStartedAt(lanes: readonly LaneRecord[], provider: LaneProvider, accountId: string): string | null;
|
|
88
|
+
/**
|
|
89
|
+
* Pick among enabled, credential-proven accounts by occupying count
|
|
90
|
+
* (lowest wins). Ties break to least-recently-started (oldest created_at),
|
|
91
|
+
* then account id ascending. Accounts at max_running are excluded from
|
|
92
|
+
* start-eligible; if every proven account is at cap the pick still returns
|
|
93
|
+
* one so admission can queue rather than overflow.
|
|
94
|
+
*/
|
|
95
|
+
export declare function selectBalancedAccount(provider: LaneProvider, config: LanesConfig, ctx?: AccountResolveContext): ResolvedAccount;
|
|
96
|
+
export declare function resolveAccount(provider: LaneProvider, requestedId: string | undefined, config: LanesConfig, ctx?: AccountResolveContext): ResolvedAccount;
|
|
97
|
+
/** Codex/Grok path. Kept for callers that still name auth.json. */
|
|
38
98
|
export declare function authJsonPath(home: string): string;
|
|
39
99
|
export declare function accountStatus(provider: LaneProvider, accountId: string, config: LanesConfig, deps?: AccountDeps): AccountStatus;
|
|
40
100
|
export declare function listAccountStatuses(provider: LaneProvider, config: LanesConfig, deps?: AccountDeps): AccountStatus[];
|