@joshuafolkken/kit 1.887.0 → 1.921.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 (108) hide show
  1. package/.claude/skills/workflow-commands/fullrun-steps.md +4 -2
  2. package/.claude/skills/workflow-commands/halfrun.md +3 -1
  3. package/.claude/skills/workflow-commands/kickoff.md +5 -3
  4. package/.claude/skills/workflow-commands/observation-filing.md +3 -1
  5. package/.claude/skills/workflow-commands/prerequisite.md +3 -1
  6. package/.claude/skills/workflow-commands/target-repository.md +1 -1
  7. package/.github/pull_request_template.md +12 -0
  8. package/.github/release.yml +3 -0
  9. package/.github/workflows/ci.yml +8 -0
  10. package/.github/workflows/production.yml +10 -1
  11. package/.github/workflows/publish.yml +72 -2
  12. package/CLAUDE.md +2 -0
  13. package/dist/CLAUDE.md +2 -0
  14. package/dist/CLAUDE.static.md +8 -0
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-36UE53LS.js → chunk-3HZ5MJIW.js} +2 -2
  17. package/dist/hooks/chunk-3V37IID6.js +82 -0
  18. package/dist/hooks/{chunk-FRJKVSK3.js → chunk-77RLKZ5V.js} +2 -2
  19. package/dist/hooks/{chunk-VGYSA2NT.js → chunk-LC765SUA.js} +16 -2
  20. package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NWD47MW3.js} +3 -3
  21. package/dist/hooks/{chunk-4OXFU3MK.js → chunk-O2FBZPSW.js} +619 -156
  22. package/dist/hooks/{chunk-6MQCXEEA.js → chunk-OLTAGGHH.js} +25 -88
  23. package/dist/hooks/{chunk-FNFOLFFV.js → chunk-OYU6KPRA.js} +1 -1
  24. package/dist/hooks/{chunk-PRDCNGA6.js → chunk-UANRLZV7.js} +4 -4
  25. package/dist/hooks/{chunk-264QY6KS.js → chunk-UULARQ5Q.js} +23 -1
  26. package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-XMR3BS35.js} +3 -3
  27. package/dist/hooks/codex-hook-adapter.js +11 -10
  28. package/dist/hooks/duplicate-read-guard.js +5 -5
  29. package/dist/hooks/format-edited.js +3 -2
  30. package/dist/hooks/investigation-guard.js +5 -5
  31. package/dist/hooks/pretool-guard.js +10 -9
  32. package/dist/hooks/stop-guard.js +3 -3
  33. package/dist/josh.js +155 -12
  34. package/package.json +68 -17
  35. package/prettier/index.js +3 -8
  36. package/prettier/static.d.ts +3 -0
  37. package/prettier/static.js +10 -0
  38. package/prompts/collaboration-workflow/issue-template.md +11 -4
  39. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  40. package/prompts/collaboration-workflow/wip-cap.md +2 -2
  41. package/prompts/review.md +2 -2
  42. package/prompts/testing-guide.md +1 -0
  43. package/scripts/build/build-claude-md.ts +6 -1
  44. package/scripts/ci/pr-classification.ts +114 -0
  45. package/scripts/document/document-byte-budget.ts +3 -2
  46. package/scripts/gate/gate-report.ts +2 -0
  47. package/scripts/gate/gate-steps.ts +50 -0
  48. package/scripts/gate/project-checks.ts +121 -0
  49. package/scripts/gate/verification-gate.ts +14 -43
  50. package/scripts/git/git-common-directory.ts +9 -1
  51. package/scripts/git/git-gh-pr-rollup.ts +29 -1
  52. package/scripts/git/git-gh-pr.ts +61 -1
  53. package/scripts/git/git-pr.ts +62 -13
  54. package/scripts/git/issue-labels.ts +9 -0
  55. package/scripts/init/distributed-paths.ts +9 -5
  56. package/scripts/init/hook-command-bootstrap.ts +40 -8
  57. package/scripts/init/hook-command-rewrite.ts +7 -3
  58. package/scripts/init/init-actions.ts +94 -7
  59. package/scripts/init/init-ai-copy.ts +104 -19
  60. package/scripts/init/init-static.ts +52 -0
  61. package/scripts/init/init.ts +97 -30
  62. package/scripts/init/project-profile-cli.ts +14 -0
  63. package/scripts/init/project-profile.ts +149 -0
  64. package/scripts/init/start.ts +30 -0
  65. package/scripts/issue/issue-bug-label.ts +13 -0
  66. package/scripts/issue/issue-classification.ts +74 -0
  67. package/scripts/issue/issue-lint-cli.ts +10 -3
  68. package/scripts/issue/markdown-section.ts +38 -4
  69. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +27 -1
  70. package/scripts/josh/josh-command-map.ts +3 -0
  71. package/scripts/josh/josh-commands-development.ts +6 -0
  72. package/scripts/josh/josh-commands-project.ts +14 -1
  73. package/scripts/josh/josh-logic.ts +13 -0
  74. package/scripts/josh/run-github-prerequisite.ts +32 -0
  75. package/scripts/lane/lane-await.ts +8 -1
  76. package/scripts/lane/openai-lane-ship.ts +30 -0
  77. package/scripts/lane/openai-lane-supervisor.ts +35 -8
  78. package/scripts/lane/openai-review-broker.ts +260 -0
  79. package/scripts/lint/cspell-cached.ts +21 -0
  80. package/scripts/lint/lint-parallel.ts +44 -1
  81. package/scripts/observations/observations-flush.ts +1 -1
  82. package/scripts/package/production-dependency-graph.ts +68 -0
  83. package/scripts/package/verify-optional-eslint-install.ts +62 -0
  84. package/scripts/release/github-release-cli.ts +11 -0
  85. package/scripts/release/github-release.ts +220 -0
  86. package/scripts/release/publish-tag-cli.ts +8 -0
  87. package/scripts/release/publish-tag.ts +11 -0
  88. package/scripts/release/release-publish.ts +7 -2
  89. package/scripts/rules/delivered-rules.ts +2 -0
  90. package/scripts/rules/issue-bug-label.ts +85 -0
  91. package/scripts/rules/issue-filing-args.ts +50 -0
  92. package/scripts/run/run-merge-cli.ts +19 -0
  93. package/scripts/run/run-merge.ts +9 -1
  94. package/scripts/run/run-ship-cli.ts +20 -0
  95. package/scripts/run/run-ship-detach.ts +180 -14
  96. package/scripts/run/run-ship-review-steps.ts +45 -25
  97. package/scripts/run/run-wake-session.ts +3 -1
  98. package/scripts/test/pilot-files.ts +0 -2
  99. package/scripts/test/skip-marker.ts +3 -0
  100. package/scripts/test/test-declared-changed.ts +2 -1
  101. package/scripts/test/test-declared-logic.ts +15 -10
  102. package/scripts/test/test-declared.ts +20 -8
  103. package/scripts/test/test-red-logic.ts +3 -3
  104. package/scripts/test/test-unit-guard.ts +24 -12
  105. package/templates/CLAUDE.static.md +8 -0
  106. package/templates/cursorrules.static +1 -0
  107. package/templates/prettierignore.static +12 -0
  108. package/templates/workflows/ci.yml +5 -0
