@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,215 @@
1
+ import {
2
+ appendFileSync,
3
+ chmodSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { PROJECT_ROOT } from '#scripts/init/init-paths'
14
+ import { execa, type Options } from 'execa'
15
+ import { z } from 'zod'
16
+ import { fresh_machine_docker, type ContainerRun } from './fresh-machine-docker'
17
+ import { fresh_machine_image } from './fresh-machine-image'
18
+ import { fresh_machine_log } from './fresh-machine-log'
19
+ import { fresh_machine_script } from './fresh-machine-script'
20
+ import type { Scenario } from './fresh-machine-spec'
21
+
22
+ // The I/O of `josh fresh:run`: build the machine, stage what the container may read, run the
23
+ // scenario, and put every line of all three into one log.
24
+
25
+ const DOCKER = 'docker'
26
+ const PNPM = 'pnpm'
27
+ const FAILURE_EXIT_CODE = 1
28
+ const STAGE_PREFIX = 'josh-fresh-machine-'
29
+ // The container's user is not the host's, so what it reads has to be readable by anyone — whatever
30
+ // the host's umask gave the seed files and the tarball `pnpm pack` wrote.
31
+ const SHARED_DIRECTORY_MODE = 0o755
32
+ const SHARED_FILE_MODE = 0o644
33
+ const TARBALL_EXTENSION = '.tgz'
34
+ const MANIFEST_SCHEMA = z.object({ name: z.string() })
35
+
36
+ interface RunRequest {
37
+ scenario: Scenario
38
+ run_name: string
39
+ log_path: string
40
+ package_version: string | undefined
41
+ // Variable name → value, each passed into the container by name and replaced in every line.
42
+ secrets: Readonly<Record<string, string>>
43
+ is_rebuild: boolean
44
+ }
45
+
46
+ interface Stage {
47
+ root: string
48
+ seed_directory: string
49
+ pack_directory: string
50
+ }
51
+
52
+ type Write = (line: string) => void
53
+
54
+ function logger(request: RunRequest): Write {
55
+ const secrets = Object.values(request.secrets)
56
+
57
+ return function write(line: string): void {
58
+ const safe = fresh_machine_log.redact(line, secrets)
59
+
60
+ console.info(safe)
61
+ appendFileSync(request.log_path, `${safe}\n`)
62
+ }
63
+ }
64
+
65
+ // One host command, its output streamed line by line into the log. A command that could not start
66
+ // (no docker on the machine) has no exit code, and its reason is the only line there is to log.
67
+ async function stream(
68
+ file: string,
69
+ arguments_: Array<string>,
70
+ options: Options,
71
+ write: Write,
72
+ ): Promise<number> {
73
+ const subprocess = execa(file, arguments_, { ...options, all: true, reject: false })
74
+ const lines = subprocess.iterable({ from: 'all' })
75
+
76
+ for await (const line of lines) write(String(line))
77
+
78
+ const result = await subprocess
79
+
80
+ if (result.exitCode === undefined) write(result.shortMessage ?? `${file} did not start`)
81
+
82
+ return result.exitCode ?? FAILURE_EXIT_CODE
83
+ }
84
+
85
+ function staged_directory(root: string, name: string): string {
86
+ const directory = path.join(root, name)
87
+
88
+ mkdirSync(directory)
89
+
90
+ return directory
91
+ }
92
+
93
+ function stage(scenario: Scenario): Stage {
94
+ const root = mkdtempSync(path.join(os.tmpdir(), STAGE_PREFIX))
95
+ const seed_directory = staged_directory(root, 'seed')
96
+
97
+ for (const [file, content] of Object.entries(scenario.files)) {
98
+ const target = path.join(seed_directory, file)
99
+
100
+ mkdirSync(path.dirname(target), { recursive: true })
101
+ writeFileSync(target, content)
102
+ }
103
+
104
+ return { root, seed_directory, pack_directory: staged_directory(root, 'pack') }
105
+ }
106
+
107
+ // Run once everything is staged, so the tarball is covered as well as the seed. The root itself stays
108
+ // as private as `mkdtemp` made it: a bind mount never enters a source through its parent, and on a
109
+ // shared host an open root would let every other user read the tarball for the length of the run.
110
+ function share(root: string): void {
111
+ const entries = readdirSync(root, { withFileTypes: true, recursive: true })
112
+
113
+ for (const entry of entries) {
114
+ const mode = entry.isDirectory() ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE
115
+
116
+ chmodSync(path.join(entry.parentPath, entry.name), mode)
117
+ }
118
+ }
119
+
120
+ // `pnpm pack` runs the checkout's own `prepack`, so the tarball holds what a release of this tree
121
+ // would.
122
+ async function pack(pack_directory: string, write: Write): Promise<string> {
123
+ const pack_arguments = ['pack', '--pack-destination', pack_directory]
124
+
125
+ write(`$ ${PNPM} ${pack_arguments.join(' ')}`)
126
+ await stream(PNPM, pack_arguments, { cwd: PROJECT_ROOT }, write)
127
+
128
+ const tarball = readdirSync(pack_directory).find((file) => file.endsWith(TARBALL_EXTENSION))
129
+
130
+ if (tarball === undefined) throw new Error('pnpm pack produced no tarball')
131
+
132
+ return tarball
133
+ }
134
+
135
+ function package_name(): string {
136
+ const manifest = readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8')
137
+
138
+ return MANIFEST_SCHEMA.parse(JSON.parse(manifest)).name
139
+ }
140
+
141
+ // The published version is always written out in the log and the install command, so a run can be
142
+ // repeated against the same release; without `--package-version` it is the registry's latest.
143
+ async function published_version(requested: string | undefined): Promise<string> {
144
+ if (requested !== undefined) return requested
145
+
146
+ const { stdout } = await execa(PNPM, ['view', package_name(), 'version'], { cwd: PROJECT_ROOT })
147
+
148
+ return stdout.trim()
149
+ }
150
+
151
+ async function container_run(
152
+ request: RunRequest,
153
+ staged: Stage,
154
+ write: Write,
155
+ ): Promise<ContainerRun> {
156
+ const { scenario } = request
157
+ const is_tarball = scenario.package_source === 'tarball'
158
+
159
+ return {
160
+ image: fresh_machine_image.tag(scenario.requires_github),
161
+ run_name: request.run_name,
162
+ script: fresh_machine_script.build(scenario),
163
+ seed_directory: staged.seed_directory,
164
+ pack_directory: staged.pack_directory,
165
+ tarball: is_tarball ? await pack(staged.pack_directory, write) : undefined,
166
+ package_version: is_tarball ? undefined : await published_version(request.package_version),
167
+ secret_names: Object.keys(request.secrets),
168
+ }
169
+ }
170
+
171
+ async function build_image(request: RunRequest, write: Write): Promise<number> {
172
+ const { scenario } = request
173
+ const image = fresh_machine_image.tag(scenario.requires_github)
174
+ const build_arguments = fresh_machine_docker.build_arguments(image, request.is_rebuild)
175
+ const input = fresh_machine_image.dockerfile(scenario.requires_github)
176
+
177
+ write(`$ ${DOCKER} ${build_arguments.join(' ')}`)
178
+
179
+ return await stream(DOCKER, build_arguments, { input }, write)
180
+ }
181
+
182
+ async function run_container(request: RunRequest, staged: Stage, write: Write): Promise<number> {
183
+ const container = await container_run(request, staged, write)
184
+
185
+ share(staged.root)
186
+ write(`$ ${DOCKER} run ${container.image} — ${request.run_name}`)
187
+ write(`# package: ${container.tarball ?? container.package_version ?? ''}`)
188
+
189
+ return await stream(
190
+ DOCKER,
191
+ fresh_machine_docker.run_arguments(container),
192
+ { env: request.secrets },
193
+ write,
194
+ )
195
+ }
196
+
197
+ async function run(request: RunRequest): Promise<number> {
198
+ const write = logger(request)
199
+ const build_code = await build_image(request, write)
200
+
201
+ if (build_code !== 0) return build_code
202
+
203
+ const staged = stage(request.scenario)
204
+
205
+ try {
206
+ return await run_container(request, staged, write)
207
+ } finally {
208
+ rmSync(staged.root, { recursive: true, force: true })
209
+ }
210
+ }
211
+
212
+ const fresh_machine_run = { run }
213
+
214
+ export { fresh_machine_run }
215
+ export type { RunRequest }
@@ -0,0 +1,45 @@
1
+ import type { Scenario } from './fresh-machine-spec'
2
+
3
+ // The script one container runs. Every command is printed before it runs and its exit status after,
4
+ // so the log reads as a terminal session; the first failing command ends the run, and the probes
5
+ // that follow a clean run are recorded whatever they exit with.
6
+
7
+ const SEED_MOUNT = '/seed'
8
+ const PACK_MOUNT = '/pack'
9
+ const COPY_SEED = `cp -R ${SEED_MOUNT}/. .`
10
+ const PROBES_HEADING = '# probes — recorded, never failing the run'
11
+ const ESCAPED_QUOTE = String.raw`'\''`
12
+ const PREAMBLE = [
13
+ String.raw`step() { printf '\n$ %s\n' "$1"; eval "$1"; local code=$?; printf '[exit %s]\n' "$code"; return "$code"; }`,
14
+ 'mkdir -p "$HOME/$RUN_NAME" && cd "$HOME/$RUN_NAME" || exit 1',
15
+ ]
16
+
17
+ function quote(command: string): string {
18
+ const escaped = command.split("'").join(ESCAPED_QUOTE)
19
+
20
+ return `'${escaped}'`
21
+ }
22
+
23
+ function required(command: string): string {
24
+ return `step ${quote(command)} || exit $?`
25
+ }
26
+
27
+ function probe_lines(probes: ReadonlyArray<string>): Array<string> {
28
+ if (probes.length === 0) return []
29
+
30
+ return [
31
+ String.raw`printf '\n%s\n' ${quote(PROBES_HEADING)}`,
32
+ ...probes.map((probe) => `step ${quote(probe)}`),
33
+ ]
34
+ }
35
+
36
+ function build(scenario: Scenario): string {
37
+ const seed = Object.keys(scenario.files).length > 0 ? [COPY_SEED] : []
38
+ const commands = [...seed, ...scenario.commands].map((command) => required(command))
39
+
40
+ return [...PREAMBLE, ...commands, ...probe_lines(scenario.probes), 'exit 0', ''].join('\n')
41
+ }
42
+
43
+ const fresh_machine_script = { build, SEED_MOUNT, PACK_MOUNT }
44
+
45
+ export { fresh_machine_script }
@@ -0,0 +1,57 @@
1
+ import path from 'node:path'
2
+ import { z } from 'zod'
3
+
4
+ // A scenario is everything one `josh fresh:run` needs to know: the files that are in the project
5
+ // before anything is installed, the commands a person would type, and the probes recorded after
6
+ // them. kit's own six patterns and a consumer toolkit's spec file are the same shape, so the machine
7
+ // and the log are shared and nothing about them is kit's alone (joshuafolkken/kit#3714).
8
+ //
9
+ // Inside the container a command reads `$RUN_NAME` (the numbered project and repository name),
10
+ // `$PACKAGE_TARBALL` (the packed checkout, for `package_source: tarball`) and `$PACKAGE_VERSION`
11
+ // (the published version, for `package_source: published`).
12
+
13
+ function is_project_relative(file: string): boolean {
14
+ const normalized = path.posix.normalize(file)
15
+
16
+ return !path.posix.isAbsolute(normalized) && !normalized.startsWith('..')
17
+ }
18
+
19
+ const COMMAND_SCHEMA = z.string().min(1)
20
+ const SCENARIO_SCHEMA = z.strictObject({
21
+ name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/u),
22
+ // A GitHub scenario runs on the image that also has git and gh, and receives the host's token.
23
+ requires_github: z.boolean().default(false),
24
+ // A Claude Code scenario receives the host's long-lived token as `CLAUDE_CODE_OAUTH_TOKEN`.
25
+ requires_claude: z.boolean().default(false),
26
+ package_source: z.enum(['tarball', 'published']).default('tarball'),
27
+ files: z.record(z.string().refine(is_project_relative), z.string()).default({}),
28
+ // A failing command ends the run; a failing probe is only recorded.
29
+ commands: z.array(COMMAND_SCHEMA).min(1),
30
+ probes: z.array(COMMAND_SCHEMA).default([]),
31
+ })
32
+
33
+ type Scenario = z.infer<typeof SCENARIO_SCHEMA>
34
+ type ScenarioInput = z.input<typeof SCENARIO_SCHEMA>
35
+
36
+ function define(input: ScenarioInput): Scenario {
37
+ return SCENARIO_SCHEMA.parse(input)
38
+ }
39
+
40
+ function problems(error: z.ZodError): string {
41
+ return error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ')
42
+ }
43
+
44
+ // A spec file is written by hand in another repository, so a mistake is reported as the fields that
45
+ // are wrong rather than as a stack trace.
46
+ function parse(json: string): Scenario {
47
+ const result = SCENARIO_SCHEMA.safeParse(JSON.parse(json))
48
+
49
+ if (!result.success) throw new Error(`Invalid scenario spec — ${problems(result.error)}`)
50
+
51
+ return result.data
52
+ }
53
+
54
+ const fresh_machine_spec = { define, parse }
55
+
56
+ export { fresh_machine_spec }
57
+ export type { Scenario, ScenarioInput }
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "kit-test-backlogrun-python",
3
+ "requires_github": true,
4
+ "package_source": "tarball",
5
+ "requires_claude": true,
6
+ "files": {
7
+ "pyproject.toml": "[project]\nname = \"greeting\"\nversion = \"0.1.0\"\nrequires-python = \">=3.9\"\n",
8
+ "main.py": "def main() -> None:\n print(\"Hello\")\n\n\nif __name__ == \"__main__\":\n main()\n"
9
+ },
10
+ "commands": [
11
+ "gh auth setup-git",
12
+ "git config --global user.name kit-test && git config --global user.email kit-test@example.com",
13
+ "printf '{ \"private\": true }\\n' > package.json",
14
+ "cp \"$PACKAGE_TARBALL\" . && pnpm add -D --allow-build=esbuild --allow-build=unrs-resolver \"./$(basename \"$PACKAGE_TARBALL\")\"",
15
+ "pnpm exec josh start --yes --github",
16
+ "pnpm josh profile | grep -x 'profile: basic (package.json josh.profile)'",
17
+ "grep -q '\"workflow\": \"github\"' package.json && test -f .claude/settings.json",
18
+ "curl -fsSL https://claude.ai/install.sh | bash && export PATH=\"$HOME/.local/bin:$PATH\" && claude --version",
19
+ "gh issue create --title 'Print a greeting with a name in main.py' --body 'Make main.py print \"Hello, Josh\" instead of \"Hello\".' --label auto-ok --label run:lane",
20
+ "timeout 60 pnpm josh backlogrun > \"$HOME/backlogrun.out\" 2>&1; cat \"$HOME/backlogrun.out\"; grep -q '^backlogrun: started' \"$HOME/backlogrun.out\"",
21
+ "pnpm josh run:event --from 0 | grep 'starting session'",
22
+ "for i in $(seq 1 90); do pnpm josh run:event --from 0 | grep -q 'dispatching' && break; sleep 10; done; pnpm josh run:event --from 0 | grep 'dispatching'",
23
+ "for i in $(seq 1 120); do test \"$(gh pr list --state all --json number --jq length)\" -gt 0 && break; sleep 10; done; pnpm josh run:board --chat",
24
+ "pnpm josh backlogrun --stop"
25
+ ],
26
+ "probes": [
27
+ "tail -n 40 \"$(sed -n 's/^backlogrun: started.*; log //p' \"$HOME/backlogrun.out\")\" | cut -c1-400",
28
+ "grep -c 'requires approval' \"$(sed -n 's/^backlogrun: started.*; log //p' \"$HOME/backlogrun.out\")\"",
29
+ "grep -c 'run:carry --begin' \"$(sed -n 's/^backlogrun: started.*; log //p' \"$HOME/backlogrun.out\")\"",
30
+ "tail -n 40 /tmp/josh-lane-dispatch-*.log | cut -c1-400",
31
+ "cat /tmp/josh-lane-dispatch-*.log | grep -c 'requires approval'",
32
+ "gh pr list --state all"
33
+ ]
34
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "kit-test-backlogrun",
3
+ "requires_github": true,
4
+ "package_source": "tarball",
5
+ "requires_claude": true,
6
+ "files": {
7
+ "index.html": "<!doctype html><html><body><h1>Hello</h1></body></html>\n"
8
+ },
9
+ "commands": [
10
+ "gh auth setup-git",
11
+ "git config --global user.name kit-test && git config --global user.email kit-test@example.com",
12
+ "printf '{ \"private\": true }\\n' > package.json",
13
+ "cp \"$PACKAGE_TARBALL\" . && pnpm add -D --allow-build=esbuild --allow-build=unrs-resolver \"./$(basename \"$PACKAGE_TARBALL\")\"",
14
+ "pnpm exec josh start --yes --github",
15
+ "gh label list --limit 100 --json name --jq '.[].name' > \"$HOME/labels.txt\"; grep -xE 'auto-ok|needs-decision|needs-human-review|already-done|review-round2-skipped|bug' \"$HOME/labels.txt\"; test \"$(grep -cxE 'auto-ok|needs-decision|needs-human-review|already-done|review-round2-skipped|bug' \"$HOME/labels.txt\")\" -eq 6",
16
+ "curl -fsSL https://claude.ai/install.sh | bash && export PATH=\"$HOME/.local/bin:$PATH\" && claude --version",
17
+ "gh issue create --title 'Add a heading to index.html' --body 'Add an h2 under the h1.' --label auto-ok --label run:lane",
18
+ "timeout 60 pnpm josh backlogrun > \"$HOME/backlogrun.out\" 2>&1; cat \"$HOME/backlogrun.out\"; grep -q '^backlogrun: started' \"$HOME/backlogrun.out\"",
19
+ "pnpm josh run:event --from 0 | grep 'starting session'",
20
+ "for i in $(seq 1 90); do pnpm josh run:event --from 0 | grep -q 'dispatching' && break; sleep 10; done; pnpm josh run:event --from 0 | grep 'dispatching'",
21
+ "for i in $(seq 1 120); do test \"$(gh pr list --state all --json number --jq length)\" -gt 0 && break; sleep 10; done; pnpm josh run:board --chat",
22
+ "pnpm josh backlogrun --stop"
23
+ ],
24
+ "probes": [
25
+ "tail -n 40 \"$(sed -n 's/^backlogrun: started.*; log //p' \"$HOME/backlogrun.out\")\" | cut -c1-400",
26
+ "grep -c 'requires approval' \"$(sed -n 's/^backlogrun: started.*; log //p' \"$HOME/backlogrun.out\")\"",
27
+ "tail -n 40 /tmp/josh-lane-dispatch-*.log | cut -c1-400",
28
+ "cat /tmp/josh-lane-dispatch-*.log | grep -c 'requires approval'",
29
+ "gh pr list --state all"
30
+ ]
31
+ }
@@ -2,6 +2,7 @@ import { GATE_COMMAND } from '#scripts/josh/josh-command-types'
2
2
  import { josh_verdict } from '#scripts/josh/josh-verdict'
