@joshuafolkken/kit 1.1136.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 (165) 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 +11 -60
  9. package/.claude/skills/workflow-commands/backlogrun-park.md +10 -8
  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 +64 -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 +33 -93
  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/AGENTS.md +1 -6
  38. package/CLAUDE.md +58 -120
  39. package/GEMINI.md +1 -6
  40. package/dist/CLAUDE.md +58 -120
  41. package/dist/hooks/batch-guard.js +4 -4
  42. package/dist/hooks/{chunk-NFZGFGOR.js → chunk-5KTCMCOT.js} +14 -1
  43. package/dist/hooks/{chunk-LJBLGHEH.js → chunk-A6P7PN7P.js} +4 -6
  44. package/dist/hooks/{chunk-FO2I7UT7.js → chunk-ANJ2WGFD.js} +1 -1
  45. package/dist/hooks/{chunk-GDU26K3J.js → chunk-DYAVAM6S.js} +563 -126
  46. package/dist/hooks/{chunk-RFWWUI5F.js → chunk-ENA7P5FB.js} +3 -3
  47. package/dist/hooks/{chunk-KBXZLMRH.js → chunk-GU5MATVE.js} +213 -65
  48. package/dist/hooks/chunk-GYNGP7HI.js +56 -0
  49. package/dist/hooks/{chunk-HBKZLITF.js → chunk-URJA6XY4.js} +4 -4
  50. package/dist/hooks/{chunk-KOKOAV6D.js → chunk-V2BTOZHT.js} +6 -4
  51. package/dist/hooks/{chunk-JKKTVDAG.js → chunk-ZDZ555YH.js} +100 -20
  52. package/dist/hooks/codex-hook-adapter.js +10 -10
  53. package/dist/hooks/duplicate-read-guard.js +5 -5
  54. package/dist/hooks/format-edited.js +2 -2
  55. package/dist/hooks/inputs.json +193 -192
  56. package/dist/hooks/investigation-guard.js +5 -5
  57. package/dist/hooks/pretool-guard.js +11 -10
  58. package/dist/hooks/stop-guard.js +5 -5
  59. package/dist/josh.js +25 -2
  60. package/dist/version/index.js +5 -1
  61. package/package.json +2 -1
  62. package/prompts/collaboration-workflow/file-edits.md +11 -10
  63. package/prompts/collaboration-workflow/gh-rest.md +2 -4
  64. package/prompts/collaboration-workflow/glossary.md +1 -1
  65. package/prompts/collaboration-workflow/issue-citation.md +5 -9
  66. package/prompts/collaboration-workflow/issue-template.md +40 -115
  67. package/prompts/collaboration-workflow/operating-rules.md +4 -5
  68. package/prompts/collaboration-workflow/output-bounds.md +12 -78
  69. package/prompts/collaboration-workflow/overview.md +7 -13
  70. package/prompts/collaboration-workflow/plan-comment.md +11 -39
  71. package/prompts/collaboration-workflow/principles.md +3 -4
  72. package/prompts/collaboration-workflow/report-format.md +28 -74
  73. package/prompts/collaboration-workflow/residency.md +12 -109
  74. package/prompts/collaboration-workflow/rule-delivery.md +98 -97
  75. package/prompts/collaboration-workflow/shell-body.md +3 -13
  76. package/prompts/collaboration-workflow/turn-batching.md +14 -88
  77. package/prompts/collaboration-workflow/upstream-interrupt.md +12 -14
  78. package/prompts/collaboration-workflow/wip-cap.md +6 -45
  79. package/prompts/collaboration-workflow.md +1 -13
  80. package/prompts/review-rubric.md +8 -71
  81. package/prompts/review.md +25 -55
  82. package/prompts/testing-guide.md +18 -67
  83. package/scripts/agent/agent-role-profile.ts +1 -1
  84. package/scripts/agent/codex-hooks.ts +3 -12
  85. package/scripts/backlog/backlog-budget.ts +1 -1
  86. package/scripts/backlog/backlog-drive-cli.ts +4 -3
  87. package/scripts/backlog/backlog-drive-offer-argv.ts +10 -1
  88. package/scripts/backlog/backlog-offer.ts +6 -6
  89. package/scripts/behavior/behavior-rules.ts +11 -4
  90. package/scripts/delegation/investigation-reads.ts +5 -5
  91. package/scripts/discovery/repo-discovery.ts +21 -7
  92. package/scripts/discovery/repo-origin.ts +26 -0
  93. package/scripts/discovery/repo-party.ts +42 -1
  94. package/scripts/document/backlogrun-parent-read-set.ts +14 -15
  95. package/scripts/document/document-byte-budget.ts +34 -20
  96. package/scripts/document/entry-read-budget.ts +21 -9
  97. package/scripts/document/lane-child-read-set.ts +12 -14
  98. package/scripts/document/read-set-cli.ts +4 -4
  99. package/scripts/document/read-set-point-of-use.ts +10 -2
  100. package/scripts/document/resident-budget.ts +47 -0
  101. package/scripts/epic/epic-shape.ts +10 -13
  102. package/scripts/epic/epic-solo.ts +1 -1
  103. package/scripts/exports/unused-members-cli.ts +7 -3
  104. package/scripts/gh/git-gh-api-path.ts +7 -0
  105. package/scripts/hooks/pretool-guard-cli.ts +2 -2
  106. package/scripts/hooks/pretool-guard.ts +45 -2
  107. package/scripts/hooks/run-hook.sh +41 -0
  108. package/scripts/init/hook-command-rewrite.ts +18 -35
  109. package/scripts/init/hook-launch.ts +21 -32
  110. package/scripts/issue/issue-auto-ok.ts +95 -0
  111. package/scripts/issue/issue-file-cli.ts +53 -7
  112. package/scripts/issue/issue-file.ts +28 -5
  113. package/scripts/issue/issue-labels.ts +12 -12
  114. package/scripts/issue/issue-rank.ts +1 -1
  115. package/scripts/issue/issue-read-cli.ts +3 -3
  116. package/scripts/issue/issue-read.ts +2 -2
  117. package/scripts/issue/issue-scout-cli.ts +2 -2
  118. package/scripts/issue/issue-state-cli.ts +1 -1
  119. package/scripts/issue/issue-state.ts +2 -2
  120. package/scripts/issue/issue-wip.ts +70 -0
  121. package/scripts/josh/hook-decision.ts +17 -0
  122. package/scripts/josh/josh-commands-development.ts +10 -0
  123. package/scripts/josh/josh-commands-issue.ts +2 -2
  124. package/scripts/lines/bytes-command.ts +29 -6
  125. package/scripts/observations/observation-ledger-line.ts +1 -1
  126. package/scripts/release/release-progress.ts +167 -0
  127. package/scripts/release/release-publish.ts +19 -8
  128. package/scripts/release/release-tag.ts +22 -8
  129. package/scripts/rules/decision-oracle-stage.ts +1 -1
  130. package/scripts/rules/decision-oracle.ts +6 -6
  131. package/scripts/rules/delivered-rules.ts +11 -11
  132. package/scripts/rules/direct-pr-create.ts +110 -0
  133. package/scripts/rules/file-body.ts +98 -42
  134. package/scripts/rules/filing-cap.ts +2 -2
  135. package/scripts/rules/filing-offer.ts +1 -1
  136. package/scripts/rules/gh-api.ts +18 -1
  137. package/scripts/rules/implementation-cut.ts +3 -1
  138. package/scripts/rules/lane-background.ts +67 -10
  139. package/scripts/rules/permission-guards.ts +10 -2
  140. package/scripts/rules/stop-rules.ts +4 -4
  141. package/scripts/rules/test-declared-commit.ts +1 -1
  142. package/scripts/rules/third-party-write.ts +56 -12
  143. package/scripts/run/run-cut-handoff.ts +21 -1
  144. package/scripts/run/run-entry-cli.ts +11 -3
  145. package/scripts/run/run-hold-cli.ts +16 -2
  146. package/scripts/run/run-label.ts +50 -0
  147. package/scripts/run/run-merge-steps.ts +8 -3
  148. package/scripts/run/run-merge.ts +1 -1
  149. package/scripts/run/run-prep-cli.ts +6 -2
  150. package/scripts/run/run-prep.ts +17 -0
  151. package/scripts/run/run-ship-preflight.ts +34 -3
  152. package/scripts/run/run-ship-review-steps.ts +1 -1
  153. package/scripts/run/run-ship-review.ts +26 -5
  154. package/scripts/run/run-step-cli.ts +20 -7
  155. package/scripts/run/run-wake-driver.ts +24 -1
  156. package/scripts/run/run-wake-session.ts +2 -1
  157. package/scripts/test/test-declared-logic.ts +1 -1
  158. package/scripts/time-runtime/time-batch-guard.ts +52 -16
  159. package/scripts/time-runtime/time-density-hook.ts +20 -2
  160. package/scripts/time-runtime/time-density.ts +4 -3
  161. package/scripts/time-runtime/time-markers.ts +15 -4
  162. package/scripts/version/npm-registry.ts +11 -1
  163. package/templates/workflows/ci.yml +9 -1
  164. package/.claude/skills/workflow-commands/followup-reference.md +0 -118
  165. package/dist/hooks/chunk-HANSKHES.js +0 -464
