@joshuafolkken/kit 1.1094.0 → 1.1128.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 (149) hide show
  1. package/.claude/skills/workflow-commands/SKILL.md +1 -1
  2. package/.claude/skills/workflow-commands/background-commands.md +36 -85
  3. package/.claude/skills/workflow-commands/backlogrun-progress.md +29 -32
  4. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -2
  5. package/.claude/skills/workflow-commands/fullrun.md +5 -4
  6. package/.claude/skills/workflow-commands/halfrun.md +5 -4
  7. package/.claude/skills/workflow-commands/latest-gate.md +7 -15
  8. package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -8
  9. package/.claude/skills/workflow-commands/split-assessment.md +4 -18
  10. package/.github/actions/setup-node/action.yml +33 -0
  11. package/.github/actions/setup-pnpm/action.yml +4 -4
  12. package/.github/workflows/auto-tag.yml +2 -3
  13. package/.github/workflows/ci.yml +7 -8
  14. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  15. package/.github/workflows/pr-classification.yml +1 -3
  16. package/.github/workflows/production.yml +1 -0
  17. package/.github/workflows/publish.yml +10 -9
  18. package/.github/workflows/sonar-qube.yml +17 -13
  19. package/.pnpmfile.mjs +29 -0
  20. package/AGENTS.md +4 -11
  21. package/CLAUDE.md +24 -17
  22. package/GEMINI.md +4 -13
  23. package/README.md +8 -3
  24. package/SECURITY.md +1 -0
  25. package/cspell/index.yaml +4 -0
  26. package/dist/CLAUDE.basic.md +6 -6
  27. package/dist/CLAUDE.md +24 -17
  28. package/dist/CLAUDE.static.md +6 -6
  29. package/dist/config-merge/index.js +33 -9
  30. package/dist/hooks/batch-guard.js +4 -4
  31. package/dist/hooks/{chunk-NKOG3XAV.js → chunk-4JZDP5X5.js} +2 -2
  32. package/dist/hooks/{chunk-3C4YCCEF.js → chunk-5SD2YM7Z.js} +4 -4
  33. package/dist/hooks/{chunk-OIZUHPJI.js → chunk-77J7IEQM.js} +1 -1
  34. package/dist/hooks/{chunk-KHWHKJOM.js → chunk-7HVB7SZG.js} +3 -3
  35. package/dist/hooks/{chunk-UD24EAIL.js → chunk-AIOIOMPX.js} +1875 -104
  36. package/dist/hooks/{chunk-M2XDCUKD.js → chunk-M7QKEKWO.js} +88 -1783
  37. package/dist/hooks/{chunk-E54KGFEQ.js → chunk-MKVGBQPA.js} +1 -1
  38. package/dist/hooks/{chunk-7ZPFAVR7.js → chunk-NXYMNPUQ.js} +3 -3
  39. package/dist/hooks/{chunk-O4LL4HTI.js → chunk-QVLT52OV.js} +2 -2
  40. package/dist/hooks/{chunk-IL27YTPH.js → chunk-XYUIQOW3.js} +2 -2
  41. package/dist/hooks/codex-hook-adapter.js +12 -47
  42. package/dist/hooks/duplicate-read-guard.js +5 -5
  43. package/dist/hooks/format-edited.js +2 -2
  44. package/dist/hooks/inputs.json +180 -179
  45. package/dist/hooks/investigation-guard.js +5 -5
  46. package/dist/hooks/pretool-guard.js +9 -9
  47. package/dist/hooks/stop-guard.js +17 -12
  48. package/dist/josh.js +79 -30
  49. package/dist/version/index.js +36 -20
  50. package/eslint/rules/unicorn.js +3 -2
  51. package/package.json +18 -2
  52. package/pnpm-workspace.yaml +5 -7
  53. package/prompts/collaboration-workflow/glossary.md +17 -0
  54. package/prompts/collaboration-workflow/issue-template.md +1 -1
  55. package/prompts/collaboration-workflow/operating-rules.md +1 -1
  56. package/prompts/collaboration-workflow/principles.md +9 -0
  57. package/prompts/collaboration-workflow.md +1 -0
  58. package/scripts/backlog/backlog-arrival.ts +30 -10
  59. package/scripts/backlog/backlog-budget-cli.ts +15 -16
  60. package/scripts/backlog/backlog-offer-cli.ts +6 -14
  61. package/scripts/backlog/backlog-plan.ts +29 -18
  62. package/scripts/backlog/backlog-ready.ts +20 -7
  63. package/scripts/config-merge/parse-jsonc.ts +33 -3
  64. package/scripts/config-merge/patch-json-key.ts +13 -6
  65. package/scripts/cost-runtime/cost-cli.ts +3 -7
  66. package/scripts/doctor/doctor-consumer.ts +3 -5
  67. package/scripts/doctor/doctor-io.ts +9 -13
  68. package/scripts/document/backlogrun-parent-read-set.ts +12 -1
  69. package/scripts/document/document-byte-budget.ts +1 -0
  70. package/scripts/document/entry-read-budget.ts +13 -7
  71. package/scripts/document/entry-read-set.ts +32 -8
  72. package/scripts/document/lane-child-read-set.ts +7 -0
  73. package/scripts/document/read-set-point-of-use.ts +8 -3
  74. package/scripts/document/read-set-trim.ts +3 -1
  75. package/scripts/epic/epic-audit.ts +3 -18
  76. package/scripts/gh/git-gh-issue-list.ts +12 -11
  77. package/scripts/gh/git-gh-issue-write.ts +4 -1
  78. package/scripts/gh/git-gh-pr-auto-merge.ts +4 -1
  79. package/scripts/git/git-command.ts +6 -1
  80. package/scripts/git/git-spawn-sync.ts +65 -0
  81. package/scripts/git/git-spawn.ts +5 -4
  82. package/scripts/git/stash/stash-pop-args.ts +1 -6
  83. package/scripts/init/init-file-action.ts +54 -0
  84. package/scripts/init/init-logic-migrate.ts +35 -17
  85. package/scripts/init/init-logic-secretlint.ts +1 -1
  86. package/scripts/init/init-logic.ts +6 -22
  87. package/scripts/init/init.ts +11 -68
  88. package/scripts/init/kit-written-paths.ts +2 -0
  89. package/scripts/init/project-profile.ts +3 -6
  90. package/scripts/init/start-steps.ts +2 -1
  91. package/scripts/issue/defect-rate-cli.ts +61 -20
  92. package/scripts/issue/issue-backlinks.ts +7 -14
  93. package/scripts/issue/issue-cite.ts +12 -6
  94. package/scripts/issue/issue-file.ts +5 -12
  95. package/scripts/issue/markdown-section.ts +21 -6
  96. package/scripts/issue/measure-rerun-cli.ts +73 -14
  97. package/scripts/josh/josh-commands-project.ts +1 -1
  98. package/scripts/josh/josh-commands-workflow.ts +1 -1
  99. package/scripts/josh/run-github-prerequisite.ts +3 -6
  100. package/scripts/lane/lane-await.ts +43 -13
  101. package/scripts/lane/lane-close.ts +4 -1
  102. package/scripts/lane/lane-launch-cli.ts +10 -16
  103. package/scripts/lane/openai-review-broker.ts +4 -1
  104. package/scripts/lib/cli-flags.ts +31 -2
  105. package/scripts/lib/error-message.ts +12 -1
  106. package/scripts/lines/line-targets.ts +0 -11
  107. package/scripts/lines/lines-command.ts +5 -3
  108. package/scripts/propagate/propagate-git.ts +3 -8
  109. package/scripts/refactor/refactor-scan.ts +2 -1
  110. package/scripts/registry-migration/migrate-logic.ts +28 -19
  111. package/scripts/registry-migration/migrate.ts +6 -5
  112. package/scripts/release/github-release-cli.ts +14 -3
  113. package/scripts/release/release-publish.ts +36 -2
  114. package/scripts/release/release-worktree.ts +97 -2
  115. package/scripts/review/review-checkout.ts +3 -2
  116. package/scripts/review/review-record-cli.ts +9 -16
  117. package/scripts/rules/early-heartbeat.ts +4 -3
  118. package/scripts/rules/lane-park.ts +9 -0
  119. package/scripts/rules/stop-rules.ts +7 -4
  120. package/scripts/run/run-carry-args.ts +8 -12
  121. package/scripts/run/run-cut-args.ts +1 -6
  122. package/scripts/run/run-ending-cli.ts +1 -5
  123. package/scripts/run/run-entry-cli.ts +40 -9
  124. package/scripts/run/run-entry-stop.ts +104 -0
  125. package/scripts/run/run-event-stream-emit.ts +7 -2
  126. package/scripts/run/run-hold.ts +4 -1
  127. package/scripts/run/run-liveness-cli.ts +1 -5
  128. package/scripts/run/run-merge-cli.ts +2 -10
  129. package/scripts/run/run-parent-cut-hook.ts +4 -1
  130. package/scripts/run/run-prep-cli.ts +4 -1
  131. package/scripts/run/run-progress-args.ts +20 -4
  132. package/scripts/run/run-progress-cli.ts +31 -29
  133. package/scripts/run/run-progress-clock.ts +4 -1
  134. package/scripts/run/run-ship-cli.ts +8 -16
  135. package/scripts/run/run-ship-probe.ts +4 -1
  136. package/scripts/run/run-status-cli.ts +2 -15
  137. package/scripts/run/run-tidy-lanes.ts +4 -1
  138. package/scripts/run/run-wake-cli.ts +2 -10
  139. package/scripts/run/run-watcher-guard.ts +9 -5
  140. package/scripts/safe-chain/pack-hook.ts +92 -0
  141. package/scripts/sync/sync-ai-files.ts +4 -0
  142. package/scripts/test/test-declared-changed.ts +13 -16
  143. package/scripts/test/test-declared.ts +3 -7
  144. package/scripts/version/version-commands.ts +11 -1
  145. package/templates/CLAUDE.basic.md +6 -6
  146. package/templates/workflows/ci.yml +16 -82
  147. package/templates/workflows/dependabot-auto-merge.yml +1 -0
  148. package/templates/workflows/github-release.yml +1 -3
  149. package/tsconfig.sonar.json +4 -22
