@joshuafolkken/kit 1.1128.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 (54) hide show
  1. package/.claude/agents/investigator.md +17 -0
  2. package/.claude/settings.json +1 -0
  3. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -2
  4. package/.claude/skills/workflow-commands/delegation.md +5 -1
  5. package/SECURITY.md +1 -0
  6. package/dist/hooks/batch-guard.js +4 -4
  7. package/dist/hooks/{chunk-77J7IEQM.js → chunk-FO2I7UT7.js} +1 -1
  8. package/dist/hooks/{chunk-4JZDP5X5.js → chunk-GDU26K3J.js} +9 -1
  9. package/dist/hooks/{chunk-QVLT52OV.js → chunk-HANSKHES.js} +2 -2
  10. package/dist/hooks/{chunk-5SD2YM7Z.js → chunk-HBKZLITF.js} +4 -4
  11. package/dist/hooks/{chunk-AIOIOMPX.js → chunk-JKKTVDAG.js} +128 -61
  12. package/dist/hooks/{chunk-M7QKEKWO.js → chunk-KBXZLMRH.js} +9 -28
  13. package/dist/hooks/{chunk-XYUIQOW3.js → chunk-KOKOAV6D.js} +2 -2
  14. package/dist/hooks/{chunk-NXYMNPUQ.js → chunk-LJBLGHEH.js} +59 -19
  15. package/dist/hooks/{chunk-MKVGBQPA.js → chunk-NFZGFGOR.js} +1 -1
  16. package/dist/hooks/{chunk-7HVB7SZG.js → chunk-RFWWUI5F.js} +4 -4
  17. package/dist/hooks/codex-hook-adapter.js +10 -10
  18. package/dist/hooks/duplicate-read-guard.js +5 -5
  19. package/dist/hooks/format-edited.js +2 -2
  20. package/dist/hooks/inputs.json +199 -198
  21. package/dist/hooks/investigation-guard.js +5 -5
  22. package/dist/hooks/pretool-guard.js +9 -9
  23. package/dist/hooks/stop-guard.js +3 -3
  24. package/dist/josh.js +8 -1
  25. package/package.json +6 -4
  26. package/scripts/delegation/delegation-policy.ts +9 -1
  27. package/scripts/delegation/investigation-guard.ts +1 -1
  28. package/scripts/delegation/investigation-reads.ts +105 -19
  29. package/scripts/document/capped-print.ts +74 -0
  30. package/scripts/document/document-section-cli.ts +4 -3
  31. package/scripts/gate/scoped-green.ts +25 -14
  32. package/scripts/init/claude-kit-only-settings.ts +29 -0
  33. package/scripts/init/init-copy-content.ts +8 -2
  34. package/scripts/init/initial-commit.ts +24 -0
  35. package/scripts/init/start-exec.ts +16 -3
  36. package/scripts/init/start-steps.ts +8 -12
  37. package/scripts/issue/issue-read-cli.ts +1 -0
  38. package/scripts/issue/issue-report-failures.ts +6 -2
  39. package/scripts/issue/issue-state-cli.ts +1 -1
  40. package/scripts/josh/josh-commands-lane.ts +1 -1
  41. package/scripts/josh/josh-commands-project.ts +7 -0
  42. package/scripts/lane/lane-await.ts +46 -2
  43. package/scripts/lane/lane-cli.ts +11 -13
  44. package/scripts/release/github-release-environment.ts +1 -0
  45. package/scripts/release/github-release.ts +103 -17
  46. package/scripts/run/run-carry-cli.ts +3 -1
  47. package/scripts/run/run-carry-conversation.ts +94 -0
  48. package/scripts/run/run-carry-reclaim.ts +37 -0
  49. package/scripts/run/run-carry.ts +21 -5
  50. package/scripts/run/run-merge-steps.ts +4 -1
  51. package/scripts/test/test-related.ts +1 -6
  52. package/scripts/test/test-unit-guard.ts +21 -7
  53. package/scripts/time-runtime/time-spans.ts +4 -0
  54. package/templates/workflows/github-release.yml +13 -25
@@ -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 —
@@ -55,10 +55,23 @@ const CONTENT_READ_LABELS: ReadonlySet<string> = new Set(
55
55
  CONTENT_READ_COMMANDS.map((command) => time_shell.bash_label(command)),
56
56
  )
57
57
 
