@joshuafolkken/kit 1.1166.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 (61) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-lanes.md +3 -3
  2. package/.claude/skills/workflow-commands/backlogrun-park.md +6 -7
  3. package/.claude/skills/workflow-commands/backlogrun-steps.md +3 -2
  4. package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -4
  5. package/.github/workflows/ci.yml +15 -2
  6. package/dist/hooks/batch-guard.js +4 -4
  7. package/dist/hooks/{chunk-5KTCMCOT.js → chunk-32GQTL4F.js} +1 -1
  8. package/dist/hooks/{chunk-URJA6XY4.js → chunk-DO6NWWHU.js} +4 -4
  9. package/dist/hooks/{chunk-GU5MATVE.js → chunk-G6KRCKEC.js} +11 -19
  10. package/dist/hooks/{chunk-ZDZ555YH.js → chunk-GDS5N2KB.js} +88 -291
  11. package/dist/hooks/{chunk-GYNGP7HI.js → chunk-I4XSL4TT.js} +1 -1
  12. package/dist/hooks/{chunk-DYAVAM6S.js → chunk-S6X6WYKR.js} +324 -49
  13. package/dist/hooks/{chunk-ANJ2WGFD.js → chunk-UAGLLIGZ.js} +1 -1
  14. package/dist/hooks/{chunk-ENA7P5FB.js → chunk-VESKRQOF.js} +3 -3
  15. package/dist/hooks/{chunk-V2BTOZHT.js → chunk-VQLLNFNT.js} +3 -3
  16. package/dist/hooks/{chunk-A6P7PN7P.js → chunk-ZAL3NRGL.js} +2 -2
  17. package/dist/hooks/codex-hook-adapter.js +10 -10
  18. package/dist/hooks/duplicate-read-guard.js +5 -5
  19. package/dist/hooks/format-edited.js +2 -2
  20. package/dist/hooks/inputs.json +192 -192
  21. package/dist/hooks/investigation-guard.js +5 -5
  22. package/dist/hooks/pretool-guard.js +9 -9
  23. package/dist/hooks/stop-guard.js +18 -4
  24. package/dist/josh.js +4 -1
  25. package/package.json +1 -1
  26. package/scripts/backlog/backlog-next.ts +15 -4
  27. package/scripts/backlog/backlog-plan-cli.ts +6 -5
  28. package/scripts/backlog/backlog-rank.ts +18 -9
  29. package/scripts/backlog/backlog-restructure.ts +152 -0
  30. package/scripts/backlog/backlog-waves.ts +15 -17
  31. package/scripts/cost-runtime/context-cut-threshold.ts +1 -9
  32. package/scripts/cost-runtime/cost-verdict.ts +15 -28
  33. package/scripts/followup/git-followup-stages.ts +9 -0
  34. package/scripts/followup/git-pr-followup.ts +1 -1
  35. package/scripts/gh/git-gh-issue.ts +5 -3
  36. package/scripts/gh/git-gh-pr-read.ts +4 -0
  37. package/scripts/gh/git-pr-checks-eval.ts +8 -0
  38. package/scripts/gh/git-pr-checks.ts +11 -1
  39. package/scripts/git/constants.ts +10 -0
  40. package/scripts/git/main-merge.ts +57 -21
  41. package/scripts/git/schemas.ts +2 -0
  42. package/scripts/josh/josh-commands-development.ts +1 -1
  43. package/scripts/lane/lane-launch-cli.ts +21 -3
  44. package/scripts/notify/telegram-notify.ts +26 -1
  45. package/scripts/rules/pre-gate-cut.ts +2 -2
  46. package/scripts/run/run-carry.ts +11 -5
  47. package/scripts/run/run-cut-cli.ts +1 -1
  48. package/scripts/run/run-cut-report.ts +2 -2
  49. package/scripts/run/run-ship-cli.ts +27 -6
  50. package/scripts/run/run-ship-next.ts +13 -0
  51. package/scripts/run/run-ship-pre-detach.ts +66 -0
  52. package/scripts/run/run-ship-return.ts +7 -4
  53. package/scripts/run/run-ship-stage.ts +13 -0
  54. package/scripts/run/run-ship-steps.ts +10 -2
  55. package/scripts/run/run-ship-sync.ts +114 -0
  56. package/scripts/run/run-ship.ts +6 -0
  57. package/scripts/run/run-wake-cli.ts +8 -1
  58. package/scripts/run/run-wake-driver.ts +42 -11
  59. package/scripts/test/test-red-logic.ts +17 -2
  60. package/scripts/test/test-red.ts +8 -7
  61. package/scripts/time-runtime/time-transcript-line.ts +27 -1