@@ -18,10 +18,11 @@ import { backlog_stalled } from './backlog-stalled'
18
18
  //
19
19
  // **"New" is measured against the watcher's own start**, so one issue wakes the parent once: the next
20
20
  // `--wait` the parent starts takes the pool as it stands then — the woken issue included, dispatched or
21
- // not — as its baseline. A reading that fails is not an arrival; the probe says nothing and the
22
- // interval still wakes the parent. A baseline that cannot be read leaves that one wait inert rather than
23
- // reading it as empty — an empty one would wake the parent for the whole pool — and the next `--wait`
24
- // reads it again.
21
+ // not — as its baseline. A reading that fails is not an arrival; the probe says nothing and reads again
22
+ // a minute later. A baseline that cannot be read is never read as empty — an empty one would wake the
23
+ // parent for the whole pool — but it is not given up on either: the scheduled report no longer ends the
24
+ // wait (joshuafolkken/kit#3102), so an inert probe would leave the parent without a wake until `--hours`. The
25
+ // probe reads the baseline again each minute until one answers.
25
26
  //
26
27
  // **Every read is bounded.** The probe is awaited inside the watcher's tick loop, so a `gh` call that
27
28
  // hangs would otherwise freeze the loop — its `--hours` bound, its liveness ping and the exit that is
