@open-tomato/rafa 0.9.1 → 0.10.0

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/README.md CHANGED
@@ -54,6 +54,8 @@ Read this once; rafa also says it the first time you start a run.
54
54
  | `rafa skill backfill --propose` | 🪙 one session per batch of skills; without `--propose`, nothing |
55
55
  | `rafa next` | 🪙 when the step it runs is one of the above; it asks before each step |
56
56
 
57
+ The same 🪙 marks appear in `rafa --help` at all levels and in `rafa describe` output.
58
+
57
59
  Everything else reads files, git and GitHub and spends nothing,
58
60
  `rafa usage` included (it reads `CLAUDE_USAGE_PERCENT` and asks
59
61
  nobody). `rafa loop resume` starts no session itself, but it lets a
@@ -330,6 +332,7 @@ built. A box is ticked by the change that finishes the feature.
330
332
  - [x] Start a plan from the main branch and rafa makes the branch for you
331
333
  - [x] One command takes you to the next step: merge, clean up, plan,
332
334
  branch, start
335
+ - [x] Every command that spends Claude usage says so in its help
333
336
  - [ ] The right skills reach the right task, chosen when the plan is
334
337
  written
335
338
  - [ ] Know which skills earn their place and which are ignored
package/dist/SKILL.md CHANGED
@@ -471,6 +471,27 @@ The report rules in this skill restate `src/report/parse.ts`, which is the autho
471
471
 
472
472
  ---
473
473
 
474
+ ## Command-development rule: Claude session declaration
475
+
476
+ When planning work on rafa commands: every command that can start a Claude
477
+ Code session must declare its `spends` field on its `RafaCommand` definition.
478
+ The field tells the help system, `describe` output, and the spawn guard in
479
+ `src/utils/claude.ts` what Claude usage the command declares. It is optional,
480
+ and takes one of four shapes:
481
+
482
+ - `{ when: 'always'; what: string }` — the command always starts a session
483
+ - `{ when: 'with'; flag: string; what: string }` — starts a session only when
484
+ the named flag is passed (flag written as `--resolve`, matched by name)
485
+ - `{ when: 'unless'; flag: string; what: string }` — starts a session unless
486
+ the named flag is passed
487
+ - `{ when: 'through'; what: string }` — may start a session through a
488
+ sub-command it calls
489
+
490
+ The `what` field is a short phrase describing the session (e.g., "one planning
491
+ session"). See `context/cli.md` for the command architecture and examples.
492
+
493
+ ---
494
+
474
495
  ## Examples
475
496
 
476
497
  Each module task below carries its own unit tests, and each stage closes on a test task over what its modules do together.
package/dist/cli.js CHANGED
@@ -877,6 +877,47 @@ function resolveScope(start, seams) {
877
877
  return { found: true, root: walk.root, home, project: scopeAt(walk.root), user };
878
878
  }
879
879
 
880
+ // src/cli/spends.ts
881
+ var SPENDS_GLYPH = "\uD83E\uDE99";
882
+ var SPENDS_WHEN = ["always", "with", "unless", "through"];
883
+ var FLAGGED = ["with", "unless"];
884
+ var FLAG_SPELLING = /^--[^\s-]\S*$/;
885
+ function flagProblem(when, flag2) {
886
+ if (FLAGGED.includes(when)) {
887
+ return typeof flag2 === "string" && FLAG_SPELLING.test(flag2) ? null : `spends.flag is ${describeValue(flag2)}, expected a flag as typed, starting with --`;
888
+ }
889
+ return flag2 === undefined ? null : `spends.flag is ${describeValue(flag2)}, expected absent when spends.when is "${when}"`;
890
+ }
891
+ function spendsProblem(value) {
892
+ if (value === undefined)
893
+ return null;
894
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
895
+ return `spends is ${describeValue(value)}, expected absent or a mapping`;
896
+ }
897
+ const { when, what, flag: flag2 } = value;
898
+ if (!SPENDS_WHEN.includes(when)) {
899
+ return `spends.when is ${describeValue(when)}, expected one of ${SPENDS_WHEN.join(", ")}`;
900
+ }
901
+ if (typeof what !== "string") {
902
+ return `spends.what is ${describeValue(what)}, expected a string`;
903
+ }
904
+ return flagProblem(when, flag2);
905
+ }
906
+ function spendsCondition(spend) {
907
+ switch (spend.when) {
908
+ case "with":
909
+ case "unless":
910
+ return `${spend.when} ${spend.flag}`;
911
+ case "always":
912
+ case "through":
913
+ return null;
914
+ }
915
+ }
916
+ function spendsMark(spend) {
917
+ const condition = spendsCondition(spend);
918
+ return condition === null ? SPENDS_GLYPH : `${SPENDS_GLYPH} ${condition}`;
919
+ }
920
+
880
921
  // src/cli/command.ts
881
922
  var COMMAND_OUTPUTS = ["text", "json", "tui"];
