@joshuafolkken/kit 1.1471.0 → 1.1514.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 (267) hide show
  1. package/.claude/settings.json +6 -6
  2. package/.claude/skills/epic-commands/SKILL.md +2 -14
  3. package/.claude/skills/epic-commands/epic-bundle.md +9 -5
  4. package/.claude/skills/epic-commands/execution-waves.md +1 -2
  5. package/.claude/skills/verify-ui/SKILL.md +1 -2
  6. package/.claude/skills/workflow-commands/SKILL.md +7 -9
  7. package/.claude/skills/workflow-commands/background-commands.md +3 -7
  8. package/.claude/skills/workflow-commands/backlogrun-child.md +3 -10
  9. package/.claude/skills/workflow-commands/backlogrun-lanes.md +6 -15
  10. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -8
  11. package/.claude/skills/workflow-commands/backlogrun-progress.md +10 -19
  12. package/.claude/skills/workflow-commands/backlogrun-recovery.md +8 -8
  13. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -10
  14. package/.claude/skills/workflow-commands/backlogrun.md +31 -50
  15. package/.claude/skills/workflow-commands/chain-rule.md +7 -6
  16. package/.claude/skills/workflow-commands/delegation.md +12 -8
  17. package/.claude/skills/workflow-commands/entry-sequence.md +1 -3
  18. package/.claude/skills/workflow-commands/followup.md +3 -6
  19. package/.claude/skills/workflow-commands/fullrun-steps.md +3 -5
  20. package/.claude/skills/workflow-commands/fullrun.md +4 -6
  21. package/.claude/skills/workflow-commands/halfrun.md +3 -7
  22. package/.claude/skills/workflow-commands/into-target.md +1 -2
  23. package/.claude/skills/workflow-commands/issue-comments.md +3 -3
  24. package/.claude/skills/workflow-commands/issue-scout.md +3 -2
  25. package/.claude/skills/workflow-commands/latest-gate.md +2 -6
  26. package/.claude/skills/workflow-commands/needs-human-review.md +2 -5
  27. package/.claude/skills/workflow-commands/observation-filing.md +0 -14
  28. package/.claude/skills/workflow-commands/observation-ledger.md +4 -9
  29. package/.claude/skills/workflow-commands/pre-gate-cut.md +11 -16
  30. package/.claude/skills/workflow-commands/prerequisite.md +1 -2
  31. package/.claude/skills/workflow-commands/progress-watcher.md +2 -7
  32. package/.claude/skills/workflow-commands/prrun.md +3 -5
  33. package/.claude/skills/workflow-commands/retrospective.md +4 -4
  34. package/.claude/skills/workflow-commands/split-assessment.md +1 -3
  35. package/.claude/skills/workflow-commands/target-repository.md +1 -2
  36. package/.claude/skills/workflow-commands/working-tree-hold.md +2 -4
  37. package/.codex/hooks.json +6 -6
  38. package/.github/actions/setup-node/action.yml +1 -1
  39. package/.github/workflows/ci.yml +1 -1
  40. package/.github/workflows/publish.yml +2 -2
  41. package/CLAUDE.md +1 -1
  42. package/cspell/index.yaml +7 -0
  43. package/dist/CLAUDE.md +1 -1
  44. package/dist/commands/document-section-cli.js +67 -34
  45. package/dist/hooks/batch-guard.js +6 -6
  46. package/dist/hooks/chunk-4KKD2UTK.js +66 -0
  47. package/dist/hooks/{chunk-J47HU5H5.js → chunk-6TS2JD6D.js} +3 -3
  48. package/dist/hooks/chunk-7DSBJVFH.js +50 -0
  49. package/dist/hooks/{chunk-YHBZAJSV.js → chunk-A65Y4YOR.js} +3 -3
  50. package/dist/hooks/{chunk-E7OIKX5Z.js → chunk-APUO6U4J.js} +85 -7
  51. package/dist/hooks/{chunk-IX6TTTI6.js → chunk-EFXE6C37.js} +8 -22
  52. package/dist/hooks/chunk-GP4IEC4O.js +20 -0
  53. package/dist/hooks/{chunk-SHGPANFB.js → chunk-JP45SOCJ.js} +3 -3
  54. package/dist/hooks/{chunk-K4EOONYM.js → chunk-LAOV7NLM.js} +1 -1
  55. package/dist/hooks/chunk-M4HGZV7U.js +83 -0
  56. package/dist/hooks/{chunk-KC5AK2GF.js → chunk-O6GDRNJB.js} +3 -3
  57. package/dist/hooks/{chunk-YYPI7SJZ.js → chunk-PF2NZL2A.js} +5 -5
  58. package/dist/hooks/{chunk-H5HVWYXK.js → chunk-PTICX4OL.js} +3 -4
  59. package/dist/hooks/{chunk-IIGFYXKC.js → chunk-RT27QRJW.js} +581 -1438
  60. package/dist/hooks/{chunk-43ZHT22P.js → chunk-SYOTDYCI.js} +1127 -1089
  61. package/dist/hooks/{chunk-AS74OU3D.js → chunk-TNUMY6NL.js} +1507 -1336
  62. package/dist/hooks/chunk-UPUZF3AN.js +279 -0
  63. package/dist/hooks/{chunk-EUMACUK2.js → chunk-WK6SMBIC.js} +1948 -806
  64. package/dist/hooks/codex-hook-adapter.js +17 -14
  65. package/dist/hooks/duplicate-read-guard.js +7 -7
  66. package/dist/hooks/format-edited.js +5 -4
  67. package/dist/hooks/inputs.json +317 -264
  68. package/dist/hooks/investigation-guard.js +8 -11
  69. package/dist/hooks/pretool-guard.js +16 -13
  70. package/dist/hooks/session-lang.js +370 -25
  71. package/dist/hooks/stop-guard.js +2576 -176
  72. package/dist/josh.js +2124 -277
  73. package/lefthook/base.yml +5 -3
  74. package/package.json +12 -10
  75. package/prompts/collaboration-workflow/file-edits.md +1 -3
  76. package/prompts/collaboration-workflow/gh-rest.md +1 -2
  77. package/prompts/collaboration-workflow/issue-citation.md +2 -4
  78. package/prompts/collaboration-workflow/issue-template.md +10 -2
  79. package/prompts/collaboration-workflow/lane-instructions.md +35 -0
  80. package/prompts/collaboration-workflow/operating-rules.md +2 -6
  81. package/prompts/collaboration-workflow/output-bounds.md +3 -3
  82. package/prompts/collaboration-workflow/overview.md +1 -1
  83. package/prompts/collaboration-workflow/principles.md +14 -8
  84. package/prompts/collaboration-workflow/report-format.md +9 -0
  85. package/prompts/collaboration-workflow/residency.md +12 -8
  86. package/prompts/collaboration-workflow/rule-delivery.md +3 -11
  87. package/prompts/collaboration-workflow/shell-body.md +6 -6
  88. package/prompts/collaboration-workflow/turn-batching.md +5 -5
  89. package/prompts/collaboration-workflow/upstream-interrupt.md +2 -2
  90. package/prompts/collaboration-workflow/wip-cap.md +6 -6
  91. package/prompts/collaboration-workflow.md +1 -0
  92. package/prompts/review.md +4 -5
  93. package/prompts/testing-guide.md +2 -2
  94. package/scripts/agent/agent-argv.ts +20 -4
  95. package/scripts/agent/agent-role-profile.ts +101 -45
  96. package/scripts/agent/agent-safe-text.ts +60 -0
  97. package/scripts/agent/claude-agent-argv.ts +14 -0
  98. package/scripts/agent/codex-agent-argv.ts +2 -0
  99. package/scripts/agent/lane-instructions.ts +144 -0
  100. package/scripts/backlog/backlog-budget.ts +4 -4
  101. package/scripts/backlog/backlog-drive-named.ts +1 -1
  102. package/scripts/backlog/backlog-drive-restore.ts +9 -1
  103. package/scripts/backlog/backlog-occupied.ts +56 -0
  104. package/scripts/backlog/backlog-ready.ts +47 -10
  105. package/scripts/delegation/duplicate-read-guard.ts +2 -2
  106. package/scripts/delegation/investigation-guard.ts +15 -65
  107. package/scripts/delegation/investigation-reads.ts +0 -1
  108. package/scripts/document/document-byte-budget.ts +6 -5
  109. package/scripts/document/document-section-cli.ts +17 -5
  110. package/scripts/document/entry-read-budget.ts +2 -2
  111. package/scripts/document/read-set-cli.ts +4 -1
  112. package/scripts/epic/epic-audit-orphans.ts +5 -8
  113. package/scripts/epic/epic-audit.ts +52 -1
  114. package/scripts/epic/epic-bundle-evidence.ts +49 -26
  115. package/scripts/epic/epic-bundle-referenced.ts +14 -3
  116. package/scripts/epic/epic-bundle.ts +17 -9
  117. package/scripts/followup/git-followup-workflow.ts +19 -1
  118. package/scripts/gate/check-inputs.ts +129 -0
  119. package/scripts/gate/check-reuse.ts +162 -0
  120. package/scripts/gate/core-budget.ts +93 -44
  121. package/scripts/gate/core-wait.ts +130 -0
  122. package/scripts/gate/free-cores.ts +49 -0
  123. package/scripts/gate/gate-ledger.ts +17 -3
  124. package/scripts/gate/gate-plan.ts +23 -1
  125. package/scripts/gate/gate-report.ts +15 -2
  126. package/scripts/gate/gate-run-steps.ts +150 -0
  127. package/scripts/gate/held-mark.ts +55 -0
  128. package/scripts/gate/machine-capacity.ts +70 -11
  129. package/scripts/gate/pre-commit-type-check.ts +14 -1
  130. package/scripts/gate/verification-gate.ts +58 -125
  131. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  132. package/scripts/gh/git-gh-issue-write.ts +24 -2
  133. package/scripts/gh/git-pr.ts +13 -1
  134. package/scripts/git/main-merge-regenerate.ts +78 -0
  135. package/scripts/git/main-merge.ts +14 -5
  136. package/scripts/hooks/batch-guard.ts +5 -8
  137. package/scripts/hooks/pre-commit-lint.ts +91 -0
  138. package/scripts/hooks/pre-push-unit.ts +1 -1
  139. package/scripts/hooks/pretool-guard.ts +27 -52
  140. package/scripts/hooks/reserved-run.ts +1 -0
  141. package/scripts/hooks/run-hook.sh +10 -12
  142. package/scripts/hooks/stop-actions.ts +204 -0
  143. package/scripts/hooks/stop-guard.ts +62 -21
  144. package/scripts/init/hook-launch.ts +12 -7
  145. package/scripts/issue/expected-effect-lint.ts +59 -0
  146. package/scripts/issue/issue-cite-cli.ts +35 -15
  147. package/scripts/issue/issue-classification.ts +1 -1
  148. package/scripts/issue/issue-lint-cli.ts +5 -3
  149. package/scripts/issue/issue-scout-cli.ts +19 -14
  150. package/scripts/issue/markdown-section.ts +19 -5
  151. package/scripts/josh/command-usage.ts +110 -0
  152. package/scripts/josh/josh-command-map.ts +2 -0
  153. package/scripts/josh/josh-command-types.ts +55 -9
  154. package/scripts/josh/josh-commands-ai.ts +13 -0
  155. package/scripts/josh/josh-commands-development.ts +1 -1
  156. package/scripts/josh/josh-commands-hooks.ts +6 -0
  157. package/scripts/josh/josh-commands-lane.ts +10 -1
  158. package/scripts/josh/josh-commands-workflow.ts +5 -0
  159. package/scripts/josh/josh-logic.ts +6 -15
  160. package/scripts/josh/josh-usage-automation.ts +121 -0
  161. package/scripts/josh/josh-usage-backlog.ts +180 -0
  162. package/scripts/josh/josh-usage-developer.ts +230 -0
  163. package/scripts/josh/josh-usage-hooks.ts +38 -0
  164. package/scripts/josh/josh-usage-issue.ts +205 -0
  165. package/scripts/josh/josh-usage-review.ts +101 -0
  166. package/scripts/josh/josh-usage-run.ts +252 -0
  167. package/scripts/josh/josh-usage-session.ts +238 -0
  168. package/scripts/josh/josh-usage-workflow.ts +212 -0
  169. package/scripts/josh/josh.ts +14 -5
  170. package/scripts/josh/package-directory.ts +20 -0
  171. package/scripts/lane/lane-capacity.ts +8 -2
  172. package/scripts/lane/lane-check-stats.ts +56 -0
  173. package/scripts/lane/lane-child-invocation.ts +35 -4
  174. package/scripts/lane/lane-dispatch.ts +3 -3
  175. package/scripts/lane/lane-guard-policy.ts +2 -2
  176. package/scripts/lane/lane-handoff.ts +8 -1
  177. package/scripts/lane/lane-install.ts +12 -4
  178. package/scripts/lane/lane-ledger.ts +43 -1
  179. package/scripts/lane/lane-limit-cli.ts +8 -9
  180. package/scripts/lane/lane-measure-cli.ts +2 -0
  181. package/scripts/lane/lane-preserve.ts +29 -1
  182. package/scripts/lane/lane-registry.ts +9 -7
  183. package/scripts/lane/lane-restart.ts +5 -26
  184. package/scripts/lane/lane-session-action.ts +146 -0
  185. package/scripts/lane/lane-session-broker.ts +57 -138
  186. package/scripts/lane/lane-withdraw-cli.ts +71 -0
  187. package/scripts/lane/lane-withdraw.ts +182 -0
  188. package/scripts/lint/format.ts +4 -4
  189. package/scripts/lint/lint-parallel.ts +10 -3
  190. package/scripts/lint/lint-related-scope.ts +4 -1
  191. package/scripts/notify/telegram-test.ts +10 -5
  192. package/scripts/report/report-format-reference.ts +2 -0
  193. package/scripts/review/acceptance-map-check.ts +61 -0
  194. package/scripts/review/acceptance-map.ts +205 -0
  195. package/scripts/review/review-brief-cli.ts +1 -1
  196. package/scripts/rules/agent-model.ts +59 -0
  197. package/scripts/rules/delivered-rules.ts +11 -2
  198. package/scripts/rules/filing-cap.ts +1 -0
  199. package/scripts/rules/firing-point.ts +6 -5
  200. package/scripts/rules/guard-remedy.ts +117 -0
  201. package/scripts/rules/issue-comments.ts +67 -6
  202. package/scripts/rules/last-prompt.ts +50 -5
  203. package/scripts/rules/oracle-list-cli.ts +7 -0
  204. package/scripts/rules/piped-verification.ts +3 -1
  205. package/scripts/rules/rule-list.ts +4 -1
  206. package/scripts/rules/rule-registry-rows.ts +10 -0
  207. package/scripts/rules/rule-registry.ts +7 -4
  208. package/scripts/rules/rule-rewrite.ts +114 -0
  209. package/scripts/rules/rule-value.ts +7 -1
  210. package/scripts/rules/shell-segments.ts +15 -0
  211. package/scripts/rules/stop-decision.ts +77 -0
  212. package/scripts/rules/stop-rules.ts +147 -47
  213. package/scripts/rules/tail-commands.ts +15 -1
  214. package/scripts/run/board/run-board-cli.ts +2 -0
  215. package/scripts/run/board/run-board-closed.ts +8 -2
  216. package/scripts/run/board/run-board-github.ts +9 -1
  217. package/scripts/run/board/run-board-header.ts +4 -1
  218. package/scripts/run/board/run-board-labels.ts +2 -0
  219. package/scripts/run/board/run-board-layout.ts +1 -1
  220. package/scripts/run/board/run-board-read.ts +16 -0
  221. package/scripts/run/board/run-board-reason.ts +10 -1
  222. package/scripts/run/board/run-board-render-legend.ts +1 -0
  223. package/scripts/run/board/run-board-render-slot.ts +1 -1
  224. package/scripts/run/board/run-board-render-waits.ts +41 -0
  225. package/scripts/run/board/run-board-render.ts +6 -1
  226. package/scripts/run/board/run-board-row-yield.ts +1 -0
  227. package/scripts/run/board/run-board-state.ts +5 -0
  228. package/scripts/run/board/run-board-status.ts +72 -21
  229. package/scripts/run/board/run-board-tick.ts +5 -1
  230. package/scripts/run/cut/run-cut-cli.ts +3 -2
  231. package/scripts/run/cut/run-cut-report.ts +16 -0
  232. package/scripts/run/detached-launch.ts +23 -4
  233. package/scripts/run/entry/run-entry-cli.ts +8 -1
  234. package/scripts/run/event/run-event-render.ts +1 -0
  235. package/scripts/run/event/run-event-scope.ts +19 -1
  236. package/scripts/run/event/run-event-stream-emit.ts +13 -6
  237. package/scripts/run/event/run-event-stream.ts +4 -0
  238. package/scripts/run/hold/run-hold-session.ts +46 -0
  239. package/scripts/run/hold/run-hold.ts +7 -1
  240. package/scripts/run/launch-priority.ts +32 -0
  241. package/scripts/run/merge/run-merge-cli.ts +16 -65
  242. package/scripts/run/merge/run-merge-signals.ts +99 -0
  243. package/scripts/run/merge/run-merge-steps.ts +11 -1
  244. package/scripts/run/merge/run-merge.ts +21 -9
  245. package/scripts/run/run-liveness.ts +1 -1
  246. package/scripts/run/run-poll-hook.ts +157 -0
  247. package/scripts/run/run-step-cli.ts +22 -15
  248. package/scripts/run/run-step-section.ts +85 -0
  249. package/scripts/run/ship/run-ship-cli.ts +5 -12
  250. package/scripts/run/ship/run-ship-detach.ts +9 -0
  251. package/scripts/run/ship/run-ship-next.ts +10 -2
  252. package/scripts/run/ship/run-ship-overlap.ts +195 -0
  253. package/scripts/run/ship/run-ship-pre-detach.ts +33 -15
  254. package/scripts/run/ship/run-ship-preflight.ts +28 -2
  255. package/scripts/run/ship/run-ship-return.ts +31 -7
  256. package/scripts/run/ship/run-ship-review-steps.ts +12 -10
  257. package/scripts/run/ship/run-ship-steps.ts +1 -1
  258. package/scripts/run/ship/run-ship-stop-context.ts +206 -0
  259. package/scripts/run/ship/run-ship-sync.ts +14 -6
  260. package/scripts/test/test-e2e-guard.ts +30 -5
  261. package/scripts/test/test-unit-guard.ts +16 -1
  262. package/scripts/test/unit-worker-share.ts +26 -4
  263. package/scripts/time-runtime/time-shell.ts +29 -4
  264. package/scripts/time-runtime/time-spans.ts +1 -0
  265. package/scripts/time-runtime/time-tool-call.ts +7 -0
  266. package/templates/gitignore +6 -2
  267. package/dist/hooks/chunk-K4LMBQC2.js +0 -68
