@joshuafolkken/kit 1.1005.0 → 1.1035.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 (272) hide show
  1. package/.claude/settings.json +1 -5
  2. package/.claude/skills/dependency-update/SKILL.md +6 -3
  3. package/.claude/skills/epic-commands/SKILL.md +46 -17
  4. package/.claude/skills/workflow-commands/SKILL.md +8 -23
  5. package/.claude/skills/workflow-commands/backlogrun-child.md +4 -2
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -12
  7. package/.claude/skills/workflow-commands/fullrun-steps.md +4 -3
  8. package/.claude/skills/workflow-commands/fullrun.md +1 -1
  9. package/.claude/skills/workflow-commands/halfrun.md +2 -1
  10. package/.claude/skills/workflow-commands/into-target.md +1 -1
  11. package/.claude/skills/workflow-commands/observation-filing.md +42 -43
  12. package/.claude/skills/workflow-commands/prerequisite.md +1 -1
  13. package/.codex/hooks.json +1 -5
  14. package/.github/workflows/sonar-qube.yml +1 -1
  15. package/AGENTS.md +4 -25
  16. package/CLAUDE.md +24 -38
  17. package/GEMINI.md +4 -24
  18. package/README.md +22 -58
  19. package/dist/CLAUDE.md +24 -38
  20. package/dist/hooks/batch-guard.js +4 -4
  21. package/dist/hooks/{chunk-WMIR5SNX.js → chunk-D67KVGGY.js} +3 -3
  22. package/dist/hooks/{chunk-EZLHXKJ6.js → chunk-EIFCAJRB.js} +70 -27
  23. package/dist/hooks/{chunk-KPZN42RT.js → chunk-GJT4BNVE.js} +3 -3
  24. package/dist/hooks/{chunk-PAXMLGSS.js → chunk-GLBDBJOK.js} +44 -24
  25. package/dist/hooks/{chunk-GBJUERYK.js → chunk-HOYJU5RM.js} +1 -1
  26. package/dist/hooks/{chunk-6PSTVOLH.js → chunk-HU66PWWL.js} +33 -57
  27. package/dist/hooks/{chunk-A2H5I2VD.js → chunk-KG6PZD72.js} +2 -2
  28. package/dist/hooks/{chunk-AWTFKKFW.js → chunk-XPLP27WA.js} +3 -8
  29. package/dist/hooks/{chunk-4GDR2YTV.js → chunk-Y27M6A7X.js} +4 -4
  30. package/dist/hooks/{chunk-56LGMJJV.js → chunk-ZXNE6NY3.js} +2 -2
  31. package/dist/hooks/codex-hook-adapter.js +10 -10
  32. package/dist/hooks/duplicate-read-guard.js +5 -5
  33. package/dist/hooks/format-edited.js +2 -2
  34. package/dist/hooks/investigation-guard.js +5 -5
  35. package/dist/hooks/pretool-guard.js +9 -9
  36. package/dist/hooks/stop-guard.js +11 -15
  37. package/dist/josh.js +38 -19
  38. package/dist/version/index.js +93 -41
  39. package/eslint/base.js +2 -11
  40. package/package.json +32 -6
  41. package/prompts/coding-standards.md +15 -290
  42. package/prompts/collaboration-workflow/cross-repo-epic.md +2 -51
  43. package/prompts/collaboration-workflow/epic-audit.md +2 -53
  44. package/prompts/collaboration-workflow/epic-bundle.md +2 -106
  45. package/prompts/collaboration-workflow/issue-template.md +4 -4
  46. package/prompts/collaboration-workflow/operating-rules.md +18 -75
  47. package/prompts/collaboration-workflow/principles.md +74 -0
  48. package/prompts/collaboration-workflow/residency.md +54 -14
  49. package/prompts/collaboration-workflow/rule-delivery.md +1 -11
  50. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  51. package/prompts/collaboration-workflow.md +22 -28
  52. package/scripts/adopt/adopt-logic.ts +2 -2
  53. package/scripts/adopt/adopt-toolkits.ts +1 -1
  54. package/scripts/adopt/adopt.ts +2 -2
  55. package/scripts/agent/agent-event.ts +2 -9
  56. package/scripts/agent/agent-exit-record.ts +2 -9
  57. package/scripts/backlog/backlog-budget-cli.ts +2 -6
  58. package/scripts/backlog/backlog-drive-cli.ts +20 -10
  59. package/scripts/backlog/backlog-drive.ts +50 -16
  60. package/scripts/backlog/backlog-offer-cli.ts +6 -5
  61. package/scripts/backlog/backlog-plan.ts +1 -1
  62. package/scripts/build/build-bin.ts +2 -1
  63. package/scripts/build/build-config-merge.ts +2 -1
  64. package/scripts/build/build-hooks.ts +24 -4
  65. package/scripts/build/build-managed-marker.ts +2 -1
  66. package/scripts/build/build-self-sync-guard.ts +2 -1
  67. package/scripts/build/build-version.ts +2 -1
  68. package/scripts/cases/cases-logic.ts +2 -6
  69. package/scripts/ci/pr-classification.ts +13 -11
  70. package/scripts/cost-runtime/cost-transcript.ts +4 -11
  71. package/scripts/doctor/doctor-consumer.ts +5 -12
  72. package/scripts/doctor/doctor-ports.ts +2 -2
  73. package/scripts/document/document-byte-budget.ts +8 -16
  74. package/scripts/document/document-section.ts +2 -6
  75. package/scripts/document/entry-read-budget.ts +1 -1
  76. package/scripts/{git/git-epic-add-body.ts → epic/epic-add-body.ts} +23 -23
  77. package/scripts/{git/git-epic-add-plan.ts → epic/epic-add-plan.ts} +23 -26
  78. package/scripts/{git/git-epic-add.ts → epic/epic-add.ts} +17 -17
  79. package/scripts/epic/epic-audit-cli.ts +4 -4
  80. package/scripts/epic/epic-audit-orphans.ts +2 -2
  81. package/scripts/epic/epic-audit-rationale.ts +1 -1
  82. package/scripts/epic/epic-audit.ts +1 -1
  83. package/scripts/{git/git-epic-body.ts → epic/epic-body.ts} +6 -6
  84. package/scripts/epic/epic-bundle-cli.ts +1 -1
  85. package/scripts/epic/epic-bundle.ts +2 -2
  86. package/scripts/epic/epic-candidate-confirm.ts +2 -1
  87. package/scripts/{git/git-epic-chains.ts → epic/epic-chains.ts} +4 -4
  88. package/{scripts-ai → scripts/epic}/epic-check.ts +5 -4
  89. package/{scripts-ai → scripts/epic}/epic-cli.ts +4 -4
  90. package/scripts/{git/git-epic-close-comment.ts → epic/epic-close-comment.ts} +9 -9
  91. package/scripts/{git/git-epic-close.ts → epic/epic-close.ts} +23 -19
  92. package/scripts/epic/epic-cross-repo.ts +21 -38
  93. package/scripts/{git/git-epic-decision.ts → epic/epic-decision.ts} +12 -14
  94. package/scripts/epic/epic-fetch.ts +3 -3
  95. package/scripts/epic/epic-graph.ts +3 -3
  96. package/scripts/epic/epic-index.ts +2 -2
  97. package/scripts/epic/epic-issue.ts +1 -1
  98. package/scripts/epic/epic-next.ts +3 -3
  99. package/scripts/{git/git-epic-parse.ts → epic/epic-parse.ts} +3 -3
  100. package/scripts/{git/git-epic-promote.ts → epic/epic-promote.ts} +10 -10
  101. package/scripts/{git/git-epic-read.ts → epic/epic-read.ts} +9 -9
  102. package/scripts/{git/git-epic-reconcile.ts → epic/epic-reconcile.ts} +18 -20
  103. package/scripts/{git/git-epic-reference.ts → epic/epic-reference.ts} +4 -23
  104. package/scripts/epic/epic-relation-recheck.ts +4 -3
  105. package/scripts/{git/git-epic-relations.ts → epic/epic-relations.ts} +5 -5
  106. package/scripts/{git/git-epic-remove-plan.ts → epic/epic-remove-plan.ts} +17 -19
  107. package/scripts/{git/git-epic-remove.ts → epic/epic-remove.ts} +13 -13
  108. package/scripts/{git/git-epic-run.ts → epic/epic-run.ts} +27 -27
  109. package/scripts/{git/git-epic-sections.ts → epic/epic-sections.ts} +6 -6
  110. package/scripts/{git/git-epic-shape.ts → epic/epic-shape.ts} +7 -7
  111. package/scripts/{git/git-epic-validate.ts → epic/epic-validate.ts} +13 -13
  112. package/{scripts-ai → scripts/epic}/epic.ts +17 -16
  113. package/scripts/gh/fix-gh-packages.ts +2 -1
  114. package/scripts/gh/gh-spawn.ts +12 -10
  115. package/scripts/git/constants.ts +1 -1
  116. package/scripts/git/git-branch.ts +10 -1
  117. package/scripts/git/git-command.ts +1 -1
  118. package/scripts/git/git-error.ts +2 -5
  119. package/{scripts-ai → scripts/git}/git-followup-finish.ts +7 -7
  120. package/scripts/git/git-followup-flush.ts +74 -48
  121. package/scripts/git/git-followup-issue-close.ts +2 -2
  122. package/scripts/git/git-followup-label.ts +1 -1
  123. package/scripts/git/git-followup-pending.ts +1 -1
  124. package/{scripts-ai → scripts/git}/git-followup-workflow.ts +23 -11
  125. package/scripts/git/git-gh-exec.ts +85 -26
  126. package/scripts/git/git-gh-issue-read.ts +2 -2
  127. package/scripts/git/git-gh-issue-rest.ts +2 -2
  128. package/scripts/git/git-gh-issue-write.ts +6 -6
  129. package/scripts/git/git-gh-pr-read.ts +0 -21
  130. package/scripts/git/git-gh-pr.ts +2 -23
  131. package/scripts/git/git-pr-followup-wrapup.ts +2 -6
  132. package/scripts/git/git-preflight.ts +3 -6
  133. package/scripts/git/git-staging.ts +21 -9
  134. package/{scripts-ai → scripts/git}/git-workflow.ts +11 -11
  135. package/scripts/git/github-issue-url.ts +1 -1
  136. package/scripts/git/issue-labels.ts +1 -1
  137. package/scripts/git/issue-reference.ts +19 -0
  138. package/scripts/git/main-merge.ts +2 -1
  139. package/scripts/git/main-sync.ts +3 -2
  140. package/scripts/git/schemas.ts +1 -1
  141. package/scripts/git/stash-pop-args.ts +7 -13
  142. package/scripts/git/stash-pop-cli.ts +2 -1
  143. package/{scripts-ai → scripts/git}/telegram-test-logic.ts +2 -2
  144. package/{scripts-ai → scripts/git}/telegram-test.ts +7 -7
  145. package/scripts/hooks/format-rewrite-notice.ts +2 -8
  146. package/scripts/init/init-ai-copy.ts +23 -14
  147. package/scripts/init/init.ts +8 -4
  148. package/scripts/init/start-steps.ts +2 -1
  149. package/scripts/init/start.ts +2 -1
  150. package/scripts/issue/issue-comment-cli.ts +2 -1
  151. package/scripts/issue/issue-file-cli.ts +2 -1
  152. package/scripts/issue/issue-fold-cli.ts +9 -16
  153. package/scripts/issue/issue-scout-cli.ts +8 -13
  154. package/scripts/issue/measure-rerun-cli.ts +7 -6
  155. package/scripts/josh/josh-commands-ai.ts +2 -2
  156. package/scripts/josh/josh-commands-backlog.ts +1 -1
  157. package/scripts/josh/josh-commands-development.ts +1 -1
  158. package/scripts/josh/josh-commands-workflow.ts +4 -4
  159. package/scripts/lane/lane-cli.ts +2 -1
  160. package/scripts/lane/lane-open.ts +3 -2
  161. package/scripts/lane/lane-registry.ts +3 -6
  162. package/scripts/lib/cli-flags.ts +20 -2
  163. package/scripts/lib/error-message.ts +9 -0
  164. package/scripts/lib/read-file.ts +26 -2
  165. package/scripts/observations/observation-ledger-home.ts +98 -26
  166. package/scripts/observations/observation-ledger-migrate.ts +26 -21
  167. package/scripts/observations/observation-ledger-prepare.ts +10 -20
  168. package/scripts/observations/observation-ledger.ts +58 -25
  169. package/scripts/observations/observations-flush-cli.ts +6 -23
  170. package/scripts/observations/observations-flush-landing.ts +2 -1
  171. package/scripts/observations/observations-flush.ts +29 -20
  172. package/scripts/package/package-scout-cli.ts +8 -16
  173. package/scripts/ports/port-command.ts +2 -1
  174. package/scripts/propagate/propagate-publish.ts +6 -7
  175. package/scripts/propagate/propagate-steps.ts +2 -1
  176. package/scripts/propagate/propagate.ts +3 -2
  177. package/scripts/refactor/import-graph.ts +2 -6
  178. package/scripts/registry-migration/migrate.ts +3 -2
  179. package/scripts/registry-migration/user-npmrc.ts +2 -2
  180. package/scripts/release/release-cli.ts +2 -1
  181. package/scripts/repo/repo-setting.ts +10 -8
  182. package/scripts/review/review-finding-ledger.ts +3 -3
  183. package/scripts/review/review-findings-cli.ts +4 -11
  184. package/scripts/review/review-record-cli.ts +14 -11
  185. package/scripts/review/review-record.ts +6 -16
  186. package/scripts/rules/decision-oracle.ts +2 -2
  187. package/scripts/rules/rule-body-guard.ts +2 -2
  188. package/scripts/rules/rule-value-cache.ts +99 -0
  189. package/scripts/rules/rule-value-cli.ts +10 -9
  190. package/scripts/rules/rule-value.ts +1 -1
  191. package/scripts/run/run-carry-args.ts +2 -6
  192. package/scripts/run/run-carry-cli.ts +5 -7
  193. package/scripts/run/run-cut-args.ts +7 -13
  194. package/scripts/run/run-ending-cli.ts +4 -7
  195. package/scripts/run/run-event-stream.ts +2 -5
  196. package/scripts/run/run-liveness-cli.ts +4 -7
  197. package/scripts/run/run-progress-args.ts +2 -6
  198. package/scripts/run/run-progress-cli.ts +2 -1
  199. package/scripts/run/run-tail-cli.ts +3 -3
  200. package/scripts/run/run-tidy-stashes.ts +7 -14
  201. package/scripts/run/run-wake-cli.ts +2 -6
  202. package/scripts/run/run-wake-driver.ts +15 -2
  203. package/scripts/run/run-wake-session.ts +14 -4
  204. package/scripts/safe-chain/project-config.ts +3 -6
  205. package/scripts/security/security-audit-provision.ts +6 -7
  206. package/scripts/sonar/sonar-hotspots-cli.ts +2 -1
  207. package/scripts/sync/directory-copy-guard.ts +2 -1
  208. package/scripts/sync/sync-ai-files.ts +3 -3
  209. package/scripts/sync/template-source-logic.ts +3 -4
  210. package/scripts/test/e2e-retry-check.ts +3 -6
  211. package/scripts/ui/ui-routes-cli.ts +3 -6
  212. package/scripts/version/bump-version.ts +2 -1
  213. package/scripts/version/version-remote.ts +18 -27
  214. package/scripts/version/version-targets.ts +3 -3
  215. package/.claude/skills/workflow-commands/rule-residency.md +0 -267
  216. package/evals/scenarios/consult-not-execute.json +0 -20
  217. package/evals/scenarios/no-implicit-workflow.json +0 -26
  218. package/evals/scenarios/overrides-both-locations.json +0 -29
  219. package/evals/scenarios/upstream-interrupt.json +0 -23
  220. package/evals/scenarios/workflow-skill-loaded.json +0 -16
  221. package/prompts/agent-lint-protocol.json +0 -79
  222. package/prompts/agent-rules.md +0 -63
  223. package/prompts/collaboration-workflow/consultation-vs-execution.md +0 -8
  224. package/prompts/collaboration-workflow/distributed-docs.md +0 -7
  225. package/prompts/collaboration-workflow/durable-rules.md +0 -24
  226. package/prompts/collaboration-workflow/latest-first.md +0 -14
  227. package/prompts/collaboration-workflow/no-clones.md +0 -11
  228. package/prompts/collaboration-workflow/simplicity-first.md +0 -9
  229. package/prompts/collaboration-workflow/single-source-rules.md +0 -13
  230. package/scripts/build/shim-shell.ts +0 -25
  231. package/scripts/claude/skill-meta.ts +0 -37
  232. package/scripts/document/document-scan.ts +0 -108
  233. package/scripts/document/generate-catalog.ts +0 -69
  234. package/scripts/document/retired-phrases.ts +0 -143
  235. package/scripts/document/review-agent-type.ts +0 -46
  236. package/scripts/document/single-source.ts +0 -73
  237. package/scripts/git/git-fixture-workspace.ts +0 -88
  238. package/scripts/hooks/format-edited-file-fixtures.ts +0 -109
  239. package/scripts/hooks/prompt-hooks.ts +0 -38
  240. package/scripts/init/hook-command-bootstrap.ts +0 -83
  241. package/scripts/issue/issue-logic.ts +0 -50
  242. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +0 -1227
  243. package/scripts/josh/josh-script-reader.ts +0 -11
  244. package/scripts/package/production-dependency-graph.ts +0 -68
  245. package/scripts/retrospective/retrospective-cli.ts +0 -97
  246. package/scripts/retrospective/retrospective.ts +0 -127
  247. package/scripts/rules/delivered-rules-harness.ts +0 -72
  248. package/scripts/rules/top-level-scripts-guard.ts +0 -47
  249. package/scripts/run/run-carry-flush.ts +0 -58
  250. package/scripts/run/run-transcript.golden.txt +0 -57
  251. package/scripts/run/run-wake-work.ts +0 -108
  252. package/scripts/sync/dependabot-pin-logic.ts +0 -135
  253. package/scripts/test/classify-isolation.ts +0 -149
  254. package/scripts/test/http-fault-injection.ts +0 -79
  255. package/scripts/test/josh-harness-environment.ts +0 -260
  256. package/scripts/test/josh-harness.ts +0 -96
  257. package/scripts/test/pilot-files.ts +0 -452
  258. package/scripts/test/pilot-measure.ts +0 -78
  259. package/scripts/test/test-network-guard.ts +0 -453
  260. package/scripts/test/test-repository-guard.ts +0 -317
  261. package/scripts/test/test-state-guard.ts +0 -85
  262. package/scripts/test/test-stdout-guard.ts +0 -54
  263. package/scripts/test/test-telegram-guard.ts +0 -90
  264. package/scripts/test/unit-guard-environment.ts +0 -12
  265. package/scripts/test/unit-projects.ts +0 -139
  266. package/scripts/test/vitest-include-globs.ts +0 -12
  267. package/scripts/time-runtime/time-batch-guard-fixtures.ts +0 -66
  268. package/scripts-ai/completion-gate.ts +0 -13
  269. package/scripts-ai/environment-loader.ts +0 -9
  270. /package/{scripts-ai → scripts/epic}/epic-cli-argv.ts +0 -0
  271. /package/{scripts-ai → scripts/epic}/epic-cli-remove.ts +0 -0
  272. /package/{scripts-ai → scripts/git}/followup-issue-number.ts +0 -0
