@indigoai-us/hq-cli 5.286.0 → 5.288.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 +57 -0
- package/assets/scaffold/core/scripts/clean-worktree-reconcile-handoff.sh +171 -0
- package/assets/scaffold/core/scripts/resume-thread-lock.sh +285 -0
- package/assets/scaffold/core/scripts/verify-story-deliverables.sh +148 -0
- package/assets/scaffold/core/scripts/worktree-gc.sh +362 -0
- package/dist/command-catalog.generated.d.ts +55 -0
- package/dist/command-catalog.generated.js +68 -0
- package/dist/commands/agent-enroll.js +10 -2
- package/dist/commands/agents.d.ts +1 -0
- package/dist/commands/agents.js +12 -4
- package/dist/commands/cloud-provision.js +99 -78
- package/dist/commands/core-native-handoff-flag.d.ts +4 -0
- package/dist/commands/core-native-handoff-flag.js +7 -0
- package/dist/commands/core-ontology.d.ts +4 -1
- package/dist/commands/core-ontology.js +26 -0
- package/dist/commands/core.js +45 -0
- package/dist/commands/lanes-resume.js +41 -2
- package/dist/commands/lanes-shared.d.ts +2 -0
- package/dist/commands/lanes.js +48 -29
- package/dist/commands/team-sync.js +71 -10
- package/dist/fast-lanes-list-route.js +2 -0
- package/dist/fast-lanes-list.js +1 -29
- package/dist/lanes-drain-hook.d.ts +12 -0
- package/dist/lanes-drain-hook.js +59 -55
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +10 -1
- package/dist/lib/checkpoint-ontology/merge-copies.js +17 -2
- package/dist/lib/checkpoint-ontology/plan.d.ts +0 -88
- package/dist/lib/checkpoint-ontology/plan.js +80 -107
- package/dist/lib/core-utils/sentry-report.js +5 -32
- package/dist/lib/core-utils/worktree-resume.d.ts +18 -0
- package/dist/lib/core-utils/worktree-resume.js +1182 -0
- package/dist/lib/lanes/dropbox.d.ts +1 -2
- package/dist/lib/lanes/dropbox.js +10 -111
- package/dist/lib/lanes/message-replay.d.ts +8 -0
- package/dist/lib/lanes/message-replay.js +119 -0
- package/dist/lib/lanes/outpost-snapshot-refresh.d.ts +21 -0
- package/dist/lib/lanes/outpost-snapshot-refresh.js +329 -0
- package/dist/lib/plan-limit-nag.js +11 -0
- package/dist/main.js +17 -4
- package/dist/utils/cli-telemetry.d.ts +21 -0
- package/dist/utils/cli-telemetry.js +101 -1
- package/dist/utils/cognito-refresh-sentry.d.ts +3 -0
- package/dist/utils/cognito-refresh-sentry.js +49 -0
- package/dist/utils/cognito-session.js +55 -0
- package/package.json +1 -1
|
@@ -266,6 +266,21 @@ export function writeCompanyConfig(hqRoot, slug, config) {
|
|
|
266
266
|
return true;
|
|
267
267
|
}
|
|
268
268
|
// ── Vault HTTP client (real impl) ────────────────────────────────────────────
|
|
269
|
+
const VAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
270
|
+
/** Run a complete Vault HTTP operation under a finite deadline. */
|
|
271
|
+
async function withVaultRequest(url, init, readResponse) {
|
|
272
|
+
const deadline = AbortSignal.timeout(VAULT_REQUEST_TIMEOUT_MS);
|
|
273
|
+
try {
|
|
274
|
+
const res = await fetch(url, { ...init, signal: deadline });
|
|
275
|
+
return await readResponse(res);
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
if (deadline.aborted) {
|
|
279
|
+
throw new ProvisionError(1, `Vault request timed out after ${VAULT_REQUEST_TIMEOUT_MS}ms`);
|
|
280
|
+
}
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
269
284
|
/**
|
|
270
285
|
* Default vault HTTP client backed by global `fetch`. Uses the `/v1/entities`
|
|
271
286
|
* route surface (matches AppBar `vault_client.rs` and hq-pro handler routes).
|
|
@@ -284,52 +299,56 @@ export function createDefaultVaultClient(apiUrl, accessToken) {
|
|
|
284
299
|
return {
|
|
285
300
|
async listMyPersonEntities() {
|
|
286
301
|
const url = `${apiUrl.replace(/\/$/, "")}/entity/by-type/person`;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
302
|
+
return withVaultRequest(url, { method: "GET", headers }, async (res) => {
|
|
303
|
+
if (res.status === 404)
|
|
304
|
+
return [];
|
|
305
|
+
if (!res.ok) {
|
|
306
|
+
const body = await safeBody(res);
|
|
307
|
+
throw new ProvisionError(1, `Vault GET /entity/by-type/person failed: ${res.status} ${res.statusText} — ${body}`);
|
|
308
|
+
}
|
|
309
|
+
const data = (await res.json());
|
|
310
|
+
return data.entities ?? [];
|
|
311
|
+
});
|
|
296
312
|
},
|
|
297
313
|
async findCompanyBySlug(slug) {
|
|
298
314
|
const url = `${apiUrl.replace(/\/$/, "")}/entity/by-slug/company/${encodeURIComponent(slug)}`;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
315
|
+
return withVaultRequest(url, { method: "GET", headers }, async (res) => {
|
|
316
|
+
if (res.status === 404)
|
|
317
|
+
return null;
|
|
318
|
+
if (!res.ok) {
|
|
319
|
+
const body = await safeBody(res);
|
|
320
|
+
throw new ProvisionError(1, `Vault GET by-slug failed: ${res.status} ${res.statusText} — ${body}`);
|
|
321
|
+
}
|
|
322
|
+
const data = (await res.json());
|
|
323
|
+
if (!data.entity) {
|
|
324
|
+
throw new ProvisionError(1, `Vault GET by-slug returned 200 with no entity body`);
|
|
325
|
+
}
|
|
326
|
+
return data.entity;
|
|
327
|
+
});
|
|
311
328
|
},
|
|
312
329
|
async checkSlugInMyNamespace(slug) {
|
|
313
330
|
const url = `${apiUrl.replace(/\/$/, "")}/entity/check-slug/me?type=company&slug=${encodeURIComponent(slug)}`;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
331
|
+
return withVaultRequest(url, { method: "GET", headers }, async (res) => {
|
|
332
|
+
if (!res.ok) {
|
|
333
|
+
const body = await safeBody(res);
|
|
334
|
+
throw new ProvisionError(1, `Vault GET /entity/check-slug/me failed: ${res.status} ${res.statusText} — ${body}`);
|
|
335
|
+
}
|
|
336
|
+
return (await res.json());
|
|
337
|
+
});
|
|
320
338
|
},
|
|
321
339
|
async getCompanyByUid(uid) {
|
|
322
340
|
const url = `${apiUrl.replace(/\/$/, "")}/entity/${encodeURIComponent(uid)}`;
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
341
|
+
return withVaultRequest(url, { method: "GET", headers }, async (res) => {
|
|
342
|
+
if (!res.ok) {
|
|
343
|
+
const body = await safeBody(res);
|
|
344
|
+
throw new ProvisionError(1, `Vault GET /entity/${uid} failed: ${res.status} ${res.statusText} — ${body}`);
|
|
345
|
+
}
|
|
346
|
+
const data = (await res.json());
|
|
347
|
+
if (!data.entity) {
|
|
348
|
+
throw new ProvisionError(1, `Vault GET /entity/${uid} returned 200 with no entity body`);
|
|
349
|
+
}
|
|
350
|
+
return data.entity;
|
|
351
|
+
});
|
|
333
352
|
},
|
|
334
353
|
async createCompanyEntity(input) {
|
|
335
354
|
const url = `${apiUrl.replace(/\/$/, "")}/entity`;
|
|
@@ -340,54 +359,56 @@ export function createDefaultVaultClient(apiUrl, accessToken) {
|
|
|
340
359
|
};
|
|
341
360
|
if (input.ownerUid)
|
|
342
361
|
body.ownerUid = input.ownerUid;
|
|
343
|
-
|
|
362
|
+
return withVaultRequest(url, {
|
|
344
363
|
method: "POST",
|
|
345
364
|
headers,
|
|
346
365
|
body: JSON.stringify(body),
|
|
366
|
+
}, async (res) => {
|
|
367
|
+
if (!res.ok) {
|
|
368
|
+
const text = await safeBody(res);
|
|
369
|
+
// 409 SLUG_IN_USE_FOR_PERSON: the caller already has the slug
|
|
370
|
+
// in their namespace (owned ∪ active-member-of). Under the
|
|
371
|
+
// per-user-namespace model this is the new same-user-collision
|
|
372
|
+
// signal — distinct from the legacy global EntityAlreadyExists.
|
|
373
|
+
// The CLI normally reaches `createCompanyEntity` only after
|
|
374
|
+
// `checkSlugInMyNamespace` reported `available: true`, so a
|
|
375
|
+
// 409 here means a race between the pre-check and the POST.
|
|
376
|
+
// Surface the response body verbatim so the caller can see the
|
|
377
|
+
// `code` + `conflictingCompanyUid` and resolve / retry.
|
|
378
|
+
throw new ProvisionError(1, `Vault POST /entity failed: ${res.status} ${res.statusText} — ${text}`);
|
|
379
|
+
}
|
|
380
|
+
const data = (await res.json());
|
|
381
|
+
if (!data.entity) {
|
|
382
|
+
throw new ProvisionError(1, `Vault POST /entity returned ${res.status} with no entity body`);
|
|
383
|
+
}
|
|
384
|
+
return data.entity;
|
|
347
385
|
});
|
|
348
|
-
if (!res.ok) {
|
|
349
|
-
const text = await safeBody(res);
|
|
350
|
-
// 409 SLUG_IN_USE_FOR_PERSON: the caller already has the slug
|
|
351
|
-
// in their namespace (owned ∪ active-member-of). Under the
|
|
352
|
-
// per-user-namespace model this is the new same-user-collision
|
|
353
|
-
// signal — distinct from the legacy global EntityAlreadyExists.
|
|
354
|
-
// The CLI normally reaches `createCompanyEntity` only after
|
|
355
|
-
// `checkSlugInMyNamespace` reported `available: true`, so a
|
|
356
|
-
// 409 here means a race between the pre-check and the POST.
|
|
357
|
-
// Surface the response body verbatim so the caller can see the
|
|
358
|
-
// `code` + `conflictingCompanyUid` and resolve / retry.
|
|
359
|
-
throw new ProvisionError(1, `Vault POST /entity failed: ${res.status} ${res.statusText} — ${text}`);
|
|
360
|
-
}
|
|
361
|
-
const data = (await res.json());
|
|
362
|
-
if (!data.entity) {
|
|
363
|
-
throw new ProvisionError(1, `Vault POST /entity returned ${res.status} with no entity body`);
|
|
364
|
-
}
|
|
365
|
-
return data.entity;
|
|
366
386
|
},
|
|
367
387
|
async softDeleteCompany(uid) {
|
|
368
388
|
const url = `${apiUrl.replace(/\/$/, "")}/entity/${encodeURIComponent(uid)}`;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
389
|
+
return withVaultRequest(url, { method: "DELETE", headers }, async (res) => {
|
|
390
|
+
if (res.status === 403) {
|
|
391
|
+
const body = await safeBody(res);
|
|
392
|
+
throw new ProvisionError(2, `Refusing to retire '${uid}': only the company owner can soft-tombstone it. ${body}`);
|
|
393
|
+
}
|
|
394
|
+
if (res.status === 404) {
|
|
395
|
+
const body = await safeBody(res);
|
|
396
|
+
throw new ProvisionError(2, `Vault DELETE /entity/${uid} failed: not found — ${body}`);
|
|
397
|
+
}
|
|
398
|
+
if (res.status === 400) {
|
|
399
|
+
const body = await safeBody(res);
|
|
400
|
+
throw new ProvisionError(2, `Vault DELETE /entity/${uid} failed: ${res.status} ${res.statusText} — ${body}`);
|
|
401
|
+
}
|
|
402
|
+
if (!res.ok) {
|
|
403
|
+
const body = await safeBody(res);
|
|
404
|
+
throw new ProvisionError(1, `Vault DELETE /entity/${uid} failed: ${res.status} ${res.statusText} — ${body}`);
|
|
405
|
+
}
|
|
406
|
+
const data = (await res.json());
|
|
407
|
+
if (data.deleted !== true || typeof data.uid !== "string") {
|
|
408
|
+
throw new ProvisionError(1, `Vault DELETE /entity/${uid} returned ${res.status} with no deleted confirmation`);
|
|
409
|
+
}
|
|
410
|
+
return { deleted: true, uid: data.uid };
|
|
411
|
+
});
|
|
391
412
|
},
|
|
392
413
|
};
|
|
393
414
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Default-off rollout gate for the native Wave 2 worktree/resume helpers. */
|
|
2
|
+
export declare const CORE_NATIVE_HANDOFF_FLAG = "core-native-handoff";
|
|
3
|
+
export declare function coreNativeHandoffEnabled(): Promise<boolean>;
|
|
4
|
+
//# sourceMappingURL=core-native-handoff-flag.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Default-off rollout gate for the native Wave 2 worktree/resume helpers. */
|
|
2
|
+
export const CORE_NATIVE_HANDOFF_FLAG = "core-native-handoff";
|
|
3
|
+
export async function coreNativeHandoffEnabled() {
|
|
4
|
+
const { resolveProcessFlagGate } = await import("../lib/flag-registry.js");
|
|
5
|
+
return resolveProcessFlagGate(CORE_NATIVE_HANDOFF_FLAG, { fallback: false }, () => false);
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=core-native-handoff-flag.js.map
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { Command } from "commander";
|
|
11
11
|
import { type FlagReader } from "../lib/checkpoint-ontology/plan.js";
|
|
12
|
-
import { type DeleteVaultKey } from "../lib/checkpoint-ontology/merge-copies.js";
|
|
12
|
+
import { type DeleteVaultKey, type ListedPair } from "../lib/checkpoint-ontology/merge-copies.js";
|
|
13
13
|
export declare const ONTOLOGY_PAYLOAD_FILE = "payload.json";
|
|
14
14
|
/** The hq-flags read, scoped to the payload company. */
|
|
15
15
|
export declare const registryFlagReader: FlagReader;
|
|
@@ -32,8 +32,11 @@ export interface MergeCopiesCommandOptions {
|
|
|
32
32
|
delete?: boolean;
|
|
33
33
|
variants?: boolean;
|
|
34
34
|
limit?: number;
|
|
35
|
+
only?: string;
|
|
35
36
|
json?: boolean;
|
|
36
37
|
}
|
|
38
|
+
/** Reads a `--only` file: a JSON array of `{ copy, original }` company-relative paths. */
|
|
39
|
+
export declare function readListedPairs(file: string): ListedPair[];
|
|
37
40
|
/**
|
|
38
41
|
* `hq core ontology merge-copies`: merge `<slug> <n>.md` copies into their
|
|
39
42
|
* original (`--apply`), then delete merged copies from the vault
|
|
@@ -153,8 +153,31 @@ function mergeCopiesSummary(report) {
|
|
|
153
153
|
lines.push(` held ${plan.copy}: ${plan.held}`);
|
|
154
154
|
for (const error of report.errors)
|
|
155
155
|
lines.push(` error ${error.path}: ${error.error}`);
|
|
156
|
+
for (const entry of report.only_unmatched ?? [])
|
|
157
|
+
lines.push(` not found ${entry.copy} -> ${entry.original}`);
|
|
156
158
|
return lines.join("\n");
|
|
157
159
|
}
|
|
160
|
+
/** Reads a `--only` file: a JSON array of `{ copy, original }` company-relative paths. */
|
|
161
|
+
export function readListedPairs(file) {
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw new OntologyUsageError(`ontology: --only file ${file} is not readable JSON: ${error.message}`);
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(parsed) ||
|
|
170
|
+
parsed.length === 0 ||
|
|
171
|
+
!parsed.every((entry) => {
|
|
172
|
+
if (!entry || typeof entry !== "object")
|
|
173
|
+
return false;
|
|
174
|
+
const record = entry;
|
|
175
|
+
return typeof record.copy === "string" && typeof record.original === "string";
|
|
176
|
+
})) {
|
|
177
|
+
throw new OntologyUsageError("ontology: --only must be a non-empty JSON array of { copy, original } paths");
|
|
178
|
+
}
|
|
179
|
+
return parsed.map((entry) => ({ copy: entry.copy, original: entry.original }));
|
|
180
|
+
}
|
|
158
181
|
/**
|
|
159
182
|
* `hq core ontology merge-copies`: merge `<slug> <n>.md` copies into their
|
|
160
183
|
* original (`--apply`), then delete merged copies from the vault
|
|
@@ -171,6 +194,7 @@ export async function runOntologyMergeCopies(hqRoot, slug, options, deps = {}) {
|
|
|
171
194
|
if (options.limit !== undefined && (!Number.isInteger(options.limit) || options.limit < 1)) {
|
|
172
195
|
throw new OntologyUsageError("ontology: --limit must be a positive whole number");
|
|
173
196
|
}
|
|
197
|
+
const only = options.only !== undefined ? readListedPairs(options.only) : undefined;
|
|
174
198
|
const resolution = resolveOntologyCompany(hqRoot, slug);
|
|
175
199
|
if (!resolution.ok)
|
|
176
200
|
throw new OntologyUsageError(`ontology: company rejected: ${resolution.reason}`);
|
|
@@ -195,6 +219,7 @@ export async function runOntologyMergeCopies(hqRoot, slug, options, deps = {}) {
|
|
|
195
219
|
now: deps.now?.() ?? new Date(),
|
|
196
220
|
...(options.variants ? { pairKind: "variants" } : {}),
|
|
197
221
|
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
222
|
+
...(only ? { only } : {}),
|
|
198
223
|
...(deleteVaultKey ? { deleteVaultKey } : {}),
|
|
199
224
|
});
|
|
200
225
|
return options.json ? JSON.stringify(report, null, 2) : mergeCopiesSummary(report);
|
|
@@ -248,6 +273,7 @@ export function registerCoreOntologyCommand(core) {
|
|
|
248
273
|
.option("--delete", "delete fully merged copies from the vault by exact key (after sync pushed the merge)")
|
|
249
274
|
.option("--variants", "merge two-file duplicate groups whose names differ only in case, spacing or punctuation (checkpoint.ontology.merge-variants)")
|
|
250
275
|
.option("--limit <n>", "handle at most this many copies", (value) => Number(value))
|
|
276
|
+
.option("--only <file>", "handle only the copy -> original pairs listed in this JSON file")
|
|
251
277
|
.option("--json", "print JSON")
|
|
252
278
|
.option("--hq-root <path>", "HQ installation to operate on")
|
|
253
279
|
.action(async (options) => {
|
package/dist/commands/core.js
CHANGED
|
@@ -52,6 +52,8 @@ import { qmdReindexAfterSync } from "../lib/core-utils/qmd-reindex-after-sync.js
|
|
|
52
52
|
import { softTimeoutCommand } from "../lib/core-utils/soft-timeout.js";
|
|
53
53
|
import { timeoutGuardCommand } from "../lib/core-utils/timeout-guard.js";
|
|
54
54
|
import { readSentryReportStdin, reportSentryEvent } from "../lib/core-utils/sentry-report.js";
|
|
55
|
+
import { CLEAN_HANDOFF_ASSET, RESUME_LOCK_ASSET, VERIFY_DELIVERABLES_ASSET, WORKTREE_GC_ASSET, runCleanWorktreeReconcileHandoff, runResumeThreadLock, runVerifyStoryDeliverables, runWorktreeGc, } from "../lib/core-utils/worktree-resume.js";
|
|
56
|
+
import { coreNativeHandoffEnabled } from "./core-native-handoff-flag.js";
|
|
55
57
|
import { describeCoreCommand, registeredCoreCommandListing, } from "./core-command-catalog.js";
|
|
56
58
|
/**
|
|
57
59
|
* These commands are now native implementations. Their retained shell assets
|
|
@@ -216,6 +218,12 @@ export const SCAFFOLD_ONLY_ASSETS = [
|
|
|
216
218
|
{ asset: "core/scripts/lib/session-scope-capability.sh", root: "live", summary: "Session scope-capability lib sourced by hq-session" },
|
|
217
219
|
{ asset: "core/scripts/lib/session-id.sh", root: "live", summary: "Session-id resolver lib sourced by hq-session" },
|
|
218
220
|
{ asset: "core/scripts/lib/work-mesh-enqueue.sh", root: "live", summary: "Work Mesh Live spool enqueue lib sourced by hooks" },
|
|
221
|
+
// Wave 2 handoff/worktree commands retain their old scripts for one release,
|
|
222
|
+
// behind the shared default-off rollback flag.
|
|
223
|
+
{ asset: WORKTREE_GC_ASSET, root: "live", summary: "Rollback companion for native worktree-gc" },
|
|
224
|
+
{ asset: CLEAN_HANDOFF_ASSET, root: "cwd", summary: "Rollback companion for native clean-worktree-reconcile-handoff" },
|
|
225
|
+
{ asset: RESUME_LOCK_ASSET, root: "live", summary: "Rollback companion for native resume-thread-lock" },
|
|
226
|
+
{ asset: VERIFY_DELIVERABLES_ASSET, root: "live", summary: "Rollback companion for native verify-story-deliverables" },
|
|
219
227
|
// Wave 1a delegate shells remain bundled as the default-off rollback path.
|
|
220
228
|
// Their roots mirror the original scripts; pickup does not read an HQ root.
|
|
221
229
|
{ asset: "core/scripts/hq-delegate-resolve.sh", root: "live", summary: "Rollback shell for native delegate resolve" },
|
|
@@ -320,6 +328,43 @@ export function registerCoreCommands(program) {
|
|
|
320
328
|
registerCoreDelegateCommand(core);
|
|
321
329
|
registerCoreKnowledgePrefsCommand(core);
|
|
322
330
|
registerCorePolicyCommand(core);
|
|
331
|
+
// Wave 2 handoff/worktree commands retain their byte-identical shell assets
|
|
332
|
+
// as a default-off rollback path for one release. Keep the gate helper small
|
|
333
|
+
// so sibling Wave 2 work can reuse it without editing this command table.
|
|
334
|
+
const handoffCommands = [
|
|
335
|
+
{ name: "worktree-gc", asset: WORKTREE_GC_ASSET, root: "live", summary: "Safely collect old, preserved HQ worktrees", run: (args, root) => runWorktreeGc(args, root) },
|
|
336
|
+
{ name: "clean-worktree-reconcile-handoff", asset: CLEAN_HANDOFF_ASSET, root: "cwd", summary: "Remove only byte-identical incoming handoff mirrors", run: (args) => runCleanWorktreeReconcileHandoff(args) },
|
|
337
|
+
{ name: "resume-thread-lock", asset: RESUME_LOCK_ASSET, root: "live", summary: "Inspect or acquire a durable resumework lock", run: (args, root) => runResumeThreadLock(args, root) },
|
|
338
|
+
{ name: "verify-story-deliverables", asset: VERIFY_DELIVERABLES_ASSET, root: "live", summary: "Check declared and returned story deliverables", run: (args, root) => runVerifyStoryDeliverables(args, root) },
|
|
339
|
+
];
|
|
340
|
+
for (const entry of handoffCommands) {
|
|
341
|
+
const command = core
|
|
342
|
+
.command(entry.name)
|
|
343
|
+
.description(entry.summary)
|
|
344
|
+
.allowUnknownOption()
|
|
345
|
+
.allowExcessArguments()
|
|
346
|
+
.helpOption(false)
|
|
347
|
+
.argument("[args...]")
|
|
348
|
+
.action(async (args = [], _options, cmd) => {
|
|
349
|
+
const scope = core.opts();
|
|
350
|
+
const operands = cmd.args.length > 0 ? cmd.args : args;
|
|
351
|
+
if (!await coreNativeHandoffEnabled()) {
|
|
352
|
+
if (entry.root === "live")
|
|
353
|
+
runEntry(entry, scope, operands);
|
|
354
|
+
else {
|
|
355
|
+
const result = runBundledScript({ asset: entry.asset, args: operands, cwd: process.cwd() });
|
|
356
|
+
if (result.code !== 0)
|
|
357
|
+
process.exit(result.code);
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const { cwd, hqRoot } = resolveEntryRoot(entry, scope);
|
|
362
|
+
const code = await entry.run(operands, hqRoot ?? cwd);
|
|
363
|
+
if (code !== 0)
|
|
364
|
+
process.exit(code);
|
|
365
|
+
});
|
|
366
|
+
describeCoreCommand(command, { execution: "native", asset: entry.asset, root: entry.root });
|
|
367
|
+
}
|
|
323
368
|
// These command names intentionally own native implementations. Their
|
|
324
369
|
// similarly named scaffold assets are forwarders, so sending them through
|
|
325
370
|
// runEntry would recurse back into this command forever.
|
|
@@ -153,10 +153,27 @@ export function createLaneResumeHandlers(deps) {
|
|
|
153
153
|
if (opts.account !== undefined && !providerSessionPortable(lane.provider)) {
|
|
154
154
|
throw new LanesError("provider_session_not_portable", sessionNotPortableMessage(lane.provider), { lane_id: laneId, provider: lane.provider });
|
|
155
155
|
}
|
|
156
|
-
if (lane.state === "queued"
|
|
156
|
+
if (lane.state === "queued" &&
|
|
157
|
+
(lane.queued_reason !== "worker_profile_changed" || opts.worker === undefined)) {
|
|
157
158
|
throw new LanesError("lane_not_resumable", `Lane ${laneId} is queued; use hq lanes admit ${laneId} to request admission`, { lane_id: laneId, state: lane.state, admission_command: `hq lanes admit ${laneId}` });
|
|
158
159
|
}
|
|
159
|
-
const
|
|
160
|
+
const queuedProfileRecovery = lane.state === "queued" &&
|
|
161
|
+
lane.queued_reason === "worker_profile_changed" &&
|
|
162
|
+
opts.worker !== undefined;
|
|
163
|
+
if (queuedProfileRecovery && (opts.text !== undefined ||
|
|
164
|
+
opts.project !== undefined ||
|
|
165
|
+
opts.story !== undefined ||
|
|
166
|
+
opts.account !== undefined ||
|
|
167
|
+
opts.model !== undefined ||
|
|
168
|
+
opts.effort !== undefined ||
|
|
169
|
+
opts.keepAlive !== undefined ||
|
|
170
|
+
opts.meshCompany !== undefined ||
|
|
171
|
+
opts.compact === false)) {
|
|
172
|
+
throw new LanesError("worker_profile_recovery_options_invalid", "Queued worker-profile recovery accepts only --worker; rerun hq lanes resume after reconcile admits the lane", { lane_id: laneId });
|
|
173
|
+
}
|
|
174
|
+
const refusal = queuedProfileRecovery
|
|
175
|
+
? null
|
|
176
|
+
: resumeCommandRefusal(lane, { allowFailed: opts.fromFailed === true });
|
|
160
177
|
if (refusal !== null) {
|
|
161
178
|
throw new LanesError(refusal.error, refusal.message, {
|
|
162
179
|
lane_id: laneId,
|
|
@@ -206,6 +223,28 @@ export function createLaneResumeHandlers(deps) {
|
|
|
206
223
|
if (resumeProfile !== undefined && (opts.account === undefined || keepCurrentAccount)) {
|
|
207
224
|
validateResumeProfileModel(ctx, lane, resumeProfile.profile);
|
|
208
225
|
}
|
|
226
|
+
if (lane.state === "queued" && lane.queued_reason === "worker_profile_changed") {
|
|
227
|
+
if (resumeProfile === undefined) {
|
|
228
|
+
throw new LanesError("worker_profile_required", `Lane ${laneId} needs --worker ${lane.worker_profile ?? "<profile>"} to recover its queued profile`, { lane_id: laneId, worker: lane.worker_profile ?? null });
|
|
229
|
+
}
|
|
230
|
+
const recovered = recordResumeSetup(ctx, lane, {
|
|
231
|
+
profile: resumeProfile,
|
|
232
|
+
...(profileRepin !== undefined ? { profileRepin } : {}),
|
|
233
|
+
clearWorkerProfileChangedQueue: true,
|
|
234
|
+
});
|
|
235
|
+
printJson({
|
|
236
|
+
ok: true,
|
|
237
|
+
action: "resume",
|
|
238
|
+
lane_id: laneId,
|
|
239
|
+
delivery: "profile_repin",
|
|
240
|
+
delivery_status: "queued_for_reconcile",
|
|
241
|
+
profile_repin: profileRepin !== undefined
|
|
242
|
+
? recovered.worker_profile_repin_history?.at(-1) ?? null
|
|
243
|
+
: null,
|
|
244
|
+
lane: recovered,
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
209
248
|
const automaticAccountSelection = opts.account === undefined
|
|
210
249
|
? await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile, { repairUnavailableAccount: true })
|
|
211
250
|
: undefined;
|
|
@@ -113,6 +113,8 @@ export interface RecordResumeSetupInput {
|
|
|
113
113
|
meshCompany?: LaneCompanyRef;
|
|
114
114
|
threadId?: string;
|
|
115
115
|
profileRepin?: ResumeProfileRepinAuthorization;
|
|
116
|
+
/** Clear only the recoverable worker_profile_changed queue after its profile is revalidated. */
|
|
117
|
+
clearWorkerProfileChangedQueue?: boolean;
|
|
116
118
|
}
|
|
117
119
|
export declare function accountRequestKeepsCurrentLane(lane: LaneRecord, rawAccount: string): boolean;
|
|
118
120
|
export declare function assertCurrentResumeAccountAvailable(ctx: Ctx, lane: LaneRecord): void;
|
package/dist/commands/lanes.js
CHANGED
|
@@ -29,6 +29,7 @@ import { registerLaneReviewCommand } from "./lanes-review.js";
|
|
|
29
29
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
30
30
|
import { withCliFeatureUsed } from "../utils/cli-telemetry.js";
|
|
31
31
|
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
32
|
+
import { startOutpostLaneSnapshotRefresh } from "../lib/lanes/outpost-snapshot-refresh.js";
|
|
32
33
|
import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
|
|
33
34
|
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
34
35
|
import { getOrFetchOutpostSnapshot, OUTPOST_SNAPSHOT_CACHE_TTL_MS, outpostLaneListSnapshotKey, OutpostSnapshotInvocationMemo, } from "../lib/lanes/outpost-snapshot-cache.js";
|
|
@@ -1025,34 +1026,6 @@ async function readOutpostSnapshotWithCache(ctx, key, isValue, fetch) {
|
|
|
1025
1026
|
return fetch();
|
|
1026
1027
|
return getOrFetchOutpostSnapshot({ hqRoot: ctx.hqRoot(), key, isValue, fetch });
|
|
1027
1028
|
}
|
|
1028
|
-
function startOutpostLaneSnapshotRefresh(root, env) {
|
|
1029
|
-
let entry;
|
|
1030
|
-
try {
|
|
1031
|
-
const currentEntry = process.argv[1];
|
|
1032
|
-
if (!currentEntry)
|
|
1033
|
-
throw new Error("process.argv[1] is empty");
|
|
1034
|
-
entry = fs.realpathSync(currentEntry);
|
|
1035
|
-
}
|
|
1036
|
-
catch (err) {
|
|
1037
|
-
process.stderr.write(`lanes snapshot refresh could not resolve the running CLI entry: ${dispatchErrorMessage(err)}\n`);
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
try {
|
|
1041
|
-
const child = spawn(process.execPath, [...process.execArgv, entry, "lanes", "list"], {
|
|
1042
|
-
cwd: root,
|
|
1043
|
-
detached: true,
|
|
1044
|
-
stdio: ["ignore", "ignore", "inherit"],
|
|
1045
|
-
env: { ...env, HQ_ROOT: root, HQ_NO_UPDATE_CHECK: "1", HQ_INTERNAL_LANES_REFRESH_REMOTE_SNAPSHOT: "1" },
|
|
1046
|
-
});
|
|
1047
|
-
child.once("error", (err) => {
|
|
1048
|
-
process.stderr.write(`lanes snapshot refresh could not start: ${dispatchErrorMessage(err)}\n`);
|
|
1049
|
-
});
|
|
1050
|
-
child.unref();
|
|
1051
|
-
}
|
|
1052
|
-
catch (err) {
|
|
1053
|
-
process.stderr.write(`lanes snapshot refresh could not start: ${dispatchErrorMessage(err)}\n`);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
1029
|
function isLaneRecordArray(value) {
|
|
1057
1030
|
if (!Array.isArray(value))
|
|
1058
1031
|
return false;
|
|
@@ -3819,6 +3792,29 @@ function authorizeResumeProfileRepin(ctx, lane, profile, encodedContext) {
|
|
|
3819
3792
|
: workerProfileHashMismatch(lane, profile);
|
|
3820
3793
|
}
|
|
3821
3794
|
}
|
|
3795
|
+
if (lane.state === "queued" && lane.queued_reason === "worker_profile_changed" && profile !== undefined) {
|
|
3796
|
+
// The hash is for the executing Outpost's profile copy, not the primary
|
|
3797
|
+
// host's copy. Only the lane senior (or its authorized owner dispatch) may
|
|
3798
|
+
// clear this hold after resolving that local copy.
|
|
3799
|
+
if (lane.host.kind !== "outpost" || lane.worker_profile !== profile.id) {
|
|
3800
|
+
throw new LanesError("worker_profile_hash_mismatch", `Queued profile recovery for lane ${lane.lane_id} must use its original Outpost worker profile`, {
|
|
3801
|
+
lane_id: lane.lane_id,
|
|
3802
|
+
worker: profile.id,
|
|
3803
|
+
lane_worker: lane.worker_profile ?? null,
|
|
3804
|
+
outpost: lane.host.kind === "outpost" ? lane.host.id : null,
|
|
3805
|
+
});
|
|
3806
|
+
}
|
|
3807
|
+
if (ssmOwnerDispatch) {
|
|
3808
|
+
if (context === undefined)
|
|
3809
|
+
throw workerProfileHashMismatch(lane, profile);
|
|
3810
|
+
}
|
|
3811
|
+
else {
|
|
3812
|
+
const callerAuthorization = callerSeniorAuthorization(ctx, lane.senior);
|
|
3813
|
+
if (!callerAuthorization.authorized) {
|
|
3814
|
+
throw workerProfileHashMismatch(lane, profile, callerAuthorization);
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3822
3818
|
if (profile === undefined ||
|
|
3823
3819
|
(lane.host.kind !== "outpost" && !(ssmOwnerDispatch && laneGrantTargetHostedHere(ctx, lane))) ||
|
|
3824
3820
|
lane.worker_profile_hash === profile.profile.contentHash) {
|
|
@@ -4086,7 +4082,13 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options, behavior =
|
|
|
4086
4082
|
remote.lane !== null &&
|
|
4087
4083
|
typeof remote.lane === "object" &&
|
|
4088
4084
|
!Array.isArray(remote.lane) &&
|
|
4089
|
-
remote.lane.state === "queued")
|
|
4085
|
+
remote.lane.state === "queued") ||
|
|
4086
|
+
(remote.delivery_status === "queued_for_reconcile" &&
|
|
4087
|
+
remote.lane !== null &&
|
|
4088
|
+
typeof remote.lane === "object" &&
|
|
4089
|
+
!Array.isArray(remote.lane) &&
|
|
4090
|
+
remote.lane.state === "queued" &&
|
|
4091
|
+
remote.lane.queued_reason !== "worker_profile_changed")));
|
|
4090
4092
|
if (!resultConfirmed) {
|
|
4091
4093
|
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without a confirmed ${action} result for lane ${lane.lane_id}`, {
|
|
4092
4094
|
lane_id: lane.lane_id,
|
|
@@ -5880,6 +5882,7 @@ function recordResumeSetup(ctx, lane, input) {
|
|
|
5880
5882
|
input.keepAlive === undefined &&
|
|
5881
5883
|
input.cliModel === undefined &&
|
|
5882
5884
|
input.meshCompany === undefined &&
|
|
5885
|
+
input.clearWorkerProfileChangedQueue !== true &&
|
|
5883
5886
|
lane.keep_alive_blocked_reason !== "terminal_state")
|
|
5884
5887
|
return lane;
|
|
5885
5888
|
return casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => {
|
|
@@ -5889,12 +5892,28 @@ function recordResumeSetup(ctx, lane, input) {
|
|
|
5889
5892
|
if (input.profileRepin !== undefined) {
|
|
5890
5893
|
assertResumeProfileRepinStillAuthorized(ctx, current, input.profileRepin);
|
|
5891
5894
|
}
|
|
5895
|
+
if (input.clearWorkerProfileChangedQueue === true && (current.state !== "queued" ||
|
|
5896
|
+
current.queued_reason !== "worker_profile_changed" ||
|
|
5897
|
+
input.profile === undefined ||
|
|
5898
|
+
current.worker_profile !== input.profile.id ||
|
|
5899
|
+
(current.worker_profile_hash !== input.profile.profile.contentHash && input.profileRepin === undefined))) {
|
|
5900
|
+
throw new LanesError("lane_resume_setup_conflict", `Lane ${lane.lane_id} is no longer eligible for worker-profile recovery; run the recovery command again`, {
|
|
5901
|
+
lane_id: lane.lane_id,
|
|
5902
|
+
state: current.state,
|
|
5903
|
+
queued_reason: current.queued_reason ?? null,
|
|
5904
|
+
});
|
|
5905
|
+
}
|
|
5892
5906
|
const next = { ...current };
|
|
5893
5907
|
// An operator resume takes the lane out of the terminal state reconcile
|
|
5894
5908
|
// recorded, so keep-alive may continue it again after this turn.
|
|
5895
5909
|
if (next.keep_alive_blocked_reason === "terminal_state")
|
|
5896
5910
|
next.keep_alive_blocked_reason = undefined;
|
|
5897
5911
|
applyResumeProfileAndEffort(current, next, { ...input, at: ctx.now().toISOString() });
|
|
5912
|
+
if (input.clearWorkerProfileChangedQueue === true) {
|
|
5913
|
+
next.queued_reason = undefined;
|
|
5914
|
+
next.queued_details = undefined;
|
|
5915
|
+
next.admission_reserved_at = undefined;
|
|
5916
|
+
}
|
|
5898
5917
|
if (input.cliModel !== undefined) {
|
|
5899
5918
|
next.model = input.cliModel.trim();
|
|
5900
5919
|
next.model_source = "cli";
|