runcastle 1.4.4 → 1.4.6
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/bin/runcastle.js +99 -18
- package/index.js +99 -18
- package/package.json +1 -1
- package/skills/packs/runcastle/skills/ideate/SKILL.md +60 -6
- package/skills/packs/runcastle/skills/ideate/references/preview-starter.html +274 -0
- package/skills/packs/runcastle/skills/ideate/references/preview.md +98 -0
- package/skills/packs/runcastle/skills/revisit/SKILL.md +3 -1
- package/skills/packs/runcastle/skills/tickets/SKILL.md +6 -0
- package/web/assets/index-7hbR8_dm.js +188 -0
- package/web/assets/index-bNgQedte.css +1 -0
- package/web/index.html +2 -2
- package/web/assets/index-BAbR9IXb.css +0 -1
- package/web/assets/index-CW8ioVQ6.js +0 -188
package/index.js
CHANGED
|
@@ -44,7 +44,7 @@ function agentDigestFillRank(relPath) {
|
|
|
44
44
|
const i = AGENT_DIGEST_FILL_ORDER.indexOf(relPath.toLowerCase());
|
|
45
45
|
return i === -1 ? AGENT_DIGEST_FILL_ORDER.length : i;
|
|
46
46
|
}
|
|
47
|
-
var AGENT_DIGEST_DOCS, WITHHELD_FEATURE_DOCS, AGENT_DIGEST_FILL_ORDER;
|
|
47
|
+
var AGENT_DIGEST_DOCS, PREVIEW_FILE = "preview.html", WITHHELD_FEATURE_DOCS, AGENT_DIGEST_FILL_ORDER;
|
|
48
48
|
var init_docs = __esm(() => {
|
|
49
49
|
AGENT_DIGEST_DOCS = ["brief.md", "decisions.md", "spec.md"];
|
|
50
50
|
WITHHELD_FEATURE_DOCS = {
|
|
@@ -263,7 +263,7 @@ var init_schemas = __esm(() => {
|
|
|
263
263
|
"drive-fix"
|
|
264
264
|
]);
|
|
265
265
|
PROJECT_SESSION_KINDS = ["prepare", "project"];
|
|
266
|
-
SessionPurpose = z3.enum(["resolve-conflict", "start-lap"]);
|
|
266
|
+
SessionPurpose = z3.enum(["resolve-conflict", "start-lap", "self-plan"]);
|
|
267
267
|
MergeBranchPair = z3.object({
|
|
268
268
|
mergeFrom: z3.string(),
|
|
269
269
|
mergeInto: z3.string()
|
|
@@ -1658,8 +1658,9 @@ ${why.replace(/\n/g, `\r
|
|
|
1658
1658
|
containment?.kill();
|
|
1659
1659
|
fireExit(code ?? 0, signal ? 1 : undefined);
|
|
1660
1660
|
});
|
|
1661
|
+
child.stdin?.on("error", () => {});
|
|
1661
1662
|
function sendToHost(obj) {
|
|
1662
|
-
if (!child.stdin || child.stdin.destroyed)
|
|
1663
|
+
if (exitFired || !child.stdin || child.stdin.destroyed)
|
|
1663
1664
|
return;
|
|
1664
1665
|
try {
|
|
1665
1666
|
child.stdin.write(JSON.stringify(obj) + `
|
|
@@ -2626,7 +2627,7 @@ function noteDocsChange(ctx, featureId, filename) {
|
|
|
2626
2627
|
const entry = watches.get(featureId);
|
|
2627
2628
|
if (!entry)
|
|
2628
2629
|
return;
|
|
2629
|
-
if (filename !== null && !filename.endsWith(".md"))
|
|
2630
|
+
if (filename !== null && !filename.endsWith(".md") && filename !== PREVIEW_FILE)
|
|
2630
2631
|
return;
|
|
2631
2632
|
if (filename)
|
|
2632
2633
|
entry.changed.add(filename);
|
|
@@ -2653,6 +2654,7 @@ function emitDocsChanged(ctx, featureId) {
|
|
|
2653
2654
|
}
|
|
2654
2655
|
var DOCS_WATCH_DEBOUNCE_MS = 300, watches;
|
|
2655
2656
|
var init_docs_watch = __esm(() => {
|
|
2657
|
+
init_src();
|
|
2656
2658
|
init_events();
|
|
2657
2659
|
init_feature_docs();
|
|
2658
2660
|
init_repo();
|
|
@@ -3575,6 +3577,9 @@ function chatBranchName(slug, unique) {
|
|
|
3575
3577
|
function burnWorktreePath(repoPath, branch) {
|
|
3576
3578
|
return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
|
|
3577
3579
|
}
|
|
3580
|
+
async function excludeBurnWorktrees(repoPath) {
|
|
3581
|
+
await excludePath(repoPath, BURN_WORKTREES_PATTERN);
|
|
3582
|
+
}
|
|
3578
3583
|
async function excludeFilePath(repoPath) {
|
|
3579
3584
|
const raw = (await git(repoPath).raw(["rev-parse", "--git-path", "info/exclude"])).trim();
|
|
3580
3585
|
return resolve3(repoPath, raw);
|
|
@@ -4615,7 +4620,7 @@ async function mergeInProgress(g) {
|
|
|
4615
4620
|
return false;
|
|
4616
4621
|
}
|
|
4617
4622
|
}
|
|
4618
|
-
var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", DENY_DRY_RUN_ACTIVE = "A preparation dry-run is in progress \u2014 stop it first", DENY_NO_DRY_RUN = "No preparation dry-run is in progress", DENY_NO_REVIEW_DRIVE = "No review drive is in progress for this feature", INITIAL_COMMIT_SUBJECT = "runcastle: initial commit", TICKET_BRANCH_PREFIX = "runcastle/ticket/", CHAT_BRANCH_PREFIX = "runcastle/chat/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState, reviewDriveRelease, readinessTiming, DRY_RUN_SLUG = "prep-dry-run", PROJECT_DRIVE_SLUG = "project-drive", DENY_NO_PROJECT_DRIVE = "No project drive is running for this project";
|
|
4623
|
+
var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", DENY_DRY_RUN_ACTIVE = "A preparation dry-run is in progress \u2014 stop it first", DENY_NO_DRY_RUN = "No preparation dry-run is in progress", DENY_NO_REVIEW_DRIVE = "No review drive is in progress for this feature", INITIAL_COMMIT_SUBJECT = "runcastle: initial commit", TICKET_BRANCH_PREFIX = "runcastle/ticket/", CHAT_BRANCH_PREFIX = "runcastle/chat/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, BURN_WORKTREES_PATTERN = ".sandcastle/worktrees/", testDriveState, reviewDriveRelease, readinessTiming, DRY_RUN_SLUG = "prep-dry-run", PROJECT_DRIVE_SLUG = "project-drive", DENY_NO_PROJECT_DRIVE = "No project drive is running for this project";
|
|
4619
4624
|
var init_git = __esm(() => {
|
|
4620
4625
|
init_src();
|
|
4621
4626
|
init_paths();
|
|
@@ -4773,6 +4778,9 @@ function entrySkillsFor(kind) {
|
|
|
4773
4778
|
function chatKickoffFor(runtime, opening) {
|
|
4774
4779
|
return opening === "ideate" ? `Proceed with your task: invoke the ${skillRef(runtime, "ideate")} skill and drive this ` + "feature's ideation to completion." : `Proceed with your task: invoke the ${skillRef(runtime, "revisit")} skill and continue this ` + "feature's conversation.";
|
|
4775
4780
|
}
|
|
4781
|
+
function selfPlanKickoffFor(runtime) {
|
|
4782
|
+
return "Proceed with your task: no human is present and nobody will reply to you. Invoke the " + `${skillRef(runtime, "ideate")} skill and follow its "${PLANNING_WITHOUT_A_HUMAN}" section ` + "to plan this feature by yourself, through the preview, the spec and the tickets, in this one run.";
|
|
4783
|
+
}
|
|
4776
4784
|
function kickoffLinesFor(runtime) {
|
|
4777
4785
|
const skill = (name) => skillRef(runtime, name);
|
|
4778
4786
|
return {
|
|
@@ -4788,7 +4796,7 @@ function projectTriageKickoffFor(runtime) {
|
|
|
4788
4796
|
function prepareConfirmKickoffFor(runtime) {
|
|
4789
4797
|
return `Proceed with your task: invoke the ${skillRef(runtime, "prepare")} skill. Every prepared field already ` + "has a value, so this is a confirmation, not a preparation \u2014 tell me what is recorded and " + "how stale it is, ask whether it still holds, and stop. Do not re-derive a settled value.";
|
|
4790
4798
|
}
|
|
4791
|
-
var PACK = "runcastle", SPELLINGS, ENTRY_SKILLS;
|
|
4799
|
+
var PACK = "runcastle", SPELLINGS, ENTRY_SKILLS, PLANNING_WITHOUT_A_HUMAN = "Planning without a human";
|
|
4792
4800
|
var init_skills = __esm(() => {
|
|
4793
4801
|
SPELLINGS = {
|
|
4794
4802
|
"claude-code": (skill) => `/${PACK}:${skill}`,
|
|
@@ -4829,7 +4837,8 @@ function chatOpening(feature, planning) {
|
|
|
4829
4837
|
function renderSystemPrompt(feature, kind, lap, purpose, runtime = DEFAULT_RUNTIME, carried, planning) {
|
|
4830
4838
|
if (lap !== undefined)
|
|
4831
4839
|
return renderRevisitPrompt(feature, lap, purpose, runtime, carried);
|
|
4832
|
-
|
|
4840
|
+
const selfPlan = purpose === "self-plan";
|
|
4841
|
+
if (kind === "chat" && !selfPlan && chatOpening(feature, planning) === "revisit") {
|
|
4833
4842
|
return renderRevisitPrompt(feature, lap, purpose, runtime, carried);
|
|
4834
4843
|
}
|
|
4835
4844
|
const docs2 = featureDocsRel(feature.slug);
|
|
@@ -4862,12 +4871,26 @@ function renderSystemPrompt(feature, kind, lap, purpose, runtime = DEFAULT_RUNTI
|
|
|
4862
4871
|
noCodeRule(docs2),
|
|
4863
4872
|
otherEntrySkillsRule(),
|
|
4864
4873
|
"",
|
|
4874
|
+
...selfPlan ? [selfPlanRule(), ""] : [],
|
|
4865
4875
|
"## Your task",
|
|
4866
4876
|
`Begin by invoking the \`${skillRef(runtime, "ideate")}\` skill and drive the ideation session to completion.`,
|
|
4867
4877
|
""
|
|
4868
4878
|
].join(`
|
|
4869
4879
|
`);
|
|
4870
4880
|
}
|
|
4881
|
+
function selfPlanRule() {
|
|
4882
|
+
return [
|
|
4883
|
+
"## No human is present",
|
|
4884
|
+
"This is a self-planning run: nobody is watching and nobody will reply.",
|
|
4885
|
+
"- Never end your turn to ask a question \u2014 ending the turn ends the run. Ask your design",
|
|
4886
|
+
" questions of yourself, as the skill describes, and answer them.",
|
|
4887
|
+
"- A refused tool is not a wait: do not retry it or wait for approval. Decide on what you",
|
|
4888
|
+
" know and treat that decision as a candidate to check first.",
|
|
4889
|
+
`- Follow the ideation skill's "${PLANNING_WITHOUT_A_HUMAN}" section through the preview, the`,
|
|
4890
|
+
" spec and the tickets, in this one run."
|
|
4891
|
+
].join(`
|
|
4892
|
+
`);
|
|
4893
|
+
}
|
|
4871
4894
|
function reviewEvidenceSection(lap, docs2, carried) {
|
|
4872
4895
|
const evidence = carried?.reviewEvidence ?? [];
|
|
4873
4896
|
return [
|
|
@@ -5451,6 +5474,10 @@ var init_skills_root = __esm(() => {
|
|
|
5451
5474
|
});
|
|
5452
5475
|
|
|
5453
5476
|
// src/launcher/runtimes/types.ts
|
|
5477
|
+
function assertHeadlessKickoff(headless, line) {
|
|
5478
|
+
if (headless && !line)
|
|
5479
|
+
throw new Error("a headless launch needs a kickoff line as its prompt");
|
|
5480
|
+
}
|
|
5454
5481
|
function assertKickoffArgv(line) {
|
|
5455
5482
|
if (line !== undefined && line.length > MAX_KICKOFF_ARGV) {
|
|
5456
5483
|
throw new Error(`kickoff line is ${line.length} characters; maximum is ${MAX_KICKOFF_ARGV}`);
|
|
@@ -5461,9 +5488,11 @@ var MAX_KICKOFF_ARGV = 30000;
|
|
|
5461
5488
|
// src/launcher/runtimes/claude.ts
|
|
5462
5489
|
function buildClaudeArgs(input) {
|
|
5463
5490
|
assertKickoffArgv(input.kickoffLine);
|
|
5491
|
+
assertHeadlessKickoff(input.headless, input.kickoffLine);
|
|
5464
5492
|
const permissionMode = input.permissionMode ?? "acceptEdits";
|
|
5465
|
-
const resume = input.resumeSessionId ? ["--resume", input.resumeSessionId] : [];
|
|
5493
|
+
const resume = input.resumeSessionId && !input.headless ? ["--resume", input.resumeSessionId] : [];
|
|
5466
5494
|
return [
|
|
5495
|
+
...input.headless ? ["-p"] : [],
|
|
5467
5496
|
...resume,
|
|
5468
5497
|
"--settings",
|
|
5469
5498
|
input.settingsPath,
|
|
@@ -5526,7 +5555,8 @@ var init_claude = __esm(() => {
|
|
|
5526
5555
|
model: input.model,
|
|
5527
5556
|
...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {},
|
|
5528
5557
|
...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {},
|
|
5529
|
-
strictMcp: input.config.sessionMcp === "runcastleOnly"
|
|
5558
|
+
strictMcp: input.config.sessionMcp === "runcastleOnly",
|
|
5559
|
+
...input.headless ? { headless: true } : {}
|
|
5530
5560
|
}),
|
|
5531
5561
|
env: {
|
|
5532
5562
|
RUNCASTLE_SESSION_ID: input.session.id,
|
|
@@ -7136,12 +7166,16 @@ function toml(value) {
|
|
|
7136
7166
|
function approvalPolicyFor(permissionMode) {
|
|
7137
7167
|
return permissionMode === undefined || permissionMode === "acceptEdits" ? "never" : "on-request";
|
|
7138
7168
|
}
|
|
7169
|
+
function sandboxModeFor(permissionMode, platform) {
|
|
7170
|
+
return platform === "win32" && approvalPolicyFor(permissionMode) === "never" ? "danger-full-access" : "workspace-write";
|
|
7171
|
+
}
|
|
7139
7172
|
function renderCodexConfig(input) {
|
|
7140
7173
|
const { session, config: config2, model, worktreePath, inheritedMcpServers, permissionMode } = input;
|
|
7174
|
+
const platform = input.platform ?? process.platform;
|
|
7141
7175
|
return [
|
|
7142
7176
|
`# Generated by runcastle for session ${session.id} \u2014 rewritten at every launch.`,
|
|
7143
7177
|
`model = ${toml(model)}`,
|
|
7144
|
-
|
|
7178
|
+
`sandbox_mode = ${toml(sandboxModeFor(permissionMode, platform))}`,
|
|
7145
7179
|
`approval_policy = ${toml(approvalPolicyFor(permissionMode))}`,
|
|
7146
7180
|
"check_for_update_on_startup = false",
|
|
7147
7181
|
"",
|
|
@@ -7155,6 +7189,7 @@ function renderCodexConfig(input) {
|
|
|
7155
7189
|
"[mcp_servers.runcastle]",
|
|
7156
7190
|
`url = ${toml(`${serverUrlFor(config2)}/mcp`)}`,
|
|
7157
7191
|
`http_headers = { "X-Runcastle-Session" = ${toml(session.id)} }`,
|
|
7192
|
+
'default_tools_approval_mode = "approve"',
|
|
7158
7193
|
""
|
|
7159
7194
|
].join(`
|
|
7160
7195
|
`);
|
|
@@ -7263,6 +7298,9 @@ function gitInfoDir(worktreePath) {
|
|
|
7263
7298
|
}
|
|
7264
7299
|
function buildCodexArgs(input) {
|
|
7265
7300
|
assertKickoffArgv(input.kickoffLine);
|
|
7301
|
+
assertHeadlessKickoff(input.headless, input.kickoffLine);
|
|
7302
|
+
if (input.headless)
|
|
7303
|
+
return ["exec", "--dangerously-bypass-hook-trust", input.kickoffLine];
|
|
7266
7304
|
return [
|
|
7267
7305
|
...input.resumeSessionId ? ["resume", input.resumeSessionId] : [],
|
|
7268
7306
|
"--dangerously-bypass-hook-trust",
|
|
@@ -7335,7 +7373,8 @@ var init_codex = __esm(() => {
|
|
|
7335
7373
|
files,
|
|
7336
7374
|
argv: buildCodexArgs({
|
|
7337
7375
|
...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {},
|
|
7338
|
-
...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {}
|
|
7376
|
+
...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {},
|
|
7377
|
+
...input.headless ? { headless: true } : {}
|
|
7339
7378
|
}),
|
|
7340
7379
|
env: {
|
|
7341
7380
|
CODEX_HOME: home,
|
|
@@ -7970,6 +8009,12 @@ function listDocs(ctx, feature) {
|
|
|
7970
8009
|
return { relPath: e.name, title: readTitle(path, e.name), ...writtenAt(path) };
|
|
7971
8010
|
}).sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
7972
8011
|
}
|
|
8012
|
+
function previewSummary(ctx, feature) {
|
|
8013
|
+
const path = featureDocPath(projectForFeature(ctx, feature), feature, PREVIEW_FILE);
|
|
8014
|
+
if (!existsSync12(path))
|
|
8015
|
+
return null;
|
|
8016
|
+
return { relPath: PREVIEW_FILE, ...writtenAt(path) };
|
|
8017
|
+
}
|
|
7973
8018
|
function readDoc(ctx, feature, relPath) {
|
|
7974
8019
|
const project = projectForFeature(ctx, feature);
|
|
7975
8020
|
const dir = featureDocsDir(project, feature);
|
|
@@ -8002,6 +8047,7 @@ function readTitle(path, fallback) {
|
|
|
8002
8047
|
}
|
|
8003
8048
|
var CHARTER_FILE = "CONTEXT.md", ADR_DIR_REL = "docs/adr", SUPERSEDED_RE;
|
|
8004
8049
|
var init_knowledge = __esm(() => {
|
|
8050
|
+
init_src();
|
|
8005
8051
|
init_errors();
|
|
8006
8052
|
init_events();
|
|
8007
8053
|
init_feature_docs();
|
|
@@ -57790,6 +57836,7 @@ async function runReviewGates(input) {
|
|
|
57790
57836
|
mkdirSync10(reviewGateLogDir(ticketId), { recursive: true });
|
|
57791
57837
|
await discardBranch();
|
|
57792
57838
|
await pinBranchAt(project3.repoPath, branch, sha);
|
|
57839
|
+
await excludeBurnWorktrees(project3.repoPath);
|
|
57793
57840
|
while (results.length < commands.length) {
|
|
57794
57841
|
await withSandbox(branch, async (sandbox3) => {
|
|
57795
57842
|
for (const [i, command] of commands.entries()) {
|
|
@@ -60552,6 +60599,7 @@ async function burnTicket(ctx, ticket, config2, token, model, hostAgentVersions,
|
|
|
60552
60599
|
await clearAttachments(burnWorktreePath(project3.repoPath, branch), project3.repoPath);
|
|
60553
60600
|
}
|
|
60554
60601
|
};
|
|
60602
|
+
await excludeBurnWorktrees(project3.repoPath);
|
|
60555
60603
|
const ticketJson = buildTicketJson(ticket);
|
|
60556
60604
|
const featureBrief = buildFeatureBrief(feature);
|
|
60557
60605
|
const docsDigest = blocks.docsDigest;
|
|
@@ -61659,6 +61707,7 @@ function getFeatureFull(ctx, id3) {
|
|
|
61659
61707
|
sessions: listSessionsByFeature(ctx, id3).map((session) => session.kind === "chat" && session.status === "ended" && !session.ccSessionId ? { ...session, title: null, transcriptMissing: true } : session),
|
|
61660
61708
|
runs: listRunsByFeature(ctx, id3),
|
|
61661
61709
|
docs: listDocs(ctx, feature),
|
|
61710
|
+
preview: previewSummary(ctx, feature),
|
|
61662
61711
|
dependsOn: dependsOnOf(ctx, id3),
|
|
61663
61712
|
blocks: blocksOf(ctx, id3)
|
|
61664
61713
|
};
|
|
@@ -64724,7 +64773,7 @@ function ptyExitMessage(exitCode) {
|
|
|
64724
64773
|
const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
|
|
64725
64774
|
return `terminal exited (code ${label})`;
|
|
64726
64775
|
}
|
|
64727
|
-
function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME) {
|
|
64776
|
+
function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME, opening = chatKickoffFor(runtime5, chatOpening(feature, planningFacts(ctx, feature)))) {
|
|
64728
64777
|
const counts = new Map;
|
|
64729
64778
|
for (const ticket of listByFeature(ctx, feature.id)) {
|
|
64730
64779
|
counts.set(ticket.status, (counts.get(ticket.status) ?? 0) + 1);
|
|
@@ -64737,7 +64786,6 @@ function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME) {
|
|
|
64737
64786
|
const outcome = !latestRun ? "never driven" : latestReview?.status === "done" ? "passed" : latestReview?.status === "failed" ? "failed" : "unverified";
|
|
64738
64787
|
review = ` Drive outcome: ${outcome}; see the review evidence in get_feature_context.`;
|
|
64739
64788
|
}
|
|
64740
|
-
const opening = chatKickoffFor(runtime5, chatOpening(feature, planningFacts(ctx, feature)));
|
|
64741
64789
|
return `${opening} Feature state: ${feature.phase}; lap ${feature.lap}; tickets: ${ticketSummary}; latest run: ${latestRun?.status ?? "none"}.${review} Call get_feature_context for the full picture.`;
|
|
64742
64790
|
}
|
|
64743
64791
|
function assertRuntimeReady(runtime5, opts) {
|
|
@@ -64764,6 +64812,21 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
|
|
|
64764
64812
|
throw new GateError(`a ${live[0].kind} session is already live for ${feature.slug} \u2014 only one terminal per feature; end or resume it first`);
|
|
64765
64813
|
}
|
|
64766
64814
|
}
|
|
64815
|
+
function isSelfPlan(input) {
|
|
64816
|
+
return input.kind === "chat" && input.purpose === "self-plan";
|
|
64817
|
+
}
|
|
64818
|
+
function assertSelfPlannable(ctx, feature) {
|
|
64819
|
+
if (feature.phase !== "planning") {
|
|
64820
|
+
throw new GateError(`${feature.slug} is past planning, so there is nothing for it to plan by itself \u2014 open the chat to change it`);
|
|
64821
|
+
}
|
|
64822
|
+
if (chatOpening(feature, planningFacts(ctx, feature)) !== "ideate") {
|
|
64823
|
+
throw new GateError(`${feature.slug} already has planning on record (decisions, a spec or tickets), so it cannot plan itself from scratch \u2014 open the chat to carry on`);
|
|
64824
|
+
}
|
|
64825
|
+
assertSpawnable(ctx, feature);
|
|
64826
|
+
}
|
|
64827
|
+
function selfPlanRunningError(feature) {
|
|
64828
|
+
return new GateError(`${feature.slug} is planning itself \u2014 stop the run first, then open the chat to pick up where it got to`);
|
|
64829
|
+
}
|
|
64767
64830
|
async function ensureWorktree(ctx, project3, feature) {
|
|
64768
64831
|
const running3 = activeRunFor(ctx, feature.id);
|
|
64769
64832
|
const besideRun = !!running3 && workflowClaimsFeatureBranch(running3.workflow);
|
|
@@ -64845,7 +64908,11 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
64845
64908
|
requireNotDraft(feature);
|
|
64846
64909
|
if (input.kind === "chat") {
|
|
64847
64910
|
const liveChat = activeSessionsForFeature(ctx, feature.id).find((s) => s.kind === "chat");
|
|
64848
|
-
if (liveChat
|
|
64911
|
+
if (liveChat?.purpose === "self-plan")
|
|
64912
|
+
throw selfPlanRunningError(feature);
|
|
64913
|
+
if (isSelfPlan(input))
|
|
64914
|
+
assertSelfPlannable(ctx, feature);
|
|
64915
|
+
else if (liveChat && isStartLap(feature, input))
|
|
64849
64916
|
endSession(ctx, liveChat.id);
|
|
64850
64917
|
else if (liveChat)
|
|
64851
64918
|
return briefLiveChat(ctx, feature, liveChat, input);
|
|
@@ -64865,8 +64932,9 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
64865
64932
|
});
|
|
64866
64933
|
const carried = startLapCarriedWork(ctx, feature.id);
|
|
64867
64934
|
const plan = planLaunchKickoff(feature, input, carried);
|
|
64935
|
+
const selfPlan = isSelfPlan(input);
|
|
64868
64936
|
if (input.kind === "chat") {
|
|
64869
|
-
const header = chatKickoffHeader(ctx, feature, runtime5.id);
|
|
64937
|
+
const header = chatKickoffHeader(ctx, feature, runtime5.id, selfPlan ? selfPlanKickoffFor(runtime5.id) : undefined);
|
|
64870
64938
|
plan.line = plan.line ? `${plan.line} ${header}` : header;
|
|
64871
64939
|
}
|
|
64872
64940
|
let resumeSessionId;
|
|
@@ -64879,6 +64947,8 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
64879
64947
|
markSessionEnded(ctx, session.id);
|
|
64880
64948
|
throw e;
|
|
64881
64949
|
}
|
|
64950
|
+
}
|
|
64951
|
+
if (input.kind === "chat" && !selfPlan) {
|
|
64882
64952
|
const prior = mostRecentResumableSession(ctx, feature.id, "chat");
|
|
64883
64953
|
if (prior?.ccSessionId) {
|
|
64884
64954
|
resumedFrom = prior;
|
|
@@ -64918,12 +64988,13 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
64918
64988
|
const kickoffLine = resumeSessionId && input.kind !== "chat" ? undefined : kickoffLineFor(input.kind, plan.line, runtime5.id);
|
|
64919
64989
|
emit(ctx, feature.id, {
|
|
64920
64990
|
type: "session.launching",
|
|
64921
|
-
message: `launching ${input.kind} session`,
|
|
64991
|
+
message: selfPlan ? "launching a self-planning run" : `launching ${input.kind} session`,
|
|
64922
64992
|
data: {
|
|
64923
64993
|
sessionId: session.id,
|
|
64924
64994
|
kind: input.kind,
|
|
64925
64995
|
worktreePath,
|
|
64926
|
-
...modelStamp(model)
|
|
64996
|
+
...modelStamp(model),
|
|
64997
|
+
...input.purpose ? { purpose: input.purpose } : {}
|
|
64927
64998
|
}
|
|
64928
64999
|
});
|
|
64929
65000
|
if (resumeSessionId) {
|
|
@@ -64960,7 +65031,8 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
64960
65031
|
model: model.id,
|
|
64961
65032
|
resumeSessionId,
|
|
64962
65033
|
resumeSourceSessionId: resumedFrom?.id,
|
|
64963
|
-
kickoffLine
|
|
65034
|
+
kickoffLine,
|
|
65035
|
+
...selfPlan ? { headless: true } : {}
|
|
64964
65036
|
});
|
|
64965
65037
|
if (opts.spawn === false) {
|
|
64966
65038
|
emit(ctx, feature.id, {
|
|
@@ -65306,6 +65378,15 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
|
|
|
65306
65378
|
}
|
|
65307
65379
|
});
|
|
65308
65380
|
}
|
|
65381
|
+
if (session.purpose === "self-plan" && feature) {
|
|
65382
|
+
const current2 = getFeatureRow(ctx, feature.id);
|
|
65383
|
+
const complete4 = current2.ticketsReadyLap === current2.lap;
|
|
65384
|
+
emitForSession(ctx, session, {
|
|
65385
|
+
type: "session.self_plan_ended",
|
|
65386
|
+
message: complete4 ? "planning finished" : "planning stopped before the plan was complete",
|
|
65387
|
+
data: { sessionId: session.id, complete: complete4, exitCode: exitCode ?? null }
|
|
65388
|
+
});
|
|
65389
|
+
}
|
|
65309
65390
|
emitForSession(ctx, session, {
|
|
65310
65391
|
type: "session.pty_exited",
|
|
65311
65392
|
message: ptyExitMessage(exitCode),
|
package/package.json
CHANGED
|
@@ -22,12 +22,14 @@ Everything happens in this one window. **Never compact. Never `/clear`. Never su
|
|
|
22
22
|
|
|
23
23
|
- `mcp__runcastle__get_feature_context` / `mcp__runcastle__read_feature_doc` / `mcp__runcastle__list_tickets` / `mcp__runcastle__get_ticket` — the record.
|
|
24
24
|
- `mcp__runcastle__create_feature({ title, oneLiner, brief, draft: true, dependsOn? })` — park scope creep as a draft feature (§1), with `dependsOn: [<this feature's slug>]` when it builds on this one. `draft: true` is the only door open to you; a full create belongs to the project session.
|
|
25
|
-
- `mcp__runcastle__complete_phase` — the phase boundaries (§
|
|
25
|
+
- `mcp__runcastle__complete_phase` — the phase boundaries (§4), and the ones `/runcastle:spec` and `/runcastle:tickets` cross after you.
|
|
26
26
|
- `mcp__runcastle__emit_tickets` — used by `/runcastle:tickets`, not by you directly.
|
|
27
27
|
- `mcp__runcastle__record_event` — the feature timeline. Most state changes already emit their own event; only reach for this when the timeline would otherwise have no record at all.
|
|
28
28
|
|
|
29
29
|
## 1. Grill relentlessly
|
|
30
30
|
|
|
31
|
+
This is the **grill path**: the human is here and answers. When your briefing says no human is present, take the **self-planning path** in "Planning without a human" below instead; the rules of this section do not bind it.
|
|
32
|
+
|
|
31
33
|
Interview the human about every aspect of the feature until you reach a genuinely shared understanding. Walk each branch of the design tree, resolving dependencies between decisions one at a time.
|
|
32
34
|
|
|
33
35
|
- **One question at a time.** Wait for the answer before the next. Asking several at once is bewildering.
|
|
@@ -38,7 +40,19 @@ Interview the human about every aspect of the feature until you reach a genuinel
|
|
|
38
40
|
- **Park scope creep instead of swallowing it.** When something surfaces that is plainly its own feature, say so and offer to park it: `mcp__runcastle__create_feature({ title, oneLiner, brief, draft: true })` creates a parked draft — no branch, nothing written — with the `brief` carrying why you deferred it and what it must not swallow. When the parked scope builds on this feature, also pass `dependsOn: [<this feature's slug>]` so it cannot be started before this one merges. Then get back to this feature. Drafts are the only thing you may create here; a full create belongs to the project session.
|
|
39
41
|
- **A feature bigger than expected gets a thin lap 1.** The project session sizes features to fit one ideation window before they reach you, so this is rare. When a feature still turns out bigger than this window mid-grill, do not grind on and do not split it yourself: put the slicing question to the human again, recommend a thin lap 1, and park the rest under the spec's `## Later laps`.
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
On the grill path, do not move on until the human confirms you have reached shared understanding. That confirmation happens on the preview (§3), not in chat alone.
|
|
44
|
+
|
|
45
|
+
## 1a. Planning without a human
|
|
46
|
+
|
|
47
|
+
This is the **self-planning path**. It applies only when your briefing says no human is present; with a human here, §1 stands and this section does not. You still look up facts, model the domain and park scope creep as §1 says. What changes is who answers.
|
|
48
|
+
|
|
49
|
+
- **One turn.** Do the whole plan in this one turn. Never end the turn to ask a question or to report progress: nobody will reply, and ending the turn ends the run.
|
|
50
|
+
- **Frontier rounds.** Work in rounds. A round is every open question that does not depend on an unanswered one. Write each as you would put it to the human, with your recommended answer and the main alternative, then accept your own recommendations and record them. Then ask what those answers opened: that is the next round. Write each round to `decisions.md` as it is decided (§2), not at the end, so a run that dies keeps what it had.
|
|
51
|
+
- **Depth rule.** Record a decision only if it changes what the feature is or does for its users, or the human might reasonably overturn it. Anything an engineer would settle while building (wording, internal formats, test coverage) is not a question; it goes to the spec as detail.
|
|
52
|
+
- **Exactly one challenger.** When a round would be empty, run one separate agent: the Agent tool in Claude Code, `spawn_agent` in Codex. Give it only the brief and `decisions.md`. Ask it for (a) design branches never asked, within the depth rule, and (b) decisions it would have made differently, with its reason. Do not tell it what you expect to hear. Ask its branches as one more round and decide them, with whatever they open. A decision it disagrees with is marked challenged with its reason (§2), and your own answer stands. There is no second pass, and no requirement that the pass come back empty. If you have no tool that runs a separate agent, challenge your own plan as a separate pass and write in `decisions.md` that the challenge was not independent.
|
|
53
|
+
- **A refused tool is not a wait.** If a tool is refused, do not retry it and do not wait: decide on what you know, and treat that decision as a candidate to check first.
|
|
54
|
+
- **Check first.** Pick at most five decisions for the human to check first: guesses about the human's intent that the brief did not settle, decisions that force several others, and decisions the challenger disagreed with. They open the preview's decisions part (`./references/preview.md`).
|
|
55
|
+
- **Straight through.** Then draw the preview and run its self-check (§3, steps 1 and 2), but do not tell the human to look and do not wait: complete ideation (§4), run `/runcastle:spec`, then `/runcastle:tickets` (§5). End with one closing message (§6): the plan was made without the human, how many decisions it holds, and which are to check first.
|
|
42
56
|
|
|
43
57
|
## 2. Lock decisions incrementally
|
|
44
58
|
|
|
@@ -50,24 +64,64 @@ The moment a decision locks, append it to `docs/features/<slug>/decisions.md`
|
|
|
50
64
|
**Why:** <the reason / the trade-off chosen over the alternatives>
|
|
51
65
|
```
|
|
52
66
|
|
|
67
|
+
A decision you made without the human (the self-planning path, §1a) keeps that shape and adds one line naming the main alternative it was chosen over. A decision the challenger disagreed with adds a further line with its reason:
|
|
68
|
+
|
|
69
|
+
```markdown
|
|
70
|
+
## <n>. <short decision title>
|
|
71
|
+
**Decision:** <the choice>
|
|
72
|
+
**Why:** <the reason>
|
|
73
|
+
**Assumed.** Alternative: <the main alternative>.
|
|
74
|
+
**Challenged:** <the challenger's reason>.
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For example:
|
|
78
|
+
|
|
79
|
+
```markdown
|
|
80
|
+
## 4. Tags and dates are left out of the PDF
|
|
81
|
+
**Decision:** The PDF carries the title and body only.
|
|
82
|
+
**Why:** The brief asks for the note, not its filing.
|
|
83
|
+
**Assumed.** Alternative: a small footer with tags and the last-edited date.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
When the human later confirms or changes such a decision, the session doing the edit removes its `**Assumed.**` and `**Challenged:**` lines; from then on it reads as any other decision.
|
|
87
|
+
|
|
53
88
|
Keep `decisions.md` free of implementation minutiae — it is the record of *what was decided and why*, the raw material spec and tickets are built from. `complete_phase` checkpoints the file into git for you; you just write it. The file is the record — do not narrate it onto the timeline as you go.
|
|
54
89
|
|
|
55
|
-
## 3.
|
|
90
|
+
## 3. Draw the preview, then wait for the human to look
|
|
91
|
+
|
|
92
|
+
**The preview is what the human confirms shared understanding on.** It is one self-contained page, `docs/features/<slug>/preview.html`, that shows the feature and every decision; the human reads the feature by looking at it.
|
|
93
|
+
|
|
94
|
+
1. **Draw it.** When the decisions are locked, read `./references/preview.md` (the rules) and write `preview.html` from `./references/preview-starter.html`.
|
|
95
|
+
2. **Check it against `decisions.md`.** Every decision appears by its number and title. Fix the preview before anyone sees it.
|
|
96
|
+
|
|
97
|
+
On the self-planning path (§1a) steps 1 and 2 are the whole of this section: go straight on to §4. Steps 3 to 5 are the grill path.
|
|
56
98
|
|
|
57
|
-
|
|
99
|
+
3. **Tell the human to look, then wait.** Say the preview is on the feature's **Overview**, under the Burn row, and that the "Preview ready" notice in Chat leads there. Then stop and wait for their answer.
|
|
100
|
+
4. **A correction is a decision edit plus a redraw.** Edit the decision in `decisions.md` and redraw the preview in the same move, then ask them to look again.
|
|
101
|
+
5. **Only after the human confirms**, complete ideation (§4).
|
|
102
|
+
|
|
103
|
+
**Never skip the preview on your own judgement** — not because the feature is small, not because the grill went quickly. A small feature gets a small preview. **If the human says to skip it**, write no file and go straight on to §4.
|
|
104
|
+
|
|
105
|
+
**The redraw rule.** While the feature is at planning, any later edit to `decisions.md` — in this session or after it — updates `preview.html` in the same move, before anything else.
|
|
106
|
+
|
|
107
|
+
## 4. Complete ideation
|
|
108
|
+
|
|
109
|
+
On the grill path, when the human confirms shared understanding on the preview and the open questions are answered or explicitly deferred. On the self-planning path (§1a), as soon as the preview passes its check, without waiting:
|
|
58
110
|
|
|
59
111
|
`mcp__runcastle__complete_phase({ phase: "ideation" })` → returns `{ ok: true, nextPhase, nextStep }`. It does not refuse and it does not move the feature: ideation, spec and tickets are three steps inside **Planning**, and this records the milestone on the timeline. `nextStep` is the next step still missing its artifact, read off disk rather than stored — so if `decisions.md` is not written yet it comes back as `"ideation"` again, which is your cue to write it. It logs its own timeline event; do not add one.
|
|
60
112
|
|
|
61
|
-
##
|
|
113
|
+
## 5. Spec → tickets — stay in this window
|
|
62
114
|
|
|
63
115
|
Invoke `/runcastle:spec` (it writes `spec.md` and completes the `spec` phase); when it returns, invoke `/runcastle:tickets`.
|
|
64
116
|
|
|
65
117
|
Do not open a new session for these. They run here, on top of everything you just learned.
|
|
66
118
|
|
|
67
|
-
##
|
|
119
|
+
## 6. Close — hand off to the human
|
|
68
120
|
|
|
69
121
|
`/runcastle:tickets` ends by emitting tickets to the runcastle store. When it returns, your job is **done**. Tell the human plainly:
|
|
70
122
|
|
|
71
123
|
> Tickets are in the runcastle UI. Review the ticket cards and click **Burn** to start the AFK agents. That is the next step — I will stop here.
|
|
72
124
|
|
|
125
|
+
On the self-planning path (§1a), the closing message also says that the plan was made without the human, how many decisions it holds, and which are to check first, by number and title. Then end the turn; the run is done.
|
|
126
|
+
|
|
73
127
|
Do **not** start burning. Burn and Merge are the only two clicks in the whole pipeline, and both belong to the human. (You do not write code here either — that rule is in your injected prompt and enforced by the edit guard, not restated as advice.)
|