@joshuafolkken/kit 1.1387.0 → 1.1435.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 (236) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +1 -1
  2. package/.claude/skills/epic-commands/epic-bundle.md +5 -0
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +5 -3
  4. package/.claude/skills/workflow-commands/backlogrun-steps.md +4 -3
  5. package/.claude/skills/workflow-commands/backlogrun.md +1 -0
  6. package/.claude/skills/workflow-commands/chain-rule.md +4 -2
  7. package/.claude/skills/workflow-commands/issue-comments.md +6 -0
  8. package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
  9. package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
  10. package/README.md +25 -0
  11. package/dist/hooks/audit-provision.js +2 -2
  12. package/dist/hooks/batch-guard.js +7 -7
  13. package/dist/hooks/{chunk-IYVOLN6A.js → chunk-2G5F2YCD.js} +3 -3
  14. package/dist/hooks/{chunk-2YSW3CXH.js → chunk-3LE6VLSR.js} +17 -6
  15. package/dist/hooks/{chunk-J3KSS7II.js → chunk-4BHUPFKF.js} +1 -1
  16. package/dist/hooks/{chunk-EAQEGME7.js → chunk-5SJSSCJG.js} +1 -1
  17. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  18. package/dist/hooks/{chunk-LMWBRJIP.js → chunk-7KMN5QB5.js} +3 -3
  19. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  20. package/dist/hooks/{chunk-STDSEPWU.js → chunk-EMB55BDF.js} +4 -4
  21. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  22. package/dist/hooks/{chunk-GJ7JPIHN.js → chunk-JXQBK323.js} +2674 -187
  23. package/dist/hooks/{chunk-DCY2GWHC.js → chunk-MSYSUSY6.js} +5 -5
  24. package/dist/hooks/{chunk-EBSHN25W.js → chunk-MU4LJFIV.js} +4 -4
  25. package/dist/hooks/{chunk-WNFJLBXI.js → chunk-QL6QTPWN.js} +3 -3
  26. package/dist/hooks/{chunk-SL4GQ2XP.js → chunk-XER6SSIB.js} +91 -18
  27. package/dist/hooks/{chunk-U32VSRNG.js → chunk-YW452ZAX.js} +89 -34
  28. package/dist/hooks/{chunk-GNDJ7NJ7.js → chunk-YZXYOAZ3.js} +202 -55
  29. package/dist/hooks/codex-hook-adapter.js +15 -15
  30. package/dist/hooks/duplicate-read-guard.js +8 -8
  31. package/dist/hooks/format-edited.js +5 -5
  32. package/dist/hooks/inputs.json +288 -229
  33. package/dist/hooks/investigation-guard.js +8 -8
  34. package/dist/hooks/pretool-guard.js +14 -14
  35. package/dist/hooks/session-lang.js +69 -16
  36. package/dist/hooks/stop-guard.js +13 -38
  37. package/dist/josh.js +62 -9
  38. package/package.json +4 -3
  39. package/prompts/review.md +22 -0
  40. package/scripts/agent/claude-agent-argv.ts +13 -1
  41. package/scripts/auth/auth-setup-cli.ts +101 -0
  42. package/scripts/auth/claude-token-output.ts +78 -0
  43. package/scripts/auth/claude-token.ts +68 -0
  44. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  45. package/scripts/backlog/backlog-drive-launch.ts +3 -0
  46. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  47. package/scripts/backlog/backlog-drive-named.ts +4 -2
  48. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  49. package/scripts/backlog/backlog-drive.ts +16 -8
  50. package/scripts/backlog/backlog-overlap.ts +22 -5
  51. package/scripts/backlog/backlog-ready.ts +1 -3
  52. package/scripts/backlog/backlogrun-args.ts +13 -2
  53. package/scripts/backlog/backlogrun-cli.ts +91 -19
  54. package/scripts/backlog/backlogrun-launch.ts +24 -0
  55. package/scripts/backlog/backlogrun-replace.ts +166 -0
  56. package/scripts/backlog/backlogrun-stop.ts +178 -0
  57. package/scripts/behavior/behavior-cli.ts +6 -1
  58. package/scripts/doctor/doctor-io.ts +60 -9
  59. package/scripts/doctor/doctor.ts +20 -1
  60. package/scripts/document/document-byte-budget.ts +3 -3
  61. package/scripts/epic/epic-bundle-cli.ts +8 -8
  62. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  63. package/scripts/epic/epic-bundle.ts +39 -4
  64. package/scripts/exports/unused-members-cli.ts +2 -1
  65. package/scripts/followup/followup-checks-event.ts +82 -0
  66. package/scripts/followup/git-followup-finish.ts +16 -2
  67. package/scripts/followup/git-followup-workflow.ts +5 -2
  68. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  69. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  70. package/scripts/followup/git-pr-followup.ts +4 -1
  71. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  72. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  73. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  74. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  75. package/scripts/fresh-machine/fresh-machine-patterns.ts +130 -0
  76. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  77. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  78. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  79. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  80. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  81. package/scripts/gate/gate-report.ts +70 -15
  82. package/scripts/gate/project-checks.ts +13 -2
  83. package/scripts/gate/scoped-green.ts +2 -1
  84. package/scripts/gate/verification-gate.ts +4 -3
  85. package/scripts/gh/gh-author-association.ts +14 -0
  86. package/scripts/gh/git-gh-conditional.ts +66 -0
  87. package/scripts/gh/git-gh-exec.ts +1 -1
  88. package/scripts/gh/git-gh-issue-read.ts +6 -3
  89. package/scripts/gh/git-gh-issue-write.ts +5 -1
  90. package/scripts/gh/git-gh-issue.ts +4 -1
  91. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  92. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  93. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  94. package/scripts/gh/git-pr-checks.ts +36 -2
  95. package/scripts/git/git-command.ts +16 -4
  96. package/scripts/git/git-schemas.ts +1 -0
  97. package/scripts/git/git-spawn-sync.ts +1 -1
  98. package/scripts/hooks/pretool-guard.ts +3 -1
  99. package/scripts/init/hook-command-rewrite.ts +31 -0
  100. package/scripts/init/init-actions.ts +4 -1
  101. package/scripts/init/init-ai-copy.ts +33 -10
  102. package/scripts/init/init-basic.ts +8 -3
  103. package/scripts/init/init-logic-toolchain.ts +76 -0
  104. package/scripts/init/init-logic.ts +2 -0
  105. package/scripts/init/init.ts +5 -23
  106. package/scripts/init/project-profile.ts +36 -7
  107. package/scripts/init/start-exec.ts +27 -1
  108. package/scripts/init/start-plan.ts +15 -0
  109. package/scripts/init/start-steps.ts +46 -0
  110. package/scripts/init/start.ts +1 -0
  111. package/scripts/init/workflow-mark.ts +35 -0
  112. package/scripts/issue/issue-ack-cli.ts +84 -0
  113. package/scripts/issue/issue-labels.ts +40 -8
  114. package/scripts/issue/issue-state-cli.ts +14 -4
  115. package/scripts/issue/issue-watch-diff.ts +152 -0
  116. package/scripts/issue/issue-watch-gate.ts +81 -0
  117. package/scripts/issue/issue-watch-hook.ts +54 -0
  118. package/scripts/issue/issue-watch.ts +240 -0
  119. package/scripts/issue/measure-rerun-cli.ts +3 -4
  120. package/scripts/josh/josh-command-map.ts +4 -0
  121. package/scripts/josh/josh-commands-backlog.ts +2 -2
  122. package/scripts/josh/josh-commands-development.ts +1 -1
  123. package/scripts/josh/josh-commands-issue.ts +7 -0
  124. package/scripts/josh/josh-commands-lane.ts +11 -2
  125. package/scripts/josh/josh-commands-project.ts +24 -0
  126. package/scripts/josh/josh-verdict.ts +33 -4
  127. package/scripts/lane/claude-lane-broker.ts +142 -0
  128. package/scripts/lane/lane-await.ts +6 -2
  129. package/scripts/lane/lane-cache.ts +37 -7
  130. package/scripts/lane/lane-capacity.ts +8 -6
  131. package/scripts/lane/lane-child-invocation.ts +1 -0
  132. package/scripts/lane/lane-cli.ts +15 -1
  133. package/scripts/lane/lane-close.ts +15 -6
  134. package/scripts/lane/lane-dispatch.ts +36 -10
  135. package/scripts/lane/lane-environment.ts +1 -1
  136. package/scripts/lane/lane-launch-cli.ts +13 -4
  137. package/scripts/lane/lane-ledger.ts +21 -3
  138. package/scripts/lane/lane-measure-cli.ts +10 -2
  139. package/scripts/lane/lane-open.ts +9 -1
  140. package/scripts/lane/lane-preserve.ts +49 -0
  141. package/scripts/lane/lane-reap.ts +20 -5
  142. package/scripts/lane/lane-registry.ts +6 -0
  143. package/scripts/lane/lane-restart-cli.ts +51 -0
  144. package/scripts/lane/lane-restart.ts +175 -0
  145. package/scripts/lane/{openai-review-broker.ts → lane-session-broker.ts} +106 -28
  146. package/scripts/lane/lane-spent.ts +195 -0
  147. package/scripts/lane/lane-stage-stats.ts +27 -3
  148. package/scripts/lane/lane-stop-stats.ts +96 -0
  149. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  150. package/scripts/lane/process-tools.ts +31 -0
  151. package/scripts/lib/buffered-process.ts +36 -2
  152. package/scripts/lib/pnpm-reach.ts +27 -0
  153. package/scripts/lib/status-icons.ts +5 -1
  154. package/scripts/lint/lint-parallel.ts +19 -2
  155. package/scripts/observations/observation-ledger-home.ts +1 -0
  156. package/scripts/review/review-attest-cli.ts +21 -6
  157. package/scripts/review/review-brief-cli.ts +2 -2
  158. package/scripts/review/review-comment-round.ts +114 -0
  159. package/scripts/review/review-record-cli.ts +39 -17
  160. package/scripts/rules/implementation-cut.ts +3 -3
  161. package/scripts/rules/pre-gate-cut.ts +1 -1
  162. package/scripts/run/board/run-board-checks.ts +42 -0
  163. package/scripts/run/board/run-board-cli.ts +2 -0
  164. package/scripts/run/board/run-board-every.ts +3 -1
  165. package/scripts/run/board/run-board-fit.ts +16 -5
  166. package/scripts/run/board/run-board-fresh.ts +64 -0
  167. package/scripts/run/board/run-board-header.ts +129 -53
  168. package/scripts/run/board/run-board-labels.ts +47 -4
  169. package/scripts/run/board/run-board-lane-event.ts +66 -0
  170. package/scripts/run/board/run-board-layout.ts +57 -9
  171. package/scripts/run/board/run-board-now.ts +234 -0
  172. package/scripts/run/board/run-board-operation-read.ts +75 -0
  173. package/scripts/run/board/run-board-operation.ts +150 -0
  174. package/scripts/run/board/run-board-phase.ts +26 -1
  175. package/scripts/run/board/run-board-plan.ts +4 -6
  176. package/scripts/run/board/run-board-read.ts +49 -3
  177. package/scripts/run/board/run-board-reason.ts +124 -0
  178. package/scripts/run/board/run-board-render-checks.ts +55 -0
  179. package/scripts/run/board/run-board-render-legend.ts +12 -3
  180. package/scripts/run/board/run-board-render-slot.ts +2 -2
  181. package/scripts/run/board/run-board-render-tail.ts +128 -0
  182. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  183. package/scripts/run/board/run-board-render-title.ts +58 -0
  184. package/scripts/run/board/run-board-render.ts +96 -80
  185. package/scripts/run/board/run-board-row-yield.ts +32 -0
  186. package/scripts/run/board/run-board-setup.ts +61 -13
  187. package/scripts/run/board/run-board-state.ts +10 -0
  188. package/scripts/run/board/run-board-status.ts +66 -7
  189. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  190. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  191. package/scripts/run/board/run-board-thanks.ts +53 -0
  192. package/scripts/run/board/run-board-tick.ts +50 -21
  193. package/scripts/run/board/run-board-usual.ts +44 -0
  194. package/scripts/run/carry/run-carry-args.ts +9 -1
  195. package/scripts/run/carry/run-carry-cli.ts +2 -45
  196. package/scripts/run/carry/run-carry-end.ts +73 -0
  197. package/scripts/run/carry/run-carry.ts +8 -1
  198. package/scripts/run/entry/run-entry-cli.ts +5 -0
  199. package/scripts/run/event/run-event-checks.ts +54 -0
  200. package/scripts/run/event/run-event-render.ts +2 -0
  201. package/scripts/run/event/run-event-scope.ts +1 -0
  202. package/scripts/run/event/run-event-setup.ts +55 -11
  203. package/scripts/run/event/run-event-stream.ts +27 -11
  204. package/scripts/run/merge/run-merge-cli.ts +7 -4
  205. package/scripts/run/merge/run-merge-ship.ts +61 -0
  206. package/scripts/run/merge/run-merge-steps.ts +7 -14
  207. package/scripts/run/merge/run-merge-token.ts +16 -2
  208. package/scripts/run/run-headless.ts +1 -0
  209. package/scripts/run/run-step.ts +2 -0
  210. package/scripts/run/ship/run-ship-cli.ts +22 -2
  211. package/scripts/run/ship/run-ship-return.ts +31 -13
  212. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  213. package/scripts/run/ship/run-ship-stage.ts +1 -0
  214. package/scripts/run/wake/run-wake-await.ts +130 -0
  215. package/scripts/run/wake/run-wake-cli.ts +60 -71
  216. package/scripts/run/wake/run-wake-describe.ts +34 -2
  217. package/scripts/run/wake/run-wake-driver.ts +11 -4
  218. package/scripts/run/wake/run-wake-session.ts +20 -3
  219. package/scripts/run/wake/run-wake-stop.ts +37 -0
  220. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  221. package/scripts/sync/claude-settings-merge.ts +144 -0
  222. package/scripts/sync/sync-ai-files.ts +28 -3
  223. package/scripts/sync/sync.ts +15 -0
  224. package/scripts/test/project-test-script.ts +73 -0
  225. package/scripts/test/skip-marker.ts +14 -1
  226. package/scripts/test/test-related.ts +3 -2
  227. package/scripts/test/test-unit-guard.ts +78 -14
  228. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  229. package/scripts/uninstall/distributed-files.ts +92 -0
  230. package/scripts/uninstall/uninstall-plan.ts +119 -0
  231. package/scripts/uninstall/uninstall-report.ts +61 -0
  232. package/scripts/uninstall/uninstall.ts +206 -0
  233. package/templates/AGENTS.basic.md +4 -0
  234. package/templates/GEMINI.basic.md +6 -0
  235. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  236. package/scripts/run/board/run-board-track.ts +0 -22
