@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
@@ -45,7 +45,15 @@ const ONE = 1
45
45
  // - `failed` — OPEN and carrying neither, and not an outage; the child did not finish.
46
46
  // - `unresolved` — the state could not be read; re-read before deciding.
47
47
  type ChildOutcome =
48
- 'merged' | 'human-review' | 'parked' | 'split' | 'cut' | 'outage' | 'failed' | 'unresolved'
48
+ | 'merged'
49
+ | 'human-review'
50
+ | 'parked'
51
+ | 'split'
52
+ | 'cut'
53
+ | 'outage'
54
+ | 'failed'
55
+ | 'unresolved'
56
+ | 'shipping'
49
57
 
50
58
  // What the CLI read beside the GitHub state: whether the exit record is an API outage, and whether the
51
59
  // lane holds a cut its successor never adopted.
@@ -392,6 +392,24 @@ function should_detach(args: ShipArguments): boolean {
392
392
  return !run_ship_detach.is_supervised() && lane_child_marker.is_child_of(process.cwd())
393
393
  }
394
394
 
395
+ async function supervised_repository(): Promise<string | undefined> {
396
+ return run_ship_detach.is_supervised() ? await run_ship_probe.repository_directory() : undefined
397
+ }
398
+
399
+ async function claim_supervised_identity(args: ShipArguments): Promise<void> {
400
+ const repository = await supervised_repository()
401
+ if (repository !== undefined) await run_ship_detach.claim_identity(repository, args.number)
402
+ }
403
+
404
+ async function record_supervised_result(
405
+ args: ShipArguments,
406
+ sections: ReadonlyArray<ShipSection>,
407
+ ): Promise<void> {
408
+ const repository = await supervised_repository()
409
+ if (repository === undefined) return
410
+ await run_ship_detach.mark_result(repository, args.number, run_ship.exit_code(sections))
411
+ }
412
+
395
413
  async function run_ship_command(args: ShipArguments): Promise<number> {
396
414
  if (should_detach(args)) {
397
415
  if (!args.is_detach) console.error(LANE_CHILD_DETACH_NOTE)
@@ -399,8 +417,10 @@ async function run_ship_command(args: ShipArguments): Promise<number> {
399
417
  return await detach(args)
400
418
  }
401
419
 
420
+ await claim_supervised_identity(args)
402
421
  const sections = await ship(args)
403
422
 
423
+ await record_supervised_result(args, sections)
404
424
  console.info(run_ship.format_report(sections))
405
425
 
406
426
  return run_ship.exit_code(sections)
@@ -1,5 +1,9 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { setTimeout as sleep } from 'node:timers/promises'
1
3
  import { agent_role_profile } from '#scripts/agent/agent-role-profile'
4
+ import { process_identity } from '#scripts/josh/process-identity'
2
5
  import { stamp_file } from '#scripts/josh/stamp-file'
6
+ import { z } from 'zod'
3
7
  import { detached_launch, type LaunchArgv } from './detached-launch'
4
8
  import { run_event_stream } from './run-event-stream'
5
9
  import { run_event_stream_emit } from './run-event-stream-emit'
@@ -19,6 +23,14 @@ import { run_event_stream_emit } from './run-event-stream-emit'
19
23
  // **One supervisor per issue.** The launched pid is kept beside the stage record, and a second detach
20
24
  // while that process is alive is refused `busy`: two supervisors would race each other's commit and
21
25
  // merge, which the resume record (`run-ship-stage.ts`) orders but cannot serialize.
26
+ //
27
+ // **The supervisor names itself once it starts** (joshuafolkken/kit#2642). The launcher reads the child's
28
+ // start time from outside, which a sandbox that refuses `ps` cannot answer; a pid with no start time
29
+ // would then be believed for as long as any process holds that number. The supervisor therefore claims
30
+ // the record with its own identity — whose socket beacon answers where `ps` cannot — and a record still
31
+ // carrying a bare pid is believed only for the claim window after its launch. A recorded start time the
32
+ // probe cannot momentarily read is the opposite case: the pid is alive and was verified once, so it is
33
+ // kept running rather than read as a crash that would license a second supervisor.
22
34
 
23
35
  const LOG_PREFIX = 'josh-ship-log-'
24
36
  const PID_PREFIX = 'josh-ship-pid-'
@@ -33,11 +45,39 @@ const NOTIFY_VALUE_INDEX = 1
33
45
  // Set on the supervisor so it knows a failed stage is its to hand back (`run-ship-return.ts`); a ship run
34
46
  // in an agent's own turn reports its failure in that turn and hands nothing back.
35
47
  const SUPERVISED_KEY = 'JOSH_SHIP_SUPERVISED'
48
+ const LAUNCH_ID_KEY = 'JOSH_SHIP_LAUNCH_ID'
36
49
  const SUPERVISED_VALUE = '1'
37
50
  const LAUNCHED = 'launched'
38
51
  const BUSY = 'busy'
39
52
  const FAILED = 'failed'
40
- const NO_SIGNAL = 0
53
+ const RESULT_PREFIX = 'josh-ship-result-'
54
+ const RECORD_POLL_MS = 25
55
+ const RECORD_WAIT_ATTEMPTS = 200
56
+ // How long a bare launched pid is believed before the supervisor must have claimed the record: far above
57
+ // a `pnpm josh ship` start-up, far below the lifetime a reused pid could otherwise be believed for.
58
+ const CLAIM_WINDOW_MS = 120_000
59
+ type ShipResult = 'running' | 'success' | 'failed' | 'abnormal'
60
+
61
+ interface ShipRecord {
62
+ pid?: number | undefined
63
+ process_start?: string | undefined
64
+ launched_at?: number | undefined
65
+ launcher_pid?: number | undefined
66
+ launcher_start?: string | undefined
67
+ launch_id: string
68
+ result?: 'success' | 'failed' | undefined
69
+ }
70
+
71
+ const ship_record_schema = z.object({
72
+ pid: z.number().int().positive().optional(),
73
+ process_start: z.string().optional(),
74
+ launched_at: z.number().optional(),
75
+ launcher_pid: z.number().int().positive().optional(),
76
+ launcher_start: z.string().optional(),
77
+ launch_id: z.string(),
78
+ result: z.enum(['success', 'failed']).optional(),
79
+ })
80
+
41
81
  // `detached_launch.log_header`'s shape — `\n=== <time> · started by process <pid> · <command> ===` — read
42
82
  // back to find where the last launch into the log began.
43
83
  const LAUNCH_MARK = ' · started by process '
@@ -67,21 +107,129 @@ function log_path(repository: string, number: string): string {
67
107
  return stamp_file.stamp_path(`${LOG_PREFIX}${number}${KEY_SEPARATOR}`, repository, LOG_SUFFIX)
68
108
  }
69
109
 
70
- function is_alive(pid: number): boolean {
110
+ function result_path(repository: string, number: string): string {
111
+ return stamp_file.stamp_path(`${RESULT_PREFIX}${number}${KEY_SEPARATOR}`, repository)
112
+ }
113
+
114
+ function read_record(repository: string, number: string): ShipRecord | undefined {
71
115
  try {
72
- process.kill(pid, NO_SIGNAL)
116
+ const raw = stamp_file.read_stamp_text(result_path(repository, number))
117
+ if (raw === undefined) return undefined
118
+ const value: unknown = JSON.parse(raw)
119
+ const parsed = ship_record_schema.safeParse(value)
120
+
121
+ return parsed.success ? parsed.data : undefined
122
+ } catch {
123
+ return undefined
124
+ }
125
+ }
126
+
127
+ function record_launch(request: DetachRequest, pid: number, launch_id: string): void {
128
+ stamp_file.write_text_stamp(key(PID_PREFIX, request), String(pid))
129
+ const process_start = process_identity.read_start(pid)
130
+
131
+ stamp_file.replace_stamp(result_path(request.repository, request.number), {
132
+ pid,
133
+ launch_id,
134
+ launched_at: Date.now(),
135
+ ...(process_start !== undefined && { process_start }),
136
+ })
137
+ }
138
+
139
+ function supervisor_environment(launch_id: string): Record<string, string | undefined> {
140
+ return {
141
+ ...agent_role_profile.handoff_environment(),
142
+ [SUPERVISED_KEY]: SUPERVISED_VALUE,
143
+ [LAUNCH_ID_KEY]: launch_id,
144
+ }
145
+ }
146
+
147
+ function is_within_claim_window(record: ShipRecord): boolean {
148
+ return record.launched_at !== undefined && Date.now() - record.launched_at < CLAIM_WINDOW_MS
149
+ }
150
+
151
+ function has_live_identity(pid: number, record: ShipRecord): boolean {
152
+ if (record.process_start === undefined) {
153
+ return is_within_claim_window(record) && process_identity.is_live_pid(pid)
154
+ }
155
+
156
+ return process_identity.is_same_process(pid, record.process_start) !== false
157
+ }
158
+
159
+ function result_of(record: ShipRecord): ShipResult {
160
+ if (record.pid === undefined) {
161
+ const is_launcher = process_identity.is_same_process(record.launcher_pid, record.launcher_start)
162
+
163
+ return is_launcher === false ? 'abnormal' : 'running'
164
+ }
165
+
166
+ if (has_live_identity(record.pid, record)) return 'running'
73
167
 
74
- return true
75
- } catch (error) {
76
- return error instanceof Error && 'code' in error && error.code === 'EPERM'
168
+ return record.result ?? 'abnormal'
169
+ }
170
+
171
+ function read_result(
172
+ repository: string,
173
+ number: string,
174
+ ): { launch_id: string; result: ShipResult } | undefined {
175
+ const record = read_record(repository, number)
176
+
177
+ return record === undefined
178
+ ? undefined
179
+ : { launch_id: record.launch_id, result: result_of(record) }
180
+ }
181
+
182
+ function current_record(repository: string, number: string): ShipRecord | undefined {
183
+ const record = read_record(repository, number)
184
+
185
+ return record?.launch_id === process.env[LAUNCH_ID_KEY] ? record : undefined
186
+ }
187
+
188
+ function is_pending(record: ShipRecord | undefined): boolean {
189
+ return record !== undefined && record.pid === undefined
190
+ }
191
+
192
+ async function ready_record(repository: string, number: string): Promise<ShipRecord | undefined> {
193
+ let record = current_record(repository, number)
194
+
195
+ for (let attempt = 0; attempt < RECORD_WAIT_ATTEMPTS; attempt += 1) {
196
+ if (!is_pending(record)) return record
197
+
198
+ await sleep(RECORD_POLL_MS)
199
+ record = current_record(repository, number)
77
200
  }
201
+
202
+ return undefined
78
203
  }
79
204
 
80
- // The recorded supervisor, when it is still running. An absent, unreadable or dead pid is no supervisor.
81
- function is_supervisor_running(pid_target: string): boolean {
82
- const pid = Number(stamp_file.read_stamp_text(pid_target)?.trim())
205
+ async function update_record(
206
+ repository: string,
207
+ number: string,
208
+ fields: Partial<ShipRecord>,
209
+ ): Promise<void> {
210
+ const record = await ready_record(repository, number)
211
+ if (record === undefined) return
83
212
 
84
- return Number.isSafeInteger(pid) && pid > 0 && is_alive(pid)
213
+ stamp_file.replace_stamp(result_path(repository, number), { ...record, ...fields })
214
+ }
215
+
216
+ async function mark_result(repository: string, number: string, code: number): Promise<void> {
217
+ await update_record(repository, number, { result: code === 0 ? 'success' : 'failed' })
218
+ }
219
+
220
+ // A supervisor that cannot name its own start either (no probe and no beacon) leaves the launcher's
221
+ // identity in place rather than pairing its own pid with the launcher-read start of another process.
222
+ async function claim_identity(repository: string, number: string): Promise<void> {
223
+ const own = process_identity.own_fields()
224
+ if (own.process_start === undefined) return
225
+
226
+ await update_record(repository, number, own)
227
+ }
228
+
229
+ function failed_launch(request: DetachRequest, note: string): DetachResult {
230
+ stamp_file.remove_stamp(result_path(request.repository, request.number))
231
+
232
+ return { verdict: FAILED, note }
85
233
  }
86
234
 
87
235
  // The notify tail the supervisor is given: an inline body moved into a file, a file body passed as is.
@@ -119,9 +267,23 @@ function supervisor_argv(request: DetachRequest): LaunchArgv {
119
267
  }
120
268
  }
121
269
 
270
+ function prepare_launch(request: DetachRequest): string {
271
+ const launch_id = randomUUID()
272
+ const launcher = process_identity.own_fields()
273
+
274
+ stamp_file.replace_stamp(result_path(request.repository, request.number), {
275
+ launch_id,
276
+ launcher_pid: launcher.pid,
277
+ ...(launcher.process_start !== undefined && { launcher_start: launcher.process_start }),
278
+ })
279
+
280
+ return launch_id
281
+ }
282
+
122
283
  function launch(request: DetachRequest): DetachResult {
123
284
  const notes: Array<string> = []
124
285
  const log = log_path(request.repository, request.number)
286
+ const launch_id = prepare_launch(request)
125
287
  const result = detached_launch.launch(
126
288
  {
127
289
  argv: supervisor_argv(request),
@@ -129,16 +291,16 @@ function launch(request: DetachRequest): DetachResult {
129
291
  log_path: log,
130
292
  // The provider travels as a mark because the launch strips the session keys that name it, and
131
293
  // `ship --review` resolves its reviewer from it (joshuafolkken/kit#2456).
132
- env: { ...agent_role_profile.handoff_environment(), [SUPERVISED_KEY]: SUPERVISED_VALUE },
294
+ env: supervisor_environment(launch_id),
133
295
  },
134
296
  (note) => {
135
297
  notes.push(note)
136
298
  },
137
299
  )
138
300
 
139
- if (result.kind === 'failed') return { verdict: FAILED, note: result.note }
301
+ if (result.kind === 'failed') return failed_launch(request, result.note)
140
302
 
141
- stamp_file.write_text_stamp(key(PID_PREFIX, request), String(result.pid))
303
+ record_launch(request, result.pid, launch_id)
142
304
 
143
305
  return {
144
306
  verdict: LAUNCHED,
@@ -147,7 +309,7 @@ function launch(request: DetachRequest): DetachResult {
147
309
  }
148
310
 
149
311
  async function detach(request: DetachRequest): Promise<DetachResult> {
150
- if (is_supervisor_running(key(PID_PREFIX, request))) {
312
+ if (read_result(request.repository, request.number)?.result === 'running') {
151
313
  return { verdict: BUSY, note: `a ship supervisor for #${request.number} is already running` }
152
314
  }
153
315
 
@@ -187,6 +349,10 @@ const run_ship_detach = {
187
349
  BUSY,
188
350
  FAILED,
189
351
  LAUNCHED,
352
+ LAUNCH_ID_KEY,
353
+ claim_identity,
354
+ mark_result,
355
+ read_result,
190
356
  SUPERVISED_KEY,
191
357
  detach,
192
358
  is_supervised,
@@ -1,10 +1,12 @@
1
- import { agent_argv } from '#scripts/agent/agent-argv'
2
- import { agent_role_profile } from '#scripts/agent/agent-role-profile'
1
+ import { agent_argv, type AgentArgv } from '#scripts/agent/agent-argv'
2
+ import { agent_role_profile, type AgentProfile } from '#scripts/agent/agent-role-profile'
3
3
  import { gate_tree } from '#scripts/gate/gate-tree'
4
4
  import { scoped_green } from '#scripts/gate/scoped-green'
5
5
  import { PROJECT_ROOT } from '#scripts/init/init-paths'
6
6
  import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
7
7
  import { stamp_file } from '#scripts/josh/stamp-file'
8
+ import { lane_child_marker } from '#scripts/lane/lane-child-marker'
9
+ import { openai_review_broker } from '#scripts/lane/openai-review-broker'
8
10
  import { detached_launch } from './detached-launch'
9
11
  import { run_ship_review, type RoundOutcome, type ScoredVerdict } from './run-ship-review'
10
12
 
@@ -41,12 +43,6 @@ const ATTEST_CHECK = ['review:attest', '--check']
41
43
  const ROUND_TWO_DECISION = ['review:round2', '--round-1-closed']
42
44
  const ROUND_TWO_BRIEF = ['review:brief', '--round', '2']
43
45
  const ROUND_TWO_REQUIRED = 'required'
44
- const BRIEF_PREFIX = 'josh-ship-review-brief-'
45
- const FINDINGS_PREFIX = 'josh-ship-review-findings-'
46
- const LOG_PREFIX = 'josh-ship-review-log-'
47
- const BRIEF_SUFFIX = '.md'
48
- const FINDINGS_SUFFIX = '.txt'
49
- const LOG_SUFFIX = '.log'
50
46
  const FIXED_JOIN_NOTE =
51
47
  'the round-1 gate read the tree before the review fixes — drained, not judged; the gate stage re-runs it.'
52
48
  const ROUND_TWO_SKIPPED_NOTE = 'round 2 not due — nothing left to verify; shipping on.'
@@ -59,11 +55,7 @@ function failure(out: string): JoshResult {
59
55
  }
60
56
 
61
57
  function paths(root: string = PROJECT_ROOT): { brief: string; findings: string; log: string } {
62
- return {
63
- brief: stamp_file.stamp_path(BRIEF_PREFIX, root, BRIEF_SUFFIX),
64
- findings: stamp_file.stamp_path(FINDINGS_PREFIX, root, FINDINGS_SUFFIX),
65
- log: stamp_file.stamp_path(LOG_PREFIX, root, LOG_SUFFIX),
66
- }
58
+ return openai_review_broker.review_paths(root)
67
59
  }
68
60
 
69
61
  async function josh(argv: ReadonlyArray<string>): Promise<JoshResult> {
@@ -77,12 +69,12 @@ function note(text: string): void {
77
69
  // Under the reviewer profile — the same model and effort (`JOSH_REVIEWER_*` included) the chain's
78
70
  // subagent is given — and waited on, so the join below runs only once the review has finished. Each
79
71
  // call is its own process, so round 2 is a fresh session by construction.
80
- async function run_session(prompt: string, log_path: string): Promise<JoshResult> {
81
- const built = agent_argv.resolve_in(prompt, agent_role_profile.REVIEWER, PROJECT_ROOT)
82
-
83
- if (built.kind === 'rejected') return failure(`reviewer profile rejected: ${built.note}`)
84
-
85
- const request = { argv: built.argv, cwd: PROJECT_ROOT, log_path, profile: built.profile }
72
+ async function run_direct_session(
73
+ argv: AgentArgv,
74
+ profile: AgentProfile,
75
+ log_path: string,
76
+ ): Promise<JoshResult> {
77
+ const request = { argv, cwd: PROJECT_ROOT, log_path, profile }
86
78
  const result = await detached_launch.launch_attached(request, note)
87
79
 
88
80
  if (result.kind === 'failed') return failure(`reviewer could not start: ${result.note}`)
@@ -91,15 +83,38 @@ async function run_session(prompt: string, log_path: string): Promise<JoshResult
91
83
  return { code: SUCCESS_EXIT_CODE, out: '' }
92
84
  }
93
85
 
86
+ async function run_session(
87
+ prompt: string,
88
+ log_path: string,
89
+ issue: string,
90
+ round: '1' | '2',
91
+ ): Promise<JoshResult> {
92
+ const built = agent_argv.resolve_in(prompt, agent_role_profile.REVIEWER, PROJECT_ROOT)
93
+ if (built.kind === 'rejected') return failure(`reviewer profile rejected: ${built.note}`)
94
+
95
+ if (built.profile.provider !== 'openai' || !lane_child_marker.is_child_of(PROJECT_ROOT)) {
96
+ return await run_direct_session(built.argv, built.profile, log_path)
97
+ }
98
+
99
+ const is_success = await openai_review_broker.request(PROJECT_ROOT, issue, round)
100
+
101
+ return is_success ? { code: SUCCESS_EXIT_CODE, out: '' } : failure('isolated reviewer failed')
102
+ }
103
+
94
104
  // The brief goes to a file and the previous round's findings are removed, so a reviewer that writes
95
105
  // nothing reads as unfinished rather than as the last round's verdict.
96
- async function launch_reviewer(brief: string, prompt_of: PromptOf): Promise<JoshResult> {
106
+ async function launch_reviewer(
107
+ brief: string,
108
+ prompt_of: PromptOf,
109
+ issue: string,
110
+ round: '1' | '2',
111
+ ): Promise<JoshResult> {
97
112
  const target = paths()
98
113
 
99
114
  stamp_file.write_text_stamp(target.brief, brief)
100
115
  stamp_file.remove_stamp(target.findings)
101
116
 
102
- return await run_session(prompt_of(target.brief, target.findings), target.log)
117
+ return await run_session(prompt_of(target.brief, target.findings), target.log, issue, round)
103
118
  }
104
119
 
105
120
  function current_verdict(): ReturnType<typeof run_ship_review.read_verdict> {
@@ -150,7 +165,12 @@ async function run_phases(phases: ReadonlyArray<Phase>): Promise<JoshResult> {
150
165
  }
151
166
 
152
167
  // A brief-minting command, then a reviewer handed what it printed.
153
- function reviewed(open: ReadonlyArray<string>, prompt_of: PromptOf): ReadonlyArray<Phase> {
168
+ function reviewed(
169
+ open: ReadonlyArray<string>,
170
+ prompt_of: PromptOf,
171
+ issue: string,
172
+ round: '1' | '2',
173
+ ): ReadonlyArray<Phase> {
154
174
  const opened = { brief: '' }
155
175
 
156
176
  return [
@@ -161,7 +181,7 @@ function reviewed(open: ReadonlyArray<string>, prompt_of: PromptOf): ReadonlyArr
161
181
 
162
182
  return result
163
183
  },
164
- async () => await launch_reviewer(opened.brief, prompt_of),
184
+ async () => await launch_reviewer(opened.brief, prompt_of, issue, round),
165
185
  ]
166
186
  }
167
187
 
@@ -195,7 +215,7 @@ async function round_one_record(issue: string): Promise<JoshResult> {
195
215
  async function review_stage(issue: string): Promise<JoshResult> {
196
216
  return await run_phases([
197
217
  scoped_pair,
198
- ...reviewed([RUN_REVIEW], run_ship_review.reviewer_prompt),
218
+ ...reviewed([RUN_REVIEW], run_ship_review.reviewer_prompt, issue, '1'),
199
219
  join_gate,
200
220
  async () => await josh(ATTEST_CHECK),
201
221
  async () => await round_one_record(issue),
@@ -216,7 +236,7 @@ async function round_two_stage(issue: string): Promise<JoshResult> {
216
236
 
217
237
  return await run_phases([
218
238
  scoped_pair,
219
- ...reviewed(ROUND_TWO_BRIEF, run_ship_review.verification_prompt),
239
+ ...reviewed(ROUND_TWO_BRIEF, run_ship_review.verification_prompt, issue, '2'),
220
240
  async () => await josh(ATTEST_CHECK),
221
241
  async () => await record_and_route(issue, run_ship_review.round_two_outcome),
222
242
  ])
@@ -105,7 +105,9 @@ function wake_argv(
105
105
  const matched = safe_invocation(invocation)
106
106
  if (matched === undefined) return undefined
107
107
  const prompt =
108
- typeof session === 'object' ? `${matched}\n\nDriver handoff:\n${session.material}` : matched
108
+ typeof session === 'object'
109
+ ? `${matched} | Driver handoff: ${session.material.replaceAll('\n', ' | ')}`
110
+ : matched
109
111
  const session_id = typeof session === 'object' ? session.id : session
110
112
 
111
113
  return profile === undefined
@@ -253,7 +253,6 @@ const PILOT_FILES: ReadonlyArray<string> = [
253
253
  'scripts/josh/kit-only.test.ts',
254
254
  'scripts/josh/platform-temporary.test.ts',
255
255
  'scripts/josh/process-identity.test.ts',
256
- 'scripts/lane/lane-await.test.ts',
257
256
  'scripts/lane/lane-capacity.test.ts',
258
257
  'scripts/lane/lane-child-invocation.test.ts',
259
258
  'scripts/lane/lane-child-marker.test.ts',
@@ -399,7 +398,6 @@ const PILOT_FILES: ReadonlyArray<string> = [
399
398
  'scripts/test/http-fault-injection.test.ts',
400
399
  'scripts/test/pilot-config.test.ts',
401
400
  'scripts/test/pilot-files.test.ts',
402
- 'scripts/test/test-declared-changed.test.ts',
403
401
  'scripts/test/test-declared-document-rule.test.ts',
404
402
  'scripts/test/test-declared-logic.test.ts',
405
403
  'scripts/test/test-declared-match.test.ts',
@@ -0,0 +1,3 @@
1
+ const SKIP_MARKER = '— skipping'
2
+
3
+ export { SKIP_MARKER }
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process'
2
+ import { project_checks } from '#scripts/gate/project-checks'
2
3
  import { test_declared_logic, type Verdict } from './test-declared-logic'
3
4
 
4
5
  // The working-tree change set and the verdict over it, read **synchronously** for the `PreToolUse`
@@ -61,7 +62,7 @@ function source_paths(): ReadonlyArray<string> {
61
62
  // The verdict over the current working tree. `paths` stays injectable for a direct caller; when it is
62
63
  // omitted the injected set is read if one is in force, else the real tree.
63
64
  function current_verdict(paths: ReadonlyArray<string> = source_paths()): Verdict {
64
- return test_declared_logic.verdict_for(paths)
65
+ return test_declared_logic.verdict_for(paths, project_checks.is_static(process.cwd()))
65
66
  }
66
67
 
67
68
  // Put the injection back to what it was before a `with_paths` span — cleared to the live reader when
@@ -27,6 +27,7 @@ const TEST_SUFFIXES: ReadonlyArray<string> = ['.e2e.ts', '.test.ts']
27
27
  const EXEMPT_PATHS: ReadonlyArray<string> = ['.editorconfig']
28
28
  const EXEMPT_PREFIXES: ReadonlyArray<string> = ['.idea/', '.vscode/', 'prompts/']
29
29
  const EXEMPT_SUFFIXES: ReadonlyArray<string> = ['.md']
30
+ const STATIC_VISUAL_SUFFIXES: ReadonlyArray<string> = ['.html', '.css']
30
31
 
31
32
  function normalize(paths: ReadonlyArray<string>): Array<string> {
32
33
  return paths.map((path) => path.trim()).filter((path) => path !== '')
@@ -36,8 +37,12 @@ function is_test_file(path: string): boolean {
36
37
  return TEST_SUFFIXES.some((suffix) => path.endsWith(suffix))
37
38
  }
38
39
 
39
- function is_exempt(path: string): boolean {
40
- if (EXEMPT_PATHS.includes(path)) return true
40
+ function is_static_visual(path: string, is_static: boolean): boolean {
41
+ return is_static && STATIC_VISUAL_SUFFIXES.some((suffix) => path.endsWith(suffix))
42
+ }
43
+
44
+ function is_exempt(path: string, is_static = false): boolean {
45
+ if (EXEMPT_PATHS.includes(path) || is_static_visual(path, is_static)) return true
41
46
 
42
47
  return (
43
48
  EXEMPT_SUFFIXES.some((suffix) => path.endsWith(suffix)) ||
@@ -47,31 +52,31 @@ function is_exempt(path: string): boolean {
47
52
 
48
53
  // A runtime file is one that is neither a test nor exempt. Its presence with no test file beside it is
49
54
  // the whole of what `required` reports.
50
- function is_runtime(path: string): boolean {
51
- return !is_test_file(path) && !is_exempt(path)
55
+ function is_runtime(path: string, is_static = false): boolean {
56
+ return !is_test_file(path) && !is_exempt(path, is_static)
52
57
  }
53
58
 
54
59
  // **A test file changed → `satisfied`, whatever else did.** A runtime file with no test beside it →
55
60
  // `required`. Everything left — an all-exempt change, and the empty diff — is `exempt`, because there
56
61
  // is no runtime file the change failed to test.
57
- function verdict_for(paths: ReadonlyArray<string>): Verdict {
62
+ function verdict_for(paths: ReadonlyArray<string>, is_static = false): Verdict {
58
63
  const changed = normalize(paths)
59
64
 
60
65
  if (changed.some((path) => is_test_file(path))) return 'satisfied'
61
- if (changed.some((path) => is_runtime(path))) return 'required'
66
+ if (changed.some((path) => is_runtime(path, is_static))) return 'required'
62
67
 
63
68
  return 'exempt'
64
69
  }
65
70
 
66
71
  // The runtime files with no test beside them — the `required` detail line, so the answer says which
67
72
  // files it is about rather than only that it refused.
68
- function runtime_files(paths: ReadonlyArray<string>): Array<string> {
69
- return normalize(paths).filter((path) => is_runtime(path))
73
+ function runtime_files(paths: ReadonlyArray<string>, is_static = false): Array<string> {
74
+ return normalize(paths).filter((path) => is_runtime(path, is_static))
70
75
  }
71
76
 
72
77
  // The paths that made the change exempt — the `exempt` detail line.
73
- function exempt_files(paths: ReadonlyArray<string>): Array<string> {
74
- return normalize(paths).filter((path) => is_exempt(path))
78
+ function exempt_files(paths: ReadonlyArray<string>, is_static = false): Array<string> {
79
+ return normalize(paths).filter((path) => is_exempt(path, is_static))
75
80
  }
76
81
 
77
82
  const test_declared_logic = {
@@ -2,6 +2,7 @@
2
2
  import { text } from 'node:stream/consumers'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { parseArgs } from 'node:util'
5
+ import { project_checks } from '#scripts/gate/project-checks'
5
6
  import { test_declared_changed } from './test-declared-changed'
6
7
  import { test_declared_logic, type Verdict } from './test-declared-logic'
7
8
  import { test_declared_match, type MatchResult } from './test-declared-match'
@@ -76,24 +77,35 @@ function typed_runtime_file(path: string): string {
76
77
  }
77
78
 
78
79
  // The word is stdout so a caller can read it alone; the reason — which files, and why — is stderr.
79
- function detail_for(verdict: Verdict, paths: ReadonlyArray<string>): string {
80
+ function exempt_detail(paths: ReadonlyArray<string>, is_static: boolean): string {
81
+ const exempt = test_declared_logic.exempt_files(paths, is_static)
82
+ const has_visual = is_static && exempt.some((path) => /\.(?:html|css)$/u.test(path))
83
+ const instruction = has_visual ? ' — confirm the rendered page in a browser' : ''
84
+
85
+ return `exempt paths: ${exempt.join(', ')}${instruction}`
86
+ }
87
+
88
+ function detail_for(verdict: Verdict, paths: ReadonlyArray<string>, is_static = false): string {
80
89
  if (verdict === 'required') {
81
- const typed = test_declared_logic.runtime_files(paths).map((path) => typed_runtime_file(path))
90
+ const typed = test_declared_logic
91
+ .runtime_files(paths, is_static)
92
+ .map((path) => typed_runtime_file(path))
82
93
 
83
94
  return `runtime files with no test: ${typed.join(', ')}`
84
95
  }
85
96
 
86
- if (verdict === 'exempt') {
87
- return `exempt paths: ${test_declared_logic.exempt_files(paths).join(', ')}`
88
- }
97
+ if (verdict === 'exempt') return exempt_detail(paths, is_static)
89
98
 
90
99
  return 'a test file changed'
91
100
  }
92
101
 
93
- function report(paths: ReadonlyArray<string>): { detail: string; verdict: Verdict } {
94
- const verdict = test_declared_logic.verdict_for(paths)
102
+ function report(
103
+ paths: ReadonlyArray<string>,
104
+ is_static: boolean = project_checks.is_static(process.cwd()),
105
+ ): { detail: string; verdict: Verdict } {
106
+ const verdict = test_declared_logic.verdict_for(paths, is_static)
95
107
 
96
- return { detail: detail_for(verdict, paths), verdict }
108
+ return { detail: detail_for(verdict, paths, is_static), verdict }
97
109
  }
98
110
 
99
111
  function run(): void {
@@ -1,4 +1,4 @@
1
- import { markdown_section } from '#scripts/issue/markdown-section'
1
+ import { issue_bug_label } from '#scripts/issue/issue-bug-label'
2
2
  import { z } from 'zod'
3
3
  import { test_declared_logic } from './test-declared-logic'
4
4
 
@@ -16,14 +16,14 @@ type RedVerdict = 'red' | 'green' | 'no-test'
16
16
  // The declaration a bug-fix Issue carries under its background heading, in the same slot as the
17
17
  // behavior-change declaration (`behavior-change-lint.ts`). Read from the declaration, never inferred,
18
18
  // so an Issue that does not declare itself a bug is never held to it.
19
- const BUG_DECLARATION_LINE = '- 種別: 不具合'
19
+ const { BUG_DECLARATION_LINE } = issue_bug_label
20
20
 
21
21
  // Only a Vitest file can be run against the pre-fix tree here — an `*.e2e.ts` needs a served app, so it
22
22
  // is left to the CI E2E job rather than counted as a test this command ran.
23
23
  const UNIT_TEST_SUFFIX = '.test.ts'
24
24
 
25
25
  function is_bug_fix(body: string): boolean {
26
- return markdown_section.has_line(body, BUG_DECLARATION_LINE)
26
+ return issue_bug_label.is_bug_fix(body)
27
27
  }
28
28
 
29
29
  // The changed paths that are Vitest files, in the order they were given.