@joshuafolkken/kit 1.1094.0 → 1.1136.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/.claude/agents/investigator.md +17 -0
  2. package/.claude/settings.json +1 -0
  3. package/.claude/skills/workflow-commands/SKILL.md +1 -1
  4. package/.claude/skills/workflow-commands/background-commands.md +36 -85
  5. package/.claude/skills/workflow-commands/backlogrun-progress.md +31 -34
  6. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -2
  7. package/.claude/skills/workflow-commands/delegation.md +5 -1
  8. package/.claude/skills/workflow-commands/fullrun.md +5 -4
  9. package/.claude/skills/workflow-commands/halfrun.md +5 -4
  10. package/.claude/skills/workflow-commands/latest-gate.md +7 -15
  11. package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -8
  12. package/.claude/skills/workflow-commands/split-assessment.md +4 -18
  13. package/.github/actions/setup-node/action.yml +33 -0
  14. package/.github/actions/setup-pnpm/action.yml +4 -4
  15. package/.github/workflows/auto-tag.yml +2 -3
  16. package/.github/workflows/ci.yml +7 -8
  17. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  18. package/.github/workflows/pr-classification.yml +1 -3
  19. package/.github/workflows/production.yml +1 -0
  20. package/.github/workflows/publish.yml +10 -9
  21. package/.github/workflows/sonar-qube.yml +17 -13
  22. package/.pnpmfile.mjs +29 -0
  23. package/AGENTS.md +4 -11
  24. package/CLAUDE.md +24 -17
  25. package/GEMINI.md +4 -13
  26. package/README.md +8 -3
  27. package/SECURITY.md +2 -0
  28. package/cspell/index.yaml +4 -0
  29. package/dist/CLAUDE.basic.md +6 -6
  30. package/dist/CLAUDE.md +24 -17
  31. package/dist/CLAUDE.static.md +6 -6
  32. package/dist/config-merge/index.js +33 -9
  33. package/dist/hooks/batch-guard.js +4 -4
  34. package/dist/hooks/{chunk-OIZUHPJI.js → chunk-FO2I7UT7.js} +1 -1
  35. package/dist/hooks/{chunk-NKOG3XAV.js → chunk-GDU26K3J.js} +11 -3
  36. package/dist/hooks/{chunk-O4LL4HTI.js → chunk-HANSKHES.js} +2 -2
  37. package/dist/hooks/{chunk-3C4YCCEF.js → chunk-HBKZLITF.js} +4 -4
  38. package/dist/hooks/{chunk-UD24EAIL.js → chunk-JKKTVDAG.js} +1984 -146
  39. package/dist/hooks/{chunk-M2XDCUKD.js → chunk-KBXZLMRH.js} +89 -1803
  40. package/dist/hooks/{chunk-IL27YTPH.js → chunk-KOKOAV6D.js} +2 -2
  41. package/dist/hooks/{chunk-7ZPFAVR7.js → chunk-LJBLGHEH.js} +59 -19
  42. package/dist/hooks/{chunk-E54KGFEQ.js → chunk-NFZGFGOR.js} +1 -1
  43. package/dist/hooks/{chunk-KHWHKJOM.js → chunk-RFWWUI5F.js} +4 -4
  44. package/dist/hooks/codex-hook-adapter.js +12 -47
  45. package/dist/hooks/duplicate-read-guard.js +5 -5
  46. package/dist/hooks/format-edited.js +2 -2
  47. package/dist/hooks/inputs.json +175 -173
  48. package/dist/hooks/investigation-guard.js +5 -5
  49. package/dist/hooks/pretool-guard.js +9 -9
  50. package/dist/hooks/stop-guard.js +17 -12
  51. package/dist/josh.js +87 -31
  52. package/dist/version/index.js +36 -20
  53. package/eslint/rules/unicorn.js +3 -2
  54. package/package.json +23 -5
  55. package/pnpm-workspace.yaml +5 -7
  56. package/prompts/collaboration-workflow/glossary.md +17 -0
  57. package/prompts/collaboration-workflow/issue-template.md +1 -1
  58. package/prompts/collaboration-workflow/operating-rules.md +1 -1
  59. package/prompts/collaboration-workflow/principles.md +9 -0
  60. package/prompts/collaboration-workflow.md +1 -0
  61. package/scripts/backlog/backlog-arrival.ts +30 -10
  62. package/scripts/backlog/backlog-budget-cli.ts +15 -16
  63. package/scripts/backlog/backlog-offer-cli.ts +6 -14
  64. package/scripts/backlog/backlog-plan.ts +29 -18
  65. package/scripts/backlog/backlog-ready.ts +20 -7
  66. package/scripts/config-merge/parse-jsonc.ts +33 -3
  67. package/scripts/config-merge/patch-json-key.ts +13 -6
  68. package/scripts/cost-runtime/cost-cli.ts +3 -7
  69. package/scripts/delegation/delegation-policy.ts +9 -1
  70. package/scripts/delegation/investigation-guard.ts +1 -1
  71. package/scripts/delegation/investigation-reads.ts +105 -19
  72. package/scripts/doctor/doctor-consumer.ts +3 -5
  73. package/scripts/doctor/doctor-io.ts +9 -13
  74. package/scripts/document/backlogrun-parent-read-set.ts +12 -1
  75. package/scripts/document/capped-print.ts +74 -0
  76. package/scripts/document/document-byte-budget.ts +1 -0
  77. package/scripts/document/document-section-cli.ts +4 -3
  78. package/scripts/document/entry-read-budget.ts +13 -7
  79. package/scripts/document/entry-read-set.ts +32 -8
  80. package/scripts/document/lane-child-read-set.ts +7 -0
  81. package/scripts/document/read-set-point-of-use.ts +8 -3
  82. package/scripts/document/read-set-trim.ts +3 -1
  83. package/scripts/epic/epic-audit.ts +3 -18
  84. package/scripts/gate/scoped-green.ts +25 -14
  85. package/scripts/gh/git-gh-issue-list.ts +12 -11
  86. package/scripts/gh/git-gh-issue-write.ts +4 -1
  87. package/scripts/gh/git-gh-pr-auto-merge.ts +4 -1
  88. package/scripts/git/git-command.ts +6 -1
  89. package/scripts/git/git-spawn-sync.ts +65 -0
  90. package/scripts/git/git-spawn.ts +5 -4
  91. package/scripts/git/stash/stash-pop-args.ts +1 -6
  92. package/scripts/init/claude-kit-only-settings.ts +29 -0
  93. package/scripts/init/init-copy-content.ts +8 -2
  94. package/scripts/init/init-file-action.ts +54 -0
  95. package/scripts/init/init-logic-migrate.ts +35 -17
  96. package/scripts/init/init-logic-secretlint.ts +1 -1
  97. package/scripts/init/init-logic.ts +6 -22
  98. package/scripts/init/init.ts +11 -68
  99. package/scripts/init/initial-commit.ts +24 -0
  100. package/scripts/init/kit-written-paths.ts +2 -0
  101. package/scripts/init/project-profile.ts +3 -6
  102. package/scripts/init/start-exec.ts +16 -3
  103. package/scripts/init/start-steps.ts +10 -13
  104. package/scripts/issue/defect-rate-cli.ts +61 -20
  105. package/scripts/issue/issue-backlinks.ts +7 -14
  106. package/scripts/issue/issue-cite.ts +12 -6
  107. package/scripts/issue/issue-file.ts +5 -12
  108. package/scripts/issue/issue-read-cli.ts +1 -0
  109. package/scripts/issue/issue-report-failures.ts +6 -2
  110. package/scripts/issue/issue-state-cli.ts +1 -1
  111. package/scripts/issue/markdown-section.ts +21 -6
  112. package/scripts/issue/measure-rerun-cli.ts +73 -14
  113. package/scripts/josh/josh-commands-lane.ts +1 -1
  114. package/scripts/josh/josh-commands-project.ts +8 -1
  115. package/scripts/josh/josh-commands-workflow.ts +1 -1
  116. package/scripts/josh/run-github-prerequisite.ts +3 -6
  117. package/scripts/lane/lane-await.ts +89 -15
  118. package/scripts/lane/lane-cli.ts +11 -13
  119. package/scripts/lane/lane-close.ts +4 -1
  120. package/scripts/lane/lane-launch-cli.ts +10 -16
  121. package/scripts/lane/openai-review-broker.ts +4 -1
  122. package/scripts/lib/cli-flags.ts +31 -2
  123. package/scripts/lib/error-message.ts +12 -1
  124. package/scripts/lines/line-targets.ts +0 -11
  125. package/scripts/lines/lines-command.ts +5 -3
  126. package/scripts/propagate/propagate-git.ts +3 -8
  127. package/scripts/refactor/refactor-scan.ts +2 -1
  128. package/scripts/registry-migration/migrate-logic.ts +28 -19
  129. package/scripts/registry-migration/migrate.ts +6 -5
  130. package/scripts/release/github-release-cli.ts +14 -3
  131. package/scripts/release/github-release-environment.ts +1 -0
  132. package/scripts/release/github-release.ts +103 -17
  133. package/scripts/release/release-publish.ts +36 -2
  134. package/scripts/release/release-worktree.ts +97 -2
  135. package/scripts/review/review-checkout.ts +3 -2
  136. package/scripts/review/review-record-cli.ts +9 -16
  137. package/scripts/rules/early-heartbeat.ts +4 -3
  138. package/scripts/rules/lane-park.ts +9 -0
  139. package/scripts/rules/stop-rules.ts +7 -4
  140. package/scripts/run/run-carry-args.ts +8 -12
  141. package/scripts/run/run-carry-cli.ts +3 -1
  142. package/scripts/run/run-carry-conversation.ts +94 -0
  143. package/scripts/run/run-carry-reclaim.ts +37 -0
  144. package/scripts/run/run-carry.ts +21 -5
  145. package/scripts/run/run-cut-args.ts +1 -6
  146. package/scripts/run/run-ending-cli.ts +1 -5
  147. package/scripts/run/run-entry-cli.ts +40 -9
  148. package/scripts/run/run-entry-stop.ts +104 -0
  149. package/scripts/run/run-event-stream-emit.ts +7 -2
  150. package/scripts/run/run-hold.ts +4 -1
  151. package/scripts/run/run-liveness-cli.ts +1 -5
  152. package/scripts/run/run-merge-cli.ts +2 -10
  153. package/scripts/run/run-merge-steps.ts +4 -1
  154. package/scripts/run/run-parent-cut-hook.ts +4 -1
  155. package/scripts/run/run-prep-cli.ts +4 -1
  156. package/scripts/run/run-progress-args.ts +20 -4
  157. package/scripts/run/run-progress-cli.ts +31 -29
  158. package/scripts/run/run-progress-clock.ts +4 -1
  159. package/scripts/run/run-ship-cli.ts +8 -16
  160. package/scripts/run/run-ship-probe.ts +4 -1
  161. package/scripts/run/run-status-cli.ts +2 -15
  162. package/scripts/run/run-tidy-lanes.ts +4 -1
  163. package/scripts/run/run-wake-cli.ts +2 -10
  164. package/scripts/run/run-watcher-guard.ts +9 -5
  165. package/scripts/safe-chain/pack-hook.ts +92 -0
  166. package/scripts/sync/sync-ai-files.ts +4 -0
  167. package/scripts/test/test-declared-changed.ts +13 -16
  168. package/scripts/test/test-declared.ts +3 -7
  169. package/scripts/test/test-related.ts +1 -6
  170. package/scripts/test/test-unit-guard.ts +21 -7
  171. package/scripts/time-runtime/time-spans.ts +4 -0
  172. package/scripts/version/version-commands.ts +11 -1
  173. package/templates/CLAUDE.basic.md +6 -6
  174. package/templates/workflows/ci.yml +16 -82
  175. package/templates/workflows/dependabot-auto-merge.yml +1 -0
  176. package/templates/workflows/github-release.yml +14 -28
  177. package/tsconfig.sonar.json +4 -22
