@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
@@ -7,6 +7,7 @@ import { init_logic_json_merge } from './init-logic-json-merge'
7
7
  import { init_logic_secretlint } from './init-logic-secretlint'
8
8
  import { init_logic_sonar } from './init-logic-sonar'
9
9
  import { init_logic_templates } from './init-logic-templates'
10
+ import { init_logic_toolchain } from './init-logic-toolchain'
10
11
  import { init_logic_workspace } from './init-logic-workspace'
11
12
  import { init_logic_yaml_merge } from './init-logic-yaml-merge'
12
13
 
@@ -417,6 +418,7 @@ const init_logic = {
417
418
  ...init_logic_sonar,
418
419
  ...init_logic_json_merge,
419
420
  ...init_logic_secretlint,
421
+ ...init_logic_toolchain,
420
422
  ...init_logic_yaml_merge,
421
423
  ...init_logic_deploy_vps,
422
424
  ...distributed_paths,
@@ -23,7 +23,6 @@ import { init_file_action } from './init-file-action'
23
23
  import { init_install } from './init-install'
24
24
  import { init_logic } from './init-logic'
25
25
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
26
- import { kit_development_versions } from './kit-development-versions'
27
26
  import { kit_setup_state } from './kit-setup-state'
28
27
  import { package_manager_pin } from './package-manager-pin'
29
28
  import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
@@ -71,27 +70,8 @@ function get_kit_self_dependency(): Record<string, string> {
71
70
  return { [KIT_PACKAGE_NAME]: version }
72
71
  }
73
72
 
74
- // Each config `init` generates runs a CLI or imports types the consumer has to resolve itself:
75
- // `cspell.config.yaml` → cspell, `playwright.config.ts` → @types/node, `lefthook.yml` → lefthook.
76
- // Prettier, its preset plugins and @playwright/test come in as peers. Without them the first
77
- // `josh gate` after `pnpm install` fails.
78
- const TOOL_DEVELOPMENT_DEPENDENCIES = ['cspell', '@types/node']
79
- const LEFTHOOK_DEVELOPMENT_DEPENDENCY = 'lefthook'
80
-
81
- function tool_dependency_names(has_git: boolean): ReadonlyArray<string> {
82
- return has_git
83
- ? [...TOOL_DEVELOPMENT_DEPENDENCIES, LEFTHOOK_DEVELOPMENT_DEPENDENCY]
84
- : TOOL_DEVELOPMENT_DEPENDENCIES
85
- }
86
-
87
- // Versions mirror kit's own devDependencies, so the consumer runs the toolchain kit is verified with.
88
- function get_toolchain_development_dependencies(has_git: boolean): Record<string, string> {
89
- return kit_development_versions.versions_of([
90
- ...kit_development_versions.peer_names(),
91
- ...tool_dependency_names(has_git),
92
- ])
93
- }
94
-
73
+ // Each config `init` generates runs a CLI or imports types the consumer has to resolve itself, so
74
+ // the toolchain is added here: without it the first `josh gate` after `pnpm install` fails.
95
75
  function apply_dependency_merges(content: string, has_git = true): string {
96
76
  const migrated = init_logic.strip_managed_postinstall(content)
97
77
  const suggested = init_logic.get_suggested_scripts_for_content(migrated)
@@ -103,7 +83,7 @@ function apply_dependency_merges(content: string, has_git = true): string {
103
83
  )
104
84
  const with_toolchain = init_logic.merge_development_dependencies(
105
85
  merged,
106
- get_toolchain_development_dependencies(has_git),
86
+ init_logic.get_toolchain_development_dependencies(has_git),
107
87
  )
108
88
  const with_secretlint = init_logic.merge_secretlint_development_deps(with_toolchain)
109
89
 
@@ -147,6 +127,7 @@ function merged_manifest(existing: string, shape: ProjectShape): string {
147
127
  return init_basic.with_recorded_profile(
148
128
  apply_package_json_merges(existing, shape.has_git),
149
129
  shape.profile,
130
+ shape.has_workflow,
150
131
  )
151
132
  }
152
133
 
@@ -325,6 +306,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
325
306
  const init = {
326
307
  copy_ai_file: init_ai_copy.copy_ai_file,
327
308
  apply_package_json_merges,
309
+ merged_manifest,
328
310
  install_lefthook,
329
311
  resolve_kit_package_manager,
330
312
  }
@@ -4,6 +4,8 @@ import { repo_origin } from '#scripts/discovery/repo-origin'
4
4
  import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
5
5
  import { json_value } from '#scripts/lib/json-value'
6
6
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
7
+ import { kit_written_paths } from './kit-written-paths'
8
+ import { workflow_mark } from './workflow-mark'
7
9
 
8
10
  type ProjectProfile = 'basic' | 'full'
9
11
 
@@ -17,6 +19,8 @@ interface ProjectShape extends ProfileResult {
17
19
  has_typescript: boolean
18
20
  has_git: boolean
19
21
  has_github: boolean
22
+ // Set up by `josh start` for the GitHub Issue workflow (`workflow-mark.ts`).
23
+ has_workflow: boolean
20
24
  }
21
25
 
22
26
  const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules', 'dist', 'build', '.svelte-kit'])
@@ -136,18 +140,27 @@ function scan_directory(directory: string, pending: Array<string>): Array<string
136
140
  .map((entry) => path.join(directory, entry.name)),
137
141
  )
138
142
 
139
- return entries.filter((entry) => entry.isFile()).map((entry) => path.extname(entry.name))
143
+ return entries.filter((entry) => entry.isFile()).map((entry) => path.join(directory, entry.name))
144
+ }
145
+
146
+ // A file kit wrote says nothing about the project: counted, the `.pnpmfile.mjs` of the first
147
+ // `josh init` would read as the user's Web file on the second and bring Prettier in
148
+ // (joshuafolkken/kit#3713).
149
+ function is_project_file(root: string, file: string): boolean {
150
+ const relative_path = path.relative(root, file).split(path.sep).join(path.posix.sep)
151
+
152
+ return !kit_written_paths.is_kit_written(relative_path)
140
153
  }
