ytdb-slate 0.1.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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Auto-pause + handoff: context-budget discipline for the orchestrator.
3
+ *
4
+ * Monitoring: after every turn, when orchestrator mode is on and context usage
5
+ * crosses pauseThresholdPercent (default 40), the store is paused — the thread
6
+ * tool then rejects NEW dispatches (in-flight ones finish) — and the
7
+ * orchestrator is steered to produce a handoff brief for the user.
8
+ *
9
+ * /slate handoff [focus] → startHandoff(): captures the orchestrator's last
10
+ * assistant message as the brief, writes <config dir>/slate/pending-handoff.json
11
+ * (state snapshot + brief + parent session), and opens a fresh session.
12
+ *
13
+ * Adoption: session replacement tears down this extension instance, so
14
+ * in-memory state cannot cross over; and the fresh session's own file has no
15
+ * slate-state entries for restore() to find (they live in the parent's file).
16
+ * The pending file bridges the gap: the NEW instance's session_start handler
17
+ * adopts the snapshot when the fresh session's parentSession header matches
18
+ * (trusted projects only; stale files are reaped regardless of trust).
19
+ */
20
+
21
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import {
24
+ CONFIG_DIR_NAME,
25
+ type ExtensionAPI,
26
+ type ExtensionCommandContext,
27
+ type ExtensionContext,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import { orchestratorCostUsd, type SlateConfig, type SlateSnapshot, type SlateStore } from "./state.ts";
30
+
31
+ const DEFAULT_PAUSE_THRESHOLD_PERCENT = 40;
32
+ /** A pending-handoff file older than this cannot belong to an in-flight handoff. */
33
+ const PENDING_MAX_AGE_MS = 15 * 60 * 1000;
34
+ const BRIEF_MAX_CHARS = 6000;
35
+
36
+ interface PendingHandoff {
37
+ parentSession: string | undefined; // undefined = in-memory session; adoption then never matches
38
+ createdAt: number;
39
+ brief: string;
40
+ snapshot: SlateSnapshot;
41
+ }
42
+
43
+ export interface SlateHandoffHooks {
44
+ startHandoff(ctx: ExtensionCommandContext, focus?: string): Promise<void>;
45
+ }
46
+
47
+ function pendingFile(cwd: string): string {
48
+ return join(cwd, CONFIG_DIR_NAME, "slate", "pending-handoff.json");
49
+ }
50
+
51
+ /** Last assistant message text on the current branch (the handoff brief). */
52
+ function lastAssistantText(ctx: ExtensionCommandContext): string {
53
+ let text = "";
54
+ for (const entry of ctx.sessionManager.getBranch()) {
55
+ const e = entry as {
56
+ type: string;
57
+ message?: { role?: string; content?: string | Array<{ type: string; text?: string }> };
58
+ };
59
+ if (e.type !== "message" || e.message?.role !== "assistant") continue;
60
+ const content = e.message.content;
61
+ // Entries come from JSON on disk; content may not match the declared
62
+ // shape. Skip anything that is neither string nor block array.
63
+ if (typeof content !== "string" && !Array.isArray(content)) continue;
64
+ const t = (typeof content === "string"
65
+ ? content
66
+ : content
67
+ .filter((c) => c.type === "text")
68
+ .map((c) => c.text ?? "")
69
+ .join("\n")
70
+ ).trim();
71
+ if (t) text = t;
72
+ }
73
+ return text.length > BRIEF_MAX_CHARS ? `${text.slice(0, BRIEF_MAX_CHARS)}\n[... brief truncated]` : text;
74
+ }
75
+
76
+ // Trust gate: the project-supplied kickoff template is a project-derived
77
+ // read injected into the fresh session's first prompt, so it is honored
78
+ // only for trusted projects; untrusted → built-in kickoff text.
79
+ function buildKickoff(cwd: string, trusted: boolean, brief: string, focus?: string): string {
80
+ const template = join(cwd, CONFIG_DIR_NAME, "slate-handoff.md");
81
+ let base: string | undefined;
82
+ if (trusted) {
83
+ // existsSync alone is not enough: the path may be a directory or
84
+ // unreadable, and a throw escaping here would strand the pending-handoff
85
+ // file startHandoff just wrote — fall back to the default kickoff text.
86
+ try {
87
+ if (existsSync(template)) base = readFileSync(template, "utf8").trim();
88
+ } catch {
89
+ /* unreadable template → default kickoff */
90
+ }
91
+ }
92
+ // The "already restored" claim is only valid when the successor session
93
+ // will actually adopt the pending state — adoption is trust-gated, and the
94
+ // successor (same process, same cwd) shares this session's trust state, so
95
+ // `trusted` decides which default text is honest here.
96
+ if (!base) {
97
+ base = trusted
98
+ ? [
99
+ "Slate orchestrator handoff (context hygiene; the previous orchestrator exceeded its context budget).",
100
+ "Orchestrator mode and all worker threads/episodes from the previous session are already restored:",
101
+ "use `threads` to list them and `episode` to fetch details. Continue the work.",
102
+ ].join("\n")
103
+ : [
104
+ "Slate orchestrator handoff (context hygiene; the previous orchestrator exceeded its context budget).",
105
+ "NOTE: this project is untrusted, so slate did NOT auto-restore the previous session's threads/episodes.",
106
+ `Run /slate on if needed, then reconstruct context from the episode files under ${CONFIG_DIR_NAME}/slate/episodes/ and continue the work.`,
107
+ ].join("\n");
108
+ }
109
+ const parts = [base];
110
+ if (brief) parts.push("", "## Handoff brief from the previous orchestrator", "", brief);
111
+ if (focus) parts.push("", `Immediate focus: ${focus}`);
112
+ return parts.join("\n");
113
+ }
114
+
115
+ export function registerSlateHandoff(
116
+ pi: ExtensionAPI,
117
+ store: SlateStore,
118
+ getConfig: () => SlateConfig,
119
+ ): SlateHandoffHooks {
120
+ const checkBudget = (ctx: ExtensionContext) => {
121
+ if (!store.orchestratorMode || store.paused) return;
122
+ const percent = ctx.getContextUsage()?.percent;
123
+ if (percent == null) return;
124
+ // slate.json is user-edited: accept only finite (0, 100] thresholds.
125
+ const configured = getConfig().pauseThresholdPercent;
126
+ const threshold =
127
+ typeof configured === "number" && Number.isFinite(configured) && configured > 0 && configured <= 100
128
+ ? configured
129
+ : DEFAULT_PAUSE_THRESHOLD_PERCENT;
130
+ if (percent < threshold) return;
131
+
132
+ store.paused = true;
133
+ store.save();
134
+ const pct = Math.round(percent);
135
+ if (ctx.hasUI) {
136
+ ctx.ui.notify(
137
+ `slate: context at ${pct}% (budget ${threshold}%) — paused. Run /slate handoff [focus] to continue in a fresh session.`,
138
+ "warning",
139
+ );
140
+ }
141
+ pi.sendMessage(
142
+ {
143
+ customType: "slate-pause",
144
+ content: [
145
+ `[slate] Context is at ${pct}% — over the ${threshold}% budget. Slate auto-paused: the thread tool now REJECTS new dispatches.`,
146
+ "Finish nothing new. Reply to the user with:",
147
+ "(1) a concise HANDOFF BRIEF — overall goal, per-thread state with episode ids, immediate next actions;",
148
+ "(2) instructions: run /slate handoff [optional focus] to continue in a fresh session where all threads and episodes are restored automatically;",
149
+ `alternatively, start a new pi session manually, run /slate on, and have the new orchestrator read the episode files under ${CONFIG_DIR_NAME}/slate/episodes/.`,
150
+ ].join("\n"),
151
+ display: true,
152
+ },
153
+ { deliverAs: "steer", triggerTurn: true },
154
+ );
155
+ };
156
+
157
+ pi.on("turn_end", async (_event, ctx) => checkBudget(ctx));
158
+ pi.on("agent_end", async (_event, ctx) => checkBudget(ctx));
159
+
160
+ // Adopt a pending handoff into a fresh session. Registered AFTER index.ts's
161
+ // restore handler (so a branch that already carries slate state wins) and
162
+ // BEFORE mode.ts's (so its tool restriction sees the adopted mode).
163
+ pi.on("session_start", async (_event, ctx) => {
164
+ if (store.threads.size > 0 || store.orchestratorMode) return; // state already restored on this branch
165
+ const file = pendingFile(ctx.cwd);
166
+ try {
167
+ if (!existsSync(file)) return;
168
+ const pending = JSON.parse(readFileSync(file, "utf8")) as PendingHandoff;
169
+ const age = Date.now() - (pending.createdAt ?? 0);
170
+ // STALE reap runs regardless of trust: it deletes an abandoned runtime
171
+ // file without injecting any content into prompts or state, and keeps
172
+ // the file from lingering forever in a never-trusted project.
173
+ if (age >= PENDING_MAX_AGE_MS) {
174
+ rmSync(file, { force: true });
175
+ return;
176
+ }
177
+ // Trust gate (ADOPTION only): the pending file is project-local state a
178
+ // cloned repo could ship pre-seeded. Never adopt its content in an
179
+ // untrusted project — but leave a FRESH file untouched: the user may
180
+ // grant trust shortly after, within the handoff window.
181
+ if (!ctx.isProjectTrusted()) return;
182
+ // Never adopt into an unrelated session.
183
+ const matches = !!pending.parentSession && pending.parentSession === ctx.sessionManager.getHeader()?.parentSession;
184
+ if (!matches) return;
185
+ store.adoptSnapshot(pending.snapshot, ctx);
186
+ store.paused = false;
187
+ store.save();
188
+ rmSync(file, { force: true });
189
+ if (ctx.hasUI) {
190
+ const t = store.threads.size;
191
+ const e = store.episodes.size;
192
+ ctx.ui.notify(
193
+ `slate: handoff state restored (${t} thread${t === 1 ? "" : "s"}, ${e} episode${e === 1 ? "" : "s"}).`,
194
+ "info",
195
+ );
196
+ }
197
+ } catch {
198
+ /* a broken pending file must never break session start */
199
+ }
200
+ });
201
+
202
+ const startHandoff = async (ctx: ExtensionCommandContext, focus?: string): Promise<void> => {
203
+ await ctx.waitForIdle();
204
+
205
+ const brief = lastAssistantText(ctx);
206
+ const parentSession = ctx.sessionManager.getSessionFile();
207
+ const pending: PendingHandoff = {
208
+ parentSession,
209
+ createdAt: Date.now(),
210
+ brief,
211
+ // The successor starts unpaused and in orchestrator mode regardless of
212
+ // the current (paused) state.
213
+ snapshot: {
214
+ ...store.snapshot(),
215
+ paused: false,
216
+ orchestratorMode: true,
217
+ // The successor's own branch sum starts at zero, so bank the parent's
218
+ // billed orchestrator spend (plus anything already carried) — the
219
+ // displayed total must survive repeated handoffs.
220
+ carriedCostUsd: store.carriedCostUsd + orchestratorCostUsd(ctx),
221
+ },
222
+ };
223
+ const file = pendingFile(ctx.cwd);
224
+ mkdirSync(dirname(file), { recursive: true });
225
+ writeFileSync(file, `${JSON.stringify(pending, null, 2)}\n`, "utf8");
226
+
227
+ const kickoff = buildKickoff(ctx.cwd, ctx.isProjectTrusted(), brief, focus);
228
+ // catch, NOT finally: on success the NEW session's adoption handler has
229
+ // already consumed and deleted the pending file (session_start fires
230
+ // inside newSession) — cleaning up there would be wrong.
231
+ try {
232
+ const { cancelled } = await ctx.newSession({
233
+ parentSession,
234
+ withSession: async (fresh) => {
235
+ await fresh.sendUserMessage(kickoff);
236
+ },
237
+ });
238
+ if (cancelled) {
239
+ // Left behind, the pending file could be adopted by an unintended fork
240
+ // or session sharing this parent within the 15-min window.
241
+ rmSync(file, { force: true });
242
+ store.paused = false;
243
+ store.save();
244
+ if (ctx.hasUI) ctx.ui.notify("slate: handoff cancelled — pause cleared, pending state removed.", "warning");
245
+ }
246
+ } catch (error) {
247
+ try {
248
+ rmSync(file, { force: true });
249
+ } catch {
250
+ /* ignore */
251
+ }
252
+ // Best-effort: if the replacement partially happened, the old pi/ctx
253
+ // are stale and these calls themselves throw.
254
+ try {
255
+ store.paused = false;
256
+ store.save();
257
+ if (ctx.hasUI) {
258
+ ctx.ui.notify(
259
+ `slate: handoff failed — ${error instanceof Error ? error.message : String(error)}. Pause cleared; pending state removed.`,
260
+ "error",
261
+ );
262
+ }
263
+ } catch {
264
+ /* stale pi/ctx after partial replacement */
265
+ }
266
+ throw error;
267
+ }
268
+ };
269
+
270
+ return { startHandoff };
271
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Slate — thread-weaving agent architecture for pi.
3
+ *
4
+ * Implements the Slate architecture — design rationale and principles in
5
+ * ../docs/design-principles.md (module headers cite decision ids D3–D9/M1–M3 from
6
+ * the original ExecPlan, which is not in-repo). An orchestrator (the main pi
7
+ * session) dispatches bounded actions to persistent worker threads via the
8
+ * `thread` tool; each completed action returns an episode — a compressed,
9
+ * structured record that the orchestrator composes into further dispatches.
10
+ *
11
+ * Modules:
12
+ * state.ts — thread/episode records, session-scoped persistence
13
+ * worker.ts — in-process worker AgentSessions (recursion-guarded)
14
+ * episodes.ts — episode compression (Sonnet-default, D5)
15
+ * threads.ts — ThreadManager: queueing, dispatch lifecycle
16
+ * tools.ts — thread / threads / episode tools
17
+ * handoff.ts — context-budget auto-pause + fresh-session handoff
18
+ *
19
+ * Optional config at <config dir>/slate.json (config dir = CONFIG_DIR_NAME,
20
+ * ".pi" by default), honored ONLY when the project is trusted — untrusted
21
+ * projects run on built-in defaults with no project file injection:
22
+ * { "episodeModel": "provider/id", "workerTools": [...], "maxConcurrent": 4,
23
+ * "pauseThresholdPercent": 40, "orchestratorModeDefault": true,
24
+ * "orchestratorPromptDocs": ["docs/orchestrator-guidelines.md"],
25
+ * "workerPromptDocs": ["docs/thread-guidelines.md"],
26
+ * "workflow": { "draftPRs": false },
27
+ * "doctrineExtraPath": "docs/project-doctrine.md",
28
+ * "reviewPerspectivesPath": "docs/review-perspectives.md" }
29
+ */
30
+
31
+ import { existsSync, readFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
34
+ import { registerSlateHandoff } from "./handoff.ts";
35
+ import { registerSlateMode } from "./mode.ts";
36
+ import { SlateStore, type SlateConfig } from "./state.ts";
37
+ import { ThreadManager } from "./threads.ts";
38
+ import { registerSlateTools } from "./tools.ts";
39
+
40
+ function loadConfig(cwd: string): SlateConfig {
41
+ const file = join(cwd, CONFIG_DIR_NAME, "slate.json");
42
+ try {
43
+ if (existsSync(file)) {
44
+ // JSON.parse accepts any JSON value; only a non-null plain object is a
45
+ // usable config — a literal `null`, array, or scalar would crash
46
+ // consumers, so anything else falls through to defaults, silently,
47
+ // like any other malformed config.
48
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
49
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
50
+ return parsed as SlateConfig;
51
+ }
52
+ }
53
+ } catch {
54
+ /* invalid config → defaults */
55
+ }
56
+ return {};
57
+ }
58
+
59
+ export default function (pi: ExtensionAPI) {
60
+ const store = new SlateStore(pi);
61
+ let manager = new ThreadManager(store, {});
62
+
63
+ registerSlateTools(pi, store, () => manager);
64
+
65
+ pi.on("session_start", async (_event, ctx) => {
66
+ manager.disposeAll();
67
+ // Trust gate: project config steers prompts, models, and tool lists, so
68
+ // it is honored only for trusted projects; untrusted → built-in defaults.
69
+ manager = new ThreadManager(store, ctx.isProjectTrusted() ? loadConfig(ctx.cwd) : {});
70
+ store.restore(ctx);
71
+ });
72
+
73
+ pi.on("session_shutdown", async () => {
74
+ manager.disposeAll();
75
+ });
76
+
77
+ // session_start ordering (registration order): restore → adopt pending
78
+ // handoff → re-apply mode tools. registerSlateHandoff must therefore sit
79
+ // between the restore handler above and registerSlateMode below.
80
+ // getConfig reads the CURRENT `manager` (reassigned on session_start).
81
+ const handoff = registerSlateHandoff(pi, store, () => manager.getConfig());
82
+
83
+ registerSlateMode(pi, store, handoff, () => manager.getConfig());
84
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Orchestrator mode (ExecPlan M2, D4).
3
+ *
4
+ * `/slate` toggles orchestrator mode:
5
+ * - active tools restricted to read-only + slate tools (no bash/edit/write):
6
+ * delegation becomes the natural behavior;
7
+ * - the thread-weaving doctrine is appended to the system prompt each turn;
8
+ * - a widget above the editor shows live thread status;
9
+ * - the mode persists in slate state and is re-applied on session restore;
10
+ * - with config `orchestratorModeDefault` (slate.json), genuinely fresh
11
+ * interactive sessions are seeded with the mode ON (unsaved until the
12
+ * first real state mutation).
13
+ *
14
+ * `/slate handoff [focus]` / `/slate resume` interact with the auto-pause
15
+ * machinery in handoff.ts (context budget → paused → fresh-session handoff).
16
+ */
17
+
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import { resolve } from "node:path";
20
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import type { SlateHandoffHooks } from "./handoff.ts";
22
+ import {
23
+ DESIGN_PRINCIPLES_DOC,
24
+ PR_PUBLISHING_DOC,
25
+ REVIEW_RULES_DOC,
26
+ TRACK_WORKFLOW_DOC,
27
+ } from "./paths.ts";
28
+ import { loadPromptDocs } from "./prompt-docs.ts";
29
+ import { orchestratorCostUsd, type SlateConfig, type SlateStore } from "./state.ts";
30
+
31
+ const ORCHESTRATOR_TOOLS = ["read", "grep", "find", "ls", "thread", "threads", "episode"];
32
+
33
+ /**
34
+ * Build the orchestrator doctrine. Rules 8–10 reference the package-shipped
35
+ * workflow/review/design docs by ABSOLUTE path (paths.ts) so they resolve
36
+ * wherever the package is installed. Rules 8 and 9 carry config-dependent
37
+ * tails (workflow.draftPRs, reviewPerspectivesPath), so the doctrine is
38
+ * assembled per prompt rather than kept as a constant. Project-derived
39
+ * additions (rule 9's perspectives pointer) apply only when `trusted` and
40
+ * the file exists (cwd-resolved) — missing files are skipped silently like
41
+ * every other project doc.
42
+ */
43
+ function buildDoctrine(cwd: string, config: SlateConfig, trusted: boolean): string {
44
+ // Rule 8 tail: with draft-PR publishing enabled, the umbrella draft PR is
45
+ // one of the gates; otherwise durable records live in the workflow log.
46
+ const rule8Tail =
47
+ config.workflow?.draftPRs === true
48
+ ? `An umbrella draft PR is part of the pre-implementation gates; PR
49
+ publishing mechanics are in ${PR_PUBLISHING_DOC}.`
50
+ : `Durable workflow records anchor in the retained repo-root workflow
51
+ log per the workflow doc.`;
52
+ const perspectives = config.reviewPerspectivesPath;
53
+ const rule9Tail =
54
+ trusted && typeof perspectives === "string" && perspectives && existsSync(resolve(cwd, perspectives))
55
+ ? `
56
+ Project-specific review perspectives are defined in ${perspectives} —
57
+ load them alongside the review rules when composing reviewers.`
58
+ : "";
59
+ return `
60
+
61
+ # Slate orchestrator mode
62
+
63
+ You are the orchestrator of a thread-weaving system. You strategize; worker
64
+ threads execute. Rules:
65
+
66
+ 1. Do tactical work ONLY by dispatching bounded actions via the \`thread\` tool
67
+ (one action = one clear, completable task). You cannot edit files or run
68
+ commands yourself.
69
+ 2. Dispatch independent actions in PARALLEL by emitting several \`thread\`
70
+ calls in one turn. Never serialize what can run concurrently.
71
+ 3. Reuse a thread for follow-up actions in the same work stream — it
72
+ remembers its prior episodes. Create a new thread for a new work stream.
73
+ 4. Compose context by reference: pass prior episode ids in \`context\` instead
74
+ of restating their content.
75
+ 5. Your read-only tools (read/grep/find/ls) are for cheap orientation only;
76
+ anything substantial goes to a thread.
77
+ 6. After every episode, update your strategy. Episodes marked STATUS: FAILED
78
+ require adaptation, not blind retry.
79
+ 7. Keep your own messages strategic: goals, task routing, synthesis.
80
+ 8. Repository changes follow the slate track-based workflow — read
81
+ ${TRACK_WORKFLOW_DOC}
82
+ (skip the read if it is already in your context). Before the FIRST
83
+ dispatch that modifies files, confirm the pre-implementation gates ran
84
+ (user design review, adversarial review, user-approved scope).
85
+ ${rule8Tail}
86
+ 9. Every non-trivial change gets reviewed before it is declared done.
87
+ Before dispatching review threads, read
88
+ ${REVIEW_RULES_DOC}
89
+ (skip the read if it is already in your context) and follow it.${rule9Tail}
90
+ 10. The design principles behind this architecture are documented in
91
+ ${DESIGN_PRINCIPLES_DOC}.
92
+ Read that file only when you must reason about slate itself (explaining
93
+ it, changing the extension, or an unusual routing/compaction decision) —
94
+ never for routine dispatching. Skip the read if it is already in your
95
+ context.`;
96
+ }
97
+
98
+ /**
99
+ * Project doctrine extension (config doctrineExtraPath): read at prompt-
100
+ * assembly time so edits are picked up live, appended AFTER the numbered
101
+ * rules under a labeled section, headed by the configured (cwd-relative)
102
+ * path as given in config. Missing/unreadable/empty file or a non-string
103
+ * path → no block, silently (matches the malformed-config behavior of
104
+ * prompt-docs.ts). Never injected for untrusted projects. Blocks carry NO
105
+ * separators — the call site prefixes them, like the prompt-doc blocks.
106
+ */
107
+ function loadDoctrineExtra(cwd: string, config: SlateConfig, trusted: boolean): string[] {
108
+ const path = config.doctrineExtraPath;
109
+ if (!trusted || typeof path !== "string" || !path) return [];
110
+ try {
111
+ const content = readFileSync(resolve(cwd, path), "utf8").trim();
112
+ if (!content) return [];
113
+ return [`# Project doctrine (injected from ${path})\n\n${content}`];
114
+ } catch {
115
+ return [];
116
+ }
117
+ }
118
+
119
+ const PAUSED_ADDENDUM = `
120
+
121
+ # PAUSED — context budget exceeded
122
+
123
+ Slate is paused for handoff: thread dispatches are REJECTED. Do not start new
124
+ work. Reply with a concise handoff brief (overall goal, per-thread state with
125
+ episode ids, immediate next actions) and direct the user to run
126
+ /slate handoff [optional focus].`;
127
+
128
+ export function registerSlateMode(
129
+ pi: ExtensionAPI,
130
+ store: SlateStore,
131
+ hooks: SlateHandoffHooks,
132
+ getConfig: () => SlateConfig,
133
+ ): void {
134
+ let savedTools: string[] | undefined;
135
+ let uiCtx: ExtensionContext | undefined;
136
+
137
+ const updateWidget = () => {
138
+ if (!uiCtx?.hasUI) return;
139
+ if (!store.orchestratorMode) {
140
+ uiCtx.ui.setWidget("slate", undefined);
141
+ uiCtx.ui.setStatus("slate", undefined);
142
+ return;
143
+ }
144
+ // Orchestrator's own spend: summed over ALL entries (billed reality —
145
+ // abandoned branches still cost money), plus spend carried across handoffs.
146
+ const orchestratorCost = orchestratorCostUsd(uiCtx);
147
+ const total = orchestratorCost + store.workerCostUsd + store.carriedCostUsd;
148
+ // Keep the line short in the common no-handoff case.
149
+ const carried = store.carriedCostUsd > 0 ? ` + carried $${store.carriedCostUsd.toFixed(4)}` : "";
150
+ const costLine = `total $${total.toFixed(4)} (me $${orchestratorCost.toFixed(4)} + workers $${store.workerCostUsd.toFixed(4)}${carried})`;
151
+ uiCtx.ui.setStatus("slate", `slate: orchestrator ⋅ ${costLine}`);
152
+ const threads = [...store.threads.values()];
153
+ const lines = [
154
+ `slate ⋅ orchestrator mode ⋅ ${threads.length} thread${threads.length === 1 ? "" : "s"}`,
155
+ ` ${costLine}`,
156
+ ...(store.paused ? [" ⛔ PAUSED (context budget) — run /slate handoff"] : []),
157
+ ...threads.map(
158
+ (t) =>
159
+ ` ${t.status === "running" ? "⏳" : "·"} ${t.id} ${t.name} [${t.status}] ${t.episodeIds.length} episode${t.episodeIds.length === 1 ? "" : "s"}`,
160
+ ),
161
+ ];
162
+ uiCtx.ui.setWidget("slate", lines);
163
+ };
164
+
165
+ const setMode = (on: boolean, persist: boolean) => {
166
+ if (on && !store.orchestratorMode) {
167
+ savedTools = pi.getActiveTools();
168
+ pi.setActiveTools(ORCHESTRATOR_TOOLS);
169
+ } else if (!on && store.orchestratorMode) {
170
+ pi.setActiveTools(savedTools ?? [...pi.getAllTools().map((t) => t.name)]);
171
+ savedTools = undefined;
172
+ }
173
+ if (!on) store.paused = false; // a pause is meaningless outside orchestrator mode
174
+ store.orchestratorMode = on;
175
+ if (persist) store.save();
176
+ updateWidget();
177
+ };
178
+
179
+ // Widget refresh whenever slate state changes (dispatch start/end, new threads).
180
+ store.onDidChange = updateWidget;
181
+
182
+ pi.registerCommand("slate", {
183
+ description: "Slate orchestrator mode: on | off | handoff [focus] | resume (no arg toggles)",
184
+ handler: async (args, ctx) => {
185
+ uiCtx = ctx;
186
+ const trimmed = args?.trim() ?? "";
187
+ const [verb, ...rest] = trimmed.split(/\s+/);
188
+ const arg = verb?.toLowerCase();
189
+ if (arg === "handoff") {
190
+ if (!store.orchestratorMode) {
191
+ if (ctx.hasUI) ctx.ui.notify("slate: orchestrator mode is not active — nothing to hand off.", "warning");
192
+ return;
193
+ }
194
+ await hooks.startHandoff(ctx, rest.join(" ") || undefined);
195
+ return;
196
+ }
197
+ if (arg === "resume") {
198
+ store.paused = false;
199
+ store.save();
200
+ if (ctx.hasUI) ctx.ui.notify("slate: pause cleared — dispatches allowed again.", "info");
201
+ return;
202
+ }
203
+ const target = arg === "on" ? true : arg === "off" ? false : !store.orchestratorMode;
204
+ setMode(target, true);
205
+ if (ctx.hasUI) {
206
+ ctx.ui.notify(
207
+ target
208
+ ? "Slate orchestrator mode ON — tactical tools removed; delegate via the thread tool."
209
+ : "Slate orchestrator mode OFF — full toolset restored.",
210
+ "info",
211
+ );
212
+ }
213
+ },
214
+ });
215
+
216
+ pi.on("before_agent_start", async (event, ctx) => {
217
+ if (!store.orchestratorMode) return;
218
+ const config = getConfig();
219
+ // Trust gate: project-derived prompt content (role docs, doctrine extra,
220
+ // review-perspectives pointer) is injected only for trusted projects.
221
+ // Config itself is already trust-gated at load (index.ts); checking again
222
+ // here keeps the file reads safe regardless of where config came from.
223
+ const trusted = ctx.isProjectTrusted();
224
+ // Doc CONTENTS are re-read from disk on every agent start, so edits are
225
+ // picked up live; the doc PATH LIST comes from config, which reloads
226
+ // only on session_start (index.ts).
227
+ const docs = trusted ? loadPromptDocs(ctx.cwd, config.orchestratorPromptDocs ?? []) : [];
228
+ // Blocks carry no separators — prefix each here. When paused, the
229
+ // addendum goes LAST so the pause directive is the final word in the
230
+ // prompt, undiluted by the role guidelines.
231
+ const parts = [
232
+ buildDoctrine(ctx.cwd, config, trusted),
233
+ ...loadDoctrineExtra(ctx.cwd, config, trusted).map((d) => `\n\n${d}`),
234
+ ...docs.map((d) => `\n\n${d}`),
235
+ ];
236
+ if (store.paused) parts.push(PAUSED_ADDENDUM);
237
+ return { systemPrompt: event.systemPrompt + parts.join("") };
238
+ });
239
+
240
+ // Refresh the orchestrator's own cost after each of its settled runs.
241
+ pi.on("agent_settled", async (_event, ctx) => {
242
+ uiCtx = ctx;
243
+ updateWidget();
244
+ });
245
+
246
+ pi.on("session_start", async (_event, ctx) => {
247
+ uiCtx = ctx;
248
+ // store.restore() (index.ts) and pending-handoff adoption (handoff.ts)
249
+ // ran before this handler in registration order; re-apply the persisted
250
+ // mode to the fresh runtime.
251
+ //
252
+ // Config-driven default: seed orchestrator mode ON for a genuinely FRESH
253
+ // interactive session. Running AFTER restore and handoff adoption means
254
+ // the seed can neither clobber persisted state nor trip the adoption
255
+ // guard. "Fresh" = no message entries and no recorded slate state on the
256
+ // branch: metadata-only entries (e.g., session naming) don't suppress the
257
+ // seed, while resumed/forked real sessions and explicit /slate off
258
+ // decisions stay untouched. Deliberately NOT saved — persisting would
259
+ // lock the default into old sessions even after the config flag is later
260
+ // turned off; the first real mutation persists it. The mode === "tui"
261
+ // gate limits the seed to interactive terminal sessions — hasUI would not
262
+ // do: it is also true in RPC mode, and scripted/automated runs
263
+ // (print/JSON/RPC) must not silently lose tactical tools.
264
+ if (!store.orchestratorMode && ctx.mode === "tui" && getConfig().orchestratorModeDefault === true) {
265
+ const fresh = !ctx.sessionManager.getBranch().some((entry) => {
266
+ // Loose cast like state.ts restore(): tolerate malformed/legacy entries.
267
+ const e = entry as { type: string; customType?: string };
268
+ return e.type === "message" || (e.type === "custom" && e.customType === "slate-state");
269
+ });
270
+ if (fresh) store.orchestratorMode = true;
271
+ }
272
+ if (store.orchestratorMode) {
273
+ const active = pi.getActiveTools();
274
+ const alreadyRestricted =
275
+ active.length === ORCHESTRATOR_TOOLS.length && ORCHESTRATOR_TOOLS.every((t) => active.includes(t));
276
+ // Never capture the restricted set as the thing to restore later —
277
+ // that would make /slate off a no-op forever.
278
+ if (!alreadyRestricted) savedTools = active;
279
+ pi.setActiveTools(ORCHESTRATOR_TOOLS);
280
+ }
281
+ updateWidget();
282
+ });
283
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Package-relative doc paths.
3
+ *
4
+ * The package ships its workflow/review/design docs under docs/ next to
5
+ * extension/. The orchestrator doctrine (mode.ts) references them by
6
+ * ABSOLUTE path — resolved from this module's own location via
7
+ * import.meta.url — so they are readable regardless of the project cwd or
8
+ * where the package is installed (project-local, user-global, node_modules).
9
+ */
10
+
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const DOCS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "docs");
15
+
16
+ export const TRACK_WORKFLOW_DOC = join(DOCS_DIR, "track-workflow.md");
17
+ export const PR_PUBLISHING_DOC = join(DOCS_DIR, "pr-publishing.md");
18
+ export const REVIEW_RULES_DOC = join(DOCS_DIR, "review-rules.md");
19
+ export const DESIGN_PRINCIPLES_DOC = join(DOCS_DIR, "design-principles.md");