@@ -10,6 +10,7 @@ import { lane_reap } from '#scripts/lane/lane-reap'
10
10
  import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
11
11
  import { lane_relaunch } from '#scripts/lane/lane-relaunch'
12
12
  import { run_carry, type CarryChange, type CarryOwner, type RunCarry } from './run-carry'
13
+ import { run_carry_conversation } from './run-carry-conversation'
13
14
  import { run_cut, type RunCut } from './run-cut'
14
15
  import { run_merge } from './run-merge'
15
16
 
@@ -105,7 +106,9 @@ async function apply_carry(
105
106
  return { kind: 'refused', carry: record.carry }
106
107
  }
107
108
 
108
- return { kind: 'applied', carry: run_carry.apply_change(record.target, record.carry, change) }
109
+ const owned = run_carry_conversation.reclaim_owner(record.carry, ctx.owner)
110
+
111
+ return { kind: 'applied', carry: run_carry.apply_change(record.target, owned, change) }
109
112
  }
110
113
 
111
114
  // The carry record when this session may not act on it — the same ownership guard `apply_carry` asks —
@@ -3,6 +3,7 @@ import { cost_cli, type CostVerdict } from '#scripts/cost-runtime/cost-cli'
3
3
  import { cost_format } from '#scripts/cost-runtime/cost-format'