@@ -0,0 +1,66 @@
1
+ import { git_gh_exec, has_stdout_field } from './git-gh-exec'
2
+
3
+ // A `gh api` GET that sends the ETag of the previous answer, so a resource polled every few minutes
4
+ // costs nothing when it has not changed: GitHub answers `304 Not Modified` to a matching
5
+ // `If-None-Match`, and a conditional request answered 304 does not count against the rate limit.
6
+ //
7
+ // **`gh` exits non-zero on a 304** (it prints `gh: HTTP 304`), so the "unchanged" answer arrives as
8
+ // a thrown failure. The status is read off the `--include` status line the failure still carries in
9
+ // its stdout — the same reading `exec_gh_api_status` makes — never off the stderr wording.
10
+
11
+ const NOT_MODIFIED_STATUS = 304
12
+ const OK_STATUS = 200
13
+ const INCLUDE_FLAG = '--include'
14
+ const HEADER_FLAG = '-H'
15
+ const IF_NONE_MATCH = 'If-None-Match: '
16
+ const HEADER_BODY_SEPARATOR = /\r?\n\r?\n/u
17
+ const ETAG_HEADER = /^etag: *(?<etag>[^\r\n]+)/imu
18
+
19
+ type ConditionalRead =
20
+ | { kind: 'unchanged' }
21
+ | { kind: 'changed'; etag: string | undefined; body: string }
22
+ | { kind: 'unreadable' }
23
+
24
+ const UNCHANGED: ConditionalRead = { kind: 'unchanged' }
25
+ const UNREADABLE: ConditionalRead = { kind: 'unreadable' }
26
+
27
+ function to_args(path: string, etag: string | undefined): Array<string> {
28
+ const header = etag === undefined ? [] : [HEADER_FLAG, `${IF_NONE_MATCH}${etag}`]
29
+
30
+ return ['api', INCLUDE_FLAG, ...header, path]
31
+ }
32
+
33
+ // The headers and the body of an `--include` answer, split at the first blank line.
34
+ function to_changed(output: string): ConditionalRead {
35
+ const [headers = '', ...rest] = output.split(HEADER_BODY_SEPARATOR)
36
+ const etag = ETAG_HEADER.exec(headers)?.groups?.['etag']
37
+
38
+ return { kind: 'changed', etag, body: rest.join('\n\n') }
39
+ }
40
+
41
+ function to_read(output: string): ConditionalRead {
42
+ return git_gh_exec.parse_status_line(output) === OK_STATUS ? to_changed(output) : UNREADABLE
43
+ }
44
+
45
+ function failure_status(error: unknown): number | undefined {
46
+ const cause = error instanceof Error ? error.cause : undefined
47
+
48
+ return has_stdout_field(cause) ? git_gh_exec.parse_status_line(cause.stdout) : undefined
49
+ }
50
+
51
+ async function read_conditional(
52
+ path: string,
53
+ etag: string | undefined,
54
+ timeout_ms?: number,
55
+ ): Promise<ConditionalRead> {
56
+ try {
57
+ return to_read(await git_gh_exec.exec_gh_command(to_args(path, etag), timeout_ms))
58
+ } catch (error) {
59
+ return failure_status(error) === NOT_MODIFIED_STATUS ? UNCHANGED : UNREADABLE
60
+ }
61
+ }
62
+
63
+ const git_gh_conditional = { read_conditional, to_args }
64
+
65
+ export type { ConditionalRead }
66
+ export { git_gh_conditional }
@@ -288,7 +288,7 @@ interface GhApiRequest {
288
288
  // second argument for the reason recorded beside that constant. `0` is
289
289
  // execa's own "no budget", kept for the one reader whose caller's writes depend on the answer
290
290
  // (`gh-spawn.ts`).
291
- timeout_ms?: number
291
+ timeout_ms?: number | undefined
292
292
  // The checkout gh runs in, which is what `{owner}/{repo}` in `path` resolves against. Absent means
293
293
  // the process's own directory.
294
294
  cwd?: string
@@ -350,11 +350,14 @@ async function issue_get_labels_and_body(issue_number: string): Promise<string |
350
350
  // No `repo` parameter, unlike the reads above: the auto-close lists its candidate epics from the
351
351
  // repository it runs in, so the epic whose comments are read is always a local one. The path builder
352
352
  // takes one when a caller that needs it appears.
353
- async function issue_list_comments(issue_number: string): Promise<string | undefined> {
353
+ async function issue_list_comments(
354
+ issue_number: string,
355
+ timeout_ms?: number,
356
+ ): Promise<string | undefined> {
354
357
  const path = `${git_gh_api_path.issue_comments_api_path(issue_number)}${git_gh_api_path.FULL_PAGE_QUERY}`
355
358
 
356
359
  try {
357
- return await git_gh_exec.exec_gh_api({ path, should_paginate: true })
360
+ return await git_gh_exec.exec_gh_api({ path, should_paginate: true, timeout_ms })
358
361
  } catch {
359
362
  return undefined
360
363
  }
@@ -436,5 +439,5 @@ const git_gh_issue_read = {
436
439
  issue_list_comments,
437
440
  }
438
441
 
439
- export type { IssueRead, IssueReadFailure }
442
+ export type { IssueRead, IssueReadFailure, IssueReadReason }
440
443
  export { git_gh_issue_read, read_blocked_by, NOT_FOUND_STATUS }
@@ -1,3 +1,4 @@
1
+ import { issue_watch_diff } from '#scripts/issue/issue-watch-diff'
1
2
  import { session_cite } from '#scripts/issue/session-cite'
2
3
  import { error_text } from '#scripts/lib/error-message'
3
4
  import { git_gh_api_path } from './git-gh-api-path'
@@ -64,10 +65,13 @@ async function issue_edit_body(issue_number: string, body: string): Promise<stri
64
65
  })
65
66
  }
