castle-web-cli 0.4.125 → 0.4.127

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,9 @@ 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 { PLAN_FILE } from './localPaths.js';
26
+ import { applyPlanOps, buildRouterPrompt, buildTaskPrompt, parsePlanOps, planOpenQuestionLines, truncateToBytes, userTurnInstruction, CLAUDE_TASK_SYSTEM_REMINDER, } from './agent-prompts.js';
27
+ import { readCastleJson } from './castleJson.js';
26
28
  import { checkOpenrouterKey, checkOpenrouterModel, openrouterCatalogEntry, primeOpenrouterCatalog, } from './openrouter-catalog.js';
27
29
  import { classifyProviderError, failureCopy, setReaderTimeZone, } from './agent-failures.js';
28
30
  import { fetchBudget, meteringHeaders, newAgentSessionId, reportCursorRun, withCustomHeaders, } from './metering.js';
@@ -517,6 +519,11 @@ const TASK_SPAWN_STAGGER_MS = Number(process.env.CASTLE_TASK_SPAWN_STAGGER_MS) |
517
519
  const TASK_POLL_MS = 1_000;
518
520
  const FENCE_HOLDBACK = '```castle-';
519
521
  const RESULT_SUMMARY_CHARS = 600;
522
+ // Byte ceiling on the plan.md injected into router turns and task prompts.
523
+ // Nothing bounds the file itself -- ops touch one line each and the user pastes
524
+ // what they like -- meanwhile the whole prompt rides inside one argv entry (see
525
+ // TRANSCRIPT_BYTE_BUDGET in agent-prompts.ts), so this term needs its own bound.
526
+ const PLAN_BYTE_BUDGET = 8 * 1024;
520
527
  const MAX_ATTACHMENTS = 6;
521
528
  const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
522
529
  const TERMINAL_STATUSES = ['done', 'failed', 'interrupted'];
@@ -548,27 +555,37 @@ function visibleLength(raw) {
548
555
  }
549
556
  return raw.length;
550
557
  }
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.
558
+ // Parse one ```castle-task fence's body (title line, optional "after:" and
559
+ // "item:" lines in either order, then the prompt) into a directive. Shared by
560
+ // the settle-time full-text extraction below and the mid-stream incremental
561
+ // scanner (runRouterTurnIn), so a fence spawned early behaves identically to
562
+ // one spawned at settle.
555
563
  function parseTaskFenceBody(body) {
556
564
  const lines = body.replace(/\r/g, '').split('\n');
557
565
  const title = (lines.shift() ?? '').trim();
558
566
  if (!title)
559
567
  return null;
560
568
  const after = [];
569
+ let item;
561
570
  while (lines.length > 0) {
562
- const headerMatch = /^(after):\s*(.*)$/i.exec((lines[0] ?? '').trim());
571
+ const headerMatch = /^(after|item):\s*(.*)$/i.exec((lines[0] ?? '').trim());
563
572
  if (!headerMatch)
564
573
  break;
565
574
  lines.shift();
575
+ if (headerMatch[1].toLowerCase() === 'item') {
576
+ const label = headerMatch[2].trim();
577
+ // `item: -` is the fence saying "chore, no plan item" -- an answer, not
578
+ // an omission, so it lands the same as no line at all.
579
+ if (label && label !== '-')
580
+ item = label;
581
+ continue;
582
+ }
566
583
  after.push(...headerMatch[2]
567
584
  .split(',')
568
585
  .map((s) => s.trim())
569
586
  .filter(Boolean));
570
587
  }
571
- return { title, after, prompt: lines.join('\n').trim() };
588
+ return { title, after, item, prompt: lines.join('\n').trim() };
572
589
  }
573
590
  // A fresh RegExp per call -- this is matched with manual .exec() loops in
574
591
  // TWO independent call sites (settle-time extractDirectives via .replace, and