58
+ // **The searches carry no file text, so they are counted in turns rather than files**
59
+ // (joshuafolkken/kit#3139). The file count above never saw a `grep`, yet searches were the commonest
60
+ // exploration turn a lane took — 457 of them in one day, each re-reading the main line's whole context.
61
+ // What a search costs is the turn, so a turn is the unit: parallel searches in one turn count once.
62
+ const SEARCH_COMMANDS: ReadonlyArray<string> = ['fd', 'find', 'grep', 'rg']
63
+ const SEARCH_LABELS: ReadonlySet<string> = new Set(
64
+ SEARCH_COMMANDS.map((command) => time_shell.bash_label(command)),
65
+ )
66
+
67
+ // The unit the refusal sends the reading to (joshuafolkken/kit#3139): `.claude/agents/investigator.md`,
68
+ // the parent's model at a lowered effort. A consumer receives it through the plugin as `kit:investigator`.
69
+ const INVESTIGATOR_AGENT = 'investigator'
70
+
58
71
  // What the refusal tells the model. It has to name the count, the command and the return shape,
59
72
  // because the deny reason is the only text that reaches the model — a run that is refused and told
60
73
  // nothing reads it as a broken tool.
61
- const REASON = `⛔ investigation: ${String(delegation_policy.INVESTIGATION_FILE_THRESHOLD)} files read and not edited since the last delegated unit, so the reading from here goes to a unit of its own. 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 — an \`Edit\` cannot be issued against text you do not hold. This run's own instructions and the harness's own session files — a backgrounded call's output, a persisted tool result, this session's scratchpad — are never counted, so they are not what took the count here. The rule is \`.claude/skills/workflow-commands/SKILL.md\` → §2b, "The pre-implementation reading".`
74
+ const REASON = `⛔ investigation: ${String(delegation_policy.INVESTIGATION_FILE_THRESHOLD)} files read and not edited, or ${String(delegation_policy.INVESTIGATION_SEARCH_TURN_THRESHOLD)} search turns, since the last delegated unit, so the reading from here goes to a unit of its own (a successful write also starts the search count over). Ask \`pnpm josh delegate investigation\`, then dispatch the \`${INVESTIGATOR_AGENT}\` agent (\`kit:${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 — an \`Edit\` cannot be issued against text you do not hold. This run's own instructions and the harness's own session files — a backgrounded call's output, a persisted tool result, this session's scratchpad — never count toward the files, so they are not what took the file count here; every read-only search turn counts, whatever it names. The rule is \`.claude/skills/workflow-commands/SKILL.md\` → §2b, "The pre-implementation reading".`
62
75
 
