@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
@@ -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
  ])
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { agent_role_profile } from '#scripts/agent/agent-role-profile'
3
4
  import { doctor_consumer } from '#scripts/doctor/doctor-consumer'
4
5
  import { hook_decision } from '#scripts/josh/hook-decision'
5
6
  import { find_package_directory } from '#scripts/josh/josh-logic'
@@ -29,6 +30,7 @@ const RETROSPECTIVE_ENV_KEY = 'JOSH_RETROSPECTIVE'
29
30
  interface RunReads {
30
31
  carry_kind: CarryRead['kind']
31
32
  is_retrospective_done: boolean
33
+ is_at_cut_cap: boolean
32
34
  last_event: string | undefined
33
35
  }
34
36
 
@@ -36,7 +38,12 @@ interface RunReads {
36
38
  // directory that cannot be read leaves the position unknowable, which `next_action` answers `unknown`.
37
39
  function read_run(directory: string | undefined, issue_number: string): RunReads {
38
40
  if (directory === undefined) {
39
- return { carry_kind: 'unreadable', is_retrospective_done: false, last_event: undefined }
41
+ return {
42
+ carry_kind: 'unreadable',
43
+ is_retrospective_done: false,
44
+ is_at_cut_cap: false,
45
+ last_event: undefined,
46
+ }
40
47
  }
41
48
 
42
49
  const carry = run_carry.read_carry(run_carry.carry_path(directory))
@@ -55,6 +62,7 @@ function read_run(directory: string | undefined, issue_number: string): RunReads
55
62
  return {
56
63
  carry_kind: carry.kind,
57
64
  is_retrospective_done: run_carry.retrospective_done_of(carry),
65
+ is_at_cut_cap: carry.kind === 'carried' && run_carry.is_at_cut_cap(carry.carry),
58
66
  last_event: last?.kind,
59
67
  }
60
68
  }
@@ -72,10 +80,12 @@ async function gather(issue_number: string): Promise<StepInput> {
72
80
  last_event: run_reads.last_event,
73
81
  carry_kind: run_reads.carry_kind,
74
82
  is_retrospective_done: run_reads.is_retrospective_done,
83
+ is_at_cut_cap: run_reads.is_at_cut_cap,
75
84
  is_lane_child: lane_child_marker.is_child_of(process.cwd()),
76
85
  is_consumer: doctor_consumer.is_kit_consumer(find_package_directory(process.cwd())),
77
86
  is_retrospective_enabled: hook_decision.is_switch_opt_in(RETROSPECTIVE_ENV_KEY),
78
87
  has_changes: parts.has_changes,
88
+ has_completion_callback: agent_role_profile.has_completion_callback(),
79
89
  }
80
90
  }
81
91
 
@@ -118,6 +118,13 @@ interface StepInput extends PreInput {
118
118
  // are opt-in — so a run that leaves the variable unset never prints the retrospective step, while the
119
119
  // retrospective's own logic is untouched and `pnpm josh retrospective` still runs by hand.
120
120
  is_retrospective_enabled: boolean
121
+ // Whether this session is woken when a background command it started completes, read by the CLI from
122
+ // the session's own environment (joshuafolkken/kit#2653). A parent without the callback cannot wait
123
+ // on its lanes except by polling, so it hands the run to the supervisor instead of waiting.
124
+ has_completion_callback: boolean
125
+ // Whether the carried invocation has already taken its maximum cuts, read off the carry record by the
126
+ // CLI (joshuafolkken/kit#2346). At the cap `run:carry --cut` refuses, so the hand-off is not offered.
127
+ is_at_cut_cap: boolean
121
128
  }
122
129
 