@@ -577,6 +594,18 @@ function parseTaskFenceBody(body) {
577
594
  function taskFenceRegex() {
578
595
  return /```castle-task[ \t]*\r?\n([\s\S]*?)```/g;
579
596
  }
597
+ // Take the body of a ```castle-plan fence out of a router reply: the ops it
598
+ // wants applied to plan.md. A reply emitting more than one is stating more ops,
599
+ // so the bodies concatenate in order. Returns the reply with every such fence
600
+ // removed.
601
+ function takePlanFence(source) {
602
+ const bodies = [];
603
+ const rest = source.replace(/```castle-plan[ \t]*\r?\n([\s\S]*?)```/g, (_match, body) => {
604
+ bodies.push(String(body));
605
+ return '';
606
+ });
607
+ return { plan: bodies.length > 0 ? bodies.join('\n') : undefined, rest };
608
+ }
580
609
  // Pull ```castle-task fenced directives out of a finished router reply.
581
610
  // Block format: title line, then an optional "after:" line, then the prompt.
582
611
  function extractDirectives(full) {
@@ -594,7 +623,8 @@ function extractDirectives(full) {
594
623
  return '';
595
624
  });
596
625
  };
597
- const withoutDone = listFence(listFence(full, 'castle-done', checkoffs), 'castle-stop', stops);
626
+ const { plan, rest } = takePlanFence(full);
627
+ const withoutDone = listFence(listFence(rest, 'castle-done', checkoffs), 'castle-stop', stops);
598
628
  const cleaned = withoutDone.replace(taskFenceRegex(), (_match, body) => {
599
629
  const directive = parseTaskFenceBody(String(body));
600
630
  if (directive)
@@ -606,8 +636,166 @@ function extractDirectives(full) {
606
636
  directives,
607
637
  checkoffs,
608
638
  stops,
639
+ plan,
640
+ };
641
+ }
642
+ const planSessions = new Map();
643
+ // What a deck that says nothing gets. Off unless the serve was told otherwise,
644
+ // so a serve nobody configured -- one started from a terminal, or by a caller
645
+ // that forgot the flag -- lands on the default rather than quietly handing the
646
+ // feature to someone outside the rollout. The cloud editor sets it per user
647
+ // from a feature gate; see serveOnPort in castle-www's cloudSandbox.ts.
648
+ function planDefault() {
649
+ return process.env.CASTLE_AGENT_PLAN === 'on';
650
+ }
651
+ function planSession(deckDir) {
652
+ const existing = planSessions.get(deckDir);
653
+ if (existing)
654
+ return existing;
655
+ const declared = readCastleJson(deckDir)?.agent?.plan;
656
+ const session = {
657
+ enabled: declared === 'on' ? true : declared === 'off' ? false : planDefault(),
658
+ notices: [],
659
+ asyncNotices: [],
660
+ quietBuildTurns: 0,
661
+ };
662
+ planSessions.set(deckDir, session);
663
+ return session;
664
+ }
665
+ function readPlanSnapshot(deckDir) {
666
+ let raw;
667
+ try {
668
+ raw = fs.readFileSync(path.join(deckDir, PLAN_FILE), 'utf8');
669
+ }
670
+ catch {
671
+ return { raw: null };
672
+ }
673
+ if (!raw.trim())
674
+ return { raw };
675
+ if (Buffer.byteLength(raw, 'utf8') <= PLAN_BYTE_BUDGET)
676
+ return { raw, text: raw.trim() };
677
+ return {
678
+ raw,
679
+ 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.)`,
680
+ };
681
+ }
682
+ // How many op notices ride into the next router turn. More than a handful is a
683
+ // fence that went wrong wholesale, and the first few say so.
684
+ const PLAN_NOTICE_LIMIT = 6;
685
+ // Apply a ```castle-plan fence's ops to <deck>/plan.md. The file is re-read
686
+ // HERE rather than passed down from prompt-build time: an op names the one line
687
+ // it touches, so a user editing another part of the plan while the turn ran
688
+ // keeps their edit and the op still lands. (Full-file fences could do neither,
689
+ // which is what the old stale-snapshot guard was for.)
690
+ function applyPlanFence(deckDir, body, sources) {
691
+ const session = planSession(deckDir);
692
+ // An opted-out deck is never told plan.md exists, so a fence here is a stray
693
+ // -- applying it would put a file on a deck that asked for none. The fence is
694
+ // still stripped from the reply upstream, same as any other.
695
+ if (!session.enabled)
696
+ return 'dropped';
697
+ const { ops, unparsed } = parsePlanOps(body);
698
+ const notices = unparsed.map((line) => `\`${line}\` is not an op, so nothing was applied for it`);
699
+ const current = readPlanSnapshot(deckDir).raw ?? '';
700
+ const result = ops.length > 0 ? applyPlanOps(current, ops, sources) : undefined;
701
+ notices.push(...(result?.notices ?? []));
702
+ for (const notice of notices)
703
+ console.error(`[router] plan fence: ${notice}`);
704
+ session.notices = notices.slice(0, PLAN_NOTICE_LIMIT);
705
+ if (!result || result.applied === 0)
706
+ return result ? 'unchanged' : 'dropped';
707
+ const text = `${result.text}\n`;
708
+ if (text === current)
709
+ return 'unchanged';
710
+ try {
711
+ fs.writeFileSync(path.join(deckDir, PLAN_FILE), text);
712
+ return 'written';
713
+ }
714
+ catch (err) {
715
+ console.error(`[router] could not write ${PLAN_FILE}: ${err instanceof Error ? err.message : String(err)}`);
716
+ return 'dropped';
717
+ }
718
+ }
719
+ // What a taste op's quote is checked against. The WHOLE message store, not the
720
+ // 40-message window the router was shown: the check is one substring test per
721
+ // message, and being permissive costs nothing here -- in practice the router
722
+ // quotes what is in front of it. Task reports reach back the same way, since a
723
+ // finished task's record outlives both its nomination and its board row. A user
724
+ // statement so old it has left the store is a memory the router can no longer
725
+ // evidence, and asking to confirm it is the right end of that.
726
+ //
727
+ // A task's quotable text is every field of it the ROUTER IS SHOWN -- the same
728
+ // notes and failure reason asPromptTask puts on the board row, not only the
729
+ // `durable:` nomination. Measured 26-08-15: with the nomination alone, 2 of 10
730
+ // taste ops in a t1 run were routed away for quoting a failed task's error line
731
+ // byte-for-byte out of the prompt in front of them. A check that rejects a
732
+ // verbatim quote of its own prompt teaches the router to stop quoting.
733
+ function planOpSources(taskStore, messages) {
734
+ const taskReports = new Map();
735
+ for (const task of taskStore.sorted()) {
736
+ const row = asPromptTask(task);
737
+ const text = [task.durable, row.error, row.notes].filter((s) => s?.trim()).join('\n');
738
+ if (text)
739
+ taskReports.set(task.id, text);
740
+ }
741
+ return {
742
+ taskReports,
743
+ userMessages: messages.filter((m) => m.role === 'user').map((m) => m.text),
744
+ };
745
+ }
746
+ // What the router's next turn is told about the plan, or undefined when the
747
+ // deck has it switched off -- see RouterPlanOpts. Reading the notices clears
748
+ // them: they are one-shots about a fence the router has since been shown the
749
+ // result of.
750
+ function routerPlanOpts(deckDir, snapshot, nominations, tasks) {
751
+ const session = planSession(deckDir);
752
+ if (!session.enabled)
753
+ return undefined;
754
+ const notices = [...session.notices, ...session.asyncNotices].slice(0, PLAN_NOTICE_LIMIT);
755
+ session.notices = [];
756
+ session.asyncNotices = [];
757
+ return {
758
+ fileText: snapshot.text,
759
+ pendingDurables: nominations.map((t) => ({
760
+ taskId: t.id,
761
+ taskTitle: t.title,
762
+ text: t.durable ?? '',
763
+ })),
764
+ finishedWork: planFinishedWork(tasks),
765
+ notices,
609
766
  };
610
767
  }
768
+ // The agenda nudge. A reply that spawned work, asked nothing (no probe chips,
769
+ // no question mark), while `## Open questions` holds a standing line, is a
770
+ // quiet build turn; the second in a row queues a notice naming the top line.
771
+ // Counting quiet turns and naming the line is exactly what five wordings of a
772
+ // prompt rule could not get the router to do for itself (0 across 14 samples,
773
+ // 26-08-18) and what the notice-shaped close rule showed it does do when told.
774
+ function trackStandingQuestions(ctx, cleaned, spawnedWork) {
775
+ const session = planSession(ctx.deckDir);
776
+ if (!session.enabled)
777
+ return;
778
+ const askedSomething = /\[\[[^\]\n]+\]\]/.test(cleaned) || cleaned.includes('?');
779
+ if (askedSomething || !spawnedWork) {
780
+ session.quietBuildTurns = 0;
781
+ return;
782
+ }
783
+ const standing = planOpenQuestionLines(readPlanSnapshot(ctx.deckDir).raw ?? '');
784
+ if (standing.length === 0) {
785
+ session.quietBuildTurns = 0;
786
+ return;
787
+ }
788
+ session.quietBuildTurns += 1;
789
+ if (session.quietBuildTurns < 2)
790
+ return;
791
+ session.quietBuildTurns = 0;
792
+ 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]}`);
793
+ }
794
+ function taskPlanOpts(deckDir, item, finishedWork) {
795
+ if (!planSession(deckDir).enabled)
796
+ return undefined;
797
+ return { fileText: readPlanSnapshot(deckDir).text, item, finishedWork };
798
+ }
611
799
  // Scan `raw` for ```castle-task fences that have FULLY closed since
