castle-web-cli 0.4.124 → 0.4.126
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/dist/agent-prompts.d.ts +60 -0
- package/dist/agent-prompts.js +677 -11
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +384 -12
- package/dist/castleJson.d.ts +3 -0
- package/dist/headlessCover.js +55 -10
- package/dist/shell/assets/{index-BfOPkSej.css → index-0CshkxoU.css} +1 -1
- package/dist/shell/assets/{index-u0nYFqbF.js → index-CAzIvwhA.js} +66 -64
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/package-lock.json +1 -1
- package/package.json +1 -1
package/dist/agent-prompts.js
CHANGED
|
@@ -20,7 +20,7 @@ const TRANSCRIPT_LIMIT = 40;
|
|
|
20
20
|
// deck contents (ROUTER_DECK_CONTENTS_BUDGET, 40KB), the task board, and this
|
|
21
21
|
// turn's instruction.
|
|
22
22
|
const TRANSCRIPT_BYTE_BUDGET = 32 * 1024;
|
|
23
|
-
const
|
|
23
|
+
const BASE_ROUTER_RULES = `You are Castle's create assistant: the fast conversational router for a game-making session. The deck (game project) lives in the current directory and runs live in a pane right next to this chat.
|
|
24
24
|
|
|
25
25
|
What a deck is: a normal web project served by vite -- index.html plus plain JS/JSX modules, with real npm dependencies (more can be installed), the castle-web-sdk package, and usually a kit framework whose engine, behaviors, scenes, editors, and drawings are ordinary files in this directory. The web platform is fully available (DOM, canvas, npm libraries like react, three, etc.). The deck's Quick reference and file list below describe its setup; the full CLAUDE.md / AGENTS.md has deeper detail. NEVER claim something is impossible or unsupported on the platform without checking that context (or, for specifics it doesn't cover, the deck's files) first.
|
|
26
26
|
|
|
@@ -82,6 +82,64 @@ Conversation style:
|
|
|
82
82
|
- The user sees a live task board above the chat -- never re-announce task status yourself.
|
|
83
83
|
- Spawning a task does NOT apply the change -- tasks run for minutes and finish on the board. Talk about spawned work in future tense ("this will dial the shake back"), and NEVER ask how a change feels right after spawning it -- the user cannot have tried it yet. Save "how is it?" for things whose task already finished.
|
|
84
84
|
- The user playtests in the pane beside this chat; finished work shows up there after a reload.`;
|
|
85
|
+
// Appended to the rules above for a deck with the plan doc on -- see
|
|
86
|
+
// planSession in agent.ts for which decks those are. One self-contained block,
|
|
87
|
+
// so a deck without it gets the pre-plan prompt byte for byte.
|
|
88
|
+
//
|
|
89
|
+
// Every rule here answers to a section of the plan format spec; two of them
|
|
90
|
+
// deliberately supersede rules in the block above (pairing a question with
|
|
91
|
+
// task directives, and holding follow-up questions back), which is why they say
|
|
92
|
+
// so out loud rather than quietly contradicting them.
|
|
93
|
+
const PLAN_RULES = `Reading the user, fresh from EACH message -- the same user hands you a spec, then a fragment, then delegates:
|
|
94
|
+
- CLEAR SPEC (mechanics named, a reference given, "like X but Y"): the gaps are closed. Build what they said; never probe taste they already expressed.
|
|
95
|
+
- FRAGMENT ("something with ants", "a cozy game about tea?"): gaps they cannot name yet. Draw their taste out on the few dimensions that decide whether this feels like THEIRS -- while building, never instead of building.
|
|
96
|
+
- A fragment is not only a whole-game ask. "how about some obstacles?", "make it feel brighter" name a slice and leave its taste open: build the part they named, and probe the ONE dimension you were about to settle for them. Deciding it silently is how a deck becomes yours instead of theirs.
|
|
97
|
+
- DELEGATION ("surprise me", "you decide"): the open calls are yours. Decide; probe nothing.
|
|
98
|
+
- Praise, a question, plain conversation: answer it and do NOTHING else -- no tasks, no probe, no chips, no plan fence. Never manufacture work to seem engaged.
|
|
99
|
+
|
|
100
|
+
Probing while you build. A probe is one short plain question plus 2-4 tappable options, written inline as [[double brackets]]:
|
|
101
|
+
|
|
102
|
+
The ants are going up on screen now -- while that builds: what are YOU in this world?
|
|
103
|
+
[[Guide them with trails]] [[Command each ant]] [[Just watch them live]]
|
|
104
|
+
|
|
105
|
+
- Tapping one sends its text as the user's next message; they can ignore it and type anything else, and the build continues either way. Never phrase a probe as if an answer were required.
|
|
106
|
+
- Spawn the work the answer cannot invalidate AND attach the probe in the same reply -- this supersedes the rules above against pairing a question with task directives. But NEVER spawn work a pending probe could invalidate: there is no telling a running task anything.
|
|
107
|
+
- At most ONE probe open at a time; none on spec, delegation, or conversation turns; never re-probe something answered. Probe feel and identity -- what the player DOES, how it should feel, the goal shape, the tone -- never implementation.
|
|
108
|
+
- Chips NEVER stand on their own: a probe's question is the line right before them, and a probe is the only thing they are for. Alone they read as answers to a question nobody asked.
|
|
109
|
+
- Praise is where the pull to offer something is strongest and most wrong: "oh nice, that works great" gets one plain sentence and nothing else. Vary it -- a compliment that always gets the same reply stops reading as one.
|
|
110
|
+
|
|
111
|
+
The plan (plan.md). The deck's plan is a real file at the deck root, yours to keep and the user's to edit, where you decide in the open: it outlives this conversation, which gets trimmed with age, and task agents are given the parts that constrain them. It is rendered above. You never edit files, so you change it by emitting OPERATIONS, one per line, in a fence:
|
|
112
|
+
|
|
113
|
+
\`\`\`castle-plan
|
|
114
|
+
identity: Calm and mesmerizing -- their word was "hypnotic".
|
|
115
|
+
decide+ no-losing: nothing kills an ant ("losing ants feels bad" -- user)
|
|
116
|
+
open+ goal-shape: whether there is a goal at all
|
|
117
|
+
tried+ predators: they made the colony tense ("nobody wanted to watch" -- task a1b2c3)
|
|
118
|
+
now+ ant-trails: draw a path the ants follow
|
|
119
|
+
now- food-hauling
|
|
120
|
+
\`\`\`
|
|
121
|
+
|
|
122
|
+
- \`decide+\` / \`open+\` / \`tried+\` / \`now+\` add one line to \`## Decisions\` / \`## Open questions\` / \`## Tried\` / \`## Now\`; the \`-\` op, carrying just the label, removes it. Nothing else in the file changes -- including what the user has been writing in it.
|
|
123
|
+
- Lines read \`<label>: <text>\`. Labels are short, kebab-case, and STABLE: never renamed, reused when work picks back up on the same thing. \`+\` on a label already there REPLACES its line -- the update path, and the correction path.
|
|
124
|
+
- \`decide+\` and \`tried+\` MUST QUOTE their source: \`("<their own words>" -- user)\` for something the user actually said, \`("<its own words>" -- task <id>)\` for a note a finished task left. The quote is checked against the real text, so copy it -- a few words, one unbroken run, no \`...\`. One whose quote is missing or is not in its source -- anything you are inferring -- is filed as a proposal under \`## Open questions\` instead, which is what it is. Quoted taste only; ASK for the rest.
|
|
125
|
+
- The line lands with its quote attached: \`decide+ no-losing: nothing kills an ant ("losing ants feels bad" -- user)\` writes \`- no-losing: nothing kills an ant ("losing ants feels bad" -- user)\`. Write the line in THEIR words -- if it says more than the quote does, quote more or say less. When the quote carries the whole thing, leave your sentence out (\`decide+ no-losing: ("losing ants feels bad" -- user)\`) -- but a quote that only approves ("sounds good", "as possible") carries nothing, so there the line must say the thing it approved.
|
|
126
|
+
- \`identity:\` lines replace \`## What this game is\` outright: send all of them (at most 6) in the one fence.
|
|
127
|
+
- \`## Now\` is the work in flight, and its labels join the plan to the tasks: every \`\`\`castle-task fence carries \`item: <label>\` -- one already in \`## Now\`, or one this reply's fence adds. A chore belonging to no item says \`item: -\`.
|
|
128
|
+
- Finishing an item is \`now- <label>\`; whatever should outlive it goes to Decisions / Tried in the SAME fence, and the rest is meant to be forgotten. An item that leaves \`## Now\` with nothing promoted takes everything the two of you settled while building it with it. You never write what EXISTS: \`## Built\` is derived from the tasks for you, and board rows are not plan lines.
|
|
129
|
+
- Ops are for inflection points -- a decision crystallizes, an experiment concludes, the user reflects, an item starts or finishes. A turn that changed nothing about the plan carries no fence at all.
|
|
130
|
+
- THE USER SHAPING THE GAME IS AN INFLECTION POINT. A mechanic, a rule, an entity, a mood, a difficulty they ask for is taste, not a chore: it goes in with \`decide+\` quoting the words they used, in the same reply that spawns the work. Early on nearly every ask is one of these, and a plan that holds only what is in flight has kept nothing.
|
|
131
|
+
- A bare number, a knob setting, or a bug they want gone is not taste -- just do the tweak. The REASON they give for one is: "make the islands bigger, it's already pretty hard" is a \`decide+\` about the difficulty they want, not about island size.
|
|
132
|
+
- Put the fence AFTER your task fences: tasks spawn the moment their fence closes, so one in front just delays the user's first build.
|
|
133
|
+
- NOTHING in \`## Open questions\` may be answered by anything else in the same reply, fence or task: asking and deciding are different acts. A probe IS the top open question, asked in its own words.
|
|
134
|
+
- An ask that goes against a \`## Decisions\` line: say what was decided and why in one sentence BEFORE you build it. They may well have changed their mind -- that is theirs to say, and the line is yours to raise.
|
|
135
|
+
- No plan.md yet and the deck is getting work? Open one here with the ops the moment supports: an \`identity:\` line, the \`## Now\` items you are spawning, an \`open+\` for what you are guessing at. SPARSE AND TRUE, and never in place of their actual ask.
|
|
136
|
+
|
|
137
|
+
Promotion. Finished tasks leave notes worth keeping, listed above with the id of the task that left each. Promote the keepers by quoting the note itself and citing its task; let the rest go -- they are offered once.
|
|
138
|
+
|
|
139
|
+
If the user asks you to ease off the questions or the plan upkeep, record that with a \`decide+\` quoting the ask, and honor it -- keep \`## Now\` current, quietly.`;
|
|
140
|
+
function routerRules(plan) {
|
|
141
|
+
return plan ? `${BASE_ROUTER_RULES}\n\n${PLAN_RULES}` : BASE_ROUTER_RULES;
|
|
142
|
+
}
|
|
85
143
|
function renderTranscriptLine(m) {
|
|
86
144
|
if (m.role === "user")
|
|
87
145
|
return `user: ${m.text}`;
|
|
@@ -92,7 +150,7 @@ function renderTranscriptLine(m) {
|
|
|
92
150
|
}
|
|
93
151
|
// Cut a line to fit `budget` BYTES without splitting a multi-byte character
|
|
94
152
|
// (a half-written character would render as a replacement glyph mid-sentence).
|
|
95
|
-
function truncateToBytes(line, budget) {
|
|
153
|
+
export function truncateToBytes(line, budget) {
|
|
96
154
|
const buf = Buffer.from(line, "utf8");
|
|
97
155
|
if (buf.byteLength <= budget)
|
|
98
156
|
return line;
|
|
@@ -132,17 +190,602 @@ function renderTranscript(messages) {
|
|
|
132
190
|
const plural = elided === 1 ? "message" : "messages";
|
|
133
191
|
return `(${elided} earlier ${plural} trimmed to keep this prompt within its size limit)\n\n${kept.join("\n\n")}`;
|
|
134
192
|
}
|
|
135
|
-
|
|
193
|
+
// Per-section line caps from the plan format. Nothing enforces them on the
|
|
194
|
+
// file -- ops touch one line each and the user writes what they like -- so one
|
|
195
|
+
// runaway section is trimmed here instead, and can never crowd the rest of the
|
|
196
|
+
// plan out of the digest. Key is the lowercased heading; "" is the title +
|
|
197
|
+
// pitch ahead of the first heading, which only the user writes.
|
|
198
|
+
const PLAN_SECTION_CAPS = {
|
|
199
|
+
"": 2,
|
|
200
|
+
"what this game is": 6,
|
|
201
|
+
decisions: 12,
|
|
202
|
+
"open questions": 7,
|
|
203
|
+
now: 10,
|
|
204
|
+
tried: 8,
|
|
205
|
+
};
|
|
206
|
+
const PLAN_SECTION_DEFAULT_CAP = 8;
|
|
207
|
+
const PLAN_DIGEST_BYTE_BUDGET = 3 * 1024;
|
|
208
|
+
const BUILT_MAX_LINES = 15;
|
|
209
|
+
// plan.md's sections in the order they are written and rendered, with the
|
|
210
|
+
// heading each is created as. `built` is listed for its POSITION only: it is
|
|
211
|
+
// derived into the digest from finished tasks (see deriveBuiltLines) and is
|
|
212
|
+
// never a section of the file.
|
|
213
|
+
const PLAN_SECTION_HEADINGS = {
|
|
214
|
+
"": "",
|
|
215
|
+
"what this game is": "## What this game is",
|
|
216
|
+
decisions: "## Decisions",
|
|
217
|
+
"open questions": "## Open questions",
|
|
218
|
+
now: "## Now",
|
|
219
|
+
built: "## Built",
|
|
220
|
+
tried: "## Tried",
|
|
221
|
+
};
|
|
222
|
+
const PLAN_SECTION_ORDER = Object.keys(PLAN_SECTION_HEADINGS);
|
|
223
|
+
// A section the plan format doesn't name (the user is free to add one) sorts
|
|
224
|
+
// last rather than jumping the order.
|
|
225
|
+
function sectionRank(key) {
|
|
226
|
+
const at = PLAN_SECTION_ORDER.indexOf(key);
|
|
227
|
+
return at < 0 ? PLAN_SECTION_ORDER.length : at;
|
|
228
|
+
}
|
|
229
|
+
// The sections a task agent is given. `## Open questions` is withheld on
|
|
230
|
+
// purpose: a task must never resolve taste, and not showing it the menu is
|
|
231
|
+
// cheaper than policing it. `## Now` is withheld too except for the one line
|
|
232
|
+
// naming this task's own item -- the rest is other agents' work.
|
|
233
|
+
const TASK_PLAN_SECTIONS = new Set(["what this game is", "decisions", "tried"]);
|
|
234
|
+
function parsePlanSections(fileText) {
|
|
235
|
+
const sections = [{ heading: "", key: "", lines: [] }];
|
|
236
|
+
for (const raw of fileText.split("\n")) {
|
|
237
|
+
const heading = /^##\s+(.*)$/.exec(raw.trim());
|
|
238
|
+
if (heading) {
|
|
239
|
+
sections.push({
|
|
240
|
+
heading: raw.trim(),
|
|
241
|
+
key: heading[1].trim().toLowerCase(),
|
|
242
|
+
lines: [],
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
sections[sections.length - 1].lines.push(raw);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return sections;
|
|
250
|
+
}
|
|
251
|
+
const OP_SECTION = {
|
|
252
|
+
decide: "decisions",
|
|
253
|
+
open: "open questions",
|
|
254
|
+
tried: "tried",
|
|
255
|
+
now: "now",
|
|
256
|
+
identity: "what this game is",
|
|
257
|
+
};
|
|
258
|
+
const OP_LINE = /^(decide|open|tried|now)\s*([+-])\s*(.*)$/i;
|
|
259
|
+
const IDENTITY_LINE = /^identity\s*:\s*(.*)$/i;
|
|
260
|
+
const LABELED_TEXT = /^([^\s:]+)\s*:\s*(.+)$/;
|
|
261
|
+
// The trailing annotation, and what it says inside. Kept separate so an op's
|
|
262
|
+
// own parenthetical -- `the hop is floaty (never bouncy)` -- is left alone:
|
|
263
|
+
// only a parenthetical that says `from:` or names a source is an annotation.
|
|
264
|
+
// Dash and quote glyphs vary with whatever produced the line, so both are.
|
|
265
|
+
const SOURCE_SUFFIX = /\s*\((from:)?\s*([^()]*?)\s*\)\s*$/i;
|
|
266
|
+
// The same annotation with the brackets left off, which the model does now and
|
|
267
|
+
// then (measured 26-08-15: 1 of 10 taste ops in a t1 run, quote and task id
|
|
268
|
+
// both exact). A quote is REQUIRED in this form -- a bare `-- user` at the end
|
|
269
|
+
// of a line is something ordinary prose could land on by accident, where a
|
|
270
|
+
// quoted fragment followed by a source is not.
|
|
271
|
+
// The delimiters are matched by KIND rather than as one class of glyph: a
|
|
272
|
+
// quote delimited by double quotes may contain an apostrophe, and "it's
|
|
273
|
+
// already pretty hard" is exactly the kind of thing a person says. Measured
|
|
274
|
+
// 26-08-18: a class that excluded every quote glyph anywhere inside dropped
|
|
275
|
+
// three verifiable quotes of one message for that apostrophe alone.
|
|
276
|
+
const QUOTED_RUN = `(?:["“”][^"“”]+["“”]|['‘’][^'‘’]+['‘’])`;
|
|
277
|
+
const BARE_SOURCE = new RegExp(`\\s*[;,]?\\s*(${QUOTED_RUN}\\s*(?:--+|[–—])\\s*(?:user|task\\s+\\S+))\\s*$`, 'i');
|
|
278
|
+
const SOURCE_BODY = /^(?:["'‘’“”](.+)["'‘’“”]\s*(?:--+|[–—,-]|from:)\s*)?(?:(user)|task\s+(\S+))$/i;
|
|
279
|
+
// A trailing quote with no attributor at all -- `bigger islands -- "it's
|
|
280
|
+
// already pretty hard"`. Measured 26-08-18: the router wrote this shape 3 of 3
|
|
281
|
+
// times on one case, throwing away a quote that was verbatim and verifiable,
|
|
282
|
+
// for want of the word "user". Read as a user quote, which is the only claim it
|
|
283
|
+
// can make good on: nothing lands unless the words are really in something the
|
|
284
|
+
// user said, so an unattributed quote of a TASK note still routes to
|
|
285
|
+
// `## Open questions` exactly as it does today.
|
|
286
|
+
const UNATTRIBUTED_QUOTE = new RegExp(`\\s*[;,]?\\s*(?:--+|[–—])\\s*(${QUOTED_RUN})\\s*$`);
|
|
287
|
+
// The quote left OUTSIDE the annotation -- `bigger islands -- "it's already
|
|
288
|
+
// pretty hard" (user)`. The attributor is explicit here, so a quoted run at the
|
|
289
|
+
// end of what remains is the quote it is pointing at, and reading it that way
|
|
290
|
+
// costs nothing: the words still have to be in something the user said.
|
|
291
|
+
// Measured 26-08-18: this shape, not the missing attributor, is what threw away
|
|
292
|
+
// all three of one case's captures.
|
|
293
|
+
const TRAILING_QUOTE = new RegExp(`\\s*(?:[;,]\\s*)?(?:(?:--+|[–—])\\s*)?(${QUOTED_RUN})\\s*$`);
|
|
294
|
+
// Split a trailing `("<quote>" -- user)` / `("<quote>" -- task <id>)` off an
|
|
295
|
+
// op's text. A source the grammar does not recognize is dropped rather than
|
|
296
|
+
// kept as prose: the claim survives, its provenance does not, and the op is
|
|
297
|
+
// treated as unsourced. So is a quoteless one -- a citation nobody can check is
|
|
298
|
+
// the thing this grammar exists to stop being enough.
|
|
299
|
+
function takeSource(text) {
|
|
300
|
+
const match = SOURCE_SUFFIX.exec(text);
|
|
301
|
+
const bare = match ? null : BARE_SOURCE.exec(text);
|
|
302
|
+
const annotation = match ?? bare;
|
|
303
|
+
if (!annotation) {
|
|
304
|
+
const loose = UNATTRIBUTED_QUOTE.exec(text);
|
|
305
|
+
if (!loose)
|
|
306
|
+
return { text };
|
|
307
|
+
return {
|
|
308
|
+
text: text.slice(0, loose.index).trim(),
|
|
309
|
+
source: { kind: "user", quote: loose[1].slice(1, -1).trim() },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const body = SOURCE_BODY.exec(match ? match[2] : annotation[1]);
|
|
313
|
+
if (!body && !match?.[1])
|
|
314
|
+
return { text };
|
|
315
|
+
const stripped = text.slice(0, annotation.index).trim();
|
|
316
|
+
let quote = body?.[1]?.trim() || undefined;
|
|
317
|
+
if (!body)
|
|
318
|
+
return { text: stripped };
|
|
319
|
+
let rest = stripped;
|
|
320
|
+
if (!quote) {
|
|
321
|
+
const trailing = TRAILING_QUOTE.exec(stripped);
|
|
322
|
+
if (trailing) {
|
|
323
|
+
quote = trailing[1].slice(1, -1).trim();
|
|
324
|
+
rest = stripped.slice(0, trailing.index).trim();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (body[2])
|
|
328
|
+
return { text: rest, source: { kind: "user", quote } };
|
|
329
|
+
return { text: rest, source: { kind: "task", id: body[3], quote } };
|
|
330
|
+
}
|
|
331
|
+
const QUOTE_GLYPHS = /["'‘’‚‛′“”„‟″]/g;
|
|
332
|
+
const DASH_GLYPHS = /[‐-―−]/g;
|
|
333
|
+
const EDGE_PUNCT = /^['.,;:!?()[\]{}\s-]+|['.,;:!?()[\]{}\s-]+$/g;
|
|
334
|
+
function normalizeQuote(text) {
|
|
335
|
+
return text
|
|
336
|
+
.replace(QUOTE_GLYPHS, "'")
|
|
337
|
+
.replace(DASH_GLYPHS, "-")
|
|
338
|
+
.toLowerCase()
|
|
339
|
+
.replace(/\s+/g, " ")
|
|
340
|
+
.replace(EDGE_PUNCT, "");
|
|
341
|
+
}
|
|
342
|
+
// Does `fragment` appear verbatim in `sourceText`? Verbatim in substance rather
|
|
343
|
+
// than in bytes: a model copying a phrase out of a chat message reliably
|
|
344
|
+
// straightens the quotes and dashes a phone keyboard produced and re-wraps the
|
|
345
|
+
// whitespace, and failing a citation over a smart apostrophe would teach the
|
|
346
|
+
// router to stop quoting. The words and their order have to match exactly.
|
|
347
|
+
//
|
|
348
|
+
// Exported so the eval harness checks a fence with the byte-identical function
|
|
349
|
+
// the runtime applies it with.
|
|
350
|
+
export function quoteMatches(fragment, sourceText) {
|
|
351
|
+
const needle = normalizeQuote(fragment);
|
|
352
|
+
return needle !== "" && normalizeQuote(sourceText).includes(needle);
|
|
353
|
+
}
|
|
354
|
+
export function parsePlanOps(body) {
|
|
355
|
+
const ops = [];
|
|
356
|
+
const unparsed = [];
|
|
357
|
+
for (const raw of body.split("\n")) {
|
|
358
|
+
// A leading bullet is tolerated: plan.md's own lines are bullets, so the
|
|
359
|
+
// model reaches for one out of habit, and refusing a line whose intent is
|
|
360
|
+
// unambiguous would cost real plan updates for nothing.
|
|
361
|
+
const line = raw.trim().replace(/^[-*]\s+/, "");
|
|
362
|
+
if (!line)
|
|
363
|
+
continue;
|
|
364
|
+
const identity = IDENTITY_LINE.exec(line);
|
|
365
|
+
if (identity?.[1].trim()) {
|
|
366
|
+
ops.push({ verb: "identity", remove: false, label: "", text: identity[1].trim() });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const op = OP_LINE.exec(line);
|
|
370
|
+
const verb = op?.[1].toLowerCase();
|
|
371
|
+
const rest = op?.[3].trim() ?? "";
|
|
372
|
+
if (!verb || !rest) {
|
|
373
|
+
unparsed.push(line);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (op?.[2] === "-") {
|
|
377
|
+
ops.push({ verb, remove: true, label: rest.split(/[\s:]/)[0], text: "" });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const labeled = LABELED_TEXT.exec(rest);
|
|
381
|
+
const { text, source } = takeSource(labeled?.[2].trim() ?? "");
|
|
382
|
+
// A taste op may be nothing but its quote. Since the landed line IS the
|
|
383
|
+
// quote, the router's own sentence is vestigial, and it writes `decide+
|
|
384
|
+
// six-five: "6 letters, 5 guesses" -- user` accordingly -- 12 of 80 op
|
|
385
|
+
// lines in the first run of that rule, every one of them well-formed in
|
|
386
|
+
// intent. The grammar admits the shape its own rule invites.
|
|
387
|
+
if (!labeled || (!text && !source?.quote)) {
|
|
388
|
+
unparsed.push(line);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
ops.push({ verb, remove: false, label: labeled[1], text, source });
|
|
392
|
+
}
|
|
393
|
+
return { ops, unparsed };
|
|
394
|
+
}
|
|
395
|
+
function insertByOrder(sections, section) {
|
|
396
|
+
const rank = sectionRank(section.key);
|
|
397
|
+
const at = sections.findIndex((s) => sectionRank(s.key) > rank);
|
|
398
|
+
if (at < 0)
|
|
399
|
+
sections.push(section);
|
|
400
|
+
else
|
|
401
|
+
sections.splice(at, 0, section);
|
|
402
|
+
}
|
|
403
|
+
function sectionFor(sections, key) {
|
|
404
|
+
const existing = sections.find((s) => s.key === key);
|
|
405
|
+
if (existing)
|
|
406
|
+
return existing;
|
|
407
|
+
const created = {
|
|
408
|
+
heading: PLAN_SECTION_HEADINGS[key] ?? `## ${key}`,
|
|
409
|
+
key,
|
|
410
|
+
lines: [],
|
|
411
|
+
};
|
|
412
|
+
insertByOrder(sections, created);
|
|
413
|
+
return created;
|
|
414
|
+
}
|
|
415
|
+
function labelAt(section, label) {
|
|
416
|
+
if (!section)
|
|
417
|
+
return -1;
|
|
418
|
+
const want = label.toLowerCase();
|
|
419
|
+
return section.lines.findIndex((l) => planItemLabel(l)?.toLowerCase() === want);
|
|
420
|
+
}
|
|
421
|
+
// Sourced means: the quote is really a fragment of what the user said, or of
|
|
422
|
+
// what the cited task handed in. Naming a real source is not enough -- a
|
|
423
|
+
// citation can point at something that exists and still attach a claim that
|
|
424
|
+
// source never made, which is the whole of what the quote is checked for. A
|
|
425
|
+
// cited id that names no task has no material at all, so it cannot verify.
|
|
426
|
+
function isSourced(op, sources) {
|
|
427
|
+
const quote = op.source?.quote;
|
|
428
|
+
if (!op.source || !quote)
|
|
429
|
+
return false;
|
|
430
|
+
if (op.source.kind === "user") {
|
|
431
|
+
return (sources?.userMessages ?? []).some((m) => quoteMatches(quote, m));
|
|
432
|
+
}
|
|
433
|
+
const reported = sources?.taskReports?.get(op.source.id ?? "");
|
|
434
|
+
return Boolean(reported) && quoteMatches(quote, reported ?? "");
|
|
435
|
+
}
|
|
436
|
+
// The quote rides into the file with the line it backs: a decision the user
|
|
437
|
+
// reads should show the words it came from, and the next turn's digest carries
|
|
438
|
+
// them back to the router as the standard its own lines are held to.
|
|
439
|
+
// The line is the router's sentence, quote appended -- the quote-only form
|
|
440
|
+
// (26-08-18, one night) lost the content whenever the quoted words were
|
|
441
|
+
// ratification rather than substance (`- fewest-strokes: "as possible" --
|
|
442
|
+
// user`, from a live sitting), which is what the 1b" ruling that killed
|
|
443
|
+
// quote-only ops predicted. The ~1-in-3 of sentences a strict judge grades as
|
|
444
|
+
// saying more than their quote is ACCEPTED: Eric read the flagged examples
|
|
445
|
+
// (26-08-18) and called them "close enough to the written intent" -- an
|
|
446
|
+
// entailment judge built to police them was torn back out the same day as not
|
|
447
|
+
// worth a model call. The periodic fidelity monitor remains the drift
|
|
448
|
+
// tripwire. An op whose text is empty still lands as its quote alone -- there
|
|
449
|
+
// the quote IS the whole content.
|
|
450
|
+
function sourceLine(label, source, text) {
|
|
451
|
+
const who = source.kind === "user" ? "user" : `task ${source.id}`;
|
|
452
|
+
if (!text.trim())
|
|
453
|
+
return `- ${label}: "${source.quote}" -- ${who}`;
|
|
454
|
+
return `- ${label}: ${text} ("${source.quote}" -- ${who})`;
|
|
455
|
+
}
|
|
456
|
+
function isTasteAdd(op) {
|
|
457
|
+
return !op.remove && (op.verb === "decide" || op.verb === "tried");
|
|
458
|
+
}
|
|
459
|
+
function applyOne(sections, op, notices, sources) {
|
|
460
|
+
// The one transform in the whole write path: taste the router cannot source
|
|
461
|
+
// becomes a question instead of a decision. It is asked rather than dropped
|
|
462
|
+
// -- the observation may well be right, it just isn't the router's to settle.
|
|
463
|
+
const taste = isTasteAdd(op);
|
|
464
|
+
const routed = taste && !isSourced(op, sources);
|
|
465
|
+
const key = routed ? "open questions" : OP_SECTION[op.verb];
|
|
466
|
+
if (op.remove) {
|
|
467
|
+
const section = sections.find((s) => s.key === key);
|
|
468
|
+
const at = labelAt(section, op.label);
|
|
469
|
+
if (!section || at < 0) {
|
|
470
|
+
notices.push(`\`${op.verb}- ${op.label}\` matched no line in ${PLAN_SECTION_HEADINGS[key]}`);
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
section.lines.splice(at, 1);
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
const line = taste && !routed && op.source
|
|
477
|
+
? sourceLine(op.label, op.source, op.text)
|
|
478
|
+
: `- ${op.label}: ${op.text}${routed ? " (proposed -- confirm?)" : ""}`;
|
|
479
|
+
const section = sectionFor(sections, key);
|
|
480
|
+
const at = labelAt(section, op.label);
|
|
481
|
+
if (at >= 0) {
|
|
482
|
+
if (section.lines[at].trim() === line)
|
|
483
|
+
return false;
|
|
484
|
+
section.lines[at] = line;
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
let end = section.lines.length;
|
|
488
|
+
while (end > 0 && !section.lines[end - 1].trim())
|
|
489
|
+
end -= 1;
|
|
490
|
+
section.lines.splice(end, 0, line);
|
|
491
|
+
}
|
|
492
|
+
if (routed) {
|
|
493
|
+
const why = op.source?.quote
|
|
494
|
+
? `quoted words that are not in ${op.source.kind === "user" ? "anything the user said" : `anything task ${op.source.id} reported`}`
|
|
495
|
+
: "carried no quote of its source";
|
|
496
|
+
notices.push(`\`${op.verb}+ ${op.label}\` ${why}, so it is an open question now, not a decision -- promote it with a verbatim quote once you can point at one.`);
|
|
497
|
+
}
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
// Re-emit the file from its sections: headings in order, one blank line before
|
|
501
|
+
// each, no trailing whitespace. Only lines an op touched differ from what came
|
|
502
|
+
// in -- everything else, including whatever the user wrote, comes back through.
|
|
503
|
+
function renderPlanFile(sections) {
|
|
504
|
+
const out = [];
|
|
505
|
+
for (const section of sections) {
|
|
506
|
+
const lines = [...section.lines];
|
|
507
|
+
while (lines.length > 0 && !lines[lines.length - 1].trim())
|
|
508
|
+
lines.pop();
|
|
509
|
+
if (section.heading) {
|
|
510
|
+
if (out.length > 0)
|
|
511
|
+
out.push("");
|
|
512
|
+
out.push(section.heading);
|
|
513
|
+
}
|
|
514
|
+
out.push(...lines);
|
|
515
|
+
}
|
|
516
|
+
return out.join("\n").trim();
|
|
517
|
+
}
|
|
518
|
+
// Apply `ops` to a plan file's text. `sources` is the material a taste op's
|
|
519
|
+
// quote is checked against; an op whose quote is not in it is routed to
|
|
520
|
+
// `## Open questions` as a proposal, the same as one that cited nothing.
|
|
521
|
+
export function applyPlanOps(fileText, ops, sources) {
|
|
522
|
+
const sections = parsePlanSections(fileText);
|
|
523
|
+
const notices = [];
|
|
524
|
+
const identity = [];
|
|
525
|
+
const closed = [];
|
|
526
|
+
let applied = 0;
|
|
527
|
+
for (const op of ops) {
|
|
528
|
+
if (op.verb === "identity")
|
|
529
|
+
identity.push(`- ${op.text}`);
|
|
530
|
+
else if (applyOne(sections, op, notices, sources)) {
|
|
531
|
+
applied += 1;
|
|
532
|
+
if (op.verb === "now" && op.remove)
|
|
533
|
+
closed.push(op.label);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// Closing an item is the moment its taste stops being visible anywhere: the
|
|
537
|
+
// board rows go, the transcript ages out, and none of it is in the file. The
|
|
538
|
+
// rule to promote first is a rule the router routinely skips, and the runtime
|
|
539
|
+
// cannot write the promotion itself -- plan.md has two writers and neither is
|
|
540
|
+
// this code -- so it says on the next turn what it can see went missing.
|
|
541
|
+
if (closed.length > 0 && !ops.some((op) => isTasteAdd(op) && isSourced(op, sources))) {
|
|
542
|
+
for (const label of closed) {
|
|
543
|
+
notices.push(`\`now- ${label}\` closed the item with nothing promoted -- if anything you settled while building it should outlive it, add it now with a \`decide+\` or \`tried+\` quoting where it came from.`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Identity is the one section written whole rather than by line: it is six
|
|
547
|
+
// lines of the deck's own voice, and rewriting it a line at a time would
|
|
548
|
+
// leave halves of two different readings standing.
|
|
549
|
+
if (identity.length > 0) {
|
|
550
|
+
const section = sectionFor(sections, OP_SECTION.identity);
|
|
551
|
+
if (section.lines.map((l) => l.trim()).filter(Boolean).join("\n") !== identity.join("\n")) {
|
|
552
|
+
section.lines = identity;
|
|
553
|
+
applied += 1;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return { text: renderPlanFile(sections), applied, notices };
|
|
557
|
+
}
|
|
558
|
+
// Blank lines are dropped rather than reproduced: the digest is read, not
|
|
559
|
+
// edited, and the file's spacing is pure byte cost here.
|
|
560
|
+
function trimSection(section) {
|
|
561
|
+
const cap = PLAN_SECTION_CAPS[section.key] ?? PLAN_SECTION_DEFAULT_CAP;
|
|
562
|
+
const kept = [];
|
|
563
|
+
let trimmed = false;
|
|
564
|
+
for (const line of section.lines) {
|
|
565
|
+
if (!line.trim())
|
|
566
|
+
continue;
|
|
567
|
+
if (kept.length >= cap) {
|
|
568
|
+
trimmed = true;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
kept.push(line.trim());
|
|
572
|
+
}
|
|
573
|
+
return { lines: kept, trimmed };
|
|
574
|
+
}
|
|
575
|
+
function planItemLabel(line) {
|
|
576
|
+
return /^[-*]\s*([^\s:]+)\s*:/.exec(line)?.[1];
|
|
577
|
+
}
|
|
578
|
+
// The machine half of the digest: which board rows belong to this plan item.
|
|
579
|
+
// It is joined in here, never written into plan.md -- the file stays prose with
|
|
580
|
+
// two human writers, and everything the runtime knows rides in the render.
|
|
581
|
+
function overlayItemTasks(line, tasks) {
|
|
582
|
+
const label = planItemLabel(line);
|
|
583
|
+
if (!label)
|
|
584
|
+
return line;
|
|
585
|
+
const rows = tasks.filter((t) => t.item === label);
|
|
586
|
+
if (rows.length === 0)
|
|
587
|
+
return line;
|
|
588
|
+
return `${line} [${rows.map((t) => `${t.status}: ${t.title}`).join("; ")}]`;
|
|
589
|
+
}
|
|
590
|
+
// Which section gives up a line next when the digest is over budget: the
|
|
591
|
+
// longest, so no section is starved by a neighbour's length. Nothing is trimmed
|
|
592
|
+
// below its last line -- a plan at every cap otherwise loses whole sections off
|
|
593
|
+
// the end.
|
|
594
|
+
//
|
|
595
|
+
// `## Built` yields only once nothing else can, because a PARTIAL list of what
|
|
596
|
+
// exists reads as the whole list: measured 26-08-15, a mature deck whose Built
|
|
597
|
+
// was trimmed to five lines had its router re-spawn work that already existed
|
|
598
|
+
// on 3 of 15 covered-work samples, and on the same fixtures with Built whole,
|
|
599
|
+
// none -- some of those turns telling the user the thing was already there
|
|
600
|
+
// instead of building it again. The exemption is free: the digest lands on the
|
|
601
|
+
// same bytes either way, the taste sections just yield them.
|
|
602
|
+
function trimVictim(sections) {
|
|
603
|
+
const longestOf = (eligible) => {
|
|
604
|
+
let longest;
|
|
605
|
+
for (const section of sections) {
|
|
606
|
+
if (section.lines.length <= 1 || !eligible(section))
|
|
607
|
+
continue;
|
|
608
|
+
if (!longest || section.lines.length > longest.lines.length)
|
|
609
|
+
longest = section;
|
|
610
|
+
}
|
|
611
|
+
return longest;
|
|
612
|
+
};
|
|
613
|
+
// Decisions is spared until everything else is down to a line, for the same
|
|
614
|
+
// reason Built is spared entirely: a decision the router cannot see is a
|
|
615
|
+
// decision it builds against (4.4's beat 5; the wall's P1 3/3). The capture
|
|
616
|
+
// rules (26-08-18) make Decisions the fastest-growing section, which under
|
|
617
|
+
// longest-first trimming made it the STANDING victim -- the instrument
|
|
618
|
+
// caught the fourth Decisions line failing to survive the digest at caps
|
|
619
|
+
// before any real deck did.
|
|
620
|
+
return (longestOf((s) => s.key !== "built" && s.key !== "decisions") ??
|
|
621
|
+
longestOf((s) => s.key !== "built") ??
|
|
622
|
+
longestOf(() => true));
|
|
623
|
+
}
|
|
624
|
+
function builtLine(work) {
|
|
625
|
+
const files = work.files?.length ? ` (files: ${work.files.join(", ")})` : "";
|
|
626
|
+
const knobs = work.knobs?.length ? ` (knobs: ${work.knobs.join(", ")})` : "";
|
|
627
|
+
return `- ${work.item}: ${work.what}${files}${knobs}`;
|
|
628
|
+
}
|
|
629
|
+
// `## Built`, derived: one line per plan item finished work reported on, newest
|
|
630
|
+
// first. Latest-wins per item rather than a log -- the section answers "what
|
|
631
|
+
// exists now", and a per-task history would grow with every task the deck ever
|
|
632
|
+
// ran. What is evicted is still on disk in the task handoffs.
|
|
633
|
+
function deriveBuiltLines(finished) {
|
|
634
|
+
const latest = new Map();
|
|
635
|
+
for (const work of finished) {
|
|
636
|
+
if (!work.item.trim() || work.item === "-" || !work.what.trim())
|
|
637
|
+
continue;
|
|
638
|
+
const prev = latest.get(work.item);
|
|
639
|
+
if (!prev || (work.at ?? "") >= (prev.at ?? ""))
|
|
640
|
+
latest.set(work.item, work);
|
|
641
|
+
}
|
|
642
|
+
const newestFirst = [...latest.values()].sort((a, b) => (b.at ?? "").localeCompare(a.at ?? ""));
|
|
643
|
+
const lines = newestFirst.slice(0, BUILT_MAX_LINES).map(builtLine);
|
|
644
|
+
const dropped = newestFirst.length - lines.length;
|
|
645
|
+
if (dropped > 0)
|
|
646
|
+
lines.push(`(${dropped} more in the archive)`);
|
|
647
|
+
return lines;
|
|
648
|
+
}
|
|
649
|
+
function fitPlanSections(plan, tasks) {
|
|
650
|
+
const sections = [];
|
|
651
|
+
for (const section of parsePlanSections(plan.fileText ?? "")) {
|
|
652
|
+
const { lines, trimmed } = trimSection(section);
|
|
653
|
+
if (lines.length === 0 && !section.heading)
|
|
654
|
+
continue;
|
|
655
|
+
sections.push({
|
|
656
|
+
heading: section.heading,
|
|
657
|
+
key: section.key,
|
|
658
|
+
lines: section.key === "now"
|
|
659
|
+
? lines.map((l) => overlayItemTasks(l, tasks))
|
|
660
|
+
: lines,
|
|
661
|
+
trimmed,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
const built = deriveBuiltLines(plan.finishedWork ?? []);
|
|
665
|
+
if (built.length > 0) {
|
|
666
|
+
insertByOrder(sections, {
|
|
667
|
+
heading: "## Built",
|
|
668
|
+
key: "built",
|
|
669
|
+
lines: built,
|
|
670
|
+
trimmed: false,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
const render = () => sections
|
|
674
|
+
.map((s) => [s.heading, ...s.lines, s.trimmed ? "(trimmed)" : ""]
|
|
675
|
+
.filter(Boolean)
|
|
676
|
+
.join("\n"))
|
|
677
|
+
.join("\n\n");
|
|
678
|
+
while (Buffer.byteLength(render(), "utf8") > PLAN_DIGEST_BYTE_BUDGET) {
|
|
679
|
+
const victim = trimVictim(sections);
|
|
680
|
+
if (!victim)
|
|
681
|
+
break;
|
|
682
|
+
victim.lines.pop();
|
|
683
|
+
victim.trimmed = true;
|
|
684
|
+
}
|
|
685
|
+
// Backstop for a plan whose remaining one-line-per-section still overruns
|
|
686
|
+
// (pathological line lengths); the 8KB file cap upstream keeps it rare.
|
|
687
|
+
const rendered = render();
|
|
688
|
+
if (Buffer.byteLength(rendered, "utf8") <= PLAN_DIGEST_BYTE_BUDGET) {
|
|
689
|
+
return rendered;
|
|
690
|
+
}
|
|
691
|
+
return `${truncateToBytes(rendered, PLAN_DIGEST_BYTE_BUDGET).trimEnd()}\n(trimmed)`;
|
|
692
|
+
}
|
|
693
|
+
// The plan as the router sees it: the file (capped, with board status joined
|
|
694
|
+
// onto its items and `## Built` derived in) plus the nominations waiting on its
|
|
695
|
+
// next fence.
|
|
696
|
+
//
|
|
697
|
+
// The nominations are appended AFTER the file's byte budget rather than inside
|
|
698
|
+
// it. They are small and bounded, and they are the input to promotion -- a
|
|
699
|
+
// long plan crowding them out would silently stop promotion working at exactly
|
|
700
|
+
// the point a deck most needs it.
|
|
701
|
+
export function renderPlanDigest(plan, tasks) {
|
|
702
|
+
const fitted = fitPlanSections(plan, tasks);
|
|
703
|
+
// A deck can have finished work before it has a plan.md -- what exists is
|
|
704
|
+
// still worth showing, and saying the file is missing is what gets one
|
|
705
|
+
// drafted.
|
|
706
|
+
const parts = [
|
|
707
|
+
plan.fileText?.trim()
|
|
708
|
+
? fitted
|
|
709
|
+
: ["(no plan.md yet)", fitted].filter(Boolean).join("\n\n"),
|
|
710
|
+
];
|
|
711
|
+
const nominations = plan.pendingDurables ?? [];
|
|
712
|
+
if (nominations.length > 0) {
|
|
713
|
+
parts.push(`Notes from finished work, not in the plan yet -- promote the keepers with a \`decide+\` / \`tried+\` quoting the note and citing its task, let the rest go:\n${nominations
|
|
714
|
+
.map((n) => `- task ${n.taskId} (${n.taskTitle}): ${n.text}`)
|
|
715
|
+
.join("\n")}`);
|
|
716
|
+
}
|
|
717
|
+
const notices = plan.notices ?? [];
|
|
718
|
+
if (notices.length > 0) {
|
|
719
|
+
parts.push(`Plan upkeep from the runtime -- act on each this turn:\n${notices
|
|
720
|
+
.map((n) => `- ${n}`)
|
|
721
|
+
.join("\n")}`);
|
|
722
|
+
}
|
|
723
|
+
return parts.join("\n\n");
|
|
724
|
+
}
|
|
725
|
+
// The non-empty lines of `## Open questions`, for the runtime's agenda nudge
|
|
726
|
+
// (agent.ts): a standing question the router keeps not asking gets named in a
|
|
727
|
+
// notice, since five wordings of a prompt rule for the same behavior all
|
|
728
|
+
// measured 0 (26-08-18) while the notice-shaped close rule measured real.
|
|
729
|
+
export function planOpenQuestionLines(fileText) {
|
|
730
|
+
const section = parsePlanSections(fileText).find((s) => s.key === "open questions");
|
|
731
|
+
return (section?.lines ?? []).map((l) => l.trim()).filter(Boolean);
|
|
732
|
+
}
|
|
733
|
+
// The slice one task agent gets: the constraints and the don't-rebuilds, plus
|
|
734
|
+
// the single item line its own work belongs to. See TASK_PLAN_SECTIONS for what
|
|
735
|
+
// is deliberately left out.
|
|
736
|
+
export function renderTaskPlanSlice(fileText, item, finishedWork) {
|
|
737
|
+
const sections = parsePlanSections(fileText);
|
|
738
|
+
const parts = [];
|
|
739
|
+
// Only this item's own Built line: what already exists where this task is
|
|
740
|
+
// about to work, so it extends rather than rebuilds. Other items' work is
|
|
741
|
+
// not its business.
|
|
742
|
+
const built = deriveBuiltLines((finishedWork ?? []).filter((w) => item && w.item === item));
|
|
743
|
+
let builtDone = built.length === 0;
|
|
744
|
+
const pushBuilt = () => {
|
|
745
|
+
if (builtDone)
|
|
746
|
+
return;
|
|
747
|
+
parts.push(["## Built", ...built].join("\n"));
|
|
748
|
+
builtDone = true;
|
|
749
|
+
};
|
|
750
|
+
for (const section of sections) {
|
|
751
|
+
if (section.key !== "" && !TASK_PLAN_SECTIONS.has(section.key))
|
|
752
|
+
continue;
|
|
753
|
+
const { lines } = trimSection(section);
|
|
754
|
+
if (lines.length === 0)
|
|
755
|
+
continue;
|
|
756
|
+
if (sectionRank(section.key) > sectionRank("built"))
|
|
757
|
+
pushBuilt();
|
|
758
|
+
parts.push([section.heading, ...lines].filter(Boolean).join("\n"));
|
|
759
|
+
}
|
|
760
|
+
pushBuilt();
|
|
761
|
+
const itemLine = item
|
|
762
|
+
? sections
|
|
763
|
+
.find((s) => s.key === "now")
|
|
764
|
+
?.lines.map((l) => l.trim())
|
|
765
|
+
.find((l) => planItemLabel(l) === item)
|
|
766
|
+
: undefined;
|
|
767
|
+
if (itemLine)
|
|
768
|
+
parts.push(`The plan item your task belongs to:\n${itemLine}`);
|
|
769
|
+
return parts.join("\n\n");
|
|
770
|
+
}
|
|
771
|
+
// `showItems` is the plan opt's presence, not the row's. A deck that ran with
|
|
772
|
+
// the plan doc on and then switched it off still has item labels on its old
|
|
773
|
+
// task records, and rendering them would put plan vocabulary in the prompt of a
|
|
774
|
+
// deck that opted out -- which is both meaningless there (nothing tells that
|
|
775
|
+
// router what an item is) and a break in the byte-identity the opt-out
|
|
776
|
+
// promises.
|
|
777
|
+
function renderTasks(tasks, showItems) {
|
|
136
778
|
if (tasks.length === 0)
|
|
137
779
|
return "(none yet)";
|
|
138
780
|
return tasks
|
|
139
781
|
.map((t) => {
|
|
782
|
+
const item = showItems && t.item ? ` -- item: ${t.item}` : "";
|
|
140
783
|
const notes = t.notes.trim() ? ` -- notes: ${t.notes.trim()}` : "";
|
|
141
784
|
const error = t.error ? ` -- error: ${t.error}` : "";
|
|
142
785
|
const blockedBy = t.blockedBy && t.blockedBy.length > 0
|
|
143
786
|
? ` -- blocked by: ${t.blockedBy.join(", ")}`
|
|
144
787
|
: "";
|
|
145
|
-
return `- [${t.status} ${t.progress}%] ${t.title} (${t.id})${notes}${error}${blockedBy}`;
|
|
788
|
+
return `- [${t.status} ${t.progress}%] ${t.title} (${t.id})${item}${notes}${error}${blockedBy}`;
|
|
146
789
|
})
|
|
147
790
|
.join("\n");
|
|
148
791
|
}
|
|
@@ -162,20 +805,26 @@ export function buildRouterPrompt(opts) {
|
|
|
162
805
|
const deckSource = opts.deckContents?.trim()
|
|
163
806
|
? `\n\n== deck source (current contents -- no need to read_file these to see what's already there) ==\n${opts.deckContents.trim()}`
|
|
164
807
|
: "";
|
|
808
|
+
// Sits with the board, not with the deck identity above: the plan changes on
|
|
809
|
+
// roughly the turns the board does, so co-locating them means it invalidates
|
|
810
|
+
// nothing the board wasn't already invalidating.
|
|
811
|
+
const plan = opts.plan
|
|
812
|
+
? `\n\n== plan ==\n${renderPlanDigest(opts.plan, opts.tasks)}`
|
|
813
|
+
: "";
|
|
165
814
|
// Section order is stable-to-volatile for prompt caching: rules + deck
|
|
166
815
|
// identity never change within a serve, the transcript is append-only, and
|
|
167
816
|
// the churning state (file tree, task board, instruction) rides at the tail
|
|
168
817
|
// so a task creating a file doesn't invalidate the cached transcript prefix.
|
|
169
|
-
return `${
|
|
818
|
+
return `${routerRules(opts.plan !== undefined)}
|
|
170
819
|
|
|
171
820
|
== deck ==
|
|
172
821
|
${opts.deckLabel}${quickReference}
|
|
173
822
|
|
|
174
823
|
== conversation so far ==
|
|
175
|
-
${renderTranscript(opts.messages)}${deckFiles}${deckSource}
|
|
824
|
+
${renderTranscript(opts.messages)}${deckFiles}${deckSource}${plan}
|
|
176
825
|
|
|
177
826
|
== background tasks ==
|
|
178
|
-
${renderTasks(opts.tasks)}
|
|
827
|
+
${renderTasks(opts.tasks, opts.plan !== undefined)}
|
|
179
828
|
|
|
180
829
|
== now ==
|
|
181
830
|
${opts.instruction}
|
|
@@ -226,6 +875,20 @@ export function buildTaskPrompt(opts) {
|
|
|
226
875
|
const deckSource = opts.deckContents?.trim()
|
|
227
876
|
? `\n\nDeck source (complete current contents of the deck's text files, snapshot at task start -- you do NOT need to read these files before editing them, edit directly; a file marked "not inlined" is either binary/generated or didn't fit the budget, so read it yourself first if you need it):\n\n${opts.deckContents.trim()}\n`
|
|
228
877
|
: "";
|
|
878
|
+
const planSliceText = opts.plan
|
|
879
|
+
? renderTaskPlanSlice(opts.plan.fileText ?? "", opts.plan.item, opts.plan.finishedWork)
|
|
880
|
+
: "";
|
|
881
|
+
const planSlice = planSliceText.trim()
|
|
882
|
+
? `\n\nThe deck's plan (from plan.md):\n\n${planSliceText}\n`
|
|
883
|
+
: "";
|
|
884
|
+
const planRules = [
|
|
885
|
+
planSlice
|
|
886
|
+
? "\n- The plan above is context, not a brief. It tells you what is settled and what already exists; it never widens your task. Do NOT edit plan.md -- it belongs to the conversation agent and the user."
|
|
887
|
+
: "",
|
|
888
|
+
opts.plan
|
|
889
|
+
? `\n- Before you finish, write these lines to ${opts.handoffPath} -- they are the ONLY record of this task once the task itself is forgotten:\n - \`what: <what now exists, and where>\` -- an existence fact, not a report of activity: "a shop panel in ui/shop.js the bag button opens", never "updated the shop logic". This is what the plan shows as already built, so a later agent extends it instead of rebuilding it.\n - \`files: <the files you created or changed, comma-separated>\`\n - \`knobs: <the values worth tuning, comma-separated>\` -- names only, and only if there are real ones.\n - \`durable: <fact>\` -- the single thing worth remembering that is NOT covered by the three above (a preference the user expressed, a constraint you ran into, an approach that did not work). At most 200 characters. Leave it out only if nothing genuinely qualifies.`
|
|
890
|
+
: "",
|
|
891
|
+
].join("");
|
|
229
892
|
// Claude-only: collapsing the wrap-up (progress 90 + restart + notes) into
|
|
230
893
|
// one shell call reliably saves 1-2 serial ~7s turns there. Cursor's
|
|
231
894
|
// composer sometimes reacts to the same rule with MORE calls, so it stays
|
|
@@ -233,21 +896,24 @@ export function buildTaskPrompt(opts) {
|
|
|
233
896
|
// (We deliberately do NOT tell agents to overwrite files via `cat > … <<EOF`
|
|
234
897
|
// -- blind whole-file rewrites made parallel agents clobber each other's
|
|
235
898
|
// edits. Read-then-edit is slower but safe; that is the right tradeoff.)
|
|
899
|
+
const wrapUpList = opts.plan
|
|
900
|
+
? "the 90-progress write, the `castle-web save-version` for your paths, the final `npm run restart`, writing the notes file, and the handoff lines"
|
|
901
|
+
: "the 90-progress write, the `castle-web save-version` for your paths, the final `npm run restart`, and writing the notes file";
|
|
236
902
|
const wrapUp = opts.backend === "claude" || opts.backend === "smith"
|
|
237
|
-
? `\n- Wrap up in ONE tool call, not several: once your last file edit is done, combine
|
|
903
|
+
? `\n- Wrap up in ONE tool call, not several: once your last file edit is done, combine ${wrapUpList} into a single shell command (\`;\`-separated so the notes land even if the restart hiccups). Then stop -- no extra turns after it.`
|
|
238
904
|
: "";
|
|
239
905
|
return `You are a background build agent for the Castle deck "${opts.deckLabel}" (current directory). A separate conversation agent dispatched you with one task. Follow the deck's CLAUDE.md / AGENTS.md conventions, and reload the served deck after changes (\`npm run restart\`).${quickReference}${layout}${deckSource}
|
|
240
906
|
|
|
241
907
|
Your task (id ${opts.taskId}): ${opts.title}
|
|
242
908
|
|
|
243
|
-
${opts.prompt}${deps}${siblings}
|
|
909
|
+
${opts.prompt}${deps}${siblings}${planSlice}
|
|
244
910
|
|
|
245
911
|
Operating rules:
|
|
246
912
|
- The file snapshot above is for orientation. Prefer going straight to the files this task touches, but if this is an investigation or debugging task, survey what you need to understand the failure.
|
|
247
913
|
- Speed is of the essence -- go for the quickest viable, testable change, unless this prompt explicitly tells you to take longer and dive deep. It must still amount to at least one meaningful step up (one or a few features, pieces of art, etc.), never a token gesture.
|
|
248
914
|
- The USER is the verifier -- the whole tasks system exists so the user playtests every change themselves. Your first priority is to finish as soon as possible with the change genuinely in place and reachable in the running deck, so the user can test it right away. Do NOT run verification (screenshots especially) unless you are really sure it will catch something a re-read of your own change cannot -- and even then at most one cheap check, never a retry loop. Time spent verifying is time the user is left waiting.
|
|
249
915
|
- The moment implementation is complete and you switch to verifying, write 90 to the progress file -- verification time must not read as stalled progress.
|
|
250
|
-
- Do this one task completely, then stop. Do not expand scope
|
|
916
|
+
- Do this one task completely, then stop. Do not expand scope.${planRules}
|
|
251
917
|
- SAVE A VERSION when your change is done, so the user can see and undo it: \`castle-web save-version -m "<short description of what you changed>" <each path you touched>\`. Name those paths explicitly -- a bare \`save-version\` with no paths records the WHOLE deck, including files a sibling agent is still working on. One version for the whole task, at the end; not one per file.
|
|
252
918
|
- Overlap with the other agents working in this deck is fine and expected -- do not try to avoid it or wait for them. Saving only your own paths is all that is asked of you; nothing anyone has written is lost by your save.
|
|
253
919
|
- NEVER run \`restore-version\`. Going back to an earlier version is the user's decision alone -- not yours, not even to undo your own work. If your change is wrong, fix it forward.
|
|
@@ -268,7 +934,7 @@ phase: wiring the paddle to touch
|
|
|
268
934
|
- \`phase\` -- a SHORT, plain-language line for this moment ("wiring the paddle to touch", "drawing the flag"). No file or code names. Update \`avatar\` and \`phase\` together each time your stage changes -- a few times across the task, not every turn. (You may also include \`progress: NN\` here, but the progress file above is the primary progress channel.)
|
|
269
935
|
- Before finishing, write ${opts.notesPath}: a tiny test guide for the PLAYER -- AT MOST 2-3 bullets (markdown \`- \` lines), each ONE short phrase: what to try and what should happen. NO code, NO API or "integration contract" detail, NO file/behavior names or implementation notes -- those are for you, not the player. Occasionally one bullet may run a little longer, but default to terse. Mention a blocker only if you hit one. The user reads this verbatim to check your work.${wrapUp}
|
|
270
936
|
- If you are truly blocked, write the blocker to the notes file and stop rather than guessing wildly.
|
|
271
|
-
- Never touch files under .castle/ other than those two paths. (The version store lives there too, but you never edit it by hand -- \`castle-web save-version\` writes it for you.)`;
|
|
937
|
+
- Never touch files under .castle/ other than ${opts.plan ? "those three paths" : "those two paths"}. (The version store lives there too, but you never edit it by hand -- \`castle-web save-version\` writes it for you.)`;
|
|
272
938
|
}
|
|
273
939
|
// Appended to claude task agents' system prompt (portable replacement for the
|
|
274
940
|
// machine-specific /goal slash command): commit to autonomous completion.
|