@joshuafolkken/kit 1.972.0 → 1.983.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 (87) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +6 -5
  2. package/.claude/skills/workflow-commands/SKILL.md +9 -9
  3. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  4. package/.claude/skills/workflow-commands/fullrun-steps.md +8 -8
  5. package/.claude/skills/workflow-commands/fullrun.md +1 -0
  6. package/.claude/skills/workflow-commands/halfrun.md +16 -12
  7. package/.claude/skills/workflow-commands/issue-comments.md +1 -1
  8. package/.claude/skills/workflow-commands/issue-scout.md +21 -22
  9. package/.claude/skills/workflow-commands/kickoff.md +8 -9
  10. package/.claude/skills/workflow-commands/observation-filing.md +9 -10
  11. package/.claude/skills/workflow-commands/prerequisite.md +7 -6
  12. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  13. package/.claude/skills/workflow-commands/rule-residency.md +2 -2
  14. package/.claude/skills/workflow-commands/split-assessment.md +4 -3
  15. package/.claude/skills/workflow-commands/target-repository.md +7 -6
  16. package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
  17. package/README.md +24 -20
  18. package/dist/hooks/batch-guard.js +4 -4
  19. package/dist/hooks/{chunk-ZPLXLJ7O.js → chunk-47SPMGOD.js} +16 -1
  20. package/dist/hooks/{chunk-BVMSPG3M.js → chunk-5JX6HXDX.js} +3 -3
  21. package/dist/hooks/{chunk-KQG4776P.js → chunk-APVOACVT.js} +1 -1
  22. package/dist/hooks/{chunk-2K52BRWV.js → chunk-ASUZGZFT.js} +2 -2
  23. package/dist/hooks/{chunk-CRGJTD6E.js → chunk-ERXMJJOV.js} +1 -1
  24. package/dist/hooks/{chunk-WWTMHHZU.js → chunk-IM3KM25Y.js} +61 -30
  25. package/dist/hooks/{chunk-BNWIOJFU.js → chunk-LBXQHXMC.js} +138 -358
  26. package/dist/hooks/{chunk-NXL3SBXY.js → chunk-P3UX7SFN.js} +2 -2
  27. package/dist/hooks/{chunk-F6OM6SIG.js → chunk-RFPHKU7C.js} +12 -12
  28. package/dist/hooks/{chunk-ZYYSBNR6.js → chunk-ZT65LR3U.js} +4 -4
  29. package/dist/hooks/codex-hook-adapter.js +10 -10
  30. package/dist/hooks/duplicate-read-guard.js +5 -5
  31. package/dist/hooks/format-edited.js +2 -2
  32. package/dist/hooks/investigation-guard.js +5 -5
  33. package/dist/hooks/pretool-guard.js +9 -9
  34. package/dist/hooks/stop-guard.js +4 -4
  35. package/dist/josh.js +16 -1
  36. package/package.json +2 -2
  37. package/prompts/collaboration-workflow/epic-bundle.md +8 -8
  38. package/prompts/collaboration-workflow/issue-template.md +5 -5
  39. package/prompts/collaboration-workflow/rule-delivery.md +43 -30
  40. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  41. package/prompts/collaboration-workflow/wip-cap.md +3 -3
  42. package/prompts/review.md +11 -9
  43. package/scripts/ci/pr-classification.ts +13 -12
  44. package/scripts/delegation/delegation-policy.ts +9 -9
  45. package/scripts/document/document-byte-budget.ts +1 -1
  46. package/scripts/git/issue-labels.ts +52 -5
  47. package/scripts/init/init-bootstrap.ts +92 -0
  48. package/scripts/init/init-install.ts +4 -1
  49. package/scripts/init/init-logic-workspace.ts +1 -1
  50. package/scripts/init/init.ts +23 -5
  51. package/scripts/init/start-plan.ts +1 -1
  52. package/scripts/init/start-steps.ts +5 -28
  53. package/scripts/issue/issue-file-cli.ts +188 -0
  54. package/scripts/issue/issue-file.ts +192 -0
  55. package/scripts/issue/issue-lint-cli.ts +1 -1
  56. package/scripts/issue/issue-scout-cli.ts +41 -97
  57. package/scripts/issue/issue-scout-closed.ts +102 -0
  58. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +9 -1
  59. package/scripts/josh/josh-command-map.ts +1 -0
  60. package/scripts/josh/josh-commands-issue.ts +11 -0
  61. package/scripts/josh/josh-commands-project.ts +5 -1
  62. package/scripts/propagate/propagate-run.ts +1 -0
  63. package/scripts/propagate/propagate-select.ts +72 -0
  64. package/scripts/propagate/propagate-targets.ts +5 -3
  65. package/scripts/propagate/propagate.ts +86 -24
  66. package/scripts/repo/repository-labels.ts +91 -0
  67. package/scripts/rules/bash-triggers.ts +19 -6
  68. package/scripts/rules/delivered-rules.ts +28 -32
  69. package/scripts/rules/direct-filing.ts +38 -0
  70. package/scripts/rules/filing-cap.ts +11 -8
  71. package/scripts/rules/issue-fold.ts +4 -4
  72. package/scripts/rules/oracle-consulted.ts +4 -4
  73. package/scripts/rules/oracle-firing.ts +13 -16
  74. package/scripts/rules/rule-body-guard.ts +6 -9
  75. package/scripts/rules/stop-rules.ts +3 -2
  76. package/scripts/run/run-entry-cli.ts +34 -1
  77. package/scripts/run/run-halfrun-resume.ts +104 -0
  78. package/scripts/run/run-hold-cli.ts +27 -4
  79. package/scripts/run/run-hold.ts +11 -0
  80. package/scripts/security/security-audit.ts +12 -1
  81. package/scripts/sync/sync.ts +6 -0
  82. package/scripts/test/pilot-files.ts +0 -1
  83. package/scripts/version/fresh-metadata-cache.ts +36 -0
  84. package/scripts/version/latest-update.ts +2 -1
  85. package/scripts/rules/issue-bug-label.ts +0 -85
  86. package/scripts/rules/issue-filing-args.ts +0 -50
  87. package/scripts/rules/issue-scout.ts +0 -64
