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-prompts.d.ts +60 -0
- package/dist/agent-prompts.js +677 -11
- package/dist/agent.d.ts +6 -0
- package/dist/agent.js +379 -12
- package/dist/castleJson.d.ts +3 -0
- package/dist/ide.js +11 -2
- package/dist/localPaths.d.ts +1 -0
- package/dist/localPaths.js +8 -0
- package/dist/shell/assets/{index-BfOPkSej.css → index-0CshkxoU.css} +1 -1
- package/dist/shell/assets/{index-u0nYFqbF.js → index-Bqkqd5Dv.js} +66 -64
- package/dist/shell/index.html +2 -2
- package/package.json +1 -1
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 {
|
|
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:"
|
|
552
|
-
// then the prompt) into a directive. Shared by
|
|
553
|
-
// extraction below and the mid-stream incremental
|
|
554
|
-
// so a fence spawned early behaves identically to
|
|
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
|
|
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
|
|
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.
|
package/dist/castleJson.d.ts
CHANGED
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
|
-
...
|
|
772
|
+
...filterOwnFiles(own, readEditorConfig(deckDir)),
|
|
764
773
|
...filterImportedFiles(deckDir, listed.filter(isImportPath)),
|
|
765
774
|
].sort((a, b) => a.localeCompare(b));
|
|
766
775
|
}
|
package/dist/localPaths.d.ts
CHANGED
|
@@ -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;
|