123
130
  interface StepAction {
@@ -134,10 +141,9 @@ function command(line: string): StepAction {
134
141
  }
135
142
 
136
143
  // The action for each event a run can be positioned at, keyed on the newest one. Each dispatches to the
137
- // command that owns that phase; `child-launch` is the one position with no command to run — the parent
138
- // waits for its child. `stop` and `drain` are handled apart in `stop_action` / `drain_action`, because
139
- // whether either still owes a retrospective needs more than the issue number. A merge and an outage share a next step: `run:merge`
140
- // classifies both, including the outage's stop condition.
144
+ // command that owns that phase. `stop`, `drain` and `child-launch` are handled apart in
145
+ // `FULL_INPUT_ACTIONS`, because each needs more than the issue number. A merge and an outage share a
146
+ // next step: `run:merge` classifies both, including the outage's stop condition.
141
147
  const KIND = run_event_stream.EVENT_KIND
142
148
  // A park and a stall share this next step: both mean runnable work is waiting to be offered
143
149
  // (joshuafolkken/kit#2359), so both are pointed at the backlog.
@@ -149,7 +155,6 @@ const EVENT_ACTIONS: Record<string, (issue_number: string) => StepAction> = {
149
155
  [KIND.OUTAGE]: (issue_number) => command(`pnpm josh run:merge ${issue_number}`),
150
156
  [KIND.PARK]: () => command(OFFER_BACKLOG),
151
157
  [KIND.CUT]: (issue_number) => command(`pnpm josh run:cut --resume ${issue_number}`),
152
- [KIND.CHILD_LAUNCH]: () => verdict(WAIT),
153
158
  // A stall is undispatched ready work with a free lane, so its next step is exactly a park's: offer
154
159
  // the backlog. The detector only reports the stall; reading it here as a dispatch is what turns the
155
160
  // report into the action that resolves it.
@@ -197,12 +202,34 @@ function drain_action(input: StepInput): StepAction {
197
202
  return is_retrospective_owed(input) ? command(RETROSPECTIVE_COMMAND) : verdict(WAIT)
198
203
  }
199
204
 
200
- // The two positions whose action needs the whole input rather than the issue number — a stop and a
201
- // drain both turn on whether the retrospective is still owed. Kept in a map so `next_action` dispatches
202
- // them in one branch rather than one `if` apiece (`is_retrospective_owed` is the shared gate).
205
+ // The hand-off a `backlogrun` parent without a completion callback takes right after dispatching
206
+ // (joshuafolkken/kit#2653). It is the existing merge-time cut, so the ownership check and the cut cap
207
+ // apply unchanged, and the supervisor's driver adopts the in-flight lanes from their `child-launch`
208
+ // events.
209
+ const HAND_OFF_COMMAND = 'pnpm josh run:carry --cut --owner "$PPID"'
210
+
211
+ // After a dispatch the parent waits for its child — where waiting is free. A session the completion
212
+ // does not re-invoke could only wait by polling, each poll a model call over its whole context, so a
213
+ // `backlogrun` parent there hands the carry record to the supervisor instead. A lane child and a run
214
+ // with no carry record have nothing to hand off, so both still wait; so does a run at the cut cap, whose
215
+ // cut would be refused on every ask and leave the carry record with nobody.
216
+ function can_hand_off(input: StepInput): boolean {
217
+ if (input.is_lane_child || input.is_at_cut_cap) return false
218
+
219
+ return input.carry_kind === 'carried' && !input.has_completion_callback
220
+ }
221
+
222
+ function child_launch_action(input: StepInput): StepAction {
223
+ return can_hand_off(input) ? command(HAND_OFF_COMMAND) : verdict(WAIT)
224
+ }
225
+
226
+ // The positions whose action needs the whole input rather than the issue number — a stop and a drain
227
+ // both turn on whether the retrospective is still owed, a child launch on whether waiting is free. Kept
228
+ // in a map so `next_action` dispatches them in one branch rather than one `if` apiece.
203
229
  const FULL_INPUT_ACTIONS: Record<string, (input: StepInput) => StepAction> = {
204
230
  [KIND.STOP]: stop_action,
205
231
  [KIND.DRAIN]: drain_action,
232
+ [KIND.CHILD_LAUNCH]: child_launch_action,
206
233
  }
207
234
 
208
235
  // An event the table does not name leaves the position unknown rather than guessing a next step. A
@@ -275,6 +302,7 @@ function next_action(input: StepInput): StepAction {
275
302
  const run_step = {
276
303
  ALREADY_DONE,
277
304
  EXPIRED_DECISION,
305
+ HAND_OFF_COMMAND,
278
306
  HUMAN_REVIEW,
279
307
  IMPLEMENT,
280
308
  KEEP_WORK,
@@ -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