@indigoai-us/hq-cli 5.282.0 → 5.284.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/assets/scaffold/.claude/hooks/validate-policy-frontmatter.sh +485 -0
  3. package/assets/scaffold/core/scripts/eval-trigger.sh +193 -0
  4. package/assets/scaffold/core/scripts/policy-age-report.sh +156 -0
  5. package/assets/scaffold/core/scripts/policy-retire.sh +199 -0
  6. package/assets/scaffold/core/scripts/read-policy-frontmatter.sh +32 -0
  7. package/dist/command-catalog.generated.d.ts +67 -0
  8. package/dist/command-catalog.generated.js +83 -0
  9. package/dist/commands/core-knowledge-prefs.d.ts +10 -0
  10. package/dist/commands/core-knowledge-prefs.js +125 -0
  11. package/dist/commands/core-native-policy-flag.d.ts +9 -0
  12. package/dist/commands/core-native-policy-flag.js +12 -0
  13. package/dist/commands/core-ontology-native.d.ts +0 -1
  14. package/dist/commands/core-ontology-native.js +2 -30
  15. package/dist/commands/core-ontology.d.ts +4 -0
  16. package/dist/commands/core-ontology.js +8 -3
  17. package/dist/commands/core-policy-frontmatter.d.ts +8 -0
  18. package/dist/commands/core-policy-frontmatter.js +52 -0
  19. package/dist/commands/core-policy.d.ts +15 -0
  20. package/dist/commands/core-policy.js +982 -0
  21. package/dist/commands/core.js +14 -1
  22. package/dist/commands/lanes-shared.d.ts +4 -1
  23. package/dist/commands/lanes-shared.js +2 -1
  24. package/dist/commands/lanes.d.ts +8 -0
  25. package/dist/commands/lanes.js +303 -91
  26. package/dist/fast-lanes-list-route.d.ts +8 -0
  27. package/dist/fast-lanes-list-route.js +30 -0
  28. package/dist/fast-lanes-list.d.ts +3 -0
  29. package/dist/fast-lanes-list.js +252 -0
  30. package/dist/fast-scaffold.d.ts +5 -0
  31. package/dist/fast-scaffold.js +10 -0
  32. package/dist/index.js +10 -1
  33. package/dist/lib/checkpoint-ontology/merge-copies.d.ts +24 -1
  34. package/dist/lib/checkpoint-ontology/merge-copies.js +70 -1
  35. package/dist/lib/lanes/flag-keys.d.ts +3 -0
  36. package/dist/lib/lanes/flag-keys.js +3 -0
  37. package/dist/lib/lanes/index.d.ts +1 -1
  38. package/dist/lib/lanes/index.js +1 -1
  39. package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
  40. package/dist/lib/lanes/outpost-dispatch.js +14 -2
  41. package/dist/lib/lanes/outpost-hire-cache.d.ts +3 -0
  42. package/dist/lib/lanes/outpost-hire-cache.js +11 -3
  43. package/dist/lib/lanes/outpost-snapshot-cache.d.ts +26 -0
  44. package/dist/lib/lanes/outpost-snapshot-cache.js +215 -0
  45. package/dist/lib/lanes/store.d.ts +4 -0
  46. package/dist/lib/lanes/store.js +39 -0
  47. package/dist/lib/mesh/live/daemon/credentials.js +8 -0
  48. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -0
  49. package/dist/lib/mesh/live/daemon/work-subscribe.js +143 -5
  50. package/dist/lib/policy-validator.d.ts +2 -0
  51. package/dist/lib/policy-validator.js +136 -0
  52. package/dist/native-fast-route.d.ts +2 -0
  53. package/dist/native-fast-route.js +6 -2
  54. package/dist/utils/contribution-table.d.ts +1 -1
  55. package/package.json +1 -1
