@joshuafolkken/kit 1.1094.0 → 1.1136.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 (177) hide show
  1. package/.claude/agents/investigator.md +17 -0
  2. package/.claude/settings.json +1 -0
  3. package/.claude/skills/workflow-commands/SKILL.md +1 -1
  4. package/.claude/skills/workflow-commands/background-commands.md +36 -85
  5. package/.claude/skills/workflow-commands/backlogrun-progress.md +31 -34
  6. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -2
  7. package/.claude/skills/workflow-commands/delegation.md +5 -1
  8. package/.claude/skills/workflow-commands/fullrun.md +5 -4
  9. package/.claude/skills/workflow-commands/halfrun.md +5 -4
  10. package/.claude/skills/workflow-commands/latest-gate.md +7 -15
  11. package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -8
  12. package/.claude/skills/workflow-commands/split-assessment.md +4 -18
  13. package/.github/actions/setup-node/action.yml +33 -0
  14. package/.github/actions/setup-pnpm/action.yml +4 -4
  15. package/.github/workflows/auto-tag.yml +2 -3
  16. package/.github/workflows/ci.yml +7 -8
  17. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  18. package/.github/workflows/pr-classification.yml +1 -3
  19. package/.github/workflows/production.yml +1 -0
  20. package/.github/workflows/publish.yml +10 -9
  21. package/.github/workflows/sonar-qube.yml +17 -13
  22. package/.pnpmfile.mjs +29 -0
  23. package/AGENTS.md +4 -11
  24. package/CLAUDE.md +24 -17
  25. package/GEMINI.md +4 -13
  26. package/README.md +8 -3
  27. package/SECURITY.md +2 -0
  28. package/cspell/index.yaml +4 -0
  29. package/dist/CLAUDE.basic.md +6 -6
  30. package/dist/CLAUDE.md +24 -17
  31. package/dist/CLAUDE.static.md +6 -6
  32. package/dist/config-merge/index.js +33 -9
  33. package/dist/hooks/batch-guard.js +4 -4
  34. package/dist/hooks/{chunk-OIZUHPJI.js → chunk-FO2I7UT7.js} +1 -1
  35. package/dist/hooks/{chunk-NKOG3XAV.js → chunk-GDU26K3J.js} +11 -3
  36. package/dist/hooks/{chunk-O4LL4HTI.js → chunk-HANSKHES.js} +2 -2
  37. package/dist/hooks/{chunk-3C4YCCEF.js → chunk-HBKZLITF.js} +4 -4
  38. package/dist/hooks/{chunk-UD24EAIL.js → chunk-JKKTVDAG.js} +1984 -146
  39. package/dist/hooks/{chunk-M2XDCUKD.js → chunk-KBXZLMRH.js} +89 -1803
  40. package/dist/hooks/{chunk-IL27YTPH.js → chunk-KOKOAV6D.js} +2 -2
  41. package/dist/hooks/{chunk-7ZPFAVR7.js → chunk-LJBLGHEH.js} +59 -19
  42. package/dist/hooks/{chunk-E54KGFEQ.js → chunk-NFZGFGOR.js} +1 -1
  43. package/dist/hooks/{chunk-KHWHKJOM.js → chunk-RFWWUI5F.js} +4 -4
  44. package/dist/hooks/codex-hook-adapter.js +12 -47
  45. package/dist/hooks/duplicate-read-guard.js +5 -5
  46. package/dist/hooks/format-edited.js +2 -2
  47. package/dist/hooks/inputs.json +175 -173
  48. package/dist/hooks/investigation-guard.js +5 -5
  49. package/dist/hooks/pretool-guard.js +9 -9
  50. package/dist/hooks/stop-guard.js +17 -12
  51. package/dist/josh.js +87 -31
  52. package/dist/version/index.js +36 -20
  53. package/eslint/rules/unicorn.js +3 -2
  54. package/package.json +23 -5
  55. package/pnpm-workspace.yaml +5 -7
  56. package/prompts/collaboration-workflow/glossary.md +17 -0
  57. package/prompts/collaboration-workflow/issue-template.md +1 -1
  58. package/prompts/collaboration-workflow/operating-rules.md +1 -1
  59. package/prompts/collaboration-workflow/principles.md +9 -0
  60. package/prompts/collaboration-workflow.md +1 -0
  61. package/scripts/backlog/backlog-arrival.ts +30 -10
  62. package/scripts/backlog/backlog-budget-cli.ts +15 -16
  63. package/scripts/backlog/backlog-offer-cli.ts +6 -14
  64. package/scripts/backlog/backlog-plan.ts +29 -18
  65. package/scripts/backlog/backlog-ready.ts +20 -7
  66. package/scripts/config-merge/parse-jsonc.ts +33 -3
  67. package/scripts/config-merge/patch-json-key.ts +13 -6
  68. package/scripts/cost-runtime/cost-cli.ts +3 -7
  69. package/scripts/delegation/delegation-policy.ts +9 -1
  70. package/scripts/delegation/investigation-guard.ts +1 -1
  71. package/scripts/delegation/investigation-reads.ts +105 -19
  72. package/scripts/doctor/doctor-consumer.ts +3 -5
  73. package/scripts/doctor/doctor-io.ts +9 -13
  74. package/scripts/document/backlogrun-parent-read-set.ts +12 -1
  75. package/scripts/document/capped-print.ts +74 -0
  76. package/scripts/document/document-byte-budget.ts +1 -0
  77. package/scripts/document/document-section-cli.ts +4 -3
  78. package/scripts/document/entry-read-budget.ts +13 -7
  79. package/scripts/document/entry-read-set.ts +32 -8
  80. package/scripts/document/lane-child-read-set.ts +7 -0
  81. package/scripts/document/read-set-point-of-use.ts +8 -3
  82. package/scripts/document/read-set-trim.ts +3 -1
  83. package/scripts/epic/epic-audit.ts +3 -18
  84. package/scripts/gate/scoped-green.ts +25 -14
  85. package/scripts/gh/git-gh-issue-list.ts +12 -11
  86. package/scripts/gh/git-gh-issue-write.ts +4 -1
  87. package/scripts/gh/git-gh-pr-auto-merge.ts +4 -1
  88. package/scripts/git/git-command.ts +6 -1
  89. package/scripts/git/git-spawn-sync.ts +65 -0
  90. package/scripts/git/git-spawn.ts +5 -4
  91. package/scripts/git/stash/stash-pop-args.ts +1 -6
  92. package/scripts/init/claude-kit-only-settings.ts +29 -0
  93. package/scripts/init/init-copy-content.ts +8 -2
  94. package/scripts/init/init-file-action.ts +54 -0
  95. package/scripts/init/init-logic-migrate.ts +35 -17
  96. package/scripts/init/init-logic-secretlint.ts +1 -1
  97. package/scripts/init/init-logic.ts +6 -22
  98. package/scripts/init/init.ts +11 -68
  99. package/scripts/init/initial-commit.ts +24 -0
  100. package/scripts/init/kit-written-paths.ts +2 -0
  101. package/scripts/init/project-profile.ts +3 -6
  102. package/scripts/init/start-exec.ts +16 -3
  103. package/scripts/init/start-steps.ts +10 -13
  104. package/scripts/issue/defect-rate-cli.ts +61 -20
  105. package/scripts/issue/issue-backlinks.ts +7 -14
  106. package/scripts/issue/issue-cite.ts +12 -6
  107. package/scripts/issue/issue-file.ts +5 -12
  108. package/scripts/issue/issue-read-cli.ts +1 -0
  109. package/scripts/issue/issue-report-failures.ts +6 -2
  110. package/scripts/issue/issue-state-cli.ts +1 -1
  111. package/scripts/issue/markdown-section.ts +21 -6
  112. package/scripts/issue/measure-rerun-cli.ts +73 -14
  113. package/scripts/josh/josh-commands-lane.ts +1 -1
  114. package/scripts/josh/josh-commands-project.ts +8 -1
  115. package/scripts/josh/josh-commands-workflow.ts +1 -1
  116. package/scripts/josh/run-github-prerequisite.ts +3 -6
  117. package/scripts/lane/lane-await.ts +89 -15
  118. package/scripts/lane/lane-cli.ts +11 -13
  119. package/scripts/lane/lane-close.ts +4 -1
  120. package/scripts/lane/lane-launch-cli.ts +10 -16
  121. package/scripts/lane/openai-review-broker.ts +4 -1
  122. package/scripts/lib/cli-flags.ts +31 -2
  123. package/scripts/lib/error-message.ts +12 -1
  124. package/scripts/lines/line-targets.ts +0 -11
  125. package/scripts/lines/lines-command.ts +5 -3
  126. package/scripts/propagate/propagate-git.ts +3 -8
  127. package/scripts/refactor/refactor-scan.ts +2 -1
  128. package/scripts/registry-migration/migrate-logic.ts +28 -19
  129. package/scripts/registry-migration/migrate.ts +6 -5
  130. package/scripts/release/github-release-cli.ts +14 -3
  131. package/scripts/release/github-release-environment.ts +1 -0
  132. package/scripts/release/github-release.ts +103 -17
  133. package/scripts/release/release-publish.ts +36 -2
  134. package/scripts/release/release-worktree.ts +97 -2
  135. package/scripts/review/review-checkout.ts +3 -2
  136. package/scripts/review/review-record-cli.ts +9 -16
  137. package/scripts/rules/early-heartbeat.ts +4 -3
  138. package/scripts/rules/lane-park.ts +9 -0
  139. package/scripts/rules/stop-rules.ts +7 -4
  140. package/scripts/run/run-carry-args.ts +8 -12
  141. package/scripts/run/run-carry-cli.ts +3 -1
  142. package/scripts/run/run-carry-conversation.ts +94 -0
  143. package/scripts/run/run-carry-reclaim.ts +37 -0
  144. package/scripts/run/run-carry.ts +21 -5
  145. package/scripts/run/run-cut-args.ts +1 -6
  146. package/scripts/run/run-ending-cli.ts +1 -5
  147. package/scripts/run/run-entry-cli.ts +40 -9
  148. package/scripts/run/run-entry-stop.ts +104 -0
  149. package/scripts/run/run-event-stream-emit.ts +7 -2
  150. package/scripts/run/run-hold.ts +4 -1
  151. package/scripts/run/run-liveness-cli.ts +1 -5
  152. package/scripts/run/run-merge-cli.ts +2 -10
  153. package/scripts/run/run-merge-steps.ts +4 -1
  154. package/scripts/run/run-parent-cut-hook.ts +4 -1
  155. package/scripts/run/run-prep-cli.ts +4 -1
  156. package/scripts/run/run-progress-args.ts +20 -4
  157. package/scripts/run/run-progress-cli.ts +31 -29
  158. package/scripts/run/run-progress-clock.ts +4 -1
  159. package/scripts/run/run-ship-cli.ts +8 -16
  160. package/scripts/run/run-ship-probe.ts +4 -1
  161. package/scripts/run/run-status-cli.ts +2 -15
  162. package/scripts/run/run-tidy-lanes.ts +4 -1
  163. package/scripts/run/run-wake-cli.ts +2 -10
  164. package/scripts/run/run-watcher-guard.ts +9 -5
  165. package/scripts/safe-chain/pack-hook.ts +92 -0
  166. package/scripts/sync/sync-ai-files.ts +4 -0
  167. package/scripts/test/test-declared-changed.ts +13 -16
  168. package/scripts/test/test-declared.ts +3 -7
  169. package/scripts/test/test-related.ts +1 -6
  170. package/scripts/test/test-unit-guard.ts +21 -7
  171. package/scripts/time-runtime/time-spans.ts +4 -0
  172. package/scripts/version/version-commands.ts +11 -1
  173. package/templates/CLAUDE.basic.md +6 -6
  174. package/templates/workflows/ci.yml +16 -82
  175. package/templates/workflows/dependabot-auto-merge.yml +1 -0
  176. package/templates/workflows/github-release.yml +14 -28
  177. package/tsconfig.sonar.json +4 -22
