@joshuafolkken/kit 1.1094.0 → 1.1128.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 (149) hide show
  1. package/.claude/skills/workflow-commands/SKILL.md +1 -1
  2. package/.claude/skills/workflow-commands/background-commands.md +36 -85
  3. package/.claude/skills/workflow-commands/backlogrun-progress.md +29 -32
  4. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -2
  5. package/.claude/skills/workflow-commands/fullrun.md +5 -4
  6. package/.claude/skills/workflow-commands/halfrun.md +5 -4
  7. package/.claude/skills/workflow-commands/latest-gate.md +7 -15
  8. package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -8
  9. package/.claude/skills/workflow-commands/split-assessment.md +4 -18
  10. package/.github/actions/setup-node/action.yml +33 -0
  11. package/.github/actions/setup-pnpm/action.yml +4 -4
  12. package/.github/workflows/auto-tag.yml +2 -3
  13. package/.github/workflows/ci.yml +7 -8
  14. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  15. package/.github/workflows/pr-classification.yml +1 -3
  16. package/.github/workflows/production.yml +1 -0
  17. package/.github/workflows/publish.yml +10 -9
  18. package/.github/workflows/sonar-qube.yml +17 -13
  19. package/.pnpmfile.mjs +29 -0
  20. package/AGENTS.md +4 -11
  21. package/CLAUDE.md +24 -17
  22. package/GEMINI.md +4 -13
  23. package/README.md +8 -3
  24. package/SECURITY.md +1 -0
  25. package/cspell/index.yaml +4 -0
  26. package/dist/CLAUDE.basic.md +6 -6
  27. package/dist/CLAUDE.md +24 -17
  28. package/dist/CLAUDE.static.md +6 -6
  29. package/dist/config-merge/index.js +33 -9
  30. package/dist/hooks/batch-guard.js +4 -4
  31. package/dist/hooks/{chunk-NKOG3XAV.js → chunk-4JZDP5X5.js} +2 -2
  32. package/dist/hooks/{chunk-3C4YCCEF.js → chunk-5SD2YM7Z.js} +4 -4
  33. package/dist/hooks/{chunk-OIZUHPJI.js → chunk-77J7IEQM.js} +1 -1
  34. package/dist/hooks/{chunk-KHWHKJOM.js → chunk-7HVB7SZG.js} +3 -3
  35. package/dist/hooks/{chunk-UD24EAIL.js → chunk-AIOIOMPX.js} +1875 -104
  36. package/dist/hooks/{chunk-M2XDCUKD.js → chunk-M7QKEKWO.js} +88 -1783
  37. package/dist/hooks/{chunk-E54KGFEQ.js → chunk-MKVGBQPA.js} +1 -1
  38. package/dist/hooks/{chunk-7ZPFAVR7.js → chunk-NXYMNPUQ.js} +3 -3
  39. package/dist/hooks/{chunk-O4LL4HTI.js → chunk-QVLT52OV.js} +2 -2
  40. package/dist/hooks/{chunk-IL27YTPH.js → chunk-XYUIQOW3.js} +2 -2
  41. package/dist/hooks/codex-hook-adapter.js +12 -47
  42. package/dist/hooks/duplicate-read-guard.js +5 -5
  43. package/dist/hooks/format-edited.js +2 -2
  44. package/dist/hooks/inputs.json +180 -179
  45. package/dist/hooks/investigation-guard.js +5 -5
  46. package/dist/hooks/pretool-guard.js +9 -9
  47. package/dist/hooks/stop-guard.js +17 -12
  48. package/dist/josh.js +79 -30
  49. package/dist/version/index.js +36 -20
  50. package/eslint/rules/unicorn.js +3 -2
  51. package/package.json +18 -2
  52. package/pnpm-workspace.yaml +5 -7
  53. package/prompts/collaboration-workflow/glossary.md +17 -0
  54. package/prompts/collaboration-workflow/issue-template.md +1 -1
  55. package/prompts/collaboration-workflow/operating-rules.md +1 -1
  56. package/prompts/collaboration-workflow/principles.md +9 -0
  57. package/prompts/collaboration-workflow.md +1 -0
  58. package/scripts/backlog/backlog-arrival.ts +30 -10
  59. package/scripts/backlog/backlog-budget-cli.ts +15 -16
  60. package/scripts/backlog/backlog-offer-cli.ts +6 -14
  61. package/scripts/backlog/backlog-plan.ts +29 -18
  62. package/scripts/backlog/backlog-ready.ts +20 -7
  63. package/scripts/config-merge/parse-jsonc.ts +33 -3
  64. package/scripts/config-merge/patch-json-key.ts +13 -6
  65. package/scripts/cost-runtime/cost-cli.ts +3 -7
  66. package/scripts/doctor/doctor-consumer.ts +3 -5
  67. package/scripts/doctor/doctor-io.ts +9 -13
  68. package/scripts/document/backlogrun-parent-read-set.ts +12 -1
  69. package/scripts/document/document-byte-budget.ts +1 -0
  70. package/scripts/document/entry-read-budget.ts +13 -7
  71. package/scripts/document/entry-read-set.ts +32 -8
  72. package/scripts/document/lane-child-read-set.ts +7 -0
  73. package/scripts/document/read-set-point-of-use.ts +8 -3
  74. package/scripts/document/read-set-trim.ts +3 -1
  75. package/scripts/epic/epic-audit.ts +3 -18
  76. package/scripts/gh/git-gh-issue-list.ts +12 -11
  77. package/scripts/gh/git-gh-issue-write.ts +4 -1
  78. package/scripts/gh/git-gh-pr-auto-merge.ts +4 -1
  79. package/scripts/git/git-command.ts +6 -1
  80. package/scripts/git/git-spawn-sync.ts +65 -0
  81. package/scripts/git/git-spawn.ts +5 -4
  82. package/scripts/git/stash/stash-pop-args.ts +1 -6
  83. package/scripts/init/init-file-action.ts +54 -0
  84. package/scripts/init/init-logic-migrate.ts +35 -17
  85. package/scripts/init/init-logic-secretlint.ts +1 -1
  86. package/scripts/init/init-logic.ts +6 -22
  87. package/scripts/init/init.ts +11 -68
  88. package/scripts/init/kit-written-paths.ts +2 -0
  89. package/scripts/init/project-profile.ts +3 -6
  90. package/scripts/init/start-steps.ts +2 -1
  91. package/scripts/issue/defect-rate-cli.ts +61 -20
  92. package/scripts/issue/issue-backlinks.ts +7 -14
  93. package/scripts/issue/issue-cite.ts +12 -6
  94. package/scripts/issue/issue-file.ts +5 -12
  95. package/scripts/issue/markdown-section.ts +21 -6
  96. package/scripts/issue/measure-rerun-cli.ts +73 -14
  97. package/scripts/josh/josh-commands-project.ts +1 -1
  98. package/scripts/josh/josh-commands-workflow.ts +1 -1
  99. package/scripts/josh/run-github-prerequisite.ts +3 -6
  100. package/scripts/lane/lane-await.ts +43 -13
  101. package/scripts/lane/lane-close.ts +4 -1
  102. package/scripts/lane/lane-launch-cli.ts +10 -16
  103. package/scripts/lane/openai-review-broker.ts +4 -1
  104. package/scripts/lib/cli-flags.ts +31 -2
  105. package/scripts/lib/error-message.ts +12 -1
  106. package/scripts/lines/line-targets.ts +0 -11
  107. package/scripts/lines/lines-command.ts +5 -3
  108. package/scripts/propagate/propagate-git.ts +3 -8
  109. package/scripts/refactor/refactor-scan.ts +2 -1
  110. package/scripts/registry-migration/migrate-logic.ts +28 -19
  111. package/scripts/registry-migration/migrate.ts +6 -5
  112. package/scripts/release/github-release-cli.ts +14 -3
  113. package/scripts/release/release-publish.ts +36 -2
  114. package/scripts/release/release-worktree.ts +97 -2
  115. package/scripts/review/review-checkout.ts +3 -2
  116. package/scripts/review/review-record-cli.ts +9 -16
  117. package/scripts/rules/early-heartbeat.ts +4 -3
  118. package/scripts/rules/lane-park.ts +9 -0
  119. package/scripts/rules/stop-rules.ts +7 -4
  120. package/scripts/run/run-carry-args.ts +8 -12
  121. package/scripts/run/run-cut-args.ts +1 -6
  122. package/scripts/run/run-ending-cli.ts +1 -5
  123. package/scripts/run/run-entry-cli.ts +40 -9
  124. package/scripts/run/run-entry-stop.ts +104 -0
  125. package/scripts/run/run-event-stream-emit.ts +7 -2
  126. package/scripts/run/run-hold.ts +4 -1
  127. package/scripts/run/run-liveness-cli.ts +1 -5
  128. package/scripts/run/run-merge-cli.ts +2 -10
  129. package/scripts/run/run-parent-cut-hook.ts +4 -1
  130. package/scripts/run/run-prep-cli.ts +4 -1
  131. package/scripts/run/run-progress-args.ts +20 -4
  132. package/scripts/run/run-progress-cli.ts +31 -29
  133. package/scripts/run/run-progress-clock.ts +4 -1
  134. package/scripts/run/run-ship-cli.ts +8 -16
  135. package/scripts/run/run-ship-probe.ts +4 -1
  136. package/scripts/run/run-status-cli.ts +2 -15
  137. package/scripts/run/run-tidy-lanes.ts +4 -1
  138. package/scripts/run/run-wake-cli.ts +2 -10
  139. package/scripts/run/run-watcher-guard.ts +9 -5
  140. package/scripts/safe-chain/pack-hook.ts +92 -0
  141. package/scripts/sync/sync-ai-files.ts +4 -0
  142. package/scripts/test/test-declared-changed.ts +13 -16
  143. package/scripts/test/test-declared.ts +3 -7
  144. package/scripts/version/version-commands.ts +11 -1
  145. package/templates/CLAUDE.basic.md +6 -6
  146. package/templates/workflows/ci.yml +16 -82
  147. package/templates/workflows/dependabot-auto-merge.yml +1 -0
  148. package/templates/workflows/github-release.yml +1 -3
  149. package/tsconfig.sonar.json +4 -22
