@joshuafolkken/kit 1.1387.0 → 1.1453.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 (278) 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-park.md +2 -0
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +13 -10
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -2
  7. package/.claude/skills/workflow-commands/chain-rule.md +4 -2
  8. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  9. package/.claude/skills/workflow-commands/halfrun.md +1 -1
  10. package/.claude/skills/workflow-commands/issue-comments.md +6 -0
  11. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  12. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  13. package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
  14. package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
  15. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  16. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  17. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  18. package/README.md +27 -4
  19. package/dist/hooks/audit-provision.js +2 -2
  20. package/dist/hooks/batch-guard.js +7 -7
  21. package/dist/hooks/{chunk-2YSW3CXH.js → chunk-3C4XRYG6.js} +35 -11
  22. package/dist/hooks/{chunk-GJ7JPIHN.js → chunk-5C7L6FRC.js} +2704 -190
  23. package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
  24. package/dist/hooks/{chunk-STDSEPWU.js → chunk-D3NJXN5M.js} +4 -4
  25. package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
  26. package/dist/hooks/{chunk-LMWBRJIP.js → chunk-EF5GQ6WS.js} +3 -3
  27. package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
  28. package/dist/hooks/{chunk-IYVOLN6A.js → chunk-LBFYEOXJ.js} +3 -3
  29. package/dist/hooks/{chunk-SL4GQ2XP.js → chunk-MBJYQPEK.js} +106 -23
  30. package/dist/hooks/{chunk-WNFJLBXI.js → chunk-NO4AALP5.js} +3 -3
  31. package/dist/hooks/{chunk-J3KSS7II.js → chunk-OLMO72ON.js} +1 -1
  32. package/dist/hooks/{chunk-DCY2GWHC.js → chunk-SHYUQOIS.js} +5 -5
  33. package/dist/hooks/{chunk-EAQEGME7.js → chunk-UT7YC3N2.js} +1 -1
  34. package/dist/hooks/{chunk-EBSHN25W.js → chunk-W5ZKGTSY.js} +4 -4
  35. package/dist/hooks/{chunk-GNDJ7NJ7.js → chunk-Y3GKIKGW.js} +209 -56
  36. package/dist/hooks/{chunk-U32VSRNG.js → chunk-Z4XI7CZS.js} +89 -34
  37. package/dist/hooks/codex-hook-adapter.js +15 -15
  38. package/dist/hooks/duplicate-read-guard.js +8 -8
  39. package/dist/hooks/format-edited.js +5 -5
  40. package/dist/hooks/inputs.json +261 -201
  41. package/dist/hooks/investigation-guard.js +8 -8
  42. package/dist/hooks/pretool-guard.js +14 -14
  43. package/dist/hooks/session-lang.js +69 -16
  44. package/dist/hooks/stop-guard.js +13 -38
  45. package/dist/josh.js +95 -19
  46. package/package.json +4 -3
  47. package/prompts/collaboration-workflow/issue-template.md +10 -8
  48. package/prompts/review.md +30 -4
  49. package/scripts/agent/claude-agent-argv.ts +13 -1
  50. package/scripts/auth/auth-setup-cli.ts +101 -0
  51. package/scripts/auth/claude-token-output.ts +78 -0
  52. package/scripts/auth/claude-token.ts +68 -0
  53. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  54. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  55. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  56. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  57. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  58. package/scripts/backlog/backlog-drive-epic.ts +24 -8
  59. package/scripts/backlog/backlog-drive-launch.ts +3 -0
  60. package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
  61. package/scripts/backlog/backlog-drive-named.ts +4 -2
  62. package/scripts/backlog/backlog-drive-unread.ts +56 -0
  63. package/scripts/backlog/backlog-drive.ts +38 -11
  64. package/scripts/backlog/backlog-overlap.ts +22 -5
  65. package/scripts/backlog/backlog-ready.ts +1 -3
  66. package/scripts/backlog/backlogrun-args.ts +13 -2
  67. package/scripts/backlog/backlogrun-cli.ts +91 -19
  68. package/scripts/backlog/backlogrun-launch.ts +24 -0
  69. package/scripts/backlog/backlogrun-replace.ts +166 -0
  70. package/scripts/backlog/backlogrun-stop.ts +178 -0
  71. package/scripts/behavior/behavior-cli.ts +6 -1
  72. package/scripts/doctor/doctor-io.ts +60 -9
  73. package/scripts/doctor/doctor.ts +20 -1
  74. package/scripts/document/document-byte-budget.ts +3 -3
  75. package/scripts/epic/epic-bundle-cli.ts +8 -8
  76. package/scripts/epic/epic-bundle-referenced.ts +2 -5
  77. package/scripts/epic/epic-bundle.ts +39 -4
  78. package/scripts/exports/unused-members-cli.ts +2 -1
  79. package/scripts/followup/followup-checks-event.ts +82 -0
  80. package/scripts/followup/git-followup-finish.ts +16 -2
  81. package/scripts/followup/git-followup-workflow.ts +5 -2
  82. package/scripts/followup/git-pr-followup-checks.ts +18 -4
  83. package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
  84. package/scripts/followup/git-pr-followup.ts +4 -1
  85. package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
  86. package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
  87. package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
  88. package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
  89. package/scripts/fresh-machine/fresh-machine-patterns.ts +175 -0
  90. package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
  91. package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
  92. package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
  93. package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
  94. package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
  95. package/scripts/gate/gate-report.ts +70 -15
  96. package/scripts/gate/project-checks.ts +18 -4
  97. package/scripts/gate/scoped-green.ts +2 -1
  98. package/scripts/gate/verification-gate.ts +4 -3
  99. package/scripts/gh/gh-author-association.ts +14 -0
  100. package/scripts/gh/git-gh-conditional.ts +66 -0
  101. package/scripts/gh/git-gh-exec.ts +1 -1
  102. package/scripts/gh/git-gh-issue-read.ts +6 -3
  103. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  104. package/scripts/gh/git-gh-issue-write.ts +5 -1
  105. package/scripts/gh/git-gh-issue.ts +13 -1
  106. package/scripts/gh/git-pr-checks-eval.ts +13 -0
  107. package/scripts/gh/git-pr-checks-parse.ts +8 -1
  108. package/scripts/gh/git-pr-checks-watch.ts +11 -4
  109. package/scripts/gh/git-pr-checks.ts +36 -2
  110. package/scripts/git/git-command.ts +16 -4
  111. package/scripts/git/git-schemas.ts +1 -0
  112. package/scripts/git/git-spawn-sync.ts +1 -1
  113. package/scripts/hooks/pretool-guard.ts +3 -1
  114. package/scripts/init/hook-command-rewrite.ts +31 -0
  115. package/scripts/init/init-actions.ts +7 -4
  116. package/scripts/init/init-ai-copy.ts +34 -11
  117. package/scripts/init/init-basic.ts +9 -4
  118. package/scripts/init/init-bootstrap.ts +6 -2
  119. package/scripts/init/init-dry-run-report.ts +150 -0
  120. package/scripts/init/init-dry-run.ts +189 -0
  121. package/scripts/init/init-install.ts +8 -1
  122. package/scripts/init/init-logic-toolchain.ts +76 -0
  123. package/scripts/init/init-logic.ts +2 -0
  124. package/scripts/init/init.ts +62 -42
  125. package/scripts/init/kit-setup-state.ts +11 -17
  126. package/scripts/init/own-formatter.ts +21 -0
  127. package/scripts/init/project-profile.ts +60 -12
  128. package/scripts/init/start-dry-run.ts +70 -0
  129. package/scripts/init/start-exec.ts +27 -1
  130. package/scripts/init/start-plan.ts +23 -3
  131. package/scripts/init/start-setup-pr.ts +13 -24
  132. package/scripts/init/start-steps.ts +62 -1
  133. package/scripts/init/start.ts +38 -17
  134. package/scripts/init/workflow-mark.ts +35 -0
  135. package/scripts/init/working-tree.ts +116 -0
  136. package/scripts/issue/issue-ack-cli.ts +84 -0
  137. package/scripts/issue/issue-auto-ok.ts +68 -99
  138. package/scripts/issue/issue-file-cli.ts +17 -17
  139. package/scripts/issue/issue-file.ts +1 -6
  140. package/scripts/issue/issue-labels.ts +46 -10
  141. package/scripts/issue/issue-release-cli.ts +69 -23
  142. package/scripts/issue/issue-release.ts +3 -1
  143. package/scripts/issue/issue-state-cli.ts +14 -4
  144. package/scripts/issue/issue-watch-diff.ts +152 -0
  145. package/scripts/issue/issue-watch-gate.ts +81 -0
  146. package/scripts/issue/issue-watch-hook.ts +54 -0
  147. package/scripts/issue/issue-watch.ts +240 -0
  148. package/scripts/issue/measure-rerun-cli.ts +3 -4
  149. package/scripts/josh/josh-command-map.ts +4 -0
  150. package/scripts/josh/josh-commands-ai.ts +11 -0
  151. package/scripts/josh/josh-commands-backlog.ts +2 -2
  152. package/scripts/josh/josh-commands-development.ts +1 -1
  153. package/scripts/josh/josh-commands-issue.ts +10 -3
  154. package/scripts/josh/josh-commands-lane.ts +11 -2
  155. package/scripts/josh/josh-commands-project.ts +26 -2
  156. package/scripts/josh/josh-verdict.ts +33 -4
  157. package/scripts/josh/process-identity.ts +32 -6
  158. package/scripts/lane/claude-lane-broker.ts +142 -0
  159. package/scripts/lane/lane-await.ts +6 -2
  160. package/scripts/lane/lane-broker-server.ts +69 -0
  161. package/scripts/lane/lane-cache.ts +37 -7
  162. package/scripts/lane/lane-capacity.ts +8 -6
  163. package/scripts/lane/lane-child-invocation.ts +1 -0
  164. package/scripts/lane/lane-cli.ts +15 -1
  165. package/scripts/lane/lane-close.ts +15 -6
  166. package/scripts/lane/lane-dispatch.ts +36 -10
  167. package/scripts/lane/lane-environment.ts +1 -1
  168. package/scripts/lane/lane-launch-cli.ts +13 -4
  169. package/scripts/lane/lane-ledger.ts +21 -3
  170. package/scripts/lane/lane-measure-cli.ts +10 -2
  171. package/scripts/lane/lane-open.ts +9 -1
  172. package/scripts/lane/lane-preserve.ts +49 -0
  173. package/scripts/lane/lane-reap.ts +20 -5
  174. package/scripts/lane/lane-registry.ts +6 -0
  175. package/scripts/lane/lane-restart-cli.ts +51 -0
  176. package/scripts/lane/lane-restart.ts +175 -0
  177. package/scripts/lane/lane-session-broker.ts +363 -0
  178. package/scripts/lane/lane-spent.ts +195 -0
  179. package/scripts/lane/lane-stage-stats.ts +27 -3
  180. package/scripts/lane/lane-stop-stats.ts +96 -0
  181. package/scripts/lane/openai-lane-supervisor.ts +2 -2
  182. package/scripts/lane/process-tools.ts +31 -0
  183. package/scripts/lib/buffered-process.ts +36 -2
  184. package/scripts/lib/pnpm-reach.ts +27 -0
  185. package/scripts/lib/status-icons.ts +5 -1
  186. package/scripts/lint/lint-parallel.ts +19 -2
  187. package/scripts/observations/observation-ledger-home.ts +1 -0
  188. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  189. package/scripts/release/release-issue-close.ts +133 -0
  190. package/scripts/release/release-publish.ts +12 -1
  191. package/scripts/repo/repository-labels.ts +11 -1
  192. package/scripts/review/comment-ranges.ts +199 -0
  193. package/scripts/review/review-attest-cli.ts +21 -6
  194. package/scripts/review/review-brief-cli.ts +24 -7
  195. package/scripts/review/review-code-tree.ts +119 -0
  196. package/scripts/review/review-comment-round.ts +114 -0
  197. package/scripts/review/review-record-cli.ts +39 -17
  198. package/scripts/review/review-round2-cli.ts +16 -5
  199. package/scripts/review/review-round2.ts +30 -10
  200. package/scripts/review/review-stamps.ts +22 -1
  201. package/scripts/rules/implementation-cut.ts +3 -3
  202. package/scripts/rules/pre-gate-cut.ts +1 -1
  203. package/scripts/run/board/run-board-checks.ts +42 -0
  204. package/scripts/run/board/run-board-cli.ts +2 -0
  205. package/scripts/run/board/run-board-every.ts +3 -1
  206. package/scripts/run/board/run-board-fit.ts +16 -5
  207. package/scripts/run/board/run-board-fresh.ts +64 -0
  208. package/scripts/run/board/run-board-header.ts +129 -53
  209. package/scripts/run/board/run-board-labels.ts +47 -4
  210. package/scripts/run/board/run-board-lane-event.ts +66 -0
  211. package/scripts/run/board/run-board-layout.ts +57 -9
  212. package/scripts/run/board/run-board-now.ts +234 -0
  213. package/scripts/run/board/run-board-operation-read.ts +75 -0
  214. package/scripts/run/board/run-board-operation.ts +150 -0
  215. package/scripts/run/board/run-board-phase.ts +26 -1
  216. package/scripts/run/board/run-board-plan.ts +4 -6
  217. package/scripts/run/board/run-board-read.ts +49 -3
  218. package/scripts/run/board/run-board-reason.ts +124 -0
  219. package/scripts/run/board/run-board-render-checks.ts +55 -0
  220. package/scripts/run/board/run-board-render-legend.ts +12 -3
  221. package/scripts/run/board/run-board-render-slot.ts +2 -2
  222. package/scripts/run/board/run-board-render-tail.ts +128 -0
  223. package/scripts/run/board/run-board-render-thanks.ts +50 -0
  224. package/scripts/run/board/run-board-render-title.ts +58 -0
  225. package/scripts/run/board/run-board-render.ts +96 -80
  226. package/scripts/run/board/run-board-row-yield.ts +32 -0
  227. package/scripts/run/board/run-board-setup.ts +61 -13
  228. package/scripts/run/board/run-board-state.ts +10 -0
  229. package/scripts/run/board/run-board-status.ts +66 -7
  230. package/scripts/run/board/run-board-thanks-csv.ts +71 -0
  231. package/scripts/run/board/run-board-thanks-read.ts +112 -0
  232. package/scripts/run/board/run-board-thanks.ts +53 -0
  233. package/scripts/run/board/run-board-tick.ts +50 -21
  234. package/scripts/run/board/run-board-usual.ts +44 -0
  235. package/scripts/run/carry/run-carry-args.ts +9 -1
  236. package/scripts/run/carry/run-carry-cli.ts +2 -45
  237. package/scripts/run/carry/run-carry-end.ts +73 -0
  238. package/scripts/run/carry/run-carry.ts +8 -1
  239. package/scripts/run/entry/run-entry-cli.ts +5 -0
  240. package/scripts/run/event/run-event-checks.ts +54 -0
  241. package/scripts/run/event/run-event-render.ts +2 -0
  242. package/scripts/run/event/run-event-scope.ts +1 -0
  243. package/scripts/run/event/run-event-setup.ts +55 -11
  244. package/scripts/run/event/run-event-stream.ts +27 -11
  245. package/scripts/run/merge/run-merge-cli.ts +7 -4
  246. package/scripts/run/merge/run-merge-ship.ts +61 -0
  247. package/scripts/run/merge/run-merge-steps.ts +7 -14
  248. package/scripts/run/merge/run-merge-token.ts +16 -2
  249. package/scripts/run/run-headless.ts +1 -0
  250. package/scripts/run/run-step.ts +2 -0
  251. package/scripts/run/ship/run-ship-cli.ts +22 -2
  252. package/scripts/run/ship/run-ship-return.ts +31 -13
  253. package/scripts/run/ship/run-ship-review-steps.ts +17 -4
  254. package/scripts/run/ship/run-ship-stage.ts +1 -0
  255. package/scripts/run/wake/run-wake-await.ts +130 -0
  256. package/scripts/run/wake/run-wake-cli.ts +60 -71
  257. package/scripts/run/wake/run-wake-describe.ts +34 -2
  258. package/scripts/run/wake/run-wake-driver.ts +11 -4
  259. package/scripts/run/wake/run-wake-session.ts +20 -3
  260. package/scripts/run/wake/run-wake-stop.ts +37 -0
  261. package/scripts/run/wake/run-wake-supervise.ts +44 -0
  262. package/scripts/sync/claude-settings-merge.ts +144 -0
  263. package/scripts/sync/sync-ai-files.ts +28 -3
  264. package/scripts/sync/sync.ts +15 -0
  265. package/scripts/test/project-test-script.ts +73 -0
  266. package/scripts/test/skip-marker.ts +14 -1
  267. package/scripts/test/test-related.ts +3 -2
  268. package/scripts/test/test-unit-guard.ts +78 -14
  269. package/scripts/time-runtime/time-reported-failure.ts +11 -3
  270. package/scripts/uninstall/distributed-files.ts +92 -0
  271. package/scripts/uninstall/uninstall-plan.ts +119 -0
  272. package/scripts/uninstall/uninstall-report.ts +61 -0
  273. package/scripts/uninstall/uninstall.ts +206 -0
  274. package/templates/AGENTS.basic.md +4 -0
  275. package/templates/GEMINI.basic.md +6 -0
  276. package/dist/hooks/chunk-6V5OPIUU.js +0 -13
  277. package/scripts/lane/openai-review-broker.ts +0 -262
  278. package/scripts/run/board/run-board-track.ts +0 -22
