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/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();
@@ -4753,6 +4758,64 @@ var init_install_guard = __esm(() => {
4753
4758
  HEREDOC_OPENER = /(?<!<)<<(?!<)(-?)[ \t]*(?:'([^'\n]+)'|"([^"\n]+)"|\\?([\w.-]+))/g;
4754
4759
  });
4755
4760
 
4761
+ // src/launcher/skills-root.ts
4762
+ import { existsSync as existsSync7 } from "fs";
4763
+ import { dirname as dirname3, join as join7, resolve as resolve6 } from "path";
4764
+ import { fileURLToPath as fileURLToPath3 } from "url";
4765
+ function resolveSkillsRoot(fromDir) {
4766
+ const override = process.env[SKILLS_DIR_ENV];
4767
+ if (override) {
4768
+ const root = resolve6(override);
4769
+ if (existsSync7(join7(root, SKILLS_MARKER)))
4770
+ return root;
4771
+ throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
4772
+ }
4773
+ const searched = [];
4774
+ let dir = fromDir;
4775
+ for (let i = 0;i < 8; i += 1) {
4776
+ const candidate = join7(dir, "packages", "skills");
4777
+ searched.push(candidate);
4778
+ if (existsSync7(join7(candidate, SKILLS_MARKER)))
4779
+ return candidate;
4780
+ const parent = dirname3(dir);
4781
+ if (parent === dir)
4782
+ break;
4783
+ dir = parent;
4784
+ }
4785
+ throw new Error(`runcastle skills root not found; searched:
4786
+ ${searched.join(`
4787
+ `)}`);
4788
+ }
4789
+ function resolvePluginDir(fromDir = dirname3(fileURLToPath3(import.meta.url))) {
4790
+ const rel = join7("packages", "skills", "packs", "runcastle");
4791
+ const override = process.env[SKILLS_DIR_ENV];
4792
+ if (override) {
4793
+ const dir2 = join7(resolve6(override), "packs", "runcastle");
4794
+ if (existsSync7(dir2))
4795
+ return dir2;
4796
+ throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
4797
+ }
4798
+ const searched = [];
4799
+ let dir = fromDir;
4800
+ for (let i = 0;i < 8; i += 1) {
4801
+ const candidate = join7(dir, rel);
4802
+ searched.push(candidate);
4803
+ if (existsSync7(candidate))
4804
+ return candidate;
4805
+ const parent = dirname3(dir);
4806
+ if (parent === dir)
4807
+ break;
4808
+ dir = parent;
4809
+ }
4810
+ throw new Error(`runcastle plugin dir (${rel}) not found; searched:
4811
+ ${searched.join(`
4812
+ `)}`);
4813
+ }
4814
+ var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
4815
+ var init_skills_root = __esm(() => {
4816
+ SKILLS_MARKER = join7("packs", "runcastle");
4817
+ });
4818
+
4756
4819
  // src/launcher/runtimes/skills.ts
