@joshuafolkken/kit 1.1035.0 → 1.1050.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.claude/skills/verify-ui/SKILL.md +1 -1
  2. package/.claude/skills/workflow-commands/SKILL.md +2 -2
  3. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  4. package/.claude/skills/workflow-commands/backlogrun-child.md +2 -2
  5. package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
  6. package/.claude/skills/workflow-commands/backlogrun-park.md +6 -0
  7. package/.claude/skills/workflow-commands/backlogrun-progress.md +30 -36
  8. package/.claude/skills/workflow-commands/backlogrun-steps.md +16 -16
  9. package/.claude/skills/workflow-commands/chain-rule.md +2 -2
  10. package/.claude/skills/workflow-commands/followup-reference.md +2 -2
  11. package/.claude/skills/workflow-commands/issue-comments.md +1 -1
  12. package/.claude/skills/workflow-commands/observation-filing.md +41 -86
  13. package/.claude/skills/workflow-commands/pre-gate-cut.md +33 -36
  14. package/.claude/skills/workflow-commands/working-tree-hold.md +2 -2
  15. package/.github/workflows/auto-tag.yml +21 -0
  16. package/.github/workflows/publish.yml +9 -10
  17. package/dist/hooks/batch-guard.js +4 -4
  18. package/dist/hooks/{chunk-HU66PWWL.js → chunk-5BK3K7V4.js} +89 -41
  19. package/dist/hooks/{chunk-GLBDBJOK.js → chunk-7VPDUBRN.js} +49 -151
  20. package/dist/hooks/{chunk-GJT4BNVE.js → chunk-DMQN45FN.js} +3 -3
  21. package/dist/hooks/{chunk-D67KVGGY.js → chunk-EJDX2TOB.js} +13 -3
  22. package/dist/hooks/{chunk-KG6PZD72.js → chunk-GQ7KP45M.js} +2 -2
  23. package/dist/hooks/{chunk-Y27M6A7X.js → chunk-HJIISEAC.js} +4 -4
  24. package/dist/hooks/{chunk-XPLP27WA.js → chunk-NAS4C7GP.js} +4 -26
  25. package/dist/hooks/{chunk-ZXNE6NY3.js → chunk-NJQOBE6L.js} +2 -2
  26. package/dist/hooks/{chunk-EIFCAJRB.js → chunk-RLDX5SOC.js} +1184 -946
  27. package/dist/hooks/{chunk-HOYJU5RM.js → chunk-WQ4OSBSS.js} +10 -3
  28. package/dist/hooks/codex-hook-adapter.js +10 -10
  29. package/dist/hooks/duplicate-read-guard.js +5 -5
  30. package/dist/hooks/format-edited.js +15 -34
  31. package/dist/hooks/investigation-guard.js +5 -5
  32. package/dist/hooks/pretool-guard.js +23 -16
  33. package/dist/hooks/stop-guard.js +13 -188
  34. package/dist/josh.js +4 -106
  35. package/dist/version/index.js +381 -9
  36. package/package.json +6 -6
  37. package/prompts/collaboration-workflow/file-edits.md +6 -53
  38. package/prompts/collaboration-workflow/residency.md +1 -1
  39. package/prompts/collaboration-workflow/rule-delivery.md +4 -35
  40. package/prompts/collaboration-workflow/shell-body.md +7 -9
  41. package/prompts/collaboration-workflow/turn-batching.md +21 -37
  42. package/prompts/collaboration-workflow/wip-cap.md +15 -18
  43. package/prompts/sonar-hotspot-handling.md +1 -1
  44. package/scripts/adopt/adopt-toolkits.ts +2 -0
  45. package/scripts/auto-ok/auto-ok-cli.ts +9 -9
  46. package/scripts/backlog/backlog-next.ts +24 -8
  47. package/scripts/backlog/backlog-plan-cli.ts +5 -1
  48. package/scripts/backlog/backlog-pool.ts +31 -11
  49. package/scripts/backlog/backlog-rank.ts +106 -0
  50. package/scripts/backlog/backlog-waves.ts +42 -29
  51. package/scripts/build/build-hooks.ts +7 -2
  52. package/scripts/delegation/delegation-policy.ts +11 -0
  53. package/scripts/document/document-byte-budget.ts +5 -5
  54. package/scripts/document/entry-read-budget.ts +5 -4
  55. package/scripts/epic/epic-lane-offer.ts +11 -22
  56. package/scripts/epic/epic-rank.ts +37 -0
  57. package/scripts/epic/epic-solo.ts +29 -28
  58. package/scripts/git/git-command.ts +1 -1
  59. package/scripts/git/git-execa-error.ts +6 -1
  60. package/scripts/git/git-next-issues.ts +34 -5
  61. package/scripts/git/git-preflight.ts +22 -4
  62. package/scripts/git/git-push-transport.ts +12 -69
  63. package/scripts/git/git-spawn.ts +79 -3
  64. package/scripts/git/git-ssh-keepalive.ts +74 -0
  65. package/scripts/git/gone-branch.ts +3 -2
  66. package/scripts/git/issue-labels.ts +12 -0
  67. package/scripts/git/issue-rank.ts +89 -0
  68. package/scripts/hooks/format-edited-cli.ts +15 -0
  69. package/scripts/hooks/format-edited-file.ts +2 -9
  70. package/scripts/hooks/pretool-guard-cli.ts +21 -0
  71. package/scripts/hooks/pretool-guard.ts +14 -15
  72. package/scripts/init/start-exec.ts +7 -1
  73. package/scripts/init/start-plan.ts +18 -5
  74. package/scripts/init/start-setup-pr.ts +46 -19
  75. package/scripts/init/start-steps.ts +41 -9
  76. package/scripts/init/start.ts +40 -10
  77. package/scripts/issue/issue-cite.ts +19 -1
  78. package/scripts/josh/josh-command-map.ts +0 -102
  79. package/scripts/josh/josh-commands-development.ts +2 -2
  80. package/scripts/josh/josh-commands-project.ts +1 -1
  81. package/scripts/lane/lane-child-marker.ts +16 -2
  82. package/scripts/lane/lane-cli.ts +32 -14
  83. package/scripts/lane/lane-report.ts +10 -4
  84. package/scripts/propagate/propagate-plan.ts +185 -0
  85. package/scripts/propagate/propagate-run.ts +5 -1
  86. package/scripts/propagate/propagate-steps.ts +16 -1
  87. package/scripts/propagate/propagate-targets.ts +13 -2
  88. package/scripts/propagate/propagate.ts +60 -42
  89. package/scripts/registry-migration/migrate-logic.ts +1 -0
  90. package/scripts/release/github-release.ts +1 -1
  91. package/scripts/review/review-attest.ts +44 -0
  92. package/scripts/review/review-brief-cli.ts +49 -2
  93. package/scripts/review/review-brief.ts +46 -1
  94. package/scripts/review/review-stamps.ts +7 -0
  95. package/scripts/rules/implementation-cut.ts +1 -1
  96. package/scripts/rules/issue-fold.ts +1 -2
  97. package/scripts/rules/lane-carry-conflict.ts +1 -2
  98. package/scripts/rules/lane-park.ts +1 -2
  99. package/scripts/rules/pre-gate-cut.ts +2 -2
  100. package/scripts/run/run-headless.ts +22 -6
  101. package/scripts/run/run-parent-cut-hook.ts +144 -0
  102. package/scripts/run/run-progress-read.ts +2 -1
  103. package/scripts/run/run-progress.ts +21 -8
  104. package/scripts/run/run-ship-cli.ts +6 -74
  105. package/scripts/run/run-ship-preflight.ts +87 -0
  106. package/scripts/run/run-ship-review-steps.ts +5 -26
  107. package/scripts/run/run-ship-review.ts +9 -0
  108. package/scripts/run/run-ship-scoped.ts +44 -0
  109. package/scripts/run/run-ship-stage.ts +5 -1
  110. package/scripts/run/run-ship-steps.ts +105 -0
  111. package/scripts/run/run-ship.ts +2 -0
  112. package/scripts/run/run-wake-cli.ts +2 -2
  113. package/scripts/run/run-wake-describe.ts +19 -1
  114. package/scripts/run/run-watcher-hook.ts +1 -1
  115. package/scripts/time-runtime/time-bundle-call.ts +2 -2
  116. package/scripts/version/npm-registry.ts +97 -0
  117. package/scripts/version/version-commands.ts +5 -4
  118. package/scripts/version/version-remote.ts +26 -12
