@joshuafolkken/kit 1.957.0 → 1.972.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 (91) hide show
  1. package/.claude/skills/workflow-commands/background-commands.md +3 -0
  2. package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
  4. package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -1
  7. package/.claude/skills/workflow-commands/followup.md +3 -0
  8. package/.claude/skills/workflow-commands/fullrun.md +3 -4
  9. package/.claude/skills/workflow-commands/observation-filing.md +31 -25
  10. package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
  11. package/.github/workflows/pr-classification.yml +12 -1
  12. package/CLAUDE.md +3 -3
  13. package/README.md +3 -3
  14. package/dist/CLAUDE.md +3 -3
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
  17. package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNWIOJFU.js} +29 -16
  18. package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
  19. package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
  20. package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
  21. package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
  22. package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
  23. package/dist/hooks/{chunk-IJPBTDTB.js → chunk-WWTMHHZU.js} +59 -18
  24. package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
  25. package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
  26. package/dist/hooks/codex-hook-adapter.js +10 -10
  27. package/dist/hooks/duplicate-read-guard.js +5 -5
  28. package/dist/hooks/format-edited.js +2 -2
  29. package/dist/hooks/investigation-guard.js +5 -5
  30. package/dist/hooks/pretool-guard.js +9 -9
  31. package/dist/hooks/stop-guard.js +206 -176
  32. package/dist/josh.js +8 -4
  33. package/package.json +3 -3
  34. package/prompts/collaboration-workflow/wip-cap.md +1 -1
  35. package/scripts/backlog/backlog-arrival.ts +11 -1
  36. package/scripts/backlog/backlog-budget.ts +25 -8
  37. package/scripts/backlog/backlog-next.ts +45 -2
  38. package/scripts/backlog/backlog-offer.ts +1 -0
  39. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  40. package/scripts/backlog/backlog-plan.ts +30 -2
  41. package/scripts/backlog/backlog-stalled.ts +9 -0
  42. package/scripts/backlog/backlog-waves.ts +169 -0
  43. package/scripts/document/document-byte-budget.ts +1 -1
  44. package/scripts/document/entry-read-budget.ts +7 -5
  45. package/scripts/epic/epic-lane-offer.ts +69 -4
  46. package/scripts/epic/epic-next.ts +2 -2
  47. package/scripts/epic/epic-solo.ts +147 -0
  48. package/scripts/epic/epic-triage.ts +47 -0
  49. package/scripts/gate/hook-gate-reuse.ts +7 -8
  50. package/scripts/git/git-closes-keyword.ts +15 -0
  51. package/scripts/git/git-followup-flush.ts +5 -6
  52. package/scripts/git/git-followup-issue-close.ts +75 -0
  53. package/scripts/git/git-pr-followup-wrapup.ts +36 -6
  54. package/scripts/git/git-pr-followup.ts +9 -16
  55. package/scripts/git/git-staging.ts +33 -20
  56. package/scripts/git/issue-labels.ts +23 -0
  57. package/scripts/hooks/stop-guard.ts +1 -0
  58. package/scripts/init/init-install.ts +64 -0
  59. package/scripts/init/init.ts +38 -11
  60. package/scripts/issue/issue-closing-pr.ts +93 -0
  61. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  62. package/scripts/josh/josh-commands-project.ts +5 -1
  63. package/scripts/josh/josh-commands-workflow.ts +3 -3
  64. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  65. package/scripts/observations/observation-ledger.ts +5 -5
  66. package/scripts/observations/observations-flush.ts +11 -30
  67. package/scripts/rules/implementation-cut.ts +49 -16
  68. package/scripts/rules/lane-background.ts +25 -5
  69. package/scripts/rules/stop-rules.ts +16 -4
  70. package/scripts/run/run-carry-cli.ts +21 -0
  71. package/scripts/run/run-cut-cli.ts +92 -110
  72. package/scripts/run/run-cut-report.ts +167 -0
  73. package/scripts/run/run-cut.ts +23 -7
  74. package/scripts/run/run-entry-cli.ts +36 -2
  75. package/scripts/run/run-headless.ts +1 -0
  76. package/scripts/run/run-hold-cli.ts +36 -15
  77. package/scripts/run/run-hold.ts +15 -4
  78. package/scripts/run/run-merge-cli.ts +28 -14
  79. package/scripts/run/run-merge-steps.ts +38 -2
  80. package/scripts/run/run-merge.ts +41 -3
  81. package/scripts/run/run-prep-cli.ts +8 -2
  82. package/scripts/run/run-prep-locate.ts +165 -0
  83. package/scripts/run/run-prep.ts +12 -1
  84. package/scripts/run/run-step-cli.ts +16 -0
  85. package/scripts/run/run-step.ts +28 -4
  86. package/scripts/run/run-tail-cli.ts +4 -2
  87. package/scripts/run/run-wake-work.ts +4 -0
  88. package/scripts/safe-chain/ci-installer-pin.ts +8 -4
  89. package/scripts/time-runtime/time-background.ts +11 -0
  90. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  91. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -16,7 +16,7 @@ import { epic_lane_offer, type LaneOffer, type LaneRequest } from './epic-lane-o
16
16
  import { epic_next_read, type EpicRead, type SnapshotReads } from './epic-next-read'
17
17
  import { epic_next_views, type EpicView } from './epic-next-views'
