@joshuafolkken/kit 1.1094.0 → 1.1136.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 (177) hide show
  1. package/.claude/agents/investigator.md +17 -0
  2. package/.claude/settings.json +1 -0
  3. package/.claude/skills/workflow-commands/SKILL.md +1 -1
  4. package/.claude/skills/workflow-commands/background-commands.md +36 -85
  5. package/.claude/skills/workflow-commands/backlogrun-progress.md +31 -34
  6. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -2
  7. package/.claude/skills/workflow-commands/delegation.md +5 -1
  8. package/.claude/skills/workflow-commands/fullrun.md +5 -4
  9. package/.claude/skills/workflow-commands/halfrun.md +5 -4
  10. package/.claude/skills/workflow-commands/latest-gate.md +7 -15
  11. package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -8
  12. package/.claude/skills/workflow-commands/split-assessment.md +4 -18
  13. package/.github/actions/setup-node/action.yml +33 -0
  14. package/.github/actions/setup-pnpm/action.yml +4 -4
  15. package/.github/workflows/auto-tag.yml +2 -3
  16. package/.github/workflows/ci.yml +7 -8
  17. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  18. package/.github/workflows/pr-classification.yml +1 -3
  19. package/.github/workflows/production.yml +1 -0
  20. package/.github/workflows/publish.yml +10 -9
  21. package/.github/workflows/sonar-qube.yml +17 -13
  22. package/.pnpmfile.mjs +29 -0
  23. package/AGENTS.md +4 -11
  24. package/CLAUDE.md +24 -17
  25. package/GEMINI.md +4 -13
  26. package/README.md +8 -3
  27. package/SECURITY.md +2 -0
  28. package/cspell/index.yaml +4 -0
  29. package/dist/CLAUDE.basic.md +6 -6
  30. package/dist/CLAUDE.md +24 -17
  31. package/dist/CLAUDE.static.md +6 -6
  32. package/dist/config-merge/index.js +33 -9
  33. package/dist/hooks/batch-guard.js +4 -4
  34. package/dist/hooks/{chunk-OIZUHPJI.js → chunk-FO2I7UT7.js} +1 -1
  35. package/dist/hooks/{chunk-NKOG3XAV.js → chunk-GDU26K3J.js} +11 -3
  36. package/dist/hooks/{chunk-O4LL4HTI.js → chunk-HANSKHES.js} +2 -2
  37. package/dist/hooks/{chunk-3C4YCCEF.js → chunk-HBKZLITF.js} +4 -4
  38. package/dist/hooks/{chunk-UD24EAIL.js → chunk-JKKTVDAG.js} +1984 -146
  39. package/dist/hooks/{chunk-M2XDCUKD.js → chunk-KBXZLMRH.js} +89 -1803
  40. package/dist/hooks/{chunk-IL27YTPH.js → chunk-KOKOAV6D.js} +2 -2
  41. package/dist/hooks/{chunk-7ZPFAVR7.js → chunk-LJBLGHEH.js} +59 -19
  42. package/dist/hooks/{chunk-E54KGFEQ.js → chunk-NFZGFGOR.js} +1 -1
  43. package/dist/hooks/{chunk-KHWHKJOM.js → chunk-RFWWUI5F.js} +4 -4
  44. package/dist/hooks/codex-hook-adapter.js +12 -47
  45. package/dist/hooks/duplicate-read-guard.js +5 -5
  46. package/dist/hooks/format-edited.js +2 -2
  47. package/dist/hooks/inputs.json +175 -173
  48. package/dist/hooks/investigation-guard.js +5 -5
  49. package/dist/hooks/pretool-guard.js +9 -9
  50. package/dist/hooks/stop-guard.js +17 -12
  51. package/dist/josh.js +87 -31
  52. package/dist/version/index.js +36 -20
  53. package/eslint/rules/unicorn.js +3 -2
  54. package/package.json +23 -5
  55. package/pnpm-workspace.yaml +5 -7
  56. package/prompts/collaboration-workflow/glossary.md +17 -0
  57. package/prompts/collaboration-workflow/issue-template.md +1 -1
  58. package/prompts/collaboration-workflow/operating-rules.md +1 -1
  59. package/prompts/collaboration-workflow/principles.md +9 -0
  60. package/prompts/collaboration-workflow.md +1 -0
  61. package/scripts/backlog/backlog-arrival.ts +30 -10
  62. package/scripts/backlog/backlog-budget-cli.ts +15 -16
  63. package/scripts/backlog/backlog-offer-cli.ts +6 -14
  64. package/scripts/backlog/backlog-plan.ts +29 -18
  65. package/scripts/backlog/backlog-ready.ts +20 -7
  66. package/scripts/config-merge/parse-jsonc.ts +33 -3
  67. package/scripts/config-merge/patch-json-key.ts +13 -6
  68. package/scripts/cost-runtime/cost-cli.ts +3 -7
  69. package/scripts/delegation/delegation-policy.ts +9 -1
  70. package/scripts/delegation/investigation-guard.ts +1 -1
  71. package/scripts/delegation/investigation-reads.ts +105 -19
  72. package/scripts/doctor/doctor-consumer.ts +3 -5
  73. package/scripts/doctor/doctor-io.ts +9 -13
  74. package/scripts/document/backlogrun-parent-read-set.ts +12 -1
  75. package/scripts/document/capped-print.ts +74 -0
  76. package/scripts/document/document-byte-budget.ts +1 -0
  77. package/scripts/document/document-section-cli.ts +4 -3
  78. package/scripts/document/entry-read-budget.ts +13 -7
  79. package/scripts/document/entry-read-set.ts +32 -8
  80. package/scripts/document/lane-child-read-set.ts +7 -0
  81. package/scripts/document/read-set-point-of-use.ts +8 -3
  82. package/scripts/document/read-set-trim.ts +3 -1
  83. package/scripts/epic/epic-audit.ts +3 -18
  84. package/scripts/gate/scoped-green.ts +25 -14
  85. package/scripts/gh/git-gh-issue-list.ts +12 -11
  86. package/scripts/gh/git-gh-issue-write.ts +4 -1
  87. package/scripts/gh/git-gh-pr-auto-merge.ts +4 -1
  88. package/scripts/git/git-command.ts +6 -1
  89. package/scripts/git/git-spawn-sync.ts +65 -0
  90. package/scripts/git/git-spawn.ts +5 -4
  91. package/scripts/git/stash/stash-pop-args.ts +1 -6
  92. package/scripts/init/claude-kit-only-settings.ts +29 -0
  93. package/scripts/init/init-copy-content.ts +8 -2
  94. package/scripts/init/init-file-action.ts +54 -0
  95. package/scripts/init/init-logic-migrate.ts +35 -17
  96. package/scripts/init/init-logic-secretlint.ts +1 -1
  97. package/scripts/init/init-logic.ts +6 -22
  98. package/scripts/init/init.ts +11 -68
  99. package/scripts/init/initial-commit.ts +24 -0
  100. package/scripts/init/kit-written-paths.ts +2 -0
  101. package/scripts/init/project-profile.ts +3 -6
  102. package/scripts/init/start-exec.ts +16 -3
  103. package/scripts/init/start-steps.ts +10 -13
  104. package/scripts/issue/defect-rate-cli.ts +61 -20
  105. package/scripts/issue/issue-backlinks.ts +7 -14
  106. package/scripts/issue/issue-cite.ts +12 -6
  107. package/scripts/issue/issue-file.ts +5 -12
  108. package/scripts/issue/issue-read-cli.ts +1 -0
  109. package/scripts/issue/issue-report-failures.ts +6 -2
  110. package/scripts/issue/issue-state-cli.ts +1 -1
  111. package/scripts/issue/markdown-section.ts +21 -6
  112. package/scripts/issue/measure-rerun-cli.ts +73 -14
  113. package/scripts/josh/josh-commands-lane.ts +1 -1
  114. package/scripts/josh/josh-commands-project.ts +8 -1
  115. package/scripts/josh/josh-commands-workflow.ts +1 -1
  116. package/scripts/josh/run-github-prerequisite.ts +3 -6
  117. package/scripts/lane/lane-await.ts +89 -15
  118. package/scripts/lane/lane-cli.ts +11 -13
  119. package/scripts/lane/lane-close.ts +4 -1
  120. package/scripts/lane/lane-launch-cli.ts +10 -16
  121. package/scripts/lane/openai-review-broker.ts +4 -1
  122. package/scripts/lib/cli-flags.ts +31 -2
  123. package/scripts/lib/error-message.ts +12 -1
  124. package/scripts/lines/line-targets.ts +0 -11
  125. package/scripts/lines/lines-command.ts +5 -3
  126. package/scripts/propagate/propagate-git.ts +3 -8
  127. package/scripts/refactor/refactor-scan.ts +2 -1
  128. package/scripts/registry-migration/migrate-logic.ts +28 -19
  129. package/scripts/registry-migration/migrate.ts +6 -5
  130. package/scripts/release/github-release-cli.ts +14 -3
  131. package/scripts/release/github-release-environment.ts +1 -0
  132. package/scripts/release/github-release.ts +103 -17
  133. package/scripts/release/release-publish.ts +36 -2
  134. package/scripts/release/release-worktree.ts +97 -2
  135. package/scripts/review/review-checkout.ts +3 -2
  136. package/scripts/review/review-record-cli.ts +9 -16
  137. package/scripts/rules/early-heartbeat.ts +4 -3
  138. package/scripts/rules/lane-park.ts +9 -0
  139. package/scripts/rules/stop-rules.ts +7 -4
  140. package/scripts/run/run-carry-args.ts +8 -12
  141. package/scripts/run/run-carry-cli.ts +3 -1
  142. package/scripts/run/run-carry-conversation.ts +94 -0
  143. package/scripts/run/run-carry-reclaim.ts +37 -0
  144. package/scripts/run/run-carry.ts +21 -5
  145. package/scripts/run/run-cut-args.ts +1 -6
  146. package/scripts/run/run-ending-cli.ts +1 -5
  147. package/scripts/run/run-entry-cli.ts +40 -9
  148. package/scripts/run/run-entry-stop.ts +104 -0
  149. package/scripts/run/run-event-stream-emit.ts +7 -2
  150. package/scripts/run/run-hold.ts +4 -1
  151. package/scripts/run/run-liveness-cli.ts +1 -5
  152. package/scripts/run/run-merge-cli.ts +2 -10
  153. package/scripts/run/run-merge-steps.ts +4 -1
  154. package/scripts/run/run-parent-cut-hook.ts +4 -1
  155. package/scripts/run/run-prep-cli.ts +4 -1
  156. package/scripts/run/run-progress-args.ts +20 -4
  157. package/scripts/run/run-progress-cli.ts +31 -29
  158. package/scripts/run/run-progress-clock.ts +4 -1
  159. package/scripts/run/run-ship-cli.ts +8 -16
  160. package/scripts/run/run-ship-probe.ts +4 -1
  161. package/scripts/run/run-status-cli.ts +2 -15
  162. package/scripts/run/run-tidy-lanes.ts +4 -1
  163. package/scripts/run/run-wake-cli.ts +2 -10
  164. package/scripts/run/run-watcher-guard.ts +9 -5
  165. package/scripts/safe-chain/pack-hook.ts +92 -0
  166. package/scripts/sync/sync-ai-files.ts +4 -0
  167. package/scripts/test/test-declared-changed.ts +13 -16
  168. package/scripts/test/test-declared.ts +3 -7
  169. package/scripts/test/test-related.ts +1 -6
  170. package/scripts/test/test-unit-guard.ts +21 -7
  171. package/scripts/time-runtime/time-spans.ts +4 -0
  172. package/scripts/version/version-commands.ts +11 -1
  173. package/templates/CLAUDE.basic.md +6 -6
  174. package/templates/workflows/ci.yml +16 -82
  175. package/templates/workflows/dependabot-auto-merge.yml +1 -0
  176. package/templates/workflows/github-release.yml +14 -28
  177. package/tsconfig.sonar.json +4 -22