@@ -1,7 +1,9 @@
1
1
  import path from 'node:path'
2
2
  import { git_command } from '#scripts/git/git-command'
3
+ import { git_remote_branch } from '#scripts/git/git-remote-branch'
3
4
  import { git_worktree } from '#scripts/git/git-worktree'
4
5
  import { lane_install } from '#scripts/lane/lane-install'
6
+ import { lane_leftover } from '#scripts/lane/lane-leftover'
5
7
  import { lane_paths } from '#scripts/lane/lane-paths'
6
8
 
7
9
  // The dedicated linked work tree `pnpm josh release` runs inside (joshuafolkken/kit#2411). The whole
@@ -51,18 +53,111 @@ async function install_or_clean(directory: string, branch_name: string): Promise
51
53
  // tree on the default branch, so the tree is created directly on the release branch — `git worktree
52
54
  // add --no-track -b release/v<version> <dir> origin/<default>`, which `worktree_add` already spells
53
55
  // when it is given a start point.
56
+ //
57
+ // **A failed add takes back the branch it made** (joshuafolkken/kit#3058). `git worktree add -b`
58
+ // creates the branch before it looks at the path, so an add refused for a path that already exists
59
+ // still leaves `release/v<version>` behind — and the next run's branch guard then refuses that name
60
+ // as a release already opened. The guard ran before this call and found no such branch, so one
61
+ // present now is this call's own.
62
+ async function add_or_take_back(directory: string, branch_name: string): Promise<void> {
63
+ const default_branch = await git_command.get_default_branch()
64
+
65
+ try {
66
+ await git_worktree.worktree_add(directory, branch_name, `origin/${default_branch}`)
67
+ } catch (error) {
68
+ if (await git_command.branch_exists(branch_name)) await git_worktree.branch_delete(branch_name)
69
+
70
+ throw error
71
+ }
72
+ }
73
+
54
74
  async function create(branch_name: string): Promise<string> {
55
75
  const directory = directory_for(process.cwd())
56
- const default_branch = await git_command.get_default_branch()
57
76
 
58
- await git_worktree.worktree_add(directory, branch_name, `origin/${default_branch}`)
77
+ await add_or_take_back(directory, branch_name)
59
78
  await install_or_clean(directory, branch_name)
60
79
 
61
80
  return directory
62
81
  }