@@ -116,19 +117,40 @@ async function never_arrives(): Promise<boolean> {
116
117
 
117
118
  const INERT: ArrivalProbe = { has_arrived: never_arrives }
118
119
 
119
- // Only the next-probe instant changes between probes, and it is set before the read is awaited.
120
+ // A probe spent on the baseline reports no arrival, whether or not the baseline answered.
121
+ async function retry_baseline(
122
+ answered: Array<ReadonlySet<string>>,
123
+ ports: ReadyPorts,
124
+ ): Promise<boolean> {
125
+ const late = await read_baseline(ports)
126
+
127
+ if (late !== undefined) answered.push(late)
128
+
129
+ return false
130
+ }
131
+
132
+ // The next-probe instant is set before any read is awaited. A baseline that did not answer is read again
133
+ // at the next probe instead of the reading, and the first one that answers is the baseline from then on
134
+ // — never an arrival itself, since it is the pool as it stands.
120
135
  function probe_of(
121
- baseline: ReadonlySet<string>,
136
+ initial: ReadonlySet<string> | undefined,
122
137
  first_ms: number,
123
138
  ports: ReadyPorts,
124
139
  ): ArrivalProbe {
125
140
  let next_ms = first_ms
141
+ // At most one entry, held in a list so the baseline that answers after an await is added rather than
142
+ // reassigned over the value read before it.
143
+ const answered: Array<ReadonlySet<string>> = initial === undefined ? [] : [initial]
126
144
 
127
145
  async function has_arrived(now_ms: number): Promise<boolean> {
128
146
  if (now_ms < next_ms) return false
129
147
 
130
148
  next_ms = now_ms + PROBE_INTERVAL_MS
131
149
 
150
+ const [baseline] = answered
151
+
152
+ if (baseline === undefined) return await retry_baseline(answered, ports)
153
+
132
154
  const reading = await read_reading(ports)
133
155
 
134
156
  return reading !== undefined && arrivals(baseline, reading).length > NONE
@@ -146,12 +168,10 @@ async function start(
146
168
  ): Promise<ArrivalProbe> {
147
169
  if (!(await is_parent_safely(is_parent))) return INERT
148
170
 
149
- const baseline = await read_baseline(ports)
150
-
151
- return baseline === undefined ? INERT : probe_of(baseline, now_ms + PROBE_INTERVAL_MS, ports)
171
+ return probe_of(await read_baseline(ports), now_ms + PROBE_INTERVAL_MS, ports)
152
172
  }
153
173
 
154
- const backlog_arrival = { INERT, PROBE_INTERVAL_MS, answered_issues, arrivals, start }
174
+ const backlog_arrival = { PROBE_INTERVAL_MS, answered_issues, arrivals, start }
155
175
 
156
176
  export type { ArrivalProbe }
157
177
  export { backlog_arrival }
@@ -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
@@ -1,10 +1,9 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
4
  import { json_value } from '#scripts/lib/json-value'
4
5
  import { file_reader } from '#scripts/lib/read-file'
5
- import { read_spawn_stdout } from '#scripts/lib/spawn-exit'
6
6
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
7
- import { execaSync } from 'execa'
8
7
  import { z } from 'zod'
9
8
 
10
9
  // The consumer-setup checks `josh doctor` runs in a repository that installs kit (joshuafolkken/kit#1930).
@@ -77,11 +76,9 @@ function is_plugin_declared(root: string): boolean {
77
76
  // The value of `core.hooksPath`, or undefined when it is unset. A set value is what stops lefthook
78
77
  // from installing kit's git hooks — the failure is silent but for one stderr line.
79
78
  function configured_hooks_path(root: string): string | undefined {
80
- const result = execaSync('git', ['-C', root, 'config', '--get', 'core.hooksPath'], {
81
- reject: false,
79
+ const value = git_spawn_sync.read(['-C', root, 'config', '--get', 'core.hooksPath'], {
82
80
  timeout: GIT_TIMEOUT_MS,
83
81
  })
84
- const value = read_spawn_stdout(result).trim()
85
82
 
86
83
  return value === '' ? undefined : value
87
84
  }
@@ -159,6 +156,7 @@ function report_consumer_setup(root: string): void {
159
156
  const doctor_consumer = {
160
157
  claude_md_line,
161
158
  claude_md_state,
159
+ configured_hooks_path,
162
160
  hooks_path_line,
163
161
  is_kit_consumer,
164
162
  is_plugin_declared,
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { read_spawn_stderr, read_spawn_stdout } from '#scripts/lib/spawn-exit'
3
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
4
+ import { read_spawn_stdout } from '#scripts/lib/spawn-exit'
4
5
  import { execaSync } from 'execa'
5
6
  import { doctor_logic } from './doctor-logic'
6
7
 
@@ -69,18 +70,13 @@ function classify_git_failure(exit_code: number | undefined, stderr: string): Gi
69
70
  // caller's check, because reporting an unknown as a clean result is the false all-clear
70
71
  // joshuafolkken/kit#805 exists to remove. See the two classifiers above for what proves what.
71
72
  function resolve_git_top_level(): GitTopLevel {
72
- const result = execaSync('git', ['rev-parse', '--show-toplevel'], {
73
- reject: false,
74
- timeout: GIT_TIMEOUT_MS,
75
- // git translates its messages, so the match below would fail under any non-English locale and
76
- // the spurious warning would come back. `C` pins the output this code was written against.
77
- env: { LC_ALL: 'C', LANGUAGE: 'C' },
78
- extendEnv: true,
79
- })
80
-
81
- if (result.exitCode === 0) return classify_top_level(read_spawn_stdout(result).trim())
82
-
83
- return classify_git_failure(result.exitCode, read_spawn_stderr(result))
73
+ // The C locale `toplevel` asks under is what keeps the stderr match below from failing on a
74
+ // translated message and bringing the spurious warning back.
75
+ const result = git_spawn_sync.toplevel(GIT_TIMEOUT_MS)
76
+
77
+ if (result.exit_code === 0) return classify_top_level(result.stdout.trim())
78
+
79
+ return classify_git_failure(result.exit_code, result.stderr)
84
80
  }
85
81
 
86
82
  // Whether one line is the setting itself rather than prose about it. kit's own template explains the
@@ -34,15 +34,26 @@ const UNUSED_SKILL_SECTIONS: ReadonlyArray<string> = [
34
34
  // the park — so it drops none. Named empty so the shared trim reads the same shape as the worker's.
35
35
  const SKIPPED_POINT_OF_USE: ReadonlySet<string> = new Set<string>()
36
36
 
37
+ // **The parent runs the end-of-run retrospective** (`SKILL.md` §2j) when `run:step` prints it, but its
38
+ // manifest names `retrospective.md` only through `backlogrun-steps.md` — one hop further than the
39
+ // derivation follows — so the parent's reach names it (joshuafolkken/kit#3078).
40
+ const REACHED_POINT_OF_USE: ReadonlySet<string> = new Set(['retrospective.md'])
41
+
37
42
  function costed(root: string): ReadSetCost {
38
43
  return read_set_trim.costed(root, {
39
44
  base_entry: BACKLOGRUN,
40
45
  label: BACKLOGRUN,
41
46
  unused_skill_sections: UNUSED_SKILL_SECTIONS,
42
47
  skipped_point_of_use: SKIPPED_POINT_OF_USE,
48
+ reached_point_of_use: REACHED_POINT_OF_USE,
43
49
  })
44
50
  }
45
51
 
46
- const backlogrun_parent_read_set = { BACKLOGRUN, UNUSED_SKILL_SECTIONS, costed }
52
+ const backlogrun_parent_read_set = {
53
+ BACKLOGRUN,
54
+ REACHED_POINT_OF_USE,
55
+ UNUSED_SKILL_SECTIONS,
56
+ costed,
57
+ }
47
58
 
48
59
  export { backlogrun_parent_read_set }
@@ -88,6 +88,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
88
88
  { path: 'prompts/collaboration-workflow.md', bytes: 8192 },
89
89
  { path: 'prompts/collaboration-workflow/file-edits.md', bytes: 12_288 },
90
90
  { path: 'prompts/collaboration-workflow/gh-rest.md', bytes: 8192 },
91
+ { path: 'prompts/collaboration-workflow/glossary.md', bytes: 4096 },
91
92
  { path: 'prompts/collaboration-workflow/issue-citation.md', bytes: 8192 },
92
93
  // #2662 adds the feature and compatibility declarations to the canonical filing procedure.
93
94
  { path: 'prompts/collaboration-workflow/issue-template.md', bytes: 28_672 },
@@ -3,8 +3,9 @@
3
3
  //
4
4
  // **The per-document ceiling was a proxy for a run's cost, not the cost.** A run does not pay "one
5
5
  // document's size"; it pays the total its entry reads — the mandated skill files, the sections they
6
- // point out to, the point-of-use documents reached later, and the resident `CLAUDE.md` every request
7
- // carries. `read:set` already computes that total, so this budgets it directly: the entry total is
6
+ // point out to, the point-of-use documents that entry reaches later, and the resident `CLAUDE.md` every
7
+ // request carries. `read:set`'s `total read` is that sum less `CLAUDE.md`, which `entry_total_bytes`
8
+ // adds back (`RESIDENT_BASE`), so this budgets the whole directly: the entry total is
8
9
  // the primary ceiling, and the per-document ceiling (`document-byte-budget.ts`) falls back to the
9
10
  // documents no entry reads (`document-reachability.ts` draws the line). A document an entry reads is
10
11
  // held by the entry total and carries no per-document ceiling, so neither budget holds it twice.
@@ -86,11 +87,16 @@ const ENTRY_READ_BUDGET: ReadonlyArray<EntryBudget> = [
86
87
  // `wip-cap.md`, so its ceiling stays.
87
88
  // joshuafolkken/kit#3023 added the `prrun` keyword to `SKILL.md` and `CLAUDE.md`, tipping backlogrun
88
89
  // one block over; `prrun` reads `fullrun`'s set plus its own short manifest.
89
- { entry: 'kickoff', bytes: 229_376 },
90
- { entry: 'fullrun', bytes: 225_280 },
91
- { entry: 'halfrun', bytes: 225_280 },
92
- { entry: 'prrun', bytes: 229_376 },
93
- { entry: 'backlogrun', bytes: 233_472 },
90
+ // Lowered in joshuafolkken/kit#3078 — a correction of an over-count, not a trim: every entry was
91
+ // charged for every global point-of-use document, so `kickoff` paid for `followup.md` and the
92
+ // `backlogrun-*` phase documents it never opens. Each total now counts only what its path reaches.
93
+ // joshuafolkken/kit#3077 trimmed non-resident rule bodies from `CLAUDE.md`, lowering fullrun, prrun
94
+ // and backlogrun one block each.
95
+ { entry: 'kickoff', bytes: 65_536 },
96
+ { entry: 'fullrun', bytes: 126_976 },
97
+ { entry: 'halfrun', bytes: 106_496 },
98
+ { entry: 'prrun', bytes: 131_072 },
99
+ { entry: 'backlogrun', bytes: 212_992 },
94
100
  { entry: LANE_CHILD, bytes: 77_824 },
95
101
  ]
96
102