4
4
  import { cost_verdict } from '#scripts/cost-runtime/cost-verdict'
5
5
  import { hook_decision } from '#scripts/josh/hook-decision'
6
+ import { error_text } from '#scripts/lib/error-message'
6
7
  import { implementation_cut_verdict } from '#scripts/rules/implementation-cut-verdict'
7
8
  import { run_carry, type RunCarry } from './run-carry'
8
9
  import { run_headless } from './run-headless'
@@ -104,7 +105,9 @@ async function refusal_for(
104
105
  function is_unit_call(raw_payload: string): boolean {
105
106
  try {
106
107
  return (hook_decision.parse_hook_payload(raw_payload)?.agent_id ?? '') !== ''
107
- } catch {
108
+ } catch (error) {
109
+ error_text.trace_swallowed('run_parent_cut_hook.is_unit_call', error)
110
+
108
111
  return false
109
112
  }
110
113
  }
@@ -4,6 +4,7 @@ import { git_stash } from '#scripts/git/stash/git-stash'
4
4
  import { issue_read_cli, type BlockRead } from '#scripts/issue/issue-read-cli'
5
5
  import { issue_state_cli, type StateRead } from '#scripts/issue/issue-state-cli'
6
6
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
7
+ import { error_text } from '#scripts/lib/error-message'
7
8
  import { latest_scope_cli } from '#scripts/version/latest-scope-cli'
8
9
  import { run_prep, type PrepParts } from './run-prep'
9
10
  import { run_prep_locate } from './run-prep-locate'
@@ -82,7 +83,9 @@ async function lane_has_changes(): Promise<boolean> {
82
83
 
83
84
  try {
84
85
  return await git_stash.has_changes(process.cwd())
85
- } catch {
86
+ } catch (error) {
87
+ error_text.trace_swallowed('run_prep_cli.lane_has_changes', error)
88
+
86
89
  return false
87
90
  }
88
91
  }
@@ -12,6 +12,12 @@ import { run_progress_config } from './run-progress-config'
12
12
  // only a heartbeat the caller restarts on the next interval, so the two guard different things and
13
13
  // only one has anything to lose by being short.
14
14
  const DEFAULT_MAX_HOURS = 1
15
+ // **`--wait` is bounded by a run, not by three intervals** (joshuafolkken/kit#3102). It no longer exits
16
+ // at its first report — the reports go to the event stream — so its one-hour bound would be the last
17
+ // wake that asks the parent for no judgement, a restart an hour. Eight hours is `run:hold`'s expiry;
18
+ // what ends a finished run's watcher sooner is `josh followup` removing its life record, and a stop
19
+ // tearing it down in the same turn.
20
+ const DEFAULT_WAIT_MAX_HOURS = 8
15
21
  const MS_PER_HOUR = 3_600_000
16
22
 
17
23
  const OPTIONS = {
@@ -37,7 +43,7 @@ interface ParsedValues {
37
43
  }
38
44
 
39
45
  function read_arguments(argv: ReadonlyArray<string>): ParsedValues | undefined {
40
- return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS })?.values
46
+ return cli_flags.values_of(argv, OPTIONS)
41
47
  }
42
48
 
43
49
  // A hand-typed `--interval` outranks the environment, the environment outranks the interval the
@@ -48,14 +54,24 @@ function to_interval_ms(raw: string | undefined): number {
48
54
  return run_progress_config.resolve_interval_ms(raw)
49
55
  }
50
56
 
