@cruxy/cli 0.16.0 → 0.18.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 (58) hide show
  1. package/dist/agent/loop.d.ts +15 -0
  2. package/dist/agent/loop.js +13 -2
  3. package/dist/agent/prompts.d.ts +7 -0
  4. package/dist/agent/prompts.js +6 -0
  5. package/dist/agent/session.d.ts +14 -0
  6. package/dist/agent/session.js +10 -1
  7. package/dist/brand/index.d.ts +1 -1
  8. package/dist/brand/index.js +1 -1
  9. package/dist/brand/voice.d.ts +20 -0
  10. package/dist/brand/voice.js +54 -0
  11. package/dist/cli/commands/memory.d.ts +8 -0
  12. package/dist/cli/commands/memory.js +98 -0
  13. package/dist/cli/commands/pr.js +9 -1
  14. package/dist/cli/program.js +2 -0
  15. package/dist/cli/session-factory.js +31 -1
  16. package/dist/config/schema.d.ts +114 -28
  17. package/dist/config/schema.js +38 -0
  18. package/dist/constants.d.ts +11 -0
  19. package/dist/constants.js +11 -0
  20. package/dist/errors/constructors.d.ts +23 -0
  21. package/dist/errors/constructors.js +86 -6
  22. package/dist/errors/types.d.ts +12 -0
  23. package/dist/errors/types.js +20 -0
  24. package/dist/hooks/types.d.ts +1 -1
  25. package/dist/memory/index.d.ts +7 -0
  26. package/dist/memory/index.js +7 -0
  27. package/dist/memory/recall.d.ts +32 -0
  28. package/dist/memory/recall.js +73 -0
  29. package/dist/memory/remember-tool.d.ts +25 -0
  30. package/dist/memory/remember-tool.js +56 -0
  31. package/dist/memory/secrets.d.ts +29 -0
  32. package/dist/memory/secrets.js +61 -0
  33. package/dist/memory/service.d.ts +92 -0
  34. package/dist/memory/service.js +164 -0
  35. package/dist/memory/store.d.ts +32 -0
  36. package/dist/memory/store.js +100 -0
  37. package/dist/memory/trust.d.ts +52 -0
  38. package/dist/memory/trust.js +106 -0
  39. package/dist/memory/types.d.ts +101 -0
  40. package/dist/memory/types.js +58 -0
  41. package/dist/plan/service.d.ts +9 -0
  42. package/dist/plan/service.js +6 -0
  43. package/dist/render/state.js +4 -1
  44. package/dist/render/types.d.ts +7 -1
  45. package/dist/routing/index.d.ts +2 -0
  46. package/dist/routing/index.js +5 -0
  47. package/dist/routing/resolve.d.ts +17 -0
  48. package/dist/routing/resolve.js +18 -0
  49. package/dist/routing/router.d.ts +47 -0
  50. package/dist/routing/router.js +84 -0
  51. package/dist/routing/types.d.ts +42 -0
  52. package/dist/routing/types.js +27 -0
  53. package/dist/subagent/orchestrator.d.ts +6 -0
  54. package/dist/subagent/orchestrator.js +2 -0
  55. package/dist/subagent/types.d.ts +6 -0
  56. package/dist/vcs/generate.d.ts +3 -1
  57. package/dist/vcs/generate.js +4 -1
  58. package/package.json +2 -2