@@ -0,0 +1,144 @@
1
+ import { json_format } from '#scripts/config-merge/json-format'
2
+ import { hook_command_rewrite } from '#scripts/init/hook-command-rewrite'
3
+
4
+ // `josh sync` used to replace a project's `.claude/settings.json` with the distributed file, which
5
+ // deleted every hook and permission the project had added — and `.claude/settings.local.json` is
6
+ // personal, so a team had nowhere to keep a shared one. The merge replaces what kit ships and keeps
7
+ // the rest:
8
+ //
9
+ // - hooks: a handler whose command has one of kit's shapes is kit's — the current ones are replaced
10
+ // and a retired one is dropped; every other handler is the project's and stays.
11
+ // - permissions: kit's `allow` / `deny` entries are always present, so a removed `deny` comes back;
12
+ // the project's own entries follow them, less the ones kit has withdrawn.
13
+ // - any other key: kit's value where kit ships one, the project's where it does not. Objects merge
14
+ // entry by entry, so a project's own `env` variable or plugin survives beside kit's.
15
+ //
16
+ // kit's keys and entries come first and in kit's order, which is what makes a file with nothing of the
17
+ // project's own come out byte-identical to the distributed one, and a second merge change nothing.
18
+
19
+ type JsonObject = Record<string, unknown>
20
+ type EntryMerge = (key: string, kit: unknown, own: unknown) => unknown
21
+
22
+ const HOOKS_KEY = 'hooks'
23
+ const PERMISSIONS_KEY = 'permissions'
24
+ // An entry kit stops shipping would otherwise stay in every merged file as if the project had added
25
+ // it, so a permission kit withdraws could never be withdrawn. Withdrawing one means removing it from
26
+ // kit's settings file and naming it here.
27
+ const WITHDRAWN_KIT_PERMISSIONS: ReadonlySet<unknown> = new Set([
28
+ 'Bash(git commit -a*)',
29
+ 'Bash(git commit --all*)',
30
+ ])
31
+
32
+ function is_json_object(value: unknown): value is JsonObject {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
34
+ }
35
+
36
+ function as_array(value: unknown): Array<unknown> {
37
+ return Array.isArray(value) ? value : []
38
+ }
39
+
40
+ function merge_objects(kit: JsonObject, own: JsonObject, merge_entry: EntryMerge): JsonObject {
41
+ const merged: JsonObject = { ...kit, ...own }
42
+
43
+ for (const [key, value] of Object.entries(kit)) merged[key] = merge_entry(key, value, own[key])
44
+
45
+ return merged
46
+ }
47
+
48
+ function merge_kit_value(kit: unknown, own: unknown): unknown {
49
+ if (!is_json_object(kit) || !is_json_object(own)) return kit
50
+
51
+ return merge_objects(kit, own, (_key, kit_entry, own_entry) =>
52
+ merge_kit_value(kit_entry, own_entry),
53
+ )
54
+ }
55
+
56
+ function merge_permission(kit: unknown, own: unknown): unknown {
57
+ if (!Array.isArray(kit) || !Array.isArray(own)) return merge_kit_value(kit, own)
58
+
59
+ return [...new Set([...as_array(kit), ...as_array(own)])]
60
+ }
61
+
62
+ function without_withdrawn(value: unknown): unknown {
63
+ if (!Array.isArray(value)) return value
64
+
65
+ return value.filter((entry) => !WITHDRAWN_KIT_PERMISSIONS.has(entry))
66
+ }
67
+
68
+ // The withdrawn entries are taken out of every list first, so one goes even when kit no longer ships
69
+ // the list it sat in.
70
+ function merge_permissions(kit: unknown, own: unknown): unknown {
71
+ if (!is_json_object(kit) || !is_json_object(own)) return kit
72
+ const kept = Object.entries(own).map(([key, value]) => [key, without_withdrawn(value)] as const)
73
+
74
+ return merge_objects(kit, Object.fromEntries(kept), (_key, kit_entry, own_entry) =>
75
+ merge_permission(kit_entry, own_entry),
76
+ )
77
+ }
78
+
79
+ function is_kit_handler(handler: unknown): boolean {
80
+ if (!is_json_object(handler)) return false
81
+ const { command } = handler
82
+
83
+ return typeof command === 'string' && hook_command_rewrite.is_kit_hook_command(command)
84
+ }
85
+
86
+ // A matcher group left with no handler of the project's own was kit's alone, and goes with them.
87
+ function without_kit_handlers(group: unknown): unknown {
88
+ if (!is_json_object(group)) return group
89
+ const handlers = as_array(group[HOOKS_KEY]).filter((handler) => !is_kit_handler(handler))
90
+
91
+ return handlers.length === 0 ? undefined : { ...group, [HOOKS_KEY]: handlers }
92
+ }
93
+
94
+ function own_groups(groups: unknown): Array<unknown> {
95
+ return as_array(groups)
96
+ .map((group) => without_kit_handlers(group))
97
+ .filter((group) => group !== undefined)
98
+ }
99
+
100
+ function own_hook_events(own: JsonObject): JsonObject {
101
+ const events = Object.entries(own).map(([event, groups]) => [event, own_groups(groups)] as const)
102
+
103
+ return Object.fromEntries(events.filter(([, groups]) => groups.length > 0))
104
+ }
105
+
106
+ function merge_hooks(kit: unknown, own: unknown): unknown {
107
+ if (!is_json_object(kit) || !is_json_object(own)) return kit
108
+
109
+ return merge_objects(kit, own_hook_events(own), (_event, kit_groups, groups) => [
110
+ ...as_array(kit_groups),
111
+ ...as_array(groups),
112
+ ])
113
+ }
114
+
115
+ function merge_top_level(key: string, kit: unknown, own: unknown): unknown {
116
+ if (key === HOOKS_KEY) return merge_hooks(kit, own)
117
+ if (key === PERMISSIONS_KEY) return merge_permissions(kit, own)
118
+
119
+ return merge_kit_value(kit, own)
120
+ }
121
+
122
+ function parse_object(content: string): JsonObject | undefined {
123
+ try {
124
+ const parsed: unknown = JSON.parse(content)
125
+
126
+ return is_json_object(parsed) ? parsed : undefined
127
+ } catch {
128
+ return undefined
129
+ }
130
+ }
131
+
132
+ // A file that does not parse as a JSON object has nothing the merge can keep, so it is replaced with
133
+ // the distributed one, as every file was before the merge existed.
134
+ function merge_claude_settings(existing: string, distributed: string): string {
135
+ const own = parse_object(existing)
136
+ const kit = parse_object(distributed)
137
+ if (own === undefined || kit === undefined) return distributed
138
+
139
+ return json_format.format_json(merge_objects(kit, own, merge_top_level))
140
+ }
141
+
142
+ const claude_settings_merge = { merge_claude_settings, WITHDRAWN_KIT_PERMISSIONS }
143
+
144
+ export { claude_settings_merge }
@@ -8,6 +8,7 @@ import type { ProjectShape } from '#scripts/init/project-profile'
8
8
  import { security_policy } from '#scripts/init/security-policy'