@@ -7,6 +7,7 @@ import { run_entry, type EntryParts } from './run-entry'
7
7
  import { run_entry_stop } from './run-entry-stop'
8
8
  import { run_halfrun_resume } from './run-halfrun-resume'
9
9
  import { run_hold_cli } from './run-hold-cli'
10
+ import { run_label } from './run-label'
10
11
  import { run_next } from './run-next'
11
12
  import { run_prep } from './run-prep'
12
13
  import { run_prep_cli } from './run-prep-cli'
@@ -231,6 +232,15 @@ function is_settled(decision: StageDecision): boolean {
231
232
  return decision.is_reached && decision.state !== run_stage.MERGED
232
233
  }
233
234
 
235
+ // The tree is held and the budget allows the run, so the issue is marked as running before its reads
236
+ // (joshuafolkken/kit#3182) — the label then shows in the `labels:` line the report below carries.
237
+ async function proceed(issue_number: string, hold: string, cost: string): Promise<number> {
238
+ await run_label.mark(issue_number)
239
+ const reads = await gather_reads(issue_number)
240
+
241
+ return emit({ issue_number, hold, cost, verdict: reads.verdict, report: reads.report })
242
+ }
243
+
234
244
  async function claim_run({ issue_number, command }: EntryRequest): Promise<number> {
235
245
  const hold = await claim_hold(issue_number, command)
236
246
 
@@ -248,9 +258,7 @@ async function claim_run({ issue_number, command }: EntryRequest): Promise<numbe
248
258
  return await stop_run({ parts, command, reason: BUDGET_REASON, should_release: true })
249
259
  }
