@joshuafolkken/kit 1.1383.0 → 1.1435.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/epic-commands/SKILL.md +1 -1
- package/.claude/skills/epic-commands/epic-bundle.md +5 -0
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +5 -3
- package/.claude/skills/workflow-commands/backlogrun-steps.md +4 -3
- package/.claude/skills/workflow-commands/backlogrun.md +1 -0
- package/.claude/skills/workflow-commands/chain-rule.md +4 -2
- package/.claude/skills/workflow-commands/issue-comments.md +6 -0
- package/.claude/skills/workflow-commands/observation-ledger.md +3 -2
- package/.claude/skills/workflow-commands/pre-gate-cut.md +5 -3
- package/README.md +25 -0
- package/dist/hooks/audit-provision.js +2 -2
- package/dist/hooks/batch-guard.js +7 -7
- package/dist/hooks/{chunk-LOJTBU5V.js → chunk-2G5F2YCD.js} +3 -3
- package/dist/hooks/{chunk-4MGHBTOM.js → chunk-3LE6VLSR.js} +25 -8
- package/dist/hooks/{chunk-Y5R47QHU.js → chunk-4BHUPFKF.js} +1 -1
- package/dist/hooks/{chunk-KC6LUZZ2.js → chunk-5SJSSCJG.js} +1 -1
- package/dist/hooks/{chunk-FZ6FUSBM.js → chunk-723HKZO5.js} +1 -1
- package/dist/hooks/{chunk-RYP3TSUJ.js → chunk-7KMN5QB5.js} +3 -3
- package/dist/hooks/{chunk-NEC4AT4Z.js → chunk-DGT54IH5.js} +4 -0
- package/dist/hooks/{chunk-YKUITFT7.js → chunk-EMB55BDF.js} +4 -4
- package/dist/hooks/{chunk-JBBMAPC4.js → chunk-IX6TTTI6.js} +4 -0
- package/dist/hooks/{chunk-6MAW7BCM.js → chunk-JXQBK323.js} +2677 -190
- package/dist/hooks/{chunk-KCF2YE7O.js → chunk-MSYSUSY6.js} +5 -5
- package/dist/hooks/{chunk-LC56R53A.js → chunk-MU4LJFIV.js} +4 -4
- package/dist/hooks/{chunk-7VSZ2N4B.js → chunk-QL6QTPWN.js} +3 -3
- package/dist/hooks/{chunk-TT742SIO.js → chunk-XER6SSIB.js} +92 -19
- package/dist/hooks/{chunk-2X2ART5G.js → chunk-YW452ZAX.js} +500 -29
- package/dist/hooks/{chunk-6AC4C2IO.js → chunk-YZXYOAZ3.js} +187 -443
- package/dist/hooks/codex-hook-adapter.js +15 -15
- package/dist/hooks/duplicate-read-guard.js +8 -8
- package/dist/hooks/format-edited.js +5 -5
- package/dist/hooks/inputs.json +262 -201
- package/dist/hooks/investigation-guard.js +8 -8
- package/dist/hooks/pretool-guard.js +14 -14
- package/dist/hooks/session-lang.js +124 -15
- package/dist/hooks/stop-guard.js +98 -55
- package/dist/josh.js +63 -10
- package/package.json +4 -3
- package/prompts/review.md +22 -0
- package/scripts/agent/claude-agent-argv.ts +13 -1
- package/scripts/auth/auth-setup-cli.ts +101 -0
- package/scripts/auth/claude-token-output.ts +78 -0
- package/scripts/auth/claude-token.ts +68 -0
- package/scripts/backlog/backlog-drive-cli.ts +2 -4
- package/scripts/backlog/backlog-drive-epic.ts +24 -8
- package/scripts/backlog/backlog-drive-launch.ts +5 -0
- package/scripts/backlog/backlog-drive-named-offer.ts +7 -2
- package/scripts/backlog/backlog-drive-named.ts +4 -2
- package/scripts/backlog/backlog-drive-owner.ts +72 -5
- package/scripts/backlog/backlog-drive-unread.ts +56 -0
- package/scripts/backlog/backlog-drive.ts +16 -8
- package/scripts/backlog/backlog-overlap.ts +22 -5
- package/scripts/backlog/backlog-ready.ts +1 -3
- package/scripts/backlog/backlogrun-args.ts +13 -2
- package/scripts/backlog/backlogrun-cli.ts +91 -19
- package/scripts/backlog/backlogrun-launch.ts +80 -5
- package/scripts/backlog/backlogrun-replace.ts +166 -0
- package/scripts/backlog/backlogrun-stop.ts +178 -0
- package/scripts/behavior/behavior-cli.ts +6 -1
- package/scripts/doctor/doctor-io.ts +60 -9
- package/scripts/doctor/doctor.ts +20 -1
- package/scripts/document/document-byte-budget.ts +3 -3
- package/scripts/epic/epic-bundle-cli.ts +8 -8
- package/scripts/epic/epic-bundle-referenced.ts +2 -5
- package/scripts/epic/epic-bundle.ts +39 -4
- package/scripts/exports/unused-members-cli.ts +2 -1
- package/scripts/followup/followup-checks-event.ts +82 -0
- package/scripts/followup/git-followup-finish.ts +16 -2
- package/scripts/followup/git-followup-workflow.ts +5 -2
- package/scripts/followup/git-pr-followup-checks.ts +18 -4
- package/scripts/followup/git-pr-followup-wrapup.ts +5 -0
- package/scripts/followup/git-pr-followup.ts +4 -1
- package/scripts/fresh-machine/fresh-machine-cli.ts +204 -0
- package/scripts/fresh-machine/fresh-machine-docker.ts +65 -0
- package/scripts/fresh-machine/fresh-machine-image.ts +62 -0
- package/scripts/fresh-machine/fresh-machine-log.ts +61 -0
- package/scripts/fresh-machine/fresh-machine-patterns.ts +130 -0
- package/scripts/fresh-machine/fresh-machine-run.ts +215 -0
- package/scripts/fresh-machine/fresh-machine-script.ts +45 -0
- package/scripts/fresh-machine/fresh-machine-spec.ts +57 -0
- package/scripts/fresh-machine/specs/kit-test-backlogrun-python.json +34 -0
- package/scripts/fresh-machine/specs/kit-test-backlogrun.json +31 -0
- package/scripts/gate/gate-report.ts +70 -15
- package/scripts/gate/project-checks.ts +13 -2
- package/scripts/gate/scoped-green.ts +2 -1
- package/scripts/gate/verification-gate.ts +4 -3
- package/scripts/gh/gh-author-association.ts +14 -0
- package/scripts/gh/git-gh-conditional.ts +66 -0
- package/scripts/gh/git-gh-exec.ts +1 -1
- package/scripts/gh/git-gh-issue-read.ts +6 -3
- package/scripts/gh/git-gh-issue-write.ts +5 -1
- package/scripts/gh/git-gh-issue.ts +4 -1
- package/scripts/gh/git-pr-checks-eval.ts +13 -0
- package/scripts/gh/git-pr-checks-parse.ts +8 -1
- package/scripts/gh/git-pr-checks-watch.ts +11 -4
- package/scripts/gh/git-pr-checks.ts +36 -2
- package/scripts/git/git-command.ts +16 -4
- package/scripts/git/git-schemas.ts +1 -0
- package/scripts/git/git-spawn-sync.ts +1 -1
- package/scripts/hooks/pretool-guard.ts +3 -1
- package/scripts/init/hook-command-rewrite.ts +31 -0
- package/scripts/init/init-actions.ts +4 -1
- package/scripts/init/init-ai-copy.ts +33 -10
- package/scripts/init/init-basic.ts +8 -3
- package/scripts/init/init-logic-toolchain.ts +76 -0
- package/scripts/init/init-logic.ts +2 -0
- package/scripts/init/init.ts +5 -23
- package/scripts/init/project-profile.ts +36 -7
- package/scripts/init/start-exec.ts +27 -1
- package/scripts/init/start-plan.ts +30 -4
- package/scripts/init/start-steps.ts +77 -3
- package/scripts/init/start.ts +21 -13
- package/scripts/init/workflow-mark.ts +35 -0
- package/scripts/issue/issue-ack-cli.ts +84 -0
- package/scripts/issue/issue-labels.ts +40 -8
- package/scripts/issue/issue-state-cli.ts +14 -4
- package/scripts/issue/issue-watch-diff.ts +152 -0
- package/scripts/issue/issue-watch-gate.ts +81 -0
- package/scripts/issue/issue-watch-hook.ts +54 -0
- package/scripts/issue/issue-watch.ts +240 -0
- package/scripts/issue/measure-rerun-cli.ts +3 -4
- package/scripts/josh/josh-command-map.ts +4 -0
- package/scripts/josh/josh-commands-backlog.ts +2 -2
- package/scripts/josh/josh-commands-development.ts +1 -1
- package/scripts/josh/josh-commands-issue.ts +7 -0
- package/scripts/josh/josh-commands-lane.ts +11 -2
- package/scripts/josh/josh-commands-project.ts +25 -1
- package/scripts/josh/josh-verdict.ts +33 -4
- package/scripts/lane/claude-lane-broker.ts +142 -0
- package/scripts/lane/lane-await.ts +6 -2
- package/scripts/lane/lane-cache.ts +37 -7
- package/scripts/lane/lane-capacity.ts +8 -6
- package/scripts/lane/lane-child-invocation.ts +1 -0
- package/scripts/lane/lane-cli.ts +15 -1
- package/scripts/lane/lane-close.ts +15 -6
- package/scripts/lane/lane-dispatch.ts +36 -10
- package/scripts/lane/lane-environment.ts +1 -1
- package/scripts/lane/lane-launch-cli.ts +13 -4
- package/scripts/lane/lane-ledger.ts +21 -3
- package/scripts/lane/lane-measure-cli.ts +10 -2
- package/scripts/lane/lane-open.ts +9 -1
- package/scripts/lane/lane-preserve.ts +49 -0
- package/scripts/lane/lane-reap.ts +20 -5
- package/scripts/lane/lane-registry.ts +6 -0
- package/scripts/lane/lane-restart-cli.ts +51 -0
- package/scripts/lane/lane-restart.ts +175 -0
- package/scripts/lane/{openai-review-broker.ts → lane-session-broker.ts} +106 -28
- package/scripts/lane/lane-spent.ts +195 -0
- package/scripts/lane/lane-stage-stats.ts +27 -3
- package/scripts/lane/lane-stop-stats.ts +96 -0
- package/scripts/lane/openai-lane-supervisor.ts +2 -2
- package/scripts/lane/process-tools.ts +31 -0
- package/scripts/lib/buffered-process.ts +36 -2
- package/scripts/lib/pnpm-reach.ts +27 -0
- package/scripts/lib/status-icons.ts +5 -1
- package/scripts/lint/lint-parallel.ts +19 -2
- package/scripts/observations/observation-ledger-home.ts +1 -0
- package/scripts/review/review-attest-cli.ts +21 -6
- package/scripts/review/review-brief-cli.ts +2 -2
- package/scripts/review/review-comment-round.ts +114 -0
- package/scripts/review/review-record-cli.ts +39 -17
- package/scripts/rules/implementation-cut.ts +3 -3
- package/scripts/rules/pre-gate-cut.ts +1 -1
- package/scripts/run/board/run-board-checks.ts +42 -0
- package/scripts/run/board/run-board-cli.ts +2 -0
- package/scripts/run/board/run-board-every.ts +3 -1
- package/scripts/run/board/run-board-fit.ts +16 -5
- package/scripts/run/board/run-board-fresh.ts +64 -0
- package/scripts/run/board/run-board-header.ts +139 -37
- package/scripts/run/board/run-board-labels.ts +47 -4
- package/scripts/run/board/run-board-lane-event.ts +66 -0
- package/scripts/run/board/run-board-layout.ts +57 -9
- package/scripts/run/board/run-board-now.ts +234 -0
- package/scripts/run/board/run-board-operation-read.ts +75 -0
- package/scripts/run/board/run-board-operation.ts +150 -0
- package/scripts/run/board/run-board-phase.ts +26 -1
- package/scripts/run/board/run-board-plan.ts +4 -6
- package/scripts/run/board/run-board-read.ts +58 -5
- package/scripts/run/board/run-board-reason.ts +124 -0
- package/scripts/run/board/run-board-render-checks.ts +55 -0
- package/scripts/run/board/run-board-render-legend.ts +12 -3
- package/scripts/run/board/run-board-render-slot.ts +2 -2
- package/scripts/run/board/run-board-render-tail.ts +128 -0
- package/scripts/run/board/run-board-render-thanks.ts +50 -0
- package/scripts/run/board/run-board-render-title.ts +58 -0
- package/scripts/run/board/run-board-render.ts +96 -80
- package/scripts/run/board/run-board-row-yield.ts +32 -0
- package/scripts/run/board/run-board-setup.ts +121 -0
- package/scripts/run/board/run-board-span.ts +49 -7
- package/scripts/run/board/run-board-state.ts +10 -0
- package/scripts/run/board/run-board-status.ts +76 -13
- package/scripts/run/board/run-board-thanks-csv.ts +71 -0
- package/scripts/run/board/run-board-thanks-read.ts +112 -0
- package/scripts/run/board/run-board-thanks.ts +53 -0
- package/scripts/run/board/run-board-tick.ts +53 -16
- package/scripts/run/board/run-board-usual.ts +44 -0
- package/scripts/run/carry/run-carry-added.ts +1 -1
- package/scripts/run/carry/run-carry-args.ts +9 -1
- package/scripts/run/carry/run-carry-change.ts +1 -1
- package/scripts/run/carry/run-carry-cli.ts +39 -52
- package/scripts/run/carry/run-carry-end.ts +73 -0
- package/scripts/run/carry/run-carry-ended.ts +9 -1
- package/scripts/run/carry/run-carry.ts +24 -1
- package/scripts/run/entry/run-entry-cli.ts +5 -0
- package/scripts/run/event/run-event-checks.ts +54 -0
- package/scripts/run/event/run-event-render.ts +3 -0
- package/scripts/run/event/run-event-scope.ts +1 -0
- package/scripts/run/event/run-event-setup.ts +91 -0
- package/scripts/run/event/run-event-stream.ts +46 -9
- package/scripts/run/merge/run-merge-cli.ts +7 -4
- package/scripts/run/merge/run-merge-ship.ts +61 -0
- package/scripts/run/merge/run-merge-steps.ts +7 -14
- package/scripts/run/merge/run-merge-token.ts +16 -2
- package/scripts/run/run-headless.ts +1 -0
- package/scripts/run/run-step.ts +2 -0
- package/scripts/run/run-stranded-detect.ts +58 -12
- package/scripts/run/ship/run-ship-cli.ts +22 -2
- package/scripts/run/ship/run-ship-return.ts +31 -13
- package/scripts/run/ship/run-ship-review-steps.ts +17 -4
- package/scripts/run/ship/run-ship-stage.ts +1 -0
- package/scripts/run/wake/run-wake-await.ts +130 -0
- package/scripts/run/wake/run-wake-cli.ts +108 -74
- package/scripts/run/wake/run-wake-describe.ts +36 -2
- package/scripts/run/wake/run-wake-driver.ts +21 -5
- package/scripts/run/wake/run-wake-handoff.ts +1 -1
- package/scripts/run/wake/run-wake-session.ts +20 -3
- package/scripts/run/wake/run-wake-stop.ts +37 -0
- package/scripts/run/wake/run-wake-supervise.ts +44 -0
- package/scripts/sync/claude-settings-merge.ts +144 -0
- package/scripts/sync/sync-ai-files.ts +28 -3
- package/scripts/sync/sync.ts +15 -0
- package/scripts/test/project-test-script.ts +73 -0
- package/scripts/test/skip-marker.ts +14 -1
- package/scripts/test/test-related.ts +3 -2
- package/scripts/test/test-unit-guard.ts +78 -14
- package/scripts/time-runtime/time-reported-failure.ts +11 -3
- package/scripts/uninstall/distributed-files.ts +92 -0
- package/scripts/uninstall/uninstall-plan.ts +119 -0
- package/scripts/uninstall/uninstall-report.ts +61 -0
- package/scripts/uninstall/uninstall.ts +206 -0
- package/templates/AGENTS.basic.md +4 -0
- package/templates/GEMINI.basic.md +6 -0
- package/dist/hooks/chunk-6V5OPIUU.js +0 -13
- package/scripts/run/board/run-board-track.ts +0 -22
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { git_gh_api_path } from '#scripts/gh/git-gh-api-path'
|
|
2
|
+
import { git_gh_conditional } from '#scripts/gh/git-gh-conditional'
|
|
3
|
+
import { git_gh_issue_read } from '#scripts/gh/git-gh-issue-read'
|
|
4
|
+
import { git_command } from '#scripts/git/git-command'
|
|
5
|
+
import { PROJECT_ROOT } from '#scripts/init/init-paths'
|
|
6
|
+
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
7
|
+
import { json_value } from '#scripts/lib/json-value'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { issue_watch_diff, type WatchSnapshot } from './issue-watch-diff'
|
|
10
|
+
|
|
11
|
+
// The record of what a run last read of its issue, and the poll that compares the issue against it.
|
|
12
|
+
//
|
|
13
|
+
// **The baseline is written where the run reads the issue** — `josh run:entry`, which prints the body
|
|
14
|
+
// and every comment — so it is the same for a dispatched lane child and a `fullrun` in a person's own
|
|
15
|
+
// checkout, and a resume that prints nothing keeps the baseline it had. One record per checkout,
|
|
16
|
+
// keyed on its root like the review attestation, carries the issue number, so the hook needs no other
|
|
17
|
+
// way to learn which issue the session works on.
|
|
18
|
+
//
|
|
19
|
+
// **A poll is a conditional request.** The issue object's ETag changes with its body and with its
|
|
20
|
+
// comment count, so a 304 there means neither moved and the comments are not read at all.
|
|
21
|
+
|
|
22
|
+
const RECORD_PREFIX = 'josh-issue-watch-'
|
|
23
|
+
const CLOSED_STATE = 'closed'
|
|
24
|
+
|
|
25
|
+
const record_schema = z.object({
|
|
26
|
+
issue: z.string(),
|
|
27
|
+
body: z.string(),
|
|
28
|
+
last_comment_id: z.number(),
|
|
29
|
+
acknowledged_at: z.string(),
|
|
30
|
+
checked_at: z.string(),
|
|
31
|
+
etag: z.string().optional(),
|
|
32
|
+
pending: z.string().optional(),
|
|
33
|
+
shown: z.string().optional(),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const issue_schema = z.looseObject({ body: z.string().nullish(), state: z.string() })
|
|
37
|
+
|
|
38
|
+
type WatchRecord = z.infer<typeof record_schema>
|
|
39
|
+
|
|
40
|
+
type Fetched =
|
|
41
|
+
| { kind: 'unchanged' }
|
|
42
|
+
| { kind: 'unreadable' }
|
|
43
|
+
| { kind: 'closed' }
|
|
44
|
+
| { kind: 'read'; snapshot: WatchSnapshot; etag: string | undefined }
|
|
45
|
+
|
|
46
|
+
const UNREADABLE: Fetched = { kind: 'unreadable' }
|
|
47
|
+
|
|
48
|
+
function record_path(root: string = PROJECT_ROOT): string {
|
|
49
|
+
return stamp_file.stamp_path(RECORD_PREFIX, root)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function read_record(root?: string): WatchRecord | undefined {
|
|
53
|
+
const raw = stamp_file.read_stamp_text(record_path(root))
|
|
54
|
+
|
|
55
|
+
return raw === undefined ? undefined : json_value.parse_with(raw, record_schema)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function write_record(record: WatchRecord, root?: string): WatchRecord {
|
|
59
|
+
stamp_file.replace_stamp(record_path(root), record)
|
|
60
|
+
|
|
61
|
+
return record
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clear(root?: string): void {
|
|
65
|
+
stamp_file.remove_stamp(record_path(root))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface FetchRequest {
|
|
69
|
+
issue: string
|
|
70
|
+
etag?: string | undefined
|
|
71
|
+
timeout_ms?: number | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function read_snapshot(
|
|
75
|
+
request: FetchRequest,
|
|
76
|
+
body: string,
|
|
77
|
+
etag: string | undefined,
|
|
78
|
+
): Promise<Fetched> {
|
|
79
|
+
const comments = issue_watch_diff.parse_comments(
|
|
80
|
+
await git_gh_issue_read.issue_list_comments(request.issue, request.timeout_ms),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return comments === undefined ? UNREADABLE : { kind: 'read', snapshot: { body, comments }, etag }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function fetch_issue(request: FetchRequest): Promise<Fetched> {
|
|
87
|
+
const read = await git_gh_conditional.read_conditional(
|
|
88
|
+
git_gh_api_path.issue_api_path(request.issue),
|
|
89
|
+
request.etag,
|
|
90
|
+
request.timeout_ms,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if (read.kind !== 'changed') return read
|
|
94
|
+
|
|
95
|
+
const fields = json_value.parse_with(read.body, issue_schema)
|
|
96
|
+
|
|
97
|
+
if (fields === undefined) return UNREADABLE
|
|
98
|
+
if (fields.state === CLOSED_STATE) return { kind: 'closed' }
|
|
99
|
+
|
|
100
|
+
return await read_snapshot(request, fields.body ?? '', read.etag)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// `acknowledged_at` is when a person's update was last taken in — the baseline's own read, or an
|
|
104
|
+
// acknowledgement that had something to take in. The review attestation compares it with the brief.
|
|
105
|
+
function fresh_record(
|
|
106
|
+
issue: string,
|
|
107
|
+
fetched: Extract<Fetched, { kind: 'read' }>,
|
|
108
|
+
now: Date,
|
|
109
|
+
acknowledged_at: string = now.toISOString(),
|
|
110
|
+
): WatchRecord {
|
|
111
|
+
const baseline = issue_watch_diff.baseline_of(fetched.snapshot)
|
|
112
|
+
const checked_at = now.toISOString()
|
|
113
|
+
|
|
114
|
+
return { issue, ...baseline, acknowledged_at, checked_at, etag: fetched.etag }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Called by `josh run:entry` once it has printed the issue. `false` when the issue could not be read,
|
|
118
|
+
// which leaves no record — a watch with no baseline would call the whole issue unread.
|
|
119
|
+
async function record_baseline(
|
|
120
|
+
issue: string,
|
|
121
|
+
root?: string,
|
|
122
|
+
now: Date = new Date(),
|
|
123
|
+
): Promise<boolean> {
|
|
124
|
+
const fetched = await fetch_issue({ issue })
|
|
125
|
+
|
|
126
|
+
if (fetched.kind !== 'read') return false
|
|
127
|
+
|
|
128
|
+
write_record(fresh_record(issue, fetched, now), root)
|
|
129
|
+
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The `_here` forms key the record on the repository root rather than `process.cwd()`, the same reason
|
|
134
|
+
// `review_attest.check_here` does: a run started from a subdirectory must find the record it wrote.
|
|
135
|
+
async function record_baseline_here(issue: string): Promise<boolean> {
|
|
136
|
+
return await record_baseline(issue, await git_command.repository_root())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function clear_here(): Promise<void> {
|
|
140
|
+
clear(await git_command.repository_root())
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function with_fetched(record: WatchRecord, fetched: Fetched, checked_at: string): WatchRecord {
|
|
144
|
+
if (fetched.kind !== 'read') return { ...record, checked_at }
|
|
145
|
+
|
|
146
|
+
const pending = issue_watch_diff.unread_diff(record, fetched.snapshot)
|
|
147
|
+
|
|
148
|
+
return { ...record, checked_at, etag: fetched.etag, pending }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface PollOptions {
|
|
152
|
+
interval_ms: number
|
|
153
|
+
timeout_ms?: number
|
|
154
|
+
now?: Date
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function is_due(record: WatchRecord, options: PollOptions, now: Date): boolean {
|
|
158
|
+
return now.getTime() - Date.parse(record.checked_at) >= options.interval_ms
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function refresh(
|
|
162
|
+
record: WatchRecord,
|
|
163
|
+
options: PollOptions,
|
|
164
|
+
now: Date,
|
|
165
|
+
root?: string,
|
|
166
|
+
): Promise<WatchRecord | undefined> {
|
|
167
|
+
const fetched = await fetch_issue({
|
|
168
|
+
issue: record.issue,
|
|
169
|
+
etag: record.etag,
|
|
170
|
+
timeout_ms: options.timeout_ms,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (fetched.kind === 'closed') {
|
|
174
|
+
clear(root)
|
|
175
|
+
|
|
176
|
+
return undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return write_record(with_fetched(record, fetched, now.toISOString()), root)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The checkout's record, polled when the last poll is at least `interval_ms` old. `undefined` when no
|
|
183
|
+
// run recorded a baseline here, or the issue has closed — a closed issue ends its watch.
|
|
184
|
+
async function poll(options: PollOptions, root?: string): Promise<WatchRecord | undefined> {
|
|
185
|
+
const record = read_record(root)
|
|
186
|
+
const now = options.now ?? new Date()
|
|
187
|
+
|
|
188
|
+
if (record === undefined || !is_due(record, options, now)) return record
|
|
189
|
+
|
|
190
|
+
return await refresh(record, options, now, root)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Marks a pending update shown, so the hook stops once per update rather than on every call.
|
|
194
|
+
function mark_shown(record: WatchRecord, root?: string): WatchRecord {
|
|
195
|
+
return write_record({ ...record, shown: record.pending }, root)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface Acknowledged {
|
|
199
|
+
issue: string
|
|
200
|
+
diff: string | undefined
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// `josh issue:ack`: read the issue as it is now, return what changed since the baseline, and make
|
|
204
|
+
// that the new baseline. **What is returned is exactly what is acknowledged** — the read and the new
|
|
205
|
+
// baseline are the same snapshot, so an update landing between the two cannot be acknowledged unseen.
|
|
206
|
+
async function acknowledge(
|
|
207
|
+
root?: string,
|
|
208
|
+
now: Date = new Date(),
|
|
209
|
+
): Promise<Acknowledged | undefined> {
|
|
210
|
+
const record = read_record(root)
|
|
211
|
+
|
|
212
|
+
if (record === undefined) return undefined
|
|
213
|
+
|
|
214
|
+
const fetched = await fetch_issue({ issue: record.issue })
|
|
215
|
+
|
|
216
|
+
if (fetched.kind !== 'read') return undefined
|
|
217
|
+
|
|
218
|
+
const diff = issue_watch_diff.unread_diff(record, fetched.snapshot)
|
|
219
|
+
|
|
220
|
+
const acknowledged_at = diff === undefined ? record.acknowledged_at : now.toISOString()
|
|
221
|
+
|
|
222
|
+
write_record(fresh_record(record.issue, fetched, now, acknowledged_at), root)
|
|
223
|
+
|
|
224
|
+
return { issue: record.issue, diff }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const issue_watch = {
|
|
228
|
+
acknowledge,
|
|
229
|
+
clear,
|
|
230
|
+
clear_here,
|
|
231
|
+
mark_shown,
|
|
232
|
+
poll,
|
|
233
|
+
read_record,
|
|
234
|
+
record_baseline,
|
|
235
|
+
record_baseline_here,
|
|
236
|
+
record_path,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type { Acknowledged, PollOptions, WatchRecord }
|
|
240
|
+
export { issue_watch }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { execSync } from 'node:child_process'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { gh_author_association } from '#scripts/gh/gh-author-association'
|
|
4
5
|
import { git_gh_issue_read } from '#scripts/gh/git-gh-issue-read'
|
|
5
6
|
import { parse_json_object_or_undefined } from '#scripts/git/parse-json-array'
|
|
6
7
|
import { issue_number_shape } from '#scripts/issue/issue-number-shape'
|
|
@@ -24,8 +25,6 @@ import { session_cite } from './session-cite'
|
|
|
24
25
|
const FAILURE_EXIT_CODE = 1
|
|
25
26
|
const ARGV_OFFSET = 2
|
|
26
27
|
const ISSUE_FIELDS = 'body,author_association'
|
|
27
|
-
const TRUSTED_ASSOCIATIONS: ReadonlySet<string> = new Set(['OWNER', 'MEMBER', 'COLLABORATOR'])
|
|
28
|
-
const TRUSTED_LIST = [...TRUSTED_ASSOCIATIONS].join(' / ')
|
|
29
28
|
const UNKNOWN_ASSOCIATION = 'unknown'
|
|
30
29
|
const USAGE = 'Usage: josh measure:rerun <issue-number>'
|
|
31
30
|
const NO_BASELINE = 'no `command → value` baseline found in ## ベースライン — nothing to re-run'
|
|
@@ -100,10 +99,10 @@ async function read_trusted_body(issue_number: string): Promise<BodyRead> {
|
|
|
100
99
|
|
|
101
100
|
const association = issue.author_association ?? UNKNOWN_ASSOCIATION
|
|
102
101
|
|
|
103
|
-
if (!
|
|
102
|
+
if (!gh_author_association.is_trusted(association)) {
|
|
104
103
|
return {
|
|
105
104
|
kind: 'refused',
|
|
106
|
-
reason: `refusing to run the baseline of ${session_cite.issue(issue_number)}: its author is ${association}, not ${TRUSTED_LIST}`,
|
|
105
|
+
reason: `refusing to run the baseline of ${session_cite.issue(issue_number)}: its author is ${association}, not ${gh_author_association.TRUSTED_LIST}`,
|
|
107
106
|
}
|
|
108
107
|
}
|
|
109
108
|
|
|
@@ -54,6 +54,7 @@ const ALIASES: Record<string, string> = {
|
|
|
54
54
|
pf: 'profile',
|
|
55
55
|
st: 'start',
|
|
56
56
|
sy: 'sync',
|
|
57
|
+
un: 'uninstall',
|
|
57
58
|
rmi: 'registry:migrate',
|
|
58
59
|
g: 'git',
|
|
59
60
|
gp: 'pr',
|
|
@@ -79,9 +80,12 @@ const ALIASES: Record<string, string> = {
|
|
|
79
80
|
rvf: 'review:findings',
|
|
80
81
|
ruv: 'rule:value',
|
|
81
82
|
ev: 'eval',
|
|
83
|
+
fr: 'fresh:run',
|
|
84
|
+
as: 'auth:setup',
|
|
82
85
|
lsm: 'lane:sample',
|
|
83
86
|
lst: 'lane:stats',
|
|
84
87
|
lli: 'lane:limit',
|
|
88
|
+
lrs: 'lane:restart',
|
|
85
89
|
blr: 'backlogrun',
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -11,10 +11,10 @@ const BACKLOG_COMMANDS: Record<string, CommandEntry> = {
|
|
|
11
11
|
backlogrun: {
|
|
12
12
|
script: 'scripts/backlog/backlogrun-cli.ts',
|
|
13
13
|
description:
|
|
14
|
-
'Start a backlogrun in the background and show its board; with one already running, only show the board',
|
|
14
|
+
'Start a backlogrun in the background and show its board; with one already running, only show the board, or replace it when it is idle and the invocation differs; --stop stops it',
|
|
15
15
|
category: 'AI tools',
|
|
16
16
|
reference: [
|
|
17
|
-
'[
|
|
17
|
+
'[<n>...] [--only] [--max <n>] [--idle <minutes>] [--agent claude|codex] | --stop',
|
|
18
18
|
'developer',
|
|
19
19
|
['files', 'network', 'processes'],
|
|
20
20
|
],
|
|
@@ -181,7 +181,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
181
181
|
'test:unit': {
|
|
182
182
|
script: 'scripts/test/test-unit-guard.ts',
|
|
183
183
|
description:
|
|
184
|
-
'Run unit tests with Vitest (
|
|
184
|
+
'Run unit tests with Vitest (without Vitest: a basic project runs its own test script, others skip; fails when Vitest has no tests)',
|
|
185
185
|
category: 'Development',
|
|
186
186
|
reference: [FILTER_ARGUMENTS, 'developer', ['processes']],
|
|
187
187
|
core_weight: gate_plan.direct_unit_weight,
|
|
@@ -11,6 +11,13 @@ const ISSUES_WITH_REPO = '<issue...> [--repo <owner/repo>]'
|
|
|
11
11
|
|
|
12
12
|
/* eslint-disable @typescript-eslint/naming-convention */
|
|
13
13
|
const ISSUE_COMMANDS: Record<string, CommandEntry> = {
|
|
14
|
+
'issue:ack': {
|
|
15
|
+
script: 'scripts/issue/issue-ack-cli.ts',
|
|
16
|
+
description:
|
|
17
|
+
"Record that the run read a person's update to its issue, printing the update it acknowledges",
|
|
18
|
+
category: 'AI tools',
|
|
19
|
+
reference: ['<issue>', 'automation', ['network']],
|
|
20
|
+
},
|
|
14
21
|
'issue:read': {
|
|
15
22
|
script: 'scripts/issue/issue-read-cli.ts',
|
|
16
23
|
description: "Print each issue's title, body and every comment on it, in one call",
|
|
@@ -40,7 +40,7 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
|
|
|
40
40
|
'lane:prune': {
|
|
41
41
|
...LANE_ARGUMENTS,
|
|
42
42
|
description:
|
|
43
|
-
'Close every lane an interruption left without its work tree, then sweep unregistered leftovers under the lanes root',
|
|
43
|
+
'Close every lane an interruption left without its work tree and every spent lane, then sweep unregistered leftovers under the lanes root',
|
|
44
44
|
category: 'AI tools',
|
|
45
45
|
reference: ['', 'automation', ['files', 'git']],
|
|
46
46
|
default_script_arguments: ['prune'],
|
|
@@ -74,6 +74,15 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
|
|
|
74
74
|
category: 'AI tools',
|
|
75
75
|
reference: ['<issue> [--stash <message>]', 'automation', ['files', 'git', 'processes']],
|
|
76
76
|
},
|
|
77
|
+
// `.env` is read for `JOSH_LANE_ROOT`, as every lane command reads it, so the lane is found.
|
|
78
|
+
'lane:restart': {
|
|
79
|
+
script: 'scripts/lane/lane-restart-cli.ts',
|
|
80
|
+
tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
|
|
81
|
+
description:
|
|
82
|
+
'Stop a running lane child, stash its uncommitted work and re-dispatch its issue in a fresh session — not counted as an outage or a failure',
|
|
83
|
+
category: 'AI tools',
|
|
84
|
+
reference: ['<issue>', 'developer', ['files', 'git', 'processes', 'network']],
|
|
85
|
+
},
|
|
77
86
|
// A live run's lane limit: `.env` is read so the bare form reports the
|
|
78
87
|
// `JOSH_LANE_LIMIT` a person set there when no override stands.
|
|
79
88
|
'lane:limit': {
|
|
@@ -97,7 +106,7 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
|
|
|
97
106
|
'lane:stats': {
|
|
98
107
|
...MEASURE_ARGUMENTS,
|
|
99
108
|
description:
|
|
100
|
-
'Print one table row of lane throughput, gate duration and machine load over a period,
|
|
109
|
+
'Print one table row of lane throughput, gate duration and machine load over a period, the per-stage durations of a lane under it, and the wait after each ship stop reason under those',
|
|
101
110
|
category: 'AI tools',
|
|
102
111
|
reference: ['--period <days> [--limit <lane-limit>]', 'maintainer', ['none']],
|
|
103
112
|
default_script_arguments: ['stats'],
|
|
@@ -17,7 +17,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
|
|
|
17
17
|
description: 'Set a project up for the GitHub Issue workflow, from git init to the setup PR',
|
|
18
18
|
category: 'Project',
|
|
19
19
|
reference: [
|
|
20
|
-
`${PROFILE_ARGUMENTS} [--yes] [--github] [--public] [--init-command <command>]`,
|
|
20
|
+
`${PROFILE_ARGUMENTS} [--yes] [--github] [--public] [--init-command <command>] [--push-preflight-command <command>]`,
|
|
21
21
|
'developer',
|
|
22
22
|
['files', 'git', 'network', 'processes'],
|
|
23
23
|
],
|
|
@@ -29,6 +29,24 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
|
|
|
29
29
|
reference: ['<dir>', 'automation', ['files', 'git']],
|
|
30
30
|
is_kit_only: true,
|
|
31
31
|
},
|
|
32
|
+
'fresh:run': {
|
|
33
|
+
script: 'scripts/fresh-machine/fresh-machine-cli.ts',
|
|
34
|
+
description:
|
|
35
|
+
'Run an install scenario in a Docker container of a machine with nothing installed, and keep the log',
|
|
36
|
+
category: 'Project',
|
|
37
|
+
reference: [
|
|
38
|
+
'<pattern> | --spec <file.json> [--package-version <version>] [--log-dir <dir>] [--rebuild] | --list',
|
|
39
|
+
'maintainer',
|
|
40
|
+
['files', 'network', 'processes'],
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
'auth:setup': {
|
|
44
|
+
script: 'scripts/auth/auth-setup-cli.ts',
|
|
45
|
+
description:
|
|
46
|
+
'Save the long-lived Claude Code token `claude setup-token` prints in the macOS keychain, for josh fresh:run',
|
|
47
|
+
category: 'Project',
|
|
48
|
+
reference: ['[--check]', 'maintainer', ['network', 'processes']],
|
|
49
|
+
},
|
|
32
50
|
init: {
|
|
33
51
|
script: 'scripts/init/init.ts',
|
|
34
52
|
description: 'Initialize config in a new project',
|
|
@@ -45,6 +63,12 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
|
|
|
45
63
|
category: 'Project',
|
|
46
64
|
reference: ['', 'developer', ['files']],
|
|
47
65
|
},
|
|
66
|
+
uninstall: {
|
|
67
|
+
script: 'scripts/uninstall/uninstall.ts',
|
|
68
|
+
description: 'Remove what kit added to a project and list what is left to review by hand',
|
|
69
|
+
category: 'Project',
|
|
70
|
+
reference: [`${PROFILE_ARGUMENTS} [--yes]`, 'developer', ['files', 'processes']],
|
|
71
|
+
},
|
|
48
72
|
'registry:migrate': {
|
|
49
73
|
script: 'scripts/registry-migration/migrate.ts',
|
|
50
74
|
description: 'Migrate a project from GitHub Packages to public npm',
|
|
@@ -35,7 +35,7 @@ import { status_icons } from '#scripts/lib/status-icons'
|
|
|
35
35
|
// gate's plan line is what says "the gate starts here", and it is single-sourced for the reason the
|
|
36
36
|
// verdict is: the printer builds it and the detector matches it from one place.
|
|
37
37
|
|
|
38
|
-
const { FAIL_ICON, PASS_ICON } = status_icons
|
|
38
|
+
const { FAIL_ICON, PASS_ICON, WARN_ICON } = status_icons
|
|
39
39
|
const GATE_SUBJECT = 'verification gate'
|
|
40
40
|
|
|
41
41
|
// The prefixes are what the detector matches **and** what the printer builds its line from, so a
|
|
@@ -43,16 +43,41 @@ const GATE_SUBJECT = 'verification gate'
|
|
|
43
43
|
// this module exists to prevent.
|
|
44
44
|
const GATE_PASSED_PREFIX = `${PASS_ICON} ${GATE_SUBJECT} passed`
|
|
45
45
|
const GATE_FAILED_PREFIX = `${FAIL_ICON} ${GATE_SUBJECT} failed`
|
|
46
|
+
// **A gate that ran no check has not passed, and it has not failed either.** Every check of a `basic`
|
|
47
|
+
// project with nothing to check exits 0 with a skip notice, which is a legitimate state — the exit code
|
|
48
|
+
// stays 0, or such a project could never commit — but a line reading `passed` made it indistinguishable
|
|
49
|
+
// from a gate that ran every check. It opens with neither icon and neither word, so no reader of the
|
|
50
|
+
// other two prefixes can take it for one of them.
|
|
51
|
+
const GATE_UNVERIFIED_PREFIX = `${WARN_ICON} ${GATE_SUBJECT} not verified`
|
|
46
52
|
|
|
47
|
-
type JoshVerdict = 'passed' | 'failed'
|
|
53
|
+
type JoshVerdict = 'passed' | 'failed' | 'unverified'
|
|
48
54
|
|
|
49
55
|
// Typed rather than left to inference: the namespace object widens a bare literal to `string`, and a
|
|
50
56
|
// caller comparing `read_verdict(line)` against it would then be comparing two unrelated types.
|
|
51
57
|
const PASSED_VERDICT: JoshVerdict = 'passed'
|
|
52
58
|
const FAILED_VERDICT: JoshVerdict = 'failed'
|
|
59
|
+
const UNVERIFIED_VERDICT: JoshVerdict = 'unverified'
|
|
53
60
|
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
// How many of a gate's checks ran, and how many passed without running.
|
|
62
|
+
interface GateCounts {
|
|
63
|
+
ran: number
|
|
64
|
+
skipped: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The split is printed only when there is one. A gate that skipped nothing — kit's own, and an ordinary
|
|
68
|
+
// consumer's — keeps the line it always printed.
|
|
69
|
+
function format_counts(counts: GateCounts): string {
|
|
70
|
+
if (counts.skipped === 0) return `${String(counts.ran)} checks`
|
|
71
|
+
|
|
72
|
+
return `${String(counts.ran)} ran, ${String(counts.skipped)} skipped`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function format_gate_passed(counts: GateCounts, total: string): string {
|
|
76
|
+
return `${GATE_PASSED_PREFIX} (${format_counts(counts)}) in ${total}.`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function format_gate_unverified(skipped: number, total: string): string {
|
|
80
|
+
return `${GATE_UNVERIFIED_PREFIX} (0 ran, ${String(skipped)} skipped) in ${total}.`
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
function format_gate_failed(failed_labels: string, total: string): string {
|
|
@@ -91,14 +116,18 @@ function read_verdict(line: string): JoshVerdict | undefined {
|
|
|
91
116
|
|
|
92
117
|
if (trimmed.startsWith(GATE_PASSED_PREFIX)) return PASSED_VERDICT
|
|
93
118
|
|
|
119
|
+
if (trimmed.startsWith(GATE_UNVERIFIED_PREFIX)) return UNVERIFIED_VERDICT
|
|
120
|
+
|
|
94
121
|
return undefined
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
const josh_verdict = {
|
|
98
125
|
PASSED_VERDICT,
|
|
99
126
|
FAILED_VERDICT,
|
|
127
|
+
UNVERIFIED_VERDICT,
|
|
100
128
|
format_gate_passed,
|
|
101
129
|
format_gate_failed,
|
|
130
|
+
format_gate_unverified,
|
|
102
131
|
GATE_OPENING_MARK,
|
|
103
132
|
GATE_OPENING_PREFIX,
|
|
104
133
|
is_gate_opening,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { error_text } from '#scripts/lib/error-message'
|
|
2
|
+
import { lane_registry, type LaneInfo } from './lane-registry'
|
|
3
|
+
import { lane_relaunch } from './lane-relaunch'
|
|
4
|
+
import { lane_session_broker } from './lane-session-broker'
|
|
5
|
+
|
|
6
|
+
// The lane broker for every open Claude lane, run inside the `run:wake` supervisor for as long as the
|
|
7
|
+
// supervisor runs, so nobody has to start or stop it.
|
|
8
|
+
//
|
|
9
|
+
// **It lives in the supervisor because the supervisor is what holds the token.** `josh backlogrun`
|
|
10
|
+
// starts it from the terminal (joshuafolkken/kit#3804), so every session it starts can sign in with a
|
|
11
|
+
// token-only credential. A lane child's detached `josh ship` cannot: it descends from the child's Bash
|
|
12
|
+
// tool, which Claude Code starts without the token (joshuafolkken/kit#3817). Its reviewer and its relaunch
|
|
13
|
+
// are asked of this broker instead (`lane-session-broker.ts`).
|
|
14
|
+
//
|
|
15
|
+
// **Not inside one `backlog:drive`**: the supervisor runs a drive per pass, and a lane's ship reaches its
|
|
16
|
+
// review whenever it does — while the drive has handed back to a session too, when no drive runs.
|
|
17
|
+
//
|
|
18
|
+
// An OpenAI lane is left to its own supervisor, which serves the same broker for that lane's lifetime;
|
|
19
|
+
// two servers for one lane would overwrite each other's response.
|
|
20
|
+
|
|
21
|
+
const SCAN_INTERVAL_MS = 5000
|
|
22
|
+
|
|
23
|
+
type ListLanes = () => Promise<ReadonlyArray<LaneInfo>>
|
|
24
|
+
|
|
25
|
+
interface Served {
|
|
26
|
+
stop: () => void
|
|
27
|
+
done: Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function is_served(lane: LaneInfo): boolean {
|
|
31
|
+
return !lane.is_stranded && !lane_relaunch.is_openai_lane(lane)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// One lane's server, held open until `stop` — a lane that could not be served is traced, never thrown,
|
|
35
|
+
// so it costs the supervisor nothing but that lane's broker.
|
|
36
|
+
async function hold_server(lane: LaneInfo, held: Promise<void>): Promise<void> {
|
|
37
|
+
try {
|
|
38
|
+
await lane_session_broker.with_server(lane, async () => {
|
|
39
|
+
await held
|
|
40
|
+
})
|
|
41
|
+
} catch (error) {
|
|
42
|
+
error_text.trace_swallowed('claude_lane_broker.hold_server', error)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function serve_lane(lane: LaneInfo): Served {
|
|
47
|
+
const release = { stop: (): void => undefined }
|
|
48
|
+
const held = new Promise<void>((resolve) => {
|
|
49
|
+
release.stop = resolve
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return { stop: release.stop, done: hold_server(lane, held) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// **A closed lane's entry stays until its server has finished.** A server still answering a reviewer
|
|
56
|
+
// keeps writing its response, so a server opened beside it for a re-listed lane would have the two
|
|
57
|
+
// overwrite each other — the lane is reopened only by the scan after the old one is gone.
|
|
58
|
+
async function forget_when_done(
|
|
59
|
+
served: Map<string, Served>,
|
|
60
|
+
issue: string,
|
|
61
|
+
entry: Served,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
await entry.done
|
|
64
|
+
if (served.get(issue) === entry) served.delete(issue)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function close_gone(served: Map<string, Served>, open: ReadonlySet<string>): void {
|
|
68
|
+
for (const [issue, entry] of served) {
|
|
69
|
+
if (open.has(issue)) {
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
entry.stop()
|
|
74
|
+
void forget_when_done(served, issue, entry)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function open_new(served: Map<string, Served>, lanes: ReadonlyArray<LaneInfo>): void {
|
|
79
|
+
for (const lane of lanes) {
|
|
80
|
+
if (!served.has(lane.issue)) served.set(lane.issue, serve_lane(lane))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function scan(served: Map<string, Served>, list: ListLanes): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
const listed = await list()
|
|
87
|
+
const lanes = listed.filter((lane) => is_served(lane))
|
|
88
|
+
|
|
89
|
+
close_gone(served, new Set(lanes.map((lane) => lane.issue)))
|
|
90
|
+
open_new(served, lanes)
|
|
91
|
+
} catch (error) {
|
|
92
|
+
error_text.trace_swallowed('claude_lane_broker.scan', error)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Each scan starts only once the previous one has finished, so two never race over the same map.
|
|
97
|
+
async function scan_after(
|
|
98
|
+
previous: Promise<void>,
|
|
99
|
+
served: Map<string, Served>,
|
|
100
|
+
list: ListLanes,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
await previous
|
|
103
|
+
await scan(served, list)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Starts serving now and on every interval; the returned stop closes every server and waits for them.
|
|
107
|
+
function start(list: ListLanes = lane_registry.list_lanes): () => Promise<void> {
|
|
108
|
+
const served = new Map<string, Served>()
|
|
109
|
+
const state = { scanning: scan(served, list) }
|
|
110
|
+
const timer = setInterval(() => {
|
|
111
|
+
state.scanning = scan_after(state.scanning, served, list)
|
|
112
|
+
}, SCAN_INTERVAL_MS).unref()
|
|
113
|
+
|
|
114
|
+
return async () => {
|
|
115
|
+
clearInterval(timer)
|
|
116
|
+
await state.scanning
|
|
117
|
+
close_gone(served, new Set())
|
|
118
|
+
await Promise.all(
|
|
119
|
+
[...served.values()].map(async (entry) => {
|
|
120
|
+
await entry.done
|
|
121
|
+
}),
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Serves every open Claude lane for exactly as long as `run` runs.
|
|
127
|
+
async function serve_during<T>(
|
|
128
|
+
run: () => Promise<T>,
|
|
129
|
+
list: ListLanes = lane_registry.list_lanes,
|
|
130
|
+
): Promise<T> {
|
|
131
|
+
const stop = start(list)
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
return await run()
|
|
135
|
+
} finally {
|
|
136
|
+
await stop()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const claude_lane_broker = { SCAN_INTERVAL_MS, serve_during, start }
|
|
141
|
+
|
|
142
|
+
export { claude_lane_broker }
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from '#scripts/run/run-liveness'
|
|
13
13
|
import { lane_child_invocation } from './lane-child-invocation'
|
|
14
14
|
import { lane_handoff } from './lane-handoff'
|
|
15
|
+
import { process_tools } from './process-tools'
|
|
15
16
|
|
|
16
17
|
// Detached lane children are not harness-tracked processes, so their completion fires no
|
|
17
18
|
// re-invocation event in the parent's session. Without a blocking wait the parent notices only
|
|
@@ -30,6 +31,7 @@ const NEVER_APPEARED_TIMEOUT_MS = 600_000
|
|
|
30
31
|
const MS_PER_SECOND = 1000
|
|
31
32
|
const PROCESS_FOUND = 0
|
|
32
33
|
const PROCESS_NOT_FOUND = 1
|
|
34
|
+
const PGREP_COMMAND = 'pgrep'
|
|
33
35
|
|
|
34
36
|
// `is_settled` is read once, when the wait starts: a child that ended between two `lane:await` calls
|
|
35
37
|
// never appears in the second one, and only its settled issue tells it apart from one not yet
|
|
@@ -77,14 +79,16 @@ interface RunConfig extends CheckConfig {
|
|
|
77
79
|
|
|
78
80
|
// Three answers, not two: only pgrep's own "no match" exit is `none`. A pgrep that failed or hit its
|
|
79
81
|
// timeout never looked, and `run:liveness` answers `undetermined` for it rather than booking a live
|
|
80
|
-
// child as stopped.
|
|
82
|
+
// child as stopped. A pgrep that is not installed could never look, so it is refused by name.
|
|
81
83
|
function process_trace_default(issue: string): ProcessTrace {
|
|
82
84
|
const pattern = lane_child_invocation.process_pattern(issue)
|
|
83
|
-
const result = spawnSync(
|
|
85
|
+
const result = spawnSync(PGREP_COMMAND, ['-f', pattern], {
|
|
84
86
|
encoding: 'utf8',
|
|
85
87
|
timeout: PROBE_TIMEOUT_MS,
|
|
86
88
|
})
|
|
87
89
|
|
|
90
|
+
process_tools.throw_if_missing(PGREP_COMMAND, result.error)
|
|
91
|
+
|
|
88
92
|
if (result.status === PROCESS_FOUND) return PROCESS_ALIVE
|
|
89
93
|
if (lane_handoff.is_ship_running(issue, process.cwd())) return PROCESS_ALIVE
|
|
90
94
|
|