@joshuafolkken/kit 1.1136.0 → 1.1178.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/.claude/settings.json +4 -4
  2. package/.claude/skills/epic-commands/SKILL.md +27 -239
  3. package/.claude/skills/epic-commands/epic-bundle.md +115 -0
  4. package/.claude/skills/epic-commands/execution-waves.md +126 -0
  5. package/.claude/skills/workflow-commands/SKILL.md +48 -194
  6. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  7. package/.claude/skills/workflow-commands/backlogrun-child.md +12 -123
  8. package/.claude/skills/workflow-commands/backlogrun-lanes.md +14 -63
  9. package/.claude/skills/workflow-commands/backlogrun-park.md +16 -15
  10. package/.claude/skills/workflow-commands/backlogrun-progress.md +17 -173
  11. package/.claude/skills/workflow-commands/backlogrun-recovery.md +155 -0
  12. package/.claude/skills/workflow-commands/backlogrun-steps.md +65 -144
  13. package/.claude/skills/workflow-commands/backlogrun.md +5 -4
  14. package/.claude/skills/workflow-commands/chain-rule.md +3 -2
  15. package/.claude/skills/workflow-commands/delegation.md +2 -2
  16. package/.claude/skills/workflow-commands/entry-sequence.md +62 -0
  17. package/.claude/skills/workflow-commands/followup.md +104 -181
  18. package/.claude/skills/workflow-commands/fullrun-steps.md +8 -9
  19. package/.claude/skills/workflow-commands/fullrun.md +16 -73
  20. package/.claude/skills/workflow-commands/halfrun.md +10 -36
  21. package/.claude/skills/workflow-commands/into-target.md +4 -4
  22. package/.claude/skills/workflow-commands/issue-comments.md +7 -8
  23. package/.claude/skills/workflow-commands/issue-scout.md +6 -6
  24. package/.claude/skills/workflow-commands/kickoff.md +6 -6
  25. package/.claude/skills/workflow-commands/needs-human-review.md +6 -6
  26. package/.claude/skills/workflow-commands/observation-filing.md +21 -172
  27. package/.claude/skills/workflow-commands/observation-ledger.md +150 -0
  28. package/.claude/skills/workflow-commands/pre-gate-cut.md +35 -95
  29. package/.claude/skills/workflow-commands/prerequisite.md +6 -6
  30. package/.claude/skills/workflow-commands/progress-watcher.md +90 -0
  31. package/.claude/skills/workflow-commands/prrun.md +5 -5
  32. package/.claude/skills/workflow-commands/retrospective.md +9 -9
  33. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  34. package/.claude/skills/workflow-commands/target-repository.md +2 -2
  35. package/.claude/skills/workflow-commands/working-tree-hold.md +7 -5
  36. package/.codex/hooks.json +4 -4
  37. package/.github/workflows/ci.yml +15 -2
  38. package/AGENTS.md +1 -6
  39. package/CLAUDE.md +58 -120
  40. package/GEMINI.md +1 -6
  41. package/dist/CLAUDE.md +58 -120
  42. package/dist/hooks/batch-guard.js +4 -4
  43. package/dist/hooks/{chunk-NFZGFGOR.js → chunk-32GQTL4F.js} +14 -1
  44. package/dist/hooks/{chunk-HBKZLITF.js → chunk-DO6NWWHU.js} +4 -4
  45. package/dist/hooks/{chunk-KBXZLMRH.js → chunk-G6KRCKEC.js} +216 -76
  46. package/dist/hooks/{chunk-JKKTVDAG.js → chunk-GDS5N2KB.js} +186 -309
  47. package/dist/hooks/chunk-I4XSL4TT.js +56 -0
  48. package/dist/hooks/{chunk-GDU26K3J.js → chunk-S6X6WYKR.js} +886 -174
  49. package/dist/hooks/{chunk-FO2I7UT7.js → chunk-UAGLLIGZ.js} +1 -1
  50. package/dist/hooks/{chunk-RFWWUI5F.js → chunk-VESKRQOF.js} +3 -3
  51. package/dist/hooks/{chunk-KOKOAV6D.js → chunk-VQLLNFNT.js} +6 -4
  52. package/dist/hooks/{chunk-LJBLGHEH.js → chunk-ZAL3NRGL.js} +4 -6
  53. package/dist/hooks/codex-hook-adapter.js +10 -10
  54. package/dist/hooks/duplicate-read-guard.js +5 -5
  55. package/dist/hooks/format-edited.js +2 -2
  56. package/dist/hooks/inputs.json +178 -177
  57. package/dist/hooks/investigation-guard.js +5 -5
  58. package/dist/hooks/pretool-guard.js +11 -10
  59. package/dist/hooks/stop-guard.js +20 -6
  60. package/dist/josh.js +29 -3
  61. package/dist/version/index.js +5 -1
  62. package/package.json +2 -1
  63. package/prompts/collaboration-workflow/file-edits.md +11 -10
  64. package/prompts/collaboration-workflow/gh-rest.md +2 -4
  65. package/prompts/collaboration-workflow/glossary.md +1 -1
  66. package/prompts/collaboration-workflow/issue-citation.md +5 -9
  67. package/prompts/collaboration-workflow/issue-template.md +40 -115
  68. package/prompts/collaboration-workflow/operating-rules.md +4 -5
  69. package/prompts/collaboration-workflow/output-bounds.md +12 -78
  70. package/prompts/collaboration-workflow/overview.md +7 -13
  71. package/prompts/collaboration-workflow/plan-comment.md +11 -39
  72. package/prompts/collaboration-workflow/principles.md +3 -4
  73. package/prompts/collaboration-workflow/report-format.md +28 -74
  74. package/prompts/collaboration-workflow/residency.md +12 -109
  75. package/prompts/collaboration-workflow/rule-delivery.md +98 -97
  76. package/prompts/collaboration-workflow/shell-body.md +3 -13
  77. package/prompts/collaboration-workflow/turn-batching.md +14 -88
  78. package/prompts/collaboration-workflow/upstream-interrupt.md +12 -14
  79. package/prompts/collaboration-workflow/wip-cap.md +6 -45
  80. package/prompts/collaboration-workflow.md +1 -13
  81. package/prompts/review-rubric.md +8 -71
  82. package/prompts/review.md +25 -55
  83. package/prompts/testing-guide.md +18 -67
  84. package/scripts/agent/agent-role-profile.ts +1 -1
  85. package/scripts/agent/codex-hooks.ts +3 -12
  86. package/scripts/backlog/backlog-budget.ts +1 -1
  87. package/scripts/backlog/backlog-drive-cli.ts +4 -3
  88. package/scripts/backlog/backlog-drive-offer-argv.ts +10 -1
  89. package/scripts/backlog/backlog-next.ts +15 -4
  90. package/scripts/backlog/backlog-offer.ts +6 -6
  91. package/scripts/backlog/backlog-plan-cli.ts +6 -5
  92. package/scripts/backlog/backlog-rank.ts +18 -9
  93. package/scripts/backlog/backlog-restructure.ts +152 -0
  94. package/scripts/backlog/backlog-waves.ts +15 -17
  95. package/scripts/behavior/behavior-rules.ts +11 -4
  96. package/scripts/cost-runtime/context-cut-threshold.ts +1 -9
  97. package/scripts/cost-runtime/cost-verdict.ts +15 -28
  98. package/scripts/delegation/investigation-reads.ts +5 -5
  99. package/scripts/discovery/repo-discovery.ts +21 -7
  100. package/scripts/discovery/repo-origin.ts +26 -0
  101. package/scripts/discovery/repo-party.ts +42 -1
  102. package/scripts/document/backlogrun-parent-read-set.ts +14 -15
  103. package/scripts/document/document-byte-budget.ts +34 -20
  104. package/scripts/document/entry-read-budget.ts +21 -9
  105. package/scripts/document/lane-child-read-set.ts +12 -14
  106. package/scripts/document/read-set-cli.ts +4 -4
  107. package/scripts/document/read-set-point-of-use.ts +10 -2
  108. package/scripts/document/resident-budget.ts +47 -0
  109. package/scripts/epic/epic-shape.ts +10 -13
  110. package/scripts/epic/epic-solo.ts +1 -1
  111. package/scripts/exports/unused-members-cli.ts +7 -3
  112. package/scripts/followup/git-followup-stages.ts +9 -0
  113. package/scripts/followup/git-pr-followup.ts +1 -1
  114. package/scripts/gh/git-gh-api-path.ts +7 -0
  115. package/scripts/gh/git-gh-issue.ts +5 -3
  116. package/scripts/gh/git-gh-pr-read.ts +4 -0
  117. package/scripts/gh/git-pr-checks-eval.ts +8 -0
  118. package/scripts/gh/git-pr-checks.ts +11 -1
  119. package/scripts/git/constants.ts +10 -0
  120. package/scripts/git/main-merge.ts +57 -21
  121. package/scripts/git/schemas.ts +2 -0
  122. package/scripts/hooks/pretool-guard-cli.ts +2 -2
  123. package/scripts/hooks/pretool-guard.ts +45 -2
  124. package/scripts/hooks/run-hook.sh +41 -0
  125. package/scripts/init/hook-command-rewrite.ts +18 -35
  126. package/scripts/init/hook-launch.ts +21 -32
  127. package/scripts/issue/issue-auto-ok.ts +95 -0
  128. package/scripts/issue/issue-file-cli.ts +53 -7
  129. package/scripts/issue/issue-file.ts +28 -5
  130. package/scripts/issue/issue-labels.ts +12 -12
  131. package/scripts/issue/issue-rank.ts +1 -1
  132. package/scripts/issue/issue-read-cli.ts +3 -3
  133. package/scripts/issue/issue-read.ts +2 -2
  134. package/scripts/issue/issue-scout-cli.ts +2 -2
  135. package/scripts/issue/issue-state-cli.ts +1 -1
  136. package/scripts/issue/issue-state.ts +2 -2
  137. package/scripts/issue/issue-wip.ts +70 -0
  138. package/scripts/josh/hook-decision.ts +17 -0
  139. package/scripts/josh/josh-commands-development.ts +11 -1
  140. package/scripts/josh/josh-commands-issue.ts +2 -2
  141. package/scripts/lane/lane-launch-cli.ts +21 -3
  142. package/scripts/lines/bytes-command.ts +29 -6
  143. package/scripts/notify/telegram-notify.ts +26 -1
  144. package/scripts/observations/observation-ledger-line.ts +1 -1
  145. package/scripts/release/release-progress.ts +167 -0
  146. package/scripts/release/release-publish.ts +19 -8
  147. package/scripts/release/release-tag.ts +22 -8
  148. package/scripts/rules/decision-oracle-stage.ts +1 -1
  149. package/scripts/rules/decision-oracle.ts +6 -6
  150. package/scripts/rules/delivered-rules.ts +11 -11
  151. package/scripts/rules/direct-pr-create.ts +110 -0
  152. package/scripts/rules/file-body.ts +98 -42
  153. package/scripts/rules/filing-cap.ts +2 -2
  154. package/scripts/rules/filing-offer.ts +1 -1
  155. package/scripts/rules/gh-api.ts +18 -1
  156. package/scripts/rules/implementation-cut.ts +3 -1
  157. package/scripts/rules/lane-background.ts +67 -10
  158. package/scripts/rules/permission-guards.ts +10 -2
  159. package/scripts/rules/pre-gate-cut.ts +2 -2
  160. package/scripts/rules/stop-rules.ts +4 -4
  161. package/scripts/rules/test-declared-commit.ts +1 -1
  162. package/scripts/rules/third-party-write.ts +56 -12
  163. package/scripts/run/run-carry.ts +11 -5
  164. package/scripts/run/run-cut-cli.ts +1 -1
  165. package/scripts/run/run-cut-handoff.ts +21 -1
  166. package/scripts/run/run-cut-report.ts +2 -2
  167. package/scripts/run/run-entry-cli.ts +11 -3
  168. package/scripts/run/run-hold-cli.ts +16 -2
  169. package/scripts/run/run-label.ts +50 -0
  170. package/scripts/run/run-merge-steps.ts +8 -3
  171. package/scripts/run/run-merge.ts +1 -1
  172. package/scripts/run/run-prep-cli.ts +6 -2
  173. package/scripts/run/run-prep.ts +17 -0
  174. package/scripts/run/run-ship-cli.ts +27 -6
  175. package/scripts/run/run-ship-next.ts +13 -0
  176. package/scripts/run/run-ship-pre-detach.ts +66 -0
  177. package/scripts/run/run-ship-preflight.ts +34 -3
  178. package/scripts/run/run-ship-return.ts +7 -4
  179. package/scripts/run/run-ship-review-steps.ts +1 -1
  180. package/scripts/run/run-ship-review.ts +26 -5
  181. package/scripts/run/run-ship-stage.ts +13 -0
  182. package/scripts/run/run-ship-steps.ts +10 -2
  183. package/scripts/run/run-ship-sync.ts +114 -0
  184. package/scripts/run/run-ship.ts +6 -0
  185. package/scripts/run/run-step-cli.ts +20 -7
  186. package/scripts/run/run-wake-cli.ts +8 -1
  187. package/scripts/run/run-wake-driver.ts +62 -8
  188. package/scripts/run/run-wake-session.ts +2 -1
  189. package/scripts/test/test-declared-logic.ts +1 -1
  190. package/scripts/test/test-red-logic.ts +17 -2
  191. package/scripts/test/test-red.ts +8 -7
  192. package/scripts/time-runtime/time-batch-guard.ts +52 -16
  193. package/scripts/time-runtime/time-density-hook.ts +20 -2
  194. package/scripts/time-runtime/time-density.ts +4 -3
  195. package/scripts/time-runtime/time-markers.ts +15 -4
  196. package/scripts/time-runtime/time-transcript-line.ts +27 -1
  197. package/scripts/version/npm-registry.ts +11 -1
  198. package/templates/workflows/ci.yml +9 -1
  199. package/.claude/skills/workflow-commands/followup-reference.md +0 -118
  200. package/dist/hooks/chunk-HANSKHES.js +0 -464
