runcastle 1.4.6 → 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
@@ -4759,6 +4759,64 @@ var init_install_guard = __esm(() => {
4759
4759
  HEREDOC_OPENER = /(?<!<)<<(?!<)(-?)[ \t]*(?:'([^'\n]+)'|"([^"\n]+)"|\\?([\w.-]+))/g;
4760
4760
  });
4761
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
+
4762
4820
  // src/launcher/runtimes/skills.ts
4763
4821
  function skillRef(runtime, skill) {
4764
4822
  return SPELLINGS[runtime](skill);
@@ -4814,10 +4872,10 @@ var init_skills = __esm(() => {
4814
4872
 
4815
4873
  // src/launcher/artifacts.ts
4816
4874
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
4817
- import { fileURLToPath as fileURLToPath3 } from "url";
4818
- import { join as join7 } from "path";
4875
+ import { fileURLToPath as fileURLToPath4 } from "url";
4876
+ import { join as join8 } from "path";
4819
4877
  function hookClientPath() {
4820
- 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)));
4821
4879
  }
4822
4880
  function serverUrlFor(config2) {
4823
4881
  return `http://localhost:${config2.serverPort}`;
@@ -5261,6 +5319,10 @@ function renderProjectPrompt(brief, runtime = DEFAULT_RUNTIME) {
5261
5319
  ].join(`
5262
5320
  `);
5263
5321
  }