882
923
  var MAX_EXIT_CODE = 255;
@@ -955,7 +996,7 @@ function commandProblem(value) {
955
996
  return `a command is ${describeValue(value)}, expected a mapping`;
956
997
  }
957
998
  const fields = value;
958
- const problem = fieldProblem(fields);
999
+ const problem = fieldProblem(fields) ?? spendsProblem(fields.spends);
959
1000
  if (problem === null)
960
1001
  return null;
961
1002
  const named = isRoutingWord(fields.subject) && isRoutingWord(fields.action) ? `command "${commandSpelling(fields)}"` : "a command";
@@ -1532,9 +1573,23 @@ function routeLine(registry, argv) {
1532
1573
  };
1533
1574
  }
1534
1575
 
1576
+ // src/cli/running.ts
1577
+ var running = null;
1578
+ function setRunningCommand(command, flags) {
1579
+ const previous = running;
1580
+ running = Object.freeze({ command, flags: Object.freeze({ ...flags }) });
1581
+ return previous;
1582
+ }
1583
+ function runningCommand() {
1584
+ return running;
1585
+ }
1586
+ function restoreRunningCommand(previous) {
1587
+ running = previous;
1588
+ }
1589
+
1535
1590
  // package.json
1536
1591
  var name = "@open-tomato/rafa";
1537
- var version = "0.9.1";
1592
+ var version = "0.10.0";
1538
1593
 
1539
1594
  // src/cli/version.ts
1540
1595
  var RAFA_VERSION = version;
@@ -1674,6 +1729,7 @@ async function runCommand(route, base, registry, project, settings) {
1674
1729
  const previous = activeOutput();
1675
1730
  const previousMode = activeOutputMode();
1676
1731
  setActiveOutput(guarded.output, base.outputMode);
1732
+ const previousRunning = setRunningCommand(route.command, base.flags);
1677
1733
  try {
1678
1734
  await route.command.run(context);
1679
1735
  return success(guarded.payload());
@@ -1698,6 +1754,7 @@ async function runCommand(route, base, registry, project, settings) {
1698
1754
  stderrLine: error.message === "" ? null : error.message
1699
1755
  };
1700
1756
  } finally {
1757
+ restoreRunningCommand(previousRunning);
1701
1758
  setActiveOutput(previous, previousMode);
1702
1759
  }
1703
1760
  }
@@ -1800,15 +1857,19 @@ var GLOBAL_FLAGS = Object.freeze([
1800
1857
  { spelling: "-v, --verbose", note: "repeat for more, up to 3; --verbose=N (also RAFA_VERBOSITY=N)" },
1801
1858
  { spelling: "--version", note: 'print "rafa <version>" and exit; typed alone, no short form' }
1802
1859
  ]);
1860
+ var SPENDS_LEGEND = `${SPENDS_GLYPH} starts Claude Code sessions, which spend your Claude usage`;
1803
1861
  var INDENT = " ";
1804
1862
  var NOTE_INDENT = " ";
1805
1863
  var USAGE_CONTINUATION = " ";
1806
1864
  var GAP = 3;
1807
1865
  var MIN_TEXT_WIDTH = 20;