@@ -55,10 +55,23 @@ const CONTENT_READ_LABELS: ReadonlySet<string> = new Set(
55
55
  CONTENT_READ_COMMANDS.map((command) => time_shell.bash_label(command)),
56
56
  )
57
57
 
58
+ // **The searches carry no file text, so they are counted in turns rather than files**
59
+ // (joshuafolkken/kit#3139). The file count above never saw a `grep`, yet searches were the commonest
60
+ // exploration turn a lane took — 457 of them in one day, each re-reading the main line's whole context.
61
+ // What a search costs is the turn, so a turn is the unit: parallel searches in one turn count once.
62
+ const SEARCH_COMMANDS: ReadonlyArray<string> = ['fd', 'find', 'grep', 'rg']
63
+ const SEARCH_LABELS: ReadonlySet<string> = new Set(
64
+ SEARCH_COMMANDS.map((command) => time_shell.bash_label(command)),
65
+ )
66
+
67
+ // The unit the refusal sends the reading to (joshuafolkken/kit#3139): `.claude/agents/investigator.md`,
68
+ // the parent's model at a lowered effort. A consumer receives it through the plugin as `kit:investigator`.
69
+ const INVESTIGATOR_AGENT = 'investigator'
70
+
58
71
  // What the refusal tells the model. It has to name the count, the command and the return shape,