612
800
  // `fromIndex` -- i.e. their closing ``` has already streamed in -- and parse
613
801
  // each into a directive. Returns the index just past the last one consumed,
@@ -1898,6 +2086,58 @@ async function runAgentTurn(opts) {
1898
2086
  function persistTaskFile(tasksDir, task) {
1899
2087
  fs.writeFileSync(path.join(tasksDir, task.id, 'task.json'), JSON.stringify(task, null, 2) + '\n');
1900
2088
  }
2089
+ // The task index: one row per task this deck has ever spawned, addressed by the
2090
+ // plan item it advanced. It is the complete address book (step 2 searches it by
2091
+ // item label to find the handoffs worth reading) and, filtered to running rows,
2092
+ // the live claims board.
2093
+ //
2094
+ // Runtime-exclusive -- unlike plan.md, which has two writers, this has exactly
2095
+ // one, which is why the claim data lives here rather than in the plan. It is
2096
+ // written whether or not the deck has the plan doc switched on: it is a disk
2097
+ // receipt, never injected into a prompt, so it costs an off deck nothing.
2098
+ const TASK_INDEX_FILE = 'index.md';
2099
+ const TASK_INDEX_HEADER = '# tasks\n# <task-id> | <item> | <title> | <created> | <status> | <touching>\n';
2100
+ // A pipe-delimited row can't carry a pipe. Titles come from the router's fence
2101
+ // and can hold anything.
2102
+ function indexCell(value) {
2103
+ return value.replace(/\|/g, '/').replace(/\s+/g, ' ').trim();
2104
+ }
2105
+ function taskIndexLine(task) {
2106
+ return [
2107
+ task.id,
2108
+ indexCell(task.item ?? '-') || '-',
2109
+ indexCell(task.title),
2110
+ task.createdAt.slice(0, 10),
2111
+ task.status,
2112
+ // `touching` is in the grammar from the start though nothing writes it
2113
+ // until claims land (step 1.5), so today's index still parses then.
2114
+ '',
2115
+ ].join(' | ');
2116
+ }
2117
+ function writeTaskIndex(tasksDir, tasks) {
2118
+ const rows = [...tasks.values()]
2119
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
2120
+ .map(taskIndexLine);
2121
+ const body = `${TASK_INDEX_HEADER}${rows.join('\n')}${rows.length > 0 ? '\n' : ''}`;
2122
+ const file = path.join(tasksDir, TASK_INDEX_FILE);
2123
+ try {
2124
+ if (fs.readFileSync(file, 'utf8') === body)
2125
+ return;
2126
+ }
2127
+ catch {
2128
+ /* no index yet -- writing it below is the change */
2129
+ }
2130
+ const tmp = `${file}.tmp`;
2131
+ try {
2132
+ // Written aside and renamed: a running task agent may be reading this file
2133
+ // at any moment, and a torn read is a claims board with rows missing.
2134
+ fs.writeFileSync(tmp, body);
2135
+ fs.renameSync(tmp, file);
2136
+ }
2137
+ catch (err) {
2138
+ console.error(`[tasks] could not write ${TASK_INDEX_FILE}: ${err instanceof Error ? err.message : String(err)}`);
2139
+ }
2140
+ }
1901
2141
  // Tasks left "running" by a dead serve are as finished as they will get. A
1902
2142
  // persisted "blocked" task is left as-is: it is not "waiting", so maybeStart
1903
2143
  // never reconsiders it and it can't wedge or auto-start; it just sits on the
@@ -1917,8 +2157,84 @@ function loadTasks(tasksDir) {
1917
2157
  }
1918
2158
  return tasks;
1919
2159
  }