9
9
  import { file_reader } from '#scripts/lib/read-file'
10
10
  import { pack_hook } from '#scripts/safe-chain/pack-hook'
11
+ import { claude_settings_merge } from './claude-settings-merge'
11
12
  import { copy_directory_failure, directory_copy_blocker } from './directory-copy-guard'
12
13
  import { file_content } from './file-content'
13
14
  import { REMOVED_SKILL_MANIFEST } from './removed-skill-manifest'
@@ -45,9 +46,32 @@ function should_skip_hook_file(filename: string): boolean {
45
46
  return true
46
47
  }
47
48
 
48
- function sync_file(source: string, filename: string): void {
49
+ // The project's own hooks, permissions and keys survive: only kit's entries are replaced
50
+ // (`claude-settings-merge.ts`). `--force` is the replacement sync used to make, as it is for
51
+ // `pnpm-workspace.yaml`.
52
+ function sync_claude_settings(
53
+ source_path: string,
54
+ destination_path: string,
55
+ is_force: boolean,
56
+ ): boolean {
57
+ const distributed = transform_copied_content(destination_path, readFileSync(source_path, 'utf8'))
58
+ const existing = is_force ? undefined : file_reader.read_optional(destination_path)
59
+ const content =
60
+ existing === undefined
61
+ ? distributed
62
+ : claude_settings_merge.merge_claude_settings(existing, distributed)
63
+
64
+ return file_content.write_text_if_changed(destination_path, content)
65
+ }
66
+
67
+ function sync_file(source: string, filename: string, is_force: boolean): void {
49
68
  if (should_skip_hook_file(filename)) return
50
- const did_change = sync_ai_file(path.join(PACKAGE_DIR, source), path.join(PROJECT_ROOT, filename))
69
+ const source_path = path.join(PACKAGE_DIR, source)
70
+ const destination_path = path.join(PROJECT_ROOT, filename)
71
+ const did_change =
72
+ filename === CLAUDE_SETTINGS_FILE
73
+ ? sync_claude_settings(source_path, destination_path, is_force)
74
+ : sync_ai_file(source_path, destination_path)
51
75
 
52
76
  console.info(` ✔ ${did_change ? 'synced ' : 'unchanged'} ${filename}`)
53
77
  }
@@ -96,7 +120,7 @@ function sync_ai_copy_file(filename: string, is_force: boolean, shape?: ProjectS
96
120
  return
97
121
  }
98
122
 
99
- sync_file(source, filename)
123
+ sync_file(source, filename, is_force)
100
124
  }
