@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
@@ -2,6 +2,8 @@
2
2
  import { fileURLToPath } from 'node:url'
3
3
  import { epic_busy } from '#scripts/epic/epic-busy'
4
4
  import { git_gh_command } from '#scripts/git/git-gh-command'
5
+ import type { OpenIssueData } from '#scripts/git/schemas'
6
+ import { issue_cite, type IssueCiter } from '#scripts/issue/issue-cite'
5
7
  import { error_text } from '#scripts/lib/error-message'
6
8
  import { run_carry } from '#scripts/run/run-carry'
7
9
  import { lane_await } from './lane-await'
@@ -150,10 +152,10 @@ function report_sweep(outcome: SweepOutcome): number {
150
152
 
151
153
  // `none` on standard output for the empty case as well, so `lane:list` keeps the contract the rest
152
154
  // of the commands follow — the prose belongs on standard error with every other explanation.
153
- function report_lanes(lanes: ReadonlyArray<LaneInfo>): number {
155
+ function report_lanes(lanes: ReadonlyArray<LaneInfo>, cite: IssueCiter): number {
154
156
  if (lanes.length === 0) console.error(lane_report.NO_LANES)
155
157
 
156
- console.info(lanes.length === 0 ? NONE_TOKEN : lane_report.describe_lanes(lanes))
158
+ console.info(lanes.length === 0 ? NONE_TOKEN : lane_report.describe_lanes(lanes, cite))
157
159
 
158
160
  return SUCCESS_EXIT_CODE
159
161
  }
@@ -192,23 +194,39 @@ const OCCUPANCY_UNREADABLE =
192
194
  // The `in-progress` issues this repository shows running, read the way `epic:next` reads lane
193
195
  // occupancy — never rebuilt (joshuafolkken/kit#2235). A read that could not see the whole listing is
194
196
  // `undefined`, so the difference is skipped rather than computed against a set known to be partial.
195
- async function to_holder_numbers(repo: string): Promise<ReadonlyArray<number> | undefined> {
197
+ async function to_holders(repo: string): Promise<ReadonlyArray<OpenIssueData> | undefined> {
196
198
  const read = await epic_busy.read_repository(repo)
197
199
  if (read.kind === 'idle') return []
198
200
 
199
- return read.kind === 'busy' ? read.issues.map((issue) => issue.number) : undefined
201
+ return read.kind === 'busy' ? read.issues : undefined
200
202
  }
201
203
 
202
- async function read_in_progress(): Promise<ReadonlyArray<number> | undefined> {
204
+ // The repository and its running issues, read once: the listing cites each lane from the titles here
205
+ // (joshuafolkken/kit#2943) and the occupancy check compares against the same set.
206
+ interface InProgress {
207
+ repo: string | undefined
208
+ holders: ReadonlyArray<OpenIssueData> | undefined
209
+ }
210
+
211
+ async function read_in_progress(): Promise<InProgress> {
203
212
  try {
204
213
  const repo = await git_gh_command.repo_get_name_with_owner()
205
214
 
206
- return repo === undefined ? undefined : await to_holder_numbers(repo)
215
+ return { repo, holders: repo === undefined ? undefined : await to_holders(repo) }
207
216
  } catch {
208
- return undefined
217
+ return { repo: undefined, holders: undefined }
209
218
  }
210
219
  }
211
220
 
221
+ function citer_of(in_progress: InProgress): IssueCiter {
222
+ const holders = in_progress.holders ?? []
223
+
224
+ return issue_cite.citer(
225
+ in_progress.repo,
226
+ new Map(holders.map((issue) => [String(issue.number), issue.title])),
227
+ )
228
+ }
229
+
212
230
  // A stranded lane's work tree is gone, so it holds no issue: it counts as no lane rather than a live
213
231
  // one, which is what turns its surviving `in-progress` label into a `stopped` anomaly.
214
232
  function live_lane_issues(lanes: ReadonlyArray<LaneInfo>): Array<number> {
@@ -217,16 +235,15 @@ function live_lane_issues(lanes: ReadonlyArray<LaneInfo>): Array<number> {
217
235
 
218
236
  // The lane/label difference, printed to standard error beside the listing. Wrapped so a GitHub read
219
237
  // that fails skips only this section — `lane:list`'s job is to list the lanes it already read.
220
- async function report_occupancy(lanes: ReadonlyArray<LaneInfo>): Promise<void> {
221
- const in_progress = await read_in_progress()
222
-
223
- if (in_progress === undefined) {
238
+ function report_occupancy(lanes: ReadonlyArray<LaneInfo>, in_progress: InProgress): void {
239
+ if (in_progress.holders === undefined) {
224
240
  console.error(OCCUPANCY_UNREADABLE)
225
241
 
226
242
  return
227
243
  }
228
244
 
229
- const report = lane_occupancy.classify(in_progress, {
245
+ const holders = in_progress.holders.map((issue) => issue.number)
246
+ const report = lane_occupancy.classify(holders, {
230
247
  kind: 'lanes',
231
248
  issues: live_lane_issues(lanes),
232
249
  })
@@ -239,9 +256,10 @@ async function list_command(rest: ReadonlyArray<string>): Promise<number> {
239
256
  if (rest.length > 0) return report_usage()
240
257
 
241
258
  const lanes = await lane_registry.list_lanes()
242
- const code = report_lanes(lanes)
259
+ const in_progress = await read_in_progress()
260
+ const code = report_lanes(lanes, citer_of(in_progress))
243
261
 
244
- await report_occupancy(lanes)
262
+ report_occupancy(lanes, in_progress)
245
263
 
246
264
  return code
247
265
  }
@@ -1,4 +1,5 @@
1
1
  import { agent_role_profile } from '#scripts/agent/agent-role-profile'
2
+ import { issue_cite, type IssueCiter } from '#scripts/issue/issue-cite'
2
3
  import { run_liveness } from '#scripts/run/run-liveness'
3
4
  import type { LaneInfo } from './lane-registry'
4
5
 
@@ -48,9 +49,14 @@ function event_column(lane: LaneInfo): string {
48
49
  return state ?? 'agent -'
49
50
  }
50
51
 
51
- function describe_lane(lane: LaneInfo): string {
52
+ // The plain `#N`, for a caller that could not read the repository the issue would link into.
53
+ const PLAIN_CITE: IssueCiter = issue_cite.citer(undefined, new Map())
54
+
55
+ // The issue leads as a citation (joshuafolkken/kit#2943): a lane status report copies this row, and a
56
+ // bare `#N` copied into it is what the Stop guard sends back.
57
+ function describe_lane(lane: LaneInfo, cite: IssueCiter = PLAIN_CITE): string {
52
58
  return [
53
- `#${lane.issue}`,
59
+ cite(lane.issue),
54
60
  `seat ${value_or_unknown(lane.seat)}`,
55
61
  `dev ${value_or_unknown(lane.development_port)}`,
56
62
  `preview ${value_or_unknown(lane.preview_port)}`,
@@ -63,10 +69,10 @@ function describe_lane(lane: LaneInfo): string {
63
69
  ].join(COLUMN_SEPARATOR)
64
70
  }
65
71
 
66
- function describe_lanes(lanes: ReadonlyArray<LaneInfo>): string {
72
+ function describe_lanes(lanes: ReadonlyArray<LaneInfo>, cite: IssueCiter = PLAIN_CITE): string {
67
73
  if (lanes.length === 0) return NO_LANES
68
74
 
69
- return lanes.map((lane) => describe_lane(lane)).join('\n')
75
+ return lanes.map((lane) => describe_lane(lane, cite)).join('\n')
70
76
  }
71
77
 
72
78
  function describe_ports(lane: LaneInfo): string {
@@ -0,0 +1,185 @@
1
+ import { adopt_toolkits } from '#scripts/adopt/adopt-toolkits'
2
+ import { propagate_run, type RunStep, type StepResult } from './propagate-run'
3
+ import { propagate_steps, type Release, type ReleasePlan } from './propagate-steps'
4
+ import { propagate_targets, type Manifest, type PropagateTarget } from './propagate-targets'
5
+
6
+ // What one `josh propagate` run carries, decided from the repository it runs in (joshuafolkken/kit#2879).
7
+ //
8
+ // The supplier is kit or any toolkit built on it — app-kit, game-kit — and a toolkit's propagation
9
+ // carries the base packages under it too, at the versions that toolkit itself has installed and was
10
+ // verified with rather than at the base's newest. Each consumer is delivered by the topmost toolkit
11
+ // it installs: a lower supplier skips it, because syncing only the base would write the overlay's
12
+ // paths back to the base's originals (`docs/sync.md` → base first, overlay after), and would hand the
13
+ // consumer a base the toolkit above it never verified. Discovery, ordering and the CLI name are
14
+ // `josh adopt`'s own reads (`scripts/adopt/adopt-toolkits.ts`), not a second copy of them.
15
+
16
+ const REFUSAL_PREFIX = 'Refusing to propagate:'
17
+ const SUPPLIER_REFUSAL = [
18
+ `${REFUSAL_PREFIX} this is not a ${adopt_toolkits.TOOLKIT_SCOPE} toolkit's own repository.`,
19
+ 'Propagation carries a published release outward, so it runs from the toolkit that published it',
20
+ '— kit, or a toolkit built on it that ships a CLI. Run it from that checkout instead.',
21
+ ].join('\n')
22
+ const UNRESOLVED_CAUSE =
23
+ 'declared here but not installed with a runnable CLI, so the consumers would receive this toolkit without it. Run `pnpm install`.'
24
+ const MISPLACED_CAUSE =
25
+ 'declared under `dependencies`, which leaves it out of the plan. Move it to `devDependencies`.'
26
+ const ARROW = ' → '
27
+ const LIST_SEPARATOR = ', '
28
+
29
+ interface Supplier {
30
+ package_name: string
31
+ bin_name: string
32
+ }
33
+
34
+ interface SupplierResolution {
35
+ supplier?: Supplier
36
+ refusal?: string
37
+ }
38
+
39
+ interface BaseResolution {
40
+ bases?: Array<Release>
41
+ cause?: string
42
+ }
43
+
44
+ interface PlanResolution {
45
+ plan?: ReleasePlan
46
+ refusal?: string
47
+ }
48
+
49
+ // A toolkit is a scoped package that ships a CLI — the same reading `josh adopt` applies to an
50
+ // installed one, applied here to the repository's own manifest.
51
+ function toolkit_name(manifest: Manifest): string | undefined {
52
+ const { name } = manifest
53
+
54
+ return name !== undefined && adopt_toolkits.is_toolkit_package(name) ? name : undefined
55
+ }
56
+
57
+ function supplier_of(manifest: Manifest | undefined): Supplier | undefined {
58
+ const package_name = manifest === undefined ? undefined : toolkit_name(manifest)
59
+ if (manifest === undefined || package_name === undefined) return undefined
60
+ const bin_name = adopt_toolkits.bin_name_of(package_name, manifest)
61
+
62
+ return bin_name === undefined ? undefined : { package_name, bin_name }
63
+ }
64
+
65
+ function resolve_supplier(project_root: string): SupplierResolution {
66
+ const supplier = supplier_of(propagate_targets.read_manifest(project_root))
67
+
68
+ return supplier === undefined ? { refusal: SUPPLIER_REFUSAL } : { supplier }
69
+ }
70
+
71
+ // A base toolkit pinned to the version installed in the supplier's own checkout — the one the
72
+ // supplier's verification gate ran against.
73
+ function pin_installed(project_root: string, release: Release): Release | undefined {
74
+ const version = propagate_targets.installed_version(project_root, release.package_name)
75
+
76
+ return version === undefined ? undefined : { ...release, version }
77
+ }
78
+
79
+ // Why a declared base toolkit would fall out of the plan, naming it — either way `josh adopt` refuses
80
+ // one for (joshuafolkken/kit#1540). A base left out is not merely left behind: every consumer that
81
+ // installs it would then read it as a toolkit above this one and be skipped.
82
+ function incomplete_cause(project_root: string): string | undefined {
83
+ const misplaced = adopt_toolkits.misplaced_toolkits(project_root)
84
+ if (misplaced.length > 0) return `${misplaced.join(LIST_SEPARATOR)} — ${MISPLACED_CAUSE}`
85
+ const unresolved = adopt_toolkits.unresolved_toolkits(project_root)
86
+
87
+ return unresolved.length === 0
88
+ ? undefined
89
+ : `${unresolved.join(LIST_SEPARATOR)} — ${UNRESOLVED_CAUSE}`
90
+ }
91
+
92
+ // The supplier's base toolkits, base first, or the names that cannot be carried.
93
+ function base_releases(project_root: string, supplier: Supplier): BaseResolution {
94
+ const cause = incomplete_cause(project_root)
95
+ if (cause !== undefined) return { cause }
96
+
97
+ const bases = adopt_toolkits
98
+ .discover_toolkits(project_root)
99
+ .filter((release) => release.package_name !== supplier.package_name)
100
+ .map((release) => pin_installed(project_root, release))
101
+
102
+ return bases.every((release) => release !== undefined) ? { bases } : { cause: UNRESOLVED_CAUSE }
103
+ }
104
+
105
+ // The releases one run carries: the base toolkits first, then the supplier at its own version, so the
106
+ // sync runs base first and the supplier's overlay is written last.
107
+ function build_plan(project_root: string, version: string): PlanResolution {
108
+ const { supplier, refusal } = resolve_supplier(project_root)
109
+ if (supplier === undefined) return { refusal: refusal ?? SUPPLIER_REFUSAL }
110
+ const { bases, cause } = base_releases(project_root, supplier)
111
+ if (bases === undefined) return { refusal: `${REFUSAL_PREFIX} ${cause ?? UNRESOLVED_CAUSE}` }
112
+ const releases = [...bases, { ...supplier, version }]
113
+
114
+ return { plan: { releases, origin: propagate_steps.PROPAGATE_ORIGIN } }
115
+ }
116
+
117
+ // The package the run publishes and waits for — by construction the plan's last release.
118
+ function supplier_name(plan: ReleasePlan): string {
119
+ return plan.releases.at(-1)?.package_name ?? ''
120
+ }
121
+
122
+ // A declared scoped package counts as a toolkit unless it is demonstrably a library: installed, and
123
+ // shipping no CLI. One that is not installed yet cannot be told apart, and treating it as a toolkit
124
+ // errs toward leaving the consumer to the propagation above rather than reverting its overlay.
125
+ function may_be_toolkit(repository_path: string, package_name: string): boolean {
126
+ if (adopt_toolkits.read_bin_name(repository_path, package_name) !== undefined) return true
127
+
128
+ return adopt_toolkits.is_unreachable_toolkit(repository_path, package_name)
129
+ }
130
+
131
+ function carriers_of(target: PropagateTarget, plan: ReleasePlan): Array<string> {
132
+ const carried = new Set(plan.releases.map((release) => release.package_name))
133
+
134
+ return adopt_toolkits
135
+ .all_declared_toolkits(target.path)
136
+ .filter((name) => !carried.has(name) && may_be_toolkit(target.path, name))
137
+ }
138
+
139
+ // A consumer that installs a toolkit this run does not carry is left to that toolkit's propagation.
140
+ // Only a candidate that would otherwise be processed or reported current is re-classified.
141
+ function mark_carried(target: PropagateTarget, plan: ReleasePlan): PropagateTarget {
142
+ if (target.state !== 'ready' && target.state !== 'up_to_date') return target
143
+ const carriers = carriers_of(target, plan)
144
+
145
+ return carriers.length === 0 ? target : { ...target, state: 'carried_above', carriers }
146
+ }
147
+
148
+ // What the dry run prints for the two steps that repeat per release: the versions each package is
149
+ // pinned to, and the sync order.
150
+ function plan_details(plan: ReleasePlan): Readonly<Record<string, string>> {
151
+ return {
152
+ [propagate_run.STEP_UPGRADE]: plan.releases
153
+ .map((release) => `${release.package_name}@${release.version}`)
154
+ .join(LIST_SEPARATOR),
155
+ [propagate_run.STEP_SYNC]: propagate_steps
156
+ .sync_commands(plan)
157
+ .map(({ command }) => command.join(' '))
158
+ .join(ARROW),
159
+ }
160
+ }
161
+
162
+ // The dry-run runner: it reports what each step would do and touches nothing.
163
+ function create_step_describer(plan: ReleasePlan): RunStep {
164
+ const details = plan_details(plan)
165
+
166
+ return (target: PropagateTarget, step: string): StepResult => {
167
+ const detail = details[step]
168
+ const suffix = detail === undefined ? '' : `: ${detail}`
169
+
170
+ console.info(` ${target.repo}: would run ${step}${suffix}`)
171
+
172
+ return { step, is_ok: true }
173
+ }
174
+ }
175
+
176
+ const propagate_plan = {
177
+ resolve_supplier,
178
+ build_plan,
179
+ supplier_name,
180
+ mark_carried,
181
+ create_step_describer,
182
+ }
183
+
184
+ export type { Supplier }
185
+ export { propagate_plan }
@@ -65,13 +65,17 @@ const SKIP_REASONS: Readonly<Record<string, string>> = {
65
65
  missing_checkout: 'no local checkout at the mapped path — reported, not cloned',
66
66
  unreadable: 'package.json could not be read',
67
67
  not_selected: 'not the repository --target named',
68
+ carried_above: 'delivered by the propagation of a toolkit above this one',
68
69
  }
69
70
 
70
71
  // The reason text for a candidate that is not going to be processed. A missing local checkout is
71
72
  // reported rather than cloned: propagation writes to a working tree, and creating one nobody asked
72
73
  // for is not a step this command may take on its own.
73
74
  function skip_reason(target: PropagateTarget): string {
74
- return SKIP_REASONS[target.state] ?? 'not eligible'
75
+ const reason = SKIP_REASONS[target.state] ?? 'not eligible'
76
+ if (target.carriers === undefined) return reason
77
+
78
+ return `${reason}: ${target.carriers.join(', ')}`
75
79
  }
76
80
 
77
81
  const LEFTOVER_NOTE = 'upgrade/sync changes left uncommitted'
@@ -88,13 +88,28 @@ function release_names(releases: ReadonlyArray<Release>): string {
88
88
  return `${names.slice(0, -1).join(NAME_SEPARATOR)}${LAST_SEPARATOR}${last}`
89
89
  }
90
90
 
91
+ // Each release with its own version, for a plan whose releases disagree on one — a toolkit's
92
+ // propagation carries the base packages at the versions that toolkit was verified with
93
+ // (joshuafolkken/kit#2879), so one shared "to <version>" would name a version most of them are not.
94
+ function versioned_names(releases: ReadonlyArray<Release>): string {
95
+ const named = releases.map((release) => ({
96
+ ...release,
97
+ package_name: `${release.package_name} to ${release.version}`,
98
+ }))
99
+
100
+ return `Upgrade ${release_names(named)}`
101
+ }
102
+
91
103
  // One release reproduces `issue_title` exactly, which is what keeps a `propagate` run's issue title
92
104
  // unchanged by the generalization.
93
105
  function plan_title(releases: ReadonlyArray<Release>): string {
94
106
  const [first] = releases
95
107
  if (first === undefined) return EMPTY_PLAN_TITLE
108
+ const is_one_version = releases.every((release) => release.version === first.version)
96
109
 
97
- return issue_title(release_names(releases), first.version)
110
+ return is_one_version
111
+ ? issue_title(release_names(releases), first.version)
112
+ : versioned_names(releases)
98
113
  }
99
114
 
100
115
  function plan_body(plan: ReleasePlan): string {
@@ -20,9 +20,17 @@ const MANIFEST_NAME = 'package.json'
20
20
  const NODE_MODULES = 'node_modules'
21
21
 
22
22
  // Why a candidate is not going to be processed, or that it is. `not_selected` is the one state set
23
- // after classification: a candidate `--target` left out (joshuafolkken/kit#2755).
23
+ // after classification: a candidate `--target` left out (joshuafolkken/kit#2755). `carried_above` is
24
+ // the other: a consumer that installs a toolkit above the supplier, which that toolkit's own
25
+ // propagation delivers instead (joshuafolkken/kit#2879).
24
26
  type TargetState =
25
- 'ready' | 'up_to_date' | 'not_downstream' | 'missing_checkout' | 'unreadable' | 'not_selected'
27
+ | 'ready'
28
+ | 'up_to_date'
29
+ | 'not_downstream'
30
+ | 'missing_checkout'
31
+ | 'unreadable'
32
+ | 'not_selected'
33
+ | 'carried_above'
26
34
 
27
35
  interface PropagateTarget {
28
36
  repo: string
@@ -31,6 +39,9 @@ interface PropagateTarget {
31
39
  // The range the consumer currently declares, when it declares one at all. Reported so a skip can
32
40
  // say what it skipped rather than only that it did.
33
41
  declared_range?: string
42
+ // The toolkits above the supplier this consumer installs, when it is `carried_above` — named in
43
+ // the skip so the report says which propagation will deliver it.
44
+ carriers?: ReadonlyArray<string>
34
45
  }
35
46
 
36
47
  // Only the fields propagation reads. `passthrough` is deliberate — a consumer manifest carries far
@@ -4,15 +4,14 @@ import { repo_discovery } from '#scripts/discovery/repo-discovery'
4
4
  import { PROJECT_ROOT } from '#scripts/init/init-paths'
5
5
  import { cli_flags } from '#scripts/lib/cli-flags'
6
6
  import { status_icons } from '#scripts/lib/status-icons'
7
- import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
8
- import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
9
7
  import { derive_versions_endpoint } from '#scripts/version/version-command-config'
10
8
  import { version_targets } from '#scripts/version/version-targets'
11
9
  import { propagate_git } from './propagate-git'
10
+ import { propagate_plan } from './propagate-plan'
12
11
  import { propagate_publish } from './propagate-publish'
13
12
  import { propagate_run } from './propagate-run'
14
13
  import { propagate_select, type TargetSelection } from './propagate-select'
15
- import { propagate_steps } from './propagate-steps'
14
+ import { propagate_steps, type ReleasePlan } from './propagate-steps'
16
15
  import { propagate_targets, type PropagateTarget } from './propagate-targets'
17
16
 
18
17
  // `josh propagate` — carry the release this repository just published into every consumer checked
@@ -78,7 +77,8 @@ function parse_options(argv: ReadonlyArray<string>): RunOptions {
78
77
  return usage === undefined ? options : { ...options, usage }
79
78
  }
80
79
 
81
- // Propagation runs from the supplier's own repository, and only there.
80
+ // Propagation runs from the supplier's own repository, and only there — kit's, or a toolkit's built
81
+ // on it (joshuafolkken/kit#2879).
82
82
  //
83
83
  // This is also what decides who propagates when several sessions are running: in the
84
84
  // per-repository concurrency model there is one session per checkout, so the session standing in
@@ -86,13 +86,7 @@ function parse_options(argv: ReadonlyArray<string>): RunOptions {
86
86
  // convention enforced at the boundary, not a lock — two checkouts of the supplier would both pass,
87
87
  // which is why each consumer is additionally refused unless its working tree is clean.
88
88
  function refuse_outside_source_repository(project_root: string): string | undefined {
89
- if (self_sync_guard.read_package_name(project_root) === KIT_PACKAGE_NAME) return undefined
90
-
91
- return [
92
- `Refusing to propagate: this is not ${KIT_PACKAGE_NAME}'s own repository.`,
93
- 'Propagation carries a published release outward, so it runs from the package that published it.',
94
- `Run it from the ${KIT_PACKAGE_NAME} checkout instead.`,
95
- ].join('\n')
89
+ return propagate_plan.resolve_supplier(project_root).refusal
96
90
  }
97
91
 
98
92
  // The version to carry: this repository's own declared version, which is what the merge published.
@@ -144,10 +138,14 @@ function resolve_run_version(project_root: string, is_dry_run = false): RunVersi
144
138
  return resolve_supplier_version(project_root, is_dry_run)
145
139
  }
146
140
 
147
- async function await_publish(target_version: string, is_skipped: boolean): Promise<boolean> {
141
+ async function await_publish(
142
+ package_name: string,
143
+ target_version: string,
144
+ is_skipped: boolean,
145
+ ): Promise<boolean> {
148
146
  if (is_skipped) return true
149
- console.info(`Waiting for ${KIT_PACKAGE_NAME}@${target_version} to appear in the registry…`)
150
- const endpoint = derive_versions_endpoint(KIT_PACKAGE_NAME)
147
+ console.info(`Waiting for ${package_name}@${target_version} to appear in the registry…`)
148
+ const endpoint = derive_versions_endpoint(package_name)
151
149
  const result = await propagate_publish.wait_for_publish(endpoint, target_version)
152
150
 
153
151
  if (result.state === 'published') {
@@ -179,49 +177,65 @@ function announce_run_version(resolved: RunVersion): string | undefined {
179
177
 
180
178
  interface RunPlan {
181
179
  version: string
180
+ releases: ReleasePlan
182
181
  targets: ReadonlyArray<PropagateTarget>
183
182
  }
184
183
 
185
184
  // The consumers this run will consider, narrowed by `--target`. Resolved before the publish wait so
186
- // a mistyped name fails at once instead of after minutes of polling the registry.
187
- function resolve_run_targets(version: string, target: string | undefined): TargetSelection {
185
+ // a mistyped name fails at once instead of after minutes of polling the registry. A consumer that
186
+ // installs a toolkit above the supplier is left to that toolkit's propagation (joshuafolkken/kit#2879).
187
+ function resolve_run_targets(
188
+ releases: ReleasePlan,
189
+ version: string,
190
+ target: string | undefined,
191
+ ): TargetSelection {
188
192
  const map = repo_discovery.discover_repositories(PROJECT_ROOT)
189
- const targets = propagate_targets.resolve_targets(map, KIT_PACKAGE_NAME, version)
193
+ const supplier = propagate_plan.supplier_name(releases)
194
+ const targets = propagate_targets
195
+ .resolve_targets(map, supplier, version)
196
+ .map((candidate) => propagate_plan.mark_carried(candidate, releases))
190
197
 
191
198
  return propagate_select.select_target(targets, target)
192
199
  }
193
200
 
194
- // The version and the consumers, or nothing once whatever stopped the run has been printed.
201
+ // Print why the run stopped before any consumer was touched.
202
+ function refuse_run(refusal: string): void {
203
+ console.error(refusal)
204
+ console.error(UNTOUCHED_NOTE)
205
+ }
206
+
207
+ interface PlanOutcome {
208
+ plan?: RunPlan
209
+ refusal?: string
210
+ }
211
+
212
+ // The releases and the consumers for a version already accepted, or the reason there are none.
213
+ function plan_consumers(version: string, options: RunOptions): PlanOutcome {
214
+ const { plan: releases, refusal } = propagate_plan.build_plan(PROJECT_ROOT, version)
215
+ if (releases === undefined) return { refusal: refusal ?? UNTOUCHED_NOTE }
216
+ const selection = resolve_run_targets(releases, version, options.target)
217
+ if (selection.refusal !== undefined) return { refusal: selection.refusal }
218
+
219
+ return { plan: { version, releases, targets: selection.targets } }
220
+ }
221
+
222
+ // The version, the releases and the consumers, or nothing once whatever stopped the run has been
223
+ // printed.
195
224
  function plan_run(options: RunOptions): RunPlan | undefined {
196
225
  const version = announce_run_version(resolve_run_version(PROJECT_ROOT, options.is_dry_run))
197
226
  if (version === undefined) return undefined
198
- const selection = resolve_run_targets(version, options.target)
227
+ const { plan, refusal } = plan_consumers(version, options)
228
+ if (refusal !== undefined) refuse_run(refusal)
199
229
 
200
- if (selection.refusal !== undefined) {
201
- console.error(selection.refusal)
202
- console.error(UNTOUCHED_NOTE)
203
-
204
- return undefined
205
- }
206
-
207
- return { version, targets: selection.targets }
230
+ return plan
208
231
  }
209
232
 
210
233
  // Everything after the publish wait: run each consumer, print one report.
211
234
  function propagate_to_consumers(plan: RunPlan, is_dry_run: boolean): number {
212
- const { version, targets } = plan
235
+ const { releases, targets } = plan
213
236
  const step = is_dry_run
214
- ? propagate_steps.describe_step
215
- : propagate_steps.create_step_runner({
216
- releases: [
217
- {
218
- package_name: KIT_PACKAGE_NAME,
219
- version,
220
- bin_name: propagate_steps.JOSH_BIN,
221
- },
222
- ],
223
- origin: propagate_steps.PROPAGATE_ORIGIN,
224
- })
237
+ ? propagate_plan.create_step_describer(releases)
238
+ : propagate_steps.create_step_runner(releases)
225
239
  const results = propagate_run.run_targets(
226
240
  targets,
227
241
  step,
@@ -236,8 +250,12 @@ function propagate_to_consumers(plan: RunPlan, is_dry_run: boolean): number {
236
250
 
237
251
  // A dry run never waits: the whole reason to ask for one is to see the target list, and a wait that
238
252
  // times out would end the run before the list was ever printed.
239
- async function resolve_publish(version: string, options: RunOptions): Promise<boolean> {
240
- return await await_publish(version, options.is_dry_run || options.is_publish_wait_skipped)
253
+ async function resolve_publish(plan: RunPlan, options: RunOptions): Promise<boolean> {
254
+ return await await_publish(
255
+ propagate_plan.supplier_name(plan.releases),
256
+ plan.version,
257
+ options.is_dry_run || options.is_publish_wait_skipped,
258
+ )
241
259
  }
242
260
 
243
261
  async function run(argv: ReadonlyArray<string>): Promise<number> {
@@ -251,7 +269,7 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
251
269
 
252
270
  const plan = plan_run(options)
253
271
  if (plan === undefined) return FAILURE_EXIT_CODE
254
- if (!(await resolve_publish(plan.version, options))) return FAILURE_EXIT_CODE
272
+ if (!(await resolve_publish(plan, options))) return FAILURE_EXIT_CODE
255
273
 
256
274
  return propagate_to_consumers(plan, options.is_dry_run)
257
275
  }
@@ -219,6 +219,7 @@ function plan(project_npmrc: string, user_npmrc: string, lockfile: string): Migr
219
219
  const migrate_logic = {
220
220
  plan,
221
221
  github_tarballs,
222
+ is_github_tarball,
222
223
  rewrite_kit_lockfile,
223
224
  registry_of,
224
225
  scoped_packages,
@@ -4,7 +4,7 @@ import { z } from 'zod'
4
4
 
5
5
  const API_URL = 'https://api.github.com/repos/joshuafolkken/kit/releases'
6
6
  const WORKFLOW_RUNS_URL =
7
- 'https://api.github.com/repos/joshuafolkken/kit/actions/workflows/publish.yml/runs?event=repository_dispatch&per_page=100'
7
+ 'https://api.github.com/repos/joshuafolkken/kit/actions/workflows/publish.yml/runs?per_page=100'
8
8
  const WORKFLOW_JOBS_URL = 'https://api.github.com/repos/joshuafolkken/kit/actions/runs'
9
9
  const TAG_PATTERN = /^v\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$/u
10
10
  const REQUEST_TIMEOUT_MS = 30_000
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto'
2
+ import { lstatSync } from 'node:fs'
2
3
  import { git_command } from '#scripts/git/git-command'
3
4
  import { PROJECT_ROOT } from '#scripts/init/init-paths'
4
5
  import { stamp_file } from '#scripts/josh/stamp-file'
@@ -240,6 +241,47 @@ function check(now: number = Date.now(), root?: string): AttestVerdict {
240
241
  return verdict
241
242
  }
242
243
 
244
+ function is_written_since(source: string, taken_at: string): boolean {
245
+ try {
246
+ const stats = lstatSync(source)
247
+
248
+ return stats.isFile() && stats.mtimeMs >= Date.parse(taken_at)
249
+ } catch {
250
+ return false
251
+ }
252
+ }
253
+
254
+ // **Whether the last briefed review of this branch finished** (joshuafolkken/kit#2945). A ship resumed
255
+ // after a stop re-issues round 1, and without this answer it briefed the whole change again — the
256
+ // files the finished review had just read, re-read at full cost. It is asked before `record_target`
257
+ // mints the next nonce, since that call drops the record it reads. The branch must match: the pointer
258
+ // is keyed on the root, and a lane root outlives the branch a previous run reviewed there.
259
+ //
260
+ // **An attestation alone is not a finished review.** The reviewer may attest and then die before it
261
+ // reports — a timeout, a usage limit — and narrowing on that would call files nobody read "already
262
+ // read". So the findings file, written only once the review has its verdict, must postdate the brief
263
+ // this pointer names; one left from an earlier review is older than that brief and does not count.
264
+ function is_reviewed_on(checkout: ReviewCheckout, findings_path: string, root?: string): boolean {
265
+ const pointer = read_pointer(root)
266
+
267
+ if (pointer === undefined) return false
268
+
269
+ const verdict = verdict_for(pointer.nonce)
270
+
271
+ if (verdict.status !== 'ok' || verdict.expected?.branch !== checkout.branch) return false
272
+
273
+ return is_written_since(findings_path, pointer.taken_at)
274
+ }
275
+
276
+ // Whether a record written at `written_at` belongs to the brief the pointer names rather than an earlier
277
+ // one (joshuafolkken/kit#2945): that brief writes it right after minting the pointer. ISO timestamps
278
+ // order as strings.
279
+ function is_briefed_since(written_at: string, root?: string): boolean {
280
+ const pointer = read_pointer(root)
281
+
282
+ return pointer !== undefined && written_at >= pointer.taken_at
283
+ }
284
+
243
285
  // The pointer is keyed on the repository root rather than on `process.cwd()`, which is what
244
286
  // `PROJECT_ROOT` is. `josh review:brief` records against the root it just asked git for, so the check
245
287
  // has to ask the same question: run the two from different directories — one from `scripts/`, one
@@ -303,6 +345,8 @@ const review_attest = {
303
345
  clear_here,
304
346
  EXPECT_PREFIX,
305
347
  expect_path,
348
+ is_briefed_since,
349
+ is_reviewed_on,
306
350
  MISMATCH_VERDICT,
307
351
  MISSING_REASON,
308
352
  POINTER_PREFIX,