@@ -0,0 +1,24 @@
1
+ import { start_exec } from './start-exec'
2
+
3
+ // The first commit of a new repository, shared by `josh start` and `josh dogfood:commit` so neither
4
+ // keeps a copy of how it is made.
5
+
6
+ const DEFAULT_BRANCH = 'main'
7
+
8
+ function create_repository(root: string): void {
9
+ start_exec.run('git', ['init', `--initial-branch=${DEFAULT_BRANCH}`], root)
10
+ }
11
+
12
+ function commit_all(root: string): void {
13
+ start_exec.run('git', ['add', '--all'], root)
14
+ // The first commit of a new repository has no branch to come from, so the hook that keeps commits
15
+ // off main — installed by the initialize step in a full project — would refuse the only commit
16
+ // that has to land there.
17
+ start_exec.run('git', ['commit', '--no-verify', '--message', 'Initial commit'], root)
18
+ // A repository created by an older `git init` may name its unborn branch `master`.
19
+ start_exec.run('git', ['branch', '--move', '--force', DEFAULT_BRANCH], root)
20
+ }
21
+
22
+ const initial_commit = { create_repository, commit_all }
23
+
24
+ export { initial_commit }
@@ -1,3 +1,4 @@
1
+ import { PACK_HOOK_FILE } from '#scripts/safe-chain/pack-hook'
1
2
  import { PROJECT_CONFIG_FILE } from '#scripts/safe-chain/project-config'