@@ -77,10 +77,12 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
77
77
  "obf": "observations:flush",
78
78
  "ol": "oracle:list",
79
79
  "ov": "overrides",
80
+ "pf": "profile",
80
81
  "pg": "propagate",
81
82
  "pks": "pkg:scout",
82
83
  "pm": "prevent-main-commit",
83
84
  "ppu": "pre-push-unit",
85
+ "prc": "pr:classification",
84
86
  "pt": "port",
85
87
  "ptc": "pre-commit-type-check",
86
88
  "ptg": "pretool:guard",
@@ -131,6 +133,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
131
133
  "sl": "session:lang",
132
134
  "sp": "stash:pop",
133
135
  "ss": "secretlint-scan",
136
+ "st": "start",
134
137
  "sy": "sync",
135
138
  "sys": "sync:scope",
136
139
  "t": "test",
@@ -445,7 +448,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
445
448
  ],
446
449
  ],
447
450
  "init": [
448
- "",
451
+ "[--profile static|node]",
449
452
  "developer",
450
453
  [
451
454
  "files",
@@ -717,6 +720,13 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
717
720
  "network",
718
721
  ],
719
722
  ],
723
+ "pr:classification": [
724
+ "",
725
+ "automation",
726
+ [
727
+ "none",
728
+ ],
729
+ ],
720
730
  "pre-commit-type-check": [
721
731
  "",
722
732
  "automation",
@@ -745,6 +755,13 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
745
755
  "none",
746
756
  ],
747
757
  ],
758
+ "profile": [
759
+ "[--profile static|node]",
760
+ "developer",
761
+ [
762
+ "files",
763
+ ],
764
+ ],
748
765
  "propagate": [
749
766
  "[--dry-run] [--skip-publish-wait]",
750
767
  "maintainer",
@@ -1075,6 +1092,15 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
1075
1092
  "none",
1076
1093
  ],
1077
1094
  ],
1095
+ "start": [
1096
+ "[--profile static|node]",
1097
+ "developer",
1098
+ [
1099
+ "files",
1100
+ "git",
1101
+ "network",
1102
+ ],
1103
+ ],
1078
1104
  "stash:pop": [
1079
1105
  "<message>",
1080
1106
  "automation",
@@ -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',
@@ -14,6 +14,12 @@ const PATH_ARGUMENTS = '<path...>'
14
14
 
15
15
  /* eslint-disable @typescript-eslint/naming-convention */
16
16
  const DEV_COMMANDS: Record<string, CommandEntry> = {
17
+ 'pr:classification': {
18
+ script: 'scripts/ci/pr-classification.ts',
19
+ description: 'Require one release classification on a pull request',
20
+ category: 'Development',
21
+ reference: ['', 'automation', ['none']],
22
+ },
17
23
  [GATE_COMMAND]: {
18
24
  script: 'scripts/gate/verification-gate.ts',
19
25
  description: 'Run lint, type check, spell check and unit tests concurrently',
@@ -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]),