@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.
- package/.claude/skills/workflow-commands/backlogrun-progress.md +13 -12
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -1
- package/.claude/skills/workflow-commands/fullrun-steps.md +4 -2
- package/.claude/skills/workflow-commands/halfrun.md +3 -1
- package/.claude/skills/workflow-commands/kickoff.md +5 -3
- package/.claude/skills/workflow-commands/observation-filing.md +3 -1
- package/.claude/skills/workflow-commands/prerequisite.md +3 -1
- package/.claude/skills/workflow-commands/target-repository.md +1 -1
- package/.github/pull_request_template.md +12 -0
- package/.github/release.yml +3 -0
- package/.github/workflows/ci.yml +8 -0
- package/.github/workflows/production.yml +10 -1
- package/.github/workflows/publish.yml +72 -2
- package/CLAUDE.md +2 -0
- package/README.md +2 -1
- package/dist/CLAUDE.md +2 -0
- package/dist/CLAUDE.static.md +8 -0
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-264QY6KS.js → chunk-2PGRQ62K.js} +25 -10
- package/dist/hooks/chunk-3V37IID6.js +82 -0
- package/dist/hooks/{chunk-36UE53LS.js → chunk-7XQ6TK3R.js} +2 -2
- package/dist/hooks/{chunk-PRDCNGA6.js → chunk-BNFG2IFK.js} +4 -4
- package/dist/hooks/{chunk-VGYSA2NT.js → chunk-GQ3EVWHY.js} +22 -3
- package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-I3JP3D4A.js} +3 -3
- package/dist/hooks/{chunk-6MQCXEEA.js → chunk-LCJZE42I.js} +25 -88
- package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NJZSO7BK.js} +3 -3
- package/dist/hooks/{chunk-SSX23SJ2.js → chunk-RKUAD3I6.js} +1 -0
- package/dist/hooks/{chunk-FRJKVSK3.js → chunk-ROULPSQC.js} +2 -2
- package/dist/hooks/{chunk-FNFOLFFV.js → chunk-SXPTRFPG.js} +2 -2
- package/dist/hooks/{chunk-FXAKJKTB.js → chunk-WPALEIRX.js} +1 -1
- package/dist/hooks/{chunk-4OXFU3MK.js → chunk-YO43HEPY.js} +634 -156
- package/dist/hooks/codex-hook-adapter.js +13 -12
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +4 -3
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -11
- package/dist/hooks/session-lang.js +2 -2
- package/dist/hooks/stop-guard.js +5 -5
- package/dist/josh.js +156 -20
- package/eslint/base.js +4 -1
- package/package.json +72 -18
- package/playwright.config.ts +9 -17
- package/ports/index.d.ts +1 -0
- package/ports/index.js +1 -0
- package/ports/web-server.d.ts +5 -0
- package/ports/web-server.js +73 -0
- package/prettier/index.js +3 -8
- package/prettier/static.d.ts +3 -0
- package/prettier/static.js +10 -0
- package/prompts/collaboration-workflow/issue-template.md +11 -4
- package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
- package/prompts/collaboration-workflow/wip-cap.md +2 -2
- package/prompts/review.md +2 -2
- package/prompts/testing-guide.md +1 -0
- package/scripts/agent/agent-role-profile.ts +11 -0
- package/scripts/build/build-claude-md.ts +6 -1
- package/scripts/ci/pr-classification.ts +114 -0
- package/scripts/document/document-byte-budget.ts +3 -2
- package/scripts/gate/gate-report.ts +2 -0
- package/scripts/gate/gate-steps.ts +50 -0
- package/scripts/gate/project-checks.ts +145 -0
- package/scripts/gate/verification-gate.ts +14 -43
- package/scripts/git/git-common-directory.ts +9 -1
- package/scripts/git/git-gh-pr-rollup.ts +29 -1
- package/scripts/git/git-gh-pr.ts +61 -1
- package/scripts/git/git-pr.ts +62 -13
- package/scripts/git/issue-labels.ts +9 -0
- package/scripts/init/distributed-paths.ts +9 -5
- package/scripts/init/hook-command-bootstrap.ts +40 -8
- package/scripts/init/hook-command-rewrite.ts +7 -3
- package/scripts/init/init-actions.ts +94 -7
- package/scripts/init/init-ai-copy.ts +107 -19
- package/scripts/init/init-logic-workspace.ts +38 -5
- package/scripts/init/init-logic.ts +1 -0
- package/scripts/init/init-static.ts +55 -0
- package/scripts/init/init.ts +97 -30
- package/scripts/init/project-profile-cli.ts +14 -0
- package/scripts/init/project-profile.ts +158 -0
- package/scripts/init/start.ts +30 -0
- package/scripts/issue/issue-bug-label.ts +13 -0
- package/scripts/issue/issue-classification.ts +74 -0
- package/scripts/issue/issue-lint-cli.ts +10 -3
- package/scripts/issue/markdown-section.ts +38 -4
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +27 -1
- package/scripts/josh/josh-command-map.ts +3 -0
- package/scripts/josh/josh-commands-development.ts +8 -15
- package/scripts/josh/josh-commands-project.ts +14 -1
- package/scripts/josh/josh-logic.ts +13 -0
- package/scripts/josh/run-github-prerequisite.ts +32 -0
- package/scripts/lane/lane-await.ts +8 -1
- package/scripts/lane/openai-lane-ship.ts +30 -0
- package/scripts/lane/openai-lane-supervisor.ts +35 -8
- package/scripts/lane/openai-review-broker.ts +260 -0
- package/scripts/lint/cspell-cached.ts +21 -0
- package/scripts/lint/format.ts +68 -0
- package/scripts/lint/lint-parallel.ts +30 -1
- package/scripts/observations/observations-flush.ts +1 -1
- package/scripts/package/production-dependency-graph.ts +68 -0
- package/scripts/package/verify-optional-eslint-install.ts +62 -0
- package/scripts/release/github-release-cli.ts +11 -0
- package/scripts/release/github-release.ts +220 -0
- package/scripts/release/publish-tag-cli.ts +8 -0
- package/scripts/release/publish-tag.ts +11 -0
- package/scripts/release/release-publish.ts +7 -2
- package/scripts/rules/delivered-rules.ts +2 -0
- package/scripts/rules/issue-bug-label.ts +85 -0
- package/scripts/rules/issue-filing-args.ts +50 -0
- package/scripts/run/run-merge-cli.ts +19 -0
- package/scripts/run/run-merge.ts +9 -1
- package/scripts/run/run-ship-cli.ts +20 -0
- package/scripts/run/run-ship-detach.ts +180 -14
- package/scripts/run/run-ship-review-steps.ts +45 -25
- package/scripts/run/run-step-cli.ts +11 -1
- package/scripts/run/run-step.ts +36 -8
- package/scripts/run/run-wake-session.ts +3 -1
- package/scripts/test/pilot-files.ts +0 -2
- package/scripts/test/skip-marker.ts +3 -0
- package/scripts/test/test-declared-changed.ts +2 -1
- package/scripts/test/test-declared-logic.ts +15 -10
- package/scripts/test/test-declared.ts +20 -8
- package/scripts/test/test-red-logic.ts +3 -3
- package/scripts/test/test-unit-guard.ts +24 -12
- package/templates/CLAUDE.static.md +8 -0
- package/templates/cursorrules.static +1 -0
- package/templates/pnpm-workspace.static.yaml +4 -0
- package/templates/prettierignore.static +12 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
301
|
+
if (result.kind === 'failed') return failed_launch(request, result.note)
|
|
140
302
|
|
|
141
|
-
|
|
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 (
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const request = { argv
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
package/scripts/run/run-step.ts
CHANGED
|
@@ -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
|
|
138
|
-
//
|
|
139
|
-
//
|
|
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
|
|
201
|
-
//
|
|
202
|
-
//
|
|
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'
|
|
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',
|
|
@@ -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
|