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.d.ts CHANGED
@@ -34,6 +34,7 @@ interface TaskRecord {
34
34
  title: string;
35
35
  prompt: string;
36
36
  after: string[];
37
+ item?: string;
37
38
  status: TaskStatus;
38
39
  progress: number;
39
40
  notes: string;
@@ -51,6 +52,11 @@ interface TaskRecord {
51
52
  phase?: string;
52
53
  acknowledged?: boolean;
53
54
  rejected?: boolean;
55
+ durable?: string;
56
+ what?: string;
57
+ files?: string[];
58
+ knobs?: string[];
59
+ durablePending?: boolean;
54
60
  blockedBy?: string[];
55
61
  }
56
62
  export declare function roleUsesOpenrouter(backend: AgentBackend, claudeModel: ClaudeModel): boolean;
package/dist/agent.js CHANGED
@@ -22,7 +22,8 @@ import * as path from 'path';
22
22
  import { nanoid } from 'nanoid';
23
23
  import { WebSocketServer } from 'ws';
24
24
  import { rawDataToString } from './ide.js';
25
- import { buildRouterPrompt, buildTaskPrompt, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
25
+ import { applyPlanOps, buildRouterPrompt, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
26
+ import { readCastleJson } from './castleJson.js';
26
27
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from './openrouter-catalog.js';
27
28
  import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
28
29
  import { fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from './metering.js';
@@ -517,6 +518,17 @@ const TASK_SPAWN_STAGGER_MS = Number(process.env.CASTLE_TASK_SPAWN_STAGGER_MS) |
517
518
  const TASK_POLL_MS = 1_000;
518
519
  const FENCE_HOLDBACK = '```castle-';
519
520
  const RESULT_SUMMARY_CHARS = 600;
521
+ // The deck's plan file: the ops in the router's ```castle-plan fences are
522
+ // applied to it, the user edits it like any other deck file, and it is rendered
523
+ // into every router turn and task prompt. Exactly two hands write it -- the
524
+ // router and the user -- so the runtime never joins machine state (task status,
525
+ // what exists) into it; that rides in the rendered digest instead.
526
+ const PLAN_FILE = 'plan.md';
527
+ // Byte ceiling on the plan.md injected into those prompts. Nothing bounds the
528
+ // file itself -- ops touch one line each and the user pastes what they like --
529
+ // meanwhile the whole prompt rides inside one argv entry (see
530
+ // TRANSCRIPT_BYTE_BUDGET in agent-prompts.ts), so this term needs its own bound.
531
+ const PLAN_BYTE_BUDGET = 8 * 1024;
520
532
  const MAX_ATTACHMENTS = 6;
521
533
  const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
522
534
  const TERMINAL_STATUSES = ['done', 'failed', 'interrupted'];
@@ -548,27 +560,37 @@ function visibleLength(raw) {
548
560
  }
549
561
  return raw.length;
550
562
  }
551
- // Parse one ```castle-task fence's body (title line, optional "after:" line,
552
- // then the prompt) into a directive. Shared by the settle-time full-text
553
- // extraction below and the mid-stream incremental scanner (runRouterTurnIn),
554
- // so a fence spawned early behaves identically to one spawned at settle.
563
+ // Parse one ```castle-task fence's body (title line, optional "after:" and
564
+ // "item:" lines in either order, then the prompt) into a directive. Shared by
565
+ // the settle-time full-text extraction below and the mid-stream incremental
566
+ // scanner (runRouterTurnIn), so a fence spawned early behaves identically to
567
+ // one spawned at settle.
555
568
  function parseTaskFenceBody(body) {
556
569
  const lines = body.replace(/\r/g, '').split('\n');
557
570
  const title = (lines.shift() ?? '').trim();
558
571
  if (!title)
559
572
  return null;
560
573
  const after = [];
574
+ let item;
561
575
  while (lines.length > 0) {
562
- const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? '').trim());
576
+ const headerMatch = /^(after|item):\s*(.*)$/i.exec((lines[0] ?? '').trim());
563
577
  if (!headerMatch)
564
578
  break;
565
579
  lines.shift();
580
+ if (headerMatch[1].toLowerCase() === 'item') {
581
+ const label = headerMatch[2].trim();
582
+ // `item: -` is the fence saying "chore, no plan item" -- an answer, not
583
+ // an omission, so it lands the same as no line at all.
584
+ if (label && label !== '-')
585
+ item = label;
586
+ continue;
587
+ }
566
588
  after.push(...headerMatch[2]
567
589
  .split(',')
568
590
  .map((s) => s.trim())
569
591
  .filter(Boolean));
570
592
  }
571
- return { title, after, prompt: lines.join('\n').trim() };
593
+ return { title, after, item, prompt: lines.join('\n').trim() };
572
594
  }
573
595
  // A fresh RegExp per call -- this is matched with manual .exec() loops in
574
596
  // TWO independent call sites (settle-time extractDirectives via .replace, and
@@ -577,6 +599,18 @@ function parseTaskFenceBody(body) {
577
599
  function taskFenceRegex() {
578
600
  return /```castle-task[ \t]*\r?\n([\s\S]*?)```/g;
579
601
  }
602
+ // Take the body of a ```castle-plan fence out of a router reply: the ops it
603
+ // wants applied to plan.md. A reply emitting more than one is stating more ops,
604
+ // so the bodies concatenate in order. Returns the reply with every such fence
605
+ // removed.
606
+ function takePlanFence(source) {
607
+ const bodies = [];
608
+ const rest = source.replace(/```castle-plan[ \t]*\r?\n([\s\S]*?)```/g, (_match, body) => {
609
+ bodies.push(String(body));
610
+ return '';
611
+ });
612
+ return { plan: bodies.length > 0 ? bodies.join('\n') : undefined, rest };
613
+ }
580
614
  // Pull ```castle-task fenced directives out of a finished router reply.
581
615
  // Block format: title line, then an optional "after:" line, then the prompt.
582
616
  function extractDirectives(full) {
@@ -594,7 +628,8 @@ function extractDirectives(full) {
594
628
  return '';
595
629
  });
596
630
  };
597
- const withoutDone = listFence(listFence(full, 'castle-done', checkoffs), 'castle-stop', stops);
631
+ const { plan, rest } = takePlanFence(full);
632
+ const withoutDone = listFence(listFence(rest, 'castle-done', checkoffs), 'castle-stop', stops);
598
633
  const cleaned = withoutDone.replace(taskFenceRegex(), (_match, body) => {
599
634
  const directive = parseTaskFenceBody(String(body));
600
635
  if (directive)
@@ -606,8 +641,166 @@ function extractDirectives(full) {
606
641
  directives,
607
642
  checkoffs,
608
643
  stops,
644
+ plan,
645
+ };
646
+ }
647
+ const planSessions = new Map();
648
+ // What a deck that says nothing gets. Off unless the serve was told otherwise,
649
+ // so a serve nobody configured -- one started from a terminal, or by a caller
650
+ // that forgot the flag -- lands on the default rather than quietly handing the
651
+ // feature to someone outside the rollout. The cloud editor sets it per user
652
+ // from a feature gate; see serveOnPort in castle-www's cloudSandbox.ts.
653
+ function planDefault() {
654
+ return process.env.CASTLE_AGENT_PLAN === 'on';
655
+ }
656
+ function planSession(deckDir) {
657
+ const existing = planSessions.get(deckDir);
658
+ if (existing)
659
+ return existing;
660
+ const declared = readCastleJson(deckDir)?.agent?.plan;
661
+ const session = {
662
+ enabled: declared === 'on' ? true : declared === 'off' ? false : planDefault(),
663
+ notices: [],
664
+ asyncNotices: [],
665
+ quietBuildTurns: 0,
666
+ };
667
+ planSessions.set(deckDir, session);
668
+ return session;
669
+ }
670
+ function readPlanSnapshot(deckDir) {
671
+ let raw;
672
+ try {
673
+ raw = fs.readFileSync(path.join(deckDir, PLAN_FILE), 'utf8');
674
+ }
675
+ catch {
676
+ return { raw: null };
677
+ }
678
+ if (!raw.trim())
679
+ return { raw };
680
+ if (Buffer.byteLength(raw, 'utf8') <= PLAN_BYTE_BUDGET)
681
+ return { raw, text: raw.trim() };
682
+ return {
683
+ raw,
684
+ text: `${truncateToBytes(raw, PLAN_BYTE_BUDGET).trim()}\n\n(trimmed -- plan.md is longer than this. Tell the user it has grown too long; it should stay a short working plan.)`,
685
+ };
686
+ }
687
+ // How many op notices ride into the next router turn. More than a handful is a
688
+ // fence that went wrong wholesale, and the first few say so.
689
+ const PLAN_NOTICE_LIMIT = 6;
690
+ // Apply a ```castle-plan fence's ops to <deck>/plan.md. The file is re-read
691
+ // HERE rather than passed down from prompt-build time: an op names the one line
692
+ // it touches, so a user editing another part of the plan while the turn ran
693
+ // keeps their edit and the op still lands. (Full-file fences could do neither,
694
+ // which is what the old stale-snapshot guard was for.)
695
+ function applyPlanFence(deckDir, body, sources) {
696
+ const session = planSession(deckDir);
697
+ // An opted-out deck is never told plan.md exists, so a fence here is a stray
698
+ // -- applying it would put a file on a deck that asked for none. The fence is
699
+ // still stripped from the reply upstream, same as any other.
700
+ if (!session.enabled)
701
+ return 'dropped';
702
+ const { ops, unparsed } = parsePlanOps(body);
703
+ const notices = unparsed.map((line) => `\`${line}\` is not an op, so nothing was applied for it`);
704
+ const current = readPlanSnapshot(deckDir).raw ?? '';
705
+ const result = ops.length > 0 ? applyPlanOps(current, ops, sources) : undefined;
706
+ notices.push(...(result?.notices ?? []));
707
+ for (const notice of notices)
708
+ console.error(`[router] plan fence: ${notice}`);
709
+ session.notices = notices.slice(0, PLAN_NOTICE_LIMIT);
710
+ if (!result || result.applied === 0)
711
+ return result ? 'unchanged' : 'dropped';
712
+ const text = `${result.text}\n`;
713
+ if (text === current)
714
+ return 'unchanged';
715
+ try {
716
+ fs.writeFileSync(path.join(deckDir, PLAN_FILE), text);
717
+ return 'written';
718
+ }
719
+ catch (err) {
720
+ console.error(`[router] could not write ${PLAN_FILE}: ${err instanceof Error ? err.message : String(err)}`);
721
+ return 'dropped';
722
+ }
723
+ }
724
+ // What a taste op's quote is checked against. The WHOLE message store, not the
725
+ // 40-message window the router was shown: the check is one substring test per
726
+ // message, and being permissive costs nothing here -- in practice the router
727
+ // quotes what is in front of it. Task reports reach back the same way, since a
728
+ // finished task's record outlives both its nomination and its board row. A user
729
+ // statement so old it has left the store is a memory the router can no longer
730
+ // evidence, and asking to confirm it is the right end of that.
731
+ //
732
+ // A task's quotable text is every field of it the ROUTER IS SHOWN -- the same
733
+ // notes and failure reason asPromptTask puts on the board row, not only the
734
+ // `durable:` nomination. Measured 26-08-15: with the nomination alone, 2 of 10
735
+ // taste ops in a t1 run were routed away for quoting a failed task's error line
736
+ // byte-for-byte out of the prompt in front of them. A check that rejects a
737
+ // verbatim quote of its own prompt teaches the router to stop quoting.
738
+ function planOpSources(taskStore, messages) {
739
+ const taskReports = new Map();
740
+ for (const task of taskStore.sorted()) {
741
+ const row = asPromptTask(task);
742
+ const text = [task.durable, row.error, row.notes].filter((s) => s?.trim()).join('\n');
743
+ if (text)
744
+ taskReports.set(task.id, text);
745
+ }
746
+ return {
747
+ taskReports,
748
+ userMessages: messages.filter((m) => m.role === 'user').map((m) => m.text),
749
+ };
750
+ }
751
+ // What the router's next turn is told about the plan, or undefined when the
752
+ // deck has it switched off -- see RouterPlanOpts. Reading the notices clears
753
+ // them: they are one-shots about a fence the router has since been shown the
754
+ // result of.
755
+ function routerPlanOpts(deckDir, snapshot, nominations, tasks) {
756
+ const session = planSession(deckDir);
757
+ if (!session.enabled)
758
+ return undefined;
759
+ const notices = [...session.notices, ...session.asyncNotices].slice(0, PLAN_NOTICE_LIMIT);
760
+ session.notices = [];
761
+ session.asyncNotices = [];
762
+ return {
763
+ fileText: snapshot.text,
764
+ pendingDurables: nominations.map((t) => ({
765
+ taskId: t.id,
766
+ taskTitle: t.title,
767
+ text: t.durable ?? '',
768
+ })),
769
+ finishedWork: planFinishedWork(tasks),
770
+ notices,
609
771
  };
610
772
  }
773
+ // The agenda nudge. A reply that spawned work, asked nothing (no probe chips,
774
+ // no question mark), while `## Open questions` holds a standing line, is a
775
+ // quiet build turn; the second in a row queues a notice naming the top line.
776
+ // Counting quiet turns and naming the line is exactly what five wordings of a
777
+ // prompt rule could not get the router to do for itself (0 across 14 samples,
778
+ // 26-08-18) and what the notice-shaped close rule showed it does do when told.
779
+ function trackStandingQuestions(ctx, cleaned, spawnedWork) {
780
+ const session = planSession(ctx.deckDir);
781
+ if (!session.enabled)
782
+ return;
783
+ const askedSomething = /\[\[[^\]\n]+\]\]/.test(cleaned) || cleaned.includes('?');
784
+ if (askedSomething || !spawnedWork) {
785
+ session.quietBuildTurns = 0;
786
+ return;
787
+ }
788
+ const standing = planOpenQuestionLines(readPlanSnapshot(ctx.deckDir).raw ?? '');
789
+ if (standing.length === 0) {
790
+ session.quietBuildTurns = 0;
791
+ return;
792
+ }
793
+ session.quietBuildTurns += 1;
794
+ if (session.quietBuildTurns < 2)
795
+ return;
796
+ session.quietBuildTurns = 0;
797
+ session.asyncNotices.push(`the plan's top open question has now sat through two build turns with nothing asked -- this is the turn to ask it, in its own words, while work spawns anyway: ${standing[0]}`);
798
+ }
799
+ function taskPlanOpts(deckDir, item, finishedWork) {
800
+ if (!planSession(deckDir).enabled)
801
+ return undefined;
802
+ return { fileText: readPlanSnapshot(deckDir).text, item, finishedWork };
803
+ }
611
804
  // Scan `raw` for ```castle-task fences that have FULLY closed since
612
805
  // `fromIndex` -- i.e. their closing ``` has already streamed in -- and parse
613
806
  // each into a directive. Returns the index just past the last one consumed,
@@ -1898,6 +2091,58 @@ async function runAgentTurn(opts) {
1898
2091
  function persistTaskFile(tasksDir, task) {
1899
2092
  fs.writeFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
1900
2093
  }
2094
+ // The task index: one row per task this deck has ever spawned, addressed by the
2095
+ // plan item it advanced. It is the complete address book (step 2 searches it by
2096
+ // item label to find the handoffs worth reading) and, filtered to running rows,
2097
+ // the live claims board.
2098
+ //
2099
+ // Runtime-exclusive -- unlike plan.md, which has two writers, this has exactly
2100
+ // one, which is why the claim data lives here rather than in the plan. It is
2101
+ // written whether or not the deck has the plan doc switched on: it is a disk
2102
+ // receipt, never injected into a prompt, so it costs an off deck nothing.
2103
+ const TASK_INDEX_FILE = 'index.md';
2104
+ const TASK_INDEX_HEADER = '# tasks\n# <task-id> | <item> | <title> | <created> | <status> | <touching>\n';
2105
+ // A pipe-delimited row can't carry a pipe. Titles come from the router's fence
2106
+ // and can hold anything.
2107
+ function indexCell(value) {
2108
+ return value.replace(/\|/g, '/').replace(/\s+/g, ' ').trim();
2109
+ }
2110
+ function taskIndexLine(task) {
2111
+ return [
2112
+ task.id,
2113
+ indexCell(task.item ?? '-') || '-',
2114
+ indexCell(task.title),
2115
+ task.createdAt.slice(0, 10),
2116
+ task.status,
2117
+ // `touching` is in the grammar from the start though nothing writes it
2118
+ // until claims land (step 1.5), so today's index still parses then.
2119
+ '',
2120
+ ].join(' | ');
2121
+ }
2122
+ function writeTaskIndex(tasksDir, tasks) {
2123
+ const rows = [...tasks.values()]
2124
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
2125
+ .map(taskIndexLine);
2126
+ const body = `${TASK_INDEX_HEADER}${rows.join('\n')}${rows.length > 0 ? '\n' : ''}`;
2127
+ const file = path.join(tasksDir, TASK_INDEX_FILE);
2128
+ try {
2129
+ if (fs.readFileSync(file, 'utf8') === body)
2130
+ return;
2131
+ }
2132
+ catch {
2133
+ /* no index yet -- writing it below is the change */
2134
+ }
2135
+ const tmp = `${file}.tmp`;
2136
+ try {
2137
+ // Written aside and renamed: a running task agent may be reading this file
2138
+ // at any moment, and a torn read is a claims board with rows missing.
2139
+ fs.writeFileSync(tmp, body);
2140
+ fs.renameSync(tmp, file);
2141
+ }
2142
+ catch (err) {
2143
+ console.error(`[tasks] could not write ${TASK_INDEX_FILE}: ${err instanceof Error ? err.message : String(err)}`);
2144
+ }
2145
+ }
1901
2146
  // Tasks left "running" by a dead serve are as finished as they will get. A
1902
2147
  // persisted "blocked" task is left as-is: it is not "waiting", so maybeStart
1903
2148
  // never reconsiders it and it can't wedge or auto-start; it just sits on the
@@ -1917,8 +2162,84 @@ function loadTasks(tasksDir) {
1917
2162
  }
1918
2163
  return tasks;
1919
2164
  }
2165
+ // A handoff line is one fact, and a long one is a summary rather than the
2166
+ // single fact the prompt asks for.
2167
+ const HANDOFF_MAX_CHARS = 200;
2168
+ const HANDOFF_MAX_LIST = 8;
2169
+ // One field of the handoff file -- the FIRST line carrying it, since the prompt
2170
+ // asks for one of each and a task that wrote several has already missed the
2171
+ // point.
2172
+ function handoffField(handoff, field) {
2173
+ const match = new RegExp(`^${field}:\\s*(.+)$`, 'i');
2174
+ for (const line of handoff.split('\n')) {
2175
+ const found = match.exec(line.trim());
2176
+ if (found)
2177
+ return found[1].trim().slice(0, HANDOFF_MAX_CHARS);
2178
+ }
2179
+ return undefined;
2180
+ }
2181
+ function handoffList(handoff, field) {
2182
+ const items = (handoffField(handoff, field) ?? '')
2183
+ .split(',')
2184
+ .map((s) => s.trim())
2185
+ .filter(Boolean)
2186
+ .slice(0, HANDOFF_MAX_LIST);
2187
+ return items.length > 0 ? items : undefined;
2188
+ }
2189
+ // Pull the handoff file into the record. `durable:` additionally goes PENDING
2190
+ // on arrival -- it is a proposal the router has to answer -- while the others
2191
+ // are just facts the digest derives `## Built` from.
2192
+ function refreshHandoff(dir, task) {
2193
+ let handoff;
2194
+ try {
2195
+ handoff = fs.readFileSync(path.join(dir, 'handoff'), 'utf8');
2196
+ }
2197
+ catch {
2198
+ return false;
2199
+ }
2200
+ let changed = false;
2201
+ const durable = handoffField(handoff, 'durable');
2202
+ if (durable && durable !== task.durable) {
2203
+ task.durable = durable;
2204
+ task.durablePending = true;
2205
+ changed = true;
2206
+ }
2207
+ const what = handoffField(handoff, 'what');
2208
+ if (what && what !== task.what) {
2209
+ task.what = what;
2210
+ changed = true;
2211
+ }
2212
+ for (const field of ['files', 'knobs']) {
2213
+ const list = handoffList(handoff, field);
2214
+ if (list && list.join(', ') !== (task[field] ?? []).join(', ')) {
2215
+ task[field] = list;
2216
+ changed = true;
2217
+ }
2218
+ }
2219
+ return changed;
2220
+ }
2221
+ // What the plan's derived `## Built` is made of: the work of tasks that
2222
+ // actually finished, addressed by the plan item they advanced. A task that
2223
+ // belongs to no item, failed, or never wrote a `what:` line has nothing to
2224
+ // contribute -- the section is what EXISTS, not what was attempted.
2225
+ function planFinishedWork(tasks) {
2226
+ const work = [];
2227
+ for (const task of tasks) {
2228
+ if (task.status !== 'done' || !task.item || task.item === '-' || !task.what)
2229
+ continue;
2230
+ work.push({
2231
+ item: task.item,
2232
+ what: task.what,
2233
+ files: task.files,
2234
+ knobs: task.knobs,
2235
+ at: task.finishedAt ?? task.updatedAt,
2236
+ });
2237
+ }
2238
+ return work;
2239
+ }
1920
2240
  // Task agents report through plain files: an integer in `progress`, free
1921
- // text in `notes.md`. Pull both into the record; true when anything changed.
2241
+ // text in `notes.md`, and a `handoff` file of one-line facts. Pull them into
2242
+ // the record; true when anything changed.
1922
2243
  function refreshTaskFiles(tasksDir, task) {
1923
2244
  const dir = path.join(tasksDir, task.id);
1924
2245
  let changed = false;
@@ -1943,6 +2264,8 @@ function refreshTaskFiles(tasksDir, task) {
1943
2264
  catch {
1944
2265
  /* no notes file yet */
1945
2266
  }
2267
+ if (refreshHandoff(dir, task))
2268
+ changed = true;
1946
2269
  return changed;
1947
2270
  }
1948
2271
  // "after:" entries may reference task ids or titles -- including titles of
@@ -2050,6 +2373,7 @@ async function runTaskAgentIn(ctx, task) {
2050
2373
  prompt: task.prompt,
2051
2374
  progressPath: path.join(relDir, 'progress'),
2052
2375
  notesPath: path.join(relDir, 'notes.md'),
2376
+ handoffPath: path.join(relDir, 'handoff'),
2053
2377
  depsSummary: ctx.depsSummary,
2054
2378
  backend: ctx.backend,
2055
2379
  // Slimmed once contents are inlined -- see DECK_TREE_SLIM_* above.
@@ -2058,6 +2382,7 @@ async function runTaskAgentIn(ctx, task) {
2058
2382
  : undefined),
2059
2383
  deckContents,
2060
2384
  quickReference: ctx.quickReference,
2385
+ plan: taskPlanOpts(ctx.deckDir, task.item, ctx.finishedWork),
2061
2386
  siblings: ctx.siblings,
2062
2387
  });
2063
2388
  // No /goal wrapper: it makes a fresh evaluator re-check the WHOLE task
@@ -2196,6 +2521,9 @@ function startTask(ctx, task) {
2196
2521
  .sorted()
2197
2522
  .filter((t) => t.id !== task.id && !(t.acknowledged && isTerminal(t.status)))
2198
2523
  .map((t) => ({ title: t.title, status: t.status })),
2524
+ // Not filtered to the live board like the siblings above: a task the user
2525
+ // has already checked off is exactly the work most likely to be rebuilt.
2526
+ finishedWork: planFinishedWork(ctx.sorted()),
2199
2527
  onFeed: (entry) => ctx.onFeed(task, entry),
2200
2528
  onRetry: (attempt) => ctx.onRetry(task, attempt),
2201
2529
  onSignal: (signal) => {
@@ -2323,6 +2651,9 @@ function haltTask(task, children, stopRequested, touch) {
2323
2651
  function createTaskStore(opts) {
2324
2652
  const { deckDir, deckLabel, tasksDir, children } = opts;
2325
2653
  const tasks = loadTasks(tasksDir);
2654
+ // Covers both a deck whose tasks predate the index and the interrupted-status
2655
+ // rewrites loadTasks just did.
2656
+ writeTaskIndex(tasksDir, tasks);
2326
2657
  // Tasks the router asked to stop: their killed process must not read as a
2327
2658
  // crash (no retry) and they finalize as interrupted, not failed.
2328
2659
  const stopRequested = new Set();
@@ -2332,6 +2663,7 @@ function createTaskStore(opts) {
2332
2663
  function touch(task) {
2333
2664
  task.updatedAt = nowIso();
2334
2665
  persistTaskFile(tasksDir, task);
2666
+ writeTaskIndex(tasksDir, tasks);
2335
2667
  opts.onUpdate(task);
2336
2668
  }
2337
2669
  function runningCount() {
@@ -2404,6 +2736,7 @@ function createTaskStore(opts) {
2404
2736
  title: directive.title,
2405
2737
  prompt: directive.prompt,
2406
2738
  after: resolveDeps(tasks, directive.after),
2739
+ item: directive.item,
2407
2740
  status: 'waiting',
2408
2741
  progress: 0,
2409
2742
  notes: '',
@@ -2414,6 +2747,7 @@ function createTaskStore(opts) {
2414
2747
  fs.mkdirSync(path.join(tasksDir, task.id), { recursive: true });
2415
2748
  tasks.set(task.id, task);
2416
2749
  persistTaskFile(tasksDir, task);
2750
+ writeTaskIndex(tasksDir, tasks);
2417
2751
  opts.onUpdate(task);
2418
2752
  maybeStart(task);
2419
2753
  return task.id;
@@ -2428,6 +2762,21 @@ function createTaskStore(opts) {
2428
2762
  touch(task);
2429
2763
  return task;
2430
2764
  }
2765
+ function pendingDurables() {
2766
+ return sorted().filter((t) => t.durablePending && t.durable);
2767
+ }
2768
+ // Every pending nomination clears on the next fence, promoted or not: the
2769
+ // router saw them all and made one pass of taste over them, and re-offering
2770
+ // the ones it passed over would just relitigate the same call every turn.
2771
+ // Lossy by design -- the handoff files on disk stay the record.
2772
+ function clearPendingDurables() {
2773
+ for (const task of tasks.values()) {
2774
+ if (!task.durablePending)
2775
+ continue;
2776
+ task.durablePending = false;
2777
+ persistTaskFile(tasksDir, task);
2778
+ }
2779
+ }
2431
2780
  const pollTimer = setInterval(() => {
2432
2781
  for (const task of tasks.values()) {
2433
2782
  if (task.status !== 'running')
@@ -2445,6 +2794,7 @@ function createTaskStore(opts) {
2445
2794
  persistTaskFile(tasksDir, task);
2446
2795
  }
2447
2796
  }
2797
+ writeTaskIndex(tasksDir, tasks);
2448
2798
  }
2449
2799
  // True when a fence body is the special token "all" / "*" (clear/stop
2450
2800
  // everything, no per-task enumeration).
@@ -2484,6 +2834,8 @@ function createTaskStore(opts) {
2484
2834
  acknowledge,
2485
2835
  checkOff,
2486
2836
  stop,
2837
+ pendingDurables,
2838
+ clearPendingDurables,
2487
2839
  shutdown,
2488
2840
  };
2489
2841
  }
@@ -2579,6 +2931,7 @@ function asPromptTask(task) {
2579
2931
  status: task.rejected ? 'rejected by user' : task.status,
2580
2932
  progress: task.progress,
2581
2933
  notes: task.notes,
2934
+ item: task.item,
2582
2935
  error: task.status === 'failed' ? firstErrorLine(task.resultSummary) : undefined,
2583
2936
  blockedBy: task.status === 'blocked' ? task.blockedBy : undefined,
2584
2937
  };
@@ -2747,7 +3100,7 @@ function resolveFailure(result) {
2747
3100
  // Assemble the full stateless prompt for one router turn: rules + deck
2748
3101
  // context + transcript replay (minus log lines and the in-flight reply) +
2749
3102
  // the live board + this turn's instruction.
2750
- function routerTurnPrompt(ctx, instruction, selfMessageId) {
3103
+ function routerTurnPrompt(ctx, instruction, selfMessageId, plan) {
2751
3104
  // Smith-only, smaller budget than a task's -- see TASK_DECK_CONTENTS_BUDGET/
2752
3105
  // ROUTER_DECK_CONTENTS_BUDGET's comment (the router prompt is already the
2753
3106
  // largest one this serve builds).
@@ -2775,6 +3128,7 @@ function routerTurnPrompt(ctx, instruction, selfMessageId) {
2775
3128
  .sorted()
2776
3129
  .filter((t) => !(t.acknowledged && isTerminal(t.status)))
2777
3130
  .map(asPromptTask),
3131
+ plan: routerPlanOpts(ctx.deckDir, plan, ctx.taskStore.pendingDurables(), ctx.taskStore.sorted()),
2778
3132
  instruction,
2779
3133
  });
2780
3134
  }
@@ -2881,7 +3235,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2881
3235
  // block later re-emits "thinking".
2882
3236
  let lastActivity = 'Thinking';
2883
3237
  ctx.broadcast({ type: 'message-activity', id: message.id, activity: 'Thinking' });
2884
- const prompt = routerTurnPrompt(ctx, instruction, message.id);
3238
+ const prompt = routerTurnPrompt(ctx, instruction, message.id, readPlanSnapshot(ctx.deckDir));
2885
3239
  const backend = ctx.backend();
2886
3240
  void runAgentTurn({
2887
3241
  backend,
@@ -2939,11 +3293,24 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2939
3293
  settleInterruptedTurn(ctx, message);
2940
3294
  return;
2941
3295
  }
2942
- const { cleaned, directives, checkoffs, stops } = extractDirectives(result.finalText);
3296
+ const { cleaned, directives, checkoffs, stops, plan } = extractDirectives(result.finalText);
2943
3297
  if (result.ok && checkoffs.length > 0)
2944
3298
  ctx.taskStore.checkOff(checkoffs);
2945
3299
  if (result.ok && stops.length > 0)
2946
3300
  ctx.taskStore.stop(stops);
3301
+ // Settle-time only, unlike task fences: nothing is waiting on the file,
3302
+ // and a half-streamed op line is a line that says something else.
3303
+ if (result.ok && plan) {
3304
+ const written = applyPlanFence(ctx.deckDir, plan, planOpSources(ctx.taskStore, ctx.log.messages));
3305
+ if (written === 'written')
3306
+ message.planUpdated = true;
3307
+ // Any fence that became ops is the router's answer to the pending
3308
+ // nominations, including one whose ops all no-oped -- it looked and
3309
+ // passed. A fence that never parsed decided nothing, so they stay
3310
+ // pending for the turn that redoes it.
3311
+ if (written !== 'dropped')
3312
+ ctx.taskStore.clearPendingDurables();
3313
+ }
2947
3314
  // Drop directives from stale turns, any whose title matches a task
2948
3315
  // already in flight (two runs reacting to the same ask), and any
2949
3316
  // already spawned mid-stream by spawnCompletedTaskFences above --
@@ -2963,6 +3330,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2963
3330
  if (result.ok) {
2964
3331
  message.text = cleaned;
2965
3332
  message.status = 'done';
3333
+ trackStandingQuestions(ctx, cleaned, taskIds.length > 0);
2966
3334
  }
2967
3335
  else {
2968
3336
  const failure = resolveFailure(result);
@@ -3003,6 +3371,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
3003
3371
  status: message.status,
3004
3372
  taskIds: message.taskIds ?? [],
3005
3373
  errorDetail: message.errorDetail,
3374
+ planUpdated: message.planUpdated,
3006
3375
  // Broadcast-only, never persisted: the client console.errors this so
3007
3376
  // the whole provider response is inspectable without leaving the
3008
3377
  // browser. Keeping it out of MessageRecord is the same call
@@ -3598,6 +3967,9 @@ export function createAgentServer(opts) {
3598
3967
  const attachmentsDir = path.join(agentDir, 'attachments');
3599
3968
  const messagesPath = path.join(agentDir, 'messages.json');
3600
3969
  fs.mkdirSync(tasksDir, { recursive: true });
3970
+ // Read the plan opt-out now so the whole serve shares one answer -- see
3971
+ // planSession.
3972
+ planSession(deckDir);
3601
3973
  // Warm the OpenRouter catalog now so the first pre-flight and the first
3602
3974
  // popover open read a cache instead of paying for the fetch. Fire-and-forget
3603
3975
  // by design -- nothing here depends on it, and it falls open if it fails.
@@ -11,6 +11,9 @@ export interface CastleJson {
11
11
  imports?: Record<string, DeckImport>;
12
12
  autoUpdateWhenImported?: boolean;
13
13
  main?: string;
14
+ agent?: {
15
+ plan?: 'on' | 'off';
16
+ };
14
17
  starterScene?: string;
15
18
  [key: string]: unknown;
16
19
  }