@indigoai-us/hq-cli 5.275.0 → 5.276.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 +36 -0
  2. package/README.md +12 -0
  3. package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
  4. package/dist/commands/core-checkpoint.d.ts +4 -2
  5. package/dist/commands/core-checkpoint.js +34 -3
  6. package/dist/commands/core-command-catalog.d.ts +4 -2
  7. package/dist/commands/core-command-catalog.js +5 -2
  8. package/dist/commands/core-ontology.js +3 -3
  9. package/dist/commands/core.js +1 -1
  10. package/dist/commands/daemon.d.ts +1 -0
  11. package/dist/commands/daemon.js +2 -1
  12. package/dist/commands/feedback.d.ts +6 -0
  13. package/dist/commands/feedback.js +86 -37
  14. package/dist/commands/lanes.js +3 -2
  15. package/dist/commands/run-feature-telemetry.d.ts +3 -0
  16. package/dist/commands/run-feature-telemetry.js +6 -0
  17. package/dist/commands/run.js +3 -2
  18. package/dist/commands/secrets.js +7 -1
  19. package/dist/commands/whoami.d.ts +4 -6
  20. package/dist/commands/whoami.js +39 -8
  21. package/dist/fast-core.d.ts +6 -7
  22. package/dist/fast-core.js +22 -8
  23. package/dist/fast-scaffold.d.ts +16 -7
  24. package/dist/fast-scaffold.js +13 -8
  25. package/dist/index.js +16 -1
  26. package/dist/lib/billing/plan-lock.d.ts +3 -1
  27. package/dist/lib/billing/plan-lock.js +7 -1
  28. package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
  29. package/dist/lib/checkpoint-ontology/company.js +15 -7
  30. package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
  31. package/dist/lib/checkpoint-ontology/plan.js +53 -4
  32. package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
  33. package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
  34. package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
  35. package/dist/lib/core-utils/timeout-guard-native.js +40 -0
  36. package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
  37. package/dist/lib/core-utils/timeout-guard.js +28 -13
  38. package/dist/lib/flag-registry.d.ts +12 -7
  39. package/dist/lib/flag-registry.js +66 -75
  40. package/dist/lib/flag-snapshot-file.d.ts +20 -0
  41. package/dist/lib/flag-snapshot-file.js +91 -0
  42. package/dist/lib/lanes/reconcile.js +20 -4
  43. package/dist/lib/process-flag-company-context.d.ts +8 -0
  44. package/dist/lib/process-flag-company-context.js +67 -0
  45. package/dist/main.js +3 -1
  46. package/dist/native-fast-route.d.ts +3 -0
  47. package/dist/native-fast-route.js +39 -0
  48. package/dist/utils/cli-telemetry.d.ts +26 -2
  49. package/dist/utils/cli-telemetry.js +178 -3
  50. package/dist/utils/hq-root-resolver.d.ts +13 -0
  51. package/dist/utils/hq-root-resolver.js +103 -0
  52. package/dist/utils/scaffold-fast-import-guard.js +6 -1
  53. package/dist/utils/vault-api.d.ts +8 -0
  54. package/dist/utils/vault-api.js +13 -1
  55. package/package.json +1 -1
@@ -6,6 +6,9 @@ import { loadCachedTokens, isExpiring, loadMachineCreds, } from '@indigoai-us/hq
6
6
  import { peekIdToken as decodeIdToken } from "../utils/id-token.js";
7
7
  import { ensureCognitoToken, loadMachineCachedTokens, resolveCognitoTokenSource, } from "../utils/cognito-session.js";
8
8
  import { colorizePlanLimitNotice, fetchPlanLimitStatus, renderPlanLine, renderPlanLimitNotice, } from "../lib/billing/plan-lock.js";