63
82
 
83
+ // **A run that never reached its `finally` leaves its tree and branch behind** (joshuafolkken/kit#3058)
84
+ // — a Ctrl-C, a closed terminal — and every later release then fails on them until a person removes
85
+ // both by hand. They are cleared here only when clearing loses nothing: a release branch origin does
86
+ // not have and that carries no commit beyond `origin/<default>`. Uncommitted edits in the tree are
87
+ // the version bump the next run writes again, so they are not counted as a loss. A branch that was
88
+ // pushed, or that holds a commit, may be a release still in flight, so it is reported and kept. A
89
+ // live run with neither yet is excluded by the caller: `release_publish.publish` holds the release
90
+ // lock, so no other release is alive when this runs.
91
+ const RELEASE_BRANCH_PATTERN = 'release/v*'
92
+
93
+ async function leftover_reason(branch_name: string, base: string): Promise<string | undefined> {
94
+ const answer = await git_remote_branch.ask(branch_name)
95
+
96
+ if (answer !== 'absent') return `origin answers \`${answer}\` for it`
97
+
98
+ const ahead = await git_command.commit_count_beyond(base, branch_name)
99
+
100
+ return ahead === 0 ? undefined : `it holds ${String(ahead)} commit(s) not on \`${base}\``
101
+ }
102
+
103
+ function kept_message(directory: string, kept: ReadonlyArray<[string, string]>): string {
104
+ const lines = kept.map(([branch_name, reason]) => `- \`${branch_name}\`: ${reason}`)
105
+ const commands = [
106
+ `git worktree remove --force ${directory}`,
107
+ ...kept.map(([branch_name]) => `git branch -D ${branch_name}`),
108
+ ].join('; ')
109
+
110
+ return [
111
+ 'A previous `pnpm josh release` left release branches that may still be in use, so nothing was removed:',
112
+ ...lines,
113
+ `If no release is running, finish or close its pull request, delete the remote branch if it was pushed, then run \`${commands}\` and \`pnpm josh release\` again.`,
114
+ ].join('\n')
115
+ }
116
+
117
+ async function kept_branches(branches: ReadonlyArray<string>): Promise<Array<[string, string]>> {
118
+ const base = `origin/${await git_command.get_default_branch()}`
119
+ const reasons = await Promise.all(branches.map(async (name) => await leftover_reason(name, base)))
120
+
121
+ return branches.flatMap((name, index) => {
122
+ const reason = reasons[index]
123
+
124
+ return reason === undefined ? [] : [[name, reason] as [string, string]]
125
+ })
126
+ }
127
+
128
+ async function is_registered(directory: string): Promise<boolean> {
129
+ const listing = await git_worktree.worktree_list()
130
+
131
+ return listing.split('\n').includes(`worktree ${directory}`)
132
+ }
133
+
134
+ // The tree goes before the branches, for the reason `remove` gives. A directory git does not register
135
+ // is `lane_leftover.reclaim`'s to remove or refuse — the same test a lane's leftover path gets.
136
+ // Registration is asked before existence: a directory deleted by hand stays registered, and git
137
+ // refuses to delete a branch a registered tree holds until `worktree remove` drops the registration.
138
+ async function remove_leftover_tree(directory: string): Promise<void> {
139
+ if (await is_registered(directory)) await git_worktree.worktree_remove(directory)
140
+ else await lane_leftover.reclaim(directory)
141
+ }
142
+
143
+ async function clear_leftover(): Promise<void> {
144
+ const directory = directory_for(process.cwd())
145
+ const branches = await git_command.branch_names(RELEASE_BRANCH_PATTERN)
146
+ const kept = await kept_branches(branches)
147
+
148
+ if (kept.length > 0) throw new Error(kept_message(directory, kept))
149
+
150
+ await remove_leftover_tree(directory)
151
+
152
+ for (const branch_name of branches) {
153
+ // eslint-disable-next-line no-await-in-loop -- each `branch -D` takes the same ref lock
154
+ await git_worktree.branch_delete(branch_name)
155
+ }
156
+ }
157
+
64
158
  const release_worktree = {
65
159
  directory_for,
160
+ clear_leftover,
66
161
  create,
67
162
  remove,
68
163
  RELEASE_WORKTREE_NAME,
@@ -21,8 +21,9 @@ interface ReviewCheckout {
21
21
  head: string
22
22
  }
23
23
 
24
- // Three git spawns that do not depend on each other, so they are issued together rather than one
25
- // after another: the brief is printed in front of a review the run is waiting on.
24
+ // Three git reads that do not depend on each other, so they are issued together rather than one after
25
+ // another: the brief is printed in front of a review the run is waiting on. The root is a near-instant
26
+ // synchronous `rev-parse` (joshuafolkken/kit#3065); the branch and head still overlap.
26
27
  async function read_checkout(): Promise<ReviewCheckout> {
27
28
  const [root, branch, head] = await Promise.all([
28
29
  git_command.repository_root(),
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
3
+ import { cli_flags } from '#scripts/lib/cli-flags'
4
4
  import { observation_ledger_home } from '#scripts/observations/observation-ledger-home'
5
5
  import { review_finding_ledger, type Finding } from './review-finding-ledger'
6
6
  import { review_record, type RecordVerdict } from './review-record'
@@ -71,21 +71,14 @@ function parse_findings(specs: ReadonlyArray<string>): ReadonlyArray<Finding> |
71
71
  }
72
72
 
73
73
  function parse_argv(argv: ReadonlyArray<string>): Parsed | undefined {
74
- try {
75
- const parsed = parseArgs({
76
- args: [...argv],
77
- options: OPTIONS,
78
- strict: true,
79
- allowPositionals: true,
80
- })
81
-
82
- return {
83
- check: parsed.values.check === true,
84
- issue: parsed.values.issue,
85
- positionals: parsed.positionals,
86
- }
87
- } catch {
88
- return undefined
74
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
75
+
76
+ if (parsed === undefined) return undefined
77
+
78
+ return {
79
+ check: parsed.values.check === true,
80
+ issue: parsed.values.issue,
81
+ positionals: parsed.positionals,
89
82
  }
90
83
  }
91
84
 
@@ -185,10 +185,11 @@ function decide(call: GuardedCall, run: GuardRun, can_record: boolean): boolean
185
185
  // is the half that makes the rule believable — the promise was kept and the interval was not.
186
186
  const EARLY_HEARTBEAT_REASON =
187
187
  '⛔ early heartbeat: this call arms a wait timer of its own, and the progress clock is not yours ' +
188
- 'to keep. `pnpm josh run:progress --wait` waits the interval out for you ' +
188
+ 'to keep. `pnpm josh run:progress --wait` keeps it for you ' +
189
189
  '(`JOSH_PROGRESS_INTERVAL_MINUTES`, or `josh.progress_interval_minutes` in `package.json`, ' +
190
- 'default 20 minutes), prints the five labelled lines and exits — start it in the background, relay ' +
191
- 'the lines it printed when it exits, and start the next one. Two timers armed at ' +
190
+ 'default 20 minutes): start it once in the background and it reports the five labelled lines to ' +
191
+ "the run's event stream on its own, without waking you — relay nothing, and start it again only " +
192
+ 'after it exits, which it does when newly runnable work arrives. Two timers armed at ' +
192
193
  'once is what produced reports 3–5 minutes apart on a 15-minute setting ' +
193
194
  '(joshuafolkken/kit#1570): a new one was armed on the turn a timer fired and again on the turn a ' +
194
195
  "child's completion woke the run, and `--mark` recorded each report without anything refusing the " +
@@ -62,7 +62,15 @@ const NOTIFY_INVOCATION = /josh\s+(?:notify|nf)\b/u
62
62
  // notify appears and a `--task-type confirmation` appears. That is looser than `is_confirmation_notify`
63
63
  // on purpose — the guard's safe direction is to read a real stop notification as present, so a run
64
64
  // that sent one is never told it did not.
65
+ //
66
+ // **A command that sent the Telegram itself prints this line instead** (joshuafolkken/kit#3099):
67
+ // `run:entry` notifies from inside its own stop, so no `josh notify` invocation reaches the transcript,
68
+ // and the line it prints is what the tail carries. One constant, printed there and matched here.
69
+ const COMMAND_NOTIFY_MARKER = 'stop notified: a confirmation Telegram was sent by this command'
70
+
65
71
  function mentions_confirmation_notify(text: string): boolean {
72
+ if (text.includes(COMMAND_NOTIFY_MARKER)) return true
73
+
66
74
  return NOTIFY_INVOCATION.test(text) && CONFIRMATION_TASK_TYPE.test(text)
67
75
  }
68
76
 
@@ -117,6 +125,7 @@ const LANE_PARK_REASON =
117
125
  'before it stops", and the park itself is `backlogrun-park.md` → "park and continue".'
118
126
 
119
127
  const lane_park = {
128
+ COMMAND_NOTIFY_MARKER,
120
129
  LANE_PARK_REASON,
121
130
  is_confirmation_notify,
122
131
  is_parking_child,
@@ -162,15 +162,18 @@ const HEADLESS_WAIT_MARKER = '⛔ headless parent:'
162
162
  const HEADLESS_REFUSAL_CAP = 3
163
163
 
164
164
  // The foreground waits as they appear in a transcript's tool-call input — the JSON `command` field, so
165
- // the refusal's own prose, which names the same commands, never matches.
166
- const HEADLESS_WAIT_CALLS = ['"command":"pnpm josh lane:await', '"command":"pnpm josh run:progress']
165
+ // the refusal's own prose, which names the same commands, never matches. `run:progress --wait` is not
166
+ // one (joshuafolkken/kit#3102): it streams its reports and runs until work arrives, so it is started in
167
+ // the background, where it lives as long as the turn the foreground wait keeps open.
168
+ const HEADLESS_WAIT_CALLS = ['"command":"pnpm josh lane:await']
167
169
 
168
170
  const HEADLESS_WAIT_BODY =
169
171
  ' this is a `claude -p` `backlogrun` parent (`JOSH_RUN_HEADLESS`) with lanes still ' +
170
172
  'in flight, and ending this turn ends the process — its background waits are killed with it and the ' +
171
173
  'lanes are left without a parent. Wait in the foreground instead: run `pnpm josh lane:await <N...>` ' +
172
- 'for the in-flight lanes (and `pnpm josh run:progress --wait` for the heartbeat) as foreground ' +
173
- 'commands within the tool timeout, act on what they report, and continue the loop. End the turn only ' +
174
+ 'for the in-flight lanes as a foreground command within the tool timeout (with ' +
175
+ '`pnpm josh run:progress --wait` in the background for the heartbeat, which streams its reports ' +
176
+ 'itself and lives while this turn does), act on what it reports, and continue the loop. End the turn only ' +
174
177
  'after `pnpm josh run:carry --cut` or `--end` (`backlogrun-progress.md` → "The parent keeps no clock ' +
175
178
  'of its own").'
176
179
 
@@ -76,17 +76,13 @@ type Request =
76
76
  const READ_REQUEST: Request = { kind: 'read' }
77
77
 
78
78
  function read_arguments(argv: ReadonlyArray<string>): ParsedValues | undefined {
79
- return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, strict: true })?.values
80
- }
81
-
82
- function text_of(value: OptionValue): string | undefined {
83
- return typeof value === 'string' ? value : undefined
79
+ return cli_flags.values_of(argv, OPTIONS)
84
80
  }
85
81
 
86
82
  // Absent is zero; present but not a count is `undefined`, which invalidates the whole invocation
87
83
  // rather than quietly counting nothing.
88
84
  function to_count(value: OptionValue): number | undefined {
89
- const text = text_of(value)
85
+ const text = cli_flags.string_of(value)
90
86
 
91
87
  if (text === undefined) return NO_INCREMENT
92
88
 
@@ -99,7 +95,7 @@ function to_count(value: OptionValue): number | undefined {
99
95
  // than a zero: there is no issue to record, which is a different fact from recording issue zero. A
100
96
  // present-but-malformed value invalidates the whole invocation, exactly as a bad `--merged` does.
101
97
  function to_done(value: OptionValue): Pick<CarryChange, 'done'> | undefined {
102
- const text = text_of(value)
98
+ const text = cli_flags.string_of(value)
103
99
 
104
100
  if (text === undefined) return {}
105
101
 
@@ -165,7 +161,7 @@ function group_count(values: ParsedValues): number {
165
161
  // the record simply declares no owner and every standing record reads as not provably live, which
166
162
  // refuses rather than resumes.
167
163
  function to_owner(value: OptionValue): CarryOwner | undefined {
168
- const text = text_of(value)
164
+ const text = cli_flags.string_of(value)
169
165
 
170
166
  if (text === undefined) return run_carry.NO_OWNER
171
167
 
@@ -179,7 +175,7 @@ function to_owner(value: OptionValue): CarryOwner | undefined {
179
175
  // A non-empty `--summary`, or nothing. An empty string is nothing here, so it reads as absent and the
180
176
  // pairing check below refuses it against `--retrospective`.
181
177
  function summary_text(values: ParsedValues): string | undefined {
182
- const summary = text_of(values.summary)
178
+ const summary = cli_flags.string_of(values.summary)
183
179
 
184
180
  return summary === undefined || summary === '' ? undefined : summary
185
181
  }
@@ -220,17 +216,17 @@ function to_claim_request(
220
216
  }
221
217
 
222
218
  function to_other_request(values: ParsedValues, owner: CarryOwner): Request | undefined {
223
- if (values.end === true) return { kind: 'end', stopped: text_of(values.stopped) }
219
+ if (values.end === true) return { kind: 'end', stopped: cli_flags.string_of(values.stopped) }
224
220
 
225
221
  return has_count(values) ? to_count_request(values, owner) : READ_REQUEST
226
222
  }
227
223
 
228
224
  function to_named_request(values: ParsedValues, owner: CarryOwner): Request | undefined {
229
- const begun = text_of(values.begin)
225
+ const begun = cli_flags.string_of(values.begin)
230
226
 
231
227
  if (begun !== undefined) return to_claim_request(begun, owner, false)
232
228
 
233
- const adopted = text_of(values.resume)
229
+ const adopted = cli_flags.string_of(values.resume)
234
230
 
235
231
  if (adopted !== undefined) return to_claim_request(adopted, owner, true)
236
232
 
@@ -47,12 +47,7 @@ type Request =
47
47
  | { kind: 'end' }
48
48
 
49
49
  function read_arguments(argv: ReadonlyArray<string>): ParsedValues | undefined {
50
- return cli_flags.parse_or_undefined({
51
- args: [...argv],
52
- options: OPTIONS,
53
- strict: true,
54
- allowPositionals: true,
55
- })
50
+ return cli_flags.arguments_of(argv, OPTIONS)
56
51
  }
57
52
 
58
53
  // The issue is the sole positional, and it must be a real issue number: a cut relaunches
@@ -56,10 +56,6 @@ interface ParsedArguments {
56
56
  values: ParsedValues
57
57
  }
58
58
 
59
- function read_arguments(argv: ReadonlyArray<string>): ParsedArguments | undefined {
60
- return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, allowPositionals: true })
61
- }
62
-
63
59
  function is_valid(parsed: ParsedArguments): boolean {
64
60
  return (
65
61
  parsed.positionals.length === 1 &&
@@ -78,7 +74,7 @@ function to_request(parsed: ParsedArguments): EndingRequest {
78
74
  }
79
75
 
80
76
  function parse_request(argv: ReadonlyArray<string>): EndingRequest | undefined {
81
- const parsed = read_arguments(argv)
77
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
82
78
 
83
79
  if (parsed === undefined || !is_valid(parsed)) return undefined
84
80
 
@@ -4,6 +4,7 @@ import { josh_command } from '#scripts/josh/josh-run'
4
4
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
5
5
  import { run_cut_report } from './run-cut-report'
6
6
  import { run_entry, type EntryParts } from './run-entry'
7
+ import { run_entry_stop } from './run-entry-stop'
7
8
  import { run_halfrun_resume } from './run-halfrun-resume'
8
9
  import { run_hold_cli } from './run-hold-cli'
9
10
  import { run_next } from './run-next'
@@ -40,6 +41,7 @@ const NEWLINE = '\n'
40
41
  const HOLD_STOP_NOTE =
41
42
  '(tree not held — see the reason above; clean up and retry, or release with `pnpm josh run:release`)'
42
43
  const COST_STOP_NOTE = '(session budget spent — see the figure above; resume in a fresh session)'
44
+ const { BUDGET_REASON, HOLD_REASON } = run_entry_stop
43
45
  const HALFRUN_RESUME_TOKEN = 'halfrun'
44
46
 
45
47
  // Exactly one issue number, or the call is refused — `run:entry` opens one run, and a second number
@@ -135,6 +137,23 @@ function stop_report(issue_number: string, hold: string, cost: string, report: s
135
137
  return emit({ issue_number, hold, cost, verdict: run_entry.NO_VERDICT, report })
136
138
  }
137
139
 
140
+ // A stop this command decided carries its own chores (joshuafolkken/kit#3099): the `confirmation`
141
+ // Telegram always, and the release where this call claimed the hold — `run_entry_stop` says why.
142
+ interface StopCall {
143
+ parts: Pick<EntryParts, 'issue_number' | 'hold' | 'cost' | 'report'>
144
+ command: StageCommand
145
+ reason: string
146
+ should_release: boolean
147
+ }
148
+
149
+ async function stop_run({ parts, command, reason, should_release }: StopCall): Promise<number> {
150
+ const code = stop_report(parts.issue_number, parts.hold, parts.cost, parts.report)
151
+
152
+ await run_entry_stop.stop({ issue_number: parts.issue_number, command, reason, should_release })
153
+
154
+ return code
155
+ }
156
+
138
157
  interface Resume {
139
158
  token: string
140
159
  code: number
@@ -166,16 +185,22 @@ function resume_report(issue_number: string, resume: Resume): number {
166
185
  // to and including implementation. **A `prrun` stopped before its merge is resumed the same way**
167
186
  // (joshuafolkken/kit#3023), under the token that says what is left of it.
168
187
  async function resume_stopped(
169
- issue_number: string,
188
+ { issue_number, command }: EntryRequest,
170
189
  token: string,
171
190
  stopped_run: { adopt: (issue: string) => Promise<boolean> },
172
191
  ): Promise<number> {
173
192
  const cost = await check_cost()
174
193
 
175
- if (cost === run_entry.COST_OVER) return stop_report(issue_number, token, cost, COST_STOP_NOTE)
194
+ if (cost === run_entry.COST_OVER) {
195
+ const parts = { issue_number, hold: token, cost, report: COST_STOP_NOTE }
196
+
197
+ return await stop_run({ parts, command, reason: BUDGET_REASON, should_release: false })
198
+ }
176
199
 
177
200
  if (!(await stopped_run.adopt(issue_number))) {
178
- return stop_report(issue_number, run_hold_cli.BUSY_VERDICT, cost, HOLD_STOP_NOTE)
201
+ const parts = { issue_number, hold: run_hold_cli.BUSY_VERDICT, cost, report: HOLD_STOP_NOTE }
202
+
203
+ return await stop_run({ parts, command, reason: HOLD_REASON, should_release: false })
179
204
  }
180
205
 
181
206
  console.info(`entry #${issue_number} — resume: ${token}`)
@@ -186,17 +211,17 @@ async function resume_stopped(
186
211
  // The stopped run a decision picks up: a `halfrun` stop resumes at the gate, a `prrun` stop at
187
212
  // `followup` under its own token. `undefined` for any other start, so the ordinary claim decides.
188
213
  async function resume_any(
189
- issue_number: string,
214
+ request: EntryRequest,
190
215
  decision: StageDecision,
191
216
  stage: StageRead,
192
217
  ): Promise<number | undefined> {
193
218
  if (decision.state === run_stage.HALFRUN_STOPPED) {
194
- return await resume_stopped(issue_number, HALFRUN_RESUME_TOKEN, run_halfrun_resume)
219
+ return await resume_stopped(request, HALFRUN_RESUME_TOKEN, run_halfrun_resume)
195
220
  }
196
221
 
197
222
  return stage.prrun_token === undefined
198
223
  ? undefined
199
- : await resume_stopped(issue_number, stage.prrun_token, run_prrun_resume)
224
+ : await resume_stopped(request, stage.prrun_token, run_prrun_resume)
200
225
  }
201
226
 
202
227
  // **A command whose stopping point the issue has already reached redoes nothing** (joshuafolkken/kit#3042)
@@ -210,12 +235,18 @@ async function claim_run({ issue_number, command }: EntryRequest): Promise<numbe
210
235
  const hold = await claim_hold(issue_number, command)
211
236
 
212
237
  if (hold !== run_hold_cli.HOLD_VERDICT) {
213
- return stop_report(issue_number, hold, run_entry.COST_SKIPPED, HOLD_STOP_NOTE)
238
+ const parts = { issue_number, hold, cost: run_entry.COST_SKIPPED, report: HOLD_STOP_NOTE }
239
+
240
+ return await stop_run({ parts, command, reason: HOLD_REASON, should_release: false })
214
241
  }
215
242
 
216
243
  const cost = await check_cost()
217
244
 
218
- if (cost === run_entry.COST_OVER) return stop_report(issue_number, hold, cost, COST_STOP_NOTE)
245
+ if (cost === run_entry.COST_OVER) {
246
+ const parts = { issue_number, hold, cost, report: COST_STOP_NOTE }
247
+
248
+ return await stop_run({ parts, command, reason: BUDGET_REASON, should_release: true })
249
+ }
219
250
 
220
251
  const reads = await gather_reads(issue_number)
221
252
 
@@ -248,7 +279,7 @@ async function open_run(request: EntryRequest): Promise<number> {
248
279
 
249
280
  if (command === run_stage.KICKOFF || is_settled(decision)) return SUCCESS_EXIT_CODE
250
281
 
251
- return (await resume_any(issue_number, decision, stage)) ?? (await claim_run(request))
282
+ return (await resume_any(request, decision, stage)) ?? (await claim_run(request))
252
283
  }
253
284
 
254
285
  async function run(argv: ReadonlyArray<string>): Promise<number> {
@@ -0,0 +1,104 @@
1
+ import { josh_command } from '#scripts/josh/josh-run'
2
+ import { telegram_notify } from '#scripts/notify/telegram-notify'
3
+ import { lane_park } from '#scripts/rules/lane-park'
4
+
5
+ // What `run:entry` does on the way out of a stop it decided itself (joshuafolkken/kit#3099).
6
+ //
7
+ // **The stop's chores used to be the agent's to remember, and the `Stop` hook was what reminded it.**
8
+ // `fullrun.md` step 3 asked the agent, on an `over` budget, to send the `confirmation` Telegram and then
9
+ // run `pnpm josh run:release` — two calls after a command had already decided the run was over. Across
10
+ // eleven measured sessions the hook sent a reply back five times for exactly those two calls, each one a
11
+ // further turn re-reading the whole context. The command that decides the stop knows everything the
12
+ // chores need, so it does them, and the hook stays a safety net that a normal run never trips.
13
+ //
14
+ // **The release is the claim's own undo, and only where this call made the claim.** A resume over a
15
+ // stopped `halfrun` / `prrun` stops on the budget before adopting anything, and the hold there is the
16
+ // stopped run's — kept on purpose over its verified diff — so that stop notifies and releases nothing.
17
+ // A `busy` / `unknown` hold is another run's, or none at all, so it is never released here either.
18
+ //
19
+ // **A dispatched lane child sends no Telegram from here.** Its stop must park the Issue before it
20
+ // notifies, and `lane_park`'s guard delivers that rule at the `josh notify` call — a Telegram sent from
21
+ // inside this command would pass that guard by and satisfy the `Stop` hook with no park behind it.
22
+
23
+ interface StopNotice {
24
+ issue_number: string
25
+ command: string
26
+ reason: string
27
+ should_release: boolean
28
+ }
29
+
30
+ interface StopPorts {
31
+ notify: (input: { issue_title: string; body: string; recovery: string }) => Promise<boolean>
32
+ release: (issue_number: string) => Promise<boolean>
33
+ is_parking_child: () => boolean
34
+ }
35
+
36
+ const RECOVERY = 'If this did not arrive, the run:entry output in the session names the stop.'
37
+ const NOTIFY_FAILED_NOTE =
38
+ '(the confirmation Telegram could not be sent — send it with `pnpm josh notify --task-type confirmation` before stopping)'
39
+ const RELEASED_NOTE = '(hold released by run:entry — the tree is free for the resume)'
40
+ const RELEASE_FAILED_NOTE =
41
+ '(the hold could not be released — run `pnpm josh run:release` before stopping)'
42
+ const should_forward_stderr = true
43
+ const SUCCESS_EXIT_CODE = 0
44
+
45
+ const BUDGET_REASON = 'the session budget is spent'
46
+ const HOLD_REASON = 'the working tree is not held for this run — see the run:entry output'
47
+
48
+ async function release(issue_number: string): Promise<boolean> {
49
+ const released = await josh_command.josh_run(['run:release', issue_number], should_forward_stderr)
50
+
51
+ return released.code === SUCCESS_EXIT_CODE
52
+ }
53
+
54
+ function is_parking_child(): boolean {
55
+ return lane_park.is_parking_child()
56
+ }
57
+
58
+ const DEFAULT_PORTS: StopPorts = { notify: telegram_notify.confirm, release, is_parking_child }
59
+
60
+ function body_of(notice: StopNotice): string {
61
+ return `${notice.command} #${notice.issue_number} stopped at entry: ${notice.reason}.\nResume with \`${notice.command} #${notice.issue_number}\`.`
62
+ }
63
+
64
+ // The release runs first, so the resume the Telegram names finds the tree free when a person acts on it.
65
+ // The marker line is printed only for a Telegram that went out: the `Stop` hook reads it as the notify,
66
+ // and a failed send has to leave the hook still asking for one. The released note, likewise, is printed
67
+ // only for a release that succeeded, so a failed one leaves the agent still asked to release.
68
+ async function release_claim(notice: StopNotice, ports: StopPorts): Promise<void> {
69
+ if (!notice.should_release) return
70
+
71
+ const is_released = await ports.release(notice.issue_number)
72
+
73
+ console.info(is_released ? RELEASED_NOTE : RELEASE_FAILED_NOTE)
74
+ }
75
+
76
+ async function notify_stop(notice: StopNotice, ports: StopPorts): Promise<void> {
77
+ if (ports.is_parking_child()) return
78
+
79
+ const is_sent = await ports.notify({
80
+ issue_title: `${notice.command} #${notice.issue_number} stopped`,
81
+ body: body_of(notice),
82
+ recovery: RECOVERY,
83
+ })
84
+
85
+ console.info(is_sent ? lane_park.COMMAND_NOTIFY_MARKER : NOTIFY_FAILED_NOTE)
86
+ }
87
+
88
+ async function stop(notice: StopNotice, ports: StopPorts = DEFAULT_PORTS): Promise<void> {
89
+ await release_claim(notice, ports)
90
+ await notify_stop(notice, ports)
91
+ }
92
+
93
+ const run_entry_stop = {
94
+ BUDGET_REASON,
95
+ HOLD_REASON,
96
+ NOTIFY_FAILED_NOTE,
97
+ RELEASED_NOTE,
98
+ RELEASE_FAILED_NOTE,
99
+ body_of,
100
+ stop,
101
+ }
102
+
103
+ export { run_entry_stop }
104
+ export type { StopNotice, StopPorts }
@@ -1,3 +1,4 @@
1
+ import { error_text } from '#scripts/lib/error-message'
1
2
  import { run_carry } from './run-carry'
2
3
  import { run_event_scope } from './run-event-scope'
3
4
  import { run_event_stream, type RunEvent } from './run-event-stream'
@@ -62,8 +63,10 @@ async function emit_once(kind: string, text: string): Promise<boolean> {
62
63
  if (target === undefined || run_event_stream.read_last(target)?.kind === kind) return false
63
64
 
64
65
  return run_event_stream.append(target, kind, text, now_iso()).appended
65
- } catch {
66
+ } catch (error) {
66
67
  // Best-effort: a failed append is dropped rather than raised into the loop's work.
68
+ error_text.trace_swallowed('run_event_stream_emit.emit_once', error)
69
+
67
70
  return false
68
71
  }
69
72
  }
@@ -98,8 +101,10 @@ async function emit_once_since(kind: string, text: string, reset_kind: string):
98
101
  }
99
102
 
100
103
  return run_event_stream.append(target, kind, text, now_iso()).appended
101
- } catch {
104
+ } catch (error) {
102
105
  // Best-effort: a failed append is dropped rather than raised into the caller's work.
106
+ error_text.trace_swallowed('run_event_stream_emit.emit_once_since', error)
107
+
103
108
  return false
104
109
  }
105
110
  }
@@ -1,5 +1,6 @@
1
1
  import { git_command } from '#scripts/git/git-command'
2
2
  import { stamp_file } from '#scripts/josh/stamp-file'
3
+ import { error_text } from '#scripts/lib/error-message'
3
4
  import { z } from 'zod'
4
5
 
5
6
  // joshuafolkken/kit#1091: `epicrun` asks `epic-busy.ts` whether a *repository* already has a child in
@@ -196,7 +197,9 @@ async function is_tree_dirty(directory?: string): Promise<boolean> {
196
197
  const status = await git_command.status(directory)
197
198
 
198
199
  return status.trim() !== ''
199
- } catch {
200
+ } catch (error) {
201
+ error_text.trace_swallowed('run_hold.is_tree_dirty', error)
202
+
200
203
  return true
201
204
  }
202
205
  }
@@ -89,10 +89,6 @@ interface ParsedArguments {
89
89
  values: ParsedValues
90
90
  }
91
91
 
92
- function read_arguments(argv: ReadonlyArray<string>): ParsedArguments | undefined {
93
- return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, allowPositionals: true })
94
- }
95
-
96
92
  function is_valid_target(parsed: ParsedArguments): boolean {
97
93
  return (
98
94
  parsed.positionals.length === 1 &&
@@ -136,7 +132,7 @@ function to_request(parsed: ParsedArguments): LivenessRequest {
136
132
  }
137
133
 
138
134
  function parse_request(argv: ReadonlyArray<string>): LivenessRequest | undefined {
139
- const parsed = read_arguments(argv)
135
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
140
136
 
141
137
  if (parsed === undefined || !is_valid(parsed)) return undefined
142
138