2
3
  import { managed_config_scope } from '#scripts/sync/managed-config-scope'
3
4
  import { BASIC_PRETTIER_CONFIG } from './init-actions'
@@ -13,6 +14,7 @@ const SETUP_ONLY_PATHS: ReadonlySet<string> = new Set([
13
14
  CLAUDE_MD_FILENAME,
14
15
  BASIC_PRETTIER_CONFIG,
15
16
  PROJECT_CONFIG_FILE,
17
+ PACK_HOOK_FILE,
16
18
  MANIFEST_FILE,
17
19
  LOCKFILE,
18
20
  ])
@@ -1,9 +1,9 @@
1
1
  import { existsSync, readdirSync, readFileSync, type Dirent } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { repo_origin } from '#scripts/discovery/repo-origin'
4
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
4
5
  import { json_value } from '#scripts/lib/json-value'
5
6
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
6
- import { execaSync } from 'execa'
7
7
 
8
8
  type ProjectProfile = 'basic' | 'full'
9
9
 
@@ -159,12 +159,9 @@ function scan_files(root: string): { has_web: boolean; has_typescript: boolean }
159
159
  }
160
160
 
161
161
  function has_github_remote(root: string): boolean {
162
- const result = execaSync('git', ['config', '--get', 'remote.origin.url'], {
163
- cwd: root,
164
- reject: false,
165
- })
162
+ const url = git_spawn_sync.origin_url(root)
166
163
 
167
- return result.exitCode === 0 && repo_origin.parse_origin_url(result.stdout) !== undefined
164
+ return url !== undefined && repo_origin.parse_origin_url(url) !== undefined
168
165
  }
169
166
 
