@indigoai-us/hq-cli 5.184.0 → 5.185.1
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 +18 -0
- package/dist/commands/lanes.d.ts +1 -1
- package/dist/commands/lanes.js +85 -20
- package/dist/lib/bot/progress.d.ts +10 -0
- package/dist/lib/bot/progress.js +55 -0
- package/dist/lib/bot/prompt.js +3 -1
- package/dist/lib/bot/run.d.ts +4 -1
- package/dist/lib/bot/run.js +60 -5
- package/dist/lib/lanes/registry-flag.d.ts +27 -0
- package/dist/lib/lanes/registry-flag.js +129 -0
- package/dist/lib/search-index/collection-path-repair-flag.d.ts +4 -0
- package/dist/lib/search-index/collection-path-repair-flag.js +7 -0
- package/dist/lib/search-index/index.d.ts +4 -0
- package/dist/lib/search-index/index.js +106 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.185.1] — 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `lanes.hire-grants` resolves its company-scoped hq-flags value before gating.
|
|
10
|
+
A valid local override wins; missing rows or failed reads leave enforcement
|
|
11
|
+
off. Backfills check the unscoped value and each target company's value
|
|
12
|
+
before writing. `hire_grant_exhausted` names the grantor and includes the
|
|
13
|
+
same recovery message in JSON and on stderr.
|
|
14
|
+
|
|
15
|
+
## [5.185.0] — 2026-09-25
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Local bots no longer post their no-reply signal, narration, blank messages, or raw model errors into rooms. On a room message that does not mention the bot, in-between messages and the working notice are held until the turn ends, and nothing is posted when the reply is the `{"action":"no_reply"}` signal or empty. Any model failure on such a turn (not only a Grok `reasoning_only` empty response, but every model error) is now silent instead of leaking "I didn't finish that — <model> stopped with an error: <raw excerpt>" into the room. When the turn does produce content, the held in-between messages are posted alongside the final answer, so a bot that answers mid-turn and ends with a short sign-off (claude/codex) no longer loses the answer; a Grok final that just concatenates the in-between messages still posts once with no duplication. The signal is never posted as progress anywhere. DMs and @-mentions still stream and still get a reply; a `reasoning_only` failure there now says "the model returned no answer" instead of the raw internal error, and other failures there keep the existing "did not finish" message. The room prompt also tells the model to produce no output rather than explain that it is staying silent.
|
|
20
|
+
|
|
5
21
|
## [5.184.0] — 2026-09-25
|
|
6
22
|
|
|
7
23
|
### Fixed
|
|
@@ -13,6 +29,8 @@
|
|
|
13
29
|
|
|
14
30
|
- `hq doctor --json` includes stable sync `reasonCode` enums. Client-health reports add `checkId: reasonCode` pairs only when the company-scoped `client-health-sync-reason-codes` hq-flags gate is enabled. `hq doctor --fix --json` reports refresh-sync outcomes without raw error details.
|
|
15
31
|
|
|
32
|
+
- Repair known stale QMD collection paths behind the default-off `qmd.collection-path-repair` hq-flags canary while preserving collection metadata.
|
|
33
|
+
|
|
16
34
|
## [5.183.0] — 2026-09-25
|
|
17
35
|
|
|
18
36
|
### Added
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -79,7 +79,7 @@ export interface LanesCommandDeps {
|
|
|
79
79
|
/** Test seam for the default-off outpost placement rollout flag. */
|
|
80
80
|
lanePlacementEnabled?: () => boolean;
|
|
81
81
|
/** Test seam for the default-off lane hire-grants rollout gate. */
|
|
82
|
-
laneHireGrantsEnabled?: () => boolean
|
|
82
|
+
laneHireGrantsEnabled?: (company?: LaneCompanyRef | null) => boolean | Promise<boolean>;
|
|
83
83
|
}
|
|
84
84
|
export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
85
85
|
export declare function parseSeniorRef(raw: string): SeniorRef;
|
package/dist/commands/lanes.js
CHANGED
|
@@ -13,6 +13,7 @@ import * as path from "node:path";
|
|
|
13
13
|
import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
|
|
14
14
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
15
15
|
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
16
|
+
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
16
17
|
import { resolveProcessFlagGate } from "../lib/flag-registry.js";
|
|
17
18
|
import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
|
|
18
19
|
export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
|
|
@@ -52,8 +53,8 @@ export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
|
52
53
|
function defaultLanePlacementEnabled() {
|
|
53
54
|
return resolveProcessFlagGate(LANE_OUTPOST_LOAD_BALANCING_FLAG, { fallback: false }, () => false);
|
|
54
55
|
}
|
|
55
|
-
function defaultLaneHireGrantsEnabled() {
|
|
56
|
-
return
|
|
56
|
+
function defaultLaneHireGrantsEnabled(company) {
|
|
57
|
+
return readLanesRegistryFlag(LANE_HIRE_GRANTS_FLAG, { company });
|
|
57
58
|
}
|
|
58
59
|
function withoutProviderHome(value) {
|
|
59
60
|
if (Array.isArray(value))
|
|
@@ -73,6 +74,9 @@ function printJson(value) {
|
|
|
73
74
|
}
|
|
74
75
|
function failJson(err) {
|
|
75
76
|
if (isLanesError(err)) {
|
|
77
|
+
if (err.error === "hire_grant_exhausted") {
|
|
78
|
+
process.stderr.write(`hq lanes: ${err.message}\n`);
|
|
79
|
+
}
|
|
76
80
|
printJson(err.toJSON());
|
|
77
81
|
process.exit(1);
|
|
78
82
|
}
|
|
@@ -515,12 +519,14 @@ const OUTPOST_HIRE_SUMMARY_WAIT_MS = 15_000;
|
|
|
515
519
|
const OUTPOST_HIRE_SUMMARY_CONCURRENCY = 4;
|
|
516
520
|
async function laneHirePresentation(ctx, lane, lanes) {
|
|
517
521
|
const local = laneHireFields(lane, lanes);
|
|
518
|
-
if (
|
|
519
|
-
lane.host.kind !== "outpost" ||
|
|
522
|
+
if (lane.host.kind !== "outpost" ||
|
|
520
523
|
isTerminalLaneState(lane.state) ||
|
|
521
524
|
laneRunsHere(lane, reconcileHostFromCtx(ctx))) {
|
|
522
525
|
return { ...local, hire_source: "local" };
|
|
523
526
|
}
|
|
527
|
+
if (!await ctx.laneHireGrantsEnabled(lane.company)) {
|
|
528
|
+
return { ...local, hire_source: "local" };
|
|
529
|
+
}
|
|
524
530
|
try {
|
|
525
531
|
return { ...(await ownerLaneHireFields(ctx, lane)), hire_source: "owner" };
|
|
526
532
|
}
|
|
@@ -548,14 +554,15 @@ async function mapWithConcurrency(values, concurrency, map) {
|
|
|
548
554
|
await Promise.all(workers);
|
|
549
555
|
return results;
|
|
550
556
|
}
|
|
551
|
-
function withResumeHireAdmission(ctx, lane, fn) {
|
|
557
|
+
async function withResumeHireAdmission(ctx, lane, fn) {
|
|
552
558
|
const hqRoot = ctx.hqRoot();
|
|
553
|
-
if (!
|
|
554
|
-
!isTerminalLaneState(lane.state) ||
|
|
559
|
+
if (!isTerminalLaneState(lane.state) ||
|
|
555
560
|
lane.senior.kind !== "lane" ||
|
|
556
561
|
!lane.senior.id) {
|
|
557
562
|
return fn();
|
|
558
563
|
}
|
|
564
|
+
if (!await ctx.laneHireGrantsEnabled(lane.company))
|
|
565
|
+
return fn();
|
|
559
566
|
return withHireGrantAdmission(hqRoot, lane.senior.id, ({ parent, children, parentGrant, availableChildGrant }) => {
|
|
560
567
|
const current = readLane(hqRoot, lane.lane_id);
|
|
561
568
|
// A different resume already reactivated this lane; it occupies the slot
|
|
@@ -588,11 +595,33 @@ function withResumeHireAdmission(ctx, lane, fn) {
|
|
|
588
595
|
}
|
|
589
596
|
function hireGrantCapacityError(parent, children) {
|
|
590
597
|
const grant = parent.hire_grant ?? 0;
|
|
591
|
-
|
|
598
|
+
const grantor = (parent.senior.kind === "lane" || parent.senior.kind === "session") &&
|
|
599
|
+
parent.senior.id
|
|
600
|
+
? { kind: parent.senior.kind, id: parent.senior.id }
|
|
601
|
+
: null;
|
|
602
|
+
const grantorReason = grantor
|
|
603
|
+
? undefined
|
|
604
|
+
: parent.senior.kind === "user"
|
|
605
|
+
? "senior_is_user"
|
|
606
|
+
: "parent_senior_missing_id";
|
|
607
|
+
const capacity = children.length === grant
|
|
608
|
+
? `is at its hire grant (${children.length} of ${grant} alive children)`
|
|
609
|
+
: `has exhausted its hire grant (${children.length} alive children; grant ${grant})`;
|
|
610
|
+
const grantCommand = `hq lanes grant ${parent.lane_id} --hire <n>`;
|
|
611
|
+
const authorityGuidance = grantor
|
|
612
|
+
? `Its senior ${grantor.kind} ${grantor.id} can raise it with: ${grantCommand}`
|
|
613
|
+
: parent.senior.kind === "user"
|
|
614
|
+
? `Its senior is the user and can raise it with: ${grantCommand}`
|
|
615
|
+
: `No senior is recorded, so an operator must resolve ownership before running: ${grantCommand}`;
|
|
616
|
+
const message = `Lane ${parent.lane_id} ${capacity}. ${authorityGuidance}`;
|
|
617
|
+
return new LanesError("hire_grant_exhausted", message, {
|
|
592
618
|
senior_lane_id: parent.lane_id,
|
|
593
619
|
parent_grant: grant,
|
|
594
620
|
alive_children: children.map((child) => child.lane_id),
|
|
595
621
|
alive_child_count: children.length,
|
|
622
|
+
grantor,
|
|
623
|
+
...(grantorReason ? { grantor_reason: grantorReason } : {}),
|
|
624
|
+
message,
|
|
596
625
|
ask_guidance: {
|
|
597
626
|
envelope: "decision=ask; include the desired total grant and the reason",
|
|
598
627
|
senior_command: `hq lanes grant ${parent.lane_id} --hire <n>`,
|
|
@@ -1858,7 +1887,12 @@ async function runCreate(ctx, opts) {
|
|
|
1858
1887
|
}
|
|
1859
1888
|
const senior = opts.senior ? parseSeniorRef(opts.senior) : defaultSenior(ctx);
|
|
1860
1889
|
assertSeniorExists(hqRoot, senior, ctx);
|
|
1861
|
-
|
|
1890
|
+
// Resolve tenant context before the registry read so the flag evaluator
|
|
1891
|
+
// applies the same company scope as this lane.
|
|
1892
|
+
const company = companyFlag
|
|
1893
|
+
? await ctx.resolveCompany(companyFlag)
|
|
1894
|
+
: null;
|
|
1895
|
+
const hireGrantsEnabled = await ctx.laneHireGrantsEnabled(company);
|
|
1862
1896
|
if (requestedHireGrant !== undefined &&
|
|
1863
1897
|
!callerMatchesSenior(ctx, senior)) {
|
|
1864
1898
|
throw new LanesError("hire_grant_unauthorized", `Only senior ${seniorKey(senior)} may set a lane's hire grant`, { senior, hire_grant: requestedHireGrant });
|
|
@@ -1874,9 +1908,6 @@ async function runCreate(ctx, opts) {
|
|
|
1874
1908
|
throw new LanesError("max_lane_depth", `Lane depth ${laneDepth} exceeds cap ${MAX_LANE_DEPTH}`, { depth: laneDepth, max: MAX_LANE_DEPTH });
|
|
1875
1909
|
}
|
|
1876
1910
|
// A no-company lane must not call company resolution (openMeshTransport).
|
|
1877
|
-
const company = companyFlag
|
|
1878
|
-
? await ctx.resolveCompany(companyFlag)
|
|
1879
|
-
: null;
|
|
1880
1911
|
let meshCompany = company;
|
|
1881
1912
|
if (meshCompanyFlag) {
|
|
1882
1913
|
const resolvedMeshCompany = await ctx.resolveCompany(meshCompanyFlag);
|
|
@@ -3024,6 +3055,17 @@ async function runList(ctx, opts) {
|
|
|
3024
3055
|
return true;
|
|
3025
3056
|
})
|
|
3026
3057
|
.sort(compareLaneQueueOrder);
|
|
3058
|
+
const companyContexts = new Map();
|
|
3059
|
+
for (const lane of selectedLanes) {
|
|
3060
|
+
if (lane.host.kind !== "outpost" ||
|
|
3061
|
+
isTerminalLaneState(lane.state) ||
|
|
3062
|
+
laneRunsHere(lane, host)) {
|
|
3063
|
+
continue;
|
|
3064
|
+
}
|
|
3065
|
+
const company = lane.company;
|
|
3066
|
+
companyContexts.set(JSON.stringify([company?.uid ?? null, company?.slug ?? null]), company);
|
|
3067
|
+
}
|
|
3068
|
+
await Promise.all([...companyContexts.values()].map((company) => ctx.laneHireGrantsEnabled(company)));
|
|
3027
3069
|
const hireByLane = new Map(await mapWithConcurrency(selectedLanes, OUTPOST_HIRE_SUMMARY_CONCURRENCY, async (lane) => [
|
|
3028
3070
|
lane.lane_id,
|
|
3029
3071
|
await laneHirePresentation(ctx, lane, allLanes),
|
|
@@ -3545,6 +3587,7 @@ async function runGrant(ctx, laneId, opts) {
|
|
|
3545
3587
|
await dispatchOutpostLaneCommand(ctx, initial, "grant", { hire: String(hireGrant) });
|
|
3546
3588
|
return;
|
|
3547
3589
|
}
|
|
3590
|
+
const enforce = await ctx.laneHireGrantsEnabled(initial.company);
|
|
3548
3591
|
const lockedLaneIds = [
|
|
3549
3592
|
laneId,
|
|
3550
3593
|
...(initial.senior.kind === "lane" && initial.senior.id
|
|
@@ -3557,7 +3600,6 @@ async function runGrant(ctx, laneId, opts) {
|
|
|
3557
3600
|
throw new LanesError("hire_grant_unauthorized", `Only senior ${seniorKey(current.senior)} may change lane ${laneId}'s hire grant`, { lane_id: laneId, senior: current.senior });
|
|
3558
3601
|
}
|
|
3559
3602
|
const hierarchy = loadAllLanes(hqRoot);
|
|
3560
|
-
const enforce = ctx.laneHireGrantsEnabled();
|
|
3561
3603
|
if (enforce && current.senior.kind === "lane") {
|
|
3562
3604
|
if (!current.senior.id) {
|
|
3563
3605
|
throw new LanesError("hire_grant_senior_invalid", `Lane ${laneId} has a lane senior without an id`, {
|
|
@@ -3611,7 +3653,7 @@ async function runBackfillGrants(ctx, opts) {
|
|
|
3611
3653
|
}
|
|
3612
3654
|
const apply = Boolean(opts.apply);
|
|
3613
3655
|
const hqRoot = ctx.hqRoot();
|
|
3614
|
-
if (apply && ctx.laneHireGrantsEnabled()) {
|
|
3656
|
+
if (apply && await ctx.laneHireGrantsEnabled(null)) {
|
|
3615
3657
|
throw new LanesError("hire_grant_backfill_gate_enabled", "Backfill must be applied and verified before lanes.hire-grants is enabled", { flag: LANE_HIRE_GRANTS_FLAG });
|
|
3616
3658
|
}
|
|
3617
3659
|
if (apply && ctx.env.HQ_LANE_ID?.trim()) {
|
|
@@ -3642,6 +3684,24 @@ async function runBackfillGrants(ctx, opts) {
|
|
|
3642
3684
|
}
|
|
3643
3685
|
let rows = planHireGrantBackfill(allLanes, profileMaxConcurrent);
|
|
3644
3686
|
if (apply) {
|
|
3687
|
+
const lanesById = new Map(allLanes.map((lane) => [lane.lane_id, lane]));
|
|
3688
|
+
const targetCompanies = new Map();
|
|
3689
|
+
for (const row of rows) {
|
|
3690
|
+
if (row.action === "unchanged")
|
|
3691
|
+
continue;
|
|
3692
|
+
const lane = lanesById.get(row.lane_id);
|
|
3693
|
+
if (!lane) {
|
|
3694
|
+
throw new LanesError("hire_grant_backfill_target_changed", `Backfill target lane ${row.lane_id} is unavailable`, { lane_id: row.lane_id });
|
|
3695
|
+
}
|
|
3696
|
+
const company = lane.company;
|
|
3697
|
+
if (!company)
|
|
3698
|
+
continue;
|
|
3699
|
+
targetCompanies.set(JSON.stringify([company.uid ?? null, company.slug ?? null]), company);
|
|
3700
|
+
}
|
|
3701
|
+
const companyGates = await Promise.all([...targetCompanies.values()].map((company) => ctx.laneHireGrantsEnabled(company)));
|
|
3702
|
+
if (companyGates.some(Boolean)) {
|
|
3703
|
+
throw new LanesError("hire_grant_backfill_gate_enabled", "Backfill must be applied and verified before lanes.hire-grants is enabled", { flag: LANE_HIRE_GRANTS_FLAG });
|
|
3704
|
+
}
|
|
3645
3705
|
const shortfalls = rows.filter((row) => row.grant_shortfall > 0);
|
|
3646
3706
|
if (shortfalls.length > 0) {
|
|
3647
3707
|
throw new LanesError("hire_grant_backfill_allocation_shortfall", "Existing parent grants do not cover the grants allocated to alive children; resolve these rows before applying the backfill", { lanes: shortfalls });
|
|
@@ -4009,7 +4069,7 @@ async function prepareAccountMove(ctx, lane, input) {
|
|
|
4009
4069
|
if (admission.action === "refuse" && admission.error)
|
|
4010
4070
|
throw admission.error;
|
|
4011
4071
|
const queued = admission.action === "queue";
|
|
4012
|
-
const moved = withResumeHireAdmission(ctx, lane, () => casUpdateLane(hqRoot, laneId, (current) => {
|
|
4072
|
+
const moved = await withResumeHireAdmission(ctx, lane, () => casUpdateLane(hqRoot, laneId, (current) => {
|
|
4013
4073
|
// The copy and every check above were made from `lane`; a record
|
|
4014
4074
|
// that moved on since (another move, a spawn, a cancel) invalidates
|
|
4015
4075
|
// them, so the write is refused rather than applied to a state it
|
|
@@ -4061,7 +4121,7 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
4061
4121
|
assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
|
|
4062
4122
|
// Check before transcript recovery, account probing, or message writes.
|
|
4063
4123
|
// The final state transition repeats this check under the senior lock.
|
|
4064
|
-
withResumeHireAdmission(ctx, lane, () => undefined);
|
|
4124
|
+
await withResumeHireAdmission(ctx, lane, () => undefined);
|
|
4065
4125
|
lane = laneWithRecoveredProviderSession(hqRoot, lane);
|
|
4066
4126
|
// An account move is refused on its own terms first: a running lane and a
|
|
4067
4127
|
// provider without session resume get the move's named errors, not the
|
|
@@ -4341,7 +4401,7 @@ async function runResume(ctx, laneId, opts) {
|
|
|
4341
4401
|
}
|
|
4342
4402
|
// Fail before provider/session work if a terminal child would consume a
|
|
4343
4403
|
// slot that its senior has already assigned elsewhere.
|
|
4344
|
-
withResumeHireAdmission(ctx, lane, () => undefined);
|
|
4404
|
+
await withResumeHireAdmission(ctx, lane, () => undefined);
|
|
4345
4405
|
assertLaneRunsHereForOperatorMutation(ctx, lane, "resume");
|
|
4346
4406
|
lane = laneWithRecoveredProviderSession(hqRoot, lane);
|
|
4347
4407
|
if (opts.model !== undefined && opts.account === undefined) {
|
|
@@ -4913,14 +4973,19 @@ async function runReparent(ctx, laneId, opts) {
|
|
|
4913
4973
|
const nextSenior = parseSeniorRef(opts.senior);
|
|
4914
4974
|
assertSeniorExists(hqRoot, nextSenior, ctx);
|
|
4915
4975
|
const initial = readLane(hqRoot, laneId);
|
|
4976
|
+
const needsHireGrantGate = nextSenior.kind === "lane" &&
|
|
4977
|
+
seniorKey(initial.senior) !== seniorKey(nextSenior);
|
|
4978
|
+
const hireGrantsEnabled = needsHireGrantGate
|
|
4979
|
+
? await ctx.laneHireGrantsEnabled(initial.company)
|
|
4980
|
+
: false;
|
|
4916
4981
|
const lockIds = [
|
|
4917
4982
|
laneId,
|
|
4918
4983
|
...(initial.senior.kind === "lane" && initial.senior.id ? [initial.senior.id] : []),
|
|
4919
4984
|
...(nextSenior.kind === "lane" && nextSenior.id ? [nextSenior.id] : []),
|
|
4920
4985
|
];
|
|
4921
|
-
withLaneHireLocks(hqRoot, lockIds, () => runReparentLocked(ctx, laneId, opts));
|
|
4986
|
+
withLaneHireLocks(hqRoot, lockIds, () => runReparentLocked(ctx, laneId, opts, hireGrantsEnabled));
|
|
4922
4987
|
}
|
|
4923
|
-
function runReparentLocked(ctx, laneId, opts) {
|
|
4988
|
+
function runReparentLocked(ctx, laneId, opts, hireGrantsEnabled) {
|
|
4924
4989
|
const hqRoot = ctx.hqRoot();
|
|
4925
4990
|
if (!opts.senior) {
|
|
4926
4991
|
throw new LanesError("missing_senior", "--senior is required", {});
|
|
@@ -4934,7 +4999,7 @@ function runReparentLocked(ctx, laneId, opts) {
|
|
|
4934
4999
|
}
|
|
4935
5000
|
const seniorCompany = companyOfSenior(hqRoot, nextSenior, ctx);
|
|
4936
5001
|
assertSameTenant(lane.company, nextSenior, seniorCompany, { lane_id: laneId });
|
|
4937
|
-
if (
|
|
5002
|
+
if (hireGrantsEnabled &&
|
|
4938
5003
|
nextSenior.kind === "lane" &&
|
|
4939
5004
|
seniorKey(lane.senior) !== seniorKey(nextSenior)) {
|
|
4940
5005
|
const parent = readLane(hqRoot, nextSenior.id);
|
|
@@ -42,9 +42,18 @@ export interface ProgressPosterOptions {
|
|
|
42
42
|
minGapMs?: number;
|
|
43
43
|
workingNoticeMs?: number;
|
|
44
44
|
workingNoticeText?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Unmentioned room turn: hold every in-between message and the working
|
|
47
|
+
* notice, and post nothing until the turn ends. `finish` then posts the
|
|
48
|
+
* in-between messages followed by the final answer when it adds something
|
|
49
|
+
* (when the turn is not silent); `abandon` posts nothing.
|
|
50
|
+
*/
|
|
51
|
+
buffer?: boolean;
|
|
45
52
|
}
|
|
46
53
|
/** Whitespace-insensitive form used to recognise repeated text. */
|
|
47
54
|
export declare function normalizeForCompare(text: string): string;
|
|
55
|
+
/** Matches {"action":"no_reply"} anywhere in a model reply, with or without preamble text. */
|
|
56
|
+
export declare function containsNoReplySentinel(text: string): boolean;
|
|
48
57
|
export declare class ProgressPoster {
|
|
49
58
|
private readonly send;
|
|
50
59
|
private readonly sendFinalFn?;
|
|
@@ -55,6 +64,7 @@ export declare class ProgressPoster {
|
|
|
55
64
|
private readonly minGapMs;
|
|
56
65
|
private readonly workingNoticeMs;
|
|
57
66
|
private readonly workingNoticeText;
|
|
67
|
+
private readonly buffer;
|
|
58
68
|
private pending;
|
|
59
69
|
/** In-between questions, held until the turn ends (see the header). */
|
|
60
70
|
private heldQuestions;
|
package/dist/lib/bot/progress.js
CHANGED
|
@@ -31,6 +31,10 @@ export const WORKING_NOTICE_TEXT = "Working on it — I'll reply here when it's
|
|
|
31
31
|
export function normalizeForCompare(text) {
|
|
32
32
|
return text.replace(/\s+/g, "");
|
|
33
33
|
}
|
|
34
|
+
/** Matches {"action":"no_reply"} anywhere in a model reply, with or without preamble text. */
|
|
35
|
+
export function containsNoReplySentinel(text) {
|
|
36
|
+
return /\{\s*"action"\s*:\s*"no_reply"\s*\}/.test(text);
|
|
37
|
+
}
|
|
34
38
|
export class ProgressPoster {
|
|
35
39
|
send;
|
|
36
40
|
sendFinalFn;
|
|
@@ -41,6 +45,7 @@ export class ProgressPoster {
|
|
|
41
45
|
minGapMs;
|
|
42
46
|
workingNoticeMs;
|
|
43
47
|
workingNoticeText;
|
|
48
|
+
buffer;
|
|
44
49
|
pending = [];
|
|
45
50
|
/** In-between questions, held until the turn ends (see the header). */
|
|
46
51
|
heldQuestions = [];
|
|
@@ -63,9 +68,12 @@ export class ProgressPoster {
|
|
|
63
68
|
this.minGapMs = opts.minGapMs ?? PROGRESS_MIN_GAP_MS;
|
|
64
69
|
this.workingNoticeMs = opts.workingNoticeMs ?? WORKING_NOTICE_MS;
|
|
65
70
|
this.workingNoticeText = opts.workingNoticeText ?? WORKING_NOTICE_TEXT;
|
|
71
|
+
this.buffer = opts.buffer ?? false;
|
|
66
72
|
}
|
|
67
73
|
/** Arm the one-time "working on it" note. Call when the turn starts. */
|
|
68
74
|
start() {
|
|
75
|
+
if (this.buffer)
|
|
76
|
+
return;
|
|
69
77
|
if (this.finished || this.noticeTimer !== null || this.workingNoticeMs <= 0)
|
|
70
78
|
return;
|
|
71
79
|
this.noticeTimer = this.setTimer(() => {
|
|
@@ -81,8 +89,16 @@ export class ProgressPoster {
|
|
|
81
89
|
const t = text.trim();
|
|
82
90
|
if (this.finished || !t)
|
|
83
91
|
return;
|
|
92
|
+
// Never treat the no_reply sentinel as something the model "said": it is a
|
|
93
|
+
// silence signal, not content, and must never be posted as progress.
|
|
94
|
+
if (containsNoReplySentinel(t))
|
|
95
|
+
return;
|
|
84
96
|
this.cancelNotice();
|
|
85
97
|
this.said.push(t);
|
|
98
|
+
// An unmentioned room turn holds every in-between message: nothing is posted
|
|
99
|
+
// until the turn ends, and then only the final answer.
|
|
100
|
+
if (this.buffer)
|
|
101
|
+
return;
|
|
86
102
|
if (t.includes("?")) {
|
|
87
103
|
this.heldQuestions.push(t);
|
|
88
104
|
return;
|
|
@@ -112,6 +128,34 @@ export class ProgressPoster {
|
|
|
112
128
|
this.cancelNotice();
|
|
113
129
|
this.cancelFlush();
|
|
114
130
|
const final = finalText.trim();
|
|
131
|
+
// Buffered (unmentioned room): nothing was posted during the turn. Post the
|
|
132
|
+
// collected in-between messages (the sentinel is already excluded from
|
|
133
|
+
// `said`), then the final answer when it adds something the in-between
|
|
134
|
+
// messages did not already say — mirroring the streaming path's
|
|
135
|
+
// repeatsSaid logic. claude/codex answer mid-turn and end with a short
|
|
136
|
+
// sign-off, so dropping the in-between messages would lose the real answer;
|
|
137
|
+
// Grok's final is every message concatenated, so it repeats and is skipped.
|
|
138
|
+
// A silent turn (run.ts decides via sentinel/empty) never reaches here.
|
|
139
|
+
if (this.buffer) {
|
|
140
|
+
this.pending = [];
|
|
141
|
+
this.heldQuestions = [];
|
|
142
|
+
for (const s of this.said) {
|
|
143
|
+
if (!containsNoReplySentinel(s))
|
|
144
|
+
this.pending.push(s);
|
|
145
|
+
}
|
|
146
|
+
if (final && !containsNoReplySentinel(final) && !this.repeatsSaid(final)) {
|
|
147
|
+
if (this.sendFinalFn) {
|
|
148
|
+
// Keep the audience split: collected messages go through send, the
|
|
149
|
+
// final reply through sendFinal.
|
|
150
|
+
this.flushNow();
|
|
151
|
+
this.enqueueWith(final, this.sendFinalFn);
|
|
152
|
+
return this.chain;
|
|
153
|
+
}
|
|
154
|
+
this.pending.push(final);
|
|
155
|
+
}
|
|
156
|
+
this.flushNow();
|
|
157
|
+
return this.chain;
|
|
158
|
+
}
|
|
115
159
|
const held = this.heldQuestions;
|
|
116
160
|
this.heldQuestions = [];
|
|
117
161
|
const shouldAddFinal = Boolean(final && !this.repeatsSaid(final));
|
|
@@ -141,6 +185,13 @@ export class ProgressPoster {
|
|
|
141
185
|
this.finished = true;
|
|
142
186
|
this.cancelNotice();
|
|
143
187
|
this.cancelFlush();
|
|
188
|
+
// Buffered turns never post salvage: nothing streamed, so there is nothing
|
|
189
|
+
// to salvage, and the caller decides whether to post anything.
|
|
190
|
+
if (this.buffer) {
|
|
191
|
+
this.pending = [];
|
|
192
|
+
this.heldQuestions = [];
|
|
193
|
+
return this.chain;
|
|
194
|
+
}
|
|
144
195
|
this.pending.push(...this.heldQuestions);
|
|
145
196
|
this.heldQuestions = [];
|
|
146
197
|
this.flushNow();
|
|
@@ -174,6 +225,10 @@ export class ProgressPoster {
|
|
|
174
225
|
this.enqueueWith(body, this.send);
|
|
175
226
|
}
|
|
176
227
|
enqueueWith(body, sendFn) {
|
|
228
|
+
// Defence in depth: never let the silence sentinel reach the chat, whatever
|
|
229
|
+
// path built this body.
|
|
230
|
+
if (containsNoReplySentinel(body))
|
|
231
|
+
return;
|
|
177
232
|
this.lastPostAt = this.now();
|
|
178
233
|
this.chain = this.chain.then(async () => {
|
|
179
234
|
try {
|
package/dist/lib/bot/prompt.js
CHANGED
|
@@ -288,7 +288,9 @@ export function buildSystemPrompt(input) {
|
|
|
288
288
|
`Your owner can also add you to group chats and channels. A message that starts with a \`[#channel · N members · from Name]\` line ` +
|
|
289
289
|
`comes from a room with other people in it (a "Recent messages:" block may follow for context; the last part is the message you are answering). ` +
|
|
290
290
|
`Answer for the room: reply to the person who asked, keep it short, and never repeat your private memory, your owner's files, ` +
|
|
291
|
-
`or anything from your DMs to other people
|
|
291
|
+
`or anything from your DMs to other people. ` +
|
|
292
|
+
`If the message is not addressed to you, is routine automated output, or you have nothing meaningful to add, produce zero output (completely empty response). ` +
|
|
293
|
+
`Do not narrate your reasoning, do not print JSON, do not explain that you are staying silent. Just produce nothing.`);
|
|
292
294
|
// Only company bots: the cloud's jobs routes refuse a personal bot's per_
|
|
293
295
|
// company claim today, so telling a personal bot it can schedule work makes
|
|
294
296
|
// it promise its owner something that then fails. Drop this condition when
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
import { type OwnerContext } from "./owner-context.js";
|
|
48
48
|
import { type BotApi, type InboxItem } from "./api.js";
|
|
49
49
|
import type { BotConfig, BotKind } from "./config.js";
|
|
50
|
-
import { type ProgressPosterOptions } from "./progress.js";
|
|
50
|
+
import { containsNoReplySentinel, type ProgressPosterOptions } from "./progress.js";
|
|
51
51
|
import { type BotLogger } from "./log.js";
|
|
52
52
|
import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "./runtime/index.js";
|
|
53
53
|
import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
|
|
@@ -188,6 +188,9 @@ export declare function turnEnv(base: NodeJS.ProcessEnv, kind: BotKind, agentUid
|
|
|
188
188
|
export declare const SETUP_WORKER_ID = "setup";
|
|
189
189
|
/** The one message a person gets when a turn on their message did not finish. */
|
|
190
190
|
export declare function didNotFinishText(reason: string, progressPosted: boolean): string;
|
|
191
|
+
export { containsNoReplySentinel };
|
|
192
|
+
/** Grok emits is_error with this message when the model produces only reasoning tokens. */
|
|
193
|
+
export declare function isReasoningOnlyError(excerpt: string): boolean;
|
|
191
194
|
/** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
|
|
192
195
|
export declare const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
|
|
193
196
|
export declare const RESTARTED_REASON = "I was restarted while I was working on it";
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -57,7 +57,7 @@ import { ensureBotSessionMeta } from "./company-bind.js";
|
|
|
57
57
|
import { readBotCredsIdentity } from "./creds.js";
|
|
58
58
|
import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
|
|
59
59
|
import { clearInflight, patchInflight, readInflight, readInflightTurns, writeInflight } from "./inflight.js";
|
|
60
|
-
import { ProgressPoster } from "./progress.js";
|
|
60
|
+
import { containsNoReplySentinel, ProgressPoster } from "./progress.js";
|
|
61
61
|
import { splitBody } from "./split.js";
|
|
62
62
|
import { createBotLogger } from "./log.js";
|
|
63
63
|
import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
|
|
@@ -178,6 +178,11 @@ export function didNotFinishText(reason, progressPosted) {
|
|
|
178
178
|
const r = reason.trim().replace(/[.\s]+$/, "");
|
|
179
179
|
return `I didn't finish that — ${r}.${progressPosted ? " The messages above show how far I got." : ""}`;
|
|
180
180
|
}
|
|
181
|
+
export { containsNoReplySentinel };
|
|
182
|
+
/** Grok emits is_error with this message when the model produces only reasoning tokens. */
|
|
183
|
+
export function isReasoningOnlyError(excerpt) {
|
|
184
|
+
return excerpt.includes("reasoning_only");
|
|
185
|
+
}
|
|
181
186
|
/** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
|
|
182
187
|
export const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
|
|
183
188
|
export const RESTARTED_REASON = "I was restarted while I was working on it";
|
|
@@ -646,6 +651,7 @@ export async function runBot(deps) {
|
|
|
646
651
|
writeInflight(dir, { v: 1, ...args.marker, startedAt: now().toISOString() });
|
|
647
652
|
let running = true;
|
|
648
653
|
let posted = 0;
|
|
654
|
+
const silenceable = args.silenceable === true;
|
|
649
655
|
const makeSendFn = (fn) => async (body) => {
|
|
650
656
|
try {
|
|
651
657
|
await fn(body);
|
|
@@ -662,7 +668,8 @@ export async function runBot(deps) {
|
|
|
662
668
|
};
|
|
663
669
|
const poster = new ProgressPoster({
|
|
664
670
|
...deps.progress,
|
|
665
|
-
...(args.workingNotice === false ? { workingNoticeMs: 0 } : {}),
|
|
671
|
+
...(args.workingNotice === false || silenceable ? { workingNoticeMs: 0 } : {}),
|
|
672
|
+
...(silenceable ? { buffer: true } : {}),
|
|
666
673
|
send: makeSendFn(args.send),
|
|
667
674
|
...(args.sendFinal ? { sendFinal: makeSendFn(args.sendFinal) } : {}),
|
|
668
675
|
});
|
|
@@ -679,8 +686,21 @@ export async function runBot(deps) {
|
|
|
679
686
|
...(args.readDirs?.length ? { readDirs: args.readDirs } : {}),
|
|
680
687
|
});
|
|
681
688
|
running = false;
|
|
682
|
-
|
|
683
|
-
|
|
689
|
+
const sentinelOrEmpty = containsNoReplySentinel(turn.text) || !turn.text.trim();
|
|
690
|
+
// Unmentioned room turn: a no_reply sentinel (bare, fenced, or with
|
|
691
|
+
// preamble) or an empty/whitespace reply means stay quiet. Abandon the
|
|
692
|
+
// poster (it posts nothing) and return a silent outcome; the caller acks
|
|
693
|
+
// without posting anything.
|
|
694
|
+
if (silenceable && sentinelOrEmpty) {
|
|
695
|
+
await poster.abandon();
|
|
696
|
+
return { outcome: { ok: true, reply: "", silent: true }, poster };
|
|
697
|
+
}
|
|
698
|
+
// DM or @-mentioned room: the person asked directly and must get a reply.
|
|
699
|
+
// Strip the silence sentinel so its raw JSON never posts, then let the
|
|
700
|
+
// poster produce its "(no reply)" fallback when there is nothing else.
|
|
701
|
+
const finalText = sentinelOrEmpty ? "" : turn.text;
|
|
702
|
+
await poster.finish(finalText);
|
|
703
|
+
return { outcome: { ok: true, reply: finalText.trim() }, poster };
|
|
684
704
|
}
|
|
685
705
|
catch (err) {
|
|
686
706
|
running = false;
|
|
@@ -984,6 +1004,7 @@ export async function runBot(deps) {
|
|
|
984
1004
|
});
|
|
985
1005
|
await clearThinking();
|
|
986
1006
|
if (outcome.ok) {
|
|
1007
|
+
// The kickoff never runs silenceable, so outcome.silent is never set here.
|
|
987
1008
|
if (poster.postedCount === 0) {
|
|
988
1009
|
patchInflight(dir, KICKOFF_MESSAGE_ID, saved(outcome.reply));
|
|
989
1010
|
log("warn", "kickoff reply failed to send; it is posted on the next start");
|
|
@@ -1334,12 +1355,23 @@ export async function runBot(deps) {
|
|
|
1334
1355
|
send,
|
|
1335
1356
|
sendFinal,
|
|
1336
1357
|
workingNotice: !isSetupBot,
|
|
1358
|
+
silenceable: target.kind === "room" && !target.mentioned,
|
|
1337
1359
|
...(readDirs.length ? { readDirs } : {}),
|
|
1338
1360
|
...(target.kind === "room"
|
|
1339
1361
|
? { afterProgressPost: () => api.postAgentStatus(target.channelId, ROOM_THINKING_STATUS, target.rootEventId) }
|
|
1340
1362
|
: {}),
|
|
1341
1363
|
});
|
|
1342
1364
|
if (outcome.ok) {
|
|
1365
|
+
// A no_reply sentinel or intentionally empty reply: ack silently, post nothing.
|
|
1366
|
+
if (outcome.silent) {
|
|
1367
|
+
markProcessed(dir, id, now().toISOString());
|
|
1368
|
+
await api.ackInbox(config.agentUid, id);
|
|
1369
|
+
clearInflight(dir, id);
|
|
1370
|
+
attempts.delete(id);
|
|
1371
|
+
await clearThinking();
|
|
1372
|
+
log("info", `no_reply sentinel or empty reply for ${id} (${where}); acked silently`);
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1343
1375
|
// Known gap (#614, not changed here): a progress post counts, so a final answer that fails after one is not resent.
|
|
1344
1376
|
if (poster.postedCount === 0) {
|
|
1345
1377
|
// The work finished but nothing reached the chat: keep the answer —
|
|
@@ -1400,8 +1432,31 @@ export async function runBot(deps) {
|
|
|
1400
1432
|
// The model started: it may already have done things, so never re-run.
|
|
1401
1433
|
if (!outcome.aborted)
|
|
1402
1434
|
recordTurnFailure(outcome.error, sessionScope);
|
|
1435
|
+
// reasoning_only means the model produced only reasoning tokens and no text.
|
|
1436
|
+
const reasoningOnly = !outcome.aborted && isReasoningOnlyError(outcome.excerpt);
|
|
1437
|
+
const silenceableTurn = target.kind === "room" && !target.mentioned;
|
|
1438
|
+
// Unmentioned room turn that posted nothing and was not stopped by the
|
|
1439
|
+
// owner: any model failure (reasoning_only or otherwise) is a silent
|
|
1440
|
+
// no-reply. The failure is already recorded above via recordTurnFailure,
|
|
1441
|
+
// so we just ack quietly and never leak the raw error into the room.
|
|
1442
|
+
if (!outcome.aborted && poster.postedCount === 0 && silenceableTurn) {
|
|
1443
|
+
markProcessed(dir, id, now().toISOString());
|
|
1444
|
+
await api.ackInbox(config.agentUid, id);
|
|
1445
|
+
clearInflight(dir, id);
|
|
1446
|
+
attempts.delete(id);
|
|
1447
|
+
await clearThinking();
|
|
1448
|
+
log("info", `unmentioned room turn ${id} failed with no post (${where}); acked silently`);
|
|
1449
|
+
if (failures.exceeded(now().getTime())) {
|
|
1450
|
+
await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
|
|
1451
|
+
}
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1403
1454
|
try {
|
|
1404
|
-
|
|
1455
|
+
// DM or @-mentioned room reasoning_only: the person asked directly, so
|
|
1456
|
+
// still say we did not finish — but with a plain reason, never the raw
|
|
1457
|
+
// internal error string.
|
|
1458
|
+
const reason = reasoningOnly ? "the model returned no answer" : failureReason(outcome);
|
|
1459
|
+
await send(didNotFinishText(reason, poster.postedCount > 0));
|
|
1405
1460
|
}
|
|
1406
1461
|
catch (err) {
|
|
1407
1462
|
// Leave it un-acked with the marker: the next poll says it did not finish.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type FlagClientOptions, type FlagResolution } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
import type { LaneCompanyRef } from "./types.js";
|
|
3
|
+
export declare const LANES_FLAG_API_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
|
|
4
|
+
export declare const LANES_FLAG_READ_TIMEOUT_MS = 5000;
|
|
5
|
+
export interface LanesFlagClient {
|
|
6
|
+
ready(): Promise<void>;
|
|
7
|
+
explain(flagKey: string, options?: {
|
|
8
|
+
fallback?: boolean;
|
|
9
|
+
}): FlagResolution;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
12
|
+
export type LanesFlagClientFactory = (options: FlagClientOptions) => LanesFlagClient;
|
|
13
|
+
export interface ReadLanesFlagOptions {
|
|
14
|
+
company?: LaneCompanyRef | null;
|
|
15
|
+
env?: NodeJS.ProcessEnv;
|
|
16
|
+
createClient?: LanesFlagClientFactory;
|
|
17
|
+
getToken?: () => string | Promise<string>;
|
|
18
|
+
reportFailure?: (error: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Read a lanes flag once for each company identity in this CLI process. The
|
|
22
|
+
* cache contains only an in-flight or settled boolean promise; it is never
|
|
23
|
+
* written to disk. A local override is checked on every call and wins first.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readLanesRegistryFlag(flagKey: string, options?: ReadLanesFlagOptions): Promise<boolean>;
|
|
26
|
+
export declare function _resetLanesRegistryFlagCacheForTests(): void;
|
|
27
|
+
//# sourceMappingURL=registry-flag.d.ts.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
import { ensureCognitoIdToken } from "../../utils/cognito-session.js";
|
|
3
|
+
export const LANES_FLAG_API_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
|
|
4
|
+
export const LANES_FLAG_READ_TIMEOUT_MS = 5_000;
|
|
5
|
+
const registryReads = new Map();
|
|
6
|
+
class MissingFlagTokenError extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("No cached HQ identity token is available");
|
|
9
|
+
this.name = "MissingFlagTokenError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
class MissingFlagRowError extends Error {
|
|
13
|
+
constructor(flagKey) {
|
|
14
|
+
super(`The hq-flags registry did not resolve ${flagKey}`);
|
|
15
|
+
this.name = "MissingFlagRowError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function errorClass(error) {
|
|
19
|
+
return error instanceof Error ? error.name : "UnknownError";
|
|
20
|
+
}
|
|
21
|
+
function reportReadFailure(flagKey, error) {
|
|
22
|
+
const suffix = flagKey.replace(/^lanes\./, "").replaceAll(/[^a-zA-Z0-9]+/g, "_");
|
|
23
|
+
process.stderr.write(`${JSON.stringify({
|
|
24
|
+
event: `lanes.${suffix}.flag_read_failed`,
|
|
25
|
+
flag: flagKey,
|
|
26
|
+
error: errorClass(error),
|
|
27
|
+
fallback: false,
|
|
28
|
+
})}\n`);
|
|
29
|
+
}
|
|
30
|
+
function cacheKey(flagKey, company, endpoint) {
|
|
31
|
+
return JSON.stringify([
|
|
32
|
+
flagKey,
|
|
33
|
+
endpoint,
|
|
34
|
+
company?.uid ?? null,
|
|
35
|
+
company?.slug ?? null,
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read a lanes flag once for each company identity in this CLI process. The
|
|
40
|
+
* cache contains only an in-flight or settled boolean promise; it is never
|
|
41
|
+
* written to disk. A local override is checked on every call and wins first.
|
|
42
|
+
*/
|
|
43
|
+
export async function readLanesRegistryFlag(flagKey, options = {}) {
|
|
44
|
+
const env = options.env ?? process.env;
|
|
45
|
+
const localOverride = readLocalOverride(flagKey, env);
|
|
46
|
+
if (localOverride !== null)
|
|
47
|
+
return localOverride;
|
|
48
|
+
const endpoint = env.HQ_FLAGS_API_URL?.trim() || LANES_FLAG_API_DEFAULT_ENDPOINT;
|
|
49
|
+
const key = cacheKey(flagKey, options.company, endpoint);
|
|
50
|
+
const cached = registryReads.get(key);
|
|
51
|
+
if (cached)
|
|
52
|
+
return cached;
|
|
53
|
+
const pending = readRegistryValue(flagKey, endpoint, options);
|
|
54
|
+
registryReads.set(key, pending);
|
|
55
|
+
return pending;
|
|
56
|
+
}
|
|
57
|
+
async function readRegistryValue(flagKey, endpoint, options) {
|
|
58
|
+
const company = options.company;
|
|
59
|
+
const companyUid = company?.uid?.trim();
|
|
60
|
+
const companySlug = company?.slug?.trim();
|
|
61
|
+
let client;
|
|
62
|
+
let enabled = false;
|
|
63
|
+
let failed = false;
|
|
64
|
+
let reported = false;
|
|
65
|
+
const reportFailure = (error) => {
|
|
66
|
+
failed = true;
|
|
67
|
+
if (reported)
|
|
68
|
+
return;
|
|
69
|
+
reported = true;
|
|
70
|
+
(options.reportFailure ?? ((cause) => reportReadFailure(flagKey, cause)))(error);
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
const acquireToken = options.getToken ?? (async () => {
|
|
74
|
+
try {
|
|
75
|
+
return await ensureCognitoIdToken({ interactive: false });
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof Error &&
|
|
79
|
+
error.message.startsWith("No valid HQ session")) {
|
|
80
|
+
throw new MissingFlagTokenError();
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
const getToken = async () => {
|
|
86
|
+
const token = await acquireToken();
|
|
87
|
+
if (typeof token !== "string" || token.trim().length === 0) {
|
|
88
|
+
throw new MissingFlagTokenError();
|
|
89
|
+
}
|
|
90
|
+
return token;
|
|
91
|
+
};
|
|
92
|
+
client = (options.createClient ?? createFlagClient)({
|
|
93
|
+
endpoint,
|
|
94
|
+
getToken,
|
|
95
|
+
env: {},
|
|
96
|
+
refreshIntervalMs: 0,
|
|
97
|
+
requestTimeoutMs: LANES_FLAG_READ_TIMEOUT_MS,
|
|
98
|
+
...(companyUid ? { companyUid } : {}),
|
|
99
|
+
...(companySlug ? { companyIdentifiers: [companySlug] } : {}),
|
|
100
|
+
onError: reportFailure,
|
|
101
|
+
});
|
|
102
|
+
await client.ready();
|
|
103
|
+
const resolution = client.explain(flagKey, { fallback: false });
|
|
104
|
+
if (failed)
|
|
105
|
+
return false;
|
|
106
|
+
if (resolution.source !== "snapshot" && resolution.source !== "local-override") {
|
|
107
|
+
reportFailure(new MissingFlagRowError(flagKey));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
enabled = resolution.value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
reportFailure(error);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
try {
|
|
118
|
+
client?.close();
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
reportFailure(error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return failed ? false : enabled;
|
|
125
|
+
}
|
|
126
|
+
export function _resetLanesRegistryFlagCacheForTests() {
|
|
127
|
+
registryReads.clear();
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=registry-flag.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const QMD_COLLECTION_PATH_REPAIR_FLAG = "qmd.collection-path-repair";
|
|
2
|
+
/** Keep collection-path repair off unless the hq-flags registry enables a canary. */
|
|
3
|
+
export declare function qmdCollectionPathRepairEnabled(): boolean;
|
|
4
|
+
//# sourceMappingURL=collection-path-repair-flag.d.ts.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { resolveProcessFlagGate } from '../flag-registry.js';
|
|
2
|
+
export const QMD_COLLECTION_PATH_REPAIR_FLAG = 'qmd.collection-path-repair';
|
|
3
|
+
/** Keep collection-path repair off unless the hq-flags registry enables a canary. */
|
|
4
|
+
export function qmdCollectionPathRepairEnabled() {
|
|
5
|
+
return resolveProcessFlagGate(QMD_COLLECTION_PATH_REPAIR_FLAG, { fallback: false }, () => false);
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=collection-path-repair-flag.js.map
|
|
@@ -537,10 +537,14 @@ export declare function deriveCollections(hqRoot: string): SearchCollection[];
|
|
|
537
537
|
export type ReconcileCollectionsOptions = {
|
|
538
538
|
bin?: string;
|
|
539
539
|
runner?: QmdProcessRunner;
|
|
540
|
+
/** Test seam for the default-off collection-path repair rollout. */
|
|
541
|
+
repairPathDrift?: boolean;
|
|
540
542
|
};
|
|
541
543
|
/**
|
|
542
544
|
* Register expected collections that qmd does not yet know about, returning the
|
|
543
545
|
* collections this call actually created (a `collection add` that exited 0).
|
|
546
|
+
* When the default-off hq-flags rollout is enabled, it also replaces a registered
|
|
547
|
+
* collection whose path matches a known prior HQ-managed layout.
|
|
544
548
|
*
|
|
545
549
|
* A same-name idempotent duplicate — qmd already owns the exact requested name
|
|
546
550
|
* in its YAML config (`Collection '<name>' already exists.`), while the
|
|
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { dump, load } from 'js-yaml';
|
|
6
7
|
import { isQmdModelDownloadError } from '../../utils/qmd-model-download-error.js';
|
|
7
8
|
import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
|
|
8
9
|
import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
|
|
@@ -12,6 +13,8 @@ import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-erro
|
|
|
12
13
|
import { markExpectedUserError } from '../../utils/expected-cli-error.js';
|
|
13
14
|
import { redactErrorText } from '../../utils/redact-error-text.js';
|
|
14
15
|
import { planCommandSpawn } from '../../utils/windows-spawn.js';
|
|
16
|
+
import { resolveQmdConfigPath } from './max-doc-bytes.js';
|
|
17
|
+
import { qmdCollectionPathRepairEnabled } from './collection-path-repair-flag.js';
|
|
15
18
|
/** The `path` implementation for a (possibly injected) platform. */
|
|
16
19
|
function pathFor(platform) {
|
|
17
20
|
return platform === 'win32' ? path.win32 : path.posix;
|
|
@@ -1350,9 +1353,90 @@ export function deriveCollections(hqRoot) {
|
|
|
1350
1353
|
}
|
|
1351
1354
|
return collections;
|
|
1352
1355
|
}
|
|
1356
|
+
function isRecord(value) {
|
|
1357
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1358
|
+
}
|
|
1359
|
+
function qmdConfigPath(hqRoot) {
|
|
1360
|
+
const env = {
|
|
1361
|
+
...process.env,
|
|
1362
|
+
HOME: process.env.HOME ?? process.env.USERPROFILE ?? os.homedir(),
|
|
1363
|
+
};
|
|
1364
|
+
return resolveQmdConfigPath({ env, startDir: hqRoot });
|
|
1365
|
+
}
|
|
1366
|
+
/** Read qmd's configured collection metadata from the same config file its CLI uses. */
|
|
1367
|
+
function configuredCollections(hqRoot) {
|
|
1368
|
+
const configPath = qmdConfigPath(hqRoot);
|
|
1369
|
+
if (!configPath || !fs.existsSync(configPath))
|
|
1370
|
+
return new Map();
|
|
1371
|
+
const document = load(fs.readFileSync(configPath, 'utf8'));
|
|
1372
|
+
if (!isRecord(document) || !isRecord(document.collections))
|
|
1373
|
+
return new Map();
|
|
1374
|
+
return new Map(Object.entries(document.collections).flatMap(([name, config]) => isRecord(config) && typeof config.path === 'string' ? [[name, config]] : []));
|
|
1375
|
+
}
|
|
1376
|
+
/** Restore existing collection options after qmd's add command rewrites the entry. */
|
|
1377
|
+
function restoreCollectionMetadata(hqRoot, collection, previousConfig) {
|
|
1378
|
+
const configPath = qmdConfigPath(hqRoot);
|
|
1379
|
+
if (!configPath || !fs.existsSync(configPath)) {
|
|
1380
|
+
throw new Error(`QMD config disappeared while repairing collection '${collection.name}'`);
|
|
1381
|
+
}
|
|
1382
|
+
const document = load(fs.readFileSync(configPath, 'utf8'));
|
|
1383
|
+
if (!isRecord(document) || !isRecord(document.collections)) {
|
|
1384
|
+
throw new Error(`QMD config has no collections map while repairing '${collection.name}'`);
|
|
1385
|
+
}
|
|
1386
|
+
const currentConfig = document.collections[collection.name];
|
|
1387
|
+
document.collections[collection.name] = {
|
|
1388
|
+
...(isRecord(currentConfig) ? currentConfig : {}),
|
|
1389
|
+
...previousConfig,
|
|
1390
|
+
path: collection.path,
|
|
1391
|
+
};
|
|
1392
|
+
fs.writeFileSync(configPath, dump(document, { lineWidth: 0 }));
|
|
1393
|
+
}
|
|
1394
|
+
function resolvedPath(value) {
|
|
1395
|
+
const absolutePath = path.resolve(value);
|
|
1396
|
+
try {
|
|
1397
|
+
return fs.realpathSync(absolutePath);
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
return absolutePath;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
function comparablePath(value) {
|
|
1404
|
+
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
1405
|
+
}
|
|
1406
|
+
/** Repair a known HQ-managed path, including the former company knowledge mirror layout. */
|
|
1407
|
+
function isPreviousHqManagedPath(hqRoot, collection, configuredPath) {
|
|
1408
|
+
const root = resolvedPath(hqRoot);
|
|
1409
|
+
const expectedPath = resolvedPath(collection.path);
|
|
1410
|
+
const managedRelativePath = path.relative(root, expectedPath);
|
|
1411
|
+
if (!managedRelativePath ||
|
|
1412
|
+
path.isAbsolute(managedRelativePath) ||
|
|
1413
|
+
managedRelativePath === '..' ||
|
|
1414
|
+
managedRelativePath.startsWith(`..${path.sep}`)) {
|
|
1415
|
+
return false;
|
|
1416
|
+
}
|
|
1417
|
+
const actualPath = resolvedPath(path.resolve(hqRoot, configuredPath));
|
|
1418
|
+
const suffix = `${path.sep}${managedRelativePath}`;
|
|
1419
|
+
const actualForComparison = comparablePath(actualPath);
|
|
1420
|
+
const suffixForComparison = comparablePath(suffix);
|
|
1421
|
+
const prefixLength = actualForComparison.length - suffixForComparison.length;
|
|
1422
|
+
if (prefixLength > path.parse(actualPath).root.length && actualForComparison.endsWith(suffixForComparison)) {
|
|
1423
|
+
return true;
|
|
1424
|
+
}
|
|
1425
|
+
const expectedParts = managedRelativePath.split(path.sep);
|
|
1426
|
+
if (expectedParts.length !== 3 || expectedParts[0] !== 'companies' || expectedParts[2] !== 'knowledge') {
|
|
1427
|
+
return false;
|
|
1428
|
+
}
|
|
1429
|
+
const slug = expectedParts[1];
|
|
1430
|
+
if (!slug)
|
|
1431
|
+
return false;
|
|
1432
|
+
const formerMirrorPath = resolvedPath(path.join(root, 'repos', 'private', `knowledge-${slug}`));
|
|
1433
|
+
return comparablePath(actualPath) === comparablePath(formerMirrorPath);
|
|
1434
|
+
}
|
|
1353
1435
|
/**
|
|
1354
1436
|
* Register expected collections that qmd does not yet know about, returning the
|
|
1355
1437
|
* collections this call actually created (a `collection add` that exited 0).
|
|
1438
|
+
* When the default-off hq-flags rollout is enabled, it also replaces a registered
|
|
1439
|
+
* collection whose path matches a known prior HQ-managed layout.
|
|
1356
1440
|
*
|
|
1357
1441
|
* A same-name idempotent duplicate — qmd already owns the exact requested name
|
|
1358
1442
|
* in its YAML config (`Collection '<name>' already exists.`), while the
|
|
@@ -1368,11 +1452,29 @@ export function deriveCollections(hqRoot) {
|
|
|
1368
1452
|
export function reconcileCollections(hqRoot, options = {}) {
|
|
1369
1453
|
const runOptions = { bin: options.bin, runner: options.runner, cwd: hqRoot };
|
|
1370
1454
|
const registered = listRegisteredCollections(hqRoot, runOptions);
|
|
1371
|
-
const
|
|
1455
|
+
const expected = deriveCollections(hqRoot);
|
|
1456
|
+
const repairPathDrift = options.repairPathDrift ?? qmdCollectionPathRepairEnabled();
|
|
1457
|
+
const configured = repairPathDrift ? configuredCollections(hqRoot) : new Map();
|
|
1458
|
+
const stale = repairPathDrift
|
|
1459
|
+
? expected.filter((collection) => {
|
|
1460
|
+
const previousConfig = configured.get(collection.name);
|
|
1461
|
+
return previousConfig !== undefined
|
|
1462
|
+
&& comparablePath(resolvedPath(path.resolve(hqRoot, previousConfig.path))) !== comparablePath(resolvedPath(collection.path))
|
|
1463
|
+
&& isPreviousHqManagedPath(hqRoot, collection, previousConfig.path);
|
|
1464
|
+
})
|
|
1465
|
+
: [];
|
|
1466
|
+
for (const collection of stale) {
|
|
1467
|
+
runQmd(['collection', 'remove', collection.name], runOptions);
|
|
1468
|
+
registered.delete(collection.name);
|
|
1469
|
+
}
|
|
1470
|
+
const missing = expected.filter((collection) => !registered.has(collection.name));
|
|
1372
1471
|
const created = [];
|
|
1373
1472
|
for (const collection of missing) {
|
|
1374
1473
|
try {
|
|
1375
1474
|
runQmd(['collection', 'add', collection.path, '--name', collection.name, '--mask', collection.mask], runOptions);
|
|
1475
|
+
const previousConfig = configured.get(collection.name);
|
|
1476
|
+
if (previousConfig)
|
|
1477
|
+
restoreCollectionMetadata(hqRoot, collection, previousConfig);
|
|
1376
1478
|
created.push(collection);
|
|
1377
1479
|
}
|
|
1378
1480
|
catch (error) {
|
|
@@ -1381,6 +1483,9 @@ export function reconcileCollections(hqRoot, options = {}) {
|
|
|
1381
1483
|
// different collection — stays fatal.
|
|
1382
1484
|
if (!(error instanceof QmdCollectionExistsError) || error.collectionName !== collection.name)
|
|
1383
1485
|
throw error;
|
|
1486
|
+
const previousConfig = configured.get(collection.name);
|
|
1487
|
+
if (previousConfig)
|
|
1488
|
+
restoreCollectionMetadata(hqRoot, collection, previousConfig);
|
|
1384
1489
|
}
|
|
1385
1490
|
runQmd(['context', 'add', `qmd://${collection.name}`, collection.context], runOptions);
|
|
1386
1491
|
}
|