63
76
  interface ReadTally {
64
77
  // The files read and not since edited, resolved so the two ways a target reaches here compare.
@@ -74,6 +87,10 @@ interface ReadTally {
74
87
  // The first instant the window covers. A refusal recorded before it cannot be checked against a
75
88
  // delegation any more, because the delegation that would re-arm it has scrolled out.
76
89
  window_start_ms: number
90
+ // The search turns since the last delegation or successful write, and how many of them came after
91
+ // the instant asked about — the search-side `pending` / `pending_since` (joshuafolkken/kit#3139).
92
+ searches: number
93
+ searches_since: number
77
94
  // The files this run has successfully edited within the window, resolved (joshuafolkken/kit#1840).
78
95
  // A re-read of one of them is a read of a file the run edited, which §2b keeps in the main line — so
79
96
  // it is excluded from the count and from a call's projected growth rather than counted as fresh
@@ -279,18 +296,65 @@ function add_all(
279
296
  // the edit's own span, so a read issued *after* the edit was added and stayed. `sed -i`, `MultiEdit`
280
297
  // and `NotebookEdit` need no special case any more — each is a write, so its target is in `edited` and
281
298
  // never enters the read count.
282
- function apply_span(pending: Map<string, number>, span: Span, edited: ReadonlySet<string>): void {
299
+ interface Accumulation {
300
+ // Read files, keyed by resolved path, valued by the instant each entered.
301
+ pending: Map<string, number>
302
+ // Search turns, keyed by message id so a turn's parallel searches count once, valued the same way.
303
+ searches: Map<string, number>
304
+ }
305
+
306
+ function is_read_only_search(span: Span): boolean {
307
+ return SEARCH_LABELS.has(span.label) && span.is_bundleable && !span.may_write
308
+ }
309
+
310
+ // **A span with no message id belongs to no known turn, so it is a turn of its own** — the reading every
311
+ // other turn counter gives `NO_MESSAGE_ID` (`time-round-trips.ts`, `time-bundles.ts`). Keyed by the
312
+ // id, every untagged search would collapse into one entry and the count would never reach its threshold.
313
+ function search_turn_key(searches: ReadonlyMap<string, number>, span: Span): string {
314
+ if (span.message_id !== time_spans.NO_MESSAGE_ID) return span.message_id
315
+
316
+ return `${time_spans.NO_MESSAGE_ID}#${String(searches.size)}`
317
+ }
318
+
319
+ // **A successful write ends the streak.** The measurement behind the threshold counted searches since
320
+ // the last edit or delegation: a run that has started writing is implementing, and the searches that
321
+ // follow locate the next edit rather than continue an investigation.
322
+ //
323
+ // **Every read-only search counts, whatever it names** (joshuafolkken/kit#3139). The file count can
324
+ // exempt the run's own instructions because a read names each file it reads; a search does not — the
325
+ // targets drop a bare directory, so `grep -rn foo CLAUDE.md scripts` looked like a search of
326
+ // `CLAUDE.md` alone and an exemption let the whole sweep through. `is_bundleable` without `may_write`
327
+ // is the read-only test `is_search_call` asks at the call, so the transcript never counts a search the
328
+ // guard could not have refused.
329
+ function apply_search(searches: Map<string, number>, span: Span): void {
330
+ if (span.is_writing) {
331
+ searches.clear()
332
+
333
+ return
334
+ }
335
+
336
+ const turn = search_turn_key(searches, span)
337
+
338
+ if (is_read_only_search(span) && !searches.has(turn)) searches.set(turn, span.ended_ms)
339
+ }
340
+
341
+ function apply_span(accumulation: Accumulation, span: Span, edited: ReadonlySet<string>): void {
283
342
  if (DELEGATION_TOOLS.has(span.label)) {
284
- pending.clear()
343
+ accumulation.pending.clear()
344
+ accumulation.searches.clear()
285
345
 
286
346
  return
287
347
  }
288
348
 
289
- if (!is_content_read(span.label) || is_failed_outcome(span)) return
349
+ if (is_failed_outcome(span)) return
350
+
351
+ apply_search(accumulation.searches, span)
352
+
353
+ if (!is_content_read(span.label)) return
290
354
 
291
355
  const fresh = subject_targets(span.targets).filter((target) => !edited.has(target))
292
356
 
293
- add_all(pending, fresh, span.ended_ms)
357
+ add_all(accumulation.pending, fresh, span.ended_ms)
294
358
  }
295
359
 
296
360
  function last_delegation_ms(spans: ReadonlyArray<Span>): number {
@@ -307,15 +371,17 @@ function pending_after(pending: ReadonlyMap<string, number>, since_ms: number):
307
371
  function tally_of(text: string, since_ms: number = hook_decision.NEVER_MS): ReadTally {
308
372
  const { spans, started_ms } = time_spans.parse_timeline(text)
309
373
  const edited = edited_files(spans)
310
- const pending = new Map<string, number>()
374
+ const accumulation: Accumulation = { pending: new Map(), searches: new Map() }
311
375
 
312
- for (const span of spans) apply_span(pending, span, edited)
376
+ for (const span of spans) apply_span(accumulation, span, edited)
313
377
 
314
378
  return {
315
- pending: [...pending.keys()],
316
- pending_since: pending_after(pending, since_ms),
379
+ pending: [...accumulation.pending.keys()],
380
+ pending_since: pending_after(accumulation.pending, since_ms),
317
381
  reset_ms: last_delegation_ms(spans),
318
382
  window_start_ms: started_ms,
383
+ searches: accumulation.searches.size,
384
+ searches_since: pending_after(accumulation.searches, since_ms),
319
385
  edited,
320
386
  }
321
387
  }
@@ -347,6 +413,11 @@ function is_at_threshold(pending_count: number): boolean {
347
413
  return pending_count + 1 >= delegation_policy.INVESTIGATION_FILE_THRESHOLD
348
414
  }
349
415
 
416
+ // The search turn that takes the count up to its threshold is the boundary, as above.
417
+ function is_at_search_threshold(search_count: number): boolean {
418
+ return search_count + 1 >= delegation_policy.INVESTIGATION_SEARCH_TURN_THRESHOLD
419
+ }
420
+
350
421
  // **One refusal per accumulation, re-armed by a delegation and by nothing else.** A run that reads on
351
422
  // regardless is not refused again, so a false positive — three files the run was about to edit — costs
352
423
  // one round trip rather than wedging the run. And a run that *does* delegate is refused again as soon
@@ -386,7 +457,7 @@ function is_rearmed(tally: ReadTally, refused_at_ms: number): boolean {
386
457
  return true
387
458
  }
388
459
 
389
- return is_at_threshold(tally.pending_since)
460
+ return is_at_threshold(tally.pending_since) || is_at_search_threshold(tally.searches_since)
390
461
  }
391
462
 
392
463
  // A transcript under a session's `subagents/` directory is a delegated unit's. The segment is
@@ -403,10 +474,19 @@ function is_unit_run(run: GuardRun | undefined): boolean {
403
474
  return run !== undefined && is_unit_transcript(run.transcript)
404
475
  }
405
476
 
406
- function is_refusable_command(call: GuardedCall): boolean {
477
+ function is_read_only_command(call: GuardedCall, labels: ReadonlySet<string>): boolean {
478
+ if (call.name !== cost_blocks.BASH_TOOL) return false
479
+
407
480
  const label = time_shell.bash_label(time_shell.bash_command(call.input))
408
481
 
409
- return CONTENT_READ_LABELS.has(label) && time_batch_guard.is_read_only_call(call)
482
+ return labels.has(label) && time_batch_guard.is_read_only_call(call)
483
+ }
484
+
485
+ // **A search is refusable without a subject target**, unlike a read: `grep -rn foo scripts` names a
486
+ // bare directory, which `call_facts` deliberately does not take as a target, and such a sweep is exactly
487
+ // the search this count exists for.
488
+ function is_search_call(call: GuardedCall): boolean {
489
+ return is_read_only_command(call, SEARCH_LABELS)
410
490
  }
411
491
 
412
492
  // **A `Read` is always safe to refuse; a `Bash` line is refused only where it writes nothing.** This
@@ -427,10 +507,20 @@ function is_refusable_command(call: GuardedCall): boolean {
427
507
  // rule being enforced — and a call whose targets could not be read at all, which cannot move the count
428
508
  // and so has nothing to be refused for.
429
509
  function is_refusable_call(call: GuardedCall): boolean {
510
+ if (is_search_call(call)) return true
430
511
  if (call_targets(call).length === 0) return false
431
512
  if (READ_TOOLS.has(call.name)) return true
432
513
 
433
- return call.name === cost_blocks.BASH_TOOL && is_refusable_command(call)
514
+ return is_read_only_command(call, CONTENT_READ_LABELS)
515
+ }
516
+
517
+ // A search call grows the search count by its own turn; a read grows the file count only by a file the
518
+ // count does not already hold.
519
+ function is_growing_past_threshold(tally: ReadTally, call: GuardedCall): boolean {
520
+ if (is_search_call(call)) return is_at_search_threshold(tally.searches)
521
+ if (!is_at_threshold(tally.pending.length)) return false
522
+
523
+ return projected_count(tally.pending, call, tally.edited) > tally.pending.length
434
524
  }
435
525
 
436
526
  function should_block(
@@ -446,15 +536,11 @@ function should_block(
446
536
 
447
537
  const tally = tally_of(text, refused_at_ms)
448
538
 
449
- if (!is_at_threshold(tally.pending.length)) return false
450
-
451
- return (
452
- projected_count(tally.pending, call, tally.edited) > tally.pending.length &&
453
- is_rearmed(tally, refused_at_ms)
454
- )
539
+ return is_growing_past_threshold(tally, call) && is_rearmed(tally, refused_at_ms)
455
540
  }
456
541
 
457
542
  const investigation_reads = {
543
+ INVESTIGATOR_AGENT,
458
544
  READ_TOOLS,
459
545
  REASON,
460
546
  is_at_threshold,
@@ -0,0 +1,74 @@
1
+ import { mkdtempSync, writeFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { PLATFORM_TEMP_ROOT } from '#scripts/josh/platform-temporary'
4
+ import { review_diff_parts } from '#scripts/review/review-diff-parts'
5
+ import { bash_output_cap_reader } from './bash-output-cap'
6
+
7
+ // Prints a command's output whole when it fits under the Bash output cap, and as part files when it
8
+ // does not (joshuafolkken/kit#3143). Past `BASH_MAX_OUTPUT_LENGTH` Claude Code saves the output and
9
+ // hands back a 2 KB preview, and the run spends a turn reading the saved file — 224 overflows and 132
10
+ // re-reads over one day's sessions, `doc:section` and `issue:read` the two most frequent `josh` sources.
11
+ //
12
+ // **The cut is #2963's, not a second one.** The parts come from `review_diff_parts.part_texts`, so each
13
+ // file — header included — fits under the cap and the bodies concatenate back to exactly the output.
14
+ // A reader opens them all with the Read tool in one turn, which is the turn the preview used to cost.
15
+ //
16
+ // **A fresh directory per call**, unlike the review's per-checkout one: a run fetches several sections
17
+ // in one turn, and a shared directory replaced on every call would delete one call's parts while
18
+ // another call's listing still names them.
19
+
20
+ const DIRECTORY_PREFIX = 'josh-capped-print-'
21
+ const FILE_MODE = 0o600
22
+ const PART_SUFFIX = '.md'
23
+ const LINE_BREAK = '\n'
24
+ // What else shares the Bash result with this output: the launcher line, a script banner, a few
25
+ // stderr lines. Measured at about 80 characters for `pnpm josh doc:section` in kit, so 512 holds the
26
+ // longest heading or issue list with room for a consumer's banner.
27
+ const HEADROOM_CHARS = 512
28
+
29
+ function write_parts(label: string, text: string, cap: number): Array<string> {
30
+ const directory = mkdtempSync(path.join(PLATFORM_TEMP_ROOT, DIRECTORY_PREFIX))
31
+
32
+ return review_diff_parts.part_texts(label, text, cap).map((part, index) => {
33
+ const file = path.join(directory, `part-${String(index + 1)}${PART_SUFFIX}`)
34
+
35
+ writeFileSync(file, part, { flag: 'wx', mode: FILE_MODE })
36
+
37
+ return file
38
+ })
39
+ }
40
+
41
+ function part_count(count: number): string {
42
+ return `written as ${String(count)} part file${count === 1 ? '' : 's'} under it`
43
+ }
44
+
45
+ function listing(text: string, cap: number, files: ReadonlyArray<string>): string {
46
+ const figures = `${text.length.toLocaleString('en-US')} > ${cap.toLocaleString('en-US')} characters`
47
+ const lead = `Over the Bash cap (${figures}) — ${part_count(files.length)}, together exactly the output. Read every part with the Read tool, in one turn, not cat:`
48
+
49
+ return [lead, ...files.map((file) => ` ${file}`)].join(LINE_BREAK)
50
+ }
51
+
52
+ // **The budget is the cap less a headroom, not the cap.** The Bash result carries more than this
53
+ // output — `pnpm josh`'s own launcher line, a consumer's script banner, the stderr lines naming a
54
+ // number that did not resolve — so a text measured against the bare cap would overflow by exactly
55
+ // those lines, and cost the re-read this module exists to remove.
56
+ function output_budget(root: string): number {
57
+ return Math.max(1, bash_output_cap_reader.bash_output_cap(root) - HEADROOM_CHARS)
58
+ }
59
+
60
+ function capped_print(text: string, label: string, root: string = process.cwd()): void {
61
+ const budget = output_budget(root)
62
+
63
+ if (text.length < budget) {
64
+ console.info(text)
65
+
66
+ return
67
+ }
68
+
69
+ console.info(listing(text, budget, write_parts(label, text, budget)))
70
+ }
71
+
72
+ const capped_output = { HEADROOM_CHARS, capped_print }
73
+
74
+ export { capped_output }
@@ -2,6 +2,7 @@
2
2
  import { existsSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
+ import { capped_output } from './capped-print'
5
6
  import { document_section } from './document-section'
6
7
  import { entry_read_set } from './entry-read-set'
7
8
 
@@ -56,12 +57,12 @@ function report_missing(markdown: string, heading: string): number {
56
57
  return FAILURE_EXIT_CODE
57
58
  }
58
59
 
59
- function print_section(markdown: string, heading: string): number {
60
+ function print_section(markdown: string, heading: string, root: string): number {
60
61
  const found = document_section.section(markdown, heading)
61
62
 
62
63
  if (found === undefined) return report_missing(markdown, heading)
63
64
 
64
- console.info(found.text)
65
+ capped_output.capped_print(found.text, heading, root)
65
66
 
66
67
  return 0
67
68
  }
@@ -87,7 +88,7 @@ function run(argv: ReadonlyArray<string>, root: string = process.cwd()): number
87
88
 
88
89
  const markdown = read_named(name, root)
89
90
 
90
- return markdown === undefined ? FAILURE_EXIT_CODE : print_section(markdown, heading)
91
+ return markdown === undefined ? FAILURE_EXIT_CODE : print_section(markdown, heading, root)
91
92
  }
92
93
 
93
94
  function main(argv: ReadonlyArray<string>): void {
@@ -2,6 +2,7 @@ import { file_map_stamp, type FileMapStampAccess } from '#scripts/josh/file-map-
2
2
  import { hook_decision } from '#scripts/josh/hook-decision'
3
3
  import { review_brief } from '#scripts/review/review-brief'
4
4
  import { review_stamps } from '#scripts/review/review-stamps'
5
+ import { test_unit_guard } from '#scripts/test/test-unit-guard'
5
6
  import { gate_tree, type GateTree } from './gate-tree'
6
7
 
7
8
  // Whether the two scoped checks have been green on *this exact tree* by the time the review is
@@ -60,26 +61,36 @@ interface ScopedCheck {
60
61
 
61
62
  // The record paths, overridable so a test can plant one without overwriting the record a real run in
62
63
  // this checkout relies on — the same reason `gate_skip.reusable_green_gate` takes a `source`.
64
+ // `is_unit_skipped` is overridable for the same reason, so a suite running in a checkout with vitest
65
+ // can stand in for a project without one.
63
66
  interface ScopedSources {
64
67
  lint?: string
65
68
  test?: string
69
+ is_unit_skipped?: boolean
66
70
  }
67
71
 
72
+ // **`test:related` is required only where it can ever answer** (joshuafolkken/kit#3136). In a project
73
+ // whose unit guard skips — vitest absent, or a basic project with no test file — `test:related` runs
74
+ // nothing and so writes no record, and requiring one refused the gate forever with no way through.
75
+ // The guard's own decision is asked rather than re-derived, so the writer and this reader agree; a
76
+ // project with a unit suite still needs the record, and a run that executed nothing still writes none.
68
77
  function checks_of(sources: ScopedSources): ReadonlyArray<ScopedCheck> {
69
- return [
70
- {
71
- script: LINT_SCRIPT,
72
- command: LINT_COMMAND,
73
- stamp: review_stamps.lint_related_stamp,
74
- source: sources.lint,
75
- },
76
- {
77
- script: TEST_SCRIPT,
78
- command: TEST_COMMAND,
79
- stamp: review_stamps.test_related_stamp,
80
- source: sources.test,
81
- },
82
- ]
78
+ const lint = {
79
+ script: LINT_SCRIPT,
80
+ command: LINT_COMMAND,
81
+ stamp: review_stamps.lint_related_stamp,
82
+ source: sources.lint,
83
+ }
84
+ const test = {
85
+ script: TEST_SCRIPT,
86
+ command: TEST_COMMAND,
87
+ stamp: review_stamps.test_related_stamp,
88
+ source: sources.test,
89
+ }
90
+
91
+ return (sources.is_unit_skipped ?? test_unit_guard.is_skipping(process.cwd()))
92
+ ? [lint]
93
+ : [lint, test]
83
94
  }
84
95
 
85
96
  // **An unreadable record reads as an absent one, and that is the safe direction.** `read` swallows a
@@ -0,0 +1,29 @@
1
+ import { patch_json_key } from '#scripts/config-merge/patch-json-key'
2
+ import { claude_plugin_config } from './claude-plugin-config'
3
+
4
+ // Keys kit's own `.claude/settings.json` sets for its sessions but never ships. `enableArtifact:
5
+ // false` keeps the Artifact tool's ~12k tokens out of every call of a kit run
6
+ // (joshuafolkken/kit#3140), but Claude Code lets any layer that sets it to `false` win, so a
7
+ // consumer given it could not turn the tool back on from `.claude/settings.local.json`.
8
+ const KIT_ONLY_SETTING_KEYS: ReadonlyArray<string> = ['enableArtifact']
9
+
10
+ function strip_kit_only_settings(content: string): string {
11
+ let stripped = content
12
+ for (const key of KIT_ONLY_SETTING_KEYS) stripped = patch_json_key.remove_json_key(stripped, key)
13
+
14
+ return stripped
15
+ }
16
+
17
+ function apply_kit_only_strip_for_destination(destination_path: string, content: string): string {
18
+ return destination_path.endsWith(claude_plugin_config.CLAUDE_SETTINGS_DESTINATION)
19
+ ? strip_kit_only_settings(content)
20
+ : content
21
+ }
22
+
23
+ const claude_kit_only_settings = {
24
+ KIT_ONLY_SETTING_KEYS,
25
+ strip_kit_only_settings,
26
+ apply_kit_only_strip_for_destination,
27
+ }
28
+
29
+ export { claude_kit_only_settings }
@@ -1,6 +1,7 @@
1
1
  import { managed_marker_logic } from '#scripts/managed-marker/managed-marker-logic'
2
2
  import { workflow_pin_logic } from '#scripts/sync/workflow-pin-logic'
3
3
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
4
+ import { claude_kit_only_settings } from './claude-kit-only-settings'
4
5
  import { claude_plugin_config } from './claude-plugin-config'
5
6
  import { hook_command_rewrite } from './hook-command-rewrite'
6
7
  import { init_logic } from './init-logic'
@@ -19,7 +20,8 @@ import { init_logic } from './init-logic'
19
20
  * workflow-pin-logic.ts for why the template refs are not authoritative, and
20
21
  * managed-marker-logic.ts for why the stamp is on the file rather than in a list.
21
22
  *
22
- * The consumer's `.claude/settings.json` gets two further passes. The kit plugin's marketplace and
23
+ * The consumer's `.claude/settings.json` gets three further passes. The keys kit sets only for its
24
+ * own sessions are removed (joshuafolkken/kit#3140, see claude-kit-only-settings.ts). The kit plugin's marketplace and
23
25
  * `enabledPlugins` declaration is injected, because the skills that used to be copied now ship as the
24
26
  * `kit` plugin and load from that declaration (joshuafolkken/kit#1879). And its hook commands are
25
27
  * rewritten from `pnpm josh` to the published bundle invoked directly with node
@@ -36,10 +38,14 @@ function transform_copied_content(destination_path: string, content: string): st
36
38
  with_pins,
37
39
  KIT_PACKAGE_NAME,
38
40
  )
39
- const with_plugin = claude_plugin_config.apply_plugin_config_for_destination(
41
+ const with_kit_only_stripped = claude_kit_only_settings.apply_kit_only_strip_for_destination(
40
42
  destination_path,
41
43
  with_marker,
42
44
  )
45
+ const with_plugin = claude_plugin_config.apply_plugin_config_for_destination(
46
+ destination_path,
47
+ with_kit_only_stripped,
48
+ )
43
49
 
44
50
  return hook_command_rewrite.apply_hook_command_rewrite_for_destination(
45
51
  destination_path,
@@ -0,0 +1,24 @@
1
+ import { start_exec } from './start-exec'
2
+
3
+ // The first commit of a new repository, shared by `josh start` and `josh dogfood:commit` so neither
4
+ // keeps a copy of how it is made.
5
+
6
+ const DEFAULT_BRANCH = 'main'
7
+
8
+ function create_repository(root: string): void {
9
+ start_exec.run('git', ['init', `--initial-branch=${DEFAULT_BRANCH}`], root)
10
+ }
11
+
12
+ function commit_all(root: string): void {
13
+ start_exec.run('git', ['add', '--all'], root)
14
+ // The first commit of a new repository has no branch to come from, so the hook that keeps commits
15
+ // off main — installed by the initialize step in a full project — would refuse the only commit
16
+ // that has to land there.
17
+ start_exec.run('git', ['commit', '--no-verify', '--message', 'Initial commit'], root)
18
+ // A repository created by an older `git init` may name its unborn branch `master`.
19
+ start_exec.run('git', ['branch', '--move', '--force', DEFAULT_BRANCH], root)
20
+ }
21
+
22
+ const initial_commit = { create_repository, commit_all }
23
+
24
+ export { initial_commit }
@@ -1,10 +1,21 @@
1
1
  import { git_gh_exec } from '#scripts/gh/git-gh-exec'
2
+ import { git_location_environment } from '#scripts/git/git-location-environment'
2
3
  import { execaSync } from 'execa'
3
4
 
4
5
  // The three spawn shapes every `josh start` step uses, shared by `start-steps.ts`, the setup pull
5
6
  // request step and the `josh init` hint so none of them keeps a copy of its own.
7
+ //
8
+ // The three git-driving shapes below act on `root`, so `cwd` has to mean it: an inherited `GIT_DIR` and friends beat `cwd`
9
+ // and would carry a `git init` or commit into whichever repository a calling hook was firing in
10
+ // (joshuafolkken/kit#3151).
11
+ function root_environment(): Record<string, string | undefined> {
12
+ return git_location_environment.location_free_environment()
13
+ }
14
+
6
15
  function succeeds(command: string, args: ReadonlyArray<string>, root: string): boolean {
7
- return execaSync(command, args, { cwd: root, reject: false }).exitCode === 0
16
+ return (
17
+ execaSync(command, args, { cwd: root, env: root_environment(), reject: false }).exitCode === 0
18
+ )
8
19
  }
9
20
 
10
21
  function read_output(
@@ -12,7 +23,7 @@ function read_output(
12
23
  args: ReadonlyArray<string>,
13
24
  root: string,
14
25
  ): string | undefined {
15
- const result = execaSync(command, args, { cwd: root, reject: false })
26
+ const result = execaSync(command, args, { cwd: root, env: root_environment(), reject: false })
16
27
 
17
28
  return result.exitCode === 0 ? result.stdout.trim() : undefined
18
29
  }
@@ -20,7 +31,9 @@ function read_output(
20
31
  // Every step spawned here dials GitHub directly, past a scanner's loopback proxy, the same as every
21
32
  // other `gh` spawn under scripts/ (joshuafolkken/kit#2436); a local `git` step is unaffected by it.
22
33
  function run(command: string, args: ReadonlyArray<string>, root: string): void {
23
- execaSync(command, args, { ...git_gh_exec.direct_environment(), cwd: root, stdio: 'inherit' })
34
+ const environment = { ...root_environment(), ...git_gh_exec.direct_environment().env }
35
+
36
+ execaSync(command, args, { env: environment, cwd: root, stdio: 'inherit' })
24
37
  }
25
38
 
26
39
  // A caller's own initialize command stands in for the in-process `josh init`, so it keeps the
@@ -6,6 +6,7 @@ import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
6
6
  import { parseCommandString } from 'execa'
7
7
  import { main as init_main } from './init'
8
8
  import { PACKAGE_DIR } from './init-paths'
9
+ import { initial_commit } from './initial-commit'
9
10
  import { kit_setup_state } from './kit-setup-state'
10
11
  import type { ProjectProfile, ProjectShape } from './project-profile'
11
12
  import { start_exec } from './start-exec'
@@ -23,7 +24,6 @@ interface RunContext extends StepContext {
23
24
  baseline: ReadonlyArray<string> | undefined
24
25
  }
25
26
 
26
- const DEFAULT_BRANCH = 'main'
27
27
  const GH_INSTALL_HINT =
28
28
  'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
29
29
  const GH_AUTH_HINT =
@@ -70,7 +70,7 @@ function self_run_refusal(root: string): string | undefined {
70
70
  }
71
71
 
72
72
  function create_git_repository(context: StepContext): void {
73
- run('git', ['init', `--initial-branch=${DEFAULT_BRANCH}`], context.root)
73
+ initial_commit.create_repository(context.root)
74
74
  }
75
75
 
76
76
  // The same setup `josh init` runs, called rather than copied, with the profile the user confirmed.
@@ -90,13 +90,7 @@ function initialize(context: StepContext): void {
90
90
  }
91
91
 
92
92
  function commit_all(context: StepContext): void {
93
- run('git', ['add', '--all'], context.root)
94
- // The first commit of a new repository has no branch to come from, so the hook that keeps commits
95
- // off main — installed by the initialize step in a full project — would refuse the only commit
96
- // that has to land there.
97
- run('git', ['commit', '--no-verify', '--message', 'Initial commit'], context.root)
98
- // A repository created by an older `git init` may name its unborn branch `master`.
99
- run('git', ['branch', '--move', '--force', DEFAULT_BRANCH], context.root)
93
+ initial_commit.commit_all(context.root)
100
94
  }
101
95
 
102
96
  function create_github_repository(context: StepContext): void {
@@ -119,13 +113,15 @@ async function open_setup_pull_request(context: RunContext): Promise<void> {
119
113
  await start_setup_pr.open(context.root, context.baseline)
120
114
  }
121
115
 
122
- // What was already changed before a caller's initialize command ran, read only when the setup pull
123
- // request will need to tell that command's files from the user's own (#2872).
116
+ // What was already changed before the setup ran, read whenever a setup pull request will follow, so
117
+ // it can tell the setup's files from the user's own (#2872). Kit's own `josh init` needs it as much as
118
+ // a caller's command: its `josh format` rewrites the project's own sources, which kit cannot name, and
119
+ // leaving them out failed the pull request's Prettier check (joshuafolkken/kit#3136).
124
120
  function baseline_of(
125
121
  steps: ReadonlyArray<StartStep>,
126
122
  context: StepContext,
127
123
  ): ReadonlyArray<string> | undefined {
128
- if (context.init_command === undefined || !steps.includes('setup_pr')) return undefined
124
+ if (!steps.includes('setup_pr')) return undefined
129
125
 
130
126
  return start_setup_pr.changed_paths(context.root)
131
127
  }
@@ -129,6 +129,7 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
129
129
  issue_report_failures.print_blocks(
130
130
  reports.flatMap((report) => issue_blocks(report)),
131
131
  BLOCK_SEPARATOR,
132
+ `issue:read ${issue_numbers.join(' ')}`,
132
133
  )
133
134
 
134
135
  return issue_report_failures.report_failures(reports, FAILURE_TERMS)
@@ -1,3 +1,5 @@
1
+ import { capped_output } from '#scripts/document/capped-print'
2
+
1
3
  // The failure report `issue:read` and `issue:state` share (joshuafolkken/kit#3046). Both read a batch
2
4
  // of numbers, print a block for each one that answered, and name every one that did not; only the
3
5
  // result kind that counts as an answer and what a gap must not be mistaken for differ between them.
@@ -22,10 +24,12 @@ interface FailureTerms<K extends string> {
22
24
  misreading: string
23
25
  }
24
26
 
25
- function print_blocks(blocks: ReadonlyArray<string>, separator: string): void {
27
+ // Under the Bash output cap, or as part files past it — a batch of long threads is the shape that
28
+ // overflowed (joshuafolkken/kit#3143).
29
+ function print_blocks(blocks: ReadonlyArray<string>, separator: string, label: string): void {
26
30
  if (blocks.length === 0) return
27
31
 
28
- console.info(blocks.join(separator))
32
+ capped_output.capped_print(blocks.join(separator), label)
29
33
  }
30
34
 
31
35
  // A number that resolves to nothing is an answer — a typo, or another repository's number quoted in