@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
package/prompts/review.md
CHANGED
|
@@ -27,6 +27,28 @@ is a verification pass over the fixes, not the first review again
|
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
+
## Before round 1 — the implementer's self-check
|
|
31
|
+
|
|
32
|
+
**Answer these five over your own diff before the scoped pair that precedes round 1.** A round-1
|
|
33
|
+
High/Medium makes round 2 due, and most of them are of a kind the implementer clears more cheaply
|
|
34
|
+
than a reviewer finds: in a sample of 135 round-1 High/Medium findings, about two in three fell under one of these questions. Fix what an answer turns up; the review, its
|
|
35
|
+
severity tests and the round cap are unchanged.
|
|
36
|
+
|
|
37
|
+
1. **Tests pin the change** — for each condition, branch or call you added or changed, name the test
|
|
38
|
+
that turns red when it is deleted or inverted, and check its fixture actually reaches that branch
|
|
39
|
+
(no assert on a string the code never draws, no comparison against a literal copy).
|
|
40
|
+
2. **Prose matches the code** — grep the docs, prompts and comments for the old wording of every
|
|
41
|
+
behavior, constant, command and heading you changed, moved or removed, `"the table above"` and
|
|
42
|
+
rewritten sentences included.
|
|
43
|
+
3. **Failure is not a negative answer** — every timeout, non-zero exit or `gh` failure on a path you
|
|
44
|
+
touched returns something a caller can tell apart from `false`, `undefined` or "nothing found".
|
|
45
|
+
4. **Every caller moved with it** — grep every reference to a function, list or command you changed,
|
|
46
|
+
and update each sibling path (the other CLI, the registry, the consumer side) in the same change.
|
|
47
|
+
5. **The Issue is met on HEAD** — the premises you relied on hold on the current tree, and each
|
|
48
|
+
acceptance criterion is checked off one by one against the diff.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
30
52
|
## Review level (decided by `pnpm josh review:brief --level-only`, never by judgement)
|
|
31
53
|
|
|
32
54
|
**Run `pnpm josh review:brief --level-only` and use what it prints** — `low` or `medium`, from the
|
|
@@ -8,7 +8,19 @@ import {
|
|
|
8
8
|
} from './agent-role-profile'
|
|
9
9
|
|
|
10
10
|
const AGENT_COMMAND = 'claude'
|
|
11
|
-
|
|
11
|
+
// **The permission mode is passed, not left to the checkout's `.claude/settings.json`.** Claude Code
|
|
12
|
+
// ignores a project's `defaultMode: bypassPermissions` (since v2.1.257) and its `permissions.allow` in
|
|
13
|
+
// a workspace whose trust dialog was never accepted — every fresh clone and every new lane worktree — so
|
|
14
|
+
// a headless child stopped on approvals nobody could give. The flag sets the mode that file declares;
|
|
15
|
+
// its `deny` list stays in effect (#3796).
|
|
16
|
+
const PERMISSION_FLAGS: ReadonlyArray<string> = ['--permission-mode', 'bypassPermissions']
|
|
17
|
+
const AGENT_FLAGS: ReadonlyArray<string> = [
|
|
18
|
+
'-p',
|
|
19
|
+
'--verbose',
|
|
20
|
+
'--output-format',
|
|
21
|
+
'stream-json',
|
|
22
|
+
...PERMISSION_FLAGS,
|
|
23
|
+
]
|
|
12
24
|
const MODEL_FLAG = '--model'
|
|
13
25
|
const EFFORT_FLAG = '--effort'
|
|
14
26
|
// Resumes a stored session by id. An `outage` re-dispatch passes the
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { execa } from 'execa'
|
|
5
|
+
import { claude_token } from './claude-token'
|
|
6
|
+
import { claude_token_output } from './claude-token-output'
|
|
7
|
+
|
|
8
|
+
// `josh auth:setup` — run `claude setup-token` and keep the token it prints in the macOS keychain,
|
|
9
|
+
// where `josh fresh:run` reads it. The token reaches neither the screen nor a file: it is replaced in
|
|
10
|
+
// the command's output. `--check` answers whether one is saved.
|
|
11
|
+
|
|
12
|
+
const ARGV_OFFSET = 2
|
|
13
|
+
const FAILURE_EXIT_CODE = 1
|
|
14
|
+
const CLAUDE = 'claude'
|
|
15
|
+
const SAVED = `saved: ${claude_token.VARIABLE} (macOS keychain)`
|
|
16
|
+
const NO_TOKEN = 'claude setup-token printed no token, so nothing was saved.'
|
|
17
|
+
const NO_KEYCHAIN = `This machine has no macOS keychain, so nothing was run. Run \`claude setup-token\` yourself and export ${claude_token.VARIABLE} — josh fresh:run reads it when the keychain has none.`
|
|
18
|
+
const NOT_SAVED = 'The keychain did not keep the token. Run `pnpm josh auth:setup` again.'
|
|
19
|
+
|
|
20
|
+
interface Printed {
|
|
21
|
+
decoder: TextDecoder
|
|
22
|
+
text: string
|
|
23
|
+
shown: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function refuse(message: string): number {
|
|
27
|
+
console.error(message)
|
|
28
|
+
|
|
29
|
+
return FAILURE_EXIT_CODE
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function check(): number {
|
|
33
|
+
const date = claude_token.saved_date()
|
|
34
|
+
|
|
35
|
+
if (date === undefined) {
|
|
36
|
+
return refuse(`missing: no ${claude_token.VARIABLE} saved. ${claude_token.SETUP_HINT}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.info(`ok: ${claude_token.VARIABLE} saved on ${date} (macOS keychain)`)
|
|
40
|
+
|
|
41
|
+
return 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function store(token: string | undefined): number {
|
|
45
|
+
if (token === undefined) return refuse(NO_TOKEN)
|
|
46
|
+
|
|
47
|
+
if (!claude_token.save(token)) return refuse(NOT_SAVED)
|
|
48
|
+
|
|
49
|
+
console.info(SAVED)
|
|
50
|
+
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The sign-in URL and any prompt are shown as they arrive; from the token on, the output is held
|
|
55
|
+
// back and shown once claude ends, with the token replaced.
|
|
56
|
+
function show_live(printed: Printed, chunk: Uint8Array): void {
|
|
57
|
+
printed.text += printed.decoder.decode(chunk, { stream: true })
|
|
58
|
+
const live = claude_token_output.live_length(printed.text)
|
|
59
|
+
|
|
60
|
+
process.stdout.write(printed.text.slice(printed.shown, live))
|
|
61
|
+
printed.shown = live
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The browser sign-in needs the terminal's stdin, so only stdout passes through josh.
|
|
65
|
+
async function setup(): Promise<number> {
|
|
66
|
+
if (!claude_token.has_keychain()) return refuse(NO_KEYCHAIN)
|
|
67
|
+
|
|
68
|
+
const printed: Printed = { decoder: new TextDecoder(), text: '', shown: 0 }
|
|
69
|
+
const child = execa(CLAUDE, ['setup-token'], {
|
|
70
|
+
stdin: 'inherit',
|
|
71
|
+
stderr: 'inherit',
|
|
72
|
+
reject: false,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
child.stdout.on('data', (chunk: Uint8Array) => {
|
|
76
|
+
show_live(printed, chunk)
|
|
77
|
+
})
|
|
78
|
+
const result = await child
|
|
79
|
+
const held = printed.text.slice(printed.shown) + printed.decoder.decode()
|
|
80
|
+
const { token, shown } = claude_token_output.capture(held)
|
|
81
|
+
|
|
82
|
+
process.stdout.write(shown)
|
|
83
|
+
|
|
84
|
+
if (result.failed) return refuse(`claude setup-token failed: ${result.shortMessage ?? CLAUDE}`)
|
|
85
|
+
|
|
86
|
+
return store(token)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
90
|
+
const { values } = parseArgs({ args: [...argv], options: { check: { type: 'boolean' } } })
|
|
91
|
+
|
|
92
|
+
return values.check === true ? check() : await setup()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const auth_setup_cli = { run }
|
|
96
|
+
|
|
97
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
98
|
+
process.exitCode = await run(process.argv.slice(ARGV_OFFSET))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { auth_setup_cli }
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util'
|
|
2
|
+
|
|
3
|
+
// What `claude setup-token` printed, split into the token and the text that may be shown: the token
|
|
4
|
+
// is replaced, so it reaches neither the screen nor a log.
|
|
5
|
+
|
|
6
|
+
const REDACTED = '[REDACTED]'
|
|
7
|
+
const TOKEN_START = 'sk-ant-oat'
|
|
8
|
+
const TOKEN_PATTERN = /sk-ant-oat\d+-[\w-]+/u
|
|
9
|
+
const EVERY_TOKEN_PATTERN = new RegExp(TOKEN_PATTERN.source, 'gu')
|
|
10
|
+
// A narrow terminal wraps the token onto lines of its own characters alone, so a continuation is a
|
|
11
|
+
// whole such line and the prose after the token never joins it.
|
|
12
|
+
const CONTINUATION_PATTERN = /^[\w-]+$/u
|
|
13
|
+
const LINE_BREAK_PATTERN = /\r?\n/u
|
|
14
|
+
|
|
15
|
+
interface Captured {
|
|
16
|
+
token: string | undefined
|
|
17
|
+
shown: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A line that starts a token is another copy of it, never the tail of the one before.
|
|
21
|
+
function is_continuation(line: string): boolean {
|
|
22
|
+
return CONTINUATION_PATTERN.test(line) && !line.startsWith(TOKEN_START)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function continuation(lines: ReadonlyArray<string>): Array<string> {
|
|
26
|
+
const length = lines.findIndex((line) => !is_continuation(line.trim()))
|
|
27
|
+
|
|
28
|
+
return (length === -1 ? lines : lines.slice(0, length)).map((line) => line.trim())
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Every copy of the token is replaced, its wrapped tail with it: Ink may draw it in more than one frame.
|
|
32
|
+
function redact(lines: ReadonlyArray<string>): Array<string> {
|
|
33
|
+
const start = lines.findIndex((line) => TOKEN_PATTERN.test(line))
|
|
34
|
+
|
|
35
|
+
if (start === -1) return [...lines]
|
|
36
|
+
|
|
37
|
+
const [head = '', ...rest] = lines.slice(start)
|
|
38
|
+
const remaining = rest.slice(continuation(rest).length)
|
|
39
|
+
|
|
40
|
+
return [
|
|
41
|
+
...lines.slice(0, start),
|
|
42
|
+
head.replaceAll(EVERY_TOKEN_PATTERN, () => REDACTED),
|
|
43
|
+
...redact(remaining),
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function capture(output: string): Captured {
|
|
48
|
+
const lines = stripVTControlCharacters(output).split(LINE_BREAK_PATTERN)
|
|
49
|
+
const start = lines.findIndex((line) => TOKEN_PATTERN.test(line))
|
|
50
|
+
const head = lines[start]
|
|
51
|
+
const shown = redact(lines).join('\n')
|
|
52
|
+
|
|
53
|
+
if (head === undefined) return { token: undefined, shown }
|
|
54
|
+
|
|
55
|
+
const token = [TOKEN_PATTERN.exec(head)?.[0], ...continuation(lines.slice(start + 1))].join('')
|
|
56
|
+
|
|
57
|
+
return { token, shown }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// How much of the output so far may be shown as it arrives — the sign-in URL and any prompt come
|
|
61
|
+
// before the token: everything before the token, less a tail that could be the start of one.
|
|
62
|
+
function live_length(output: string): number {
|
|
63
|
+
const start = output.indexOf(TOKEN_START)
|
|
64
|
+
|
|
65
|
+
if (start !== -1) return start
|
|
66
|
+
|
|
67
|
+
const prefixes = Array.from({ length: TOKEN_START.length }, (_, index) =>
|
|
68
|
+
TOKEN_START.slice(0, index + 1),
|
|
69
|
+
)
|
|
70
|
+
const held = prefixes.findLast((prefix) => output.endsWith(prefix))
|
|
71
|
+
|
|
72
|
+
return output.length - (held?.length ?? 0)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const claude_token_output = { capture, live_length }
|
|
76
|
+
|
|
77
|
+
export { claude_token_output }
|
|
78
|
+
export type { Captured }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execaSync } from 'execa'
|
|
2
|
+
|
|
3
|
+
// Where the Claude Code token of `josh fresh:run` lives on the host: the macOS keychain item
|
|
4
|
+
// `josh auth:setup` writes, or — on a machine without a keychain — `CLAUDE_CODE_OAUTH_TOKEN` in the
|
|
5
|
+
// environment. No shell profile is read or written, so no other Claude Code on the host changes how
|
|
6
|
+
// it signs in.
|
|
7
|
+
|
|
8
|
+
const VARIABLE = 'CLAUDE_CODE_OAUTH_TOKEN'
|
|
9
|
+
const SERVICE = 'josh-claude-code-oauth-token'
|
|
10
|
+
const ACCOUNT = 'josh'
|
|
11
|
+
const SECURITY = 'security'
|
|
12
|
+
const SECURITY_TIMEOUT_MS = 10_000
|
|
13
|
+
const FIND = 'find-generic-password'
|
|
14
|
+
const ITEM_ARGUMENTS: ReadonlyArray<string> = ['-s', SERVICE, '-a', ACCOUNT]
|
|
15
|
+
// The modification date of the item, `"mdat"<timedate>=0x… "20261014071518Z\000"`, in UTC.
|
|
16
|
+
const SAVED_DATE_PATTERN = /"mdat"<timedate>=\S+\s+"(\d{4})(\d{2})(\d{2})/u
|
|
17
|
+
|
|
18
|
+
function security(arguments_: ReadonlyArray<string>, input = ''): string | undefined {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout } = execaSync(SECURITY, [...arguments_], { input, timeout: SECURITY_TIMEOUT_MS })
|
|
21
|
+
|
|
22
|
+
return stdout
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function non_empty(value: string | undefined): string | undefined {
|
|
29
|
+
const trimmed = value?.trim()
|
|
30
|
+
|
|
31
|
+
return trimmed === undefined || trimmed === '' ? undefined : trimmed
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function from_keychain(): string | undefined {
|
|
35
|
+
return non_empty(security([FIND, ...ITEM_ARGUMENTS, '-w']))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The keychain first, then the environment; `undefined` when neither holds a token.
|
|
39
|
+
function read(): string | undefined {
|
|
40
|
+
return from_keychain() ?? non_empty(process.env[VARIABLE])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `security -i` reads its command from stdin, so the token is in no argument list `ps` could show.
|
|
44
|
+
// The item is read back, because the interactive mode exits 0 whether or not the command succeeded.
|
|
45
|
+
function save(token: string): boolean {
|
|
46
|
+
security(['-i'], `add-generic-password -U ${ITEM_ARGUMENTS.join(' ')} -w "${token}"\n`)
|
|
47
|
+
|
|
48
|
+
return from_keychain() === token
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The day the item was last written, `YYYY-MM-DD`; `undefined` when there is none.
|
|
52
|
+
function saved_date(): string | undefined {
|
|
53
|
+
const attributes = security([FIND, ...ITEM_ARGUMENTS])
|
|
54
|
+
const match = attributes === undefined ? undefined : SAVED_DATE_PATTERN.exec(attributes)
|
|
55
|
+
|
|
56
|
+
return match?.slice(1).join('-')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Whether `security` answers at all, asked before a sign-in whose token would have nowhere to go.
|
|
60
|
+
function has_keychain(): boolean {
|
|
61
|
+
return security(['list-keychains']) !== undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const SETUP_HINT = 'Run `pnpm josh auth:setup` to save one.'
|
|
65
|
+
|
|
66
|
+
const claude_token = { read, save, saved_date, has_keychain, VARIABLE, SETUP_HINT }
|
|
67
|
+
|
|
68
|
+
export { claude_token }
|
|
@@ -79,10 +79,7 @@ async function read_offer(
|
|
|
79
79
|
state: DriveState,
|
|
80
80
|
context: DriveContext,
|
|
81
81
|
): Promise<OfferRead | undefined> {
|
|
82
|
-
await backlog_drive_owner.assert_current(context.owner)
|
|
83
|
-
const carry = await read_record()
|
|
84
|
-
|
|
85
|
-
if (carry === undefined) return undefined
|
|
82
|
+
const carry = await backlog_drive_owner.assert_current(context.owner)
|
|
86
83
|
const named = await backlog_drive_named_offer.read(carry, state, context)
|
|
87
84
|
if (named !== undefined) return named
|
|
88
85
|
|
|
@@ -269,6 +266,7 @@ const backlog_drive_cli = {
|
|
|
269
266
|
merge_token,
|
|
270
267
|
offer_argv: backlog_drive_offer_argv.offer_argv,
|
|
271
268
|
parse: backlog_drive_args.parse,
|
|
269
|
+
read_offer,
|
|
272
270
|
resume_line: backlog_drive_args.resume_line,
|
|
273
271
|
run,
|
|
274
272
|
to_offer,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { epic_triage } from '#scripts/epic/epic-triage'
|
|
1
2
|
import { git_gh_repo } from '#scripts/gh/git-gh-repo'
|
|
2
3
|
import { issue_cite } from '#scripts/issue/issue-cite'
|
|
3
4
|
import { issue_number_shape } from '#scripts/issue/issue-number-shape'
|
|
@@ -12,7 +13,12 @@ import { backlog_offer } from './backlog-offer'
|
|
|
12
13
|
// is runnable, without `run:add` and without waking a parent session.
|
|
13
14
|
//
|
|
14
15
|
// **Only what needs a person is handed back.** `stop` means every child left waits on a person, which a
|
|
15
|
-
// judgment session reads.
|
|
16
|
+
// judgment session reads. `triage` means a child carries neither `run:solo` nor `run:lane`, so
|
|
17
|
+
// `epic:next --lanes` offers nothing until a session judges it — it exits 0 and repeats on every pass,
|
|
18
|
+
// so waiting on it would never end. It is handed back under its own word, as the pool path's is, never
|
|
19
|
+
// as `epic #N`: that verdict sends the session to run the epic's children, and the single-child
|
|
20
|
+
// `epic:next` it asks there is not triage-gated, so it would launch the untriaged child instead of
|
|
21
|
+
// labelling it. A failed `epic:next` is either a network blip or a broken graph (a cycle, an
|
|
16
22
|
// undeclared order, an unreadable child), and its exit code does not tell them apart — so a failure is
|
|
17
23
|
// a `wait` that retries on the next pass, and `backlog:offer`'s consecutive-retry limit of them hands the
|
|
18
24
|
// epic back too, rather than waiting out the idle budget on an anomaly nobody is shown.
|
|
@@ -21,6 +27,7 @@ const NO_RETRIES = 0
|
|
|
21
27
|
const SUCCESS_EXIT_CODE = 0
|
|
22
28
|
const FIRST_LINE = 0
|
|
23
29
|
const WAIT: OfferRead = { verdict: 'wait', issues: [], retries: NO_RETRIES }
|
|
30
|
+
const TRIAGE: OfferRead = { ...WAIT, verdict: epic_triage.TRIAGE_VERDICT }
|
|
24
31
|
|
|
25
32
|
// `--lanes` lists every child a free lane could take, so one the loop already knows about is passed
|
|
26
33
|
// over rather than standing in front of the rest. `undefined` is an answer that could not be read.
|
|
@@ -53,22 +60,31 @@ function child_offer(children: ReadonlyArray<string>, state: DriveState): OfferR
|
|
|
53
60
|
return child === undefined ? WAIT : { verdict: 'run', issues: [child], retries: NO_RETRIES }
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
// The answers only a judgment session can move.
|
|
64
|
+
function judgment_offer(epic: string, token: string): OfferRead | undefined {
|
|
65
|
+
if (token === 'stop') return handed_back(epic)
|
|
66
|
+
|
|
67
|
+
return token === epic_triage.TRIAGE_VERDICT ? TRIAGE : undefined
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
async function answered_offer(
|
|
57
71
|
epic: string,
|
|
58
72
|
answers: ReadonlyArray<string>,
|
|
59
73
|
state: DriveState,
|
|
60
74
|
owner: string,
|
|
61
75
|
): Promise<OfferRead> {
|
|
62
|
-
const token = answers[FIRST_LINE]
|
|
76
|
+
const token = answers[FIRST_LINE] ?? ''
|
|
63
77
|
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (token === 'stop') return handed_back(epic)
|
|
78
|
+
if (issue_number_shape.ISSUE_NUMBER_PATTERN.test(token)) return child_offer(answers, state)
|
|
79
|
+
const judgment = judgment_offer(epic, token)
|
|
80
|
+
if (judgment !== undefined) return judgment
|
|
69
81
|
|
|
70
82
|
if (token === 'complete') {
|
|
71
|
-
await backlog_drive_named.mark_done(
|
|
83
|
+
await backlog_drive_named.mark_done(
|
|
84
|
+
epic,
|
|
85
|
+
{ outcome: 'merged', code: 0, token: 'none', is_offer: true },
|
|
86
|
+
owner,
|
|
87
|
+
)
|
|
72
88
|
}
|
|
73
89
|
|
|
74
90
|
return WAIT
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { git_stash } from '#scripts/git/stash/git-stash'
|
|
2
2
|
import { lane_launch_cli, type LaunchOutcome } from '#scripts/lane/lane-launch-cli'
|
|
3
|
+
import { run_event_setup } from '#scripts/run/event/run-event-setup'
|
|
3
4
|
import { backlog_drive_owner } from './backlog-drive-owner'
|
|
4
5
|
|
|
5
6
|
// The driver's lane launch. The parent that ran `josh latest` in the primary
|
|
@@ -28,9 +29,13 @@ async function latest_stash(): Promise<string | undefined> {
|
|
|
28
29
|
|
|
29
30
|
async function launch(issue: string, owner: string): Promise<LaunchOutcome['kind']> {
|
|
30
31
|
await backlog_drive_owner.assert_current(owner)
|
|
32
|
+
await run_event_setup.emit(run_event_setup.opening_lane(issue))
|
|
31
33
|
|
|
32
34
|
const outcome = await lane_launch_cli.launch_lane({ issue, stash: await latest_stash() })
|
|
33
35
|
|
|
36
|
+
// A refusal the driver waits out writes nothing else, so the lane's stage is ended here.
|
|
37
|
+
if (outcome.kind !== 'launched') await run_event_setup.emit(run_event_setup.STAGE.NO_CHILD)
|
|
38
|
+
|
|
34
39
|
return outcome.kind
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -10,6 +10,7 @@ import { backlog_budget } from './backlog-budget'
|
|
|
10
10
|
import type { DriveState, OfferRead } from './backlog-drive'
|
|
11
11
|
import { backlog_drive_epic } from './backlog-drive-epic'
|
|
12
12
|
import { backlog_drive_named } from './backlog-drive-named'
|
|
13
|
+
import { backlog_drive_unread } from './backlog-drive-unread'
|
|
13
14
|
|
|
14
15
|
interface NamedContext {
|
|
15
16
|
is_only: boolean
|
|
@@ -67,12 +68,16 @@ async function classify(
|
|
|
67
68
|
): Promise<OfferRead> {
|
|
68
69
|
const result = await issue_state_cli.read_issue(issue)
|
|
69
70
|
|
|
70
|
-
if (result.kind !== 'state') return
|
|
71
|
+
if (result.kind !== 'state') return backlog_drive_unread.offer(issue, result, state.retries)
|
|
71
72
|
|
|
72
73
|
const outcome = settled_outcome(result.state)
|
|
73
74
|
|
|
74
75
|
if (outcome !== undefined) {
|
|
75
|
-
await backlog_drive_named.mark_done(
|
|
76
|
+
await backlog_drive_named.mark_done(
|
|
77
|
+
issue,
|
|
78
|
+
{ outcome, code: 0, token: 'none', is_offer: true },
|
|
79
|
+
owner,
|
|
80
|
+
)
|
|
76
81
|
|
|
77
82
|
return { verdict: 'wait', issues: [], retries: NO_RETRIES }
|
|
78
83
|
}
|
|
@@ -55,7 +55,9 @@ function is_done(result: MergeResult): boolean {
|
|
|
55
55
|
return result.code === 0 && result.token !== MERGE_TOKEN.BUSY
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
return
|
|
58
|
+
return (
|
|
59
|
+
result.outcome === 'failed' && result.code === 0 && !run_merge_token.is_control_stop(result)
|
|
60
|
+
)
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
function can_mark(carry: RunCarry, issue: string, owner: string): boolean {
|
|
@@ -125,7 +127,7 @@ async function reconcile(
|
|
|
125
127
|
const outcome = parked.has(issue) ? 'parked' : 'merged'
|
|
126
128
|
|
|
127
129
|
// eslint-disable-next-line no-await-in-loop -- each mark rewrites the same carry file
|
|
128
|
-
await mark_done(String(issue), { outcome, code: 0, token: 'none' }, owner)
|
|
130
|
+
await mark_done(String(issue), { outcome, code: 0, token: 'none', is_offer: true }, owner)
|
|
129
131
|
}
|
|
130
132
|
}
|
|
131
133
|
|
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { poll } from '#scripts/lib/poll'
|
|
3
|
+
import { run_carry, type CarryRead, type RunCarry } from '#scripts/run/carry/run-carry'
|
|
4
|
+
import { run_carry_ended } from '#scripts/run/carry/run-carry-ended'
|
|
5
|
+
import { backlog_budget } from './backlog-budget'
|
|
6
|
+
|
|
7
|
+
// **A record that does not read is asked again before the driver gives up on it.** The driver is the
|
|
8
|
+
// only thing looking for work during the idle watch, so ending on one failed read throws away whatever
|
|
9
|
+
// is left of the watch. The read is repeated at the offer's own interval, and
|
|
10
|
+
// the count bounds it: a record that stays unreadable still ends the driver, with the reason said.
|
|
11
|
+
const READ_ATTEMPTS = 6
|
|
12
|
+
const ENDED_MESSAGE = 'The carry record is no longer active.'
|
|
13
|
+
|
|
14
|
+
// The run this driver last read, so a record that is gone can be told from one `run:carry --end` ended.
|
|
15
|
+
const seen: { started_at?: string } = {}
|
|
2
16
|
|
|
3
17
|
function owns_record(carry: RunCarry, owner: string): boolean {
|
|
4
18
|
const current = run_carry.owner_of(Number(owner))
|
|
@@ -7,16 +21,69 @@ function owns_record(carry: RunCarry, owner: string): boolean {
|
|
|
7
21
|
return has_owner && run_carry.is_owner_live(carry) && !carry.is_handed_off
|
|
8
22
|
}
|
|
9
23
|
|
|
10
|
-
|
|
24
|
+
// `--end` records the run it removes, so an ended run answers at once rather than after the re-reads.
|
|
25
|
+
function is_ended(directory: string): boolean {
|
|
26
|
+
return run_carry_ended.has_ended(run_carry_ended.ended_path(directory), seen.started_at)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Whether the file is there is said beside the read, because `none` is the answer both to a record
|
|
30
|
+
// that is gone and to one that is present and could not be opened.
|
|
31
|
+
function unread_note(read: CarryRead, directory: string): string {
|
|
32
|
+
const presence = existsSync(run_carry.carry_path(directory)) ? 'present' : 'absent'
|
|
33
|
+
|
|
34
|
+
return `The carry record read \`${read.kind}\` with its file ${presence}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function is_settled(read: CarryRead, directory: string): boolean {
|
|
38
|
+
if (read.kind === 'carried' || is_ended(directory)) return true
|
|
39
|
+
|
|
40
|
+
console.error(`${unread_note(read, directory)}; reading it again.`)
|
|
41
|
+
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function read_record(directory: string): Promise<CarryRead> {
|
|
46
|
+
const target = run_carry.carry_path(directory)
|
|
47
|
+
let read: CarryRead = { kind: 'none' }
|
|
48
|
+
|
|
49
|
+
await poll.poll_until(
|
|
50
|
+
async () => {
|
|
51
|
+
read = run_carry.read_carry(target)
|
|
52
|
+
|
|
53
|
+
return is_settled(read, directory)
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
attempts: READ_ATTEMPTS,
|
|
57
|
+
interval_ms: backlog_budget.OFFER_INTERVAL_MS,
|
|
58
|
+
sleeper: async (duration_ms) => {
|
|
59
|
+
await poll.sleep(duration_ms)
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return read
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unread_message(read: CarryRead, directory: string): string {
|
|
68
|
+
if (is_ended(directory)) return ENDED_MESSAGE
|
|
69
|
+
|
|
70
|
+
return `${unread_note(read, directory)}, ${String(READ_ATTEMPTS)} times in a row.`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function assert_current(owner: string): Promise<RunCarry> {
|
|
11
74
|
const directory = await run_carry.repository_directory()
|
|
12
75
|
if (directory === undefined) throw new Error('The carry record cannot be located.')
|
|
13
76
|
|
|
14
|
-
const read =
|
|
15
|
-
if (read.kind !== 'carried') throw new Error(
|
|
77
|
+
const read = await read_record(directory)
|
|
78
|
+
if (read.kind !== 'carried') throw new Error(unread_message(read, directory))
|
|
16
79
|
|
|
17
80
|
if (!owns_record(read.carry, owner)) {
|
|
18
81
|
throw new Error('The driver no longer owns the carry record.')
|
|
19
82
|
}
|
|
83
|
+
|
|
84
|
+
seen.started_at = read.carry.started_at
|
|
85
|
+
|
|
86
|
+
return read.carry
|
|
20
87
|
}
|
|
21
88
|
|
|
22
|
-
export const backlog_drive_owner = { assert_current }
|
|
89
|
+
export const backlog_drive_owner = { READ_ATTEMPTS, assert_current }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { issue_cite } from '#scripts/issue/issue-cite'
|
|
2
|
+
import type { StateRead } from '#scripts/issue/issue-state-cli'
|
|
3
|
+
import type { OfferRead } from './backlog-drive'
|
|
4
|
+
import { backlog_offer } from './backlog-offer'
|
|
5
|
+
|
|
6
|
+
// **A named issue whose state did not read is asked again before a session hears of it.** The read is
|
|
7
|
+
// one `gh` request, and a dropped connection fails it for a moment and no longer: handing that to a
|
|
8
|
+
// session costs a wake that can only cut and hand the loop back. So the failure is a `wait` that reads
|
|
9
|
+
// again on the next pass — the driver keeps collecting children meanwhile — and `backlog:offer`'s
|
|
10
|
+
// consecutive-retry limit bounds it, the count `backlog-drive-epic.ts` advances for a failed `epic:next`.
|
|
11
|
+
//
|
|
12
|
+
// **What is handed back is `offer`, with the issue and the reason on stderr.** That is the row a session
|
|
13
|
+
// already reads for an offer that produced nothing to start: report what stderr printed, pick no issue
|
|
14
|
+
// by hand. A number that resolves to nothing is an answer rather than a gap, so it is not asked again.
|
|
15
|
+
|
|
16
|
+
type UnreadState = Exclude<StateRead, { kind: 'state' }>
|
|
17
|
+
|
|
18
|
+
const NO_RETRIES = 0
|
|
19
|
+
// The loop's own word for an offer it could not use (`backlog-drive.ts` → `ask_offer`).
|
|
20
|
+
const HANDED_BACK = 'offer'
|
|
21
|
+
const UNKNOWN_REASON = 'unknown reason'
|
|
22
|
+
|
|
23
|
+
function cause_of(read: UnreadState): string {
|
|
24
|
+
if (read.kind === 'missing') return 'the number does not resolve'
|
|
25
|
+
const status = read.status === undefined ? [] : [`status ${String(read.status)}`]
|
|
26
|
+
|
|
27
|
+
return [read.reason ?? UNKNOWN_REASON, ...status].join(', ')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function note_of(issue: string, read: UnreadState): string {
|
|
31
|
+
return `The state of named issue ${issue_cite.plain(issue)} could not be read (${cause_of(read)})`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function handed_back(note: string): OfferRead {
|
|
35
|
+
console.error(note)
|
|
36
|
+
|
|
37
|
+
return { verdict: HANDED_BACK, issues: [], retries: NO_RETRIES }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// `retries` is the count of consecutive failed offers before this one. A named epic's failed
|
|
41
|
+
// `epic:next` advances it too, so the note counts offers, never state reads.
|
|
42
|
+
function offer(issue: string, read: UnreadState, retries: number): OfferRead {
|
|
43
|
+
const note = note_of(issue, read)
|
|
44
|
+
if (read.kind === 'missing') return handed_back(`${note}; check the number and the repository.`)
|
|
45
|
+
const failures = retries + 1
|
|
46
|
+
|
|
47
|
+
if (failures >= backlog_offer.RETRY_LIMIT) {
|
|
48
|
+
return handed_back(`${note}; the offer failed ${String(failures)} times in a row.`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.error(`${note}; reading it again.`)
|
|
52
|
+
|
|
53
|
+
return { verdict: 'wait', issues: [], retries: failures }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const backlog_drive_unread = { HANDED_BACK, offer }
|
|
@@ -26,10 +26,14 @@ const RUN_VERDICT = 'run'
|
|
|
26
26
|
const STOP_VERDICT = 'stop'
|
|
27
27
|
// How the loop reads each `run:merge` token — keyed by the producer's own type, so a token added there
|
|
28
28
|
// is a type error here until it is classified. `hand-back` ends the loop at once: a hand-off, a person's
|
|
29
|
-
// stop, the environment down, a foreign owner, an unreadable state. `stop` —
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
29
|
+
// stop, the environment down, a foreign owner, an unreadable state. `stop` — the failure guard, or a
|
|
30
|
+
// failed child left unparked — collects the child, starts nothing more, and collects the children still
|
|
31
|
+
// in flight before the loop ends. `await` is a cut resumed in place: the same lane is awaited again.
|
|
32
|
+
//
|
|
33
|
+
// **The offer `run:merge` passes through is never read here** (joshuafolkken/kit#3781): its `stop` —
|
|
34
|
+
// only person-waiting issues left — is spelled like the control one, and acting on it ended the run
|
|
35
|
+
// without the idle watch. The loop asks `backlog:offer` on the next pass instead, so whether a parked
|
|
36
|
+
// backlog waits or ends is `backlog:budget`'s alone, after a merge as after any other pass.
|
|
33
37
|
type TokenHandling = 'hand-back' | 'stop' | 'await'
|
|
34
38
|
const TOKEN_HANDLING: Readonly<Record<MergeToken, TokenHandling>> = {
|
|
35
39
|
[MERGE_TOKEN.OVER]: 'hand-back',
|
|
@@ -103,7 +107,7 @@ interface DriveEnd {
|
|
|
103
107
|
is_finish?: boolean | undefined
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
type Collected = Pick<MergeResult, 'token' | 'outcome'>
|
|
110
|
+
type Collected = Pick<MergeResult, 'token' | 'outcome' | 'is_offer'>
|
|
107
111
|
|
|
108
112
|
interface DrivePorts {
|
|
109
113
|
// Whether a child's process has confirmed-ended — `lane:await`'s re-confirmed check in production.
|
|
@@ -209,15 +213,19 @@ function collected_state(
|
|
|
209
213
|
// One finished child: handed back on an immediate token, otherwise collected.
|
|
210
214
|
async function collect(issue: string, state: DriveState, ports: DrivePorts): Promise<PassResult> {
|
|
211
215
|
const collected = await ports.merge(issue)
|
|
212
|
-
const { token } = collected
|
|
216
|
+
const { token, is_offer } = collected
|
|
213
217
|
|
|
214
218
|
// An empty first line is a `run:merge` that failed before printing one: never read as a collection.
|
|
219
|
+
// An offer's `retry` is `backlog:next`'s transport answer, left to the next `backlog:offer`.
|
|
215
220
|
if (token === '') return ended('merge', state, undefined, issue)
|
|
216
|
-
if (IMMEDIATE_TOKENS.has(token)) return ended('merge', state, token, issue)
|
|
221
|
+
if (!is_offer && IMMEDIATE_TOKENS.has(token)) return ended('merge', state, token, issue)
|
|
217
222
|
|
|
218
223
|
const next = collected_state(issue, collected, state, ports.now().toISOString())
|
|
219
224
|
|
|
220
|
-
return {
|
|
225
|
+
return {
|
|
226
|
+
kind: 'continue',
|
|
227
|
+
state: run_merge_token.is_control_stop(collected) ? merge_stopped(issue, next) : next,
|
|
228
|
+
}
|
|
221
229
|
}
|
|
222
230
|
|
|
223
231
|
async function collect_finished(state: DriveState, ports: DrivePorts): Promise<PassResult> {
|