@joshuafolkken/kit 1.929.0 → 1.953.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 (83) hide show
  1. package/.claude/skills/workflow-commands/background-commands.md +6 -0
  2. package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +4 -3
  4. package/.claude/skills/workflow-commands/observation-filing.md +3 -3
  5. package/.claude/skills/workflow-commands/prerequisite.md +1 -1
  6. package/.github/workflows/ci.yml +26 -8
  7. package/.github/workflows/pr-classification.yml +76 -0
  8. package/README.md +44 -22
  9. package/dist/hooks/batch-guard.js +4 -4
  10. package/dist/hooks/{chunk-GQ3EVWHY.js → chunk-452OKRQX.js} +421 -195
  11. package/dist/hooks/{chunk-2PGRQ62K.js → chunk-EYFJ74MJ.js} +11 -6
  12. package/dist/hooks/{chunk-LCJZE42I.js → chunk-IA7WC4NS.js} +1 -1
  13. package/dist/hooks/{chunk-BNFG2IFK.js → chunk-IBFC25IX.js} +4 -4
  14. package/dist/hooks/{chunk-I3JP3D4A.js → chunk-M4U4YNRE.js} +3 -3
  15. package/dist/hooks/{chunk-NJZSO7BK.js → chunk-NKVLBB65.js} +3 -3
  16. package/dist/hooks/{chunk-SXPTRFPG.js → chunk-O3PEDF3M.js} +1 -1
  17. package/dist/hooks/{chunk-7XQ6TK3R.js → chunk-OM5VDM6F.js} +2 -2
  18. package/dist/hooks/{chunk-ROULPSQC.js → chunk-V7RH56HU.js} +2 -2
  19. package/dist/hooks/{chunk-YO43HEPY.js → chunk-Z5TVZ4AP.js} +237 -235
  20. package/dist/hooks/codex-hook-adapter.js +10 -10
  21. package/dist/hooks/duplicate-read-guard.js +5 -5
  22. package/dist/hooks/format-edited.js +2 -2
  23. package/dist/hooks/investigation-guard.js +5 -5
  24. package/dist/hooks/pretool-guard.js +9 -9
  25. package/dist/hooks/stop-guard.js +9 -3
  26. package/dist/josh.js +11 -6
  27. package/dist/version/index.js +7 -4
  28. package/package.json +13 -1
  29. package/prompts/collaboration-workflow/issue-template.md +1 -1
  30. package/scripts/ci/pr-classification.ts +27 -6
  31. package/scripts/document/document-byte-budget.ts +1 -1
  32. package/scripts/gate/hook-gate-reuse.ts +1 -1
  33. package/scripts/gate/project-checks.ts +45 -15
  34. package/scripts/gate/type-check-command.ts +43 -0
  35. package/scripts/git/constants.ts +4 -0
  36. package/scripts/git/git-command.ts +11 -5
  37. package/scripts/git/git-gh-pr-rollup.ts +56 -16
  38. package/scripts/git/git-staging.ts +6 -4
  39. package/scripts/git/git-stash.ts +73 -1
  40. package/scripts/git/git-worktree.ts +35 -4
  41. package/scripts/git/repository-lock.ts +138 -0
  42. package/scripts/git/stash-orphans.ts +24 -2
  43. package/scripts/git/stash-sweep-lock.ts +30 -0
  44. package/scripts/hooks/stop-guard.ts +2 -0
  45. package/scripts/init/init-actions.ts +3 -2
  46. package/scripts/init/init-logic-migrate.ts +10 -1
  47. package/scripts/init/init-logic-templates.ts +27 -7
  48. package/scripts/init/init-logic-workspace.ts +52 -1
  49. package/scripts/init/init-logic.ts +6 -1
  50. package/scripts/init/init.ts +60 -19
  51. package/scripts/issue/issue-merged.ts +75 -0
  52. package/scripts/issue/measure-rerun-cli.ts +2 -2
  53. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +10 -0
  54. package/scripts/josh/josh-command-map.ts +1 -0
  55. package/scripts/josh/josh-command-types.ts +0 -3
  56. package/scripts/josh/josh-commands-ai.ts +6 -0
  57. package/scripts/josh/josh-commands-development.ts +5 -5
  58. package/scripts/lint/lint-parallel.ts +19 -18
  59. package/scripts/observations/observation-ledger-home.ts +22 -2
  60. package/scripts/observations/observation-ledger-line.ts +11 -0
  61. package/scripts/observations/observation-ledger-migrate.ts +146 -0
  62. package/scripts/observations/observation-ledger.ts +51 -3
  63. package/scripts/observations/observations-flush.ts +38 -7
  64. package/scripts/review/review-finding-ledger.ts +1 -1
  65. package/scripts/review/review-record-cli.ts +1 -7
  66. package/scripts/rules/delivered-rules.ts +10 -0
  67. package/scripts/rules/lane-background.ts +111 -0
  68. package/scripts/rules/stop-rules.ts +15 -2
  69. package/scripts/run/run-carry-flush.ts +2 -7
  70. package/scripts/run/run-hold-cli.ts +10 -2
  71. package/scripts/run/run-tidy-cli.ts +50 -0
  72. package/scripts/run/run-tidy-lanes.ts +110 -0
  73. package/scripts/run/run-tidy-stashes.ts +160 -0
  74. package/scripts/run/run-tidy.ts +101 -0
  75. package/scripts/safe-chain/ci-installer-pin.ts +90 -0
  76. package/scripts/safe-chain/preinstall-command.ts +39 -0
  77. package/scripts/safe-chain/project-config.ts +55 -10
  78. package/scripts/sync/sync.ts +8 -3
  79. package/scripts/test/pilot-files.ts +0 -1
  80. package/scripts/version/preinstall-version-update.ts +2 -0
  81. package/scripts/version/release-age.ts +11 -6
  82. package/templates/workflows/ci.yml +21 -7
  83. package/tsconfig/base.json +4 -1