@@ -0,0 +1,87 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { changed_paths } from '#scripts/git/changed-paths'
3
+ import { git_branch } from '#scripts/git/git-branch'
4
+ import { git_gh_command } from '#scripts/git/git-gh-command'
5
+ import { git_preflight } from '#scripts/git/git-preflight'
6
+ import type { JoshResult } from '#scripts/josh/josh-run'
7
+ import { error_text } from '#scripts/lib/error-message'
8
+ import { live_evidence } from '#scripts/review/live-evidence'
9
+ import { run_ship_scoped } from './run-ship-scoped'
10
+
11
+ // `josh ship`'s first stage (joshuafolkken/kit#2946). A detached ship used to meet the pull request's
12
+ // preconditions only at the commit stage — `git -y`'s preflight — or at the merge — `followup`'s
13
+ // live-evidence refusal — each after the review and the gate had already run, so every such stop
14
+ // relaunched a session to repeat them. **Every precondition decidable before the review is asked here,
15
+ // and all of them are reported at once.** No check is new: the classification, issue and branch answers
16
+ // are `git_preflight.problems_of`, and the evidence answer is `live_evidence.verdict_for` over the same
17
+ // changed paths and the body this ship will deliver — `--body-file` when given, else the open PR's.
18
+ //
19
+ // The scoped pair runs last, met rather than stopped on (`run-ship-scoped.ts`), so the review and the
20
+ // gate start on a tree both checks are green on.
21
+
22
+ const SUCCESS_EXIT_CODE = 0
23
+ const FAILURE_EXIT_CODE = 1
24
+ const EVIDENCE_PROBLEM = `This change touches runtime code, but the PR body has no ${live_evidence.EVIDENCE_HEADING} section: run the acceptance criteria for real and pass the body with --body-file <path>.`
25
+
26
+ interface PreflightRequest {
27
+ title: string
28
+ body_path: string | undefined
29
+ }
30
+
31
+ async function body_text(body_path: string | undefined): Promise<string | undefined> {
32
+ if (body_path !== undefined) return await readFile(body_path, 'utf8')
33
+
34
+ return await git_gh_command.pr_get_body(await git_branch.current())
35
+ }
36
+
37
+ async function evidence_problems(body_path: string | undefined): Promise<Array<string>> {
38
+ const [paths, body] = await Promise.all([
39
+ changed_paths.read_changed_paths(false),
40
+ body_text(body_path),
41
+ ])
42
+
43
+ return live_evidence.verdict_for(paths, body) === 'required' ? [EVIDENCE_PROBLEM] : []
44
+ }
45
+
46
+ function refusal(problems: ReadonlyArray<string>): JoshResult {
47
+ const lines = problems.map((problem) => ` - ${problem}`)
48
+ const head = `${String(problems.length)} pull-request precondition(s) unmet before the review and the gate:`
49
+
50
+ return { code: FAILURE_EXIT_CODE, out: [head, ...lines].join('\n') }
51
+ }
52
+
53
+ // The reads here run in the ship's own process, where the commit stage's ran in a `git -y` child: a
54
+ // throw — a `--body-file` path that does not exist, a git read that failed — would escape `ship()`
55
+ // past the stop a supervised ship hands back on, so it is reported as one more unmet precondition.
56
+ // Each side is caught on its own, so one side's failed read never hides what the other side found.
57
+ async function caught(problems: Promise<Array<string>>): Promise<Array<string>> {
58
+ try {
59
+ return await problems
60
+ } catch (error) {
61
+ return [error_text.message_of(error)]
62
+ }
63
+ }
64
+
65
+ async function asked_problems(request: PreflightRequest): Promise<Array<string>> {
66
+ const [preflight, evidence] = await Promise.all([
67
+ caught(git_preflight.problems_of({ cli_input: request.title, will_open_pr: true })),
68
+ caught(evidence_problems(request.body_path)),
69
+ ])
70
+
71
+ return [...preflight, ...evidence]
72
+ }
73
+
74
+ async function stage(request: PreflightRequest): Promise<JoshResult> {
75
+ const problems = await asked_problems(request)
76
+
77
+ if (problems.length > 0) return refusal(problems)
78
+
79
+ const scoped = await run_ship_scoped.scoped_pair()
80
+
81
+ return scoped.code === SUCCESS_EXIT_CODE ? { code: SUCCESS_EXIT_CODE, out: 'ready' } : scoped
82
+ }
83
+
84
+ const run_ship_preflight = { EVIDENCE_PROBLEM, stage }
85
+
86
+ export type { PreflightRequest }
87
+ export { run_ship_preflight }
@@ -1,7 +1,5 @@
1
1
  import { agent_argv, type AgentArgv } from '#scripts/agent/agent-argv'
