@indigoai-us/hq-cli 5.345.135 → 5.345.137
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 +44 -0
- package/assets/scaffold/core/scripts/worktree-gc.sh +98 -8
- package/dist/command-catalog.generated.d.ts +17 -6
- package/dist/command-catalog.generated.js +23 -7
- package/dist/commands/core-checkpoint-native.d.ts +10 -0
- package/dist/commands/core-checkpoint-native.js +60 -15
- package/dist/commands/daemon.js +3 -1
- package/dist/commands/integrations-core.d.ts +22 -0
- package/dist/commands/integrations-core.js +25 -0
- package/dist/commands/integrations-manage.d.ts +24 -1
- package/dist/commands/integrations-manage.js +92 -17
- package/dist/commands/integrations.d.ts +11 -0
- package/dist/commands/integrations.js +100 -19
- package/dist/commands/lanes-loop.d.ts +9 -0
- package/dist/commands/lanes-loop.js +9 -1
- package/dist/commands/lanes.js +69 -54
- package/dist/commands/meetings.d.ts +2 -0
- package/dist/commands/meetings.js +57 -14
- package/dist/commands/mesh.d.ts +19 -1
- package/dist/commands/mesh.js +158 -70
- package/dist/commands/reindex.js +80 -5
- package/dist/commands/storage-offload.d.ts +93 -28
- package/dist/commands/storage-offload.js +290 -34
- package/dist/commands/storage.js +60 -7
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2550 -1748
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.js +4 -0
- package/dist/lib/capacity/route.js +24 -0
- package/dist/lib/core-utils/session-company.d.ts +11 -0
- package/dist/lib/core-utils/session-company.js +34 -0
- package/dist/lib/core-utils/worktree-resume.js +44 -4
- package/dist/lib/core-utils/worktree-session-owner.d.ts +27 -0
- package/dist/lib/core-utils/worktree-session-owner.js +157 -0
- package/dist/lib/daemon/catalog.d.ts +2 -0
- package/dist/lib/daemon/catalog.js +3 -3
- package/dist/lib/daemon/hq-child-bootstrap-stage.d.ts +5 -0
- package/dist/lib/daemon/hq-child-bootstrap-stage.js +3 -11
- package/dist/lib/daemon/server.js +10 -1
- package/dist/lib/daemon/supervisor.js +6 -0
- package/dist/lib/daemon/sync-runner-bundle.d.ts +5 -0
- package/dist/lib/daemon/sync-runner-bundle.js +4 -12
- package/dist/lib/doctor/checks/claude-wiring.js +2 -1
- package/dist/lib/doctor/fix/apply.d.ts +20 -0
- package/dist/lib/doctor/fix/apply.js +63 -3
- package/dist/lib/doctor/fix/remediation.d.ts +1 -1
- package/dist/lib/doctor/fix/remediation.js +16 -0
- package/dist/lib/doctor/shared.d.ts +11 -1
- package/dist/lib/doctor/shared.js +16 -2
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/monitor.js +14 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
- package/dist/lib/mesh/live/daemon/install.js +22 -0
- package/dist/lib/mesh/live/flush.js +106 -3
- package/dist/lib/mesh/live/session-events-client.d.ts +9 -0
- package/dist/lib/mesh/live/session-events-client.js +15 -0
- package/dist/lib/work-context/state.d.ts +10 -0
- package/dist/lib/work-context/state.js +5 -0
- package/dist/utils/fsync-directory.d.ts +17 -0
- package/dist/utils/fsync-directory.js +27 -0
- package/dist/utils/windows-hide-default.d.ts +42 -0
- package/dist/utils/windows-hide-default.js +98 -0
- package/package.json +2 -2
|
@@ -251,26 +251,69 @@ function renderSignals(signals) {
|
|
|
251
251
|
}
|
|
252
252
|
console.log();
|
|
253
253
|
}
|
|
254
|
+
/** The server caps one `/v1/meetings` page at this many rows. */
|
|
255
|
+
const MEETINGS_PAGE_SIZE = "100";
|
|
256
|
+
/** Upper bound on list pages one short-id lookup reads (per cursor chain). */
|
|
257
|
+
export const SHORT_ID_MAX_PAGES = 50;
|
|
258
|
+
/**
|
|
259
|
+
* Resolve the 8-character id `hq meetings list` prints to a full meeting id.
|
|
260
|
+
*
|
|
261
|
+
* `/v1/meetings` is paged: the server caps a page at 100 rows (it ignores a
|
|
262
|
+
* larger `limit`) and may end a page early, even empty, with
|
|
263
|
+
* `truncated: true` when it runs out of time. Reading only the first page
|
|
264
|
+
* missed any meeting past it, so follow `nextToken` (and `nativeNextToken`
|
|
265
|
+
* for HQ Meet calls) until the prefix is found or the list ends. A full
|
|
266
|
+
* 8-character prefix stops at the first page with a match; a shorter prefix
|
|
267
|
+
* reads every page so ambiguity is still reported.
|
|
268
|
+
*/
|
|
254
269
|
async function resolveShortId(token, prefix, query) {
|
|
255
270
|
if (prefix.includes("-") && prefix.length > 8)
|
|
256
271
|
return prefix;
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
272
|
+
const stopOnFirstMatch = prefix.length >= 8;
|
|
273
|
+
const matches = new Map();
|
|
274
|
+
let capped = false;
|
|
275
|
+
const fetchPage = async (extra) => {
|
|
276
|
+
const res = await vaultApiFetch({
|
|
277
|
+
token,
|
|
278
|
+
path: "/v1/meetings",
|
|
279
|
+
query: { ...query, limit: MEETINGS_PAGE_SIZE, ...extra },
|
|
280
|
+
});
|
|
281
|
+
if (!res.ok) {
|
|
282
|
+
throw new Error(`Could not resolve meeting ID "${prefix}" — failed to list meetings (${res.status}). Pass the full meeting id.`);
|
|
283
|
+
}
|
|
284
|
+
const data = (await res.json());
|
|
285
|
+
for (const m of data.meetings ?? []) {
|
|
286
|
+
if (typeof m.meetingId === "string" && m.meetingId.startsWith(prefix))
|
|
287
|
+
matches.set(m.meetingId, m);
|
|
288
|
+
}
|
|
289
|
+
return data;
|
|
290
|
+
};
|
|
291
|
+
const first = await fetchPage({});
|
|
292
|
+
let nextToken = first.nextToken;
|
|
293
|
+
for (let page = 1; nextToken && !(stopOnFirstMatch && matches.size > 0); page += 1) {
|
|
294
|
+
if (page >= SHORT_ID_MAX_PAGES) {
|
|
295
|
+
capped = true;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
nextToken = (await fetchPage({ nextToken })).nextToken;
|
|
299
|
+
}
|
|
300
|
+
let nativeCursor = first.nativeNextToken;
|
|
301
|
+
for (let page = 1; nativeCursor && !(stopOnFirstMatch && matches.size > 0); page += 1) {
|
|
302
|
+
if (page >= SHORT_ID_MAX_PAGES) {
|
|
303
|
+
capped = true;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
nativeCursor = (await fetchPage({ nativeCursor })).nativeNextToken;
|
|
264
307
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (matches.
|
|
268
|
-
|
|
269
|
-
if (matches.length > 1) {
|
|
270
|
-
console.error(chalk.red(`Ambiguous ID prefix "${prefix}" — matches ${matches.length} meetings. Use a longer prefix.`));
|
|
308
|
+
if (matches.size === 1)
|
|
309
|
+
return [...matches.keys()][0];
|
|
310
|
+
if (matches.size > 1) {
|
|
311
|
+
console.error(chalk.red(`Ambiguous ID prefix "${prefix}" — matches ${matches.size} meetings. Use a longer prefix.`));
|
|
271
312
|
process.exit(1);
|
|
272
313
|
}
|
|
273
|
-
console.error(chalk.red(
|
|
314
|
+
console.error(chalk.red(capped
|
|
315
|
+
? `No meeting matches ID "${prefix}" in the newest ${SHORT_ID_MAX_PAGES * Number(MEETINGS_PAGE_SIZE)} meetings. Pass the full meeting id (shown by \`hq meetings list --json\`).`
|
|
316
|
+
: `No meeting matches ID "${prefix}". It may still be processing or attributed to a different company. Pass the full meeting id, or add --company <slug>.`));
|
|
274
317
|
process.exit(1);
|
|
275
318
|
}
|
|
276
319
|
async function handleApiError(res, json = false) {
|
package/dist/commands/mesh.d.ts
CHANGED
|
@@ -6,10 +6,27 @@
|
|
|
6
6
|
* Distinct from `hq doctor` (hook guardrails).
|
|
7
7
|
*/
|
|
8
8
|
import { Command } from "commander";
|
|
9
|
-
import { type MeshCompany, type MeshThread } from "../lib/mesh/api.js";
|
|
9
|
+
import { type MeshCompany, type MeshProjectRepo, type MeshProjectStoryInput, type MeshThread } from "../lib/mesh/api.js";
|
|
10
|
+
import { UnknownProjectStoryStateError } from "../lib/mesh/story-status.js";
|
|
10
11
|
import { type DaemonInstallPlatform, type InstallPaths } from "../lib/mesh/live/daemon/install.js";
|
|
11
12
|
import { type RunCommand } from "../lib/service-manager/launchd.js";
|
|
12
13
|
export declare function formatCheckLines(threads: MeshThread[], company: MeshCompany, projectId?: string): string[];
|
|
14
|
+
/** 400 PROJECT_VIEW_INVALID "Unknown story ..." from a story PATCH. */
|
|
15
|
+
export declare function isUnknownBoardStory(error: unknown): boolean;
|
|
16
|
+
/** The local PRD for this project when it lists `storyId`; null otherwise. */
|
|
17
|
+
export declare function localPrdWithStory(company: {
|
|
18
|
+
companySlug?: string;
|
|
19
|
+
}, projectId: string, storyId: string, hqRoot?: string | undefined): {
|
|
20
|
+
prdPath: string;
|
|
21
|
+
source: ProjectPlanSource;
|
|
22
|
+
} | null;
|
|
23
|
+
type ProjectPlanSource = {
|
|
24
|
+
name?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
stories: MeshProjectStoryInput[];
|
|
27
|
+
repos?: MeshProjectRepo[];
|
|
28
|
+
rejectedStories: UnknownProjectStoryStateError[];
|
|
29
|
+
};
|
|
13
30
|
/** Test seam for the usage-attribution question asked by `default set`. */
|
|
14
31
|
export interface DefaultCompanyPromptDeps {
|
|
15
32
|
/** True when stdin and stdout are a terminal (a person can answer). */
|
|
@@ -61,4 +78,5 @@ export declare function runDaemonStatus(opts: {
|
|
|
61
78
|
run?: RunCommand;
|
|
62
79
|
uid?: number;
|
|
63
80
|
}): Promise<void>;
|
|
81
|
+
export {};
|
|
64
82
|
//# sourceMappingURL=mesh.d.ts.map
|
package/dist/commands/mesh.js
CHANGED
|
@@ -174,6 +174,42 @@ async function requireAssignableSelf(token, verb) {
|
|
|
174
174
|
}
|
|
175
175
|
return resolved;
|
|
176
176
|
}
|
|
177
|
+
/** 400 PROJECT_VIEW_INVALID "Unknown story ..." from a story PATCH. */
|
|
178
|
+
export function isUnknownBoardStory(error) {
|
|
179
|
+
if (!error || typeof error !== "object")
|
|
180
|
+
return false;
|
|
181
|
+
const candidate = error;
|
|
182
|
+
return (httpStatus(error) === 400 &&
|
|
183
|
+
(candidate.code === "PROJECT_VIEW_INVALID" ||
|
|
184
|
+
(typeof candidate.message === "string" && candidate.message.includes("PROJECT_VIEW_INVALID"))) &&
|
|
185
|
+
typeof candidate.message === "string" &&
|
|
186
|
+
candidate.message.includes("Unknown story"));
|
|
187
|
+
}
|
|
188
|
+
/** The local PRD for this project when it lists `storyId`; null otherwise. */
|
|
189
|
+
export function localPrdWithStory(company, projectId, storyId, hqRoot = process.env.HQ_ROOT?.trim() || findHqRoot() || undefined) {
|
|
190
|
+
const prdPath = findLocalPrd(hqRoot, company.companySlug, projectId);
|
|
191
|
+
if (!prdPath)
|
|
192
|
+
return null;
|
|
193
|
+
let source;
|
|
194
|
+
try {
|
|
195
|
+
source = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath, { requireUserStories: true });
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return source.stories.some((story) => story.id === storyId) ? { prdPath, source } : null;
|
|
201
|
+
}
|
|
202
|
+
function unknownStoryHint(error, company, projectId, storyId, prdPath) {
|
|
203
|
+
const base = error instanceof Error ? error.message : String(error);
|
|
204
|
+
const command = `hq mesh project ensure ${projectId} --company ${company.companySlug ?? company.companyUid} --stories-file ${prdPath ?? "<prd.json>"}`;
|
|
205
|
+
const hint = prdPath
|
|
206
|
+
? `${storyId} is in ${prdPath} but not on the Board. Register it with: ${command}`
|
|
207
|
+
: `${storyId} is not on the ${projectId} Board. Add it to the project PRD, then run: ${command}`;
|
|
208
|
+
return Object.assign(new Error(`${base}\n${hint}`), {
|
|
209
|
+
status: httpStatus(error),
|
|
210
|
+
code: error?.code,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
177
213
|
async function runStory(opts) {
|
|
178
214
|
const storyId = opts.story?.trim();
|
|
179
215
|
const status = opts.status?.trim();
|
|
@@ -203,14 +239,46 @@ async function runStory(opts) {
|
|
|
203
239
|
}
|
|
204
240
|
assigneeUid = assignee;
|
|
205
241
|
}
|
|
242
|
+
const patch = {
|
|
243
|
+
...(status ? { status } : {}),
|
|
244
|
+
...(assigneeUid ? { assigneeUid } : {}),
|
|
245
|
+
...(clearAssignee ? { clearAssignee: true } : {}),
|
|
246
|
+
...(opts.force ? { force: true } : {}),
|
|
247
|
+
};
|
|
206
248
|
let patched;
|
|
249
|
+
let registeredFrom;
|
|
207
250
|
try {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
251
|
+
try {
|
|
252
|
+
patched = await patchStory(token, company.companyUid, projectId, storyId, patch);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (!isUnknownBoardStory(err))
|
|
256
|
+
throw err;
|
|
257
|
+
// The story is in the local PRD but not on the Board yet: register the
|
|
258
|
+
// PRD (the same merge `hq mesh project ensure` does), then retry once.
|
|
259
|
+
const recovery = localPrdWithStory(company, projectId, storyId);
|
|
260
|
+
if (!recovery)
|
|
261
|
+
throw unknownStoryHint(err, company, projectId, storyId);
|
|
262
|
+
if (opts.ensure === false) {
|
|
263
|
+
throw unknownStoryHint(err, company, projectId, storyId, recovery.prdPath);
|
|
264
|
+
}
|
|
265
|
+
await ensureProjectFromSource({
|
|
266
|
+
token,
|
|
267
|
+
company,
|
|
268
|
+
projectId,
|
|
269
|
+
source: recovery.source,
|
|
270
|
+
current: await getProjectView(token, company.companyUid, projectId).catch((viewErr) => {
|
|
271
|
+
if (httpStatus(viewErr) === 404)
|
|
272
|
+
return undefined;
|
|
273
|
+
throw viewErr;
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
registeredFrom = recovery.prdPath;
|
|
277
|
+
if (!opts.json) {
|
|
278
|
+
console.error(`Work mesh: ${storyId} was not on the ${projectId} Board; registered the project from ${recovery.prdPath}.`);
|
|
279
|
+
}
|
|
280
|
+
patched = await patchStory(token, company.companyUid, projectId, storyId, patch);
|
|
281
|
+
}
|
|
214
282
|
}
|
|
215
283
|
catch (err) {
|
|
216
284
|
const conflict = storyConflictFromError(err);
|
|
@@ -219,7 +287,14 @@ async function runStory(opts) {
|
|
|
219
287
|
throw err;
|
|
220
288
|
}
|
|
221
289
|
if (opts.json) {
|
|
222
|
-
console.log(JSON.stringify({
|
|
290
|
+
console.log(JSON.stringify({
|
|
291
|
+
ok: true,
|
|
292
|
+
action: "story",
|
|
293
|
+
company,
|
|
294
|
+
...patched,
|
|
295
|
+
storyId,
|
|
296
|
+
...(registeredFrom ? { registeredFrom } : {}),
|
|
297
|
+
}, null, 2));
|
|
223
298
|
return;
|
|
224
299
|
}
|
|
225
300
|
const assignment = assignee === "none"
|
|
@@ -590,67 +665,13 @@ async function runProjectMemberAdd(projectId, personUid, opts) {
|
|
|
590
665
|
? `Work mesh: added ${personUid} to project ${projectId}`
|
|
591
666
|
: `Work mesh: ${personUid} is already a member of project ${projectId}`);
|
|
592
667
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
catch (err) {
|
|
601
|
-
if (!(err instanceof RejectedProjectStoryStatesError) || !opts.json)
|
|
602
|
-
throw err;
|
|
603
|
-
failProjectStoryStates(err.rejected, true);
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
const { token, company } = await withCompany(opts);
|
|
607
|
-
let current;
|
|
608
|
-
try {
|
|
609
|
-
current = await getProjectView(token, company.companyUid, projectId);
|
|
610
|
-
}
|
|
611
|
-
catch (err) {
|
|
612
|
-
if (httpStatus(err) !== 404)
|
|
613
|
-
throw err;
|
|
614
|
-
}
|
|
615
|
-
if (opts.dryRun) {
|
|
616
|
-
if (current && (current.companyUid !== company.companyUid || current.projectId !== projectId)) {
|
|
617
|
-
fail("Work mesh project ensure dry-run: Board company or project id did not match the request.");
|
|
618
|
-
}
|
|
619
|
-
const stories = current ? mergeProjectStories(current.stories, source.stories) : source.stories;
|
|
620
|
-
const sourceStatuses = source.stories.map((story) => ({
|
|
621
|
-
id: story.id,
|
|
622
|
-
title: story.title,
|
|
623
|
-
status: boardStoryStatus(story),
|
|
624
|
-
}));
|
|
625
|
-
const boardStatuses = stories.map((story) => ({
|
|
626
|
-
id: story.id,
|
|
627
|
-
title: story.title,
|
|
628
|
-
status: boardStoryStatus(story),
|
|
629
|
-
}));
|
|
630
|
-
if (opts.json) {
|
|
631
|
-
console.log(JSON.stringify({
|
|
632
|
-
ok: source.rejectedStories.length === 0,
|
|
633
|
-
action: "project.ensure.dry-run",
|
|
634
|
-
projectId,
|
|
635
|
-
companyUid: company.companyUid,
|
|
636
|
-
sourceStatuses,
|
|
637
|
-
stories: boardStatuses,
|
|
638
|
-
...(source.rejectedStories.length ? { errors: projectStateErrors(source.rejectedStories) } : {}),
|
|
639
|
-
}, null, 2));
|
|
640
|
-
}
|
|
641
|
-
else {
|
|
642
|
-
console.log(`Work mesh: project ${projectId} dry run (${sourceStatuses.length} source stories)`);
|
|
643
|
-
console.log("Source statuses:");
|
|
644
|
-
for (const story of sourceStatuses)
|
|
645
|
-
console.log(`${story.id}\t${story.status}`);
|
|
646
|
-
console.log("Merged Board statuses:");
|
|
647
|
-
for (const story of boardStatuses)
|
|
648
|
-
console.log(`${story.id}\t${story.status}`);
|
|
649
|
-
}
|
|
650
|
-
if (source.rejectedStories.length)
|
|
651
|
-
process.exitCode = 1;
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
668
|
+
/**
|
|
669
|
+
* Create or merge the project Board view from a parsed PRD, register the
|
|
670
|
+
* project, and verify every PRD story and repo is on the Board. Shared by
|
|
671
|
+
* `hq mesh project ensure` and the `hq mesh story` unknown-story recovery.
|
|
672
|
+
*/
|
|
673
|
+
async function ensureProjectFromSource(input) {
|
|
674
|
+
const { token, company, projectId, source, current } = input;
|
|
654
675
|
// Genesis thread, then the view, then register. Register returns 403
|
|
655
676
|
// PROJECT_NOT_IN_COMPANY until both exist. A missing view uses that chain
|
|
656
677
|
// from ensureProjectBoard; an existing view is merged, then registered.
|
|
@@ -726,12 +747,78 @@ async function runProjectEnsure(projectId, opts) {
|
|
|
726
747
|
if (verified.version === undefined) {
|
|
727
748
|
fail("Work mesh project ensure: verified view has no version.");
|
|
728
749
|
}
|
|
729
|
-
|
|
750
|
+
return {
|
|
730
751
|
projectId: verified.projectId,
|
|
731
752
|
threadId: registration.threadId,
|
|
732
753
|
channelId: registration.channelId,
|
|
733
754
|
version: verified.version,
|
|
734
755
|
stories: verified.stories,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
async function runProjectEnsure(projectId, opts) {
|
|
759
|
+
if (!opts.storiesFile?.trim())
|
|
760
|
+
fail("`--stories-file <prd.json>` is required.");
|
|
761
|
+
let source;
|
|
762
|
+
try {
|
|
763
|
+
source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { requireUserStories: true });
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
if (!(err instanceof RejectedProjectStoryStatesError) || !opts.json)
|
|
767
|
+
throw err;
|
|
768
|
+
failProjectStoryStates(err.rejected, true);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const { token, company } = await withCompany(opts);
|
|
772
|
+
let current;
|
|
773
|
+
try {
|
|
774
|
+
current = await getProjectView(token, company.companyUid, projectId);
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
if (httpStatus(err) !== 404)
|
|
778
|
+
throw err;
|
|
779
|
+
}
|
|
780
|
+
if (opts.dryRun) {
|
|
781
|
+
if (current && (current.companyUid !== company.companyUid || current.projectId !== projectId)) {
|
|
782
|
+
fail("Work mesh project ensure dry-run: Board company or project id did not match the request.");
|
|
783
|
+
}
|
|
784
|
+
const stories = current ? mergeProjectStories(current.stories, source.stories) : source.stories;
|
|
785
|
+
const sourceStatuses = source.stories.map((story) => ({
|
|
786
|
+
id: story.id,
|
|
787
|
+
title: story.title,
|
|
788
|
+
status: boardStoryStatus(story),
|
|
789
|
+
}));
|
|
790
|
+
const boardStatuses = stories.map((story) => ({
|
|
791
|
+
id: story.id,
|
|
792
|
+
title: story.title,
|
|
793
|
+
status: boardStoryStatus(story),
|
|
794
|
+
}));
|
|
795
|
+
if (opts.json) {
|
|
796
|
+
console.log(JSON.stringify({
|
|
797
|
+
ok: source.rejectedStories.length === 0,
|
|
798
|
+
action: "project.ensure.dry-run",
|
|
799
|
+
projectId,
|
|
800
|
+
companyUid: company.companyUid,
|
|
801
|
+
sourceStatuses,
|
|
802
|
+
stories: boardStatuses,
|
|
803
|
+
...(source.rejectedStories.length ? { errors: projectStateErrors(source.rejectedStories) } : {}),
|
|
804
|
+
}, null, 2));
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
console.log(`Work mesh: project ${projectId} dry run (${sourceStatuses.length} source stories)`);
|
|
808
|
+
console.log("Source statuses:");
|
|
809
|
+
for (const story of sourceStatuses)
|
|
810
|
+
console.log(`${story.id}\t${story.status}`);
|
|
811
|
+
console.log("Merged Board statuses:");
|
|
812
|
+
for (const story of boardStatuses)
|
|
813
|
+
console.log(`${story.id}\t${story.status}`);
|
|
814
|
+
}
|
|
815
|
+
if (source.rejectedStories.length)
|
|
816
|
+
process.exitCode = 1;
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const ensured = await ensureProjectFromSource({ token, company, projectId, source, current });
|
|
820
|
+
const payload = {
|
|
821
|
+
...ensured,
|
|
735
822
|
...(source.rejectedStories.length ? { ok: false, errors: projectStateErrors(source.rejectedStories) } : {}),
|
|
736
823
|
};
|
|
737
824
|
noteCallingSessionBound(payload.projectId, company);
|
|
@@ -2520,7 +2607,8 @@ export function registerMeshCommand(program, deps = {}) {
|
|
|
2520
2607
|
.requiredOption("--story <id>", "Story id, e.g. US-001")
|
|
2521
2608
|
.option("--status <status>", "queued|in_progress|review|done")
|
|
2522
2609
|
.option("--assignee <uid|me|none>", "Assign a person or agent uid, yourself, or clear")
|
|
2523
|
-
.option("--force", "Override another holder's assignment")
|
|
2610
|
+
.option("--force", "Override another holder's assignment")
|
|
2611
|
+
.option("--no-ensure", "Do not register the project from its local prd.json when the story is not on the Board")).action((opts) => wrap(() => runStory(opts))());
|
|
2524
2612
|
addSharedFlags(mesh
|
|
2525
2613
|
.command("claim")
|
|
2526
2614
|
.description("Assign a story to yourself and move it to in_progress")
|
package/dist/commands/reindex.js
CHANGED
|
@@ -33,6 +33,7 @@ import { reindex, rescue } from '@indigoai-us/hq-cloud';
|
|
|
33
33
|
import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
|
|
34
34
|
import { findHqRoot } from '../utils/manifest.js';
|
|
35
35
|
import { guardLargeFiles } from '../utils/large-file-guard.js';
|
|
36
|
+
import { liveProcessCwds, recentTranscriptCwds, sessionOwnerReason, } from '../lib/core-utils/worktree-session-owner.js';
|
|
36
37
|
const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
|
|
37
38
|
const BYPASS_MODE = 'bypassPermissions';
|
|
38
39
|
const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
|
|
@@ -296,6 +297,34 @@ function hasRecentWorktreeActivity(worktree, gitDir, cutoff, deadline = Number.P
|
|
|
296
297
|
});
|
|
297
298
|
return latestContentChange(dirtyPaths) >= cutoff;
|
|
298
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Why removing this worktree could lose work, or undefined when it is clean
|
|
302
|
+
* and every commit on HEAD is on its upstream and on a remote. A Git failure
|
|
303
|
+
* (including a deadline hit) is a reason too, so the worktree is retained.
|
|
304
|
+
*/
|
|
305
|
+
function unpreservedWorkReason(worktree, deadline) {
|
|
306
|
+
const status = runGit(worktree, ['status', '--porcelain', '--untracked-files=all', '--ignored=no'], deadline);
|
|
307
|
+
if (status?.status !== 0)
|
|
308
|
+
return 'git-error';
|
|
309
|
+
if (status.stdout.trim() !== '')
|
|
310
|
+
return 'dirty';
|
|
311
|
+
const upstream = runGit(worktree, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], deadline);
|
|
312
|
+
if (upstream === undefined)
|
|
313
|
+
return 'git-error';
|
|
314
|
+
if (upstream.status !== 0 || !upstream.stdout.trim())
|
|
315
|
+
return 'no-upstream';
|
|
316
|
+
const ahead = runGit(worktree, ['rev-list', '--count', '@{u}..HEAD'], deadline);
|
|
317
|
+
if (ahead?.status !== 0)
|
|
318
|
+
return 'git-error';
|
|
319
|
+
if (ahead.stdout.trim() !== '0')
|
|
320
|
+
return 'unpushed';
|
|
321
|
+
const offRemote = runGit(worktree, ['rev-list', '--count', 'HEAD', '--not', '--remotes'], deadline);
|
|
322
|
+
if (offRemote?.status !== 0)
|
|
323
|
+
return 'git-error';
|
|
324
|
+
if (offRemote.stdout.trim() !== '0')
|
|
325
|
+
return 'unpushed';
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
299
328
|
/**
|
|
300
329
|
* Remove inactive worktrees created by `hq worktree` without ever deleting
|
|
301
330
|
* directories directly. This runs on the hook path, so it uses a shallow
|
|
@@ -305,6 +334,11 @@ export function removeStaleHqWorktrees(hqRoot, now = Date.now(), deadline = Numb
|
|
|
305
334
|
if (!isHqRoot(hqRoot))
|
|
306
335
|
return;
|
|
307
336
|
const cutoff = now - WORKTREE_STALE_AFTER_MS;
|
|
337
|
+
const kept = (worktree, reason) => {
|
|
338
|
+
console.log(`reindex: kept stale worktree ${worktree} (${reason})`);
|
|
339
|
+
};
|
|
340
|
+
// Pass 1: cheap local checks. Only clean, pushed, idle worktrees go on.
|
|
341
|
+
const eligible = [];
|
|
308
342
|
for (const worktree of managedWorktreeCandidates(hqRoot)) {
|
|
309
343
|
if (Date.now() >= deadline)
|
|
310
344
|
break;
|
|
@@ -313,18 +347,59 @@ export function removeStaleHqWorktrees(hqRoot, now = Date.now(), deadline = Numb
|
|
|
313
347
|
const gitDir = linkedWorktreeGitDir(worktree, deadline);
|
|
314
348
|
if (!gitDir || hasRecentWorktreeActivity(worktree, gitDir, cutoff, deadline))
|
|
315
349
|
continue;
|
|
350
|
+
const unsafe = unpreservedWorkReason(worktree, deadline);
|
|
351
|
+
if (unsafe) {
|
|
352
|
+
kept(worktree, unsafe);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
eligible.push(worktree);
|
|
356
|
+
}
|
|
357
|
+
if (eligible.length === 0)
|
|
358
|
+
return;
|
|
359
|
+
// Pass 2: session ownership. A session working in the worktree may have no
|
|
360
|
+
// process parked there between tool calls, so recent transcripts count too.
|
|
361
|
+
// A probe that cannot finish inside the budget retains everything.
|
|
362
|
+
const processCwds = liveProcessCwds(Math.max(1, Math.min(WORKTREE_GIT_TIMEOUT_MS, deadline - Date.now())));
|
|
363
|
+
const transcriptCwds = processCwds === undefined
|
|
364
|
+
? undefined
|
|
365
|
+
: recentTranscriptCwds({ nowMs: now, deadline });
|
|
366
|
+
if (processCwds === undefined || transcriptCwds === undefined) {
|
|
367
|
+
for (const worktree of eligible)
|
|
368
|
+
kept(worktree, 'session-check-incomplete');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
for (const worktree of eligible) {
|
|
372
|
+
if (Date.now() >= deadline)
|
|
373
|
+
break;
|
|
374
|
+
let real = worktree;
|
|
375
|
+
try {
|
|
376
|
+
real = fs.realpathSync(worktree);
|
|
377
|
+
}
|
|
378
|
+
catch { /* compare the literal spelling */ }
|
|
379
|
+
const owner = sessionOwnerReason(processCwds, transcriptCwds, worktree, real);
|
|
380
|
+
if (owner) {
|
|
381
|
+
kept(worktree, owner);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
316
384
|
// Narrow the destructive TOCTOU window: re-resolve the linked-worktree
|
|
317
|
-
// identity and re-measure activity immediately before
|
|
318
|
-
// it. Git remains the only component that deletes the
|
|
385
|
+
// identity and re-measure activity and preservation immediately before
|
|
386
|
+
// asking Git to remove it. Git remains the only component that deletes the
|
|
387
|
+
// directory.
|
|
319
388
|
const confirmedGitDir = linkedWorktreeGitDir(worktree, deadline);
|
|
320
389
|
if (!confirmedGitDir || hasRecentWorktreeActivity(worktree, confirmedGitDir, cutoff, deadline))
|
|
321
390
|
continue;
|
|
391
|
+
const confirmedUnsafe = unpreservedWorkReason(worktree, deadline);
|
|
392
|
+
if (confirmedUnsafe) {
|
|
393
|
+
kept(worktree, confirmedUnsafe);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
322
396
|
// Recursive removal is not transactional. Only start it inside the sweep
|
|
323
397
|
// budget, then let Git finish so a timeout cannot leave a registered,
|
|
324
|
-
// partially deleted worktree behind.
|
|
325
|
-
|
|
398
|
+
// partially deleted worktree behind. No --force: Git itself refuses a
|
|
399
|
+
// worktree that gained changes since the checks above.
|
|
400
|
+
const removal = runGit(worktree, ['worktree', 'remove', worktree], deadline, true);
|
|
326
401
|
if (removal?.status === 0) {
|
|
327
|
-
console.log(`reindex: removed stale worktree ${worktree} (
|
|
402
|
+
console.log(`reindex: removed stale worktree ${worktree} (clean and pushed; branch kept)`);
|
|
328
403
|
continue;
|
|
329
404
|
}
|
|
330
405
|
const reason = removal?.stderr.trim() || removal?.stdout.trim() || 'Git could not remove it';
|