2160
+ // A handoff line is one fact, and a long one is a summary rather than the
2161
+ // single fact the prompt asks for.
2162
+ const HANDOFF_MAX_CHARS = 200;
2163
+ const HANDOFF_MAX_LIST = 8;
2164
+ // One field of the handoff file -- the FIRST line carrying it, since the prompt
2165
+ // asks for one of each and a task that wrote several has already missed the
2166
+ // point.
2167
+ function handoffField(handoff, field) {
2168
+ const match = new RegExp(`^${field}:\\s*(.+)$`, 'i');
2169
+ for (const line of handoff.split('\n')) {
2170
+ const found = match.exec(line.trim());
2171
+ if (found)
2172
+ return found[1].trim().slice(0, HANDOFF_MAX_CHARS);
2173
+ }
2174
+ return undefined;
2175
+ }
2176
+ function handoffList(handoff, field) {
2177
+ const items = (handoffField(handoff, field) ?? '')
2178
+ .split(',')
2179
+ .map((s) => s.trim())
2180
+ .filter(Boolean)
2181
+ .slice(0, HANDOFF_MAX_LIST);
2182
+ return items.length > 0 ? items : undefined;
2183
+ }
2184
+ // Pull the handoff file into the record. `durable:` additionally goes PENDING
2185
+ // on arrival -- it is a proposal the router has to answer -- while the others
2186
+ // are just facts the digest derives `## Built` from.
2187
+ function refreshHandoff(dir, task) {
2188
+ let handoff;
2189
+ try {
2190
+ handoff = fs.readFileSync(path.join(dir, 'handoff'), 'utf8');
2191
+ }
2192
+ catch {
2193
+ return false;
2194
+ }
2195
+ let changed = false;
2196
+ const durable = handoffField(handoff, 'durable');
2197
+ if (durable && durable !== task.durable) {
2198
+ task.durable = durable;
2199
+ task.durablePending = true;
2200
+ changed = true;
2201
+ }
2202
+ const what = handoffField(handoff, 'what');
2203
+ if (what && what !== task.what) {
2204
+ task.what = what;
2205
+ changed = true;
2206
+ }
2207
+ for (const field of ['files', 'knobs']) {
2208
+ const list = handoffList(handoff, field);
2209
+ if (list && list.join(', ') !== (task[field] ?? []).join(', ')) {
2210
+ task[field] = list;
2211
+ changed = true;
2212
+ }
2213
+ }
2214
+ return changed;
2215
+ }
2216
+ // What the plan's derived `## Built` is made of: the work of tasks that
2217
+ // actually finished, addressed by the plan item they advanced. A task that
2218
+ // belongs to no item, failed, or never wrote a `what:` line has nothing to
2219
+ // contribute -- the section is what EXISTS, not what was attempted.
2220
+ function planFinishedWork(tasks) {
2221
+ const work = [];
2222
+ for (const task of tasks) {
2223
+ if (task.status !== 'done' || !task.item || task.item === '-' || !task.what)
2224
+ continue;
2225
+ work.push({
2226
+ item: task.item,
2227
+ what: task.what,
2228
+ files: task.files,
2229
+ knobs: task.knobs,
2230
+ at: task.finishedAt ?? task.updatedAt,
2231
+ });
2232
+ }
2233
+ return work;
2234
+ }
1920
2235
  // 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.