170
167
  function inspect_project(root: string, requested?: ProjectProfile): ProjectShape {
@@ -1,10 +1,21 @@
1
1
  import { git_gh_exec } from '#scripts/gh/git-gh-exec'
2
+ import { git_location_environment } from '#scripts/git/git-location-environment'
2
3
  import { execaSync } from 'execa'
3
4
 
4
5
  // The three spawn shapes every `josh start` step uses, shared by `start-steps.ts`, the setup pull
5
6
  // request step and the `josh init` hint so none of them keeps a copy of its own.
7
+ //
8
+ // The three git-driving shapes below act on `root`, so `cwd` has to mean it: an inherited `GIT_DIR` and friends beat `cwd`
9
+ // and would carry a `git init` or commit into whichever repository a calling hook was firing in
10
+ // (joshuafolkken/kit#3151).
11
+ function root_environment(): Record<string, string | undefined> {
12
+ return git_location_environment.location_free_environment()
13
+ }
14
+
6
15
  function succeeds(command: string, args: ReadonlyArray<string>, root: string): boolean {
7
- return execaSync(command, args, { cwd: root, reject: false }).exitCode === 0
16
+ return (
17
+ execaSync(command, args, { cwd: root, env: root_environment(), reject: false }).exitCode === 0
18
+ )
8
19
  }
9
20
 
10
21
  function read_output(
@@ -12,7 +23,7 @@ function read_output(
12
23
  args: ReadonlyArray<string>,
13
24
  root: string,
14
25
  ): string | undefined {
15
- const result = execaSync(command, args, { cwd: root, reject: false })
26
+ const result = execaSync(command, args, { cwd: root, env: root_environment(), reject: false })
16
27
 
17
28
  return result.exitCode === 0 ? result.stdout.trim() : undefined
18
29
  }
@@ -20,7 +31,9 @@ function read_output(
20
31
  // Every step spawned here dials GitHub directly, past a scanner's loopback proxy, the same as every
21
32
  // other `gh` spawn under scripts/ (joshuafolkken/kit#2436); a local `git` step is unaffected by it.
22
33
  function run(command: string, args: ReadonlyArray<string>, root: string): void {
23
- execaSync(command, args, { ...git_gh_exec.direct_environment(), cwd: root, stdio: 'inherit' })
34
+ const environment = { ...root_environment(), ...git_gh_exec.direct_environment().env }
35
+
36
+ execaSync(command, args, { env: environment, cwd: root, stdio: 'inherit' })
24
37
  }
25
38
 
26
39
  // A caller's own initialize command stands in for the in-process `josh init`, so it keeps the
@@ -1,10 +1,12 @@
1
1
  import path from 'node:path'
2
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
2
3
  import { error_text } from '#scripts/lib/error-message'
3
4
  import { repository_labels } from '#scripts/repo/repository-labels'
4
5
  import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
5
6
  import { parseCommandString } from 'execa'
6
7
  import { main as init_main } from './init'
7
8
  import { PACKAGE_DIR } from './init-paths'
9
+ import { initial_commit } from './initial-commit'
8
10
  import { kit_setup_state } from './kit-setup-state'
9
11
  import type { ProjectProfile, ProjectShape } from './project-profile'
10
12
  import { start_exec } from './start-exec'
@@ -22,7 +24,6 @@ interface RunContext extends StepContext {
22
24
  baseline: ReadonlyArray<string> | undefined
23
25
  }
24
26
 
25
- const DEFAULT_BRANCH = 'main'
26
27
  const GH_INSTALL_HINT =
27
28
  'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
28
29
  const GH_AUTH_HINT =
@@ -44,7 +45,7 @@ function read_git_state(root: string, shape: ProjectShape): GitState {
44
45
  }
45
46
  }
46
47
 
47
- const has_origin = succeeds('git', ['remote', 'get-url', 'origin'], root)
48
+ const has_origin = git_spawn_sync.origin_url(root) !== undefined
48
49
  const branch = read_output('git', ['symbolic-ref', '--short', 'HEAD'], root)
49
50
  const has_commits = succeeds('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], root)
50
51
  const has_kit_committed = kit_setup_state.is_kit_committed(root)
@@ -69,7 +70,7 @@ function self_run_refusal(root: string): string | undefined {
69
70
  }
70
71
 
71
72
  function create_git_repository(context: StepContext): void {
72
- run('git', ['init', `--initial-branch=${DEFAULT_BRANCH}`], context.root)
73
+ initial_commit.create_repository(context.root)
73
74
  }
74
75
 
75
76
  // The same setup `josh init` runs, called rather than copied, with the profile the user confirmed.
@@ -89,13 +90,7 @@ function initialize(context: StepContext): void {
89
90
  }
90
91
 
91
92
  function commit_all(context: StepContext): void {
92
- run('git', ['add', '--all'], context.root)
93
- // The first commit of a new repository has no branch to come from, so the hook that keeps commits
94
- // off main — installed by the initialize step in a full project — would refuse the only commit
95
- // that has to land there.
96
- run('git', ['commit', '--no-verify', '--message', 'Initial commit'], context.root)
97
- // A repository created by an older `git init` may name its unborn branch `master`.
98
- run('git', ['branch', '--move', '--force', DEFAULT_BRANCH], context.root)
93
+ initial_commit.commit_all(context.root)
99
94
  }
100
95
 
101
96
  function create_github_repository(context: StepContext): void {
@@ -118,13 +113,15 @@ async function open_setup_pull_request(context: RunContext): Promise<void> {
118
113
  await start_setup_pr.open(context.root, context.baseline)
119
114
  }
120
115
 
121
- // What was already changed before a caller's initialize command ran, read only when the setup pull
122
- // request will need to tell that command's files from the user's own (#2872).
116
+ // What was already changed before the setup ran, read whenever a setup pull request will follow, so
117
+ // it can tell the setup's files from the user's own (#2872). Kit's own `josh init` needs it as much as
118
+ // a caller's command: its `josh format` rewrites the project's own sources, which kit cannot name, and
119
+ // leaving them out failed the pull request's Prettier check (joshuafolkken/kit#3136).
123
120
  function baseline_of(
124
121
  steps: ReadonlyArray<StartStep>,
125
122
  context: StepContext,
126
123
  ): ReadonlyArray<string> | undefined {
127
- if (context.init_command === undefined || !steps.includes('setup_pr')) return undefined
124
+ if (!steps.includes('setup_pr')) return undefined
128
125
 
129
126
  return start_setup_pr.changed_paths(context.root)
130
127
  }
@@ -1,16 +1,24 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
4
3
  import { git_gh_command } from '#scripts/gh/git-gh-command'
5
4
  import { git_gh_exec } from '#scripts/gh/git-gh-exec'
6
- import { parse_json_array_or_undefined } from '#scripts/git/parse-json-array'
5
+ import { parse_json } from '#scripts/git/parse-json-array'
7
6
  import { issue_label_schema } from '#scripts/git/schemas'
7
+ import { bounded_pool } from '#scripts/lib/bounded-pool'
8
+ import { cli_flags } from '#scripts/lib/cli-flags'
8
9
  import { z } from 'zod'
9
10
  import { defect_rate, type DefectRate, type RateIssue } from './defect-rate'
10
11
 
11
12
  const SUCCESS_EXIT_CODE = 0
12
13
  const FAILURE_EXIT_CODE = 1
13
14
  const PER_PAGE = '100'
15
+ const FIRST_PAGE = 1
16
+ const SECOND_PAGE = 2
17
+ // The search API serves at most this many results, so a window past it holds no further page.
18
+ const SEARCH_RESULT_CEILING = 1000
19
+ // The search API allows 30 requests a minute; both searches together stay at the ~10 requests the
20
+ // sequential walk made, so this bounds only how many are in flight at once.
21
+ const PAGE_CONCURRENCY = 4
14
22
  const USAGE = 'Usage: pnpm josh defect:rate [--days <n>]'
15
23
  const UNKNOWN_REPO_MESSAGE =
16
24
  'Could not read this repository from `git remote`, so no issue can be searched — check `gh auth status` and that this is a checkout with an `origin` remote.'
@@ -33,7 +41,7 @@ const search_item_schema = z.object({
33
41
  labels: z.array(issue_label_schema),
34
42
  })
35
43
 
36
- // One page of `search/issues`. `--paginate --slurp` hands back every page as one array.
44
+ // One page of `search/issues`.
37
45
  const search_page_schema = z.object({
38
46
  total_count: z.number(),
39
47
  incomplete_results: z.boolean(),
@@ -48,22 +56,60 @@ interface SearchResult {
48
56
  }
49
57
 
50
58
  function read_days(argv: ReadonlyArray<string>): number | undefined {
51
- try {
52
- const { values } = parseArgs({ args: [...argv], options: OPTIONS, strict: true })
53
- const raw = values.days ?? String(defect_rate.DEFAULT_WINDOW_DAYS)
59
+ const values = cli_flags.values_of(argv, OPTIONS)
54
60
 
55
- if (!DAYS_PATTERN.test(raw)) return undefined
61
+ if (values === undefined) return undefined
56
62
 
57
- const days = Number(raw)
63
+ const raw = values.days ?? String(defect_rate.DEFAULT_WINDOW_DAYS)
58
64
 
59
- return days <= MAX_WINDOW_DAYS ? days : undefined
60
- } catch {
61
- return undefined
62
- }
65
+ if (!DAYS_PATTERN.test(raw)) return undefined
66
+
67
+ const days = Number(raw)
68
+
69
+ return days <= MAX_WINDOW_DAYS ? days : undefined
70
+ }
71
+
72
+ function search_path(query: string, page: number = FIRST_PAGE): string {
73
+ const parameters = new URLSearchParams({ q: query, per_page: PER_PAGE })
74
+
75
+ if (page !== FIRST_PAGE) parameters.set('page', String(page))
76
+
77
+ return `search/issues?${parameters.toString()}`
63
78
  }
64
79
 
65
- function search_path(query: string): string {
66
- return `search/issues?${new URLSearchParams({ q: query, per_page: PER_PAGE }).toString()}`
80
+ // How many pages the search holds, read off the first page's own count. Bounded by the API's
81
+ // ceiling, past which GitHub serves no page at all — so this asks for exactly the pages `gh api
82
+ // --paginate` used to walk to.
83
+ function page_count(first: SearchPage): number {
84
+ return Math.ceil(Math.min(first.total_count, SEARCH_RESULT_CEILING) / Number(PER_PAGE))
85
+ }
86
+
87
+ async function read_page(query: string, page: number): Promise<SearchPage | undefined> {
88
+ const raw = await git_gh_exec.exec_gh_api({ path: search_path(query, page) })
89
+
90
+ return parse_json.parse_json_object_safe(raw, search_page_schema)
91
+ }
92
+
93
+ // Every page of one search. joshuafolkken/kit#3103: `--paginate` follows each page's `next` link, so
94
+ // a two-week window of ~5 pages cost ~1.5 s a page, one after another, on every `backlog:plan`. The
95
+ // first page names the total, so the rest are fetched together; any page that does not read fails
96
+ // the whole search, as a failed `--paginate` did.
97
+ async function read_pages(query: string): Promise<ReadonlyArray<SearchPage> | undefined> {
98
+ const first = await read_page(query, FIRST_PAGE)
99
+
100
+ if (first === undefined) return undefined
101
+
102
+ const rest_numbers = Array.from(
103
+ { length: Math.max(page_count(first) - FIRST_PAGE, 0) },
104
+ (_value, index) => index + SECOND_PAGE,
105
+ )
106
+ const rest = await bounded_pool.bounded_map(
107
+ rest_numbers,
108
+ PAGE_CONCURRENCY,
109
+ async (page) => await read_page(query, page),
110
+ )
111
+
112
+ return rest.every((page) => page !== undefined) ? [first, ...rest] : undefined
67
113
  }
68
114
 
69
115
  function to_result(pages: ReadonlyArray<SearchPage>): SearchResult {
@@ -83,12 +129,7 @@ function to_result(pages: ReadonlyArray<SearchPage>): SearchResult {
83
129
 
84
130
  async function search(query: string): Promise<SearchResult | undefined> {
85
131
  try {
86
- const raw = await git_gh_exec.exec_gh_api({
87
- path: search_path(query),
88
- should_paginate: true,
89
- should_slurp: true,
90
- })
91
- const pages = parse_json_array_or_undefined(raw, search_page_schema)
132
+ const pages = await read_pages(query)
92
133
 
93
134
  return pages === undefined ? undefined : to_result(pages)
94
135
  } catch {
@@ -4,12 +4,13 @@
4
4
  // undetected. This classifies an origin issue's filed backlinks into one of four fixed words
5
5
  // (joshuafolkken/kit#2123). The headings are the single source's, pinned by the document test.
6
6
 
7
+ import { markdown_section } from './markdown-section'
8
+
7
9
  const ORIGIN_HEADING = '## Origin'
8
10
  const UPSTREAM_ISSUES_HEADING = '## Upstream issues'
9
11
  const UPSTREAM_CANDIDATE_HEADING = '## Upstream candidate'
10
12
  const CHECKBOX_PATTERN = /^\s*-\s*\[[ xX]\]/u
11
13
  const WRONG_UPSTREAM_HEADING_PATTERN = /^##\s+Upstream\b/iu
12
- const SUBHEADING_PATTERN = /^##\s/u
13
14
  const BARE_REFERENCE_PATTERN = /^#\d+$/u
14
15
  const DIGITS_PATTERN = /^\d+$/u
15
16
  const REFERENCE_MARK = '#'
@@ -27,31 +28,23 @@ interface UpstreamEntry {
27
28
  body: string
28
29
  }
29
30
 
30
- function trimmed_lines(body: string): ReadonlyArray<string> {
31
- return body.split('\n').map((line) => line.trimEnd())
32
- }
33
-
31
+ // A heading inside a code fence is example text, so neither lookup counts one (joshuafolkken/kit#3066).
34
32
  function has_heading(body: string, heading: string): boolean {
35
- return trimmed_lines(body).some((line) => line.trim() === heading)
33
+ return markdown_section.has_line(body, heading)
36
34
  }
37
35
 
38
36
  // The lines under `heading`, up to the next `##` heading; `undefined` when the heading is absent.
39
37
  function section_lines(body: string, heading: string): ReadonlyArray<string> | undefined {
40
- const lines = trimmed_lines(body)
41
- const start = lines.findIndex((line) => line.trim() === heading)
42
- if (start === -1) return undefined
43
-
44
- const rest = lines.slice(start + 1)
45
- const end = rest.findIndex((line) => SUBHEADING_PATTERN.test(line.trim()))
38
+ if (!has_heading(body, heading)) return undefined
46
39
 
47
- return end === -1 ? rest : rest.slice(0, end)
40
+ return markdown_section.section_lines(body, heading)
48
41
  }
49
42
 
50
43
  // A heading that looks like the upstream one but is not exactly it (`## Upstream`, `## Upstreams`,
51
44
  // wrong case) — the grep the template promises would miss it, so it is a wrong-heading rather than a
52
45
  // missing one. `## Upstream candidate` is a legitimate separate heading and is excluded.
53
46
  function has_wrong_upstream_heading(body: string): boolean {
54
- return trimmed_lines(body).some((line) => {
47
+ return markdown_section.unfenced_lines(body).some((line) => {
55
48
  const heading = line.trim()
56
49
  if (heading === UPSTREAM_ISSUES_HEADING || heading === UPSTREAM_CANDIDATE_HEADING) return false
57
50
 
@@ -46,14 +46,19 @@ function citation_line(slug: string, number: string, summary: string): string {
46
46
  // and the plain `#N` only when the repository could not be read at all.
47
47
  type IssueCiter = (number: string) => string
48
48
 
49
+ // One issue, named from what the caller holds — the single decision every listing's citation goes
50
+ // through (joshuafolkken/kit#3099), so a renderer holding one title never re-derives the fallback.
51
+ function reference(slug: string | undefined, number: string, title: string | undefined): string {
52
+ if (slug === undefined) return `#${number}`
53
+
54
+ return title === undefined
55
+ ? `[#${number}](${issue_url(slug, number)})`
56
+ : citation_line(slug, number, title)
57
+ }
58
+
49
59
  function citer(slug: string | undefined, titles: ReadonlyMap<string, string>): IssueCiter {
50
60
  return function cite(number: string): string {
51
- if (slug === undefined) return `#${number}`
52
- const title = titles.get(number)
53
-
54
- return title === undefined
55
- ? `[#${number}](${issue_url(slug, number)})`
56
- : citation_line(slug, number, title)
61
+ return reference(slug, number, titles.get(number))
57
62
  }
58
63
  }
59
64
 
@@ -111,6 +116,7 @@ const issue_cite = {
111
116
  missing_line,
112
117
  no_repo_line,
113
118
  parse_target,
119
+ reference,
114
120
  unreadable_line,
115
121
  }
116
122
 
@@ -1,6 +1,7 @@
1
- import { parseArgs } from 'node:util'
1
+ import type { parseArgs } from 'node:util'
2
2
  import { epic_issue } from '#scripts/epic/epic-issue'
3
3
  import { github_issue_url } from '#scripts/gh/github-issue-url'
4
+ import { cli_flags } from '#scripts/lib/cli-flags'
4
5
  import { issue_backlinks } from './issue-backlinks'
5
6
  import { issue_classification } from './issue-classification'
6
7
  import { DEPTH_LABEL_ORDER, FILING_ROUTE_LABELS, has_label_name } from './issue-labels'
@@ -103,19 +104,11 @@ function arguments_of(values: ParsedValues, title: string | undefined): FileArgu
103
104
  return { title, body_file, ...labelled, labels: values.label ?? [], repo: values.repo }
104
105
  }
105
106
 
106
- // An unknown flag makes `parseArgs` throw; caught so the answer is the usage line, not a stack trace.
107
+ // An unknown flag is `undefined` rather than a throw, so the answer is the usage line, not a stack trace.
107
108
  function parse(argv: ReadonlyArray<string>): FileArguments | undefined {
108
- try {
109
- const { values, positionals } = parseArgs({
110
- args: [...argv],
111
- options: OPTIONS,
112
- allowPositionals: true,
113
- })
109
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
114
110
 
115
- return arguments_of(values, positionals[0])
116
- } catch {
117
- return undefined
118
- }
111
+ return parsed === undefined ? undefined : arguments_of(parsed.values, parsed.positionals[0])
119
112
  }
120
113
 
121
114
  // A classification label is the body's to declare, so an extra `--label` naming one the body does not
@@ -129,6 +129,7 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
129
129
  issue_report_failures.print_blocks(
130
130
  reports.flatMap((report) => issue_blocks(report)),
131
131
  BLOCK_SEPARATOR,
132
+ `issue:read ${issue_numbers.join(' ')}`,
132
133
  )
133
134
 
134
135
  return issue_report_failures.report_failures(reports, FAILURE_TERMS)
@@ -1,3 +1,5 @@
1
+ import { capped_output } from '#scripts/document/capped-print'
2
+
1
3
  // The failure report `issue:read` and `issue:state` share (joshuafolkken/kit#3046). Both read a batch
2
4
  // of numbers, print a block for each one that answered, and name every one that did not; only the
3
5
  // result kind that counts as an answer and what a gap must not be mistaken for differ between them.
@@ -22,10 +24,12 @@ interface FailureTerms<K extends string> {
22
24
  misreading: string
23
25
  }
24
26
 
25
- function print_blocks(blocks: ReadonlyArray<string>, separator: string): void {
27
+ // Under the Bash output cap, or as part files past it — a batch of long threads is the shape that
28
+ // overflowed (joshuafolkken/kit#3143).
29
+ function print_blocks(blocks: ReadonlyArray<string>, separator: string, label: string): void {
26
30
  if (blocks.length === 0) return
27
31
 
28
- console.info(blocks.join(separator))
32
+ capped_output.capped_print(blocks.join(separator), label)
29
33
  }
30
34
 
31
35
  // A number that resolves to nothing is an answer — a typo, or another repository's number quoted in
@@ -188,7 +188,7 @@ function print_states(reports: ReadonlyArray<IssueReport>): void {
188
188
  const should_attribute = reports.length > 1
189
189
  const blocks = reports.flatMap((report) => state_blocks(report, should_attribute))
190
190
 
191
- issue_report_failures.print_blocks(blocks, BLOCK_SEPARATOR)
191
+ issue_report_failures.print_blocks(blocks, BLOCK_SEPARATOR, 'issue:state')
192
192
  }
193
193
 
194
194
  async function run(argv: ReadonlyArray<string>): Promise<number> {
@@ -52,13 +52,16 @@ function has_line(body: string, text: string): boolean {
52
52
  return has_unfenced_line(body, text)
53
53
  }
54
54
 
55
- // The lines between `heading` and the next `## ` heading, trimmed of the heading line itself; an empty
56
- // array when the heading is absent. A heading matched inside a sentence is not the section heading, so
57
- // only a line that is exactly the heading opens the section.
58
- function section_lines(body: string, heading: string): ReadonlyArray<string> {
55
+ // The lines between the first unfenced line `is_target` accepts and the next unfenced `## ` heading,
56
+ // trimmed of the heading line itself; an empty array when no line is accepted. A `## ` line inside a
57
+ // code fence is example text, so it neither opens nor closes a section (joshuafolkken/kit#3066).
58
+ function section_lines_matching(
59
+ body: string,
60
+ is_target: (line: string) => boolean,
61
+ ): ReadonlyArray<string> {
59
62
  const lines = body.split('\n')
60
63
  const visible = unfenced_lines(body)
61
- const start = visible.findIndex((line) => is_the_heading(line, heading))
64
+ const start = visible.findIndex((line) => is_target(line))
62
65
 
63
66
  if (start === -1) return []
64
67
 
@@ -68,6 +71,18 @@ function section_lines(body: string, heading: string): ReadonlyArray<string> {
68
71
  return end === -1 ? rest : rest.slice(0, end)
69
72
  }
70
73
 
71
- const markdown_section = { section_lines, has_line, has_unfenced_line }
74
+ // A heading matched inside a sentence is not the section heading, so only a line that is exactly the
75
+ // heading opens the section.
76
+ function section_lines(body: string, heading: string): ReadonlyArray<string> {
77
+ return section_lines_matching(body, (line) => is_the_heading(line, heading))
78
+ }
79
+
80
+ const markdown_section = {
81
+ section_lines,
82
+ section_lines_matching,
83
+ unfenced_lines,
84
+ has_line,
85
+ has_unfenced_line,
86
+ }
72
87
 
73
88
  export { markdown_section }
@@ -1,26 +1,52 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { execSync } from 'node:child_process'
3
- import { readFile } from 'node:fs/promises'
4
3
  import { fileURLToPath } from 'node:url'
4
+ import { git_gh_issue_read } from '#scripts/gh/git-gh-issue-read'
5
+ import { parse_json_object_safe } from '#scripts/git/parse-json-array'
5
6
  import { error_text } from '#scripts/lib/error-message'
6
7
  import { observation_ledger_home } from '#scripts/observations/observation-ledger-home'
8
+ import { z } from 'zod'
7
9
  import { baseline_measure, type Baseline } from './baseline-measure'
8
10
 
9
- // `josh measure:rerun <path>` — read a behavior-change Issue body after merge, re-run each baseline
11
+ // `josh measure:rerun <N>` — read a behavior-change Issue body after merge, re-run each baseline
10
12
  // command, and print the before/after pair (joshuafolkken/kit#2212). A value that did not move means
11
13
  // the premise the rule rested on is refuted, so a line is appended to the observation ledger — reusing
12
14
  // that append-only, same-key mechanism rather than a second one.
15
+ //
16
+ // **The baseline is shell, and the body is written by whoever filed the Issue** (joshuafolkken/kit#3064).
17
+ // A baseline needs pipes and loops (`git log … | wc -l`, `for d in $(ls …)`), so an allow-list of
18
+ // commands cannot make it safe; what can is who wrote it. The input is therefore the Issue number, not
19
+ // a file, so the author's `author_association` arrives with the body from the same REST read, and only
20
+ // a body written by someone with write access to the repository is run.
13
21
 
14
22
  const FAILURE_EXIT_CODE = 1
15
- const USAGE = 'Usage: josh measure:rerun <path-to-issue-body>'
23
+ const ARGV_OFFSET = 2
24
+ const ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/u
25
+ const ISSUE_FIELDS = 'body,author_association'
26
+ const TRUSTED_ASSOCIATIONS: ReadonlySet<string> = new Set(['OWNER', 'MEMBER', 'COLLABORATOR'])
27
+ const TRUSTED_LIST = [...TRUSTED_ASSOCIATIONS].join(' / ')
28
+ const UNKNOWN_ASSOCIATION = 'unknown'
29
+ const USAGE = 'Usage: josh measure:rerun <issue-number>'
16
30
  const NO_BASELINE = 'no `command → value` baseline found in ## ベースライン — nothing to re-run'
17
31
  const DATE_END = 10
18
-
19
- // The command's stdout, trimmed to one value. A failing command yields its message so the pair still
20
- // prints rather than aborting the whole re-measurement.
32
+ // One baseline command's budget — the same minute `josh-harness.ts` gives one command it spawns. A
33
+ // baseline is a count or a digest that answers in seconds, so a command still running after a minute
34
+ // is stuck, and without a bound it would hold the whole re-measurement open with nothing printed.
35
+ const MEASURE_TIMEOUT_MS = 60_000
36
+ const issue_schema = z.object({
37
+ body: z.string().nullish(),
38
+ author_association: z.string().nullish(),
39
+ })
40
+
41
+ type IssueFields = z.infer<typeof issue_schema>
42
+ type BodyRead = { kind: 'trusted'; body: string } | { kind: 'refused'; reason: string }
43
+
44
+ // The command's stdout, trimmed to one value. A failing or timed-out command yields its message so the
45
+ // pair still prints and the remaining baselines still run, rather than aborting the whole
46
+ // re-measurement.
21
47
  function measure(command: string): string {
22
48
  try {
23
- return execSync(command, { encoding: 'utf8' }).trim()
49
+ return execSync(command, { encoding: 'utf8', timeout: MEASURE_TIMEOUT_MS }).trim()
24
50
  } catch (error) {
25
51
  return `(command failed: ${error_text.message_of(error)})`
26
52
  }
@@ -56,8 +82,35 @@ function today(now: Date): string {
56
82
  return now.toISOString().slice(0, DATE_END)
57
83
  }
58
84
 
59
- async function rerun(body_path: string, now: Date): Promise<number> {
60
- const baselines = baseline_measure.parse_baselines(await readFile(body_path, 'utf8'))
85
+ async function read_issue(issue_number: string): Promise<IssueFields | undefined> {
86
+ const json = await git_gh_issue_read.issue_view_json(issue_number, ISSUE_FIELDS)
87
+
88
+ return json === undefined ? undefined : parse_json_object_safe(json, issue_schema)
89
+ }
90
+
91
+ // The body, only when its author can write to the repository. A read that fails is refused as well:
92
+ // an unknown author is not a trusted one.
93
+ async function read_trusted_body(issue_number: string): Promise<BodyRead> {
94
+ const issue = await read_issue(issue_number)
95
+
96
+ if (issue === undefined) {
97
+ return { kind: 'refused', reason: `could not read issue #${issue_number}` }
98
+ }
99
+
100
+ const association = issue.author_association ?? UNKNOWN_ASSOCIATION
101
+
102
+ if (!TRUSTED_ASSOCIATIONS.has(association)) {
103
+ return {
104
+ kind: 'refused',
105
+ reason: `refusing to run the baseline of #${issue_number}: its author is ${association}, not ${TRUSTED_LIST}`,
106
+ }
107
+ }
108
+
109
+ return { kind: 'trusted', body: issue.body ?? '' }
110
+ }
111
+
112
+ async function rerun(body: string, now: Date): Promise<number> {
113
+ const baselines = baseline_measure.parse_baselines(body)
61
114
 
62
115
  if (baselines.length === 0) {
63
116
  console.error(NO_BASELINE)
@@ -76,14 +129,22 @@ async function rerun(body_path: string, now: Date): Promise<number> {
76
129
  return 0
77
130
  }
78
131
 
79
- async function run(body_path: string | undefined, now: Date): Promise<number> {
80
- if (body_path === undefined) {
132
+ async function run(issue_number: string | undefined, now: Date): Promise<number> {
133
+ if (issue_number === undefined || !ISSUE_NUMBER_PATTERN.test(issue_number)) {
81
134
  console.error(USAGE)
82
135
 
83
136
  return FAILURE_EXIT_CODE
84
137
  }
85
138
 
86
- return await rerun(body_path, now)
139
+ const read = await read_trusted_body(issue_number)
140
+
141
+ if (read.kind === 'refused') {
142
+ console.error(read.reason)
143
+
144
+ return FAILURE_EXIT_CODE
145
+ }
146
+
147
+ return await rerun(read.body, now)
87
148
  }
88
149
 
89
150
  async function main(argv: ReadonlyArray<string>): Promise<void> {
@@ -92,8 +153,6 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
92
153
 
93
154
  const measure_rerun_cli = { run }
94
155
 
95
- const ARGV_OFFSET = 2
96
-
97
156
  if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
98
157
 
99
158
  export { measure_rerun_cli }
@@ -59,7 +59,7 @@ const LANE_COMMANDS: Record<string, CommandEntry> = {
59
59
  description:
60
60
  'Block until any of the named in-flight lane children completes; prints which one finished',
61
61
  category: 'AI tools',
62
- reference: ['<issue> [<issue>...]', 'automation', ['processes']],
62
+ reference: ['<issue> [<issue>...] [--owner <pid>]', 'automation', ['files', 'processes']],
63
63
  default_script_arguments: ['await'],
64
64
  },
65
65
  'lane:launch': {