59
72
  // because the deny reason is the only text that reaches the model — a run that is refused and told
60
73
  // nothing reads it as a broken tool.
61
- const REASON = `⛔ investigation: ${String(delegation_policy.INVESTIGATION_FILE_THRESHOLD)} files read and not edited since the last delegated unit, so the reading from here goes to a unit of its own. Ask \`pnpm josh delegate investigation\`, then brief a unit with what the main line has already concluded and what is left to find out; it returns the conclusion plus its \`file:line\` citations, never the file text. Keep a read in the main line only where this run will edit that file — an \`Edit\` cannot be issued against text you do not hold. This run's own instructions and the harness's own session files — a backgrounded call's output, a persisted tool result, this session's scratchpad — are never counted, so they are not what took the count here. The rule is \`.claude/skills/workflow-commands/SKILL.md\` → §2b, "The pre-implementation reading".`
74
+ const REASON = `⛔ investigation: ${String(delegation_policy.INVESTIGATION_FILE_THRESHOLD)} files read and not edited, or ${String(delegation_policy.INVESTIGATION_SEARCH_TURN_THRESHOLD)} search turns, since the last delegated unit, so the reading from here goes to a unit of its own (a successful write also starts the search count over). Ask \`pnpm josh delegate investigation\`, then dispatch the \`${INVESTIGATOR_AGENT}\` agent (\`kit:${INVESTIGATOR_AGENT}\` in a consumer) with a brief of what the main line has already concluded and what is left to find out; it returns the conclusion plus its \`file:line\` citations, never the file text. Keep a read in the main line only where this run will edit that file — an \`Edit\` cannot be issued against text you do not hold. This run's own instructions and the harness's own session files — a backgrounded call's output, a persisted tool result, this session's scratchpad — never count toward the files, so they are not what took the file count here; every read-only search turn counts, whatever it names. The rule is \`.claude/skills/workflow-commands/SKILL.md\` → §2b, "The pre-implementation reading".`
62
75
 