4757
4820
  function skillRef(runtime, skill) {
4758
4821
  return SPELLINGS[runtime](skill);
@@ -4773,6 +4836,9 @@ function entrySkillsFor(kind) {
4773
4836
  function chatKickoffFor(runtime, opening) {
4774
4837
  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
4838
  }
4839
+ function selfPlanKickoffFor(runtime) {
4840
+ 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.";
4841
+ }
4776
4842
  function kickoffLinesFor(runtime) {
4777
4843
  const skill = (name) => skillRef(runtime, name);
4778
4844
  return {
@@ -4788,7 +4854,7 @@ function projectTriageKickoffFor(runtime) {
4788
4854
  function prepareConfirmKickoffFor(runtime) {
4789
4855
  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
4856
  }
4791
- var PACK = "runcastle", SPELLINGS, ENTRY_SKILLS;
4857
+ var PACK = "runcastle", SPELLINGS, ENTRY_SKILLS, PLANNING_WITHOUT_A_HUMAN = "Planning without a human";
4792
4858
  var init_skills = __esm(() => {
4793
4859
  SPELLINGS = {
4794
4860
  "claude-code": (skill) => `/${PACK}:${skill}`,
@@ -4805,10 +4871,10 @@ var init_skills = __esm(() => {
4805
4871
 
4806
4872
  // src/launcher/artifacts.ts
4807
4873
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
4808
- import { fileURLToPath as fileURLToPath3 } from "url";
4809
- import { join as join7 } from "path";
4874
+ import { fileURLToPath as fileURLToPath4 } from "url";
4875
+ import { join as join8 } from "path";
4810
4876
  function hookClientPath() {
4811
- return resolveAsset(ASSET_ENV.hookClient, fileURLToPath3(new URL("./hook-client.ts", import.meta.url)));
4877
+ return resolveAsset(ASSET_ENV.hookClient, fileURLToPath4(new URL("./hook-client.ts", import.meta.url)));
4812
4878
  }
4813
4879
  function serverUrlFor(config2) {
4814
4880
  return `http://localhost:${config2.serverPort}`;
@@ -4829,7 +4895,8 @@ function chatOpening(feature, planning) {
4829
4895
  function renderSystemPrompt(feature, kind, lap, purpose, runtime = DEFAULT_RUNTIME, carried, planning) {
4830
4896
  if (lap !== undefined)
4831
4897
  return renderRevisitPrompt(feature, lap, purpose, runtime, carried);
4832
- if (kind === "chat" && chatOpening(feature, planning) === "revisit") {
4898
+ const selfPlan = purpose === "self-plan";
4899
+ if (kind === "chat" && !selfPlan && chatOpening(feature, planning) === "revisit") {
4833
4900
  return renderRevisitPrompt(feature, lap, purpose, runtime, carried);
4834
4901
  }
4835
4902
  const docs2 = featureDocsRel(feature.slug);
@@ -4862,12 +4929,26 @@ function renderSystemPrompt(feature, kind, lap, purpose, runtime = DEFAULT_RUNTI
4862
4929
  noCodeRule(docs2),
4863
4930
  otherEntrySkillsRule(),
4864
4931
  "",
4932
+ ...selfPlan ? [selfPlanRule(), ""] : [],
4865
4933
  "## Your task",
4866
4934
  `Begin by invoking the \`${skillRef(runtime, "ideate")}\` skill and drive the ideation session to completion.`,
4867
4935
  ""
4868
4936
  ].join(`
4869
4937
  `);
4870
4938
  }
4939
+ function selfPlanRule() {
4940
+ return [
4941
+ "## No human is present",
4942
+ "This is a self-planning run: nobody is watching and nobody will reply.",
4943
+ "- Never end your turn to ask a question \u2014 ending the turn ends the run. Ask your design",
4944
+ " questions of yourself, as the skill describes, and answer them.",
4945
+ "- A refused tool is not a wait: do not retry it or wait for approval. Decide on what you",
4946
+ " know and treat that decision as a candidate to check first.",
4947
+ `- Follow the ideation skill's "${PLANNING_WITHOUT_A_HUMAN}" section through the preview, the`,
4948
+ " spec and the tickets, in this one run."
4949
+ ].join(`
4950
+ `);
4951
+ }
4871
4952
  function reviewEvidenceSection(lap, docs2, carried) {
4872
4953
  const evidence = carried?.reviewEvidence ?? [];
4873
4954
  return [
@@ -5237,6 +5318,10 @@ function renderProjectPrompt(brief, runtime = DEFAULT_RUNTIME) {
5237
5318
  ].join(`
5238
5319
  `);
5239
5320
  }
5321
+ function skillPackReadRule(packDir) {
5322
+ const posix2 = packDir.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`).replace(/\/+$/, "");
5323
+ return `Read(/${posix2}/**)`;
5324
+ }
5240
5325
  function entrySkillDenyRules(kind) {
5241
5326
  const allowed = new Set(entrySkillsFor(kind));
5242
5327
  return ENTRY_SKILLS.filter((skill) => !allowed.has(skill)).flatMap((skill) => [
@@ -5255,7 +5340,7 @@ function sessionBashAllowRules(kind) {
5255
5340
  }
5256
5341
  return SESSION_BASH_ALLOW_RULES;
5257
5342
  }
5258
- function renderSettings(hookClient, kind) {
5343
+ function renderSettings(hookClient, kind, skillPackDir) {
5259
5344
  const cmd = (event) => ({
5260
5345
  type: "command",
5261
5346
  command: `bun run "${hookClient}" ${event}`,
@@ -5263,7 +5348,11 @@ function renderSettings(hookClient, kind) {
5263
5348
  });
5264
5349
  return {
5265
5350
  permissions: {
5266
- allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...sessionBashAllowRules(kind)],
5351
+ allow: [
5352
+ ...RUNCASTLE_MCP_ALLOW_RULES,
5353
+ ...sessionBashAllowRules(kind),
5354
+ ...skillPackDir ? [skillPackReadRule(skillPackDir)] : []
5355
+ ],
5267
5356
  deny: entrySkillDenyRules(kind)
5268
5357
  },
5269
5358
  hooks: {
@@ -5324,15 +5413,15 @@ function renderSessionPrompt(input) {
5324
5413
  return renderProjectPrompt(projectBrief, runtime);
5325
5414
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
5326
5415
  }
5327
- async function writeSessionArtifacts(input) {
5416
+ async function writeSessionArtifacts(input, pluginDir = resolvePluginDir()) {
5328
5417
  const { session, config: config2 } = input;
5329
5418
  const dir = sessionDir(session.id);
5330
5419
  mkdirSync3(dir, { recursive: true });
5331
- const systemPromptPath = join7(dir, "system-prompt.md");
5332
- const settingsPath = join7(dir, "settings.json");
5333
- const mcpConfigPath = join7(dir, "mcp.json");
5420
+ const systemPromptPath = join8(dir, "system-prompt.md");
5421
+ const settingsPath = join8(dir, "settings.json");
5422
+ const mcpConfigPath = join8(dir, "mcp.json");
5334
5423
  writeFileSync2(systemPromptPath, renderSessionPrompt(input), "utf8");
5335
- writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
5424
+ writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind, pluginDir), null, 2), "utf8");
5336
5425
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
5337
5426
  return { systemPromptPath, settingsPath, mcpConfigPath };
5338
5427
  }
@@ -5344,6 +5433,7 @@ var init_artifacts = __esm(() => {
5344
5433
  init_asset_paths();
5345
5434
  init_edit_guard();
5346
5435
  init_install_guard();
5436
+ init_skills_root();
5347
5437
  init_skills();
5348
5438
  RUNCASTLE_MCP_ALLOW_RULES = [
5349
5439
  "mcp__runcastle__get_feature_context",
@@ -5392,65 +5482,11 @@ var init_artifacts = __esm(() => {
5392
5482
  SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
5393
5483
  });
5394
5484
 
5395
- // src/launcher/skills-root.ts
5396
- import { existsSync as existsSync7 } from "fs";
5397
- import { dirname as dirname3, join as join8, resolve as resolve6 } from "path";
5398
- import { fileURLToPath as fileURLToPath4 } from "url";
5399
- function resolveSkillsRoot(fromDir) {
5400
- const override = process.env[SKILLS_DIR_ENV];
5401
- if (override) {
5402
- const root = resolve6(override);
5403
- if (existsSync7(join8(root, SKILLS_MARKER)))
5404
- return root;
5405
- throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
5406
- }
5407
- const searched = [];
5408
- let dir = fromDir;
5409
- for (let i = 0;i < 8; i += 1) {
5410
- const candidate = join8(dir, "packages", "skills");
5411
- searched.push(candidate);
5412
- if (existsSync7(join8(candidate, SKILLS_MARKER)))
5413
- return candidate;
5414
- const parent = dirname3(dir);
5415
- if (parent === dir)
5416
- break;
5417
- dir = parent;
5418
- }
5419
- throw new Error(`runcastle skills root not found; searched:
5420
- ${searched.join(`
5421
- `)}`);
5422
- }
5423
- function resolvePluginDir(fromDir = dirname3(fileURLToPath4(import.meta.url))) {
5424
- const rel = join8("packages", "skills", "packs", "runcastle");
5425
- const override = process.env[SKILLS_DIR_ENV];
5426
- if (override) {
5427
- const dir2 = join8(resolve6(override), "packs", "runcastle");
5428
- if (existsSync7(dir2))
5429
- return dir2;
5430
- throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
5431
- }
5432
- const searched = [];
5433
- let dir = fromDir;
5434
- for (let i = 0;i < 8; i += 1) {
5435
- const candidate = join8(dir, rel);
5436
- searched.push(candidate);
5437
- if (existsSync7(candidate))
5438
- return candidate;
5439
- const parent = dirname3(dir);
5440
- if (parent === dir)
5441
- break;
5442
- dir = parent;
5443
- }
5444
- throw new Error(`runcastle plugin dir (${rel}) not found; searched:
5445
- ${searched.join(`
5446
- `)}`);
5447
- }
5448
- var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
5449
- var init_skills_root = __esm(() => {
5450
- SKILLS_MARKER = join8("packs", "runcastle");
5451
- });
5452
-
5453
5485
  // src/launcher/runtimes/types.ts
5486
+ function assertHeadlessKickoff(headless, line) {
5487
+ if (headless && !line)
5488
+ throw new Error("a headless launch needs a kickoff line as its prompt");
5489
+ }
5454
5490
  function assertKickoffArgv(line) {
5455
5491
  if (line !== undefined && line.length > MAX_KICKOFF_ARGV) {
5456
5492
  throw new Error(`kickoff line is ${line.length} characters; maximum is ${MAX_KICKOFF_ARGV}`);
@@ -5461,9 +5497,11 @@ var MAX_KICKOFF_ARGV = 30000;
5461
5497
  // src/launcher/runtimes/claude.ts
5462
5498
  function buildClaudeArgs(input) {
5463
5499
  assertKickoffArgv(input.kickoffLine);
5500
+ assertHeadlessKickoff(input.headless, input.kickoffLine);
5464
5501
  const permissionMode = input.permissionMode ?? "acceptEdits";
5465
- const resume = input.resumeSessionId ? ["--resume", input.resumeSessionId] : [];
5502
+ const resume = input.resumeSessionId && !input.headless ? ["--resume", input.resumeSessionId] : [];
5466
5503
  return [
5504
+ ...input.headless ? ["-p"] : [],
5467
5505
  ...resume,
5468
5506
  "--settings",
5469
5507
  input.settingsPath,
@@ -5514,11 +5552,12 @@ var init_claude = __esm(() => {
5514
5552
  };
5515
5553
  },
5516
5554
  async writeArtifacts(input) {
5517
- const files = await writeSessionArtifacts({ ...input, runtime: this.id });
5555
+ const pluginDir = resolvePluginDir();
5556
+ const files = await writeSessionArtifacts({ ...input, runtime: this.id }, pluginDir);
5518
5557
  return {
5519
5558
  files: [files.systemPromptPath, files.settingsPath, files.mcpConfigPath],
5520
5559
  argv: buildClaudeArgs({
5521
- pluginDir: resolvePluginDir(),
5560
+ pluginDir,
5522
5561
  settingsPath: files.settingsPath,
5523
5562
  mcpConfigPath: files.mcpConfigPath,
5524
5563
  systemPromptPath: files.systemPromptPath,
@@ -5526,7 +5565,8 @@ var init_claude = __esm(() => {
5526
5565
  model: input.model,
5527
5566
  ...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {},
5528
5567
  ...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {},
5529
- strictMcp: input.config.sessionMcp === "runcastleOnly"
5568
+ strictMcp: input.config.sessionMcp === "runcastleOnly",
5569
+ ...input.headless ? { headless: true } : {}
5530
5570
  }),
5531
5571
  env: {
5532
5572
  RUNCASTLE_SESSION_ID: input.session.id,
@@ -7136,12 +7176,16 @@ function toml(value) {
7136
7176
  function approvalPolicyFor(permissionMode) {
7137
7177
  return permissionMode === undefined || permissionMode === "acceptEdits" ? "never" : "on-request";
7138
7178
  }
7179
+ function sandboxModeFor(permissionMode, platform) {
7180
+ return platform === "win32" && approvalPolicyFor(permissionMode) === "never" ? "danger-full-access" : "workspace-write";
7181
+ }
7139
7182
  function renderCodexConfig(input) {
7140
7183
  const { session, config: config2, model, worktreePath, inheritedMcpServers, permissionMode } = input;
7184
+ const platform = input.platform ?? process.platform;
7141
7185
  return [
7142
7186
  `# Generated by runcastle for session ${session.id} \u2014 rewritten at every launch.`,
7143
7187
  `model = ${toml(model)}`,
7144
- 'sandbox_mode = "workspace-write"',
7188
+ `sandbox_mode = ${toml(sandboxModeFor(permissionMode, platform))}`,
7145
7189
  `approval_policy = ${toml(approvalPolicyFor(permissionMode))}`,
7146
7190
  "check_for_update_on_startup = false",
7147
7191
  "",
@@ -7155,6 +7199,7 @@ function renderCodexConfig(input) {
7155
7199
  "[mcp_servers.runcastle]",
7156
7200
  `url = ${toml(`${serverUrlFor(config2)}/mcp`)}`,
7157
7201
  `http_headers = { "X-Runcastle-Session" = ${toml(session.id)} }`,
7202
+ 'default_tools_approval_mode = "approve"',
7158
7203
  ""
7159
7204
  ].join(`
7160
7205
  `);
@@ -7263,6 +7308,9 @@ function gitInfoDir(worktreePath) {
7263
7308
  }
7264
7309
  function buildCodexArgs(input) {
7265
7310
  assertKickoffArgv(input.kickoffLine);
7311
+ assertHeadlessKickoff(input.headless, input.kickoffLine);
7312
+ if (input.headless)
7313
+ return ["exec", "--dangerously-bypass-hook-trust", input.kickoffLine];
7266
7314
  return [
7267
7315
  ...input.resumeSessionId ? ["resume", input.resumeSessionId] : [],
7268
7316
  "--dangerously-bypass-hook-trust",
@@ -7335,7 +7383,8 @@ var init_codex = __esm(() => {
7335
7383
  files,
7336
7384
  argv: buildCodexArgs({
7337
7385
  ...input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {},
7338
- ...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {}
7386
+ ...input.kickoffLine ? { kickoffLine: input.kickoffLine } : {},
7387
+ ...input.headless ? { headless: true } : {}
7339
7388
  }),
7340
7389
  env: {
7341
7390
  CODEX_HOME: home,
@@ -7970,6 +8019,12 @@ function listDocs(ctx, feature) {
7970
8019
  return { relPath: e.name, title: readTitle(path, e.name), ...writtenAt(path) };
7971
8020
  }).sort((a, b) => a.relPath.localeCompare(b.relPath));
7972
8021
  }
8022
+ function previewSummary(ctx, feature) {
8023
+ const path = featureDocPath(projectForFeature(ctx, feature), feature, PREVIEW_FILE);
8024
+ if (!existsSync12(path))
8025
+ return null;
8026
+ return { relPath: PREVIEW_FILE, ...writtenAt(path) };
8027
+ }
7973
8028
  function readDoc(ctx, feature, relPath) {
7974
8029
  const project = projectForFeature(ctx, feature);
7975
8030
  const dir = featureDocsDir(project, feature);
@@ -8002,6 +8057,7 @@ function readTitle(path, fallback) {
8002
8057
  }
8003
8058
  var CHARTER_FILE = "CONTEXT.md", ADR_DIR_REL = "docs/adr", SUPERSEDED_RE;
8004
8059
  var init_knowledge = __esm(() => {
8060
+ init_src();
8005
8061
  init_errors();
8006
8062
  init_events();
8007
8063
  init_feature_docs();
@@ -57790,6 +57846,7 @@ async function runReviewGates(input) {
57790
57846
  mkdirSync10(reviewGateLogDir(ticketId), { recursive: true });
57791
57847
  await discardBranch();
57792
57848
  await pinBranchAt(project3.repoPath, branch, sha);
57849
+ await excludeBurnWorktrees(project3.repoPath);
57793
57850
  while (results.length < commands.length) {
57794
57851
  await withSandbox(branch, async (sandbox3) => {
57795
57852
  for (const [i, command] of commands.entries()) {
@@ -60552,6 +60609,7 @@ async function burnTicket(ctx, ticket, config2, token, model, hostAgentVersions,
60552
60609
  await clearAttachments(burnWorktreePath(project3.repoPath, branch), project3.repoPath);
60553
60610
  }
60554
60611
  };
60612
+ await excludeBurnWorktrees(project3.repoPath);
60555
60613
  const ticketJson = buildTicketJson(ticket);
60556
60614
  const featureBrief = buildFeatureBrief(feature);
60557
60615
  const docsDigest = blocks.docsDigest;
@@ -61659,6 +61717,7 @@ function getFeatureFull(ctx, id3) {
61659
61717
  sessions: listSessionsByFeature(ctx, id3).map((session) => session.kind === "chat" && session.status === "ended" && !session.ccSessionId ? { ...session, title: null, transcriptMissing: true } : session),
61660
61718
  runs: listRunsByFeature(ctx, id3),
61661
61719
  docs: listDocs(ctx, feature),
61720
+ preview: previewSummary(ctx, feature),
61662
61721
  dependsOn: dependsOnOf(ctx, id3),
61663
61722
  blocks: blocksOf(ctx, id3)
61664
61723
  };
@@ -62787,6 +62846,24 @@ async function commitDocsCheckpoint(ctx, session, summary) {
62787
62846
  }
62788
62847
  await landChatCommits(ctx, project3, feature);
62789
62848
  }
62849
+ function checkpointDocsAfterReply(ctx, session, summary) {
62850
+ let queue = docsCheckpointQueues.get(session.worktreePath);
62851
+ if (!queue) {
62852
+ queue = createSerialQueue();
62853
+ docsCheckpointQueues.set(session.worktreePath, queue);
62854
+ }
62855
+ queue(() => commitDocsCheckpoint(ctx, session, summary)).catch((e) => {
62856
+ try {
62857
+ emit(ctx, requireFeatureId(session), {
62858
+ type: "git.commit_pending",
62859
+ message: `docs checkpoint failed: ${e instanceof Error ? e.message : String(e)}`,
62860
+ data: { summary }
62861
+ });
62862
+ } catch (err) {
62863
+ console.error(`docs checkpoint failed for session ${session.id}`, e, err);
62864
+ }
62865
+ });
62866
+ }
62790
62867
  function requireProject(ctx, session) {
62791
62868
  if (!session.projectId) {
62792
62869
  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.");
@@ -63527,7 +63604,7 @@ function buildMcpServer(audience) {
63527
63604
  if (!rs)
63528
63605
  return noSession();
63529
63606
  const result = toolEmitTickets(rs.ctx, rs.session, args2);
63530
- await commitDocsCheckpoint(rs.ctx, rs.session, `tickets emitted (${result.stored})`);
63607
+ checkpointDocsAfterReply(rs.ctx, rs.session, `tickets emitted (${result.stored})`);
63531
63608
  return ok(result);
63532
63609
  });
63533
63610
  }
@@ -63549,7 +63626,7 @@ function buildMcpServer(audience) {
63549
63626
  if (!rs)
63550
63627
  return noSession();
63551
63628
  const result = toolUpdateTicket(rs.ctx, rs.session, args2);
63552
- await commitDocsCheckpoint(rs.ctx, rs.session, `ticket ${result.ticket.seq} updated`);
63629
+ checkpointDocsAfterReply(rs.ctx, rs.session, `ticket ${result.ticket.seq} updated`);
63553
63630
  return ok(result);
63554
63631
  });
63555
63632
  }
@@ -63566,7 +63643,7 @@ function buildMcpServer(audience) {
63566
63643
  if (!rs)
63567
63644
  return noSession();
63568
63645
  const result = toolCancelTicket(rs.ctx, rs.session, args2);
63569
- await commitDocsCheckpoint(rs.ctx, rs.session, `ticket ${result.ticket.seq} cancelled`);
63646
+ checkpointDocsAfterReply(rs.ctx, rs.session, `ticket ${result.ticket.seq} cancelled`);
63570
63647
  return ok(result);
63571
63648
  });
63572
63649
  }
@@ -63610,7 +63687,7 @@ function buildMcpServer(audience) {
63610
63687
  return noSession();
63611
63688
  const result = toolCompletePhase(rs.ctx, rs.session, args2);
63612
63689
  if (result.ok) {
63613
- await commitDocsCheckpoint(rs.ctx, rs.session, `phase '${args2.phase}' complete`);
63690
+ checkpointDocsAfterReply(rs.ctx, rs.session, `phase '${args2.phase}' complete`);
63614
63691
  }
63615
63692
  return ok(result);
63616
63693
  });
@@ -63643,7 +63720,7 @@ async function readMcpBody(c) {
63643
63720
  return;
63644
63721
  }
63645
63722
  }
63646
- 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;
63723
+ 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;
63647
63724
  var init_server = __esm(() => {
63648
63725
  init_src();
63649
63726
  init_paths();
@@ -63662,6 +63739,7 @@ var init_server = __esm(() => {
63662
63739
  init_review_findings();
63663
63740
  init_git();
63664
63741
  init_knowledge();
63742
+ init_landing_queue();
63665
63743
  init_planning();
63666
63744
  init_project_notes();
63667
63745
  init_repo();
@@ -63686,6 +63764,7 @@ var init_server = __esm(() => {
63686
63764
  });
63687
63765
  }
63688
63766
  });
63767
+ docsCheckpointQueues = new Map;
63689
63768
  DRAFTING_KINDS = ["chat"];
63690
63769
  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/.";
63691
63770
  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 }).";
@@ -64724,7 +64803,7 @@ function ptyExitMessage(exitCode) {
64724
64803
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
64725
64804
  return `terminal exited (code ${label})`;
64726
64805
  }
64727
- function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME) {
64806
+ function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME, opening = chatKickoffFor(runtime5, chatOpening(feature, planningFacts(ctx, feature)))) {
64728
64807
  const counts = new Map;
64729
64808
  for (const ticket of listByFeature(ctx, feature.id)) {
64730
64809
  counts.set(ticket.status, (counts.get(ticket.status) ?? 0) + 1);
@@ -64737,7 +64816,6 @@ function chatKickoffHeader(ctx, feature, runtime5 = DEFAULT_RUNTIME) {
64737
64816
  const outcome = !latestRun ? "never driven" : latestReview?.status === "done" ? "passed" : latestReview?.status === "failed" ? "failed" : "unverified";
64738
64817
  review = ` Drive outcome: ${outcome}; see the review evidence in get_feature_context.`;
64739
64818
  }
64740
- const opening = chatKickoffFor(runtime5, chatOpening(feature, planningFacts(ctx, feature)));
64741
64819
  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
64820
  }
64743
64821
  function assertRuntimeReady(runtime5, opts) {
@@ -64764,6 +64842,21 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
64764
64842
  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
64843
  }
64766
64844
  }
64845
+ function isSelfPlan(input) {
64846
+ return input.kind === "chat" && input.purpose === "self-plan";
64847
+ }
64848
+ function assertSelfPlannable(ctx, feature) {
64849
+ if (feature.phase !== "planning") {
64850
+ 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`);
64851
+ }
64852
+ if (chatOpening(feature, planningFacts(ctx, feature)) !== "ideate") {
64853
+ 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`);
64854
+ }
64855
+ assertSpawnable(ctx, feature);
64856
+ }
64857
+ function selfPlanRunningError(feature) {
64858
+ return new GateError(`${feature.slug} is planning itself \u2014 stop the run first, then open the chat to pick up where it got to`);
64859
+ }
64767
64860
  async function ensureWorktree(ctx, project3, feature) {
64768
64861
  const running3 = activeRunFor(ctx, feature.id);
64769
64862
  const besideRun = !!running3 && workflowClaimsFeatureBranch(running3.workflow);
@@ -64845,7 +64938,11 @@ async function launchSession(ctx, input, opts = {}) {
64845
64938
  requireNotDraft(feature);
64846
64939
  if (input.kind === "chat") {
64847
64940
  const liveChat = activeSessionsForFeature(ctx, feature.id).find((s) => s.kind === "chat");
64848
- if (liveChat && isStartLap(feature, input))
64941
+ if (liveChat?.purpose === "self-plan")
64942
+ throw selfPlanRunningError(feature);
64943
+ if (isSelfPlan(input))
64944
+ assertSelfPlannable(ctx, feature);
64945
+ else if (liveChat && isStartLap(feature, input))
64849
64946
  endSession(ctx, liveChat.id);
64850
64947
  else if (liveChat)
64851
64948
  return briefLiveChat(ctx, feature, liveChat, input);
@@ -64865,8 +64962,9 @@ async function launchSession(ctx, input, opts = {}) {
64865
64962
  });
64866
64963
  const carried = startLapCarriedWork(ctx, feature.id);
64867
64964
  const plan = planLaunchKickoff(feature, input, carried);
64965
+ const selfPlan = isSelfPlan(input);
64868
64966
  if (input.kind === "chat") {
64869
- const header = chatKickoffHeader(ctx, feature, runtime5.id);
64967
+ const header = chatKickoffHeader(ctx, feature, runtime5.id, selfPlan ? selfPlanKickoffFor(runtime5.id) : undefined);
64870
64968
  plan.line = plan.line ? `${plan.line} ${header}` : header;
64871
64969
  }
64872
64970
  let resumeSessionId;
@@ -64879,6 +64977,8 @@ async function launchSession(ctx, input, opts = {}) {
64879
64977
  markSessionEnded(ctx, session.id);
64880
64978
  throw e;
64881
64979
  }
64980
+ }
64981
+ if (input.kind === "chat" && !selfPlan) {
64882
64982
  const prior = mostRecentResumableSession(ctx, feature.id, "chat");
64883
64983
  if (prior?.ccSessionId) {
64884
64984
  resumedFrom = prior;
@@ -64918,12 +65018,13 @@ async function launchSession(ctx, input, opts = {}) {
64918
65018
  const kickoffLine = resumeSessionId && input.kind !== "chat" ? undefined : kickoffLineFor(input.kind, plan.line, runtime5.id);
64919
65019
  emit(ctx, feature.id, {
64920
65020
  type: "session.launching",
64921
- message: `launching ${input.kind} session`,
65021
+ message: selfPlan ? "launching a self-planning run" : `launching ${input.kind} session`,
64922
65022
  data: {
64923
65023
  sessionId: session.id,
64924
65024
  kind: input.kind,
64925
65025
  worktreePath,
64926
- ...modelStamp(model)
65026
+ ...modelStamp(model),
65027
+ ...input.purpose ? { purpose: input.purpose } : {}
64927
65028
  }
64928
65029
  });
64929
65030
  if (resumeSessionId) {
@@ -64960,7 +65061,8 @@ async function launchSession(ctx, input, opts = {}) {
64960
65061
  model: model.id,
64961
65062
  resumeSessionId,
64962
65063
  resumeSourceSessionId: resumedFrom?.id,
64963
- kickoffLine
65064
+ kickoffLine,
65065
+ ...selfPlan ? { headless: true } : {}
64964
65066
  });
64965
65067
  if (opts.spawn === false) {
64966
65068
  emit(ctx, feature.id, {
@@ -65306,6 +65408,15 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
65306
65408
  }
65307
65409
  });
65308
65410
  }
65411
+ if (session.purpose === "self-plan" && feature) {
65412
+ const current2 = getFeatureRow(ctx, feature.id);
65413
+ const complete4 = current2.ticketsReadyLap === current2.lap;
65414
+ emitForSession(ctx, session, {
65415
+ type: "session.self_plan_ended",
65416
+ message: complete4 ? "planning finished" : "planning stopped before the plan was complete",
65417
+ data: { sessionId: session.id, complete: complete4, exitCode: exitCode ?? null }
65418
+ });
65419
+ }
65309
65420
  emitForSession(ctx, session, {
65310
65421
  type: "session.pty_exited",
65311
65422
  message: ptyExitMessage(exitCode),
@@ -65892,8 +66003,8 @@ var init_transcripts = __esm(() => {
65892
66003
 
65893
66004
  // src/services/conversations.ts
65894
66005
  function withoutKickoff(turns, kind, runtime5) {
65895
- const kickoff = kickoffLineFor(kind, undefined, runtime5);
65896
- return turns.filter((turn) => !(turn.role === "user" && promptMatchesKickoff(kickoff, turn.text)));
66006
+ const kickoffs = [kickoffLineFor(kind, undefined, runtime5), selfPlanKickoffFor(runtime5)];
66007
+ return turns.filter((turn) => !(turn.role === "user" && kickoffs.some((kickoff) => promptMatchesKickoff(kickoff, turn.text))));
65897
66008
  }
65898
66009
  function promptMatchesKickoff(line, prompt) {
65899
66010
  if (!prompt)
@@ -65987,6 +66098,7 @@ var TITLE_MAX = 60, MATCH_PREFIX = 40, INTERRUPTED = "[Request interrupted by us
65987
66098
  var init_conversations = __esm(() => {
65988
66099
  init_src();
65989
66100
  init_sessions();
66101
+ init_skills();
65990
66102
  init_transcripts();
65991
66103
  });
65992
66104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.4.4",
3
+ "version": "1.4.7",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",