runcastle 1.4.6 → 1.4.8

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
@@ -4639,6 +4639,63 @@ var init_git = __esm(() => {
4639
4639
  ];
4640
4640
  });
4641
4641
 
4642
+ // src/doctor/system-exec.ts
4643
+ import { spawn as spawn4 } from "child_process";
4644
+ function createSystemExec(opts = {}) {
4645
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
4646
+ const containFn = opts.containFn ?? containHostProcess;
4647
+ return (command, args) => new Promise((resolve4) => {
4648
+ const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
4649
+ let stdout = "";
4650
+ let stderr = "";
4651
+ let settled = false;
4652
+ const settle = (outcome) => {
4653
+ if (settled)
4654
+ return;
4655
+ settled = true;
4656
+ clearTimeout(timer);
4657
+ resolve4(outcome);
4658
+ };
4659
+ const child = spawn4(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
4660
+ const containment = child.pid === undefined ? null : containFn(child.pid);
4661
+ const timer = setTimeout(() => {
4662
+ if (containment)
4663
+ containment.kill();
4664
+ else if (child.pid !== undefined)
4665
+ killProcessTree(child.pid);
4666
+ const note = `${command} ${args.join(" ")} timed out after ${Math.round(timeoutMs / 1000)}s`;
4667
+ settle({
4668
+ ok: true,
4669
+ code: null,
4670
+ stdout,
4671
+ stderr: stderr ? `${stderr}
4672
+ ${note}` : note,
4673
+ timedOut: true
4674
+ });
4675
+ }, timeoutMs);
4676
+ child.stdout?.on("data", (d) => {
4677
+ stdout += d.toString();
4678
+ });
4679
+ child.stderr?.on("data", (d) => {
4680
+ stderr += d.toString();
4681
+ });
4682
+ child.on("error", (err) => {
4683
+ containment?.kill();
4684
+ settle({ ok: false, code: null, stdout, stderr: stderr || String(err) });
4685
+ });
4686
+ child.on("close", (code) => {
4687
+ containment?.kill();
4688
+ settle({ ok: true, code: code ?? 0, stdout, stderr });
4689
+ });
4690
+ });
4691
+ }
4692
+ var DEFAULT_EXEC_TIMEOUT_MS = 60000;
4693
+ var init_system_exec = __esm(() => {
4694
+ init_kill_tree();
4695
+ init_contain_host();
4696
+ init_resolve_executable();
4697
+ });
4698
+
4642
4699
  // src/launcher/edit-guard.ts
4643
4700
  import { isAbsolute, relative, resolve as resolve4 } from "path";
4644
4701
  function guardsEdits(kind) {
@@ -4758,6 +4815,64 @@ var init_install_guard = __esm(() => {
4758
4815
  HEREDOC_OPENER = /(?<!<)<<(?!<)(-?)[ \t]*(?:'([^'\n]+)'|"([^"\n]+)"|\\?([\w.-]+))/g;
4759
4816
  });
4760
4817
 
4818
+ // src/launcher/skills-root.ts
4819
+ import { existsSync as existsSync7 } from "fs";
4820
+ import { dirname as dirname3, join as join7, resolve as resolve6 } from "path";
4821
+ import { fileURLToPath as fileURLToPath3 } from "url";
4822
+ function resolveSkillsRoot(fromDir) {
4823
+ const override = process.env[SKILLS_DIR_ENV];
4824
+ if (override) {
4825
+ const root = resolve6(override);
4826
+ if (existsSync7(join7(root, SKILLS_MARKER)))
4827
+ return root;
4828
+ throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
4829
+ }
4830
+ const searched = [];
4831
+ let dir = fromDir;
4832
+ for (let i = 0;i < 8; i += 1) {
4833
+ const candidate = join7(dir, "packages", "skills");
4834
+ searched.push(candidate);
4835
+ if (existsSync7(join7(candidate, SKILLS_MARKER)))
4836
+ return candidate;
4837
+ const parent = dirname3(dir);
4838
+ if (parent === dir)
4839
+ break;
4840
+ dir = parent;
4841
+ }
4842
+ throw new Error(`runcastle skills root not found; searched:
4843
+ ${searched.join(`
4844
+ `)}`);
4845
+ }
4846
+ function resolvePluginDir(fromDir = dirname3(fileURLToPath3(import.meta.url))) {
4847
+ const rel = join7("packages", "skills", "packs", "runcastle");
4848
+ const override = process.env[SKILLS_DIR_ENV];
4849
+ if (override) {
4850
+ const dir2 = join7(resolve6(override), "packs", "runcastle");
4851
+ if (existsSync7(dir2))
4852
+ return dir2;
4853
+ throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
4854
+ }
4855
+ const searched = [];
4856
+ let dir = fromDir;
4857
+ for (let i = 0;i < 8; i += 1) {
4858
+ const candidate = join7(dir, rel);
4859
+ searched.push(candidate);
4860
+ if (existsSync7(candidate))
4861
+ return candidate;
4862
+ const parent = dirname3(dir);
4863
+ if (parent === dir)
4864
+ break;
4865
+ dir = parent;
4866
+ }
4867
+ throw new Error(`runcastle plugin dir (${rel}) not found; searched:
4868
+ ${searched.join(`
4869
+ `)}`);
4870
+ }
4871
+ var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
4872
+ var init_skills_root = __esm(() => {
4873
+ SKILLS_MARKER = join7("packs", "runcastle");
4874
+ });
4875
+
4761
4876
  // src/launcher/runtimes/skills.ts
4762
4877
  function skillRef(runtime, skill) {
4763
4878
  return SPELLINGS[runtime](skill);
@@ -4813,10 +4928,10 @@ var init_skills = __esm(() => {
4813
4928
 
4814
4929
  // src/launcher/artifacts.ts
4815
4930
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
4816
- import { fileURLToPath as fileURLToPath3 } from "url";
4817
- import { join as join7 } from "path";
4931
+ import { fileURLToPath as fileURLToPath4 } from "url";
4932
+ import { join as join8 } from "path";
4818
4933
  function hookClientPath() {
4819
- return resolveAsset(ASSET_ENV.hookClient, fileURLToPath3(new URL("./hook-client.ts", import.meta.url)));
4934
+ return resolveAsset(ASSET_ENV.hookClient, fileURLToPath4(new URL("./hook-client.ts", import.meta.url)));
4820
4935
  }
4821
4936
  function serverUrlFor(config2) {
4822
4937
  return `http://localhost:${config2.serverPort}`;
@@ -5260,6 +5375,10 @@ function renderProjectPrompt(brief, runtime = DEFAULT_RUNTIME) {
5260
5375
  ].join(`
5261
5376
  `);
5262
5377
  }
5378
+ function skillPackReadRule(packDir) {
5379
+ const posix2 = packDir.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`).replace(/\/+$/, "");
5380
+ return `Read(/${posix2}/**)`;
5381
+ }
5263
5382
  function entrySkillDenyRules(kind) {
5264
5383
  const allowed = new Set(entrySkillsFor(kind));
5265
5384
  return ENTRY_SKILLS.filter((skill) => !allowed.has(skill)).flatMap((skill) => [
@@ -5278,7 +5397,7 @@ function sessionBashAllowRules(kind) {
5278
5397
  }
5279
5398
  return SESSION_BASH_ALLOW_RULES;
5280
5399
  }
5281
- function renderSettings(hookClient, kind) {
5400
+ function renderSettings(hookClient, kind, skillPackDir) {
5282
5401
  const cmd = (event) => ({
5283
5402
  type: "command",
5284
5403
  command: `bun run "${hookClient}" ${event}`,
@@ -5286,7 +5405,11 @@ function renderSettings(hookClient, kind) {
5286
5405
  });
5287
5406
  return {
5288
5407
  permissions: {
5289
- allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...sessionBashAllowRules(kind)],
5408
+ allow: [
5409
+ ...RUNCASTLE_MCP_ALLOW_RULES,
5410
+ ...sessionBashAllowRules(kind),
5411
+ ...skillPackDir ? [skillPackReadRule(skillPackDir)] : []
5412
+ ],
5290
5413
  deny: entrySkillDenyRules(kind)
5291
5414
  },
5292
5415
  hooks: {
@@ -5347,15 +5470,15 @@ function renderSessionPrompt(input) {
5347
5470
  return renderProjectPrompt(projectBrief, runtime);
5348
5471
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
5349
5472
  }
5350
- async function writeSessionArtifacts(input) {
5473
+ async function writeSessionArtifacts(input, pluginDir = resolvePluginDir()) {
5351
5474
  const { session, config: config2 } = input;
5352
5475
  const dir = sessionDir(session.id);
5353
5476
  mkdirSync3(dir, { recursive: true });
5354
- const systemPromptPath = join7(dir, "system-prompt.md");
5355
- const settingsPath = join7(dir, "settings.json");
5356
- const mcpConfigPath = join7(dir, "mcp.json");
5477
+ const systemPromptPath = join8(dir, "system-prompt.md");
5478
+ const settingsPath = join8(dir, "settings.json");
5479
+ const mcpConfigPath = join8(dir, "mcp.json");
5357
5480
  writeFileSync2(systemPromptPath, renderSessionPrompt(input), "utf8");
5358
- writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
5481
+ writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind, pluginDir), null, 2), "utf8");
5359
5482
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
5360
5483
  return { systemPromptPath, settingsPath, mcpConfigPath };
5361
5484
  }
@@ -5367,6 +5490,7 @@ var init_artifacts = __esm(() => {
5367
5490
  init_asset_paths();
5368
5491
  init_edit_guard();
5369
5492
  init_install_guard();
5493
+ init_skills_root();
5370
5494
  init_skills();
5371
5495
  RUNCASTLE_MCP_ALLOW_RULES = [
5372
5496
  "mcp__runcastle__get_feature_context",
@@ -5415,64 +5539,6 @@ var init_artifacts = __esm(() => {
5415
5539
  SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
5416
5540
  });
5417
5541
 
5418
- // src/launcher/skills-root.ts
5419
- import { existsSync as existsSync7 } from "fs";
5420
- import { dirname as dirname3, join as join8, resolve as resolve6 } from "path";
5421
- import { fileURLToPath as fileURLToPath4 } from "url";
5422
- function resolveSkillsRoot(fromDir) {
5423
- const override = process.env[SKILLS_DIR_ENV];
5424
- if (override) {
5425
- const root = resolve6(override);
5426
- if (existsSync7(join8(root, SKILLS_MARKER)))
5427
- return root;
5428
- throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
5429
- }
5430
- const searched = [];
5431
- let dir = fromDir;
5432
- for (let i = 0;i < 8; i += 1) {
5433
- const candidate = join8(dir, "packages", "skills");
5434
- searched.push(candidate);
5435
- if (existsSync7(join8(candidate, SKILLS_MARKER)))
5436
- return candidate;
5437
- const parent = dirname3(dir);
5438
- if (parent === dir)
5439
- break;
5440
- dir = parent;
5441
- }
5442
- throw new Error(`runcastle skills root not found; searched:
5443
- ${searched.join(`
5444
- `)}`);
5445
- }
5446
- function resolvePluginDir(fromDir = dirname3(fileURLToPath4(import.meta.url))) {
5447
- const rel = join8("packages", "skills", "packs", "runcastle");
5448
- const override = process.env[SKILLS_DIR_ENV];
5449
- if (override) {
5450
- const dir2 = join8(resolve6(override), "packs", "runcastle");
5451
- if (existsSync7(dir2))
5452
- return dir2;
5453
- throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
5454
- }
5455
- const searched = [];
5456
- let dir = fromDir;
5457
- for (let i = 0;i < 8; i += 1) {
5458
- const candidate = join8(dir, rel);
5459
- searched.push(candidate);
5460
- if (existsSync7(candidate))
5461
- return candidate;
5462
- const parent = dirname3(dir);
5463
- if (parent === dir)
5464
- break;
5465
- dir = parent;
5466
- }
5467
- throw new Error(`runcastle plugin dir (${rel}) not found; searched:
5468
- ${searched.join(`
5469
- `)}`);
5470
- }
5471
- var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
5472
- var init_skills_root = __esm(() => {
5473
- SKILLS_MARKER = join8("packs", "runcastle");
5474
- });
5475
-
5476
5542
  // src/launcher/runtimes/types.ts
5477
5543
  function assertHeadlessKickoff(headless, line) {
5478
5544
  if (headless && !line)
@@ -5510,8 +5576,27 @@ function buildClaudeArgs(input) {
5510
5576
  ...input.kickoffLine ? [input.kickoffLine] : []
5511
5577
  ];
5512
5578
  }
5513
- var CC_NESTING_ENV, KICKOFF_LINES, CLAUDE_BIN = "claude", claudeRuntime;
5579
+ function findBackgroundSessionIn(listing, ccSessionId) {
5580
+ if (!Array.isArray(listing))
5581
+ return;
5582
+ const held = listing.find((entry) => typeof entry === "object" && entry !== null && entry.sessionId === ccSessionId && entry.kind === "background");
5583
+ if (!held)
5584
+ return;
5585
+ return { sessionId: ccSessionId, shortId: typeof held.id === "string" ? held.id : ccSessionId.slice(0, 8) };
5586
+ }
5587
+ async function listClaudeAgents() {
5588
+ try {
5589
+ const out = await createSystemExec({ timeoutMs: AGENTS_LISTING_TIMEOUT_MS })(CLAUDE_BIN, ["agents", "--json"]);
5590
+ if (!out.ok || out.timedOut)
5591
+ return;
5592
+ return JSON.parse(out.stdout);
5593
+ } catch {
5594
+ return;
5595
+ }
5596
+ }
5597
+ var CC_NESTING_ENV, KICKOFF_LINES, AGENTS_LISTING_TIMEOUT_MS = 1e4, CLAUDE_BIN = "claude", claudeRuntime;
5514
5598
  var init_claude = __esm(() => {
5599
+ init_system_exec();
5515
5600
  init_resolve_executable();
5516
5601
  init_artifacts();
5517
5602
  init_skills_root();
@@ -5543,11 +5628,12 @@ var init_claude = __esm(() => {
5543
5628
  };
5544
5629
  },
5545
5630
  async writeArtifacts(input) {
5546
- const files = await writeSessionArtifacts({ ...input, runtime: this.id });
5631
+ const pluginDir = resolvePluginDir();
5632
+ const files = await writeSessionArtifacts({ ...input, runtime: this.id }, pluginDir);
5547
5633
  return {
5548
5634
  files: [files.systemPromptPath, files.settingsPath, files.mcpConfigPath],
5549
5635
  argv: buildClaudeArgs({
5550
- pluginDir: resolvePluginDir(),
5636
+ pluginDir,
5551
5637
  settingsPath: files.settingsPath,
5552
5638
  mcpConfigPath: files.mcpConfigPath,
5553
5639
  systemPromptPath: files.systemPromptPath,
@@ -5560,13 +5646,17 @@ var init_claude = __esm(() => {
5560
5646
  }),
5561
5647
  env: {
5562
5648
  RUNCASTLE_SESSION_ID: input.session.id,
5563
- RUNCASTLE_SERVER_URL: input.serverUrl
5649
+ RUNCASTLE_SERVER_URL: input.serverUrl,
5650
+ CLAUDE_CODE_DISABLE_AGENT_VIEW: "1"
5564
5651
  },
5565
5652
  envScrub: CC_NESTING_ENV
5566
5653
  };
5567
5654
  },
5568
5655
  kickoffLine(kind) {
5569
5656
  return KICKOFF_LINES[kind];
5657
+ },
5658
+ async findBackgroundSession(ccSessionId) {
5659
+ return findBackgroundSessionIn(await listClaudeAgents(), ccSessionId);
5570
5660
  }
5571
5661
  };
5572
5662
  });
@@ -8213,7 +8303,7 @@ var init_chat_branch = __esm(() => {
8213
8303
  });
8214
8304
 
8215
8305
  // src/workflows/kill-registry.ts
8216
- import { spawn as spawn4 } from "child_process";
8306
+ import { spawn as spawn5 } from "child_process";
8217
8307
  function log2(msg) {
8218
8308
  console.error(`[kill-registry] ${msg}`);
8219
8309
  }
@@ -8230,7 +8320,7 @@ function runDockerCommand(args) {
8230
8320
  const timer = setTimeout(() => settle(false), DOCKER_COMMAND_TIMEOUT_MS);
8231
8321
  timer.unref?.();
8232
8322
  try {
8233
- const child = spawn4("docker", args, { windowsHide: true, stdio: "ignore" });
8323
+ const child = spawn5("docker", args, { windowsHide: true, stdio: "ignore" });
8234
8324
  child.on("exit", (code) => settle(code === 0));
8235
8325
  child.on("close", (code) => settle(code === 0));
8236
8326
  child.on("error", () => settle(false));
@@ -8361,63 +8451,6 @@ var init_kill_registry = __esm(() => {
8361
8451
  REGISTRY_KEY2 = Symbol.for("runcastle.kill.registry");
8362
8452
  });
8363
8453
 
8364
- // src/doctor/system-exec.ts
8365
- import { spawn as spawn5 } from "child_process";
8366
- function createSystemExec(opts = {}) {
8367
- const timeoutMs = opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
8368
- const containFn = opts.containFn ?? containHostProcess;
8369
- return (command, args) => new Promise((resolve9) => {
8370
- const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
8371
- let stdout = "";
8372
- let stderr = "";
8373
- let settled = false;
8374
- const settle = (outcome) => {
8375
- if (settled)
8376
- return;
8377
- settled = true;
8378
- clearTimeout(timer);
8379
- resolve9(outcome);
8380
- };
8381
- const child = spawn5(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
8382
- const containment = child.pid === undefined ? null : containFn(child.pid);
8383
- const timer = setTimeout(() => {
8384
- if (containment)
8385
- containment.kill();
8386
- else if (child.pid !== undefined)
8387
- killProcessTree(child.pid);
8388
- const note = `${command} ${args.join(" ")} timed out after ${Math.round(timeoutMs / 1000)}s`;
8389
- settle({
8390
- ok: true,
8391
- code: null,
8392
- stdout,
8393
- stderr: stderr ? `${stderr}
8394
- ${note}` : note,
8395
- timedOut: true
8396
- });
8397
- }, timeoutMs);
8398
- child.stdout?.on("data", (d) => {
8399
- stdout += d.toString();
8400
- });
8401
- child.stderr?.on("data", (d) => {
8402
- stderr += d.toString();
8403
- });
8404
- child.on("error", (err) => {
8405
- containment?.kill();
8406
- settle({ ok: false, code: null, stdout, stderr: stderr || String(err) });
8407
- });
8408
- child.on("close", (code) => {
8409
- containment?.kill();
8410
- settle({ ok: true, code: code ?? 0, stdout, stderr });
8411
- });
8412
- });
8413
- }
8414
- var DEFAULT_EXEC_TIMEOUT_MS = 60000;
8415
- var init_system_exec = __esm(() => {
8416
- init_kill_tree();
8417
- init_contain_host();
8418
- init_resolve_executable();
8419
- });
8420
-
8421
8454
  // src/services/settings.ts
8422
8455
  import { existsSync as existsSync13, mkdirSync as mkdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
8423
8456
  import { dirname as dirname6 } from "path";
@@ -57723,7 +57756,8 @@ function runCommand2(bin, args2) {
57723
57756
  const timer = setTimeout(() => settle(false), COMMAND_TIMEOUT_MS);
57724
57757
  timer.unref?.();
57725
57758
  try {
57726
- const child = spawn8(bin, [...args2], { windowsHide: true, stdio: "ignore" });
57759
+ const target = spawnTargetFor(bin, [...args2]);
57760
+ const child = spawn8(target.file, target.args, { windowsHide: true, stdio: "ignore" });
57727
57761
  child.on("exit", (code) => settle(code === 0));
57728
57762
  child.on("close", (code) => settle(code === 0));
57729
57763
  child.on("error", () => settle(false));
@@ -57789,6 +57823,7 @@ async function reapRecorder(ticketId, options = {}) {
57789
57823
  var AGENT_BROWSER_BIN = "agent-browser", DEFAULT_TIMEOUT_MS2 = 1e4, POLL_INTERVAL_MS = 100, COMMAND_TIMEOUT_MS = 5000, REAL_DEPS2;
57790
57824
  var init_recorder_reap = __esm(() => {
57791
57825
  init_paths();
57826
+ init_resolve_executable();
57792
57827
  REAL_DEPS2 = {
57793
57828
  findBrowser: () => findOnPath(AGENT_BROWSER_BIN),
57794
57829
  fileExists: existsSync16,
@@ -58399,7 +58434,10 @@ function couldNotReview(ticket, reason, digest) {
58399
58434
  **${headline2}**` : `**${headline2}**`
58400
58435
  };
58401
58436
  }
58402
- var FFMPEG_BIN = "ffmpeg", spawnVersion = (path2) => spawnSync(path2, ["--version"], { timeout: 3000, stdio: "ignore" }), withheldDrives, DECLARATION, REVIEW_DIRECTORY_FS;
58437
+ var FFMPEG_BIN = "ffmpeg", spawnVersion = (path2) => {
58438
+ const { file, args: args2 } = spawnTargetFor(path2, ["--version"]);
58439
+ return spawnSync(file, args2, { timeout: 3000, stdio: "ignore", windowsHide: true });
58440
+ }, withheldDrives, DECLARATION, REVIEW_DIRECTORY_FS;
58403
58441
  var init_review_ticket = __esm(() => {
58404
58442
  init_src();
58405
58443
  init_paths();
@@ -58409,6 +58447,7 @@ var init_review_ticket = __esm(() => {
58409
58447
  init_agent_stream();
58410
58448
  init_git();
58411
58449
  init_review_findings();
58450
+ init_resolve_executable();
58412
58451
  init_kill_registry();
58413
58452
  init_recorder_reap();
58414
58453
  init_recorder_reap();
@@ -62836,6 +62875,24 @@ async function commitDocsCheckpoint(ctx, session, summary) {
62836
62875
  }
62837
62876
  await landChatCommits(ctx, project3, feature);
62838
62877
  }
62878
+ function checkpointDocsAfterReply(ctx, session, summary) {
62879
+ let queue = docsCheckpointQueues.get(session.worktreePath);
62880
+ if (!queue) {
62881
+ queue = createSerialQueue();
62882
+ docsCheckpointQueues.set(session.worktreePath, queue);
62883
+ }
62884
+ queue(() => commitDocsCheckpoint(ctx, session, summary)).catch((e) => {
62885
+ try {
62886
+ emit(ctx, requireFeatureId(session), {
62887
+ type: "git.commit_pending",
62888
+ message: `docs checkpoint failed: ${e instanceof Error ? e.message : String(e)}`,
62889
+ data: { summary }
62890
+ });
62891
+ } catch (err) {
62892
+ console.error(`docs checkpoint failed for session ${session.id}`, e, err);
62893
+ }
62894
+ });
62895
+ }
62839
62896
  function requireProject(ctx, session) {
62840
62897
  if (!session.projectId) {
62841
62898
  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.");
@@ -63576,7 +63633,7 @@ function buildMcpServer(audience) {
63576
63633
  if (!rs)
63577
63634
  return noSession();
63578
63635
  const result = toolEmitTickets(rs.ctx, rs.session, args2);
63579
- await commitDocsCheckpoint(rs.ctx, rs.session, `tickets emitted (${result.stored})`);
63636
+ checkpointDocsAfterReply(rs.ctx, rs.session, `tickets emitted (${result.stored})`);
63580
63637
  return ok(result);
63581
63638
  });
63582
63639
  }
@@ -63598,7 +63655,7 @@ function buildMcpServer(audience) {
63598
63655
  if (!rs)
63599
63656
  return noSession();
63600
63657
  const result = toolUpdateTicket(rs.ctx, rs.session, args2);
63601
- await commitDocsCheckpoint(rs.ctx, rs.session, `ticket ${result.ticket.seq} updated`);
63658
+ checkpointDocsAfterReply(rs.ctx, rs.session, `ticket ${result.ticket.seq} updated`);
63602
63659
  return ok(result);
63603
63660
  });
63604
63661
  }
@@ -63615,7 +63672,7 @@ function buildMcpServer(audience) {
63615
63672
  if (!rs)
63616
63673
  return noSession();
63617
63674
  const result = toolCancelTicket(rs.ctx, rs.session, args2);
63618
- await commitDocsCheckpoint(rs.ctx, rs.session, `ticket ${result.ticket.seq} cancelled`);
63675
+ checkpointDocsAfterReply(rs.ctx, rs.session, `ticket ${result.ticket.seq} cancelled`);
63619
63676
  return ok(result);
63620
63677
  });
63621
63678
  }
@@ -63659,7 +63716,7 @@ function buildMcpServer(audience) {
63659
63716
  return noSession();
63660
63717
  const result = toolCompletePhase(rs.ctx, rs.session, args2);
63661
63718
  if (result.ok) {
63662
- await commitDocsCheckpoint(rs.ctx, rs.session, `phase '${args2.phase}' complete`);
63719
+ checkpointDocsAfterReply(rs.ctx, rs.session, `phase '${args2.phase}' complete`);
63663
63720
  }
63664
63721
  return ok(result);
63665
63722
  });
@@ -63692,7 +63749,7 @@ async function readMcpBody(c) {
63692
63749
  return;
63693
63750
  }
63694
63751
  }
63695
- 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;
63752
+ 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;
63696
63753
  var init_server = __esm(() => {
63697
63754
  init_src();
63698
63755
  init_paths();
@@ -63711,6 +63768,7 @@ var init_server = __esm(() => {
63711
63768
  init_review_findings();
63712
63769
  init_git();
63713
63770
  init_knowledge();
63771
+ init_landing_queue();
63714
63772
  init_planning();
63715
63773
  init_project_notes();
63716
63774
  init_repo();
@@ -63735,6 +63793,7 @@ var init_server = __esm(() => {
63735
63793
  });
63736
63794
  }
63737
63795
  });
63796
+ docsCheckpointQueues = new Map;
63738
63797
  DRAFTING_KINDS = ["chat"];
63739
63798
  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/.";
63740
63799
  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 }).";
@@ -65367,16 +65426,17 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
65367
65426
  if (session.runtime === "codex" && feature) {
65368
65427
  noteResolvedMerge(ctx, session, feature).catch(() => {});
65369
65428
  }
65429
+ let resumeFailureReported;
65370
65430
  if (diedBeforeLive && meta.resumeSessionId) {
65371
- emitForSession(ctx, session, {
65372
- type: "session.resume_failed",
65373
- message: `resume failed for ${session.kind} \u2014 the session exited before starting (code ${exitCode ?? "unknown"}); the previous conversation is still resumable`,
65374
- data: {
65375
- sessionId: session.id,
65376
- resumeSessionId: meta.resumeSessionId,
65377
- exitCode: exitCode ?? null
65378
- }
65379
- });
65431
+ const resumeSessionId = meta.resumeSessionId;
65432
+ const report = (background) => emitResumeFailed(ctx, session, resumeSessionId, exitCode, background);
65433
+ if (meta.findBackgroundSession) {
65434
+ resumeFailureReported = meta.findBackgroundSession(resumeSessionId).catch(() => {
65435
+ return;
65436
+ }).then(report);
65437
+ } else {
65438
+ report(undefined);
65439
+ }
65380
65440
  }
65381
65441
  if (session.purpose === "self-plan" && feature) {
65382
65442
  const current2 = getFeatureRow(ctx, feature.id);
@@ -65392,19 +65452,36 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
65392
65452
  message: ptyExitMessage(exitCode),
65393
65453
  data: { sessionId: session.id, exitCode: exitCode ?? null }
65394
65454
  });
65455
+ return resumeFailureReported;
65456
+ }
65457
+ function emitResumeFailed(ctx, session, resumeSessionId, exitCode, background) {
65458
+ const exited = `resume failed for ${session.kind} \u2014 the session exited before starting (code ${exitCode ?? "unknown"})`;
65459
+ const short = background?.shortId;
65460
+ emitForSession(ctx, session, {
65461
+ type: "session.resume_failed",
65462
+ message: short ? `${exited}: its conversation is running as a Claude Code background session (${short}), which cannot be resumed here. ` + `Run \`claude attach ${short}\` to look at it, then \`claude stop ${short}\` (stopping keeps the conversation), then relaunch.` : `${exited}; the previous conversation is still resumable`,
65463
+ data: {
65464
+ sessionId: session.id,
65465
+ resumeSessionId,
65466
+ exitCode: exitCode ?? null,
65467
+ ...short ? { backgroundSessionId: short } : {}
65468
+ }
65469
+ });
65395
65470
  }
65396
65471
  function spawnEmbeddedPty(ctx, feature, session, worktreePath, runtime5, spec, meta = {}) {
65397
65472
  const { file, args: args2 } = spawnTargetFor(runtime5.resolveBinary(), spec.argv);
65398
65473
  const env = { ...process.env, ...spec.env };
65399
65474
  for (const key of spec.envScrub)
65400
65475
  delete env[key];
65476
+ const findBackgroundSession = runtime5.findBackgroundSession?.bind(runtime5);
65477
+ const exitMeta = findBackgroundSession ? { ...meta, findBackgroundSession } : meta;
65401
65478
  try {
65402
65479
  const entry = ptyRegistry().create({
65403
65480
  sessionId: session.id,
65404
65481
  cmd: file,
65405
65482
  args: args2,
65406
65483
  opts: { cwd: worktreePath, env, cols: 80, rows: 24, useConpty: true },
65407
- onExit: ({ exitCode }) => handlePtyExit(ctx, feature, session, meta, exitCode)
65484
+ onExit: ({ exitCode }) => void handlePtyExit(ctx, feature, session, exitMeta, exitCode)
65408
65485
  });
65409
65486
  if (feature)
65410
65487
  startDocsWatch(ctx, feature);
@@ -65973,8 +66050,8 @@ var init_transcripts = __esm(() => {
65973
66050
 
65974
66051
  // src/services/conversations.ts
65975
66052
  function withoutKickoff(turns, kind, runtime5) {
65976
- const kickoff = kickoffLineFor(kind, undefined, runtime5);
65977
- return turns.filter((turn) => !(turn.role === "user" && promptMatchesKickoff(kickoff, turn.text)));
66053
+ const kickoffs = [kickoffLineFor(kind, undefined, runtime5), selfPlanKickoffFor(runtime5)];
66054
+ return turns.filter((turn) => !(turn.role === "user" && kickoffs.some((kickoff) => promptMatchesKickoff(kickoff, turn.text))));
65978
66055
  }
65979
66056
  function promptMatchesKickoff(line, prompt) {
65980
66057
  if (!prompt)
@@ -66068,6 +66145,7 @@ var TITLE_MAX = 60, MATCH_PREFIX = 40, INTERRUPTED = "[Request interrupted by us
66068
66145
  var init_conversations = __esm(() => {
66069
66146
  init_src();
66070
66147
  init_sessions();
66148
+ init_skills();
66071
66149
  init_transcripts();
66072
66150
  });
66073
66151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.4.6",
3
+ "version": "1.4.8",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
@@ -51,8 +51,9 @@ This is the **self-planning path**. It applies only when your briefing says no h
51
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
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
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
+ - **A timed-out tool is not a refused one.** A refusal is runcastle's answer; a timeout is no answer, and the call may have landed anyway. Retry it once, checking first that it did not land: `complete_phase` is safe to repeat (a step already reported comes back ok with a note), but before repeating `emit_tickets`, `update_ticket` or `cancel_ticket`, read `list_tickets` and send only what is missing. If the retry times out too, carry on and say so in the closing message. Never leave `complete_phase({ phase: "tickets" })` on a timeout: until it lands, Burn is not armed.
54
55
  - **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.
56
+ - **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) that covers the plan only: the plan was made without the human, how many decisions it holds, and which are to check first, plus anything that stopped you doing the job (a refused or failed tool the plan needed). Leave out the state of tools and connectors the plan did not need.
56
57
 
57
58
  ## 2. Lock decisions incrementally
58
59
 
@@ -122,6 +123,6 @@ Do not open a new session for these. They run here, on top of everything you jus
122
123
 
123
124
  > 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.
124
125
 
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
+ 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. It covers the plan only. Report anything that stopped you doing the job: a tool the plan needed that was refused or failed, and what you decided without it. Do not report the state of tools and connectors the plan did not need, such as MCP servers that failed to connect or connectors waiting to be authorised: the session inherits the human's own, and they are not part of the plan. Then end the turn; the run is done.
126
127
 
127
128
  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.)