@joshuafolkken/kit 1.957.0 → 1.978.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 (102) hide show
  1. package/.claude/skills/workflow-commands/background-commands.md +3 -0
  2. package/.claude/skills/workflow-commands/backlogrun-child.md +75 -120
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +78 -115
  4. package/.claude/skills/workflow-commands/backlogrun-progress.md +82 -146
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +173 -229
  6. package/.claude/skills/workflow-commands/backlogrun.md +3 -1
  7. package/.claude/skills/workflow-commands/followup.md +3 -0
  8. package/.claude/skills/workflow-commands/fullrun.md +4 -4
  9. package/.claude/skills/workflow-commands/halfrun.md +12 -7
  10. package/.claude/skills/workflow-commands/observation-filing.md +31 -25
  11. package/.claude/skills/workflow-commands/pre-gate-cut.md +96 -161
  12. package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
  13. package/.github/workflows/pr-classification.yml +12 -1
  14. package/CLAUDE.md +3 -3
  15. package/README.md +25 -21
  16. package/dist/CLAUDE.md +3 -3
  17. package/dist/hooks/batch-guard.js +4 -4
  18. package/dist/hooks/{chunk-XSL4SRMA.js → chunk-2K52BRWV.js} +2 -2
  19. package/dist/hooks/{chunk-M6K7EQDO.js → chunk-BNGGEY3Z.js} +38 -32
  20. package/dist/hooks/{chunk-KOCK5FXQ.js → chunk-BVMSPG3M.js} +3 -3
  21. package/dist/hooks/{chunk-3ON7HOYB.js → chunk-CRGJTD6E.js} +1 -1
  22. package/dist/hooks/{chunk-HJXQZ7V4.js → chunk-F6OM6SIG.js} +3 -3
  23. package/dist/hooks/{chunk-LDQDZYHX.js → chunk-KQG4776P.js} +1 -1
  24. package/dist/hooks/{chunk-244WYOEO.js → chunk-NXL3SBXY.js} +2 -2
  25. package/dist/hooks/{chunk-IJPBTDTB.js → chunk-Q7LE77CO.js} +88 -18
  26. package/dist/hooks/{chunk-BELI5XSS.js → chunk-ZPLXLJ7O.js} +15 -5
  27. package/dist/hooks/{chunk-GNOITLRR.js → chunk-ZYYSBNR6.js} +4 -4
  28. package/dist/hooks/codex-hook-adapter.js +10 -10
  29. package/dist/hooks/duplicate-read-guard.js +5 -5
  30. package/dist/hooks/format-edited.js +2 -2
  31. package/dist/hooks/investigation-guard.js +5 -5
  32. package/dist/hooks/pretool-guard.js +9 -9
  33. package/dist/hooks/stop-guard.js +206 -176
  34. package/dist/josh.js +8 -4
  35. package/package.json +4 -4
  36. package/prompts/collaboration-workflow/wip-cap.md +1 -1
  37. package/scripts/backlog/backlog-arrival.ts +11 -1
  38. package/scripts/backlog/backlog-budget.ts +25 -8
  39. package/scripts/backlog/backlog-next.ts +45 -2
  40. package/scripts/backlog/backlog-offer.ts +1 -0
  41. package/scripts/backlog/backlog-plan-cli.ts +66 -27
  42. package/scripts/backlog/backlog-plan.ts +30 -2
  43. package/scripts/backlog/backlog-stalled.ts +9 -0
  44. package/scripts/backlog/backlog-waves.ts +169 -0
  45. package/scripts/ci/pr-classification.ts +13 -12
  46. package/scripts/document/document-byte-budget.ts +1 -1
  47. package/scripts/document/entry-read-budget.ts +7 -5
  48. package/scripts/epic/epic-lane-offer.ts +69 -4
  49. package/scripts/epic/epic-next.ts +2 -2
  50. package/scripts/epic/epic-solo.ts +147 -0
  51. package/scripts/epic/epic-triage.ts +47 -0
  52. package/scripts/gate/hook-gate-reuse.ts +7 -8
  53. package/scripts/git/git-closes-keyword.ts +15 -0
  54. package/scripts/git/git-followup-flush.ts +5 -6
  55. package/scripts/git/git-followup-issue-close.ts +75 -0
  56. package/scripts/git/git-pr-followup-wrapup.ts +36 -6
  57. package/scripts/git/git-pr-followup.ts +9 -16
  58. package/scripts/git/git-staging.ts +33 -20
  59. package/scripts/git/issue-labels.ts +75 -5
  60. package/scripts/hooks/stop-guard.ts +1 -0
  61. package/scripts/init/init-bootstrap.ts +92 -0
  62. package/scripts/init/init-install.ts +67 -0
  63. package/scripts/init/init-logic-workspace.ts +1 -1
  64. package/scripts/init/init.ts +58 -13
  65. package/scripts/init/start-plan.ts +1 -1
  66. package/scripts/init/start-steps.ts +5 -28
  67. package/scripts/issue/issue-closing-pr.ts +93 -0
  68. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +2 -1
  69. package/scripts/josh/josh-commands-project.ts +5 -1
  70. package/scripts/josh/josh-commands-workflow.ts +3 -3
  71. package/scripts/observations/observation-ledger-prepare.ts +31 -0
  72. package/scripts/observations/observation-ledger.ts +5 -5
  73. package/scripts/observations/observations-flush.ts +11 -30
  74. package/scripts/repo/repository-labels.ts +91 -0
  75. package/scripts/rules/implementation-cut.ts +49 -16
  76. package/scripts/rules/lane-background.ts +25 -5
  77. package/scripts/rules/stop-rules.ts +16 -4
  78. package/scripts/run/run-carry-cli.ts +21 -0
  79. package/scripts/run/run-cut-cli.ts +92 -110
  80. package/scripts/run/run-cut-report.ts +167 -0
  81. package/scripts/run/run-cut.ts +23 -7
  82. package/scripts/run/run-entry-cli.ts +70 -3
  83. package/scripts/run/run-halfrun-resume.ts +104 -0
  84. package/scripts/run/run-headless.ts +1 -0
  85. package/scripts/run/run-hold-cli.ts +57 -13
  86. package/scripts/run/run-hold.ts +26 -4
  87. package/scripts/run/run-merge-cli.ts +28 -14
  88. package/scripts/run/run-merge-steps.ts +38 -2
  89. package/scripts/run/run-merge.ts +41 -3
  90. package/scripts/run/run-prep-cli.ts +8 -2
  91. package/scripts/run/run-prep-locate.ts +165 -0
  92. package/scripts/run/run-prep.ts +12 -1
  93. package/scripts/run/run-step-cli.ts +16 -0
  94. package/scripts/run/run-step.ts +28 -4
  95. package/scripts/run/run-tail-cli.ts +4 -2
  96. package/scripts/run/run-wake-work.ts +4 -0
  97. package/scripts/safe-chain/ci-installer-pin.ts +8 -4
  98. package/scripts/security/security-audit.ts +12 -1
  99. package/scripts/sync/sync.ts +6 -0
  100. package/scripts/time-runtime/time-background.ts +11 -0
  101. package/scripts/time-runtime/time-transcript-line.ts +4 -0
  102. package/templates/pnpm-workspace.static.yaml +2 -0