@@ -0,0 +1,100 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { globalDir } from "../config/paths.js";
4
+ import { GLOBAL_DIR_NAME, MEMORY_DIR_NAME, MEMORY_FILE_NAME, } from "../constants.js";
5
+ import { containsSecret } from "./secrets.js";
6
+ import { MemoryEntrySchema, MemoryFileSchema, MEMORY_FILE_VERSION, } from "./types.js";
7
+ /** The default source files for a project root. */
8
+ export function defaultMemorySources(cwd) {
9
+ const root = path.resolve(cwd);
10
+ return {
11
+ user: path.join(globalDir(), MEMORY_DIR_NAME, MEMORY_FILE_NAME),
12
+ project: path.join(root, GLOBAL_DIR_NAME, MEMORY_DIR_NAME, MEMORY_FILE_NAME),
13
+ };
14
+ }
15
+ /**
16
+ * Read and validate one scope's file. A missing file yields an empty result
17
+ * (not an error). Each entry is (1) schema-validated and (2) scanned for secret
18
+ * content; failures are excluded and collected. The returned `entries` all carry
19
+ * the requested `scope` (the on-disk `scope` field is normalized to it, so a
20
+ * mislabeled entry can't cross scopes).
21
+ */
22
+ export function loadScope(file, scope) {
23
+ let raw;
24
+ try {
25
+ raw = readFileSync(file, "utf8");
26
+ }
27
+ catch {
28
+ // Missing / unreadable → nothing recalled from this scope (not an error).
29
+ return { entries: [], errors: [] };
30
+ }
31
+ let parsedFile;
32
+ try {
33
+ parsedFile = JSON.parse(raw);
34
+ }
35
+ catch {
36
+ return {
37
+ entries: [],
38
+ errors: [
39
+ { scope, reason: "invalid", id: "(file)", message: "not valid JSON" },
40
+ ],
41
+ };
42
+ }
43
+ const fileResult = MemoryFileSchema.safeParse(parsedFile);
44
+ if (!fileResult.success) {
45
+ return {
46
+ entries: [],
47
+ errors: [
48
+ {
49
+ scope,
50
+ reason: "invalid",
51
+ id: "(file)",
52
+ message: `malformed memory file: ${fileResult.error.issues[0]?.message ?? "invalid shape"}`,
53
+ },
54
+ ],
55
+ };
56
+ }
57
+ const entries = [];
58
+ const errors = [];
59
+ fileResult.data.entries.forEach((candidate, i) => {
60
+ const parsed = MemoryEntrySchema.safeParse(candidate);
61
+ if (!parsed.success) {
62
+ const id = candidate && typeof candidate === "object" && "id" in candidate
63
+ ? String(candidate.id)
64
+ : `#${i}`;
65
+ errors.push({
66
+ scope,
67
+ reason: "invalid",
68
+ id,
69
+ message: parsed.error.issues[0]?.message ?? "invalid entry",
70
+ });
71
+ return;
72
+ }
73
+ // Secrets denylist on LOAD (defense in depth): a hand-edited file cannot
74
+ // smuggle a secret into context even though `remember` also refuses on write.
75
+ const scan = containsSecret(parsed.data.content);
76
+ if (scan.secret) {
77
+ errors.push({
78
+ scope,
79
+ reason: "secret",
80
+ id: parsed.data.id,
81
+ message: `excluded — content looks like a secret (${scan.kind})`,
82
+ });
83
+ return;
84
+ }
85
+ // Normalize scope to the file it came from (never trust the on-disk label to
86
+ // move an entry across scopes).
87
+ entries.push({ ...parsed.data, scope });
88
+ });
89
+ return { entries, errors };
90
+ }
91
+ /**
92
+ * Persist a scope's entries, overwriting the file. Creates the memory dir if
93
+ * needed. The user scope is written `0600` (it is personal, cross-project data);
94
+ * the project scope inherits normal repo permissions (it may be committed).
95
+ */
96
+ export function saveScope(file, entries, scope) {
97
+ mkdirSync(path.dirname(file), { recursive: true });
98
+ const body = JSON.stringify({ version: MEMORY_FILE_VERSION, entries }, null, 2);
99
+ writeFileSync(file, body, scope === "user" ? { mode: 0o600 } : undefined);
100
+ }
@@ -0,0 +1,52 @@
1
+ import type { MemoryEntry, MemoryTrust } from "./types.js";
2
+ /**
3
+ * The project-MEMORY trust model (C.29) — the exact supply-chain treatment C.19
4
+ * applies to project hooks, but with its OWN store so memory and hook trust are
5
+ * independent (the hook `trust.json` keys a single fingerprint per root; sharing
6
+ * it would couple the two, so memory gets `~/.cruxy/memory-trust.json`).
7
+ *
8
+ * Trust is recorded in the GLOBAL dir — in the user's home, NEVER inside a repo —
9
+ * so cloning a repo carries zero memory trust and an attacker cannot ship a
10
+ * pre-trusted marker. It is bound to a {@link fingerprintMemory fingerprint} of
11
+ * the exact project entries seen at trust time and re-checked on every run: if
12
+ * the project's memory changes, the fingerprint no longer matches and trust is
13
+ * stale → the entries are not recalled until re-trusted. This defeats
14
+ * trust-then-swap.
15
+ */
16
+ /** ~/.cruxy/memory-trust.json */
17
+ export declare function memoryTrustPath(): string;
18
+ /**
19
+ * A stable content fingerprint of a repo's PROJECT memory entries. Canonical by
20
+ * construction so a benign reformat (reordered entries, whitespace, added/removed
21
+ * `id`/`createdAt` metadata) does NOT change it, while any change to the
22
+ * meaning-bearing content DOES:
23
+ * - only `kind` + `content` are hashed (id/createdAt/scope are not meaning);
24
+ * - content is whitespace-normalized (trim + collapse runs);
25
+ * - entries are sorted so order doesn't matter.
26
+ *
27
+ * The empty set has a fixed fingerprint — trusting "no project memory" is
28
+ * meaningful (adding the first foreign entry re-gates).
29
+ */
30
+ export declare function fingerprintMemory(entries: readonly MemoryEntry[]): string;
31
+ /** The persisted trust seam — file-backed in production, injectable for tests. */
32
+ export interface MemoryTrustStore {
33
+ /** The recorded decision for a repo root, or undefined if never trusted. */
34
+ get(root: string): MemoryTrust | undefined;
35
+ /** Persist a trust decision (overwrites any prior one for the same root). */
36
+ record(trust: MemoryTrust): void;
37
+ }
38
+ /**
39
+ * Is this repo's current project memory trusted? True only when a decision
40
+ * exists AND its fingerprint matches the current one — changed project memory is
41
+ * treated as untrusted (stale), forcing a fresh `cruxy memory trust`.
42
+ */
43
+ export declare function isMemoryTrusted(store: MemoryTrustStore, root: string, currentFingerprint: string): boolean;
44
+ /**
45
+ * The real store, persisting to `~/.cruxy/memory-trust.json` as
46
+ * `{ [root]: MemoryTrust }`. Reads are lazy + cached; a corrupt file is treated
47
+ * as "no trust recorded" (fail-closed — a broken trust file must never grant
48
+ * trust).
49
+ */
50
+ export declare function fileMemoryTrustStore(file?: string): MemoryTrustStore;
51
+ /** An in-memory store for tests (and any ephemeral run). */
52
+ export declare function memoryMemoryTrustStore(seed?: MemoryTrust[]): MemoryTrustStore;
@@ -0,0 +1,106 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { globalDir } from "../config/paths.js";
5
+ import { MEMORY_TRUST_FILE_NAME } from "../constants.js";
6
+ /**
7
+ * The project-MEMORY trust model (C.29) — the exact supply-chain treatment C.19
8
+ * applies to project hooks, but with its OWN store so memory and hook trust are
9
+ * independent (the hook `trust.json` keys a single fingerprint per root; sharing
10
+ * it would couple the two, so memory gets `~/.cruxy/memory-trust.json`).
11
+ *
12
+ * Trust is recorded in the GLOBAL dir — in the user's home, NEVER inside a repo —
13
+ * so cloning a repo carries zero memory trust and an attacker cannot ship a
14
+ * pre-trusted marker. It is bound to a {@link fingerprintMemory fingerprint} of
15
+ * the exact project entries seen at trust time and re-checked on every run: if
16
+ * the project's memory changes, the fingerprint no longer matches and trust is
17
+ * stale → the entries are not recalled until re-trusted. This defeats
18
+ * trust-then-swap.
19
+ */
20
+ /** ~/.cruxy/memory-trust.json */
21
+ export function memoryTrustPath() {
22
+ return path.join(globalDir(), MEMORY_TRUST_FILE_NAME);
23
+ }
24
+ /**
25
+ * A stable content fingerprint of a repo's PROJECT memory entries. Canonical by
26
+ * construction so a benign reformat (reordered entries, whitespace, added/removed
27
+ * `id`/`createdAt` metadata) does NOT change it, while any change to the
28
+ * meaning-bearing content DOES:
29
+ * - only `kind` + `content` are hashed (id/createdAt/scope are not meaning);
30
+ * - content is whitespace-normalized (trim + collapse runs);
31
+ * - entries are sorted so order doesn't matter.
32
+ *
33
+ * The empty set has a fixed fingerprint — trusting "no project memory" is
34
+ * meaningful (adding the first foreign entry re-gates).
35
+ */
36
+ export function fingerprintMemory(entries) {
37
+ const canonical = entries
38
+ .map((e) => [e.kind, e.content.trim().replace(/\s+/g, " ")])
39
+ .sort((a, b) => a[0] === b[0] ? a[1].localeCompare(b[1]) : a[0].localeCompare(b[0]));
40
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
41
+ }
42
+ /**
43
+ * Is this repo's current project memory trusted? True only when a decision
44
+ * exists AND its fingerprint matches the current one — changed project memory is
45
+ * treated as untrusted (stale), forcing a fresh `cruxy memory trust`.
46
+ */
47
+ export function isMemoryTrusted(store, root, currentFingerprint) {
48
+ const record = store.get(path.resolve(root));
49
+ return record !== undefined && record.fingerprint === currentFingerprint;
50
+ }
51
+ // ── file-backed store ─────────────────────────────────────────────────────────
52
+ /**
53
+ * The real store, persisting to `~/.cruxy/memory-trust.json` as
54
+ * `{ [root]: MemoryTrust }`. Reads are lazy + cached; a corrupt file is treated
55
+ * as "no trust recorded" (fail-closed — a broken trust file must never grant
56
+ * trust).
57
+ */
58
+ export function fileMemoryTrustStore(file = memoryTrustPath()) {
59
+ let cache = null;
60
+ const load = () => {
61
+ if (cache)
62
+ return cache;
63
+ try {
64
+ const raw = JSON.parse(readFileSync(file, "utf8"));
65
+ cache =
66
+ raw && typeof raw === "object"
67
+ ? raw
68
+ : {};
69
+ }
70
+ catch {
71
+ // Missing or corrupt → no trust (fail-closed).
72
+ cache = {};
73
+ }
74
+ return cache;
75
+ };
76
+ return {
77
+ get(root) {
78
+ return load()[path.resolve(root)];
79
+ },
80
+ record(trust) {
81
+ const store = load();
82
+ store[path.resolve(trust.root)] = {
83
+ ...trust,
84
+ root: path.resolve(trust.root),
85
+ };
86
+ mkdirSync(path.dirname(file), { recursive: true });
87
+ // 0600: trust records name local paths; keep them owner-only.
88
+ writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
89
+ if (existsSync(file))
90
+ cache = store;
91
+ },
92
+ };
93
+ }
94
+ /** An in-memory store for tests (and any ephemeral run). */
95
+ export function memoryMemoryTrustStore(seed = []) {
96
+ const store = new Map();
97
+ for (const t of seed)
98
+ store.set(path.resolve(t.root), t);
99
+ return {
100
+ get: (root) => store.get(path.resolve(root)),
101
+ record: (trust) => void store.set(path.resolve(trust.root), {
102
+ ...trust,
103
+ root: path.resolve(trust.root),
104
+ }),
105
+ };
106
+ }
@@ -0,0 +1,101 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Types for persistent memory (C.29). Everything here is **data**: a memory
4
+ * entry is validated (fail-loud, malformed excluded) and stored as structured
5
+ * JSON that is NEVER eval'd. Recalled entries are injected into the model's
6
+ * context as clearly-demarcated REFERENCE DATA (see `recall.ts`) — they are not
7
+ * instructions and can never grant authority: the U.3 approval gate is
8
+ * structural and does not read memory at all (proven in `recall.test.ts`).
9
+ */
10
+ /**
11
+ * What a memory entry records. A small, closed set — the enum is the extension
12
+ * point (out of scope: freeform blobs, which we deliberately reject).
13
+ * - `fact` — a durable fact about the user or project.
14
+ * - `decision` — a choice made and its rationale.
15
+ * - `preference` — how the user likes things done.
16
+ */
17
+ export declare const MEMORY_KINDS: readonly ["fact", "decision", "preference"];
18
+ export type MemoryKind = (typeof MEMORY_KINDS)[number];
19
+ /**
20
+ * Where an entry lives. `user` (`~/.cruxy/memory`) is your own memory, authored
21
+ * on this machine → trusted. `project` (`<repo>/.cruxy/memory`) can arrive with
22
+ * a cloned repo (another author) → untrusted until explicitly trusted (see
23
+ * `trust.ts`). This is the supply-chain boundary.
24
+ */
25
+ export declare const MEMORY_SCOPES: readonly ["user", "project"];
26
+ export type MemoryScope = (typeof MEMORY_SCOPES)[number];
27
+ /** Recall renders `user` first (trusted, cross-project), then `project`. */
28
+ export declare const MEMORY_SCOPE_ORDER: readonly MemoryScope[];
29
+ /** Hard cap on a single note's length — memory is for terse facts, not blobs;
30
+ * this also bounds the per-entry cost of the recall budget. */
31
+ export declare const MAX_CONTENT_CHARS = 2000;
32
+ /**
33
+ * Strict schema for one memory entry as persisted. `.strict()` rejects unknown
34
+ * keys so a malformed/foreign object is a loud validation error (excluded), not
35
+ * silently absorbed. Parsed with this on every load — the file is never trusted
36
+ * shape-wise just because it is JSON.
37
+ */
38
+ export declare const MemoryEntrySchema: z.ZodObject<{
39
+ /** Stable unique id (crypto.randomUUID) — the handle for `forget`. */
40
+ id: z.ZodString;
41
+ kind: z.ZodEnum<["fact", "decision", "preference"]>;
42
+ /** The note. DATA, never instructions. Non-empty, length-capped. */
43
+ content: z.ZodString;
44
+ scope: z.ZodEnum<["user", "project"]>;
45
+ /** ISO 8601 timestamp the entry was recorded. */
46
+ createdAt: z.ZodString;
47
+ }, "strict", z.ZodTypeAny, {
48
+ id: string;
49
+ kind: "fact" | "decision" | "preference";
50
+ createdAt: string;
51
+ content: string;
52
+ scope: "project" | "user";
53
+ }, {
54
+ id: string;
55
+ kind: "fact" | "decision" | "preference";
56
+ createdAt: string;
57
+ content: string;
58
+ scope: "project" | "user";
59
+ }>;
60
+ export type MemoryEntry = z.infer<typeof MemoryEntrySchema>;
61
+ /** Current on-disk file version — lets a future migration detect old files. */
62
+ export declare const MEMORY_FILE_VERSION = 1;
63
+ /** The whole `entries.json` file shape (one file per scope). */
64
+ export declare const MemoryFileSchema: z.ZodObject<{
65
+ version: z.ZodDefault<z.ZodNumber>;
66
+ entries: z.ZodDefault<z.ZodArray<z.ZodUnknown, "many">>;
67
+ }, "strict", z.ZodTypeAny, {
68
+ entries: unknown[];
69
+ version: number;
70
+ }, {
71
+ entries?: unknown[] | undefined;
72
+ version?: number | undefined;
73
+ }>;
74
+ /** Why an entry was rejected on load — surfaced, never silently dropped. */
75
+ export type MemoryLoadReason = "invalid" | "secret";
76
+ /** A rejected entry (malformed, or a detected secret), excluded from recall and
77
+ * from the writable set, and surfaced loudly. */
78
+ export interface MemoryLoadError {
79
+ scope: MemoryScope;
80
+ reason: MemoryLoadReason;
81
+ /** The offending entry's id when known (else a positional marker). */
82
+ id: string;
83
+ message: string;
84
+ }
85
+ /** The validated result of loading one scope's file: the good entries plus the
86
+ * excluded ones. A missing file is not an error — it yields an empty result. */
87
+ export interface MemoryLoad {
88
+ entries: MemoryEntry[];
89
+ errors: MemoryLoadError[];
90
+ }
91
+ /** One repo's recorded PROJECT-memory trust decision (persisted in
92
+ * `~/.cruxy/memory-trust.json`). The `fingerprint` binds trust to the exact
93
+ * project entries seen at trust time, so editing them re-gates (see `trust.ts`). */
94
+ export interface MemoryTrust {
95
+ /** Absolute project root. */
96
+ root: string;
97
+ /** sha256 of the canonicalized project entries (see `fingerprintMemory`). */
98
+ fingerprint: string;
99
+ /** ISO timestamp the decision was recorded. */
100
+ at: string;
101
+ }
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Types for persistent memory (C.29). Everything here is **data**: a memory
4
+ * entry is validated (fail-loud, malformed excluded) and stored as structured
5
+ * JSON that is NEVER eval'd. Recalled entries are injected into the model's
6
+ * context as clearly-demarcated REFERENCE DATA (see `recall.ts`) — they are not
7
+ * instructions and can never grant authority: the U.3 approval gate is
8
+ * structural and does not read memory at all (proven in `recall.test.ts`).
9
+ */
10
+ // ── entry kind + scope ────────────────────────────────────────────────────────
11
+ /**
12
+ * What a memory entry records. A small, closed set — the enum is the extension
13
+ * point (out of scope: freeform blobs, which we deliberately reject).
14
+ * - `fact` — a durable fact about the user or project.
15
+ * - `decision` — a choice made and its rationale.
16
+ * - `preference` — how the user likes things done.
17
+ */
18
+ export const MEMORY_KINDS = ["fact", "decision", "preference"];
19
+ /**
20
+ * Where an entry lives. `user` (`~/.cruxy/memory`) is your own memory, authored
21
+ * on this machine → trusted. `project` (`<repo>/.cruxy/memory`) can arrive with
22
+ * a cloned repo (another author) → untrusted until explicitly trusted (see
23
+ * `trust.ts`). This is the supply-chain boundary.
24
+ */
25
+ export const MEMORY_SCOPES = ["user", "project"];
26
+ /** Recall renders `user` first (trusted, cross-project), then `project`. */
27
+ export const MEMORY_SCOPE_ORDER = ["user", "project"];
28
+ // ── entry schema ──────────────────────────────────────────────────────────────
29
+ /** Hard cap on a single note's length — memory is for terse facts, not blobs;
30
+ * this also bounds the per-entry cost of the recall budget. */
31
+ export const MAX_CONTENT_CHARS = 2000;
32
+ /**
33
+ * Strict schema for one memory entry as persisted. `.strict()` rejects unknown
34
+ * keys so a malformed/foreign object is a loud validation error (excluded), not
35
+ * silently absorbed. Parsed with this on every load — the file is never trusted
36
+ * shape-wise just because it is JSON.
37
+ */
38
+ export const MemoryEntrySchema = z
39
+ .object({
40
+ /** Stable unique id (crypto.randomUUID) — the handle for `forget`. */
41
+ id: z.string().min(1),
42
+ kind: z.enum(MEMORY_KINDS),
43
+ /** The note. DATA, never instructions. Non-empty, length-capped. */
44
+ content: z.string().min(1).max(MAX_CONTENT_CHARS),
45
+ scope: z.enum(MEMORY_SCOPES),
46
+ /** ISO 8601 timestamp the entry was recorded. */
47
+ createdAt: z.string().min(1),
48
+ })
49
+ .strict();
50
+ /** Current on-disk file version — lets a future migration detect old files. */
51
+ export const MEMORY_FILE_VERSION = 1;
52
+ /** The whole `entries.json` file shape (one file per scope). */
53
+ export const MemoryFileSchema = z
54
+ .object({
55
+ version: z.number().int().positive().default(MEMORY_FILE_VERSION),
56
+ entries: z.array(z.unknown()).default([]),
57
+ })
58
+ .strict();
@@ -4,6 +4,7 @@ import type { PromptIO } from "../approval/index.js";
4
4
  import { ToolRegistry, type ToolContext } from "../tools/index.js";
