@joshuafolkken/kit 1.1128.0 → 1.1166.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 (192) hide show
  1. package/.claude/agents/investigator.md +17 -0
  2. package/.claude/settings.json +5 -4
  3. package/.claude/skills/epic-commands/SKILL.md +27 -239
  4. package/.claude/skills/epic-commands/epic-bundle.md +115 -0
  5. package/.claude/skills/epic-commands/execution-waves.md +126 -0
  6. package/.claude/skills/workflow-commands/SKILL.md +48 -194
  7. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  8. package/.claude/skills/workflow-commands/backlogrun-child.md +12 -123
  9. package/.claude/skills/workflow-commands/backlogrun-lanes.md +11 -60
  10. package/.claude/skills/workflow-commands/backlogrun-park.md +10 -8
  11. package/.claude/skills/workflow-commands/backlogrun-progress.md +19 -175
  12. package/.claude/skills/workflow-commands/backlogrun-recovery.md +155 -0
  13. package/.claude/skills/workflow-commands/backlogrun-steps.md +64 -144
  14. package/.claude/skills/workflow-commands/backlogrun.md +5 -4
  15. package/.claude/skills/workflow-commands/chain-rule.md +3 -2
  16. package/.claude/skills/workflow-commands/delegation.md +7 -3
  17. package/.claude/skills/workflow-commands/entry-sequence.md +62 -0
  18. package/.claude/skills/workflow-commands/followup.md +104 -181
  19. package/.claude/skills/workflow-commands/fullrun-steps.md +8 -9
  20. package/.claude/skills/workflow-commands/fullrun.md +16 -73
  21. package/.claude/skills/workflow-commands/halfrun.md +10 -36
  22. package/.claude/skills/workflow-commands/into-target.md +4 -4
  23. package/.claude/skills/workflow-commands/issue-comments.md +7 -8
  24. package/.claude/skills/workflow-commands/issue-scout.md +6 -6
  25. package/.claude/skills/workflow-commands/kickoff.md +6 -6
  26. package/.claude/skills/workflow-commands/needs-human-review.md +6 -6
  27. package/.claude/skills/workflow-commands/observation-filing.md +21 -172
  28. package/.claude/skills/workflow-commands/observation-ledger.md +150 -0
  29. package/.claude/skills/workflow-commands/pre-gate-cut.md +33 -93
  30. package/.claude/skills/workflow-commands/prerequisite.md +6 -6
  31. package/.claude/skills/workflow-commands/progress-watcher.md +90 -0
  32. package/.claude/skills/workflow-commands/prrun.md +5 -5
  33. package/.claude/skills/workflow-commands/retrospective.md +9 -9
  34. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  35. package/.claude/skills/workflow-commands/target-repository.md +2 -2
  36. package/.claude/skills/workflow-commands/working-tree-hold.md +7 -5
  37. package/.codex/hooks.json +4 -4
  38. package/AGENTS.md +1 -6
  39. package/CLAUDE.md +58 -120
  40. package/GEMINI.md +1 -6
  41. package/SECURITY.md +1 -0
  42. package/dist/CLAUDE.md +58 -120
  43. package/dist/hooks/batch-guard.js +4 -4
  44. package/dist/hooks/{chunk-MKVGBQPA.js → chunk-5KTCMCOT.js} +14 -1
  45. package/dist/hooks/{chunk-NXYMNPUQ.js → chunk-A6P7PN7P.js} +59 -21
  46. package/dist/hooks/{chunk-77J7IEQM.js → chunk-ANJ2WGFD.js} +1 -1
  47. package/dist/hooks/{chunk-4JZDP5X5.js → chunk-DYAVAM6S.js} +572 -127
  48. package/dist/hooks/{chunk-7HVB7SZG.js → chunk-ENA7P5FB.js} +4 -4
  49. package/dist/hooks/{chunk-M7QKEKWO.js → chunk-GU5MATVE.js} +214 -85
  50. package/dist/hooks/chunk-GYNGP7HI.js +56 -0
  51. package/dist/hooks/{chunk-5SD2YM7Z.js → chunk-URJA6XY4.js} +4 -4
  52. package/dist/hooks/{chunk-XYUIQOW3.js → chunk-V2BTOZHT.js} +6 -4
  53. package/dist/hooks/{chunk-AIOIOMPX.js → chunk-ZDZ555YH.js} +225 -78
  54. package/dist/hooks/codex-hook-adapter.js +10 -10
  55. package/dist/hooks/duplicate-read-guard.js +5 -5
  56. package/dist/hooks/format-edited.js +2 -2
  57. package/dist/hooks/inputs.json +186 -184
  58. package/dist/hooks/investigation-guard.js +5 -5
  59. package/dist/hooks/pretool-guard.js +11 -10
  60. package/dist/hooks/stop-guard.js +5 -5
  61. package/dist/josh.js +33 -3
  62. package/dist/version/index.js +5 -1
  63. package/package.json +7 -4
  64. package/prompts/collaboration-workflow/file-edits.md +11 -10
  65. package/prompts/collaboration-workflow/gh-rest.md +2 -4
  66. package/prompts/collaboration-workflow/glossary.md +1 -1
  67. package/prompts/collaboration-workflow/issue-citation.md +5 -9
  68. package/prompts/collaboration-workflow/issue-template.md +40 -115
  69. package/prompts/collaboration-workflow/operating-rules.md +4 -5
  70. package/prompts/collaboration-workflow/output-bounds.md +12 -78
  71. package/prompts/collaboration-workflow/overview.md +7 -13
  72. package/prompts/collaboration-workflow/plan-comment.md +11 -39
  73. package/prompts/collaboration-workflow/principles.md +3 -4
  74. package/prompts/collaboration-workflow/report-format.md +28 -74
  75. package/prompts/collaboration-workflow/residency.md +12 -109
  76. package/prompts/collaboration-workflow/rule-delivery.md +98 -97
  77. package/prompts/collaboration-workflow/shell-body.md +3 -13
  78. package/prompts/collaboration-workflow/turn-batching.md +14 -88
  79. package/prompts/collaboration-workflow/upstream-interrupt.md +12 -14
  80. package/prompts/collaboration-workflow/wip-cap.md +6 -45
  81. package/prompts/collaboration-workflow.md +1 -13
  82. package/prompts/review-rubric.md +8 -71
  83. package/prompts/review.md +25 -55
  84. package/prompts/testing-guide.md +18 -67
  85. package/scripts/agent/agent-role-profile.ts +1 -1
  86. package/scripts/agent/codex-hooks.ts +3 -12
  87. package/scripts/backlog/backlog-budget.ts +1 -1
  88. package/scripts/backlog/backlog-drive-cli.ts +4 -3
  89. package/scripts/backlog/backlog-drive-offer-argv.ts +10 -1
  90. package/scripts/backlog/backlog-offer.ts +6 -6
  91. package/scripts/behavior/behavior-rules.ts +11 -4
  92. package/scripts/delegation/delegation-policy.ts +9 -1
  93. package/scripts/delegation/investigation-guard.ts +1 -1
  94. package/scripts/delegation/investigation-reads.ts +109 -23
  95. package/scripts/discovery/repo-discovery.ts +21 -7
  96. package/scripts/discovery/repo-origin.ts +26 -0
  97. package/scripts/discovery/repo-party.ts +42 -1
  98. package/scripts/document/backlogrun-parent-read-set.ts +14 -15
  99. package/scripts/document/capped-print.ts +74 -0
  100. package/scripts/document/document-byte-budget.ts +34 -20
  101. package/scripts/document/document-section-cli.ts +4 -3
  102. package/scripts/document/entry-read-budget.ts +21 -9
  103. package/scripts/document/lane-child-read-set.ts +12 -14
  104. package/scripts/document/read-set-cli.ts +4 -4
  105. package/scripts/document/read-set-point-of-use.ts +10 -2
  106. package/scripts/document/resident-budget.ts +47 -0
  107. package/scripts/epic/epic-shape.ts +10 -13
  108. package/scripts/epic/epic-solo.ts +1 -1
  109. package/scripts/exports/unused-members-cli.ts +7 -3
  110. package/scripts/gate/scoped-green.ts +25 -14
  111. package/scripts/gh/git-gh-api-path.ts +7 -0
  112. package/scripts/hooks/pretool-guard-cli.ts +2 -2
  113. package/scripts/hooks/pretool-guard.ts +45 -2
  114. package/scripts/hooks/run-hook.sh +41 -0
  115. package/scripts/init/claude-kit-only-settings.ts +29 -0
  116. package/scripts/init/hook-command-rewrite.ts +18 -35
  117. package/scripts/init/hook-launch.ts +21 -32
  118. package/scripts/init/init-copy-content.ts +8 -2
  119. package/scripts/init/initial-commit.ts +24 -0
  120. package/scripts/init/start-exec.ts +16 -3
  121. package/scripts/init/start-steps.ts +8 -12
  122. package/scripts/issue/issue-auto-ok.ts +95 -0
  123. package/scripts/issue/issue-file-cli.ts +53 -7
  124. package/scripts/issue/issue-file.ts +28 -5
  125. package/scripts/issue/issue-labels.ts +12 -12
  126. package/scripts/issue/issue-rank.ts +1 -1
  127. package/scripts/issue/issue-read-cli.ts +4 -3
  128. package/scripts/issue/issue-read.ts +2 -2
  129. package/scripts/issue/issue-report-failures.ts +6 -2
  130. package/scripts/issue/issue-scout-cli.ts +2 -2
  131. package/scripts/issue/issue-state-cli.ts +2 -2
  132. package/scripts/issue/issue-state.ts +2 -2
  133. package/scripts/issue/issue-wip.ts +70 -0
  134. package/scripts/josh/hook-decision.ts +17 -0
  135. package/scripts/josh/josh-commands-development.ts +10 -0
  136. package/scripts/josh/josh-commands-issue.ts +2 -2
  137. package/scripts/josh/josh-commands-lane.ts +1 -1
  138. package/scripts/josh/josh-commands-project.ts +7 -0
  139. package/scripts/lane/lane-await.ts +46 -2
  140. package/scripts/lane/lane-cli.ts +11 -13
  141. package/scripts/lines/bytes-command.ts +29 -6
  142. package/scripts/observations/observation-ledger-line.ts +1 -1
  143. package/scripts/release/github-release-environment.ts +1 -0
  144. package/scripts/release/github-release.ts +103 -17
  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/stop-rules.ts +4 -4
  160. package/scripts/rules/test-declared-commit.ts +1 -1
  161. package/scripts/rules/third-party-write.ts +56 -12
  162. package/scripts/run/run-carry-cli.ts +3 -1
  163. package/scripts/run/run-carry-conversation.ts +94 -0
  164. package/scripts/run/run-carry-reclaim.ts +37 -0
  165. package/scripts/run/run-carry.ts +21 -5
  166. package/scripts/run/run-cut-handoff.ts +21 -1
  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 +12 -4
  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-preflight.ts +34 -3
  175. package/scripts/run/run-ship-review-steps.ts +1 -1
  176. package/scripts/run/run-ship-review.ts +26 -5
  177. package/scripts/run/run-step-cli.ts +20 -7
  178. package/scripts/run/run-wake-driver.ts +24 -1
  179. package/scripts/run/run-wake-session.ts +2 -1
  180. package/scripts/test/test-declared-logic.ts +1 -1
  181. package/scripts/test/test-related.ts +1 -6
  182. package/scripts/test/test-unit-guard.ts +21 -7
  183. package/scripts/time-runtime/time-batch-guard.ts +52 -16
  184. package/scripts/time-runtime/time-density-hook.ts +20 -2
  185. package/scripts/time-runtime/time-density.ts +4 -3
  186. package/scripts/time-runtime/time-markers.ts +15 -4
  187. package/scripts/time-runtime/time-spans.ts +4 -0
  188. package/scripts/version/npm-registry.ts +11 -1
  189. package/templates/workflows/ci.yml +9 -1
  190. package/templates/workflows/github-release.yml +13 -25
  191. package/.claude/skills/workflow-commands/followup-reference.md +0 -118
  192. package/dist/hooks/chunk-QVLT52OV.js +0 -464