63
76
  interface ReadTally {
64
77
  // The files read and not since edited, resolved so the two ways a target reaches here compare.
@@ -74,6 +87,10 @@ interface ReadTally {
74
87
  // The first instant the window covers. A refusal recorded before it cannot be checked against a
75
88
  // delegation any more, because the delegation that would re-arm it has scrolled out.
76
89
  window_start_ms: number
90
+ // The search turns since the last delegation or successful write, and how many of them came after
91
+ // the instant asked about — the search-side `pending` / `pending_since` (joshuafolkken/kit#3139).
92
+ searches: number
93
+ searches_since: number
77
94
  // The files this run has successfully edited within the window, resolved (joshuafolkken/kit#1840).
78
95
  // A re-read of one of them is a read of a file the run edited, which §2b keeps in the main line — so
79
96
  // it is excluded from the count and from a call's projected growth rather than counted as fresh
@@ -279,18 +296,65 @@ function add_all(
279
296
  // the edit's own span, so a read issued *after* the edit was added and stayed. `sed -i`, `MultiEdit`
280
297
  // and `NotebookEdit` need no special case any more — each is a write, so its target is in `edited` and
281
298
  // never enters the read count.
282
- function apply_span(pending: Map<string, number>, span: Span, edited: ReadonlySet<string>): void {
299
+ interface Accumulation {
300
+ // Read files, keyed by resolved path, valued by the instant each entered.
301
+ pending: Map<string, number>
302
+ // Search turns, keyed by message id so a turn's parallel searches count once, valued the same way.
303
+ searches: Map<string, number>
304
+ }
305
+
306
+ function is_read_only_search(span: Span): boolean {
307
+ return SEARCH_LABELS.has(span.label) && span.is_bundleable && !span.may_write
308
+ }
309
+
310
+ // **A span with no message id belongs to no known turn, so it is a turn of its own** — the reading every
311
+ // other turn counter gives `NO_MESSAGE_ID` (`time-round-trips.ts`, `time-bundles.ts`). Keyed by the
312
+ // id, every untagged search would collapse into one entry and the count would never reach its threshold.
313
+ function search_turn_key(searches: ReadonlyMap<string, number>, span: Span): string {
314
+ if (span.message_id !== time_spans.NO_MESSAGE_ID) return span.message_id
315
+
316
+ return `${time_spans.NO_MESSAGE_ID}#${String(searches.size)}`
317
+ }
318
+
319
+ // **A successful write ends the streak.** The measurement behind the threshold counted searches since
320
+ // the last edit or delegation: a run that has started writing is implementing, and the searches that
321
+ // follow locate the next edit rather than continue an investigation.
322
+ //
323
+ // **Every read-only search counts, whatever it names** (joshuafolkken/kit#3139). The file count can
324
+ // exempt the run's own instructions because a read names each file it reads; a search does not — the
325
+ // targets drop a bare directory, so `grep -rn foo CLAUDE.md scripts` looked like a search of
326
+ // `CLAUDE.md` alone and an exemption let the whole sweep through. `is_bundleable` without `may_write`
327
+ // is the read-only test `is_search_call` asks at the call, so the transcript never counts a search the
328
+ // guard could not have refused.
329
+ function apply_search(searches: Map<string, number>, span: Span): void {
330
+ if (span.is_writing) {
331
+ searches.clear()
332
+
333
+ return
334
+ }
335
+
336
+ const turn = search_turn_key(searches, span)
337
+
338
+ if (is_read_only_search(span) && !searches.has(turn)) searches.set(turn, span.ended_ms)
339
+ }
340
+
341
+ function apply_span(accumulation: Accumulation, span: Span, edited: ReadonlySet<string>): void {
283
342
  if (DELEGATION_TOOLS.has(span.label)) {
284
- pending.clear()
343
+ accumulation.pending.clear()
344
+ accumulation.searches.clear()
285
345
 
286
346
  return
287
347
  }
288
348
 
289
- if (!is_content_read(span.label) || is_failed_outcome(span)) return
349
+ if (is_failed_outcome(span)) return
350
+
351
+ apply_search(accumulation.searches, span)
352
+
353
+ if (!is_content_read(span.label)) return
290
354
 
291
355
  const fresh = subject_targets(span.targets).filter((target) => !edited.has(target))
292
356
 
293
- add_all(pending, fresh, span.ended_ms)
357
+ add_all(accumulation.pending, fresh, span.ended_ms)
294
358
  }
295
359
 
296
360
  function last_delegation_ms(spans: ReadonlyArray<Span>): number {
@@ -307,15 +371,17 @@ function pending_after(pending: ReadonlyMap<string, number>, since_ms: number):
307
371
  function tally_of(text: string, since_ms: number = hook_decision.NEVER_MS): ReadTally {
308
372
  const { spans, started_ms } = time_spans.parse_timeline(text)
309
373
  const edited = edited_files(spans)
310
- const pending = new Map<string, number>()
374
+ const accumulation: Accumulation = { pending: new Map(), searches: new Map() }
311
375
 
312
- for (const span of spans) apply_span(pending, span, edited)
376
+ for (const span of spans) apply_span(accumulation, span, edited)
313
377
 
314
378
  return {
315
- pending: [...pending.keys()],
316
- pending_since: pending_after(pending, since_ms),
379
+ pending: [...accumulation.pending.keys()],
380
+ pending_since: pending_after(accumulation.pending, since_ms),
317
381
  reset_ms: last_delegation_ms(spans),
318
382
  window_start_ms: started_ms,
383
+ searches: accumulation.searches.size,
384
+ searches_since: pending_after(accumulation.searches, since_ms),
319
385
  edited,
320
386
  }
321
387
  }
@@ -347,6 +413,11 @@ function is_at_threshold(pending_count: number): boolean {
347
413
  return pending_count + 1 >= delegation_policy.INVESTIGATION_FILE_THRESHOLD
348
414
  }
349
415
 
416
+ // The search turn that takes the count up to its threshold is the boundary, as above.
417
+ function is_at_search_threshold(search_count: number): boolean {
418
+ return search_count + 1 >= delegation_policy.INVESTIGATION_SEARCH_TURN_THRESHOLD
419
+ }
420
+
350
421
  // **One refusal per accumulation, re-armed by a delegation and by nothing else.** A run that reads on
351
422
  // regardless is not refused again, so a false positive — three files the run was about to edit — costs
352
423
  // one round trip rather than wedging the run. And a run that *does* delegate is refused again as soon
@@ -386,7 +457,7 @@ function is_rearmed(tally: ReadTally, refused_at_ms: number): boolean {
386
457
  return true
387
458
  }
388
459
 
389
- return is_at_threshold(tally.pending_since)
460
+ return is_at_threshold(tally.pending_since) || is_at_search_threshold(tally.searches_since)
390
461
  }
391
462
 
392
463
  // A transcript under a session's `subagents/` directory is a delegated unit's. The segment is
@@ -403,10 +474,19 @@ function is_unit_run(run: GuardRun | undefined): boolean {
403
474
  return run !== undefined && is_unit_transcript(run.transcript)
404
475
  }
405
476
 
406
- function is_refusable_command(call: GuardedCall): boolean {
477
+ function is_read_only_command(call: GuardedCall, labels: ReadonlySet<string>): boolean {
478
+ if (call.name !== cost_blocks.BASH_TOOL) return false
479
+
407
480
  const label = time_shell.bash_label(time_shell.bash_command(call.input))
408
481
 
409
- return CONTENT_READ_LABELS.has(label) && time_batch_guard.is_read_only_call(call)
482
+ return labels.has(label) && time_batch_guard.is_read_only_call(call)
483
+ }
484
+
485
+ // **A search is refusable without a subject target**, unlike a read: `grep -rn foo scripts` names a
486
+ // bare directory, which `call_facts` deliberately does not take as a target, and such a sweep is exactly
487
+ // the search this count exists for.
488
+ function is_search_call(call: GuardedCall): boolean {
489
+ return is_read_only_command(call, SEARCH_LABELS)
410
490
  }
411
491
 
412
492
  // **A `Read` is always safe to refuse; a `Bash` line is refused only where it writes nothing.** This
@@ -427,10 +507,20 @@ function is_refusable_command(call: GuardedCall): boolean {
427
507
  // rule being enforced — and a call whose targets could not be read at all, which cannot move the count
428
508
  // and so has nothing to be refused for.
429
509
  function is_refusable_call(call: GuardedCall): boolean {
510
+ if (is_search_call(call)) return true
430
511
  if (call_targets(call).length === 0) return false
431
512
  if (READ_TOOLS.has(call.name)) return true
432
513
 
433
- return call.name === cost_blocks.BASH_TOOL && is_refusable_command(call)
514
+ return is_read_only_command(call, CONTENT_READ_LABELS)
515
+ }
516
+
517
+ // A search call grows the search count by its own turn; a read grows the file count only by a file the
518
+ // count does not already hold.
519
+ function is_growing_past_threshold(tally: ReadTally, call: GuardedCall): boolean {
520
+ if (is_search_call(call)) return is_at_search_threshold(tally.searches)
521
+ if (!is_at_threshold(tally.pending.length)) return false
522
+
523
+ return projected_count(tally.pending, call, tally.edited) > tally.pending.length
434
524
  }
435
525
 
436
526
  function should_block(
@@ -446,15 +536,11 @@ function should_block(
446
536
 
447
537
  const tally = tally_of(text, refused_at_ms)
448
538
 
449
- if (!is_at_threshold(tally.pending.length)) return false
450
-
451
- return (
452
- projected_count(tally.pending, call, tally.edited) > tally.pending.length &&
453
- is_rearmed(tally, refused_at_ms)
454
- )
539
+ return is_growing_past_threshold(tally, call) && is_rearmed(tally, refused_at_ms)
455
540
  }
456
541
 
457
542
  const investigation_reads = {
543
+ INVESTIGATOR_AGENT,
458
544
  READ_TOOLS,
459
545
  REASON,
460
546
  is_at_threshold,
@@ -1,10 +1,9 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
4
  import { json_value } from '#scripts/lib/json-value'
4
5
  import { file_reader } from '#scripts/lib/read-file'
5
- import { read_spawn_stdout } from '#scripts/lib/spawn-exit'
6
6
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
7
- import { execaSync } from 'execa'
8
7
  import { z } from 'zod'
9
8
 
10
9
  // The consumer-setup checks `josh doctor` runs in a repository that installs kit (joshuafolkken/kit#1930).
@@ -77,11 +76,9 @@ function is_plugin_declared(root: string): boolean {
77
76
  // The value of `core.hooksPath`, or undefined when it is unset. A set value is what stops lefthook
78
77
  // from installing kit's git hooks — the failure is silent but for one stderr line.
79
78
  function configured_hooks_path(root: string): string | undefined {
80
- const result = execaSync('git', ['-C', root, 'config', '--get', 'core.hooksPath'], {
81
- reject: false,
79
+ const value = git_spawn_sync.read(['-C', root, 'config', '--get', 'core.hooksPath'], {
82
80
  timeout: GIT_TIMEOUT_MS,
83
81
  })
84
- const value = read_spawn_stdout(result).trim()
85
82
 
86
83
  return value === '' ? undefined : value
87
84
  }
@@ -159,6 +156,7 @@ function report_consumer_setup(root: string): void {
159
156
  const doctor_consumer = {
160
157
  claude_md_line,
161
158
  claude_md_state,
159
+ configured_hooks_path,
162
160
  hooks_path_line,
163
161
  is_kit_consumer,
164
162
  is_plugin_declared,
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { read_spawn_stderr, read_spawn_stdout } from '#scripts/lib/spawn-exit'
3
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
4
+ import { read_spawn_stdout } from '#scripts/lib/spawn-exit'
4
5
  import { execaSync } from 'execa'
5
6
  import { doctor_logic } from './doctor-logic'
6
7
 
@@ -69,18 +70,13 @@ function classify_git_failure(exit_code: number | undefined, stderr: string): Gi
69
70
  // caller's check, because reporting an unknown as a clean result is the false all-clear
70
71
  // joshuafolkken/kit#805 exists to remove. See the two classifiers above for what proves what.
71
72
  function resolve_git_top_level(): GitTopLevel {
72
- const result = execaSync('git', ['rev-parse', '--show-toplevel'], {
73
- reject: false,
74
- timeout: GIT_TIMEOUT_MS,
75
- // git translates its messages, so the match below would fail under any non-English locale and
76
- // the spurious warning would come back. `C` pins the output this code was written against.
77
- env: { LC_ALL: 'C', LANGUAGE: 'C' },
78
- extendEnv: true,
79
- })
80
-
81
- if (result.exitCode === 0) return classify_top_level(read_spawn_stdout(result).trim())
82
-
83
- return classify_git_failure(result.exitCode, read_spawn_stderr(result))
73
+ // The C locale `toplevel` asks under is what keeps the stderr match below from failing on a
74
+ // translated message and bringing the spurious warning back.
75
+ const result = git_spawn_sync.toplevel(GIT_TIMEOUT_MS)
76
+
77
+ if (result.exit_code === 0) return classify_top_level(result.stdout.trim())
78
+
79
+ return classify_git_failure(result.exit_code, result.stderr)
84
80
  }
85
81
 
86
82
  // Whether one line is the setting itself rather than prose about it. kit's own template explains the
@@ -34,15 +34,26 @@ const UNUSED_SKILL_SECTIONS: ReadonlyArray<string> = [
34
34
  // the park — so it drops none. Named empty so the shared trim reads the same shape as the worker's.
35
35
  const SKIPPED_POINT_OF_USE: ReadonlySet<string> = new Set<string>()
36
36
 
37
+ // **The parent runs the end-of-run retrospective** (`SKILL.md` §2j) when `run:step` prints it, but its
38
+ // manifest names `retrospective.md` only through `backlogrun-steps.md` — one hop further than the
39
+ // derivation follows — so the parent's reach names it (joshuafolkken/kit#3078).
40
+ const REACHED_POINT_OF_USE: ReadonlySet<string> = new Set(['retrospective.md'])
41
+
37
42
  function costed(root: string): ReadSetCost {
38
43
  return read_set_trim.costed(root, {
39
44
  base_entry: BACKLOGRUN,
40
45
  label: BACKLOGRUN,
41
46
  unused_skill_sections: UNUSED_SKILL_SECTIONS,
42
47
  skipped_point_of_use: SKIPPED_POINT_OF_USE,
48
+ reached_point_of_use: REACHED_POINT_OF_USE,
43
49
  })
44
50
  }
45
51
 
46
- const backlogrun_parent_read_set = { BACKLOGRUN, UNUSED_SKILL_SECTIONS, costed }
52
+ const backlogrun_parent_read_set = {
53
+ BACKLOGRUN,
54
+ REACHED_POINT_OF_USE,
55
+ UNUSED_SKILL_SECTIONS,
56
+ costed,
57
+ }
47
58
 
48
59
  export { backlogrun_parent_read_set }
@@ -0,0 +1,74 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { PLATFORM_TEMP_ROOT } from '#scripts/josh/platform-temporary'
4
+ import { review_diff_parts } from '#scripts/review/review-diff-parts'
5
+ import { bash_output_cap_reader } from './bash-output-cap'
6
+
7
+ // Prints a command's output whole when it fits under the Bash output cap, and as part files when it
8
+ // does not (joshuafolkken/kit#3143). Past `BASH_MAX_OUTPUT_LENGTH` Claude Code saves the output and
9
+ // hands back a 2 KB preview, and the run spends a turn reading the saved file — 224 overflows and 132
10
+ // re-reads over one day's sessions, `doc:section` and `issue:read` the two most frequent `josh` sources.
11
+ //
12
+ // **The cut is #2963's, not a second one.** The parts come from `review_diff_parts.part_texts`, so each
13
+ // file — header included — fits under the cap and the bodies concatenate back to exactly the output.
14
+ // A reader opens them all with the Read tool in one turn, which is the turn the preview used to cost.
15
+ //
16
+ // **A fresh directory per call**, unlike the review's per-checkout one: a run fetches several sections
17
+ // in one turn, and a shared directory replaced on every call would delete one call's parts while
18
+ // another call's listing still names them.
19
+
20
+ const DIRECTORY_PREFIX = 'josh-capped-print-'
21
+ const FILE_MODE = 0o600
22
+ const PART_SUFFIX = '.md'
23
+ const LINE_BREAK = '\n'
24
+ // What else shares the Bash result with this output: the launcher line, a script banner, a few
25
+ // stderr lines. Measured at about 80 characters for `pnpm josh doc:section` in kit, so 512 holds the
26
+ // longest heading or issue list with room for a consumer's banner.
27
+ const HEADROOM_CHARS = 512
28
+
29
+ function write_parts(label: string, text: string, cap: number): Array<string> {
30
+ const directory = mkdtempSync(path.join(PLATFORM_TEMP_ROOT, DIRECTORY_PREFIX))
31
+
32
+ return review_diff_parts.part_texts(label, text, cap).map((part, index) => {
33
+ const file = path.join(directory, `part-${String(index + 1)}${PART_SUFFIX}`)
34
+
35
+ writeFileSync(file, part, { flag: 'wx', mode: FILE_MODE })
36
+
37
+ return file
38
+ })
39
+ }
40
+
41
+ function part_count(count: number): string {
42
+ return `written as ${String(count)} part file${count === 1 ? '' : 's'} under it`
43
+ }
44
+
45
+ function listing(text: string, cap: number, files: ReadonlyArray<string>): string {
46
+ const figures = `${text.length.toLocaleString('en-US')} > ${cap.toLocaleString('en-US')} characters`
47
+ const lead = `Over the Bash cap (${figures}) — ${part_count(files.length)}, together exactly the output. Read every part with the Read tool, in one turn, not cat:`
48
+
49
+ return [lead, ...files.map((file) => ` ${file}`)].join(LINE_BREAK)
50
+ }
51
+
52
+ // **The budget is the cap less a headroom, not the cap.** The Bash result carries more than this
53
+ // output — `pnpm josh`'s own launcher line, a consumer's script banner, the stderr lines naming a
54
+ // number that did not resolve — so a text measured against the bare cap would overflow by exactly
55
+ // those lines, and cost the re-read this module exists to remove.
56
+ function output_budget(root: string): number {
57
+ return Math.max(1, bash_output_cap_reader.bash_output_cap(root) - HEADROOM_CHARS)
58
+ }
59
+
60
+ function capped_print(text: string, label: string, root: string = process.cwd()): void {
61
+ const budget = output_budget(root)
62
+
63
+ if (text.length < budget) {
64
+ console.info(text)
65
+
66
+ return
67
+ }
68
+
69
+ console.info(listing(text, budget, write_parts(label, text, budget)))
70
+ }
71
+
72
+ const capped_output = { HEADROOM_CHARS, capped_print }
73
+
74
+ export { capped_output }
@@ -88,6 +88,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
88
88
  { path: 'prompts/collaboration-workflow.md', bytes: 8192 },
89
89
  { path: 'prompts/collaboration-workflow/file-edits.md', bytes: 12_288 },
90
90
  { path: 'prompts/collaboration-workflow/gh-rest.md', bytes: 8192 },
91
+ { path: 'prompts/collaboration-workflow/glossary.md', bytes: 4096 },
91
92
  { path: 'prompts/collaboration-workflow/issue-citation.md', bytes: 8192 },
92
93
  // #2662 adds the feature and compatibility declarations to the canonical filing procedure.
93
94
  { path: 'prompts/collaboration-workflow/issue-template.md', bytes: 28_672 },
@@ -2,6 +2,7 @@
2
2
  import { existsSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
+ import { capped_output } from './capped-print'
5
6
  import { document_section } from './document-section'
6
7
  import { entry_read_set } from './entry-read-set'
7
8
 
@@ -56,12 +57,12 @@ function report_missing(markdown: string, heading: string): number {
56
57
  return FAILURE_EXIT_CODE
57
58
  }
58
59
 
59
- function print_section(markdown: string, heading: string): number {
60
+ function print_section(markdown: string, heading: string, root: string): number {
60
61
  const found = document_section.section(markdown, heading)
61
62
 
62
63
  if (found === undefined) return report_missing(markdown, heading)
63
64
 
64
- console.info(found.text)
65
+ capped_output.capped_print(found.text, heading, root)
65
66
 
66
67
  return 0
67
68
  }
@@ -87,7 +88,7 @@ function run(argv: ReadonlyArray<string>, root: string = process.cwd()): number
87
88
 
88
89
  const markdown = read_named(name, root)
89
90
 
90
- return markdown === undefined ? FAILURE_EXIT_CODE : print_section(markdown, heading)
91
+ return markdown === undefined ? FAILURE_EXIT_CODE : print_section(markdown, heading, root)
91
92
  }
92
93
 
93
94
  function main(argv: ReadonlyArray<string>): void {
@@ -3,8 +3,9 @@
3
3
  //
4
4
  // **The per-document ceiling was a proxy for a run's cost, not the cost.** A run does not pay "one
5
5
  // document's size"; it pays the total its entry reads — the mandated skill files, the sections they
6
- // point out to, the point-of-use documents reached later, and the resident `CLAUDE.md` every request
7
- // carries. `read:set` already computes that total, so this budgets it directly: the entry total is
6
+ // point out to, the point-of-use documents that entry reaches later, and the resident `CLAUDE.md` every
7
+ // request carries. `read:set`'s `total read` is that sum less `CLAUDE.md`, which `entry_total_bytes`
8
+ // adds back (`RESIDENT_BASE`), so this budgets the whole directly: the entry total is
8
9
  // the primary ceiling, and the per-document ceiling (`document-byte-budget.ts`) falls back to the
9
10
  // documents no entry reads (`document-reachability.ts` draws the line). A document an entry reads is
10
11
  // held by the entry total and carries no per-document ceiling, so neither budget holds it twice.
@@ -86,11 +87,16 @@ const ENTRY_READ_BUDGET: ReadonlyArray<EntryBudget> = [
86
87
  // `wip-cap.md`, so its ceiling stays.
87
88
  // joshuafolkken/kit#3023 added the `prrun` keyword to `SKILL.md` and `CLAUDE.md`, tipping backlogrun
88
89
  // one block over; `prrun` reads `fullrun`'s set plus its own short manifest.
89
- { entry: 'kickoff', bytes: 229_376 },
90
- { entry: 'fullrun', bytes: 225_280 },
91
- { entry: 'halfrun', bytes: 225_280 },
92
- { entry: 'prrun', bytes: 229_376 },
93
- { entry: 'backlogrun', bytes: 233_472 },
90
+ // Lowered in joshuafolkken/kit#3078 — a correction of an over-count, not a trim: every entry was
91
+ // charged for every global point-of-use document, so `kickoff` paid for `followup.md` and the
92
+ // `backlogrun-*` phase documents it never opens. Each total now counts only what its path reaches.
93
+ // joshuafolkken/kit#3077 trimmed non-resident rule bodies from `CLAUDE.md`, lowering fullrun, prrun
94
+ // and backlogrun one block each.
95
+ { entry: 'kickoff', bytes: 65_536 },
96
+ { entry: 'fullrun', bytes: 126_976 },
97
+ { entry: 'halfrun', bytes: 106_496 },
98
+ { entry: 'prrun', bytes: 131_072 },
99
+ { entry: 'backlogrun', bytes: 212_992 },
94
100
  { entry: LANE_CHILD, bytes: 77_824 },
95
101
  ]
96
102
 
@@ -420,16 +420,40 @@ function point_of_use_cost(
420
420
  return { file, heading: '', cost: cost_of(read_document(root, file)), is_resolved: true }
421
421
  }
422
422
 
423
- function point_of_use_costs(root: string, entry: string): Array<SectionCost> {
424
- const found = document_section.section(read_document(root, SKILL_FILE), TABLE_SECTION)
425
- const references = all_matches(found?.text ?? '', SECTION_REFERENCE).map((match) =>
426
- to_reference(match),
427
- )
423
+ // **What an entry's path names: its own manifests, and the §1 trigger rows that name the entry**
424
+ // (joshuafolkken/kit#3078). `SKILL.md`'s other text is left out for the reason `sections_for` gives —
425
+ // §2 names every document, so counting it would charge a `kickoff` for `followup.md`. One hop only:
426
+ // the point-of-use documents cite one another, and following them would reach the whole set again.
427
+ // `also` is what a role reaches beside its base entry's path (`read-set-trim.ts`).
428
+ function cited_by(
429
+ root: string,
430
+ entry: string,
431
+ table_text: string,
432
+ also: ReadonlySet<string>,
433
+ ): Set<string> {
434
+ const manifests = files_for(root, entry).filter((file) => file !== SKILL_FILE)
435
+ const rows = table_text
436
+ .split('\n')
437
+ .filter((line) => line.startsWith('|') && line.includes(`\`${entry}\``))
438
+ const texts = [...manifests.map((file) => read_document(root, file)), ...rows]
439
+
440
+ return new Set([...also, ...texts.flatMap((text) => names_in(text))])
441
+ }
442
+
443
+ function point_of_use_costs(
444
+ root: string,
445
+ entry: string,
446
+ also: ReadonlySet<string>,
447
+ ): Array<SectionCost> {
448
+ const table_text =
449
+ document_section.section(read_document(root, SKILL_FILE), TABLE_SECTION)?.text ?? ''
450
+ const references = all_matches(table_text, SECTION_REFERENCE).map((match) => to_reference(match))
451
+ const cited = cited_by(root, entry, table_text, also)
428
452
 
429
- return point_of_use_files(entry).map((file) => point_of_use_cost(root, file, references))
453
+ return point_of_use_files(entry, cited).map((file) => point_of_use_cost(root, file, references))
430
454
  }
431
455
 
432
- function costed(root: string, entry: string): ReadSetCost {
456
+ function costed(root: string, entry: string, also: ReadonlySet<string> = new Set()): ReadSetCost {
433
457
  const { files, sections } = read_set(root, entry)
434
458
  const own_costs = file_costs(root, files)
435
459
  const section_costs = sections.map((reference) => section_cost(root, reference))
@@ -439,7 +463,7 @@ function costed(root: string, entry: string): ReadSetCost {
439
463
  entry,
440
464
  files: own_costs,
441
465
  sections: section_costs,
442
- point_of_use: point_of_use_costs(root, entry),
466
+ point_of_use: point_of_use_costs(root, entry, also),
443
467
  bash_output_cap: bash_output_cap_reader.bash_output_cap(root),
444
468
  whole: total([own, referenced_cost(root, sections)]),
445
469
  scoped: total([own, scoped_cost(root, sections)]),
@@ -64,17 +64,24 @@ const SKIPPED_POINT_OF_USE: ReadonlySet<string> = new Set([
64
64
  'retrospective.md',
65
65
  ])
66
66
 
67
+ // **A child that parks reads `backlogrun-park.md`** (`SKILL.md` §1, "A lane child that parks"), a
68
+ // document a standalone `fullrun`'s path never names — so the child's reach adds it to its base
69
+ // entry's (joshuafolkken/kit#3078).
70
+ const REACHED_POINT_OF_USE: ReadonlySet<string> = new Set(['backlogrun-park.md'])
71
+
67
72
  function costed(root: string): ReadSetCost {
68
73
  return read_set_trim.costed(root, {
69
74
  base_entry: FULLRUN,
70
75
  label: LANE_CHILD,
71
76
  unused_skill_sections: UNUSED_SKILL_SECTIONS,
72
77
  skipped_point_of_use: SKIPPED_POINT_OF_USE,
78
+ reached_point_of_use: REACHED_POINT_OF_USE,
73
79
  })
74
80
  }
75
81
 
76
82
  const lane_child_read_set = {
77
83
  LANE_CHILD,
84
+ REACHED_POINT_OF_USE,
78
85
  SKIPPED_POINT_OF_USE,
79
86
  UNUSED_SKILL_SECTIONS,
80
87
  costed,
@@ -75,9 +75,14 @@ function is_point_of_use(entry: string, file: string): boolean {
75
75
  return (POINT_OF_USE_BY_ENTRY.get(entry) ?? NO_PER_ENTRY_FILES).has(file)
76
76
  }
77
77
 
78
- // The global point-of-use files plus the ones this entry names in its own row.
79
- function point_of_use_files(entry: string): Array<string> {
80
- return [...POINT_OF_USE_FILES, ...(POINT_OF_USE_BY_ENTRY.get(entry) ?? NO_PER_ENTRY_FILES)]
78
+ // **Only the global files the entry reaches, plus the ones this entry names in its own row**
79
+ // (joshuafolkken/kit#3078). `cited` is every document the entry's path names; a global file outside it
80
+ // is one this entry never opens — a `kickoff` never issues `followup` — so charging it made every
81
+ // entry's total the same figure and the per-entry ratchet measured nothing.
82
+ function point_of_use_files(entry: string, cited: ReadonlySet<string>): Array<string> {
83
+ const reached = [...POINT_OF_USE_FILES].filter((file) => cited.has(file))
84
+
85
+ return [...reached, ...(POINT_OF_USE_BY_ENTRY.get(entry) ?? NO_PER_ENTRY_FILES)]
81
86
  }
82
87
 
83
88
  const read_set_point_of_use = {
@@ -24,6 +24,8 @@ interface TrimSpec {
24
24
  unused_skill_sections: ReadonlyArray<string>
25
25
  // The point-of-use documents this role's parent owns; empty when the role owns them all.
26
26
  skipped_point_of_use: ReadonlySet<string>
27
+ // The point-of-use documents this role reaches that its base entry's path does not name.
28
+ reached_point_of_use: ReadonlySet<string>
27
29
  }
28
30
 
29
31
  function subtract(left: Cost, right: Cost): Cost {
@@ -59,7 +61,7 @@ function reduce_skill(file: FileCost, saving: Cost): FileCost {
59
61
  // **`whole` and `scoped` both carry the own-files cost identically**, so the `SKILL.md` saving lands
60
62
  // on each by the same amount — there is no need to re-derive the referenced-section split.
61
63
  function costed(root: string, spec: TrimSpec): ReadSetCost {
62
- const base = entry_read_set.costed(root, spec.base_entry)
64
+ const base = entry_read_set.costed(root, spec.base_entry, spec.reached_point_of_use)
63
65
  const saving = unused_skill_cost(root, spec.unused_skill_sections)
64
66
 
65
67
  return {
@@ -1,3 +1,4 @@
1
+ import { markdown_section } from '#scripts/issue/markdown-section'
1
2
  import { epic_graph, type EpicChild, type IssueReference } from './epic-graph'
2
3
 
3
4
  // Reading an epic's children against each other.
@@ -43,7 +44,6 @@ const ACCEPTANCE_HEADINGS: ReadonlySet<string> = new Set([
43
44
  '## 受け入れ条件',
44
45
  '## Acceptance criteria',
45
46
  ])
46
- const HEADING_PREFIX = '## '
47
47
 
48
48
  type FindingLevel = 'error' | 'warning'
49
49
 
@@ -169,26 +169,11 @@ function is_acceptance_heading(line: string): boolean {
169
169
  return ACCEPTANCE_HEADINGS.has(line.trim())
170
170
  }
171
171
 
172
- function is_heading(line: string): boolean {
173
- return line.trimStart().startsWith(HEADING_PREFIX)
174
- }
175
-
176
- // The lines under the acceptance-criteria heading, up to the next `##`. Shaped like the other
177
- // section readers in this repository, for the same reason: a regex spanning the block backtracks.
178
- function section_lines(lines: ReadonlyArray<string>): Array<string> {
179
- const start = lines.findIndex((line) => is_acceptance_heading(line))
180
- if (start === -1) return []
181
- const rest = lines.slice(start + 1)
182
- const end = rest.findIndex((line) => is_heading(line))
183
-
184
- return end === -1 ? rest : rest.slice(0, end)
185
- }
186
-
187
- // The acceptance-criteria section of a child's body, or an empty string.
172
+ // The acceptance-criteria section of a child's body, up to the next unfenced `##`, or an empty string.
188
173
  function acceptance_section(body: string | undefined): string {
189
174
  if (body === undefined) return ''
190
175
 
191
- return section_lines(body.split('\n')).join('\n')
176
+ return markdown_section.section_lines_matching(body, is_acceptance_heading).join('\n')
192
177
  }
193
178
 
194
179
  // Everything `node` depends on, directly or through a chain. Walked iteratively so a graph that