51
- function to_max_ms(raw: string | undefined): number {
57
+ function default_hours(is_wait: boolean): number {
58
+ return is_wait ? DEFAULT_WAIT_MAX_HOURS : DEFAULT_MAX_HOURS
59
+ }
60
+
61
+ function to_max_ms(raw: string | undefined, is_wait = false): number {
52
62
  const hours = Number(raw)
53
63
  const is_usable = raw !== undefined && Number.isFinite(hours) && hours > 0
54
64
 
55
- return (is_usable ? hours : DEFAULT_MAX_HOURS) * MS_PER_HOUR
65
+ return (is_usable ? hours : default_hours(is_wait)) * MS_PER_HOUR
56
66
  }
57
67
 
58
- const run_progress_args = { DEFAULT_MAX_HOURS, read_arguments, to_interval_ms, to_max_ms }
68
+ const run_progress_args = {
69
+ DEFAULT_MAX_HOURS,
70
+ DEFAULT_WAIT_MAX_HOURS,
71
+ read_arguments,
72
+ to_interval_ms,
73
+ to_max_ms,
74
+ }
59
75
 
60
76
  export type { ParsedValues }
61
77
  export { run_progress_args }
@@ -51,7 +51,7 @@ const TICK_SECONDS = 30
51
51
  const DECLINE_RETRY_SECONDS = 120
52
52
  // The clock a fresh loop starts with: nothing has declined yet, so nothing is being waited out.
53
53
  const NO_RETRY = 0
54
- const { DEFAULT_MAX_HOURS } = run_progress_args
54
+ const { DEFAULT_WAIT_MAX_HOURS } = run_progress_args
55
55
  const ENVIRONMENT_KEY = 'JOSH_PROGRESS'
56
56
  const DISABLED_VALUE = '0'
57
57
 
@@ -74,7 +74,7 @@ const UNREADABLE_NOTICE =
74
74
  'The `in-progress` listing could not be read, so nothing is reported. That is not "nothing is running" — check `gh auth status` and ask again.'
75
75
  const FAILED_TICK_PREFIX =
76
76
  'A progress reading failed, so nothing is reported for it. The watcher is still running, and will read again after the cooldown:'
77
- const WAIT_EXPIRED_NOTICE = `The watch bound (\`--hours\`, ${String(DEFAULT_MAX_HOURS)} by default) ran out before the run had been quiet for a whole interval, so there is nothing to report. Starting another \`--wait\` resumes the same clock.`
77
+ const WAIT_EXPIRED_NOTICE = `The watch bound (\`--hours\`, ${String(DEFAULT_WAIT_MAX_HOURS)} by default for \`--wait\`) ran out with no newly runnable work arriving. Every report it made is already on the run's event stream (\`pnpm josh run:event --watch\`), so there is nothing to relay. Starting another \`--wait\` resumes the same clock.`
78
78
  // The repository name is only ever printed, never written against, so the bounded lookup is the right
79
79
  // one: a `gh` call that hangs would otherwise block the synchronous read at startup and leave the
80
80
  // watcher neither running nor saying so.
@@ -104,6 +104,10 @@ interface WatchOptions {
104
104
  output_paths: ReadonlyArray<string>
105
105
  repo: string
106
106
  tick_ms: number
107
+ // `--wait` alone: the line goes to the run's event stream and the ambient log, never to standard
108
+ // output, because a background command's output reaches the session only as a wake
109
+ // (joshuafolkken/kit#3102).
110
+ is_stream_only?: boolean
107
111
  }
108
112
 
109
113
  interface EmitContext {
@@ -202,7 +206,7 @@ async function emit(options: WatchOptions, context: EmitContext): Promise<EmitRe
202
206
  unchanged_since_ms: state.unchanged_since_ms,
203
207
  })
204
208
 
205
- console.info(line)
209
+ if (options.is_stream_only !== true) console.info(line)
206
210
 
207
211
  return { kind: 'observed', state, line }
208
212
  }
@@ -287,8 +291,8 @@ async function step(options: WatchOptions, target: string, loop: WatchLoop): Pro
287
291
  /**
288
292
  * The loop both reporting forms are, with one difference between them.
289
293
  *
290
- * `watch` runs until `--hours` expires; `--wait` stops at the first line it prints
291
- * (joshuafolkken/kit#1576). Everything else — where the clock is seeded from, the tick, the decline
294
+ * `watch` runs until `--hours` expires; `--wait` stops at the first arrival and keeps its lines off
295
+ * standard output (joshuafolkken/kit#3102). Everything else — where the clock is seeded from, the tick, the decline
292
296
  * cooldown `step` keeps — is one implementation on purpose: two copies would let a fix to the bound or
293
297
  * to the initial silence reach one form and not the other, and `--wait` is the form a run starts.
294
298
  */
