@joshuafolkken/kit 1.1136.0 → 1.1178.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.
Files changed (200) hide show
  1. package/.claude/settings.json +4 -4
  2. package/.claude/skills/epic-commands/SKILL.md +27 -239
  3. package/.claude/skills/epic-commands/epic-bundle.md +115 -0
  4. package/.claude/skills/epic-commands/execution-waves.md +126 -0
  5. package/.claude/skills/workflow-commands/SKILL.md +48 -194
  6. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  7. package/.claude/skills/workflow-commands/backlogrun-child.md +12 -123
  8. package/.claude/skills/workflow-commands/backlogrun-lanes.md +14 -63
  9. package/.claude/skills/workflow-commands/backlogrun-park.md +16 -15
  10. package/.claude/skills/workflow-commands/backlogrun-progress.md +17 -173
  11. package/.claude/skills/workflow-commands/backlogrun-recovery.md +155 -0
  12. package/.claude/skills/workflow-commands/backlogrun-steps.md +65 -144
  13. package/.claude/skills/workflow-commands/backlogrun.md +5 -4
  14. package/.claude/skills/workflow-commands/chain-rule.md +3 -2
  15. package/.claude/skills/workflow-commands/delegation.md +2 -2
  16. package/.claude/skills/workflow-commands/entry-sequence.md +62 -0
  17. package/.claude/skills/workflow-commands/followup.md +104 -181
  18. package/.claude/skills/workflow-commands/fullrun-steps.md +8 -9
  19. package/.claude/skills/workflow-commands/fullrun.md +16 -73
  20. package/.claude/skills/workflow-commands/halfrun.md +10 -36
  21. package/.claude/skills/workflow-commands/into-target.md +4 -4
  22. package/.claude/skills/workflow-commands/issue-comments.md +7 -8
  23. package/.claude/skills/workflow-commands/issue-scout.md +6 -6
  24. package/.claude/skills/workflow-commands/kickoff.md +6 -6
  25. package/.claude/skills/workflow-commands/needs-human-review.md +6 -6
  26. package/.claude/skills/workflow-commands/observation-filing.md +21 -172
  27. package/.claude/skills/workflow-commands/observation-ledger.md +150 -0
  28. package/.claude/skills/workflow-commands/pre-gate-cut.md +35 -95
  29. package/.claude/skills/workflow-commands/prerequisite.md +6 -6
  30. package/.claude/skills/workflow-commands/progress-watcher.md +90 -0
  31. package/.claude/skills/workflow-commands/prrun.md +5 -5
  32. package/.claude/skills/workflow-commands/retrospective.md +9 -9
  33. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  34. package/.claude/skills/workflow-commands/target-repository.md +2 -2
  35. package/.claude/skills/workflow-commands/working-tree-hold.md +7 -5
  36. package/.codex/hooks.json +4 -4
  37. package/.github/workflows/ci.yml +15 -2
  38. package/AGENTS.md +1 -6
  39. package/CLAUDE.md +58 -120
  40. package/GEMINI.md +1 -6
  41. package/dist/CLAUDE.md +58 -120
  42. package/dist/hooks/batch-guard.js +4 -4
  43. package/dist/hooks/{chunk-NFZGFGOR.js → chunk-32GQTL4F.js} +14 -1
  44. package/dist/hooks/{chunk-HBKZLITF.js → chunk-DO6NWWHU.js} +4 -4
  45. package/dist/hooks/{chunk-KBXZLMRH.js → chunk-G6KRCKEC.js} +216 -76
  46. package/dist/hooks/{chunk-JKKTVDAG.js → chunk-GDS5N2KB.js} +186 -309
  47. package/dist/hooks/chunk-I4XSL4TT.js +56 -0
  48. package/dist/hooks/{chunk-GDU26K3J.js → chunk-S6X6WYKR.js} +886 -174
  49. package/dist/hooks/{chunk-FO2I7UT7.js → chunk-UAGLLIGZ.js} +1 -1
  50. package/dist/hooks/{chunk-RFWWUI5F.js → chunk-VESKRQOF.js} +3 -3
  51. package/dist/hooks/{chunk-KOKOAV6D.js → chunk-VQLLNFNT.js} +6 -4
  52. package/dist/hooks/{chunk-LJBLGHEH.js → chunk-ZAL3NRGL.js} +4 -6
  53. package/dist/hooks/codex-hook-adapter.js +10 -10
  54. package/dist/hooks/duplicate-read-guard.js +5 -5
  55. package/dist/hooks/format-edited.js +2 -2
  56. package/dist/hooks/inputs.json +178 -177
  57. package/dist/hooks/investigation-guard.js +5 -5
  58. package/dist/hooks/pretool-guard.js +11 -10
  59. package/dist/hooks/stop-guard.js +20 -6
  60. package/dist/josh.js +29 -3
  61. package/dist/version/index.js +5 -1
  62. package/package.json +2 -1
  63. package/prompts/collaboration-workflow/file-edits.md +11 -10
  64. package/prompts/collaboration-workflow/gh-rest.md +2 -4
  65. package/prompts/collaboration-workflow/glossary.md +1 -1
  66. package/prompts/collaboration-workflow/issue-citation.md +5 -9
  67. package/prompts/collaboration-workflow/issue-template.md +40 -115
  68. package/prompts/collaboration-workflow/operating-rules.md +4 -5
  69. package/prompts/collaboration-workflow/output-bounds.md +12 -78
  70. package/prompts/collaboration-workflow/overview.md +7 -13
  71. package/prompts/collaboration-workflow/plan-comment.md +11 -39
  72. package/prompts/collaboration-workflow/principles.md +3 -4
  73. package/prompts/collaboration-workflow/report-format.md +28 -74
  74. package/prompts/collaboration-workflow/residency.md +12 -109
  75. package/prompts/collaboration-workflow/rule-delivery.md +98 -97
  76. package/prompts/collaboration-workflow/shell-body.md +3 -13
  77. package/prompts/collaboration-workflow/turn-batching.md +14 -88
  78. package/prompts/collaboration-workflow/upstream-interrupt.md +12 -14
  79. package/prompts/collaboration-workflow/wip-cap.md +6 -45
  80. package/prompts/collaboration-workflow.md +1 -13
  81. package/prompts/review-rubric.md +8 -71
  82. package/prompts/review.md +25 -55
  83. package/prompts/testing-guide.md +18 -67
  84. package/scripts/agent/agent-role-profile.ts +1 -1
  85. package/scripts/agent/codex-hooks.ts +3 -12
  86. package/scripts/backlog/backlog-budget.ts +1 -1
  87. package/scripts/backlog/backlog-drive-cli.ts +4 -3
  88. package/scripts/backlog/backlog-drive-offer-argv.ts +10 -1
  89. package/scripts/backlog/backlog-next.ts +15 -4
  90. package/scripts/backlog/backlog-offer.ts +6 -6
  91. package/scripts/backlog/backlog-plan-cli.ts +6 -5
  92. package/scripts/backlog/backlog-rank.ts +18 -9
  93. package/scripts/backlog/backlog-restructure.ts +152 -0
  94. package/scripts/backlog/backlog-waves.ts +15 -17
  95. package/scripts/behavior/behavior-rules.ts +11 -4
  96. package/scripts/cost-runtime/context-cut-threshold.ts +1 -9
  97. package/scripts/cost-runtime/cost-verdict.ts +15 -28
  98. package/scripts/delegation/investigation-reads.ts +5 -5
  99. package/scripts/discovery/repo-discovery.ts +21 -7
  100. package/scripts/discovery/repo-origin.ts +26 -0
  101. package/scripts/discovery/repo-party.ts +42 -1
  102. package/scripts/document/backlogrun-parent-read-set.ts +14 -15
  103. package/scripts/document/document-byte-budget.ts +34 -20
  104. package/scripts/document/entry-read-budget.ts +21 -9
  105. package/scripts/document/lane-child-read-set.ts +12 -14
  106. package/scripts/document/read-set-cli.ts +4 -4
  107. package/scripts/document/read-set-point-of-use.ts +10 -2
  108. package/scripts/document/resident-budget.ts +47 -0
  109. package/scripts/epic/epic-shape.ts +10 -13
  110. package/scripts/epic/epic-solo.ts +1 -1
  111. package/scripts/exports/unused-members-cli.ts +7 -3
  112. package/scripts/followup/git-followup-stages.ts +9 -0
  113. package/scripts/followup/git-pr-followup.ts +1 -1
  114. package/scripts/gh/git-gh-api-path.ts +7 -0
  115. package/scripts/gh/git-gh-issue.ts +5 -3
  116. package/scripts/gh/git-gh-pr-read.ts +4 -0
  117. package/scripts/gh/git-pr-checks-eval.ts +8 -0
  118. package/scripts/gh/git-pr-checks.ts +11 -1
  119. package/scripts/git/constants.ts +10 -0
  120. package/scripts/git/main-merge.ts +57 -21
  121. package/scripts/git/schemas.ts +2 -0
  122. package/scripts/hooks/pretool-guard-cli.ts +2 -2
  123. package/scripts/hooks/pretool-guard.ts +45 -2
  124. package/scripts/hooks/run-hook.sh +41 -0
  125. package/scripts/init/hook-command-rewrite.ts +18 -35
  126. package/scripts/init/hook-launch.ts +21 -32
  127. package/scripts/issue/issue-auto-ok.ts +95 -0
  128. package/scripts/issue/issue-file-cli.ts +53 -7
  129. package/scripts/issue/issue-file.ts +28 -5
  130. package/scripts/issue/issue-labels.ts +12 -12
  131. package/scripts/issue/issue-rank.ts +1 -1
  132. package/scripts/issue/issue-read-cli.ts +3 -3
  133. package/scripts/issue/issue-read.ts +2 -2
  134. package/scripts/issue/issue-scout-cli.ts +2 -2
  135. package/scripts/issue/issue-state-cli.ts +1 -1
  136. package/scripts/issue/issue-state.ts +2 -2
  137. package/scripts/issue/issue-wip.ts +70 -0
  138. package/scripts/josh/hook-decision.ts +17 -0
  139. package/scripts/josh/josh-commands-development.ts +11 -1
  140. package/scripts/josh/josh-commands-issue.ts +2 -2
  141. package/scripts/lane/lane-launch-cli.ts +21 -3
  142. package/scripts/lines/bytes-command.ts +29 -6
  143. package/scripts/notify/telegram-notify.ts +26 -1
  144. package/scripts/observations/observation-ledger-line.ts +1 -1
  145. package/scripts/release/release-progress.ts +167 -0
  146. package/scripts/release/release-publish.ts +19 -8
  147. package/scripts/release/release-tag.ts +22 -8
  148. package/scripts/rules/decision-oracle-stage.ts +1 -1
  149. package/scripts/rules/decision-oracle.ts +6 -6
  150. package/scripts/rules/delivered-rules.ts +11 -11
  151. package/scripts/rules/direct-pr-create.ts +110 -0
  152. package/scripts/rules/file-body.ts +98 -42
  153. package/scripts/rules/filing-cap.ts +2 -2
  154. package/scripts/rules/filing-offer.ts +1 -1
  155. package/scripts/rules/gh-api.ts +18 -1
  156. package/scripts/rules/implementation-cut.ts +3 -1
  157. package/scripts/rules/lane-background.ts +67 -10
  158. package/scripts/rules/permission-guards.ts +10 -2
  159. package/scripts/rules/pre-gate-cut.ts +2 -2
  160. package/scripts/rules/stop-rules.ts +4 -4
  161. package/scripts/rules/test-declared-commit.ts +1 -1
  162. package/scripts/rules/third-party-write.ts +56 -12
  163. package/scripts/run/run-carry.ts +11 -5
  164. package/scripts/run/run-cut-cli.ts +1 -1
  165. package/scripts/run/run-cut-handoff.ts +21 -1
  166. package/scripts/run/run-cut-report.ts +2 -2
  167. package/scripts/run/run-entry-cli.ts +11 -3
  168. package/scripts/run/run-hold-cli.ts +16 -2
  169. package/scripts/run/run-label.ts +50 -0
  170. package/scripts/run/run-merge-steps.ts +8 -3
  171. package/scripts/run/run-merge.ts +1 -1
  172. package/scripts/run/run-prep-cli.ts +6 -2
  173. package/scripts/run/run-prep.ts +17 -0
  174. package/scripts/run/run-ship-cli.ts +27 -6
  175. package/scripts/run/run-ship-next.ts +13 -0
  176. package/scripts/run/run-ship-pre-detach.ts +66 -0
  177. package/scripts/run/run-ship-preflight.ts +34 -3
  178. package/scripts/run/run-ship-return.ts +7 -4
  179. package/scripts/run/run-ship-review-steps.ts +1 -1
  180. package/scripts/run/run-ship-review.ts +26 -5
  181. package/scripts/run/run-ship-stage.ts +13 -0
  182. package/scripts/run/run-ship-steps.ts +10 -2
  183. package/scripts/run/run-ship-sync.ts +114 -0
  184. package/scripts/run/run-ship.ts +6 -0
  185. package/scripts/run/run-step-cli.ts +20 -7
  186. package/scripts/run/run-wake-cli.ts +8 -1
  187. package/scripts/run/run-wake-driver.ts +62 -8
  188. package/scripts/run/run-wake-session.ts +2 -1
  189. package/scripts/test/test-declared-logic.ts +1 -1
  190. package/scripts/test/test-red-logic.ts +17 -2
  191. package/scripts/test/test-red.ts +8 -7
  192. package/scripts/time-runtime/time-batch-guard.ts +52 -16
  193. package/scripts/time-runtime/time-density-hook.ts +20 -2
  194. package/scripts/time-runtime/time-density.ts +4 -3
  195. package/scripts/time-runtime/time-markers.ts +15 -4
  196. package/scripts/time-runtime/time-transcript-line.ts +27 -1
  197. package/scripts/version/npm-registry.ts +11 -1
  198. package/templates/workflows/ci.yml +9 -1
  199. package/.claude/skills/workflow-commands/followup-reference.md +0 -118
  200. package/dist/hooks/chunk-HANSKHES.js +0 -464