@@ -32,7 +32,7 @@ function is_toolkit_package(package_name: string): boolean {
32
32
 
33
33
  // kit is the base tier and every other toolkit distributes files *derived* from kit's, so the two
34
34
  // can manage the same path and whichever `sync` ran last decides its contents
35
- // (`docs/sync.md` → "two distribution tiers"). Ordering is therefore base-first: kit syncs, then the
35
+ // (`docs/maintainers/sync-rationale.md` → "The managed-workflow stamp"). Ordering is therefore base-first: kit syncs, then the
36
36
  // toolkits that overlay it. Sorting by name would put kit last and have it overwrite the overlay
37
37
  // with the original every single run.
38
38
  function toolkit_rank(package_name: string): number {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { PROJECT_ROOT } from '#scripts/init/init-paths'
4
- import { refuse_unknown_flags } from '#scripts/lib/cli-flags'
4
+ import { cli_flags } from '#scripts/lib/cli-flags'
5
5
  import { propagate_run, type TargetResult } from '#scripts/propagate/propagate-run'
6
6
  import { propagate_steps, type Release, type ReleasePlan } from '#scripts/propagate/propagate-steps'
7
7
  import type { PropagateTarget } from '#scripts/propagate/propagate-targets'
@@ -40,7 +40,7 @@ interface RunOptions {
40
40
 
41
41
  function parse_options(argv: ReadonlyArray<string>): RunOptions {
42
42
  const options: RunOptions = { is_dry_run: argv.includes(DRY_RUN_FLAG) }
43
- const usage = refuse_unknown_flags(argv, KNOWN_FLAGS, 'adopt')
43
+ const usage = cli_flags.refuse_unknown_flags(argv, KNOWN_FLAGS, 'adopt')
44
44
 
45
45
  return usage === undefined ? options : { ...options, usage }
46
46
  }
@@ -1,3 +1,4 @@
1
+ import { json_value } from '#scripts/lib/json-value'
1
2
  import { z } from 'zod'
2
3
  import type { AgentProvider } from './agent-role-profile'
3
4
  import { claude_result_event } from './claude-result-event'
@@ -53,14 +54,6 @@ const EMPTY_STATE: AgentEventState = {
53
54
  const LAUNCH_HEADER_PREFIX = '=== '
54
55
  const LAUNCH_HEADER_MARKER = ' · started by process '
55
56
 
56
- function parse_line(line: string): unknown {
57
- try {
58
- return JSON.parse(line)
59
- } catch {
60
- return undefined
61
- }
62
- }
63
-
64
57
  function usable_text(value: unknown): string | undefined {
65
58
  return typeof value === 'string' && value.trim() !== '' ? value : undefined
66
59
  }
@@ -149,7 +142,7 @@ function next_state(line: string, state: AgentEventState): AgentEventState {
149
142
  const is_header = line.startsWith(LAUNCH_HEADER_PREFIX) && line.includes(LAUNCH_HEADER_MARKER)
150
143
  if (is_header) return EMPTY_STATE
151
144
 
152
- return normalized(parse_line(line), state)
145
+ return normalized(json_value.parse_or_undefined(line), state)
153
146
  }
154
147
 
155
148
  function read(transcript: string): AgentEventState {
@@ -1,3 +1,4 @@
1
+ import { json_value } from '#scripts/lib/json-value'
1
2
  import { claude_result_event, type ClaudeResultEvent } from './claude-result-event'
2
3
 
3
4
  // The exit record of a dispatched lane child — the terminal `result` event a `claude -p` session
@@ -12,18 +13,10 @@ import { claude_result_event, type ClaudeResultEvent } from './claude-result-eve
12
13
  // over; a transcript with no result event at all reads as `undefined`, which the classifier treats as
13
14
  // an unreadable ending rather than guessing one.
14
15
 
15
- function parse_line(line: string): unknown {
16
- try {
17
- return JSON.parse(line)
18
- } catch {
19
- return undefined
20
- }
21
- }
22
-
23
16
  function read_exit_record(transcript: string): ClaudeResultEvent | undefined {
24
17
  return transcript
25
18
  .split('\n')
26
- .map((line) => claude_result_event.decode(parse_line(line)))
19
+ .map((line) => claude_result_event.decode(json_value.parse_or_undefined(line)))
27
20
  .findLast((record) => record !== undefined)
28
21
  }
29
22
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
4
3
  import { path_decision } from '#scripts/josh/path-decision'
4
+ import { cli_flags } from '#scripts/lib/cli-flags'
5
5
  import { backlog_budget, type BacklogAnswer, type BudgetInput } from './backlog-budget'
6
6
 
7
7
  // The thin half of `josh backlog:budget` (joshuafolkken/kit#1632): read the loop's state off the
@@ -32,11 +32,7 @@ type ParsedValues = Partial<Record<OptionName, string | boolean>>
32
32
  // A misspelled flag is refused rather than defaulted, the contract `path_decision.has_unknown_flag`
33
33
  // states for the path-decided commands: an invocation nobody can read is never answered.
34
34
  function read_arguments(argv: ReadonlyArray<string>): ParsedValues | undefined {
35
- try {
36
- return parseArgs({ args: [...argv], options: OPTIONS, strict: true }).values
37
- } catch {
38
- return undefined
39
- }
35
+ return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, strict: true })?.values
40
36
  }
41
37
 
42
38
  function text_of(value: string | boolean | undefined): string | undefined {
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { setTimeout as sleep } from 'node:timers/promises'
3
3
  import { fileURLToPath } from 'node:url'
4
- import { parseArgs } from 'node:util'
5
4
  import { CONTEXT_CUT_THRESHOLD } from '#scripts/cost-runtime/context-cut-threshold'
6
5
  import { hook_decision } from '#scripts/josh/hook-decision'
7
6
  import { josh_command } from '#scripts/josh/josh-run'
8
7
  import { lane_await, type AwaitState } from '#scripts/lane/lane-await'
9
8
  import { lane_launch_cli } from '#scripts/lane/lane-launch-cli'
10
9
  import { lane_registry } from '#scripts/lane/lane-registry'
10
+ import { cli_flags } from '#scripts/lib/cli-flags'
11
+ import { error_text } from '#scripts/lib/error-message'
11
12
  import { run_carry, type RunCarry } from '#scripts/run/run-carry'
12
13
  import { run_event_stream_emit } from '#scripts/run/run-event-stream-emit'
13
14
  import { run_merge_cli } from '#scripts/run/run-merge-cli'
@@ -49,7 +50,7 @@ const LIST_SEPARATOR = ','
49
50
  const COUNT_PATTERN = /^\d+$/u
50
51
  const ISSUE_PATTERN = /^[1-9]\d*$/u
51
52
  const USAGE =
52
- 'Usage: josh backlog:drive --owner <pid> [--active <ISO-8601>] [--max <n>] [--idle <minutes>] [--only] [--exclude <n>[,<n>...]] [--await <n>[,<n>...]] [--window <minutes>]'
53
+ 'Usage: josh backlog:drive --owner <pid> [--active <ISO-8601>] [--max <n>] [--idle <minutes>] [--only] [--exclude <n>[,<n>...]] [--await <n>[,<n>...]] [--window <minutes>] [--stopped <n>]'
53
54
 
54
55
  const OPTIONS = {
55
56
  active: { type: 'string' },
@@ -59,6 +60,7 @@ const OPTIONS = {
59
60
  max: { type: 'string' },
60
61
  only: { type: 'boolean' },
61
62
  owner: { type: 'string' },
63
+ stopped: { type: 'string' },
62
64
  window: { type: 'string' },
63
65
  } as const
64
66
 
@@ -72,16 +74,14 @@ interface DriveContext {
72
74
  window_ms: number | undefined
73
75
  window: string | undefined
74
76
  is_only: boolean
77
+ // The child whose merge answered `stop` before the hand-back: the resumed run starts already stopping.
78
+ stopped: string | undefined
75
79
  }
76
80
 
77
81
  type Values = Partial<Record<Exclude<keyof typeof OPTIONS, 'only'>, string>> & { only?: boolean }
78
82
 
79
83
  function read_values(argv: ReadonlyArray<string>): Values | undefined {
80
- try {
81
- return parseArgs({ args: [...argv], options: OPTIONS, strict: true }).values
82
- } catch {
83
- return undefined
84
- }
84
+ return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, strict: true })?.values
85
85
  }
86
86
 
87
87
  // A comma list of issue numbers; one entry that is not an issue number refuses the whole list.
@@ -110,6 +110,12 @@ function is_valid_window(raw: string | undefined): boolean {
110
110
  return raw === undefined || COUNT_PATTERN.test(raw)
111
111
  }
112
112
 
113
+ function is_valid_single(values: Values): boolean {
114
+ const is_valid_stopped = values.stopped === undefined || ISSUE_PATTERN.test(values.stopped)
115
+
116
+ return is_valid_stopped && is_valid_window(values.window)
117
+ }
118
+
113
119
  function owner_of(values: Values | undefined): string | undefined {
114
120
  const owner = values?.owner
115
121
 
@@ -117,7 +123,7 @@ function owner_of(values: Values | undefined): string | undefined {
117
123
  }
118
124
 
119
125
  function to_context(values: Values, owner: string): DriveContext | undefined {
120
- if (!is_valid_window(values.window)) return undefined
126
+ if (!is_valid_single(values)) return undefined
121
127
 
122
128
  const forwarded = forwarded_of(values)
123
129
  const exclude = to_issues(values.exclude)
@@ -136,6 +142,7 @@ function to_context(values: Values, owner: string): DriveContext | undefined {
136
142
  window_ms,
137
143
  window: values.window,
138
144
  is_only: values.only === true,
145
+ stopped: values.stopped,
139
146
  }
140
147
  }
141
148
 
@@ -274,6 +281,7 @@ function resume_line(state: DriveState, context: DriveContext): string {
274
281
 
275
282
  if (exclude.length > 0) flags.push('--exclude', exclude.join(LIST_SEPARATOR))
276
283
  if (context.window !== undefined) flags.push('--window', context.window)
284
+ if (state.stopped_by !== undefined) flags.push('--stopped', state.stopped_by)
277
285
 
278
286
  return `resume: ${flags.join(' ')}`
279
287
  }
@@ -325,7 +333,9 @@ async function drive(context: DriveContext): Promise<number> {
325
333
  const seeded = await seeded_lanes(context)
326
334
 
327
335
  if (seeded === undefined) return FAILURE_EXIT_CODE
328
- const initial = backlog_drive.initial_state(seeded, context.active ?? new Date().toISOString())
336
+ const fresh = backlog_drive.initial_state(seeded, context.active ?? new Date().toISOString())
337
+ const initial =
338
+ context.stopped === undefined ? fresh : backlog_drive.merge_stopped(context.stopped, fresh)
329
339
  const last = { state: { ...initial, exclude: [...context.exclude] } }
330
340
  const config = { poll_ms: POLL_MS, offer_ms: OFFER_MS, window_ms: context.window_ms }
331
341
  const end = await backlog_drive.run_loop(last.state, config, ports_of(context, seeded, last))
@@ -340,7 +350,7 @@ async function run_safe(context: DriveContext): Promise<number> {
340
350
  try {
341
351
  return await drive(context)
342
352
  } catch (error) {
343
- console.info(`error ${error instanceof Error ? error.message : String(error)}`)
353
+ console.info(`error ${error_text.message_of(error)}`)
344
354
 
345
355
  return FAILURE_EXIT_CODE
346
356
  }
@@ -16,19 +16,22 @@
16
16
  // **Everything it touches is a port**, the pattern `run-wake-loop.ts` set, so the sequencing is pinned
17
17
  // without a process, a network or a clock.
18
18
 
19
- // The `run:merge` tokens that need the parent: a hand-off, a person's stop, a tripped guard, a foreign
20
- // owner, an unreadable state. `resumed` is not among them — the same lane is awaited again.
21
- const HANDOFF_TOKENS: ReadonlySet<string> = new Set([
19
+ const RUN_VERDICT = 'run'
20
+ const STOP_VERDICT = 'stop'
21
+ // The `run:merge` tokens that end the loop at once: a hand-off, a person's stop, the environment down,
22
+ // a foreign owner, an unreadable state. `resumed` is not among them — the same lane is awaited again.
23
+ const IMMEDIATE_TOKENS: ReadonlySet<string> = new Set([
22
24
  'over',
23
25
  'human-review',
24
- 'stop',
25
26
  'environment',
26
27
  'busy',
27
28
  'retry',
28
29
  ])
30
+ // Every token `run:merge` prints first even on a non-zero exit. `stop` — a parked backlog or the failure
31
+ // guard — is the offer's `stop` reached through a merge: the child is collected, nothing more starts,
32
+ // and the children still in flight are collected before the loop ends (joshuafolkken/kit#2881).
33
+ const HANDOFF_TOKENS: ReadonlySet<string> = new Set([...IMMEDIATE_TOKENS, STOP_VERDICT])
29
34
  const RESUMED_TOKEN = 'resumed'
30
- const RUN_VERDICT = 'run'
31
- const STOP_VERDICT = 'stop'
32
35
  const WATCH_VERDICT = 'watch'
33
36
  // The verdicts the loop acts on without the parent. Any other is handed back as it was printed.
34
37
  const KNOWN_VERDICTS: ReadonlySet<string> = new Set([
@@ -63,6 +66,8 @@ interface DriveState {
63
66
  is_stopping: boolean
64
67
  stop_reason?: string | undefined
65
68
  stop_is_finish?: boolean | undefined
69
+ // The child whose merge answered `stop`, carried on the resume line: no offer re-derives that stop.
70
+ stopped_by?: string | undefined
66
71
  }
67
72
 
68
73
  interface DriveEnd {
@@ -133,22 +138,43 @@ function initial_state(in_flight: ReadonlyArray<string>, active: string): DriveS
133
138
  }
134
139
  }
135
140
 
136
- // One finished child: collected, excluded, and — unless its cut was resumed in place — out of flight.
141
+ // The stop a merge reached: kept as the first stop's reason when an offer already stopped the run.
142
+ function merge_stopped(issue: string, state: DriveState): DriveState {
143
+ if (state.is_stopping) return state
144
+
145
+ return {
146
+ ...state,
147
+ is_stopping: true,
148
+ stop_reason: `run:merge #${issue} answered stop`,
149
+ stopped_by: issue,
150
+ }
151
+ }
152
+
153
+ // The child collected and excluded, and — unless its cut was resumed in place — out of flight.
154
+ function collected_state(
155
+ issue: string,
156
+ token: string,
157
+ state: DriveState,
158
+ active: string,
159
+ ): DriveState {
160
+ const in_flight =
161
+ token === RESUMED_TOKEN ? state.in_flight : state.in_flight.filter((item) => item !== issue)
162
+ const exclude = state.exclude.includes(issue) ? state.exclude : [...state.exclude, issue]
163
+
164
+ return { ...state, in_flight, exclude, active }
165
+ }
166
+
167
+ // One finished child: handed back on an immediate token, otherwise collected.
137
168
  async function collect(issue: string, state: DriveState, ports: DrivePorts): Promise<PassResult> {
138
169
  const token = await ports.merge(issue)
139
170
 
140
171
  // An empty first line is a `run:merge` that failed before printing one: never read as a collection.
141
172
  if (token === '') return ended('merge', state, undefined, issue)
142
- if (HANDOFF_TOKENS.has(token)) return ended('merge', state, token, issue)
173
+ if (IMMEDIATE_TOKENS.has(token)) return ended('merge', state, token, issue)
143
174
 
144
- const in_flight =
145
- token === RESUMED_TOKEN ? state.in_flight : state.in_flight.filter((item) => item !== issue)
146
- const exclude = state.exclude.includes(issue) ? state.exclude : [...state.exclude, issue]
175
+ const next = collected_state(issue, token, state, ports.now().toISOString())
147
176
 
148
- return {
149
- kind: 'continue',
150
- state: { ...state, in_flight, exclude, active: ports.now().toISOString() },
151
- }
177
+ return { kind: 'continue', state: token === STOP_VERDICT ? merge_stopped(issue, next) : next }
152
178
  }
153
179
 
154
180
  async function collect_finished(state: DriveState, ports: DrivePorts): Promise<PassResult> {
@@ -320,7 +346,15 @@ async function run_loop(
320
346
  return step.end
321
347
  }
322
348
 
323
- const backlog_drive = { HANDOFF_TOKENS, collect, initial_state, on_verdict, run_loop, run_pass }
349
+ const backlog_drive = {
350
+ HANDOFF_TOKENS,
351
+ collect,
352
+ initial_state,
353
+ merge_stopped,
354
+ on_verdict,
355
+ run_loop,
356
+ run_pass,
357
+ }
324
358
 
325
359
  export type { DriveEnd, DrivePorts, DriveState, LoopConfig, LoopPorts, OfferRead, PassResult }
326
360
  export { backlog_drive }
@@ -2,7 +2,7 @@
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { parseArgs } from 'node:util'
4
4
  import { josh_command } from '#scripts/josh/josh-run'
5
- import { rule_value_cli } from '#scripts/rules/rule-value-cli'
5
+ import { rule_value_cache } from '#scripts/rules/rule-value-cache'
6
6
  import { run_event_stream } from '#scripts/run/run-event-stream'
7
7
  import { run_event_stream_emit } from '#scripts/run/run-event-stream-emit'
8
8
  import { z } from 'zod'
@@ -242,11 +242,12 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
242
242
 
243
243
  if (values === undefined) return refuse()
244
244
 
245
- // The loop head is the one place `rule:value` is called from: once per iteration, the parent's
245
+ // The loop head is the one place `rule:value` is read from: once per iteration, the parent's
246
246
  // context still small, so a rule that never fires shows up as a printed row rather than as
247
- // something a person has to remember to measure (joshuafolkken/kit#2271). It reports to stderr and
248
- // never fails, so a broken reading never stops the backlog.
249
- rule_value_cli.emit()
247
+ // something a person has to remember to measure (joshuafolkken/kit#2271). It prints the cached
248
+ // reading and refreshes it in a detached process, so the offer never waits on the measurement
249
+ // (joshuafolkken/kit#2881), and it never fails, so a broken reading never stops the backlog.
250
+ rule_value_cache.emit()
250
251
 
251
252
  const counts = counts_of(values)
252
253
 
@@ -1,8 +1,8 @@
1
1
  import type { EpicChild } from '#scripts/epic/epic-graph'
2
+ import type { IssueReference } from '#scripts/epic/epic-reference'
2
3
  import { epic_report, type EpicNextResult } from '#scripts/epic/epic-report'
3
4
  import { epic_solo } from '#scripts/epic/epic-solo'
4
5
  import { epic_triage } from '#scripts/epic/epic-triage'
5
- import type { IssueReference } from '#scripts/git/git-epic-reference'
6
6
  import { has_label_name, IN_PROGRESS_LABEL, RUN_SOLO_LABEL } from '#scripts/git/issue-labels'
7
7
  import type { OutOfScopeRow } from './backlog-scope'
8
8
 
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import path from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
+ import { error_text } from '#scripts/lib/error-message'
4
5
  import { build } from 'esbuild'
5
6
 
6
7
  const PACKAGE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
@@ -26,7 +27,7 @@ async function main(): Promise<void> {
26
27
  await build_bin()
27
28
  console.info(` ✔ ${OUTFILE} built`)
28
29
  } catch (error) {
29
- console.error(error instanceof Error ? error.message : String(error))
30
+ console.error(error_text.message_of(error))
30
31
  process.exit(1)
31
32
  }
32
33
  }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { error_text } from '#scripts/lib/error-message'
3
4
  import { build_library, library_paths } from './build-library'
4
5
 
5
6
  const CONFIG_MERGE_LIBRARY = 'config-merge'
@@ -16,7 +17,7 @@ async function main(): Promise<void> {
16
17
  console.info(` ✔ ${CONFIG_MERGE_OUTFILE} built`)
17
18
  console.info(` ✔ ${CONFIG_MERGE_DTS_FILE} built`)
18
19
  } catch (error) {
19
- console.error(error instanceof Error ? error.message : String(error))
20
+ console.error(error_text.message_of(error))
20
21
  process.exit(1)
21
22
  }
22
23
  }
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env tsx
2
+ import { readdirSync, rmSync } from 'node:fs'
2
3
  import path from 'node:path'
3
4
  import { fileURLToPath } from 'node:url'
5
+ import { error_text } from '#scripts/lib/error-message'
4
6
  import { build } from 'esbuild'
5
7
 
6
8
  const PACKAGE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
@@ -50,17 +52,35 @@ function outfile_for(bundle: HookBundle): string {
50
52
  return path.join(OUT_DIR, `${bundle.out}.js`)
51
53
  }
52
54
 
53
- async function build_hooks(): Promise<void> {
54
- await build({
55
+ // The output directory is flat (every entry and chunk sits at its top level), so a file whose name
56
+ // this build did not emit is a leftover of an earlier one.
57
+ function prune_stale_outputs(out_directory: string, outputs: ReadonlyArray<string>): void {
58
+ const live = new Set(outputs.map((output) => path.basename(output)))
59
+
60
+ for (const name of readdirSync(out_directory)) {
61
+ if (!live.has(name)) rmSync(path.join(out_directory, name), { recursive: true, force: true })
62
+ }
63
+ }
64
+
65
+ // With `splitting: true` every build emits freshly hashed `chunk-*.js` files, so without a cleanup
66
+ // the chunks of every earlier build pile up beside the live ones and ship in any local pack
67
+ // (joshuafolkken/kit#2885). The cleanup runs after the build rather than emptying the directory
68
+ // first: this checkout's own settings.json launches `node dist/hooks/<name>.js` on every tool call,
69
+ // and an emptied directory would make a hook launched mid-build fail to load and its guard fail open.
70
+ async function build_hooks(out_directory: string = OUT_DIR): Promise<void> {
71
+ const { metafile } = await build({
55
72
  entryPoints: HOOK_BUNDLES.map((bundle) => entry_point(bundle)),
56
- outdir: OUT_DIR,
73
+ outdir: out_directory,
57
74
  bundle: true,
58
75
  splitting: true,
59
76
  platform: 'node',
60
77
  format: 'esm',
61
78
  target: 'node22',
62
79
  packages: 'external',
80
+ metafile: true,
63
81
  })
82
+
83
+ prune_stale_outputs(out_directory, Object.keys(metafile.outputs))
64
84
  }
65
85
 
66
86
  async function main(): Promise<void> {
@@ -68,7 +88,7 @@ async function main(): Promise<void> {
68
88
  await build_hooks()
69
89
  for (const bundle of HOOK_BUNDLES) console.info(` ✔ ${outfile_for(bundle)} built`)
70
90
  } catch (error) {
71
- console.error(error instanceof Error ? error.message : String(error))
91
+ console.error(error_text.message_of(error))
72
92
  process.exit(1)
73
93
  }
74
94
  }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { error_text } from '#scripts/lib/error-message'
3
4
  import { build_library, library_paths } from './build-library'
4
5
 
5
6
  const MANAGED_MARKER_LIBRARY = 'managed-marker'
@@ -16,7 +17,7 @@ async function main(): Promise<void> {
16
17
  console.info(` ✔ ${MANAGED_MARKER_OUTFILE} built`)
17
18
  console.info(` ✔ ${MANAGED_MARKER_DTS_FILE} built`)
18
19
  } catch (error) {
19
- console.error(error instanceof Error ? error.message : String(error))
20
+ console.error(error_text.message_of(error))
20
21
  process.exit(1)
21
22
  }
22
23
  }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { error_text } from '#scripts/lib/error-message'
3
4
  import { build_library, library_paths } from './build-library'
4
5
 
5
6
  const SELF_SYNC_GUARD_LIBRARY = 'self-sync-guard'
@@ -16,7 +17,7 @@ async function main(): Promise<void> {
16
17
  console.info(` ✔ ${SELF_SYNC_GUARD_OUTFILE} built`)
17
18
  console.info(` ✔ ${SELF_SYNC_GUARD_DTS_FILE} built`)
18
19
  } catch (error) {
19
- console.error(error instanceof Error ? error.message : String(error))
20
+ console.error(error_text.message_of(error))
20
21
  process.exit(1)
21
22
  }
22
23
  }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { error_text } from '#scripts/lib/error-message'
3
4
  import { build_library, library_paths } from './build-library'
4
5
 
5
6
  const VERSION_LIBRARY = 'version'
@@ -15,7 +16,7 @@ async function main(): Promise<void> {
15
16
  console.info(` ✔ ${VERSION_OUTFILE} built`)
16
17
  console.info(` ✔ ${VERSION_DTS_FILE} built`)
17
18
  } catch (error) {
18
- console.error(error instanceof Error ? error.message : String(error))
19
+ console.error(error_text.message_of(error))
19
20
  process.exit(1)
20
21
  }
21
22
  }
@@ -1,4 +1,4 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { file_reader } from '#scripts/lib/read-file'
2
2
 
3
3
  // `josh cases <path...>` (joshuafolkken/kit#2246): read the changed paths and answer which I/O
4
4
  // boundaries the code crosses — network, process start, filesystem — then name the abnormal cases
@@ -63,11 +63,7 @@ const BOUNDARIES: ReadonlyArray<BoundaryDefinition> = [
63
63
  // A path that cannot be read counts as no boundary rather than an error: a deleted or renamed path in
64
64
  // a diff is still passed in, and the answer for it is simply "nothing crossed here".
65
65
  function read_file(path: string): string {
66
- try {
67
- return readFileSync(path, 'utf8')
68
- } catch {
69
- return ''
70
- }
66
+ return file_reader.read_if_readable(path) ?? ''
71
67
  }
72
68
 
73
69
  // The distinct boundaries any of the paths cross. The content is joined and each boundary is tested
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { git_gh_exec } from '#scripts/git/git-gh-exec'
3
4
  import {
4
5
  BREAKING_CHANGE_LABEL as BREAKING,
5
6
  BUGFIX_LABEL as BUGFIX,
@@ -7,7 +8,7 @@ import {
7
8
  ENHANCEMENT_LABEL,
8
9
  type ReleaseClassification,
9
10
  } from '#scripts/git/issue-labels'
10
- import { execaSync } from 'execa'
11
+ import { error_text } from '#scripts/lib/error-message'
11
12
  import { z } from 'zod'
12
13
 
13
14
  const CLASSIFICATION_SET: ReadonlySet<string> = new Set(CLASSIFICATION_LABELS)
@@ -95,17 +96,17 @@ function select_issue_classification(issue_json: string): ReleaseClassification
95
96
  // payload, that run fails, and when it lands in the newer check suite GitHub keeps its red result —
96
97
  // a rerun replays the same payload and fails again (joshuafolkken/kit#2712).
97
98
  function fetch_current_labels(repository: string, pull_number: number): ReadonlyArray<string> {
98
- const result = execaSync(
99
- 'gh',
100
- ['api', `repos/${repository}/issues/${String(pull_number)}/labels`, '--jq', '.[].name'],
101
- { reject: false },
102
- )
103
-
104
- if (result.exitCode !== 0) {
105
- throw new Error(`Could not read the pull request labels: ${result.stderr}`)
106
- }
99
+ const path = `repos/${repository}/issues/${String(pull_number)}/labels`
100
+
101
+ try {
102
+ const names = git_gh_exec.exec_gh_api_sync({ path, jq_filter: '.[].name' })
107
103
 
108
- return result.stdout.split('\n').filter((name) => name !== '')
104
+ return names.split('\n').filter((name) => name !== '')
105
+ } catch (error) {
106
+ const detail = error_text.message_of(error)
107
+
108
+ throw new Error(`Could not read the pull request labels: ${detail}`, { cause: error })
109
+ }
109
110
  }
110
111
 
111
112
  function check_event(
@@ -133,6 +134,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
133
134
  const pr_classification = {
134
135
  classification_error,
135
136
  check_event,
137
+ fetch_current_labels,
136
138
  declaration,
137
139
  is_classification,
138
140
  select_issue_classification,
@@ -2,6 +2,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { lane_paths } from '#scripts/lane/lane-paths'
5
+ import { file_reader } from '#scripts/lib/read-file'
5
6
  import { cost_usage, type UsageRecord } from './cost-usage'
6
7
 
7
8
  // Finding and reading Claude Code's session transcripts (joshuafolkken/kit#962).
@@ -404,16 +405,8 @@ function tally(content: string): Omit<SessionUsage, 'session_id'> {
404
405
  }
405
406
  }
406
407
 
407
- function read_text(file_path: string): string | undefined {
408
- try {
409
- return readFileSync(file_path, 'utf8')
410
- } catch {
411
- return undefined
412
- }
413
- }
414
-
415
408
  function read_session(file: SessionFile): SessionUsage {
416
- const content = read_text(file.path)
409
+ const content = file_reader.read_if_readable(file.path)
417
410
 
418
411
  if (content === undefined) return { session_id: file.session_id, ...empty_session() }
419
412
 
@@ -426,7 +419,7 @@ function read_session(file: SessionFile): SessionUsage {
426
419
  // "could not be read" is already reported through `is_readable`, and throwing here would turn one
427
420
  // missing session into a failed command.
428
421
  function read_raw(file: SessionFile): string {
429
- return read_text(file.path) ?? ''
422
+ return file_reader.read_if_readable(file.path) ?? ''
430
423
  }
431
424
 
432
425
  // The same read, with the failure left visible (joshuafolkken/kit#1439). **An unreadable transcript
@@ -435,7 +428,7 @@ function read_raw(file: SessionFile): string {
435
428
  // measured* rather than as measured at zero. `is_readable` on `SessionUsage` is the same distinction
436
429
  // on the cost side; this is it for a caller that wants the text.
437
430
  function read_optional(file: SessionFile): string | undefined {
438
- return read_text(file.path)
431
+ return file_reader.read_if_readable(file.path)
439
432
  }
440
433
 
441
434
  // One directory the search looked in, and whether it was there to look in. `exists` tells a directory
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { file_reader } from '#scripts/lib/read-file'
3
4
  import { read_spawn_stdout } from '#scripts/lib/spawn-exit'
4
5
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
5
6
  import { safe_json_parse } from '#scripts/version/parse-json'
@@ -36,16 +37,8 @@ const STATE_TARGET_MISSING = 'target-missing'
36
37
  const STATE_MISSING = 'missing'
37
38
  type ClaudeMdState = typeof STATE_OK | typeof STATE_TARGET_MISSING | typeof STATE_MISSING
38
39
 
39
- function read_optional(file_path: string): string | undefined {
40
- try {
41
- return existsSync(file_path) ? readFileSync(file_path, 'utf8') : undefined
42
- } catch {
43
- return undefined
44
- }
45
- }
46
-
47
40
  function parse_manifest(root: string): ManifestShape | undefined {
48
- const content = read_optional(path.join(root, 'package.json'))
41
+ const content = file_reader.read_if_readable(path.join(root, 'package.json'))
49
42
  if (content === undefined) return undefined
50
43
  const parsed = manifest_schema.safeParse(safe_json_parse(content))
51
44
 
@@ -74,7 +67,7 @@ function is_kit_consumer(root: string): boolean {
74
67
  }
75
68
 
76
69
  function is_plugin_declared(root: string): boolean {
77
- const content = read_optional(path.join(root, SETTINGS_PATH))
70
+ const content = file_reader.read_if_readable(path.join(root, SETTINGS_PATH))
78
71
  if (content === undefined) return false
79
72
  const parsed = settings_schema.safeParse(safe_json_parse(content))
80
73
 
@@ -94,7 +87,7 @@ function configured_hooks_path(root: string): string | undefined {
94
87
  }
95
88
 
96
89
  function claude_md_state(root: string): ClaudeMdState {
97
- const content = read_optional(path.join(root, CLAUDE_MD))
90
+ const content = file_reader.read_if_readable(path.join(root, CLAUDE_MD))
98
91
  if (content === undefined) return STATE_MISSING
99
92
  if (!content.includes(CLAUDE_MD_POINTER)) return STATE_OK
100
93