@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
@@ -32,11 +32,7 @@ type ParsedValues = Partial<Record<OptionName, string | boolean>>
32
32
  // A misspelled flag is refused rather than defaulted, the contract `path_decision.has_unknown_flag`
33
33
  // states for the path-decided commands: an invocation nobody can read is never answered.
34
34
  function read_arguments(argv: ReadonlyArray<string>): ParsedValues | undefined {
35
- return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, strict: true })?.values
36
- }
37
-
38
- function text_of(value: string | boolean | undefined): string | undefined {
39
- return typeof value === 'string' ? value : undefined
35
+ return cli_flags.values_of(argv, OPTIONS)
40
36
  }
41
37
 
42
38
  // `find` rather than a cast: an unrecognized word has to be refused, and a type assertion would
@@ -79,15 +75,15 @@ type Counts = Record<(typeof COUNT_NAMES)[number], number | undefined>
79
75
 
80
76
  function counts_of(values: ParsedValues): Counts {
81
77
  return {
82
- idle: to_count(text_of(values.idle)),
83
- max: to_count(text_of(values.max)),
84
- merged: to_count(text_of(values.merged)),
85
- running: to_count(text_of(values.running)),
78
+ idle: to_count(cli_flags.string_of(values.idle)),
79
+ max: to_count(cli_flags.string_of(values.max)),
80
+ merged: to_count(cli_flags.string_of(values.merged)),
81
+ running: to_count(cli_flags.string_of(values.running)),
86
82
  }
87
83
  }
88
84
 
89
85
  function are_counts_readable(values: ParsedValues, counts: Counts): boolean {
90
- return COUNT_NAMES.every((name) => is_readable(text_of(values[name]), counts[name]))
86
+ return COUNT_NAMES.every((name) => is_readable(cli_flags.string_of(values[name]), counts[name]))
91
87
  }
92
88
 
93
89
  // **`--idle 0` is how the watch is turned off, and omitting the flag takes the default**
