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.
- package/LICENSE +201 -0
- package/README.md +72 -0
- package/docs/design-principles.md +183 -0
- package/docs/pr-publishing.md +123 -0
- package/docs/review-rules.md +172 -0
- package/docs/track-workflow.md +310 -0
- package/extension/episodes.ts +200 -0
- package/extension/handoff.ts +271 -0
- package/extension/index.ts +84 -0
- package/extension/mode.ts +283 -0
- package/extension/paths.ts +19 -0
- package/extension/prompt-docs.ts +49 -0
- package/extension/render.ts +138 -0
- package/extension/state.ts +191 -0
- package/extension/threads.ts +311 -0
- package/extension/tools.ts +134 -0
- package/extension/worker.ts +119 -0
- package/package.json +26 -0
|
@@ -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");
|