9
+ import { resolveCompanyFlagGate, } from "../lib/flag-registry.js";
10
+ import { queuePlanLimitInviteTeammateExposure } from "../utils/cli-telemetry.js";
11
+ export const CLI_LIMIT_INVITE_TEAMMATE_FLAG = "cli.limit-invite-teammate-v1";
9
12
  /**
10
13
  * Resolves the person represented by an ID token without pairing a delegated
11
14
  * email with the token subject of the delegating machine.
@@ -62,7 +65,13 @@ async function readPlanLimits(company) {
62
65
  // default `interactive: true`, a box with no cached session would launch a
63
66
  // browser sign-in and block the caller instead of resolving to UNKNOWN.
64
67
  const token = await ensureCognitoToken({ interactive: false });
65
- return await fetchPlanLimitStatus(token, slug);
68
+ const status = await fetchPlanLimitStatus(token, slug);
69
+ if (!status)
70
+ return null;
71
+ return {
72
+ status,
73
+ idToken: loadCachedTokens()?.idToken,
74
+ };
66
75
  }
67
76
  catch {
68
77
  return null;
@@ -73,19 +82,41 @@ async function readPlanLimits(company) {
73
82
  * workspace is over its Starter limits, the one-line `Plan: Starter — …`
74
83
  * orientation when it is Starter and healthy, and nothing at all otherwise.
75
84
  */