101
125
 
102
126
  // The reason this directory was not synced, or nothing when it was. Both halves answer the same
@@ -203,6 +227,7 @@ const sync_ai_files = {
203
227
  sync_ai_file,
204
228
  sync_workspace_yaml,
205
229
  sync_claude_md,
230
+ ensured_claude_md,
206
231
  sync_directory,
207
232
  sync_ai_copy_all,
208
233
  }
@@ -192,6 +192,19 @@ function sync_secretlint_development_deps(destination_path: string): void {
192
192
  )
193
193
  }
194
194
 
195
+ // A tool `josh init` started adding after a consumer was initialized never reached it: `lefthook.yml`
196
+ // was synced, the lefthook that runs it was not. Only the tools the consumer resolves itself are
197
+ // added, and only where the manifest declares them nowhere; an existing range is left as it is.
198
+ function sync_consumer_resolved_development_deps(destination_path: string): void {
199
+ const has_git = project_profile.has_git(path.dirname(destination_path))
200
+
201
+ sync_package_json_with(
202
+ destination_path,
203
+ (existing) => init_logic.merge_consumer_resolved_development_deps(existing, has_git),
204
+ ' ✔ synced toolchain devDependencies (run `pnpm install`)',
205
+ )
206
+ }
207
+
195
208
  // `lefthook install` fails silently when `core.hooksPath` is set, leaving a consumer with no hooks