1808
- function pack(text2, first, rest) {
1866
+ function wordsOf(text2) {
1867
+ return text2.split(/\s+/).filter((part) => part !== "");
1868
+ }
1869
+ function pack(words, first, rest) {
1809
1870
  const lines = [];
1810
1871
  let line = "";
1811
- for (const word of text2.split(/\s+/).filter((part) => part !== "")) {
1872
+ for (const word of words) {
1812
1873
  const width = lines.length === 0 ? first : rest;
1813
1874
  if (line === "") {
1814
1875
  line = word;
@@ -1821,17 +1882,38 @@ function pack(text2, first, rest) {
1821
1882
  }
1822
1883
  return line === "" ? lines : [...lines, line];
1823
1884
  }
1824
- function hang(prefix, text2, indent = prefix) {
1885
+ function hangWords(prefix, words, indent = prefix) {
1825
1886
  const width = (used) => Math.max(HELP_WIDTH - used.length, MIN_TEXT_WIDTH);
1826
- return pack(text2, width(prefix), width(indent)).map((line, index) => index === 0 ? `${prefix}${line}` : `${indent}${line}`);
1887
+ return pack(words, width(prefix), width(indent)).map((line, index) => index === 0 ? `${prefix}${line}` : `${indent}${line}`);
1888
+ }
1889
+ function hang(prefix, text2, indent = prefix) {
1890
+ return hangWords(prefix, wordsOf(text2), indent);
1827
1891
  }
1828
1892
  function columnOf(rows) {
1829
1893
  const lefts = rows.filter(([, right]) => right !== "").map(([left]) => left.length);
1830
1894
  return INDENT.length + Math.max(0, ...lefts) + GAP;
1831
1895
  }
1832
- function row([left, right], column) {
1896
+ function row([left, right, mark = null], column) {
1833
1897
  const head = `${INDENT}${left}`;
1834
- return right === "" ? [head] : hang(head.padEnd(column), right, " ".repeat(column));
1898
+ const words = mark === null ? wordsOf(right) : [...wordsOf(right), mark];
1899
+ return words.length === 0 ? [head] : hangWords(head.padEnd(column), words, " ".repeat(column));
1900
+ }
1901
+ function markOf(command) {
1902
+ return command.spends === undefined ? null : spendsMark(command.spends);
1903
+ }
1904
+ function subjectMark(subject, registry) {
1905
+ return registry.actionsOf(subject.name).some((action) => action.spends !== undefined) ? SPENDS_GLYPH : null;
1906
+ }
1907
+ function commandWord(command) {
1908
+ const mark = markOf(command);
1909
+ return mark === null ? command.subject : `${command.subject} ${mark}`;
1910
+ }
1911
+ function spendsLines(command) {
1912
+ const { spends } = command;
1913
+ if (spends === undefined)
1914
+ return [];
1915
+ const mark = spendsCondition(spends) === null ? SPENDS_GLYPH : `${spendsMark(spends)}:`;
1916
+ return hangWords(INDENT, [mark, ...wordsOf(spends.what)]);
1835
1917
  }
1836
1918
  function rows(entries) {
1837
1919
  const column = columnOf(entries);
@@ -1879,9 +1961,10 @@ function renderRoot(registry) {
1879
1961
  ["rafa <subject> <action> --help", "arguments, flags, examples"]
1880
1962
  ])),
1881
1963
  block("Quick start", quickStart.map((example) => `${INDENT}${example.cmd}`)),
1882
- block("Subjects", rows(subjects.map((subject) => [subject.name, subject.summary]))),
1883
- block("Commands", hang(INDENT, topLevel.map((command) => command.subject).join(", "))),
1884
- block("Global flags", rows(GLOBAL_FLAGS.map((flag2) => [flag2.spelling, flag2.note])))
1964
+ block("Subjects", rows(subjects.map((subject) => [subject.name, subject.summary, subjectMark(subject, registry)]))),
1965
+ block("Commands", hangWords(INDENT, topLevel.map((command, index) => index === topLevel.length - 1 ? commandWord(command) : `${commandWord(command)},`))),
1966
+ block("Global flags", rows(GLOBAL_FLAGS.map((flag2) => [flag2.spelling, flag2.note]))),
1967
+ registry.commands().some((command) => command.spends !== undefined) ? [SPENDS_LEGEND] : []
1885
1968
  ]);
1886
1969
  }
1887
1970
  function examplesAcross(actions, count) {
@@ -1898,7 +1981,7 @@ function renderSubject(subject, registry) {
1898
1981
  [`${spelling} <action> [args] [flags]`, ""],
1899
1982
  [`${spelling} <action> --help`, "arguments, flags, examples"]
1900
1983
  ])),
1901
- block("Actions", rows(actions.map((action) => [action.action, action.summary]))),
1984
+ block("Actions", rows(actions.map((action) => [action.action, action.summary, markOf(action)]))),
1902
1985
  block("Examples", examplesAcross(actions, SUBJECT_EXAMPLES).flatMap(exampleLines))
1903
1986
  ]);
1904
1987
  }