18
18
  import { epic_outside_blocker } from './epic-outside-blocker'
19
- import { epic_report, type EpicNextResult, type EpicVerdict } from './epic-report'
19
+ import { epic_report, type EpicNextResult } from './epic-report'
20
20
 
21
21
  // `josh epic:next <E…>` — which of the named epics' children can be started right now, bundled per
22
22
  // repository, and what the rest are waiting on (joshuafolkken/kit#860).
@@ -210,7 +210,7 @@ function refuse(reason: string): number {
210
210
  // The verdict as it applies to *this* repository. `run` never reaches a caller here: it means some
211
211
  // other repository has work, which for this session is something to wait on rather than a state its
212
212
  // loop has a branch for. The whole-run timeout is what bounds that wait.
213
- function repo_verdict(verdict: EpicVerdict): EpicVerdict {
213
+ function repo_verdict(verdict: LaneOffer['verdict']): LaneOffer['verdict'] {
214
214
  return verdict === 'run' ? 'wait' : verdict
215
215
  }
216
216
 
@@ -0,0 +1,147 @@
1
+ import { has_any_label, has_label_name, RUN_SOLO_LABEL } from '#scripts/git/issue-labels'
2
+ import { issue_citation } from '#scripts/rules/issue-citation'
3
+ import { epic_busy, type BusyRead } from './epic-busy'
4
+ import type { EpicChild } from './epic-graph'
5
+ import { epic_report, type EpicNextResult } from './epic-report'
6
+
7
+ // The `run:solo` gate on what a run is offered (joshuafolkken/kit#2776).
8
+ //
9
+ // `wip-cap.md`'s run-alone section says an issue whose defect reaches the verification path runs
10
+ // alone, and until this gate that was a judgement made at dispatch — the offer handed such an issue
11
+ // out beside others and `backlog:drive` launched every number it was given. The label records the
12
+ // judgement once; this applies it to every ask, so it holds without an agent in the loop.
13
+ //
14
+ // **Both offer paths call `select`**: `backlog:next` (the pool drain, through `gate`) and
15
+ // `epic:next --lanes` (a named epic's children, `epic-lane-offer.ts`). One rule, so the two cannot
16
+ // disagree about what may start beside what.
17
+ //
18
+ // Three rules, all against the repository's `in-progress` holders (`epic_busy.read_repository`):
19
+ // a running `run:solo` issue lets nothing new start; a `run:solo` candidate starts only into an idle
20
+ // repository and then alone; and nothing ranked below a waiting `run:solo` candidate is offered past
21
+ // it, so it is not starved by the lanes it is waiting on. A listing that could not be read is not an
22
+ // idle repository — the same fail-safe answer `epic_busy` gives.
23
+ //
24
+ // **An idle repository takes its first `run:solo` candidate ahead of the ranking**
25
+ // (joshuafolkken/kit#2778). An issue carries the label because its defect reaches the verification
26
+ // path, and `wip-cap.md` resumes a batch only once that has merged — so an ordinary candidate ranked
27
+ // ahead of it would spend a whole wave running alone, then hold the fix back behind its own merge.
28
+ // While lanes are running the ranking stands: nothing overtakes a waiting `run:solo` candidate, and
29
+ // the ones ahead of it keep their lanes.
30
+
31
+ const SOLO_LABELS: ReadonlySet<string> = new Set([RUN_SOLO_LABEL])
32
+
33
+ // Which of the ordered candidates may start, which are held back, and why when a reason is worth
34
+ // saying. `offered` keeps the candidates' own order.
35
+ interface SoloSelection {
36
+ offered: ReadonlyArray<EpicChild>
37
+ withheld: ReadonlyArray<EpicChild>
38
+ notice?: string
39
+ }
40
+
41
+ interface SoloGate {
42
+ result: EpicNextResult
43
+ notice?: string
44
+ }
45
+
46
+ function is_solo(child: EpicChild): boolean {
47
+ return has_label_name(child.labels, RUN_SOLO_LABEL)
48
+ }
49
+
50
+ function solo_holder(read: BusyRead): number | undefined {
51
+ if (read.kind !== 'busy') return undefined
52
+
53
+ return read.issues.find((issue) => has_any_label(issue.labels, SOLO_LABELS))?.number
54
+ }
55
+
56
+ function held_message(holder: number, repo: string): string {
57
+ return issue_citation.linkify(
58
+ `#${String(holder)} carries \`${RUN_SOLO_LABEL}\` and is running in ${repo}; nothing else starts until it merges or is parked.`,
59
+ repo,
60
+ )
61
+ }
62
+
63
+ function waiting_message(solo: number, repo: string): string {
64
+ return issue_citation.linkify(
65
+ `#${String(solo)} carries \`${RUN_SOLO_LABEL}\` and waits for ${repo}'s running lanes to finish; nothing ranked below it is offered past it.`,
66
+ repo,
67
+ )
68
+ }
69
+
70
+ function first_of(children: ReadonlyArray<EpicChild>, keep: number): SoloSelection {
71
+ return { offered: children.slice(0, keep), withheld: children.slice(keep) }
72
+ }
73
+
74
+ function none_of(children: ReadonlyArray<EpicChild>, notice: string): SoloSelection {
75
+ return { offered: [], withheld: [...children], notice }
76
+ }
77
+
78
+ // The ranking with the first `run:solo` candidate moved to the head — but only into an idle
79
+ // repository, the one place it may start. Anywhere else the ranking is returned as it was.
80
+ function prefer(children: ReadonlyArray<EpicChild>, read: BusyRead): ReadonlyArray<EpicChild> {
81
+ const solo = children.find((child) => is_solo(child))
82
+
83
+ if (solo === undefined || read.kind !== 'idle') return children
84
+
85
+ return [solo, ...children.filter((child) => child !== solo)]
86
+ }
87
+
88
+ // Where the first `run:solo` candidate sits decides what starts: everything when there is none, the
89
+ // candidates ahead of it when lanes are running, and it alone into an idle repository.
90
+ function select_candidates(
91
+ children: ReadonlyArray<EpicChild>,
92
+ read: BusyRead,
93
+ repo: string,
94
+ ): SoloSelection {
95
+ const ordered = prefer(children, read)
96
+ const index = ordered.findIndex((child) => is_solo(child))
97
+ const solo = ordered[index]
98
+
99
+ if (solo === undefined) return first_of(ordered, ordered.length)
100
+ if (index > 0) return first_of(ordered, index)
101
+ if (read.kind === 'idle') return first_of(ordered, 1)
102
+
103
+ return none_of(ordered, waiting_message(solo.number, repo))
104
+ }
105
+
106
+ // An unreadable or truncated listing keeps nothing: without it a running `run:solo` issue cannot be
107
+ // ruled out.
108
+ function select(children: ReadonlyArray<EpicChild>, read: BusyRead, repo: string): SoloSelection {
109
+ if (read.kind === 'unreadable' || read.kind === 'truncated') {
110
+ return none_of(children, epic_busy.busy_reason(read, repo, 0))
111
+ }
112
+
113
+ const holder = solo_holder(read)
114
+
115
+ if (holder !== undefined) return none_of(children, held_message(holder, repo))
116
+
117
+ return select_candidates(children, read, repo)
118
+ }
119
+
120
+ // The offered candidates of `repo` stay; the withheld ones move to `waiting`, which is where the
121
+ // report already lists children that time alone will release.
122
+ function withhold(result: EpicNextResult, repo: string, selection: SoloSelection): EpicNextResult {
123
+ return {
124
+ ...result,
125
+ candidates: result.candidates.map((bundle) =>
126
+ bundle.repo === repo ? { ...bundle, children: selection.offered } : bundle,
127
+ ),
128
+ waiting: [...result.waiting, ...selection.withheld],
129
+ }
130
+ }
131
+
132
+ // Only a `run` answer offers anything, so every other verdict passes through untouched.
133
+ function gate(result: EpicNextResult, read: BusyRead, repo: string): SoloGate {
134
+ if (result.verdict !== 'run') return { result }
135
+
136
+ const selection = select(epic_report.candidates_for_repo(result, repo), read, repo)
137
+ const gated = withhold(result, repo, selection)
138
+
139
+ return selection.notice === undefined
140
+ ? { result: gated }
141
+ : { result: gated, notice: selection.notice }
142
+ }
143
+
144
+ const epic_solo = { gate, is_solo, prefer, select }
145
+
146
+ export type { SoloGate, SoloSelection }
147
+ export { epic_solo }
@@ -0,0 +1,47 @@
1
+ import { has_label_name, RUN_LANE_LABEL, RUN_SOLO_LABEL } from '#scripts/git/issue-labels'
2
+ import { issue_citation } from '#scripts/rules/issue-citation'
3
+ import type { EpicChild } from './epic-graph'
4
+
5
+ // The triage gate on what a run is offered (joshuafolkken/kit#2779).
6
+ //
7
+ // `run:solo` (joshuafolkken/kit#2776) records that an issue runs alone, but its absence said nothing:
8
+ // "judged safe beside others" and "never judged" looked the same, and an issue opted in mid-run reached
9
+ // `backlog:drive` without any agent reading it. `run:lane` records the other answer, so an issue with
10
+ // neither label is one nobody has judged — and such an issue may be one that must run alone.
11
+ //
12
+ // **One untriaged candidate withholds every candidate**, not just itself: whether the others may start
13
+ // depends on whether it is `run:solo`, which is exactly what is not known yet. The answer is the
14
+ // `triage` verdict, which `backlog:drive` hands back to the parent rather than acting on.
15
+ //
16
+ // **Both offer paths call `untriaged`**: `backlog:next` (the pool drain) and `epic:next --lanes`
17
+ // (a named epic's children, `epic-lane-offer.ts`) — the same pairing `epic_solo` keeps.
18
+
19
+ type TriageVerdict = 'triage'
20
+
21
+ const TRIAGE_VERDICT: TriageVerdict = 'triage'
22
+
23
+ function is_triaged(child: EpicChild): boolean {
24
+ const { labels } = child
25
+
26
+ return has_label_name(labels, RUN_SOLO_LABEL) || has_label_name(labels, RUN_LANE_LABEL)
27
+ }
28
+
29
+ // The candidates nobody has judged, in the order they were given.
30
+ function untriaged(children: ReadonlyArray<EpicChild>): ReadonlyArray<EpicChild> {
31
+ return children.filter((child) => !is_triaged(child))
32
+ }
33
+
34
+ // Names every untriaged number, so the parent reading standard error knows which issues to judge.
35
+ function message(children: ReadonlyArray<EpicChild>, repo: string): string {
36
+ const numbers = children.map((child) => `#${String(child.number)}`).join(', ')
37
+
38
+ return issue_citation.linkify(
39
+ `Untriaged: ${numbers} carry neither \`${RUN_SOLO_LABEL}\` nor \`${RUN_LANE_LABEL}\` in ${repo}, so nothing is offered until each is judged. Read each one, record its blocked-by order and one of the two labels with a comment saying why, then ask again.`,
40
+ repo,
41
+ )
42
+ }
43
+
44
+ const epic_triage = { TRIAGE_VERDICT, is_triaged, message, untriaged }
45
+
46
+ export type { TriageVerdict }
47
+ export { epic_triage }
@@ -53,14 +53,13 @@ function is_force_requested(force_environment: string): boolean {
53
53
  // nothing" for "git could not be asked". Every predicate below treats the two differently.
54
54
  //
55
55
  // **The observation ledger is dropped from the reading, and it is the one line that may be**
56
- // (joshuafolkken/kit#1756). Since that issue `pnpm josh git` excludes the ledger from
57
- // what it stages, so a parent's appended line sits modified-but-never-staged for the whole interval
58
- // between the append and the next `pnpm josh observations:flush` — and every condition below reads a
59
- // non-empty status as "this operation carries a tree no check has read". Left in, one ledger line
60
- // would send every commit and every push in the primary checkout back to the full gate for days,
61
- // which is the reuse this module exists to grant. **Dropping it is sound because nothing can carry
62
- // it**: the exclusion means the index and the push are byte-for-byte the recorded tree whatever the
63
- // ledger says, and the ledger's own content is checked by the CI of the pull request the flush opens.
56
+ // (joshuafolkken/kit#1756). Lines are appended throughout a run — a review's record lands after its
57
+ // gate — and every condition below reads a non-empty status as "this operation carries a tree no
58
+ // check has read". Left in, one ledger line would send every commit and every push in the primary
59
+ // checkout back to the full gate, which is the reuse this module exists to grant. **Dropping it is
60
+ // sound because the ledger is no code any check runs**: `pnpm josh git` stages it with the run's
61
+ // commit only after its grammar has been parsed (joshuafolkken/kit#2763), and the rest of its content
62
+ // is checked by the CI of the pull request that carries it.
64
63
  async function read_status_lines(): Promise<ReadonlyArray<string> | undefined> {
65
64
  try {
66
65
  const status = await git_command.status()
@@ -0,0 +1,15 @@
1
+ // Captures the number, so the one pattern answers every question asked of a PR body: whether the
2
+ // linked issue will auto-close, which issue that is when the invocation did not say
3
+ // (joshuafolkken/kit#1539), and whether a merged pull request closes a child a `backlogrun` judged
4
+ // unfinished (joshuafolkken/kit#2769). A second pattern would be two readings of "closes #N" to disagree.
5
+ const CLOSES_PATTERN = /closes\s+#(\d+)/iu
6
+
7
+ function parse_closes_issue_number(body: string | undefined): string | undefined {
8
+ if (body === undefined) return undefined
9
+
10
+ return CLOSES_PATTERN.exec(body)?.[1]
11
+ }
12
+
13
+ const git_closes_keyword = { parse_closes_issue_number }
14
+
15
+ export { git_closes_keyword }
@@ -5,12 +5,11 @@ import { git_command } from './git-command'
5
5
  import { git_followup_cleanup } from './git-followup-cleanup'
6
6
  import { main_sync } from './main-sync'
7
7
 
8
- // The observation ledger's only commit path is `pnpm josh observations:flush`, and until now no
9
- // command's procedure called it (joshuafolkken/kit#1810). The ledger is excluded from ordinary
10
- // staging (joshuafolkken/kit#1756), so an appended line stayed in the working tree until a person
11
- // remembered to flush it — the same "a run that had to remember is the run that forgets" defect the
12
- // staging side was built to avoid, left standing on the commit side. This wires the flush into
13
- // `pnpm josh followup`, the one step that knows the merge has landed.
8
+ // The residual commit path for the observation ledger (joshuafolkken/kit#1810). A run's appended lines
9
+ // ride its own commit (joshuafolkken/kit#2763), so after the merge the ledger is usually clean and this
10
+ // short-circuits; what it still catches is a line appended after the commit — a second review round's
11
+ // record — which would otherwise stay in the working tree until a person remembered to flush it. This
12
+ // wires the flush into `pnpm josh followup`, the one step that knows the merge has landed.
14
13
  //
15
14
  // **It runs before the working-tree hold is released** (`scripts-ai/git-followup-finish.ts`, whose
16
15
  // `finish()` runs after `git_pr_followup.run()` returns), because a flush switches branches, and a
@@ -0,0 +1,75 @@
1
+ import { poll, type PollOptions } from '#scripts/lib/poll'
2
+ import { z } from 'zod'
3
+ import { git_epic_parse } from './git-epic-parse'
4
+ import { git_gh_command } from './git-gh-command'
5
+ import { parse_json_object_safe } from './parse-json-array'
6
+
7
+ // **A merged pull request whose `closes #N` GitHub did not apply** (joshuafolkken/kit#2770). From
8
+ // 2026-09-30 06:34Z every merge `followup` made left its issue open — seven in a row, where every
9
+ // earlier one was closed by the pull request within a second — although the merge request, the
10
+ // merging account and the `closes #N` first line were all the same as before. The run then reported
11
+ // success, and a `backlogrun` read the still-open child as a failure and stopped.
12
+ //
13
+ // **The issue's state is what is checked, not `closingIssuesReferences`.** That field is GraphQL-only
14
+ // (a cloud session is answered 403 there, joshuafolkken/kit#1022) and it is not the fact that failed:
15
+ // PR #2768 answered it empty at the time and names #2761 now, while #2761 was never closed by it. The
16
+ // observable defect is an issue left open, so that is what is read, and what is repaired.
17
+ //
18
+ // **A short wait first**, because GitHub applies the keyword asynchronously — the closes it did apply
19
+ // landed within a second of the merge. Closing on the first read would take the close away from the
20
+ // pull request on every run that merely raced it.
21
+ const CLOSE_WAIT: PollOptions = { attempts: 3, interval_ms: 2000 }
22
+
23
+ const CLOSE_RECOVERY = 'gh api -X PATCH repos/{owner}/{repo}/issues/<N> -f state=closed'
24
+
25
+ const state_read_schema = z.object({ state: z.string().optional() })
26
+
27
+ async function is_issue_closed(issue_number: string): Promise<boolean> {
28
+ const raw = await git_gh_command.issue_get_state_and_relations(issue_number)
29
+ const parsed = raw === undefined ? undefined : parse_json_object_safe(raw, state_read_schema)
30
+
31
+ return git_epic_parse.is_state_closed(parsed?.state)
32
+ }
33
+
34
+ const FOLLOWUP_CLOSER = 'pnpm josh followup'
35
+
36
+ // English, like every other string a script posts: the comment says why the close came from this
37
+ // command rather than from the pull request, so a reader of the issue is not left guessing. `closer`
38
+ // names the command that closed it — `followup` at the merge, or `run:merge` when a `backlogrun`
39
+ // finds the child still open afterwards (joshuafolkken/kit#2769).
40
+ function build_close_comment(pr_url: string | undefined, closer: string = FOLLOWUP_CLOSER): string {
41
+ return (
42
+ `Closed by \`${closer}\`: ${pr_url ?? 'the merged pull request'} merged, ` +
43
+ 'but GitHub did not apply its ' +
44
+ '`closes #N` keyword (joshuafolkken/kit#2770).'
45
+ )
46
+ }
47
+
48
+ // **Throws when the issue is still open**, so the guard around it reports the failure with the
49
+ // command that finishes it by hand — an issue left open after a merge is the one thing this step
50
+ // exists to stop going unnoticed.
51
+ async function ensure_issue_closed(
52
+ input: { issue_number: string | undefined; pr_url: string | undefined; closer?: string },
53
+ wait: PollOptions = CLOSE_WAIT,
54
+ ): Promise<void> {
55
+ const { issue_number } = input
56
+ const is_done =
57
+ issue_number === undefined ||
58
+ (await poll.poll_until(async () => await is_issue_closed(issue_number), wait))
59
+ if (is_done) return
60
+
61
+ console.warn(`⚠️ Issue #${issue_number} is still open after the merge — closing it.`)
62
+ const comment = build_close_comment(input.pr_url, input.closer)
63
+
64
+ if (!(await git_gh_command.issue_close(issue_number, comment))) {
65
+ throw new Error(`gh api could not close issue #${issue_number}`)
66
+ }
67
+ }
68
+
69
+ const git_followup_issue_close = {
70
+ ensure_issue_closed,
71
+ build_close_comment,
72
+ CLOSE_RECOVERY,
73
+ }
74
+
75
+ export { git_followup_issue_close }
@@ -1,6 +1,7 @@
1
1
  import { git_epic_close } from './git-epic-close'
2
2
  import { git_followup_cleanup } from './git-followup-cleanup'
3
3
  import { git_followup_flush } from './git-followup-flush'
4
+ import { git_followup_issue_close } from './git-followup-issue-close'
4
5
  import { git_followup_label } from './git-followup-label'
5
6
  import { git_followup_stages, type StageLog } from './git-followup-stages'
6
7
  import { git_gh_command } from './git-gh-command'
@@ -25,6 +26,9 @@ const IN_PROGRESS_RECOVERY = 'gh api -X DELETE repos/{owner}/{repo}/issues/<N>/l
25
26
  interface WrapupInput {
26
27
  branch_name: string
27
28
  issue_number: string | undefined
29
+ // The number the pull request body's `closes #N` names — the only issue the merge claimed to
30
+ // close, so the only one the merged-issue close may write to (joshuafolkken/kit#2770).
31
+ closes_number: string | undefined
28
32
  notify_config: GitNotifyConfig | undefined
29
33
  pr_url: string | undefined
30
34
  should_merge: boolean
@@ -164,9 +168,30 @@ async function in_progress_step(input: WrapupInput): Promise<void> {
164
168
  })
165
169
  }
166
170
 
171
+ // **The merged issue's close, confirmed rather than assumed** (joshuafolkken/kit#2770): GitHub stopped
172
+ // applying `closes #N` on some merges, and nothing noticed. The epic auto-close does not wait on it —
173
+ // it already excludes the merged issue from its reads — so it runs beside the other steps. It acts on
174
+ // the body's `closes #N` rather than the run's issue number: a pull request that closes nothing, or
175
+ // another issue, never closes the one the invocation named.
176
+ async function issue_close_step(input: WrapupInput): Promise<void> {
177
+ if (!input.should_merge) return
178
+
179
+ await git_followup_cleanup.run_guarded_step(input.should_merge, {
180
+ label: 'The merged issue close',
181
+ recovery: git_followup_issue_close.CLOSE_RECOVERY,
182
+ run: async () => {
183
+ await git_followup_issue_close.ensure_issue_closed({
184
+ issue_number: input.closes_number,
185
+ pr_url: input.pr_url,
186
+ })
187
+ },
188
+ })
189
+ }
190
+
167
191
  // **The steps after the merge are issued together** (joshuafolkken/kit#1446). The completion
168
- // comment writes to the issue or the pull request, the auto-close reads the open epics, and the
169
- // label removal reads and writes the issue's labels; none needs another's answer, and measured
192
+ // comment writes to the issue or the pull request, the auto-close reads the open epics, the label
193
+ // removal reads and writes the issue's labels, and the merged-issue close reads and may close the
194
+ // issue; none needs another's answer, and measured
170
195
  // serially the first two were 1.9 s and 3.1 s of `followup`'s own clock. Past the merge none can
171
196
  // reject another — `run_guarded_step` reports the failure and answers instead — so they settle
172
197
  // independently and the stage costs the longest of them rather than their sum.
@@ -178,9 +203,9 @@ async function in_progress_step(input: WrapupInput): Promise<void> {
178
203
  // (`git-followup-cleanup.ts`), which is easier to read as one sequence than as a settled set.
179
204
  //
180
205
  // **What the overlap costs, stated rather than glossed**: mutating requests now go out at once
181
- // against one repository — a comment write, an auto-close that comments on and closes epics, and a
182
- // label delete — which is what GitHub's secondary rate limit guidance is about. Past the merge all
183
- // of them are guarded, so the worst case is a cleanup reported as unfinished rather than a failed
206
+ // against one repository — a comment write, an auto-close that comments on and closes epics, a label
207
+ // delete, and the merged issue's close when GitHub did not apply it — which is what GitHub's
208
+ // secondary rate limit guidance is about. Past the merge all of them are guarded, so the worst case is a cleanup reported as unfinished rather than a failed
184
209
  // run, and this is a handful of requests rather than a fan-out. **Their console output can
185
210
  // interleave too**: the guard's warning and its recovery line are printed as they happen, so an
186
211
  // auto-close progress line can land between them. The recovery line names its own command and stands
@@ -194,7 +219,12 @@ async function run_tail_steps(input: WrapupInput): Promise<void> {
194
219
  return
195
220
  }
196
221
 
197
- await Promise.all([notify_step(input), epic_close_step(input), in_progress_step(input)])
222
+ await Promise.all([
223
+ notify_step(input),
224
+ epic_close_step(input),
225
+ in_progress_step(input),
226
+ issue_close_step(input),
227
+ ])
198
228
  }
199
229
 
200
230
  async function run_wrapup(input: WrapupInput, log: StageLog): Promise<void> {
@@ -1,3 +1,4 @@
1
+ import { git_closes_keyword } from './git-closes-keyword'
1
2
  import { git_followup_pending } from './git-followup-pending'
2
3
  import { git_followup_stages, type StageLog } from './git-followup-stages'
3
4
  import { git_gh_command } from './git-gh-command'
@@ -15,10 +16,6 @@ import { telegram_notify, type TelegramSendInput, type TelegramTaskType } from '
15
16
 
16
17
  const { STAGE, lap } = git_followup_stages
17
18
 
18
- // Captures the number, so the one pattern answers both questions the run asks of a PR body: whether
19
- // the linked issue will auto-close, and — since joshuafolkken/kit#1539 — which issue that is when the
20
- // invocation did not say. A second pattern here would be two readings of "closes #N" to disagree.
21
- const CLOSES_PATTERN = /closes\s+#(\d+)/iu
22
19
  // Named so a test can pin the note without restating it (joshuafolkken/kit#999).
23
20
  const WATCH_FAILED_NOTE = 'pr checks --watch failed; falling through to polling'
24
21
  const REPO_NAME_SEPARATOR = '/'
@@ -69,11 +66,7 @@ interface FollowupInput {
69
66
  should_merge: boolean
70
67
  }
71
68
 
72
- function parse_closes_issue_number(body: string | undefined): string | undefined {
73
- if (body === undefined) return undefined
74
-
75
- return CLOSES_PATTERN.exec(body)?.[1]
76
- }
69
+ const { parse_closes_issue_number } = git_closes_keyword
77
70
 
78
71
  function has_closes_keyword(body: string | undefined): boolean {
79
72
  return parse_closes_issue_number(body) !== undefined
@@ -234,6 +227,7 @@ async function read_issue_title(
234
227
 
235
228
  interface RunContext {
236
229
  issue_number: string | undefined
230
+ closes_number: string | undefined
237
231
  context: TelegramContext
238
232
  }
239
233
 
@@ -256,19 +250,17 @@ async function read_run_context(input: {
256
250
  issue_number: string | undefined
257
251
  }): Promise<RunContext> {
258
252
  const closes = warn_if_missing_closes(input.branch_name)
259
- const owner = git_gh_command.repo_get_name_with_owner()
260
- const url = git_gh_command.pr_get_url(input.branch_name)
261
- const title = read_issue_title(input.issue_number, closes)
262
253
  const [closes_number, name_with_owner, pr_url, issue_title] = await Promise.all([
263
254
  closes,
264
- owner,
265
- url,
266
- title,
255
+ git_gh_command.repo_get_name_with_owner(),
256
+ git_gh_command.pr_get_url(input.branch_name),
257
+ read_issue_title(input.issue_number, closes),
267
258
  ])
268
259
  const issue_number = input.issue_number ?? closes_number
269
260
 
270
261
  return {
271
262
  issue_number,
263
+ closes_number,
272
264
  context: {
273
265
  repo_name: parse_repo_name(name_with_owner),
274
266
  issue_title,
@@ -373,7 +365,7 @@ async function run_review_checks(
373
365
  // the epic close all name the same issue — and so the caller's own tail can record a run whose number
374
366
  // only the pull request knew.
375
367
  async function run_stages(input: FollowupInput, log: StageLog): Promise<string | undefined> {
376
- const { issue_number, context } = await read_run_context(input)
368
+ const { issue_number, closes_number, context } = await read_run_context(input)
377
369
 
378
370
  lap(log, STAGE.closes_and_context)
379
371
  const checks = await run_review_checks(input, context, log)
@@ -385,6 +377,7 @@ async function run_stages(input: FollowupInput, log: StageLog): Promise<string |
385
377
  {
386
378
  branch_name: input.branch_name,
387
379
  issue_number,
380
+ closes_number,
388
381
  notify_config: input.notify_config,
389
382
  pr_url: context.pr_url,
390
383
  should_merge: input.should_merge,
@@ -2,6 +2,7 @@ import {
2
2
  observation_ledger,
3
3
  OBSERVATION_LEDGER_PATHS,
4
4
  } from '#scripts/observations/observation-ledger'
5
+ import { observation_ledger_prepare } from '#scripts/observations/observation-ledger-prepare'
5
6
  import { git_command } from './git-command'
6
7
  import { git_prompt } from './git-prompt'
7
8
  import { git_status } from './git-status'
@@ -67,18 +68,26 @@ async function stage_untracked_files(files: ReadonlyArray<string>): Promise<void
67
68
  for (const file of files) console.info(` + ${file}`)
68
69
  }
69
70
 
70
- // **The observation ledger is never staged by an ordinary commit** (joshuafolkken/kit#1756). A
71
- // parent session appends to it in the primary checkout and nothing there commits it, so the next
72
- // `pnpm josh git` run in that checkout swept the line into a pull request about something else
73
- // entirely — a diff a reviewer has no reason to question. Excluding it here covers every entry
74
- // point, because this is the one staging step all of them go through;
75
- // `pnpm josh observations:flush` is what commits it, on a branch of its own.
76
- // Both of its paths are excluded (joshuafolkken/kit#2724): a run on the old code still appends at
77
- // the old one until `observation-ledger-migrate.ts` moves it.
78
- const PATHS_EXCLUDED_FROM_STAGING: ReadonlyArray<string> = OBSERVATION_LEDGER_PATHS
79
-
80
- function is_stageable(file_path: string): boolean {
81
- return !observation_ledger.is_ledger_path(file_path)
71
+ // **The observation ledger rides the run's own commit** (joshuafolkken/kit#2763). It used to be
72
+ // excluded here (joshuafolkken/kit#1756) and committed afterwards by `pnpm josh observations:flush` as
73
+ // a pull request of its own — a second branch, CI wait and merge behind every run that appended a
74
+ // line, 144 of them in one month. Staged with the run instead, the lines are reviewed and merged with
75
+ // the pull request whose run recorded them, while CI for that pull request is the only wait.
76
+ //
77
+ // **A line that breaks the grammar keeps the old exclusion** (joshuafolkken/kit#2123): the commit
78
+ // goes ahead without the ledger, and `pnpm josh observations:flush` — which refuses the same line and
79
+ // names it — is where it is repaired. The migration is run first, as the flush runs it, so a line
80
+ // still on the old path (joshuafolkken/kit#2724) is committed at the new one. A lane has no ledger
81
+ // line of its own to carry: its writers append to the primary checkout's.
82
+ async function ledger_exclusions(): Promise<ReadonlyArray<string>> {
83
+ const broken = await observation_ledger_prepare.prepare(await git_command.repository_root())
84
+
85
+ return broken.length > 0 ? OBSERVATION_LEDGER_PATHS : []
86
+ }
87
+
88
+ // A migration claim is a copy mid-move, never the ledger itself, so it is never staged.
89
+ function is_stageable(file_path: string, excluded: ReadonlyArray<string>): boolean {
90
+ return !observation_ledger.is_migration_claim(file_path) && !excluded.includes(file_path)
82
91
  }
83
92
 
84
93
  // **Said out loud, because the alternative is an unexplained failure.** With the ledger the only
@@ -89,27 +98,31 @@ function is_stageable(file_path: string): boolean {
89
98
  // **The paths are parsed rather than matched as substrings**: `docs/maintainers/observations.md.bak` contains the
90
99
  // ledger's path, and a hint naming a file the exclusion never touched is a hint that teaches the
91
100
  // reader to ignore it.
92
- function report_excluded_paths(status_output: string): void {
93
- const excluded = status_output
101
+ function report_excluded_paths(status_output: string, excluded: ReadonlyArray<string>): void {
102
+ const left_out = status_output
94
103
  .split('\n')
95
104
  .filter((line) => line.trim().length > 0)
96
105
  .map((line) => observation_ledger.status_path(line))
97
- .filter((file_path) => !is_stageable(file_path))
106
+ .filter((file_path) => excluded.includes(file_path))
98
107
 
99
- for (const file_path of excluded) {
100
- console.info(`💡 ${file_path} is not staged; commit it with \`pnpm josh observations:flush\`.`)
108
+ for (const file_path of left_out) {
109
+ console.info(
110
+ `💡 ${file_path} is not staged: a ledger line breaks the grammar; \`pnpm josh observations:flush\` names it.`,
111
+ )
101
112
  }
102
113
  }
103
114
 
115
+ // The status is read after the migration, so an old-path ledger it moved is not staged by its old name.
104
116
  async function stage_tracked_files(): Promise<void> {
117
+ const excluded = await ledger_exclusions()
105
118
  const status_output = await git_command.status()
106
119
  const untracked = git_status
107
120
  .list_untracked_files(status_output)
108
- .filter((file_path) => is_stageable(file_path))
121
+ .filter((file_path) => is_stageable(file_path, excluded))
109
122
 
110
- await git_command.add_tracked(PATHS_EXCLUDED_FROM_STAGING)
123
+ await git_command.add_tracked(excluded)
111
124
  console.info('💡 Auto-staged tracked modified files (git add -u).')
112
- report_excluded_paths(status_output)
125
+ report_excluded_paths(status_output, excluded)
113
126
  await stage_untracked_files(untracked)
114
127
  }
115
128
 
@@ -10,6 +10,15 @@ const IN_PROGRESS_LABEL = 'in-progress'
10
10
  // it: a parked child is why a run reports "nothing left that time will fix" rather than waiting
11
11
  // forever (joshuafolkken/kit#860).
12
12
  const NEEDS_DECISION_LABEL = 'needs-decision'
13
+ // Marks an issue whose defect reaches the verification path, so it runs with nothing beside it
14
+ // (joshuafolkken/kit#2776). A run applies it from the verification-path enumeration in `wip-cap.md`;
15
+ // `backlog:next` enforces it (`backlog-solo.ts`), so the rule no longer rests on a judgement at
16
+ // dispatch time.
17
+ const RUN_SOLO_LABEL = 'run:solo'
18
+ // The other half of the same judgement: an issue a run read and found safe to run beside others
19
+ // (joshuafolkken/kit#2779). Without it an issue lacking `run:solo` could mean "judged parallel" or
20
+ // "never judged", and `backlog:next` withholds the second (`epic-triage.ts`).
21
+ const RUN_LANE_LABEL = 'run:lane'
13
22
  // Opts one issue outside any epic into unattended execution (joshuafolkken/kit#906). **Only a
14
23
  // person applies it.** `epicrun #<E>` approves the merges inside `#<E>`; this label is the only way
15
24
  // a person extends that approval past the epic's edge, so a label an AI could apply to itself would
@@ -191,6 +200,16 @@ const WORKFLOW_LABELS: ReadonlyArray<{
191
200
  description: 'Tracks a batch of child issues from one split',
192
201
  },
193
202
  { name: IN_PROGRESS_LABEL, color: '0075ca', description: 'Work is actively in progress' },
203
+ {
204
+ name: RUN_SOLO_LABEL,
205
+ color: 'b60205',
206
+ description: 'Runs alone in a backlogrun: nothing starts beside it',
207
+ },
208
+ {
209
+ name: RUN_LANE_LABEL,
210
+ color: '0e8a16',
211
+ description: 'Judged safe to run beside others in a backlogrun',
212
+ },
194
213
  ...FILING_ROUTE_LABELS,
195
214
  ...DEPTH_LABELS,
196
215
  ]
@@ -269,6 +288,8 @@ const ALL_LABELS: ReadonlySet<string> = new Set([
269
288
  INTERRUPT_ROUTE_LABEL,
270
289
  REVIEW_CAP_ROUTE_LABEL,
271
290
  REVIEW_ROUND2_SKIPPED_LABEL,
291
+ RUN_LANE_LABEL,
292
+ RUN_SOLO_LABEL,
272
293
  SPLIT_ROUTE_LABEL,
273
294
  TIER_A_ROUTE_LABEL,
274
295
  DEPTH_0_LABEL,
@@ -301,6 +322,8 @@ export {
301
322
  NOT_DIRECTLY_RUNNABLE_LABELS,
302
323
  REVIEW_CAP_ROUTE_LABEL,
303
324
  REVIEW_ROUND2_SKIPPED_LABEL,
325
+ RUN_LANE_LABEL,
326
+ RUN_SOLO_LABEL,
304
327
  SPLIT_ROUTE_LABEL,
305
328
  TIER_A_ROUTE_LABEL,
306
329
  WORKFLOW_LABELS,