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/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
- if (kind === "chat" && chatOpening(feature, planning) === "revisit") {
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
- 'sandbox_mode = "workspace-write"',
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 && isStartLap(feature, input))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
@@ -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 (§3), and the ones `/runcastle:spec` and `/runcastle:tickets` cross after you.
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
- Do not move on until the human confirms you have reached shared understanding.
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. Complete ideation
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
- When the human confirms shared understanding and the open questions are answered or explicitly deferred:
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
- ## 4. Spec → tickets — stay in this window
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
- ## 5. Close — hand off to the human
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.)