76
- export function renderWhoamiPlanBlock(status) {
85
+ function shouldOfferInviteTeammateSuggestion(status) {
86
+ if (!status.over ||
87
+ status.plan !== "free" ||
88
+ status.members?.used !== 1 ||
89
+ !status.companyUid) {
90
+ return false;
91
+ }
92
+ return true;
93
+ }
94
+ async function inviteTeammateSuggestionEnabled(status, idToken) {
95
+ if (!shouldOfferInviteTeammateSuggestion(status) || !status.companyUid) {
96
+ return false;
97
+ }
98
+ return resolveCompanyFlagGate(status.companyUid, idToken ?? "", CLI_LIMIT_INVITE_TEAMMATE_FLAG, { fallback: false }, () => false);
99
+ }
100
+ export function renderWhoamiPlanBlock(status, opts = {}) {
77
101
  if (status.over) {
78
- return colorizePlanLimitNotice(renderPlanLimitNotice(status));
102
+ const inviteTeammateEnabled = opts.inviteTeammateEnabled === true;
103
+ return colorizePlanLimitNotice(renderPlanLimitNotice(status, { inviteTeammateEnabled }));
79
104
  }
80
105
  return renderPlanLine(status);
81
106
  }
82
107
  async function printPlanBlock(company) {
83
- const status = await readPlanLimits(company);
84
- if (!status)
108
+ const planLimits = await readPlanLimits(company);
109
+ if (!planLimits)
110
+ return;
111
+ const { status, idToken } = planLimits;
112
+ const inviteTeammateEnabled = await inviteTeammateSuggestionEnabled(status, idToken);
113
+ const block = renderWhoamiPlanBlock(status, { inviteTeammateEnabled });
114
+ if (!block)
85
115
  return;
86
- const block = renderWhoamiPlanBlock(status);
87
- if (block)
88
- console.log(block);
116
+ console.log(block);
117
+ if (inviteTeammateEnabled && status.companyUid) {
118
+ queuePlanLimitInviteTeammateExposure(status.companyUid);
119
+ }
89
120
  }
90
121
  export function registerWhoamiCommand(program) {
91
122
  program
@@ -1,16 +1,15 @@
1
1
  /**
2
- * Fast-path dispatcher for relocated scaffold scripts.
2
+ * Fast-path dispatcher for selected core commands.
3
3
  *
4
4
  * Reached from the entrypoint BEFORE `main.ts` (and its heavy command graph) is
5
- * ever imported, so a hot `hq core hq-session` / `hq core checkpoint-stop-gate`
6
- * dispatches in roughly node's own startup time. It imports only the light root
7
- * resolver and the bundled-script runner. The slower commander registration in
8
- * `core.ts` remains the complete path for any invocation this does not match.
5
+ * ever imported, so hot hook commands avoid the full commander graph. Shell
6
+ * entries use the bundled-script runner; native entries import their one handler
7
+ * module. The registration in `core.ts` remains the complete fallback path.
9
8
  */
10
9
  /**
11
- * Run the fast-path scaffold script named in `argv` and exit with its code.
10
+ * Run the fast-path core command named in `argv` and exit with its code.
12
11
  * Only call when {@link isFastCoreRequest} is true; a non-match returns without
13
12
  * doing anything.
14
13
  */
15
- export declare function runFastCore(argv: readonly string[]): void;
14
+ export declare function runFastCore(argv: readonly string[]): Promise<void>;
16
15
  //# sourceMappingURL=fast-core.d.ts.map
package/dist/fast-core.js CHANGED
@@ -1,21 +1,20 @@
1
1
  /**
2
- * Fast-path dispatcher for relocated scaffold scripts.
2
+ * Fast-path dispatcher for selected core commands.
3
3
  *
4
4
  * Reached from the entrypoint BEFORE `main.ts` (and its heavy command graph) is
5
- * ever imported, so a hot `hq core hq-session` / `hq core checkpoint-stop-gate`
6
- * dispatches in roughly node's own startup time. It imports only the light root
7
- * resolver and the bundled-script runner. The slower commander registration in
8
- * `core.ts` remains the complete path for any invocation this does not match.
5
+ * ever imported, so hot hook commands avoid the full commander graph. Shell
6
+ * entries use the bundled-script runner; native entries import their one handler
7
+ * module. The registration in `core.ts` remains the complete fallback path.
9
8
  */
10
9
  import { resolveLiveRoot } from "./utils/hq-roots.js";
11
10
  import { runBundledScript } from "./utils/run-bundled-script.js";
12
11
  import { parseFastCore } from "./fast-scaffold.js";
13
12
  /**
14
- * Run the fast-path scaffold script named in `argv` and exit with its code.
13
+ * Run the fast-path core command named in `argv` and exit with its code.
15
14
  * Only call when {@link isFastCoreRequest} is true; a non-match returns without
16
15
  * doing anything.
17
16
  */
18
- export function runFastCore(argv) {
17
+ export async function runFastCore(argv) {
19
18
  const parsed = parseFastCore(argv);
20
19
  if (!parsed)
21
20
  return;
@@ -31,12 +30,27 @@ export function runFastCore(argv) {
31
30
  catch {
32
31
  hqRoot = undefined;
33
32
  }
33
+ if (parsed.entry.kind === "native") {
34
+ const handlerModule = await import(parsed.entry.modulePath);
35
+ const handler = handlerModule[parsed.entry.exportName];
36
+ if (typeof handler !== "function") {
37
+ throw new Error(`Native fast handler ${parsed.entry.exportName} was not exported`);
38
+ }
39
+ const chunks = [];
40
+ for await (const chunk of process.stdin)
41
+ chunks.push(chunk);
42
+ const code = await handler(argv, Buffer.concat(chunks).toString("utf8"), process.cwd(), hqRoot);
43
+ // Let Node drain handler output before terminating. The timeout guard's
44
+ // stderr message must survive the native route's process boundary.
45
+ process.exitCode = code;
46
+ return;
47
+ }
34
48
  // Keep the CHILD in the caller's cwd, unlike the generic "live" dispatch. The
35
49
  // relocated scripts read their data tree from the injected root, not cwd, and
36
50
  // hq-session's Work Mesh registration serializes $PWD — so changing directory
37
51
  // to the HQ root would mislabel a bind made from inside a project/worktree.
38
52
  const { code } = runBundledScript({
39
- asset: parsed.asset,
53
+ asset: parsed.entry.asset,
40
54
  args: parsed.args,
41
55
  cwd: process.cwd(),
42
56
  hqRoot,
@@ -1,27 +1,36 @@
1
1
  /**
2
- * Fast-path manifest for relocated scaffold scripts, kept deliberately
2
+ * Fast-path manifest for selected core commands, kept deliberately
3
3
  * dependency-light.
4
4
  *
5
5
  * `main.ts` eagerly imports the CLI's entire (~60-module) command graph, so any
6
6
  * command routed through it pays seconds of startup — fine for a human typing a
7
7
  * command, ruinous for the hot plumbing this hosts: `hq core hq-session` is
8
8
  * called several times per skill/hook, and `hq core checkpoint-stop-gate` runs
9
- * on every turn's Stop. The entrypoint routes those directly to
10
- * `runBundledScript` instead, and this module holds the tiny parser + table that
11
- * routing needs WITHOUT importing anything heavy. `core.ts` names the same
12
- * assets, and a test pins the two lists together so they cannot drift.
9
+ * on every turn's Stop. The entrypoint routes eligible commands before loading
10
+ * the graph, and this module holds the parser + table that routing needs.
13
11
  */
14
12
  export type FastScaffoldScript = {
15
13
  /** Direct subcommand name — `hq core <name>`. */
16
14
  name: string;
15
+ kind: "shell";
17
16
  /** Path under `assets/scaffold/`, e.g. `core/scripts/foo.sh`. */
18
17
  asset: string;
18
+ } | {
19
+ /** Direct subcommand name — `hq core <name>`. */
20
+ name: string;
21
+ kind: "native";
22
+ /** Relative ESM module specifier, resolved from fast-core.js. */
23
+ modulePath: string;
24
+ /** Named export which accepts the native fast-handler contract. */
25
+ exportName: string;
26
+ /** Default-off registry flag for this native route. */
27
+ flagKey: string;
19
28
  };
20
29
  /** The relocated scripts that qualify for the entrypoint fast path. */
21
30
  export declare const FAST_SCAFFOLD_SCRIPTS: readonly FastScaffoldScript[];
22
31
  export type ParsedFastCore = {
23
- /** The bundled asset to run. */
24
- asset: string;
32
+ /** The manifest entry to run. */
33
+ entry: FastScaffoldScript;
25
34
  /** `--hq-root <path>` value, if the caller passed the group option. */
26
35
  hqRootArg?: string;
27
36
  /** Everything after the command name, passed to the script verbatim. */
@@ -1,20 +1,25 @@
1
1
  /**
2
- * Fast-path manifest for relocated scaffold scripts, kept deliberately
2
+ * Fast-path manifest for selected core commands, kept deliberately
3
3
  * dependency-light.
4
4
  *
5
5
  * `main.ts` eagerly imports the CLI's entire (~60-module) command graph, so any
6
6
  * command routed through it pays seconds of startup — fine for a human typing a
7
7
  * command, ruinous for the hot plumbing this hosts: `hq core hq-session` is
8
8
  * called several times per skill/hook, and `hq core checkpoint-stop-gate` runs
9
- * on every turn's Stop. The entrypoint routes those directly to
10
- * `runBundledScript` instead, and this module holds the tiny parser + table that
11
- * routing needs WITHOUT importing anything heavy. `core.ts` names the same
12
- * assets, and a test pins the two lists together so they cannot drift.
9
+ * on every turn's Stop. The entrypoint routes eligible commands before loading
10
+ * the graph, and this module holds the parser + table that routing needs.
13
11
  */
14
12
  /** The relocated scripts that qualify for the entrypoint fast path. */
15
13
  export const FAST_SCAFFOLD_SCRIPTS = [
16
- { name: "checkpoint-stop-gate", asset: "core/scripts/checkpoint-stop-gate.sh" },
17
- { name: "hq-session", asset: "core/scripts/hq-session.sh" },
14
+ { name: "checkpoint-stop-gate", kind: "shell", asset: "core/scripts/checkpoint-stop-gate.sh" },
15
+ { name: "hq-session", kind: "shell", asset: "core/scripts/hq-session.sh" },
16
+ {
17
+ name: "timeout-guard",
18
+ kind: "native",
19
+ modulePath: "./lib/core-utils/timeout-guard-native.js",
20
+ exportName: "runTimeoutGuardFast",
21
+ flagKey: "hq-core-native-fast-route",
22
+ },
18
23
  ];
19
24
  /**
20
25
  * Parse `hq core [--hq-root <path>] <name> [args...]` when `<name>` is a
@@ -48,7 +53,7 @@ export function parseFastCore(argv) {
48
53
  const entry = FAST_SCAFFOLD_SCRIPTS.find((candidate) => candidate.name === name);
49
54
  if (!entry)
50
55
  return null;
51
- return { asset: entry.asset, hqRootArg, args: argv.slice(i + 1) };
56
+ return { entry, hqRootArg, args: argv.slice(i + 1) };
52
57
  }
53
58
  /** Whether this invocation is a fast-path scaffold dispatch. */
54
59
  export function isFastCoreRequest(argv) {
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 } from "./fast-scaffold.js";
9
+ import { isFastCoreRequest, parseFastCore } 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";
@@ -30,6 +30,21 @@ if (isVersionRequest(process.argv)) {
30
30
  });
31
31
  }
32
32
  }
33
+ else if (parseFastCore(process.argv)?.entry.kind === "native") {
34
+ // Native routes are default-off. Resolve the company-scoped persisted flag
35
+ // without loading the command graph; a miss or stale snapshot takes the
36
+ // existing full-graph route.
37
+ void import("./native-fast-route.js").then(async ({ nativeFastRouteEnabled }) => {
38
+ if (await nativeFastRouteEnabled(process.argv)) {
39
+ const { runFastCore } = await import("./fast-core.js");
40
+ await runFastCore(process.argv);
41
+ }
42
+ else {
43
+ const { runCli } = await import("./main.js");
44
+ await runCli();
45
+ }
46
+ });
47
+ }
33
48
  else if (isFastCoreRequest(process.argv)) {
34
49
  // Hot relocated plumbing (`hq core hq-session`, `hq core checkpoint-stop-gate`)
35
50
  // dispatches straight to the bundled-script runner, so it never pays for the
@@ -125,7 +125,9 @@ export declare const HARD_STOP_SENTENCE: string;
125
125
  * dimension this CLI does not know) renders a generic line rather than a claim
126
126
  * about a dimension nobody measured.
127
127
  */
128
- export declare function renderPlanLimitNotice(status: PlanLimitStatus): string;
128
+ export declare function renderPlanLimitNotice(status: PlanLimitStatus, opts?: {
129
+ inviteTeammateEnabled?: boolean;
130
+ }): string;
129
131
  /**
130
132
  * The one-line form injected on every turn while the workspace stays over.
131
133
  * Kept to a single line on purpose — it repeats each turn.
@@ -375,7 +375,7 @@ function capitalize(text) {
375
375
  * dimension this CLI does not know) renders a generic line rather than a claim
376
376
  * about a dimension nobody measured.
377
377
  */
378
- export function renderPlanLimitNotice(status) {
378
+ export function renderPlanLimitNotice(status, opts = {}) {
379
379
  const { companySlug, freeMonth } = status;
380
380
  const lines = [];
381
381
  lines.push(`HQ Starter — ${companySlug} is over its plan limits.`);
@@ -405,6 +405,12 @@ export function renderPlanLimitNotice(status) {
405
405
  lines.push(` 2. Upgrade to ${WORKFORCE_PLAN_LABEL}.`);
406
406
  }
407
407
  lines.push(` Upgrade: ${status.upgradeUrl}`);
408
+ if (opts.inviteTeammateEnabled === true &&
409
+ status.plan === "free" &&
410
+ status.over &&
411
+ status.members?.used === 1) {
412
+ lines.push(` Invite a teammate with: hq members invite <email> --company ${companySlug} --role member`);
413
+ }
408
414
  return lines.join("\n");
409
415
  }
410
416
  /**
@@ -29,10 +29,14 @@ export declare class WriteBoundsError extends Error {
29
29
  constructor(message: string);
30
30
  }
31
31
  /**
32
- * Write `content` to `target` only when it stays inside one of `roots` with no
33
- * symlink anywhere between `anchor` (the HQ root) and the file. A root or a
34
- * directory under it that is a symlink fails closed, and so does a target that
35
- * is itself a symlink. Missing directories are created only after that check.
32
+ * Resolve `target` only when it stays inside one of `roots` with no symlink
33
+ * anywhere between `anchor` (the HQ root) and the file. A root or a directory
34
+ * under it that is a symlink fails closed, and so does a target that is itself
35
+ * a symlink. Missing directories are created only after that check.
36
+ */
37
+ export declare function assertSafeTarget(anchor: string, roots: string[], target: string): string;
38
+ /**
39
+ * Write `content` to `target` only when {@link assertSafeTarget} accepts it.
36
40
  * The file is written to a temporary sibling and renamed, so a reader never
37
41
  * sees half a file.
38
42
  */
@@ -71,14 +71,12 @@ function nearestExisting(target) {
71
71
  return current;
72
72
  }
73
73
  /**
74
- * Write `content` to `target` only when it stays inside one of `roots` with no
75
- * symlink anywhere between `anchor` (the HQ root) and the file. A root or a
76
- * directory under it that is a symlink fails closed, and so does a target that
77
- * is itself a symlink. Missing directories are created only after that check.
78
- * The file is written to a temporary sibling and renamed, so a reader never
79
- * sees half a file.
74
+ * Resolve `target` only when it stays inside one of `roots` with no symlink
75
+ * anywhere between `anchor` (the HQ root) and the file. A root or a directory
76
+ * under it that is a symlink fails closed, and so does a target that is itself
77
+ * a symlink. Missing directories are created only after that check.
80
78
  */
81
- export function safeWriteFile(anchor, roots, target, content) {
79
+ export function assertSafeTarget(anchor, roots, target) {
82
80
  const resolved = path.resolve(target);
83
81
  const root = roots.map((candidate) => path.resolve(candidate)).find((candidate) => isInside(candidate, resolved));
84
82
  if (!root)
@@ -103,6 +101,16 @@ export function safeWriteFile(anchor, roots, target, content) {
103
101
  if (error.code !== "ENOENT")
104
102
  throw error;
105
103
  }
104
+ return path.resolve(target);
105
+ }
106
+ /**
107
+ * Write `content` to `target` only when {@link assertSafeTarget} accepts it.
108
+ * The file is written to a temporary sibling and renamed, so a reader never
109
+ * sees half a file.
110
+ */
111
+ export function safeWriteFile(anchor, roots, target, content) {
112
+ const resolved = assertSafeTarget(anchor, roots, target);
113
+ const parent = path.dirname(resolved);
106
114
  const tmp = path.join(parent, `.${path.basename(resolved)}.${process.pid}.tmp`);
107
115
  fs.writeFileSync(tmp, content);
108
116
  fs.renameSync(tmp, resolved);
@@ -94,6 +94,15 @@ export declare const CONCEPT_PROJECT_LINK_FLAG = "checkpoint.ontology.concept-pr
94
94
  * and lost no gold link.
95
95
  */
96
96
  export declare const NAMED_EVIDENCE_FLAG = "checkpoint.ontology.named-evidence";
97
+ /**
98
+ * CO-59: consume the files touched by company checkpoints that were queued
99
+ * behind a busy sibling (see queued-touches.ts). On, the plan adds the
100
+ * company's queued touches to this run's session-touched set, and a run whose
101
+ * own payload names no company plans the company the parent picked for it
102
+ * (`ontology_company`), without that run's session text. Off, queued touches
103
+ * wait on disk and a company-less run skips the ontology step as before.
104
+ */
105
+ export declare const QUEUED_TOUCHES_FLAG = "checkpoint.ontology.queued-touches";
97
106
  export declare const PLAN_FILE = "ontology-plan.json";
98
107
  export declare const CANDIDATES_FILE = "ontology-candidates.json";
99
108
  export declare const REPORT_FILE = "ontology-report.json";
@@ -183,6 +192,11 @@ export interface OntologyPlan {
183
192
  }
184
193
  export interface CheckpointPayloadLike {
185
194
  company?: unknown;
195
+ /**
196
+ * Set by `hq core checkpoint` only when the payload names no company and
197
+ * another company has queued touches (CO-59). Never scopes anything else.
198
+ */
199
+ ontology_company?: unknown;
186
200
  /** The sibling backend `hq core checkpoint` spawned for this payload. */
187
201
  backend?: unknown;
188
202
  files?: unknown;
@@ -234,6 +248,15 @@ export declare function refreshLedger(company: OntologyCompany, existing: Ledger
234
248
  ledger: Ledger;
235
249
  firstRun: boolean;
236
250
  };
251
+ /**
252
+ * The company this run's ontology step works on: the payload's own company,
253
+ * or else the `ontology_company` the checkpoint parent picked for queued
254
+ * touches (`borrowed`).
255
+ */
256
+ export declare function payloadOntologyCompany(payload: CheckpointPayloadLike): {
257
+ slug: string | null;
258
+ borrowed: boolean;
259
+ };
237
260
  /** Plan this run. Never throws for a skip; returns the reason instead. */
238
261
  export declare function planOntologyRun(options: PlanOptions): Promise<PlanOutcome>;
239
262
  /**
@@ -13,6 +13,7 @@ import * as path from "node:path";
13
13
  import { writeCompanyBrief } from "./company-brief.js";
14
14
  import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
15
15
  import { startMultiWriterRun } from "./multi-writer.js";
16
+ import { claimQueuedTouches, recordQueuedTouches } from "./queued-touches.js";
16
17
  import { checkSyncClient, readSyncClientVersions } from "./sync-client.js";
17
18
  import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
18
19
  export const ONTOLOGY_FLAG = "checkpoint.ontology";
@@ -98,6 +99,15 @@ export const CONCEPT_PROJECT_LINK_FLAG = "checkpoint.ontology.concept-project-li
98
99
  * and lost no gold link.
99
100
  */
100
101
  export const NAMED_EVIDENCE_FLAG = "checkpoint.ontology.named-evidence";
102
+ /**
103
+ * CO-59: consume the files touched by company checkpoints that were queued
104
+ * behind a busy sibling (see queued-touches.ts). On, the plan adds the
105
+ * company's queued touches to this run's session-touched set, and a run whose
106
+ * own payload names no company plans the company the parent picked for it
107
+ * (`ontology_company`), without that run's session text. Off, queued touches
108
+ * wait on disk and a company-less run skips the ontology step as before.
109
+ */
110
+ export const QUEUED_TOUCHES_FLAG = "checkpoint.ontology.queued-touches";
101
111
  export const PLAN_FILE = "ontology-plan.json";
102
112
  export const CANDIDATES_FILE = "ontology-candidates.json";
103
113
  export const REPORT_FILE = "ontology-report.json";
@@ -255,13 +265,32 @@ export function refreshLedger(company, existing, touched) {
255
265
  }
256
266
  return { ledger: { schema: LEDGER_SCHEMA, company: company.slug, boundary, entries }, firstRun };
257
267
  }
268
+ /**
269
+ * The company this run's ontology step works on: the payload's own company,
270
+ * or else the `ontology_company` the checkpoint parent picked for queued
271
+ * touches (`borrowed`).
272
+ */
273
+ export function payloadOntologyCompany(payload) {
274
+ if (typeof payload.company === "string" && payload.company)
275
+ return { slug: payload.company, borrowed: false };
276
+ if (typeof payload.ontology_company === "string" && payload.ontology_company) {
277
+ return { slug: payload.ontology_company, borrowed: true };
278
+ }
279
+ return { slug: null, borrowed: false };
280
+ }
258
281
  /** Plan this run. Never throws for a skip; returns the reason instead. */
259
282
  export async function planOntologyRun(options) {
260
283
  const { hqRoot, runDir, payload } = options;
261
- const resolution = resolveOntologyCompany(hqRoot, typeof payload.company === "string" ? payload.company : null);
284
+ const { slug, borrowed } = payloadOntologyCompany(payload);
285
+ const resolution = resolveOntologyCompany(hqRoot, slug);
262
286
  if (!resolution.ok)
263
287
  return { status: "skipped", reason: resolution.reason };
264
288
  const company = resolution.company;
289
+ // A borrowed company exists only for queued touches, so its run needs the
290
+ // queued-touches flag before anything else is read or written.
291
+ if (borrowed && !(await options.readFlag(QUEUED_TOUCHES_FLAG, company))) {
292
+ return { status: "skipped", reason: "flag off" };
293
+ }
265
294
  const ontologyOn = (await options.readFlag(ONTOLOGY_FLAG, company)) || (await options.readFlag(ONTOLOGY_SYNC_GATED_FLAG, company));
266
295
  const syncClient = ontologyOn ? checkSyncClient((options.syncClientVersions ?? readSyncClientVersions)()) : null;
267
296
  if (!ontologyOn || !syncClient?.ok) {
@@ -278,7 +307,14 @@ export async function planOntologyRun(options) {
278
307
  const skipAmbiguousConceptLinks = await options.readFlag(SKIP_AMBIGUOUS_CONCEPT_LINKS_FLAG, company);
279
308
  const conceptProjectLink = await options.readFlag(CONCEPT_PROJECT_LINK_FLAG, company);
280
309
  const namedEvidence = await options.readFlag(NAMED_EVIDENCE_FLAG, company);
281
- const touched = sessionTouched(hqRoot, company, payload.files);
310
+ const queuedTouchesOn = borrowed || (await options.readFlag(QUEUED_TOUCHES_FLAG, company));
311
+ // A borrowed company's run belongs to another session: its own files and
312
+ // session text are not this company's input.
313
+ const touched = sessionTouched(hqRoot, company, borrowed ? [] : payload.files);
314
+ if (queuedTouchesOn) {
315
+ for (const rel of sessionTouched(hqRoot, company, claimQueuedTouches(hqRoot, company, runDir)))
316
+ touched.add(rel);
317
+ }
282
318
  // A first run takes its boundary from the entity tree, so the ledger is
283
319
  // refreshed before merge-back moves any `last_updated` forward.
284
320
  const { ledger, firstRun } = refreshLedger(company, readLedger(hqRoot, company), touched);
@@ -293,9 +329,11 @@ export async function planOntologyRun(options) {
293
329
  const inputs = [];
294
330
  let tokens = 0;
295
331
  let remaining = 0;
332
+ const leftover = [];
296
333
  for (const [rel, entry] of pending) {
297
334
  if (inputs.length >= BUDGET.maxInputs || tokens >= BUDGET.maxTokens) {
298
335
  remaining += 1;
336
+ leftover.push(rel);
299
337
  continue;
300
338
  }
301
339
  const text = fs.readFileSync(path.join(company.dir, rel), "utf8");
@@ -329,10 +367,21 @@ export async function planOntologyRun(options) {
329
367
  });
330
368
  tokens += cost;
331
369
  }
332
- if (index < chunks.length)
370
+ if (index < chunks.length) {
333
371
  remaining += 1;
372
+ leftover.push(rel);
373
+ }
334
374
  }
335
375
  writeLedger(hqRoot, company, ledger);
376
+ // The leftover files stay queued so a later company-less run can borrow this company again.
377
+ if (queuedTouchesOn && leftover.length > 0) {
378
+ try {
379
+ recordQueuedTouches(hqRoot, company.slug, leftover.map((rel) => path.relative(hqRoot, path.join(company.dir, rel))), options.now ?? new Date());
380
+ }
381
+ catch {
382
+ // Re-queueing leftovers must not fail the plan.
383
+ }
384
+ }
336
385
  if (inputs.length === 0) {
337
386
  const reason = pending.length > 0 ? "over budget" : "nothing changed";
338
387
  return {
@@ -345,7 +394,7 @@ export async function planOntologyRun(options) {
345
394
  // Session content is scoped to the session's user (design section 6, D2).
346
395
  // v1 records the existing entities it names as links in the report and
347
396
  // writes nothing from it, so it rides along only when files changed.
348
- const sessionText = sessionContent(payload);
397
+ const sessionText = borrowed ? null : sessionContent(payload);
349
398
  if (sessionText) {
350
399
  const cost = estTokens(Buffer.byteLength(sessionText, "utf8"));
351
400
  inputs.unshift({
@@ -0,0 +1,32 @@
1
+ import { type OntologyCompany } from "./company.js";
2
+ export declare const QUEUED_TOUCHES_FILE = "queued-touches.jsonl";
3
+ export declare const QUEUED_TOUCHES_CLAIM_FILE = "queued-touches-claimed.jsonl";
4
+ export declare const QUEUED_TOUCHES_BORROWED_FILE = "queued-touches-borrowed-at";
5
+ export declare const MAX_QUEUED_TOUCHES = 200;
6
+ export declare function queuedTouchesPath(hqRoot: string, slug: string): string;
7
+ /**
8
+ * Record the files a queued checkpoint touched inside its company. Files
9
+ * outside `companies/<slug>/` are dropped. Returns the number of files
10
+ * recorded; 0 when the company is not a manifest company or nothing is inside
11
+ * it. The queue keeps its newest {@link MAX_QUEUED_TOUCHES} lines.
12
+ */
13
+ export declare function recordQueuedTouches(hqRoot: string, slug: string | null, files: string[], now: Date): number;
14
+ /**
15
+ * Record that a company-less run borrowed `slug`, so the next such run prefers
16
+ * a company that has waited longer.
17
+ */
18
+ export declare function markBorrowed(hqRoot: string, slug: string, now: Date): void;
19
+ /**
20
+ * The manifest company whose queued touches have waited longest, or null.
21
+ * The order key is the later of the first queued record and the last borrow,
22
+ * so a company a company-less run just borrowed goes to the back. A run whose
23
+ * own payload names no company uses it for its ontology step.
24
+ */
25
+ export declare function oldestQueuedCompany(hqRoot: string): string | null;
26
+ /**
27
+ * Take the company's queued touches by renaming the file into the run
28
+ * directory, then return the HQ-relative paths it lists. Renaming is atomic,
29
+ * so a touch recorded during the claim lands in a fresh file for a later run.
30
+ */
31
+ export declare function claimQueuedTouches(hqRoot: string, company: OntologyCompany, runDir: string): string[];
32
+ //# sourceMappingURL=queued-touches.d.ts.map