2
2
  import { agent_role_profile, type AgentProfile } from '#scripts/agent/agent-role-profile'
3
- import { gate_tree } from '#scripts/gate/gate-tree'
4
- import { scoped_green } from '#scripts/gate/scoped-green'
5
3
  import { PROJECT_ROOT } from '#scripts/init/init-paths'
6
4
  import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
7
5
  import { stamp_file } from '#scripts/josh/stamp-file'
@@ -9,6 +7,7 @@ import { lane_child_marker } from '#scripts/lane/lane-child-marker'
9
7
  import { openai_review_broker } from '#scripts/lane/openai-review-broker'
10
8
  import { detached_launch } from './detached-launch'
11
9
  import { run_ship_review, type RoundOutcome, type ScoredVerdict } from './run-ship-review'
10
+ import { run_ship_scoped, type Phase } from './run-ship-scoped'
12
11
 
13
12
  // The side effects of `josh ship --review` (joshuafolkken/kit#2427): the round-1 review the chain used
14
13
  // to open, launch, join, attest and record across five agent turns, run by the supervisor beside the
@@ -47,7 +46,10 @@ const FIXED_JOIN_NOTE =
47
46
  'the round-1 gate read the tree before the review fixes — drained, not judged; the gate stage re-runs it.'
48
47
  const ROUND_TWO_SKIPPED_NOTE = 'round 2 not due — nothing left to verify; shipping on.'
49
48
 
50
- type Phase = () => Promise<JoshResult>
49
+ // The scoped pair's precondition, met by the supervisor itself rather than stopped on
50
+ // (joshuafolkken/kit#2500), single-sourced with the preflight and gate stages (joshuafolkken/kit#2946).
51
+ const { run_phases, scoped_pair } = run_ship_scoped
52
+
51
53
  type PromptOf = (brief_path: string, findings_path: string) => string
52
54
 