3
3
  import { buffered_process, type BufferedProcessResult } from '#scripts/lib/buffered-process'
4
4
  import { status_icons } from '#scripts/lib/status-icons'
5
+ import { NOT_USED_MARKER, NOTHING_TO_CHECK_MARKER } from '#scripts/test/skip-marker'
5
6
  import { test_unit_guard } from '#scripts/test/test-unit-guard'
6
7
  import { gate_log } from './gate-log'
7
8
  import { gate_plan } from './gate-plan'
@@ -11,7 +12,7 @@ import { gate_plan } from './gate-plan'
11
12
  // about what is printed changes in the move: the four blocks, the log notice and the verdict come out
12
13
  // exactly as before, with one addition — the per-failure action lines below.
13
14
 
14
- const { FAIL_ICON, PASS_ICON } = status_icons
15
+ const { FAIL_ICON, PASS_ICON, WARN_ICON } = status_icons
15
16
 
16
17
  interface GateStep {
17
18
  label: string
@@ -76,16 +77,56 @@ function has_warnings(result: GateStepResult): boolean {
76
77
  // other line still withholds.
77
78
  const SAFE_CHAIN_BANNER = 'safe-chain is not available'
78
79
 
80
+ function checker_lines(result: GateStepResult): ReadonlyArray<string> {
81
+ return result.output.split('\n').filter((line) => !line.includes(SAFE_CHAIN_BANNER))
82
+ }
83
+
79
84
  function has_checker_warning(result: GateStepResult): boolean {
80
85
  if (!gate_plan.WARNING_CHECKER_LABELS.includes(result.label)) return false
81
86
 
82
- return result.output
83
- .split('\n')
84
- .some(
85
- (line) =>
86
- !line.includes(SAFE_CHAIN_BANNER) &&
87
- WARNING_MARKERS.some((marker) => line.includes(marker)),
88
- )
87
+ return checker_lines(result).some((line) =>
88
+ WARNING_MARKERS.some((marker) => line.includes(marker)),
89
+ )
90
+ }
91
+
92
+ // The record's counterpart to `is_skip_notice`, narrower for the reason `has_checker_warning` is
93
+ // narrower than `has_warnings`: a skip of a tool the project does not use (`NOT_USED_MARKER`) left
94
+ // nothing unverified, so only another skip line withholds the green record.
95
+ function is_unverified_skip(result: GateStepResult): boolean {
96
+ return checker_lines(result).some(
97
+ (line) => line.includes(test_unit_guard.SKIP_MARKER) && !line.includes(NOT_USED_MARKER),
98
+ )
99
+ }
100
+
101
+ // **A check the verdict counts as skipped said nothing but that it checked nothing.** `is_skip_notice`
102
+ // asks whether a check carries a skip notice, which is the right question for printing its body; it is
103
+ // the wrong one for a count, twice over. A check can skip half of
104
+ // itself — a `basic` project's `lint` runs Prettier and skips eslint — and calling it skipped would
105
+ // have the verdict say nothing ran on a gate that checked the formatting. And a check can run with
106
+ // nothing of the project's to look at — `exports` in a consumer, `behavior` with no transcript — which
107
+ // is no skip, and still left an empty `basic` project reading `2 ran`. A line that is neither notice is
108
+ // something a tool printed, so the check ran. A tool that ran clean and silent beside a skipped one is
109
+ // given a line of its own by the check (`project_checks.ran_notice`), so the reading holds for it too.
110
+ //
111
+ // **pnpm's script header is not something the check printed.** Every check is spawned as
112
+ // `pnpm josh <target>`, and where the project declares a `josh` script — `josh init` writes one — pnpm
113
+ // opens the captured output with `$ josh <target>`. Counted as a line, it made every spawned check one
114
+ // that ran, and `not verified` unreachable in the very project it was written for.
115
+ const IDLE_MARKERS: ReadonlyArray<string> = [test_unit_guard.SKIP_MARKER, NOTHING_TO_CHECK_MARKER]
116
+ const PNPM_SCRIPT_HEADER = '$ '
117
+
118
+ function is_idle_line(line: string): boolean {
119
+ return IDLE_MARKERS.some((marker) => line.includes(marker))
120
+ }
121
+
122
+ function is_tool_line(line: string): boolean {
123
+ return line.trim() !== '' && !line.startsWith(PNPM_SCRIPT_HEADER)
124
+ }
125
+
126
+ function is_idle(result: GateStepResult): boolean {
127
+ const lines = checker_lines(result).filter((line) => is_tool_line(line))
128
+
129
+ return lines.length > 0 && lines.every((line) => is_idle_line(line))
89
130
  }
90
131
 
91
132
  function should_print_body(result: GateStepResult, is_verbose: boolean): boolean {
@@ -144,10 +185,7 @@ function format_failure_actions(failed: ReadonlyArray<GateStepResult>): string {
144
185
 
145
186
  // **A check that started past the core budget is said in the summary, not only in the log**.
146
187
  // The wait cap admits a check that waited two minutes whatever the budget says,
147
- // so a gate that ran over-subscribed would otherwise look exactly like one that fitted. The icon is
148
- // local rather than in `status-icons.ts` because nothing reads this line back.
149
- const WARN_ICON = '⚠'
150
-
188
+ // so a gate that ran over-subscribed would otherwise look exactly like one that fitted.
151
189
  function format_budget_notes(results: ReadonlyArray<GateStepResult>): string {
152
190
  const lines = results.flatMap((result) =>
153
191
  result.budget_note === undefined
@@ -158,6 +196,24 @@ function format_budget_notes(results: ReadonlyArray<GateStepResult>): string {
158
196
  return lines.length === 0 ? '' : `${lines.join('\n')}\n`
159
197
  }
160
198
 
199
+ // **The verdict counts what ran apart from what was skipped, and a gate that ran nothing does not say
200
+ // `passed`.** It printed the planned number of checks whatever happened to them, so a `basic` project
201
+ // whose every check skipped read `passed (6 checks)` — the same last line as a gate that ran them all,
202
+ // and the skip this module keeps visible in the bodies was invisible in the one line a `tail` keeps.
203
+ //
204
+ // **A gate with no skip keeps the line it always printed.** A check with nothing to check is counted
205
+ // apart only beside a skip: `exports` says so in every consumer, and counting it alone would reword the
206
+ // verdict of every gate that skipped nothing.
207
+ function format_green_verdict(results: ReadonlyArray<GateStepResult>, total: string): string {
208
+ const has_skip = results.some((result) => is_skip_notice(result))
209
+ const skipped = has_skip ? results.filter((result) => is_idle(result)).length : 0
210
+ const ran = results.length - skipped
211
+
212
+ if (ran === 0) return josh_verdict.format_gate_unverified(skipped, total)
213
+
214
+ return josh_verdict.format_gate_passed({ ran, skipped }, total)
215
+ }
216
+
161
217
  // The total is wall-clock for the whole command, not the sum of the four — they run concurrently,
162
218
  // so a sum would report about three times what the caller waited.
163
219
  //
@@ -172,7 +228,7 @@ function print_gate_summary(
172
228
  const total = format_seconds(report.elapsed_ms)
173
229
  const verdict =
174
230
  failed.length === 0
175
- ? josh_verdict.format_gate_passed(report.step_count, total)
231
+ ? format_green_verdict(results, total)
176
232
  : josh_verdict.format_gate_failed(failed.map((result) => result.label).join(', '), total)
177
233
  const notes = format_budget_notes(results)
178
234
 
@@ -185,7 +241,6 @@ interface GateReport {
185
241
  is_verbose: boolean
186
242
  log_path: string | undefined
187
243
  elapsed_ms: number
188
- step_count: string
189
244
  }
190
245
 
191
246
  // Everything a finished run says, in the order a reader meets it: the four blocks, then the log the
@@ -213,7 +268,7 @@ const gate_report = {
213
268
  format_failure_actions,
214
269
  has_checker_warning,
215
270
  is_gate_step_failed,
216
- is_skip_notice,
271
+ is_unverified_skip,
217
272
  report_gate_steps,
218
273
  }
219
274
 
@@ -5,7 +5,7 @@ import { doctor_consumer } from '#scripts/doctor/doctor-consumer'
5
5
  import { project_profile } from '#scripts/init/project-profile'
6
6
  import { ancestor_directories } from '#scripts/lib/ancestor-directories'
7
7
  import { json_value } from '#scripts/lib/json-value'
8
- import { SKIP_MARKER } from '#scripts/test/skip-marker'
8
+ import { NOT_USED_MARKER, SKIP_MARKER } from '#scripts/test/skip-marker'
9
9
  import { z } from 'zod'
10
10
 
11
11
  const PACKAGE_JSON = 'package.json'
@@ -126,8 +126,17 @@ function missing_tool_reason(
126
126
  return `${package_name} is not installed`
127
127
  }
128
128
 
129
+ // Every skip reason here names a tool the project does not use — undeclared, or with no files or no
130
+ // configuration to read — so the notice says so (`NOT_USED_MARKER`).
129
131
  function skip_notice(check: string, reason: string): string {
130
- return `josh ${check}: ${reason} ${SKIP_MARKER} ${check}.`
132
+ return `josh ${check}: ${reason} ${SKIP_MARKER} ${check}, ${NOT_USED_MARKER}.`
133
+ }
134
+
135
+ // **A tool that ran clean beside a skipped one says that it ran.** A clean eslint prints nothing, so a
136
+ // `lint` whose Prettier was skipped read as nothing but a skip notice — the same output as a `lint`
137
+ // that ran neither — and the gate's verdict counted a check that linted the whole tree as skipped.
138
+ function ran_notice(check: string): string {
139
+ return `josh ${check}: ran and reported no problems.`
131
140
  }
132
141
 
133
142
  // Shared by `josh check` and the gate's type-check step, so neither reaches `tsc` on a basic
@@ -173,6 +182,8 @@ const project_checks = {
173
182
  is_kit_repository,
174
183
  prettier_skip_reason,
175
184
  project_root,
185
+ ran_notice,
186
+ read_manifest,
176
187
  skip_notice,
177
188
  type_check_skip_reason,
178
189
  }
@@ -64,7 +64,8 @@ interface ScopedSources {
64
64
  }
65
65
 
66
66
  // **`test:related` is required only where it can ever answer**. In a project
67
- // whose unit guard skips — vitest absent, or a basic project with no test file — `test:related` runs
67
+ // whose unit guard skips — vitest absent with no project test script to run in its place, or a basic
68
+ // project with no test file — `test:related` runs
68
69
  // nothing and so writes no record, and requiring one refused the gate forever with no way through.
69
70
  // The guard's own decision is asked rather than re-derived, so the writer and this reader agree; a
70
71
  // project with a unit suite still needs the record, and a run that executed nothing still writes none.
@@ -63,7 +63,9 @@ async function run_gate_step(step: GateStep): Promise<GateStepResult> {
63
63
  // passed *without running* — `test-unit-guard` exits 0 with a notice when vitest is absent or the
64
64
  // project has no tests — must not become "the unit tests all passed": the gate keeps that skip
65
65
  // visible on the console, and a record erasing it would have the brief tell a review agent not to
66
- // re-run tests that never ran. A step that passed **with a checker's warnings** is withheld for the
66
+ // re-run tests that never ran. A skip of a tool the project does not use (`NOT_USED_MARKER` — eslint
67
+ // with no ESLint configuration) is not one: nothing went unverified, so it withholds nothing
68
+ // (`gate_report.is_unverified_skip`). A step that passed **with a checker's warnings** is withheld for the
67
69
  // same reason one place further on: since the record is reused instead of the checks being re-run, a
68
70
  // warning printed once would never be printed again on that tree, and hiding an eslint or
69
71
  // svelte-check finding is the same failure as hiding a skip. It is `has_checker_warning`, not
@@ -80,7 +82,7 @@ async function run_gate_step(step: GateStep): Promise<GateStepResult> {
80
82
  // The destination is a parameter so a test can exercise the record without overwriting the one a
81
83
  // real run may be relying on — `josh gate` and `josh review:brief` share one path by design.
82
84
  function has_nothing_to_say(result: GateStepResult): boolean {
83
- return !gate_report.is_skip_notice(result) && !gate_report.has_checker_warning(result)
85
+ return !gate_report.is_unverified_skip(result) && !gate_report.has_checker_warning(result)
84
86
  }
85
87
 
86
88
  async function record_green_gate(
@@ -362,7 +364,6 @@ async function settle_gate(
362
364
  is_verbose: options.is_verbose ?? false,
363
365
  log_path: options.log_path,
364
366
  elapsed_ms,
365
- step_count: String(plan.checks.length),
366
367
  })
367
368
  const is_passed = failed_labels.length === 0
368
369
 
@@ -0,0 +1,14 @@
1
+ // Whose words kit acts on: the `author_association` REST reports with an issue or a comment, and the
2
+ // ones that can write to the repository. On a public repository anyone can file an issue or comment,
3
+ // so a body kit runs (`josh measure:rerun`) or copies into the ledger (`review:record`) is taken only
4
+ // from an author with write access — and an unknown author is not a trusted one.
5
+ const TRUSTED_ASSOCIATIONS: ReadonlySet<string> = new Set(['OWNER', 'MEMBER', 'COLLABORATOR'])
6
+ const TRUSTED_LIST = [...TRUSTED_ASSOCIATIONS].join(' / ')
7
+
8
+ function is_trusted(association: string | null | undefined): boolean {
9
+ return typeof association === 'string' && TRUSTED_ASSOCIATIONS.has(association)
10
+ }
11
+
12
+ const gh_author_association = { TRUSTED_LIST, is_trusted }
13
+
14
+ export { gh_author_association }