@@ -0,0 +1,146 @@
1
+ import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import {
4
+ LEGACY_OBSERVATION_LEDGER_PATH,
5
+ MIGRATION_CLAIM_SUFFIX,
6
+ OBSERVATION_LEDGER_PATH,
7
+ } from './observation-ledger'
8
+ import { observation_ledger_line } from './observation-ledger-line'
9
+
10
+ // Moves the ledger's lines from the path it had before joshuafolkken/kit#2724 to the one it has now
11
+ // (`observation-ledger.ts` carries why both are recognized). Changing the constant alone split the
12
+ // appends across two files: a run still on the old code recreated `docs/observations.md`, and a
13
+ // consumer repository's existing ledger stayed where nothing read it. So resolving the ledger's path
14
+ // migrates first (`observation-ledger-home.ts`), and whatever reached the old path lands at the tail
15
+ // of the new one before anything reads or appends.
16
+ //
17
+ // **The old file is claimed with a rename before it is read.** Two sessions resolving the ledger at
18
+ // once would otherwise both read it and both copy its lines — and a repeated line under one key is
19
+ // what promotes an observation to an issue. A rename succeeds for exactly one of them; the other finds
20
+ // nothing.
21
+ //
22
+ // **A claim whose process is gone is absorbed like the old file itself.** A process killed between the
23
+ // rename and the append leaves the ledger's lines only in the claim file; the next migration finds it
24
+ // by its name and moves it, so no line is stranded where nothing looks.
25
+ //
26
+ // **Synchronous on purpose**: the path resolver every reader and writer calls is synchronous, and a
27
+ // migration with no await point cannot be interleaved with another write in the same process.
28
+
29
+ const NEWLINE = '\n'
30
+ const NO_SIGNAL = 0
31
+ const NO_SUCH_PROCESS = 'ESRCH'
32
+
33
+ function claim_path(legacy: string): string {
34
+ return `${legacy}.${String(process.pid)}${MIGRATION_CLAIM_SUFFIX}`
35
+ }
36
+
37
+ function claim(from: string, to: string): boolean {
38
+ try {
39
+ renameSync(from, to)
40
+
41
+ return true
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
47
+ // Only `ESRCH` means the process is gone; `EPERM` means it runs under another user, and its claim is
48
+ // still its own.
49
+ function is_alive(pid: number): boolean {
50
+ try {
51
+ process.kill(pid, NO_SIGNAL)
52
+
53
+ return true
54
+ } catch (error) {
55
+ return (error as NodeJS.ErrnoException).code !== NO_SUCH_PROCESS
56
+ }
57
+ }
58
+
59
+ // A claim is named `<old ledger>.<pid>.migrating`, so the pid is what sits between the two.
60
+ function is_stale_claim(name: string, legacy_name: string): boolean {
61
+ const prefix = `${legacy_name}.`
62
+
63
+ if (!name.startsWith(prefix) || !name.endsWith(MIGRATION_CLAIM_SUFFIX)) return false
64
+
65
+ const pid = Number(name.slice(prefix.length, -MIGRATION_CLAIM_SUFFIX.length))
66
+
67
+ return Number.isSafeInteger(pid) && !is_alive(pid)
68
+ }
69
+
70
+ function stale_claims(legacy: string): ReadonlyArray<string> {
71
+ const directory = path.dirname(legacy)
72
+
73
+ try {
74
+ return readdirSync(directory)
75
+ .filter((name) => is_stale_claim(name, path.basename(legacy)))
76
+ .map((name) => path.join(directory, name))
77
+ } catch {
78
+ return []
79
+ }
80
+ }
81
+
82
+ function terminated(content: string): string {
83
+ return content.length === 0 || content.endsWith(NEWLINE) ? content : `${content}${NEWLINE}`
84
+ }
85
+
86
+ function read_or_undefined(file_path: string): string | undefined {
87
+ try {
88
+ return readFileSync(file_path, 'utf8')
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ // What of the claimed file goes to the ledger's tail: all of it, verbatim, when there is no ledger yet,
95
+ // and otherwise only the lines the ledger does not already hold. **The old file can come back after
96
+ // its lines were moved** — `git restore`, `git reset --hard` or a stash without `-u` restores a
97
+ // tracked old ledger beside the untracked new one — and copying it whole again would repeat every line
98
+ // in the history, which the digest reads as every observation recurring.
99
+ function carried(content: string, ledger: string | undefined): string {
100
+ if (ledger === undefined) return terminated(content)
101
+
102
+ const lines = content.split(NEWLINE).filter((line) => line.length > 0)
103
+ const missing = observation_ledger_line.missing_lines(lines, ledger)
104
+
105
+ return missing.length === 0 ? '' : `${missing.join(NEWLINE)}${NEWLINE}`
106
+ }
107
+
108
+ // Moves one claimed file's lines to the tail of the ledger, then removes the claim.
109
+ function absorb(claimed: string, target: string): void {
110
+ mkdirSync(path.dirname(target), { recursive: true })
111
+ appendFileSync(target, carried(readFileSync(claimed, 'utf8'), read_or_undefined(target)), 'utf8')
112
+ rmSync(claimed)
113
+ }
114
+
115
+ function absorb_stale(legacy: string, target: string): number {
116
+ let count = 0
117
+
118
+ for (const stale of stale_claims(legacy)) {
119
+ const claimed = claim_path(legacy)
120
+
121
+ if (!claim(stale, claimed)) continue
122
+
123
+ absorb(claimed, target)
124
+ count += 1
125
+ }
126
+
127
+ return count
128
+ }
129
+
130
+ // Returns whether anything was moved. `root` is the checkout the ledger lives in.
131
+ function migrate(root: string): boolean {
132
+ const legacy = path.join(root, LEGACY_OBSERVATION_LEDGER_PATH)
133
+ const target = path.join(root, OBSERVATION_LEDGER_PATH)
134
+ const recovered = absorb_stale(legacy, target)
135
+ const claimed = claim_path(legacy)
136
+
137
+ if (!claim(legacy, claimed)) return recovered > 0
138
+
139
+ absorb(claimed, target)
140
+
141
+ return true
142
+ }
143
+
144
+ const observation_ledger_migrate = { claim_path, migrate }
145
+
146
+ export { observation_ledger_migrate }
@@ -4,7 +4,21 @@
4
4
  // `observations-flush.ts` is the only thing that stages it — so a literal in each would be three
5
5
  // answers to one question, and a rename reaching only some of them would silently restore the
6
6
  // contamination the exclusion exists to prevent.
7
- const OBSERVATION_LEDGER_PATH = 'docs/observations.md'
7
+ //
8
+ // **It sits under `docs/maintainers/`, apart from the reader-facing documents** (joshuafolkken/kit#2724).
9
+ const OBSERVATION_LEDGER_PATH = 'docs/maintainers/observations.md'
10
+
11
+ // Where the ledger lived before joshuafolkken/kit#2724, kept as a migration source rather than
12
+ // forgotten: a run still on the old code, a stash cut before the move and a consumer repository's
13
+ // existing ledger all write here, and `observation-ledger-migrate.ts` moves what it finds to the path
14
+ // above. Every place that recognizes the ledger recognizes both, so a line on either path is never
15
+ // staged into an ordinary commit. Remove it once every repository holding a ledger has migrated.
16
+ const LEGACY_OBSERVATION_LEDGER_PATH = 'docs/observations.md'
17
+
18
+ const OBSERVATION_LEDGER_PATHS: ReadonlyArray<string> = [
19
+ OBSERVATION_LEDGER_PATH,
20
+ LEGACY_OBSERVATION_LEDGER_PATH,
21
+ ]
8
22
 
9
23
  // `git status --porcelain` prints `XY PATH`, so the path starts at the fourth character.
10
24
  const STATUS_PATH_INDEX = 3
@@ -13,8 +27,34 @@ function status_path(line: string): string {
13
27
  return line.slice(STATUS_PATH_INDEX).trim()
14
28
  }
15
29
 
30
+ // The suffix of the file a migration renames the old ledger to while it copies it
31
+ // (`observation-ledger-migrate.ts`): `docs/observations.md.<pid>.migrating`.
32
+ const MIGRATION_CLAIM_SUFFIX = '.migrating'
33
+
34
+ // A claim left by a process killed mid-migration holds ledger lines, so it is the ledger too: never
35
+ // staged by an ordinary commit, and absorbed by the next migration.
36
+ function is_migration_claim(file_path: string): boolean {
37
+ return (
38
+ file_path.startsWith(`${LEGACY_OBSERVATION_LEDGER_PATH}.`) &&
39
+ file_path.endsWith(MIGRATION_CLAIM_SUFFIX)
40
+ )
41
+ }
42
+
43
+ function is_ledger_path(file_path: string): boolean {
44
+ return OBSERVATION_LEDGER_PATHS.includes(file_path) || is_migration_claim(file_path)
45
+ }
46
+
16
47
  function is_ledger_line(line: string): boolean {
17
- return status_path(line) === OBSERVATION_LEDGER_PATH
48
+ return is_ledger_path(status_path(line))
49
+ }
50
+
51
+ // The ledger paths a porcelain status names — what a flush stages after a migration, which may be the
52
+ // new file together with the old one's deletion.
53
+ function ledger_paths(status_output: string): ReadonlyArray<string> {
54
+ return status_output
55
+ .split('\n')
56
+ .filter((line) => is_ledger_line(line))
57
+ .map((line) => status_path(line))
18
58
  }
19
59
 
20
60
  // **The one place that answers "does this working tree hold a pending observation append?"**
@@ -29,7 +69,15 @@ function has_pending_append(status_output: string): boolean {
29
69
  const observation_ledger = {
30
70
  has_pending_append,
31
71
  is_ledger_line,
72
+ is_ledger_path,
73
+ ledger_paths,
32
74
  status_path,
33
75
  }
34
76
 
35
- export { OBSERVATION_LEDGER_PATH, observation_ledger }
77
+ export {
78
+ LEGACY_OBSERVATION_LEDGER_PATH,
79
+ MIGRATION_CLAIM_SUFFIX,
80
+ OBSERVATION_LEDGER_PATH,
81
+ OBSERVATION_LEDGER_PATHS,
82
+ observation_ledger,
83
+ }
@@ -4,6 +4,7 @@ import { git_gh_command } from '#scripts/git/git-gh-command'
4
4
  import { main_sync } from '#scripts/git/main-sync'
5
5
  import { observation_ledger, OBSERVATION_LEDGER_PATH } from './observation-ledger'
6
6
  import { observation_ledger_line, type BrokenLine } from './observation-ledger-line'
7
+ import { observation_ledger_migrate } from './observation-ledger-migrate'
7
8
  import { observations_flush_landing } from './observations-flush-landing'
8
9
 
9
10
  // The commit path the observation ledger did not have (joshuafolkken/kit#1756). The parent session
@@ -78,7 +79,9 @@ function has_ledger_change(status_output: string): boolean {
78
79
  }
79
80
 
80
81
  function other_changed_paths(status_output: string): ReadonlyArray<string> {
81
- return status_paths(status_output).filter((file_path) => file_path !== OBSERVATION_LEDGER_PATH)
82
+ return status_paths(status_output).filter(
83
+ (file_path) => !observation_ledger.is_ledger_path(file_path),
84
+ )
82
85
  }
83
86
 
84
87
  function message_of(error: unknown): string {
@@ -295,9 +298,21 @@ function rejection_message(
295
298
  return roll_back_failure_message(branch_name, reason, roll_back_reason)
296
299
  }
297
300
 
301
+ // **Every ledger path the status names is staged, not the one constant** (joshuafolkken/kit#2724).
302
+ // After a migration the change is the new file together with the old one's deletion, and a commit
303
+ // holding only the first would leave the deletion behind as a change no later flush could tell apart
304
+ // from a fresh append.
305
+ async function stage_ledger(): Promise<void> {
306
+ const ledger_paths = observation_ledger.ledger_paths(await git_command.status())
307
+
308
+ for (const file_path of ledger_paths) {
309
+ await git_command.add_path(file_path)
310
+ }
311
+ }
312
+
298
313
  async function commit_ledger(branch_name: string, default_branch: string): Promise<void> {
299
314
  try {
300
- await git_command.add_path(OBSERVATION_LEDGER_PATH)
315
+ await stage_ledger()
301
316
  await git_command.commit(COMMIT_MESSAGE)
302
317
  } catch (error) {
303
318
  const roll_back_reason = await roll_back(branch_name, default_branch)
@@ -316,7 +331,7 @@ async function open_pull_request(branch_name: string, default_branch: string): P
316
331
  }
317
332
 
318
333
  // **The checkout goes back to the default branch only on the way that merged.** A `finally` here
319
- // would check the default branch out after a red check too — and `docs/observations.md` would then
334
+ // would check the default branch out after a red check too — and the ledger would then
320
335
  // revert to main's content, leaving the appended lines on a branch nothing in this checkout points
321
336
  // at any more. The next flush would read `has_ledger_change` as false and report `clean`, which is
322
337
  // the silent loss the refusals above exist to prevent. Left on the branch, the refusal fires.
@@ -359,14 +374,30 @@ function broken_lines_message(broken: ReadonlyArray<BrokenLine>): string {
359
374
  // **A malformed line is refused before a branch is cut, not after.** The ledger path was collected
360
375
  // without a line ever being parsed (joshuafolkken/kit#2123), so a broken append could ride a flush
361
376
  // through; validating here keeps the committed ledger to its grammar.
377
+ //
378
+ // **A ledger that is not there has no line to break** — the change the status saw is then its
379
+ // deletion, and a flush commits that like any other change to it.
380
+ async function read_ledger(): Promise<string> {
381
+ try {
382
+ return await readFile(OBSERVATION_LEDGER_PATH, 'utf8')
383
+ } catch {
384
+ return ''
385
+ }
386
+ }
387
+
362
388
  async function refuse_broken_ledger(): Promise<void> {
363
- const broken = observation_ledger_line.broken_ledger_lines(
364
- await readFile(OBSERVATION_LEDGER_PATH, 'utf8'),
365
- )
389
+ const broken = observation_ledger_line.broken_ledger_lines(await read_ledger())
366
390
 
367
391
  if (broken.length > 0) throw new Error(broken_lines_message(broken))
368
392
  }
369
393
 
394
+ // Lines still on the ledger's old path are moved before anything is validated or staged
395
+ // (joshuafolkken/kit#2724), so the flush commits the move and the lines together.
396
+ async function prepare_ledger(): Promise<void> {
397
+ observation_ledger_migrate.migrate(process.cwd())
398
+ await refuse_broken_ledger()
399
+ }
400
+
370
401
  // **Nothing pulls in front of the branch, and that is deliberate.** The ledger is dirty by
371
402
  // definition at this point, so `git pull --ff-only` aborts on it in exactly the case a pull would
372
403
  // have been for — an upstream flush that already advanced the ledger — and reports a failure about
@@ -382,7 +413,7 @@ async function flush(now: Date): Promise<string> {
382
413
 
383
414
  if (!has_ledger_change(status_output)) return CLEAN_MESSAGE
384
415
 
385
- await refuse_broken_ledger()
416
+ await prepare_ledger()
386
417
 
387
418
  const deferred = await observations_flush_landing.refuse_open_flush(BRANCH_PREFIX)
388
419
  if (deferred !== undefined) return deferred
@@ -1,7 +1,7 @@
1
1
  // The review-finding line's grammar, its category vocabulary and the aggregation over it — the single
2
2
  // place all three are defined (joshuafolkken/kit#2325). A `/code-review` round's findings evaporated
3
3
  // the moment they were fixed, so no one could say which category recurred. They now land in the same
4
- // append-only ledger the observation lines use (`docs/observations.md`), under a distinct `- rf:`
4
+ // append-only ledger the observation lines use (`docs/maintainers/observations.md`), under a distinct `- rf:`
5
5
  // prefix so `observation-ledger-line.ts`'s `- k:` grammar never treats them as its own — one ledger
6
6
  // file, one flush path, a second line *type* rather than a second ledger.
7
7
 
@@ -1,6 +1,4 @@
1
1
  #!/usr/bin/env tsx
2
- import { appendFile, mkdir } from 'node:fs/promises'
3
- import path from 'node:path'
4
2
  import { fileURLToPath } from 'node:url'
5
3
  import { parseArgs } from 'node:util'
6
4
  import { observation_ledger_home } from '#scripts/observations/observation-ledger-home'
@@ -154,11 +152,7 @@ async function run_record(parsed: Parsed, now: Date, ledger_path: string): Promi
154
152
 
155
153
  const lines = build_lines(request, today(now))
156
154
 
157
- // A consumer that does not keep the observation ledger has no `docs/` at its root, so the append
158
- // below would fail with ENOENT and leave the merge gate unsatisfiable (joshuafolkken/kit#2402).
159
- // Creating the parent first is the one write path that can bring the ledger into existence there.
160
- await mkdir(path.dirname(ledger_path), { recursive: true })
161
- await appendFile(ledger_path, `${lines.join('\n')}\n`, 'utf8')
155
+ await observation_ledger_home.append(ledger_path, lines)
162
156
  console.info(confirmation(lines.length, ledger_path))
163
157
 
164
158
  return 0
@@ -13,6 +13,7 @@ import { issue_bug_label_rule } from './issue-bug-label'
13
13
  import { issue_fold } from './issue-fold'
14
14
  import { issue_scout } from './issue-scout'
15
15
  import { josh_git_bare } from './josh-git-bare'
16
+ import { lane_background } from './lane-background'
16
17
  import { lane_carry_conflict } from './lane-carry-conflict'
17
18
  import { lane_interactive_ask } from './lane-interactive-ask'
18
19
  import { lane_park } from './lane-park'
@@ -360,6 +361,15 @@ const DELIVERED_RULES: ReadonlyArray<DeliveredRule> = [
360
361
  // verdict is `required`; in the hermetic non-repo suite the git read fails to `exempt`, so
361
362
  // `test-declared` claims nothing and the "exactly one rule" invariant over `run-tail`'s fixture holds.
362
363
  test_declared_commit.ROW,
364
+ // **The long-running josh command a lane child backgrounds and loses** (joshuafolkken/kit#2704). A
365
+ // headless child's background tasks die with its turn, so a backgrounded `josh gate` / `git` /
366
+ // `followup` is killed before it reports; this refuses the launch and hands back `josh ship
367
+ // --detach`, whose supervisor outlives the turn. **Listed before `run-tail`, whose push it claims in
368
+ // a child**: that row answers a foreground push with "reissue it backgrounded", the reissue this row
369
+ // refuses, so a child is sent to the detached ship on its first refusal instead of through both.
370
+ // Outside a child it claims nothing. A backgrounded bare `josh git` also trips `josh-git-bare`
371
+ // below; this row wins, and `ship --detach` satisfies both. It fires on every occurrence.
372
+ lane_background.ROW,
363
373
  // **The one row whose trigger reads a field of the input beside the command**, so it supplies its
364
374
  // own tool-name check rather than going through `on_bash_command`: a push step already issued with
365
375
  // `run_in_background` is the rule obeyed, and refusing it would charge a run for doing the right
@@ -0,0 +1,111 @@
1
+ import { cost_blocks } from '#scripts/cost-runtime/cost-blocks'
2
+ import { lane_child_marker } from '#scripts/lane/lane-child-marker'
3
+ import { json_value } from '#scripts/lib/json-value'
4
+ import type { GuardedCall } from '#scripts/time-runtime/time-batch-guard'
5
+ import { time_shell } from '#scripts/time-runtime/time-shell'
6
+ import { time_transcript_line } from '#scripts/time-runtime/time-transcript-line'
7
+ import { run_tail } from './run-tail'
8
+
9
+ // A dispatched lane child is stopped from backgrounding a long-running josh command (joshuafolkken/kit#2704).
10
+ //
11
+ // **A headless child's turn-end is its process's end.** A lane child runs as `claude -p`, and there the
12
+ // background Bash tasks die with the turn — so "a background command's exit re-invokes the session"
13
+ // (`background-commands.md`) does not hold. joshuafolkken/kit#2457 refused a non-detached `josh ship`
14
+ // for exactly that reason, but only `ship`: the #2606 child backgrounded `pnpm josh gate && pnpm josh git
15
+ // -y …` directly, ended its turn, and the gate died at `exit code 143` with the implementation, the
16
+ // scoped checks, a review round and the live verification all done — parked instead of merged.
17
+ //
18
+ // **So the refusal is put at the launch, and it names the detached route.** The region from the gate to
19
+ // the merge belongs to `pnpm josh ship --detach` in a child (`chain-rule.md` step 0), whose supervisor
20
+ // outlives the turn. `ship` itself is not in the set: #2457 already detaches it implicitly in a child.
21
+ //
22
+ // **Its stop-time half reads the same fact from the other side.** A backgrounded task the transcript
23
+ // launched but never saw finish is still running, and in a child the stop about to happen kills it —
24
+ // so the `Stop` hook sends the child back to wait or detach rather than asking for a notify
25
+ // (`stop-rules.ts`). The launch and finish ids are `time_transcript_line`'s, never re-parsed here.
26
+
27
+ // The josh subcommands that run long enough to be backgrounded, as `time_shell` names them — canonical,
28
+ // so the `ga` / `g` / `fu` aliases are covered by the one spelling.
29
+ const LONG_RUNNING_COMMANDS: ReadonlySet<string> = new Set(
30
+ ['gate', 'git', 'followup'].map((name) => `${time_shell.JOSH_PREFIX}${name}`),
31
+ )
32
+
33
+ const BACKGROUND_KEY = 'run_in_background'
34
+
35
+ function is_backgrounded(input: unknown): boolean {
36
+ return json_value.is_record(input) && input[BACKGROUND_KEY] === true
37
+ }
38
+
39
+ function runs_long_command(command: string): boolean {
40
+ return time_shell.josh_commands_of(command).some((name) => LONG_RUNNING_COMMANDS.has(name))
41
+ }
42
+
43
+ // The call's own fields alone: a backgrounded Bash call that runs a long josh command, or the push step
44
+ // in either spelling. A child's foreground push is claimed too, because `run-tail` would otherwise answer
45
+ // it with "reissue it backgrounded" — the one reissue this row then refuses — so the child is handed the
46
+ // detached route on its first refusal rather than two contradictory ones.
47
+ function is_candidate(call: GuardedCall): boolean {
48
+ if (run_tail.is_push_step_call(call)) return true
49
+ if (call.name !== cost_blocks.BASH_TOOL || !is_backgrounded(call.input)) return false
50
+
51
+ return runs_long_command(time_shell.bash_command(call.input))
52
+ }
53
+
54
+ // True when this session is a dispatched lane child backgrounding a long-running josh command. The call's
55
+ // own fields are tested first, so the dispatch mark is read only on the handful of calls that match.
56
+ function is_background_long_run(
57
+ call: GuardedCall,
58
+ is_lane_child: () => boolean = () => lane_child_marker.is_child_of(process.cwd()),
59
+ ): boolean {
60
+ return is_candidate(call) && is_lane_child()
61
+ }
62
+
63
+ // The background tasks the transcript tail launched and never saw finish. A launch is read off its own
64
+ // tool result and a finish off the harness notice, both by `time_transcript_line`.
65
+ function pending_background_ids(tail: string): ReadonlyArray<string> {
66
+ const lines = tail.split('\n').map((line) => time_transcript_line.parse_line(line))
67
+ const finished = new Set(lines.map((line) => line?.finished_background ?? ''))
68
+ const launched = lines.flatMap((line) => line?.blocks.map((block) => block.background_id) ?? [])
69
+
70
+ return launched.filter((id) => id !== '' && !finished.has(id))
71
+ }
72
+
73
+ const LANE_BACKGROUND_REASON =
74
+ '⛔ lane background: this session is a dispatched lane child (`JOSH_LANE_CHILD`), a headless ' +
75
+ '`claude -p` process whose background Bash tasks are killed when the turn ends — so a backgrounded ' +
76
+ '`josh gate` / `josh git` / `josh followup` never re-invokes you; it dies at `exit code 143` ' +
77
+ '(joshuafolkken/kit#2704, measured on the #2606 child: implementation, review and live verification ' +
78
+ 'done, the run parked instead of merged). Hand the gate-to-merge region to the detached supervisor ' +
79
+ 'instead: `pnpm josh ship --detach --review "<title> #<N>"` (add `--cite <N>`), then end the turn on ' +
80
+ '`launched` / `busy` — `.claude/skills/workflow-commands/chain-rule.md` step 0 is the single source. A ' +
81
+ 'single check you must read before continuing runs in the foreground within the tool timeout; the ' +
82
+ 'commit-push step does not — in a child it too belongs to the detached ship, in either spelling. This ' +
83
+ 'rule fires on every occurrence, not once per run.'
84
+
85
+ const LANE_BACKGROUND_STOP_REASON =
86
+ '⛔ lane background stop: this is a dispatched lane child (`JOSH_LANE_CHILD`) and a background task ' +
87
+ 'it launched has not finished — ending this headless turn kills it, and nothing re-invokes you ' +
88
+ 'afterwards (joshuafolkken/kit#2704). Do not send a notification and do not stop: wait for the task ' +
89
+ 'in the foreground and act on its result, or re-issue the work through `pnpm josh ship --detach`, ' +
90
+ 'whose supervisor outlives the turn (`.claude/skills/workflow-commands/chain-rule.md` step 0). Do ' +
91
+ 'not repeat your previous reply.'
92
+
93
+ // The row itself, so `delivered-rules.ts` spreads one entry. It fires on every occurrence (`decide`
94
+ // returns true): a child must never background these, so the route is always the detached ship, never
95
+ // a reissue. It declares no `keeps`: the detached ship is a different command, not this call made right.
96
+ const ROW = {
97
+ id: 'lane-background',
98
+ is_trigger: (call: GuardedCall): boolean => is_background_long_run(call),
99
+ reason: LANE_BACKGROUND_REASON,
100
+ decide: (): boolean => true,
101
+ }
102
+
103
+ const lane_background = {
104
+ LANE_BACKGROUND_REASON,
105
+ LANE_BACKGROUND_STOP_REASON,
106
+ ROW,
107
+ is_background_long_run,
108
+ pending_background_ids,
109
+ }
110
+
111
+ export { lane_background }
@@ -2,6 +2,7 @@ import { hook_decision } from '#scripts/josh/hook-decision'
2
2
  import { z } from 'zod'
3
3
  import { filing_offer } from './filing-offer'
4
4
  import { issue_citation } from './issue-citation'
5
+ import { lane_background } from './lane-background'
5
6
  import { reply_language } from './reply-language'
6
7
 
7
8
  // The stop-time rules, delivered on the `Stop` hook (joshuafolkken/kit#2121, joshuafolkken/kit#2422,
@@ -66,6 +67,9 @@ interface StopContext {
66
67
  headless_refusals: number
67
68
  // This session is a dispatched lane child for this checkout — `lane_child_marker.is_child_of`.
68
69
  lane_child: boolean
70
+ // A background task this run launched has not finished — `lane_background.pending_background_ids`
71
+ // over the transcript tail (joshuafolkken/kit#2704).
72
+ background_pending: boolean
69
73
  // The resolved `JOSH_SESSION_LANG` — `session_language.resolve_session_lang` (joshuafolkken/kit#2470).
70
74
  session_lang: string
71
75
  }
@@ -256,10 +260,19 @@ function count_headless_refusals(tail: string): number {
256
260
  return tail.slice(Math.max(last_wait, 0)).split(HEADLESS_WAIT_MARKER).length - 1
257
261
  }
258
262
 
259
- // The two hold rules, notify ahead of release — behind the lane index route (joshuafolkken/kit#2445),
260
- // since the notify they would ask for announces a pause the run has no reason to take.
263
+ // **A lane child with a task still running is sent back to wait, not to notify** (joshuafolkken/kit#2704).
264
+ // Its turn-end kills the task, so the notify the hold rules would ask for announces a stop that loses
265
+ // the work; the #2606 child obeyed exactly that and its gate died at `exit code 143`.
266
+ function needs_background_wait(context: StopContext): boolean {
267
+ return context.lane_child && context.background_pending
268
+ }
269
+
270
+ // The two hold rules, notify ahead of release — behind the lane index route (joshuafolkken/kit#2445)
271
+ // and the lane background wait (joshuafolkken/kit#2704), since the notify they would ask for announces
272
+ // a pause the run has no reason to take.
261
273
  function hold_reason(context: StopContext): string | undefined {
262
274
  if (needs_index_route(context)) return LANE_INDEX_REASON
275
+ if (needs_background_wait(context)) return lane_background.LANE_BACKGROUND_STOP_REASON
263
276
  if (needs_notify(context)) return STOP_NOTIFY_REASON
264
277
  if (needs_release(context)) return HOLD_RELEASE_REASON
265
278
 
@@ -1,3 +1,4 @@
1
+ import { PORCELAIN_FLAG, UNTRACKED_FILES_FLAG } from '#scripts/git/constants'
1
2
  import { git_spawn } from '#scripts/git/git-spawn'
2
3
  import { josh_command } from '#scripts/josh/josh-run'
3
4
  import { observation_ledger } from '#scripts/observations/observation-ledger'
@@ -31,13 +32,7 @@ const FAILURE_NOTE =
31
32
 
32
33
  async function has_pending_append(): Promise<boolean> {
33
34
  const root = observation_ledger_home.ledger_root()
34
- const status = await git_spawn.read([
35
- '-C',
36
- root,
37
- 'status',
38
- '--porcelain',
39
- '--untracked-files=normal',
40
- ])
35
+ const status = await git_spawn.read(['-C', root, 'status', PORCELAIN_FLAG, UNTRACKED_FILES_FLAG])
41
36
 
42
37
  return observation_ledger.has_pending_append(status)
43
38
  }
@@ -2,6 +2,7 @@
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { run_hold, type HoldRead, type RunHold } from './run-hold'
4
4
  import { run_preflight, type PreflightDecision } from './run-preflight'
5
+ import { run_tidy_cli } from './run-tidy-cli'
5
6
 
6
7
  // `josh run:hold [<N>]` and `josh run:release [<N> | --force]` — the working-tree guard the typed
7
8
  // entry points ask before they start (joshuafolkken/kit#1091).
@@ -28,6 +29,10 @@ import { run_preflight, type PreflightDecision } from './run-preflight'
28
29
  // The contract is `epic:next`'s: **standard output carries exactly one token** — `hold`, `busy` or
29
30
  // `unknown` — and every explanation goes to standard error, so `answer=$(pnpm josh run:hold 1091)`
30
31
  // captures something a loop can branch on.
32
+ //
33
+ // **A successful claim then sweeps merged residue** — `run:tidy` (joshuafolkken/kit#2701). After the
34
+ // claim rather than before it: the sweep may append to the primary checkout's observation ledger, and
35
+ // doing that ahead of the preflight would turn a clean primary checkout into a `reclaim`.
31
36
 
32
37
  const SUCCESS_EXIT_CODE = 0
33
38
  const FAILURE_EXIT_CODE = 1
@@ -181,9 +186,12 @@ async function claim(target: string, issue: string, is_linked: boolean): Promise
181
186
 
182
187
  if (blocked !== undefined) return report_busy(blocked)
183
188
 
184
- if (read.kind === 'stale') return replace_stale(target, issue, read.hold)
189
+ const code =
190
+ read.kind === 'stale' ? replace_stale(target, issue, read.hold) : take_free_tree(target, issue)
185
191
 
186
- return take_free_tree(target, issue)
192
+ if (code === SUCCESS_EXIT_CODE) await run_tidy_cli.sweep()
193
+
194
+ return code
187
195
  }
188
196
 
189
197
  function remove_record(target: string, was_present: boolean): number {
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env tsx
2
+ import { fileURLToPath } from 'node:url'
3
+ import { issue_merged } from '#scripts/issue/issue-merged'
4
+ import { run_tidy } from './run-tidy'
5
+ import { run_tidy_lanes, type IsMerged } from './run-tidy-lanes'
6
+ import { run_tidy_stashes } from './run-tidy-stashes'
7
+
8
+ // `josh run:tidy` — sweep what merged work left behind at a run's start (joshuafolkken/kit#2701): the
9
+ // lanes still holding a seat after their issue merged, and the stashes whose issues are all merged.
10
+ // Both used to wait for a person — `lane:list` flagged a merged lane, `run:carry --end` printed the
11
+ // closed-issue stashes — and a batch ran with two of six seats held by finished work until someone did.
12
+ //
13
+ // `run:hold` calls `sweep` once a claim succeeds, so every `fullrun` / `halfrun` start runs it; a
14
+ // `backlogrun` runs the command itself in its once-per-repository preparation, beside `lane:prune`.
15
+ // **The sweep never fails the run that called it**: the report goes to standard error, where
16
+ // `run:hold`'s one-token standard output is not disturbed, and a failed read is a note, not an exit.
17
+
18
+ const FAILURE_NOTE = 'run:tidy could not finish; merged lanes and stashes may remain:'
19
+
20
+ // One read per issue across both halves — a lane and its stash usually name the same one.
21
+ function memoized(read: IsMerged): IsMerged {
22
+ const answers = new Map<string, Promise<boolean>>()
23
+
24
+ return async function read_once(issue: string): Promise<boolean> {
25
+ const cached = answers.get(issue) ?? read(issue)
26
+
27
+ answers.set(issue, cached)
28
+
29
+ return await cached
30
+ }
31
+ }
32
+
33
+ async function sweep(): Promise<void> {
34
+ try {
35
+ const read_merged = memoized(issue_merged.read_merged)
36
+ const lanes = await run_tidy_lanes.tidy_lanes(read_merged)
37
+ const stashes = await run_tidy_stashes.tidy_stashes(read_merged)
38
+ const report = run_tidy.format_report([...lanes, ...stashes])
39
+
40
+ if (report !== undefined) console.error(report)
41
+ } catch (error) {
42
+ console.error(FAILURE_NOTE, error)
43
+ }
44
+ }
45
+
46
+ const run_tidy_cli = { sweep }
47
+
48
+ if (process.argv[1] === fileURLToPath(import.meta.url)) await sweep()
49
+
50
+ export { run_tidy_cli }