141
154
 
142
155
  function file_extensions(root: string): Array<string> {
143
156
  const pending = [root]
144
- const extensions: Array<string> = []
157
+ const files: Array<string> = []
145
158
 
146
159
  for (const directory of pending) {
147
- extensions.push(...scan_directory(directory, pending))
160
+ files.push(...scan_directory(directory, pending))
148
161
  }
149
162
 
150
- return extensions
163
+ return files.filter((file) => is_project_file(root, file)).map((file) => path.extname(file))
151
164
  }
152
165
 
153
166
  function scan_files(root: string): { has_web: boolean; has_typescript: boolean } {
@@ -164,17 +177,33 @@ function has_github_remote(root: string): boolean {
164
177
  return url !== undefined && repo_origin.parse_origin_url(url) !== undefined
165
178
  }
166
179
 
180
+ function has_git(root: string): boolean {
181
+ return existsSync(path.join(root, '.git'))
182
+ }
183
+
184
+ // The run is asked first, so a `josh start` whose setup has not written `package.json` yet reads it.
185
+ function has_workflow(root: string): boolean {
186
+ return workflow_mark.is_requested() || workflow_mark.is_recorded(read_manifest(root))
187
+ }
188
+
167
189
  function inspect_project(root: string, requested?: ProjectProfile): ProjectShape {
168
190
  const profile = resolve_profile(root, requested)
169
191
  const files = scan_files(root)
170
- const has_git = existsSync(path.join(root, '.git'))
171
-
172
- return { ...profile, ...files, has_git, has_github: has_git && has_github_remote(root) }
192
+ const is_git = has_git(root)
193
+
194
+ return {
195
+ ...profile,
196
+ ...files,
197
+ has_git: is_git,
198
+ has_github: is_git && has_github_remote(root),
199
+ has_workflow: has_workflow(root),
200
+ }
173
201
  }
174
202
 
175
203
  const project_profile = {
176
204
  parse_profile,
177
205
  resolve_profile,
206
+ has_git,
178
207
  inspect_project,
179
208
  requested_profile,
180
209
  with_legacy_profile_names,
@@ -1,6 +1,6 @@
1
1
  import { git_gh_exec } from '#scripts/gh/git-gh-exec'
2
2
  import { git_location_environment } from '#scripts/git/git-location-environment'
3
- import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
+ import { git_spawn_sync, type GitSyncOptions } from '#scripts/git/git-spawn-sync'
4
4
  import { COMMAND_TIMEOUT_MS, SUITE_TIMEOUT_MS } from '#scripts/lib/timeouts'
5
5
  import { execaSync } from 'execa'
6
6
  import type { Visibility } from './start-plan'
@@ -16,6 +16,8 @@ import type { Visibility } from './start-plan'
16
16
  // project's own commit and push hooks, which may run its whole suite, so it takes the suite budget:
17
17
  // long enough for that, short enough that a push that never answers ends the run.
18
18
  const SUCCESS_EXIT_CODE = 0
19
+ const GIT_DIRECTORY = '.git'
20
+ const IDENTITY_VARIABLES = ['GIT_AUTHOR_IDENT', 'GIT_COMMITTER_IDENT']
19
21
 
20
22
  function root_environment(): Record<string, string | undefined> {
21
23
  return git_location_environment.location_free_environment()
@@ -29,6 +31,29 @@ function git_read(args: ReadonlyArray<string>, root: string): string | undefined
29
31
  return git_spawn_sync.read(args, { cwd: root, env: root_environment() })
30
32
  }
31
33
 
34
+ // Asked of git itself, so the answer is the one its commit would reach: config, the environment and
35
+ // the identity git can detect all count (#3754).
36
+ //
37
+ // **Only an answer from git counts as a missing identity.** A git that could not be started or never
38
+ // answered is a different failure, which the first step that runs git reports as what it is.
39
+ function is_identity_refused(variable: string, options: GitSyncOptions): boolean {
40
+ const { exit_code } = git_spawn_sync.run(['var', variable], options)
41
+
42
+ return exit_code !== undefined && exit_code !== SUCCESS_EXIT_CODE
43
+ }
44
+
45
+ // A plan that creates the repository asks from where it will be: an identity given per directory
46
+ // (`includeIf "gitdir:…"`) is matched against the git directory, which no `cwd` supplies before
47
+ // `git init` has run. The path stays relative to `root` so git resolves it as its commit will — by
48
+ // the path the user reached the directory through as well as the physical one, which is what a
49
+ // `gitdir:` under a symlinked parent is written against.
50
+ function lacks_git_identity(root: string, is_new_repository: boolean): boolean {
51
+ const location = is_new_repository ? { GIT_DIR: GIT_DIRECTORY } : {}
52
+ const options = { cwd: root, env: { ...root_environment(), ...location } }
53
+
54
+ return IDENTITY_VARIABLES.some((variable) => is_identity_refused(variable, options))
55
+ }
56
+
32
57
  function git_run(args: ReadonlyArray<string>, root: string): void {
33
58
  const options = { cwd: root, env: root_environment(), timeout: SUITE_TIMEOUT_MS }
34
59
  const { exit_code } = git_spawn_sync.run(args, { ...options, stdio: 'inherit' })
@@ -86,6 +111,7 @@ const start_exec = {
86
111
  git_succeeds,
87
112
  git_read,
88
113
  git_run,
114
+ lacks_git_identity,
89
115
  is_gh_installed,
90
116
  is_gh_signed_in,
91
117
  create_github_repository,
@@ -58,6 +58,9 @@ const STEP_LABELS: Readonly<Record<StartStep, string>> = {
58
58
  // so they are also the steps a caller's push preflight guards (#3702).
59
59
  const GITHUB_WRITE_STEPS: ReadonlySet<StartStep> = new Set(['repository', 'setup_pr'])
60
60
 
61
+ // The steps that create a commit, which git refuses without an author identity (#3754).
62
+ const COMMIT_STEPS: ReadonlySet<StartStep> = new Set(['commit', 'setup_pr'])
63
+
61
64
  function refused(refusal: string): StepPlan {
62
65
  return { steps: [], refusal }
63
66
  }
@@ -113,6 +116,10 @@ function has_push(steps: ReadonlyArray<StartStep>): boolean {
113
116
  return steps.some((step) => GITHUB_WRITE_STEPS.has(step))
114
117
  }
115
118
 
119
+ function has_commit(steps: ReadonlyArray<StartStep>): boolean {
120
+ return steps.some((step) => COMMIT_STEPS.has(step))
121
+ }
122
+
116
123
  function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interaction> {
117
124
  if (options.is_yes) return { value: 'unattended', refusal: undefined }
118
125
  if (is_tty) return { value: 'interactive', refusal: undefined }
@@ -167,6 +174,12 @@ function preflight_report(command: string, cause: string): string {
167
174
  return `josh start stopped before its first step: the push preflight (${command}) failed. Nothing was changed.\nCause: ${cause}`
168
175
  }
169
176
 
177
+ // What a run prints when the scanner the pre-push audit needs could not be put in place: every push
178
+ // the plan makes would be refused by that hook, so it stops while nothing has changed (#3755).
179
+ function scanner_report(cause: string): string {
180
+ return `josh start stopped before its first step: osv-scanner, which the pre-push audit needs, is not installed and could not be fetched. Nothing was changed.\nCause: ${cause}\nInstall osv-scanner (https://google.github.io/osv-scanner/) or restore the network, then run josh start again.`
181
+ }
182
+
170
183
  function plan_summary(
171
184
  steps: ReadonlyArray<StartStep>,
172
185
  labels: Readonly<Record<StartStep, string>> = STEP_LABELS,
@@ -179,10 +192,12 @@ function plan_summary(
179
192
  const start_plan = {
180
193
  plan_steps,
181
194
  has_push,
195
+ has_commit,
182
196
  interaction_of,
183
197
  github_consent,
184
198
  progress_report,
185
199
  preflight_report,
200
+ scanner_report,
186
201
  plan_summary,
187
202
  step_labels,
188
203
  STEP_LABELS,
@@ -2,6 +2,8 @@ import path from 'node:path'
2
2
  import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
3
  import { error_text } from '#scripts/lib/error-message'
4
4
  import { repository_labels } from '#scripts/repo/repository-labels'
5
+ import { security_audit } from '#scripts/security/security-audit'
6
+ import { security_audit_provision } from '#scripts/security/security-audit-provision'
5
7
  import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
6
8
  import { parseCommandString } from 'execa'
7
9
  import { main as init_main } from './init'
@@ -12,6 +14,7 @@ import type { ProjectProfile, ProjectShape } from './project-profile'
12
14
  import { start_exec } from './start-exec'
13
15
  import { start_plan, type GitState, type StartStep, type Visibility } from './start-plan'
14
16
  import { start_setup_pr } from './start-setup-pr'
17
+ import { workflow_mark } from './workflow-mark'
15
18
 
16
19
  interface StepContext {
17
20
  root: string
@@ -29,6 +32,12 @@ const GH_INSTALL_HINT =
29
32
  'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
30
33
  const GH_AUTH_HINT =
31
34
  'GitHub CLI is not signed in. Run gh auth login, then run josh start again. Nothing was changed.'
35
+ const GIT_IDENTITY_HINT =
36
+ 'Git has no author identity, which the commit josh start creates needs. Run git config --global user.name "Your Name" and git config --global user.email "you@example.com", then run josh start again. Nothing was changed.'
37
+ // Printed before the scanner is looked for: a first run downloads tens of megabytes, and a terminal
38
+ // that says nothing for minutes reads as a hang the user interrupts (#3755).
39
+ const SCANNER_NOTICE =
40
+ 'Checking for osv-scanner, which the pre-push audit needs. A first run downloads it, which can take a few minutes on a slow connection.'
32
41
 
33
42
  const { git_succeeds, git_read, run_local } = start_exec
34
43
 
@@ -63,6 +72,17 @@ function github_cli_refusal(root: string): string | undefined {
63
72
  return undefined
64
73
  }
65
74
 
75
+ // A machine Git was just installed on has no author identity, and the commit that needs one comes
76
+ // after the repository and kit's files are written. It is asked for before the first write, and only
77
+ // when the plan commits — a re-run with nothing left to commit is not stopped by it (#3754).
78
+ function git_identity_refusal(steps: ReadonlyArray<StartStep>, root: string): string | undefined {
79
+ if (!start_plan.has_commit(steps)) return undefined
80
+
81
+ return start_exec.lacks_git_identity(root, steps.includes('git_init'))
82
+ ? GIT_IDENTITY_HINT
83
+ : undefined
84
+ }
85
+
66
86
  // `josh init` refuses to run inside the kit source repository by printing and setting the exit code
67
87
  // rather than throwing, so its step would pass and the later steps would commit and publish the
68
88
  // source repository. The same check is raised before the first write instead.
@@ -85,7 +105,10 @@ function create_git_repository(context: StepContext): void {
85
105
  // A toolkit that layers its files over kit's (`josh-app init`) names its own setup instead, so every
86
106
  // other step and guard stays here rather than in a copy of this command (#2872). That command runs
87
107
  // without a shell, is given the same profile, and a non-zero exit stops the run before the commit.
108
+ // Either one is told the project is set up for the GitHub Issue workflow (`workflow-mark.ts`).
88
109
  function initialize(context: StepContext): void {
110
+ workflow_mark.request()
111
+
89
112
  if (context.init_command === undefined) {
90
113
  init_main(['--profile', context.profile])
91
114
 
@@ -148,6 +171,27 @@ function run_push_preflight(steps: ReadonlyArray<StartStep>, context: StepContex
148
171
  }
149
172
  }
150
173
 
174
+ // The `full` profile installs kit's pre-push hook, whose audit refuses a push without osv-scanner. The
175
+ // scanner is otherwise fetched at an agent's session start, which a first `josh start` from a terminal
176
+ // has not had, so it is fetched here — forced, because a person asked for this run. A scanner that
177
+ // still cannot be found stops the run before the first step rather than at the push (#3755).
178
+ async function ensure_audit_scanner(
179
+ steps: ReadonlyArray<StartStep>,
180
+ context: StepContext,
181
+ ): Promise<void> {
182
+ if (context.profile !== 'full' || !start_plan.has_push(steps)) return
183
+
184
+ const { platform, arch } = process
185
+
186
+ console.info(`\n${SCANNER_NOTICE}`)
187
+ const outcome = await security_audit_provision.report(context.root, platform, arch, true)
188
+
189
+ console.info(outcome)
190
+ if (security_audit.resolve_scanner(context.root, platform) !== undefined) return
191
+
192
+ throw new Error(start_plan.scanner_report(outcome))
193
+ }
194
+
151
195
  type StepAction = (context: RunContext) => void | Promise<void>
152
196
 
153
197
  const STEP_ACTIONS: Readonly<Record<StartStep, StepAction>> = {
@@ -178,6 +222,7 @@ async function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext):
178
222
  const labels = start_plan.step_labels(context.init_command)
179
223
 
180
224
  run_push_preflight(steps, context)
225
+ await ensure_audit_scanner(steps, context)
181
226
  const run_context: RunContext = { ...context, baseline: baseline_of(steps, context) }
182
227
 
183
228
  for (const [index, step] of steps.entries()) {
@@ -192,6 +237,7 @@ async function run_steps(steps: ReadonlyArray<StartStep>, context: StepContext):
192
237
  const start_steps = {
193
238
  read_git_state,
194
239
  github_cli_refusal,
240
+ git_identity_refusal,
195
241
  self_run_refusal,
196
242
  run_steps,
197
243
  }
@@ -106,6 +106,7 @@ function prepare(args: ReadonlyArray<string>, is_tty: boolean, root: string): Pr
106
106
  const consent: Consent = settle(start_plan.github_consent(plan.steps, options, interaction))
107
107
 
108
108
  stop_on(start_steps.github_cli_refusal(root))
109
+ stop_on(start_steps.git_identity_refusal(plan.steps, root))
109
110
 
110
111
  return {
111
112
  steps: plan.steps,
@@ -0,0 +1,35 @@
1
+ import { json_value } from '#scripts/lib/json-value'
2
+
3
+ // `josh start` sets a project up for the GitHub Issue workflow and `josh init` alone does not. The
4
+ // difference decides whether a `basic` project receives `.claude/settings.json`, whose permissions,
5
+ // hooks and plugin an unattended `backlogrun` agent runs on (#3796).
6
+ //
7
+ // `josh start` hands the mark to the setup it runs through this variable, not an argument: that setup
8
+ // may be the project's own kit it hands off to or a toolkit's command (`--init-command`), and an older
9
+ // one refuses an argument it does not know but ignores a variable. The setup records the mark in
10
+ // `package.json`, where every later `josh init` and `josh sync` reads it.
11
+ const WORKFLOW_ENV = 'JOSH_WORKFLOW'
12
+ const GITHUB_WORKFLOW = 'github'
13
+
14
+ function is_requested(environment: NodeJS.ProcessEnv = process.env): boolean {
15
+ return environment[WORKFLOW_ENV] === GITHUB_WORKFLOW
16
+ }
17
+
18
+ function is_recorded(manifest: Record<string, unknown> | undefined): boolean {
19
+ const josh = manifest?.['josh']
20
+
21
+ return json_value.is_record(josh) && josh['workflow'] === GITHUB_WORKFLOW
22
+ }
23
+
24
+ function request(environment: NodeJS.ProcessEnv = process.env): void {
25
+ environment[WORKFLOW_ENV] = GITHUB_WORKFLOW
26
+ }
27
+
28
+ // The `josh` record's field for the mark — none for a project `josh start` did not set up, so a
29
+ // `josh init` never writes the mark of its own accord.
30
+ function recorded_fields(has_workflow: boolean): Record<string, string> {
31
+ return has_workflow ? { workflow: GITHUB_WORKFLOW } : {}
32
+ }
33
+
34
+ const workflow_mark = { is_requested, is_recorded, request, recorded_fields }
35
+ export { workflow_mark }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env tsx
2
+ import { fileURLToPath } from 'node:url'
3
+ import { git_command } from '#scripts/git/git-command'
4
+ import { issue_number_shape } from '#scripts/issue/issue-number-shape'
5
+ import { session_cite } from '#scripts/issue/session-cite'
6
+ import { issue_watch, type Acknowledged } from './issue-watch'
7
+
8
+ // `josh issue:ack <N>` — record that the run has read a person's update to its issue, so the hook,
9
+ // the review attestation and `josh followup` stop raising it. It prints the update it acknowledges:
10
+ // the same read becomes the new baseline (`issue-watch.ts` → `acknowledge`), so nothing that arrived
11
+ // after the hook spoke can be acknowledged without being printed here.
12
+ //
13
+ // **The number is asked for, though the record already carries it**, so an acknowledgement typed for
14
+ // one issue cannot silently clear the watch on another.
15
+
16
+ const SUCCESS_EXIT_CODE = 0
17
+ const FAILURE_EXIT_CODE = 1
18
+ const ARGV_OFFSET = 2
19
+ const USAGE = 'Usage: josh issue:ack <issue-number>'
20
+ const NO_WATCH_LINE =
21
+ 'No run recorded a baseline for that issue in this checkout (`josh run:entry` writes it), or the issue could not be read — nothing was acknowledged.'
22
+ const NOTHING_NEW_LINE = 'No person has updated the issue since the run last read it.'
23
+
24
+ function parse_number(argv: ReadonlyArray<string>): string | undefined {
25
+ const [first] = argv
26
+
27
+ if (first === undefined || argv.length !== 1) return undefined
28
+
29
+ return issue_number_shape.ISSUE_NUMBER_PATTERN.test(first) ? first : undefined
30
+ }
31
+
32
+ function report(acknowledged: Acknowledged): void {
33
+ const heading = `Acknowledged issue ${session_cite.issue(acknowledged.issue)}.`
34
+
35
+ console.info(
36
+ acknowledged.diff === undefined
37
+ ? `${heading} ${NOTHING_NEW_LINE}`
38
+ : `${heading}\n\n${acknowledged.diff}`,
39
+ )
40
+ }
41
+
42
+ async function acknowledge(issue_number: string): Promise<number> {
43
+ const root = await git_command.repository_root()
44
+
45
+ if (issue_watch.read_record(root)?.issue !== issue_number) {
46
+ console.error(NO_WATCH_LINE)
47
+
48
+ return FAILURE_EXIT_CODE
49
+ }
50
+
51
+ const acknowledged = await issue_watch.acknowledge(root)
52
+
53
+ if (acknowledged === undefined) {
54
+ console.error(NO_WATCH_LINE)
55
+
56
+ return FAILURE_EXIT_CODE
57
+ }
58
+
59
+ report(acknowledged)
60
+
61
+ return SUCCESS_EXIT_CODE
62
+ }
63
+
64
+ async function run(argv: ReadonlyArray<string>): Promise<number> {
65
+ const issue_number = parse_number(argv)
66
+
67
+ if (issue_number === undefined) {
68
+ console.error(USAGE)
69
+
70
+ return FAILURE_EXIT_CODE
71
+ }
72
+
73
+ return await acknowledge(issue_number)
74
+ }
75
+
76
+ async function main(argv: ReadonlyArray<string>): Promise<void> {
77
+ process.exitCode = await run(argv)
78
+ }
79
+
80
+ const issue_ack_cli = { run }
81
+
82
+ if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
83
+
84
+ export { issue_ack_cli }
@@ -70,6 +70,9 @@ const ALREADY_DONE_LABEL = 'already-done'
70
70
  // it; **`auto-ok` never rides on it** — `issue-auto-ok.ts` refuses the default for it on every path,
71
71
  // since a release is Tier C — and only a person opts it into unattended execution.
72
72
  const RELEASE_LABEL = 'release'
73
+ // Applied to an Issue whose second review round was skipped, so the condition stays auditable
74
+ // (`prompts/review.md` → "When round 2 is skipped entirely, and when it is not").
75
+ const REVIEW_ROUND2_SKIPPED_LABEL = 'review-round2-skipped'
73
76
 
74
77
  // The four labels that mean an open issue must not be handed to a run as it stands: an `epic`
75
78
  // tracks a batch and is never run directly (its children are), `in-progress` is already claimed by
@@ -205,8 +208,11 @@ interface LabelDefinition {
205
208
  }
206
209
 
207
210
  // What `josh start` provisions on a new repository so the first `kickoff` finds every label a run
208
- // applies with its intended color. The `epic` and `in-progress` metadata is
209
- // the one the workflow documents create them with; the labels only a person applies are left out.
211
+ // applies with its intended color. The metadata is the one the workflow documents create each label
212
+ // with. **The labels only a person applies are provisioned too**: a person cannot apply a label the
213
+ // repository does not have, so leaving one out made the first `auto-ok` a manual creation step.
214
+ // `bug` is GitHub's default label, kept because `issue:file` applies it to a bug fix and a
215
+ // repository may have been created without the defaults.
210
216
  const WORKFLOW_LABELS: ReadonlyArray<LabelDefinition> = [
211
217
  {
212
218
  name: EPIC_LABEL,
@@ -235,6 +241,32 @@ const WORKFLOW_LABELS: ReadonlyArray<LabelDefinition> = [
235
241
  color: 'fef2c0',
236
242
  description: 'Tracks the next release; its blockers are the merged work it publishes',
237
243
  },
244
+ {
245
+ name: AUTO_OK_LABEL,
246
+ color: '0e8a16',
247
+ description: 'Opted in to unattended execution outside an epic',
248
+ },
249
+ {
250
+ name: NEEDS_DECISION_LABEL,
251
+ color: 'd93f0b',
252
+ description: 'Parked: needs a decision before it can run',
253
+ },
254
+ {
255
+ name: NEEDS_HUMAN_REVIEW_LABEL,
256
+ color: 'd93f0b',
257
+ description: 'Implement and verify, but stop before committing so a person can look',
258
+ },
259
+ {
260
+ name: ALREADY_DONE_LABEL,
261
+ color: '6f42c1',
262
+ description: 'Verified already merged — a person closes it',
263
+ },
264
+ {
265
+ name: REVIEW_ROUND2_SKIPPED_LABEL,
266
+ color: 'c5def5',
267
+ description: 'Review round 2 was skipped under the condition in prompts/review.md',
268
+ },
269
+ { name: BUG_LABEL, color: 'd73a4a', description: "Something isn't working" },
238
270
  ...FILING_ROUTE_LABELS,
239
271
  ...DEPTH_LABELS,
240
272
  ]
@@ -329,12 +361,6 @@ function depth_label_of(labels: ReadonlyArray<LabelReference> | undefined): stri
329
361
  // document operates on a label name that does not exist here — a stale or mistyped `labels[]=…`
330
362
  // otherwise fails silently at run time. One source, so a label added above joins the scan without a
331
363
  // second edit.
332
- // Applied to an Issue whose second review round was skipped, so the condition stays auditable
333
- // (`prompts/review.md` → "When round 2 is skipped entirely, and when it is not"). Provisioned inline
334
- // by the run that applies it rather than through `FILING_ROUTE_LABELS`, but still a managed label the
335
- // documents operate on, so the label-reference scan has to know it.
336
- const REVIEW_ROUND2_SKIPPED_LABEL = 'review-round2-skipped'
337
-
338
364
  const ALL_LABELS: ReadonlySet<string> = new Set([
339
365
  EPIC_LABEL,
340
366
  BUG_LABEL,
@@ -363,6 +389,11 @@ const ALL_LABELS: ReadonlySet<string> = new Set([
363
389
  IGNORE_FOR_RELEASE_LABEL,
364
390
  ])
365
391
 
392
+ // The managed labels `REPOSITORY_LABELS` deliberately does not provision, each with its reason. Every
393
+ // other member of `ALL_LABELS` is provisioned — `issue-labels.test.ts` fails on one that is in
394
+ // neither list, so a label added above cannot silently become one a repository lacks. Empty today.
395
+ const EXEMPT_FROM_PROVISIONING: ReadonlyMap<string, string> = new Map<string, string>()
396
+
366
397
  export type { LabelDefinition, ReleaseClassification }
367
398
  export {
368
399
  ALL_LABELS,
@@ -379,6 +410,7 @@ export {
379
410
  DEPTH_LABELS,
380
411
  ENHANCEMENT_LABEL,
381
412
  EPIC_LABEL,
413
+ EXEMPT_FROM_PROVISIONING,
382
414
  FILING_ROUTE_LABELS,
383
415
  has_any_label,
384
416
  has_label_name,
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { git_gh_command } from '#scripts/gh/git-gh-command'
4
+ import type { IssueReadReason } from '#scripts/gh/git-gh-issue-read'
4
5
  import { issue_number_shape } from '#scripts/issue/issue-number-shape'
5
6
  import { bounded_pool } from '#scripts/lib/bounded-pool'
6
7
  import { cli_flags } from '#scripts/lib/cli-flags'
7
- import { issue_report_failures, type ReadFailureKind } from './issue-report-failures'
8
+ import { issue_report_failures } from './issue-report-failures'
8
9
  import { issue_state, type IssueState } from './issue-state'
9
10
 
10
11
  // `josh issue:state <N> [<N> ...]` — print each issue's state and labels, in the spelling the
@@ -50,7 +51,14 @@ interface StateRequest {
50
51
  // One issue's state, or the failure kind. Named for export because `run:prep` reads the state beside
51
52
  // the body and the dependency-update scope, reusing this read rather than a
52
53
  // second one that would drift from the `human_review` decision `parse_issue_state` owns.
53
- type StateRead = { kind: 'state'; state: IssueState } | { kind: ReadFailureKind }
54
+ //
55
+ // **Why a read failed rides along where the request said it**: `backlog:drive` names the reason when it
56
+ // hands an unreadable named issue back. Both are optional, so a caller that only separates the kinds
57
+ // needs no edit.
58
+ type StateRead =
59
+ | { kind: 'state'; state: IssueState }
60
+ | { kind: 'missing' }
61
+ | { kind: 'unreadable'; reason?: IssueReadReason | undefined; status?: number | undefined }
54
62
 
55
63
  interface IssueReport {
56
64
  issue_number: string
@@ -102,11 +110,13 @@ function parse_request(argv: ReadonlyArray<string>): StateRequest | undefined {
102
110
  async function read_issue(issue_number: string, repo?: string): Promise<StateRead> {
103
111
  const read = await git_gh_command.issue_view_json_classified(issue_number, STATE_FIELDS, repo)
104
112
 
105
- if (read.kind !== 'read') return { kind: read.kind }
113
+ if (read.kind !== 'read') return read
106
114
 
107
115
  const parsed = issue_state.parse_issue_state(read.json)
108
116
 
109
- return parsed === undefined ? { kind: 'unreadable' } : { kind: 'state', state: parsed }
117
+ return parsed === undefined
118
+ ? { kind: 'unreadable', reason: 'malformed' }
119
+ : { kind: 'state', state: parsed }
110
120
  }
111
121
 
112
122
  // Through the shared pool rather than a raw `Promise.all`: each read spawns a `gh` process, and a