@@ -23,6 +23,12 @@ const LINE_SEPARATOR = /\r?\n/u
23
23
  const HIGH = 'high'
24
24
  const MEDIUM = 'medium'
25
25
  const FIXED_PREFIX = 'fixed '
26
+ // What a finding line carries after its spec: one sentence saying what is wrong, so a stopped ship's
27
+ // log tells the resumed lane child the defect rather than only its location (joshuafolkken/kit#3159).
28
+ const DESCRIPTION_SEPARATOR = ' — '
29
+ // Where the spec ends on read: a reviewer that types the dash as `-`, `--` or `–` still ends it there,
30
+ // so its sentence never lands in the ledger's file field.
31
+ const DESCRIPTION_BOUNDARY = / [—–-]{1,2} /u
26
32
 
27
33
  const VERDICT = {
28
34
  CLEAN: 'clean',
@@ -35,6 +41,7 @@ type ReviewVerdict =
35
41
  | {
36
42
  kind: typeof VERDICT.CLEAN | typeof VERDICT.FIXED | typeof VERDICT.BLOCKING
37
43
  specs: ReadonlyArray<string>
44
+ report: ReadonlyArray<string>
38
45
  }
39
46
  | { kind: typeof VERDICT.INVALID; note: string }
40
47
 
@@ -49,6 +56,7 @@ interface RoundOutcome {
49
56
  interface Finding {
50
57
  spec: string
51
58
  is_fixed: boolean
59
+ line: string
52
60
  }
53
61
 
54
62
  const MISSING_NOTE = 'the reviewer wrote no findings file — the review did not complete'
@@ -69,7 +77,8 @@ function findings_instruction(findings_path: string, fixed_clause: string): stri
69
77
  const categories = review_finding_ledger.CATEGORIES.join(', ')
70
78
 
71
79
  return [
72
- `Then write every finding to ${findings_path}, one per line as <category>:<severity>:<file>[:<line>]`,
80
+ `Then write every finding to ${findings_path}, one per line as`,
81
+ `<category>:<severity>:<file>[:<line>]${DESCRIPTION_SEPARATOR}<one sentence: what is wrong and why>`,
73
82
  `(category one of ${categories}; severity high, medium or low)${fixed_clause},`,
74
83
  'and an empty file when there is none.',
75
84
  ].join(' ')
@@ -99,10 +108,17 @@ function verification_prompt(brief_path: string, findings_path: string): string
99
108
  ].join(' ')
100
109
  }