250
260
 
251
- const reads = await gather_reads(issue_number)
252
-
253
- return emit({ issue_number, hold, cost, verdict: reads.verdict, report: reads.report })
261
+ return await proceed(issue_number, hold, cost)
254
262
  }
255
263
 
256
264
  // A carried cut is asked first, before anything is read (`run:cut --resume`); `kickoff` claims and cuts
@@ -2,6 +2,7 @@
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { run_halfrun_resume } from './run-halfrun-resume'
4
4
  import { run_hold, type HoldRead, type RunHold } from './run-hold'
5
+ import { run_label } from './run-label'
5
6
  import { run_preflight, type PreflightDecision } from './run-preflight'
6
7
  import { run_prrun_resume } from './run-prrun-resume'
7
8
  import { run_tidy_cli } from './run-tidy-cli'
@@ -267,7 +268,7 @@ function report_unreadable_release(): number {
267
268
  // Until then this removed whatever was there, and the `busy` stop message sent a person here to clear
268
269
  // a record they had judged stale from outside the run that wrote it — so the guard's own recovery
269
270
  // instruction was a way to free a live run's tree, which is the incident it was written after.
270
- function release(target: string, claimant: string): number {
271
+ function release_record(target: string, claimant: string): number {
271
272
  const read = run_hold.read_hold(target)
272
273
 
273
274
  if (read.kind === 'free') return remove_record(target, false)
@@ -279,6 +280,19 @@ function release(target: string, claimant: string): number {
279
280
  return remove_record(target, true)
280
281
  }
281
282
 
283
+ // **A numbered run's own release takes its `in-progress` marker back off** (joshuafolkken/kit#3182),
284
+ // whether or not a record was still there — the stop that releases is the run ending its hold on the
285
+ // issue. A refused release (another run's record, an unreadable one) touches no label, and an
286
+ // unnumbered run names no issue to touch.
287
+ async function release(target: string, claimant: string): Promise<number> {
288
+ const code = release_record(target, claimant)
289
+ const is_own_end = code === SUCCESS_EXIT_CODE && claimant !== run_hold.UNNUMBERED_ISSUE
290
+
291
+ if (is_own_end) await run_label.unmark(claimant)
292
+
293
+ return code
294
+ }
295
+
282
296
  // The one path that removes a record without matching it, which is why it says what it removed: a
283
297
  // record genuinely abandoned by a crashed session has no run left to release it, and the eight-hour
284
298
  // expiry alone leaves a dirty tree held until a person acts.
@@ -335,7 +349,7 @@ async function dispatch(request: HoldRequest, target: string, is_linked: boolean
335
349
 
336
350
  if (request.kind === FORCE_RELEASE_KIND) return force_release(target)
337
351
 
338
- return release(target, request.claimant)
352
+ return await release(target, request.claimant)
339
353
  }
340
354
 
341
355
  async function answer(request: HoldRequest): Promise<number> {
@@ -0,0 +1,50 @@
1
+ import { git_followup_label } from '#scripts/followup/git-followup-label'
2
+ import { git_gh_command } from '#scripts/gh/git-gh-command'
3
+ import { IN_PROGRESS_LABEL } from '#scripts/issue/issue-labels'
4
+
5
+ // **The `in-progress` marker belongs to the commands that open and release a run**
6
+ // (joshuafolkken/kit#3182). Every manifest used to spell the label's create, apply and removal as raw
7
+ // `gh api` calls, so each copy was a place the marker could be forgotten; `run:entry` applies it the
8
+ // moment the tree is held and the budget allows the run, and `run:release` takes it back off.
9
+ //
10
+ // **Neither half ever fails the command it rides on.** The hold is the guard and the label only
11
+ // reports it, so a refused write is warned about by name — with the call that finishes it by hand —
12
+ // and the run's own verdict stands.
13
+
14
+ const MARK_RECOVERY = "gh api repos/{owner}/{repo}/issues/<N>/labels -f 'labels[]=in-progress'"
15
+ const UNMARK_RECOVERY = 'gh api -X DELETE repos/{owner}/{repo}/issues/<N>/labels/in-progress'
16
+
17
+ function warn(action: string, issue: string, recovery: string): void {
18
+ console.warn(`⚠ could not ${action} \`in-progress\` on #${issue} — run by hand: ${recovery}`)
19
+ }
20
+
21
+ // `issue_add_label` creates the label when the repository lacks it (`lane-dispatch.ts`), so no
22
+ // separate provisioning call is made here.
23
+ async function mark(issue: string): Promise<boolean> {
24
+ const is_marked = await git_gh_command.issue_add_label(issue, IN_PROGRESS_LABEL)
25
+
26
+ if (!is_marked) warn('apply', issue, MARK_RECOVERY)
27
+
28
+ return is_marked
29
+ }
30
+
31
+ // The removal is `followup`'s own — read first, removed only when present, under the stored
32
+ // casing — so an issue that never carried the label is never written to.
33
+ async function unmark(issue: string): Promise<boolean> {
34
+ try {
35
+ await git_followup_label.strip_in_progress(issue)
36
+
37
+ return true
38
+ } catch {
39
+ warn('remove', issue, UNMARK_RECOVERY)
40
+
41
+ return false
42
+ }
43
+ }
44
+
45
+ const run_label = {
46
+ mark,
47
+ unmark,
48
+ }
49
+
50
+ export { run_label }
@@ -48,7 +48,10 @@ interface MergeContext {
48
48
  child: string
49
49
  epic: string | undefined
50
50
  repo: string | undefined
51
- over: number
51
+ // The hand-off threshold, or `undefined` where no AI session owns the loop — the supervisor's
52
+ // `backlog:drive` has no context to cut, so a merge there is never handed back as `over`
53
+ // (joshuafolkken/kit#3156).
54
+ over: number | undefined
52
55
  owner: CarryOwner
53
56
  // The child's transcript output path, when the caller passed `--output`. Read to tell an API-outage
54
57
  // ending apart from a genuine child failure (joshuafolkken/kit#2240); absent leaves outage detection
@@ -327,8 +330,10 @@ async function resume_cut(child: string): Promise<boolean> {
327
330
  }
328
331
 
329
332
  // The hand-off check, asked at a merge alone. A subprocess that cannot measure exits non-zero, which
330
- // is read as `over` — "could not measure" is never "still cheap".
331
- async function is_over_budget(over: number): Promise<boolean> {
333
+ // is read as `over` — "could not measure" is never "still cheap". No threshold is no session to measure.
334
+ async function is_over_budget(over: number | undefined): Promise<boolean> {
335
+ if (over === undefined) return false
336
+
332
337
  const result = await josh(['cost', '--over', String(over)])
333
338
 
334
339
  return result.code !== 0 || result.out === OVER
@@ -35,7 +35,7 @@ const ONE = 1
35
35
  // - `merged` — CLOSED; the child finished and its pull request merged. Also an OPEN, unparked
36
36
  // child a merged pull request's `closes #N` names — GitHub merged it without closing
37
37
  // the issue (joshuafolkken/kit#2769).
38
- // - `human-review` — OPEN and carrying `needs-human-review`; the run's own ending (SKILL.md → §2z).
38
+ // - `human-review` — OPEN and carrying `needs-human-review`; the run's own ending (needs-human-review.md).
39
39
  // - `parked` — OPEN and carrying `needs-decision` or `already-done`; a person still owns it.
40
40
  // - `split` — OPEN and carrying `epic`; its work was divided into new children.
41
41
  // - `outage` — OPEN and carrying neither, but the exit record shows it could not reach the API; not
@@ -8,6 +8,7 @@ import { error_text } from '#scripts/lib/error-message'
8
8
  import { latest_scope_cli } from '#scripts/version/latest-scope-cli'
9
9
  import { run_prep, type PrepParts } from './run-prep'
10
10
  import { run_prep_locate } from './run-prep-locate'
11
+ import { run_ship_preflight } from './run-ship-preflight'
11
12
 
12
13
  // `josh run:prep <N>` — one call for the reads a `fullrun` makes before its first edit
13
14
  // (joshuafolkken/kit#1978): the issue body and comments (`issue:read`), the state, labels and
@@ -41,6 +42,7 @@ interface PrepReads {
41
42
  latest: LatestDecision
42
43
  has_changes: boolean
43
44
  locations: string
45
+ ship_problems: ReadonlyArray<string>
44
46
  }
45
47
 
46
48
  // Exactly one issue number, or the call is refused: `run:prep` prepares one run, and a second number
@@ -91,15 +93,16 @@ async function lane_has_changes(): Promise<boolean> {
91
93
  }
92
94
 
93
95
  async function gather(issue_number: string): Promise<PrepReads> {
94
- const [content, state, has_changes] = await Promise.all([
96
+ const [content, state, has_changes, ship_problems] = await Promise.all([
95
97
  issue_read_cli.read_block(issue_number),
96
98
  issue_state_cli.read_issue(issue_number),
97
99
  lane_has_changes(),
100
+ run_ship_preflight.ahead(issue_number),
98
101
  ])
99
102
  // Located from the body just read, so it waits for that read rather than joining the batch above.
100
103
  const locations = await run_prep_locate.locate(content.kind === 'ok' ? content.block : '')
101
104
 
102
- return { content, state, latest: latest_decision(), has_changes, locations }
105
+ return { content, state, latest: latest_decision(), has_changes, locations, ship_problems }
103
106
  }
104
107
 
105
108
  function content_body(issue_number: string, content: BlockRead): string {
@@ -119,6 +122,7 @@ function to_parts(issue_number: string, reads: PrepReads): PrepParts {
119
122
  latest_reason: reads.latest.reason,
120
123
  has_changes: reads.has_changes,
121
124
  locations: reads.locations,
125
+ ship_problems: reads.ship_problems,
122
126
  }
123
127
  }
124
128
 
@@ -24,6 +24,10 @@ const CONTENT_HEADER = '=== issue ==='
24
24
  const STATE_HEADER = '=== state ==='
25
25
  const LATEST_HEADER = '=== dependency update ==='
26
26
  const LOCATIONS_HEADER = '=== code locations ==='
27
+ const SHIP_HEADER = '=== ship preconditions ==='
28
+ const SHIP_MET = 'met — nothing `josh ship` refuses on as the tree stands'
29
+ const SHIP_UNMET =
30
+ 'unmet — `josh ship` will refuse until these are fixed; fix them before the gate:'
27
31
  const SECTION_SEPARATOR = '\n\n'
28
32
  const LATEST_JOINER = ' — '
29
33
 
@@ -41,6 +45,9 @@ interface PrepParts {
41
45
  // Whether a lane child's tree holds uncommitted work — read for the verdict, not printed in the
42
46
  // report (joshuafolkken/kit#2476).
43
47
  has_changes: boolean
48
+ // What `josh ship` would refuse on as the tree stands — `run_ship_preflight.ahead`
49
+ // (joshuafolkken/kit#3154).
50
+ ship_problems: ReadonlyArray<string>
44
51
  }
45
52
 
46
53
  function human_review_value(state: IssueState | undefined): string {
@@ -72,6 +79,12 @@ function section(header: string, body: string): string {
72
79
  return `${header}\n${body}`
73
80
  }
74
81
 
82
+ function ship_body(problems: ReadonlyArray<string>): string {
83
+ if (problems.length === 0) return SHIP_MET
84
+
85
+ return [SHIP_UNMET, ...problems.map((problem) => ` - ${problem}`)].join('\n')
86
+ }
87
+
75
88
  function format_report(parts: PrepParts): string {
76
89
  return [
77
90
  summary(parts),
@@ -79,6 +92,7 @@ function format_report(parts: PrepParts): string {
79
92
  section(STATE_HEADER, state_body(parts)),
80
93
  section(LATEST_HEADER, `${parts.latest_scope}${LATEST_JOINER}${parts.latest_reason}`),
81
94
  section(LOCATIONS_HEADER, parts.locations),
95
+ section(SHIP_HEADER, ship_body(parts.ship_problems)),
82
96
  ].join(SECTION_SEPARATOR)
83
97
  }
84
98
 
@@ -86,9 +100,12 @@ const run_prep = {
86
100
  CONTENT_HEADER,
87
101
  LATEST_HEADER,
88
102
  LOCATIONS_HEADER,
103
+ SHIP_HEADER,
104
+ SHIP_UNMET,
89
105
  STATE_HEADER,
90
106
  SUMMARY_PREFIX,
91
107
  format_report,
108
+ ship_body,
92
109
  }
93
110
 
94
111
  export type { PrepParts }
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { git_gh_command } from '#scripts/gh/git-gh-command'
3
+ import { git_pr } from '#scripts/gh/git-pr'
3
4
  import { changed_paths } from '#scripts/git/changed-paths'
4
5
  import { git_branch } from '#scripts/git/git-branch'
5
6
  import { git_preflight } from '#scripts/git/git-preflight'
@@ -22,6 +23,7 @@ import { run_ship_scoped } from './run-ship-scoped'
22
23
  const SUCCESS_EXIT_CODE = 0
23
24
  const FAILURE_EXIT_CODE = 1
24
25
  const EVIDENCE_PROBLEM = `This change touches runtime code, but the PR body has no ${live_evidence.EVIDENCE_HEADING} section: run the acceptance criteria for real and pass the body with --body-file <path>.`
26
+ const EVIDENCE_PENDING = `This change touches runtime code and no PR is open yet: write the ${live_evidence.EVIDENCE_HEADING} section to a file and hand it to josh ship with --body-file <path>, or the ship refuses.`
25
27
 
26
28
  interface PreflightRequest {
27
29
  title: string
@@ -34,13 +36,20 @@ async function body_text(body_path: string | undefined): Promise<string | undefi
34
36
  return await git_gh_command.pr_get_body(await git_branch.current())
35
37
  }
36
38
 
37
- async function evidence_problems(body_path: string | undefined): Promise<Array<string>> {
39
+ // `no_body_problem` answers a runtime change with no body to read at all — no `--body-file` and no open
40
+ // PR, which is every run before its ship opens one.
41
+ async function evidence_problems(
42
+ body_path: string | undefined,
43
+ no_body_problem: string = EVIDENCE_PROBLEM,
44
+ ): Promise<Array<string>> {
38
45
  const [paths, body] = await Promise.all([
39
46
  changed_paths.read_changed_paths(false),
40
47
  body_text(body_path),
41
48
  ])
42
49
 
43
- return live_evidence.verdict_for(paths, body) === 'required' ? [EVIDENCE_PROBLEM] : []
50
+ if (live_evidence.verdict_for(paths, body) !== 'required') return []
51
+
52
+ return [body === undefined ? no_body_problem : EVIDENCE_PROBLEM]
44
53
  }
45
54
 
46
55
  function refusal(problems: ReadonlyArray<string>): JoshResult {
@@ -81,7 +90,29 @@ async function stage(request: PreflightRequest): Promise<JoshResult> {
81
90
  return scoped.code === SUCCESS_EXIT_CODE ? { code: SUCCESS_EXIT_CODE, out: 'ready' } : scoped
82
91
  }
83
92
 
84
- const run_ship_preflight = { EVIDENCE_PROBLEM, stage }
93
+ async function classification_problems(issue_number: string): Promise<Array<string>> {
94
+ await git_pr.release_classification(issue_number, await git_branch.current())
95
+
96
+ return []
97
+ }
98
+
99
+ // **The same two refusals, asked before the ship rather than by it** (joshuafolkken/kit#3154). In one
100
+ // measured backlogrun 20 of 41 lane `ship` refusals were a missing classification or missing evidence —
101
+ // facts fixed long before the ship, refused at the run's largest context. `run:prep` (and so `run:entry`)
102
+ // and `run:step` print this, so the Issue body and the evidence are met while they are cheap. Nothing
103
+ // here replaces `stage`: the ship and `followup` still refuse on both. Before the PR exists the evidence
104
+ // can only live in the file the ship will be handed, so it is asked for as that file, not reported as a
105
+ // body that already failed.
106
+ async function ahead(issue_number: string): Promise<Array<string>> {
107
+ const [classification, evidence] = await Promise.all([
108
+ caught(classification_problems(issue_number)),
109
+ caught(evidence_problems(undefined, EVIDENCE_PENDING)),
110
+ ])
111
+
112
+ return [...classification, ...evidence]
113
+ }
114
+
115
+ const run_ship_preflight = { EVIDENCE_PENDING, EVIDENCE_PROBLEM, ahead, stage }
85
116
 
86
117
  export type { PreflightRequest }
87
118
  export { run_ship_preflight }
@@ -155,7 +155,7 @@ async function record_and_route(
155
155
  if (recorded.code !== SUCCESS_EXIT_CODE) return recorded
156
156
 
157
157
  const outcome = outcome_of(verdict)
158
- const out = [...verdict.specs, outcome.note].join('\n')
158
+ const out = [...verdict.report, outcome.note].join('\n')
159
159
 
160
160
  return outcome.is_passing ? { code: SUCCESS_EXIT_CODE, out } : failure(out)
161
161
  }
@@ -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.