@@ -7,18 +7,17 @@ import { epic_bundle, type BacklogIssue, type BundleDecision } from '#scripts/ep
7
7
  import { epic_bundle_cli } from '#scripts/epic/epic-bundle-cli'
8
8
  import { epic_bundle_gaps } from '#scripts/epic/epic-bundle-gaps'
9
9
  import { git_gh_command } from '#scripts/git/git-gh-command'
10
- import { EPIC_LABEL, has_any_label } from '#scripts/git/issue-labels'
11
- import { PAGE_CEILING_CAUSE } from '#scripts/git/listing-cutoff'
12
- import { parse_json_array_or_undefined } from '#scripts/git/parse-json-array'
13
- import { issue_label_schema } from '#scripts/git/schemas'
14
10
  import { issue_citation } from '#scripts/rules/issue-citation'
15
- import { z } from 'zod'
16
11
  import {
17
12
  issue_scout,
18
13
  type DuplicateCandidate,
19
14
  type DuplicateSearch,
20
15
  type ScoutIssue,
21
16
  } from './issue-scout'
17
+ import { issue_scout_closed, type ClosedScan } from './issue-scout-closed'
18
+
19
+ const { CLOSED_CEILING_LINE, CLOSED_UNREADABLE_LINE, read_recently_closed, warn_about_closed } =
20
+ issue_scout_closed
22
21
 
23
22
  // `josh issue:scout "<title>" [--body "<summary>"]` — before an issue is filed, answer the two
24
23
  // questions every `new` entry point asks first: has this already been filed, and which epic does it
@@ -44,87 +43,6 @@ const UNKNOWN_REPO_MESSAGE =
44
43
  // to be one. Zero is not a valid issue number, so it collides with nothing in the listing.
45
44
  const DRAFT_NUMBER = 0
46
45
 