@@ -3,11 +3,11 @@ import { investigation_outcome } from '#scripts/delegation/investigation-guard'
3
3
  import { issue_watch_hook } from '#scripts/issue/issue-watch-hook'
4
4
  import { hook_decision, type GuardOutcome } from '#scripts/josh/hook-decision'
5
5
  import { lane_phase } from '#scripts/lane/lane-phase'
6
- import { json_value } from '#scripts/lib/json-value'
7
6
  import { delivered_rules } from '#scripts/rules/delivered-rules'
8
7
  import { lane_background } from '#scripts/rules/lane-background'
9
- import { piped_verification } from '#scripts/rules/piped-verification'
8
+ import { rule_rewrite, type RuleRewrite } from '#scripts/rules/rule-rewrite'
10
9
  import { run_parent_cut_hook } from '#scripts/run/run-parent-cut-hook'
10
+ import { run_poll_hook } from '#scripts/run/run-poll-hook'
11
11
  import { run_watcher_hook } from '#scripts/run/run-watcher-hook'
12
12
  import type { GuardedCall } from '#scripts/time-runtime/time-batch-guard'
13
13
  import { batch_outcome } from './batch-guard'
@@ -22,11 +22,12 @@ import { step_zero_notice } from './step-zero-notice'
22
22
  //