@@ -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,
@@ -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)
@@ -40,6 +42,11 @@ function details_tail(details: string): string {
40
42
  // **The hand-off names its next move, so the woken session never searches the procedure for it**
41
43
  // (joshuafolkken/kit#3156). Whatever the branch, the session acts on it and hands the loop back to the
42
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.
43
50
  const HAND_BACK = 'then hand the loop back with pnpm josh run:carry --cut --owner "$PPID"'
44
51
  const NEXT_BY_VERDICT: ReadonlyArray<readonly [string, string]> = [
45
52
  [
@@ -48,28 +55,38 @@ const NEXT_BY_VERDICT: ReadonlyArray<readonly [string, string]> = [
48
55
  ],
49
56
  [
50
57
  'merge human-review',
51
- 'the child stopped before its commit for a person; stop the run (needs-human-review.md)',
58
+ 'stop the run, because the child stopped before its commit for a person (needs-human-review.md)',
52
59
  ],
53
60
  ['epic #', `follow backlogrun-steps.md → "Named issues run first, in order", ${HAND_BACK}`],
54
61
  ]
55
62
  const NEXT_DEFAULT = `act on the result by backlogrun-steps.md → "The loop", ${HAND_BACK}`
56
63
 
57
- function next_move(verdict: string): string {
64
+ function next_move(verdict: string, invocation: string): string {
58
65
  const found = NEXT_BY_VERDICT.find(([prefix]) => verdict.startsWith(prefix))
59
66
 
60
- return `\nNext: ${found?.[1] ?? NEXT_DEFAULT}`
67
+ return `\nNext: first claim the carry record with ${run_carry.claim_command(invocation)}, then ${found?.[1] ?? NEXT_DEFAULT}`
61
68
  }
62
69
 
63
- function handoff_material(verdict: string, resume: string, details: string): string {
70
+ function handoff_material(
71
+ verdict: string,
72
+ resume: string,
73
+ invocation: string,
74
+ details: string,
75
+ ): string {
64
76
  const context = details === '' ? '' : `\nDetails: ${details_tail(details)}`
65
77
  const epic = verdict.startsWith('epic #')
66
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.'
67
79
  : ''
68
80
 
69
- return `Driver result: ${verdict}\n${resume}${epic}${next_move(verdict)}${context}`
81
+ return `Driver result: ${verdict}\n${resume}${epic}${next_move(verdict, invocation)}${context}`
70
82
  }
71
83
 
72
- 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 {
73
90
  const lines = out.split('\n')
74
91
  const verdict = lines[FIRST_LINE]
75
92
 
@@ -80,13 +97,27 @@ function driver_result(out: string, read: CarryRead, details = ''): DriveResult
80
97
  return { kind: 'failed', note: `backlog:drive returned an incomplete result: ${out}` }
81
98
  }
82
99
 
83
- return { kind: 'judgment', material: handoff_material(verdict, resume, details) }
100
+ return { kind: 'judgment', material: handoff_material(verdict, resume, invocation, details) }
84
101
  }
85
102
 
86
- function read_result(result: JoshResult, target: string): DriveResult {
87
- if (result.code !== SUCCESS) return { kind: 'failed', note: result.err ?? result.out }
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))
88
109
 
89
- return driver_result(result.out, run_carry.read_carry(target), result.err)
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')
115
+ }
116
+
117
+ function read_result(result: JoshResult, target: string, invocation: string): DriveResult {
118
+ if (result.code !== SUCCESS) return { kind: 'failed', note: failure_note(result) }
119
+
120
+ return driver_result(result.out, run_carry.read_carry(target), invocation, result.err)
90
121
  }
91
122
 
92
123
  function claim_record(target: string, invocation: string): boolean {
@@ -110,7 +141,7 @@ async function drive(target: string): Promise<DriveResult> {
110
141
 
111
142
  const result = await josh_command.josh_run(args, true)
112
143
 
113
- return read_result(result, target)
144
+ return read_result(result, target, read.carry.invocation)
114
145
  }
115
146
 
116
147
  export const run_wake_driver = { drive, driver_args, driver_result }
@@ -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
  }
@@ -79,6 +79,13 @@ const ERROR_TEXT_LIMIT = 256
79
79
  // shared constant exists because each guard writes the literal in its own reason, so the one place a
80
80
  // refusal is *detected* names it here.
81
81
  const REFUSAL_MARKER = '⛔'
82
+ // The label the harness puts in front of a PreToolUse hook's deny reason, naming the hook and the tool.
83
+ const HOOK_ERROR_LABEL = /^PreToolUse:\S+ hook error: /u
84
+ // A line that is one bare verdict token — `cut`, `busy`, `not-a-lane` — the single word a josh verdict
85
+ // command prints on standard output (joshuafolkken/kit#3223). How many are kept bounds the field the
86
+ // way `ERROR_TEXT_LIMIT` bounds `error_text`.
87
+ const TOKEN_LINE = /^[a-z][a-z-]*$/u
88
+ const TOKEN_LINE_LIMIT = 8
82
89
 
83
90
  interface Block {
84
91
  type: string
@@ -111,6 +118,11 @@ interface Block {
111
118
  // the parsed block rather than by re-scanning the body downstream. `''` where the result was not a
112
119
  // `⛔` refusal, so a reader tells "no guard spoke" from "this guard spoke" without a second parse.
113
120
  refusal_guard: string
121
+ // The bare-token lines of a result that did not fail, and `[]` for every other block
122
+ // (joshuafolkken/kit#3223). It obeys the same prohibition as `error_text` — what is kept is a few
123
+ // single words, never the body — and it exists so the verdict a josh command answered with
124
+ // (`run:cut`'s `cut`) is read from the parsed block rather than by searching the raw line.
125
+ token_lines: ReadonlyArray<string>
114
126
  // The id the harness assigned to a command it took into the background, and `''` for every other
115
127
  // block (joshuafolkken/kit#1662). It is the fourth field read off the body under the same
116
128
  // prohibition as the three above — what is kept is the id, never the text it was read from — and
@@ -159,8 +171,12 @@ function block_names(
159
171
  // before the first `:` — `⛔ pre-gate cut: …` is `pre-gate cut`. A reason with no colon (a headline
160
172
  // like `⛔ scoped checks not green on this tree`) is taken whole. Reading the opening rather than
161
173
  // searching the body is what stops a result that merely quoted a reason being read as a refusal.
174
+ //
175
+ // **The harness writes the reason behind its own label** — `PreToolUse:Edit hook error: ⛔ …` — so that
176
+ // label is stripped before the opening is read (joshuafolkken/kit#3223). Without it no live refusal
177
+ // carried a guard: the fixtures wrote the bare `⛔` body and passed while every recorded one read `''`.
162
178
  function guard_from_refusal(text: string): string {
163
- const trimmed = text.trimStart()
179
+ const trimmed = text.trimStart().replace(HOOK_ERROR_LABEL, '')
164
180
  if (!trimmed.startsWith(REFUSAL_MARKER)) return ''
165
181
 
166
182
  const headline = trimmed.slice(REFUSAL_MARKER.length).split('\n', 1)[0]?.trimStart() ?? ''
@@ -169,6 +185,14 @@ function guard_from_refusal(text: string): string {
169
185
  return (colon === -1 ? headline : headline.slice(0, colon)).trim()
170
186
  }
171
187
 
188
+ function token_lines_of(text: string): Array<string> {
189
+ return text
190
+ .split('\n')
191
+ .map((line) => line.trim())
192
+ .filter((line) => TOKEN_LINE.test(line))
193
+ .slice(0, TOKEN_LINE_LIMIT)
194
+ }
195
+
172
196
  // **The body is flattened once and both readers are handed the text.** `result_text` is what turns a
173
197
  // content field that may be a string or a list of blocks into one string, and it is idempotent on a
174
198
  // string — so passing its own output back to `has_failure_line` reads exactly as passing the raw
@@ -185,6 +209,7 @@ function to_block(raw: z.infer<typeof BLOCK_SCHEMA>): Block {
185
209
  followup_stages: time_followup_stage.read_stages(text),
186
210
  error_text: is_error === true ? text.slice(0, ERROR_TEXT_LIMIT) : '',
187
211
  refusal_guard: is_error === true ? guard_from_refusal(text) : '',
212
+ token_lines: is_error === true ? [] : token_lines_of(text),
188
213
  background_id: time_background.launch_id(text),
189
214
  agent_id: time_background.agent_launch_id(text),
190
215
  }
@@ -248,6 +273,7 @@ function parse_line(line: string): TranscriptLine | undefined {
248
273
  const time_transcript_line = {
249
274
  ERROR_TEXT_LIMIT,
250
275
  NO_MESSAGE_ID,
276
+ TOKEN_LINE_LIMIT,
251
277
  guard_from_refusal,
252
278
  parse_line,
253
279
  }