@@ -301,17 +305,14 @@ function seed_loop(target: string, started_ms: number): WatchLoop {
301
305
  // Why the loop stopped, so `drive` prints the bound-expired notice only when the bound is what
302
306
  // expired — not when `josh followup` ended the watcher, which is a clean stop with nothing to report.
303
307
  // `arrived` is a `--wait` ended early by newly runnable work (joshuafolkken/kit#2503).
304
- type WatchExit = 'reported' | 'arrived' | 'ended' | 'bound'
308
+ type WatchExit = 'arrived' | 'ended' | 'bound'
305
309
 
306
- // `wait` is present for `--wait` alone, which ends at the first line it prints or at the first arrival
307
- // its probe sees. `state` is set by the one branch of `step` that printed a line and by no other, so
308
- // this asks "was anything reported" without keeping a second copy of the loop's own bookkeeping.
309
- async function wait_exit(
310
- loop: WatchLoop,
311
- wait: ArrivalProbe | undefined,
312
- ): Promise<WatchExit | undefined> {
310
+ // `wait` is present for `--wait` alone, which ends at the first arrival its probe sees. **A report is
311
+ // not an exit** (joshuafolkken/kit#3102): every exit wakes the session that started it, and a wake
312
+ // spent relaying a line the event stream already carries is the most expensive turn a run takes, so
313
+ // only what the parent has to act on ends the wait.
314
+ async function wait_exit(wait: ArrivalProbe | undefined): Promise<WatchExit | undefined> {
313
315
  if (wait === undefined) return undefined
314
- if (loop.state !== undefined) return 'reported'
315
316
 
316
317
  return (await wait.has_arrived(Date.now())) ? 'arrived' : undefined
317
318
  }
@@ -335,8 +336,8 @@ function final_exit(life: string): WatchExit {
335
336
  // The loop both reporting forms share, reading its own liveness record at the top of each pass — the
336
337
  // `run-wake-loop.ts` `run_loop` shape (joshuafolkken/kit#1727). A record gone means `josh followup`
337
338
  // removed it at the merge, so the watcher stops at once instead of waiting out its bound
338
- // (joshuafolkken/kit#1821); `--wait` also stops at the first line it prints, and otherwise the loop
339
- // runs until `--hours` expires.
339
+ // (joshuafolkken/kit#1821); `--wait` also stops at the first arrival, and otherwise the loop runs
340
+ // until `--hours` expires.
340
341
  async function run_ticks(
341
342
  options: WatchOptions,
342
343
  target: string,
@@ -354,7 +355,7 @@ async function run_ticks(
354
355
  loop = await step(options, target, loop)
355
356
 
356
357
  // eslint-disable-next-line no-await-in-loop -- polling: each read waits on the state the previous one saw
357
- const exit = await wait_exit(loop, wait)
358
+ const exit = await wait_exit(wait)
358
359
 
359
360
  if (exit !== undefined) return exit
360
361
  }
@@ -397,14 +398,16 @@ async function once(options: WatchOptions): Promise<number> {
397
398
  }
398
399
 
399
400
  /**
400
- * One interval of silence, one line, and then exit — the form that reports where a long-running
401
- * command cannot (joshuafolkken/kit#1576).
401
+ * The form a run starts in the background: it reports on its own and wakes the caller only for work
402
+ * (joshuafolkken/kit#3102).
402
403
  *
403
- * **The exit is the whole point.** A harness that only delivers a background command's standard
404
- * output *when that command exits* — Claude Code is one — relays nothing at all from `watch`, which
405
- * by design never exits: `epicrun #1474` was measured at 2h36m without a single progress line while
406
- * children were in flight. This form waits the same clock out and then ends, so the line it printed
407
- * is delivered; the caller relays it and starts the next one.
404
+ * **A report is not a wake.** It once ended at its first line so a harness that delivers a background
405
+ * command's output only on exit would relay it (joshuafolkken/kit#1576) — which cost the orchestrating
406
+ * session one turn per heartbeat, re-reading a hundred thousand tokens of context to pass on a line it
407
+ * was forbidden to change. Every line already reaches the run's event stream as a `heartbeat` and the
408
+ * ambient log, which a person's `run:event --watch` pane reads with no model in between, so this form
409
+ * keeps its lines off standard output and exits only on an arrival, on `josh followup` ending its life
410
+ * record, or on its bound. The caller starts it once and restarts it only after one of those.
408
411
  *
409
412
  * **The clock is still this command's, not the caller's.** It is the watch loop itself, a tick at a
410
413
  * time, rather than a second reading of the same record — so a real report elsewhere pushes the next
@@ -419,10 +422,10 @@ async function once(options: WatchOptions): Promise<number> {
419
422
  * carried budget, no lane, and no `in-progress` child — so a genuinely idle repository keeps the loop
420
423
  * waiting instead of ending it; returning there would hand the caller an instant answer to restart,
421
424
  * and the documented restart makes that a poll rather than a heartbeat. Once a run has started the
422
- * loop reports and exits even before the first child carries `in-progress` (joshuafolkken/kit#1900).
425
+ * loop reports even before the first child carries `in-progress` (joshuafolkken/kit#1900).
423
426
  */
424
427
  async function wait_once(options: WatchOptions): Promise<number> {
425
- return await drive(options, true)
428
+ return await drive({ ...options, is_stream_only: true }, true)
426
429
  }
427
430
 
428
431
  async function mark_now(): Promise<number> {
@@ -448,7 +451,7 @@ function to_options(values: ParsedValues): WatchOptions | undefined {
448
451
 
449
452
  return {
450
453
  interval_ms: run_progress_args.to_interval_ms(values.interval),
451
- max_ms: run_progress_args.to_max_ms(values.hours),
454
+ max_ms: run_progress_args.to_max_ms(values.hours, values.wait === true),
452
455
  output_paths: values.output ?? [],
453
456
  repo,
454
457
  tick_ms: TICK_SECONDS * run_progress.MS_PER_SECOND,
@@ -494,7 +497,7 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
494
497
  }
495
498
 
496
499
  const run_progress_cli = {
497
- DEFAULT_MAX_HOURS,
500
+ DEFAULT_WAIT_MAX_HOURS,
498
501
  DISABLED_NOTICE,
499
502
  FAILED_TICK_PREFIX,
500
503
  FRESH_LOOP,
@@ -508,7 +511,6 @@ const run_progress_cli = {
508
511
  run,
509
512
  step,
510
513
  to_interval_ms: run_progress_args.to_interval_ms,
511
- to_max_ms: run_progress_args.to_max_ms,
512
514
  wait_once,
513
515
  }
514
516
 
@@ -1,5 +1,6 @@
1
1
  import { git_common_directory } from '#scripts/git/git-common-directory'
2
2
  import { stamp_file } from '#scripts/josh/stamp-file'
3
+ import { error_text } from '#scripts/lib/error-message'
3
4
  import { z } from 'zod'
4
5
 
5
6
  // The report clock `josh run:progress` keeps, in the one form both of its readers can use
@@ -232,7 +233,9 @@ function is_life_fresh(target: string, threshold_ms: number): boolean {
232
233
  const pinged_ms = parse_pinged_at(raw)
233
234
 
234
235
  return pinged_ms !== undefined && Date.now() - pinged_ms < threshold_ms
235
- } catch {
236
+ } catch (error) {
237
+ error_text.trace_swallowed('run_progress_clock.is_life_fresh', error)
238
+
236
239
  return false
237
240
  }
238
241
  }
@@ -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 { lane_child_marker } from '#scripts/lane/lane-child-marker'
4
+ import { cli_flags } from '#scripts/lib/cli-flags'
5
5
  import { run_event_stream } from './run-event-stream'
6
6
  import { run_event_stream_emit } from './run-event-stream-emit'
7
7
  import { run_ship, type ShipSection } from './run-ship'
@@ -121,13 +121,6 @@ interface ParsedValues extends NotifyValues {
121
121
  cite?: Array<string>
122
122
  }
123
123
 
124
- function parse_values(argv: ReadonlyArray<string>): {
125
- values: ParsedValues
126
- positionals: Array<string>
127
- } {
128
- return parseArgs({ args: [...argv], options: OPTIONS, allowPositionals: true, strict: true })
129
- }
130
-
131
124
  function ship_args(
132
125
  title: string,
133
126
  rest: ReadonlyArray<string>,
@@ -155,9 +148,10 @@ function log_command(number: string, positionals: ReadonlyArray<string>): ShipCo
155
148
  return CITE_PATTERN.test(number) && positionals.length === 0 ? { kind: 'log', number } : undefined
156
149
  }
157
150
 
158
- function read_args(argv: ReadonlyArray<string>): ShipCommand | undefined {
159
- const { values, positionals } = parse_values(argv)
160
-
151
+ function command_of(
152
+ values: ParsedValues,
153
+ positionals: ReadonlyArray<string>,
154
+ ): ShipCommand | undefined {
161
155
  if (values.log !== undefined) return log_command(values.log, positionals)
162
156
 
163
157
  const title = positionals[FIRST]
@@ -170,11 +164,9 @@ function read_args(argv: ReadonlyArray<string>): ShipCommand | undefined {
170
164
  // Numbers-only tail and a strict parse, so a title with no `#<N>` or a stray flag is refused rather
171
165
  // than shipped past the wrong step.
172
166
  function parse(argv: ReadonlyArray<string>): ShipCommand | undefined {
173
- try {
174
- return read_args(argv)
175
- } catch {
176
- return undefined
177
- }
167
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
168
+
169
+ return parsed === undefined ? undefined : command_of(parsed.values, parsed.positionals)
178
170
  }
179
171
 
180
172
  async function run_step(step: Step, args: ShipArguments, state: ShipState): Promise<ShipSection> {
@@ -1,5 +1,6 @@
1
1
  import { git_command } from '#scripts/git/git-command'
2
2
  import { git_worktree } from '#scripts/git/git-worktree'
3
+ import { error_text } from '#scripts/lib/error-message'
3
4
  import { run_hold } from './run-hold'
4
5
  import { run_preflight } from './run-preflight'
5
6
  import { run_ship_stage, type ShipState } from './run-ship-stage'
@@ -20,7 +21,9 @@ const HEAD_REFERENCE = 'HEAD'
20
21
  async function or_false(is_true: () => Promise<boolean>): Promise<boolean> {
21
22
  try {
22
23
  return await is_true()
23
- } catch {
24
+ } catch (error) {
25
+ error_text.trace_swallowed('run_ship_probe.or_false', error)
26
+
24
27
  return false
25
28
  }
26
29
  }
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
4
3
  import { cost_cli, type CostVerdict } from '#scripts/cost-runtime/cost-cli'
5
4
  import { issue_state_cli, type StateRead } from '#scripts/issue/issue-state-cli'
5
+ import { cli_flags } from '#scripts/lib/cli-flags'
6
6
  import { run_carry, type CarryRead } from './run-carry'
7
7
  import { run_status, type StatusParts } from './run-status'
8
8
 
@@ -41,19 +41,6 @@ interface StatusReads {
41
41
  cost: CostVerdict
42
42
  }
43
43
 
44
- interface ParsedArguments {
45
- positionals: ReadonlyArray<string>
46
- values: { repo?: string }
47
- }
48
-
49
- function read_args(argv: ReadonlyArray<string>): ParsedArguments | undefined {
50
- try {
51
- return parseArgs({ args: [...argv], options: OPTIONS, allowPositionals: true, strict: true })
52
- } catch {
53
- return undefined
54
- }
55
- }
56
-
57
44
  // Exactly one issue number, or the call is refused: `run:status` reports one run, and a second number
58
45
  // would print a second issue's state a caller reads as this run's own.
59
46
  function valid_issue(positionals: ReadonlyArray<string>): string | undefined {
@@ -65,7 +52,7 @@ function valid_issue(positionals: ReadonlyArray<string>): string | undefined {
65
52
  }
66
53
 
67
54
  function parse(argv: ReadonlyArray<string>): StatusRequest | undefined {
68
- const parsed = read_args(argv)
55
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
69
56
 
70
57
  if (parsed === undefined) return undefined
71
58
 
@@ -2,6 +2,7 @@ import { git_spawn } from '#scripts/git/git-spawn'
2
2
  import { git_stash } from '#scripts/git/stash/git-stash'
3
3
  import { lane_close } from '#scripts/lane/lane-close'
4
4
  import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
5
+ import { error_text } from '#scripts/lib/error-message'
5
6
  import { run_hold } from './run-hold'
6
7
  import { run_tidy, type Outcome, type Verdict } from './run-tidy'
7
8
 
@@ -31,7 +32,9 @@ async function has_unpushed(directory: string): Promise<boolean> {
31
32
  ])
32
33
 
33
34
  return count !== '0'
34
- } catch {
35
+ } catch (error) {
36
+ error_text.trace_swallowed('run_tidy_lanes.has_unpushed', error)
37
+
35
38
  return true
36
39
  }
37
40
  }
@@ -119,14 +119,6 @@ function note_to_stderr(note: string): void {
119
119
  console.error(`wake: ${note}`)
120
120
  }
121
121
 
122
- function read_arguments(argv: ReadonlyArray<string>): ParsedValues | undefined {
123
- return cli_flags.parse_or_undefined({ args: [...argv], options: OPTIONS, strict: true })?.values
124
- }
125
-
126
- function text_of(value: string | boolean | undefined): string | undefined {
127
- return typeof value === 'string' ? value : undefined
128
- }
129
-
130
122
  function to_interval_ms(value: string | boolean | undefined): number | undefined {
131
123
  if (typeof value !== 'string') return DEFAULT_INTERVAL_SECONDS * MS_PER_SECOND
132
124
  if (!INTERVAL_PATTERN.test(value)) return undefined
@@ -379,14 +371,14 @@ async function dispatch(
379
371
  interval_ms: number,
380
372
  ): Promise<number> {
381
373
  if (values.loop === true) return await loop(context, interval_ms)
382
- if (values.start === true) return start(context, text_of(values.interval))
374
+ if (values.start === true) return start(context, cli_flags.string_of(values.interval))
383
375
  if (values.stop === true) return stop_supervisor(context)
384
376
 
385
377
  return list(context)
386
378
  }
387
379
 
388
380
  async function run(argv: ReadonlyArray<string>): Promise<number> {
389
- const values = read_arguments(argv)
381
+ const values = cli_flags.values_of(argv, OPTIONS)
390
382
 
391
383
  if (values === undefined || group_count(values) !== ONE_GROUP) return usage()
392
384
 
@@ -2,13 +2,15 @@ import { lane_registry } from '#scripts/lane/lane-registry'
2
2
  import { run_headless } from './run-headless'
3
3
  import { run_progress_clock } from './run-progress-clock'
4
4
 
5
- // A watcher that stopped while children are still in-flight leaves the parent blind to completions
6
- // for up to the full heartbeat interval. This guard detects that state so a hook can refuse the
7
- // calls that follow a missed restart (joshuafolkken/kit#2113).
5
+ // A watcher that stopped while children are still in-flight leaves the run's event stream without a
6
+ // heartbeat and the parent without the wake an arrival would deliver. This guard detects that state so
7
+ // a hook can refuse the calls that follow a missed restart (joshuafolkken/kit#2113).
8
8
  //
9
9
  // **Staleness threshold is `TICK_MULTIPLIER` watcher ticks.** The watcher pings its life record on
10
10
  // every tick (every 30 s), so a gap wider than three ticks means the process has almost certainly
11
- // stopped: either the bound expired or the session was cut without a restart. Three gives one missed
11
+ // stopped: it exited on an arrival, its bound expired, or the session was cut without a restart. A
12
+ // report is no longer one of those (joshuafolkken/kit#3102) — `--wait` streams its reports and keeps
13
+ // running — so a restart is owed only after an exit the parent was woken for. Three gives one missed
12
14
  // tick of slack before the guard fires.
13
15
  //
14
16
  // **It watches no relay** (joshuafolkken/kit#2492). A session following the run's event stream after a
@@ -24,7 +26,9 @@ const STALE_THRESHOLD_S = STALE_THRESHOLD_MS / MS_PER_SECOND
24
26
 
25
27
  const STALE_NOTE =
26
28
  `Lane children are in-flight but the progress watcher has not pinged in the last ` +
27
- `${String(STALE_THRESHOLD_S)}s — run \`pnpm josh run:progress --wait\` in the background before proceeding`
29
+ `${String(STALE_THRESHOLD_S)}s — run \`pnpm josh run:progress --wait\` in the background before proceeding. ` +
30
+ 'It reports to the run event stream on its own and exits only when newly runnable work arrives, so ' +
31
+ 'start it once and relay nothing'
28
32
 
29
33
  type GuardResult = { kind: 'ok' } | { kind: 'stale'; note: string }
30
34
 
@@ -0,0 +1,92 @@
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { file_reader } from '#scripts/lib/read-file'
4
+ import { file_content } from '#scripts/sync/file-content'
5
+ import { z } from 'zod'
6
+
7
+ // kit writes the Safe Chain `preinstall` into every project it sets up, and a package that publishes
8
+ // would ship it: the consumer's pnpm then refuses it as an unapproved build script and `pnpm add`
9
+ // exits 1 (joshuafolkken/kit#3110). kit's own `.pnpmfile.mjs` strips it at pack time
10
+ // (joshuafolkken/kit#2693), so that one file is what a publishing project receives — never a copy of
11
+ // its logic.
12
+ //
13
+ // A private package is left alone: it never packs, and a new pnpmfile changes the lockfile's
14
+ // `pnpmfileChecksum`, which fails a frozen install until the lockfile is refreshed.
15
+ const PACK_HOOK_FILE = '.pnpmfile.mjs'
16
+ const MANAGED_LINE = '// josh-managed-pnpmfile: @joshuafolkken/kit'
17
+ const MANIFEST_FILE = 'package.json'
18
+ // pnpm loads `.pnpmfile.mjs` in preference to `.pnpmfile.cjs`, and neither once a `pnpmfile` setting
19
+ // names another file — so writing ours beside either would silently drop the project's hooks, or
20
+ // report a hook pnpm never runs.
21
+ const LEGACY_HOOK_FILE = '.pnpmfile.cjs'
22
+ const CONFIG_FILES: ReadonlyArray<string> = ['pnpm-workspace.yaml', '.npmrc']
23
+ const PNPMFILE_SETTINGS: ReadonlySet<string> = new Set(['pnpmfile', 'pnpmfiles'])
24
+ const SETTING_KEY_END = /[\s:=]/u
25
+ const manifest_schema = z.looseObject({ private: z.boolean().optional() })
26
+
27
+ type PackHookResult = 'written' | 'unchanged' | 'private' | 'owned' | 'withdrawn'
28
+
29
+ function is_published(manifest_text: string): boolean {
30
+ return manifest_schema.parse(JSON.parse(manifest_text)).private !== true
31
+ }
32
+
33
+ function sets_pnpmfile(config_text: string): boolean {
34
+ return config_text
35
+ .split('\n')
36
+ .some((line) => PNPMFILE_SETTINGS.has(line.trimStart().split(SETTING_KEY_END, 1)[0] ?? ''))
37
+ }
38
+
39
+ function has_own_hook_source(project_root: string): boolean {
40
+ if (existsSync(path.join(project_root, LEGACY_HOOK_FILE))) return true
41
+
42
+ return CONFIG_FILES.some((name) =>
43
+ sets_pnpmfile(file_reader.read_file_or_empty(path.join(project_root, name))),
44
+ )
45
+ }
46
+
47
+ function write_hook(destination: string, package_directory: string): PackHookResult {
48
+ const source = readFileSync(path.join(package_directory, PACK_HOOK_FILE), 'utf8')
49
+
50
+ return file_content.write_text_if_changed(destination, source) ? 'written' : 'unchanged'
51
+ }
52
+
53
+ // A copy kit wrote before the project added its own pnpmfile would still shadow that one.
54
+ function withdraw(destination: string, existing: string | undefined): PackHookResult {
55
+ if (existing === undefined) return 'owned'
56
+ rmSync(destination)
57
+
58
+ return 'withdrawn'
59
+ }
60
+
61
+ // A pnpmfile without the managed line is the project's own; overwriting it would drop its hooks.
62
+ function apply(project_root: string, package_directory: string): PackHookResult {
63
+ const destination = path.join(project_root, PACK_HOOK_FILE)
64
+ const existing = file_reader.read_optional(destination)
65
+ if (existing !== undefined && !existing.startsWith(MANAGED_LINE)) return 'owned'
66
+ if (has_own_hook_source(project_root)) return withdraw(destination, existing)
67
+
68
+ return write_hook(destination, package_directory)
69
+ }
70
+
71
+ const MESSAGES: Readonly<Record<PackHookResult, string | undefined>> = {
72
+ written: ` ✔ synced ${PACK_HOOK_FILE} (run \`pnpm install\` to refresh pnpmfileChecksum)`,
73
+ unchanged: ` ✔ unchanged ${PACK_HOOK_FILE}`,
74
+ private: undefined,
75
+ owned: ` ⚠ skipped ${PACK_HOOK_FILE} — the project has its own pnpmfile; strip the safe-chain preinstall at pack time there`,
76
+ withdrawn: ` ⚠ removed ${PACK_HOOK_FILE} — it shadowed the project's own pnpmfile; strip the safe-chain preinstall at pack time there`,
77
+ }
78
+
79
+ function sync_pack_hook(project_root: string, package_directory: string): PackHookResult {
80
+ const manifest_text = file_reader.read_optional(path.join(project_root, MANIFEST_FILE))
81
+ const is_private = manifest_text === undefined || !is_published(manifest_text)
82
+ const result = is_private ? 'private' : apply(project_root, package_directory)
83
+ const message = MESSAGES[result]
84
+ if (message !== undefined) console.info(message)
85
+
86
+ return result
87
+ }
88
+
89
+ const pack_hook = { MANAGED_LINE, sync_pack_hook }
90
+
91
+ export { pack_hook, PACK_HOOK_FILE }
92
+ export type { PackHookResult }
@@ -6,6 +6,7 @@ import { init_logic } from '#scripts/init/init-logic'
6
6
  import { PACKAGE_DIR, PROJECT_ROOT } from '#scripts/init/init-paths'
7
7
  import type { ProjectShape } from '#scripts/init/project-profile'
8
8
  import { file_reader } from '#scripts/lib/read-file'
9
+ import { pack_hook } from '#scripts/safe-chain/pack-hook'
9
10
  import { copy_directory_failure, directory_copy_blocker } from './directory-copy-guard'
10
11
  import { file_content } from './file-content'
11
12
  import { REMOVED_SKILL_MANIFEST } from './removed-skill-manifest'
@@ -189,6 +190,9 @@ function sync_ai_copy_all(is_force: boolean, shape?: ProjectShape): void {
189
190
  }
190
191
 
191
192
  migrate_removed_skills()
193
+ // Both profiles write the Safe Chain `preinstall`, so both get the hook that keeps it out of a
194
+ // published package (joshuafolkken/kit#3110).
195
+ pack_hook.sync_pack_hook(PROJECT_ROOT, PACKAGE_DIR)
192
196
  }
193
197
 
194
198
  const sync_ai_files = {
@@ -1,5 +1,6 @@
1
- import { execFileSync } from 'node:child_process'
2
1
  import { project_checks } from '#scripts/gate/project-checks'
2
+ import { PORCELAIN_FLAG } from '#scripts/git/constants'
3
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
4
  import { test_declared_logic, type Verdict } from './test-declared-logic'
4
5
 
5
6
  // The working-tree change set and the verdict over it, read **synchronously** for the `PreToolUse`
@@ -30,21 +31,17 @@ function path_of(line: string): string {
30
31
  }
31
32
 
32
33
  function read_changed_paths_sync(): Array<string> {
33
- try {
34
- // stderr is discarded: outside a repository git prints `fatal: not a git repository`, which the
35
- // catch below already handles as an empty change set — surfacing it would only be noise.
36
- const output = execFileSync('git', ['status', '--porcelain'], {
37
- encoding: 'utf8',
38
- stdio: ['ignore', 'pipe', 'ignore'],
39
- })
40
-
41
- return output
42
- .split('\n')
43
- .filter((line) => line.length > STATUS_PREFIX_LENGTH)
44
- .map((line) => path_of(line))
45
- } catch {
46
- return []
47
- }
34
+ // Outside a repository git fails with `fatal: not a git repository`, which reads as an empty change
35
+ // set; its stderr is captured rather than surfaced, since it would only be noise.
36
+ // Unbounded (`timeout: 0`) on purpose: a timed-out status would read as an empty change set, and so
37
+ // as `exempt`, letting an untested runtime change through on a slow checkout.
38
+ const output = git_spawn_sync.run(['status', PORCELAIN_FLAG], { timeout: 0 })
39
+ if (output.exit_code !== 0) return []
40
+
41
+ return output.stdout
42
+ .split('\n')
43
+ .filter((line) => line.length > STATUS_PREFIX_LENGTH)
44
+ .map((line) => path_of(line))
48
45
  }
49
46
 
50
47
  // The working-tree read behind `current_verdict`, injectable so the delivery path can be exercised