5
5
  import { type AgentResult } from "../agent/loop.js";
6
6
  import type { StreamRenderer } from "../render/index.js";
7
+ import type { Router } from "../routing/index.js";
7
8
  import { PlanExecutionPolicy } from "./policy.js";
8
9
  /**
9
10
  * Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
@@ -33,8 +34,16 @@ export interface PlanSessionArgs {
33
34
  dirty: boolean;
34
35
  } | null;
35
36
  projectInstructions?: string | null;
37
+ /** Persistent memory (C.29): recall block injected into the propose + execute
38
+ * phases' system prompts (reference data only). */
39
+ recalledMemory?: string | null;
36
40
  renderer?: StreamRenderer;
37
41
  /** Revision cap (defaults to {@link MAX_PLAN_REVISIONS}). */
38
42
  maxRevisions?: number;
43
+ /**
44
+ * Multi-model routing (C.30). When set, the propose phase routes on `plan`
45
+ * and step execution on `main-turn`; omitted → the provider default.
46
+ */
47
+ router?: Router;
39
48
  }
40
49
  export declare function runPlanSession(args: PlanSessionArgs): Promise<AgentResult>;
@@ -73,8 +73,11 @@ export async function runPlanSession(args) {
73
73
  ctx: args.ctx,
74
74
  git: args.git,
75
75
  projectInstructions: args.projectInstructions,
76
+ recalledMemory: args.recalledMemory,
76
77
  renderer: args.renderer,
77
78
  planMode: true,
79
+ router: args.router,
80
+ taskClass: "plan",
78
81
  }));