2236
+ // text in `notes.md`, and a `handoff` file of one-line facts. Pull them into
2237
+ // the record; true when anything changed.
1922
2238
  function refreshTaskFiles(tasksDir, task) {
1923
2239
  const dir = path.join(tasksDir, task.id);
1924
2240
  let changed = false;
@@ -1943,6 +2259,8 @@ function refreshTaskFiles(tasksDir, task) {
1943
2259
  catch {
1944
2260
  /* no notes file yet */
1945
2261
  }
2262
+ if (refreshHandoff(dir, task))
2263
+ changed = true;
1946
2264
  return changed;
1947
2265
  }
1948
2266
  // "after:" entries may reference task ids or titles -- including titles of
@@ -2050,6 +2368,7 @@ async function runTaskAgentIn(ctx, task) {
2050
2368
  prompt: task.prompt,
2051
2369
  progressPath: path.join(relDir, 'progress'),
2052
2370
  notesPath: path.join(relDir, 'notes.md'),
2371
+ handoffPath: path.join(relDir, 'handoff'),
2053
2372
  depsSummary: ctx.depsSummary,
2054
2373
  backend: ctx.backend,
2055
2374
  // Slimmed once contents are inlined -- see DECK_TREE_SLIM_* above.
@@ -2058,6 +2377,7 @@ async function runTaskAgentIn(ctx, task) {
2058
2377
  : undefined),
2059
2378
  deckContents,
2060
2379
  quickReference: ctx.quickReference,
2380
+ plan: taskPlanOpts(ctx.deckDir, task.item, ctx.finishedWork),
2061
2381
  siblings: ctx.siblings,
2062
2382
  });
