@joshuafolkken/kit 1.887.0 → 1.929.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 (127) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-progress.md +13 -12
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -1
  3. package/.claude/skills/workflow-commands/fullrun-steps.md +4 -2
  4. package/.claude/skills/workflow-commands/halfrun.md +3 -1
  5. package/.claude/skills/workflow-commands/kickoff.md +5 -3
  6. package/.claude/skills/workflow-commands/observation-filing.md +3 -1
  7. package/.claude/skills/workflow-commands/prerequisite.md +3 -1
  8. package/.claude/skills/workflow-commands/target-repository.md +1 -1
  9. package/.github/pull_request_template.md +12 -0
  10. package/.github/release.yml +3 -0
  11. package/.github/workflows/ci.yml +8 -0
  12. package/.github/workflows/production.yml +10 -1
  13. package/.github/workflows/publish.yml +72 -2
  14. package/CLAUDE.md +2 -0
  15. package/README.md +2 -1
  16. package/dist/CLAUDE.md +2 -0
  17. package/dist/CLAUDE.static.md +8 -0
  18. package/dist/hooks/batch-guard.js +6 -6
  19. package/dist/hooks/{chunk-264QY6KS.js → chunk-2PGRQ62K.js} +25 -10
  20. package/dist/hooks/chunk-3V37IID6.js +82 -0
  21. package/dist/hooks/{chunk-36UE53LS.js → chunk-7XQ6TK3R.js} +2 -2
  22. package/dist/hooks/{chunk-PRDCNGA6.js → chunk-BNFG2IFK.js} +4 -4
  23. package/dist/hooks/{chunk-VGYSA2NT.js → chunk-GQ3EVWHY.js} +22 -3
  24. package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-I3JP3D4A.js} +3 -3
  25. package/dist/hooks/{chunk-6MQCXEEA.js → chunk-LCJZE42I.js} +25 -88
  26. package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NJZSO7BK.js} +3 -3
  27. package/dist/hooks/{chunk-SSX23SJ2.js → chunk-RKUAD3I6.js} +1 -0
  28. package/dist/hooks/{chunk-FRJKVSK3.js → chunk-ROULPSQC.js} +2 -2
  29. package/dist/hooks/{chunk-FNFOLFFV.js → chunk-SXPTRFPG.js} +2 -2
  30. package/dist/hooks/{chunk-FXAKJKTB.js → chunk-WPALEIRX.js} +1 -1
  31. package/dist/hooks/{chunk-4OXFU3MK.js → chunk-YO43HEPY.js} +634 -156
  32. package/dist/hooks/codex-hook-adapter.js +13 -12
  33. package/dist/hooks/duplicate-read-guard.js +7 -7
  34. package/dist/hooks/format-edited.js +4 -3
  35. package/dist/hooks/investigation-guard.js +7 -7
  36. package/dist/hooks/pretool-guard.js +12 -11
  37. package/dist/hooks/session-lang.js +2 -2
  38. package/dist/hooks/stop-guard.js +5 -5
  39. package/dist/josh.js +156 -20
  40. package/eslint/base.js +4 -1
  41. package/package.json +72 -18
  42. package/playwright.config.ts +9 -17
  43. package/ports/index.d.ts +1 -0
  44. package/ports/index.js +1 -0
  45. package/ports/web-server.d.ts +5 -0
  46. package/ports/web-server.js +73 -0
  47. package/prettier/index.js +3 -8
  48. package/prettier/static.d.ts +3 -0
  49. package/prettier/static.js +10 -0
  50. package/prompts/collaboration-workflow/issue-template.md +11 -4
  51. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  52. package/prompts/collaboration-workflow/wip-cap.md +2 -2
  53. package/prompts/review.md +2 -2
  54. package/prompts/testing-guide.md +1 -0
  55. package/scripts/agent/agent-role-profile.ts +11 -0
  56. package/scripts/build/build-claude-md.ts +6 -1
  57. package/scripts/ci/pr-classification.ts +114 -0
  58. package/scripts/document/document-byte-budget.ts +3 -2
  59. package/scripts/gate/gate-report.ts +2 -0
  60. package/scripts/gate/gate-steps.ts +50 -0
  61. package/scripts/gate/project-checks.ts +145 -0
  62. package/scripts/gate/verification-gate.ts +14 -43
  63. package/scripts/git/git-common-directory.ts +9 -1
  64. package/scripts/git/git-gh-pr-rollup.ts +29 -1
  65. package/scripts/git/git-gh-pr.ts +61 -1
  66. package/scripts/git/git-pr.ts +62 -13
  67. package/scripts/git/issue-labels.ts +9 -0
  68. package/scripts/init/distributed-paths.ts +9 -5
  69. package/scripts/init/hook-command-bootstrap.ts +40 -8
  70. package/scripts/init/hook-command-rewrite.ts +7 -3
  71. package/scripts/init/init-actions.ts +94 -7
  72. package/scripts/init/init-ai-copy.ts +107 -19
  73. package/scripts/init/init-logic-workspace.ts +38 -5
  74. package/scripts/init/init-logic.ts +1 -0
  75. package/scripts/init/init-static.ts +55 -0
  76. package/scripts/init/init.ts +97 -30
  77. package/scripts/init/project-profile-cli.ts +14 -0
  78. package/scripts/init/project-profile.ts +158 -0
  79. package/scripts/init/start.ts +30 -0
  80. package/scripts/issue/issue-bug-label.ts +13 -0
  81. package/scripts/issue/issue-classification.ts +74 -0
  82. package/scripts/issue/issue-lint-cli.ts +10 -3
  83. package/scripts/issue/markdown-section.ts +38 -4
  84. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +27 -1
  85. package/scripts/josh/josh-command-map.ts +3 -0
  86. package/scripts/josh/josh-commands-development.ts +8 -15
  87. package/scripts/josh/josh-commands-project.ts +14 -1
  88. package/scripts/josh/josh-logic.ts +13 -0
  89. package/scripts/josh/run-github-prerequisite.ts +32 -0
  90. package/scripts/lane/lane-await.ts +8 -1
  91. package/scripts/lane/openai-lane-ship.ts +30 -0
  92. package/scripts/lane/openai-lane-supervisor.ts +35 -8
  93. package/scripts/lane/openai-review-broker.ts +260 -0
  94. package/scripts/lint/cspell-cached.ts +21 -0
  95. package/scripts/lint/format.ts +68 -0
  96. package/scripts/lint/lint-parallel.ts +30 -1
  97. package/scripts/observations/observations-flush.ts +1 -1
  98. package/scripts/package/production-dependency-graph.ts +68 -0
  99. package/scripts/package/verify-optional-eslint-install.ts +62 -0
  100. package/scripts/release/github-release-cli.ts +11 -0
  101. package/scripts/release/github-release.ts +220 -0
  102. package/scripts/release/publish-tag-cli.ts +8 -0
  103. package/scripts/release/publish-tag.ts +11 -0
  104. package/scripts/release/release-publish.ts +7 -2
  105. package/scripts/rules/delivered-rules.ts +2 -0
  106. package/scripts/rules/issue-bug-label.ts +85 -0
  107. package/scripts/rules/issue-filing-args.ts +50 -0
  108. package/scripts/run/run-merge-cli.ts +19 -0
  109. package/scripts/run/run-merge.ts +9 -1
  110. package/scripts/run/run-ship-cli.ts +20 -0
  111. package/scripts/run/run-ship-detach.ts +180 -14
  112. package/scripts/run/run-ship-review-steps.ts +45 -25
  113. package/scripts/run/run-step-cli.ts +11 -1
  114. package/scripts/run/run-step.ts +36 -8
  115. package/scripts/run/run-wake-session.ts +3 -1
  116. package/scripts/test/pilot-files.ts +0 -2
  117. package/scripts/test/skip-marker.ts +3 -0
  118. package/scripts/test/test-declared-changed.ts +2 -1
  119. package/scripts/test/test-declared-logic.ts +15 -10
  120. package/scripts/test/test-declared.ts +20 -8
  121. package/scripts/test/test-red-logic.ts +3 -3
  122. package/scripts/test/test-unit-guard.ts +24 -12
  123. package/templates/CLAUDE.static.md +8 -0
  124. package/templates/cursorrules.static +1 -0
  125. package/templates/pnpm-workspace.static.yaml +4 -0
  126. package/templates/prettierignore.static +12 -0
  127. package/templates/workflows/ci.yml +5 -0