23
23
  // **The verdict is identical to running the three in turn**, and that is the whole point. Each guard
24
24
  // self-gates on the tool name inside its own `is_candidate` / trigger, so running all three on the
25
- // union matcher (`Bash|Edit|Read|Write|AskUserQuestion`) refuses exactly what the three separate
26
- // entries did — `AskUserQuestion` is matched for the rule guard's lane-child interactive-ask row,
27
- // and the batching and investigation guards self-gate away from it. A
28
- // refusal from any guard wins first; only when none refuses is a guard's non-blocking notice or fault
29
- // (the whole-file-write notice, a lane child's investigation notice) emitted. Each guard still honours its own
25
+ // union matcher (`Bash|Edit|Read|Write|AskUserQuestion|Agent|Task`) refuses exactly what the three
26
+ // separate entries did — `AskUserQuestion` is matched for the rule guard's lane-child interactive-ask
27
+ // row and `Agent|Task` for its `agent-model` row, and the batching and investigation guards self-gate
28
+ // away from both. A refusal from any guard wins first; only when none refuses is a guard's non-blocking
29
+ // notice or fault (the whole-file-write notice, a lane child's duplicate-read notice) emitted. Each
30
+ // guard still honours its own
30
31
  // switch (`JOSH_BATCH_GUARD` / `JOSH_INVESTIGATION_GUARD` / `JOSH_RULE_GUARD`) internally, so this
