@joshuafolkken/kit 1.978.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 (69) 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/halfrun.md +4 -5
  6. package/.claude/skills/workflow-commands/issue-comments.md +1 -1
  7. package/.claude/skills/workflow-commands/issue-scout.md +21 -22
  8. package/.claude/skills/workflow-commands/kickoff.md +8 -9
  9. package/.claude/skills/workflow-commands/observation-filing.md +9 -10
  10. package/.claude/skills/workflow-commands/prerequisite.md +7 -6
  11. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  12. package/.claude/skills/workflow-commands/rule-residency.md +2 -2
  13. package/.claude/skills/workflow-commands/split-assessment.md +4 -3
  14. package/.claude/skills/workflow-commands/target-repository.md +7 -6
  15. package/dist/hooks/batch-guard.js +4 -4
  16. package/dist/hooks/{chunk-ZPLXLJ7O.js → chunk-47SPMGOD.js} +16 -1
  17. package/dist/hooks/{chunk-BVMSPG3M.js → chunk-5JX6HXDX.js} +3 -3
  18. package/dist/hooks/{chunk-KQG4776P.js → chunk-APVOACVT.js} +1 -1
  19. package/dist/hooks/{chunk-2K52BRWV.js → chunk-ASUZGZFT.js} +2 -2
  20. package/dist/hooks/{chunk-CRGJTD6E.js → chunk-ERXMJJOV.js} +1 -1
  21. package/dist/hooks/{chunk-Q7LE77CO.js → chunk-IM3KM25Y.js} +31 -29
  22. package/dist/hooks/{chunk-BNGGEY3Z.js → chunk-LBXQHXMC.js} +129 -342
  23. package/dist/hooks/{chunk-NXL3SBXY.js → chunk-P3UX7SFN.js} +2 -2
  24. package/dist/hooks/{chunk-F6OM6SIG.js → chunk-RFPHKU7C.js} +12 -12
  25. package/dist/hooks/{chunk-ZYYSBNR6.js → chunk-ZT65LR3U.js} +4 -4
  26. package/dist/hooks/codex-hook-adapter.js +10 -10
  27. package/dist/hooks/duplicate-read-guard.js +5 -5
  28. package/dist/hooks/format-edited.js +2 -2
  29. package/dist/hooks/investigation-guard.js +5 -5
  30. package/dist/hooks/pretool-guard.js +9 -9
  31. package/dist/hooks/stop-guard.js +4 -4
  32. package/dist/josh.js +16 -1
  33. package/package.json +1 -1
  34. package/prompts/collaboration-workflow/epic-bundle.md +8 -8
  35. package/prompts/collaboration-workflow/issue-template.md +5 -5
  36. package/prompts/collaboration-workflow/rule-delivery.md +43 -30
  37. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  38. package/prompts/collaboration-workflow/wip-cap.md +3 -3
  39. package/prompts/review.md +11 -9
  40. package/scripts/delegation/delegation-policy.ts +9 -9
  41. package/scripts/document/document-byte-budget.ts +1 -1
  42. package/scripts/issue/issue-file-cli.ts +188 -0
  43. package/scripts/issue/issue-file.ts +192 -0
  44. package/scripts/issue/issue-lint-cli.ts +1 -1
  45. package/scripts/issue/issue-scout-cli.ts +41 -97
  46. package/scripts/issue/issue-scout-closed.ts +102 -0
  47. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +9 -1
  48. package/scripts/josh/josh-command-map.ts +1 -0
  49. package/scripts/josh/josh-commands-issue.ts +11 -0
  50. package/scripts/josh/josh-commands-project.ts +5 -1
  51. package/scripts/propagate/propagate-run.ts +1 -0
  52. package/scripts/propagate/propagate-select.ts +72 -0
  53. package/scripts/propagate/propagate-targets.ts +5 -3
  54. package/scripts/propagate/propagate.ts +86 -24
  55. package/scripts/rules/bash-triggers.ts +19 -6
  56. package/scripts/rules/delivered-rules.ts +28 -32
  57. package/scripts/rules/direct-filing.ts +38 -0
  58. package/scripts/rules/filing-cap.ts +11 -8
  59. package/scripts/rules/issue-fold.ts +4 -4
  60. package/scripts/rules/oracle-consulted.ts +4 -4
  61. package/scripts/rules/oracle-firing.ts +13 -16
  62. package/scripts/rules/rule-body-guard.ts +6 -9
  63. package/scripts/rules/stop-rules.ts +3 -2
  64. package/scripts/test/pilot-files.ts +0 -1
  65. package/scripts/version/fresh-metadata-cache.ts +36 -0
  66. package/scripts/version/latest-update.ts +2 -1
  67. package/scripts/rules/issue-bug-label.ts +0 -85
  68. package/scripts/rules/issue-filing-args.ts +0 -50
  69. package/scripts/rules/issue-scout.ts +0 -64
