@joshuafolkken/kit 1.883.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.
- 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/dist/CLAUDE.md +2 -0
- package/dist/CLAUDE.static.md +8 -0
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-36UE53LS.js → chunk-3HZ5MJIW.js} +2 -2
- package/dist/hooks/chunk-3V37IID6.js +82 -0
- package/dist/hooks/{chunk-FRJKVSK3.js → chunk-77RLKZ5V.js} +2 -2
- package/dist/hooks/{chunk-VGYSA2NT.js → chunk-LC765SUA.js} +16 -2
- package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NWD47MW3.js} +3 -3
- package/dist/hooks/{chunk-4OXFU3MK.js → chunk-O2FBZPSW.js} +619 -156
- package/dist/hooks/{chunk-6MQCXEEA.js → chunk-OLTAGGHH.js} +25 -88
- package/dist/hooks/{chunk-FNFOLFFV.js → chunk-OYU6KPRA.js} +1 -1
- package/dist/hooks/{chunk-PRDCNGA6.js → chunk-UANRLZV7.js} +4 -4
- package/dist/hooks/{chunk-264QY6KS.js → chunk-UULARQ5Q.js} +23 -1
- package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-XMR3BS35.js} +3 -3
- package/dist/hooks/codex-hook-adapter.js +11 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +3 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +10 -9
- package/dist/hooks/stop-guard.js +3 -3
- package/dist/josh.js +155 -12
- package/package.json +70 -19
- 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/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 +121 -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 +104 -19
- package/scripts/init/init-static.ts +52 -0
- package/scripts/init/init.ts +97 -30
- package/scripts/init/project-profile-cli.ts +14 -0
- package/scripts/init/project-profile.ts +149 -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 +6 -0
- 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/lint-parallel.ts +44 -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/propagate/propagate-run.ts +6 -0
- package/scripts/propagate/propagate-steps.ts +2 -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-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/scripts/version/version-check-arguments.ts +14 -0
- package/scripts/version/version-check.ts +10 -3
- package/templates/CLAUDE.static.md +8 -0
- package/templates/cursorrules.static +1 -0
- package/templates/prettierignore.static +12 -0
- package/templates/workflows/ci.yml +5 -0
|
@@ -11,6 +11,8 @@ import { run_event_stream_emit } from './run-event-stream-emit'
|
|
|
11
11
|
import { run_issue_number } from './run-issue-number'
|
|
12
12
|
import { run_merge, type ChildOutcome, type EndingSignals } from './run-merge'
|
|
13
13
|
import { run_merge_steps, type MergeContext } from './run-merge-steps'
|
|
14
|
+
import { run_ship_detach } from './run-ship-detach'
|
|
15
|
+
import { run_ship_probe } from './run-ship-probe'
|
|
14
16
|
|
|
15
17
|
// `josh run:merge <N>` — one composite command for a `backlogrun` merge event (joshuafolkken/kit#2024).
|
|
16
18
|
// The parent's context is the largest and its per-turn cost the highest, and the event was two turns:
|
|
@@ -324,6 +326,10 @@ async function on_unresolved(): Promise<MergeVerdict> {
|
|
|
324
326
|
return emit(RETRY_TOKEN, FAILURE_EXIT_CODE)
|
|
325
327
|
}
|
|
326
328
|
|
|
329
|
+
async function on_shipping(): Promise<MergeVerdict> {
|
|
330
|
+
return emit(RESUMED_TOKEN, SUCCESS_EXIT_CODE)
|
|
331
|
+
}
|
|
332
|
+
|
|
327
333
|
const HANDLERS: Readonly<Record<ChildOutcome, (ctx: MergeContext) => Promise<MergeVerdict>>> = {
|
|
328
334
|
cut: on_cut,
|
|
329
335
|
failed: on_failed,
|
|
@@ -333,11 +339,24 @@ const HANDLERS: Readonly<Record<ChildOutcome, (ctx: MergeContext) => Promise<Mer
|
|
|
333
339
|
parked: on_parked,
|
|
334
340
|
split: on_skipped,
|
|
335
341
|
unresolved: on_unresolved,
|
|
342
|
+
shipping: on_shipping,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function is_ship_running(child: string): Promise<boolean> {
|
|
346
|
+
const repository = await run_ship_probe.repository_directory()
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
repository !== undefined && run_ship_detach.read_result(repository, child)?.result === 'running'
|
|
350
|
+
)
|
|
336
351
|
}
|
|
337
352
|
|
|
338
353
|
// The whole merge event for one returned child, in-process: read its state, classify it, and run the
|
|
339
354
|
// handler. The CLI prints the token; `backlog:drive` branches on the outcome (joshuafolkken/kit#2508).
|
|
340
355
|
async function merge_child(ctx: MergeContext): Promise<MergeResult> {
|
|
356
|
+
if (await is_ship_running(ctx.child)) {
|
|
357
|
+
return { outcome: 'shipping', ...(await on_shipping()) }
|
|
358
|
+
}
|
|
359
|
+
|
|
341
360
|
const read = await issue_state_cli.read_issue(ctx.child, ctx.repo)
|
|
342
361
|
const outcome = outcome_of(read, await read_signals(ctx))
|
|
343
362
|
|
package/scripts/run/run-merge.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
])
|
|
@@ -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
|
|
@@ -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
|
|
40
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
94
|
-
|
|
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 {
|