@@ -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.
@@ -20,6 +20,7 @@ const STAGE = {
20
20
  PREFLIGHT: 'preflight',
21
21
  REVIEW: 'review',
22
22
  GATE: 'gate',
23
+ SYNC: 'sync',
23
24
  COMMIT: 'commit',
24
25
  ROUND_TWO: 'round-2',
25
26
  FOLLOWUP: 'followup',
@@ -99,6 +100,9 @@ const DONE_BY: Record<Stage, (done: ReadonlySet<string>, state: ShipState) => bo
99
100
  // the recorded round, and only a commit pins the tree that round read.
100
101
  [STAGE.REVIEW]: (_done, state) => state.is_merged || state.is_committed,
101
102
  [STAGE.GATE]: (_done, state) => state.is_merged || state.is_committed,
103
+ // The merge of the default branch before the commit (joshuafolkken/kit#3221): once committed, a
104
+ // conflicting pull request is the followup's to merge again.
105
+ [STAGE.SYNC]: (_done, state) => state.is_merged || state.is_committed,
102
106
  [STAGE.COMMIT]: (done, state) => state.is_merged || (done.has(STAGE.COMMIT) && is_shipped(state)),
103
107
  // The round-2 pass (joshuafolkken/kit#2489) reads the pushed fix delta, so its record is honored only
104
108
  // while that push still stands — new work pushed on top reruns it.
@@ -138,6 +142,14 @@ function event_text(issue: string, stage: Stage, phase: Phase): string {
138
142
  return `#${issue} ${stage} ${phase}`
139
143
  }
140
144
 
145
+ // A stop on a merge git could not finish is named `conflict` rather than by the stage it happened in
146
+ // (joshuafolkken/kit#3221), so the event stream reads the cause without anyone opening the report.
147
+ const CONFLICT = 'conflict'
148
+
149
+ function stop_reason(stage: Stage, conflicts: ReadonlyArray<string> = []): string {
150
+ return conflicts.length > 0 ? CONFLICT : stage
151
+ }
152
+
141
153
  const run_ship_stage = {
142
154
  PHASE,
143
155
  STAGE,
@@ -148,6 +160,7 @@ const run_ship_stage = {
148
160
  mark_done,
149
161
  read_done,
150
162
  record_path,
163
+ stop_reason,
151
164
  }
152
165
 
153
166
  export type { Phase, ShipState, Stage }
@@ -4,6 +4,7 @@ import { run_ship_preflight } from './run-ship-preflight'
4
4
  import { run_ship_review_steps } from './run-ship-review-steps'
5
5
  import { run_ship_scoped } from './run-ship-scoped'
6
6
  import { run_ship_stage, type ShipState, type Stage } from './run-ship-stage'
7
+ import { run_ship_sync, type StepResult } from './run-ship-sync'
7
8
 
8
9
  // The stages `josh ship` runs, in the order a change ships (joshuafolkken/kit#2398), kept apart from the
9
10
  // chaining CLI so the order is read in one place.
@@ -24,7 +25,7 @@ interface ShipArguments {
24
25
  interface Step {
25
26
  stage: Stage
26
27
  header: string
27
- run: (args: ShipArguments, state: ShipState) => Promise<JoshResult>
28
+ run: (args: ShipArguments, state: ShipState) => Promise<StepResult>
28
29
  }
29
30
 
30
31
  const { STAGE } = run_ship_stage
@@ -56,6 +57,13 @@ const COMMIT_STEPS: ReadonlyArray<Step> = [
56
57
  async () => await josh(['gate']),
57
58
  ]),
58
59
  },
60
+ // joshuafolkken/kit#3221: the default branch merged again right before the commit, so the pull
61
+ // request opens on a branch that is current; a clean merge goes on without waking anyone.
62
+ {
63
+ stage: STAGE.SYNC,
64
+ header: run_ship.SYNC_HEADER,
65
+ run: async () => await run_ship_sync.sync_stage(),
66
+ },
59
67
  {
60
68
  stage: STAGE.COMMIT,
61
69
  header: run_ship.COMMIT_HEADER,
@@ -69,7 +77,7 @@ const MERGE_STEPS: ReadonlyArray<Step> = [
69
77
  {
70
78
  stage: STAGE.FOLLOWUP,
71
79
  header: run_ship.FOLLOWUP_HEADER,
72
- run: async (args) => await josh(['followup', args.title, ...args.notify]),
80
+ run: async (args) => await run_ship_sync.followup_stage(args.title, args.notify),
73
81
  },
74
82
  {
75
83
  stage: STAGE.REPORT,
@@ -0,0 +1,114 @@
1
+ import { git_gh_pr_read } from '#scripts/gh/git-gh-pr-read'
2
+ import { is_merge_conflict } from '#scripts/gh/git-pr-checks-eval'
3
+ import { git_command } from '#scripts/git/git-command'
4
+ import { main_merge, type MergeOutcome } from '#scripts/git/main-merge'
5
+ import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
6
+ import { error_text } from '#scripts/lib/error-message'
7
+ import { run_ship_scoped } from './run-ship-scoped'
8
+
9
+ // `josh ship` keeps the branch current with the default branch itself (joshuafolkken/kit#3221). The
10
+ // lane merged it once, before the hand-off, and nothing merged it again while the pull request opened
11
+ // and CI ran — so a default branch that moved meanwhile stopped the followup as `interrupted`, and a
12
+ // relaunched session spent its first turns finding out that the cause was a conflict. Most of those
13
+ // conflicts were not conflicts at all to git: merging the default branch again resolved them.
14
+ //
15
+ // So the merge happens twice more, mechanically: once before the commit (`sync_stage`), and once when
16
+ // the followup fails on a pull request GitHub reports `DIRTY` (`followup_stage`). Only a merge git
17
+ // itself cannot finish stops the ship, and it stops as `conflict`, naming the unmerged paths so the
18
+ // resumed session goes straight to them.
19
+
20
+ const should_forward_stderr = true
21
+ const SUCCESS_EXIT_CODE = 0
22
+ const FAILURE_EXIT_CODE = 1
23
+ // A default branch that keeps moving under a pull request is merged again at most this often per ship;
24
+ // past it the followup failure stands, so a busy default branch cannot hold a ship in a loop.
25
+ const MAX_MERGE_RETRIES = 2
26
+ const CONFLICT_NOTE = 'merging the default branch left unmerged paths:'
27
+ const DEFERRED_PREFIX = 'merge deferred to the followup: '
28
+ const CURRENT_SUFFIX = ' brings nothing in'
29
+
30
+ // A stage's result, plus the paths a stopped merge left unmerged — carried to the stop prompt.
31
+ interface StepResult extends JoshResult {
32
+ conflicts?: ReadonlyArray<string>
33
+ }
34
+
35
+ async function josh(argv: ReadonlyArray<string>): Promise<JoshResult> {
36
+ return await josh_command.josh_run(argv, should_forward_stderr)
37
+ }
38
+
39
+ // A merge that could not even be attempted — a fetch that failed — reads as a refusal: nothing was
40
+ // changed, and the followup still finds a real conflict on GitHub's side.
41
+ async function merge_outcome(): Promise<MergeOutcome> {
42
+ try {
43
+ return await main_merge.merge()
44
+ } catch (error) {
45
+ return { kind: 'refused', message: error_text.message_of(error) }
46
+ }
47
+ }
48
+
49
+ function conflict_result(files: ReadonlyArray<string>, lead: string): StepResult {
50
+ const out = [lead, CONFLICT_NOTE, ...files].filter((line) => line !== '').join('\n')
51
+
52
+ return { code: FAILURE_EXIT_CODE, out, conflicts: files }
53
+ }
54
+
55
+ // A refusal does not stop the commit: the guard refuses only uncommitted work the default branch also
56
+ // touches, and once that work is committed the followup merges again on a clean tree. A merge that changed
57
+ // the tree meets the scoped pair again, so the commit never carries an unchecked merge.
58
+ async function sync_stage(): Promise<StepResult> {
59
+ const outcome = await merge_outcome()
60
+
61
+ if (outcome.kind === 'conflict') return conflict_result(outcome.files, '')
62
+ if (outcome.kind === 'merged') return await run_ship_scoped.scoped_pair()
63
+
64
+ const out =
65
+ outcome.kind === 'current'
66
+ ? `${outcome.branch}${CURRENT_SUFFIX}`
67
+ : `${DEFERRED_PREFIX}${outcome.message}`
68
+
69
+ return { code: SUCCESS_EXIT_CODE, out }
70
+ }
71
+
72
+ // Any read that fails answers "not conflicting": the followup failure then stands as it was.
73
+ async function is_conflicting(): Promise<boolean> {
74
+ try {
75
+ const state = await git_gh_pr_read.pr_get_merge_state(await git_command.branch())
76
+
77
+ return is_merge_conflict(state?.merge_state_status)
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ // The result to stop on, or `undefined` once a clean merge is pushed and the followup can wait again.
84
+ async function merge_again(title: string, failed: JoshResult): Promise<StepResult | undefined> {
85
+ if (!(await is_conflicting())) return failed
86
+
87
+ const outcome = await merge_outcome()
88
+
89
+ if (outcome.kind === 'conflict') return conflict_result(outcome.files, failed.out)
90
+ if (outcome.kind !== 'merged') return failed
91
+
92
+ const push = await josh(['git', '-y', '--skip-commit', '--skip-pr', title])
93
+
94
+ return push.code === SUCCESS_EXIT_CODE ? undefined : push
95
+ }
96
+
97
+ async function followup_stage(
98
+ title: string,
99
+ notify: ReadonlyArray<string>,
100
+ retries = 0,
101
+ ): Promise<StepResult> {
102
+ const result = await josh(['followup', title, ...notify])
103
+
104
+ if (result.code === SUCCESS_EXIT_CODE || retries >= MAX_MERGE_RETRIES) return result
105
+
106
+ const stop = await merge_again(title, result)
107
+
108
+ return stop ?? (await followup_stage(title, notify, retries + 1))
109
+ }
110
+
111
+ const run_ship_sync = { CONFLICT_NOTE, MAX_MERGE_RETRIES, followup_stage, sync_stage }
112
+
113
+ export type { StepResult }
114
+ export { run_ship_sync }
@@ -10,8 +10,10 @@
10
10
  // composite ends at the failure, and the report names the step that failed so the run reads only it.
11
11
 
12
12
  const PREFLIGHT_HEADER = '=== preflight ==='
13
+ const PRE_DETACH_HEADER = '=== pre-detach checks ==='
13
14
  const REVIEW_HEADER = '=== review ==='
14
15
  const GATE_HEADER = '=== gate ==='
16
+ const SYNC_HEADER = '=== sync origin/main ==='
15
17
  const COMMIT_HEADER = '=== commit/push/PR ==='
16
18
  const ROUND_TWO_HEADER = '=== round-2 review ==='
17
19
  const FOLLOWUP_HEADER = '=== followup ==='
@@ -29,6 +31,8 @@ interface ShipSection {
29
31
  header: string
30
32
  body: string
31
33
  code: number
34
+ // The paths a stopped merge left unmerged (joshuafolkken/kit#3221), handed to the stop prompt.
35
+ conflicts?: ReadonlyArray<string>
32
36
  }
33
37
 
34
38
  function section_report(section: ShipSection): string {
@@ -65,10 +69,12 @@ const run_ship = {
65
69
  FOLLOWUP_HEADER,
66
70
  GATE_HEADER,
67
71
  PREFLIGHT_HEADER,
72
+ PRE_DETACH_HEADER,
68
73
  REPORT_HEADER,
69
74
  REVIEW_HEADER,
70
75
  ROUND_TWO_HEADER,
71
76
  SKIPPED_BODY,
77
+ SYNC_HEADER,
72
78
  STOPPED_PREFIX,
73
79
  exit_code,
74
80
  failed_section,
@@ -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.
@@ -209,10 +209,16 @@ function log_hint(context: WakeContext): string {
209
209
  return `The output of every session this supervisor started is in ${context.log_target}.`
210
210
  }
211
211
 
212
+ // The log hint goes ahead of the note: the Telegram length limit cuts the tail, and a long failure note
213
+ // (stdout tail plus stderr tail) would otherwise take the one line that says where to look next.
214
+ function warning_body(stop: LoopStop, body: string, context: WakeContext): string {
215
+ return [body, log_hint(context), stop.note].filter(Boolean).join('\n')
216
+ }
217
+
212
218
  async function warn_of_stop(stop: LoopStop, body: string, context: WakeContext): Promise<void> {
213
219
  await telegram_notify.warn({
214
220
  issue_title: WARNING_TITLE,
215
- body: [body, stop.note, log_hint(context)].filter(Boolean).join('\n'),
221
+ body: warning_body(stop, body, context),
216
222
  recovery: WARNING_RECOVERY,
217
223
  })
218
224
  }
@@ -408,6 +414,7 @@ const run_wake_cli = {
408
414
  USAGE,
409
415
  run,
410
416
  stop_body,
417
+ warning_body,
411
418
  }
412
419
 
413
420
  if (process.argv[1] === SCRIPT_PATH) await main(process.argv.slice(ARGV_OFFSET))
@@ -8,6 +8,8 @@ const FIRST_LINE = 0
8
8
  const RESUME_PREFIX = 'resume: '
9
9
  const DETAILS_TAIL_LENGTH = 2048
10
10
  const TRUNCATION_MARK = '... '
11
+ // The prefix `backlog-drive-cli.ts` gives the exception it catches.
12
+ const ERROR_PREFIX = 'error '
11
13
 
12
14
  function driver_args(invocation: string): ReadonlyArray<string> | undefined {
13
15
  const rebuilt = run_invocation.rebuild(invocation)
@@ -37,16 +39,54 @@ function details_tail(details: string): string {
37
39
  : details
38
40
  }
39
41
 
40
- function handoff_material(verdict: string, resume: string, details: string): string {
42
+ // **The hand-off names its next move, so the woken session never searches the procedure for it**
43
+ // (joshuafolkken/kit#3156). Whatever the branch, the session acts on it and hands the loop back to the
44
+ // supervisor with a cut; only a person's stop ends the run instead.
45
+ //
46
+ // **The claim comes first** (joshuafolkken/kit#3238). The supervisor hands the record off before the
47
+ // wake, so until the session adopts it with `--resume` every count it writes — `--merged`, `--done`,
48
+ // the closing `--cut` — is refused as advancing a handed-off budget, and the supervisor, seeing no
49
+ // claim, ends the run as a failed wake.
50
+ const HAND_BACK = 'then hand the loop back with pnpm josh run:carry --cut --owner "$PPID"'
51
+ const NEXT_BY_VERDICT: ReadonlyArray<readonly [string, string]> = [
52
+ [
53
+ 'retrospective',
54
+ `run what pnpm josh run:step prints (retrospective.md), close it with pnpm josh run:carry --retrospective --summary "<result>" --owner "$PPID", ${HAND_BACK}`,
55
+ ],
56
+ [
57
+ 'merge human-review',
58
+ 'stop the run, because the child stopped before its commit for a person (needs-human-review.md)',
59
+ ],
60
+ ['epic #', `follow backlogrun-steps.md → "Named issues run first, in order", ${HAND_BACK}`],
61
+ ]
62
+ const NEXT_DEFAULT = `act on the result by backlogrun-steps.md → "The loop", ${HAND_BACK}`
63
+
64
+ function next_move(verdict: string, invocation: string): string {
65
+ const found = NEXT_BY_VERDICT.find(([prefix]) => verdict.startsWith(prefix))
66
+
67
+ return `\nNext: first claim the carry record with ${run_carry.claim_command(invocation)}, then ${found?.[1] ?? NEXT_DEFAULT}`
68
+ }
69
+
70
+ function handoff_material(
71
+ verdict: string,
72
+ resume: string,
73
+ invocation: string,
74
+ details: string,
75
+ ): string {
41
76
  const context = details === '' ? '' : `\nDetails: ${details_tail(details)}`
42
77
  const epic = verdict.startsWith('epic #')
43
78
  ? '\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
79
  : ''
45
80
 
46
- return `Driver result: ${verdict}\n${resume}${epic}${context}`
81
+ return `Driver result: ${verdict}\n${resume}${epic}${next_move(verdict, invocation)}${context}`
47
82
  }
48
83
 
49
- function driver_result(out: string, read: CarryRead, details = ''): DriveResult {
84
+ function driver_result(
85
+ out: string,
86
+ read: CarryRead,
87
+ invocation: string,
88
+ details = '',
89
+ ): DriveResult {
50
90
  const lines = out.split('\n')
51
91
  const verdict = lines[FIRST_LINE]
52
92
 
@@ -57,13 +97,27 @@ function driver_result(out: string, read: CarryRead, details = ''): DriveResult
57
97
  return { kind: 'failed', note: `backlog:drive returned an incomplete result: ${out}` }
58
98
  }
59
99
 
60
- return { kind: 'judgment', material: handoff_material(verdict, resume, details) }
100
+ return { kind: 'judgment', material: handoff_material(verdict, resume, invocation, details) }
101
+ }
102
+
103
+ // **The cause is the driver's stdout `error …` line, so it leads the note** (joshuafolkken/kit#3242).
104
+ // `backlog:drive` prints an exception only to stdout, and its stderr is never empty — every command it
105
+ // ran is there — so reading `err ?? out` dropped the one line that said why. The stderr follows as its
106
+ // tail, for the same reason `details_tail` cuts a judgment's details.
107
+ function cause_of(out: string): string {
108
+ const errors = out.split('\n').filter((line) => line.startsWith(ERROR_PREFIX))
109
+
110
+ return errors.length > 0 ? errors.join('\n') : details_tail(out)
111
+ }
112
+
113
+ function failure_note(result: JoshResult): string {
114
+ return [cause_of(result.out), details_tail(result.err ?? '')].filter(Boolean).join('\n')
61
115
  }
62
116
 
63
- function read_result(result: JoshResult, target: string): DriveResult {
64
- if (result.code !== SUCCESS) return { kind: 'failed', note: result.err ?? result.out }
117
+ function read_result(result: JoshResult, target: string, invocation: string): DriveResult {
118
+ if (result.code !== SUCCESS) return { kind: 'failed', note: failure_note(result) }
65
119
 
66
- return driver_result(result.out, run_carry.read_carry(target), result.err)
120
+ return driver_result(result.out, run_carry.read_carry(target), invocation, result.err)
67
121
  }
68
122
 
69
123
  function claim_record(target: string, invocation: string): boolean {
@@ -87,7 +141,7 @@ async function drive(target: string): Promise<DriveResult> {
87
141
 
88
142
  const result = await josh_command.josh_run(args, true)
89
143
 
90
- return read_result(result, target)
144
+ return read_result(result, target, read.carry.invocation)
91
145
  }
92
146
 
93
147
  export const run_wake_driver = { drive, driver_args, driver_result }
@@ -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.
@@ -1,4 +1,5 @@
1
1
  import { issue_bug_label } from '#scripts/issue/issue-bug-label'
2
+ import { observation_ledger } from '#scripts/observations/observation-ledger'
2
3
  import { z } from 'zod'
3
4
  import { test_declared_logic } from './test-declared-logic'
4
5
 
@@ -11,7 +12,7 @@ import { test_declared_logic } from './test-declared-logic'
11
12
  // that test catches anything — the added and changed tests run against the merge-base, and at least one
12
13
  // of them has to fail there.
13
14
 
14
- type RedVerdict = 'red' | 'green' | 'no-test'
15
+ type RedVerdict = 'red' | 'green' | 'no-test' | 'test-only'
15
16
 
16
17
  // The declaration a bug-fix Issue carries under its background heading, in the same slot as the
17
18
  // behavior-change declaration (`behavior-change-lint.ts`). Read from the declaration, never inferred,
@@ -33,6 +34,18 @@ function unit_test_files(paths: ReadonlyArray<string>): Array<string> {
33
34
  .filter((path) => test_declared_logic.is_test_file(path) && path.endsWith(UNIT_TEST_SUFFIX))
34
35
  }
35
36
 
37
+ // **A change whose every path is a test file has no pre-fix code to run against**
38
+ // (joshuafolkken/kit#3214). The merge-base then runs the same code as HEAD, so its result equals HEAD's
39
+ // and says nothing about the fix. Exempt paths do not count: a document-rule test reads `*.md` and
40
+ // `prompts/` files, so a change to one of those is pre-fix content the test can be red against.
41
+ // Observation ledger paths do not count either way (joshuafolkken/kit#3230): a run commits its own
42
+ // ledger entry, and no test reads it as pre-fix content.
43
+ function is_test_only(paths: ReadonlyArray<string>): boolean {
44
+ const counted = paths.filter((path) => !observation_ledger.is_ledger_path(path))
45
+
46
+ return counted.length > 0 && counted.every((path) => test_declared_logic.is_test_file(path))
47
+ }
48
+
36
49
  // Vitest's JSON report carries one `testResults` entry per file it loaded — a file that failed to load
37
50
  // included — and none for a file its config excluded, so this is how many files actually ran. The
38
51
  // changed-path count cannot stand in for it: a file the merge-base config does not include runs nothing
@@ -58,7 +71,8 @@ function verdict_for(test_count: number, is_failed: boolean): RedVerdict {
58
71
  }
59
72
 
60
73
  // Only `green` on a declared bug fix is refused. `no-test` is already `josh test:declared`'s refusal
61
- // when a runtime file changed, and a declared fix that touched only docs has nothing to reproduce.
74
+ // when a runtime file changed, a declared fix that touched only docs has nothing to reproduce, and a
75
+ // `test-only` fix runs the same code on the merge-base as on HEAD.
62
76
  function is_refused(body: string, verdict: RedVerdict): boolean {
63
77
  return verdict === 'green' && is_bug_fix(body)
64
78
  }
@@ -67,6 +81,7 @@ const test_red_logic = {
67
81
  BUG_DECLARATION_LINE,
68
82
  is_bug_fix,
69
83
  is_refused,
84
+ is_test_only,
70
85
  ran_file_count,
71
86
  unit_test_files,
72
87
  verdict_for,
@@ -11,7 +11,7 @@ import { execa } from 'execa'
11
11
  import { test_red_logic, type RedVerdict } from './test-red-logic'
12
12
 
13
13
  // `josh test:red` — run the added and changed Vitest files against the pre-fix tree and print `red`,
14
- // `green` or `no-test` (joshuafolkken/kit#2448).
14
+ // `green`, `no-test` or `test-only` (joshuafolkken/kit#2448).
15
15
  //
16
16
  // **The pre-fix tree is a detached worktree at the merge-base, never the caller's checkout.** The tests
17
17
  // are copied in from the working tree, so what runs is the new test against the old code, and the
@@ -26,7 +26,7 @@ const REPORT_NAME = 'report.json'
26
26
  const NODE_MODULES = 'node_modules'
27
27
  const VITEST_BIN = path.join(NODE_MODULES, '.bin', 'vitest')
28
28
  const USAGE =
29
- 'Usage: josh test:red — prints red / green / no-test for the changed tests on the merge-base'
29
+ 'Usage: josh test:red — prints red / green / no-test / test-only for the changed tests on the merge-base'
30
30
 
31
31
  interface RedRun {
32
32
  verdict: RedVerdict
@@ -34,9 +34,7 @@ interface RedRun {
34
34
  }
35
35
 
36
36
  // The changed Vitest files that still exist — a deleted test has nothing to run.
37
- async function changed_tests(): Promise<Array<string>> {
38
- const paths = await changed_paths.read_changed_paths(false)
39
-
37
+ function changed_tests(paths: ReadonlyArray<string>): Array<string> {
40
38
  return test_red_logic.unit_test_files(paths).filter((file) => existsSync(file))
41
39
  }
42
40
 
@@ -126,12 +124,15 @@ async function verdict_on(base: string, files: ReadonlyArray<string>): Promise<R
126
124
  }
127
125
 
128
126
  // The verdict over the current checkout. No merge-base (not a repository, no default branch) reads as
129
- // `no-test` — there is no pre-fix tree to run against, and a guard fails open.
127
+ // `no-test` — there is no pre-fix tree to run against, and a guard fails open. A change whose every path
128
+ // is a test file is `test-only` without a run: the merge-base runs the same code as HEAD.
130
129
  async function run(): Promise<RedRun> {
131
130
  const base = await change_base.resolved()
132
- const files = await changed_tests()
131
+ const paths = await changed_paths.read_changed_paths(false)
132
+ const files = changed_tests(paths)
133
133
 
134
134
  if (base === undefined || files.length === 0) return { verdict: 'no-test', files }
135
+ if (test_red_logic.is_test_only(paths)) return { verdict: 'test-only', files }
135
136
 
136
137
  return { verdict: await verdict_on(base, files), files }
137
138
  }