31
32
  // process needs no switch of its own.
32
33
 
@@ -96,12 +97,14 @@ async function mark_phase(outcome: GuardOutcome): Promise<void> {
96
97
  // **The parent hand-off guard is the second asynchronous rule, asked after the watcher's**.
97
98
  // It reads the carry record to tell the `backlogrun` parent apart, so it sits
98
99
  // beside the watcher rather than among the synchronous rows; a stale watcher is the cheaper fix and is
99
- // surfaced first.
100
+ // surfaced first. **The poll guard is third**: it reads the same carry record, and only for a call that
101
+ // repeats a state read, so the hand-off still wins over it.
100
102
  async function async_reason(raw_payload: string): Promise<string | undefined> {
101
103
  const watcher = await run_watcher_hook.watcher_hook_reason(raw_payload)
102
104
  const cut = watcher ?? (await run_parent_cut_hook.parent_cut_reason(raw_payload))
105
+ const poll = cut ?? (await run_poll_hook.safe_poll_reason(raw_payload))
103
106
 
104
- return cut ?? (await issue_watch_hook.watch_reason())
107
+ return poll ?? (await issue_watch_hook.watch_reason())
105
108
  }
106
109
 
107
110
  async function pretool_outcome_async(raw_payload: string): Promise<GuardOutcome> {
@@ -159,59 +162,32 @@ function foreground_rewrite(
159
162
  )
160
163
  }
161
164
 
162
- interface PipefailRewrite {
163
- payload: string
164
- input: Record<string, unknown>
165
- }
166
-
167
- // A piped josh check the hook runs under `set -o pipefail` instead of refusing it:
168
- // the rewritten input, and the payload that carries it. The rewrite is the
169
- // rule guard's delivery, so the rule guard's switch turns it off too.
170
- function pipefail_rewrite(raw_payload: string): PipefailRewrite | undefined {
171
- if (!delivered_rules.is_enabled()) return undefined
172
-
173
- const call = guarded_call(raw_payload)
174
- const input = call === undefined ? undefined : piped_verification.pipefail_input(call)
175
- const payload = json_value.parse_or_undefined(raw_payload)
176
-
177
- if (input === undefined || !json_value.is_record(payload)) return undefined
178
-
179
- return { payload: JSON.stringify({ ...payload, tool_input: input }), input }
180
- }
181
-
182
- function pipefail_envelope(
165
+ // The rows' rewrite carries no permission decision: whatever else the line runs meets its own prompt.
166
+ function rule_envelope(
183
167
  outcome: GuardOutcome,
184
- input: Record<string, unknown> | undefined,
168
+ rewrite: RuleRewrite | undefined,
185
169
  ): string | undefined {
186
- if (input === undefined || outcome.reason !== undefined) return undefined
170
+ if (rewrite === undefined || outcome.reason !== undefined) return undefined
187
171
 
188
- return hook_decision.flow_rewrite_envelope(
189
- input,
190
- rewrite_context(piped_verification.PIPEFAIL_NOTE, outcome),
191
- )
172
+ return hook_decision.flow_rewrite_envelope(rewrite.input, rewrite_context(rewrite.note, outcome))
192
173
  }
193
174
 
194
175
  // The hook's one write: a rewrite where one applies, the outcome's own envelope otherwise.
195
- function emit(
196
- raw_payload: string,
197
- outcome: GuardOutcome,
198
- pipefail_input?: Record<string, unknown>,
199
- ): void {
200
- const rewrite =
201
- pipefail_envelope(outcome, pipefail_input) ?? foreground_rewrite(outcome, raw_payload)
176
+ function emit(raw_payload: string, outcome: GuardOutcome, rewrite?: RuleRewrite): void {
177
+ const envelope = rule_envelope(outcome, rewrite) ?? foreground_rewrite(outcome, raw_payload)
202
178
 
203
- if (rewrite === undefined) hook_decision.emit_outcome(outcome)
204
- else process.stdout.write(`${rewrite}\n`)
179
+ if (envelope === undefined) hook_decision.emit_outcome(outcome)
180
+ else process.stdout.write(`${envelope}\n`)
205
181
  }
206
182
 
207
- // **The guards judge the call that will run.** A rewritable piped check reaches every guard already
208
- // prefixed, so the piped-verification row stays quiet on it while any other guard's refusal still wins
209
- // over the rewrite. The Codex adapter cannot rewrite a call, so it goes on judging the call as typed.
183
+ // **The guards judge the call that will run.** A rewritten call reaches every guard already made
184
+ // right, so the rewriting row stays quiet on it while any other guard's refusal still wins over the
185
+ // rewrite. The Codex adapter cannot rewrite a call, so it goes on judging the call as typed.
210
186
  async function respond(raw_payload: string): Promise<void> {
211
- const pipefail = pipefail_rewrite(raw_payload)
212
- const judged = pipefail?.payload ?? raw_payload
187
+ const rewrite = rule_rewrite.rewrite(raw_payload)
188
+ const judged = rewrite?.payload ?? raw_payload
213
189
 
214
- emit(judged, await pretool_outcome_async(judged), pipefail?.input)
190
+ emit(judged, await pretool_outcome_async(judged), rewrite)
215
191
  }
216
192
 
217
193
  const pretool_guard = {
@@ -219,7 +195,6 @@ const pretool_guard = {
219
195
  emit,
220
196
  foreground_rewrite,
221
197
  mark_phase,
222
- pipefail_rewrite,
223
198
  pretool_outcome,
224
199
  respond,
225
200
  }
@@ -78,6 +78,7 @@ async function run_reserved(argv: ReadonlyArray<string>): Promise<number> {
78
78
  return await core_budget.with_core_reservation(
79
79
  parsed.weight,
80
80
  async () => await run_command(parsed.command),
81
+ { command: parsed.command.join(' ') },
81
82
  )
82
83
  }
83
84
 
@@ -5,9 +5,8 @@
5
5
  # A hook command in `.claude/settings.json` / `.codex/hooks.json` only moves to the project root
6
6
  # (`scripts/init/hook-launch.ts`) and calls this script, so everything every hook shares lives here once:
7
7
  #
8
- # - The git location variables a git hook exports are cleared when the root resolves without them, so
9
- # the hook's own git calls act on the checkout it stands in. They are kept when the checkout needs
10
- # them — git metadata outside the work tree.
8
+ # - The root prefix has already cleared the git location variables a git hook exports, or kept them
9
+ # when the checkout needs them; this script starts no `git` of its own (joshuafolkken/kit#3946).
11
10
  # - The pre-built bundle `dist/hooks/<name>.js` runs once its gate passes; otherwise the live source
12
11
  # runs through josh, whose command is the name with its first `-` turned into `:`
13
12
  # (`pretool-guard` → `pretool:guard`).
@@ -16,27 +15,26 @@
16
15
  # starts node once (joshuafolkken/kit#3327). In an installed package it is a presence check: the
17
16
  # published bundles cannot be stale against a source the consumer never edits, and Node strips no
18
17
  # types under `node_modules`.
18
+ # - Nothing is spawned that the shell can do itself: the directory is a parameter expansion rather than
19
+ # `dirname`, and `node` replaces this shell through `exec` rather than running beside it.
19
20
  # - An `if`/`else`, never `&&`/`||`: a guard's refusal is a non-zero exit, and a chain would read it as
20
21
  # a missing bundle and run the hook twice.
21
22
 
22
- location_variables='GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_DIR GIT_INDEX_FILE GIT_NAMESPACE GIT_OBJECT_DIRECTORY GIT_WORK_TREE'
23
- unset_options=''
24
- for variable in $location_variables; do unset_options="$unset_options -u $variable"; done
25
- # shellcheck disable=SC2086
26
- if env $unset_options git rev-parse --show-toplevel >/dev/null 2>&1; then unset $location_variables; fi
27
-
28
23
  hook_name=$1
29
24
  shift
30
- hooks_dir=$(dirname "$0")
25
+ case $0 in
26
+ */*) hooks_dir=${0%/*} ;;
27
+ *) hooks_dir=. ;;
28
+ esac
31
29
  package_dir="$hooks_dir/../.."
32
30
  bundle="$package_dir/dist/hooks/$hook_name.js"
33
31
  josh_command="${hook_name%%-*}:${hook_name#*-}"
34
32
 
35
33
  case $0 in
36
34
  *node_modules/*)
37
- if [ -d "$package_dir/dist/hooks" ]; then node "$bundle" "$@"; else node "$package_dir/dist/josh.js" "$josh_command" "$@"; fi
35
+ if [ -d "$package_dir/dist/hooks" ]; then exec node "$bundle" "$@"; else exec node "$package_dir/dist/josh.js" "$josh_command" "$@"; fi
38
36
  ;;
39
37
  *)
40
- node --disable-warning=ExperimentalWarning "$hooks_dir/hook-bundle-ready.ts" "$hook_name" "$josh_command" "$@"
38
+ exec node --disable-warning=ExperimentalWarning "$hooks_dir/hook-bundle-ready.ts" "$hook_name" "$josh_command" "$@"
41
39
  ;;
42
40
  esac
@@ -0,0 +1,204 @@
1
+ import { git_gh_repo } from '#scripts/gh/git-gh-repo'
2
+ import { issue_cite } from '#scripts/issue/issue-cite'
3
+ import { issue_cite_cli } from '#scripts/issue/issue-cite-cli'
4
+ import { session_cite } from '#scripts/issue/session-cite'
5
+ import { bounded_pool } from '#scripts/lib/bounded-pool'
6
+ import { telegram_test } from '#scripts/notify/telegram-test'
7
+ import type { CliValues } from '#scripts/notify/telegram-test-logic'
8
+ import { issue_citation } from '#scripts/rules/issue-citation'
9
+ import { stop_rules, type StopAction, type StopOutcome } from '#scripts/rules/stop-rules'
10
+ import { run_hold } from '#scripts/run/hold/run-hold'
11
+ import { run_hold_cli } from '#scripts/run/hold/run-hold-cli'
12
+ import { run_hold_session } from '#scripts/run/hold/run-hold-session'
13
+
14
+ type NotifyAction = Extract<StopAction, { kind: 'notify' }>
15
+
16
+ // The `Stop` hook running the operations whose answer is fixed (joshuafolkken/kit#3919): the
17
+ // `confirmation` notify, the hold release and the citation lines, through the very calls
18
+ // `josh notify`, `josh run:release` and `josh issue:cite` make — in process, since three `pnpm`
19
+ // launches would spend most of the hook's 20-second budget. What each did reaches the person as a
20
+ // `systemMessage`; one that did not take falls back to the refusal it replaced (`stop_rules.fallback_reason`).
21
+
22
+ const SUCCESS_EXIT_CODE = 0
23
+ const NOTICE_SEPARATOR = '\n'
24
+ // The reply is the stop's reason, but a Telegram is read at a glance — its opening is enough.
25
+ const SUMMARY_CHARS = 600
26
+ // A `#N`, `owner/repo#N` or URL is a run of printable ASCII; a cut inside one would link the wrong Issue.
27
+ const TOKEN_CHAR = /^[!-~]$/u
28
+ const EMPTY_SUMMARY = 'The run stopped and is waiting for you.'
29
+
30
+ interface Settled {
31
+ notices: ReadonlyArray<string>
32
+ failure: string | undefined
33
+ }
34
+
35
+ // One action at a time — the order is the rule order.
36
+ const SEQUENTIAL = 1
37
+
38
+ // Where the run of token characters `text` ends with starts.
39
+ function token_start(text: string): number {
40
+ let index = text.length
41
+
42
+ while (index > 0 && TOKEN_CHAR.test(text.charAt(index - 1))) index -= 1
43
+
44
+ return index
45
+ }
46
+
47
+ // Cut before linking, on a boundary that keeps every reference whole: the character past the cut shows
48
+ // whether the last token ran on, and a token that did is dropped rather than halved.
49
+ function summary_of(message: string): string {
50
+ const trimmed = message.trim()
51
+ if (trimmed === '') return EMPTY_SUMMARY
52
+ if (trimmed.length <= SUMMARY_CHARS) return trimmed
53
+
54
+ const overshoot = trimmed.slice(0, SUMMARY_CHARS + 1)
55
+ const cut = overshoot.slice(0, Math.min(token_start(overshoot), SUMMARY_CHARS))
56
+
57
+ return cut === '' ? trimmed.slice(0, SUMMARY_CHARS) : cut
58
+ }
59
+
60
+ // The held Issue's URL, or `undefined` for a `new` entry or a repository that cannot be read.
61
+ async function issue_url_of(issue: string): Promise<string | undefined> {
62
+ if (issue === run_hold.UNNUMBERED_ISSUE) return undefined
63
+
64
+ const slug = await git_gh_repo.repo_get_name_with_owner()
65
+
66
+ return slug === undefined ? undefined : issue_cite.issue_url(slug, issue)
67
+ }
68
+
69
+ // The body is the reply's opening copied into a Telegram, so its bare `#N` are linked (`issue-citation.md`).
70
+ // The choices are the reply's own decision lines (joshuafolkken/kit#3961).
71
+ function notify_values(issue_url: string | undefined, action: NotifyAction): CliValues {
72
+ const { recommended, alternative } = action.decision
73
+ const values: CliValues = {
74
+ 'task-type': 'confirmation',
75
+ body: session_cite.text(summary_of(action.message)),
76
+ recommended,
77
+ ...(alternative !== undefined && { alternative: [alternative] }),
78
+ }
79
+
80
+ return issue_url === undefined ? values : { ...values, 'issue-url': issue_url }
81
+ }
82
+
83
+ // The send is stamped on the hold, so a later stop of the same pause does not announce it again. A
84
+ // stamp that fails does not fail the send: the Telegram is out, and a refused stop would send it twice.
85
+ async function mark_notified(): Promise<void> {
86
+ try {
87
+ const directory = await run_hold.worktree_directory()
88
+
89
+ if (directory !== undefined) run_hold_session.mark_notified(run_hold.hold_path(directory))
90
+ } catch (error) {
91
+ console.warn(`stop:guard could not stamp the notify on the hold: ${String(error)}`)
92
+ }
93
+ }
94
+
95
+ async function notify(action: NotifyAction): Promise<string> {
96
+ const issue_url = await issue_url_of(action.issue)
97
+
98
+ await telegram_test.send_values(notify_values(issue_url, action))
99
+ await mark_notified()
100
+
101
+ return `📱 stop:guard sent the confirmation notify (${issue_url ?? action.issue}).`
102
+ }
103
+
104
+ async function release(issue: string): Promise<string | undefined> {
105
+ const argv = issue === run_hold.UNNUMBERED_ISSUE ? ['--release'] : ['--release', issue]
106
+ const code = await run_hold_cli.run(argv)
107
+
108
+ return code === SUCCESS_EXIT_CODE ? '🔓 stop:guard released the run:hold record.' : undefined
109
+ }
110
+
111
+ // Every line or none: a citation with a number missing is the half-fix the refusal exists to avoid.
112
+ async function cite(references: ReadonlyArray<string>): Promise<string | undefined> {
113
+ const cited = await issue_cite_cli.cite(issue_citation.cite_arguments(references))
114
+ if (cited === undefined || cited.failures.length > 0) return undefined
115
+
116
+ return ['Issue citations:', ...cited.lines].join(NOTICE_SEPARATOR)
117
+ }
118
+
119
+ // An action's line for the person, or `undefined` when it did not take.
120
+ async function run_action(action: StopAction): Promise<string | undefined> {
121
+ if (action.kind === 'notify') return await notify(action)
122
+ if (action.kind === 'release') return await release(action.issue)
123
+
124
+ return await cite(action.references)
125
+ }
126
+
127
+ // **The hook's stdout is the decision envelope, so nothing else may reach it**: the reused calls report
128
+ // on stdout (`josh run:release`'s verdict token, the notify's sent line) and are sent to stderr instead.
129
+ type StreamWrite = typeof process.stdout.write
130
+
131
+ function set_stdout_write(stream_write: StreamWrite): void {
132
+ process.stdout.write = stream_write
133
+ }
134
+
135
+ async function on_stderr<T>(work: () => Promise<T>): Promise<T> {
136
+ const saved = { write: process.stdout.write.bind(process.stdout) }
137
+
138
+ set_stdout_write(process.stderr.write.bind(process.stderr))
139
+
140
+ try {
141
+ return await work()
142
+ } finally {
143
+ set_stdout_write(saved.write)
144
+ }
145
+ }
146
+
147
+ // A throw is an action that did not take, the same as one that answered it had failed.
148
+ async function attempt(action: StopAction): Promise<string | undefined> {
149
+ try {
150
+ return await run_action(action)
151
+ } catch {
152
+ return undefined
153
+ }
154
+ }
155
+
156
+ function is_notice(notice: string | undefined): notice is string {
157
+ return notice !== undefined
158
+ }
159
+
160
+ // In order, one at a time: the notify reports the hold the release then ends. The first failure is the
161
+ // one refused with, as the rule order put it first.
162
+ async function settle(actions: ReadonlyArray<StopAction>): Promise<Settled> {
163
+ const notices = await on_stderr(
164
+ async () => await bounded_pool.bounded_map(actions, SEQUENTIAL, attempt),
165
+ )
166
+ const failed = actions.find((_action, index) => notices[index] === undefined)
167
+
168
+ return {
169
+ notices: notices.filter((notice) => is_notice(notice)),
170
+ failure: failed === undefined ? undefined : stop_rules.fallback_reason(failed),
171
+ }
172
+ }
173
+
174
+ function joined(notices: ReadonlyArray<string>): string | undefined {
175
+ return notices.length === 0 ? undefined : notices.join(NOTICE_SEPARATOR)
176
+ }
177
+
178
+ // A failure refuses only where the outcome allows it — never past the loop-breaker.
179
+ function settled_envelope(
180
+ settled: Settled,
181
+ is_blocking: boolean,
182
+ lang: string,
183
+ ): string | undefined {
184
+ const notice = joined(settled.notices)
185
+
186
+ if (is_blocking && settled.failure !== undefined) {
187
+ return stop_rules.block_envelope(settled.failure, lang, notice)
188
+ }
189
+
190
+ return notice === undefined ? undefined : stop_rules.notice_envelope(notice)
191
+ }
192
+
193
+ // What the hook writes for one stop's outcome, or `undefined` for a stop that writes nothing.
194
+ async function envelope_of(outcome: StopOutcome, lang: string): Promise<string | undefined> {
195
+ if (outcome.reason !== undefined) return stop_rules.block_envelope(outcome.reason, lang)
196
+
197
+ const settled = await settle(outcome.actions)
198
+
199
+ return settled_envelope(settled, outcome.blocks_on_failure, lang)
200
+ }
201
+
202
+ const stop_actions = { envelope_of, summary_of }
203
+
204
+ export { stop_actions }
@@ -5,6 +5,7 @@ import { agent_headless } from '#scripts/agent/agent-headless'
5
5
  import { backlog_ready } from '#scripts/backlog/backlog-ready'
6
6
  import { backlog_stalled_detect } from '#scripts/backlog/backlog-stalled-detect'
7
7
  import { repo_party } from '#scripts/discovery/repo-party'
8
+ import { stop_actions } from '#scripts/hooks/stop-actions'
8
9
  import { hook_decision } from '#scripts/josh/hook-decision'
9
10
  import { session_language } from '#scripts/josh/session-language'
10
11
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
@@ -13,37 +14,64 @@ import { filing_cap } from '#scripts/rules/filing-cap'
13
14
  import { lane_background } from '#scripts/rules/lane-background'
14
15
  import { lane_park } from '#scripts/rules/lane-park'
15
16
  import { last_prompt } from '#scripts/rules/last-prompt'
17
+ import { pre_gate_cut } from '#scripts/rules/pre-gate-cut'
18
+ import { stop_decision } from '#scripts/rules/stop-decision'
16
19
  import { stop_rules, type StopContext, type StopOutcome } from '#scripts/rules/stop-rules'
20
+ import { tail_commands } from '#scripts/rules/tail-commands'
17
21
  import { run_carry } from '#scripts/run/carry/run-carry'
18
22
  import { run_cut } from '#scripts/run/cut/run-cut'
19
- import { run_hold } from '#scripts/run/hold/run-hold'
23
+ import { run_cut_report } from '#scripts/run/cut/run-cut-report'
24
+ import { run_hold, type RunHold } from '#scripts/run/hold/run-hold'
25
+ import { run_hold_session } from '#scripts/run/hold/run-hold-session'
20
26
  import { run_halfrun_resume } from '#scripts/run/run-halfrun-resume'
21
27
  import { run_headless } from '#scripts/run/run-headless'
22
28
  import { run_stranded_detect } from '#scripts/run/run-stranded-detect'
23
29
  import { time_density_hook } from '#scripts/time-runtime/time-density-hook'
24
30
  import { time_hook_transcript } from '#scripts/time-runtime/time-hook-transcript'
25
31
 
26
- // The `Stop` hook entry: one process that delivers the three stop-time rules.
32
+ // The `Stop` hook entry: one process that delivers the stop-time rules, running the fixed ones itself
33
+ // (`stop-actions.ts`).
27
34
  // It is the stop-time counterpart of `pretool-guard.ts` — a second entry on the same `hook_decision`
28
35
  // foundation, not a second copy of it.
29
36
  //
30
37
  // **Every failure allows the stop.** A missing transcript, an unreadable hold record, a git status
31
38
  // that will not read — each ends as "no block", for the same reason the PreToolUse guard fails open:
32
- // a hook that failed closed would wedge a run over its own plumbing. The block rules are self-
33
- // resolving (send the notify, release the hold) and `stop_hook_active` is the backstop, so failing
39
+ // a hook that failed closed would wedge a run over its own plumbing. The hook runs the fixed
40
+ // operations itself (send the notify, release the hold) and `stop_hook_active` is the backstop, so failing
34
41
  // open here costs at most a missed nudge, never a stuck run.
35
42
 
36
- // The hold record for this working tree is still in place, and whether it is a `prrun` stop's.
37
- // `unreadable` reads as absent: the safe direction for a stop guard is not to
38
- // block, and an unreadable record is not proof a run is holding.
39
- async function hold_facts(): Promise<Pick<StopContext, 'hold_present' | 'prrun_stopped'>> {
43
+ // The hold record for this working tree. `unreadable` reads as absent: the safe direction for a stop
44
+ // guard is not to act, and an unreadable record is not proof a run is holding.
45
+ async function held_record(): Promise<RunHold | undefined> {
40
46
  const directory = await run_hold.worktree_directory()
41
- const hold =
42
- directory === undefined
43
- ? undefined
44
- : run_halfrun_resume.hold_of(run_hold.read_hold(run_hold.hold_path(directory)))
47
+ if (directory === undefined) return undefined
45
48
 
46
- return { hold_present: hold !== undefined, prrun_stopped: hold?.prrun_stop_head !== undefined }
49
+ return run_halfrun_resume.hold_of(run_hold.read_hold(run_hold.hold_path(directory)))
50
+ }
51
+
52
+ // The pause was announced: by a `josh notify` on the tail, or by this hook's own send, recorded on the
53
+ // hold since the pause began (joshuafolkken/kit#3961).
54
+ function was_notified(hold: RunHold | undefined, tail: string): boolean {
55
+ if (lane_park.mentions_confirmation_notify(tail)) return true
56
+
57
+ return (
58
+ hold !== undefined && run_hold_session.is_notified_since(hold, last_prompt.pause_start(tail))
59
+ )
60
+ }
61
+
62
+ // The Issue the hold names, whether it is a `prrun` stop's and this session's own, and whether its
63
+ // pause was already announced.
64
+ async function hold_facts(
65
+ tail: string,
66
+ ): Promise<Pick<StopContext, 'hold_issue' | 'hold_owned' | 'notified' | 'prrun_stopped'>> {
67
+ const hold = await held_record()
68
+
69
+ return {
70
+ hold_issue: hold?.issue,
71
+ hold_owned: hold === undefined || run_hold_session.is_session_hold(hold),
72
+ notified: was_notified(hold, tail),
73
+ prrun_stopped: hold?.prrun_stop_head !== undefined,
74
+ }
47
75
  }
48
76
 
49
77
  // A `confirmation` notify sits on this run's transcript tail. The tail is derived exactly as the
@@ -60,6 +88,19 @@ function was_filed(tail: string): boolean {
60
88
  return filing_cap.turn_filing_count(tail) > 0
61
89
  }
62
90
 
91
+ // This turn ended at a cut — a record still `carried`, or this turn's `run:cut` relaunching a successor.
92
+ // The second covers the successor's resume clearing the record before this stop (joshuafolkken/kit#3936);
93
+ // the held cut outside a lane prints no marker, so its person is still notified.
94
+ function is_cut_stop(tail: string): boolean {
95
+ if (run_cut.carried_cut_sync() !== undefined) return true
96
+
97
+ return tail_commands.answered(
98
+ filing_cap.current_turn(tail),
99
+ pre_gate_cut.takes_the_cut,
100
+ run_cut_report.RELAUNCHED_CUT_MARKER,
101
+ )
102
+ }
103
+
63
104
  // The session language the refusal is written for — `write_stop_decision` has already loaded the `.env`.
64
105
  function session_lang(): string {
65
106
  return session_language.resolve_session_lang().lang
@@ -85,13 +126,13 @@ async function build_context(
85
126
  const tail = transcript_tail(transcript_path)
86
127
 
87
128
  return {
88
- ...(await hold_facts()),
129
+ ...(await hold_facts(tail)),
89
130
  tree_clean: !(await run_hold.is_tree_dirty()),
90
- notified: lane_park.mentions_confirmation_notify(tail),
91
131
  message: payload_tail.message,
92
132
  prompt: last_prompt.prompt_text(tail),
133
+ answers_question: stop_decision.is_question(last_prompt.turn_prompt(tail)),
93
134
  stop_hook_active: payload_tail.stop_hook_active,
94
- cut_pending: run_cut.carried_cut_sync() !== undefined,
135
+ cut_pending: is_cut_stop(tail),
95
136
  filed: was_filed(tail),
96
137
  session_owner: repo_party.current_owner(),
97
138
  ...(await session_role_facts()),
@@ -167,17 +208,17 @@ async function run_report_checks(): Promise<void> {
167
208
  await run_stranded_detect.run_stranded_check()
168
209
  }
169
210
 
170
- // Nothing reaches stdout on an ordinary stop, so what the harness parses stays empty unless the stop
171
- // is being held — all three rules block, so a bare `#N` is reported the same way.
211
+ // Nothing reaches stdout on an ordinary stop, so what the harness parses stays empty unless the stop is
212
+ // held or the hook acted on it (`stop-actions.ts`).
172
213
  async function write_stop_decision(raw_payload: string): Promise<void> {
173
214
  hook_decision.load_environment_file()
174
215
  await run_report_checks()
175
216
 
176
- const { reason } = await outcome_of(raw_payload)
217
+ const envelope = await stop_actions.envelope_of(await outcome_of(raw_payload), session_lang())
177
218
 
178
- if (reason === undefined) return
219
+ if (envelope === undefined) return
179
220
 
180
- process.stdout.write(`${stop_rules.block_envelope(reason, session_lang())}\n`)
221
+ process.stdout.write(`${envelope}\n`)
181
222
  }
182
223
 
183
224
  const stop_guard = { outcome_of, stop_outcome_for_payload }
@@ -9,18 +9,23 @@ import { GIT_LOCATION_VARIABLES } from '#scripts/git/git-location-environment'
9
9
  // Every path after the prefix is relative, so a hook fired from a
10
10
  // subdirectory would otherwise miss its script. The root is git's, resolved with the location
11
11
  // variables a git hook exports cleared first, and resolved with them only when that fails — git
12
- // metadata outside the work tree. The consumer rewrite (`hook-command-rewrite.ts`) keeps this prefix,
13
- // so both sides stay one mechanism for Claude Code and Codex alike.
12
+ // metadata outside the work tree. When the cleared lookup succeeds the variables are cleared for the
13
+ // hook too, so its own git calls act on the checkout it stands in. The consumer rewrite
14
+ // (`hook-command-rewrite.ts`) keeps this prefix, so both sides stay one mechanism for Claude Code and
15
+ // Codex alike.
14
16
  //
15
- // **Everything after the root is one script** (`scripts/hooks/run-hook.sh`):
16
- // clearing the location variables for the hook itself, the bundle-ready gate and the live-source
17
- // fallback live there once, so each hook command names only its hook.
17
+ // **A hook runs on every tool call, so its launch spawns as little as it can** (joshuafolkken/kit#3946):
18
+ // the shell's own `unset` clears the variables rather than an `env` process, and that one lookup
19
+ // both finds the root and decides whether the variables stay — `git` starts once.
20
+ //
21
+ // **Everything after the root is one script** (`scripts/hooks/run-hook.sh`): the bundle-ready gate and
22
+ // the live-source fallback live there once, so each hook command names only its hook.
18
23
 
19
24
  const HOOK_DIST_DIR = 'dist/hooks'
20
25
  const RUN_HOOK_SCRIPT = 'scripts/hooks/run-hook.sh'
21
- const UNSET_OPTIONS = GIT_LOCATION_VARIABLES.map((name) => `-u ${name}`).join(' ')
26
+ const LOCATION_VARIABLES = GIT_LOCATION_VARIABLES.join(' ')
22
27
  const ROOT_LOOKUP = 'git rev-parse --show-toplevel'
23
- const PROJECT_ROOT_PREFIX = `project_root="$(env ${UNSET_OPTIONS} ${ROOT_LOOKUP} 2>/dev/null || ${ROOT_LOOKUP})" && cd "$project_root" && `
28
+ const PROJECT_ROOT_PREFIX = `if project_root="$(unset ${LOCATION_VARIABLES}; ${ROOT_LOOKUP} 2>/dev/null)"; then unset ${LOCATION_VARIABLES}; else project_root="$(${ROOT_LOOKUP})"; fi && cd "$project_root" && `
24
29
 
25
30
  // `name` is the hook's bundle under `dist/hooks` without `.js` (e.g. `pretool-guard`), followed by any
26
31
  // arguments it takes (e.g. `codex-hook-adapter pretool`).
@@ -0,0 +1,59 @@
1
+ import { markdown_section } from './markdown-section'
2
+
3
+ // joshuafolkken/kit#3909: a run-time-shortening proposal was filed and implemented on a criterion that
4
+ // never weighed how often the speed-up applies against what it costs when it does not. An Issue that
5
+ // declares that aim must therefore state its expected effect — the hit rate, the gain per hit, the loss
6
+ // per miss and where each number came from — before `issue:lint` and `issue:file` accept it. The
7
+ // declaration, the heading and the item labels are the template's, pinned by the document test.
8
+
9
+ const DECLARATION_LINE = '- 目的: 時間短縮'
10
+ const BACKGROUND_HEADING = '## 背景'
11
+ const HEADING = '## 期待効果'
12
+ const ITEM_LABELS: ReadonlyArray<string> = ['効く率', '1 回あたりの得', '外れたときの損', '出典']
13
+ // A template placeholder value — the `<...>` guidance a filed Issue replaces. It is not an estimate, but a
14
+ // value that only opens with `<` (an upper bound such as `<1 min`) is one, so the placeholder must close with `>` too.
15
+ const PLACEHOLDER = /^<.*>$/u
16
+
17
+ // Whether the body declares a run-time-shortening aim under its background heading.
18
+ function is_target(body: string): boolean {
19
+ const background = markdown_section.section_lines(body, BACKGROUND_HEADING).join('\n')
20
+
21
+ return markdown_section.has_unfenced_line(background, DECLARATION_LINE)
22
+ }
23
+
24
+ function item_value(line: string, label: string): string | undefined {
25
+ const prefix = `- ${label}:`
26
+
27
+ if (!line.startsWith(prefix)) return undefined
28
+
29
+ return line.slice(prefix.length).trim()
30
+ }
31
+
32
+ function has_item(lines: ReadonlyArray<string>, label: string): boolean {
33
+ return lines.some((line) => {
34
+ const value = item_value(line.trim(), label)
35
+
36
+ return value !== undefined && value.length > 0 && !PLACEHOLDER.test(value)
37
+ })
38
+ }
39
+
40
+ // Every expected-effect problem a body has, or an empty array. A body without the declaration has none.
41
+ function problems(body: string): ReadonlyArray<string> {
42
+ if (!is_target(body)) return []
43
+ if (!markdown_section.has_line(body, HEADING)) return [`missing heading: ${HEADING}`]
44
+ const section = markdown_section.section_lines(body, HEADING).join('\n')
45
+ const lines = markdown_section.unfenced_lines(section)
46
+
47
+ return ITEM_LABELS.filter((label) => !has_item(lines, label)).map(
48
+ (label) => `missing expected-effect item: - ${label}: <value>`,
49
+ )
50
+ }
51
+
52
+ const expected_effect_lint = {
53
+ DECLARATION_LINE,
54
+ HEADING,
55
+ ITEM_LABELS,
56
+ problems,
57
+ }
58
+
59
+ export { expected_effect_lint }