@@ -0,0 +1,165 @@
1
+ import { get_exit_code } from '#scripts/git/git-execa-error'
2
+ import { git_spawn } from '#scripts/git/git-spawn'
3
+
4
+ // The code locations an issue names, bundled into `run:prep` (joshuafolkken/kit#2761). Across the 90
5
+ // runs `.time-history.jsonl` split by contributor, 53% of round trips were investigation, and most of
6
+ // it opened the files an issue names one read at a time. This finds each path, identifier and command
7
+ // the body puts in backticks and hands back where it occurs in code — `file:line` and a short excerpt,
8
+ // never a whole file — so the first read lands on the right place.
9
+ //
10
+ // **Only a backticked span is a target.** A prose word is not a name, and a span holding a space is a
11
+ // command line or a sentence rather than one thing to look up; the one exception is a `josh` command,
12
+ // whose subcommand is the name.
13
+
14
+ const BACKTICKED = /`([^`\n]+)`/gu
15
+ const JOSH_PREFIX = /^(?:pnpm\s+)?josh\s+(\S+)/u
16
+ // A `file:line` citation names the file; the suffix would make both the file lookup and the grep miss.
17
+ const LINE_SUFFIX = /(?::\d+)+$/u
18
+ // A name carries a separator a prose word does not — `run:prep`, `run_prep`, `run-prep.ts`, `a/b`.
19
+ const NAME_CHARACTERS = /^[\w@./:-]+$/u
20
+ const NAME_SEPARATOR = /[./:_-]/u
21
+ const GREP_LINE = /^(?<path>[^:]+):(?<line>\d+):(?<text>.*)$/u
22
+ const MIN_TARGET_LENGTH = 3
23
+ const MAX_TARGETS = 10
24
+ const MAX_FILES_PER_TARGET = 3
25
+ const MAX_EXCERPT_LENGTH = 80
26
+ const ELLIPSIS = '…'
27
+ const INDENT = ' '
28
+ const HIT_SEPARATOR = ' '
29
+ const NO_MATCH = ' — no match in code'
30
+ const SEARCH_FAILED = ' — git grep failed; not searched'
31
+ // `git grep` exits 1 for no match; any other exit is a search that never ran.
32
+ const GREP_NO_MATCH_EXIT = 1
33
+ const FILE_PATHSPEC = ':(glob)**/'
34
+ const FILE_HIT = { line: '1', excerpt: '(file named in the issue)' }
35
+ const NO_TARGETS = '(no backticked path or identifier in the issue)'
36
+ // Code only, tests and snapshots excluded: a document mentions a name far more often than code
37
+ // defines it, and the three-file cap would fill with prose before reaching the implementation.
38
+ const CODE_PATHS = [
39
+ '*.ts',
40
+ '*.js',
41
+ '*.svelte',
42
+ ':(exclude)*.test.ts',
43
+ ':(exclude)*.e2e.ts',
44
+ ':(exclude)*.snap',
45
+ ]
46
+
47
+ interface Hit {
48
+ path: string
49
+ line: string
50
+ excerpt: string
51
+ }
52
+
53
+ interface Located {
54
+ target: string
55
+ hits: ReadonlyArray<Hit>
56
+ has_failed: boolean
57
+ }
58
+
59
+ function to_target(span: string): string {
60
+ const josh = JOSH_PREFIX.exec(span)
61
+
62
+ return josh?.[1] ?? span.trim().replace(LINE_SUFFIX, '')
63
+ }
64
+
65
+ function is_name(target: string): boolean {
66
+ if (target.length < MIN_TARGET_LENGTH || target.includes('://')) return false
67
+
68
+ return NAME_CHARACTERS.test(target) && NAME_SEPARATOR.test(target)
69
+ }
70
+
71
+ function extract_targets(text: string): Array<string> {
72
+ const targets = [...text.matchAll(BACKTICKED)].map((match) => to_target(match[1] ?? ''))
73
+
74
+ return [...new Set(targets.filter((target) => is_name(target)))].slice(0, MAX_TARGETS)
75
+ }
76
+
77
+ function shorten(text: string): string {
78
+ const trimmed = text.trim()
79
+
80
+ if (trimmed.length <= MAX_EXCERPT_LENGTH) return trimmed
81
+
82
+ return `${trimmed.slice(0, MAX_EXCERPT_LENGTH)}${ELLIPSIS}`
83
+ }
84
+
85
+ // `git grep -n` prints `path:line:text`; the text may itself hold colons, so only the first two split.
86
+ function to_hit(output_line: string): Hit | undefined {
87
+ const groups = GREP_LINE.exec(output_line)?.groups
88
+
89
+ if (groups === undefined) return undefined
90
+
91
+ // All three groups are non-optional in `GREP_LINE`, so a match always carries each of them.
92
+ const { path, line, text } = groups as Record<'line' | 'path' | 'text', string>
93
+
94
+ return { path, line, excerpt: shorten(text) }
95
+ }
96
+
97
+ function parse_hits(stdout: string): Array<Hit> {
98
+ const hits = stdout.split('\n').map((line) => to_hit(line))
99
+
100
+ return hits.filter((hit): hit is Hit => hit !== undefined).slice(0, MAX_FILES_PER_TARGET)
101
+ }
102
+
103
+ function format_target(located: Located): string {
104
+ if (located.has_failed) return `${located.target}${SEARCH_FAILED}`
105
+ if (located.hits.length === 0) return `${located.target}${NO_MATCH}`
106
+
107
+ const lines = located.hits.map(
108
+ (hit) => `${INDENT}${hit.path}:${hit.line}${HIT_SEPARATOR}${hit.excerpt}`,
109
+ )
110
+
111
+ return [located.target, ...lines].join('\n')
112
+ }
113
+
114
+ function format_locations(located: ReadonlyArray<Located>): string {
115
+ if (located.length === 0) return NO_TARGETS
116
+
117
+ return located.map((entry) => format_target(entry)).join('\n')
118
+ }
119
+
120
+ // `git grep` exits 1 when nothing matches, which `git_spawn.read` raises. Neither that nor a search
121
+ // that could not run fails the bundle — the issue body is still worth printing without it — but only
122
+ // the first is reported as "no match": a git without `grep -m` (older than 2.38) exits 129, and naming
123
+ // every target absent then would send the reader away from code that is there.
124
+ async function grep(target: string): Promise<Located> {
125
+ const grep_arguments = ['grep', '-n', '-I', '-F', '-m', '1', '-e', target, '--', ...CODE_PATHS]
126
+
127
+ try {
128
+ return { target, hits: parse_hits(await git_spawn.read(grep_arguments)), has_failed: false }
129
+ } catch (error) {
130
+ return { target, hits: [], has_failed: get_exit_code(error) !== GREP_NO_MATCH_EXIT }
131
+ }
132
+ }
133
+
134
+ // A path is rarely spelled out inside code — imports drop the extension and resolve relatively — so
135
+ // grepping `scripts/run/run-prep.ts` misses the very file an issue most often names. A tracked file
136
+ // whose path ends in the target is the location itself; `**/` lets a bare file name match too.
137
+ async function find_files(target: string): Promise<Array<Hit>> {
138
+ try {
139
+ const stdout = await git_spawn.read(['ls-files', '--', `${FILE_PATHSPEC}${target}`])
140
+ const paths = stdout.split('\n').filter((path) => path !== '')
141
+
142
+ return paths.slice(0, MAX_FILES_PER_TARGET).map((path) => ({ path, ...FILE_HIT }))
143
+ } catch {
144
+ return []
145
+ }
146
+ }
147
+
148
+ async function find(target: string): Promise<Located> {
149
+ const files = await find_files(target)
150
+
151
+ if (files.length > 0) return { target, hits: files, has_failed: false }
152
+
153
+ return await grep(target)
154
+ }
155
+
156
+ async function locate(text: string): Promise<string> {
157
+ const located = await Promise.all(extract_targets(text).map(async (target) => await find(target)))
158
+
159
+ return format_locations(located)
160
+ }
161
+
162
+ const run_prep_locate = { extract_targets, format_locations, locate, parse_hits }
163
+
164
+ export type { Hit, Located }
165
+ export { run_prep_locate }
@@ -23,6 +23,7 @@ const NO = 'no'
23
23
  const CONTENT_HEADER = '=== issue ==='
24
24
  const STATE_HEADER = '=== state ==='
25
25
  const LATEST_HEADER = '=== dependency update ==='
26
+ const LOCATIONS_HEADER = '=== code locations ==='
26
27
  const SECTION_SEPARATOR = '\n\n'
27
28
  const LATEST_JOINER = ' — '
28
29
 
@@ -35,6 +36,8 @@ interface PrepParts {
35
36
  state_failure: string
36
37
  latest_scope: string
37
38
  latest_reason: string
39
+ // Where the paths and identifiers the issue names occur in code (joshuafolkken/kit#2761).
40
+ locations: string
38
41
  // Whether a lane child's tree holds uncommitted work — read for the verdict, not printed in the
39
42
  // report (joshuafolkken/kit#2476).
40
43
  has_changes: boolean
@@ -75,10 +78,18 @@ function format_report(parts: PrepParts): string {
75
78
  section(CONTENT_HEADER, parts.content_body),
76
79
  section(STATE_HEADER, state_body(parts)),
77
80
  section(LATEST_HEADER, `${parts.latest_scope}${LATEST_JOINER}${parts.latest_reason}`),
81
+ section(LOCATIONS_HEADER, parts.locations),
78
82
  ].join(SECTION_SEPARATOR)
79
83
  }
80
84
 
81
- const run_prep = { CONTENT_HEADER, LATEST_HEADER, STATE_HEADER, SUMMARY_PREFIX, format_report }
85
+ const run_prep = {
86
+ CONTENT_HEADER,
87
+ LATEST_HEADER,
88
+ LOCATIONS_HEADER,
89
+ STATE_HEADER,
90
+ SUMMARY_PREFIX,
91
+ format_report,
92
+ }
82
93
 
83
94
  export type { PrepParts }
84
95
  export { run_prep }
@@ -5,9 +5,11 @@ import { doctor_consumer } from '#scripts/doctor/doctor-consumer'
5
5
  import { hook_decision } from '#scripts/josh/hook-decision'
6
6
  import { find_package_directory } from '#scripts/josh/josh-logic'
7
7
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
8
+ import { lane_reap } from '#scripts/lane/lane-reap'
8
9
  import { run_carry, type CarryRead } from './run-carry'
9
10
  import { run_event_scope } from './run-event-scope'
10
11
  import { run_event_stream } from './run-event-stream'
12
+ import { run_headless } from './run-headless'
11
13
  import { run_prep_cli } from './run-prep-cli'
12
14
  import { run_retrospective } from './run-retrospective'
13
15
  import { run_step, type StepInput } from './run-step'
@@ -28,9 +30,20 @@ interface RunReads {
28
30
  carry_kind: CarryRead['kind']
29
31
  is_retrospective_done: boolean
30
32
  is_at_cut_cap: boolean
33
+ is_handed_off: boolean
31
34
  last_event: string | undefined
32
35
  }
33
36
 
37
+ // **A hand-off stops only the session that declared the record** (joshuafolkken/kit#2760). The record is
38
+ // one per repository, so a `fullrun #M` a person starts in a fresh conversation beside a `backlogrun`
39
+ // waiting for its successor reads the same handed-off mark — and would be told to stop on every step.
40
+ // The owner is `--owner "$PPID"`, an ancestor of this process only in the session that took the cut.
41
+ function is_handed_off_here(carry: CarryRead): boolean {
42
+ if (carry.kind !== 'carried' || carry.carry.is_handed_off !== true) return false
43
+
44
+ return run_headless.is_owned_here(carry.carry, lane_reap.own_ancestry)
45
+ }
46
+
34
47
  // The run-level reads, keyed on the common git directory both the carry and the event stream share. A