@@ -114,6 +114,10 @@ interface GuardedCall {
114
114
  input: unknown
115
115
  }
116
116
 
117
+ // What naming a call reads off it — a closed turn's span or the call in hand alike, so the live density
118
+ // line can name the call that has just run beside the turns behind it (joshuafolkken/kit#3157).
119
+ type NamedCall = Pick<Span, 'is_bundleable' | 'is_writing' | 'label' | 'targets'>
120
+
117
121
  // **A write is refusable, and the exclusion that said otherwise is gone** (joshuafolkken/kit#1762).
118
122
  // Read over 19 recorded runs, 261 of 1,737 round trips (15.0%) were recoverable by batching and **184
119
123
  // of them (70.5%) were writes** — 158 `Edit` alone. The refusal's own text has always said the rule
@@ -171,7 +175,7 @@ const REASON =
171
175
  `the calls meant to follow it that do not need its result. **The criterion is whether this call's ` +
172
176
  `input needs another call's result, not what kind of call it is** — edits are covered exactly as ` +
173
177
  `reads are, and it never authorizes weakening a verification gate or a review: fewer turns, never ` +
174
- `less work. The measured cost and the rejected mechanisms are in ` +
178
+ `less work. The rule, and where its measured cost and rejected mechanisms are, is in ` +
175
179
  `\`prompts/collaboration-workflow/turn-batching.md\`. If this turn was already batching, or the call ` +