53
55
  function failure(out: string): JoshResult {
@@ -153,17 +155,6 @@ async function record_and_route(
153
155
  return outcome.is_passing ? { code: SUCCESS_EXIT_CODE, out } : failure(out)
154
156
  }
155
157
 
156
- async function run_phases(phases: ReadonlyArray<Phase>): Promise<JoshResult> {
157
- let last: JoshResult = { code: SUCCESS_EXIT_CODE, out: '' }
158
-
159
- for (const phase of phases) {
160
- last = await phase()
161
- if (last.code !== SUCCESS_EXIT_CODE) return last
162
- }
163
-
164
- return last
165
- }
166
-
167
158
  // A brief-minting command, then a reviewer handed what it printed.
168
159
  function reviewed(
169
160
  open: ReadonlyArray<string>,
@@ -185,18 +176,6 @@ function reviewed(
185
176
  ]
186
177
  }
187
178
 
188
- // The scoped pair's precondition, met by the supervisor itself rather than stopped on
189
- // (joshuafolkken/kit#2500): only the checks with no green record for this tree run — the same
190
- // question `review:brief` and the local gate refuse on — so a fresh record costs nothing and a stale
191
- // or absent one costs a scoped run instead of a stop and a relaunched session. A check that genuinely
192
- // fails still stops the ship, its output forwarded to the ship log.
193
- async function scoped_pair(): Promise<JoshResult> {
194
- const tree = await gate_tree.read_gate_tree()
195
- const scripts = scoped_green.missing_scripts(tree.files, tree.base)
196
-
197
- return await run_phases(scripts.map((script) => async () => await josh([script])))
198
- }
199
-
200
179
  // Fixes made in place are counted only once the scoped pair is green on the fixed tree. The reviewer is
201
180
  // asked to leave it green; this is the check that it did, and a fix it could not get green is routed as
202
181
  // the finding it still is — recorded, then stopped before the gate and the commit take it in.
@@ -154,6 +154,14 @@ function round_one_outcome(verdict: ScoredVerdict): RoundOutcome {
154
154
  return { is_passing: true, note: verdict.kind === VERDICT.FIXED ? FIXED_NOTE : CLEAN_NOTE }
155
155
  }
156
156
 
157
+ // Whether a findings file records a clean round 1 (joshuafolkken/kit#2945). A resumed round 1 narrows to
158
+ // the delta only past a review that found nothing to fix. A blocking one has a finding in a file the
159
+ // delta may not touch. A fixed one approved its own edits, not the briefed tree the delta is measured
160
+ // from, so a file reverted to that tree would drop out of the delta with its finding back in it.
161
+ function is_clean_round_one(text: string | undefined): boolean {
162
+ return read_verdict(text).kind === VERDICT.CLEAN
163
+ }
164
+
157
165
  // Round 2 is final and reads only, so it passes clean or Low-only alone — a fix it made would itself be
158
166
  // unreviewed.
159
167
  function round_two_outcome(verdict: ScoredVerdict): RoundOutcome {
@@ -166,6 +174,7 @@ const run_ship_review = {
166
174
  FIXED_PREFIX,
167
175
  UNVERIFIED_OUTCOME,
168
176
  VERDICT,
177
+ is_clean_round_one,
169
178
  read_verdict,
170
179
  reviewer_prompt,
171
180
  round_one_outcome,
@@ -0,0 +1,44 @@
1
+ import { gate_tree } from '#scripts/gate/gate-tree'
2
+ import { scoped_green } from '#scripts/gate/scoped-green'
3
+ import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
4
+
5
+ // The scoped pair `josh ship` meets itself rather than stops on (joshuafolkken/kit#2500), shared by
6
+ // every stage that needs a green record for this tree (joshuafolkken/kit#2946): the preflight stage at
7
+ // the start, the review rounds, and the gate stage — a reviewer may have edited the tree after round
8
+ // 1's pair ran, and `josh gate` refuses a tree with no green record.
9
+
10
+ const SUCCESS_EXIT_CODE = 0
11
+ const should_forward_stderr = true
12
+
13
+ type Phase = () => Promise<JoshResult>
14
+
15
+ async function run_phases(phases: ReadonlyArray<Phase>): Promise<JoshResult> {
16
+ let last: JoshResult = { code: SUCCESS_EXIT_CODE, out: '' }
17
+
18
+ for (const phase of phases) {
19
+ last = await phase()
20
+ if (last.code !== SUCCESS_EXIT_CODE) return last
21
+ }
22
+
23
+ return last
24
+ }
25
+
26
+ // Only the checks with no green record for this tree run — the same question `review:brief` and the
27
+ // local gate refuse on — so a fresh record costs nothing and a stale or absent one costs a scoped run
28
+ // instead of a stop and a relaunched session. A check that genuinely fails still stops the ship, its
29
+ // output forwarded to the ship log.
30
+ async function scoped_pair(): Promise<JoshResult> {
31
+ const tree = await gate_tree.read_gate_tree()
32
+ const scripts = scoped_green.missing_scripts(tree.files, tree.base)
33
+
34
+ return await run_phases(
35
+ scripts.map(
36
+ (script) => async () => await josh_command.josh_run([script], should_forward_stderr),
37
+ ),
38
+ )
39
+ }
40
+
41
+ const run_ship_scoped = { run_phases, scoped_pair }
42
+
43
+ export type { Phase }
44
+ export { run_ship_scoped }
@@ -17,6 +17,7 @@ import { z } from 'zod'
17
17
  // re-run on an unchanged tree a reuse rather than a second execution. No quality gate is folded away.
18
18
 
19
19
  const STAGE = {
20
+ PREFLIGHT: 'preflight',
20
21
  REVIEW: 'review',
21
22
  GATE: 'gate',
22
23
  COMMIT: 'commit',
@@ -78,7 +79,7 @@ function mark_done(target: string, stage: Stage): void {
78
79
  stamp_file.replace_stamp(target, { done: [...done] })
79
80
  }
80
81
 
81
- // Removed once the report stage completes, so a later ship of the same issue starts from the gate.
82
+ // Removed once the report stage completes, so a later ship of the same issue starts from the preflight.
82
83
  function clear(target: string): void {
83
84
  stamp_file.remove_stamp(target)
84
85
  }
@@ -90,6 +91,9 @@ function is_shipped(state: ShipState): boolean {
90
91
  }
91
92
 
92
93
  const DONE_BY: Record<Stage, (done: ReadonlySet<string>, state: ShipState) => boolean> = {
94
+ // The preflight (joshuafolkken/kit#2946) asks what the commit stage would refuse on, so a commit is
95
+ // its output as it is the gate's; a resumed commit stage still asks `git -y`'s own preflight.
96
+ [STAGE.PREFLIGHT]: (_done, state) => state.is_merged || state.is_committed,
93
97
  // The `--review` round (joshuafolkken/kit#2427) precedes the commit, so a commit is its output as it
94
98
  // is the gate's. A record alone is not honored: before a commit the tree may have been edited since
95
99
  // the recorded round, and only a commit pins the tree that round read.
@@ -0,0 +1,105 @@
1
+ import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
2
+ import { run_ship } from './run-ship'
3
+ import { run_ship_preflight } from './run-ship-preflight'
4
+ import { run_ship_review_steps } from './run-ship-review-steps'
5
+ import { run_ship_scoped } from './run-ship-scoped'
6
+ import { run_ship_stage, type ShipState, type Stage } from './run-ship-stage'
7
+
8
+ // The stages `josh ship` runs, in the order a change ships (joshuafolkken/kit#2398), kept apart from the
9
+ // chaining CLI so the order is read in one place.
10
+
11
+ const should_forward_stderr = true
12
+
13
+ interface ShipArguments {
14
+ title: string
15
+ number: string
16
+ notify: ReadonlyArray<string>
17
+ body: ReadonlyArray<string>
18
+ body_path: string | undefined
19
+ cites: ReadonlyArray<string>
20
+ is_review: boolean
21
+ is_detach: boolean
22
+ }
23
+
24
+ interface Step {
25
+ stage: Stage
26
+ header: string
27
+ run: (args: ShipArguments, state: ShipState) => Promise<JoshResult>
28
+ }
29
+
30
+ const { STAGE } = run_ship_stage
31
+
32
+ async function josh(argv: ReadonlyArray<string>): Promise<JoshResult> {
33
+ return await josh_command.josh_run(argv, should_forward_stderr)
34
+ }
35
+
36
+ // joshuafolkken/kit#2946: the pull request's preconditions and the scoped pair are asked first, so a
37
+ // stop they would cause lands before the review and the gate rather than after them.
38
+ const PREFLIGHT_STEP: Step = {
39
+ stage: STAGE.PREFLIGHT,
40
+ header: run_ship.PREFLIGHT_HEADER,
41
+ run: async (args) =>
42
+ await run_ship_preflight.stage({ title: args.title, body_path: args.body_path }),
43
+ }
44
+
45
+ // The gate before the commit, the commit/push/PR before the merge. The gate meets the scoped pair
46
+ // first: a round-1 reviewer may have edited the tree since the preflight, and `josh gate` refuses a tree
47
+ // with no green record (joshuafolkken/kit#2946). The commit step carries the `--skip-*` flags a resumed
48
+ // ship needs, so an existing commit or push is never made twice.
49
+ const COMMIT_STEPS: ReadonlyArray<Step> = [
50
+ {
51
+ stage: STAGE.GATE,
52
+ header: run_ship.GATE_HEADER,
53
+ run: async () =>
54
+ await run_ship_scoped.run_phases([
55
+ run_ship_scoped.scoped_pair,
56
+ async () => await josh(['gate']),
57
+ ]),
58
+ },
59
+ {
60
+ stage: STAGE.COMMIT,
61
+ header: run_ship.COMMIT_HEADER,
62
+ run: async (args, state) =>
63
+ await josh(['git', '-y', ...run_ship_stage.commit_flags(state), ...args.body, args.title]),
64
+ },
65
+ ]
66
+
67
+ // The merge before the report bookkeeping.
68
+ const MERGE_STEPS: ReadonlyArray<Step> = [
69
+ {
70
+ stage: STAGE.FOLLOWUP,
71
+ header: run_ship.FOLLOWUP_HEADER,
72
+ run: async (args) => await josh(['followup', args.title, ...args.notify]),
73
+ },
74
+ {
75
+ stage: STAGE.REPORT,
76
+ header: run_ship.REPORT_HEADER,
77
+ run: async (args) => await josh(['run:tail', args.number, ...args.cites]),
78
+ },
79
+ ]
80
+
81
+ // `--review` (joshuafolkken/kit#2427) puts the supervised round-1 review in front of the gate: it
82
+ // launches the gate itself, so the gate stage that follows reuses that tree's green record. It also
83
+ // puts the round-2 pass between the commit and the followup (joshuafolkken/kit#2489), so the PR opens
84
+ // between the rounds and round 2 runs beside CI — a no-op when round 1 left no fix delta.
85
+ const REVIEW_STEP: Step = {
86
+ stage: STAGE.REVIEW,
87
+ header: run_ship.REVIEW_HEADER,
88
+ run: async (args) => await run_ship_review_steps.review_stage(args.number),
89
+ }
90
+ const ROUND_TWO_STEP: Step = {
91
+ stage: STAGE.ROUND_TWO,
92
+ header: run_ship.ROUND_TWO_HEADER,
93
+ run: async (args) => await run_ship_review_steps.round_two_stage(args.number),
94
+ }
95
+
96
+ function steps(args: ShipArguments): ReadonlyArray<Step> {
97
+ if (!args.is_review) return [PREFLIGHT_STEP, ...COMMIT_STEPS, ...MERGE_STEPS]
98
+
99
+ return [PREFLIGHT_STEP, REVIEW_STEP, ...COMMIT_STEPS, ROUND_TWO_STEP, ...MERGE_STEPS]
100
+ }
101
+
102
+ const run_ship_steps = { steps }
103
+
104
+ export type { ShipArguments, Step }
105
+ export { run_ship_steps }
@@ -9,6 +9,7 @@
9
9
  // `run:tail`, ship stops at the first failed step: a red gate must never reach the commit, so the
10
10
  // composite ends at the failure, and the report names the step that failed so the run reads only it.
11
11
 
12
+ const PREFLIGHT_HEADER = '=== preflight ==='
12
13
  const REVIEW_HEADER = '=== review ==='
13
14
  const GATE_HEADER = '=== gate ==='
14
15
  const COMMIT_HEADER = '=== commit/push/PR ==='
@@ -63,6 +64,7 @@ const run_ship = {
63
64
  COMMIT_HEADER,
64
65
  FOLLOWUP_HEADER,
65
66
  GATE_HEADER,
67
+ PREFLIGHT_HEADER,
66
68
  REPORT_HEADER,
67
69
  REVIEW_HEADER,
68
70
  ROUND_TWO_HEADER,
@@ -290,7 +290,7 @@ function start(context: WakeContext, interval: string | undefined): number {
290
290
  const existing = run_wake.read_wake(context.wake_target)
291
291
 
292
292
  if (existing !== undefined && run_wake.is_supervisor_live(existing)) {
293
- console.error(run_wake_describe.describe_wake(existing, context))
293
+ console.error(run_wake_describe.describe_wake_cited(existing, context))
294
294
 
295
295
  return report(RUNNING_VERDICT)
296
296
  }
@@ -342,7 +342,7 @@ function list(context: WakeContext): number {
342
342
 
343
343
  if (wake === undefined) return report(NONE_VERDICT)
344
344
 
345
- console.error(run_wake_describe.describe_wake(wake, context))
345
+ console.error(run_wake_describe.describe_wake_cited(wake, context))
346
346
 
347
347
  return report(run_wake.is_supervisor_live(wake) ? SUPERVISING_VERDICT : STALE_VERDICT)
348
348
  }
@@ -1,4 +1,6 @@
1
1
  import { agent_role_profile } from '#scripts/agent/agent-role-profile'
2
+ import { gh_spawn } from '#scripts/gh/gh-spawn'
3
+ import { issue_citation } from '#scripts/rules/issue-citation'
2
4
  import { run_carry, type CarryRead } from './run-carry'
3
5
  import { run_event_stream } from './run-event-stream'
4
6
  import { run_liveness } from './run-liveness'
@@ -9,6 +11,7 @@ import { run_wake, type RunWake } from './run-wake'
9
11
  // record reads back to a person lives here, one cohesive block.
10
12
 
11
13
  const UNKNOWN_CUTS = 'an unreadable number of'
14
+ const REPO_LOOKUP_TIMEOUT_MS = 10_000
12
15
  const STOP_COMMAND = 'pnpm josh run:wake --stop'
13
16
 
14
17
  // The three paths every verb needs: the carry record it reads, its own record, and the work tree a
@@ -91,7 +94,22 @@ function describe_wake(wake: RunWake, context: WakeContext): string {
91
94
  .join('\n')
92
95
  }
93
96
 
94
- const run_wake_describe = { STOP_COMMAND, describe_wake }
97
+ // The description as a person reads it, every issue on it a number-link (joshuafolkken/kit#2943). The
98
+ // invocation and the newest event both name issues (`backlogrun #1 #2`, `#1904 merged`), and a report
99
+ // that copies them bare is what the Stop guard sends back. The repository is asked here, bounded, rather
100
+ // than in the context every verb resolves: only the two describing verbs print it, and a lookup that
101
+ // does not answer leaves the description as it was rather than withholding it.
102
+ function cite_description(description: string, repo: string | undefined): string {
103
+ return repo === undefined ? description : issue_citation.linkify(description, repo)
104
+ }
105
+
106
+ function describe_wake_cited(wake: RunWake, context: WakeContext): string {
107
+ const repo = gh_spawn.get_repo_name_with_owner_within(REPO_LOOKUP_TIMEOUT_MS)
108
+
109
+ return cite_description(describe_wake(wake, context), repo)
110
+ }
111
+
112
+ const run_wake_describe = { STOP_COMMAND, cite_description, describe_wake, describe_wake_cited }
95
113
 
96
114
  export type { WakeContext }
97
115
  export { run_wake_describe }
@@ -81,6 +81,6 @@ async function watcher_hook_reason(
81
81
  return await stale_reason(transcript, now_ms)
82
82
  }
83
83
 
84
- const run_watcher_hook = { SWITCH_ENV_KEY, STAMP_PREFIX, watcher_hook_reason }
84
+ const run_watcher_hook = { SWITCH_ENV_KEY, STAMP_PREFIX, transcript_of, watcher_hook_reason }
85
85
 
86
86
  export { run_watcher_hook }
@@ -336,8 +336,8 @@ function josh_subcommand(command: string): string | undefined {
336
336
  }
337
337
 
338
338
  // Whether a shell line is one of the read-only `josh` bookkeeping commands, and nothing else. A chained
339
- // line falls through. An alias is resolved to its canonical name first — a run may type `josh ist` for
340
- // `issue:state` — so the allow-list holds canonical names alone. Everything not on the list falls
339
+ // line falls through. An alias is resolved to its canonical name first, so the allow-list holds
340
+ // canonical names alone. Everything not on the list falls
341
341
  // through to the read/mutation test, which is what keeps `josh gate` / `followup` / `git`
342
342
  // non-bundleable (joshuafolkken/kit#1875).
343
343
  function is_read_josh(command: string): boolean {
@@ -0,0 +1,97 @@
1
+ import { migrate_logic } from '#scripts/registry-migration/migrate-logic'
2
+ import { execaSync } from 'execa'
3
+ import { z } from 'zod'
4
+ import { safe_json_parse } from './parse-json'
5
+ import { release_age } from './release-age'
6
+
7
+ // The public npm registry, read without credentials so a consumer who installed from public npm
8
+ // can see the latest release without a `read:packages` GitHub token (joshuafolkken/kit#2882). The
9
+ // GitHub Packages versions API stays the source for a scope routed there, and the fallback for a
10
+ // package public npm does not carry.
11
+ const PUBLIC_REGISTRY = 'https://registry.npmjs.org'
12
+ // The abbreviated packument carries `dist-tags` without every version's full manifest.
13
+ const ABBREVIATED_ACCEPT = 'application/vnd.npm.install-v1+json'
14
+ const FULL_ACCEPT = 'application/json'
15
+ const REQUEST_TIMEOUT_MS = 10_000
16
+ const SCOPE_SEPARATOR = '/'
17
+ const ENCODED_SCOPE_SEPARATOR = '%2f'
18
+ const SCOPE_PATTERN = /^@[^/]+(?=\/)/u
19
+ const SCOPE_REGISTRY_SUFFIX = ':registry'
20
+ const github_scopes = new Map<string, boolean>()
21
+
22
+ // The version commands are synchronous end to end and exported that way to consumers, so the
23
+ // request runs in a child Node process rather than turning the whole chain async. Node's own
24
+ // `fetch` needs no external binary, and a non-2xx answer exits non-zero so it reads as "no answer".
25
+ const FETCH_SCRIPT = [
26
+ 'const [url, accept] = process.argv.slice(1)',
27
+ 'const response = await fetch(url, { headers: { accept } })',
28
+ 'if (!response.ok) process.exit(1)',
29
+ 'process.stdout.write(await response.text())',
30
+ ].join('\n')
31
+
32
+ const latest_schema = z.looseObject({ 'dist-tags': z.looseObject({ latest: z.string() }) })
33
+ const times_schema = z.looseObject({ time: release_age.release_times_schema })
34
+
35
+ // The packument URL for a package name; a scoped name keeps its `@` and encodes the separator, the
36
+ // form the registry documents for scoped packages.
37
+ function packument_url(package_name: string): string {
38
+ return `${PUBLIC_REGISTRY}/${package_name.replace(SCOPE_SEPARATOR, () => ENCODED_SCOPE_SEPARATOR)}`
39
+ }
40
+
41
+ function read_packument(package_name: string, accept: string): unknown {
42
+ const result = execaSync(
43
+ process.execPath,
44
+ ['--input-type=module', '-e', FETCH_SCRIPT, packument_url(package_name), accept],
45
+ { reject: false, timeout: REQUEST_TIMEOUT_MS },
46
+ )
47
+ if (result.exitCode !== 0) return undefined
48
+
49
+ return safe_json_parse(result.stdout)
50
+ }
51
+
52
+ // Whether this checkout installs the package's scope from GitHub Packages, read with the same
53
+ // `pnpm config get` `josh registry:migrate` uses (project `.npmrc` over user `~/.npmrc`). The two
54
+ // registries are published by parallel jobs and can drift, so a consumer routed to GitHub Packages
55
+ // keeps reading it: public npm could otherwise offer a release its registry does not carry yet.
56
+ // Memoized per scope — kit and app-kit share one, and one report asks for both packages.
57
+ function is_github_scope(scope: string): boolean {
58
+ const is_cached_github = github_scopes.get(scope)
59
+ if (is_cached_github !== undefined) return is_cached_github
60
+ const result = execaSync('pnpm', ['config', 'get', `${scope}${SCOPE_REGISTRY_SUFFIX}`], {
61
+ reject: false,
62
+ timeout: REQUEST_TIMEOUT_MS,
63
+ })
64
+ const is_github = result.exitCode === 0 && migrate_logic.is_github_tarball(result.stdout.trim())
65
+
66
+ github_scopes.set(scope, is_github)
67
+
68
+ return is_github
69
+ }
70
+
71
+ function is_public_source(package_name: string): boolean {
72
+ const scope = SCOPE_PATTERN.exec(package_name)?.[0]
73
+
74
+ return scope === undefined || !is_github_scope(scope)
75
+ }
76
+
77
+ // The `latest` dist-tag on public npm, or nothing when the package is not installed from there or
78
+ // the registry cannot answer for it (offline, timed out, or not published there).
79
+ function read_latest(package_name: string): string | undefined {
80
+ if (!is_public_source(package_name)) return undefined
81
+ const parsed = latest_schema.safeParse(read_packument(package_name, ABBREVIATED_ACCEPT))
82
+
83
+ return parsed.success ? parsed.data['dist-tags'].latest : undefined
84
+ }
85
+
86
+ // The version → publish-date map from public npm, under the same source rule as `read_latest`. It
87
+ // also carries `created` / `modified` keys, which the release-age selector skips as non-semver.
88
+ function read_release_times(package_name: string): Record<string, string> | undefined {
89
+ if (!is_public_source(package_name)) return undefined
90
+ const parsed = times_schema.safeParse(read_packument(package_name, FULL_ACCEPT))
91
+
92
+ return parsed.success ? parsed.data.time : undefined
93
+ }
94
+
95
+ const npm_registry = { packument_url, read_latest, read_release_times }
96
+
97
+ export { npm_registry }
@@ -13,6 +13,7 @@ import {
13
13
  type VersionSnapshot,
14
14
  } from './version-check-logic'
15
15
  import type {
16
+ PackageVersionConfig,
16
17
  UpstreamHookContext,
17
18
  UpstreamVersionConfig,
18
19
  VersionCommandConfig,
@@ -29,11 +30,11 @@ const NO_QUARANTINE_MINUTES = 0
29
30
  // whose `installable` is undefined when the timestamps cannot be read — the report then renders
30
31
  // exactly as it did before (joshuafolkken/kit#808).
31
32
  //
32
- // Skipped entirely unless some target is behind `latest`: the timestamps cost a second `gh api`
33
+ // Skipped entirely unless some target is behind `latest`: the timestamps cost a second registry
33
34
  // round trip per package, and a package with nothing stale has no gap for a hold to explain. On the
34
35
  // common all-current run the report costs exactly what it did before this feature.
35
36
  function read_release_hold(
36
- versions_endpoint: string | undefined,
37
+ package_config: PackageVersionConfig,
37
38
  latest: string,
38
39
  versions: ReadonlyArray<string | undefined>,
39
40
  ): ReleaseHold | undefined {
@@ -45,7 +46,7 @@ function read_release_hold(
45
46
  // a registry round trip discovering that.
46
47
  if (minimum_age_minutes <= NO_QUARANTINE_MINUTES) return undefined
47
48
  const installable = release_hold.resolve_installable(
48
- fetch_release_times(versions_endpoint),
49
+ fetch_release_times(package_config.versions_endpoint, package_config.package_name),
49
50
  latest,
50
51
  minimum_age_minutes,
51
52
  Date.now(),
@@ -157,7 +158,7 @@ function read_upstream_report(
157
158
  // After the effective install is attached, so its version counts toward the staleness gate.
158
159
  // Only the effective install is peer-resolved, so it is the only target the window can hold back.
159
160
  const hold = is_hold_wanted
160
- ? read_release_hold(upstream.versions_endpoint, latest, [report.effective?.version])
161
+ ? read_release_hold(upstream, latest, [report.effective?.version])
161
162
  : undefined
162
163
  if (hold !== undefined) report.hold = hold
163
164
 
@@ -1,5 +1,6 @@
1
1
  import { git_gh_exec } from '#scripts/git/git-gh-exec'
2
2
  import { error_text } from '#scripts/lib/error-message'
3
+ import { npm_registry } from './npm-registry'
3
4
  import { safe_json_parse } from './parse-json'
4
5
  import { release_age } from './release-age'
5
6
 
@@ -22,14 +23,18 @@ function require_endpoint(versions_endpoint: string | undefined, package_name: s
22
23
  )
23
24
  }
24
25
 
25
- // Fetch the latest published version from a GitHub Packages versions endpoint. The endpoint is
26
- // supplied per package (e.g. `/users/joshuafolkken/packages/npm/kit/versions?per_page=1`) so the
27
- // same fetcher serves kit, jgame, and app-kit. Guards an undefined/empty endpoint and wraps
28
- // `gh api` failures with an actionable message instead of a raw ExecaSyncError stack. The detail is
29
- // the shared layer's failure text: gh's own stderr (e.g. "gh: Not Found (HTTP 404)") when it wrote
30
- // one, execa's message otherwise, followed by any JSON error body gh wrote to stdout.
26
+ // Fetch the latest published version: public npm's `latest` dist-tag first, which needs no
27
+ // credentials (joshuafolkken/kit#2882), then the GitHub Packages versions endpoint for a package
28
+ // public npm does not answer for. The endpoint is supplied per package (e.g.
29
+ // `/users/joshuafolkken/packages/npm/kit/versions?per_page=1`) so the same fetcher serves kit, jgame,
30
+ // and app-kit. Guards an undefined/empty endpoint and wraps `gh api` failures with an actionable
31
+ // message instead of a raw ExecaSyncError stack. The detail is the shared layer's failure text: gh's
32
+ // own stderr (e.g. "gh: Not Found (HTTP 404)") when it wrote one, execa's message otherwise, followed
33
+ // by any JSON error body gh wrote to stdout.
31
34
  function fetch_latest_version(versions_endpoint: string | undefined, package_name: string): string {
32
35
  const endpoint = require_endpoint(versions_endpoint, package_name)
36
+ const public_latest = npm_registry.read_latest(package_name)
37
+ if (public_latest !== undefined) return public_latest
33
38
 
34
39
  try {
35
40
  return git_gh_exec.exec_gh_api_sync({ path: endpoint, jq_filter: '.[0].name' }).trim()
@@ -59,19 +64,28 @@ function with_page_size(endpoint: string, page_size: number): string {
59
64
  return `${path_part}?${query.toString()}`
60
65
  }
61
66
 
67
+ function read_github_release_times(versions_endpoint: string): Record<string, string> | undefined {
68
+ const endpoint = with_page_size(versions_endpoint, TIMES_PAGE_SIZE)
69
+ const stdout = git_gh_exec.read_gh_api_sync({ path: endpoint, jq_filter: TIMES_JQ })
70
+ if (stdout === undefined) return undefined
71
+ const parsed = release_age.release_times_schema.safeParse(safe_json_parse(stdout))
72
+
73
+ return parsed.success ? parsed.data : undefined
74
+ }
75
+
62
76
  // Publish timestamps for the package's recent releases, or nothing. Unlike `fetch_latest_version`
63
77
  // this never throws: the timestamps only enrich an explanation, so a package whose history cannot be
64
- // read keeps the report it had before (joshuafolkken/kit#808).
78
+ // read keeps the report it had before (joshuafolkken/kit#808). Read in the same source order as
79
+ // the latest, so the hold is explained against the registry `latest` came from.
65
80
  function fetch_release_times(
66
81
  versions_endpoint: string | undefined,
82
+ package_name: string,
67
83
  ): Record<string, string> | undefined {
68
84
  if (versions_endpoint === undefined || versions_endpoint.trim() === '') return undefined
69
- const endpoint = with_page_size(versions_endpoint, TIMES_PAGE_SIZE)
70
- const stdout = git_gh_exec.read_gh_api_sync({ path: endpoint, jq_filter: TIMES_JQ })
71
- if (stdout === undefined) return undefined
72
- const parsed = release_age.release_times_schema.safeParse(safe_json_parse(stdout))
73
85
 
74
- return parsed.success ? parsed.data : undefined
86
+ return (
87
+ npm_registry.read_release_times(package_name) ?? read_github_release_times(versions_endpoint)
88
+ )
75
89
  }
76
90
 
77
91
  export { fetch_latest_version, fetch_release_times, with_page_size }