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.
@@ -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 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.
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
- function renderTasks(tasks) {
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 `${ROUTER_RULES}
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 the 90-progress write, the \`castle-web save-version\` for your paths, the final \`npm run restart\`, and writing the notes file into a single shell command (\`;\`-separated so the notes land even if the restart hiccups). Then stop -- no extra turns after it.`
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.