@@ -1948,6 +2031,7 @@ function renderAction(request, registry) {
1948
2031
  block("Deprecated", deprecated === undefined ? [] : hang(INDENT, `since ${deprecated.since}; use "rafa ${deprecated.use}"`)),
1949
2032
  block("Usage", hang(INDENT, usage, USAGE_CONTINUATION)),
1950
2033
  block("Description", hang(INDENT, command.description)),
2034
+ block("Spends", spendsLines(command)),
1951
2035
  block("Arguments", table(command.args.map(argRow))),
1952
2036
  block("Flags", table(command.flags.map(flagRow))),
1953
2037
  block("Examples", command.examples.flatMap(exampleLines)),
@@ -3076,7 +3160,7 @@ function typedAlias(alias) {
3076
3160
  return alias.trim().split(/\s+/).join(" ");
3077
3161
  }
3078
3162
  function describeCommand(command, name2, module) {
3079
- const { deprecated } = command;
3163
+ const { deprecated, spends } = command;
3080
3164
  return {
3081
3165
  name: name2,
3082
3166
  summary: command.summary,
@@ -3087,7 +3171,8 @@ function describeCommand(command, name2, module) {
3087
3171
  outputs: [...command.outputs],
3088
3172
  aliases: module === null ? (command.aliases ?? []).map(typedAlias) : [],
3089
3173
  deprecated: deprecated === undefined ? null : { since: deprecated.since, use: deprecated.use },
3090
- module
3174
+ module,
3175
+ spends: spends === undefined ? null : { ...spends }
3091
3176
  };
3092
3177
  }
3093
3178
  function mountedThrough(exec, registry) {
@@ -3553,7 +3638,7 @@ function requireIssueNumber(issue) {
3553
3638
  }
3554
3639
  return issue;
3555
3640
  }
3556
- function wordsOf(title2) {
3641
+ function wordsOf2(title2) {
3557
3642
  return title2.toLowerCase().replace(NON_WORD, " ").split(" ").filter((word) => word !== "");
3558
3643
  }
3559
3644
  function withoutStopWords(words) {
@@ -3563,8 +3648,8 @@ function withoutStopWords(words) {
3563
3648
  function slugFromTitle(title2) {
3564
3649
  const withoutId = title2.replace(LEADING_ID, "");
3565
3650
  const [firstClause = ""] = withoutId.split(CLAUSE_BREAK);
3566
- const whole = withoutStopWords(wordsOf(withoutId));
3567
- const clause = withoutStopWords(wordsOf(firstClause));
3651
+ const whole = withoutStopWords(wordsOf2(withoutId));
3652
+ const clause = withoutStopWords(wordsOf2(firstClause));
3568
3653
  const chosen = clause.length >= MIN_SLUG_WORDS ? clause : whole;
3569
3654
  if (chosen.length === 0)
3570
3655
  return FALLBACK_SLUG;
@@ -8048,7 +8133,7 @@ function payloadProblem(payload) {
8048
8133
  function heldInstinctProblem(value) {
8049
8134
  return fieldsProblem(value, HELD_INSTINCT_CHECKS, "the line") ?? fieldsProblem(value.instinct, INSTINCT_CHECKS, "the instinct");
8050
8135
  }
8051
- function flagProblem(value) {
8136
+ function flagProblem2(value) {
8052
8137
  return fieldsProblem(value, FLAG_CHECKS, "the flag");
8053
8138
  }
8054
8139
  function copyInstinct(record) {
@@ -8127,7 +8212,7 @@ function createLocalLearning(options) {
8127
8212
  async function flaggedIds() {
8128
8213
  const lines = numberedLines(await readIfPresent2(flagsPath));
8129
8214
  return new Set(lines.map(([number, line]) => {
8130
- const reading2 = readLine2(line, flagProblem);
8215
+ const reading2 = readLine2(line, flagProblem2);
8131
8216
  if (reading2.ok)
8132
8217
  return reading2.value.id;
8133
8218
  throw new Error(`${PREFIX7}: line ${number} of ${flagsPath} is not a flag,` + ` and a bundle skipping it could hold an instinct it flags: ${reading2.problem}`);
@@ -8161,7 +8246,7 @@ function createLocalLearning(options) {
8161
8246
  },
8162
8247
  flag: async (id, reason) => {
8163
8248
  const flag2 = { id, reason, flagged_at: now() };
8164
- const problem = flagProblem(flag2);
8249
+ const problem = flagProblem2(flag2);
8165
8250
  if (problem !== null) {
8166
8251
  throw new TypeError(`${PREFIX7}: refused to write an invalid flag: ${problem}`);
8167
8252
  }
@@ -8394,9 +8479,44 @@ function claudeSessionEnv() {
8394
8479
  CLAUDE_CODE_ENTRYPOINT: "cli"
8395
8480
  };
8396
8481
  }
8482
+
8483
+ class UndeclaredSpendError extends Error {
8484
+ name = "UndeclaredSpendError";
8485
+ }
8486
+ function carriesFlag(flags, flag2) {
8487
+ const name2 = flag2.replace(/^--/, "");
8488
+ const value = flags[name2];
8489
+ if (value !== undefined && value !== false)
8490
+ return true;
8491
+ return name2.startsWith("no-") && flags[name2.slice(3)] === false;
8492
+ }
8493
+ function spendRefusal(running2) {
8494
+ if (running2 === null)
8495
+ return null;
8496
+ const { command, flags } = running2;
8497
+ const spend = command.spends;
8498
+ const named = `rafa ${commandSpelling(command)}`;
8499
+ const remedy = "declare spends on the command to cover this run";
8500
+ if (spend === undefined) {
8501
+ return `${named} started a Claude session but declares no spends: ${remedy}`;
8502
+ }
8503
+ if (spend.when === "with" && !carriesFlag(flags, spend.flag)) {
8504
+ return `${named} started a Claude session without ${spend.flag}, and its spends covers only a run with ${spend.flag}: ${remedy}`;
8505
+ }
8506
+ if (spend.when === "unless" && carriesFlag(flags, spend.flag)) {
8507
+ return `${named} started a Claude session with ${spend.flag}, and its spends covers no run with ${spend.flag}: ${remedy}`;
8508
+ }
8509
+ return null;
8510
+ }
8511
+ function guardSpend() {
8512
+ const refusal3 = spendRefusal(runningCommand());
8513
+ if (refusal3 !== null)
8514
+ throw new UndeclaredSpendError(refusal3);
8515
+ }
8397
8516
  async function spawnClaude(args, prompt) {
8517
+ guardSpend();
8398
8518
  if (activeOutputMode() === "json") {
8399
- const { exitCode } = await spawnClaudeCaptured(args, prompt);
8519
+ const { exitCode } = await spawnCaptured(args, prompt);
8400
8520
  return exitCode;
8401
8521
  }
8402
8522
  const proc = Bun.spawn([CLAUDE_BIN, ...args], {
@@ -8448,6 +8568,10 @@ async function teeToOperator(stream) {
8448
8568
  return text2 + tail;
8449
8569
  }
8450
8570
  async function spawnClaudeCaptured(args, prompt) {
8571
+ guardSpend();
8572
+ return spawnCaptured(args, prompt);
8573
+ }
8574
+ async function spawnCaptured(args, prompt) {
8451
8575
  const proc = Bun.spawn([CLAUDE_BIN, ...args], {
8452
8576
  stdin: new TextEncoder().encode(prompt),
8453
8577
  stdout: "pipe",
@@ -16390,13 +16514,18 @@ function openNextSources(context, seams = DEFAULT_NEXT_SOURCE_SEAMS) {
16390
16514
  const git = (seams.openGit ?? createGitRunner)(project.root);
16391
16515
  const gh = (seams.openGh ?? ((root) => createGhRunner({ cwd: root })))(project.root);
16392
16516
  const loop = resolveLoopSeams({ isAlive: seams.isAlive });
16393
- return Object.freeze({
16517
+ const board = () => ghNextBoard({ gh, git, configured: config.roadmapIssue });
16518
+ const held = {
16394
16519
  base: config.prBase ?? DEFAULT_BASE_BRANCH,
16395
16520
  plans: plansDirAt(project.root, config.planDir),
16396
16521
  runs: () => readRecords(project.root, loop),
16397
16522
  git,
16398
- pulls: (seams.pullRequests ?? ghPullRequestsIn)(project.root),
16399
- board: ghNextBoard({ gh, git, configured: config.roadmapIssue })
16523
+ pulls: (seams.pullRequests ?? ghPullRequestsIn)(project.root)
16524
+ };
16525
+ return Object.freeze({
16526
+ ...held,
16527
+ board: board(),
16528
+ answer: () => Object.freeze({ ...held, board: board() })
16400
16529
  });
16401
16530
  }
16402
16531
 
@@ -18012,16 +18141,16 @@ function conflictLine(repoRoot, conflict) {
18012
18141
  }
18013
18142
  function sessionRefusal(repoRoot, draft, conflicts) {
18014
18143
  const onOtherBranch = conflicts.some((conflict) => conflict.reason === "branch");
18015
- const running = conflicts.some((conflict) => conflict.reason === "live");
18144
+ const running2 = conflicts.some((conflict) => conflict.reason === "live");
18016
18145
  const why = [
18017
18146
  onOtherBranch ? "it already has a branch" : null,
18018
- running ? "a session is already running it" : null
18147
+ running2 ? "a session is already running it" : null
18019
18148
  ].filter((reason) => reason !== null);
18020
18149
  return [
18021
18150
  `\u274C Refusing to run plan \`${draft.planStub ?? draft.plan}\` on \`${draft.branch}\`: ${why.join(", and ")}.`,
18022
18151
  ...conflicts.map((conflict) => conflictLine(repoRoot, conflict)),
18023
18152
  ...onOtherBranch ? [" A plan runs on one branch: check out that branch to run it there, or revise the plan under a new stub."] : [],
18024
- ...running ? [" One session runs a plan at a time: let that run end, or interrupt it, then run again."] : [],
18153
+ ...running2 ? [" One session runs a plan at a time: let that run end, or interrupt it, then run again."] : [],
18025
18154
  NOTHING_DISPATCHED
18026
18155
  ].join(`
18027
18156
  `);
@@ -21099,7 +21228,11 @@ var wrapped = wrapPhaseZeroCommand({
21099
21228
  }
21100
21229
  ],
21101
21230
  aliases: ["start"],
21102
- outputs: ["text", "json"]
21231
+ outputs: ["text", "json"],
21232
+ spends: {
21233
+ when: "always",
21234
+ what: "one session per task, one for the wrap-up, and repair sessions while CI is red"
21235
+ }
21103
21236
  }, start);
21104
21237
  var loopStart = Object.freeze({
21105
21238
  ...wrapped,
@@ -21507,7 +21640,7 @@ function readProvides(raw, label, declared) {
21507
21640
  }
21508
21641
  return all.length > 0 ? failed2(...all) : passed(Object.freeze(Object.fromEntries(provided)));
21509
21642
  }
21510
- function readRange(raw, label, running) {
21643
+ function readRange(raw, label, running2) {
21511
21644
  const read = readText4(raw, label, RANGE_EXPECTED);
21512
21645
  if (read.value === undefined)
21513
21646
  return read;
@@ -21515,7 +21648,7 @@ function readRange(raw, label, running) {
21515
21648
  if (!range.split("||").every((alternative) => ALTERNATIVE_PATTERN.test(alternative))) {
21516
21649
  return failed2(expected(label, raw, RANGE_EXPECTED));
21517
21650
  }
21518
- return Bun.semver.satisfies(running, range) ? passed(range) : failed2(`${label} is ${JSON.stringify(range)}, which the running rafa ${running} does not satisfy`);
21651
+ return Bun.semver.satisfies(running2, range) ? passed(range) : failed2(`${label} is ${JSON.stringify(range)}, which the running rafa ${running2} does not satisfy`);
21519
21652
  }
21520
21653
  function readPorts(raw, label, declared, served) {
21521
21654
  const versions = new Map(Object.entries(served));
@@ -22130,7 +22263,7 @@ async function runNext(context, seams) {
22130
22263
  const prompter = lazyPrompter(openPrompter);
22131
22264
  try {
22132
22265
  return await runNextChain({
22133
- read: () => readNextState(sources),
22266
+ read: () => readNextState(sources.answer()),
22134
22267
  run: (state) => runStateAction(context, sources, state),
22135
22268
  ask: prompter.ask,
22136
22269
  handOver: prompter.close,
@@ -22184,6 +22317,7 @@ function createNextCommand(seams = DEFAULT_NEXT_SEAMS) {
22184
22317
  }
22185
22318
  ],
22186
22319
  outputs: ["text", "json"],
22320
+ spends: { when: "through", what: "when the step it runs is one of the above" },
22187
22321
  run: async (context) => {
22188
22322
  const report2 = await runNext(context, seams);
22189
22323
  if (context.outputMode === "json")
@@ -22622,7 +22756,8 @@ var wrapped2 = wrapPhaseZeroCommand({
22622
22756
  }
22623
22757
  ],
22624
22758
  aliases: ["plan"],
22625
- outputs: ["text", "json"]
22759
+ outputs: ["text", "json"],
22760
+ spends: { when: "always", what: "one planning session" }
22626
22761
  }, plan);
22627
22762
  var create_default2 = endingWith(wrapped2);
22628
22763
 
@@ -24905,9 +25040,9 @@ function alreadyHeadline(at, block2) {
24905
25040
  return `already assessed at ${when}, as ${what}; the head has not moved since`;
24906
25041
  }
24907
25042
  function pendingHeadline(head, rows2, at) {
24908
- const running = rows2.filter((row2) => row2.outcome === "pending").length;
25043
+ const running2 = rows2.filter((row2) => row2.outcome === "pending").length;
24909
25044
  const when = at ?? "an unrecorded time";
24910
- return `the head moved to ${shortHead(head)} and ${checkCount2(running)} of ${rows2.length} ` + `still running, so the triage from ${when} stands`;
25045
+ return `the head moved to ${shortHead(head)} and ${checkCount2(running2)} of ${rows2.length} ` + `still running, so the triage from ${when} stands`;
24911
25046
  }
24912
25047
  function assessHeadline(head, verdict, write) {
24913
25048
  const state = verdict === "none" ? "reports no checks at all" : "is not green";
@@ -26293,6 +26428,7 @@ function createPrTriageCommand(seams = DEFAULT_TRIAGE_SEAMS) {
26293
26428
  }
26294
26429
  ],
26295
26430
  outputs: ["text", "json"],
26431
+ spends: { when: "with", flag: "--resolve", what: "runs a small fixed plan through the loop" },
26296
26432
  run: async (context) => {
26297
26433
  const triaged = await runTriage(context, seams);
26298
26434
  if (context.outputMode === "json") {
@@ -28942,6 +29078,7 @@ function createSkillBackfillCommand(seams = DEFAULT_BACKFILL_SEAMS) {
28942
29078
  }
28943
29079
  ],
28944
29080
  outputs: ["text", "json"],
29081
+ spends: { when: "with", flag: "--propose", what: "one session per batch of skills" },
28945
29082
  run: async (context) => {
28946
29083
  await runBackfill(context, seams);
28947
29084
  }
@@ -4,7 +4,7 @@ import {
4
4
  openNdjsonStore,
5
5
  openSqliteStore,
6
6
  selectEffortStore
7
- } from "../../index-eqsm86n7.js";
7
+ } from "../../index-srdtw7w1.js";
8
8
  import {
9
9
  STORE_BACKENDS
10
10
  } from "../../index-96pz9023.js";
@@ -840,6 +840,33 @@ function parseSpecReview(output) {
840
840
  return judged(verdict, readGaps(document, issues), block, issues);
841
841
  }
842
842
 
843
+ // src/cli/command.ts
844
+ var MAX_EXIT_CODE = 255;
845
+
846
+ class CommandExit extends Error {
847
+ exitCode;
848
+ constructor(exitCode, message = "") {
849
+ super(message);
850
+ if (!Number.isInteger(exitCode) || exitCode < 0 || exitCode > MAX_EXIT_CODE) {
851
+ throw new TypeError(`CommandExit: exit code is ${describeValue(exitCode)}, expected a whole number from 0 to ${MAX_EXIT_CODE}`);
852
+ }
853
+ this.name = "CommandExit";
854
+ this.exitCode = exitCode;
855
+ }
856
+ }
857
+ function isTopLevel(command) {
858
+ return command.subject === command.action;
859
+ }
860
+ function commandSpelling(command) {
861
+ return isTopLevel(command) ? command.subject : `${command.subject} ${command.action}`;
862
+ }
863
+
864
+ // src/cli/running.ts
865
+ var running = null;
866
+ function runningCommand() {
867
+ return running;
868
+ }
869
+
843
870
  // src/utils/claude.ts
844
871
  async function getClaudeUsagePercent() {
845
872
  const envPct = process.env["CLAUDE_USAGE_PERCENT"];
@@ -891,9 +918,44 @@ function claudeSessionEnv() {
891
918
  CLAUDE_CODE_ENTRYPOINT: "cli"
892
919
  };
893
920
  }
921
+
922
+ class UndeclaredSpendError extends Error {
923
+ name = "UndeclaredSpendError";
924
+ }
925
+ function carriesFlag(flags, flag) {
926
+ const name = flag.replace(/^--/, "");
927
+ const value = flags[name];
928
+ if (value !== undefined && value !== false)
929
+ return true;
930
+ return name.startsWith("no-") && flags[name.slice(3)] === false;
931
+ }
932
+ function spendRefusal(running2) {
933
+ if (running2 === null)
934
+ return null;
935
+ const { command, flags } = running2;
936
+ const spend = command.spends;
937
+ const named = `rafa ${commandSpelling(command)}`;
938
+ const remedy = "declare spends on the command to cover this run";
939
+ if (spend === undefined) {
940
+ return `${named} started a Claude session but declares no spends: ${remedy}`;
941
+ }
942
+ if (spend.when === "with" && !carriesFlag(flags, spend.flag)) {
943
+ return `${named} started a Claude session without ${spend.flag}, and its spends covers only a run with ${spend.flag}: ${remedy}`;
944
+ }
945
+ if (spend.when === "unless" && carriesFlag(flags, spend.flag)) {
946
+ return `${named} started a Claude session with ${spend.flag}, and its spends covers no run with ${spend.flag}: ${remedy}`;
947
+ }
948
+ return null;
949
+ }
950
+ function guardSpend() {
951
+ const refusal = spendRefusal(runningCommand());
952
+ if (refusal !== null)
953
+ throw new UndeclaredSpendError(refusal);
954
+ }
894
955
  async function spawnClaude(args, prompt) {
956
+ guardSpend();
895
957
  if (activeOutputMode() === "json") {
896
- const { exitCode } = await spawnClaudeCaptured(args, prompt);
958
+ const { exitCode } = await spawnCaptured(args, prompt);
897
959
  return exitCode;
898
960
  }
899
961
  const proc = Bun.spawn([CLAUDE_BIN, ...args], {
@@ -945,6 +1007,10 @@ async function teeToOperator(stream) {
945
1007
  return text + tail;
946
1008
  }
947
1009
  async function spawnClaudeCaptured(args, prompt) {
1010
+ guardSpend();
1011
+ return spawnCaptured(args, prompt);
1012
+ }
1013
+ async function spawnCaptured(args, prompt) {
948
1014
  const proc = Bun.spawn([CLAUDE_BIN, ...args], {
949
1015
  stdin: new TextEncoder().encode(prompt),
950
1016
  stdout: "pipe",
@@ -1771,4 +1837,4 @@ function selectEffortStore(repoRoot, config) {
1771
1837
  return adapter.create({ repoRoot });
1772
1838
  }
1773
1839
 
1774
- export { EFFORT_KEY_PROJECTIONS, openNdjsonStore, sqliteStorePath, LONE_SURROGATE, withSqliteStore, writeSqliteStore, openSqliteStore, getClaudeUsagePercent, checkUsage, interruptClaudeSessions, runClaude, runClaudeCaptured, planFilePath, ClaudePlannerError, createGhRunner, createLocalTracker, PORT_VERSIONS, createAdapterRegistry, CORE_ADAPTER_REGISTRY, selectEffortStore };
1840
+ export { EFFORT_KEY_PROJECTIONS, openNdjsonStore, sqliteStorePath, LONE_SURROGATE, withSqliteStore, writeSqliteStore, openSqliteStore, CommandExit, commandSpelling, getClaudeUsagePercent, checkUsage, interruptClaudeSessions, runClaude, runClaudeCaptured, planFilePath, ClaudePlannerError, createGhRunner, createLocalTracker, PORT_VERSIONS, createAdapterRegistry, CORE_ADAPTER_REGISTRY, selectEffortStore };
package/dist/index.js CHANGED
@@ -30,10 +30,12 @@ import {
30
30
  import {
31
31
  CORE_ADAPTER_REGISTRY,
32
32
  ClaudePlannerError,
33
+ CommandExit,
33
34
  EFFORT_KEY_PROJECTIONS,
34
35
  LONE_SURROGATE,
35
36
  PORT_VERSIONS,
36
37
  checkUsage,
38
+ commandSpelling,
37
39
  createAdapterRegistry,
38
40
  createGhRunner,
39
41
  createLocalTracker,
@@ -48,7 +50,7 @@ import {
48
50
  sqliteStorePath,
49
51
  withSqliteStore,
50
52
  writeSqliteStore
51
- } from "./index-eqsm86n7.js";
53
+ } from "./index-srdtw7w1.js";
52
54
  import {
53
55
  BUDGET_FLAG,
54
56
  CLAUDE_SETTING_SOURCES,
@@ -132,27 +134,6 @@ import { existsSync as existsSync2, readdirSync, statSync } from "fs";
132
134
  import { homedir } from "os";
133
135
  import { join, resolve } from "path";
134
136
 
135
- // src/cli/command.ts
136
- var MAX_EXIT_CODE = 255;
137
-
138
- class CommandExit extends Error {
139
- exitCode;
140
- constructor(exitCode, message = "") {
141
- super(message);
142
- if (!Number.isInteger(exitCode) || exitCode < 0 || exitCode > MAX_EXIT_CODE) {
143
- throw new TypeError(`CommandExit: exit code is ${describeValue(exitCode)}, expected a whole number from 0 to ${MAX_EXIT_CODE}`);
144
- }
145
- this.name = "CommandExit";
146
- this.exitCode = exitCode;
147
- }
148
- }
149
- function isTopLevel(command) {
150
- return command.subject === command.action;
151
- }
152
- function commandSpelling(command) {
153
- return isTopLevel(command) ? command.subject : `${command.subject} ${command.action}`;
154
- }
155
-
156
137
  // src/effort/session-log.ts
157
138
  import { createReadStream } from "fs";
158
139
  import { basename } from "path";
@@ -1914,7 +1895,7 @@ async function report(args, repoRoot) {
1914
1895
  }
1915
1896
  }
1916
1897
  // package.json
1917
- var version = "0.9.1";
1898
+ var version = "0.10.0";
1918
1899
 
1919
1900
  // src/modules/manifest.ts
1920
1901
  var MANIFEST_VERSION = 1;
@@ -6006,13 +5987,18 @@ function openNextSources(context, seams = DEFAULT_NEXT_SOURCE_SEAMS) {
6006
5987
  const git = (seams.openGit ?? createGitRunner)(project.root);
6007
5988
  const gh = (seams.openGh ?? ((root) => createGhRunner({ cwd: root })))(project.root);
6008
5989
  const loop = resolveLoopSeams({ isAlive: seams.isAlive });
6009
- return Object.freeze({
5990
+ const board = () => ghNextBoard({ gh, git, configured: config.roadmapIssue });
5991
+ const held = {
6010
5992
  base: config.prBase ?? DEFAULT_BASE_BRANCH,
6011
5993
  plans: plansDirAt(project.root, config.planDir),
6012
5994
  runs: () => readRecords(project.root, loop),
6013
5995
  git,
6014
- pulls: (seams.pullRequests ?? ghPullRequestsIn)(project.root),
6015
- board: ghNextBoard({ gh, git, configured: config.roadmapIssue })
5996
+ pulls: (seams.pullRequests ?? ghPullRequestsIn)(project.root)
5997
+ };
5998
+ return Object.freeze({
5999
+ ...held,
6000
+ board: board(),
6001
+ answer: () => Object.freeze({ ...held, board: board() })
6016
6002
  });
6017
6003
  }
6018
6004
 
@@ -20,6 +20,10 @@ The slug summarizes what the user gets, using two to four words joined by
20
20
  hyphens. The order of work lives in ONE place: the pinned "Roadmap" issue
21
21
  on the `open-tomato/rafa` board.
22
22
 
23
+ * Commands that start Claude sessions must declare `spends` on their
24
+ `RafaCommand` — see the dev-planner skill's "Command-development rule" for
25
+ guidance.
26
+
23
27
  * Before planning, review the spec as written, and report that review at the
24
28
  END of your final message as a `rafa:spec-review` block carrying
25
29
  `verdict: ready` or `verdict: not-ready`, with empty `gaps:` for ready, or a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-tomato/rafa",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Alpha. Runs a plan through the ralph loop: one Claude Code session per task, one commit per task, then a pull request and a CI wait.",
5
5
  "private": false,
6
6
  "publishConfig": {