79
82
  if (!holder.plan) {
80
83
  throw planInvalid("the model ended its turn without calling submit_plan");
@@ -105,7 +108,10 @@ export async function runPlanSession(args) {
105
108
  ctx: args.ctx,
106
109
  git: args.git,
107
110
  projectInstructions: args.projectInstructions,
111
+ recalledMemory: args.recalledMemory,
108
112
  renderer: args.renderer,
113
+ router: args.router,
114
+ taskClass: "main-turn",
109
115
  }));
110
116
  };
111
117
  await executePlan(plan, {
@@ -39,9 +39,12 @@ export function describePhase(phase, glyph = UNICODE_GLYPHS) {
39
39
  case "thinking": {
40
40
  const t = phase.tokens;
41
41
  // Honest numbers only: no usage yet → no figure at all.
42
- return t && t.input + t.output > 0
42
+ const base = t && t.input + t.output > 0
43
43
  ? `thinking${e} ${glyph.sep} tokens ${glyph.caretUp}${formatTokens(t.input)} ${glyph.caretDown}${formatTokens(t.output)}`
44
44
  : `thinking${e}`;
45
+ // The real routing tier (C.30), appended honestly when present — same
46
+ // ` · ` joiner as the token counts.
47
+ return phase.tier ? `${base} ${glyph.sep} ${phase.tier}` : base;
45
48
  }
46
49
  case "calling-tool":
47
50
  return `${phase.label}${e}`;
@@ -53,10 +53,16 @@ export interface TokenUsage {
53
53
  * One phase is live at a time — it is a register, not a queue.
54
54
  */
55
55
  export type RenderPhase =
56
- /** Waiting on the model. `tokens` = usage accumulated so far, omitted at 0. */
56
+ /**
57
+ * Waiting on the model. `tokens` = usage accumulated so far, omitted at 0.
58
+ * `tier` = the routing tier this turn runs on (C.30), shown only when routing
59
+ * is active — an honest signal of the real tier, never a fabricated one, and
60
+ * always a tier name (never an upstream model id, U.8).
61
+ */
57
62
  {
58
63
  kind: "thinking";
59
64
  tokens?: TokenUsage;
65
+ tier?: string;
60
66
  }
61
67
  /** A tool call is executing; `label` is the human form ("read_file src/x.ts"). */
62
68
  | {
@@ -0,0 +1,2 @@
1
+ export * from "./types.js";
2
+ export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
3
+ // `resolve.ts` (tier → wire model-id) is deliberately NOT re-exported: the
4
+ // mapping is internal to routing, so it can never be reached from a user-facing
5
+ // render path (the internal-mapping-isolation guarantee, U.8).
@@ -0,0 +1,17 @@
1
+ import type { Tier } from "./types.js";
2
+ /**
3
+ * Tier → gateway wire model-id. This is the SINGLE place the mapping lives, and
4
+ * it is INTERNAL to the routing package (deliberately not re-exported from the
5
+ * barrel) so it can never be called from a user-facing render path.
6
+ *
7
+ * For the Cruxy gateway a tier IS the wire model id — the gateway maps the tier
8
+ * to a concrete upstream model SERVER-SIDE. So this returns the tier name
9
+ * unchanged: the output is always a tier, and no upstream model name can
10
+ * originate here. That property is what keeps the U.8 gag structural rather than
11
+ * a filter — there is no upstream id in the process to leak (see the tier-gag
12
+ * test, which asserts this output is always a MODEL_TIERS member).
13
+ *
14
+ * The seam exists for a future provider whose tiers map to distinct wire ids;
15
+ * that mapping would live here and here only.
16
+ */
17
+ export declare function resolveModelId(tier: Tier): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Tier → gateway wire model-id. This is the SINGLE place the mapping lives, and
3
+ * it is INTERNAL to the routing package (deliberately not re-exported from the
4
+ * barrel) so it can never be called from a user-facing render path.
5
+ *
6
+ * For the Cruxy gateway a tier IS the wire model id — the gateway maps the tier
7
+ * to a concrete upstream model SERVER-SIDE. So this returns the tier name
8
+ * unchanged: the output is always a tier, and no upstream model name can
9
+ * originate here. That property is what keeps the U.8 gag structural rather than
10
+ * a filter — there is no upstream id in the process to leak (see the tier-gag
11
+ * test, which asserts this output is always a MODEL_TIERS member).
12
+ *
13
+ * The seam exists for a future provider whose tiers map to distinct wire ids;
14
+ * that mapping would live here and here only.
15
+ */
16
+ export function resolveModelId(tier) {
17
+ return tier;
18
+ }
@@ -0,0 +1,47 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { Router, RoutingConfig, TaskClass, Tier } from "./types.js";
3
+ /**
4
+ * The tier a config resolves to when nothing else pins one down — mirrors the
5
+ * gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
6
+ * cruxy session lands on exactly the tier it does today.
7
+ */
8
+ export declare const DEFAULT_TIER: Tier;
9
+ /**
10
+ * The config-driven {@link Router}: maps a declared task class to a tier from
11
+ * `{ default, map }`, and fails loud when the resolved tier is not offered. It
12
+ * NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
13
+ */
14
+ export declare class ConfigRouter implements Router {
15
+ private readonly cfg;
16
+ private readonly offered;
17
+ /**
18
+ * @param cfg the resolved routing table (default tier + per-task map)
19
+ * @param offered the tiers this gateway/plan actually provides; a resolved
20
+ * tier outside this set fails loud. Defaults to all tiers — the
21
+ * seam a future entitlement check narrows (never a silent
22
+ * downgrade).
23
+ */
24
+ constructor(cfg: RoutingConfig, offered?: Iterable<Tier>);
25
+ select(taskClass: TaskClass): Tier;
26
+ }
27
+ /**
28
+ * Build a router from resolved config, or `null` when routing should stay
29
+ * inert. Routing is:
30
+ *
31
+ * - a cruxy-gateway concept — tiers do not apply to BYO providers, so non-cruxy
32
+ * providers get `null` (no override, their `model.model` is used unchanged);
33
+ * - opt-in — with no `routing.default` and an empty `routing.map`, this returns
34
+ * `null` so behavior (and the wire body, and the state line) is byte-identical
35
+ * to today. Multi-tier routing activates only once the user configures it.
36
+ */
37
+ export declare function routerForConfig(config: CruxyConfig): Router | null;
38
+ /**
39
+ * Resolve a declared task class to `{ tier, model }`: the tier for honest
40
+ * surfacing (the U.4 state line), the wire model id for the request. The model
41
+ * id comes from the internal {@link resolveModelId} — callers never touch that
42
+ * mapping directly, so it stays the single source of truth.
43
+ */
44
+ export declare function resolveTaskModel(router: Router, taskClass: TaskClass): {
45
+ tier: Tier;
46
+ model: string;
47
+ };