@staix/agent-hub 0.12.36 → 0.12.37
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 +11 -0
- package/README.md +13 -8
- package/docs/agent-notes/adapters.md +7 -3
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/daemon.md +5 -1
- package/docs/agent-notes/tasks.md +5 -2
- package/docs/agent-notes/tests.md +2 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +42 -10
- package/docs/quickstart.md +7 -7
- package/docs/sdlc.md +29 -3
- package/docs/security.md +6 -2
- package/docs/smoke.md +18 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +34 -4
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verified.json +18 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +3091 -2236
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/claude-print.ts +18 -156
- package/src/adapters/codex-review-isolation.ts +276 -0
- package/src/adapters/definitions/builtin-metadata.ts +4 -1
- package/src/adapters/definitions/native-factories.ts +17 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +32 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/registry-types.ts +7 -3
- package/src/adapters/registry.ts +17 -5
- package/src/adapters/reviewer-print.ts +319 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/identity.ts +1 -1
- package/src/cli/main.ts +19 -0
- package/src/cli/memory.ts +13 -12
- package/src/hub/board.ts +34 -6
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +101 -27
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +5 -0
- package/src/hub/instances.ts +13 -3
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +70 -0
- package/src/hub/routing.ts +42 -3
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/tasks.ts +254 -36
package/src/adapters/jcode.ts
CHANGED
|
@@ -17,15 +17,29 @@ export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: s
|
|
|
17
17
|
return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
|
|
18
18
|
...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", "agent-hub-jcode"] : ["--mcp-tools", "eager"])];
|
|
19
19
|
}
|
|
20
|
+
/** Set on every owned jcode process: no Claude MCP import, and no native telemetry (jcode-telemetry-core is_enabled, #529). */
|
|
21
|
+
const JCODE_OWNED_ENV = { JCODE_DISABLE_CLAUDE_MCP: "1", JCODE_NO_TELEMETRY: "1" } as const;
|
|
22
|
+
/** The oldest native release accepted; its ACP, serve and MCP contract was verified live on 0.98.1 (docs/verification). */
|
|
23
|
+
export const JCODE_MIN_VERSION = "0.98.0";
|
|
24
|
+
/** The first native release assumed to break that contract: the next major. Lower it when a release is shown to break. */
|
|
25
|
+
export const JCODE_BREAKING_VERSION = "1.0.0";
|
|
26
|
+
const release = (version: string) => /^(\d+)\.(\d+)\.(\d+)$/.exec(version)?.slice(1).map(Number);
|
|
27
|
+
const compare = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]!;
|
|
28
|
+
/** A plain release from JCODE_MIN_VERSION up to, not including, JCODE_BREAKING_VERSION; the minor is not pinned (#529). */
|
|
29
|
+
export function jcodeVersionSupported(version: unknown): boolean {
|
|
30
|
+
const v = typeof version === "string" ? release(version) : undefined;
|
|
31
|
+
return !!v && compare(v, release(JCODE_MIN_VERSION)!) >= 0 && compare(v, release(JCODE_BREAKING_VERSION)!) < 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
/** The wrapper contract preserves every configured prefix argument before the native probe. */
|
|
21
35
|
export function verifiedJcodeCommand(command: string[], cwd: string): boolean {
|
|
22
36
|
const [bin, ...prefix] = command;
|
|
23
37
|
if (!bin) return false;
|
|
24
38
|
try {
|
|
25
|
-
const result = spawnSync(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: peerChildEnv("jcode"), encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
39
|
+
const result = spawnSync(bin, [...prefix, "--quiet", "version", "--json"], { cwd, env: { ...peerChildEnv("jcode"), ...JCODE_OWNED_ENV }, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
26
40
|
if (result.status !== 0) return false;
|
|
27
41
|
const version = JSON.parse(result.stdout);
|
|
28
|
-
return version.semver
|
|
42
|
+
return jcodeVersionSupported(version.semver);
|
|
29
43
|
} catch { return false; }
|
|
30
44
|
}
|
|
31
45
|
|
|
@@ -41,9 +55,10 @@ export class JcodePeer extends AcpPeer {
|
|
|
41
55
|
constructor(private readonly jcode: JcodeOptions) {
|
|
42
56
|
if (!isAbsolute(jcode.home)) throw new Error("jcode_home must be an absolute native data path");
|
|
43
57
|
jcodeCommand(jcode.command, "acp", "preview", jcode.cwd);
|
|
44
|
-
|
|
58
|
+
// The name must end in ".sock": jcode names its debug socket by replacing that suffix, so "s" binds one path twice.
|
|
59
|
+
const root = mkdtempSync(join(tmpdir(), "ahub-jcode-")), socket = join(root, "j.sock");
|
|
45
60
|
const cmd = jcodeCommand(jcode.command, "acp", socket, jcode.cwd, jcode.launchModel, jcode.providerProfile);
|
|
46
|
-
super("jcode", { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket,
|
|
61
|
+
super("jcode", { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, ...JCODE_OWNED_ENV }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
|
|
47
62
|
this.root = root; this.socket = socket;
|
|
48
63
|
}
|
|
49
64
|
|
|
@@ -53,7 +68,7 @@ export class JcodePeer extends AcpPeer {
|
|
|
53
68
|
if (existsSync(this.socket)) { this.preserveRoot = true; throw new Error("Jcode private socket is occupied; no daemon was adopted or removed"); }
|
|
54
69
|
this.ending = false;
|
|
55
70
|
const [bin, ...args] = jcodeCommand(this.jcode.command, "serve", this.socket, this.jcode.cwd, this.jcode.launchModel, this.jcode.providerProfile);
|
|
56
|
-
const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv("jcode"), JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, JCODE_READY_FD: "3",
|
|
71
|
+
const server = spawn(bin!, args, { cwd: this.jcode.cwd, detached: true, env: { ...peerChildEnv("jcode"), JCODE_HOME: this.jcode.home, JCODE_SOCKET: this.socket, JCODE_READY_FD: "3", ...JCODE_OWNED_ENV }, stdio: ["ignore", "ignore", "ignore", "pipe"] });
|
|
57
72
|
this.server = server; trackGroup(server);
|
|
58
73
|
try {
|
|
59
74
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReviewerBackend, ReviewerPrintOptions } from "./reviewer-print.ts";
|
|
1
2
|
import type { SettingDef } from "../hub/settings.ts";
|
|
2
3
|
import type { Addon } from "../hub/tooling.ts";
|
|
3
4
|
import type { RecoveryContinuityBookmark } from "../hub/recovery-continuity.ts";
|
|
@@ -16,7 +17,7 @@ import type { PiOptions } from "./pi.ts";
|
|
|
16
17
|
import type { ClaudePrintOptions } from "./claude-print.ts";
|
|
17
18
|
|
|
18
19
|
export type StartForm = "tui" | "headless";
|
|
19
|
-
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print";
|
|
20
|
+
export type DaemonEngine = "codex-appserver" | "acp" | "owned-acp" | "pi-rpc" | "local-worker" | "claude-print" | "reviewer-print";
|
|
20
21
|
|
|
21
22
|
/** Existing buildLaunch inputs plus the startup-bound instance and project identity. */
|
|
22
23
|
export interface LaunchContext {
|
|
@@ -55,7 +56,8 @@ export type DaemonPorts =
|
|
|
55
56
|
| { jcode: JcodeOptions }
|
|
56
57
|
| { pi: PiOptions }
|
|
57
58
|
| { local: LocalOptions }
|
|
58
|
-
| { claudePrint: ClaudePrintOptions }
|
|
59
|
+
| { claudePrint: ClaudePrintOptions }
|
|
60
|
+
| { reviewerPrint: ReviewerPrintOptions };
|
|
59
61
|
|
|
60
62
|
export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
|
|
61
63
|
instance: string;
|
|
@@ -264,7 +266,9 @@ export interface AdapterDefinition {
|
|
|
264
266
|
hooks?: Partial<NativeHooks>;
|
|
265
267
|
/** #223: what a declared headless reviewer-only instance of this kind runs as. Fixed builtins only. */
|
|
266
268
|
reviewerVariant?: AdapterDefinition;
|
|
269
|
+
/** Fixed host backend; runtime isolation proof is required before bus admission. */
|
|
270
|
+
reviewer?: { backend?: ReviewerBackend; defaultPool: boolean; unavailable?: string; declaration: "automatic" | "explicit" };
|
|
267
271
|
}
|
|
268
272
|
|
|
269
273
|
/** Explicit project declarations, passed by the bound host. No process-global instance registration. */
|
|
270
|
-
export type KindMap = Readonly<Record<string, { readonly kind: string }>>;
|
|
274
|
+
export type KindMap = Readonly<Record<string, { readonly kind: string; readonly roles?: readonly string[]; readonly start_mode?: "tui" | "headless"; readonly review_mode?: "fresh" }>>;
|
package/src/adapters/registry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AGY_DEFINITION } from "./definitions/reviewer-variants.ts";
|
|
1
2
|
import { builtinCliEntries } from "./definitions/native-cli-entry.ts";
|
|
2
3
|
import { builtinPreview } from "./definitions/native-cli-preview.ts";
|
|
3
4
|
import { DAEMON_PORTS } from "./definitions/native-factories.ts";
|
|
@@ -25,7 +26,7 @@ function immutable<T>(value: T, seen = new WeakSet<object>()): T {
|
|
|
25
26
|
return Object.freeze(value);
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION]);
|
|
29
|
+
const fixedBuiltinDefinitions = new Set<AdapterDefinition>([...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN, QWEN_DEFINITION, AGY_DEFINITION]);
|
|
29
30
|
for (const definition of [...fixedBuiltinDefinitions]) if (definition.reviewerVariant) fixedBuiltinDefinitions.add(definition.reviewerVariant);
|
|
30
31
|
|
|
31
32
|
/** Keep the native catalog's order, independently of the legacy default-role object order. */
|
|
@@ -34,8 +35,10 @@ export const ADAPTERS: readonly AdapterDefinition[] = immutable([
|
|
|
34
35
|
QWEN_DEFINITION,
|
|
35
36
|
...NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN.filter(definition => definition.kind === "jcode"),
|
|
36
37
|
]);
|
|
38
|
+
// Unavailable declarations are metadata, not supported native peers or launchers.
|
|
39
|
+
const declarationOnlyDefinitions: readonly AdapterDefinition[] = immutable([AGY_DEFINITION]);
|
|
37
40
|
const definitions = new Map<string, AdapterDefinition>();
|
|
38
|
-
for (const definition of ADAPTERS) {
|
|
41
|
+
for (const definition of [...ADAPTERS, ...declarationOnlyDefinitions]) {
|
|
39
42
|
assertDaemonEngine(definition);
|
|
40
43
|
assertNativeCapabilityScope(definition);
|
|
41
44
|
if (definition.reviewerVariant) { assertDaemonEngine(definition.reviewerVariant); assertNativeCapabilityScope(definition.reviewerVariant); }
|
|
@@ -43,6 +46,10 @@ for (const definition of ADAPTERS) {
|
|
|
43
46
|
definitions.set(definition.kind, definition);
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/** Default kind slots derive from fixed reviewer policy, not a second vendor list in the host. */
|
|
50
|
+
export const REVIEW_POOL_KINDS: readonly string[] = Object.freeze([...definitions.values()].filter(definition => !!(definition.reviewerVariant ?? definition).reviewer).map(definition => definition.kind));
|
|
51
|
+
export const DEFAULT_REVIEW_POOL: readonly string[] = Object.freeze(ADAPTERS.filter(definition => (definition.reviewerVariant ?? definition).reviewer?.defaultPool === true).map(definition => definition.kind));
|
|
52
|
+
|
|
46
53
|
/** Native quota handoff platform names, distinct from per-delivery recall injection policy. */
|
|
47
54
|
export const QUOTA_HANDOFF_PLATFORMS: Readonly<Record<string, string>> = Object.freeze(Object.fromEntries(
|
|
48
55
|
ADAPTERS.flatMap(adapter => adapter.hooks?.recallPlatform ? [[adapter.kind, adapter.hooks.recallPlatform]] : []),
|
|
@@ -77,7 +84,11 @@ export function definitionOf(id: string, instances?: KindMap): AdapterDefinition
|
|
|
77
84
|
const kind = kindOf(id, instances);
|
|
78
85
|
const definition = kind === undefined ? undefined : definitions.get(kind);
|
|
79
86
|
// #223: only a declared extra (never the builtin id) with start_mode "headless" runs as the kind's reviewer variant.
|
|
80
|
-
|
|
87
|
+
const declaration = ownField(instances, id), roles = ownField(declaration, "roles");
|
|
88
|
+
const reviewerOnly = Array.isArray(roles) && !types.isProxy(roles) && roles.length === 1 && Object.getOwnPropertyDescriptor(roles, "0")?.value === "reviewer";
|
|
89
|
+
const variant = definition?.reviewerVariant;
|
|
90
|
+
const fresh = variant?.reviewer?.declaration === "automatic" || ownField(declaration, "review_mode") === "fresh";
|
|
91
|
+
return variant && kind !== id && reviewerOnly && ownField(declaration, "start_mode") === "headless" && fresh ? variant : definition;
|
|
81
92
|
}
|
|
82
93
|
export function familyOf(id: string, instances?: KindMap): string | undefined {
|
|
83
94
|
return definitionOf(id, instances)?.family;
|
|
@@ -125,6 +136,7 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
125
136
|
["start.daemonPort pi-rpc", definition.start.daemonPort === "pi-rpc"],
|
|
126
137
|
["routing", definition.routing !== undefined],
|
|
127
138
|
["reviewerVariant", definition.reviewerVariant !== undefined],
|
|
139
|
+
["reviewer", definition.reviewer !== undefined],
|
|
128
140
|
];
|
|
129
141
|
const field = restricted.find(([, present]) => present)?.[0];
|
|
130
142
|
if (field) throw new Error(`${field} is restricted to fixed builtin adapter definitions`);
|
|
@@ -133,8 +145,8 @@ export function assertNativeCapabilityScope(definition: AdapterDefinition): void
|
|
|
133
145
|
/** Ordered catalog projections consumed by generic core hosts. */
|
|
134
146
|
export const STARTABLE_PEERS = SUPPORTED_PEERS;
|
|
135
147
|
export type SupportedPeer = typeof SUPPORTED_PEERS[number];
|
|
136
|
-
export function isSupportedPeer(peer: unknown): peer is SupportedPeer { return typeof peer === "string" &&
|
|
137
|
-
export function isStartablePeer(peer: unknown): peer is SupportedPeer { return
|
|
148
|
+
export function isSupportedPeer(peer: unknown): peer is SupportedPeer { return typeof peer === "string" && ADAPTERS.some(definition => definition.kind === peer); }
|
|
149
|
+
export function isStartablePeer(peer: unknown): peer is SupportedPeer { return isSupportedPeer(peer) && definitionOf(peer)?.start.conductorStartable === true; }
|
|
138
150
|
|
|
139
151
|
/** Body-free browser metadata, derived from the same native participation and managed-tool contracts. */
|
|
140
152
|
export const ADAPTER_DISPLAY = Object.fromEntries(ADAPTERS.map(adapter => [adapter.kind, {
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
4
|
+
import { HUB, frame } from "../hub/envelope.ts";
|
|
5
|
+
import { BasePeer } from "../hub/peers.ts";
|
|
6
|
+
import type { Envelope } from "../hub/envelope.ts";
|
|
7
|
+
|
|
8
|
+
export interface ReviewerPrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
9
|
+
export interface ReviewerIsolationReceipt {
|
|
10
|
+
backend: string;
|
|
11
|
+
version: string;
|
|
12
|
+
binary: string;
|
|
13
|
+
profile: string;
|
|
14
|
+
readOnly: true;
|
|
15
|
+
strictMcp: true;
|
|
16
|
+
ephemeral: true;
|
|
17
|
+
evidence: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export interface ReviewerPrepareContext {
|
|
20
|
+
peer: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
mcpConfig: string;
|
|
24
|
+
systemPrompt: string;
|
|
25
|
+
privatePaths: readonly string[];
|
|
26
|
+
command?: readonly string[];
|
|
27
|
+
signal: AbortSignal;
|
|
28
|
+
}
|
|
29
|
+
export interface ReviewerLaunch {
|
|
30
|
+
command: readonly string[];
|
|
31
|
+
args: readonly string[];
|
|
32
|
+
isolation: ReviewerIsolationReceipt;
|
|
33
|
+
/** Recheck the fixed producer's binary/config binding immediately before the model child is spawned. */
|
|
34
|
+
current?: () => boolean;
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
cleanup?: () => void | Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export interface ReviewerBackend {
|
|
39
|
+
id: string;
|
|
40
|
+
prepare(context: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }>;
|
|
41
|
+
usage?(event: unknown): ReviewerPrintUsage | undefined;
|
|
42
|
+
failure?(event: unknown): "quota" | undefined;
|
|
43
|
+
}
|
|
44
|
+
export interface ReviewerFailure {
|
|
45
|
+
task: number;
|
|
46
|
+
head?: string;
|
|
47
|
+
flowRevision?: number;
|
|
48
|
+
cause: "start_failed" | "quota" | "no_verdict";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ReviewerPrintOptions {
|
|
52
|
+
cwd: string;
|
|
53
|
+
command?: readonly string[];
|
|
54
|
+
mcpConfig: string;
|
|
55
|
+
systemPrompt: string;
|
|
56
|
+
privatePaths?: readonly string[];
|
|
57
|
+
watchdogMs: number;
|
|
58
|
+
backend: ReviewerBackend;
|
|
59
|
+
facts?(env: Envelope): string | undefined;
|
|
60
|
+
verdictSince(task: number, since: number): boolean;
|
|
61
|
+
onUsage?(usage: ReviewerPrintUsage, task: number): void;
|
|
62
|
+
onUnavailable?(failure: ReviewerFailure, current: () => boolean): void | Promise<void>;
|
|
63
|
+
log?(line: string): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ponytail: 256 KiB per NDJSON frame and 4 MiB stdout per review child. Raise only with measured native receipts.
|
|
67
|
+
const MAX_REVIEW_FRAME_BYTES = 256 * 1024, MAX_REVIEW_OUTPUT_BYTES = 4 * 1024 * 1024;
|
|
68
|
+
class GroupCleanupError extends Error {}
|
|
69
|
+
type ReviewTarget = Omit<ReviewerFailure, "cause">;
|
|
70
|
+
interface ReviewBatch { targets: (ReviewTarget | undefined)[]; next: number; failures: ReviewerFailure[] }
|
|
71
|
+
const reviewTarget = (env: Envelope): ReviewTarget | undefined => {
|
|
72
|
+
const task = reviewTask(env);
|
|
73
|
+
if (task === undefined || env.private) return undefined;
|
|
74
|
+
const revision = Number(env.refs?.reviewRevision);
|
|
75
|
+
return { task, ...(typeof env.refs?.commit === "string" ? { head: env.refs.commit } : {}), ...(Number.isSafeInteger(revision) && revision > 0 ? { flowRevision: revision } : {}) };
|
|
76
|
+
};
|
|
77
|
+
const remainingFailures = (batch: ReviewBatch, start: number, cause: ReviewerFailure["cause"]): ReviewerFailure[] =>
|
|
78
|
+
batch.targets.slice(start).flatMap(target => target ? [{ ...target, cause }] : []);
|
|
79
|
+
const reviewTask = (env: Envelope): number | undefined => {
|
|
80
|
+
if (env.from !== HUB || env.kind !== "review") return undefined;
|
|
81
|
+
const id = Number(env.refs?.task);
|
|
82
|
+
return Number.isSafeInteger(id) && id > 0 ? id : undefined;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export class ReviewerPrintPeer extends BasePeer {
|
|
86
|
+
readonly hubNative = true;
|
|
87
|
+
private generation = 0;
|
|
88
|
+
private child: ChildProcess | undefined;
|
|
89
|
+
private activeDeliveryId: string | undefined;
|
|
90
|
+
private batch: ReviewBatch | undefined;
|
|
91
|
+
private preparation: { abort: AbortController; done: Promise<ReviewerLaunch> } | undefined;
|
|
92
|
+
private receipt: ReviewerIsolationReceipt | undefined;
|
|
93
|
+
get isolationReceipt(): ReviewerIsolationReceipt | undefined { return this.receipt; }
|
|
94
|
+
/** The task whose review the running child was started for (R4: the hub records `fresh` for its verdict). */
|
|
95
|
+
reviewing: number | undefined;
|
|
96
|
+
private reviewSeq = 0;
|
|
97
|
+
/** Identifies the running child's authority over `task`; a replacement child for the same task gets a new token. */
|
|
98
|
+
reviewToken(task: number): number | undefined { return this.reviewing === task ? this.reviewSeq : undefined; }
|
|
99
|
+
|
|
100
|
+
constructor(id: string, private readonly opts: ReviewerPrintOptions) { super(id, opts.watchdogMs); }
|
|
101
|
+
|
|
102
|
+
recoveryMetadata(): Record<string, unknown> { return { launch: { kind: "reviewer-print", peer: this.id } }; }
|
|
103
|
+
|
|
104
|
+
async start(): Promise<void> {
|
|
105
|
+
// A retained child means a review's process group was never confirmed gone: no new review beside it.
|
|
106
|
+
if (this.child) throw new Error(`${this.id}: an earlier review's process group is still running; stop it first`);
|
|
107
|
+
if (this.preparation) throw new Error(`${this.id}: reviewer preparation is still running`);
|
|
108
|
+
this.receipt = undefined;
|
|
109
|
+
const generation = ++this.generation;
|
|
110
|
+
try {
|
|
111
|
+
const launch = await this.prepare(randomUUID());
|
|
112
|
+
try {
|
|
113
|
+
if (generation !== this.generation || launch.current?.() === false) throw new Error("reviewer isolation changed before startup");
|
|
114
|
+
this.receipt = launch.isolation;
|
|
115
|
+
} finally { await launch.cleanup?.(); }
|
|
116
|
+
} catch (error) { if (generation === this.generation) this.setState("offline"); throw error; }
|
|
117
|
+
if (generation === this.generation) this.setState("idle");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async stop(reason?: string): Promise<void> {
|
|
121
|
+
this.generation++; // fence before the first await: a late exit settles nothing
|
|
122
|
+
this.receipt = undefined;
|
|
123
|
+
this.batch = undefined;
|
|
124
|
+
this.preparation?.abort.abort();
|
|
125
|
+
await this.preparation?.done.catch(() => {});
|
|
126
|
+
this.settle("needs_review", reason ?? "review stopped before settlement");
|
|
127
|
+
if (this.child) this.setState("busy"); // unavailable, not offline, until the group is confirmed gone
|
|
128
|
+
await this.kill(); // throws when the child could not be stopped; its handle is kept and the peer stays busy
|
|
129
|
+
this.setState("offline");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
protected override onWatchdog(): void { this.abortReview("review watchdog timeout"); }
|
|
133
|
+
|
|
134
|
+
private abortReview(reason: "review watchdog timeout" | "review output limit exceeded"): void {
|
|
135
|
+
const generation = ++this.generation, batch = this.batch, preparation = this.preparation;
|
|
136
|
+
this.reviewing = undefined;
|
|
137
|
+
this.receipt = undefined;
|
|
138
|
+
this.clearWatchdog();
|
|
139
|
+
preparation?.abort.abort();
|
|
140
|
+
this.settle("needs_review", reason);
|
|
141
|
+
// Revoke first, then confirm both preparation and the model's whole group are gone before fallback.
|
|
142
|
+
void (async () => {
|
|
143
|
+
if (preparation) await preparation.done.catch(() => {});
|
|
144
|
+
if (generation !== this.generation) return;
|
|
145
|
+
await this.kill();
|
|
146
|
+
if (generation !== this.generation) return; // a person's stop owns the hold; never reroute it
|
|
147
|
+
if (batch) await this.reportFailures([...batch.failures, ...remainingFailures(batch, batch.next, "no_verdict")], generation);
|
|
148
|
+
if (generation === this.generation && this.state === "busy") { this.batch = undefined; super.onWatchdog(); }
|
|
149
|
+
})().catch((error: Error) => this.opts.log?.(`[${this.id}] review cleanup failed, staying busy`));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async deliver(envs: Envelope[], deliveryId?: string): Promise<void> {
|
|
153
|
+
if (this.state !== "idle") {
|
|
154
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: `${this.id} is ${this.state}` });
|
|
155
|
+
throw new Error(`${this.id} is ${this.state}`);
|
|
156
|
+
}
|
|
157
|
+
const generation = ++this.generation;
|
|
158
|
+
this.activeDeliveryId = deliveryId;
|
|
159
|
+
this.batch = { targets: envs.map(reviewTarget), next: 0, failures: [] };
|
|
160
|
+
this.setState("busy");
|
|
161
|
+
if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
|
|
162
|
+
void this.run(envs, generation).catch((error: Error) => {
|
|
163
|
+
if (generation !== this.generation) return;
|
|
164
|
+
this.settle("needs_review", `reviewer failed: ${error.message}`);
|
|
165
|
+
void this.kill().then(() => { if (generation === this.generation) { this.batch = undefined; this.setState("idle"); } }, () => undefined);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async run(envs: Envelope[], generation: number): Promise<void> {
|
|
170
|
+
const missing: number[] = [];
|
|
171
|
+
let started = false;
|
|
172
|
+
const batch = this.batch!;
|
|
173
|
+
const reroute = batch.failures;
|
|
174
|
+
let failure: { state: "failed_safe" | "needs_review"; reason: string } | undefined;
|
|
175
|
+
for (const [index, env] of envs.entries()) {
|
|
176
|
+
batch.next = index;
|
|
177
|
+
const task = reviewTask(env);
|
|
178
|
+
if (task === undefined) continue; // a withdrawal or other notice: nothing to review
|
|
179
|
+
if (env.private) { failure = { state: "needs_review", reason: "a private review is the person's, never a cloud reviewer's" }; break; }
|
|
180
|
+
const since = Date.now();
|
|
181
|
+
let result: { code: number | null; quota: boolean };
|
|
182
|
+
try { result = await this.review(env, task, generation); }
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (generation !== this.generation) return;
|
|
185
|
+
if (error instanceof GroupCleanupError) {
|
|
186
|
+
// Unavailable until a stop confirms the group gone: no new review runs beside the survivors.
|
|
187
|
+
this.opts.log?.(`[${this.id}] review of task #${task}: ${error.message}`);
|
|
188
|
+
this.settle("needs_review", `review process group still running: ${error.message}`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Once an earlier review in this batch ran, the batch had effects: hold it for a person, never requeue it.
|
|
192
|
+
failure = { state: started ? "needs_review" : "failed_safe", reason: `reviewer could not start: ${(error as Error).message}` };
|
|
193
|
+
reroute.push(...remainingFailures(batch, index, "start_failed"));
|
|
194
|
+
batch.next = envs.length;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
started = true;
|
|
198
|
+
if (generation !== this.generation) return;
|
|
199
|
+
let recorded = false;
|
|
200
|
+
try { recorded = this.opts.verdictSince(task, since); } catch (error) { this.opts.log?.(`[${this.id}] verdict check for task #${task} failed: ${(error as Error).message}`); }
|
|
201
|
+
if (!recorded) {
|
|
202
|
+
missing.push(task);
|
|
203
|
+
const target = batch.targets[index];
|
|
204
|
+
if (target) reroute.push({ ...target, cause: result.quota ? "quota" : "no_verdict" });
|
|
205
|
+
if (result.quota) {
|
|
206
|
+
reroute.push(...remainingFailures(batch, index + 1, "quota"));
|
|
207
|
+
batch.next = envs.length;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
batch.next = index + 1;
|
|
212
|
+
if (result.code !== 0) this.opts.log?.(`[${this.id}] review of task #${task} exited ${result.code}`);
|
|
213
|
+
}
|
|
214
|
+
if (generation !== this.generation) return;
|
|
215
|
+
this.clearWatchdog(); // the child is gone; keep busy while the host settles the remaining walk
|
|
216
|
+
this.settle(failure?.state ?? (missing.length ? "needs_review" : "completed"), failure?.reason ?? (missing.length ? `no verdict recorded for task ${missing.map(id => `#${id}`).join(", ")}` : undefined));
|
|
217
|
+
await this.reportFailures(reroute, generation);
|
|
218
|
+
if (generation === this.generation) { this.batch = undefined; this.setState("idle"); }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async reportFailures(failures: readonly ReviewerFailure[], generation: number): Promise<void> {
|
|
222
|
+
const seen = new Set<string>();
|
|
223
|
+
for (const failed of failures) {
|
|
224
|
+
if (generation !== this.generation) return;
|
|
225
|
+
const identity = JSON.stringify([failed.task, failed.head, failed.flowRevision]);
|
|
226
|
+
if (seen.has(identity)) continue;
|
|
227
|
+
seen.add(identity);
|
|
228
|
+
try { await this.opts.onUnavailable?.(failed, () => generation === this.generation); }
|
|
229
|
+
catch { this.opts.log?.(`[${this.id}] reviewer fallback could not be recorded; inspect the task`); }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private prepare(sessionId: string): Promise<ReviewerLaunch> {
|
|
234
|
+
const abort = new AbortController();
|
|
235
|
+
const done = (async () => {
|
|
236
|
+
const launch = await this.opts.backend.prepare({ peer: this.id, cwd: this.opts.cwd, sessionId, mcpConfig: this.opts.mcpConfig,
|
|
237
|
+
systemPrompt: this.opts.systemPrompt, privatePaths: this.opts.privatePaths ?? [], command: this.opts.command, signal: abort.signal });
|
|
238
|
+
if ("unavailable" in launch) throw new Error(launch.unavailable);
|
|
239
|
+
const proof = launch.isolation;
|
|
240
|
+
if (abort.signal.aborted || !proof || proof.backend !== this.opts.backend.id || proof.readOnly !== true || proof.strictMcp !== true || proof.ephemeral !== true || !proof.binary || !proof.version || !proof.profile || !proof.evidence.length || !launch.command.length) {
|
|
241
|
+
await launch.cleanup?.();
|
|
242
|
+
throw new Error("reviewer isolation is unavailable or preparation was cancelled");
|
|
243
|
+
}
|
|
244
|
+
return launch;
|
|
245
|
+
})();
|
|
246
|
+
const pending = { abort, done };
|
|
247
|
+
this.preparation = pending;
|
|
248
|
+
void done.finally(() => { if (this.preparation === pending) this.preparation = undefined; }).catch(() => {});
|
|
249
|
+
return done;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async review(env: Envelope, task: number, generation: number): Promise<{ code: number | null; quota: boolean }> {
|
|
253
|
+
const launch = await this.prepare(randomUUID());
|
|
254
|
+
try {
|
|
255
|
+
if (generation !== this.generation || launch.current?.() === false) throw new Error("reviewer isolation changed before child spawn");
|
|
256
|
+
this.receipt = launch.isolation;
|
|
257
|
+
const [cmd, ...prefix] = launch.command;
|
|
258
|
+
let quota = false;
|
|
259
|
+
return await new Promise<{ code: number | null; quota: boolean }>((resolve, reject) => {
|
|
260
|
+
const child = spawn(cmd!, [...prefix, ...launch.args], { cwd: this.opts.cwd, detached: true, env: peerChildEnv(this.id, { ...process.env, ...launch.env }), stdio: ["pipe", "pipe", "ignore"] });
|
|
261
|
+
// Owned before any await, so a stop that lands before "spawn" still finds and kills it.
|
|
262
|
+
this.child = child;
|
|
263
|
+
child.once("error", error => { if (this.child === child) this.child = undefined; reject(error); });
|
|
264
|
+
child.stdin!.on("error", () => { /* the child exited before reading its prompt; its exit settles the review */ });
|
|
265
|
+
child.once("spawn", () => {
|
|
266
|
+
trackGroup(child);
|
|
267
|
+
if (generation !== this.generation) { child.stdin!.destroy(); return; } // stopped meanwhile: no prompt
|
|
268
|
+
this.reviewing = task; this.reviewSeq++;
|
|
269
|
+
let facts: string | undefined;
|
|
270
|
+
try { facts = this.opts.facts?.(env); } catch (error) { this.opts.log?.(`[${this.id}] review facts for task #${task} failed: ${(error as Error).message}`); }
|
|
271
|
+
child.stdin!.end(`${frame(env)}${facts ? `\n\n${facts}` : ""}`);
|
|
272
|
+
});
|
|
273
|
+
let buffer = "", outputBytes = 0;
|
|
274
|
+
child.stdout!.on("data", (chunk: Buffer) => {
|
|
275
|
+
if (generation !== this.generation) return;
|
|
276
|
+
outputBytes += chunk.length;
|
|
277
|
+
if (outputBytes > MAX_REVIEW_OUTPUT_BYTES) { this.abortReview("review output limit exceeded"); return; }
|
|
278
|
+
buffer += chunk.toString("utf8");
|
|
279
|
+
for (let i; (i = buffer.indexOf("\n")) >= 0;) {
|
|
280
|
+
const line = buffer.slice(0, i); buffer = buffer.slice(i + 1);
|
|
281
|
+
if (Buffer.byteLength(line) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
|
|
282
|
+
try {
|
|
283
|
+
const event: unknown = JSON.parse(line);
|
|
284
|
+
const usage = this.opts.backend.usage?.(event);
|
|
285
|
+
if (usage) this.opts.onUsage?.(usage, task);
|
|
286
|
+
if (this.opts.backend.failure?.(event) === "quota") quota = true;
|
|
287
|
+
} catch { /* not a stream-json line */ }
|
|
288
|
+
}
|
|
289
|
+
if (Buffer.byteLength(buffer) > MAX_REVIEW_FRAME_BYTES) { this.abortReview("review output limit exceeded"); return; }
|
|
290
|
+
this.touch();
|
|
291
|
+
});
|
|
292
|
+
child.once("exit", code => {
|
|
293
|
+
if (this.child === child) this.reviewing = undefined; // the verdict authority ends with the leader
|
|
294
|
+
// The review ends when its whole process group is gone (an MCP server or other descendant may outlive the leader).
|
|
295
|
+
stopOwnedProcess(child, { group: true }).then(() => { if (this.child === child) this.child = undefined; resolve({ code, quota }); },
|
|
296
|
+
(error: Error) => reject(new GroupCleanupError(error.message)));
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
} finally {
|
|
300
|
+
try { await launch.cleanup?.(); }
|
|
301
|
+
catch { this.opts.log?.(`[${this.id}] reviewer isolation cleanup incomplete`); }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string): void {
|
|
306
|
+
const id = this.activeDeliveryId;
|
|
307
|
+
this.activeDeliveryId = undefined;
|
|
308
|
+
if (id) this.delivery({ id, state, ...(reason ? { reason } : {}) });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Revokes the verdict authority at once, then stops the child; on failure the handle is kept and the error thrown. */
|
|
312
|
+
private async kill(): Promise<void> {
|
|
313
|
+
this.reviewing = undefined;
|
|
314
|
+
const child = this.child;
|
|
315
|
+
if (!child) return;
|
|
316
|
+
await stopOwnedProcess(child, { group: true });
|
|
317
|
+
if (this.child === child) this.child = undefined;
|
|
318
|
+
}
|
|
319
|
+
}
|
package/src/cli/help.ts
CHANGED
|
@@ -25,6 +25,8 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
|
|
|
25
25
|
["ahub sdlc queue <owner/repo> | release <owner/repo> [live-issue...]", "read-only merge and release evidence (person only)"],
|
|
26
26
|
["ahub sdlc accept <task> --issue <n> [--repo owner/repo]", "accept the task's spec issue at its current body; build entry needs it in enforce (person only)"],
|
|
27
27
|
["ahub sdlc plan-review <task> <revision> <reason...> [--review full|delta|none --reviewers N --ci full|focused|reuse]", "record planner review, scope and host-read issue metadata"],
|
|
28
|
+
["ahub sdlc train report|configure|cut <release-id> [revision] [json]", "plan a release and freeze its approved scope after resource/quota admission"],
|
|
29
|
+
["ahub sdlc release-assign <task> <revision> <release-id|none> <reason>", "assign approved/in-flight work without changing execution"],
|
|
28
30
|
["ahub sdlc costs [owner/repo]", "read bounded planner cost metrics for linked merged PRs"],
|
|
29
31
|
["ahub backlog groom | return <task> <revision> <reason...>", "read grooming findings or return accepted untouched work to backlog (planner/person)"],
|
|
30
32
|
["ahub status [--json] [--full] [--color=auto|always|never]", "the hub, its peers, model backends and task counts"],
|
package/src/cli/identity.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const LIVE_TEST_PERSON_REFUSAL = "live-test cannot write person-attribute
|
|
|
13
13
|
|
|
14
14
|
/** Person verdicts and user-authored workflow content are never delegated. */
|
|
15
15
|
export function liveTestRefusal(command: string, args: readonly string[], context: { repairedFresh?: boolean } = {}): boolean {
|
|
16
|
-
if (command === "sdlc" && args[0] === "plan-review") return true;
|
|
16
|
+
if (command === "sdlc" && (args[0] === "plan-review" || args[0] === "release-assign" || args[0] === "train" && args[1] !== "report")) return true;
|
|
17
17
|
if (args.some(arg => arg === "--as-user" || arg.startsWith("--as-user="))) return true;
|
|
18
18
|
const sub = command === "recovery" ? recoveryAction(args) : args.find(arg => arg !== "--json");
|
|
19
19
|
return ["task", "role", "goal"].includes(command)
|
package/src/cli/main.ts
CHANGED
|
@@ -1118,6 +1118,25 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
1118
1118
|
finally { hub.close(); }
|
|
1119
1119
|
return;
|
|
1120
1120
|
}
|
|
1121
|
+
if (sub === "train") {
|
|
1122
|
+
const operation = args[1], releaseId = Number(args[2]), revision = Number(args[3]);
|
|
1123
|
+
if (!Number.isSafeInteger(releaseId) || releaseId <= 0) fail("usage: ahub sdlc train report|configure|cut <release-id> [revision] [configuration-json]");
|
|
1124
|
+
if (operation === "report" && args.length === 3) return console.log(await taskOp("hub_release_report", { id: releaseId }));
|
|
1125
|
+
if (!Number.isSafeInteger(revision) || revision < 0) fail("train mutation requires its current revision");
|
|
1126
|
+
if (operation === "cut" && args.length === 4) return console.log(await taskOp("hub_release_cut", { id: releaseId, revision }));
|
|
1127
|
+
if (operation === "configure" && args.length === 5) {
|
|
1128
|
+
let fields;
|
|
1129
|
+
try { fields = JSON.parse(args[4]!); } catch { return fail("release train configuration must be JSON"); }
|
|
1130
|
+
if (!fields || typeof fields !== "object" || Array.isArray(fields) || Object.keys(fields).some(key => !["state", "cut", "hotfix"].includes(key))) fail("invalid release train configuration fields");
|
|
1131
|
+
return console.log(await taskOp("hub_release_configure", { ...fields, id: releaseId, revision }));
|
|
1132
|
+
}
|
|
1133
|
+
fail("usage: ahub sdlc train report|configure|cut <release-id> [revision] [configuration-json]");
|
|
1134
|
+
}
|
|
1135
|
+
if (sub === "release-assign") {
|
|
1136
|
+
const revision = Number(args[2]), release = args[3] === "none" ? null : Number(args[3]);
|
|
1137
|
+
if (!Number.isSafeInteger(id) || id! <= 0 || !Number.isSafeInteger(revision) || revision < 0 || release !== null && (!Number.isSafeInteger(release) || release <= 0) || args.length < 5) fail("usage: ahub sdlc release-assign <task> <planning-revision> <release-id|none> <reason...>");
|
|
1138
|
+
return console.log(await taskOp("hub_release_assign", { id, revision, release, reason: args.slice(4).join(" ") }));
|
|
1139
|
+
}
|
|
1121
1140
|
if (sub === "costs") {
|
|
1122
1141
|
if (args.length > 2) fail("usage: ahub sdlc costs [owner/repository]");
|
|
1123
1142
|
return console.log(await taskOp("hub_sdlc_costs", { ...(args[1] ? { repo: args[1] } : {}) }));
|
package/src/cli/memory.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { detectCliIdentity } from "./identity.ts";
|
|
2
2
|
import { createStalenessProbe } from "../hub/memory-staleness.ts";
|
|
3
|
-
import { looksSecret, type MemoryStore
|
|
3
|
+
import { looksSecret, type MemoryStore } from "@staix/agent-mem";
|
|
4
4
|
import type { SyncOptions } from "@staix/agent-mem";
|
|
5
5
|
import { MEMORY_USAGE as CORE_MEMORY_USAGE, rawMemoryCommand, rawGoalCommand } from "@staix/agent-mem";
|
|
6
|
-
import { memoryProjectKey } from "@staix/agent-mem";
|
|
7
6
|
import { mentionsPrivatePath, promotionScreen, withoutPii } from "../hub/memory-policy.ts";
|
|
8
7
|
import { currentRouting, detectSignals } from "../hub/routing.ts";
|
|
9
8
|
import { loadConfig } from "../hub/daemon.ts";
|
|
10
|
-
import { bindProfileMemory, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, type MemoryProfileReceipt } from "../hub/memory-profile.ts";
|
|
9
|
+
import { bindProfileMemory, memoryProjectFor, openProfileMemory, MEMORY_PROFILE_UNAVAILABLE, MEMORY_SPLIT_INCOMPLETE, type MemoryProfileReceipt, type ProfileMemoryOptions } from "../hub/memory-profile.ts";
|
|
11
10
|
|
|
12
11
|
export { GOAL_USAGE } from "@staix/agent-mem";
|
|
13
12
|
export const MEMORY_USAGE = `${CORE_MEMORY_USAGE} | decay [--days N] [--json] | profile-check [--json]`;
|
|
@@ -18,9 +17,9 @@ const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch
|
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
/** Bind each command at entry; the CLI closes default opens and injected stores remain caller-owned. */
|
|
21
|
-
function commandStores(provided
|
|
20
|
+
function commandStores(provided: MemoryStore | undefined, options: ProfileMemoryOptions) {
|
|
22
21
|
try { return { ...(provided ? bindProfileMemory(provided) : openProfileMemory(options)), owned: !provided }; }
|
|
23
|
-
catch { throw new Error(MEMORY_PROFILE_UNAVAILABLE); }
|
|
22
|
+
catch (error) { throw new Error(error instanceof Error && error.message === MEMORY_SPLIT_INCOMPLETE ? error.message : MEMORY_PROFILE_UNAVAILABLE); }
|
|
24
23
|
}
|
|
25
24
|
function profileOutput(text: string, json: boolean, profile: MemoryProfileReceipt | undefined, person: boolean): string {
|
|
26
25
|
if (!profile) return text;
|
|
@@ -44,9 +43,9 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
44
43
|
const cutoffMs = (opts.now ?? Date.now)() - days * 24 * 3600_000;
|
|
45
44
|
if (!Number.isSafeInteger(days) || !Number.isFinite(cutoffMs) || Math.abs(cutoffMs) > 8_640_000_000_000_000) fail("--days is outside the supported date range");
|
|
46
45
|
const cutoff = new Date(cutoffMs).toISOString();
|
|
47
|
-
const project =
|
|
46
|
+
const project = memoryProjectFor(cwd), routing = currentRouting(cwd), paths = privatePaths();
|
|
48
47
|
// Review must not promote a held source while reading it: only explicit person actions may append rows.
|
|
49
|
-
const context = commandStores(opts.store, { deferPromotions: true, now: opts.now });
|
|
48
|
+
const context = commandStores(opts.store, { project, deferPromotions: true, now: opts.now });
|
|
50
49
|
const store = context.store;
|
|
51
50
|
try {
|
|
52
51
|
context.binding.assertCurrent();
|
|
@@ -68,11 +67,12 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
68
67
|
} finally { if (context.owned) store.close(); }
|
|
69
68
|
}
|
|
70
69
|
let showStale = false;
|
|
71
|
-
const
|
|
72
|
-
|
|
70
|
+
const project = memoryProjectFor(cwd);
|
|
71
|
+
const context = commandStores(opts.store, { project, ttlMs: (loadConfigSafe(cwd)?.memory.short_ttl_days ?? 14) * 24 * 3600_000,
|
|
72
|
+
screen: promotionScreen(project, cwd, () => currentRouting(cwd), privatePaths) });
|
|
73
73
|
try {
|
|
74
74
|
context.binding.assertCurrent();
|
|
75
|
-
const text = await rawMemoryCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
|
|
75
|
+
const text = await rawMemoryCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
|
|
76
76
|
readable: notes => {
|
|
77
77
|
const paths = privatePaths(), visible = withoutPii(notes, currentRouting(cwd), cwd, paths);
|
|
78
78
|
if (args[0] !== "show" || args.includes("--json")) return visible;
|
|
@@ -92,10 +92,11 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
|
|
|
92
92
|
export async function goalCommand(args: string[], cwd: string, opts: { store?: MemoryStore; link?: (goal: string, task: string) => Promise<string>; person?: boolean } = {}): Promise<string> {
|
|
93
93
|
const person = opts.person ?? detectCliIdentity(process.env).role === "console";
|
|
94
94
|
const policy = (): string[] => { try { return loadConfig(cwd).pii?.paths ?? []; } catch (e) { return fail(`the project's config does not load (${(e as Error).message}); fix it before using long-term tasks`); } };
|
|
95
|
-
const
|
|
95
|
+
const project = memoryProjectFor(cwd);
|
|
96
|
+
const context = commandStores(opts.store, { project });
|
|
96
97
|
try {
|
|
97
98
|
context.binding.assertCurrent();
|
|
98
|
-
const text = await rawGoalCommand(args, cwd, { ...opts, store: context.store, open: () => context.store,
|
|
99
|
+
const text = await rawGoalCommand(args, cwd, { ...opts, project, store: context.store, open: () => context.store,
|
|
99
100
|
clean: (title, milestones, project) => !looksSecret(`${title}\n${milestones.join("\n")}`) && withoutPii([{ title, text: milestones.join("\n"), project }], currentRouting(cwd), cwd, policy()).length > 0,
|
|
100
101
|
});
|
|
101
102
|
return profileOutput(text, args.includes("--json"), context.binding.status(context.store), person);
|