196
209
  // and no message. `josh init` rewrites the clause it wrote so the failure
197
210
  // is reported; this covers everyone already initialized, who never re-runs `josh init`.
@@ -219,6 +232,7 @@ function sync_package_json_migrations(destination_path: string): void {
219
232
  sync_package_manager_version(destination_path)
220
233
  sync_package_manager_on_fail(destination_path)
221
234
  sync_secretlint_development_deps(destination_path)
235
+ sync_consumer_resolved_development_deps(destination_path)
222
236
  sync_prepare_lefthook_warning(destination_path)
223
237
  sync_safe_chain_preinstall(destination_path)
224
238
  }
@@ -300,6 +314,7 @@ const sync = {
300
314
  sync_package_manager_version,
301
315
  sync_package_manager_on_fail,
302
316
  sync_secretlint_development_deps,
317
+ sync_consumer_resolved_development_deps,
303
318
  sync_prepare_lefthook_warning,
304
319
  sync_safe_chain_preinstall,
305
320
  sync_package_json_migrations,
@@ -0,0 +1,73 @@
1
+ import { project_checks } from '#scripts/gate/project-checks'
2
+ import { json_value } from '#scripts/lib/json-value'
3
+ import { execa } from 'execa'
4
+
5
+ // **The unit check of a basic project that carries no vitest is the test script the project itself
6
+ // declares** — `scripts.test` in its `package.json`. A basic project is one of any language and any
7
+ // runner (`node --test`, jest, mocha, a wrapper around another language's), and its own script is the one statement of
8
+ // how its tests run that holds for all of them.
9
+ //
10
+ // Rejected: detecting `node --test` files and starting that runner here. Every further runner would
11
+ // need its own detection and its own invocation, and the project has already written both down.
12
+ const PNPM = 'pnpm'
13
+ const SCRIPT_NAME = 'test'
14
+ const FAIL_EXIT_CODE = 1
15
+
16
+ // What `npm init` and `pnpm init` write when no test command is given. It exits 1 by design, so
17
+ // running it would fail every freshly-initialized project for having declared nothing.
18
+ const INIT_PLACEHOLDER = 'echo "Error: no test specified" && exit 1'
19
+
20
+ // Set on the script this module spawns. A `scripts.test` that calls back into josh (`josh test:unit`,
21
+ // `josh gate`) reaches this check again with it set, and running the script from there would recurse
22
+ // without end.
23
+ const REENTRY_KEY = 'JOSH_PROJECT_TEST_SCRIPT'
24
+ const REENTRY_VALUE = '1'
25
+
26
+ type ProjectScriptState = 'declared' | 'absent' | 'reentrant'
27
+
28
+ const EMPTY_COMMANDS = new Set(['', INIT_PLACEHOLDER])
29
+
30
+ function field_of(value: unknown, key: string): unknown {
31
+ return json_value.is_record(value) ? value[key] : undefined
32
+ }
33
+
34
+ function declared_script(project_directory: string): string | undefined {
35
+ const manifest: unknown = project_checks.read_manifest(project_directory)
36
+ const script = field_of(field_of(manifest, 'scripts'), SCRIPT_NAME)
37
+ if (typeof script !== 'string') return undefined
38
+
39
+ const command = script.trim()
40
+
41
+ return EMPTY_COMMANDS.has(command) ? undefined : command
42
+ }
43
+
44
+ function state(
45
+ project_directory: string,
46
+ environment: NodeJS.ProcessEnv = process.env,
47
+ ): ProjectScriptState {
48
+ if (declared_script(project_directory) === undefined) return 'absent'
49
+
50
+ return environment[REENTRY_KEY] === REENTRY_VALUE ? 'reentrant' : 'declared'
51
+ }
52
+
53
+ // The notice is printed before the spawn, so a reader of the gate's output sees which command stood in
54
+ // for vitest rather than a bare runner log under the `test:unit` label.
55
+ async function run(project_directory: string, command_label: string): Promise<number> {
56
+ console.info(
57
+ `josh ${command_label}: vitest is not installed — running the project's own test script (${PNPM} run ${SCRIPT_NAME}).`,
58
+ )
59
+
60
+ const result = await execa(PNPM, ['run', SCRIPT_NAME], {
61
+ cwd: project_checks.project_root(project_directory),
62
+ env: { [REENTRY_KEY]: REENTRY_VALUE },
63
+ stdio: 'inherit',
64
+ reject: false,
65
+ })
66
+
67
+ return result.exitCode ?? FAIL_EXIT_CODE
68
+ }
69
+
70
+ const project_test_script = { INIT_PLACEHOLDER, REENTRY_KEY, declared_script, state, run }
71
+
72
+ export type { ProjectScriptState }
73
+ export { project_test_script }
@@ -1,3 +1,16 @@
1
1
  const SKIP_MARKER = '— skipping'
2
2
 
3
- export { SKIP_MARKER }
3
+ // **A check that ran and had nothing of the project's to look at says so with this, not with the skip
4
+ // marker.** `josh exports:unused` in a consumer and `josh behavior` with no transcript are out of scope
5
+ // by design rather than unverified, so they must not withhold the gate's green record the way a skip
6
+ // does — but neither checked anything, and the gate's verdict has to be able to tell them from a check
7
+ // that did. Shared for the reason the skip marker is: the notice and its reader cannot drift apart.
8
+ const NOTHING_TO_CHECK_MARKER = 'nothing to check'
9
+
10
+ // **A skip of a tool the project does not use carries this beside the skip marker.** A `basic`
11
+ // project with no ESLint configuration skips eslint, and the notice stays a visible skip the verdict
12
+ // counts — but nothing the project asked for went unverified, so it must not withhold the gate's
13
+ // green record the way a unit suite that did not run does.
14
+ const NOT_USED_MARKER = 'which this project does not use'
15
+
16
+ export { NOT_USED_MARKER, NOTHING_TO_CHECK_MARKER, SKIP_MARKER }
@@ -22,11 +22,12 @@ import { test_unit_guard } from './test-unit-guard'
22
22
  // project with no unit suite the record would say the tests were green over a run in which none
23
23
  // executed, and `josh review:brief` would stop refusing on the strength of it. **The guard's own
24
24
  // decision is asked again rather than re-derived here**, so the two can never disagree; it is asked
25
- // only when a record is otherwise due, since the question costs a project-wide glob.
25
+ // only when a record is otherwise due, since the question costs a project-wide glob. A basic project's
26
+ // own test script counts as a run: it executes the whole suite, which covers any narrowing.
26
27
  function conclusive_tree(before: GateTree | undefined): GateTree | undefined {
27
28
  if (before === undefined) return undefined
28
29
 
29
- return test_unit_guard.guard_action(process.cwd()) === 'run' ? before : undefined
30
+ return test_unit_guard.is_running(process.cwd()) ? before : undefined
30
31
  }
31
32
 
32
33
  async function run_related_tests(command_arguments: ReadonlyArray<string>): Promise<number> {
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
5
5
  import { PROJECT_ENVIRONMENT_KEYS } from '#ports'
6
6
  import { project_checks } from '#scripts/gate/project-checks'
7
7
  import { execa } from 'execa'
8
+ import { project_test_script, type ProjectScriptState } from './project-test-script'
8
9
  import { SKIP_MARKER } from './skip-marker'
9
10
  import { unit_worker_share } from './unit-worker-share'
10
11
 
@@ -35,29 +36,71 @@ const NODE_MODULES = 'node_modules'
35
36
  // oversight.** The argument above rests on the package's presence being a declaration, and
36
37
  // `@playwright/test` is not one: it is an optional peer dependency that vitest's browser mode also
37
38
  // needs, so a project can legitimately have it installed with no `*.e2e.{ts,js}` file at all.
39
+ //
40
+ // **A basic project without vitest is not thereby a project without tests.** It may be of any
41
+ // language and any runner, and the `scripts.test` it declares is run in vitest's place
42
+ // (`project-test-script.ts`); only a basic project that declares none skips. A full project keeps the
43
+ // plain skip: its unit suite is vitest's, and `scripts.test` there is not this check's to run.
38
44
  const SKIP_ACTION = 'skip-missing-package'
39
45
  const SKIP_NO_TESTS_ACTION = 'skip-no-tests'
46
+ const SKIP_NO_SCRIPT_ACTION = 'skip-no-test-script'
47
+ const SKIP_REENTRANT_ACTION = 'skip-reentrant'
48
+ const RUN_SCRIPT_ACTION = 'run-project-script'
40
49
  const FAIL_ACTION = 'fail-no-tests'
41
50
 
42
- type GuardAction = 'run' | typeof SKIP_ACTION | typeof SKIP_NO_TESTS_ACTION | typeof FAIL_ACTION
43
-
44
- // The word the gate looks for to know a passing step did not actually run. Exported and reused on
45
- // both sides rather than matched by eye: the gate does not print a passing check's body, so without
46
- // this a gate that skipped the whole unit suite would print the same lines as one that ran it.
47
- const SKIP_REASON = 'vitest is not installed'
48
- const SKIP_NO_TESTS_REASON = 'no unit test files were found in this basic project'
51
+ type SkipAction =
52
+ | typeof SKIP_ACTION
53
+ | typeof SKIP_NO_TESTS_ACTION
54
+ | typeof SKIP_NO_SCRIPT_ACTION
55
+ | typeof SKIP_REENTRANT_ACTION
56
+ type GuardAction = 'run' | typeof RUN_SCRIPT_ACTION | SkipAction | typeof FAIL_ACTION
57
+
58
+ // What each skip did not run, said in full: a basic project that declares no test script was not
59
+ // skipped for lacking vitest alone, and the unit tests it skipped were never vitest's. The
60
+ // `SKIP_MARKER` between the two halves is the word the gate looks for to know a passing step did not
61
+ // actually run; the gate does not print a passing check's body, so without it a gate that skipped the
62
+ // whole unit suite would print the same lines as one that ran it.
63
+ const VITEST_SUBJECT = 'vitest unit tests'
64
+ const UNIT_SUBJECT = 'unit tests'
65
+ const SKIP_NOTICES: Record<SkipAction, { reason: string; subject: string }> = {
66
+ [SKIP_ACTION]: { reason: 'vitest is not installed', subject: VITEST_SUBJECT },
67
+ [SKIP_NO_TESTS_ACTION]: {
68
+ reason: 'no unit test files were found in this basic project',
69
+ subject: VITEST_SUBJECT,
70
+ },
71
+ [SKIP_NO_SCRIPT_ACTION]: {
72
+ reason: 'vitest is not installed and package.json declares no test script',
73
+ subject: UNIT_SUBJECT,
74
+ },
75
+ [SKIP_REENTRANT_ACTION]: {
76
+ reason: "the project's test script called back into this check",
77
+ subject: UNIT_SUBJECT,
78
+ },
79
+ }
49
80
  const FAIL_REASON =
50
81
  'vitest is installed but no *.{test,spec}.{ts,js} test file was found — refusing to report a unit check that ran nothing'
51
82
 
83
+ const BASIC_MISSING_PACKAGE_ACTIONS: Record<ProjectScriptState, GuardAction> = {
84
+ declared: RUN_SCRIPT_ACTION,
85
+ absent: SKIP_NO_SCRIPT_ACTION,
86
+ reentrant: SKIP_REENTRANT_ACTION,
87
+ }
88
+
89
+ function resolve_installed_action(has_tests: boolean, is_basic: boolean): GuardAction {
90
+ if (has_tests) return 'run'
91
+
92
+ return is_basic ? SKIP_NO_TESTS_ACTION : FAIL_ACTION
93
+ }
94
+
52
95
  function resolve_guard_action(
53
96
  is_installed: boolean,
54
97
  has_tests: boolean,
55
98
  is_basic = false,
99
+ script_state: ProjectScriptState = 'absent',
56
100
  ): GuardAction {
57
- if (!is_installed) return SKIP_ACTION
58
- if (has_tests) return 'run'
101
+ if (is_installed) return resolve_installed_action(has_tests, is_basic)
59
102
 
60
- return is_basic ? SKIP_NO_TESTS_ACTION : FAIL_ACTION
103
+ return is_basic ? BASIC_MISSING_PACKAGE_ACTIONS[script_state] : SKIP_ACTION
61
104
  }
62
105
 
63
106
  function is_vitest_installed(project_directory: string): boolean {
@@ -84,15 +127,27 @@ function guard_action(project_directory: string): GuardAction {
84
127
  is_vitest_installed(project_directory),
85
128
  has_unit_tests(project_directory),
86
129
  project_checks.is_basic(project_directory),
130
+ project_test_script.state(project_directory),
87
131
  )
88
132
  }
89
133
 
134
+ function is_skip_action(action: GuardAction): action is SkipAction {
135
+ return Object.hasOwn(SKIP_NOTICES, action)
136
+ }
137
+
90
138
  // **A skip is the project having no unit suite, so no unit check can ever vouch for its tree.** The
91
139
  // failing branch is not one: it is a broken suite, and it is left to fail where it is reported.
92
140
  function is_skipping(project_directory: string): boolean {
141
+ return is_skip_action(guard_action(project_directory))
142
+ }
143
+
144
+ // **Whether a green exit vouches for the tree** — vitest ran, or the project's own script did. The
145
+ // scoped record is written on this answer, so a project `is_skipping` requires a record from is
146
+ // always one that can write it.
147
+ function is_running(project_directory: string): boolean {
93
148
  const action = guard_action(project_directory)
94
149
 
95
- return action === SKIP_ACTION || action === SKIP_NO_TESTS_ACTION
150
+ return action === 'run' || action === RUN_SCRIPT_ACTION
96
151
  }
97
152
 
98
153
  // A lane's port allocation belongs to its development and preview servers, not to unit fixtures.
@@ -146,16 +201,16 @@ const UNIT_COMMAND_LABEL = 'test:unit'
146
201
  // they mean different things: one says the project has no unit suite yet, the other says it has one
147
202
  // that could not be found. Only the first keeps `SKIP_MARKER`, so the
148
203
  // gate's "passed without running" handling still means exactly what it did.
149
- function report_no_run(action: Exclude<GuardAction, 'run'>, command_label: string): number {
204
+ function report_no_run(action: SkipAction | typeof FAIL_ACTION, command_label: string): number {
150
205
  if (action === FAIL_ACTION) {
151
206
  console.error(`josh ${command_label}: ${FAIL_REASON}.`)
152
207
 
153
208
  return FAIL_EXIT_CODE
154
209
  }
155
210
 
156
- const reason = action === SKIP_NO_TESTS_ACTION ? SKIP_NO_TESTS_REASON : SKIP_REASON
211
+ const { reason, subject } = SKIP_NOTICES[action]
157
212
 
158
- console.info(`josh ${command_label}: ${reason} ${SKIP_MARKER} vitest unit tests.`)
213
+ console.info(`josh ${command_label}: ${reason} ${SKIP_MARKER} ${subject}.`)
159
214
 
160
215
  return 0
161
216
  }
@@ -163,6 +218,10 @@ function report_no_run(action: Exclude<GuardAction, 'run'>, command_label: strin
163
218
  // `announcement` is printed only on the branch that actually spawns vitest. A caller that wrote it
164
219
  // itself would claim a run before this guard had decided there would be one — the same "a passing
165
220
  // step did not actually run" ambiguity `SKIP_MARKER` exists for, reintroduced one layer up.
221
+ //
222
+ // **The project's own script takes neither the vitest arguments nor the announcement.** Both describe
223
+ // a vitest run — a filter, a narrowing to related files — and the script runs as the project declared
224
+ // it, whole; it prints its own notice saying so.
166
225
  async function run_guarded_vitest(
167
226
  project_directory: string,
168
227
  vitest_arguments: ReadonlyArray<string>,
@@ -171,6 +230,10 @@ async function run_guarded_vitest(
171
230
  ): Promise<number> {
172
231
  const action = guard_action(project_directory)
173
232
 
233
+ if (action === RUN_SCRIPT_ACTION) {
234
+ return await project_test_script.run(project_directory, command_label)
235
+ }
236
+
174
237
  if (action !== 'run') return report_no_run(action, command_label)
175
238
  if (announcement !== undefined) process.stdout.write(`${announcement}\n`)
176
239
 
@@ -195,6 +258,7 @@ const test_unit_guard = {
195
258
  SKIP_MARKER,
196
259
  guard_action,
197
260
  is_skipping,
261
+ is_running,
198
262
  resolve_guard_action,
199
263
  is_vitest_installed,
200
264
  has_unit_tests,
@@ -112,8 +112,16 @@ function is_failed_verdict(line: string): boolean {
112
112
  return josh_verdict.read_verdict(line) === josh_verdict.FAILED_VERDICT
113
113
  }
114
114
 
115
- function is_passed_verdict(line: string): boolean {
116
- return josh_verdict.read_verdict(line) === josh_verdict.PASSED_VERDICT
115
+ // **A gate that ran no check closes its region exactly as a passed one does.** Its `not verified`
116
+ // verdict is still the gate's own statement that no check failed, so a failure mark a skip notice
117
+ // forwarded in front of it is no more the gate's than one in front of `passed`.
118
+ const GREEN_VERDICTS: ReadonlySet<string | undefined> = new Set([
119
+ josh_verdict.PASSED_VERDICT,
120
+ josh_verdict.UNVERIFIED_VERDICT,
121
+ ])
122
+
123
+ function is_green_verdict(line: string): boolean {
124
+ return GREEN_VERDICTS.has(josh_verdict.read_verdict(line))
117
125
  }
118
126
 
119
127
  // What one pass over the body has established. The two failure buckets are kept apart because a
@@ -165,7 +173,7 @@ function note_failure(scan: FailureScan): void {
165
173
  // anything about the region: it is the gate's report about one check, and the run it belongs to states
166
174
  // a failed verdict of its own.
167
175
  function scan_line(scan: FailureScan, line: string): void {
168
- if (is_passed_verdict(line)) {
176
+ if (is_green_verdict(line)) {
169
177
  close_region(scan)
170
178
 
171
179
  return
@@ -0,0 +1,92 @@
1
+ import path from 'node:path'
2
+ import { init } from '#scripts/init/init'
3
+ import { init_actions } from '#scripts/init/init-actions'
4
+ import { CLAUDE_MD_FILENAME, init_ai_copy, WORKSPACE_YAML } from '#scripts/init/init-ai-copy'
5
+ import { init_basic } from '#scripts/init/init-basic'
6
+ import { package_path, PROJECT_ROOT } from '#scripts/init/init-paths'
7
+ import type { ProjectProfile, ProjectShape } from '#scripts/init/project-profile'
8
+ import { file_reader } from '#scripts/lib/read-file'
9
+ import { PACK_HOOK_FILE } from '#scripts/safe-chain/pack-hook'
10
+ import { sync_ai_files } from '#scripts/sync/sync-ai-files'
11
+ import { IGNORE_FILE, type DistributedFile } from './uninstall-plan'
12
+
13
+ // Every file the profile can write, whatever the project looks like today: a project that has since
14
+ // lost its last web file still holds the Prettier config kit wrote while it had one. Asking for the
15
+ // widest shape is safe because a file is only ever deleted while it equals what kit writes.
16
+ function widest_shape(profile: ProjectProfile): ProjectShape {
17
+ return {
18
+ profile,
19
+ reason: 'josh uninstall',
20
+ should_use_prettier: true,
21
+ has_typescript: true,
22
+ has_git: true,
23
+ has_github: true,
24
+ has_workflow: true,
25
+ }
26
+ }
27
+
28
+ // Some content names the repository it lands in and cannot be produced where there is none. Such a
29
+ // file has no known distribution content, so it is kept.
30
+ function content_or_unknown(produce: () => string): string | undefined {
31
+ try {
32
+ return produce()
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ function config_files(shape: ProjectShape): Array<DistributedFile> {
39
+ return init_actions.build_file_actions(shape).map((action) => ({
40
+ dest: action.dest,
41
+ distributed: content_or_unknown(() => action.create()),
42
+ }))
43
+ }
44
+
45
+ function copied_file(filename: string, shape: ProjectShape): DistributedFile {
46
+ const source_path = package_path(init_ai_copy.ai_file_source(filename, shape))
47
+ const destination_path = path.join(PROJECT_ROOT, filename)
48
+ const read_content =
49
+ filename === WORKSPACE_YAML ? init_ai_copy.workspace_content : init_ai_copy.copied_content
50
+
51
+ return {
52
+ dest: filename,
53
+ distributed: content_or_unknown(() => read_content(source_path, destination_path)),
54
+ }
55
+ }
56
+
57
+ function mapped_file(source: string, destination: string): DistributedFile {
58
+ return {
59
+ dest: destination,
60
+ distributed: content_or_unknown(() =>
61
+ init_ai_copy.copied_content(package_path(source), path.join(PROJECT_ROOT, destination)),
62
+ ),
63
+ }
64
+ }
65
+
66
+ function ai_files(shape: ProjectShape): Array<DistributedFile> {
67
+ return [
68
+ { dest: CLAUDE_MD_FILENAME, distributed: sync_ai_files.ensured_claude_md(undefined, shape) },
69
+ ...init_ai_copy.ai_files(shape).map((filename) => copied_file(filename, shape)),
70
+ ...init_ai_copy.ai_file_mappings(shape).map(({ src, dest }) => mapped_file(src, dest)),
71
+ { dest: PACK_HOOK_FILE, distributed: file_reader.read_optional(package_path(PACK_HOOK_FILE)) },
72
+ ]
73
+ }
74
+
75
+ // The lists are `josh init`'s and `josh sync`'s own — the same builders, read for what they would
76
+ // write and not run — so a file added to either is one this command already knows about. The ignore
77
+ // file is left out: it is named for review and never compared, so it can never be removed.
78
+ function list(profile: ProjectProfile): Array<DistributedFile> {
79
+ const shape = widest_shape(profile)
80
+
81
+ return [...config_files(shape), ...ai_files(shape)].filter((file) => file.dest !== IGNORE_FILE)
82
+ }
83
+
84
+ // What kit's merge writes into the manifest it starts a project without one from, which names every
85
+ // entry it adds — `private` included, the one entry that start alone writes.
86
+ function kit_manifest(profile: ProjectProfile): string {
87
+ return init.merged_manifest(init_basic.initial_manifest(), widest_shape(profile))
88
+ }
89
+
90
+ const distributed_files = { list, kit_manifest }
91
+
92
+ export { distributed_files }