@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.
- package/CHANGELOG.md +36 -0
- package/README.md +12 -0
- package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
- package/dist/commands/core-checkpoint.d.ts +4 -2
- package/dist/commands/core-checkpoint.js +34 -3
- package/dist/commands/core-command-catalog.d.ts +4 -2
- package/dist/commands/core-command-catalog.js +5 -2
- package/dist/commands/core-ontology.js +3 -3
- package/dist/commands/core.js +1 -1
- package/dist/commands/daemon.d.ts +1 -0
- package/dist/commands/daemon.js +2 -1
- package/dist/commands/feedback.d.ts +6 -0
- package/dist/commands/feedback.js +86 -37
- package/dist/commands/lanes.js +3 -2
- package/dist/commands/run-feature-telemetry.d.ts +3 -0
- package/dist/commands/run-feature-telemetry.js +6 -0
- package/dist/commands/run.js +3 -2
- package/dist/commands/secrets.js +7 -1
- package/dist/commands/whoami.d.ts +4 -6
- package/dist/commands/whoami.js +39 -8
- package/dist/fast-core.d.ts +6 -7
- package/dist/fast-core.js +22 -8
- package/dist/fast-scaffold.d.ts +16 -7
- package/dist/fast-scaffold.js +13 -8
- package/dist/index.js +16 -1
- package/dist/lib/billing/plan-lock.d.ts +3 -1
- package/dist/lib/billing/plan-lock.js +7 -1
- package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
- package/dist/lib/checkpoint-ontology/company.js +15 -7
- package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
- package/dist/lib/checkpoint-ontology/plan.js +53 -4
- package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
- package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
- package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
- package/dist/lib/core-utils/timeout-guard-native.js +40 -0
- package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
- package/dist/lib/core-utils/timeout-guard.js +28 -13
- package/dist/lib/flag-registry.d.ts +12 -7
- package/dist/lib/flag-registry.js +66 -75
- package/dist/lib/flag-snapshot-file.d.ts +20 -0
- package/dist/lib/flag-snapshot-file.js +91 -0
- package/dist/lib/lanes/reconcile.js +20 -4
- package/dist/lib/process-flag-company-context.d.ts +8 -0
- package/dist/lib/process-flag-company-context.js +67 -0
- package/dist/main.js +3 -1
- package/dist/native-fast-route.d.ts +3 -0
- package/dist/native-fast-route.js +39 -0
- package/dist/utils/cli-telemetry.d.ts +26 -2
- package/dist/utils/cli-telemetry.js +178 -3
- package/dist/utils/hq-root-resolver.d.ts +13 -0
- package/dist/utils/hq-root-resolver.js +103 -0
- package/dist/utils/scaffold-fast-import-guard.js +6 -1
- package/dist/utils/vault-api.d.ts +8 -0
- package/dist/utils/vault-api.js +13 -1
- package/package.json +1 -1
package/dist/commands/whoami.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
if (!
|
|
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
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
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
|
package/dist/fast-core.d.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fast-path dispatcher for
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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,
|
package/dist/fast-scaffold.d.ts
CHANGED
|
@@ -1,27 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fast-path manifest for
|
|
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
|
|
10
|
-
*
|
|
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
|
|
24
|
-
|
|
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. */
|
package/dist/fast-scaffold.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fast-path manifest for
|
|
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
|
|
10
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|