@@ -116,14 +112,17 @@ function to_idle_ms(idle_minutes: number | undefined): number | undefined {
116
112
  // lands on the very first ask, before the run has started anything, and the fix is one flag the loop
117
113
  // already holds: it always knows when it last had work.
118
114
  function is_watch_readable(values: ParsedValues): boolean {
119
- if (to_idle_ms(to_count(text_of(values.idle))) === undefined) return true
115
+ if (to_idle_ms(to_count(cli_flags.string_of(values.idle))) === undefined) return true
120
116
 
121
- return text_of(values.active) !== undefined
117
+ return cli_flags.string_of(values.active) !== undefined
122
118
  }
123
119
 
124
120
  function are_values_readable(values: ParsedValues): boolean {
125
121
  return (
126
- is_readable(text_of(values.active), to_time_ms(text_of(values.active))) &&
122
+ is_readable(
123
+ cli_flags.string_of(values.active),
124
+ to_time_ms(cli_flags.string_of(values.active)),
125
+ ) &&
127
126
  is_watch_readable(values) &&
128
127
  are_counts_readable(values, counts_of(values))
129
128
  )
@@ -138,7 +137,7 @@ function input_of(values: ParsedValues, base: InputBase): BudgetInput {
138
137
 
139
138
  return {
140
139
  ...base,
141
- active_at_ms: to_time_ms(text_of(values.active)) ?? base.started_at_ms,
140
+ active_at_ms: to_time_ms(cli_flags.string_of(values.active)) ?? base.started_at_ms,
142
141
  merged: counts.merged ?? 0,
143
142
  running: counts.running ?? 0,
144
143
  max_issues: counts.max,
@@ -147,11 +146,11 @@ function input_of(values: ParsedValues, base: InputBase): BudgetInput {
147
146
  }
148
147
 
149
148
  function build_input(values: ParsedValues, now_ms: number): BudgetInput | undefined {
150
- const answer = to_answer(text_of(values.answer))
149
+ const answer = to_answer(cli_flags.string_of(values.answer))
151
150
 
152
151
  if (answer === undefined) return undefined
153
152
 
154
- const started_at_ms = to_time_ms(text_of(values.started))
153
+ const started_at_ms = to_time_ms(cli_flags.string_of(values.started))
155
154
 
156
155
  if (started_at_ms === undefined) return undefined
157
156
  if (!are_values_readable(values)) return undefined
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
4
3
  import { josh_command } from '#scripts/josh/josh-run'
4
+ import { cli_flags } from '#scripts/lib/cli-flags'
5
5
  import { rule_value_cache } from '#scripts/rules/rule-value-cache'
6
6
  import { run_event_stream } from '#scripts/run/run-event-stream'
7
7
  import { run_event_stream_emit } from '#scripts/run/run-event-stream-emit'
@@ -61,15 +61,7 @@ type ParsedValues = Partial<Record<OptionName, string | boolean | ReadonlyArray<
61
61
  const FORWARDED = ['started', 'active', 'merged', 'running', 'max', 'idle'] as const
62
62
 
63
63
  function read_values(argv: ReadonlyArray<string>): ParsedValues | undefined {
64
- try {
65
- return parseArgs({ args: [...argv], options: OPTIONS, strict: true }).values
66
- } catch {
67
- return undefined
68
- }
69
- }
70
-
71
- function text_of(value: string | boolean | ReadonlyArray<string> | undefined): string | undefined {
72
- return typeof value === 'string' ? value : undefined
64
+ return cli_flags.values_of(argv, OPTIONS)
73
65
  }
74
66
 
75
67
  function to_count(raw: string | undefined): number | undefined {
@@ -94,8 +86,8 @@ interface OfferCounts {
94
86
  // The two counts the mapping needs: `--running` decides `wait`, `--retries` decides `retry`. Either
95
87
  // given but unreadable refuses the whole invocation.
96
88
  function counts_of(values: ParsedValues): OfferCounts | undefined {
97
- const running = count_of(text_of(values.running), DEFAULT_COUNT)
98
- const retries = count_of(text_of(values.retries), DEFAULT_COUNT)
89
+ const running = count_of(cli_flags.string_of(values.running), DEFAULT_COUNT)
90
+ const retries = count_of(cli_flags.string_of(values.retries), DEFAULT_COUNT)
99
91
 
100
92
  if (running === undefined || retries === undefined) return undefined
101
93
 
@@ -109,7 +101,7 @@ function excludes_of(values: ParsedValues): ReadonlyArray<string> {
109
101
  }
110
102
 
111
103
  function next_argv(values: ParsedValues): ReadonlyArray<string> {
112
- const repo = text_of(values.repo)
104
+ const repo = cli_flags.string_of(values.repo)
113
105
 
114
106
  return [
115
107
  'backlog:next',
@@ -120,7 +112,7 @@ function next_argv(values: ParsedValues): ReadonlyArray<string> {
120
112
 
121
113
  function budget_argv(values: ParsedValues, answer: string): ReadonlyArray<string> {
122
114
  const forwarded = FORWARDED.flatMap((name) => {
123
- const value = text_of(values[name])
115
+ const value = cli_flags.string_of(values[name])
124
116
 
125
117
  return value === undefined ? [] : [`--${name}`, value]
126
118
  })
@@ -3,6 +3,7 @@ import type { IssueReference } from '#scripts/epic/epic-reference'
3
3
  import { epic_report, type EpicNextResult } from '#scripts/epic/epic-report'
4
4
  import { epic_solo } from '#scripts/epic/epic-solo'
5
5
  import { epic_triage } from '#scripts/epic/epic-triage'
6
+ import { issue_cite } from '#scripts/issue/issue-cite'
6
7
  import { has_label_name, IN_PROGRESS_LABEL, RUN_SOLO_LABEL } from '#scripts/issue/issue-labels'
7
8
  import type { OutOfScopeRow } from './backlog-scope'
8
9
 
@@ -57,23 +58,29 @@ interface PlanContext {
57
58
 
58
59
  // A child elsewhere is qualified, because a bare number would name *this* repository's issue of that
59
60
  // number — a different issue (joshuafolkken/kit#1016). Takes the reference rather than the child, so
60
- // a `blocked_by` edge is named by the same rule the child itself is.
61
- function reference_of(reference: IssueReference, repo: string): string {
61
+ // a `blocked_by` edge is named by the same rule the child itself is. This repository's issue is cited
62
+ // (joshuafolkken/kit#3099): the plan is copied into a report verbatim, so the line it prints is the
63
+ // citation the report carries.
64
+ function cite_of(reference: IssueReference, repo: string, title: string | undefined): string {
62
65
  const number = String(reference.number)
63
66
 
64
- return reference.repo === repo ? `#${number}` : `${reference.repo}#${number}`
67
+ return reference.repo === repo
68
+ ? issue_cite.reference(repo, number, title)
69
+ : `${reference.repo}#${number}`
70
+ }
71
+
72
+ function reference_of(reference: IssueReference, repo: string): string {
73
+ return cite_of(reference, repo, undefined)
65
74
  }
66
75
 
67
76
  // A title is only known for this repository's issues — the open listing the titles come from is this
68
77
  // repository's. A child elsewhere is still named, just without one.
69
- function title_of(child: EpicChild, context: PlanContext): string {
70
- return child.repo === context.repo ? (context.titles.get(child.number) ?? '') : ''
78
+ function title_of(child: EpicChild, context: PlanContext): string | undefined {
79
+ return child.repo === context.repo ? context.titles.get(child.number) : undefined
71
80
  }
72
81
 
73
- function join_row(reference: string, title: string, note: string): string {
74
- const parts = [reference, title, note === '' ? '' : `— ${note}`].filter((part) => part !== '')
75
-
76
- return `${ROW_INDENT}${parts.join(' ')}`
82
+ function join_row(reference: string, note: string): string {
83
+ return note === '' ? `${ROW_INDENT}${reference}` : `${ROW_INDENT}${reference} — ${note}`
77
84
  }
78
85
 
79
86
  // A `run:solo` child is marked beside its number, so a person reading the plan sees which issues
@@ -89,15 +96,15 @@ function mark_of(child: EpicChild): string | undefined {
89
96
  return epic_triage.is_triaged(child) ? undefined : UNTRIAGED_MARK
90
97
  }
91
98
 
92
- function marked_reference(child: EpicChild, repo: string): string {
93
- const reference = reference_of(child, repo)
99
+ function marked_reference(child: EpicChild, repo: string, title?: string): string {
100
+ const reference = cite_of(child, repo, title)
94
101
  const mark = mark_of(child)
95
102
 
96
103
  return mark === undefined ? reference : `${reference} ${mark}`
97
104
  }
98
105
 
99
106
  function row_of(child: EpicChild, context: PlanContext, note: string): string {
100
- return join_row(marked_reference(child, context.repo), title_of(child, context), note)
107
+ return join_row(marked_reference(child, context.repo, title_of(child, context)), note)
101
108
  }
102
109
 
103
110
  // Why this one is not offered yet. The blocker numbers are the answer whenever there are any — that
@@ -164,8 +171,10 @@ function waiting_lines(children: ReadonlyArray<EpicChild>, context: PlanContext)
164
171
  })
165
172
  }
166
173
 
167
- function scope_lines(rows: ReadonlyArray<OutOfScopeRow>): Array<string> {
168
- return rows.map((row) => join_row(`#${String(row.number)}`, row.title, row.reason))
174
+ function scope_lines(rows: ReadonlyArray<OutOfScopeRow>, repo: string): Array<string> {
175
+ return rows.map((row) =>
176
+ join_row(issue_cite.reference(repo, String(row.number), row.title), row.reason),
177
+ )
169
178
  }
170
179
 
171
180
  // The classification could not be made, so every section below it would be rendered from buckets
@@ -186,10 +195,12 @@ interface NamedPlan {
186
195
  only: boolean
187
196
  }
188
197
 
189
- // The named issues are this repository's own, so a bare `#N` names them and a title is looked up the
190
- // same way the pool's rows are. The order is the invocation's, never sorted.
198
+ // The named issues are this repository's own, so they are cited against it and a title is looked up
199
+ // the same way the pool's rows are. The order is the invocation's, never sorted.
191
200
  function named_lines(named: ReadonlyArray<number>, context: PlanContext): Array<string> {
192
- return named.map((issue) => join_row(`#${String(issue)}`, context.titles.get(issue) ?? '', ''))
201
+ return named.map((issue) =>
202
+ join_row(issue_cite.reference(context.repo, String(issue), context.titles.get(issue)), ''),
203
+ )
193
204
  }
194
205
 
195
206
  // The section leads the plan when the invocation named issues, and is absent otherwise — a bare
@@ -230,7 +241,7 @@ function format_plan(
230
241
  '',
231
242
  section(HUMAN_HEADING, child_lines(result.blocked_on_people, context, no_note)),
232
243
  '',
233
- section(SCOPE_HEADING, scope_lines(out_of_scope)),
244
+ section(SCOPE_HEADING, scope_lines(out_of_scope, context.repo)),
234
245
  ].join('\n')
235
246
  }
236
247
 
@@ -1,3 +1,5 @@
1
+ import { git_gh_command } from '#scripts/gh/git-gh-command'
2
+ import { issue_cite } from '#scripts/issue/issue-cite'
1
3
  import { josh_command, type JoshResult } from '#scripts/josh/josh-run'
2
4
  import { lane_capacity } from '#scripts/lane/lane-capacity'
3
5
  import { lane_registry } from '#scripts/lane/lane-registry'
@@ -92,35 +94,46 @@ async function read_ready(ports: ReadyPorts = DEFAULT_PORTS): Promise<ReadyReadi
92
94
  return { issues: await ports.ready_issues(), free_lanes }
93
95
  }
94
96
 
95
- // `undefined` unless there is both runnable work and a lane for it — the only state worth a line.
96
- function ready_line(reading: ReadyReading): string | undefined {
97
+ // `undefined` unless there is both runnable work and a lane for it — the only state worth a line. Each
98
+ // issue is a number-link against `slug` (joshuafolkken/kit#3099): the parent copies this line into its
99
+ // report, so a bare `#N` here is the citation the Stop guard sends back.
100
+ function ready_line(reading: ReadyReading, slug: string | undefined): string | undefined {
97
101
  if (reading.issues.length === NONE || reading.free_lanes <= NO_FREE) return undefined
98
102
 
99
- const issues = reading.issues.map((issue) => `#${issue}`).join(' ')
103
+ const cite = issue_cite.citer(slug, new Map())
104
+ const issues = reading.issues.map((issue) => cite(issue)).join(' ')
100
105
 
101
106
  return `ready ${issues} · free lanes ${String(reading.free_lanes)}`
102
107
  }
103
108
 
109
+ type RepoRead = () => Promise<string | undefined>
110
+
104
111
  // The watcher's line on exit, for the driving parent alone — a `fullrun` watcher has no pool to pick
105
112
  // from. Best-effort: a reading that fails prints nothing rather than failing the watcher whose exit is
106
113
  // the wake.
107
114
  async function parent_ready_line(
108
115
  ports: ReadyPorts,
109
116
  is_parent: () => Promise<boolean>,
117
+ read_repo: RepoRead,
110
118
  ): Promise<string | undefined> {
111
119
  if (!(await is_parent())) return undefined
112
120
 
113
- return ready_line(await read_ready(ports))
121
+ const reading = await read_ready(ports)
122
+
123
+ return reading.issues.length === NONE ? undefined : ready_line(reading, await read_repo())
124
+ }
125
+
126
+ function print_line(line: string | undefined): void {
127
+ if (line !== undefined) console.info(line)
114
128
  }
115
129
 
116
130
  async function print_ready_line(
117
131
  ports: ReadyPorts = DEFAULT_PORTS,
118
132
  is_parent: () => Promise<boolean> = run_headless.is_backlog_parent,
133
+ read_repo: RepoRead = git_gh_command.repo_get_name_with_owner,
119
134
  ): Promise<void> {
120
135
  try {
121
- const line = await parent_ready_line(ports, is_parent)
122
-
123
- if (line !== undefined) console.info(line)
136
+ print_line(await parent_ready_line(ports, is_parent, read_repo))
124
137
  } catch {
125
138
  // A pick-up line is a hint; the wake it rides must still be delivered.
126
139
  }
@@ -1,14 +1,44 @@
1
1
  import { json_object_schema } from '#scripts/lib/schemas'
2
- import strip_json_comments from 'strip-json-comments'
2
+ import { parseTree, printParseErrorCode, type Node, type ParseError } from 'jsonc-parser'
3
3
 
4
4
  // Every managed JSON config kit reads (tsconfig.json, package.json, .vscode/*.json) may legally
5
5
  // carry comments and trailing commas — TypeScript and VS Code both parse them as JSONC — so a plain
6
6
  // `JSON.parse` would throw on files consumers hand-authored. Single-sourced here because the
7
7
  // config-merge library and the init/sync merge helpers both need the exact same tolerant read.
8
+
9
+ // Built from the tree rather than with jsonc-parser's own `parse` / `getNodeValue`, which assign each
10
+ // key: a `"__proto__"` key would become the object's prototype instead of the own property
11
+ // `JSON.parse` keeps, and the spread-and-stringify write-back would then drop it from the file.
12
+ // `Object.fromEntries` defines properties, so every key stays data.
13
+ function property_key(property: Node): string {
14
+ return String(property.children?.[0]?.value)
15
+ }
16
+
17
+ function node_value(node: Node | undefined): unknown {
18
+ if (node === undefined) return undefined
19
+ const children = node.children ?? []
20
+ if (node.type === 'array') return children.map((child) => node_value(child))
21
+ if (node.type !== 'object') return node.value
22
+
23
+ return Object.fromEntries(
24
+ children.map((property) => [property_key(property), node_value(property.children?.[1])]),
25
+ )
26
+ }
27
+
28
+ // jsonc-parser recovers from errors and returns a best-effort tree, so any reported error is
29
+ // rethrown: a malformed document must fail the read the way `JSON.parse` would, not merge partially.
8
30
  function parse_jsonc(content: string): Record<string, unknown> {
9
- const stripped = strip_json_comments(content, { trailingCommas: true })
31
+ const errors: Array<ParseError> = []
32
+ const tree = parseTree(content, errors, { allowTrailingComma: true })
33
+ const [first_error] = errors
34
+
35
+ if (first_error !== undefined) {
36
+ throw new SyntaxError(
37
+ `Invalid JSONC: ${printParseErrorCode(first_error.error)} at offset ${String(first_error.offset)}`,
38
+ )
39
+ }
10
40
 
11
- return json_object_schema.parse(JSON.parse(stripped))
41
+ return json_object_schema.parse(node_value(tree))
12
42
  }
13
43
 
14
44
  export { parse_jsonc }
@@ -1,5 +1,11 @@
1
- import { createScanner, findNodeAtLocation, getNodeValue, parseTree, type Node } from 'jsonc-parser'
2
- import strip_json_comments from 'strip-json-comments'
1
+ import {
2
+ createScanner,
3
+ findNodeAtLocation,
4
+ getNodeValue,
5
+ parseTree,
6
+ stripComments,
7
+ type Node,
8
+ } from 'jsonc-parser'
3
9
  import { json_format } from './json-format'
4
10
 
5
11
  // Set or remove one top-level key while leaving every other byte of the document alone.
@@ -163,13 +169,14 @@ function target_value_end(property: Node | undefined): number | undefined {
163
169
  return value_node === undefined ? undefined : value_node.offset + value_node.length
164
170
  }
165
171
 
166
- // Whether the span holds a comment. `strip_json_comments` blanks comments while preserving length
167
- // and is string-aware, so a `//` inside a string value is not counted — a scanner-based check would
168
- // need `SyntaxKind`, which is an ambient const enum this project cannot import.
172
+ // Whether the span holds a comment. `stripComments` drops every comment token the scanner finds, so
173
+ // it changes the text exactly when a comment is present, and a `//` inside a string value is not
174
+ // counted — comparing token kinds directly would need `SyntaxKind`, an ambient const enum this
175
+ // project cannot import.
169
176
  function contains_comment(content: string, node: Node): boolean {
170
177
  const text = content.slice(node.offset, node.offset + node.length)
171
178
 
172
- return strip_json_comments(text) !== text
179
+ return stripComments(text) !== text
173
180
  }
174
181
 
175
182
  // Re-lay-out a property whose trailing comma has just appeared or disappeared. That one column
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
4
3
  import { agent_role_profile, type AgentProvider } from '#scripts/agent/agent-role-profile'
4
+ import { cli_flags } from '#scripts/lib/cli-flags'
5
5
  import { codex_usage } from './codex-usage'
6
6
  import { CONTEXT_CUT_THRESHOLD } from './context-cut-threshold'
7
7
  import { cost_corpus } from './cost-corpus'
@@ -103,13 +103,9 @@ const PARSE_ARGS_OPTIONS = {
103
103
  } as const
104
104
 
105
105
  function parse_options(argv: ReadonlyArray<string>): Options | undefined {
106
- try {
107
- const { values } = parseArgs({ args: [...argv], options: PARSE_ARGS_OPTIONS, strict: true })
106
+ const values = cli_flags.values_of(argv, PARSE_ARGS_OPTIONS)
108
107
 
109
- return to_options(values)
110
- } catch {
111
- return undefined
112
- }
108
+ return values === undefined ? undefined : to_options(values)
113
109
  }
114
110
 
115
111
  // An empty corpus is reported, never priced at zero. "No transcript was found" and "this run was
@@ -32,6 +32,13 @@ interface DelegatableStep {
32
32
  // wait per turn — and below the threshold that round trip costs more than carrying the text.
33
33
  const INVESTIGATION_FILE_THRESHOLD = 3
34
34
 
35
+ // The same boundary for searches, counted in turns (joshuafolkken/kit#3139). A `grep` carries no file
36
+ // text, so it never reached the file count — yet across 100 lane transcripts on 2026-10-04, 424 turns
37
+ // were searches alone and 160 of them came third or later since the last edit or delegation, each
38
+ // re-reading the whole main-line context. Three keeps the first two searches, which locate an edit
39
+ // target, in the main line and sends the continuing sweep to a unit.
40
+ const INVESTIGATION_SEARCH_TURN_THRESHOLD = 3
41
+
35
42
  // The enumeration. Short on purpose: every entry had to name a verifier, and most candidates could
36
43
  // not (below).
37
44
  const DELEGATABLE_STEPS: ReadonlyArray<DelegatableStep> = [
@@ -88,7 +95,7 @@ const DELEGATABLE_STEPS: ReadonlyArray<DelegatableStep> = [
88
95
  // verifiers differ, so widening `survey` would put both in one field and lose the boundary below.
89
96
  // **No ordinal.** `${N}rd` would print `1rd` for any other threshold, past a test that only looks
90
97
  // for the numeral, so the count is phrased as a plain "N or more".
91
- does: `read the Issue's subject to find out how it currently works — once ${String(INVESTIGATION_FILE_THRESHOLD)} or more files the run will not edit have to be read — and return the conclusion plus the \`file:line\` citations that support it, never the file text, which would put the cost back where it was. A throwaway probe script is written, run and deleted inside the unit, which returns its output alone`,
98
+ does: `read the Issue's subject to find out how it currently works — once ${String(INVESTIGATION_FILE_THRESHOLD)} or more files the run will not edit have to be read, or ${String(INVESTIGATION_SEARCH_TURN_THRESHOLD)} or more search turns run without an edit — and return the conclusion plus the \`file:line\` citations that support it, never the file text, which would put the cost back where it was. A throwaway probe script is written, run and deleted inside the unit, which returns its output alone`,
92
99
  verifier:
93
100
  'the parent opens the cited lines; a conclusion those lines do not support fails there, and reading a handful of cited regions costs far less than redoing the reading. The unit reports what the code does and never what to change — deciding that is `diagnosis`, which stays kept, so a unit returning a root cause would be delegating the rejected row under this name',
94
101
  },
@@ -176,6 +183,7 @@ function reason_for(name: string): string {
176
183
  const delegation_policy = {
177
184
  DELEGATE_VERDICT,
178
185
  INVESTIGATION_FILE_THRESHOLD,
186
+ INVESTIGATION_SEARCH_TURN_THRESHOLD,
179
187
  KEEP_VERDICT,
180
188
  DELEGATABLE_STEPS,
181
189
  REJECTED_STEPS,
@@ -72,7 +72,7 @@ function should_notify(
72
72
  // (joshuafolkken/kit#2138, joshuafolkken/kit#2382). A dispatched lane child ends its turn on a denial, so
73
73
  // the same guidance is carried without a `permissionDecision`: the read proceeds and the child is nudged
74
74
  // to delegate rather than killed. It carries no ⛔, so a person watching reads it as advice.
75
- const NOTICE = `💡 investigation: files read and not edited since the last delegated unit reached the threshold, so the reading from here should go to a unit of its own. This is a notice, not a refusal — the read proceeds, because a dispatched lane child ends its turn on a denial. Ask \`pnpm josh delegate investigation\`, then brief a unit with what the main line has already concluded and what is left to find out; it returns the conclusion plus its \`file:line\` citations, never the file text. Keep a read in the main line only where this run will edit that file. This recurs on the next accumulation.`
75
+ const NOTICE = `💡 investigation: files read and not edited, or search turns, since the last delegated unit reached the threshold, so the reading from here should go to a unit of its own. This is a notice, not a refusal — the read proceeds, because a dispatched lane child ends its turn on a denial. Ask \`pnpm josh delegate investigation\`, then dispatch the \`${investigation_reads.INVESTIGATOR_AGENT}\` agent (\`kit:${investigation_reads.INVESTIGATOR_AGENT}\` in a consumer) with a brief of what the main line has already concluded and what is left to find out; it returns the conclusion plus its \`file:line\` citations, never the file text. Keep a read in the main line only where this run will edit that file. This recurs on the next accumulation.`
76
76
 
77
77
  // The notice as it reaches the model: the base guidance with the concrete unedited reads named (kit#2276,
78
78
  // kit#2382), so a lane child is shown *which* reads to send to a unit rather than only told to delegate —