runcastle 1.4.4 → 1.4.7

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 CHANGED
@@ -45,7 +45,7 @@ function agentDigestFillRank(relPath) {
45
45
  const i = AGENT_DIGEST_FILL_ORDER.indexOf(relPath.toLowerCase());
46
46
  return i === -1 ? AGENT_DIGEST_FILL_ORDER.length : i;
47
47
  }
48
- var AGENT_DIGEST_DOCS, WITHHELD_FEATURE_DOCS, AGENT_DIGEST_FILL_ORDER;
48
+ var AGENT_DIGEST_DOCS, PREVIEW_FILE = "preview.html", WITHHELD_FEATURE_DOCS, AGENT_DIGEST_FILL_ORDER;
49
49
  var init_docs = __esm(() => {
50
50
  AGENT_DIGEST_DOCS = ["brief.md", "decisions.md", "spec.md"];
51
51
  WITHHELD_FEATURE_DOCS = {
@@ -264,7 +264,7 @@ var init_schemas = __esm(() => {
264
264
  "drive-fix"
265
265
  ]);
266
266
  PROJECT_SESSION_KINDS = ["prepare", "project"];
267
- SessionPurpose = z3.enum(["resolve-conflict", "start-lap"]);
267
+ SessionPurpose = z3.enum(["resolve-conflict", "start-lap", "self-plan"]);
268
268
  MergeBranchPair = z3.object({
269
269
  mergeFrom: z3.string(),
270
270
  mergeInto: z3.string()
@@ -1659,8 +1659,9 @@ ${why.replace(/\n/g, `\r
1659
1659
  containment?.kill();
1660
1660
  fireExit(code ?? 0, signal ? 1 : undefined);
1661
1661
  });
1662
+ child.stdin?.on("error", () => {});
1662
1663
  function sendToHost(obj) {
1663
- if (!child.stdin || child.stdin.destroyed)
1664
+ if (exitFired || !child.stdin || child.stdin.destroyed)
1664
1665
  return;
1665
1666
  try {
1666
1667
  child.stdin.write(JSON.stringify(obj) + `
@@ -2627,7 +2628,7 @@ function noteDocsChange(ctx, featureId, filename) {
2627
2628
  const entry = watches.get(featureId);
2628
2629
  if (!entry)
2629
2630
  return;
2630
- if (filename !== null && !filename.endsWith(".md"))
2631
+ if (filename !== null && !filename.endsWith(".md") && filename !== PREVIEW_FILE)
2631
2632
  return;
2632
2633
  if (filename)
2633
2634
  entry.changed.add(filename);
@@ -2654,6 +2655,7 @@ function emitDocsChanged(ctx, featureId) {
2654
2655
  }
2655
2656
  var DOCS_WATCH_DEBOUNCE_MS = 300, watches;
2656
2657
  var init_docs_watch = __esm(() => {
2658
+ init_src();
2657
2659
  init_events();
2658
2660
  init_feature_docs();
2659
2661
  init_repo();
@@ -3576,6 +3578,9 @@ function chatBranchName(slug, unique) {
3576
3578
  function burnWorktreePath(repoPath, branch) {
3577
3579
  return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
3578
3580
  }
3581
+ async function excludeBurnWorktrees(repoPath) {
3582
+ await excludePath(repoPath, BURN_WORKTREES_PATTERN);
3583
+ }
3579
3584
  async function excludeFilePath(repoPath) {
3580
3585
  const raw = (await git(repoPath).raw(["rev-parse", "--git-path", "info/exclude"])).trim();
3581
3586
  return resolve3(repoPath, raw);
@@ -4616,7 +4621,7 @@ async function mergeInProgress(g) {
4616
4621
  return false;
4617
4622
  }
4618
4623
  }
4619
- 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";
4624
+ 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";
4620
4625
  var init_git = __esm(() => {
4621
4626
  init_src();
4622
4627
  init_paths();
@@ -4754,6 +4759,64 @@ var init_install_guard = __esm(() => {
4754
4759
  HEREDOC_OPENER = /(?<!<)<<(?!<)(-?)[ \t]*(?:'([^'\n]+)'|"([^"\n]+)"|\\?([\w.-]+))/g;
4755
4760
  });
4756
4761
 
4762
+ // src/launcher/skills-root.ts
4763
+ import { existsSync as existsSync7 } from "fs";
4764
+ import { dirname as dirname3, join as join7, resolve as resolve6 } from "path";
4765
+ import { fileURLToPath as fileURLToPath3 } from "url";
4766
+ function resolveSkillsRoot(fromDir) {
4767
+ const override = process.env[SKILLS_DIR_ENV];
4768
+ if (override) {
4769
+ const root = resolve6(override);
4770
+ if (existsSync7(join7(root, SKILLS_MARKER)))
4771
+ return root;
4772
+ throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
4773
+ }
4774
+ const searched = [];
4775
+ let dir = fromDir;
4776
+ for (let i = 0;i < 8; i += 1) {
4777
+ const candidate = join7(dir, "packages", "skills");
4778
+ searched.push(candidate);
4779
+ if (existsSync7(join7(candidate, SKILLS_MARKER)))
4780
+ return candidate;
4781
+ const parent = dirname3(dir);
4782
+ if (parent === dir)
4783
+ break;
4784
+ dir = parent;
4785
+ }
4786
+ throw new Error(`runcastle skills root not found; searched:
4787
+ ${searched.join(`
4788
+ `)}`);
4789
+ }
4790
+ function resolvePluginDir(fromDir = dirname3(fileURLToPath3(import.meta.url))) {
4791
+ const rel = join7("packages", "skills", "packs", "runcastle");
4792
+ const override = process.env[SKILLS_DIR_ENV];
4793
+ if (override) {
4794
+ const dir2 = join7(resolve6(override), "packs", "runcastle");
4795
+ if (existsSync7(dir2))
4796
+ return dir2;
4797
+ throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
4798
+ }
4799
+ const searched = [];
4800
+ let dir = fromDir;
4801
+ for (let i = 0;i < 8; i += 1) {
4802
+ const candidate = join7(dir, rel);
4803
+ searched.push(candidate);
4804
+ if (existsSync7(candidate))
4805
+ return candidate;
4806
+ const parent = dirname3(dir);
4807
+ if (parent === dir)
4808
+ break;
4809
+ dir = parent;
4810
+ }
4811
+ throw new Error(`runcastle plugin dir (${rel}) not found; searched:
4812
+ ${searched.join(`
4813
+ `)}`);
4814
+ }
4815
+ var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
4816
+ var init_skills_root = __esm(() => {
4817
+ SKILLS_MARKER = join7("packs", "runcastle");
4818
+ });
4819
+
4757
4820
  // src/launcher/runtimes/skills.ts
4758
4821
  function skillRef(runtime, skill) {
4759
4822
  return SPELLINGS[runtime](skill);
@@ -4774,6 +4837,9 @@ function entrySkillsFor(kind) {
4774
4837
  function chatKickoffFor(runtime, opening) {
4775
4838
  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.";
4776
4839
  }
4840
+ function selfPlanKickoffFor(runtime) {
4841
+ 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.";
4842
+ }
4777
4843
  function kickoffLinesFor(runtime) {
4778
4844
  const skill = (name) => skillRef(runtime, name);
4779
4845
  return {
@@ -4789,7 +4855,7 @@ function projectTriageKickoffFor(runtime) {
4789
4855
  function prepareConfirmKickoffFor(runtime) {
4790
4856
  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.";
4791
4857
  }
4792
- var PACK = "runcastle", SPELLINGS, ENTRY_SKILLS;
4858
+ var PACK = "runcastle", SPELLINGS, ENTRY_SKILLS, PLANNING_WITHOUT_A_HUMAN = "Planning without a human";
4793
4859
  var init_skills = __esm(() => {
4794
4860
  SPELLINGS = {
4795
4861
  "claude-code": (skill) => `/${PACK}:${skill}`,
@@ -4806,10 +4872,10 @@ var init_skills = __esm(() => {
4806
4872
 
4807
4873
  // src/launcher/artifacts.ts
4808
4874
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
4809
- import { fileURLToPath as fileURLToPath3 } from "url";
4810
- import { join as join7 } from "path";
4875
+ import { fileURLToPath as fileURLToPath4 } from "url";
4876
+ import { join as join8 } from "path";
4811
4877
  function hookClientPath() {
4812
- return resolveAsset(ASSET_ENV.hookClient, fileURLToPath3(new URL("./hook-client.ts", import.meta.url)));
4878
+ return resolveAsset(ASSET_ENV.hookClient, fileURLToPath4(new URL("./hook-client.ts", import.meta.url)));
4813
4879
  }
4814
4880
  function serverUrlFor(config2) {
4815
4881
  return `http://localhost:${config2.serverPort}`;
@@ -4830,7 +4896,8 @@ function chatOpening(feature, planning) {
4830
4896
  function renderSystemPrompt(feature, kind, lap, purpose, runtime = DEFAULT_RUNTIME, carried, planning) {
4831
4897
  if (lap !== undefined)
4832
4898
  return renderRevisitPrompt(feature, lap, purpose, runtime, carried);
4833
- if (kind === "chat" && chatOpening(feature, planning) === "revisit") {
4899
+ const selfPlan = purpose === "self-plan";
4900
+ if (kind === "chat" && !selfPlan && chatOpening(feature, planning) === "revisit") {
4834
4901
  return renderRevisitPrompt(feature, lap, purpose, runtime, carried);
4835
4902
  }
4836
4903
  const docs2 = featureDocsRel(feature.slug);
@@ -4863,12 +4930,26 @@ function renderSystemPrompt(feature, kind, lap, purpose, runtime = DEFAULT_RUNTI
4863
4930
  noCodeRule(docs2),
4864
4931
  otherEntrySkillsRule(),
4865
4932
  "",
4933
+ ...selfPlan ? [selfPlanRule(), ""] : [],
4866
4934
  "## Your task",
4867
4935
  `Begin by invoking the \`${skillRef(runtime, "ideate")}\` skill and drive the ideation session to completion.`,
4868
4936
  ""
4869
4937
  ].join(`
4870
4938
  `);
4871
4939
  }
4940
+ function selfPlanRule() {
4941
+ return [
4942
+ "## No human is present",
4943
+ "This is a self-planning run: nobody is watching and nobody will reply.",
4944
+ "- Never end your turn to ask a question \u2014 ending the turn ends the run. Ask your design",
4945
+ " questions of yourself, as the skill describes, and answer them.",
4946
+ "- A refused tool is not a wait: do not retry it or wait for approval. Decide on what you",
4947
+ " know and treat that decision as a candidate to check first.",
4948
+ `- Follow the ideation skill's "${PLANNING_WITHOUT_A_HUMAN}" section through the preview, the`,
4949
+ " spec and the tickets, in this one run."
4950
+ ].join(`
4951
+ `);
4952
+ }
4872
4953
  function reviewEvidenceSection(lap, docs2, carried) {
4873
4954
  const evidence = carried?.reviewEvidence ?? [];
4874
4955
  return [
@@ -5238,6 +5319,10 @@ function renderProjectPrompt(brief, runtime = DEFAULT_RUNTIME) {
5238
5319
  ].join(`
5239
5320
  `);
5240
5321
  }
5322
+ function skillPackReadRule(packDir) {
5323
+ const posix2 = packDir.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`).replace(/\/+$/, "");
5324
+ return `Read(/${posix2}/**)`;
5325
+ }
5241
5326
  function entrySkillDenyRules(kind) {
5242
5327
  const allowed = new Set(entrySkillsFor(kind));
5243
5328
  return ENTRY_SKILLS.filter((skill) => !allowed.has(skill)).flatMap((skill) => [
@@ -5256,7 +5341,7 @@ function sessionBashAllowRules(kind) {
5256
5341
  }
5257
5342
  return SESSION_BASH_ALLOW_RULES;
5258
5343
  }
5259
- function renderSettings(hookClient, kind) {
5344
+ function renderSettings(hookClient, kind, skillPackDir) {
5260
5345
  const cmd = (event) => ({
5261
5346
  type: "command",
5262
5347
  command: `bun run "${hookClient}" ${event}`,
@@ -5264,7 +5349,11 @@ function renderSettings(hookClient, kind) {
5264
5349
  });
5265
5350
  return {
5266
5351
  permissions: {
5267
- allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...sessionBashAllowRules(kind)],
5352
+ allow: [
5353
+ ...RUNCASTLE_MCP_ALLOW_RULES,
5354
+ ...sessionBashAllowRules(kind),
5355
+ ...skillPackDir ? [skillPackReadRule(skillPackDir)] : []
5356
+ ],
5268
5357
  deny: entrySkillDenyRules(kind)
5269
5358
  },
5270
5359
  hooks: {
@@ -5325,15 +5414,15 @@ function renderSessionPrompt(input) {
5325
5414
  return renderProjectPrompt(projectBrief, runtime);
5326
5415
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
5327
5416
  }
5328
- async function writeSessionArtifacts(input) {
5417
+ async function writeSessionArtifacts(input, pluginDir = resolvePluginDir()) {
5329
5418
  const { session, config: config2 } = input;
5330
5419
  const dir = sessionDir(session.id);
5331
5420
  mkdirSync3(dir, { recursive: true });
5332
- const systemPromptPath = join7(dir, "system-prompt.md");
5333
- const settingsPath = join7(dir, "settings.json");
5334
- const mcpConfigPath = join7(dir, "mcp.json");
5421
+ const systemPromptPath = join8(dir, "system-prompt.md");
5422
+ const settingsPath = join8(dir, "settings.json");
5423
+ const mcpConfigPath = join8(dir, "mcp.json");
5335
5424
  writeFileSync2(systemPromptPath, renderSessionPrompt(input), "utf8");
5336
- writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
5425
+ writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind, pluginDir), null, 2), "utf8");
5337
5426
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
5338
5427
  return { systemPromptPath, settingsPath, mcpConfigPath };
5339
5428
  }
@@ -5345,6 +5434,7 @@ var init_artifacts = __esm(() => {
5345
5434
  init_asset_paths();
5346
5435
  init_edit_guard();
5347
5436
  init_install_guard();
5437
+ init_skills_root();
5348
5438
  init_skills();
5349
5439
  RUNCASTLE_MCP_ALLOW_RULES = [
5350
5440
  "mcp__runcastle__get_feature_context",
@@ -5393,65 +5483,11 @@ var init_artifacts = __esm(() => {
5393
5483
  SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
5394
5484
  });
5395
5485
 
5396
- // src/launcher/skills-root.ts
5397
- import { existsSync as existsSync7 } from "fs";
5398
- import { dirname as dirname3, join as join8, resolve as resolve6 } from "path";
5399
- import { fileURLToPath as fileURLToPath4 } from "url";
5400
- function resolveSkillsRoot(fromDir) {
5401
- const override = process.env[SKILLS_DIR_ENV];
5402
- if (override) {
5403
- const root = resolve6(override);
5404
- if (existsSync7(join8(root, SKILLS_MARKER)))
5405
- return root;
5406
- throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
5407
- }
5408
- const searched = [];
5409
- let dir = fromDir;
5410
- for (let i = 0;i < 8; i += 1) {
5411
- const candidate = join8(dir, "packages", "skills");
5412
- searched.push(candidate);
5413
- if (existsSync7(join8(candidate, SKILLS_MARKER)))
5414
- return candidate;
5415
- const parent = dirname3(dir);
5416
- if (parent === dir)
5417
- break;
5418
- dir = parent;
5419
- }
5420
- throw new Error(`runcastle skills root not found; searched:
5421
- ${searched.join(`
5422
- `)}`);
5423
- }
5424
- function resolvePluginDir(fromDir = dirname3(fileURLToPath4(import.meta.url))) {
5425
- const rel = join8("packages", "skills", "packs", "runcastle");
5426
- const override = process.env[SKILLS_DIR_ENV];
5427
- if (override) {
5428
- const dir2 = join8(resolve6(override), "packs", "runcastle");
5429
- if (existsSync7(dir2))
5430
- return dir2;
5431
- throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
5432
- }
5433
- const searched = [];
5434
- let dir = fromDir;
5435
- for (let i = 0;i < 8; i += 1) {
5436
- const candidate = join8(dir, rel);
5437
- searched.push(candidate);
5438
- if (existsSync7(candidate))
5439
- return candidate;
5440
- const parent = dirname3(dir);
5441
- if (parent === dir)
5442
- break;
5443
- dir = parent;
5444
- }
5445
- throw new Error(`runcastle plugin dir (${rel}) not found; searched:
5446
- ${searched.join(`
5447
- `)}`);
5448
- }
5449
- var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
5450
- var init_skills_root = __esm(() => {
5451
- SKILLS_MARKER = join8("packs", "runcastle");
5452
- });
5453
-
5454
5486
  // src/launcher/runtimes/types.ts
5487
+ function assertHeadlessKickoff(headless, line) {
5488
+ if (headless && !line)
5489
+ throw new Error("a headless launch needs a kickoff line as its prompt");
5490
+ }
5455
5491
  function assertKickoffArgv(line) {
5456
5492
  if (line !== undefined && line.length > MAX_KICKOFF_ARGV) {
5457
5493
  throw new Error(`kickoff line is ${line.length} characters; maximum is ${MAX_KICKOFF_ARGV}`);
@@ -5462,9 +5498,11 @@ var MAX_KICKOFF_ARGV = 30000;
5462
5498
  // src/launcher/runtimes/claude.ts
5463
5499
  function buildClaudeArgs(input) {
5464
5500
  assertKickoffArgv(input.kickoffLine);
5501
+ assertHeadlessKickoff(input.headless, input.kickoffLine);
5465
5502
  const permissionMode = input.permissionMode ?? "acceptEdits";
5466
- const resume = input.resumeSessionId ? ["--resume", input.resumeSessionId] : [];
5503
+ const resume = input.resumeSessionId && !input.headless ? ["--resume", input.resumeSessionId] : [];
5467
5504
  return [
5505
+ ...input.headless ? ["-p"] : [],
5468
5506
  ...resume,
5469
5507
  "--settings",
5470
5508
  input.settingsPath,
@@ -5515,11 +5553,12 @@ var init_claude = __esm(() => {
5515
5553
  };
5516
5554
  },
5517
5555
  async writeArtifacts(input) {
5518
- const files = await writeSessionArtifacts({ ...input, runtime: this.id });
5556
+ const pluginDir = resolvePluginDir();
5557
+ const files = await writeSessionArtifacts({ ...input, runtime: this.id }, pluginDir);
5519
5558
  return {
5520
5559
  files: [files.systemPromptPath, files.settingsPath, files.mcpConfigPath],
5521
5560
  argv: buildClaudeArgs({
5522
- pluginDir: resolvePluginDir(),
5561
+ pluginDir,
5523
5562
  settingsPath: files.settingsPath,
5524
5563
  mcpConfigPath: files.mcpConfigPath,
5525
5564
  systemPromptPath: files.systemPromptPath,
@@ -5527,7 +5566,8 @@ var init_claude = __esm(() => {
5527
5566
  model: input.model,
5528
5567
  ...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {},
5529
5568
  ...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {},
5530
- strictMcp: input.config.sessionMcp === "runcastleOnly"
5569
+ strictMcp: input.config.sessionMcp === "runcastleOnly",
5570
+ ...input.headless ? { headless: true } : {}
5531
5571
  }),
5532
5572
  env: {
5533
5573
  RUNCASTLE_SESSION_ID: input.session.id,
@@ -7137,12 +7177,16 @@ function toml(value) {
7137
7177
  function approvalPolicyFor(permissionMode) {
7138
7178
  return permissionMode === undefined || permissionMode === "acceptEdits" ? "never" : "on-request";
7139
7179
  }
7180
+ function sandboxModeFor(permissionMode, platform) {
7181
+ return platform === "win32" && approvalPolicyFor(permissionMode) === "never" ? "danger-full-access" : "workspace-write";
7182
+ }
7140
7183
  function renderCodexConfig(input) {
7141
7184
  const { session, config: config2, model, worktreePath, inheritedMcpServers, permissionMode } = input;
7185
+ const platform = input.platform ?? process.platform;
7142
7186
  return [
7143
7187
  `# Generated by runcastle for session ${session.id} \u2014 rewritten at every launch.`,
7144
7188
  `model = ${toml(model)}`,
7145
- 'sandbox_mode = "workspace-write"',
7189
+ `sandbox_mode = ${toml(sandboxModeFor(permissionMode, platform))}`,
7146
7190
  `approval_policy = ${toml(approvalPolicyFor(permissionMode))}`,
7147
7191
  "check_for_update_on_startup = false",
7148
7192
  "",
@@ -7156,6 +7200,7 @@ function renderCodexConfig(input) {
7156
7200
  "[mcp_servers.runcastle]",
7157
7201
  `url = ${toml(`${serverUrlFor(config2)}/mcp`)}`,
7158
7202
  `http_headers = { "X-Runcastle-Session" = ${toml(session.id)} }`,
7203
+ 'default_tools_approval_mode = "approve"',
7159
7204
  ""
7160
7205
  ].join(`
7161
7206
  `);
@@ -7264,6 +7309,9 @@ function gitInfoDir(worktreePath) {
7264
7309
  }
7265
7310
  function buildCodexArgs(input) {
7266
7311
  assertKickoffArgv(input.kickoffLine);
7312
+ assertHeadlessKickoff(input.headless, input.kickoffLine);
7313
+ if (input.headless)
7314
+ return ["exec", "--dangerously-bypass-hook-trust", input.kickoffLine];
7267
7315
  return [
7268
7316
  ...input.resumeSessionId ? ["resume", input.resumeSessionId] : [],
7269
7317
  "--dangerously-bypass-hook-trust",
@@ -7336,7 +7384,8 @@ var init_codex = __esm(() => {
7336
7384
  files,
7337
7385
  argv: buildCodexArgs({
7338
7386
  ...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {},
7339
- ...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {}
7387
+ ...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {},
7388
+ ...input.headless ? { headless: true } : {}
7340
7389
  }),
7341
7390
  env: {
7342
7391
  CODEX_HOME: home,
@@ -7971,6 +8020,12 @@ function listDocs(ctx, feature) {
7971
8020
  return { relPath: e.name, title: readTitle(path, e.name), ...writtenAt(path) };
7972
8021
  }).sort((a, b) => a.relPath.localeCompare(b.relPath));
7973
8022
  }
8023
+ function previewSummary(ctx, feature) {
8024
+ const path = featureDocPath(projectForFeature(ctx, feature), feature, PREVIEW_FILE);
8025
+ if (!existsSync12(path))
8026
+ return null;
8027
+ return { relPath: PREVIEW_FILE, ...writtenAt(path) };
8028
+ }
7974
8029
  function readDoc(ctx, feature, relPath) {
7975
8030
  const project = projectForFeature(ctx, feature);
7976
8031
  const dir = featureDocsDir(project, feature);
@@ -8003,6 +8058,7 @@ function readTitle(path, fallback) {
8003
8058
  }
8004
8059
  var CHARTER_FILE = "CONTEXT.md", ADR_DIR_REL = "docs/adr", SUPERSEDED_RE;
8005
8060
  var init_knowledge = __esm(() => {
8061
+ init_src();
8006
8062
  init_errors();
8007
8063
  init_events();
8008
8064
  init_feature_docs();
@@ -57791,6 +57847,7 @@ async function runReviewGates(input) {
57791
57847
  mkdirSync10(reviewGateLogDir(ticketId), { recursive: true });
57792
57848
  await discardBranch();
57793
57849
  await pinBranchAt(project3.repoPath, branch, sha);
57850
+ await excludeBurnWorktrees(project3.repoPath);
57794
57851
  while (results.length < commands.length) {
57795
57852
  await withSandbox(branch, async (sandbox3) => {
57796
57853
  for (const [i, command] of commands.entries()) {
@@ -60553,6 +60610,7 @@ async function burnTicket(ctx, ticket, config2, token, model, hostAgentVersions,
60553
60610
  await clearAttachments(burnWorktreePath(project3.repoPath, branch), project3.repoPath);
60554
60611
  }
60555
60612
  };
60613
+ await excludeBurnWorktrees(project3.repoPath);
60556
60614
  const ticketJson = buildTicketJson(ticket);
60557
60615
  const featureBrief = buildFeatureBrief(feature);
60558
60616
  const docsDigest = blocks.docsDigest;
@@ -61660,6 +61718,7 @@ function getFeatureFull(ctx, id3) {
61660
61718
  sessions: listSessionsByFeature(ctx, id3).map((session) => session.kind === "chat" && session.status === "ended" && !session.ccSessionId ? { ...session, title: null, transcriptMissing: true } : session),
61661
61719
  runs: listRunsByFeature(ctx, id3),
61662
61720
  docs: listDocs(ctx, feature),
61721
+ preview: previewSummary(ctx, feature),
61663
61722
  dependsOn: dependsOnOf(ctx, id3),
61664
61723
  blocks: blocksOf(ctx, id3)
61665
61724
  };
@@ -62788,6 +62847,24 @@ async function commitDocsCheckpoint(ctx, session, summary) {
62788
62847
  }
62789
62848
  await landChatCommits(ctx, project3, feature);
62790
62849
  }
62850
+ function checkpointDocsAfterReply(ctx, session, summary) {
62851
+ let queue = docsCheckpointQueues.get(session.worktreePath);
62852
+ if (!queue) {
62853
+ queue = createSerialQueue();
62854
+ docsCheckpointQueues.set(session.worktreePath, queue);
62855
+ }
62856
+ queue(() => commitDocsCheckpoint(ctx, session, summary)).catch((e) => {
62857
+ try {
62858
+ emit(ctx, requireFeatureId(session), {
62859
+ type: "git.commit_pending",
62860
+ message: `docs checkpoint failed: ${e instanceof Error ? e.message : String(e)}`,
62861
+ data: { summary }
62862
+ });
62863
+ } catch (err) {
62864
+ console.error(`docs checkpoint failed for session ${session.id}`, e, err);
62865
+ }
62866
+ });
62867
+ }
62791
62868
  function requireProject(ctx, session) {
62792
62869
  if (!session.projectId) {
62793
62870
  throw new GateError(`this tool belongs to a project-scoped session; this is a ${session.kind} session on a ` + "feature. Use get_feature_context and the pipeline tools for feature work.");
@@ -63528,7 +63605,7 @@ function buildMcpServer(audience) {
63528
63605
  if (!rs)
63529
63606
  return noSession();
63530
63607
  const result = toolEmitTickets(rs.ctx, rs.session, args2);
63531
- await commitDocsCheckpoint(rs.ctx, rs.session, `tickets emitted (${result.stored})`);
63608
+ checkpointDocsAfterReply(rs.ctx, rs.session, `tickets emitted (${result.stored})`);
63532
63609
  return ok(result);
63533
63610
  });
63534
63611
  }
@@ -63550,7 +63627,7 @@ function buildMcpServer(audience) {
63550
63627
  if (!rs)
63551
63628
  return noSession();
63552
63629
  const result = toolUpdateTicket(rs.ctx, rs.session, args2);
63553
- await commitDocsCheckpoint(rs.ctx, rs.session, `ticket ${result.ticket.seq} updated`);
63630
+ checkpointDocsAfterReply(rs.ctx, rs.session, `ticket ${result.ticket.seq} updated`);
63554
63631
  return ok(result);
63555
63632
  });
63556
63633
  }
@@ -63567,7 +63644,7 @@ function buildMcpServer(audience) {
63567
63644
  if (!rs)
63568
63645
  return noSession();
63569
63646
  const result = toolCancelTicket(rs.ctx, rs.session, args2);
63570
- await commitDocsCheckpoint(rs.ctx, rs.session, `ticket ${result.ticket.seq} cancelled`);
63647
+ checkpointDocsAfterReply(rs.ctx, rs.session, `ticket ${result.ticket.seq} cancelled`);
63571
63648
  return ok(result);
63572
63649
  });
63573
63650
  }
@@ -63611,7 +63688,7 @@ function buildMcpServer(audience) {
63611
63688
  return noSession();
63612
63689
  const result = toolCompletePhase(rs.ctx, rs.session, args2);
63613
63690
  if (result.ok) {
63614
- await commitDocsCheckpoint(rs.ctx, rs.session, `phase '${args2.phase}' complete`);
63691
+ checkpointDocsAfterReply(rs.ctx, rs.session, `phase '${args2.phase}' complete`);
63615
63692
  }
63616
63693
  return ok(result);
63617
63694
  });
@@ -63644,7 +63721,7 @@ async function readMcpBody(c) {
63644
63721
  return;
63645
63722
  }
63646
63723
  }
63647
- var DOCS_NOTE, TICKETS_NOTE, REVIEW_TITLE, ResolveFindingShape, ResolveFindingInput, DRAFTING_KINDS, ADRS_NOTE, FEATURE_INDEX_NOTE, FEATURE_INDEX_SHIPPED_CAP = 50, WORK_RECORD_DIGESTS_NOTE, FEATURE_KINDS, PROJECT_KINDS, ALL_AUDIENCES, TOOL_AUDIENCES, CompletablePhase, EventType, mcp, server_default;
63724
+ var DOCS_NOTE, TICKETS_NOTE, REVIEW_TITLE, ResolveFindingShape, ResolveFindingInput, docsCheckpointQueues, DRAFTING_KINDS, ADRS_NOTE, FEATURE_INDEX_NOTE, FEATURE_INDEX_SHIPPED_CAP = 50, WORK_RECORD_DIGESTS_NOTE, FEATURE_KINDS, PROJECT_KINDS, ALL_AUDIENCES, TOOL_AUDIENCES, CompletablePhase, EventType, mcp, server_default;
63648
63725
  var init_server = __esm(() => {
63649
63726
  init_src();
63650
63727
  init_paths();
@@ -63663,6 +63740,7 @@ var init_server = __esm(() => {
63663
63740
  init_review_findings();
63664
63741
  init_git();
63665
63742
  init_knowledge();
63743
+ init_landing_queue();
63666
63744
  init_planning();
63667
63745
  init_project_notes();
63668
63746
  init_repo();
@@ -63687,6 +63765,7 @@ var init_server = __esm(() => {
63687
63765
  });
63688
63766
  }
63689
63767
  });
63768
+ docsCheckpointQueues = new Map;
63690
63769
  DRAFTING_KINDS = ["chat"];
63691
63770
  ADRS_NOTE = "adrs[] is an index of the project\u2019s LIVE decisions (superseded ones are already omitted), not " + "their text. Read the ones your work touches with read_adr({ relPath }) \u2014 they bind you the " + "same either way. They are also plain files in this worktree at docs/adr/.";
63692
63771
  FEATURE_INDEX_NOTE = "featureIndex lists every in-flight feature, then every draft, then the most recently shipped, " + "one `slug \u2014 title [state]` line each. A trailing `\xB7 waits on: a, b \u2713` lists the features it " + "must wait to merge (\u2713 = already shipped), and `[draft, waiting]` marks a draft that cannot " + "start until every unmarked one ships. A shipped feature\u2019s docs live at docs/features/<slug>/ " + "in this worktree; an in-flight or draft feature\u2019s docs live on an unmerged branch. Any " + "feature\u2019s brief is read_feature_brief({ slug }); what one actually did is " + "get_work_record({ featureSlug }).";
@@ -64725,7 +64804,7 @@ function ptyExitMessage(exitCode) {
64725
64804
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
64726
64805
  return `terminal exited (code ${label})`;
64727
64806
  }
64728
- function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME) {
64807
+ function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME, opening = chatKickoffFor(runtime5, chatOpening(feature, planningFacts(ctx, feature)))) {
64729
64808
  const counts = new Map;
64730
64809
  for (const ticket of listByFeature(ctx, feature.id)) {
64731
64810
  counts.set(ticket.status, (counts.get(ticket.status) ?? 0) + 1);
@@ -64738,7 +64817,6 @@ function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME) {
64738
64817
  const outcome = !latestRun ? "never driven" : latestReview?.status === "done" ? "passed" : latestReview?.status === "failed" ? "failed" : "unverified";
64739
64818
  review = ` Drive outcome: ${outcome}; see the review evidence in get_feature_context.`;
64740
64819
  }
64741
- const opening = chatKickoffFor(runtime5, chatOpening(feature, planningFacts(ctx, feature)));
64742
64820
  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.`;
64743
64821
  }
64744
64822
  function assertRuntimeReady(runtime5, opts) {
@@ -64765,6 +64843,21 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
64765
64843
  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`);
64766
64844
  }
64767
64845
  }
64846
+ function isSelfPlan(input) {
64847
+ return input.kind === "chat" && input.purpose === "self-plan";
64848
+ }
64849
+ function assertSelfPlannable(ctx, feature) {
64850
+ if (feature.phase !== "planning") {
64851
+ 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`);
64852
+ }
64853
+ if (chatOpening(feature, planningFacts(ctx, feature)) !== "ideate") {
64854
+ 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`);
64855
+ }
64856
+ assertSpawnable(ctx, feature);
64857
+ }
64858
+ function selfPlanRunningError(feature) {
64859
+ return new GateError(`${feature.slug} is planning itself \u2014 stop the run first, then open the chat to pick up where it got to`);
64860
+ }
64768
64861
  async function ensureWorktree(ctx, project3, feature) {
64769
64862
  const running3 = activeRunFor(ctx, feature.id);
64770
64863
  const besideRun = !!running3 && workflowClaimsFeatureBranch(running3.workflow);
@@ -64846,7 +64939,11 @@ async function launchSession(ctx, input, opts = {}) {
64846
64939
  requireNotDraft(feature);
64847
64940
  if (input.kind === "chat") {
64848
64941
  const liveChat = activeSessionsForFeature(ctx, feature.id).find((s) => s.kind === "chat");
64849
- if (liveChat && isStartLap(feature, input))
64942
+ if (liveChat?.purpose === "self-plan")
64943
+ throw selfPlanRunningError(feature);
64944
+ if (isSelfPlan(input))
64945
+ assertSelfPlannable(ctx, feature);
64946
+ else if (liveChat && isStartLap(feature, input))
64850
64947
  endSession(ctx, liveChat.id);
64851
64948
  else if (liveChat)
64852
64949
  return briefLiveChat(ctx, feature, liveChat, input);
@@ -64866,8 +64963,9 @@ async function launchSession(ctx, input, opts = {}) {
64866
64963
  });
64867
64964
  const carried = startLapCarriedWork(ctx, feature.id);
64868
64965
  const plan = planLaunchKickoff(feature, input, carried);
64966
+ const selfPlan = isSelfPlan(input);
64869
64967
  if (input.kind === "chat") {
64870
- const header = chatKickoffHeader(ctx, feature, runtime5.id);
64968
+ const header = chatKickoffHeader(ctx, feature, runtime5.id, selfPlan ? selfPlanKickoffFor(runtime5.id) : undefined);
64871
64969
  plan.line = plan.line ? `${plan.line} ${header}` : header;
64872
64970
  }
64873
64971
  let resumeSessionId;
@@ -64880,6 +64978,8 @@ async function launchSession(ctx, input, opts = {}) {
64880
64978
  markSessionEnded(ctx, session.id);
64881
64979
  throw e;
64882
64980
  }
64981
+ }
64982
+ if (input.kind === "chat" && !selfPlan) {
64883
64983
  const prior = mostRecentResumableSession(ctx, feature.id, "chat");
64884
64984
  if (prior?.ccSessionId) {
64885
64985
  resumedFrom = prior;
@@ -64919,12 +65019,13 @@ async function launchSession(ctx, input, opts = {}) {
64919
65019
  const kickoffLine = resumeSessionId && input.kind !== "chat" ? undefined : kickoffLineFor(input.kind, plan.line, runtime5.id);
64920
65020
  emit(ctx, feature.id, {
64921
65021
  type: "session.launching",
64922
- message: `launching ${input.kind} session`,
65022
+ message: selfPlan ? "launching a self-planning run" : `launching ${input.kind} session`,
64923
65023
  data: {
64924
65024
  sessionId: session.id,
64925
65025
  kind: input.kind,
64926
65026
  worktreePath,
64927
- ...modelStamp(model)
65027
+ ...modelStamp(model),
65028
+ ...input.purpose ? { purpose: input.purpose } : {}
64928
65029
  }
64929
65030
  });
64930
65031
  if (resumeSessionId) {
@@ -64961,7 +65062,8 @@ async function launchSession(ctx, input, opts = {}) {
64961
65062
  model: model.id,
64962
65063
  resumeSessionId,
64963
65064
  resumeSourceSessionId: resumedFrom?.id,
64964
- kickoffLine
65065
+ kickoffLine,
65066
+ ...selfPlan ? { headless: true } : {}
64965
65067
  });
64966
65068
  if (opts.spawn === false) {
64967
65069
  emit(ctx, feature.id, {
@@ -65307,6 +65409,15 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
65307
65409
  }
65308
65410
  });
65309
65411
  }
65412
+ if (session.purpose === "self-plan" && feature) {
65413
+ const current2 = getFeatureRow(ctx, feature.id);
65414
+ const complete4 = current2.ticketsReadyLap === current2.lap;
65415
+ emitForSession(ctx, session, {
65416
+ type: "session.self_plan_ended",
65417
+ message: complete4 ? "planning finished" : "planning stopped before the plan was complete",
65418
+ data: { sessionId: session.id, complete: complete4, exitCode: exitCode ?? null }
65419
+ });
65420
+ }
65310
65421
  emitForSession(ctx, session, {
65311
65422
  type: "session.pty_exited",
65312
65423
  message: ptyExitMessage(exitCode),
@@ -65893,8 +66004,8 @@ var init_transcripts = __esm(() => {
65893
66004
 
65894
66005
  // src/services/conversations.ts
65895
66006
  function withoutKickoff(turns, kind, runtime5) {
65896
- const kickoff = kickoffLineFor(kind, undefined, runtime5);
65897
- return turns.filter((turn) => !(turn.role === "user" && promptMatchesKickoff(kickoff, turn.text)));
66007
+ const kickoffs = [kickoffLineFor(kind, undefined, runtime5), selfPlanKickoffFor(runtime5)];
66008
+ return turns.filter((turn) => !(turn.role === "user" && kickoffs.some((kickoff) => promptMatchesKickoff(kickoff, turn.text))));
65898
66009
  }
65899
66010
  function promptMatchesKickoff(line, prompt) {
65900
66011
  if (!prompt)
@@ -65988,6 +66099,7 @@ var TITLE_MAX = 60, MATCH_PREFIX = 40, INTERRUPTED = "[Request interrupted by us
65988
66099
  var init_conversations = __esm(() => {
65989
66100
  init_src();
65990
66101
  init_sessions();
66102
+ init_skills();
65991
66103
  init_transcripts();
65992
66104
  });
65993
66105