176
180
  `genuinely has nothing to go beside it, reissue it as it was: this fires once per run of ` +
177
181
  `single-call turns and cannot repeat on the call in hand.`
@@ -188,7 +192,7 @@ const NOTICE =
188
192
  `write proceeds. Where the writes meant to follow it do not need its result, issue them in one turn ` +
189
193
  `together. **The criterion is whether a call's input needs another call's result, not what kind of ` +
190
194
  `call it is** — it never authorizes weakening a verification gate or a review: fewer turns, never ` +
191
- `less work. The measured cost and the rejected mechanisms are in ` +
195
+ `less work. The rule, and where its measured cost and rejected mechanisms are, is in ` +
192
196
  `\`prompts/collaboration-workflow/turn-batching.md\`. If this run was already batching, or the write ` +
193
197
  `genuinely has nothing to go beside it, carry on: this recurs every ` +
194
198
  `${String(NOTICE_REFIRE_EVERY)} further single-call turn(s).`
@@ -205,8 +209,8 @@ const LANE_NOTICE =
205
209
  `proceeds, because a dispatched lane child ends its turn on a denial. Where the calls meant to follow ` +
206
210
  `it do not need its result, issue them in one turn together. **The criterion is whether a call's ` +
207
211
  `input needs another call's result, not what kind of call it is** — it never authorizes weakening a ` +
208
- `verification gate or a review: fewer turns, never less work. The measured cost and the rejected ` +
209
- `mechanisms are in \`prompts/collaboration-workflow/turn-batching.md\`. If this run was already ` +
212
+ `verification gate or a review: fewer turns, never less work. The rule, and where its measured cost ` +
213
+ `and rejected mechanisms are, is in \`prompts/collaboration-workflow/turn-batching.md\`. If this run was already ` +
210
214
  `batching, or the call genuinely has nothing to go beside it, carry on: this recurs every ` +
211
215
  `${String(NOTICE_REFIRE_EVERY)} further single-call turn(s).`
212
216
 
@@ -410,7 +414,7 @@ function should_notify(text: string, call: GuardedCall, notified_at_ms: number):
410
414
  // touched where the label does not already carry it (joshuafolkken/kit#2276). `label` is the tool name
411
415
  // for a non-shell call and the command for a shell one, so the target is appended only where it adds
412
416
  // something the label has not already said.