@@ -0,0 +1,8 @@
1
+ export interface FastLanesListRouteDeps {
2
+ flagEnabled: (flagKey: string, env: NodeJS.ProcessEnv) => Promise<boolean>;
3
+ runFastList: () => Promise<void>;
4
+ runPlacementList: () => Promise<void>;
5
+ }
6
+ /** Returns false unless this exact list form is safely enabled in the local flag snapshot. */
7
+ export declare function runFastLanesListRequest(argv: readonly string[], env?: NodeJS.ProcessEnv, deps?: FastLanesListRouteDeps): Promise<boolean>;
8
+ //# sourceMappingURL=fast-lanes-list-route.d.ts.map
@@ -0,0 +1,30 @@
1
+ import { isFastCachedLanesListRequest } from "./fast-scaffold.js";
2
+ import { fastFlagEnabled } from "./native-fast-route.js";
3
+ import { LANE_OUTPOST_SNAPSHOT_CACHE_FLAG } from "./lib/lanes/outpost-hire-cache.js";
4
+ import { LANE_OUTPOST_LOAD_BALANCING_FLAG } from "./lib/lanes/flag-keys.js";
5
+ /** Returns false unless this exact list form is safely enabled in the local flag snapshot. */
6
+ export async function runFastLanesListRequest(argv, env = process.env, deps = {
7
+ flagEnabled: fastFlagEnabled,
8
+ runFastList: async () => {
9
+ const { runFastCachedLanesList } = await import("./fast-lanes-list.js");
10
+ await runFastCachedLanesList();
11
+ },
12
+ runPlacementList: async () => {
13
+ const { runFastCachedLanesListFromEntrypoint } = await import("./commands/lanes.js");
14
+ await runFastCachedLanesListFromEntrypoint();
15
+ },
16
+ }) {
17
+ if (!isFastCachedLanesListRequest(argv))
18
+ return false;
19
+ if (env.HQ_INTERNAL_LANES_REFRESH_REMOTE_SNAPSHOT === "1")
20
+ return false;
21
+ if (!await deps.flagEnabled(LANE_OUTPOST_SNAPSHOT_CACHE_FLAG, env))
22
+ return false;
23
+ const lanePlacementEnabled = await deps.flagEnabled(LANE_OUTPOST_LOAD_BALANCING_FLAG, env);
24
+ if (lanePlacementEnabled)
25
+ await deps.runPlacementList();
26
+ else
27
+ await deps.runFastList();
28
+ return true;
29
+ }
30
+ //# sourceMappingURL=fast-lanes-list-route.js.map
@@ -0,0 +1,3 @@
1
+ /** Lightweight implementation for the exact cache-first `lanes list --json` entrypoint. */
2
+ export declare function runFastCachedLanesList(): Promise<void>;
3
+ //# sourceMappingURL=fast-lanes-list.d.ts.map
@@ -0,0 +1,252 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { compareLaneQueueOrder, laneRunsOnHost } from "./lib/lanes/admission.js";
6
+ import { readOutpostIdentity, resolveAdmissionHost } from "./lib/lanes/admission-host.js";
7
+ import { LaneSchemaError, isLanesError } from "./lib/lanes/errors.js";
8
+ import { OUTPOST_HIRE_SUMMARY_CACHE_TTL_MS, outpostHireSummaryCacheKey, readOutpostHireSummaryCacheIncludingStale, } from "./lib/lanes/outpost-hire-cache.js";
9
+ import { resolveLanesHqRoot } from "./lib/lanes/hq-root.js";
10
+ import { laneStories } from "./lib/lanes/stories.js";
11
+ import { readAllLanes } from "./lib/lanes/store.js";
12
+ import { isTerminalLaneState } from "./lib/lanes/types.js";
13
+ const LANES_RUNS_REL = path.join("workspace", "lanes-runs");
14
+ function withoutProviderHome(value) {
15
+ if (Array.isArray(value))
16
+ return value.map(withoutProviderHome);
17
+ if (value === null || typeof value !== "object")
18
+ return value;
19
+ const safe = {};
20
+ for (const [key, nested] of Object.entries(value)) {
21
+ if (key === "provider_home_at_spawn")
22
+ continue;
23
+ safe[key] = withoutProviderHome(nested);
24
+ }
25
+ return safe;
26
+ }
27
+ function printJson(value) {
28
+ console.log(JSON.stringify(withoutProviderHome(value), null, 2));
29
+ }
30
+ function errorMessage(err) {
31
+ return err instanceof Error ? err.message : String(err);
32
+ }
33
+ function failJson(err) {
34
+ if (isLanesError(err)) {
35
+ if (err.error === "hire_grant_exhausted") {
36
+ process.stderr.write(`hq lanes: ${err.message}\n`);
37
+ }
38
+ printJson({ ...err.toJSON(), message: err.message });
39
+ }
40
+ else {
41
+ const message = errorMessage(err);
42
+ process.stderr.write(`hq lanes: ${message}${err instanceof Error && err.stack ? `\n${err.stack}` : ""}\n`);
43
+ printJson({ error: "internal", message });
44
+ }
45
+ process.exitCode = 1;
46
+ }
47
+ function laneHireFields(lane, aliveChildrenByParent) {
48
+ const aliveChildCount = aliveChildrenByParent.get(lane.lane_id) ?? 0;
49
+ const hireGrant = lane.hire_grant ?? 0;
50
+ return {
51
+ hire_grant: hireGrant,
52
+ hire_alive_children: aliveChildCount,
53
+ hire: `${aliveChildCount}/${hireGrant}`,
54
+ hire_summary: `hire ${aliveChildCount}/${hireGrant}`,
55
+ };
56
+ }
57
+ function isOverdue(hqRoot, laneId, now) {
58
+ const filePath = path.join(hqRoot, LANES_RUNS_REL, laneId, "timeout.json");
59
+ let text;
60
+ try {
61
+ text = fs.readFileSync(filePath, "utf8");
62
+ }
63
+ catch (err) {
64
+ if (err.code === "ENOENT")
65
+ return false;
66
+ throw err;
67
+ }
68
+ let raw;
69
+ try {
70
+ raw = JSON.parse(text);
71
+ }
72
+ catch (err) {
73
+ throw new LaneSchemaError(`Invalid JSON in ${filePath}: ${errorMessage(err)}`, { path: filePath });
74
+ }
75
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
76
+ return false;
77
+ const deadline = raw.deadline_at;
78
+ if (typeof deadline !== "string")
79
+ return false;
80
+ const at = Date.parse(deadline);
81
+ if (!Number.isFinite(at))
82
+ return false;
83
+ return now.getTime() > at;
84
+ }
85
+ function startOutpostLaneSnapshotRefresh(root, env) {
86
+ let entry;
87
+ try {
88
+ const currentEntry = process.argv[1];
89
+ if (!currentEntry)
90
+ throw new Error("process.argv[1] is empty");
91
+ entry = fs.realpathSync(currentEntry);
92
+ }
93
+ catch (err) {
94
+ process.stderr.write(`lanes snapshot refresh could not resolve the running CLI entry: ${errorMessage(err)}\n`);
95
+ return;
96
+ }
97
+ try {
98
+ const child = spawn(process.execPath, [...process.execArgv, entry, "lanes", "list"], {
99
+ cwd: root,
100
+ detached: true,
101
+ stdio: ["ignore", "ignore", "inherit"],
102
+ env: { ...env, HQ_ROOT: root, HQ_NO_UPDATE_CHECK: "1", HQ_INTERNAL_LANES_REFRESH_REMOTE_SNAPSHOT: "1" },
103
+ });
104
+ child.once("error", (err) => {
105
+ process.stderr.write(`lanes snapshot refresh could not start: ${errorMessage(err)}\n`);
106
+ });
107
+ child.unref();
108
+ }
109
+ catch (err) {
110
+ process.stderr.write(`lanes snapshot refresh could not start: ${errorMessage(err)}\n`);
111
+ }
112
+ }
113
+ function isOwnerLane(lane, host) {
114
+ return lane.host.kind === "outpost" &&
115
+ !isTerminalLaneState(lane.state) &&
116
+ !laneRunsOnHost(lane, host);
117
+ }
118
+ async function buildLaneList(hqRoot, sourceLanes, host, now, env) {
119
+ const selectedLanes = sourceLanes.sort(compareLaneQueueOrder);
120
+ const aliveChildrenByParent = new Map();
121
+ for (const child of selectedLanes) {
122
+ if (child.senior.kind !== "lane" || !child.senior.id || isTerminalLaneState(child.state))
123
+ continue;
124
+ aliveChildrenByParent.set(child.senior.id, (aliveChildrenByParent.get(child.senior.id) ?? 0) + 1);
125
+ }
126
+ const ownerLanes = selectedLanes.filter((lane) => isOwnerLane(lane, host));
127
+ const hireByLane = new Map();
128
+ const remoteSnapshotMetadata = new Map();
129
+ let remoteLanesLoading = false;
130
+ for (const lane of selectedLanes) {
131
+ if (!isOwnerLane(lane, host)) {
132
+ hireByLane.set(lane.lane_id, {
133
+ ...laneHireFields(lane, aliveChildrenByParent),
134
+ hire_source: "local",
135
+ });
136
+ }
137
+ }
138
+ if (ownerLanes.length > 0) {
139
+ const nowMs = now.getTime();
140
+ const cached = readOutpostHireSummaryCacheIncludingStale(hqRoot, nowMs);
141
+ let refreshNeeded = false;
142
+ for (const lane of ownerLanes) {
143
+ if (lane.host.kind !== "outpost")
144
+ continue;
145
+ const summary = cached.get(outpostHireSummaryCacheKey(lane.host.id, lane.lane_id));
146
+ if (!summary) {
147
+ hireByLane.set(lane.lane_id, {
148
+ ...laneHireFields(lane, aliveChildrenByParent),
149
+ hire_source: "local",
150
+ });
151
+ remoteSnapshotMetadata.set(lane.lane_id, { ageSeconds: null, stale: true, loading: true });
152
+ refreshNeeded = true;
153
+ remoteLanesLoading = true;
154
+ continue;
155
+ }
156
+ const ageMs = Math.max(0, nowMs - Date.parse(summary.cached_at));
157
+ const stale = ageMs >= OUTPOST_HIRE_SUMMARY_CACHE_TTL_MS;
158
+ hireByLane.set(lane.lane_id, {
159
+ hire_grant: summary.hire_grant,
160
+ hire_alive_children: summary.hire_alive_children,
161
+ hire: summary.hire,
162
+ hire_summary: summary.hire_summary,
163
+ hire_source: "owner",
164
+ });
165
+ remoteSnapshotMetadata.set(lane.lane_id, {
166
+ ageSeconds: Math.floor(ageMs / 1_000),
167
+ stale,
168
+ loading: stale,
169
+ });
170
+ if (stale) {
171
+ refreshNeeded = true;
172
+ remoteLanesLoading = true;
173
+ }
174
+ }
175
+ if (refreshNeeded)
176
+ startOutpostLaneSnapshotRefresh(hqRoot, env);
177
+ }
178
+ const sessions = selectedLanes.filter((lane) => lane.state === "running" && lane.senior.kind === "session" && Boolean(lane.senior.id));
179
+ let sessionLiveness;
180
+ let livenessDeps;
181
+ if (sessions.length > 0) {
182
+ const liveness = await import("./lib/lanes/liveness.js");
183
+ sessionLiveness = liveness.sessionLiveness;
184
+ const base = liveness.defaultLivenessDeps();
185
+ livenessDeps = {
186
+ ...base,
187
+ now: () => now.getTime(),
188
+ homedir: () => os.homedir(),
189
+ env,
190
+ existsSync: (file) => fs.existsSync(file),
191
+ readFileSync: (file, encoding) => fs.readFileSync(file, encoding),
192
+ hostname: () => os.hostname(),
193
+ };
194
+ }
195
+ const livenessBySession = new Map();
196
+ const lanes = selectedLanes.map((lane) => {
197
+ const stories = laneStories(lane);
198
+ const remoteQueued = lane.state === "queued" && lane.host.kind === "outpost" && !laneRunsOnHost(lane, host);
199
+ let seniorLost = false;
200
+ if (lane.state === "running" && lane.senior.kind === "session" && lane.senior.id && sessionLiveness && livenessDeps) {
201
+ const sessionId = lane.senior.id;
202
+ const cached = livenessBySession.get(sessionId);
203
+ if (cached !== undefined)
204
+ seniorLost = cached;
205
+ else {
206
+ const state = sessionLiveness(hqRoot, sessionId, {
207
+ nowMs: now.getTime(),
208
+ deps: livenessDeps,
209
+ });
210
+ seniorLost = state === null || state === "ended" || state === "lost";
211
+ livenessBySession.set(sessionId, seniorLost);
212
+ }
213
+ }
214
+ return {
215
+ ...lane,
216
+ ...hireByLane.get(lane.lane_id),
217
+ stories,
218
+ story_ids: stories.map((story) => story.story_id),
219
+ no_story_reason: lane.no_story_reason ?? null,
220
+ ...(remoteQueued ? { state: "remote", remote_state: lane.state, queued_reason: undefined } : {}),
221
+ ...(remoteSnapshotMetadata.has(lane.lane_id)
222
+ ? {
223
+ remote_snapshot_age_s: remoteSnapshotMetadata.get(lane.lane_id).ageSeconds,
224
+ remote_snapshot_stale: remoteSnapshotMetadata.get(lane.lane_id).stale,
225
+ ...(remoteSnapshotMetadata.get(lane.lane_id).loading ? { remote_snapshot_loading: true } : {}),
226
+ }
227
+ : {}),
228
+ overdue: Boolean(lane.overdue) || isOverdue(hqRoot, lane.lane_id, now),
229
+ stalled: Boolean(lane.stalled),
230
+ senior_lost: seniorLost,
231
+ };
232
+ });
233
+ return { lanes, ...(remoteLanesLoading ? { remote_lanes_loading: true } : {}) };
234
+ }
235
+ /** Lightweight implementation for the exact cache-first `lanes list --json` entrypoint. */
236
+ export async function runFastCachedLanesList() {
237
+ try {
238
+ const env = process.env;
239
+ const hqRoot = resolveLanesHqRoot({ env, cwd: process.cwd() });
240
+ const now = new Date();
241
+ const host = resolveAdmissionHost({
242
+ hostname: () => os.hostname(),
243
+ readOutpostIdentity: () => readOutpostIdentity(),
244
+ });
245
+ const lanes = await buildLaneList(hqRoot, readAllLanes(hqRoot), host, now, env);
246
+ printJson({ ok: true, action: "list", ...lanes });
247
+ }
248
+ catch (err) {
249
+ failJson(err);
250
+ }
251
+ }
252
+ //# sourceMappingURL=fast-lanes-list.js.map
@@ -53,6 +53,11 @@ export type ParsedSecretsEnvFastRequest = {
53
53
  * the full CLI, which remains the source of truth for help and errors.
54
54
  */
55
55
  export declare function parseSecretsEnvFastRequest(argv: readonly string[]): ParsedSecretsEnvFastRequest | null;
56
+ /**
57
+ * The cache-first list path only handles the bare JSON request. `--fresh`,
58
+ * filters, help, and unknown options stay with Commander and the full CLI.
59
+ */
60
+ export declare function isFastCachedLanesListRequest(argv: readonly string[]): boolean;
56
61
  /**
57
62
  * Parse `hq core [--hq-root <path>] <name> [args...]` when `<name>` is a
58
63
  * fast-path script. Returns null for anything else so the caller defers to the
@@ -112,6 +112,16 @@ export function parseSecretsEnvFastRequest(argv) {
112
112
  }
113
113
  return hasOnly ? { options, scope } : null;
114
114
  }
115
+ /**
116
+ * The cache-first list path only handles the bare JSON request. `--fresh`,
117
+ * filters, help, and unknown options stay with Commander and the full CLI.
118
+ */
119
+ export function isFastCachedLanesListRequest(argv) {
120
+ return argv.length === 5 &&
121
+ argv[2] === "lanes" &&
122
+ argv[3] === "list" &&
123
+ argv[4] === "--json";
124
+ }
115
125
  /**
116
126
  * Parse `hq core [--hq-root <path>] <name> [args...]` when `<name>` is a
117
127
  * fast-path script. Returns null for anything else so the caller defers to the
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import "./node-network-compat.js";
6
6
  import { isVersionRequest, wantsLabelledVersionReport } from "./version-request.js";
7
7
  // Dependency-light: a pure parser + table, no command graph. Safe to load on
8
8
  // every path (including --version) without reintroducing the heavy startup.
9
- import { isFastCoreRequest, parseFastCore, parseSecretsEnvFastRequest, } from "./fast-scaffold.js";
9
+ import { isFastCachedLanesListRequest, isFastCoreRequest, parseFastCore, parseSecretsEnvFastRequest, } from "./fast-scaffold.js";
10
10
  // Also dependency-light: registers a process-level rejection boundary; its only
11
11
  // heavy import (main.js) is lazy and reached only when a rejection fires.
12
12
  import { installProcessRejectionBoundary } from "./unhandled-rejection-boundary.js";
@@ -61,6 +61,15 @@ else if (parseSecretsEnvFastRequest(process.argv)) {
61
61
  });
62
62
  });
63
63
  }
64
+ else if (isFastCachedLanesListRequest(process.argv)) {
65
+ void import("./fast-lanes-list-route.js").then(async ({ runFastLanesListRequest }) => {
66
+ const handled = await runFastLanesListRequest(process.argv);
67
+ if (!handled) {
68
+ const { runCli } = await import("./main.js");
69
+ await runCli();
70
+ }
71
+ });
72
+ }
64
73
  else if (isFastCoreRequest(process.argv)) {
65
74
  // Hot relocated plumbing (`hq core hq-session`, `hq core checkpoint-stop-gate`)
66
75
  // dispatches straight to the bundled-script runner, so it never pays for the
@@ -27,11 +27,23 @@
27
27
  *
28
28
  * A signal whose `entity_refs` names a copy holds that copy: signals are
29
29
  * server-owned, so the ref cannot be re-pointed from here.
30
+ *
31
+ * With `pairKind: "variants"` (flag `checkpoint.ontology.merge-variants`) the
32
+ * pairs are two-file duplicate groups whose canonical names differ only in
33
+ * case, spacing or punctuation; the survivor is the spaced name, then the
34
+ * higher `signal_count`, then path order. The copy's canonical name becomes
35
+ * an alias of the survivor. Merge and delete work as for numbered copies.
30
36
  */
31
37
  import { type OntologyCompany } from "./company.js";
32
38
  import { type EntityIndex } from "./tree.js";
33
39
  export declare const MERGE_COPIES_FLAG = "checkpoint.ontology.merge-copies";
34
- export type CopySkipReason = "no_original" | "original_invalid" | "identity_differs" | "not_in_duplicate_group";
40
+ /**
41
+ * `merge-copies --variants`: merge two-file duplicate groups whose canonical
42
+ * names differ only in spacing, case or punctuation (CO-65).
43
+ */
44
+ export declare const MERGE_VARIANTS_FLAG = "checkpoint.ontology.merge-variants";
45
+ export type CopySkipReason = "no_original" | "original_invalid" | "identity_differs" | "not_in_duplicate_group" | "group_size" | "type_differs" | "names_differ" | "numbered_copy";
46
+ export type PairKind = "numbered" | "variants";
35
47
  export interface CopySkip {
36
48
  copy: string;
37
49
  reason: CopySkipReason;
@@ -66,6 +78,7 @@ export interface CopyPlan extends CopyPair {
66
78
  export interface MergeCopiesReport {
67
79
  company: string;
68
80
  mode: "dry_run" | "merge" | "delete";
81
+ pair_kind: PairKind;
69
82
  duplicate_groups_before: number;
70
83
  duplicate_groups_after: number;
71
84
  copies_found: number;
@@ -97,6 +110,8 @@ export interface MergeCopiesOptions {
97
110
  hqRoot: string;
98
111
  company: OntologyCompany;
99
112
  mode: "dry_run" | "merge" | "delete";
113
+ /** Which pairs to merge; default numbered copies. */
114
+ pairKind?: PairKind;
100
115
  /** Handle at most this many copies (sorted by path). */
101
116
  limit?: number;
102
117
  now: Date;
@@ -107,6 +122,14 @@ export declare function findCopyPairs(index: EntityIndex): {
107
122
  pairs: CopyPair[];
108
123
  skipped: CopySkip[];
109
124
  };
125
+ /**
126
+ * Two-file duplicate groups whose canonical names differ only in case, spacing
127
+ * or punctuation. Numbered copies are left to `findCopyPairs`. Read-only.
128
+ */
129
+ export declare function findVariantPairs(index: EntityIndex): {
130
+ pairs: CopyPair[];
131
+ skipped: CopySkip[];
132
+ };
110
133
  /**
111
134
  * Plan, merge or delete numbered copies for one company. The dry run writes
112
135
  * nothing; merge writes entity files only; delete removes vault keys and
@@ -27,6 +27,12 @@
27
27
  *
28
28
  * A signal whose `entity_refs` names a copy holds that copy: signals are
29
29
  * server-owned, so the ref cannot be re-pointed from here.
30
+ *
31
+ * With `pairKind: "variants"` (flag `checkpoint.ontology.merge-variants`) the
32
+ * pairs are two-file duplicate groups whose canonical names differ only in
33
+ * case, spacing or punctuation; the survivor is the spaced name, then the
34
+ * higher `signal_count`, then path order. The copy's canonical name becomes
35
+ * an alias of the survivor. Merge and delete work as for numbered copies.
30
36
  */
31
37
  import * as fs from "node:fs";
32
38
  import * as path from "node:path";
@@ -38,6 +44,11 @@ import { isWritableEntityType, normalizeEntityName } from "./identity.js";
38
44
  import { findDuplicateGroups, readBackRefs, recordOutbox } from "./multi-writer.js";
39
45
  import { buildEntityIndex, listSignalFiles, readSignal } from "./tree.js";
40
46
  export const MERGE_COPIES_FLAG = "checkpoint.ontology.merge-copies";
47
+ /**
48
+ * `merge-copies --variants`: merge two-file duplicate groups whose canonical
49
+ * names differ only in spacing, case or punctuation (CO-65).
50
+ */
51
+ export const MERGE_VARIANTS_FLAG = "checkpoint.ontology.merge-variants";
41
52
  /** `<slug> <n>.md`, the name a file manager gives a second copy. */
42
53
  const COPY_NAME = /^(.+) (\d+)\.md$/;
43
54
  /** A relationship list item with a link, as the gardener and checkpoint write them. */
@@ -133,6 +144,62 @@ export function findCopyPairs(index) {
133
144
  }
134
145
  return { pairs, skipped };
135
146
  }
147
+ /** The canonical name with case, spacing and punctuation removed. */
148
+ function compactName(name, type) {
149
+ return normalizeEntityName(name, type).replace(/[^\p{L}\p{N}]/gu, "");
150
+ }
151
+ /**
152
+ * Two-file duplicate groups whose canonical names differ only in case, spacing
153
+ * or punctuation. Numbered copies are left to `findCopyPairs`. Read-only.
154
+ */
155
+ export function findVariantPairs(index) {
156
+ const pairs = [];
157
+ const skipped = [];
158
+ for (const group of findDuplicateGroups(index)) {
159
+ const paths = [...group.paths].sort((a, b) => a.localeCompare(b));
160
+ const numbered = paths.filter((rel) => COPY_NAME.test(path.posix.basename(rel)));
161
+ if (numbered.length > 0) {
162
+ for (const rel of numbered)
163
+ skipped.push({ copy: rel, reason: "numbered_copy" });
164
+ continue;
165
+ }
166
+ if (paths.length !== 2) {
167
+ skipped.push({ copy: paths[0], reason: "group_size" });
168
+ continue;
169
+ }
170
+ const left = index.byRel.get(paths[0]);
171
+ const right = index.byRel.get(paths[1]);
172
+ if (!left || !right) {
173
+ skipped.push({ copy: paths[0], reason: "no_original" });
174
+ continue;
175
+ }
176
+ if (left.type !== right.type) {
177
+ skipped.push({ copy: paths[0], reason: "type_differs" });
178
+ continue;
179
+ }
180
+ const leftName = compactName(left.canonical_name, left.type);
181
+ const rightName = compactName(right.canonical_name, right.type);
182
+ if (leftName === "" || rightName === "" || leftName !== rightName) {
183
+ skipped.push({ copy: paths[0], reason: "names_differ" });
184
+ continue;
185
+ }
186
+ const leftSpaced = left.canonical_name.includes(" ");
187
+ const rightSpaced = right.canonical_name.includes(" ");
188
+ let originalRel = paths[0];
189
+ let copyRel = paths[1];
190
+ if (leftSpaced !== rightSpaced) {
191
+ originalRel = leftSpaced ? paths[0] : paths[1];
192
+ copyRel = leftSpaced ? paths[1] : paths[0];
193
+ }
194
+ else if (left.signal_count !== right.signal_count) {
195
+ originalRel = left.signal_count > right.signal_count ? paths[0] : paths[1];
196
+ copyRel = originalRel === paths[0] ? paths[1] : paths[0];
197
+ }
198
+ pairs.push({ type: left.type, copy: copyRel, original: originalRel });
199
+ }
200
+ pairs.sort((a, b) => a.copy.localeCompare(b.copy));
201
+ return { pairs, skipped };
202
+ }
136
203
  /** Resolve a relative link in `fromRel` to a company-relative path, or null. */
137
204
  function resolveLink(fromRel, href) {
138
205
  if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#") || href.startsWith("/"))
@@ -431,13 +498,15 @@ function writeEntity(hqRoot, company, rel, content, now) {
431
498
  export async function mergeCopies(options) {
432
499
  const { hqRoot, company, mode, now } = options;
433
500
  const index = buildEntityIndex(company.dir);
434
- const found = findCopyPairs(index);
501
+ const pairKind = options.pairKind ?? "numbered";
502
+ const found = pairKind === "variants" ? findVariantPairs(index) : findCopyPairs(index);
435
503
  const pairs = options.limit !== undefined ? found.pairs.slice(0, Math.max(0, options.limit)) : found.pairs;
436
504
  const before = scanLinks(company).broken;
437
505
  const { plans, merges, finals, notReady } = plan(company, index, pairs, found.pairs, now);
438
506
  const report = {
439
507
  company: company.slug,
440
508
  mode,
509
+ pair_kind: pairKind,
441
510
  duplicate_groups_before: findDuplicateGroups(index).length,
442
511
  duplicate_groups_after: 0,
443
512
  copies_found: found.pairs.length,
@@ -0,0 +1,3 @@
1
+ /** hq-flags keys used by lane list and placement behavior. */
2
+ export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
3
+ //# sourceMappingURL=flag-keys.d.ts.map
@@ -0,0 +1,3 @@
1
+ /** hq-flags keys used by lane list and placement behavior. */
2
+ export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
3
+ //# sourceMappingURL=flag-keys.js.map
@@ -20,7 +20,7 @@ export { LANE_STORY_LEDGER_FLAG, buildLaneStatusSnapshot, formatLaneStatusHuman,
20
20
  export type { LaneStatusSnapshot, ProjectStoryLedger, StoryLedgerWriteDeps } from "./story-ledger.js";
21
21
  export { buildOutpostPickupCommand, parseOutpostPickupPayload, } from "./outpost-dispatch.js";
22
22
  export type { OutpostDispatchResult, OutpostPickupPayload, } from "./outpost-dispatch.js";
23
- export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, syncLaneRecordFromOwner, updateLane, updateQuestion, withLaneHireLocks, } from "./store.js";
23
+ export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readAllLanes, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, syncLaneRecordFromOwner, updateLane, updateQuestion, withLaneHireLocks, } from "./store.js";
24
24
  export type { StoreDeps } from "./store.js";
25
25
  export { DEFAULT_LANE_WATCH_INTERVAL_MS, MAX_LANE_WATCH_INTERVAL_MS, MAX_WATCHED_LANES, MAX_WATCH_OUTPUT_LINES, runLaneWatch, } from "./watch.js";
26
26
  export type { LaneWatchDeps, LaneWatchResult } from "./watch.js";
@@ -12,7 +12,7 @@ export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFT
12
12
  export { resolveLanesHqRoot } from "./hq-root.js";
13
13
  export { LANE_STORY_LEDGER_FLAG, buildLaneStatusSnapshot, formatLaneStatusHuman, laneStoryLedgerEnabled, resolveProjectStoryLedger, syncLaneToProjectStoryLedger, validateProjectStories, } from "./story-ledger.js";
14
14
  export { buildOutpostPickupCommand, parseOutpostPickupPayload, } from "./outpost-dispatch.js";
15
- export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, syncLaneRecordFromOwner, updateLane, updateQuestion, withLaneHireLocks, } from "./store.js";
15
+ export { LANES_STORE_REL, LANE_RECORDS_DIR, PENDING_INDEX_DIR, QUESTION_RECORDS_DIR, appendCappedHistory, appendLaneAttribution, appendLaneSeniorHistory, appendQuestionRouting, createLane, createQuestion, isConflictSidecarName, laneRecordPath, lanesStoreRoot, listLaneIds, listQuestionIds, mergeLaneRecords, mergeQuestionRecords, mintLaneId, mintQuestionId, parseLaneRecord, parseQuestionRecord, pendingMarkerPath, questionRecordPath, readAllLanes, readLane, readQuestion, resolveStoreHqRoot, sanitizeHostComponent, tryReadLane, tryReadQuestion, retryUpdateLane, retryUpdateQuestion, syncLaneRecordFromOwner, updateLane, updateQuestion, withLaneHireLocks, } from "./store.js";
16
16
  export { DEFAULT_LANE_WATCH_INTERVAL_MS, MAX_LANE_WATCH_INTERVAL_MS, MAX_WATCHED_LANES, MAX_WATCH_OUTPUT_LINES, runLaneWatch, } from "./watch.js";
17
17
  export { MONITOR_ERROR_EXIT_CODE, MONITOR_ENGINES, MONITOR_MAX_TIMEOUT_MS, MONITOR_TASK_OUTPUT_MAX_BYTES, MONITOR_TRANSCRIPT_MAX_BYTES, MONITOR_UNCOVERED_EXIT_CODE, coveredLaneIds, isMonitorTaskLive, monitorReminderText, parseCanonicalWatchCommand, parseMonitorTranscript, readLiveMonitorEvidence, } from "./monitor.js";
18
18
  export { IMDS_HOST, IMDS_INSTANCE_ID_URL, IMDS_TIMEOUT_MS, IMDS_TOKEN_URL, defaultReadImdsInstanceId, resolveMachineIdentity, } from "./machine-identity.js";
@@ -81,12 +81,20 @@ export declare function parseOutpostPickupPayload(raw: unknown): OutpostPickupPa
81
81
  export declare function buildOutpostPickupCommand(payloadUrl: string): string;
82
82
  /** Bounded, read-only capacity query executed by the target Outpost CLI. */
83
83
  export declare function buildOutpostCapacityCommand(): string;
84
+ /**
85
+ * Marks a lane list that runs on an Outpost for another host. Such a list
86
+ * answers from the box's own records and never dispatches to other Outposts:
87
+ * two boxes that each hold a lane placed on the other would otherwise run
88
+ * lists on each other without end. `env` keeps older CLIs working, since they
89
+ * ignore the variable instead of rejecting an unknown flag.
90
+ */
91
+ export declare const REMOTE_LANE_READ_ENV = "HQ_LANES_REMOTE_READ";
84
92
  /** Read lane records owned by an Outpost, using its configured HQ binary when set. */
85
93
  export declare function buildOutpostLaneListCommand(input?: {
86
94
  hqBinary?: string;
87
95
  laneIds?: readonly string[];
88
96
  active?: boolean;
89
- hierarchy?: string;
97
+ hierarchy?: string | readonly string[];
90
98
  snapshot?: boolean;
91
99
  }): string;
92
100
  /** Read questions on one Outpost, scoped to a company when supplied. */
@@ -65,9 +65,19 @@ export function buildOutpostPickupCommand(payloadUrl) {
65
65
  export function buildOutpostCapacityCommand() {
66
66
  return withRemoteHqDir(joinCommandParts(["hq", "lanes", "capacity", "--json"]));
67
67
  }
68
+ /**
69
+ * Marks a lane list that runs on an Outpost for another host. Such a list
70
+ * answers from the box's own records and never dispatches to other Outposts:
71
+ * two boxes that each hold a lane placed on the other would otherwise run
72
+ * lists on each other without end. `env` keeps older CLIs working, since they
73
+ * ignore the variable instead of rejecting an unknown flag.
74
+ */
75
+ export const REMOTE_LANE_READ_ENV = "HQ_LANES_REMOTE_READ";
68
76
  /** Read lane records owned by an Outpost, using its configured HQ binary when set. */
69
77
  export function buildOutpostLaneListCommand(input = {}) {
70
78
  const args = [
79
+ "env",
80
+ `${REMOTE_LANE_READ_ENV}=1`,
71
81
  input.hqBinary?.trim() || "hq",
72
82
  "lanes",
73
83
  "list",
@@ -77,8 +87,10 @@ export function buildOutpostLaneListCommand(input = {}) {
77
87
  args.push("--lane", laneId);
78
88
  if (input.active)
79
89
  args.push("--active");
80
- if (input.hierarchy !== undefined)
81
- args.push("--hierarchy", input.hierarchy);
90
+ for (const laneId of typeof input.hierarchy === "string"
91
+ ? [input.hierarchy]
92
+ : input.hierarchy ?? [])
93
+ args.push("--hierarchy", laneId);
82
94
  if (input.snapshot)
83
95
  args.push("--snapshot");
84
96
  return withRemoteHqDir(joinCommandParts(args));
@@ -1,4 +1,5 @@
1
1
  export declare const OUTPOST_HIRE_SUMMARY_CACHE_TTL_MS = 60000;
2
+ export declare const LANE_OUTPOST_SNAPSHOT_CACHE_FLAG = "lanes.outpost-snapshot-cache";
2
3
  export interface OutpostHireSummary {
3
4
  hire_grant: number;
4
5
  hire_alive_children: number;
@@ -13,6 +14,8 @@ export interface OutpostHireSummaryCacheEntry extends OutpostHireSummary {
13
14
  export declare function outpostHireSummaryCachePath(hqRoot: string): string;
14
15
  export declare function outpostHireSummaryCacheKey(outpostId: string, laneId: string): string;
15
16
  export declare function readOutpostHireSummaryCache(hqRoot: string, nowMs: number): Map<string, OutpostHireSummaryCacheEntry>;
17
+ /** Reads the last validated summaries even after their freshness window expires. */
18
+ export declare function readOutpostHireSummaryCacheIncludingStale(hqRoot: string, nowMs: number): Map<string, OutpostHireSummaryCacheEntry>;
16
19
  export declare function writeOutpostHireSummaryCache(hqRoot: string, updates: readonly (OutpostHireSummary & {
17
20
  outpost_id: string;
18
21
  lane_id: string;