66
67
 
68
+ // **Every comment josh posts carries `RUN_MARKER`**, an HTML comment GitHub does not render. A run
69
+ // posts under the person's own `gh` login, so the marker is the only thing that tells the issue watch
70
+ // (`issue-watch-diff.ts`) a run's comment from a person's update.
67
71
  async function issue_comment(issue_number: string, body: string): Promise<string> {
68
72
  return await git_gh_exec.exec_gh_api({
69
73
  path: `${git_gh_api_path.issue_api_path(issue_number)}${COMMENTS_SEGMENT}`,
70
- body: JSON.stringify({ body }),
74
+ body: JSON.stringify({ body: `${body}\n\n${issue_watch_diff.RUN_MARKER}` }),
71
75
  jq_filter: HTML_URL_FILTER,
72
76
  })
73
77
  }
@@ -14,7 +14,10 @@ const NUMBER_AND_BODY_FIELDS = 'number,body'
14
14
  // tell a row with no blockers from one with some **before** deciding whether that row is worth a
15
15
  // per-issue read, which would otherwise be made for every open issue in the repository. Absent on a
16
16
  // row whose summary is missing, which must not be read as zero; `git-gh-issue-rest.ts` states why.
17
- const BACKLOG_SCAN_FIELDS = `number,title,body,${BLOCKED_BY_COUNT_FIELD}`
17
+ //
18
+ // `labels` rides along on the same response too: `epic:bundle` reads the release label off it,
19
+ // because the release issue's blockers are recorded mechanically and signal no shared work.
20
+ const BACKLOG_SCAN_FIELDS = `number,title,body,labels,${BLOCKED_BY_COUNT_FIELD}`
18
21
  // The recently-closed scan's fields. It compares titles like the scan above