5322
+ function skillPackReadRule(packDir) {
5323
+ const posix2 = packDir.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`).replace(/\/+$/, "");
5324
+ return `Read(/${posix2}/**)`;
5325
+ }
5264
5326
  function entrySkillDenyRules(kind) {
5265
5327
  const allowed = new Set(entrySkillsFor(kind));
5266
5328
  return ENTRY_SKILLS.filter((skill) => !allowed.has(skill)).flatMap((skill) => [
@@ -5279,7 +5341,7 @@ function sessionBashAllowRules(kind) {
5279
5341
  }
5280
5342
  return SESSION_BASH_ALLOW_RULES;
5281
5343
  }
5282
- function renderSettings(hookClient, kind) {
5344
+ function renderSettings(hookClient, kind, skillPackDir) {
5283
5345
  const cmd = (event) => ({
5284
5346
  type: "command",
5285
5347
  command: `bun run "${hookClient}" ${event}`,
@@ -5287,7 +5349,11 @@ function renderSettings(hookClient, kind) {
5287
5349
  });
5288
5350
  return {
5289
5351
  permissions: {
5290
- allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...sessionBashAllowRules(kind)],
5352
+ allow: [
5353
+ ...RUNCASTLE_MCP_ALLOW_RULES,
5354
+ ...sessionBashAllowRules(kind),
5355
+ ...skillPackDir ? [skillPackReadRule(skillPackDir)] : []
5356
+ ],
5291
5357
  deny: entrySkillDenyRules(kind)
5292
5358
  },
5293
5359
  hooks: {
@@ -5348,15 +5414,15 @@ function renderSessionPrompt(input) {
5348
5414
  return renderProjectPrompt(projectBrief, runtime);
5349
5415
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
5350
5416
  }
5351
- async function writeSessionArtifacts(input) {
5417
+ async function writeSessionArtifacts(input, pluginDir = resolvePluginDir()) {
5352
5418
  const { session, config: config2 } = input;
5353
5419
  const dir = sessionDir(session.id);
5354
5420
  mkdirSync3(dir, { recursive: true });
5355
- const systemPromptPath = join7(dir, "system-prompt.md");
5356
- const settingsPath = join7(dir, "settings.json");
5357
- 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");
5358
5424
  writeFileSync2(systemPromptPath, renderSessionPrompt(input), "utf8");
5359
- writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
5425
+ writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind, pluginDir), null, 2), "utf8");
5360
5426
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
5361
5427
  return { systemPromptPath, settingsPath, mcpConfigPath };
5362
5428
  }
@@ -5368,6 +5434,7 @@ var init_artifacts = __esm(() => {
5368
5434
  init_asset_paths();
5369
5435
  init_edit_guard();
5370
5436
  init_install_guard();
5437
+ init_skills_root();
5371
5438
  init_skills();
5372
5439
  RUNCASTLE_MCP_ALLOW_RULES = [
5373
5440
  "mcp__runcastle__get_feature_context",
@@ -5416,64 +5483,6 @@ var init_artifacts = __esm(() => {
5416
5483
  SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
5417
5484
  });
5418
5485
 
5419
- // src/launcher/skills-root.ts
5420
- import { existsSync as existsSync7 } from "fs";
5421
- import { dirname as dirname3, join as join8, resolve as resolve6 } from "path";
5422
- import { fileURLToPath as fileURLToPath4 } from "url";
5423
- function resolveSkillsRoot(fromDir) {
5424
- const override = process.env[SKILLS_DIR_ENV];
5425
- if (override) {
5426
- const root = resolve6(override);
5427
- if (existsSync7(join8(root, SKILLS_MARKER)))
5428
- return root;
5429
- throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
5430
- }
5431
- const searched = [];
5432
- let dir = fromDir;
5433
- for (let i = 0;i < 8; i += 1) {
5434
- const candidate = join8(dir, "packages", "skills");
5435
- searched.push(candidate);
5436
- if (existsSync7(join8(candidate, SKILLS_MARKER)))
5437
- return candidate;
5438
- const parent = dirname3(dir);
5439
- if (parent === dir)
5440
- break;
5441
- dir = parent;
5442
- }
5443
- throw new Error(`runcastle skills root not found; searched:
5444
- ${searched.join(`
5445
- `)}`);
5446
- }
5447
- function resolvePluginDir(fromDir = dirname3(fileURLToPath4(import.meta.url))) {
5448
- const rel = join8("packages", "skills", "packs", "runcastle");
5449
- const override = process.env[SKILLS_DIR_ENV];
5450
- if (override) {
5451
- const dir2 = join8(resolve6(override), "packs", "runcastle");
5452
- if (existsSync7(dir2))
5453
- return dir2;
5454
- throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
5455
- }
5456
- const searched = [];
5457
- let dir = fromDir;
5458
- for (let i = 0;i < 8; i += 1) {
5459
- const candidate = join8(dir, rel);
5460
- searched.push(candidate);
5461
- if (existsSync7(candidate))
5462
- return candidate;
5463
- const parent = dirname3(dir);
5464
- if (parent === dir)
5465
- break;
5466
- dir = parent;
5467
- }
5468
- throw new Error(`runcastle plugin dir (${rel}) not found; searched:
5469
- ${searched.join(`
5470
- `)}`);
5471
- }
5472
- var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
5473
- var init_skills_root = __esm(() => {
5474
- SKILLS_MARKER = join8("packs", "runcastle");
5475
- });
5476
-
5477
5486
  // src/launcher/runtimes/types.ts
5478
5487
  function assertHeadlessKickoff(headless, line) {
5479
5488
  if (headless && !line)
@@ -5544,11 +5553,12 @@ var init_claude = __esm(() => {
5544
5553
  };
5545
5554
  },
5546
5555
  async writeArtifacts(input) {
5547
- const files = await writeSessionArtifacts({ ...input, runtime: this.id });
5556
+ const pluginDir = resolvePluginDir();
5557
+ const files = await writeSessionArtifacts({ ...input, runtime: this.id }, pluginDir);
5548
5558
  return {
5549
5559
  files: [files.systemPromptPath, files.settingsPath, files.mcpConfigPath],
5550
5560
  argv: buildClaudeArgs({
5551
- pluginDir: resolvePluginDir(),
5561
+ pluginDir,
5552
5562
  settingsPath: files.settingsPath,
5553
5563
  mcpConfigPath: files.mcpConfigPath,
5554
5564
  systemPromptPath: files.systemPromptPath,
@@ -62837,6 +62847,24 @@ async function commitDocsCheckpoint(ctx, session, summary) {
62837
62847
  }
62838
62848
  await landChatCommits(ctx, project3, feature);
62839
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
+ }
62840
62868
  function requireProject(ctx, session) {
62841
62869
  if (!session.projectId) {
62842
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.");
@@ -63577,7 +63605,7 @@ function buildMcpServer(audience) {
63577
63605
  if (!rs)
63578
63606
  return noSession();
63579
63607
  const result = toolEmitTickets(rs.ctx, rs.session, args2);
63580
- await commitDocsCheckpoint(rs.ctx, rs.session, `tickets emitted (${result.stored})`);
63608
+ checkpointDocsAfterReply(rs.ctx, rs.session, `tickets emitted (${result.stored})`);
63581
63609
  return ok(result);
63582
63610
  });
63583
63611
  }
@@ -63599,7 +63627,7 @@ function buildMcpServer(audience) {
63599
63627
  if (!rs)
63600
63628
  return noSession();
63601
63629
  const result = toolUpdateTicket(rs.ctx, rs.session, args2);
63602
- await commitDocsCheckpoint(rs.ctx, rs.session, `ticket ${result.ticket.seq} updated`);
63630
+ checkpointDocsAfterReply(rs.ctx, rs.session, `ticket ${result.ticket.seq} updated`);
63603
63631
  return ok(result);
63604
63632
  });
63605
63633
  }
@@ -63616,7 +63644,7 @@ function buildMcpServer(audience) {
63616
63644
  if (!rs)
63617
63645
  return noSession();
63618
63646
  const result = toolCancelTicket(rs.ctx, rs.session, args2);
63619
- await commitDocsCheckpoint(rs.ctx, rs.session, `ticket ${result.ticket.seq} cancelled`);
63647
+ checkpointDocsAfterReply(rs.ctx, rs.session, `ticket ${result.ticket.seq} cancelled`);
63620
63648
  return ok(result);
63621
63649
  });
63622
63650
  }
@@ -63660,7 +63688,7 @@ function buildMcpServer(audience) {
63660
63688
  return noSession();
63661
63689
  const result = toolCompletePhase(rs.ctx, rs.session, args2);
63662
63690
  if (result.ok) {
63663
- await commitDocsCheckpoint(rs.ctx, rs.session, `phase '${args2.phase}' complete`);
63691
+ checkpointDocsAfterReply(rs.ctx, rs.session, `phase '${args2.phase}' complete`);
63664
63692
  }
63665
63693
  return ok(result);
63666
63694
  });
@@ -63693,7 +63721,7 @@ async function readMcpBody(c) {
63693
63721
  return;
63694
63722
  }
63695
63723
  }
63696
- 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;
63697
63725
  var init_server = __esm(() => {
63698
63726
  init_src();
63699
63727
  init_paths();
@@ -63712,6 +63740,7 @@ var init_server = __esm(() => {
63712
63740
  init_review_findings();
63713
63741
  init_git();
63714
63742
  init_knowledge();
63743
+ init_landing_queue();
63715
63744
  init_planning();
63716
63745
  init_project_notes();
63717
63746
  init_repo();
@@ -63736,6 +63765,7 @@ var init_server = __esm(() => {
63736
63765
  });
63737
63766
  }
63738
63767
  });
63768
+ docsCheckpointQueues = new Map;
63739
63769
  DRAFTING_KINDS = ["chat"];
63740
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/.";
63741
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 }).";
@@ -65974,8 +66004,8 @@ var init_transcripts = __esm(() => {
65974
66004
 
65975
66005
  // src/services/conversations.ts
65976
66006
  function withoutKickoff(turns, kind, runtime5) {
65977
- const kickoff = kickoffLineFor(kind, undefined, runtime5);
65978
- 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))));
65979
66009
  }
65980
66010
  function promptMatchesKickoff(line, prompt) {
65981
66011
  if (!prompt)
@@ -66069,6 +66099,7 @@ var TITLE_MAX = 60, MATCH_PREFIX = 40, INTERRUPTED = "[Request interrupted by us
66069
66099
  var init_conversations = __esm(() => {
66070
66100
  init_src();
66071
66101
  init_sessions();
66102
+ init_skills();
66072
66103
  init_transcripts();
66073
66104
  });
66074
66105
 
package/index.js CHANGED
@@ -4758,6 +4758,64 @@ var init_install_guard = __esm(() => {
4758
4758
  HEREDOC_OPENER = /(?<!<)<<(?!<)(-?)[ \t]*(?:'([^'\n]+)'|"([^"\n]+)"|\\?([\w.-]+))/g;
4759
4759
  });
4760
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
+
4761
4819
  // src/launcher/runtimes/skills.ts
4762
4820
  function skillRef(runtime, skill) {
4763
4821
  return SPELLINGS[runtime](skill);
@@ -4813,10 +4871,10 @@ var init_skills = __esm(() => {
4813
4871
 
4814
4872
  // src/launcher/artifacts.ts
4815
4873
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
4816
- import { fileURLToPath as fileURLToPath3 } from "url";
4817
- import { join as join7 } from "path";
4874
+ import { fileURLToPath as fileURLToPath4 } from "url";
4875
+ import { join as join8 } from "path";
4818
4876
  function hookClientPath() {
4819
- 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)));
4820
4878
  }
4821
4879
  function serverUrlFor(config2) {
4822
4880
  return `http://localhost:${config2.serverPort}`;
@@ -5260,6 +5318,10 @@ function renderProjectPrompt(brief, runtime = DEFAULT_RUNTIME) {
5260
5318
  ].join(`
5261
5319
  `);
5262
5320
  }
5321
+ function skillPackReadRule(packDir) {
5322
+ const posix2 = packDir.replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`).replace(/\/+$/, "");
5323
+ return `Read(/${posix2}/**)`;
5324
+ }
5263
5325
  function entrySkillDenyRules(kind) {
5264
5326
  const allowed = new Set(entrySkillsFor(kind));
5265
5327
  return ENTRY_SKILLS.filter((skill) => !allowed.has(skill)).flatMap((skill) => [
@@ -5278,7 +5340,7 @@ function sessionBashAllowRules(kind) {
5278
5340
  }
5279
5341
  return SESSION_BASH_ALLOW_RULES;
5280
5342
  }
5281
- function renderSettings(hookClient, kind) {
5343
+ function renderSettings(hookClient, kind, skillPackDir) {
5282
5344
  const cmd = (event) => ({
5283
5345
  type: "command",
5284
5346
  command: `bun run "${hookClient}" ${event}`,
@@ -5286,7 +5348,11 @@ function renderSettings(hookClient, kind) {
5286
5348
  });
5287
5349
  return {
5288
5350
  permissions: {
5289
- allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...sessionBashAllowRules(kind)],
5351
+ allow: [
5352
+ ...RUNCASTLE_MCP_ALLOW_RULES,
5353
+ ...sessionBashAllowRules(kind),
5354
+ ...skillPackDir ? [skillPackReadRule(skillPackDir)] : []
5355
+ ],
5290
5356
  deny: entrySkillDenyRules(kind)
5291
5357
  },
5292
5358
  hooks: {
@@ -5347,15 +5413,15 @@ function renderSessionPrompt(input) {
5347
5413
  return renderProjectPrompt(projectBrief, runtime);
5348
5414
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
5349
5415
  }
5350
- async function writeSessionArtifacts(input) {
5416
+ async function writeSessionArtifacts(input, pluginDir = resolvePluginDir()) {
5351
5417
  const { session, config: config2 } = input;
5352
5418
  const dir = sessionDir(session.id);
5353
5419
  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");
5420
+ const systemPromptPath = join8(dir, "system-prompt.md");
5421
+ const settingsPath = join8(dir, "settings.json");
5422
+ const mcpConfigPath = join8(dir, "mcp.json");
5357
5423
  writeFileSync2(systemPromptPath, renderSessionPrompt(input), "utf8");
5358
- writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
5424
+ writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind, pluginDir), null, 2), "utf8");
5359
5425
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
5360
5426
  return { systemPromptPath, settingsPath, mcpConfigPath };
5361
5427
  }
@@ -5367,6 +5433,7 @@ var init_artifacts = __esm(() => {
5367
5433
  init_asset_paths();
5368
5434
  init_edit_guard();
5369
5435
  init_install_guard();
5436
+ init_skills_root();
5370
5437
  init_skills();
5371
5438
  RUNCASTLE_MCP_ALLOW_RULES = [
5372
5439
  "mcp__runcastle__get_feature_context",
@@ -5415,64 +5482,6 @@ var init_artifacts = __esm(() => {
5415
5482
  SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
5416
5483
  });
5417
5484
 
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
5485
  // src/launcher/runtimes/types.ts
5477
5486
  function assertHeadlessKickoff(headless, line) {
5478
5487
  if (headless && !line)
@@ -5543,11 +5552,12 @@ var init_claude = __esm(() => {
5543
5552
  };
5544
5553
  },
5545
5554
  async writeArtifacts(input) {
5546
- const files = await writeSessionArtifacts({ ...input, runtime: this.id });
5555
+ const pluginDir = resolvePluginDir();
5556
+ const files = await writeSessionArtifacts({ ...input, runtime: this.id }, pluginDir);
5547
5557
  return {
5548
5558
  files: [files.systemPromptPath, files.settingsPath, files.mcpConfigPath],
5549
5559
  argv: buildClaudeArgs({
5550
- pluginDir: resolvePluginDir(),
5560
+ pluginDir,
5551
5561
  settingsPath: files.settingsPath,
5552
5562
  mcpConfigPath: files.mcpConfigPath,
5553
5563
  systemPromptPath: files.systemPromptPath,
@@ -62836,6 +62846,24 @@ async function commitDocsCheckpoint(ctx, session, summary) {
62836
62846
  }
62837
62847
  await landChatCommits(ctx, project3, feature);
62838
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
+ }
62839
62867
  function requireProject(ctx, session) {
62840
62868
  if (!session.projectId) {
62841
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.");
@@ -63576,7 +63604,7 @@ function buildMcpServer(audience) {
63576
63604
  if (!rs)
63577
63605
  return noSession();
63578
63606
  const result = toolEmitTickets(rs.ctx, rs.session, args2);
63579
- await commitDocsCheckpoint(rs.ctx, rs.session, `tickets emitted (${result.stored})`);
63607
+ checkpointDocsAfterReply(rs.ctx, rs.session, `tickets emitted (${result.stored})`);
63580
63608
  return ok(result);
63581
63609
  });
63582
63610
  }
@@ -63598,7 +63626,7 @@ function buildMcpServer(audience) {
63598
63626
  if (!rs)
63599
63627
  return noSession();
63600
63628
  const result = toolUpdateTicket(rs.ctx, rs.session, args2);
63601
- await commitDocsCheckpoint(rs.ctx, rs.session, `ticket ${result.ticket.seq} updated`);
63629
+ checkpointDocsAfterReply(rs.ctx, rs.session, `ticket ${result.ticket.seq} updated`);
63602
63630
  return ok(result);
63603
63631
  });
63604
63632
  }
@@ -63615,7 +63643,7 @@ function buildMcpServer(audience) {
63615
63643
  if (!rs)
63616
63644
  return noSession();
63617
63645
  const result = toolCancelTicket(rs.ctx, rs.session, args2);
63618
- await commitDocsCheckpoint(rs.ctx, rs.session, `ticket ${result.ticket.seq} cancelled`);
63646
+ checkpointDocsAfterReply(rs.ctx, rs.session, `ticket ${result.ticket.seq} cancelled`);
63619
63647
  return ok(result);
63620
63648
  });
63621
63649
  }
@@ -63659,7 +63687,7 @@ function buildMcpServer(audience) {
63659
63687
  return noSession();
63660
63688
  const result = toolCompletePhase(rs.ctx, rs.session, args2);
63661
63689
  if (result.ok) {
63662
- await commitDocsCheckpoint(rs.ctx, rs.session, `phase '${args2.phase}' complete`);
63690
+ checkpointDocsAfterReply(rs.ctx, rs.session, `phase '${args2.phase}' complete`);
63663
63691
  }
63664
63692
  return ok(result);
63665
63693
  });
@@ -63692,7 +63720,7 @@ async function readMcpBody(c) {
63692
63720
  return;
63693
63721
  }
63694
63722
  }
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;
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;
63696
63724
  var init_server = __esm(() => {
63697
63725
  init_src();
63698
63726
  init_paths();
@@ -63711,6 +63739,7 @@ var init_server = __esm(() => {
63711
63739
  init_review_findings();
63712
63740
  init_git();
63713
63741
  init_knowledge();
63742
+ init_landing_queue();
63714
63743
  init_planning();
63715
63744
  init_project_notes();
63716
63745
  init_repo();
@@ -63735,6 +63764,7 @@ var init_server = __esm(() => {
63735
63764
  });
63736
63765
  }
63737
63766
  });
63767
+ docsCheckpointQueues = new Map;
63738
63768
  DRAFTING_KINDS = ["chat"];
63739
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/.";
63740
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 }).";
@@ -65973,8 +66003,8 @@ var init_transcripts = __esm(() => {
65973
66003
 
65974
66004
  // src/services/conversations.ts
65975
66005
  function withoutKickoff(turns, kind, runtime5) {
65976
- const kickoff = kickoffLineFor(kind, undefined, runtime5);
65977
- 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))));
65978
66008
  }
65979
66009
  function promptMatchesKickoff(line, prompt) {
65980
66010
  if (!prompt)
@@ -66068,6 +66098,7 @@ var TITLE_MAX = 60, MATCH_PREFIX = 40, INTERRUPTED = "[Request interrupted by us
66068
66098
  var init_conversations = __esm(() => {
66069
66099
  init_src();
66070
66100
  init_sessions();
66101
+ init_skills();
66071
66102
  init_transcripts();
66072
66103
  });
66073
66104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.4.6",
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",
@@ -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.)