@indigoai-us/hq-cli 5.345.56 → 5.345.58
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 +9 -0
- package/dist/command-catalog.generated.d.ts +88 -0
- package/dist/command-catalog.generated.js +109 -0
- package/dist/commands/access.d.ts +14 -0
- package/dist/commands/access.js +69 -1
- package/dist/commands/lanes-loop.d.ts +12 -0
- package/dist/commands/lanes-loop.js +214 -0
- package/dist/commands/lanes-questions.js +37 -11
- package/dist/commands/lanes-resume.js +19 -1
- package/dist/commands/lanes-shared.d.ts +12 -1
- package/dist/commands/lanes.js +262 -22
- package/dist/lib/lanes/delivery.d.ts +3 -0
- package/dist/lib/lanes/delivery.js +5 -0
- package/dist/lib/lanes/dropbox.d.ts +3 -0
- package/dist/lib/lanes/dropbox.js +103 -1
- package/dist/lib/lanes/flag-keys.d.ts +4 -0
- package/dist/lib/lanes/flag-keys.js +4 -0
- package/dist/lib/lanes/loop.d.ts +158 -0
- package/dist/lib/lanes/loop.js +743 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
- package/dist/lib/lanes/outpost-dispatch.js +21 -7
- package/dist/lib/lanes/reconcile.d.ts +19 -0
- package/dist/lib/lanes/reconcile.js +173 -6
- package/dist/lib/lanes/spawn.d.ts +2 -0
- package/dist/lib/lanes/spawn.js +10 -8
- package/dist/lib/lanes/store.js +49 -2
- package/dist/lib/lanes/types.d.ts +18 -0
- package/dist/lib/lanes/types.js +2 -2
- package/dist/lib/lanes/watcher.js +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.58] — 2026-10-09
|
|
6
|
+
- hq access now accepts `--as <member>` to view another member's access, read-only.
|
|
7
|
+
|
|
8
|
+
## [5.345.57] — 2026-10-09
|
|
9
|
+
- Add `hq lanes create --loop` and `hq lanes enqueue` for persistent worker lanes that process FIFO phase envelopes, return generic results or section 8 handoffs, reuse sessions per story, retry one stall, and park a second stall for the senior. Loop behavior uses the default-on `lanes.conduct-loop` hq-flags kill switch. See `docs/lanes.md` for command output and exit codes.
|
|
10
|
+
|
|
11
|
+
- Add six-hour escalation for continuously blocked lanes, including durable cross-host inbox delivery and per-lane escalation history.
|
|
12
|
+
- Lane questions addressed to the current operator session record session-specific visibility on the owning host; unanswered questions cannot age past that session before it sees them, and reparenting to a different audience clears the marker. Visibility is separate from answer delivery so conflict-sidecar merging cannot suppress answer delivery. Outpost question show and answer lookups ignore malformed rows for unrelated question ids while still validating the requested record.
|
|
13
|
+
|
|
5
14
|
## [5.345.56] — 2026-10-09
|
|
6
15
|
- Add `hq lanes reap` to report local lanes whose session senior is confirmed ended, clear stale pid markers on request, and keep process-tree stopping behind the `lanes.reap-stop` hq-flags key. SessionStart runs the report-only sweep detached and stores its report in host-specific user state.
|
|
7
16
|
- Record privacy-limited `hq members invite` success and failure outcomes through the existing CLI feature telemetry event.
|
|
@@ -2871,6 +2871,10 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2871
2871
|
readonly flags: "--company <slug>";
|
|
2872
2872
|
readonly description: "Company slug";
|
|
2873
2873
|
readonly hidden: false;
|
|
2874
|
+
}, {
|
|
2875
|
+
readonly flags: "--as <person-uid>";
|
|
2876
|
+
readonly description: "Check a person's access (exact prs_ or agt_ UID; self or owner/admin)";
|
|
2877
|
+
readonly hidden: false;
|
|
2874
2878
|
}, {
|
|
2875
2879
|
readonly flags: "--json";
|
|
2876
2880
|
readonly description: "Print a single JSON object";
|
|
@@ -11654,6 +11658,10 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
11654
11658
|
readonly flags: "--fresh-thread";
|
|
11655
11659
|
readonly description: "Skip worker continuity and start a cold provider session";
|
|
11656
11660
|
readonly hidden: false;
|
|
11661
|
+
}, {
|
|
11662
|
+
readonly flags: "--loop";
|
|
11663
|
+
readonly description: "Keep one worker lane registered to process queued phase envelopes";
|
|
11664
|
+
readonly hidden: false;
|
|
11657
11665
|
}, {
|
|
11658
11666
|
readonly flags: "--provider <name>";
|
|
11659
11667
|
readonly description: "claude | codex | grok";
|
|
@@ -11734,6 +11742,52 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
11734
11742
|
readonly hidden: false;
|
|
11735
11743
|
}];
|
|
11736
11744
|
readonly subcommands: readonly [];
|
|
11745
|
+
}, {
|
|
11746
|
+
readonly name: "_loop-worker";
|
|
11747
|
+
readonly description: "";
|
|
11748
|
+
readonly aliases: readonly [];
|
|
11749
|
+
readonly hidden: true;
|
|
11750
|
+
readonly helpOption: true;
|
|
11751
|
+
readonly helpCommand: false;
|
|
11752
|
+
readonly usage: "[options] <lane-id>";
|
|
11753
|
+
readonly helpText: readonly [];
|
|
11754
|
+
readonly arguments: readonly [{
|
|
11755
|
+
readonly name: "lane-id";
|
|
11756
|
+
readonly required: true;
|
|
11757
|
+
readonly variadic: false;
|
|
11758
|
+
readonly description: "Loop lane id";
|
|
11759
|
+
}];
|
|
11760
|
+
readonly options: readonly [{
|
|
11761
|
+
readonly flags: "--json";
|
|
11762
|
+
readonly description: "JSON on stdout (always)";
|
|
11763
|
+
readonly hidden: false;
|
|
11764
|
+
}];
|
|
11765
|
+
readonly subcommands: readonly [];
|
|
11766
|
+
}, {
|
|
11767
|
+
readonly name: "enqueue";
|
|
11768
|
+
readonly description: "Validate and queue one phase envelope for a loop lane";
|
|
11769
|
+
readonly aliases: readonly [];
|
|
11770
|
+
readonly hidden: false;
|
|
11771
|
+
readonly helpOption: true;
|
|
11772
|
+
readonly helpCommand: false;
|
|
11773
|
+
readonly usage: "[options] <lane-id>";
|
|
11774
|
+
readonly helpText: readonly [];
|
|
11775
|
+
readonly arguments: readonly [{
|
|
11776
|
+
readonly name: "lane-id";
|
|
11777
|
+
readonly required: true;
|
|
11778
|
+
readonly variadic: false;
|
|
11779
|
+
readonly description: "Loop lane id";
|
|
11780
|
+
}];
|
|
11781
|
+
readonly options: readonly [{
|
|
11782
|
+
readonly flags: "--envelope <file>";
|
|
11783
|
+
readonly description: "Generic phase or hq-phase-envelope/v1 JSON file";
|
|
11784
|
+
readonly hidden: false;
|
|
11785
|
+
}, {
|
|
11786
|
+
readonly flags: "--json";
|
|
11787
|
+
readonly description: "JSON on stdout (always)";
|
|
11788
|
+
readonly hidden: false;
|
|
11789
|
+
}];
|
|
11790
|
+
readonly subcommands: readonly [];
|
|
11737
11791
|
}, {
|
|
11738
11792
|
readonly name: "reparent-target";
|
|
11739
11793
|
readonly description: "";
|
|
@@ -12606,6 +12660,40 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
12606
12660
|
readonly hidden: false;
|
|
12607
12661
|
}];
|
|
12608
12662
|
readonly subcommands: readonly [];
|
|
12663
|
+
}, {
|
|
12664
|
+
readonly name: "_enqueue-blocked-escalation";
|
|
12665
|
+
readonly description: "";
|
|
12666
|
+
readonly aliases: readonly [];
|
|
12667
|
+
readonly hidden: true;
|
|
12668
|
+
readonly helpOption: true;
|
|
12669
|
+
readonly helpCommand: false;
|
|
12670
|
+
readonly usage: "[options] <recipient-kind> <recipient-id>";
|
|
12671
|
+
readonly helpText: readonly [];
|
|
12672
|
+
readonly arguments: readonly [{
|
|
12673
|
+
readonly name: "recipient-kind";
|
|
12674
|
+
readonly required: true;
|
|
12675
|
+
readonly variadic: false;
|
|
12676
|
+
readonly description: "";
|
|
12677
|
+
}, {
|
|
12678
|
+
readonly name: "recipient-id";
|
|
12679
|
+
readonly required: true;
|
|
12680
|
+
readonly variadic: false;
|
|
12681
|
+
readonly description: "";
|
|
12682
|
+
}];
|
|
12683
|
+
readonly options: readonly [{
|
|
12684
|
+
readonly flags: "--text <text>";
|
|
12685
|
+
readonly description: "Escalation notice";
|
|
12686
|
+
readonly hidden: false;
|
|
12687
|
+
}, {
|
|
12688
|
+
readonly flags: "--message-id <id>";
|
|
12689
|
+
readonly description: "Idempotency key";
|
|
12690
|
+
readonly hidden: false;
|
|
12691
|
+
}, {
|
|
12692
|
+
readonly flags: "--json";
|
|
12693
|
+
readonly description: "JSON on stdout (always)";
|
|
12694
|
+
readonly hidden: false;
|
|
12695
|
+
}];
|
|
12696
|
+
readonly subcommands: readonly [];
|
|
12609
12697
|
}, {
|
|
12610
12698
|
readonly name: "_spawn-envelope-repair";
|
|
12611
12699
|
readonly description: "";
|
|
@@ -3462,6 +3462,11 @@ export const COMMAND_CATALOG = [
|
|
|
3462
3462
|
"description": "Company slug",
|
|
3463
3463
|
"hidden": false
|
|
3464
3464
|
},
|
|
3465
|
+
{
|
|
3466
|
+
"flags": "--as <person-uid>",
|
|
3467
|
+
"description": "Check a person's access (exact prs_ or agt_ UID; self or owner/admin)",
|
|
3468
|
+
"hidden": false
|
|
3469
|
+
},
|
|
3465
3470
|
{
|
|
3466
3471
|
"flags": "--json",
|
|
3467
3472
|
"description": "Print a single JSON object",
|
|
@@ -14162,6 +14167,11 @@ export const COMMAND_CATALOG = [
|
|
|
14162
14167
|
"description": "Skip worker continuity and start a cold provider session",
|
|
14163
14168
|
"hidden": false
|
|
14164
14169
|
},
|
|
14170
|
+
{
|
|
14171
|
+
"flags": "--loop",
|
|
14172
|
+
"description": "Keep one worker lane registered to process queued phase envelopes",
|
|
14173
|
+
"hidden": false
|
|
14174
|
+
},
|
|
14165
14175
|
{
|
|
14166
14176
|
"flags": "--provider <name>",
|
|
14167
14177
|
"description": "claude | codex | grok",
|
|
@@ -14262,6 +14272,63 @@ export const COMMAND_CATALOG = [
|
|
|
14262
14272
|
],
|
|
14263
14273
|
"subcommands": []
|
|
14264
14274
|
},
|
|
14275
|
+
{
|
|
14276
|
+
"name": "_loop-worker",
|
|
14277
|
+
"description": "",
|
|
14278
|
+
"aliases": [],
|
|
14279
|
+
"hidden": true,
|
|
14280
|
+
"helpOption": true,
|
|
14281
|
+
"helpCommand": false,
|
|
14282
|
+
"usage": "[options] <lane-id>",
|
|
14283
|
+
"helpText": [],
|
|
14284
|
+
"arguments": [
|
|
14285
|
+
{
|
|
14286
|
+
"name": "lane-id",
|
|
14287
|
+
"required": true,
|
|
14288
|
+
"variadic": false,
|
|
14289
|
+
"description": "Loop lane id"
|
|
14290
|
+
}
|
|
14291
|
+
],
|
|
14292
|
+
"options": [
|
|
14293
|
+
{
|
|
14294
|
+
"flags": "--json",
|
|
14295
|
+
"description": "JSON on stdout (always)",
|
|
14296
|
+
"hidden": false
|
|
14297
|
+
}
|
|
14298
|
+
],
|
|
14299
|
+
"subcommands": []
|
|
14300
|
+
},
|
|
14301
|
+
{
|
|
14302
|
+
"name": "enqueue",
|
|
14303
|
+
"description": "Validate and queue one phase envelope for a loop lane",
|
|
14304
|
+
"aliases": [],
|
|
14305
|
+
"hidden": false,
|
|
14306
|
+
"helpOption": true,
|
|
14307
|
+
"helpCommand": false,
|
|
14308
|
+
"usage": "[options] <lane-id>",
|
|
14309
|
+
"helpText": [],
|
|
14310
|
+
"arguments": [
|
|
14311
|
+
{
|
|
14312
|
+
"name": "lane-id",
|
|
14313
|
+
"required": true,
|
|
14314
|
+
"variadic": false,
|
|
14315
|
+
"description": "Loop lane id"
|
|
14316
|
+
}
|
|
14317
|
+
],
|
|
14318
|
+
"options": [
|
|
14319
|
+
{
|
|
14320
|
+
"flags": "--envelope <file>",
|
|
14321
|
+
"description": "Generic phase or hq-phase-envelope/v1 JSON file",
|
|
14322
|
+
"hidden": false
|
|
14323
|
+
},
|
|
14324
|
+
{
|
|
14325
|
+
"flags": "--json",
|
|
14326
|
+
"description": "JSON on stdout (always)",
|
|
14327
|
+
"hidden": false
|
|
14328
|
+
}
|
|
14329
|
+
],
|
|
14330
|
+
"subcommands": []
|
|
14331
|
+
},
|
|
14265
14332
|
{
|
|
14266
14333
|
"name": "reparent-target",
|
|
14267
14334
|
"description": "",
|
|
@@ -15334,6 +15401,48 @@ export const COMMAND_CATALOG = [
|
|
|
15334
15401
|
],
|
|
15335
15402
|
"subcommands": []
|
|
15336
15403
|
},
|
|
15404
|
+
{
|
|
15405
|
+
"name": "_enqueue-blocked-escalation",
|
|
15406
|
+
"description": "",
|
|
15407
|
+
"aliases": [],
|
|
15408
|
+
"hidden": true,
|
|
15409
|
+
"helpOption": true,
|
|
15410
|
+
"helpCommand": false,
|
|
15411
|
+
"usage": "[options] <recipient-kind> <recipient-id>",
|
|
15412
|
+
"helpText": [],
|
|
15413
|
+
"arguments": [
|
|
15414
|
+
{
|
|
15415
|
+
"name": "recipient-kind",
|
|
15416
|
+
"required": true,
|
|
15417
|
+
"variadic": false,
|
|
15418
|
+
"description": ""
|
|
15419
|
+
},
|
|
15420
|
+
{
|
|
15421
|
+
"name": "recipient-id",
|
|
15422
|
+
"required": true,
|
|
15423
|
+
"variadic": false,
|
|
15424
|
+
"description": ""
|
|
15425
|
+
}
|
|
15426
|
+
],
|
|
15427
|
+
"options": [
|
|
15428
|
+
{
|
|
15429
|
+
"flags": "--text <text>",
|
|
15430
|
+
"description": "Escalation notice",
|
|
15431
|
+
"hidden": false
|
|
15432
|
+
},
|
|
15433
|
+
{
|
|
15434
|
+
"flags": "--message-id <id>",
|
|
15435
|
+
"description": "Idempotency key",
|
|
15436
|
+
"hidden": false
|
|
15437
|
+
},
|
|
15438
|
+
{
|
|
15439
|
+
"flags": "--json",
|
|
15440
|
+
"description": "JSON on stdout (always)",
|
|
15441
|
+
"hidden": false
|
|
15442
|
+
}
|
|
15443
|
+
],
|
|
15444
|
+
"subcommands": []
|
|
15445
|
+
},
|
|
15337
15446
|
{
|
|
15338
15447
|
"name": "_spawn-envelope-repair",
|
|
15339
15448
|
"description": "",
|
|
@@ -135,6 +135,15 @@ export interface RunAccessResult {
|
|
|
135
135
|
alreadyAskedAt?: string;
|
|
136
136
|
requestNote?: string;
|
|
137
137
|
}
|
|
138
|
+
export interface AccessAsResult {
|
|
139
|
+
personUid: string;
|
|
140
|
+
company: string;
|
|
141
|
+
path: string;
|
|
142
|
+
permission: "read" | "write" | "admin" | null;
|
|
143
|
+
canRead: boolean;
|
|
144
|
+
roleBypass: boolean;
|
|
145
|
+
exitCode: 0 | 3;
|
|
146
|
+
}
|
|
138
147
|
export declare function outcomeExitCode(outcome: AccessOutcome): 0 | 2 | 3 | 4;
|
|
139
148
|
export declare function formatAccessOutcome(result: RunAccessResult): string;
|
|
140
149
|
export declare function resolveGrantor(tree: AccessAclTree | null, members: AccessMember[]): AccessGrantor | null;
|
|
@@ -149,6 +158,11 @@ export declare function resolveAccessCompany(target: string, opts?: {
|
|
|
149
158
|
company?: string;
|
|
150
159
|
hqRoot?: string;
|
|
151
160
|
}): string | undefined;
|
|
161
|
+
export declare function runAccessForPerson(rawPath: string, personUid: string, opts?: {
|
|
162
|
+
company?: string;
|
|
163
|
+
hqRoot?: string;
|
|
164
|
+
}): Promise<AccessAsResult>;
|
|
165
|
+
export declare function formatAccessForPerson(result: AccessAsResult): string;
|
|
152
166
|
export declare function runAccessForPath(path: string, opts?: {
|
|
153
167
|
company?: string;
|
|
154
168
|
hqRoot?: string;
|
package/dist/commands/access.js
CHANGED
|
@@ -20,7 +20,7 @@ import * as readline from "node:readline";
|
|
|
20
20
|
import { ListObjectsV2Command, } from "@aws-sdk/client-s3";
|
|
21
21
|
import { DEFAULT_HQ_ROOT } from "../utils/cognito-session.js";
|
|
22
22
|
import { getCompanyUid, vaultApiFetch, resolveCallerUid } from "../utils/vault-api.js";
|
|
23
|
-
import { parseCompanySlugFromPath, resolveBrowseSession, makeCompanyPresignFactory, runGet, } from "./files-browse.js";
|
|
23
|
+
import { parseCompanySlugFromPath, classifyAclSource, resolveBrowseSession, makeCompanyPresignFactory, runGet, toBucketRelative, } from "./files-browse.js";
|
|
24
24
|
import { listJournals, syncDoctor, } from "@indigoai-us/hq-cloud";
|
|
25
25
|
import { listActiveMembers } from "./members.js";
|
|
26
26
|
import { buildDmBody } from "./dm.js";
|
|
@@ -87,6 +87,10 @@ function normalizeTarget(raw, slug) {
|
|
|
87
87
|
t = t.slice(anchor.length);
|
|
88
88
|
return t;
|
|
89
89
|
}
|
|
90
|
+
function fileGrantCoversPath(grantPath, targetPath) {
|
|
91
|
+
return classifyAclSource(targetPath, [grantPath]) !== "role-bypass";
|
|
92
|
+
}
|
|
93
|
+
const FILE_PERMISSION_RANK = { read: 1, write: 2, admin: 3 };
|
|
90
94
|
function httpStatusOf(err) {
|
|
91
95
|
if (!err || typeof err !== "object")
|
|
92
96
|
return undefined;
|
|
@@ -637,6 +641,7 @@ export function registerAccessCommand(program) {
|
|
|
637
641
|
.command("access <path-or-query>")
|
|
638
642
|
.description("Tell whether a vault file exists and whether you can read it. Never a bare not-found. Exit codes: 0 local/not-synced, 2 never-existed, 3 no-access, 4 ambiguous.")
|
|
639
643
|
.option("--company <slug>", "Company slug")
|
|
644
|
+
.option("--as <person-uid>", "Check a person's access (exact prs_ or agt_ UID; self or owner/admin)")
|
|
640
645
|
.option("--json", "Print a single JSON object")
|
|
641
646
|
.option("--no-fix", "Diagnose only: do not fetch, pin, or repair sync")
|
|
642
647
|
.option("--yes", "Answer the one send-confirmation yes (hosts must pass this explicitly)")
|
|
@@ -650,6 +655,12 @@ Exit codes:
|
|
|
650
655
|
`)
|
|
651
656
|
.action(async (target, opts) => {
|
|
652
657
|
try {
|
|
658
|
+
if (opts.as) {
|
|
659
|
+
const result = await runAccessForPerson(target, opts.as, opts);
|
|
660
|
+
console.log(opts.json ? JSON.stringify(result) : formatAccessForPerson(result));
|
|
661
|
+
process.exitCode = result.exitCode;
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
653
664
|
const result = await runAccessForPath(target, opts);
|
|
654
665
|
process.exitCode = result.exitCode;
|
|
655
666
|
}
|
|
@@ -669,6 +680,63 @@ export function resolveAccessCompany(target, opts) {
|
|
|
669
680
|
const hqRoot = opts?.hqRoot ?? DEFAULT_HQ_ROOT;
|
|
670
681
|
return opts?.company ?? slugFromTarget(target) ?? readActiveCompany(hqRoot);
|
|
671
682
|
}
|
|
683
|
+
export async function runAccessForPerson(rawPath, personUid, opts) {
|
|
684
|
+
const uid = personUid.trim();
|
|
685
|
+
if (!/^(?:prs|agt)_[A-Za-z0-9_-]+$/.test(uid)) {
|
|
686
|
+
throw new Error("Pass the person's exact prs_ or agt_ UID to --as.");
|
|
687
|
+
}
|
|
688
|
+
const pathSlug = slugFromTarget(rawPath);
|
|
689
|
+
if (opts?.company && pathSlug && opts.company !== pathSlug) {
|
|
690
|
+
throw new Error("The --company value does not match the company in the path.");
|
|
691
|
+
}
|
|
692
|
+
const company = resolveAccessCompany(rawPath, opts);
|
|
693
|
+
if (!company)
|
|
694
|
+
throw new Error("Pass --company <slug> or an anchored company path.");
|
|
695
|
+
const relativePath = normalizeTarget(rawPath, company);
|
|
696
|
+
if (!relativePath || relativePath.split(/[\\/]/).includes("..")) {
|
|
697
|
+
throw new Error("Pass a company-relative path without parent-directory segments.");
|
|
698
|
+
}
|
|
699
|
+
const { token } = await resolveBrowseSession();
|
|
700
|
+
const companyUid = await getCompanyUid(token, company);
|
|
701
|
+
const response = await vaultApiFetch({
|
|
702
|
+
token,
|
|
703
|
+
path: `/files/${encodeURIComponent(companyUid)}/members/${encodeURIComponent(uid)}/access`,
|
|
704
|
+
method: "GET",
|
|
705
|
+
});
|
|
706
|
+
if (!response.ok) {
|
|
707
|
+
if (response.status === 403) {
|
|
708
|
+
throw new Error("Only the person themself or a company owner/admin can check this access.");
|
|
709
|
+
}
|
|
710
|
+
if (response.status === 404) {
|
|
711
|
+
throw new Error("That person is not an active member of this company.");
|
|
712
|
+
}
|
|
713
|
+
throw new Error(`Member access check failed (${response.status}).`);
|
|
714
|
+
}
|
|
715
|
+
const projection = (await response.json());
|
|
716
|
+
if (projection.personUid !== uid || !projection.files) {
|
|
717
|
+
throw new Error("The member access response did not match the requested person.");
|
|
718
|
+
}
|
|
719
|
+
const roleBypass = projection.files.roleBypass === true;
|
|
720
|
+
const grants = Array.isArray(projection.files.grants) ? projection.files.grants : [];
|
|
721
|
+
const matchingPermissions = grants
|
|
722
|
+
.filter((grant) => typeof grant.path === "string" && typeof grant.permission === "string" &&
|
|
723
|
+
grant.permission in FILE_PERMISSION_RANK &&
|
|
724
|
+
fileGrantCoversPath(toBucketRelative(grant.path, company), relativePath))
|
|
725
|
+
.map((grant) => grant.permission);
|
|
726
|
+
const permission = roleBypass ? "admin" : matchingPermissions.reduce((best, candidate) => best === null || FILE_PERMISSION_RANK[candidate] > FILE_PERMISSION_RANK[best] ? candidate : best, null);
|
|
727
|
+
return {
|
|
728
|
+
personUid: uid,
|
|
729
|
+
company,
|
|
730
|
+
path: `companies/${company}/${relativePath}`,
|
|
731
|
+
permission,
|
|
732
|
+
canRead: permission !== null,
|
|
733
|
+
roleBypass,
|
|
734
|
+
exitCode: permission === null ? 3 : 0,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
export function formatAccessForPerson(result) {
|
|
738
|
+
return `${result.personUid} has ${result.permission ?? "no access"} access to ${result.path}.`;
|
|
739
|
+
}
|
|
672
740
|
export async function runAccessForPath(path, opts) {
|
|
673
741
|
const target = path;
|
|
674
742
|
const hqRoot = opts?.hqRoot ?? DEFAULT_HQ_ROOT;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Ctx, LaneResumeOptions } from "./lanes-shared.js";
|
|
2
|
+
import type { LaneRecord } from "../lib/lanes/types.js";
|
|
3
|
+
export interface LoopControllerDeps {
|
|
4
|
+
resumeLane(laneId: string, options: LaneResumeOptions): Promise<unknown>;
|
|
5
|
+
interruptLane(laneId: string, reason: string, options: {
|
|
6
|
+
preserveLoopQueue: boolean;
|
|
7
|
+
}): Promise<void>;
|
|
8
|
+
emitTerminal(lane: LaneRecord): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare function startLaneLoopController(ctx: Ctx, laneId: string, initialAdmission?: boolean): number;
|
|
11
|
+
export declare function runLaneLoopController(ctx: Ctx, laneId: string, deps: LoopControllerDeps): Promise<void>;
|
|
12
|
+
//# sourceMappingURL=lanes-loop.d.ts.map
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { LanesError } from "../lib/lanes/errors.js";
|
|
5
|
+
import { readLane, retryUpdateLane } from "../lib/lanes/store.js";
|
|
6
|
+
import { readLatestEnvelopeRecord } from "../lib/lanes/watcher.js";
|
|
7
|
+
import { spawnLaneWorker } from "../lib/lanes/spawn.js";
|
|
8
|
+
import { raiseLaneQuestion } from "../lib/lanes/questions.js";
|
|
9
|
+
import { isTerminalLaneState } from "../lib/lanes/types.js";
|
|
10
|
+
import { clearLoopControllerPid, loopAdmissionPath, runLoopLane, setLoopControllerPid } from "../lib/lanes/loop.js";
|
|
11
|
+
import { laneStories } from "../lib/lanes/stories.js";
|
|
12
|
+
const LOOP_RUNS_REL = path.join("workspace", "lanes-runs");
|
|
13
|
+
function loopRunDir(hqRoot, laneId) {
|
|
14
|
+
return path.join(hqRoot, LOOP_RUNS_REL, laneId);
|
|
15
|
+
}
|
|
16
|
+
export function startLaneLoopController(ctx, laneId, initialAdmission = false) {
|
|
17
|
+
const entry = process.argv[1];
|
|
18
|
+
if (!entry)
|
|
19
|
+
throw new LanesError("loop_controller_entry_missing", "Cannot resolve the hq CLI entry point for the loop controller", { lane_id: laneId });
|
|
20
|
+
const child = spawn(process.execPath, [entry, "lanes", "_loop-worker", laneId], {
|
|
21
|
+
cwd: ctx.hqRoot(),
|
|
22
|
+
detached: true,
|
|
23
|
+
stdio: "ignore",
|
|
24
|
+
env: { ...ctx.env, HQ_LANE_LOOP_CONTROLLER: "1", ...(initialAdmission ? { HQ_LANE_LOOP_INITIAL: "1" } : {}) },
|
|
25
|
+
});
|
|
26
|
+
child.unref();
|
|
27
|
+
if (child.pid === undefined)
|
|
28
|
+
throw new LanesError("loop_controller_spawn_failed", "Loop controller did not start", { lane_id: laneId });
|
|
29
|
+
setLoopControllerPid(loopRunDir(ctx.hqRoot(), laneId), laneId, child.pid);
|
|
30
|
+
return child.pid;
|
|
31
|
+
}
|
|
32
|
+
export async function runLaneLoopController(ctx, laneId, deps) {
|
|
33
|
+
const hqRoot = ctx.hqRoot();
|
|
34
|
+
const lane = readLane(hqRoot, laneId);
|
|
35
|
+
if (lane.loop_mode !== true)
|
|
36
|
+
throw new LanesError("lane_not_loop_mode", `Lane ${laneId} is not a loop lane`, { lane_id: laneId });
|
|
37
|
+
const runDir = loopRunDir(hqRoot, laneId);
|
|
38
|
+
let initialAdmission = ctx.env.HQ_LANE_LOOP_INITIAL === "1";
|
|
39
|
+
const stop = new AbortController();
|
|
40
|
+
const onSignal = () => stop.abort();
|
|
41
|
+
process.once("SIGTERM", onSignal);
|
|
42
|
+
process.once("SIGINT", onSignal);
|
|
43
|
+
try {
|
|
44
|
+
const result = await runLoopLane({
|
|
45
|
+
lane,
|
|
46
|
+
runDir,
|
|
47
|
+
signal: stop.signal,
|
|
48
|
+
idlePollMs: 250,
|
|
49
|
+
execute: async ({ envelope, signal, fresh, sessionId }) => {
|
|
50
|
+
let current = readLane(hqRoot, laneId);
|
|
51
|
+
const data = envelope;
|
|
52
|
+
const storyId = typeof data.story_id === "string" && data.story_id.trim() ? data.story_id.trim() : current.story_id;
|
|
53
|
+
if (storyId !== current.story_id) {
|
|
54
|
+
current = retryUpdateLane(hqRoot, laneId, "command", (record) => {
|
|
55
|
+
const stories = laneStories(record);
|
|
56
|
+
if (!stories.some((story) => story.project_id === record.project_id && story.story_id === storyId)) {
|
|
57
|
+
stories.push({ project_id: record.project_id, story_id: storyId, status: "in_progress", added_at: ctx.now().toISOString(), updated_at: ctx.now().toISOString() });
|
|
58
|
+
}
|
|
59
|
+
return { ...record, story_id: storyId, stories };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const worktree = typeof data.worktree === "string" ? path.resolve(data.worktree) : hqRoot;
|
|
63
|
+
if (typeof data.worktree === "string") {
|
|
64
|
+
let isDirectory = false;
|
|
65
|
+
try {
|
|
66
|
+
isDirectory = fs.statSync(worktree).isDirectory();
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTDIR")
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
if (!isDirectory)
|
|
73
|
+
throw new Error(`phase worktree is not an accessible directory: ${worktree}`);
|
|
74
|
+
}
|
|
75
|
+
const phaseContext = data.schema === "hq-phase-envelope/v1"
|
|
76
|
+
? [
|
|
77
|
+
`Story id: ${storyId}`,
|
|
78
|
+
`Story title: ${String(data.story_title ?? "")}`,
|
|
79
|
+
`Story description: ${String(data.story_description ?? "")}`,
|
|
80
|
+
`Phase: ${String(data.phase)}`,
|
|
81
|
+
`Working directory (worktree): ${worktree}`,
|
|
82
|
+
`Incoming handoff: ${String(data.incoming_handoff ?? "None: this is the first phase")}`,
|
|
83
|
+
"Acceptance criteria:",
|
|
84
|
+
...(Array.isArray(data.acceptance_criteria) ? data.acceptance_criteria.map((item, index) => `${index}. ${String(item)}`) : []),
|
|
85
|
+
"Constraints:",
|
|
86
|
+
...(Array.isArray(data.constraints) ? data.constraints.map((item) => `- ${String(item)}`) : []),
|
|
87
|
+
].join("\n")
|
|
88
|
+
: `Execute the ${String(data.phase ?? "requested")} phase for story ${storyId}.`;
|
|
89
|
+
const prompt = typeof data.prompt === "string" ? data.prompt :
|
|
90
|
+
`${phaseContext}\n\nPhase envelope:\n${JSON.stringify(envelope, null, 2)}`;
|
|
91
|
+
const briefPath = path.join(runDir, "brief.md");
|
|
92
|
+
fs.writeFileSync(briefPath, [
|
|
93
|
+
"Follow the lane worker instructions and complete this single bounded phase.",
|
|
94
|
+
"Return the standard hq-lane-envelope.v1 envelope when the phase is complete. For a section 8 phase envelope, put a complete hq-phase-handoff/v1 object in artifacts; it must identify the requested story_id and phase and include worker_id, status, summary, files_changed, commits, back_pressure (tests/lint/typecheck/build), and context_for_next.",
|
|
95
|
+
"",
|
|
96
|
+
prompt,
|
|
97
|
+
].join("\n"));
|
|
98
|
+
const admissionPath = loopAdmissionPath(current.state, current.pending_resume !== undefined, initialAdmission);
|
|
99
|
+
if (admissionPath === "initial") {
|
|
100
|
+
initialAdmission = false;
|
|
101
|
+
if (current.state === "running") {
|
|
102
|
+
retryUpdateLane(hqRoot, laneId, "command", (record) => ({
|
|
103
|
+
...record,
|
|
104
|
+
state: "queued",
|
|
105
|
+
queued_reason: undefined,
|
|
106
|
+
queued_details: undefined,
|
|
107
|
+
admission_reserved_at: undefined,
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
await spawnLaneWorker(hqRoot, current, {
|
|
111
|
+
env: ctx.env,
|
|
112
|
+
modelDeps: { readFileSync: ctx.readFileSync, probe: ctx.probeProvider, env: ctx.env },
|
|
113
|
+
existsSync: ctx.existsSync,
|
|
114
|
+
spawnProcess: ctx.spawnProcess,
|
|
115
|
+
proveDetached: ctx.proveDetached,
|
|
116
|
+
killProcess: ctx.killProcess,
|
|
117
|
+
now: ctx.now,
|
|
118
|
+
resolveMachineIdentity: ctx.resolveMachineIdentity,
|
|
119
|
+
runCompaction: ctx.runCompaction,
|
|
120
|
+
locateTranscript: ctx.locateTranscript,
|
|
121
|
+
workingDirectory: worktree,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else if (admissionPath === "deferred") {
|
|
125
|
+
retryUpdateLane(hqRoot, laneId, "command", (record) => ({
|
|
126
|
+
...record,
|
|
127
|
+
pending_resume: record.pending_resume ? {
|
|
128
|
+
...record.pending_resume,
|
|
129
|
+
...(fresh ? { session_id: undefined, fresh_thread: true } : sessionId ? { session_id: sessionId, fresh_thread: undefined } : {}),
|
|
130
|
+
working_directory: worktree,
|
|
131
|
+
} : undefined,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
await deps.resumeLane(laneId, {
|
|
136
|
+
text: `Read ${briefPath} and follow it as the complete instruction for this phase.`,
|
|
137
|
+
project: current.project_id,
|
|
138
|
+
story: storyId,
|
|
139
|
+
freshThread: fresh,
|
|
140
|
+
...(sessionId ? { loopSessionId: sessionId } : {}),
|
|
141
|
+
workingDirectory: worktree,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
let interruptPromise;
|
|
145
|
+
const abortPhase = () => {
|
|
146
|
+
interruptPromise = deps.interruptLane(laneId, "loop phase deadline exceeded", { preserveLoopQueue: true }).catch((error) => {
|
|
147
|
+
process.stderr.write(`hq lanes: could not interrupt stalled loop phase ${laneId}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
signal.addEventListener("abort", abortPhase, { once: true });
|
|
151
|
+
try {
|
|
152
|
+
for (;;) {
|
|
153
|
+
if (signal.aborted) {
|
|
154
|
+
await interruptPromise;
|
|
155
|
+
throw signal.reason ?? new Error("phase deadline exceeded");
|
|
156
|
+
}
|
|
157
|
+
const latest = readLane(hqRoot, laneId);
|
|
158
|
+
if (latest.state === "done" || latest.state === "blocked" || latest.state === "awaiting_input" || isTerminalLaneState(latest.state)) {
|
|
159
|
+
const envelopeRecord = readLatestEnvelopeRecord(hqRoot, laneId);
|
|
160
|
+
if (latest.state !== "done" && latest.state !== "blocked" && latest.state !== "awaiting_input") {
|
|
161
|
+
throw new Error(`lane phase ended in ${latest.state}${latest.terminal_reason ? ` (${latest.terminal_reason})` : ""}`);
|
|
162
|
+
}
|
|
163
|
+
if (!envelopeRecord)
|
|
164
|
+
throw new Error(`lane ${laneId} ended without writing a phase envelope`);
|
|
165
|
+
const handoff = envelopeRecord.envelope.artifacts.find((artifact) => typeof artifact === "object" && artifact !== null &&
|
|
166
|
+
artifact.schema === "hq-phase-handoff/v1");
|
|
167
|
+
if (data.schema === "hq-phase-envelope/v1" && !handoff) {
|
|
168
|
+
throw new Error("section 8 phase requires an hq-phase-handoff/v1 object in the lane envelope artifacts");
|
|
169
|
+
}
|
|
170
|
+
return { output: JSON.stringify(handoff ?? envelopeRecord.envelope), ...(latest.provider_session_id ? { sessionId: latest.provider_session_id } : {}) };
|
|
171
|
+
}
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
signal.removeEventListener("abort", abortPhase);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
raiseQuestion: ({ envelope, envelopeId, attempt, error }) => {
|
|
180
|
+
const current = readLane(hqRoot, laneId);
|
|
181
|
+
const data = envelope;
|
|
182
|
+
raiseLaneQuestion({
|
|
183
|
+
hqRoot,
|
|
184
|
+
lane: current,
|
|
185
|
+
raisedVia: "cli",
|
|
186
|
+
at: ctx.now().toISOString(),
|
|
187
|
+
body: {
|
|
188
|
+
question: `Loop phase ${String(data.phase ?? envelopeId)} stalled twice. How should it proceed?`,
|
|
189
|
+
context: `${error}\nThe phase is parked; later envelopes remain queued. Stall attempt: ${attempt}.`,
|
|
190
|
+
options: ["Retry with a longer deadline", "Reroute to another worker", "Drop this phase and block the story"],
|
|
191
|
+
recommended: "Retry with a longer deadline",
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
if (result.state === "stopped") {
|
|
197
|
+
const stopped = retryUpdateLane(hqRoot, laneId, "command", (current) => ({
|
|
198
|
+
...current,
|
|
199
|
+
state: "done",
|
|
200
|
+
queued_reason: undefined,
|
|
201
|
+
queued_details: undefined,
|
|
202
|
+
admission_reserved_at: undefined,
|
|
203
|
+
terminal_reason: "loop_stopped",
|
|
204
|
+
}));
|
|
205
|
+
await deps.emitTerminal(stopped);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
process.removeListener("SIGTERM", onSignal);
|
|
210
|
+
process.removeListener("SIGINT", onSignal);
|
|
211
|
+
clearLoopControllerPid(runDir, laneId, process.pid);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
//# sourceMappingURL=lanes-loop.js.map
|