19
22
  // and never reads a body, and it needs the labels to tell an epic from a deliverable — the open half
20
23
  // gets that from the epic listing, which by construction holds no closed epic.
@@ -119,6 +119,18 @@ function is_coderabbit_check(check_name: string): boolean {
119
119
  return is_required_match(check_name, CODERABBIT_CHECK_NAME)
120
120
  }
121
121
 
122
+ // CodeRabbit reports a rate-limited review as a *passing* status whose description says so
123
+ // (`Review rate limited`), so only the description tells it from a finished review
124
+ // (joshuafolkken/kit#3770).
125
+ const CODERABBIT_RATE_LIMIT_DESCRIPTION = /rate limit/iu
126
+
127
+ function is_coderabbit_rate_limited(check: RollupCheck): boolean {
128
+ return (
129
+ is_coderabbit_check(check.name) &&
130
+ CODERABBIT_RATE_LIMIT_DESCRIPTION.test(check.description ?? '')
131
+ )
132
+ }
133
+
122
134
  // Temporary: a pending or failing CodeRabbit check makes GitHub report UNSTABLE even
123
135
  // though CodeRabbit is not required. Accept that state only when CodeRabbit checks are the
124
136
  // sole non-passing ones — any other non-passing check keeps the gate strict.
@@ -249,6 +261,7 @@ export {
249
261
  is_review_decision_decisive,
250
262
  read_required_statuses,
251
263
  is_coderabbit_check,
264
+ is_coderabbit_rate_limited,
252
265
  collect_blocking_failures,
253
266
  describe_pr_failure,
254
267
  REQUIRED_CHECKS,
@@ -31,10 +31,14 @@ const KEY_STATUS = 'status'
31
31
  const KEY_CONCLUSION = 'conclusion'
32
32
  const KEY_NAME = 'name'
33
33
  const KEY_CONTEXT = 'context'
34
+ const KEY_DESCRIPTION = 'description'
34
35
 
35
36
  interface RollupCheck {
36
37
  name: string
37
38
  status: string
39
+ // A status context's own words — CodeRabbit says `Review rate limited` under a `success` state
40
+ // (joshuafolkken/kit#3770). Absent where the check carries none.
41
+ description?: string
38
42
  }
39
43
 
40
44
  interface PrStateSnapshot {
@@ -95,7 +99,10 @@ function parse_rollup_item(item: RollupItemData): RollupCheck | undefined {
95
99
  const name = read_string(item[KEY_NAME]) ?? read_string(item[KEY_CONTEXT])
96
100
  if (name === undefined) return undefined
97
101
 
98
- return { name, status: parse_rollup_status(item) }
102
+ const check = { name, status: parse_rollup_status(item) }
103
+ const description = read_string(item[KEY_DESCRIPTION])
104
+
105
+ return description === undefined ? check : { ...check, description }
99
106
  }
100
107
 
101
108
  function parse_rollup_checks(raw_json: string): Array<RollupCheck> {
@@ -4,9 +4,11 @@ import {
4
4
  default_fetch_pr_state,
5
5
  DEFAULT_STABLE_READS,
6
6
  is_pr_checks_timeout,
7
+ observed_fetcher,
7
8
  SECONDS_TO_MS,
8
9
  wait_for_pr_success,
9
10
  type PrStateEvaluator,
11
+ type SnapshotObserver,
10
12
  } from './git-pr-checks'
11
13
  import { is_coderabbit_check, is_required_check, type PrEvaluation } from './git-pr-checks-eval'
12
14
  import {
@@ -125,10 +127,12 @@ const CHECKS_SETTLED_EVALUATOR: PrStateEvaluator = {
125
127
  should_read_review_decision: should_never_read_review_decision,
126
128
  }
127
129
 
128
- async function watch_until_settled(branch_name: string): Promise<void> {
130
+ // `observe` sees the watch's polls too, so a red wait's checks line is replaced while the new CI is
131
+ // still pending rather than only after the watch (joshuafolkken/kit#3770).
132
+ async function watch_until_settled(branch_name: string, observe?: SnapshotObserver): Promise<void> {
129
133
  await wait_for_pr_success({
130
134
  branch_name,
131
- fetcher: default_fetch_pr_state,
135
+ fetcher: observed_fetcher(observe),
132
136
  interval_ms: CHECK_WAIT_INTERVAL_MS,
133
137
  max_attempts: compute_max_attempts(
134
138
  PR_CHECKS_WATCH_TIMEOUT_MS / SECONDS_TO_MS,
@@ -154,9 +158,12 @@ async function fail_when_no_checks(branch_name: string): Promise<void> {
154
158
  // Everything else — a failing check, an unreadable read, a branch with no checks — is rethrown, which
155
159
  // is what the caller already handles: `git-pr-followup.ts` swallows it and falls through to its own
156
160
  // poll.
157
- async function pr_checks_watch(branch_name: string): Promise<WatchResult> {
161
+ async function pr_checks_watch(
162
+ branch_name: string,
163
+ observe?: SnapshotObserver,
164
+ ): Promise<WatchResult> {
158
165
  try {
159
- await watch_until_settled(branch_name)
166
+ await watch_until_settled(branch_name, observe)
160
167
 
161
168
  return { timed_out: false }
162
169
  } catch (error) {
@@ -128,6 +128,9 @@ interface WaitForPrSuccessOptions {
128
128
  max_attempts: number
129
129
  required_stable_reads: number
130
130
  evaluator?: PrStateEvaluator
131
+ // Asked before every poll; a throw ends the wait with that error. `josh followup` stops here on a
132
+ // person's unread issue update (`issue-watch-gate.ts`).
133
+ before_poll?: (() => Promise<void>) | undefined
131
134
  }
132
135
 
133
136
  function parse_repo_name_from_package(package_json_content: string): string {
@@ -272,6 +275,8 @@ async function wait_for_pr_success(options: WaitForPrSuccessOptions): Promise<Pr
272
275
 
273
276
  for (let attempt = 0; attempt < options.max_attempts; attempt += 1) {
274
277
  console.info(`Checking PR status… (${String(attempt + 1)}/${String(options.max_attempts)})`)
278
+ // eslint-disable-next-line no-await-in-loop -- polling: the check belongs to this poll, not to all of them at once
279
+ await options.before_poll?.()
275
280
  // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
276
281
  carried = await attempt_pr_success_poll({
277
282
  options,
@@ -311,16 +316,38 @@ async function default_fetch_pr_state(
311
316
  // **`required_stable_reads` is a parameter, not a constant, so the caller can lower it** — `run_checks`
312
317
  // passes `WATCH_CONFIRMED_STABLE_READS` when the watch already confirmed completion. It defaults to
313
318
  // `DEFAULT_STABLE_READS`, so every caller that names only the branch is exactly where it was.
319
+ // What a caller is shown of each poll it waits on — `followup` reports the checks to the run's event
320
+ // stream from it (joshuafolkken/kit#3770). It decides nothing: the wait reads the same snapshot.
321
+ type SnapshotObserver = (snapshot: PrStateSnapshot) => Promise<void>
322
+
323
+ function observed_fetcher(observe: SnapshotObserver | undefined): PrStateFetcher {
324
+ if (observe === undefined) return default_fetch_pr_state
325
+
326
+ return async function fetch_observed(
327
+ branch_name: string,
328
+ should_read_review_decision: ReviewDecisionPredicate,
329
+ ): Promise<PrStateSnapshot> {
330
+ const snapshot = await default_fetch_pr_state(branch_name, should_read_review_decision)
331
+
332
+ await observe(snapshot)
333
+
334
+ return snapshot
335
+ }
336
+ }
337
+
314
338
  async function wait_for_pr_success_default(
315
339
  branch_name: string,
316
340
  required_stable_reads: number = DEFAULT_STABLE_READS,
341
+ before_poll?: () => Promise<void>,
342
+ observe?: SnapshotObserver,
317
343
  ): Promise<PrStateSnapshot> {
318
344
  return await wait_for_pr_success({
319
345
  branch_name,
320
- fetcher: default_fetch_pr_state,
346
+ fetcher: observed_fetcher(observe),
321
347
  interval_ms: CHECK_WAIT_INTERVAL_MS,
322
348
  max_attempts: CHECK_MAX_ATTEMPTS,
323
349
  required_stable_reads,
350
+ before_poll,
324
351
  })
325
352
  }
326
353
 
@@ -383,10 +410,17 @@ export {
383
410
  SECONDS_TO_MS,
384
411
  is_pr_checks_timeout,
385
412
  default_fetch_pr_state,
413
+ observed_fetcher,
386
414
  MERGE_GATE_EVALUATOR,
387
415
  should_always_read_review_decision,
388
416
  }
389
- export type { MergeProgress, PrStateFetcher, PrStateEvaluator, ReviewDecisionPredicate }
417
+ export type {
418
+ MergeProgress,
419
+ PrStateFetcher,
420
+ PrStateEvaluator,
421
+ ReviewDecisionPredicate,
422
+ SnapshotObserver,
423
+ }
390
424
  export {
391
425
  collect_blocking_failures,
392
426
  describe_pr_failure,
@@ -166,14 +166,25 @@ async function delete_branch(branch_name: string): Promise<void> {
166
166
  // report "nothing is stranded" about a branch nobody measured, which is the one wrong answer that
167
167
  // loses work. `count_merges` guards the same way and falls back to `0` instead, because there a
168
168
  // missing count means an empty range rather than a claim about somebody's commits.
169
- async function commit_count_beyond(base: string, tip: string): Promise<number> {
170
- const range = `${base}..${tip}`
171
- const output = await git_spawn.read(['rev-list', '--count', range])
169
+ async function read_commit_count(revisions: ReadonlyArray<string>): Promise<number> {
170
+ const output = await git_spawn.read(['rev-list', '--count', ...revisions])
172
171
  const parsed = Number(output)
173
172
 
174
173
  if (output.length > 0 && Number.isFinite(parsed)) return parsed
175
174
 
176
- throw new Error(`\`git rev-list --count ${range}\` printed \`${output}\` rather than a count`)
175
+ throw new Error(
176
+ `\`git rev-list --count ${revisions.join(' ')}\` printed \`${output}\` rather than a count`,
177
+ )
178
+ }
179
+
180
+ async function commit_count_beyond(base: string, tip: string): Promise<number> {
181
+ return await read_commit_count([`${base}..${tip}`])
182
+ }
183
+
184
+ // How many commits `branch_name` holds that no remote-tracking ref reaches — what deleting the branch
185
+ // would lose. An unparseable count throws for the reason `commit_count_beyond`'s does.
186
+ async function commit_count_unpushed(branch_name: string): Promise<number> {
187
+ return await read_commit_count([branch_name, '--not', '--remotes'])
177
188
  }
178
189
 
179
190
  function is_exit_code_128(cause: unknown): boolean {
@@ -397,6 +408,7 @@ const git_command = {
397
408
  checkout,
398
409
  commit,
399
410
  commit_count_beyond,
411
+ commit_count_unpushed,
400
412
  delete_branch,
401
413
  push,
402
414
  pull_fast_forward,
@@ -10,6 +10,7 @@ const rollup_item_schema = z.looseObject({
10
10
  conclusion: z.string().optional(),
11
11
  name: z.string().optional(),
12
12
  context: z.string().optional(),
13
+ description: z.string().nullish(),
13
14
  })
14
15
 
15
16
  const pr_raw_schema = z.object({
@@ -72,4 +72,4 @@ function toplevel(timeout: number = GIT_TIMEOUT_MS): GitSyncResult {
72
72
  const git_spawn_sync = { GIT_TIMEOUT_MS, C_LOCALE, run, read, origin_url, toplevel }
73
73
 
74
74
  export { git_spawn_sync }
75
- export type { GitSyncResult }
75
+ export type { GitSyncOptions, GitSyncResult }
@@ -1,5 +1,6 @@
1
1
  import { duplicate_read_outcome } from '#scripts/delegation/duplicate-read-guard'
2
2
  import { investigation_refusal } from '#scripts/delegation/investigation-guard'
3
+ import { issue_watch_hook } from '#scripts/issue/issue-watch-hook'
3
4
  import { hook_decision, type GuardOutcome } from '#scripts/josh/hook-decision'
4
5
  import { lane_phase } from '#scripts/lane/lane-phase'
5
6
  import { json_value } from '#scripts/lib/json-value'
@@ -104,8 +105,9 @@ async function mark_phase(outcome: GuardOutcome): Promise<void> {
104
105
  // surfaced first.
105
106
  async function async_reason(raw_payload: string): Promise<string | undefined> {
106
107
  const watcher = await run_watcher_hook.watcher_hook_reason(raw_payload)
108
+ const cut = watcher ?? (await run_parent_cut_hook.parent_cut_reason(raw_payload))
107
109
 
108
- return watcher ?? (await run_parent_cut_hook.parent_cut_reason(raw_payload))
110
+ return cut ?? (await issue_watch_hook.watch_reason())
109
111
  }
110
112
 
111
113
  async function pretool_outcome_async(raw_payload: string): Promise<GuardOutcome> {
@@ -25,6 +25,30 @@ const CONSUMER_PACKAGE_MANIFEST = `${claude_plugin_config.MARKETPLACE_PATH}/pack
25
25
  const MISSING_INSTALL_NOTICE = 'kit hooks inactive: run pnpm install, then reread CLAUDE.md'
26
26
  const MISSING_INSTALL_GUARD = `if [ ! -f ${CONSUMER_PACKAGE_MANIFEST} ]; then echo '${MISSING_INSTALL_NOTICE}' >&2; exit 0; fi; `
27
27
  const CODEX_HOOKS_DESTINATION = '.codex/hooks.json'
28
+ // Every shape a hook command kit wrote can carry: the launcher as kit's own file names it, the
29
+ // launcher rebased onto the installed package, and the installed dispatcher the releases from
30
+ // 2026-09-13 (joshuafolkken/kit#1930) to the launcher rebased `pnpm josh` onto. The settings merge
31
+ // tells kit's hooks from a project's own by these and the list below, so the shapes stay beside the
32
+ // rewrite that produces them.
33
+ const KIT_COMMAND_MARKERS: ReadonlyArray<string> = [
34
+ RUN_HOOK_INVOCATION,
35
+ CONSUMER_RUN_HOOK_INVOCATION,
36
+ BUNDLE_INVOCATION,
37
+ ]
38
+ // Before joshuafolkken/kit#1930 nothing was rebased: a consumer received these six as the whole hook
39
+ // command. A plain `pnpm josh <command>` is also how a project writes a hook of its own, so only the
40
+ // exact commands count — and kit still delivers each of them through the launcher, so one left in
41
+ // place would run its guard or formatter a second time. The list is history and never grows.
42
+ const PLAIN_KIT_COMMANDS: ReadonlySet<string> = new Set(
43
+ [
44
+ 'audit:provision',
45
+ 'batch:guard',
46
+ 'format:edited',
47
+ 'investigation:guard',
48
+ 'rule:guard',
49
+ 'session:lang',
50
+ ].map((command) => `${PNPM_JOSH_PREFIX}${command}`),
51
+ )
28
52
  // The values below are matched inside raw JSON text, so the root prefix is taken in its escaped form.
29
53
  const PROJECT_ROOT_PREFIX = JSON.stringify(hook_launch.PROJECT_ROOT_PREFIX).slice(1, -1)
30
54
  // Capture the value of every `"command"` field, escapes and all, so the rewrites below touch command
@@ -80,8 +104,15 @@ function apply_hook_command_rewrite_for_destination(
80
104
  return content
81
105
  }
82
106
 
107
+ function is_kit_hook_command(command: string): boolean {
108
+ if (PLAIN_KIT_COMMANDS.has(command)) return true
109
+
110
+ return KIT_COMMAND_MARKERS.some((marker) => command.includes(marker))
111
+ }
112
+
83
113
  const hook_command_rewrite = {
84
114
  apply_hook_command_rewrite_for_destination,
115
+ is_kit_hook_command,
85
116
  rewrite_hook_commands,
86
117
  }
87
118
 
@@ -221,6 +221,9 @@ function build_basic_actions(shape: ProjectShape): ReadonlyArray<FileAction> {
221
221
  }
222
222
 
223
223
  actions.push(...build_basic_vscode_actions(shape))
224
+ // The `.claude/settings.json` a `josh start` project receives wires the `session:lang` hook, whose
225
+ // unset default is `ja`, so the language is seeded from the locale as on `full`.
226
+ if (shape.has_workflow) actions.push(...session_language_environment.build_session_lang_actions())
224
227
 
225
228
  return actions
226
229
  }
@@ -248,4 +251,4 @@ function build_file_actions(shape?: ProjectShape): ReadonlyArray<FileAction> {
248
251
  const init_actions = { build_file_actions, read_package_json }
249
252
 
250
253
  export type { FileAction }
251
- export { init_actions, PRETTIER_CONFIG_JS, BASIC_PRETTIER_CONFIG }
254
+ export { init_actions, PRETTIER_CONFIG_JS, BASIC_PRETTIER_CONFIG, LEFTHOOK_PATH }
@@ -25,9 +25,11 @@ const PRETTIER_IGNORE = '.prettierignore'
25
25
  const GIT_ATTRIBUTES = '.gitattributes'
26
26
  const CODE_OF_CONDUCT = 'CODE_OF_CONDUCT.md'
27
27
  const { SECURITY_MD } = security_policy
28
+ const AGENTS_MD = 'AGENTS.md'
29
+ const GEMINI_MD = 'GEMINI.md'
28
30
  const BASIC_AI_FILES = [
29
- 'AGENTS.md',
30
- 'GEMINI.md',
31
+ AGENTS_MD,
32
+ GEMINI_MD,
31
33
  PRETTIER_IGNORE,
32
34
  GIT_ATTRIBUTES,
33
35
  CODE_OF_CONDUCT,
@@ -36,6 +38,8 @@ const BASIC_AI_FILES = [
36
38
  ]
37
39
  // A basic project gets its own template for these, not kit's development copy.
38
40
  const BASIC_SOURCES: Readonly<Record<string, string>> = {
41
+ [AGENTS_MD]: 'templates/AGENTS.basic.md',
42
+ [GEMINI_MD]: 'templates/GEMINI.basic.md',
39
43
  [PRETTIER_IGNORE]: 'templates/prettierignore.basic',
40
44
  [WORKSPACE_YAML]: 'templates/pnpm-workspace.basic.yaml',
41
45
  }
@@ -43,6 +47,9 @@ const BASIC_SOURCES: Readonly<Record<string, string>> = {
43
47
  const CONSUMER_SOURCES: Readonly<Record<string, string>> = {
44
48
  [SECURITY_MD]: security_policy.SECURITY_TEMPLATE,
45
49
  }
50
+ // A basic project `josh start` set up for the GitHub Issue workflow runs its agents unattended, on the
51
+ // permissions, hooks and plugin this file declares (#3796).
52
+ const BASIC_WORKFLOW_AI_FILES = [...BASIC_AI_FILES, '.claude/settings.json']
46
53
  const GITHUB_FILES = new Set([CODE_OF_CONDUCT, SECURITY_MD])
47
54
  const BASIC_POINTER_MAPPING = { src: 'templates/cursorrules.basic', dest: '.cursorrules' }
48
55
  const PULL_REQUEST_TEMPLATE = '.github/pull_request_template.md'
@@ -62,11 +69,15 @@ function did_read_unstamped(destination_path: string): boolean {
62
69
  }
63
70
  }
64
71
 
65
- function copy_ai_file(source_path: string, destination_path: string): void {
66
- const content = readFileSync(source_path, 'utf8')
72
+ // What kit writes at a destination it copies to — the content `josh uninstall` compares a project's
73
+ // file against, so it is read from the one place the copy itself reads it.
74
+ function copied_content(source_path: string, destination_path: string): string {
75
+ return transform_copied_content(destination_path, readFileSync(source_path, 'utf8'))
76
+ }
67
77
 
78
+ function copy_ai_file(source_path: string, destination_path: string): void {
68
79
  mkdirSync(path.dirname(destination_path), { recursive: true })
69
- writeFileSync(destination_path, transform_copied_content(destination_path, content))
80
+ writeFileSync(destination_path, copied_content(source_path, destination_path))
70
81
  }
71
82
 
72
83
  // `init` declines to modify a file that already exists, and that includes not stamping it: the
@@ -132,6 +143,14 @@ function merge_existing_workspace_yaml(source_path: string, destination_path: st
132
143
  console.info(` ✔ updated ${WORKSPACE_YAML}`)
133
144
  }
134
145
 
146
+ // The workspace file kit writes where the project has none.
147
+ function workspace_content(source_path: string, destination_path: string): string {
148
+ return transform_copied_content(
149
+ destination_path,
150
+ read_workspace_template(source_path, destination_path),
151
+ )
152
+ }
153
+
135
154
  function did_skip_workspace_yaml_copy(source_path: string, destination_path: string): boolean {
136
155
  if (existsSync(destination_path)) {
137
156
  merge_existing_workspace_yaml(source_path, destination_path)
@@ -139,9 +158,7 @@ function did_skip_workspace_yaml_copy(source_path: string, destination_path: str
139
158
  return false
140
159
  }
141
160
 
142
- const template = read_workspace_template(source_path, destination_path)
143
-
144
- writeFileSync(destination_path, transform_copied_content(destination_path, template))
161
+ writeFileSync(destination_path, workspace_content(source_path, destination_path))
145
162
  console.info(` ✔ created ${WORKSPACE_YAML}`)
146
163
 
147
164
  return false
@@ -342,8 +359,12 @@ function should_copy_ai_file(filename: string, shape?: ProjectShape): boolean {
342
359
  )
343
360
  }
344
361
 
362
+ function basic_ai_files(shape: ProjectShape): ReadonlyArray<string> {
363
+ return shape.has_workflow ? BASIC_WORKFLOW_AI_FILES : BASIC_AI_FILES
364
+ }
365
+
345
366
  function ai_files(shape?: ProjectShape): ReadonlyArray<string> {
346
- const files = shape?.profile === 'basic' ? BASIC_AI_FILES : init_logic.get_ai_copy_files()
367
+ const files = shape?.profile === 'basic' ? basic_ai_files(shape) : init_logic.get_ai_copy_files()
347
368
 
348
369
  return files.filter((filename) => should_copy_ai_file(filename, shape))
349
370
  }
@@ -397,6 +418,8 @@ function run_ai_copies(shape?: ProjectShape): string | undefined {
397
418
 
398
419
  const init_ai_copy = {
399
420
  copy_ai_file,
421
+ copied_content,
422
+ workspace_content,
400
423
  run_ai_copies,
401
424
  ai_files,
402
425
  ai_file_source,
@@ -405,4 +428,4 @@ const init_ai_copy = {
405
428
  read_workspace_template,
406
429
  }
407
430
 
408
- export { init_ai_copy, CLAUDE_MD_FILENAME }
431
+ export { init_ai_copy, CLAUDE_MD_FILENAME, WORKSPACE_YAML }
@@ -3,6 +3,7 @@ import { json_value } from '#scripts/lib/json-value'
3
3
  import { z } from 'zod'
4
4
  import { init_logic } from './init-logic'
5
5
  import type { ProjectShape } from './project-profile'
6
+ import { workflow_mark } from './workflow-mark'
6
7
 
7
8
  const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
8
9
  const record_schema = z.record(z.string(), z.unknown())
@@ -16,13 +17,17 @@ function initial_manifest(): string {
16
17
  return json_format.format_json({ private: true })
17
18
  }
18
19
 
19
- function with_recorded_profile(content: string, profile: ProjectShape['profile']): string {
20
+ function with_recorded_profile(
21
+ content: string,
22
+ profile: ProjectShape['profile'],
23
+ has_workflow = false,
24
+ ): string {
20
25
  const parsed: unknown = JSON.parse(content)
21
26
  const manifest = record_schema.parse(parsed)
22
27
  const existing = manifest['josh']
23
28
  const josh = json_value.is_record(existing) ? existing : {}
24
29
 
25
- manifest['josh'] = { ...josh, profile }
30
+ manifest['josh'] = { ...josh, profile, ...workflow_mark.recorded_fields(has_workflow) }
26
31
 
27
32
  return json_format.format_json(manifest)
28
33
  }
@@ -32,7 +37,7 @@ function merge_basic_manifest(
32
37
  shape: ProjectShape,
33
38
  versions: BasicVersions,
34
39
  ): string {
35
- const with_profile = with_recorded_profile(content, shape.profile)
40
+ const with_profile = with_recorded_profile(content, shape.profile, shape.has_workflow)
36
41
  const with_script = init_logic.merge_package_scripts(with_profile, {
37
42
  preinstall: init_logic.SAFE_CHAIN_CMD,
38
43
  josh: 'josh',
@@ -0,0 +1,76 @@
1
+ import { parse_jsonc } from '#scripts/config-merge/parse-jsonc'
2
+ import { json_value } from '#scripts/lib/json-value'
3
+ import { init_logic_json_merge } from './init-logic-json-merge'
4
+ import { kit_development_versions } from './kit-development-versions'
5
+
6
+ // The tools a consumer has to resolve itself, as opposed to the ones only kit's own ESLint config
7
+ // imports. Those are optional kit peers, which pnpm does not install unlisted, so a consumer still
8
+ // needs them — `josh init` writes them, `josh sync` leaves them to it (#3711). A tool belongs here
9
+ // when the consumer runs its bin from the project root (eslint, prettier, tsc, playwright, cspell,
10
+ // lefthook), when a config the consumer owns imports it (`playwright.config.ts` → @playwright/test,
11
+ // @types/node), or when it is named by a string Prettier resolves from the consumer (the preset's
12
+ // plugins). `josh init` and `josh sync` both read this one list, so a tool added here reaches a fresh
13
+ // project and an already-initialized one alike.
14
+ const CONSUMER_RESOLVED_TOOLS = [
15
+ 'eslint',
16
+ 'prettier',
17
+ 'typescript',
18
+ '@playwright/test',
19
+ '@ianvs/prettier-plugin-sort-imports',
20
+ 'prettier-plugin-svelte',
21
+ 'prettier-plugin-tailwindcss',
22
+ 'cspell',
23
+ '@types/node',
24
+ ]
25
+ // Only a project with Git gets `lefthook.yml`, so only that one needs the bin.
26
+ const LEFTHOOK_DEVELOPMENT_DEPENDENCY = 'lefthook'
27
+ const DECLARING_FIELDS = [
28
+ 'dependencies',
29
+ 'devDependencies',
30
+ 'peerDependencies',
31
+ 'optionalDependencies',
32
+ ]
33
+
34
+ function consumer_resolved_names(has_git: boolean): ReadonlyArray<string> {
35
+ return has_git
36
+ ? [...CONSUMER_RESOLVED_TOOLS, LEFTHOOK_DEVELOPMENT_DEPENDENCY]
37
+ : CONSUMER_RESOLVED_TOOLS
38
+ }
39
+
40
+ // What `josh init` adds: every kit peer — the ones only kit's own configs import included — and the
41
+ // tools above. Versions mirror kit's own devDependencies, so the consumer runs the toolchain kit is
42
+ // verified with.
43
+ function get_toolchain_development_dependencies(has_git: boolean): Record<string, string> {
44
+ return kit_development_versions.versions_of([
45
+ ...kit_development_versions.peer_names(),
46
+ ...consumer_resolved_names(has_git),
47
+ ])
48
+ }
49
+
50
+ function is_declared(manifest: Record<string, unknown>, name: string): boolean {
51
+ return DECLARING_FIELDS.some((field) => {
52
+ const dependencies = manifest[field]
53
+
54
+ return json_value.is_record(dependencies) && Object.hasOwn(dependencies, name)
55
+ })
56
+ }
57
+
58
+ // What `josh sync` adds to an already-initialized consumer: only the tools above, and only the ones
59
+ // the manifest declares nowhere — a tool kept under `dependencies` is present, not missing.
60
+ function merge_consumer_resolved_development_deps(content: string, has_git: boolean): string {
61
+ const manifest = parse_jsonc(content)
62
+ const missing = consumer_resolved_names(has_git).filter((name) => !is_declared(manifest, name))
63
+
64
+ return init_logic_json_merge.merge_development_dependencies(
65
+ content,
66
+ kit_development_versions.versions_of(missing),
67
+ )
68
+ }
69
+
70
+ const init_logic_toolchain = {
71
+ consumer_resolved_names,
72
+ get_toolchain_development_dependencies,
73
+ merge_consumer_resolved_development_deps,
74
+ }
75
+
76
+ export { init_logic_toolchain }