2063
2383
  // No /goal wrapper: it makes a fresh evaluator re-check the WHOLE task
@@ -2196,6 +2516,9 @@ function startTask(ctx, task) {
2196
2516
  .sorted()
2197
2517
  .filter((t) => t.id !== task.id && !(t.acknowledged && isTerminal(t.status)))
2198
2518
  .map((t) => ({ title: t.title, status: t.status })),
2519
+ // Not filtered to the live board like the siblings above: a task the user
2520
+ // has already checked off is exactly the work most likely to be rebuilt.
2521
+ finishedWork: planFinishedWork(ctx.sorted()),
2199
2522
  onFeed: (entry) => ctx.onFeed(task, entry),
2200
2523
  onRetry: (attempt) => ctx.onRetry(task, attempt),
2201
2524
  onSignal: (signal) => {
@@ -2323,6 +2646,9 @@ function haltTask(task, children, stopRequested, touch) {
2323
2646
  function createTaskStore(opts) {
2324
2647
  const { deckDir, deckLabel, tasksDir, children } = opts;
2325
2648
  const tasks = loadTasks(tasksDir);
2649
+ // Covers both a deck whose tasks predate the index and the interrupted-status
2650
+ // rewrites loadTasks just did.
2651
+ writeTaskIndex(tasksDir, tasks);
2326
2652
  // Tasks the router asked to stop: their killed process must not read as a
2327
2653
  // crash (no retry) and they finalize as interrupted, not failed.
2328
2654
  const stopRequested = new Set();
@@ -2332,6 +2658,7 @@ function createTaskStore(opts) {
2332
2658
  function touch(task) {
2333
2659
  task.updatedAt = nowIso();
2334
2660
  persistTaskFile(tasksDir, task);
2661
+ writeTaskIndex(tasksDir, tasks);
2335
2662
  opts.onUpdate(task);
2336
2663
  }
2337
2664
  function runningCount() {
@@ -2404,6 +2731,7 @@ function createTaskStore(opts) {
2404
2731
  title: directive.title,
2405
2732
  prompt: directive.prompt,
2406
2733
  after: resolveDeps(tasks, directive.after),
2734
+ item: directive.item,
2407
2735
  status: 'waiting',
2408
2736
  progress: 0,
2409
2737
  notes: '',
@@ -2414,6 +2742,7 @@ function createTaskStore(opts) {
2414
2742
  fs.mkdirSync(path.join(tasksDir, task.id), { recursive: true });
2415
2743
  tasks.set(task.id, task);
2416
2744
  persistTaskFile(tasksDir, task);
2745
+ writeTaskIndex(tasksDir, tasks);
2417
2746
  opts.onUpdate(task);
2418
2747
  maybeStart(task);
2419
2748
  return task.id;
@@ -2428,6 +2757,21 @@ function createTaskStore(opts) {
2428
2757
  touch(task);
2429
2758
  return task;
2430
2759
  }
2760
+ function pendingDurables() {
2761
+ return sorted().filter((t) => t.durablePending && t.durable);
2762
+ }
2763
+ // Every pending nomination clears on the next fence, promoted or not: the
2764
+ // router saw them all and made one pass of taste over them, and re-offering
2765
+ // the ones it passed over would just relitigate the same call every turn.
2766
+ // Lossy by design -- the handoff files on disk stay the record.
2767
+ function clearPendingDurables() {
2768
+ for (const task of tasks.values()) {
2769
+ if (!task.durablePending)
2770
+ continue;
2771
+ task.durablePending = false;
2772
+ persistTaskFile(tasksDir, task);
2773
+ }
2774
+ }
2431
2775
  const pollTimer = setInterval(() => {
2432
2776
  for (const task of tasks.values()) {
2433
2777
  if (task.status !== 'running')
@@ -2445,6 +2789,7 @@ function createTaskStore(opts) {
2445
2789
  persistTaskFile(tasksDir, task);
2446
2790
  }
2447
2791
  }
2792
+ writeTaskIndex(tasksDir, tasks);
2448
2793
  }
2449
2794
  // True when a fence body is the special token "all" / "*" (clear/stop
2450
2795
  // everything, no per-task enumeration).
@@ -2484,6 +2829,8 @@ function createTaskStore(opts) {
2484
2829
  acknowledge,
2485
2830
  checkOff,
2486
2831
  stop,
2832
+ pendingDurables,
2833
+ clearPendingDurables,
2487
2834
  shutdown,
2488
2835
  };
2489
2836
  }
@@ -2579,6 +2926,7 @@ function asPromptTask(task) {
2579
2926
  status: task.rejected ? 'rejected by user' : task.status,
2580
2927
  progress: task.progress,
2581
2928
  notes: task.notes,
2929
+ item: task.item,
2582
2930
  error: task.status === 'failed' ? firstErrorLine(task.resultSummary) : undefined,
2583
2931
  blockedBy: task.status === 'blocked' ? task.blockedBy : undefined,
2584
2932
  };
@@ -2747,7 +3095,7 @@ function resolveFailure(result) {
2747
3095
  // Assemble the full stateless prompt for one router turn: rules + deck
2748
3096
  // context + transcript replay (minus log lines and the in-flight reply) +
2749
3097
  // the live board + this turn's instruction.
2750
- function routerTurnPrompt(ctx, instruction, selfMessageId) {
3098
+ function routerTurnPrompt(ctx, instruction, selfMessageId, plan) {
2751
3099
  // Smith-only, smaller budget than a task's -- see TASK_DECK_CONTENTS_BUDGET/
2752
3100
  // ROUTER_DECK_CONTENTS_BUDGET's comment (the router prompt is already the
2753
3101
  // largest one this serve builds).
@@ -2775,6 +3123,7 @@ function routerTurnPrompt(ctx, instruction, selfMessageId) {
2775
3123
  .sorted()
2776
3124
  .filter((t) => !(t.acknowledged && isTerminal(t.status)))
2777
3125
  .map(asPromptTask),
3126
+ plan: routerPlanOpts(ctx.deckDir, plan, ctx.taskStore.pendingDurables(), ctx.taskStore.sorted()),
2778
3127
  instruction,
2779
3128
  });
2780
3129
  }
@@ -2881,7 +3230,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2881
3230
  // block later re-emits "thinking".
2882
3231
  let lastActivity = 'Thinking';
2883
3232
  ctx.broadcast({ type: 'message-activity', id: message.id, activity: 'Thinking' });
2884
- const prompt = routerTurnPrompt(ctx, instruction, message.id);
3233
+ const prompt = routerTurnPrompt(ctx, instruction, message.id, readPlanSnapshot(ctx.deckDir));
2885
3234
  const backend = ctx.backend();
2886
3235
  void runAgentTurn({
2887
3236
  backend,
@@ -2939,11 +3288,24 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2939
3288
  settleInterruptedTurn(ctx, message);
2940
3289
  return;
2941
3290
  }
2942
- const { cleaned, directives, checkoffs, stops } = extractDirectives(result.finalText);
3291
+ const { cleaned, directives, checkoffs, stops, plan } = extractDirectives(result.finalText);
2943
3292
  if (result.ok && checkoffs.length > 0)
2944
3293
  ctx.taskStore.checkOff(checkoffs);
2945
3294
  if (result.ok && stops.length > 0)
2946
3295
  ctx.taskStore.stop(stops);
3296
+ // Settle-time only, unlike task fences: nothing is waiting on the file,
3297
+ // and a half-streamed op line is a line that says something else.
3298
+ if (result.ok && plan) {
3299
+ const written = applyPlanFence(ctx.deckDir, plan, planOpSources(ctx.taskStore, ctx.log.messages));
3300
+ if (written === 'written')
3301
+ message.planUpdated = true;
3302
+ // Any fence that became ops is the router's answer to the pending
3303
+ // nominations, including one whose ops all no-oped -- it looked and
3304
+ // passed. A fence that never parsed decided nothing, so they stay
3305
+ // pending for the turn that redoes it.
3306
+ if (written !== 'dropped')
3307
+ ctx.taskStore.clearPendingDurables();
3308
+ }
2947
3309
  // Drop directives from stale turns, any whose title matches a task
2948
3310
  // already in flight (two runs reacting to the same ask), and any
2949
3311
  // already spawned mid-stream by spawnCompletedTaskFences above --
@@ -2963,6 +3325,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
2963
3325
  if (result.ok) {
2964
3326
  message.text = cleaned;
2965
3327
  message.status = 'done';
3328
+ trackStandingQuestions(ctx, cleaned, taskIds.length > 0);
2966
3329
  }
2967
3330
  else {
2968
3331
  const failure = resolveFailure(result);
@@ -3003,6 +3366,7 @@ function runRouterTurnIn(ctx, instruction, attachments = []) {
3003
3366
  status: message.status,
3004
3367
  taskIds: message.taskIds ?? [],
3005
3368
  errorDetail: message.errorDetail,
3369
+ planUpdated: message.planUpdated,
3006
3370
  // Broadcast-only, never persisted: the client console.errors this so
3007
3371
  // the whole provider response is inspectable without leaving the
3008
3372
  // browser. Keeping it out of MessageRecord is the same call
@@ -3598,6 +3962,9 @@ export function createAgentServer(opts) {
3598
3962
  const attachmentsDir = path.join(agentDir, 'attachments');
3599
3963
  const messagesPath = path.join(agentDir, 'messages.json');
3600
3964
  fs.mkdirSync(tasksDir, { recursive: true });
3965
+ // Read the plan opt-out now so the whole serve shares one answer -- see
3966
+ // planSession.
3967
+ planSession(deckDir);
3601
3968
  // Warm the OpenRouter catalog now so the first pre-flight and the first
3602
3969
  // popover open read a cache instead of paying for the fetch. Fire-and-forget
3603
3970
  // 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
  }
package/dist/ide.js CHANGED
@@ -21,7 +21,7 @@ import { readEditorConfig, resolveFileTypes, } from './editorConfig.js';
21
21
  import { UNSUPPORTED_MEDIA } from './unsupportedMedia.js';
22
22
  import { IMPORT_API_PREFIX, handleImportApi } from './importBrowse.js';
23
23
  import { readRequestBody, sendJson } from './httpJson.js';
24
- import { COVER_FILE } from './localPaths.js';
24
+ import { COVER_FILE, PLAN_FILE } from './localPaths.js';
25
25
  import { applyVersionRestore, createVersion, NotOnThisLine, pendingChanges, UnsavedChanges, versionSummaries, } from './versions.js';
26
26
  import { envForUserShell, installCliShims } from './byo-auth.js';
27
27
  const HeadlessTerminal = headlessPkg.Terminal;
@@ -301,6 +301,15 @@ function filterDeckFiles(files, config) {
301
301
  }
302
302
  return result;
303
303
  }
304
+ // The deck's own files, curated. The plan is exempt: it is the platform's file,
305
+ // not one a kit's visiblePaths were written to name, so a curated deck would
306
+ // hide it by omission -- it shows whenever it exists.
307
+ function filterOwnFiles(files, config) {
308
+ const result = filterDeckFiles(files, config);
309
+ if (files.includes(PLAN_FILE) && !result.includes(PLAN_FILE))
310
+ result.push(PLAN_FILE);
311
+ return result;
312
+ }
304
313
  // Filter each import by the editor config of the deck it came from: strip the
305
314
  // `imports/<alias>/` prefix so the dependency's own visible/hidden globs match
306
315
  // the paths they were written against, then put it back.
@@ -760,7 +769,7 @@ function handleFilesApi(deckDir, req, res, reqPath) {
760
769
  // is the deck that knows which of its files are worth showing.
761
770
  const own = listed.filter((f) => !isImportPath(f));
762
771
  files = [
763
- ...filterDeckFiles(own, readEditorConfig(deckDir)),
772
+ ...filterOwnFiles(own, readEditorConfig(deckDir)),
764
773
  ...filterImportedFiles(deckDir, listed.filter(isImportPath)),
765
774
  ].sort((a, b) => a.localeCompare(b));
766
775
  }
@@ -4,4 +4,5 @@ export declare function getCliEntryPath(): string;
4
4
  export declare function getSdkPackagePath(): string;
5
5
  export declare function getKitsDir(): string;
6
6
  export declare const COVER_FILE = "preview.png";
7
+ export declare const PLAN_FILE = "plan.md";
7
8
  export declare function toPosixPath(filepath: string): string;