@@ -16,7 +16,7 @@
16
16
 
17
17
  1. **現在の作業を退避する**: `git stash push -u -m "upstream-interrupt: paused #<N>"`(WIP コミットでも可)。退避したことを忘れないよう、この時点で stash を作る。メッセージ付きで積むのは、復元をメッセージで特定するため(下記ステップ 5)
18
18
  2. **進行中の Issue に状況を明記する**: `pnpm josh issue:comment <N> --body-file <path>` で、(a) 作業を stash したこと、(b) 中断理由(どの別パッケージの・どんな問題で中断したか)、(c) 対象パッケージに作成した新 Issue へのリンクを `## Upstream issues` 見出しの下に `owner/repo#N` 形式で、を記載する。これにより「なぜこの Issue が一時停止しているか」と「何を待っているのか」が後から監査できる(→「起票元へのバックリンク」)
19
- 3. **対象パッケージのリポジトリに新しい Issue を作成する(確認なし)**: `gh api repos/<owner>/<repo>/issues -f title="<root-cause title>" -f 'labels[]=route:tier-a' -f 'labels[]=depth:<n>' -F body=@<body-file>`(`route:tier-a` は実装中の Tier A 起票を経路として集計するためのラベル — joshuafolkken/kit#1083。付与するのは first-party の場合だけで、third-party はそもそもこの手順に到達せず起票しない)。起票前に `pnpm josh issue:lint <body-file>` を実行し、`labels:` に列挙された分類ラベルをすべて同じ作成呼び出しに付ける。分類の単一ソースは `prompts/collaboration-workflow/issue-template.md`。根本原因・再現・期待結果を Step 1 のテンプレに沿って記載し、**本文に `## Origin` 節を置いて起票元 Issue を `owner/repo#N` 形式で書く**(→「起票元へのバックリンク」)。上流 Issue は欠陥を、起票元 Issue は証拠を持つため、リンクがないと上流 Issue は後から解釈できなくなる。ここで「起票してよいか」を尋ねて停止してはならない。**ただし確認なしで起票してよいのは対象が first-party のときだけ**で、third-party(owner が自リポジトリと一致しないリポジトリ)なら起票せずに停止する(→「第三者リポジトリへの書き込みは Tier C(明示指示が必要)」)
19
+ 3. **対象パッケージのリポジトリに新しい Issue を作成する(確認なし)**: `pnpm josh issue:file "<root-cause title>" --body-file <body-file> --depth <n> --route tier-a --repo <owner>/<repo>`(`route:tier-a` は実装中の Tier A 起票を経路として集計するためのラベル — joshuafolkken/kit#1083。付与するのは first-party の場合だけで、third-party はコマンド自身が拒否し起票しない)。本文の検査、本文が宣言する分類ラベルの付与、`## Origin` 節の確認、起票先での重複探し、`epic:bundle` はこのコマンドが実行する(`docs/josh-commands.md` → `josh issue:file`)。分類の単一ソースは `prompts/collaboration-workflow/issue-template.md`。根本原因・再現・期待結果を Step 1 のテンプレに沿って記載し、**本文に `## Origin` 節を置いて起票元 Issue を `owner/repo#N` 形式で書く**(→「起票元へのバックリンク」)。上流 Issue は欠陥を、起票元 Issue は証拠を持つため、リンクがないと上流 Issue は後から解釈できなくなる。ここで「起票してよいか」を尋ねて停止してはならない。**ただし確認なしで起票してよいのは対象が first-party のときだけ**で、third-party(owner が自リポジトリと一致しないリポジトリ)なら起票せずに停止する(→「第三者リポジトリへの書き込みは Tier C(明示指示が必要)」)
20
20
  4. **`confirmation` Telegram を送って停止する**: 上流 Issue の URL と、何が止まっているかを本文に書く(→「確認待ちで停止するときの Telegram 通知(`confirmation`)」)。無人実行でも画面外でユーザーが気付ける。停止は Issue が既に存在する状態で行うので、ユーザーは「待つ / 先送りする」を Issue を見ながら 1 語で答えられる
21
21
  5. **元の作業を再開する**: 上流の修正がマージされた、または**ユーザーが先送りを明示判断した**後に `pnpm josh stash:pop "upstream-interrupt: paused #<N>"`(位置指定や引数なしの `git stash pop` は使わない — stash は全 work tree が共有する 1 本のスタックで、別のレーンの stash を取り込む恐れがある)して、退避していた元タスクを続行する。上流 Issue を割り込みで実装するかどうかもユーザーの判断(上流パッケージの実装・PR・マージはそれぞれのワークフロー規則に従う)
22
22
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  **単一ソースはこのファイルである。**
4
4
 
5
- **この規則は常駐していない — 引き金つき配送に移った**(joshuafolkken/kit#1524)。`pnpm josh rule:guard` が Issue を作成する `Bash` 呼び出し(`gh issue create`、または `…/issues` への `title` 付き POST)を拒否し、そこで数え方・拒否・2 つの免除・免除を決める 3 条件を突きつける。起票が無いターンでは何も起きず、それは上限に触れる行為が無いということである。配送は**ラン 1 回につき 1 度**なので、数えたうえで同じ呼び出しをもう一度出せばよい。機構と列挙表は `rule-delivery.md`、配送文の実体は `scripts/rules/delivered-rules.ts` の `WIP_CAP_REASON` にあり、`scripts/backlog/backlog-manufacturing-rule.test.ts` がその中身を固定している。
5
+ **この規則は常駐していない — 引き金つき配送に移った**(joshuafolkken/kit#1524)。`pnpm josh rule:guard` が Issue を作成する `Bash` 呼び出し(`pnpm josh issue:file`。`gh issue create` や `…/issues` への `title` 付き POST による直接起票は `direct-filing` が毎回拒否する)を拒否し、そこで数え方・拒否・2 つの免除・免除を決める 3 条件を突きつける。起票が無いターンでは何も起きず、それは上限に触れる行為が無いということである。配送は**ラン 1 回につき 1 度**なので、数えたうえで同じ呼び出しをもう一度出せばよい。機構と列挙表は `rule-delivery.md`、配送文の実体は `scripts/rules/delivered-rules.ts` の `WIP_CAP_REASON` にあり、`scripts/backlog/backlog-manufacturing-rule.test.ts` がその中身を固定している。
6
6
 
7
7
  ## 規則
8
8
 
@@ -69,10 +69,10 @@ gh api "repos/{owner}/{repo}/issues?state=open&per_page=100" --paginate \
69
69
 
70
70
  #### 該当したときの手順
71
71
 
72
- 1. **上限に関係なく起票する。** 1 件閉じることを条件にしない。起票前に `pnpm josh issue:lint <body-file>` を実行し、その `labels:` に列挙された分類ラベルをすべて同じ作成呼び出しに付ける。`prompts/collaboration-workflow/issue-template.md` の手順に従う。
72
+ 1. **上限に関係なく起票する。** 1 件閉じることを条件にしない。起票は `pnpm josh issue:file` で行う。本文の検査、本文が宣言する分類ラベルの付与、重複探し、`epic:bundle` はこのコマンドが実行する(`docs/josh-commands.md` → `josh issue:file`)。
73
73
 
74
74
  ```bash
75
- gh api repos/{owner}/{repo}/issues -f title="<title>" -f 'labels[]=route:interrupt' -f 'labels[]=depth:<n>' -F body=@<body-file>
75
+ pnpm josh issue:file "<title>" --body-file <body-file> --depth <n> --route interrupt
76
76
  ```
77
77
 
78
78
  2. **超過している事実と、当たった条件の番号を本文に 1 行書く**(例:「オープン 37 件で起票。3 条件のうち 2(文書化された手順が完了できない)に該当」)。**どちらか一方では足りない** — 件数だけでは判定が再現できず、条件だけでは上限の可視化が効かない。
package/prompts/review.md CHANGED
@@ -183,7 +183,7 @@ Only branch 2 files an Issue. What follows applies to that branch.
183
183
  than being deferred — see below.)
184
184
  - **Filing does not end at the Issue.** `epic:next` only ever offers a child the task list of an epic
185
185
  names, so an Issue in no epic is never handed to a running `backlogrun` — the deferred finding is parked
186
- forever, which reads the same from the backlog. **The three steps run inside the CI wait, not before
186
+ forever, which reads the same from the backlog. **The steps run inside the CI wait, not before
187
187
  the commit.** Where the run opens a pull request — `fullrun`, `backlogrun` — they go after
188
188
  `pnpm josh git -y` and before `pnpm josh followup`; where it does not — `halfrun`, or a standalone
189
189
  pre-commit self-review — they run as soon as the disposition is decided. **The chain may run in a
@@ -191,14 +191,16 @@ Only branch 2 files an Issue. What follows applies to that branch.
191
191
  - **When the round files a second follow-up, run `pnpm josh issue:fold` first.** Several findings from
192
192
  one review fold into one Issue by default — the filing-time counterpart to the split assessment,
193
193
  reading the same two questions (`.claude/skills/workflow-commands/split-assessment.md` → "The same
194
- two questions decide the filing-time fold"). `pnpm josh rule:guard` refuses the second `gh api …
195
- issues` call until it is folded; the first filing asks nothing.
196
-
197
- 1. File the follow-up Issue referencing the current one, tagged `route:review-cap`. Run `pnpm josh issue:lint <body-file>` first and apply every label in its `labels:` answer at creation, following `prompts/collaboration-workflow/issue-template.md`:
198
- `gh api repos/{owner}/{repo}/issues -f title="<title>" -f 'labels[]=route:review-cap' -f 'labels[]=depth:<n>' -F body=@<body-file>`.
199
- 2. Run `pnpm josh epic:bundle <new>` — **before the current Issue closes.** The candidate search reads
200
- open issues only, so once the parent has closed the command answers `none` permanently.
201
- 3. Act on its answer. **`epic:bundle` recommends and writes nothing**, so acting means running the
194
+ two questions decide the filing-time fold"). `pnpm josh rule:guard` refuses the second
195
+ `pnpm josh issue:file` call until it is folded; the first filing asks nothing.
196
+
197
+ 1. File the follow-up Issue referencing the current one, tagged `route:review-cap` — **before the current Issue closes.**
198
+ `pnpm josh issue:file "<title>" --body-file <body-file> --depth <n> --route review-cap`.
199
+ The command lints the body, applies the classification labels it declares, runs the duplicate scan
200
+ and then `epic:bundle` on the new Issue (`docs/josh-commands.md` → `josh issue:file`). The
201
+ `epic:bundle` candidate search reads open issues only, so once the parent has closed it answers
202
+ `none` permanently.
203
+ 2. Act on the `epic:bundle` answer it prints. **`epic:bundle` recommends and writes nothing**, so acting means running the
202
204
  write command yourself — never a hand edit of the epic body, which leaves the task list and the
203
205
  `blocked-by` relations disagreeing and `epic:next` returning `error`.
204
206
 
@@ -53,16 +53,16 @@ const DELEGATABLE_STEPS: ReadonlyArray<DelegatableStep> = [
53
53
  },
54
54
  {
55
55
  name: 'followup-filing',
56
- // The late-run follow-up filing chain — issue:scout → file the Issue → epic:bundle → epic --add
57
- // — is a dependency chain `batch:guard` correctly keeps one call per step, and what makes it
58
- // expensive is the ~340k context it runs in at the tail of a run, not the step count
59
- // (joshuafolkken/kit#1892). A fresh unit runs the same chain at a small context, and the review
60
- // round cap already composed the finding text, so what the unit is handed is mechanical: run the
61
- // four commands and act on their deterministic answers. This is not `batch:guard`'s business —
62
- // the chain stays one call per step; delegation only moves it to a cheaper context.
63
- does: 'run the late-run follow-up filing chain in an isolated unit — issue:scout, file the Issue, epic:bundle, epic --add — given the finding text the parent already composed, and return the new Issue number and the epic:bundle answer',
56
+ // The late-run follow-up filing chain — `issue:file` (which scouts, lints, labels and runs
57
+ // epic:bundle itself, joshuafolkken/kit#2808) → epic --add — is a dependency chain `batch:guard`
58
+ // correctly keeps one call per step, and what makes it expensive is the ~340k context it runs in
59
+ // at the tail of a run, not the step count (joshuafolkken/kit#1892). A fresh unit runs the same
60
+ // chain at a small context, and the review round cap already composed the finding text, so what
61
+ // the unit is handed is mechanical: run the commands and act on their deterministic answers. This
62
+ // is not `batch:guard`'s business — delegation only moves the chain to a cheaper context.
63
+ does: 'run the late-run follow-up filing chain in an isolated unit — issue:file (scout, lint, labels, epic:bundle), then epic --add — given the finding text the parent already composed, and return the new Issue number and the epic:bundle answer',
64
64
  verifier:
65
- "the parent reads the filed Issue from GitHub with `pnpm josh issue:state <new>`, not the unit's summary; a follow-up reported filed but not created is still absent, the way `epic-child`'s reported-done-but-not-merged child is still open. The finding text came from the parent and `issue:scout` / `epic:bundle` are deterministic, so the unit's work is the mechanical execution `issue:state` confirms",
65
+ "the parent reads the filed Issue from GitHub with `pnpm josh issue:state <new>`, not the unit's summary; a follow-up reported filed but not created is still absent, the way `epic-child`'s reported-done-but-not-merged child is still open. The finding text came from the parent and `issue:file`'s steps are deterministic, so the unit's work is the mechanical execution `issue:state` confirms",
66
66
  },
67
67
  {
68
68
  name: 'implementation-unit',
@@ -82,7 +82,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
82
82
  { path: '.claude/skills/workflow-commands/rule-residency.md', bytes: 24_576 },
83
83
  { path: '.claude/skills/workflow-commands/target-repository.md', bytes: 8192 },
84
84
  { path: '.claude/skills/workflow-commands/working-tree-hold.md', bytes: 8192 },
85
- { path: 'docs/josh-commands.md', bytes: 172_032 },
85
+ { path: 'docs/josh-commands.md', bytes: 176_128 },
86
86
  { path: 'prompts/agent-rules.md', bytes: 4096 },
87
87
  { path: 'prompts/coding-standards.md', bytes: 16_384 },
88
88
  { path: 'prompts/collaboration-workflow.md', bytes: 8192 },
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env tsx
2
+ import { readFile } from 'node:fs/promises'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { repo_party } from '#scripts/discovery/repo-party'
5
+ import { epic_bundle_cli } from '#scripts/epic/epic-bundle-cli'
6
+ import { git_gh_command } from '#scripts/git/git-gh-command'
7
+ import { git_gh_exec } from '#scripts/git/git-gh-exec'
8
+ import { git_gh_issue_write } from '#scripts/git/git-gh-issue-write'
9
+ import { github_issue_url } from '#scripts/git/github-issue-url'
10
+ import { issue_file, type FileArguments } from './issue-file'
11
+ import { issue_lint_cli } from './issue-lint-cli'
12
+ import { issue_scout_cli } from './issue-scout-cli'
13
+
14
+ // `josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>]…
15
+ // [--repo <owner/repo>] [--distinct <N,…>]` — file an Issue with every filing step run in order
16
+ // (joshuafolkken/kit#2808): the third-party refusal, the body lint, the `## Origin` check for another
17
+ // repository, the duplicate scout, the create call carrying every label, and `epic:bundle` after it.
18
+ // A direct `gh api …/issues` filing is refused by the `direct-filing` delivered rule and pointed here.
19
+
20
+ const SUCCESS_EXIT_CODE = 0
21
+ const FAILURE_EXIT_CODE = 1
22
+ const ARGV_OFFSET = 2
23
+ const USAGE =
24
+ 'Usage: josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <tier-a|split|interrupt|review-cap>] [--label <name>]… [--repo <owner/repo>] [--distinct <N,…>]'
25
+ const UNKNOWN_REPO_MESSAGE =
26
+ 'Could not read this repository from `git remote`, so the filing has no repository to compare against — check `gh auth status`.'
27
+ const THIRD_PARTY_MESSAGE =
28
+ '✖ third-party target: a tracker we do not own is Tier C — draft the Issue for a person to post instead (`CLAUDE.md` → "Third-party repositories are Tier C").'
29
+ // `GH_REPO` is the variable `gh` reads `{owner}/{repo}` from, so setting it points every listing the
30
+ // scout and `epic:bundle` make at the target repository rather than at this checkout.
31
+ const GH_REPO_VARIABLE = 'GH_REPO'
32
+
33
+ interface Filing {
34
+ args: FileArguments
35
+ body: string
36
+ target: string
37
+ current: string
38
+ }
39
+
40
+ async function read_body(path: string): Promise<string | undefined> {
41
+ try {
42
+ return await readFile(path, 'utf8')
43
+ } catch {
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ function is_third_party(target: string): boolean {
49
+ const party = repo_party.classify(repo_party.current_owner(), repo_party.target_owner(target))
50
+
51
+ return party === repo_party.THIRD_PARTY
52
+ }
53
+
54
+ // Every refusal the filing can meet before a request is sent, in the order the steps run — `[]` when
55
+ // the filing may go on to the scout.
56
+ function refusals_of(filing: Filing): ReadonlyArray<string> {
57
+ if (is_third_party(filing.target)) return [THIRD_PARTY_MESSAGE]
58
+ const problems = issue_lint_cli.problems(filing.body).map((problem) => `✖ ${problem}`)
59
+ const origin = issue_file.origin_problem(filing.body, filing.target, filing.current)
60
+
61
+ return origin === undefined ? problems : [...problems, `✖ ${origin}`]
62
+ }
63
+
64
+ function unacknowledged_message(numbers: ReadonlyArray<number>): string {
65
+ const listed = numbers.map(String).join(',')
66
+
67
+ return `✖ duplicate candidate(s) ${listed} not declared separate — read each (\`issue-fold-existing.md\`); fold into a duplicate, or reissue with \`--distinct ${listed}\` when each is a separate deliverable.`
68
+ }
69
+
70
+ // Set before the scout and left set for `epic:bundle`, so both read the target's backlog.
71
+ function point_listings_at(filing: Filing): void {
72
+ if (issue_file.is_same_repository(filing.target, filing.current)) return
73
+
74
+ process.env[GH_REPO_VARIABLE] = filing.target
75
+ }
76
+
77
+ // The scout's report is printed whatever it finds, and the filing is held until every candidate it
78
+ // names is declared separate.
79
+ async function is_scout_clear(filing: Filing): Promise<boolean> {
80
+ point_listings_at(filing)
81
+ const outcome = await issue_scout_cli.scout(
82
+ { title: filing.args.title, body: filing.body },
83
+ filing.target,
84
+ )
85
+
86
+ if (outcome === undefined) return false
87
+ console.info(outcome.report)
88
+ const open = issue_file.unacknowledged(outcome.candidates, filing.args.distinct)
89
+
90
+ if (open.length > 0) console.error(unacknowledged_message(open))
91
+
92
+ return open.length === 0
93
+ }
94
+
95
+ // `undefined` when the create call failed. `gh`'s standard error is captured into the thrown error
96
+ // rather than printed, so it is printed here — otherwise the filing fails with no reason given.
97
+ async function create(filing: Filing): Promise<string | undefined> {
98
+ const request = git_gh_issue_write.issue_create_request({
99
+ title: filing.args.title,
100
+ body: filing.body,
101
+ labels: issue_file.labels_of(filing.args, filing.body),
102
+ repo: filing.target,
103
+ })
104
+
105
+ try {
106
+ return await git_gh_exec.exec_gh_api(request)
107
+ } catch (error) {
108
+ const reason = error instanceof Error ? error.message : String(error)
109
+
110
+ console.error(`✖ the create call failed: ${reason}`)
111
+
112
+ return undefined
113
+ }
114
+ }
115
+
116
+ // The Issue exists once `create` returns, so a failed placement is reported rather than failed: a
117
+ // non-zero exit would read as "not filed" and invite the second filing the scout exists to stop.
118
+ async function place(url: string, target: string): Promise<void> {
119
+ const issue_number = Number(github_issue_url.parse(url)?.issue_number)
120
+ const exit_code = Number.isSafeInteger(issue_number)
121
+ ? await epic_bundle_cli.report_for(issue_number, target)
122
+ : FAILURE_EXIT_CODE
123
+
124
+ if (exit_code !== SUCCESS_EXIT_CODE) {
125
+ console.error(`⚠ epic:bundle did not answer for ${url} — run \`pnpm josh epic:bundle <N>\`.`)
126
+ }
127
+ }
128
+
129
+ async function file(filing: Filing): Promise<number> {
130
+ const refusals = refusals_of(filing)
131
+
132
+ if (refusals.length > 0) {
133
+ console.error(refusals.join('\n'))
134
+
135
+ return FAILURE_EXIT_CODE
136
+ }
137
+
138
+ if (!(await is_scout_clear(filing))) return FAILURE_EXIT_CODE
139
+ const url = await create(filing)
140
+
141
+ if (url === undefined) return FAILURE_EXIT_CODE
142
+ console.info(url)
143
+ await place(url, filing.target)
144
+
145
+ return SUCCESS_EXIT_CODE
146
+ }
147
+
148
+ async function filing_of(args: FileArguments): Promise<Filing | string> {
149
+ const body = await read_body(args.body_file)
150
+
151
+ if (body === undefined) return `Could not read the body file ${args.body_file}.`
152
+ const current = await git_gh_command.repo_get_name_with_owner()
153
+
154
+ if (current === undefined) return UNKNOWN_REPO_MESSAGE
155
+
156
+ return { args, body, target: args.repo ?? current, current }
157
+ }
158
+
159
+ async function run(argv: ReadonlyArray<string>): Promise<number> {
160
+ const args = issue_file.parse(argv)
161
+
162
+ if (args === undefined) {
163
+ console.error(USAGE)
164
+
165
+ return FAILURE_EXIT_CODE
166
+ }
167
+
168
+ const filing = await filing_of(args)
169
+
170
+ if (typeof filing === 'string') {
171
+ console.error(filing)
172
+
173
+ return FAILURE_EXIT_CODE
174
+ }
175
+
176
+ return await file(filing)
177
+ }
178
+
179
+ // `process.exitCode` rather than `process.exit()`, so the report on standard output drains first.
180
+ async function main(argv: ReadonlyArray<string>): Promise<void> {
181
+ process.exitCode = await run(argv)
182
+ }
183
+
184
+ const issue_file_cli = { USAGE, THIRD_PARTY_MESSAGE, UNKNOWN_REPO_MESSAGE, run, main }
185
+
186
+ if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
187
+
188
+ export { issue_file_cli }
@@ -0,0 +1,192 @@
1
+ import { parseArgs } from 'node:util'
2
+ import { epic_issue } from '#scripts/epic/epic-issue'
3
+ import { github_issue_url } from '#scripts/git/github-issue-url'
4
+ import { DEPTH_LABEL_ORDER, FILING_ROUTE_LABELS, has_label_name } from '#scripts/git/issue-labels'
5
+ import { issue_backlinks } from './issue-backlinks'
6
+ import { issue_classification } from './issue-classification'
7
+ import { markdown_section } from './markdown-section'
8
+
9
+ // The decisions behind `josh issue:file` (joshuafolkken/kit#2808), kept apart from the command that
10
+ // reads the network so each one is a pure answer a test can pin. Every filing used to be a hand-built
11
+ // `gh api …/issues` call beside five steps spread over as many documents, and joshuafolkken/kit#2805
12
+ // ran two of them and skipped the lint and the labels. Here the steps are the command's arguments:
13
+ // a label the filing owes is a required option rather than a line to remember.
14
+
15
+ const DEPTH_PREFIX = 'depth:'
16
+ const ROUTE_PREFIX = 'route:'
17
+ const LIST_SEPARATOR = ','
18
+ const WORD_SEPARATOR = /\s+/u
19
+ const CODE_SPAN = /`/gu
20
+ // Prose written against a reference with no space — `(owner/repo#N)`, `owner/repo#N。`, Japanese text
21
+ // after the number — is trimmed off, so the anchored parsers below still see the reference itself.
22
+ const LEADING_NON_WORD = /^[^\w]+/u
23
+ const DIGIT = /\d/u
24
+
25
+ interface FileArguments {
26
+ title: string
27
+ body_file: string
28
+ depth: string
29
+ route: string | undefined
30
+ labels: ReadonlyArray<string>
31
+ repo: string | undefined
32
+ distinct: ReadonlyArray<number>
33
+ }
34
+
35
+ const OPTIONS = {
36
+ 'body-file': { type: 'string' },
37
+ depth: { type: 'string' },
38
+ route: { type: 'string' },
39
+ label: { type: 'string', multiple: true },
40
+ repo: { type: 'string' },
41
+ distinct: { type: 'string', multiple: true },
42
+ } as const
43
+
44
+ // The label a `--depth` / `--route` value names, or `undefined` when it names none — read against the
45
+ // label sets `issue-labels.ts` defines, so the command cannot apply a depth or route nobody provisions.
46
+ function label_of(
47
+ prefix: string,
48
+ value: string | undefined,
49
+ known: ReadonlyArray<string>,
50
+ ): string | undefined {
51
+ if (value === undefined) return undefined
52
+ const label = value.startsWith(prefix) ? value : `${prefix}${value}`
53
+
54
+ return known.includes(label) ? label : undefined
55
+ }
56
+
57
+ const ROUTE_NAMES: ReadonlyArray<string> = FILING_ROUTE_LABELS.map((label) => label.name)
58
+
59
+ // `--distinct 12,34 --distinct 56` → `[12, 34, 56]`; `undefined` when any entry is not an issue number,
60
+ // so a typo is refused rather than read as an acknowledgement of nothing.
61
+ function distinct_numbers(values: ReadonlyArray<string>): ReadonlyArray<number> | undefined {
62
+ const numbers = values
63
+ .flatMap((value) => value.split(LIST_SEPARATOR))
64
+ .map((raw) => {
65
+ return Number(raw.trim().replace('#', ''))
66
+ })
67
+
68
+ return numbers.every((number) => Number.isSafeInteger(number) && number > 0) ? numbers : undefined
69
+ }
70
+
71
+ type ParsedValues = ReturnType<typeof parseArgs<{ options: typeof OPTIONS }>>['values']
72
+
73
+ function route_of(values: ParsedValues): string | undefined | false {
74
+ if (values.route === undefined) return undefined
75
+
76
+ return label_of(ROUTE_PREFIX, values.route, ROUTE_NAMES) ?? false
77
+ }
78
+
79
+ function has_text(value: string | undefined): value is string {
80
+ return value !== undefined && value.trim() !== ''
81
+ }
82
+
83
+ // The labelled half of the arguments, or `undefined` when a depth, route or `--distinct` entry is not
84
+ // one the command knows.
85
+ function labelled_of(
86
+ values: ParsedValues,
87
+ ): Pick<FileArguments, 'depth' | 'route' | 'distinct'> | undefined {
88
+ const depth = label_of(DEPTH_PREFIX, values.depth, DEPTH_LABEL_ORDER)
89
+ const route = route_of(values)
90
+ const distinct = distinct_numbers(values.distinct ?? [])
91
+
92
+ if (route === false || depth === undefined || distinct === undefined) return undefined
93
+
94
+ return { depth, route, distinct }
95
+ }
96
+
97
+ function arguments_of(values: ParsedValues, title: string | undefined): FileArguments | undefined {
98
+ const labelled = labelled_of(values)
99
+ const body_file = values['body-file']
100
+
101
+ if (labelled === undefined || !has_text(title) || !has_text(body_file)) return undefined
102
+
103
+ return { title, body_file, ...labelled, labels: values.label ?? [], repo: values.repo }
104
+ }
105
+
106
+ // An unknown flag makes `parseArgs` throw; caught so the answer is the usage line, not a stack trace.
107
+ function parse(argv: ReadonlyArray<string>): FileArguments | undefined {
108
+ try {
109
+ const { values, positionals } = parseArgs({
110
+ args: [...argv],
111
+ options: OPTIONS,
112
+ allowPositionals: true,
113
+ })
114
+
115
+ return arguments_of(values, positionals[0])
116
+ } catch {
117
+ return undefined
118
+ }
119
+ }
120
+
121
+ // A classification label is the body's to declare, so an extra `--label` naming one the body does not
122
+ // declare is dropped rather than applied — the label then always matches what `issue:lint` reads.
123
+ function is_undeclared_classification(label: string, body: string): boolean {
124
+ const is_classification = has_label_name(issue_classification.CLASSIFICATION_LABELS, label)
125
+
126
+ return is_classification && !has_label_name(issue_classification.required_labels(body), label)
127
+ }
128
+
129
+ // Every label the filing carries: the depth, the route when there is one, the classification labels
130
+ // the body declares (`issue_classification.labels_for`, which merges case-insensitively), then any
131
+ // extra `--label`. One request applies them all, so no label is a separate step to forget.
132
+ function labels_of(args: FileArguments, body: string): ReadonlyArray<string> {
133
+ const extra = args.labels.filter((label) => !is_undeclared_classification(label, body))
134
+ const declared = [args.depth, ...(args.route === undefined ? [] : [args.route]), ...extra]
135
+
136
+ return issue_classification.labels_for(body, declared)
137
+ }
138
+
139
+ function is_same_repository(target: string, current: string): boolean {
140
+ return target.toLowerCase() === current.toLowerCase()
141
+ }
142
+
143
+ // `owner/repo#N` or a github.com issue URL — the qualified forms the issue template's backlink section
144
+ // requires, never a bare `#N`, which resolves to whichever repository renders it. Read through the
145
+ // parsers that already know both forms rather than a third pattern.
146
+ function without_trailing_prose(text: string): string {
147
+ let end = text.length
148
+
149
+ while (end > 0 && !DIGIT.test(text.charAt(end - 1))) end -= 1
150
+
151
+ return text.slice(0, end)
152
+ }
153
+
154
+ function is_qualified_reference(word: string): boolean {
155
+ const bare = without_trailing_prose(word.replaceAll(CODE_SPAN, '').replace(LEADING_NON_WORD, ''))
156
+
157
+ return (
158
+ epic_issue.parse_epic_reference(bare)?.repo !== undefined ||
159
+ github_issue_url.parse(bare) !== undefined
160
+ )
161
+ }
162
+
163
+ function cites_origin(line: string): boolean {
164
+ return line.split(WORD_SEPARATOR).some((word) => is_qualified_reference(word))
165
+ }
166
+
167
+ // Why a cross-repository filing is refused, or `undefined` when it is not refused. A filing into the
168
+ // session's own repository owes no `## Origin`; one into another repository owes the section with a
169
+ // qualified reference back to the Issue that found it (`issue-template.md`, the backlink section).
170
+ function origin_problem(body: string, target: string, current: string): string | undefined {
171
+ if (is_same_repository(target, current)) return undefined
172
+ const lines = markdown_section.section_lines(body, issue_backlinks.ORIGIN_HEADING)
173
+
174
+ if (lines.some((line) => cites_origin(line))) return undefined
175
+
176
+ return `filing into ${target} from ${current} needs a \`${issue_backlinks.ORIGIN_HEADING}\` section citing the originating Issue as \`owner/repo#N\` or its URL`
177
+ }
178
+
179
+ // The duplicate candidates the caller has not declared separate. The scan cannot tell a duplicate from
180
+ // a neighbor — that is a reading of each candidate (`issue-fold-existing.md`) — so the command holds
181
+ // the filing until every candidate it printed is named back with `--distinct`.
182
+ function unacknowledged(
183
+ candidates: ReadonlyArray<number>,
184
+ distinct: ReadonlyArray<number>,
185
+ ): ReadonlyArray<number> {
186
+ return candidates.filter((candidate) => !distinct.includes(candidate))
187
+ }
188
+
189
+ const issue_file = { parse, labels_of, origin_problem, unacknowledged, is_same_repository }
190
+
191
+ export type { FileArguments }
192
+ export { issue_file }
@@ -58,7 +58,7 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
58
58
  process.exitCode = await run(argv[0])
59
59
  }
60
60
 
61
- const issue_lint_cli = { run, OK_MESSAGE, USAGE }
61
+ const issue_lint_cli = { problems, run, OK_MESSAGE, USAGE }
62
62
 
63
63
  const ARGV_OFFSET = 2
64
64