35
48
  // directory that cannot be read leaves the position unknowable, which `next_action` answers `unknown`.
36
49
  function read_run(directory: string | undefined, issue_number: string): RunReads {
@@ -39,6 +52,7 @@ function read_run(directory: string | undefined, issue_number: string): RunReads
39
52
  carry_kind: 'unreadable',
40
53
  is_retrospective_done: false,
41
54
  is_at_cut_cap: false,
55
+ is_handed_off: false,
42
56
  last_event: undefined,
43
57
  }
44
58
  }
@@ -60,6 +74,7 @@ function read_run(directory: string | undefined, issue_number: string): RunReads
60
74
  carry_kind: carry.kind,
61
75
  is_retrospective_done: run_carry.retrospective_done_of(carry),
62
76
  is_at_cut_cap: carry.kind === 'carried' && run_carry.is_at_cut_cap(carry.carry),
77
+ is_handed_off: is_handed_off_here(carry),
63
78
  last_event: last?.kind,
64
79
  }
65
80
  }
@@ -78,6 +93,7 @@ async function gather(issue_number: string): Promise<StepInput> {
78
93
  carry_kind: run_reads.carry_kind,
79
94
  is_retrospective_done: run_reads.is_retrospective_done,
80
95
  is_at_cut_cap: run_reads.is_at_cut_cap,
96
+ is_handed_off: run_reads.is_handed_off,
81
97
  is_lane_child: lane_child_marker.is_child_of(process.cwd()),
82
98
  is_consumer: doctor_consumer.is_kit_consumer(find_package_directory(process.cwd())),
83
99
  // Read here rather than in `run-step.ts` so the position logic stays a pure function of its input.
@@ -126,6 +126,11 @@ interface StepInput extends PreInput {
126
126
  // Whether the carried invocation has already taken its maximum cuts, read off the carry record by the
127
127
  // CLI (joshuafolkken/kit#2346). At the cap `run:carry --cut` refuses, so the hand-off is not offered.
128
128
  is_at_cut_cap: boolean
129
+ // Whether the carry record has been handed off by a cut and no successor has adopted it yet, read off
130
+ // the record by the CLI (joshuafolkken/kit#2760) and set only in the session that declared the record.
131
+ // The session that took the cut is the only one that can read this — a successor spends the mark when
132
+ // it begins — so it is what tells that session to stop.
133
+ is_handed_off: boolean
129
134
  }
130
135
 
131
136
  interface StepAction {
@@ -258,12 +263,31 @@ function closed_action(input: StepInput): StepAction {
258
263
  }
259
264
  }
260
265
 
261
- // The terminal answers read before the run's position matters: an unreadable carry record leaves the
262
- // position unknowable, a spent budget is the person's call, and the issue's own state can end the run
263
- // whatever the events say. `undefined` when none applies and the position decides.
264
- function terminal_action(input: StepInput): StepAction | undefined {
266
+ // **A cut hands the run on, so the session that took it stops** (joshuafolkken/kit#2760). The driver had
267
+ // no handed-off state, so after `run:carry --cut` it went on answering `wait` or the next offer — a
268
+ // `backlogrun` parent measured at 233k kept launching children in the same conversation up to 294k. A
269
+ // lane child never holds the carry record, so its own position is not decided here.
270
+ function is_handed_off_parent(input: StepInput): boolean {
271
+ return input.is_handed_off && input.carry_kind === 'carried' && !input.is_lane_child
272
+ }
273
+
274
+ // The carry record's terminal answers: an unreadable record leaves the position unknowable, a handed-off
275
+ // run is its successor's, and a spent budget is the person's call.
276
+ function carry_terminal_action(input: StepInput): StepAction | undefined {
265
277
  if (input.carry_kind === 'unreadable') return verdict(UNKNOWN)
278
+ if (is_handed_off_parent(input)) return verdict(STOP)
266
279
  if (input.carry_kind === 'expired') return { kind: 'decide', line: EXPIRED_DECISION }
280
+
281
+ return undefined
282
+ }
283
+
284
+ // The terminal answers read before the run's position matters: the carry record's first, then the
285
+ // issue's own state, which can end the run whatever the events say. `undefined` when none applies and
286
+ // the position decides.
287
+ function terminal_action(input: StepInput): StepAction | undefined {
288
+ const carry_action = carry_terminal_action(input)
289
+
290
+ if (carry_action !== undefined) return carry_action
267
291
  if (input.state === undefined) return verdict(UNKNOWN)
268
292
  if (input.state === CLOSED) return closed_action(input)
269
293
 
@@ -33,8 +33,10 @@ interface Step {
33
33
  argv: (issues: ReadonlyArray<string>) => ReadonlyArray<string>
34
34
  }
35
35
 
36
- // The steps in the order a run closes on: the ledger is committed first so the recurrence count is on
37
- // main, the citations are read for the completion report, and the release scope is decided last.
36
+ // The steps in the order a run closes on: any ledger line the run's own commit did not carry
37
+ // (joshuafolkken/kit#2763) is committed first so the recurrence count is on main — on most runs the
38
+ // flush answers `clean` — the citations are read for the completion report, and the release scope is
39
+ // decided last.
38
40
  const FLUSH_STEP: Step = {
39
41
  header: run_tail.OBSERVATIONS_HEADER,
40
42
  argv: () => ['observations:flush'],
@@ -29,6 +29,7 @@ import { run_invocation } from './run-invocation'
29
29
  const NO_FREE = 0
30
30
  const NO_READY = 0
31
31
  const SUCCESS_EXIT_CODE = 0
32
+ const TRIAGE_WORK = 1
32
33
 
33
34
  interface WorkPorts {
34
35
  // The free-lane count, or `undefined` where the lane limit could not be read.
@@ -41,6 +42,9 @@ async function real_ready_count(): Promise<number | undefined> {
41
42
  const result = await josh_command.josh_run(['backlog:next'], true)
42
43
 
43
44
  if (result.code !== SUCCESS_EXIT_CODE) return undefined
45
+ // A `triage` answer offers no number, but the untriaged issues are a session's to judge
46
+ // (joshuafolkken/kit#2779) — counted as one piece of work, or the whole pool would idle with nobody judging it.
47
+ if (backlog_stalled.needs_triage(result.out)) return TRIAGE_WORK
44
48
 
45
49
  return backlog_stalled.count_ready_tokens(result.out)
46
50
  }
@@ -2,11 +2,15 @@ import { createHash } from 'node:crypto'
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
3
3
  import { execaSync } from 'execa'
4
4
 
5
- // The workflows that install safe-chain on the runner. Both carry the release and the installer's
5
+ // The workflows that install safe-chain on the runner. Each carries the release and the installer's
6
6
  // SHA-256 as workflow-level env, so one pin per file moves every "Setup safe-chain" step at once
7
- // (joshuafolkken/kit#2711). A consumer has only the first, and `josh sync` rewrites it from the
8
- // template, so moving it anywhere but kit is harmless and short-lived.
9
- const WORKFLOW_PATHS = ['.github/workflows/ci.yml', 'templates/workflows/ci.yml']
7
+ // (joshuafolkken/kit#2711, joshuafolkken/kit#2765). A consumer has the first and the last, and
8
+ // `josh sync` rewrites both from kit, so moving them anywhere but kit is harmless and short-lived.
9
+ const WORKFLOW_PATHS = [
10
+ '.github/workflows/ci.yml',
11
+ 'templates/workflows/ci.yml',
12
+ '.github/workflows/pr-classification.yml',
13
+ ]
10
14
  // `[ \t]` rather than `\s`: under the `m` flag `\s*` would run across line ends and swallow the
11
15
  // blank line that follows the env block.
12
16
  const VERSION_RE = /^(?<key>[ \t]*SAFE_CHAIN_INSTALLER_VERSION:[ \t]*)(?<value>\S+)[ \t]*$/mu
@@ -86,10 +86,21 @@ function resolve_scanner(project_root: string, platform: string): ScannerChoice
86
86
  return has_path_binary ? { path: BINARY_NAME, is_below_floor: true } : undefined
87
87
  }
88
88
 
89
+ // **Output is forwarded, never handed the terminal itself.** osv-scanner is a Go binary that probes a
90
+ // terminal on its stdout for its background color (an OSC 11 query followed by a cursor-position
91
+ // request) and never reads the answers, so they sat in the terminal's input queue and surfaced at the
92
+ // next shell prompt as `11;rgb:0101/0404/0909;1R` after a push ran this audit from the pre-push hook.
93
+ // A pipe means the scanner sees no terminal and asks nothing; the `inherit` half still prints its report.
94
+ // The fix belongs here, at the one command that asks, rather than on `git push`: piping the push would
95
+ // make its timeout wait on any process a hook left running (joshuafolkken/kit#2801).
96
+ const FORWARDED_OUTPUT = ['inherit', 'pipe'] as const
97
+
89
98
  function run_scanner(scanner_path: string): number {
90
99
  const { LOCKFILE_PATH, build_scanner_arguments } = security_audit_logic
91
100
  const result = execaSync(scanner_path, [...build_scanner_arguments(LOCKFILE_PATH)], {
92
- stdio: 'inherit',
101
+ stdin: 'inherit',
102
+ stdout: FORWARDED_OUTPUT,
103
+ stderr: FORWARDED_OUTPUT,
93
104
  reject: false,
94
105
  })
95
106
 
@@ -8,6 +8,7 @@ import { init_logic } from '#scripts/init/init-logic'
8
8
  import { PACKAGE_DIR, PROJECT_ROOT } from '#scripts/init/init-paths'
9
9
  import { plugin_install_hint_module } from '#scripts/init/plugin-install-hint'
10
10
  import { auto_merge_setting } from '#scripts/repo/auto-merge-setting'
11
+ import { repository_labels } from '#scripts/repo/repository-labels'
11
12
  import { project_config } from '#scripts/safe-chain/project-config'
12
13
  import { security_updates } from '#scripts/security/security-updates'
13
14
  import { sonar_file } from '#scripts/security/sonar-file'
@@ -364,9 +365,14 @@ function sync_package_json_migrations(destination_path: string): void {
364
365
  // unless the repository allows auto-merge (joshuafolkken/kit#834). Neither ever fails the sync: both
365
366
  // are GitHub-side state, not synced artifacts. Unconditional, unlike `init`, because `sync`
366
367
  // overwrites both files on every run.
368
+ //
369
+ // The labels are the same kind of GitHub-side prerequisite, and one kit *can* write: the synced
370
+ // `pr-classification.yml` requires one of the release classification labels, so the missing ones are
371
+ // created here rather than reported (joshuafolkken/kit#2797).
367
372
  function report_repository_settings(name_with_owner: string | undefined): void {
368
373
  security_updates.report_security_updates_section(name_with_owner)
369
374
  auto_merge_setting.report_auto_merge_section(name_with_owner)
375
+ repository_labels.ensure_labels(name_with_owner)
370
376
  }
371
377
 
372
378
  function sync_project_artifacts(is_force: boolean): void {
@@ -50,6 +50,8 @@ const NO_FINISH = 0
50
50
  // The launch's own result body, which is the only place the harness writes the id it assigns. Read
51
51
  // as a derived fact and the body discarded, exactly as `has_failure_line` and `followup_stages` are.
52
52
  const LAUNCH_PATTERN = /running in background with ID:\s*([\w-]+)/iu
53
+ // A backgrounded subagent's launch result, which names its id on an `agentId:` line of its own.
54
+ const AGENT_LAUNCH_PATTERN = /^Async agent launched[\s\S]*?\bagentId:\s*([\w-]+)/u
53
55
  // The notice the harness writes when a task it took into the background ends
54
56
  // (joshuafolkken/kit#1696). It is anchored at the start because the whole content of that line *is*
55
57
  // the notice — a body merely quoting one is text, the same hazard `read_id` unquotes for — and it
@@ -96,6 +98,14 @@ function launch_id(text: string): string {
96
98
  return LAUNCH_PATTERN.exec(text)?.[1] ?? NO_BACKGROUND
97
99
  }
98
100
 
101
+ // The id of a subagent the harness took into the background (joshuafolkken/kit#2774). Its end is the
102
+ // same `<task-id>` notice a command's is, so `finished_id` pairs it unchanged. It is kept apart from
103
+ // `launch_id` on purpose: a subagent is not a command, and folding it in would open a timeline window
104
+ // that re-stamps the spans a backgrounded gate running beside the review is owed.
105
+ function agent_launch_id(text: string): string {
106
+ return AGENT_LAUNCH_PATTERN.exec(text)?.[1] ?? NO_BACKGROUND
107
+ }
108
+
99
109
  // The background run a task-notification line reports the end of, or nothing for every other line —
100
110
  // which is nearly all of them, since only one line kind in a transcript carries this notice.
101
111
  function finished_id(text: string): string {
@@ -288,6 +298,7 @@ const time_background = {
288
298
  NO_BACKGROUND,
289
299
  NO_FINISH,
290
300
  launch_id,
301
+ agent_launch_id,
291
302
  finished_id,
292
303
  finished_at,
293
304
  read_id,
@@ -117,6 +117,9 @@ interface Block {
117
117
  // it is what pairs a launch with the call that later reads its output, so the minutes the command
118
118
  // actually ran can be placed on the timeline instead of only the seconds its launch call took.
119
119
  background_id: string
120
+ // The id of a subagent this block launched into the background, and `''` for every other block
121
+ // (joshuafolkken/kit#2774) — apart from `background_id` so the timeline windows stay commands only.
122
+ agent_id: string
120
123
  }
121
124
 
122
125
  interface TranscriptLine {
@@ -183,6 +186,7 @@ function to_block(raw: z.infer<typeof BLOCK_SCHEMA>): Block {
183
186
  error_text: is_error === true ? text.slice(0, ERROR_TEXT_LIMIT) : '',
184
187
  refusal_guard: is_error === true ? guard_from_refusal(text) : '',
185
188
  background_id: time_background.launch_id(text),
189
+ agent_id: time_background.agent_launch_id(text),
186
190
  }
187
191
  }
188
192
 
@@ -1,4 +1,6 @@
1
1
  # Written by `josh init` for a static project (joshuafolkken/kit#2693). kit's CLI runs on tsx, which
2
2
  # depends on esbuild, and pnpm fails an install whose dependencies carry an unapproved build script.
3
+ # unrs-resolver arrives through kit's optional ESLint import peers (joshuafolkken/kit#2785).
3
4
  allowBuilds:
4
5
  esbuild: true
6
+ unrs-resolver: true