413
- function describe_span(span: Span): string {
417
+ function describe_span(span: NamedCall): string {
414
418
  const target = span.targets[0] ?? ''
415
419
  if (target === '' || span.label.includes(target)) return span.label
416
420
 
@@ -428,7 +432,7 @@ const FOLD_MINIMUM = 2
428
432
  // `read:files` cannot read — it would emit `Cannot read scripts/` and exit 1, so the paste-ready
429
433
  // command must never carry it). The directory test is `has_extension` reused from the target scanner,
430
434
  // not a second copy. Deduped, so a run that read one file twice folds it once and names it once.
431
- function read_targets(sequence: ReadonlyArray<Span>): ReadonlyArray<string> {
435
+ function read_targets(sequence: ReadonlyArray<NamedCall>): ReadonlyArray<string> {
432
436
  const paths = sequence
433
437
  .filter((span) => span.is_bundleable && !span.is_writing)
434
438
  .map((span) => span.targets[0] ?? '')
@@ -445,7 +449,7 @@ function read_targets(sequence: ReadonlyArray<Span>): ReadonlyArray<string> {
445
449
  // kit#2202); so the guard now offers *that* command, the one the model can emit in a single turn,
446
450
  // rather than another way to say "batch". Below two reads there is nothing to fold, so it is empty and
447
451
  // the guidance stands alone.
448
- function read_fold_directive(sequence: ReadonlyArray<Span>): string {
452
+ function read_fold_directive(sequence: ReadonlyArray<NamedCall>): string {
449
453
  const paths = read_targets(sequence)
450
454
  if (paths.length < FOLD_MINIMUM) return ''
451
455
 
@@ -459,7 +463,7 @@ function read_fold_directive(sequence: ReadonlyArray<Span>): string {
459
463
  // can emit in a single turn where reissuing the edits as parallel `tool_use` blocks is the shape it
460
464
  // resists. Only `Edit` counts: a whole-file `Write` creates content this command cannot content-address,
461
465
  // and it earns its own notice already. Below two edits there is nothing to fold.
462
- function write_targets(sequence: ReadonlyArray<Span>): ReadonlyArray<string> {
466
+ function write_targets(sequence: ReadonlyArray<NamedCall>): ReadonlyArray<string> {
463
467
  const paths = sequence
464
468
  .filter((span) => span.label === EDIT_TOOL)
465
469
  .map((span) => span.targets[0] ?? '')
@@ -468,13 +472,24 @@ function write_targets(sequence: ReadonlyArray<Span>): ReadonlyArray<string> {
468
472
  return [...new Set(paths)]
469
473
  }
470
474
 
471
- function write_fold_directive(sequence: ReadonlyArray<Span>): string {
475
+ function write_fold_directive(sequence: ReadonlyArray<NamedCall>): string {
472
476
  const paths = write_targets(sequence)
473
477
  if (paths.length < FOLD_MINIMUM) return ''
474
478
 
475
479
  return ` Fold them into one \`pnpm josh edit:files\` call with a plan over: ${paths.join(' ')}.`
476
480
  }
477
481
 
482
+ // The calls named, and their folds offered — the text both the refusable-call notice and the live
483
+ // density line carry, written once so the two cannot word the same calls differently.
484
+ function name_candidates(calls: ReadonlyArray<NamedCall>): string {
485
+ if (calls.length === NONE) return ''
486
+
487
+ const named = calls.map((call) => describe_span(call)).join(', ')
488
+ const folds = `${read_fold_directive(calls)}${write_fold_directive(calls)}`
489
+
490
+ return ` Just issued one per turn, so at least these could have shared a turn: ${named}.${folds}`
491
+ }
492
+
478
493
  // **The concrete half of the notice** (joshuafolkken/kit#2276): the most recent single-call turns,
479
494
  // named, so the model is shown the calls it should have batched rather than only told to batch — and,
480
495
  // since kit#2311, the one `read:files` call that folds them where they were reads. Parsed from the tail
@@ -482,16 +497,36 @@ function write_fold_directive(sequence: ReadonlyArray<Span>): string {
482
497
  // which is rare enough that the second parse costs less than carrying the spans through the hook's
483
498
  // notice contract. An empty sequence names nothing and the notice falls back to its guidance alone.
484
499
  function recent_candidates(tail: string): string {
485
- const recent = time_bundles
486
- .open_sequence(time_spans.parse_timeline(tail).spans)
487
- .slice(-NAMED_CANDIDATE_COUNT)
500
+ return name_candidates(
501
+ time_bundles.open_sequence(time_spans.parse_timeline(tail).spans).slice(-NAMED_CANDIDATE_COUNT),
502
+ )
503
+ }
504
+
505
+ // **The candidates for a call that has already run** (joshuafolkken/kit#3157): what the `PostToolUse`
506
+ // density line names. A lane child never sees the notice above — kit#2405 turned it `off` there — so
507
+ // the density line is the one batching signal a lane receives, and until this it named nothing. The
508
+ // call in hand is not in the open sequence (its turn has not closed), so it is named after the turns
509
+ // behind it — and it is checked against them with the same shared-target test the refusal uses, so a
510
+ // call that read what the turn before it wrote is never offered as one that could have gone beside
511
+ // it. A call that is not bundleable names no targets, so the shared-target test cannot see what it
512
+ // needs — `pnpm josh gate` after a run of edits reads every one of them — and it is never offered
513
+ // either, exactly as `is_guarded_call` never refuses one. An empty answer means there is no concrete
514
+ // pair to name, and the caller says nothing at all. A call the payload does not name is judged on the
515
+ // closed turns alone.
516
+ function pairing_candidates(tail: string, call: GuardedCall | undefined): string {
517
+ const sequence = time_bundles.open_sequence(time_spans.parse_timeline(tail).spans)
518
+
519
+ if (call === undefined || sequence.length === NONE) {
520
+ return name_candidates(sequence.slice(-NAMED_CANDIDATE_COUNT))
521
+ }
488
522
 
489
- if (recent.length === NONE) return ''
523
+ const facts = time_bundle_call.call_facts(call.name, call.input)
490
524
 
491
- const named = recent.map((span) => describe_span(span)).join(', ')
492
- const folds = `${read_fold_directive(recent)}${write_fold_directive(recent)}`
525
+ if (!facts.is_bundleable || depends_on_sequence(sequence, facts)) return ''
493
526
 
494
- return ` Just issued one per turn, so at least these could have shared a turn: ${named}.${folds}`
527
+ const current = time_spans.to_tool_call(call.name, call.input, time_spans.NO_MESSAGE_ID)
528
+
529
+ return name_candidates([...sequence.slice(ONE_TURN - NAMED_CANDIDATE_COUNT), current])
495
530
  }
496
531
 
497
532
  // **This guard's own name for its once-per-run record.** It lives beside the rule rather than in
@@ -519,6 +554,7 @@ const time_batch_guard = {
519
554
  is_guarded_call,
520
555
  is_notice_call,
521
556
  is_read_only_call,
557
+ pairing_candidates,
522
558
  recent_candidates,
523
559
  should_block,
524
560
  should_notify,
@@ -1,6 +1,7 @@
1
1
  import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
2
2
  import { stamp_file } from '#scripts/josh/stamp-file'
3
3
  import { z } from 'zod'
4
+ import { time_batch_guard, type GuardedCall } from './time-batch-guard'
4
5
  import { time_density } from './time-density'
5
6
  import { time_hook_transcript } from './time-hook-transcript'
6
7
 
@@ -61,6 +62,7 @@ const payload_schema = z.object({
61
62
  agent_id: z.string().nullish(),
62
63
  })
63
64
  const notice_schema = z.object({ notified_at_ms: z.number() })
65
+ const call_schema = z.object({ tool_name: z.string().min(1), tool_input: z.unknown() })
64
66
 
65
67
  function parse_transcript_path(raw_payload: string): string | undefined {
66
68
  const parsed = payload_schema.safeParse(JSON.parse(raw_payload))
@@ -70,6 +72,17 @@ function parse_transcript_path(raw_payload: string): string | undefined {
70
72
  return time_hook_transcript.transcript_of(parsed.data.transcript_path, parsed.data.agent_id)
71
73
  }
72
74
 
75
+ // The call that has just run, as the payload names it (joshuafolkken/kit#3157) — what the line checks
76
+ // against the turns behind it, so a call that needed their result is never offered as one that could
77
+ // have shared their turn. A payload without a tool name answers `undefined`, judged on those turns alone.
78
+ function parse_call(raw_payload: string): GuardedCall | undefined {
79
+ const parsed = call_schema.safeParse(JSON.parse(raw_payload))
80
+
81
+ if (!parsed.success) return undefined
82
+
83
+ return { name: parsed.data.tool_name, input: parsed.data.tool_input }
84
+ }
85
+
73
86
  // The last `max_bytes` of a file, as text. The leading line is usually cut mid-way, which every
74
87
  // reader below already handles: an unparseable line is dropped rather than dated.
75
88
  //
@@ -135,14 +148,19 @@ function notice_for(raw_payload: string, now_ms: number): string | undefined {
135
148
 
136
149
  if (transcript_path === undefined) return undefined
137
150
 
138
- const reading = time_density.read_window(read_tail(transcript_path))
151
+ const tail = read_tail(transcript_path)
152
+ const reading = time_density.read_window(tail)
139
153
  const target = notice_path(transcript_path)
140
154
 
141
155
  if (!time_density.is_due(reading, now_ms - last_notice_ms(target))) return undefined
142
156
 
157
+ const candidates = time_batch_guard.pairing_candidates(tail, parse_call(raw_payload))
158
+
159
+ if (candidates === '') return undefined
160
+
143
161
  arm_throttle(target, now_ms)
144
162
 
145
- return time_density.format_notice(reading)
163
+ return time_density.format_notice(reading, candidates)
146
164
  }
147
165
 
148
166
  // The whole call in one guard: every path above may throw on a file that moved or a payload that is
@@ -120,8 +120,9 @@ function is_due(reading: DensityReading, since_last_ms: number): boolean {
120
120
  // One line, and it says three things: what the density is, what it is being measured against, and
121
121
  // what to do differently. The instruction is last because it is the only part worth acting on, and it
122
122
  // names the resident rule rather than restating it — the rule is already in the run's context, and a
123
- // second wording of it would be a clone that can drift.
124
- function format_notice(reading: DensityReading): string {
123
+ // second wording of it would be a clone that can drift. `candidates` is the concrete calls that could
124
+ // have shared a turn (joshuafolkken/kit#3157), appended last so the line ends on what to do next.
125
+ function format_notice(reading: DensityReading, candidates = ''): string {
125
126
  const density = time_round_trips.format_density(reading.density)
126
127
  const floor = time_round_trips.format_density(time_round_trips.CALLS_PER_ROUND_TRIP_FLOOR)
127
128
 
@@ -130,7 +131,7 @@ function format_notice(reading: DensityReading): string {
130
131
  `under the ${floor} floor, and this turn issued one call. ` +
131
132
  `Independent reads, greps, gh queries and edits go out together; the criterion is whether this ` +
132
133
  `call's input needs another call's result, not what kind of call it is — ` +
133
- `prompts/collaboration-workflow/turn-batching.md.`
134
+ `prompts/collaboration-workflow/turn-batching.md.${candidates}`
134
135
  )
135
136
  }
136
137
 
@@ -111,6 +111,12 @@ const BODY_FLAG_PATTERN = /--body(?:-file)?\b/u
111
111
  const ISSUE_LABELS_PATTERN = /repos\/\S+\/issues\/(\d+)\/labels\b(?!\/)/u
112
112
  const IN_PROGRESS_FIELD = 'labels[]=in-progress'
113
113
 
114
+ // **`run:entry` applies the label itself now** (joshuafolkken/kit#3182), so a run opened through it
115
+ // leaves no `gh api` add form in its transcript — the call that opens the run and names its issue is
116
+ // `pnpm josh run:entry <N>`, and it is read as the same declaration. `run:release` is not: like the
117
+ // removal form above, it ends a hold rather than opening one.
118
+ const RUN_ENTRY_PATTERN = /\bjosh\s+run:entry\s+(\d+)\b/u
119
+
114
120
  // "This call declares no issue", which is the attribution's own sentinel rather than a second one:
115
121
  // what `bash_issue` returns is read by `cost_attribute` and by nothing else, so two spellings of "no
116
122
  // issue" could only drift apart.
@@ -161,6 +167,10 @@ function is_in_progress_label(command: string): boolean {
161
167
  return ISSUE_LABELS_PATTERN.test(command) && command.includes(IN_PROGRESS_FIELD)
162
168
  }
163
169
 
170
+ function is_run_start(command: string): boolean {
171
+ return is_in_progress_label(command) || RUN_ENTRY_PATTERN.test(command)
172
+ }
173
+
164
174
  // Which issue a Bash call declares its run to be running, or `undefined` where it declares nothing.
165
175
  //
166
176
  // **Attribution's other evidence is the branch, and a lane run has none to give**
@@ -185,15 +195,16 @@ function is_in_progress_label(command: string): boolean {
185
195
  // shape `sonarjs/super-linear-regex` refuses.
186
196
  const COMMAND_SEPARATOR = /&&|\|\||;/u
187
197
 
188
- function label_issue(part: string): number {
189
- const matched = ISSUE_LABELS_PATTERN.exec(part)
198
+ function declared_issue(part: string): number {
199
+ const pattern = is_in_progress_label(part) ? ISSUE_LABELS_PATTERN : RUN_ENTRY_PATTERN
200
+ const matched = pattern.exec(part)
190
201
 
191
202
  return matched?.[1] === undefined ? NO_ISSUE : Number(matched[1])
192
203
  }
193
204
 
194
205
  function bash_issue(command: string): number {
195
206
  for (const part of command.split(COMMAND_SEPARATOR)) {
196
- if (is_in_progress_label(part)) return label_issue(part)
207
+ if (is_run_start(part)) return declared_issue(part)
197
208
  }
198
209
 
199
210
  return NO_ISSUE
@@ -207,7 +218,7 @@ function bash_issue(command: string): number {
207
218
  // auto-decision log take the same shape, so `time-phases.ts` accepts only a marker that closes
208
219
  // before the first edit. The label carries no body, so the two tests cannot both match one command.
209
220
  function bash_marker(command: string): PhaseMarker {
210
- if (is_in_progress_label(command)) return WORKFLOW_MARKER
221
+ if (is_run_start(command)) return WORKFLOW_MARKER
211
222
 
212
223
  return is_api_body_write(command) || is_issue_comment(command) ? PLAN_MARKER : NO_MARKER
213
224
  }
@@ -79,6 +79,13 @@ const ERROR_TEXT_LIMIT = 256
79
79
  // shared constant exists because each guard writes the literal in its own reason, so the one place a
80
80
  // refusal is *detected* names it here.
81
81
  const REFUSAL_MARKER = '⛔'
82
+ // The label the harness puts in front of a PreToolUse hook's deny reason, naming the hook and the tool.
83
+ const HOOK_ERROR_LABEL = /^PreToolUse:\S+ hook error: /u
84
+ // A line that is one bare verdict token — `cut`, `busy`, `not-a-lane` — the single word a josh verdict
85
+ // command prints on standard output (joshuafolkken/kit#3223). How many are kept bounds the field the
86
+ // way `ERROR_TEXT_LIMIT` bounds `error_text`.
87
+ const TOKEN_LINE = /^[a-z][a-z-]*$/u
88
+ const TOKEN_LINE_LIMIT = 8
82
89
 
83
90
  interface Block {
84
91
  type: string
@@ -111,6 +118,11 @@ interface Block {
111
118
  // the parsed block rather than by re-scanning the body downstream. `''` where the result was not a
112
119
  // `⛔` refusal, so a reader tells "no guard spoke" from "this guard spoke" without a second parse.
113
120
  refusal_guard: string
121
+ // The bare-token lines of a result that did not fail, and `[]` for every other block
122
+ // (joshuafolkken/kit#3223). It obeys the same prohibition as `error_text` — what is kept is a few
123
+ // single words, never the body — and it exists so the verdict a josh command answered with
124
+ // (`run:cut`'s `cut`) is read from the parsed block rather than by searching the raw line.
125
+ token_lines: ReadonlyArray<string>
114
126
  // The id the harness assigned to a command it took into the background, and `''` for every other
115
127
  // block (joshuafolkken/kit#1662). It is the fourth field read off the body under the same
116
128
  // prohibition as the three above — what is kept is the id, never the text it was read from — and
@@ -159,8 +171,12 @@ function block_names(
159
171
  // before the first `:` — `⛔ pre-gate cut: …` is `pre-gate cut`. A reason with no colon (a headline
160
172
  // like `⛔ scoped checks not green on this tree`) is taken whole. Reading the opening rather than
161
173
  // searching the body is what stops a result that merely quoted a reason being read as a refusal.
174
+ //
175
+ // **The harness writes the reason behind its own label** — `PreToolUse:Edit hook error: ⛔ …` — so that
176
+ // label is stripped before the opening is read (joshuafolkken/kit#3223). Without it no live refusal
177
+ // carried a guard: the fixtures wrote the bare `⛔` body and passed while every recorded one read `''`.
162
178
  function guard_from_refusal(text: string): string {
163
- const trimmed = text.trimStart()
179
+ const trimmed = text.trimStart().replace(HOOK_ERROR_LABEL, '')
164
180
  if (!trimmed.startsWith(REFUSAL_MARKER)) return ''
165
181
 
166
182
  const headline = trimmed.slice(REFUSAL_MARKER.length).split('\n', 1)[0]?.trimStart() ?? ''
@@ -169,6 +185,14 @@ function guard_from_refusal(text: string): string {
169
185
  return (colon === -1 ? headline : headline.slice(0, colon)).trim()
170
186
  }
171
187
 
188
+ function token_lines_of(text: string): Array<string> {
189
+ return text
190
+ .split('\n')
191
+ .map((line) => line.trim())
192
+ .filter((line) => TOKEN_LINE.test(line))
193
+ .slice(0, TOKEN_LINE_LIMIT)
194
+ }
195
+
172
196
  // **The body is flattened once and both readers are handed the text.** `result_text` is what turns a
173
197
  // content field that may be a string or a list of blocks into one string, and it is idempotent on a
174
198
  // string — so passing its own output back to `has_failure_line` reads exactly as passing the raw
@@ -185,6 +209,7 @@ function to_block(raw: z.infer<typeof BLOCK_SCHEMA>): Block {
185
209
  followup_stages: time_followup_stage.read_stages(text),
186
210
  error_text: is_error === true ? text.slice(0, ERROR_TEXT_LIMIT) : '',
187
211
  refusal_guard: is_error === true ? guard_from_refusal(text) : '',
212
+ token_lines: is_error === true ? [] : token_lines_of(text),
188
213
  background_id: time_background.launch_id(text),
189
214
  agent_id: time_background.agent_launch_id(text),
190
215
  }
@@ -248,6 +273,7 @@ function parse_line(line: string): TranscriptLine | undefined {
248
273
  const time_transcript_line = {
249
274
  ERROR_TEXT_LIMIT,
250
275
  NO_MESSAGE_ID,
276
+ TOKEN_LINE_LIMIT,
251
277
  guard_from_refusal,
252
278
  parse_line,
253
279
  }
@@ -92,6 +92,16 @@ function read_release_times(package_name: string): Record<string, string> | unde
92
92
  return parsed.success ? parsed.data.time : undefined
93
93
  }
94
94
 
95
- const npm_registry = { packument_url, read_latest, read_release_times }
95
+ // Whether public npm already carries `<name>@<version>`. **Asked of public npm regardless of how this
96
+ // checkout routes the scope**, unlike the two readers above: `josh release` waits for `publish.yml`'s
97
+ // `publish-npm` job and links the npmjs.com page, so GitHub Packages answering would not be the
98
+ // question it asks (joshuafolkken/kit#3193).
99
+ function has_public_version(package_name: string, version: string): boolean {
100
+ const parsed = times_schema.safeParse(read_packument(package_name, FULL_ACCEPT))
101
+
102
+ return parsed.success && parsed.data.time[version] !== undefined
103
+ }
104
+
105
+ const npm_registry = { has_public_version, packument_url, read_latest, read_release_times }
96
106
 
97
107
  export { npm_registry }
@@ -227,9 +227,17 @@ jobs:
227
227
  if: steps.profile.outputs.is_basic == 'true'
228
228
  run: pnpm josh gate --verbose
229
229
 
230
+ # size-limit is an opt-in bundle budget kit does not install, so a full project that never
231
+ # added it skips the check instead of failing on a missing command (joshuafolkken/kit#3217).
230
232
  - name: Check bundle size
233
+ id: bundle-size
231
234
  if: steps.profile.outputs.is_basic != 'true'
232
- run: pnpm size-limit
235
+ run: |
236
+ if [ -x node_modules/.bin/size-limit ]; then
237
+ pnpm size-limit
238
+ else
239
+ echo 'size-limit is not installed — skipping the bundle-size check.'
240
+ fi
233
241
 
234
242
  security:
235
243
  name: Security Audit
@@ -1,118 +0,0 @@
1
- # `pnpm josh followup` — the post-execution reference
2
-
3
- The parts of the `followup` procedure a run reaches **after** it has issued `pnpm josh followup`: how
4
- to read the stage-timing block the command prints, the AI-reviewer comment scan and the config-file
5
- report it runs, and the release ask that closes the completion summary. `followup.md` carries what a
6
- run needs **before** it issues the command and points here for the rest, so the read that lands in the
7
- turn that issues `pnpm josh followup` stays small (joshuafolkken/kit#1905).
8
-
9
- This file is the single source of the sections below; `followup.md`, `fullrun.md`, `backlogrun.md` and
10
- `backlogrun.md` point here rather than restating them.
11
-
12
- ## Reading the stage-timing block
13
-
14
- ### It prints how long each of those stages took
15
-
16
- Since joshuafolkken/kit#1349 the command closes with one row per stage and a total, so "where did
17
- `followup` spend its time" is read rather than guessed. The shape, with illustrative durations:
18
-
19
- ```
20
- followup stage: closes-and-context 2.1 s
21
- followup stage: checks-wait 28.4 s
22
- followup stage: coderabbit-comments 1.3 s
23
- followup stage: ai-review-comments 2.0 s
24
- followup stage: telegram 1.1 s
25
- followup stage: merge 2.6 s
26
- followup stage: completion-and-epic-close 1.4 s
27
- followup stages total: 38.9 s
28
- ```
29
-
30
- - **The block is printed on a failed run too**, up to and including the stage that threw, which is
31
- named `interrupted`. A `followup` that exits non-zero on an AI-review blocker or a red check is the
32
- invocation whose wait was longest, and one that printed nothing would leave the measurement blind to
33
- exactly those. **A short block there is the run stopping early, not the printer breaking**: the
34
- stages after the failure never ran, so they have no duration to report.
35
- - **The `merge` row appears only on a run that merged**, so a `--no-merge` block is a row shorter.
36
- - **Two rows say `and` because the requests behind them go out together** (joshuafolkken/kit#1446).
37
- `closes-and-context` is the former `closes-check` plus `context` — the pull request body, the
38
- repository name, the pull request URL and the issue title, four reads that need nothing from one
39
- another; `completion-and-epic-close` is the former `completion-comment` plus `epic-close`. A lap
40
- records an interval rather than a call, so a batch is one row, and the name is what says which
41
- stages it holds. **The one dependency inside the batch is kept**: with no issue number on the
42
- command line the title read waits for the body that names it, and only that read waits.
43
- **`checks-wait` is untouched**, which is the point — what was overlapped was never a wait.
44
- - **The total is the sum of the stages, not the command's whole wall clock.** What sits outside it is
45
- the tail the workflow script runs afterwards — the review-record clears, the hold
46
- release, the next-issue listing and the version line — so a run-timing reading of the same
47
- span is longer, and that gap is the tail. It is normally a second or two.
48
- - **What was measured is on joshuafolkken/kit#1349**, and cutting any of it is deliberately not this
49
- block's business: the required-check wait and the AI-review scan are the merge gate, and narrowing
50
- either to make a number smaller is the workaround `CLAUDE.md` prohibits.
51
-
52
- A `failure` Telegram is sent **by hand, exactly once**, and only when the agent has finally given up
53
- recovering — never once per retry:
54
-
55
- ```bash
56
- pnpm josh notify --task-type failure --issue-url "<issue-url>" --body "<the reason and what is unresolved>"
57
- ```
58
-
59
- ## AI reviewer comment scan (automatic in `pnpm josh followup`)
60
-
61
- `pnpm josh followup` scans top-level PR comments from AI reviewers (Claude Review, CodeRabbit summary comments) **independently of CI status**. This scan runs after CI is green and after the existing CodeRabbit line-comment check. The goal is to ensure substantive findings posted by AI reviewers _after_ CI goes green are not silently shipped.
62
-
63
- **Temporary (kit#753)**: while CodeRabbit reviews are slow, CodeRabbit is non-blocking end to end — it is excluded from the default required checks (restore via `JOSH_REQUIRED_CHECKS`), `Actionable comments posted: N` is downgraded to an informational log, and unresolved CodeRabbit line comments no longer require an ignore reason. Every skip is printed to the console and appended to the completion Telegram body. Claude Review blockers are unchanged. Revert together with kit#752.
64
-
65
- - Blocker heuristics (conservative, structural — not NLP):
66
- - **Claude Review** (`author.login = claude`): body contains `### Issues`, `### Problem`, `#### Logic bug`, or a numbered finding heading like `### 1. ...`
67
- - **CodeRabbit** (`author.login = coderabbitai` / `coderabbitai[bot]`): body contains `Actionable comments posted: N` with N > 0. Rate-limit notices (`rate limited by coderabbit.ai` / `Rate limit exceeded`) and "No actionable comments" summaries are ignored.
68
- - If blockers exist and **no** ignore reason is supplied: `pnpm josh followup` sends a `confirmation` Telegram and exits non-zero. Fix the findings (or provide an ignore reason) and re-run.
69
- - If blockers exist and `--ai-review-ignore-reason "<reason>"` is supplied: the workflow posts an ignore-reason comment to the PR (mirroring the CodeRabbit ignore-reason flow) and proceeds to completion.
70
- - Acknowledgment-only Claude comments (`All issues resolved ✓`, `Everything else looks good`) do not match the blocker heuristics, so rounds where the AI reviewer explicitly signs off do not trigger a false positive.
71
- - **A comment listing that could not be read is treated exactly like a standing blocker.** A rate limit, expired auth, a dropped connection — every one of them used to arrive as an empty listing, so the gate passed without having read anything (joshuafolkken/kit#973). `confirmation` Telegram, non-zero exit, re-run once the read succeeds. `--ai-review-ignore-reason` still gets past it, because what it means is unchanged: a person has looked — and the run then carries an audit note saying the scan was bypassed.
72
- - **The CodeRabbit line-comment listing is the exception**: unreadable there is reported as an audit note rather than blocking, because kit#753 has CodeRabbit not blocking the merge at all. That reader also answers unreadable when the PR number itself would not resolve, which the top-level scan never has to do. Revert with kit#752.
73
-
74
- ## Config file reporting (inside `pnpm josh followup`)
75
-
76
- **Nothing here asks you to compare anything by eye** (joshuafolkken/kit#1578). `pnpm josh followup` reads the tracked branch diff itself and matches every changed path against everything `josh sync` distributes — the three lists `AI_COPY_FILES`, `AI_COPY_FILE_MAPPINGS`, `AI_COPY_DIRECTORIES`, plus `SYNCED_PATHS`, the destinations `sync` writes directly (`playwright.config.ts`, `eslint.config.js`, the rest).
77
-
78
- **It stops nothing** (joshuafolkken/kit#1592). What it produces is a report: the claimed paths and the list that claimed each one go into **the completion notification and the completion report on the Issue**, and the run continues to the CI wait and the merge exactly as a run that distributes nothing does. A run passing `--no-merge` is not reported on, because nothing has been distributed yet.
79
-
80
- **It was a confirmation stop until joshuafolkken/kit#1592, and measurement is what removed it rather than preference.** In kit the condition is nearly always true, because kit is the distribution source: every change to `CLAUDE.md`, to `prompts/`, to `.claude/skills/` or to the distributed part of `docs/` is by definition a change to a distributed path. Measured on `epicrun #1413`, **two of three children stopped here** — and in both, the distributed file was the one that child's own acceptance criteria had ordered changed. The gate was not catching an unintended edit; it was stopping the work, and ending unattended execution until a person retyped one command.
81
-
82
- **There is deliberately no branch on which repository this is.** Stopping in a consumer project — where editing a distributed file really is the mistake `CLAUDE.md` → "Route distributed-doc / config changes upstream to kit" names, since the next `josh sync` overwrites it silently — while staying quiet in kit was weighed and rejected on joshuafolkken/kit#1592: it needs a distribution-source test this package does not have, no case is recorded of the gate having saved a consumer's work, and the report below reaches a consumer's reader just as well.
83
-
84
- **The matching itself is unchanged, and so is the reason it is mechanical.** The instruction the mechanical read replaced was skipped in two runs out of three on the day it was measured, and one of those two could not have succeeded by eye at all: `AI_COPY_DIRECTORIES` holds directories, so a distributed path such as `.claude/skills/workflow-commands/backlogrun.md` appears in no list textually.
85
-
86
- ```bash
87
- pnpm josh sync:scope # managed | clean, naming which list claimed each path
88
- ```
89
-
90
- - **There is no flag to pass and nothing to approve.** `--managed-config-ignore-reason` is gone with the stop it existed to get past.
91
- - This report is produced independently of AI reviewer comment scanning, which still stops the run on a standing blocker
92
-
93
- ## When `pnpm josh release` runs
94
-
95
- **The release point is a position plus a command's answer, never a judgement** (joshuafolkken/kit#1582). joshuafolkken/kit#1169 took the version off the branch and put it behind one command a person types; nothing said *when* to type it, and because nothing fails when nobody does — CI green, every pull request merged, every Issue closed — 53 merges reached main unreleased and no consumer of this package saw one of them.
96
-
97
- **The position: once per invocation, after the last merge.** Ask when `pnpm josh followup` has merged the last pull request *this invocation* authorized — a lone `fullrun`'s only one, a `backlogrun`'s **last** child, never once per child. In a lane the parent asks it, in the primary checkout, after the last lane is closed. Asked per child it would cut a release in the middle of a batch whose remaining children are still moving main.
98
-
99
- **The answer: `pnpm josh release:scope`.**
100
-
101
- ```bash
102
- pnpm josh release:scope # → required | skip | unknown
103
- pnpm josh release:scope --json # the same answer as one JSON object
104
- ```
105
-
106
- | It answers | What it means | What the run does |
107
- | ---------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------- |
108
- | `required` | main has taken at least one merge since the version last changed | Close the completion report with the release request, naming `pnpm josh release` |
109
- | `skip` | the count is zero — nothing is waiting to ship | Say so in one line and finish |
110
- | `unknown` | the count could not be read | **Report it as `unknown`** — it is never read as `skip` |
111
-
112
- **The threshold is one, and that is deliberate.** joshuafolkken/kit#1582's own complaint is that accumulating makes a single version larger and its contents harder to trace afterwards, so a release per invocation is the cadence rather than a compromise — and it is the cadence that already held while every child bumped, before joshuafolkken/kit#1486 removed that. What keeps it from firing per child is the position above, not a larger number.
113
-
114
- **The run never types `pnpm josh release` itself.** That command opens a pull request of its own, merges it, and starts the tag → publish → `production` chain: outward-facing and effectively irreversible, so it is Tier C (`CLAUDE.md` → "Decision autonomy"). Typing `fullrun` authorizes merging *this Issue's* pull request and nothing past it. On `required` the run reports the request as its closing line and stops there; a person types the command in the primary checkout, on the default branch, with a clean tree.
115
-
116
- **`pnpm josh release --dry-run` was checked first and does not answer this.** It refuses off the default branch and on a dirty working tree, and it counts against `HEAD` rather than `origin/<default>` — and every position above is a feature branch or a lane, which is exactly where it throws. So `release:scope` adds **no counting of its own**: it reads `git_followup_pending.read_pending`, the same fetch-then-count `pnpm josh followup` already uses for the Telegram line, and the two therefore cannot disagree.
117
-
118
- **This section is the single source.** `fullrun.md`, `backlogrun.md` and `backlogrun.md` point here rather than restating it, and `docs/josh-commands-automation.md` → "`josh release:scope`" documents the command itself.