47
- // How far back the closed half of the scan reaches (joshuafolkken/kit#1679). One page: the duplicate
48
- // this half exists to catch closed hours ago, not months, and the listing is ordered by update so
49
- // the newest hundred already covers a run's own day several times over.
50
- //
51
- // **It is a window, not a cut, which is why reaching it is not warned about.** Every other listing in
52
- // this command reports its `limit` as a gap, because there the caller wanted the whole backlog and
53
- // got part of it; here the hundredth-most-recently-updated closed issue is where the question itself
54
- // stops. A warning printed on every invocation in any repository with a hundred closed issues would
55
- // carry no signal and would train a reader past the gap lines beside it that do. **The page ceiling
56
- // is still reported**, and `ClosedScan` below says why the two are not the same event.
57
- const CLOSED_LIMIT = 100
58
-
59
- // Read through `has_any_label` rather than compared directly, for the casing reason
60
- // `issue-labels.ts` records: GitHub keeps the spelling a label was created with.
61
- const EPIC_LABELS: ReadonlySet<string> = new Set([EPIC_LABEL])
62
-
63
- // The listing's own label shape, not a second spelling of it: a copy here would be the drift the
64
- // shared schema module exists to prevent.
65
- const closed_labels_schema = z.array(issue_label_schema).optional()
66
-
67
- const closed_row_schema = z.object({
68
- number: z.number(),
69
- title: z.string().nullable(),
70
- labels: closed_labels_schema,
71
- })
72
-
73
- type ClosedRow = z.infer<typeof closed_row_schema>
74
-
75
- // `rows` is `undefined` — never `[]` — when the listing failed: "nothing closed recently" is the
76
- // answer that sends a run straight on to file the duplicate, which is the failure this half was
77
- // added to prevent.
78
- function to_closed_row(row: ClosedRow): ScoutIssue {
79
- return {
80
- number: row.number,
81
- title: row.title ?? '',
82
- is_closed: true,
83
- // A closed epic is still a container, and `epic_bundle_cli`'s marking cannot reach it: that one
84
- // is built from the *open* epic listing. Reported as a duplicate it reads as "this work is
85
- // already done" against an epic that never had an implementation of its own.
86
- is_epic: has_any_label(row.labels, EPIC_LABELS),
87
- }
88
- }
89
-
90
- function to_closed_rows(json: string): Array<ScoutIssue> | undefined {
91
- const rows = parse_json_array_or_undefined(json, closed_row_schema)
92
-
93
- return rows?.map((row) => to_closed_row(row))
94
- }
95
-
96
- // The rows, and whether the **page ceiling** stopped the paging short of the window. That is a
97
- // different thing from the `limit` above, which is the window itself: pull requests are filtered out
98
- // client-side, so a repository whose recent closures are mostly merged pull requests can select
99
- // fewer than `CLOSED_LIMIT` issue rows while the listing still has more to give. The scan then covers
100
- // less than it was asked for, which `git-gh-issue.ts`'s disposition table records as a warning.
101
- interface ClosedScan {
102
- rows: Array<ScoutIssue> | undefined
103
- is_capped: boolean
104
- }
105
-
106
- async function read_recently_closed(): Promise<ClosedScan> {
107
- const { json, is_capped } = await git_gh_command.issue_list_recently_closed(CLOSED_LIMIT)
108
-
109
- return { rows: json === undefined ? undefined : to_closed_rows(json), is_capped }
110
- }
111
-
112
- // The open half already ran, so both of these are gaps in the answer rather than failures of it —
113
- // the same disposition every other truncated listing in this command takes.
114
- const CLOSED_UNREADABLE_LINE =
115
- '⚠ The recently closed issues could not be read, so the scan below covers open issues only — work that finished hours ago will not appear in it.'
116
- const CLOSED_CEILING_LINE = `⚠ The recently closed listing ${PAGE_CEILING_CAUSE}, so a duplicate that closed inside the window may not be below.`
117
-
118
- function warn_about_closed(scan: ClosedScan): void {
119
- if (scan.rows === undefined) {
120
- console.error(CLOSED_UNREADABLE_LINE)
121
-
122
- return
123
- }
124
-
125
- if (scan.is_capped) console.error(CLOSED_CEILING_LINE)
126
- }
127
-
128
46
  interface ScoutArguments {
129
47
  title: string
130
48
  body: string
@@ -333,28 +251,32 @@ function format_epic_answer(
333
251
  // are still being worked on, and a closed one can neither gain a sibling nor be recommended as an
334
252
  // epic — so widening the placement pool with them would answer a different question from the one it
335
253
  // was asked (joshuafolkken/kit#1679).
254
+ function duplicates_of(
255
+ draft: BacklogIssue,
256
+ issues: ReadonlyArray<BacklogIssue>,
257
+ closed: ReadonlyArray<ScoutIssue>,
258
+ ): DuplicateSearch {
259
+ const references = epic_audit_logic.parse_references(draft.body, draft.repo)
260
+
261
+ return issue_scout.find_duplicates(draft.title ?? '', [...issues, ...closed], references)
262
+ }
263
+
336
264
  function format_report(
337
265
  draft: BacklogIssue,
338
266
  issues: ReadonlyArray<BacklogIssue>,
339
267
  is_membership_established: boolean,
340
268
  closed: ReadonlyArray<ScoutIssue> = [],
341
269
  ): string {
342
- const references = epic_audit_logic.parse_references(draft.body, draft.repo)
343
- const duplicates = issue_scout.find_duplicates(
344
- draft.title ?? '',
345
- [...issues, ...closed],
346
- references,
347
- )
348
270
  const decision = epic_bundle.decide_bundle(draft, issues)
349
271
 
350
272
  return [
351
- format_duplicates(duplicates),
273
+ format_duplicates(duplicates_of(draft, issues, closed)),
352
274
  format_epic_answer(draft, decision, issues, is_membership_established),
353
275
  ].join('\n')
354
276
  }
355
277
 
356
278
  type Widened = Awaited<ReturnType<typeof epic_bundle_cli.widen_with_referenced>>
357
- type ClosedListing = Awaited<ReturnType<typeof read_recently_closed>>
279
+ type ClosedListing = ClosedScan
358
280
 
359
281
  function is_duplicate_scan_incomplete(widened: Widened, closed: ClosedListing): boolean {
360
282
  const has_open_gap = widened.cutoff !== undefined && widened.cutoff !== 'none'
@@ -397,16 +319,23 @@ function linkified_report(
397
319
  return issue_citation.linkify(qualified, repo)
398
320
  }
399
321
 
322
+ // The scan's answer in both shapes it is read in: the report a person reads, and the candidate numbers
323
+ // `issue:file` holds a filing to (joshuafolkken/kit#2808).
324
+ interface ScoutOutcome {
325
+ report: string
326
+ candidates: ReadonlyArray<number>
327
+ }
328
+
400
329
  // The backlog is read without its `blocked-by` relations: a draft has no number, so no recorded
401
330
  // dependency can name it and it declares none — the reads cannot change either half of the answer,
402
331
  // and skipping them takes one request per open issue off the command a run makes before every filing.
403
- async function report(args: ScoutArguments, repo: string): Promise<number> {
332
+ async function scout(args: ScoutArguments, repo: string): Promise<ScoutOutcome | undefined> {
404
333
  const backlog = await epic_bundle_cli.read_backlog(repo, { include_relations: false })
405
334
 
406
335
  if (!backlog.is_readable) {
407
336
  console.error(epic_bundle_cli.unreadable_backlog_message(backlog))
408
337
 
409
- return FAILURE_EXIT_CODE
338
+ return undefined
410
339
  }
411
340
 
412
341
  const draft = draft_of(args, repo)
@@ -420,9 +349,22 @@ async function report(args: ScoutArguments, repo: string): Promise<number> {
420
349
 
421
350
  epic_bundle_cli.warn_about_gaps(widened)
422
351
  warn_about_closed(closed)
352
+ const duplicates = duplicates_of(draft, widened.issues, closed.rows ?? [])
353
+
423
354
  // The same cut `warn_about_gaps` reports on standard error, read once more so standard output is
424
355
  // held to it too — a ⚠ beside an executable instruction is not what stops a run acting on it.
425
- console.info(linkified_report(draft, widened, closed, repo))
356
+ return {
357
+ report: linkified_report(draft, widened, closed, repo),
358
+ candidates: duplicates.candidates.map((candidate) => candidate.number),
359
+ }
360
+ }
361
+
362
+ async function report(args: ScoutArguments, repo: string): Promise<number> {
363
+ const outcome = await scout(args, repo)
364
+
365
+ if (outcome === undefined) return FAILURE_EXIT_CODE
366
+
367
+ console.info(outcome.report)
426
368
 
427
369
  return SUCCESS_EXIT_CODE
428
370
  }
@@ -473,10 +415,12 @@ const issue_scout_cli = {
473
415
  format_duplicates,
474
416
  format_epic_decision,
475
417
  format_report,
418
+ scout,
476
419
  run,
477
420
  main,
478
421
  }
479
422
 
480
423
  if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
481
424
 
425
+ export type { ScoutArguments, ScoutOutcome }
482
426
  export { issue_scout_cli }
@@ -0,0 +1,102 @@
1
+ import { git_gh_command } from '#scripts/git/git-gh-command'
2
+ import { EPIC_LABEL, has_any_label } from '#scripts/git/issue-labels'
3
+ import { PAGE_CEILING_CAUSE } from '#scripts/git/listing-cutoff'
4
+ import { parse_json_array_or_undefined } from '#scripts/git/parse-json-array'
5
+ import { issue_label_schema } from '#scripts/git/schemas'
6
+ import { z } from 'zod'
7
+ import type { ScoutIssue } from './issue-scout'
8
+
9
+ // The closed half of `issue:scout`'s duplicate scan (joshuafolkken/kit#1679), held apart from the CLI
10
+ // so the command that reports the scan and the one that files after it (`issue:file`,
11
+ // joshuafolkken/kit#2808) read one listing rather than two.
12
+
13
+ // How far back the closed half of the scan reaches (joshuafolkken/kit#1679). One page: the duplicate
14
+ // this half exists to catch closed hours ago, not months, and the listing is ordered by update so
15
+ // the newest hundred already covers a run's own day several times over.
16
+ //
17
+ // **It is a window, not a cut, which is why reaching it is not warned about.** Every other listing in
18
+ // this command reports its `limit` as a gap, because there the caller wanted the whole backlog and
19
+ // got part of it; here the hundredth-most-recently-updated closed issue is where the question itself
20
+ // stops. A warning printed on every invocation in any repository with a hundred closed issues would
21
+ // carry no signal and would train a reader past the gap lines beside it that do. **The page ceiling
22
+ // is still reported**, and `ClosedScan` below says why the two are not the same event.
23
+ const CLOSED_LIMIT = 100
24
+
25
+ // Read through `has_any_label` rather than compared directly, for the casing reason
26
+ // `issue-labels.ts` records: GitHub keeps the spelling a label was created with.
27
+ const EPIC_LABELS: ReadonlySet<string> = new Set([EPIC_LABEL])
28
+
29
+ // The listing's own label shape, not a second spelling of it: a copy here would be the drift the
30
+ // shared schema module exists to prevent.
31
+ const closed_labels_schema = z.array(issue_label_schema).optional()
32
+
33
+ const closed_row_schema = z.object({
34
+ number: z.number(),
35
+ title: z.string().nullable(),
36
+ labels: closed_labels_schema,
37
+ })
38
+
39
+ type ClosedRow = z.infer<typeof closed_row_schema>
40
+
41
+ // `rows` is `undefined` — never `[]` — when the listing failed: "nothing closed recently" is the
42
+ // answer that sends a run straight on to file the duplicate, which is the failure this half was
43
+ // added to prevent.
44
+ function to_closed_row(row: ClosedRow): ScoutIssue {
45
+ return {
46
+ number: row.number,
47
+ title: row.title ?? '',
48
+ is_closed: true,
49
+ // A closed epic is still a container, and `epic_bundle_cli`'s marking cannot reach it: that one
50
+ // is built from the *open* epic listing. Reported as a duplicate it reads as "this work is
51
+ // already done" against an epic that never had an implementation of its own.
52
+ is_epic: has_any_label(row.labels, EPIC_LABELS),
53
+ }
54
+ }
55
+
56
+ function to_closed_rows(json: string): Array<ScoutIssue> | undefined {
57
+ const rows = parse_json_array_or_undefined(json, closed_row_schema)
58
+
59
+ return rows?.map((row) => to_closed_row(row))
60
+ }
61
+
62
+ // The rows, and whether the **page ceiling** stopped the paging short of the window. That is a
63
+ // different thing from the `limit` above, which is the window itself: pull requests are filtered out
64
+ // client-side, so a repository whose recent closures are mostly merged pull requests can select
65
+ // fewer than `CLOSED_LIMIT` issue rows while the listing still has more to give. The scan then covers
66
+ // less than it was asked for, which `git-gh-issue.ts`'s disposition table records as a warning.
67
+ interface ClosedScan {
68
+ rows: Array<ScoutIssue> | undefined
69
+ is_capped: boolean
70
+ }
71
+
72
+ async function read_recently_closed(): Promise<ClosedScan> {
73
+ const { json, is_capped } = await git_gh_command.issue_list_recently_closed(CLOSED_LIMIT)
74
+
75
+ return { rows: json === undefined ? undefined : to_closed_rows(json), is_capped }
76
+ }
77
+
78
+ // The open half already ran, so both of these are gaps in the answer rather than failures of it —
79
+ // the same disposition every other truncated listing in this command takes.
80
+ const CLOSED_UNREADABLE_LINE =
81
+ '⚠ The recently closed issues could not be read, so the scan below covers open issues only — work that finished hours ago will not appear in it.'
82
+ const CLOSED_CEILING_LINE = `⚠ The recently closed listing ${PAGE_CEILING_CAUSE}, so a duplicate that closed inside the window may not be below.`
83
+
84
+ function warn_about_closed(scan: ClosedScan): void {
85
+ if (scan.rows === undefined) {
86
+ console.error(CLOSED_UNREADABLE_LINE)
87
+
88
+ return
89
+ }
90
+
91
+ if (scan.is_capped) console.error(CLOSED_CEILING_LINE)
92
+ }
93
+
94
+ const issue_scout_closed = {
95
+ CLOSED_CEILING_LINE,
96
+ CLOSED_UNREADABLE_LINE,
97
+ read_recently_closed,
98
+ warn_about_closed,
99
+ }
100
+
101
+ export type { ClosedScan }
102
+ export { issue_scout_closed }
@@ -48,6 +48,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
48
48
  "ibl": "issue:backlinks",
49
49
  "ici": "issue:cite",
50
50
  "icm": "issue:comment",
51
+ "ifl": "issue:file",
51
52
  "ig": "investigation:guard",
52
53
  "iln": "issue:lint",
53
54
  "ird": "issue:read",
@@ -485,6 +486,13 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
485
486
  "network",
486
487
  ],
487
488
  ],
489
+ "issue:file": [
490
+ "<title> --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>] [--repo <owner/repo>] [--distinct <N,…>]",
491
+ "automation",
492
+ [
493
+ "network",
494
+ ],
495
+ ],
488
496
  "issue:fold": [
489
497
  "<title>... [--not-separable] [--json]",
490
498
  "automation",
@@ -765,7 +773,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
765
773
  ],
766
774
  ],
767
775
  "propagate": [
768
- "[--dry-run] [--skip-publish-wait]",
776
+ "[--dry-run] [--skip-publish-wait] [--target <repo>]",
769
777
  "maintainer",
770
778
  [
771
779
  "files",
@@ -93,6 +93,7 @@ const ALIASES: Record<string, string> = {
93
93
  ird: 'issue:read',
94
94
  ist: 'issue:state',
95
95
  isc: 'issue:scout',
96
+ ifl: 'issue:file',
96
97
  isf: 'issue:fold',
97
98
  isfe: 'issue:fold-existing',
98
99
  dfr: 'defect:rate',
@@ -63,6 +63,17 @@ const ISSUE_COMMANDS: Record<string, CommandEntry> = {
63
63
  category: 'AI tools',
64
64
  reference: ['<issue> --body <text> | --body-file <path>', 'automation', ['network']],
65
65
  },
66
+ 'issue:file': {
67
+ script: 'scripts/issue/issue-file-cli.ts',
68
+ description:
69
+ 'File an Issue with every filing step: lint, Origin, duplicate scout, labels, then epic:bundle',
70
+ category: 'AI tools',
71
+ reference: [
72
+ '<title> --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>] [--repo <owner/repo>] [--distinct <N,…>]',
73
+ 'automation',
74
+ ['network'],
75
+ ],
76
+ },
66
77
  }
67
78
  /* eslint-enable @typescript-eslint/naming-convention */
68
79
 
@@ -67,7 +67,11 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
67
67
  script: 'scripts/propagate/propagate.ts',
68
68
  description: 'Carry the published release into every consumer repository next to this one',
69
69
  category: 'Project',
70
- reference: ['[--dry-run] [--skip-publish-wait]', 'maintainer', ['files', 'network']],
70
+ reference: [
71
+ '[--dry-run] [--skip-publish-wait] [--target <repo>]',
72
+ 'maintainer',
73
+ ['files', 'network'],
74
+ ],
71
75
  },
72
76
  adopt: {
73
77
  script: 'scripts/adopt/adopt.ts',
@@ -64,6 +64,7 @@ const SKIP_REASONS: Readonly<Record<string, string>> = {
64
64
  not_downstream: 'does not depend on this package',
65
65
  missing_checkout: 'no local checkout at the mapped path — reported, not cloned',
66
66
  unreadable: 'package.json could not be read',
67
+ not_selected: 'not the repository --target named',
67
68
  }
68
69
 
69
70
  // The reason text for a candidate that is not going to be processed. A missing local checkout is
@@ -0,0 +1,72 @@
1
+ import { propagate_run } from './propagate-run'
2
+ import type { PropagateTarget, TargetState } from './propagate-targets'
3
+
4
+ // Narrowing a run to the one consumer `--target` names (joshuafolkken/kit#2755).
5
+ //
6
+ // Every other consumer stays in the list as `not_selected`, so the report still accounts for each
7
+ // repository considered and says why it was left alone. A name that matches nothing — or matches a
8
+ // candidate that cannot take the release — refuses the whole run before anything is written, rather
9
+ // than quietly propagating to nobody.
10
+
11
+ const SELECTABLE_STATES: ReadonlySet<TargetState> = new Set(['ready', 'up_to_date'])
12
+ const REFUSAL_PREFIX = 'Refusing to propagate:'
13
+
14
+ interface TargetSelection {
15
+ targets: Array<PropagateTarget>
16
+ refusal?: string
17
+ }
18
+
19
+ // A discovery key is `owner/repo`; the bare repository name is accepted too, because that is what a
20
+ // person types.
21
+ function matches_name(repo: string, name: string): boolean {
22
+ return repo === name || repo.slice(repo.indexOf('/') + 1) === name
23
+ }
24
+
25
+ function refuse_match(name: string, matches: ReadonlyArray<PropagateTarget>): string | undefined {
26
+ const [match] = matches
27
+
28
+ if (match === undefined) {
29
+ return `${REFUSAL_PREFIX} no repository named "${name}" is checked out next to this one.`
30
+ }
31
+
32
+ if (matches.length > 1) {
33
+ return `${REFUSAL_PREFIX} "${name}" names more than one repository; use the owner/repo form.`
34
+ }
35
+
36
+ if (SELECTABLE_STATES.has(match.state)) return undefined
37
+
38
+ return `${REFUSAL_PREFIX} ${match.repo}: ${propagate_run.skip_reason(match)}.`
39
+ }
40
+
41
+ // Only a candidate the run would otherwise have processed becomes `not_selected`. One that was never
42
+ // going to be — not downstream, no checkout — keeps its own reason, which says more than "not named".
43
+ function mark_unselected(
44
+ target: PropagateTarget,
45
+ matches: ReadonlyArray<PropagateTarget>,
46
+ ): PropagateTarget {
47
+ if (matches.includes(target) || !SELECTABLE_STATES.has(target.state)) return target
48
+
49
+ return { ...target, state: 'not_selected' }
50
+ }
51
+
52
+ // The run's targets with every other consumer marked `not_selected`, or the reason `name` cannot be
53
+ // targeted. No name leaves the list untouched — the default run still covers every consumer.
54
+ function select_target(
55
+ targets: ReadonlyArray<PropagateTarget>,
56
+ name: string | undefined,
57
+ ): TargetSelection {
58
+ if (name === undefined) return { targets: [...targets] }
59
+ const matches = targets.filter((target) => matches_name(target.repo, name))
60
+ const refusal = refuse_match(name, matches)
61
+ if (refusal !== undefined) return { targets: [], refusal }
62
+
63
+ return { targets: targets.map((target) => mark_unselected(target, matches)) }
64
+ }
65
+
66
+ const propagate_select = {
67
+ matches_name,
68
+ select_target,
69
+ }
70
+
71
+ export type { TargetSelection }
72
+ export { propagate_select }
@@ -19,8 +19,10 @@ import { z } from 'zod'
19
19
  const MANIFEST_NAME = 'package.json'
20
20
  const NODE_MODULES = 'node_modules'
21
21
 
22
- // Why a candidate is not going to be processed, or that it is.
23
- type TargetState = 'ready' | 'up_to_date' | 'not_downstream' | 'missing_checkout' | 'unreadable'
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).
24
+ type TargetState =
25
+ 'ready' | 'up_to_date' | 'not_downstream' | 'missing_checkout' | 'unreadable' | 'not_selected'
24
26
 
25
27
  interface PropagateTarget {
26
28
  repo: string
@@ -156,5 +158,5 @@ const propagate_targets = {
156
158
  resolve_targets,
157
159
  }
158
160
 
159
- export type { Manifest, PropagateTarget }
161
+ export type { Manifest, PropagateTarget, TargetState }
160
162
  export { propagate_targets }
@@ -11,8 +11,9 @@ import { version_targets } from '#scripts/version/version-targets'
11
11
  import { propagate_git } from './propagate-git'
12
12
  import { propagate_publish } from './propagate-publish'
13
13
  import { propagate_run } from './propagate-run'
14
+ import { propagate_select, type TargetSelection } from './propagate-select'
14
15
  import { propagate_steps } from './propagate-steps'
15
- import { propagate_targets } from './propagate-targets'
16
+ import { propagate_targets, type PropagateTarget } from './propagate-targets'
16
17
 
17
18
  // `josh propagate` — carry the release this repository just published into every consumer checked
18
19
  // out next to it (joshuafolkken/kit#863).
@@ -22,25 +23,56 @@ const FAILURE_EXIT_CODE = 1
22
23
  const ARGV_OFFSET = 2
23
24
  const SKIP_PUBLISH_FLAG = '--skip-publish-wait'
24
25
  const DRY_RUN_FLAG = '--dry-run'
26
+ const TARGET_FLAG = '--target'
25
27
  const KNOWN_FLAGS: ReadonlyArray<string> = [SKIP_PUBLISH_FLAG, DRY_RUN_FLAG]
28
+ // Shown in the usage line only. `--target` carries a value, so it is taken out of argv before the
29
+ // flag-only refusal runs, and this entry never has to match an argument.
30
+ const TARGET_USAGE = `${TARGET_FLAG} <repo>`
26
31
  const DRY_RUN_REASON = 'would be propagated'
32
+ const UNTOUCHED_NOTE = 'No consumer was touched.'
27
33
 
28
34
  interface RunOptions {
29
35
  is_dry_run: boolean
30
36
  is_publish_wait_skipped: boolean
37
+ // The one consumer to propagate to, when `--target` narrowed the run (joshuafolkken/kit#2755).
38
+ target?: string
31
39
  // The usage message, when the arguments were not accepted. Carried alongside rather than returned
32
40
  // instead, so the caller has one shape to branch on.
33
41
  usage?: string
34
42
  }
35
43
 
44
+ interface TargetArgument {
45
+ rest: Array<string>
46
+ target?: string
47
+ usage?: string
48
+ }
49
+
50
+ // Take `--target <repo>` out of argv. A second `--target` stays in `rest`, where the flag-only
51
+ // refusal rejects it rather than letting one of the two names win silently.
52
+ function split_target(argv: ReadonlyArray<string>): TargetArgument {
53
+ const index = argv.indexOf(TARGET_FLAG)
54
+ if (index === -1) return { rest: [...argv] }
55
+ const target = argv[index + 1]
56
+ const rest = argv.filter((_, position) => position !== index && position !== index + 1)
57
+
58
+ if (target === undefined || target.startsWith('-')) {
59
+ return { rest, usage: `${TARGET_FLAG} needs a repository name, e.g. ${TARGET_FLAG} app-kit` }
60
+ }
61
+
62
+ return { rest, target }
63
+ }
64
+
36
65
  // Reject anything not on the list rather than ignoring it. `--dryrun` silently falling through to
37
66
  // the real write path is the mistake this refusal exists to prevent.
38
67
  function parse_options(argv: ReadonlyArray<string>): RunOptions {
68
+ const { rest, target, usage: target_usage } = split_target(argv)
39
69
  const options: RunOptions = {
40
- is_dry_run: argv.includes(DRY_RUN_FLAG),
41
- is_publish_wait_skipped: argv.includes(SKIP_PUBLISH_FLAG),
70
+ is_dry_run: rest.includes(DRY_RUN_FLAG),
71
+ is_publish_wait_skipped: rest.includes(SKIP_PUBLISH_FLAG),
72
+ ...(target !== undefined && { target }),
42
73
  }
43
- const usage = refuse_unknown_flags(argv, KNOWN_FLAGS, 'propagate')
74
+ const usage =
75
+ target_usage ?? refuse_unknown_flags(rest, [...KNOWN_FLAGS, TARGET_USAGE], 'propagate')
44
76
 
45
77
  return usage === undefined ? options : { ...options, usage }
46
78
  }
@@ -126,15 +158,57 @@ async function await_publish(target_version: string, is_skipped: boolean): Promi
126
158
  console.error(
127
159
  `${status_icons.FAIL_ICON} ${target_version} did not become available: ${result.state}.`,
128
160
  )
129
- console.error('No consumer was touched.')
161
+ console.error(UNTOUCHED_NOTE)
130
162
 
131
163
  return false
132
164
  }
133
165
 
134
- // Everything after the publish wait: resolve the consumers, run each one, print one report.
135
- function propagate_to_consumers(version: string, is_dry_run: boolean): number {
166
+ // Print whatever the version resolution had to say, and hand back the version when there is one.
167
+ function announce_run_version(resolved: RunVersion): string | undefined {
168
+ if (resolved.version === undefined) {
169
+ console.error(resolved.refusal ?? 'Nothing to propagate.')
170
+
171
+ return undefined
172
+ }
173
+
174
+ if (resolved.warning !== undefined) console.info(resolved.warning)
175
+
176
+ return resolved.version
177
+ }
178
+
179
+ interface RunPlan {
180
+ version: string
181
+ targets: ReadonlyArray<PropagateTarget>
182
+ }
183
+
184
+ // The consumers this run will consider, narrowed by `--target`. Resolved before the publish wait so
185
+ // a mistyped name fails at once instead of after minutes of polling the registry.
186
+ function resolve_run_targets(version: string, target: string | undefined): TargetSelection {
136
187
  const map = repo_discovery.discover_repositories(PROJECT_ROOT)
137
188
  const targets = propagate_targets.resolve_targets(map, KIT_PACKAGE_NAME, version)
189
+
190
+ return propagate_select.select_target(targets, target)
191
+ }
192
+
193
+ // The version and the consumers, or nothing once whatever stopped the run has been printed.
194
+ function plan_run(options: RunOptions): RunPlan | undefined {
195
+ const version = announce_run_version(resolve_run_version(PROJECT_ROOT, options.is_dry_run))
196
+ if (version === undefined) return undefined
197
+ const selection = resolve_run_targets(version, options.target)
198
+
199
+ if (selection.refusal !== undefined) {
200
+ console.error(selection.refusal)
201
+ console.error(UNTOUCHED_NOTE)
202
+
203
+ return undefined
204
+ }
205
+
206
+ return { version, targets: selection.targets }
207
+ }
208
+
209
+ // Everything after the publish wait: run each consumer, print one report.
210
+ function propagate_to_consumers(plan: RunPlan, is_dry_run: boolean): number {
211
+ const { version, targets } = plan
138
212
  const step = is_dry_run
139
213
  ? propagate_steps.describe_step
140
214
  : propagate_steps.create_step_runner({
@@ -165,19 +239,6 @@ async function resolve_publish(version: string, options: RunOptions): Promise<bo
165
239
  return await await_publish(version, options.is_dry_run || options.is_publish_wait_skipped)
166
240
  }
167
241
 
168
- // Print whatever the version resolution had to say, and hand back the version when there is one.
169
- function announce_run_version(resolved: RunVersion): string | undefined {
170
- if (resolved.version === undefined) {
171
- console.error(resolved.refusal ?? 'Nothing to propagate.')
172
-
173
- return undefined
174
- }
175
-
176
- if (resolved.warning !== undefined) console.info(resolved.warning)
177
-
178
- return resolved.version
179
- }
180
-
181
242
  async function run(argv: ReadonlyArray<string>): Promise<number> {
182
243
  const options = parse_options(argv)
183
244
 
@@ -187,11 +248,11 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
187
248
  return FAILURE_EXIT_CODE
188
249
  }
189
250
 
190
- const version = announce_run_version(resolve_run_version(PROJECT_ROOT, options.is_dry_run))
191
- if (version === undefined) return FAILURE_EXIT_CODE
192
- if (!(await resolve_publish(version, options))) return FAILURE_EXIT_CODE
251
+ const plan = plan_run(options)
252
+ if (plan === undefined) return FAILURE_EXIT_CODE
253
+ if (!(await resolve_publish(plan.version, options))) return FAILURE_EXIT_CODE
193
254
 
194
- return propagate_to_consumers(version, options.is_dry_run)
255
+ return propagate_to_consumers(plan, options.is_dry_run)
195
256
  }
196
257
 
197
258
  async function main(argv: ReadonlyArray<string>): Promise<void> {
@@ -201,6 +262,7 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
201
262
  const propagate = {
202
263
  DRY_RUN_REASON,
203
264
  KNOWN_FLAGS,
265
+ TARGET_FLAG,
204
266
  parse_options,
205
267
  refuse_outside_source_repository,
206
268
  resolve_target_version,