101
110
 
111
+ // The spec is what `review:record` reads; the description after it travels only in the stage output.
112
+ function spec_of(unmarked: string): string {
113
+ const end = unmarked.search(DESCRIPTION_BOUNDARY)
114
+
115
+ return (end === -1 ? unmarked : unmarked.slice(0, end)).trim()
116
+ }
117
+
102
118
  function finding_of(line: string): Finding {
103
- if (!line.startsWith(FIXED_PREFIX)) return { spec: line, is_fixed: false }
119
+ const is_fixed = line.startsWith(FIXED_PREFIX)
104
120
 
105
- return { spec: line.slice(FIXED_PREFIX.length).trim(), is_fixed: true }
121
+ return { spec: spec_of(is_fixed ? line.slice(FIXED_PREFIX.length) : line), is_fixed, line }
106
122
  }
107
123
 
108
124
  function spec_findings(text: string): ReadonlyArray<Finding> {
@@ -133,7 +149,8 @@ function scored_kind(findings: ReadonlyArray<Finding>): ScoredVerdict['kind'] {
133
149
 
134
150
  // Absent means the reviewer never finished; a line outside the grammar means its verdict cannot be
135
151
  // recorded — both stop the ship rather than read as clean. The specs are recorded without the `fixed`
136
- // mark: a fixed finding was still a finding of that round.
152
+ // mark: a fixed finding was still a finding of that round. The report keeps each line whole — mark and
153
+ // description — for the stage output a resumed lane child reads.
137
154
  function read_verdict(text: string | undefined): ReviewVerdict {
138
155
  if (text === undefined) return { kind: VERDICT.INVALID, note: MISSING_NOTE }
139
156
 
@@ -144,7 +161,11 @@ function read_verdict(text: string | undefined): ReviewVerdict {
144
161
  return { kind: VERDICT.INVALID, note: `unreadable finding: ${invalid.spec}` }
145
162
  }
146
163
 
147
- return { kind: scored_kind(findings), specs: findings.map((finding) => finding.spec) }
164
+ return {
165
+ kind: scored_kind(findings),
166
+ specs: findings.map((finding) => finding.spec),
167
+ report: findings.map((finding) => finding.line),
168
+ }
148
169
  }
149
170
 
150
171
  // Round 1 passes clean or with every blocking finding fixed in place; round 2 follows the commit.
@@ -10,6 +10,7 @@ import { run_carry, type CarryRead } from './run-carry'
10
10
  import { run_event_scope } from './run-event-scope'
11
11
  import { run_event_stream } from './run-event-stream'
12
12
  import { run_headless } from './run-headless'
13
+ import { run_prep, type PrepParts } from './run-prep'
13
14
  import { run_prep_cli } from './run-prep-cli'
14
15
  import { run_retrospective } from './run-retrospective'
15
16
  import { run_step, type StepInput } from './run-step'
@@ -85,14 +86,14 @@ function read_run(
85
86
  }
86
87
  }
87
88
 
88
- async function gather(issue_number: string): Promise<StepInput> {
89
- const reads = await run_prep_cli.gather(issue_number)
90
- const parts = run_prep_cli.to_parts(issue_number, reads)
91
- const is_lane_child = lane_child_marker.is_child_of(process.cwd())
92
- const run_reads = read_run(await run_carry.repository_directory(), issue_number, is_lane_child)
89
+ interface Gathered {
90
+ input: StepInput
91
+ ship_problems: ReadonlyArray<string>
92
+ }
93
93
 
94
+ function step_input(parts: PrepParts, run_reads: RunReads, is_lane_child: boolean): StepInput {
94
95
  return {
95
- issue_number,
96
+ issue_number: parts.issue_number,
96
97
  state: parts.state?.state,
97
98
  is_human_review: parts.state?.is_human_review ?? false,
98
99
  latest_scope: parts.latest_scope,
@@ -110,6 +111,15 @@ async function gather(issue_number: string): Promise<StepInput> {
110
111
  }
111
112
  }
112
113
 
114
+ async function gather(issue_number: string): Promise<Gathered> {
115
+ const reads = await run_prep_cli.gather(issue_number)
116
+ const parts = run_prep_cli.to_parts(issue_number, reads)
117
+ const is_lane_child = lane_child_marker.is_child_of(process.cwd())
118
+ const run_reads = read_run(await run_carry.repository_directory(), issue_number, is_lane_child)
119
+
120
+ return { input: step_input(parts, run_reads, is_lane_child), ship_problems: parts.ship_problems }
121
+ }
122
+
113
123
  async function run(argv: ReadonlyArray<string>): Promise<number> {
114
124
  const issue_number = run_prep_cli.parse_number(argv)
115
125
 
@@ -119,9 +129,12 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
119
129
  return FAILURE_EXIT_CODE
120
130
  }
121
131
 
122
- const input = await gather(issue_number)
132
+ const { input, ship_problems } = await gather(issue_number)
123
133
 
124
134
  console.info(run_step.next_action(input).line)
135
+ // On stderr, so stdout stays the one action line (joshuafolkken/kit#3154): what `josh ship` would
136
+ // refuse on is said at every step, while it is still cheap to meet.
137
+ if (ship_problems.length > 0) console.error(run_prep.ship_body(ship_problems))
125
138
 
126
139
  // A state that could not be read exits non-zero, exactly as `run:prep` and `run:next` exit on the
127
140
  // same failure, so a position missing its issue state is never read as a confident next step.
@@ -37,13 +37,36 @@ function details_tail(details: string): string {
37
37
  : details
38
38
  }
39
39
 
40
+ // **The hand-off names its next move, so the woken session never searches the procedure for it**
41
+ // (joshuafolkken/kit#3156). Whatever the branch, the session acts on it and hands the loop back to the
42
+ // supervisor with a cut; only a person's stop ends the run instead.
43
+ const HAND_BACK = 'then hand the loop back with pnpm josh run:carry --cut --owner "$PPID"'
44
+ const NEXT_BY_VERDICT: ReadonlyArray<readonly [string, string]> = [
45
+ [
46
+ 'retrospective',
47
+ `run what pnpm josh run:step prints (retrospective.md), close it with pnpm josh run:carry --retrospective --summary "<result>" --owner "$PPID", ${HAND_BACK}`,
48
+ ],
49
+ [
50
+ 'merge human-review',
51
+ 'the child stopped before its commit for a person; stop the run (needs-human-review.md)',
52
+ ],
53
+ ['epic #', `follow backlogrun-steps.md → "Named issues run first, in order", ${HAND_BACK}`],
54
+ ]
55
+ const NEXT_DEFAULT = `act on the result by backlogrun-steps.md → "The loop", ${HAND_BACK}`
56
+
57
+ function next_move(verdict: string): string {
58
+ const found = NEXT_BY_VERDICT.find(([prefix]) => verdict.startsWith(prefix))
59
+
60
+ return `\nNext: ${found?.[1] ?? NEXT_DEFAULT}`
61
+ }
62
+
40
63
  function handoff_material(verdict: string, resume: string, details: string): string {
41
64
  const context = details === '' ? '' : `\nDetails: ${details_tail(details)}`
42
65
  const epic = verdict.startsWith('epic #')
43
66
  ? '\nThe named item is an epic. Follow backlogrun-steps.md named epic procedure; do not launch the epic root as a fullrun child. After all children merge or park, record the root with pnpm josh run:carry --done <epic-number> --owner "$PPID" before continuing to the next named item.'
44
67
  : ''
45
68
 
46
- return `Driver result: ${verdict}\n${resume}${epic}${context}`
69
+ return `Driver result: ${verdict}\n${resume}${epic}${next_move(verdict)}${context}`
47
70
  }
48
71
 
49
72
  function driver_result(out: string, read: CarryRead, details = ''): DriveResult {
@@ -29,7 +29,8 @@ import { run_invocation } from './run-invocation'
29
29
  //
30
30
  // **The waker adds nothing to what may be run.** The argument vector is the constant command plus an
31
31
  // invocation rebuilt to say exactly what the record said, and nothing here writes a label: `auto-ok`
32
- // stays a person's to apply, so a woken session is offered by exactly the rules the first one was. A
32
+ // arrives only through `issue:file`'s default, so a woken session is offered by exactly the rules the
33
+ // first one was. A
33
34
  // pool that grew across the seam is `backlogrun-steps.md` → "What one invocation approves", not the
34
35
  // waker's doing (joshuafolkken/kit#1675).
35
36
  const WAKE_COMMAND = claude_agent_argv.AGENT_COMMAND
@@ -12,7 +12,7 @@
12
12
  // can catch a defect in it; this set keeps documentation *in* because a test could never have caught a
13
13
  // prose defect either way. The two answer different questions, so sharing one list would be wrong, not
14
14
  // a clone avoided. Here only paths that are mechanically certain to touch no runtime code are exempt —
15
- // the non-executable config and cosmetic-asset arms of `CLAUDE.md`'s "Non-runtime updates" exception
15
+ // the non-executable config and cosmetic-asset arms of `prompts/testing-guide.md`'s "Non-runtime updates" exception
16
16
  // stay `required`, and a person declares that exception in the Step 0 work summary. Under-exempting
17
17
  // costs one declared exception; over-exempting ships an untested runtime change. The one widening is
18
18
  // the basic profile's manual check below, for files no runner kit has could test.
@@ -26,12 +26,7 @@ import { test_unit_guard } from './test-unit-guard'
26
26
  function conclusive_tree(before: GateTree | undefined): GateTree | undefined {
27
27
  if (before === undefined) return undefined
28
28
 
29
- const action = test_unit_guard.resolve_guard_action(
30
- test_unit_guard.is_vitest_installed(process.cwd()),
31
- test_unit_guard.has_unit_tests(process.cwd()),
32
- )
33
-
34
- return action === 'run' ? before : undefined
29
+ return test_unit_guard.guard_action(process.cwd()) === 'run' ? before : undefined
35
30
  }
36
31
 
37
32
  async function run_related_tests(command_arguments: ReadonlyArray<string>): Promise<number> {
@@ -79,6 +79,24 @@ function has_unit_tests(project_directory: string): boolean {
79
79
  return matches.length > 0
80
80
  }
81
81
 
82
+ // The guard's decision for one project, asked here once so the run, the scoped record and the gate's
83
+ // precondition can never disagree about whether this project's vitest runs (joshuafolkken/kit#3136).
84
+ function guard_action(project_directory: string): GuardAction {
85
+ return resolve_guard_action(
86
+ is_vitest_installed(project_directory),
87
+ has_unit_tests(project_directory),
88
+ project_checks.is_basic(project_directory),
89
+ )
90
+ }
91
+
92
+ // **A skip is the project having no unit suite, so no unit check can ever vouch for its tree.** The
93
+ // failing branch is not one: it is a broken suite, and it is left to fail where it is reported.
94
+ function is_skipping(project_directory: string): boolean {
95
+ const action = guard_action(project_directory)
96
+
97
+ return action === SKIP_ACTION || action === SKIP_NO_TESTS_ACTION
98
+ }
99
+
82
100
  // A lane's port allocation belongs to its development and preview servers, not to unit fixtures.
83
101
  // Passing it into Vitest makes every case that deliberately omits or replaces the seed inherit the
84
102
  // checkout's seat instead. Keep the rest of the caller's environment, including CI and PATH guards.
@@ -155,13 +173,7 @@ async function run_guarded_vitest(
155
173
  command_label: string = UNIT_COMMAND_LABEL,
156
174
  announcement?: string,
157
175
  ): Promise<number> {
158
- const is_installed = is_vitest_installed(project_directory)
159
- const has_tests = has_unit_tests(project_directory)
160
- const action = resolve_guard_action(
161
- is_installed,
162
- has_tests,
163
- project_checks.is_basic(project_directory),
164
- )
176
+ const action = guard_action(project_directory)
165
177
 
166
178
  if (action !== 'run') return report_no_run(action, command_label)
167
179
  if (announcement !== undefined) process.stdout.write(`${announcement}\n`)
@@ -185,6 +197,8 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
185
197
 
186
198
  const test_unit_guard = {
187
199
  SKIP_MARKER,
200
+ guard_action,
201
+ is_skipping,
188
202
  resolve_guard_action,
189
203
  is_vitest_installed,
190
204
  has_unit_tests,
@@ -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
  }
@@ -138,6 +138,9 @@ interface ToolCall {
138
138
  // second write to one file is independent work, a read of a file just written is not
139
139
  // (joshuafolkken/kit#1509).
140
140
  is_writing: boolean
141
+ // The conservative write answer `is_read_only_call` asks of a live call, carried so a reader of the
142
+ // transcript asks the same read-only question as the guard (joshuafolkken/kit#3139).
143
+ may_write: boolean
141
144
  // Whether this is a subagent launch whose prompt builds on an earlier launch's finding
142
145
  // (joshuafolkken/kit#1854). Carried for the reason every field here is — the prompt it is read from
143
146
  // is the input, and a span keeps none. `time-agent-bundles.ts` reads it.
@@ -474,6 +477,7 @@ function to_spans(events: ReadonlyArray<TimelineEvent>, finished: FinishedAt): A
474
477
  is_bundleable: event.is_bundleable,
475
478
  targets: event.targets,
476
479
  is_writing: event.is_writing,
480
+ may_write: event.may_write,
477
481
  has_prior_reference: event.has_prior_reference,
478
482
  writes: event.writes,
479
483
  message_id: event.message_id,
@@ -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