@@ -53,11 +53,14 @@ const ALIASES: Record<string, string> = {
53
53
  c: 'check',
54
54
  pt: 'port',
55
55
  i: 'init',
56
+ pf: 'profile',
57
+ st: 'start',
56
58
  sy: 'sync',
57
59
  rmi: 'registry:migrate',
58
60
  sys: 'sync:scope',
59
61
  g: 'git',
60
62
  gp: 'pr',
63
+ prc: 'pr:classification',
61
64
  fu: 'followup',
62
65
  nf: 'notify',
63
66
  obf: 'observations:flush',
@@ -1,10 +1,4 @@
1
- import {
2
- ESLINT_CACHE_FLAGS,
3
- GATE_COMMAND,
4
- PE,
5
- TS_CACHE_FLAGS,
6
- type CommandEntry,
7
- } from './josh-command-types'
1
+ import { GATE_COMMAND, PE, TS_CACHE_FLAGS, type CommandEntry } from './josh-command-types'
8
2
 
9
3
  const FILE_ARGUMENTS = '[files...]'
10
4
  const REQUIRED_FILE_ARGUMENTS = '<files...>'
@@ -14,6 +8,12 @@ const PATH_ARGUMENTS = '<path...>'
14
8
 
15
9
  /* eslint-disable @typescript-eslint/naming-convention */
16
10
  const DEV_COMMANDS: Record<string, CommandEntry> = {
11
+ 'pr:classification': {
12
+ script: 'scripts/ci/pr-classification.ts',
13
+ description: 'Require one release classification on a pull request',
14
+ category: 'Development',
15
+ reference: ['', 'automation', ['none']],
16
+ },
17
17
  [GATE_COMMAND]: {
18
18
  script: 'scripts/gate/verification-gate.ts',
19
19
  description: 'Run lint, type check, spell check and unit tests concurrently',
@@ -60,14 +60,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
60
60
  reference: ['', 'developer', ['none']],
61
61
  },
62
62
  format: {
63
- // prettier first here, unlike `format:edited`, and deliberately: `eslint --fix` exits 1
64
- // whenever a non-autofixable error remains, so putting it first behind `&&` would mean one
65
- // unused variable anywhere in the tree stops prettier from running at all.
66
- shell: [
67
- 'sh',
68
- '-c',
69
- `pnpm exec prettier --write . && pnpm exec eslint . --fix ${ESLINT_CACHE_FLAGS.join(' ')}`,
70
- ],
63
+ script: 'scripts/lint/format.ts',
71
64
  description: 'Format code with prettier and eslint',
72
65
  category: 'Development',
73
66
  reference: ['', 'developer', ['files', 'processes']],
@@ -4,12 +4,25 @@ import type { CommandEntry } from './josh-command-types'
4
4
  // no identifier format allows. The same disable sits at the top of `josh-commands-maintenance.ts`
5
5
  // for the same reason.
6
6
  /* eslint-disable @typescript-eslint/naming-convention */
7
+ const PROFILE_ARGUMENTS = '[--profile static|node]'
7
8
  const PROJECT_COMMANDS: Record<string, CommandEntry> = {
9
+ profile: {
10
+ script: 'scripts/init/project-profile-cli.ts',
11
+ description: 'Show the project profile and the reason for it',
12
+ category: 'Project',
13
+ reference: [PROFILE_ARGUMENTS, 'developer', ['files']],
14
+ },
15
+ start: {
16
+ script: 'scripts/init/start.ts',
17
+ description: 'Initialize a project for the GitHub Issue workflow',
18
+ category: 'Project',
19
+ reference: [PROFILE_ARGUMENTS, 'developer', ['files', 'git', 'network']],
20
+ },
8
21
  init: {
9
22
  script: 'scripts/init/init.ts',
10
23
  description: 'Initialize config in a new project',
11
24
  category: 'Project',
12
- reference: ['', 'developer', ['files', 'processes']],
25
+ reference: [PROFILE_ARGUMENTS, 'developer', ['files', 'processes']],
13
26
  },
14
27
  sync: {
15
28
  script: 'scripts/sync/sync.ts',
@@ -16,6 +16,7 @@ import {
16
16
  import { composite_arguments, USAGE_ERROR_EXIT_CODE } from './josh-composite-arguments'
17
17
  import { josh_in_process } from './josh-in-process'
18
18
  import { kit_only } from './kit-only'
19
+ import { run_github_prerequisite } from './run-github-prerequisite'
19
20
 
20
21
  const COLUMN_WIDTH = 26
21
22
  const ALIAS_PAD_WIDTH = 2
@@ -235,6 +236,15 @@ async function run_script_entry(
235
236
  return spawn_script_entry(entry, script_path, script_arguments)
236
237
  }
237
238
 
239
+ function github_prerequisite_exit(resolved: string, is_consumer: boolean): number | undefined {
240
+ if (!is_consumer) return undefined
241
+ const prerequisite = run_github_prerequisite.explanation(process.cwd(), resolved)
242
+ if (prerequisite === undefined) return undefined
243
+ console.error(prerequisite)
244
+
245
+ return USAGE_ERROR_EXIT_CODE
246
+ }
247
+
238
248
  // The exit a command answers with before it runs, or `undefined` to go ahead: a consumer is refused a
239
249
  // kit-only command with guidance, and a composite command rejects extra arguments
240
250
  // (joshuafolkken/kit#1988).
@@ -250,6 +260,9 @@ function pre_dispatch_exit(
250
260
  return USAGE_ERROR_EXIT_CODE
251
261
  }
252
262
 
263
+ const prerequisite_exit = github_prerequisite_exit(resolved, is_consumer)
264
+ if (prerequisite_exit !== undefined) return prerequisite_exit
265
+
253
266
  const rejection = composite_arguments.reject_extra_arguments(
254
267
  resolved,
255
268
  entry,
@@ -0,0 +1,32 @@
1
+ import { existsSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { repo_origin } from '#scripts/discovery/repo-origin'
4
+ import { execaSync } from 'execa'
5
+
6
+ const GITHUB_RUN_COMMANDS = new Set([
7
+ 'run:entry',
8
+ 'run:prep',
9
+ 'run:next',
10
+ 'run:step',
11
+ 'run:merge',
12
+ 'run:tail',
13
+ ])
14
+
15
+ function explanation(root: string, command: string): string | undefined {
16
+ if (!GITHUB_RUN_COMMANDS.has(command)) return undefined
17
+
18
+ if (!existsSync(path.join(root, '.git'))) {
19
+ return `${command} requires Git and a GitHub origin. Run git init and add a GitHub origin first.`
20
+ }
21
+
22
+ const result = execaSync('git', ['config', '--get', 'remote.origin.url'], {
23
+ cwd: root,
24
+ reject: false,
25
+ })
26
+ if (result.exitCode === 0 && repo_origin.parse_origin_url(result.stdout)) return undefined
27
+
28
+ return `${command} requires a GitHub origin. Add an origin pointing to GitHub first.`
29
+ }
30
+
31
+ const run_github_prerequisite = { explanation }
32
+ export { run_github_prerequisite }
@@ -1,5 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { setTimeout as sleep } from 'node:timers/promises'
3
+ import { git_common_directory } from '#scripts/git/git-common-directory'
4
+ import { run_ship_detach } from '#scripts/run/run-ship-detach'
3
5
  import { lane_child_invocation } from './lane-child-invocation'
4
6
 
5
7
  // Detached lane children are not harness-tracked processes, so their completion fires no
@@ -49,7 +51,12 @@ function is_process_running_default(issue: string): boolean {
49
51
  const pattern = lane_child_invocation.process_pattern(issue)
50
52
  const result = spawnSync('pgrep', ['-f', pattern], { encoding: 'utf8' })
51
53
 
52
- return result.status === PROCESS_FOUND
54
+ if (result.status === PROCESS_FOUND) return true
55
+ const repository = git_common_directory.repository(process.cwd())
56
+
57
+ return (
58
+ repository !== undefined && run_ship_detach.read_result(repository, issue)?.result === 'running'
59
+ )
53
60
  }
54
61
 
55
62
  function make_initial_state(): AwaitState {
@@ -0,0 +1,30 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises'
2
+ import { run_ship_detach } from '#scripts/run/run-ship-detach'
3
+ import { run_ship_probe } from '#scripts/run/run-ship-probe'
4
+
5
+ const POLL_MS = 250
6
+ type ShipEnding = 'none' | 'success' | 'failed' | 'abnormal'
7
+ type ShipReading = NonNullable<ReturnType<typeof run_ship_detach.read_result>>
8
+
9
+ async function current(issue: string): Promise<ShipReading | undefined> {
10
+ const repository = await run_ship_probe.repository_directory()
11
+
12
+ return repository === undefined ? undefined : run_ship_detach.read_result(repository, issue)
13
+ }
14
+
15
+ async function wait_for_ship(issue: string, prior_launch: string | undefined): Promise<ShipEnding> {
16
+ let read = await current(issue)
17
+ if (read === undefined || read.launch_id === prior_launch) return 'none'
18
+
19
+ while (read.result === 'running') {
20
+ await sleep(POLL_MS)
21
+ read = await current(issue)
22
+ if (read === undefined) return 'abnormal'
23
+ }
24
+
25
+ return read.result
26
+ }
27
+
28
+ const openai_lane_ship = { current, wait_for_ship }
29
+
30
+ export { openai_lane_ship }
@@ -12,7 +12,9 @@ import { lane_child_invocation } from './lane-child-invocation'
12
12
  import { lane_child_marker } from './lane-child-marker'
13
13
  import { lane_dispatch_log } from './lane-dispatch-log'
14
14
  import type { LaneInfo } from './lane-registry'
15
+ import { openai_lane_ship } from './openai-lane-ship'
15
16
  import { openai_lane_supervisor_decision } from './openai-lane-supervisor-decision'
17
+ import { openai_review_broker } from './openai-review-broker'
16
18
 
17
19
  const OWNER_PREFIX = 'josh-openai-lane-supervisor-'
18
20
  const STATE_PREFIX = 'josh-openai-lane-supervisor-state-'
@@ -187,10 +189,17 @@ async function standing_cut(issue: string): Promise<RunCut | undefined> {
187
189
  return read_cut.kind === 'carried' && handed_off(read_cut.cut, issue) ? read_cut.cut : undefined
188
190
  }
189
191
 
190
- function child_argv(lane: LaneInfo, profile: AgentProfile, is_resume: boolean): LaunchArgv {
191
- const invocation = is_resume
192
- ? lane_child_invocation.resume_invocation(lane.issue)
193
- : lane_child_invocation.child_invocation(lane.issue)
192
+ function child_argv(
193
+ lane: LaneInfo,
194
+ profile: AgentProfile,
195
+ is_resume: boolean,
196
+ override?: string,
197
+ ): LaunchArgv {
198
+ const invocation =
199
+ override ??
200
+ (is_resume
201
+ ? lane_child_invocation.resume_invocation(lane.issue)
202
+ : lane_child_invocation.child_invocation(lane.issue))
194
203
  const built = agent_argv.with_profile_in(invocation, profile, lane.directory)
195
204
  if (built.kind === 'rejected') throw new Error(built.note)
196
205
 
@@ -209,6 +218,11 @@ interface Generation {
209
218
  lane: LaneInfo
210
219
  owner: SupervisorOwner
211
220
  profile: AgentProfile
221
+ invocation?: string
222
+ }
223
+
224
+ function completion_code(result: ChildResult, ship: string): number {
225
+ return ship === 'abnormal' || result.kind === 'abnormal' ? 1 : 0
212
226
  }
213
227
 
214
228
  function record_child(generation: Generation, child_pid: number): void {
@@ -239,7 +253,7 @@ async function run_child(generation: Generation): Promise<ChildResult> {
239
253
  const { lane, profile, is_resume } = generation
240
254
  const result = await detached_launch.launch_attached(
241
255
  {
242
- argv: child_argv(lane, profile, is_resume),
256
+ argv: child_argv(lane, profile, is_resume, generation.invocation),
243
257
  cwd: lane.directory,
244
258
  log_path: lane_dispatch_log.default_log_path(lane),
245
259
  profile,
@@ -257,6 +271,7 @@ async function run_child(generation: Generation): Promise<ChildResult> {
257
271
  }
258
272
 
259
273
  async function run_generations(generation: Generation): Promise<number> {
274
+ const prior = await openai_lane_ship.current(generation.lane.issue)
260
275
  const result = await run_child(generation)
261
276
  if (result.kind === LAUNCH_FAILED_KIND) return 1
262
277
 
@@ -264,9 +279,18 @@ async function run_generations(generation: Generation): Promise<number> {
264
279
  return await run_generations({ ...generation, epoch: generation.epoch + 1, is_resume: true })
265
280
  }
266
281
 
267
- if (result.kind === 'abnormal') return 1
282
+ const ship = await openai_lane_ship.wait_for_ship(generation.lane.issue, prior?.launch_id)
283
+
284
+ if (ship === 'failed') {
285
+ return await run_generations({
286
+ ...generation,
287
+ epoch: generation.epoch + 1,
288
+ is_resume: false,
289
+ invocation: lane_child_invocation.ship_stop_invocation(generation.lane.issue),
290
+ })
291
+ }
268
292
 
269
- return 0
293
+ return completion_code(result, ship)
270
294
  }
271
295
 
272
296
  async function wait_for_inherited_child(
@@ -308,7 +332,10 @@ async function supervise(lane: LaneInfo, profile: AgentProfile, nonce: string):
308
332
  try {
309
333
  if (!(await openai_lane_supervisor_decision.is_approved(lane.directory, nonce))) return 0
310
334
 
311
- return await run_claimed(lane, profile, owner)
335
+ return await openai_review_broker.with_server(
336
+ lane,
337
+ async () => await run_claimed(lane, profile, owner),
338
+ )
312
339
  } finally {
313
340
  release(lane.directory, nonce)
314
341
  }
@@ -0,0 +1,260 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { setTimeout as sleep } from 'node:timers/promises'
5
+ import { agent_argv } from '#scripts/agent/agent-argv'
6
+ import { agent_role_profile } from '#scripts/agent/agent-role-profile'
7
+ import { git_common_directory } from '#scripts/git/git-common-directory'
8
+ import { stamp_file } from '#scripts/josh/stamp-file'
9
+ import { detached_launch } from '#scripts/run/detached-launch'
10
+ import { run_ship_review } from '#scripts/run/run-ship-review'
11
+ import { z } from 'zod'
12
+ import type { LaneInfo } from './lane-registry'
13
+
14
+ const REQUEST_PREFIX = '.josh-review-request-'
15
+ const RESPONSE_PREFIX = '.josh-review-response-'
16
+ const BRIEF_PREFIX = 'josh-ship-review-brief-'
17
+ const FINDINGS_PREFIX = 'josh-ship-review-findings-'
18
+ const LOG_PREFIX = 'josh-ship-review-log-'
19
+ const CACHE_DIRECTORY = ['node_modules', '.cache']
20
+ const POLL_MS = 100
21
+ const HEARTBEAT_MS = 500
22
+ const HEARTBEAT_LIMIT_MS = 3000
23
+ const MINUTE_MS = 60_000
24
+ const REQUEST_LIMIT_MINUTES = 30
25
+ const REQUEST_LIMIT_MS = REQUEST_LIMIT_MINUTES * MINUTE_MS
26
+ const request_schema = z
27
+ .object({ session: z.uuid(), nonce: z.uuid(), round: z.enum(['1', '2']) })
28
+ .strict()
29
+ const response_schema = z
30
+ .object({
31
+ session: z.uuid(),
32
+ nonce: z.string(),
33
+ is_success: z.boolean(),
34
+ heartbeat_at: z.number().int().positive(),
35
+ })
36
+ .strict()
37
+ type ReviewRequest = z.infer<typeof request_schema>
38
+ type ReviewResponse = z.infer<typeof response_schema>
39
+ type ReviewRound = ReviewRequest['round']
40
+
41
+ function review_paths(directory: string): { brief: string; findings: string; log: string } {
42
+ return {
43
+ brief: stamp_file.stamp_path(BRIEF_PREFIX, directory, '.md'),
44
+ findings: stamp_file.stamp_path(FINDINGS_PREFIX, directory, '.txt'),
45
+ log: stamp_file.stamp_path(LOG_PREFIX, directory, '.log'),
46
+ }
47
+ }
48
+
49
+ function request_path(directory: string, issue: string): string {
50
+ return path.join(directory, ...CACHE_DIRECTORY, `${REQUEST_PREFIX}${issue}.json`)
51
+ }
52
+
53
+ function response_path(directory: string, issue: string): string | undefined {
54
+ const common = git_common_directory.resolve(directory)
55
+ if (common === undefined || path.basename(common) !== '.git') return undefined
56
+
57
+ return path.join(path.dirname(common), ...CACHE_DIRECTORY, `${RESPONSE_PREFIX}${issue}.json`)
58
+ }
59
+
60
+ function read_record<T>(target: string, schema: z.ZodType<T>): T | undefined {
61
+ try {
62
+ const content: unknown = JSON.parse(readFileSync(target, 'utf8'))
63
+ const parsed = schema.safeParse(content)
64
+
65
+ return parsed.success ? parsed.data : undefined
66
+ } catch {
67
+ return undefined
68
+ }
69
+ }
70
+
71
+ async function launch_review(lane: LaneInfo, round: ReviewRound): Promise<boolean> {
72
+ const target = review_paths(lane.directory)
73
+ const prompt_of =
74
+ round === '1' ? run_ship_review.reviewer_prompt : run_ship_review.verification_prompt
75
+ const built = agent_argv.resolve_in(
76
+ prompt_of(target.brief, target.findings),
77
+ agent_role_profile.REVIEWER,
78
+ lane.directory,
79
+ )
80
+ if (built.kind === 'rejected' || built.profile.provider !== 'openai') return false
81
+
82
+ const result = await detached_launch.launch_attached(
83
+ {
84
+ argv: built.argv,
85
+ cwd: lane.directory,
86
+ log_path: target.log,
87
+ profile: built.profile,
88
+ },
89
+ console.error,
90
+ )
91
+
92
+ return result.kind === 'completed' && result.exit_code === 0
93
+ }
94
+
95
+ function write_response(target: string, body: z.infer<typeof response_schema>): void {
96
+ const temporary = `${target}.${String(process.pid)}.tmp`
97
+
98
+ writeFileSync(temporary, JSON.stringify(body))
99
+ renameSync(temporary, target)
100
+ }
101
+
102
+ async function launched(lane: LaneInfo, round: ReviewRound): Promise<boolean> {
103
+ try {
104
+ return await launch_review(lane, round)
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
110
+ async function process_request(
111
+ lane: LaneInfo,
112
+ response: string,
113
+ received: ReviewRequest,
114
+ state: { current: ReviewResponse },
115
+ ): Promise<void> {
116
+ const is_success = await launched(lane, received.round)
117
+
118
+ state.current = {
119
+ session: received.session,
120
+ nonce: received.nonce,
121
+ is_success,
122
+ heartbeat_at: Date.now(),
123
+ }
124
+ write_response(response, state.current)
125
+ }
126
+
127
+ async function serve(
128
+ lane: LaneInfo,
129
+ session: string,
130
+ response: string,
131
+ state: { is_running: boolean; current: ReviewResponse },
132
+ ): Promise<void> {
133
+ let last_nonce = ''
134
+ const target = request_path(lane.directory, lane.issue)
135
+
136
+ while (state.is_running) {
137
+ const received = read_record(target, request_schema)
138
+
139
+ if (received?.session === session && received.nonce !== last_nonce) {
140
+ last_nonce = received.nonce
141
+ await process_request(lane, response, received, state)
142
+ }
143
+
144
+ await sleep(POLL_MS)
145
+ }
146
+ }
147
+
148
+ function prepare(lane: LaneInfo): { response: string; current: ReviewResponse } {
149
+ const response = response_path(lane.directory, lane.issue)
150
+ if (response === undefined) throw new Error('review broker has no trusted response location')
151
+
152
+ const session = randomUUID()
153
+
154
+ mkdirSync(path.dirname(response), { recursive: true })
155
+ mkdirSync(path.dirname(request_path(lane.directory, lane.issue)), { recursive: true })
156
+ const current: ReviewResponse = {
157
+ session,
158
+ nonce: '',
159
+ is_success: false,
160
+ heartbeat_at: Date.now(),
161
+ }
162
+
163
+ write_response(response, current)
164
+
165
+ return { response, current }
166
+ }
167
+
168
+ function start_heartbeat(state: { current: ReviewResponse }, response: string): NodeJS.Timeout {
169
+ const heartbeat = setInterval(() => {
170
+ state.current = { ...state.current, heartbeat_at: Date.now() }
171
+ write_response(response, state.current)
172
+ }, HEARTBEAT_MS)
173
+
174
+ heartbeat.unref()
175
+
176
+ return heartbeat
177
+ }
178
+
179
+ async function stop_server(
180
+ state: { is_running: boolean },
181
+ heartbeat: NodeJS.Timeout,
182
+ serving: Promise<void>,
183
+ response: string,
184
+ ): Promise<void> {
185
+ clearInterval(heartbeat)
186
+ state.is_running = false
187
+ await serving
188
+ unlinkSync(response)
189
+ }
190
+
191
+ async function with_server<T>(lane: LaneInfo, run: () => Promise<T>): Promise<T> {
192
+ const { response, current } = prepare(lane)
193
+ const state = { is_running: true, current }
194
+ const heartbeat = start_heartbeat(state, response)
195
+ const serving = serve(lane, current.session, response, state)
196
+
197
+ try {
198
+ return await run()
199
+ } finally {
200
+ await stop_server(state, heartbeat, serving, response)
201
+ }
202
+ }
203
+
204
+ function is_live_response(
205
+ response: ReviewResponse | undefined,
206
+ session: string,
207
+ ): response is ReviewResponse {
208
+ const now = Date.now()
209
+
210
+ return (
211
+ response?.session === session &&
212
+ response.heartbeat_at <= now &&
213
+ response.heartbeat_at >= now - HEARTBEAT_LIMIT_MS
214
+ )
215
+ }
216
+
217
+ function response_verdict(
218
+ response: ReviewResponse | undefined,
219
+ session: string,
220
+ nonce: string,
221
+ ): 'wait' | 'success' | 'failure' {
222
+ if (!is_live_response(response, session)) return 'failure'
223
+ if (response.nonce !== nonce) return 'wait'
224
+
225
+ return response.is_success ? 'success' : 'failure'
226
+ }
227
+
228
+ async function wait_response(target: string, session: string, nonce: string): Promise<boolean> {
229
+ const deadline = Date.now() + REQUEST_LIMIT_MS
230
+
231
+ while (Date.now() < deadline) {
232
+ const verdict = response_verdict(read_record(target, response_schema), session, nonce)
233
+ if (verdict !== 'wait') return verdict === 'success'
234
+
235
+ await sleep(POLL_MS)
236
+ }
237
+
238
+ return false
239
+ }
240
+
241
+ async function request(directory: string, issue: string, round: ReviewRound): Promise<boolean> {
242
+ const target = response_path(directory, issue)
243
+ if (target === undefined) return false
244
+ const response = read_record(target, response_schema)
245
+
246
+ if (response === undefined || !is_live_response(response, response.session)) {
247
+ return false
248
+ }
249
+
250
+ const nonce = randomUUID()
251
+ const body: ReviewRequest = { session: response.session, nonce, round }
252
+
253
+ stamp_file.replace_stamp(request_path(directory, issue), body)
254
+
255
+ return await wait_response(target, response.session, nonce)
256
+ }
257
+
258
+ const openai_review_broker = { request, request_path, response_path, review_paths, with_server }
259
+
260
+ export { openai_review_broker }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { project_checks } from '#scripts/gate/project-checks'
3
4
  import { CSPELL_CACHE_FILE, CSPELL_CACHE_FLAGS } from '#scripts/josh/josh-command-types'
4
5
  import { lane_cache_run } from '#scripts/lane/lane-cache-run'
5
6
  import { buffered_process, FAIL_EXIT_CODE } from '#scripts/lib/buffered-process'
@@ -22,7 +23,27 @@ const CSPELL_ARGS = [
22
23
 
23
24
  // The wrapper puts cache transfer directly around cspell rather than around the whole gate (#2060),
24
25
  // so a later failing check cannot prevent a completed cache from reaching the primary checkout.
26
+ function static_skip_reason(directory: string): string | undefined {
27
+ if (!project_checks.is_static(directory)) return undefined
28
+
29
+ if (!project_checks.has_config(directory, project_checks.CSPELL_CONFIGS)) {
30
+ return 'no cspell configuration was found'
31
+ }
32
+
33
+ if (!project_checks.has_bin(directory, 'cspell')) return 'cspell is not installed'
34
+
35
+ return undefined
36
+ }
37
+
25
38
  async function run(extra_arguments: ReadonlyArray<string> = []): Promise<number> {
39
+ const reason = static_skip_reason(process.cwd())
40
+
41
+ if (reason !== undefined) {
42
+ console.info(project_checks.skip_notice('cspell', reason))
43
+
44
+ return 0
45
+ }
46
+
26
47
  const result = await lane_cache_run.run(
27
48
  CSPELL_CACHE_FILE,
28
49
  async () => await buffered_process.run_buffered_process([...CSPELL_ARGS, ...extra_arguments]),
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env tsx
2
+ import { fileURLToPath } from 'node:url'
3
+ import { project_checks } from '#scripts/gate/project-checks'
4
+ import { ESLINT_CACHE_FLAGS } from '#scripts/josh/josh-command-types'
5
+ import { composite_arguments, USAGE_ERROR_EXIT_CODE } from '#scripts/josh/josh-composite-arguments'
6
+ import { execa } from 'execa'
7
+
8
+ const COMMAND_NAME = 'format'
9
+ const ARGV_OFFSET = 2
10
+ const FAIL_EXIT_CODE = 1
11
+ const PRETTIER_ARGS = ['exec', 'prettier', '--write', '.'] as const
12
+ const ESLINT_FIX_ARGS = ['exec', 'eslint', '.', '--fix', ...ESLINT_CACHE_FLAGS] as const
13
+
14
+ async function run_step(args: ReadonlyArray<string>, directory: string): Promise<number> {
15
+ const result = await execa('pnpm', args, { cwd: directory, stdio: 'inherit', reject: false })
16
+
17
+ return result.exitCode ?? FAIL_EXIT_CODE
18
+ }
19
+
20
+ // prettier first, deliberately: `eslint --fix` exits 1 whenever a non-autofixable error remains, so
21
+ // putting it first would mean one unused variable anywhere in the tree stops prettier from running.
22
+ // A static project without Prettier or ESLint is skipped with the same reason `josh lint` prints
23
+ // (joshuafolkken/kit#2693, joshuafolkken/kit#2606).
24
+ async function run_unless_skipped(
25
+ check: string,
26
+ reason: string | undefined,
27
+ args: ReadonlyArray<string>,
28
+ directory: string,
29
+ ): Promise<number> {
30
+ if (reason === undefined) return await run_step(args, directory)
31
+ console.info(project_checks.skip_notice(check, reason))
32
+
33
+ return 0
34
+ }
35
+
36
+ async function run_format(directory: string): Promise<number> {
37
+ const prettier_reason = project_checks.prettier_skip_reason(directory)
38
+ const prettier_exit_code = await run_unless_skipped(
39
+ 'prettier',
40
+ prettier_reason,
41
+ PRETTIER_ARGS,
42
+ directory,
43
+ )
44
+ if (prettier_exit_code !== 0) return prettier_exit_code
45
+ const eslint_reason = project_checks.eslint_skip_reason(directory)
46
+
47
+ return await run_unless_skipped('eslint', eslint_reason, ESLINT_FIX_ARGS, directory)
48
+ }
49
+
50
+ // The argument refusal survives leaving the `sh -c` composite shape: both tools run over the whole
51
+ // tree, so a path passed here would be silently discarded and every file rewritten instead.
52
+ async function run(argv: ReadonlyArray<string>, directory: string): Promise<number> {
53
+ if (argv.length > 0) {
54
+ console.error(composite_arguments.format_rejection(COMMAND_NAME, []))
55
+
56
+ return USAGE_ERROR_EXIT_CODE
57
+ }
58
+
59
+ return await run_format(directory)
60
+ }
61
+
62
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
63
+ process.exitCode = await run(process.argv.slice(ARGV_OFFSET), process.cwd())
64
+ }
65
+
66
+ const format = { run, run_format }
67
+
68
+ export { ESLINT_FIX_ARGS, format }