@joshuafolkken/kit 1.1035.0 → 1.1050.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 (118) hide show
  1. package/.claude/skills/verify-ui/SKILL.md +1 -1
  2. package/.claude/skills/workflow-commands/SKILL.md +2 -2
  3. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  4. package/.claude/skills/workflow-commands/backlogrun-child.md +2 -2
  5. package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
  6. package/.claude/skills/workflow-commands/backlogrun-park.md +6 -0
  7. package/.claude/skills/workflow-commands/backlogrun-progress.md +30 -36
  8. package/.claude/skills/workflow-commands/backlogrun-steps.md +16 -16
  9. package/.claude/skills/workflow-commands/chain-rule.md +2 -2
  10. package/.claude/skills/workflow-commands/followup-reference.md +2 -2
  11. package/.claude/skills/workflow-commands/issue-comments.md +1 -1
  12. package/.claude/skills/workflow-commands/observation-filing.md +41 -86
  13. package/.claude/skills/workflow-commands/pre-gate-cut.md +33 -36
  14. package/.claude/skills/workflow-commands/working-tree-hold.md +2 -2
  15. package/.github/workflows/auto-tag.yml +21 -0
  16. package/.github/workflows/publish.yml +9 -10
  17. package/dist/hooks/batch-guard.js +4 -4
  18. package/dist/hooks/{chunk-HU66PWWL.js → chunk-5BK3K7V4.js} +89 -41
  19. package/dist/hooks/{chunk-GLBDBJOK.js → chunk-7VPDUBRN.js} +49 -151
  20. package/dist/hooks/{chunk-GJT4BNVE.js → chunk-DMQN45FN.js} +3 -3
  21. package/dist/hooks/{chunk-D67KVGGY.js → chunk-EJDX2TOB.js} +13 -3
  22. package/dist/hooks/{chunk-KG6PZD72.js → chunk-GQ7KP45M.js} +2 -2
  23. package/dist/hooks/{chunk-Y27M6A7X.js → chunk-HJIISEAC.js} +4 -4
  24. package/dist/hooks/{chunk-XPLP27WA.js → chunk-NAS4C7GP.js} +4 -26
  25. package/dist/hooks/{chunk-ZXNE6NY3.js → chunk-NJQOBE6L.js} +2 -2
  26. package/dist/hooks/{chunk-EIFCAJRB.js → chunk-RLDX5SOC.js} +1184 -946
  27. package/dist/hooks/{chunk-HOYJU5RM.js → chunk-WQ4OSBSS.js} +10 -3
  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 +15 -34
  31. package/dist/hooks/investigation-guard.js +5 -5
  32. package/dist/hooks/pretool-guard.js +23 -16
  33. package/dist/hooks/stop-guard.js +13 -188
  34. package/dist/josh.js +4 -106
  35. package/dist/version/index.js +381 -9
  36. package/package.json +6 -6
  37. package/prompts/collaboration-workflow/file-edits.md +6 -53
  38. package/prompts/collaboration-workflow/residency.md +1 -1
  39. package/prompts/collaboration-workflow/rule-delivery.md +4 -35
  40. package/prompts/collaboration-workflow/shell-body.md +7 -9
  41. package/prompts/collaboration-workflow/turn-batching.md +21 -37
  42. package/prompts/collaboration-workflow/wip-cap.md +15 -18
  43. package/prompts/sonar-hotspot-handling.md +1 -1
  44. package/scripts/adopt/adopt-toolkits.ts +2 -0
  45. package/scripts/auto-ok/auto-ok-cli.ts +9 -9
  46. package/scripts/backlog/backlog-next.ts +24 -8
  47. package/scripts/backlog/backlog-plan-cli.ts +5 -1
  48. package/scripts/backlog/backlog-pool.ts +31 -11
  49. package/scripts/backlog/backlog-rank.ts +106 -0
  50. package/scripts/backlog/backlog-waves.ts +42 -29
  51. package/scripts/build/build-hooks.ts +7 -2
  52. package/scripts/delegation/delegation-policy.ts +11 -0
  53. package/scripts/document/document-byte-budget.ts +5 -5
  54. package/scripts/document/entry-read-budget.ts +5 -4
  55. package/scripts/epic/epic-lane-offer.ts +11 -22
  56. package/scripts/epic/epic-rank.ts +37 -0
  57. package/scripts/epic/epic-solo.ts +29 -28
  58. package/scripts/git/git-command.ts +1 -1
  59. package/scripts/git/git-execa-error.ts +6 -1
  60. package/scripts/git/git-next-issues.ts +34 -5
  61. package/scripts/git/git-preflight.ts +22 -4
  62. package/scripts/git/git-push-transport.ts +12 -69
  63. package/scripts/git/git-spawn.ts +79 -3
  64. package/scripts/git/git-ssh-keepalive.ts +74 -0
  65. package/scripts/git/gone-branch.ts +3 -2
  66. package/scripts/git/issue-labels.ts +12 -0
  67. package/scripts/git/issue-rank.ts +89 -0
  68. package/scripts/hooks/format-edited-cli.ts +15 -0
  69. package/scripts/hooks/format-edited-file.ts +2 -9
  70. package/scripts/hooks/pretool-guard-cli.ts +21 -0
  71. package/scripts/hooks/pretool-guard.ts +14 -15
  72. package/scripts/init/start-exec.ts +7 -1
  73. package/scripts/init/start-plan.ts +18 -5
  74. package/scripts/init/start-setup-pr.ts +46 -19
  75. package/scripts/init/start-steps.ts +41 -9
  76. package/scripts/init/start.ts +40 -10
  77. package/scripts/issue/issue-cite.ts +19 -1
  78. package/scripts/josh/josh-command-map.ts +0 -102
  79. package/scripts/josh/josh-commands-development.ts +2 -2
  80. package/scripts/josh/josh-commands-project.ts +1 -1
  81. package/scripts/lane/lane-child-marker.ts +16 -2
  82. package/scripts/lane/lane-cli.ts +32 -14
  83. package/scripts/lane/lane-report.ts +10 -4
  84. package/scripts/propagate/propagate-plan.ts +185 -0
  85. package/scripts/propagate/propagate-run.ts +5 -1
  86. package/scripts/propagate/propagate-steps.ts +16 -1
  87. package/scripts/propagate/propagate-targets.ts +13 -2
  88. package/scripts/propagate/propagate.ts +60 -42
  89. package/scripts/registry-migration/migrate-logic.ts +1 -0
  90. package/scripts/release/github-release.ts +1 -1
  91. package/scripts/review/review-attest.ts +44 -0
  92. package/scripts/review/review-brief-cli.ts +49 -2
  93. package/scripts/review/review-brief.ts +46 -1
  94. package/scripts/review/review-stamps.ts +7 -0
  95. package/scripts/rules/implementation-cut.ts +1 -1
  96. package/scripts/rules/issue-fold.ts +1 -2
  97. package/scripts/rules/lane-carry-conflict.ts +1 -2
  98. package/scripts/rules/lane-park.ts +1 -2
  99. package/scripts/rules/pre-gate-cut.ts +2 -2
  100. package/scripts/run/run-headless.ts +22 -6
  101. package/scripts/run/run-parent-cut-hook.ts +144 -0
  102. package/scripts/run/run-progress-read.ts +2 -1
  103. package/scripts/run/run-progress.ts +21 -8
  104. package/scripts/run/run-ship-cli.ts +6 -74
  105. package/scripts/run/run-ship-preflight.ts +87 -0
  106. package/scripts/run/run-ship-review-steps.ts +5 -26
  107. package/scripts/run/run-ship-review.ts +9 -0
  108. package/scripts/run/run-ship-scoped.ts +44 -0
  109. package/scripts/run/run-ship-stage.ts +5 -1
  110. package/scripts/run/run-ship-steps.ts +105 -0
  111. package/scripts/run/run-ship.ts +2 -0
  112. package/scripts/run/run-wake-cli.ts +2 -2
  113. package/scripts/run/run-wake-describe.ts +19 -1
  114. package/scripts/run/run-watcher-hook.ts +1 -1
  115. package/scripts/time-runtime/time-bundle-call.ts +2 -2
  116. package/scripts/version/npm-registry.ts +97 -0
  117. package/scripts/version/version-commands.ts +5 -4
  118. package/scripts/version/version-remote.ts +26 -12
@@ -1,12 +1,14 @@
1
1
  import { git_gh_command } from './git-gh-command'
2
2
  import { has_any_label, NOT_DIRECTLY_RUNNABLE_LABELS } from './issue-labels'
3
+ import { issue_rank, type DependencyRow } from './issue-rank'
3
4
  import { parse_json_array_safe } from './parse-json-array'
4
5
  import { open_issue_schema, type OpenIssueData } from './schemas'
5
6
 
6
7
  // The next-issues display printed when a workflow completes (#821): up to five open issues in
7
8
  // priority order, so the user picks the next run from the completion output instead of opening the
8
9
  // issue list. Priority is recency — a newer issue usually encodes the most current understanding
9
- // of the backlog — with the label-based exclusions below.
10
+ // of the backlog — with the label-based exclusions below, under the ranking keys `issue-rank.ts`
11
+ // defines (joshuafolkken/kit#2928).
10
12
  //
11
13
  // `prioritize` is also the order `epicrun` picks up `auto-ok` issues in (joshuafolkken/kit#906).
12
14
  // The two ask one question — which open issue outside an epic is run next — so they share one
@@ -37,6 +39,34 @@ function compare_newest_first(first: OpenIssueData, second: OpenIssueData): numb
37
39
  return first.createdAt < second.createdAt ? 1 : -1
38
40
  }
39
41
 
42
+ // A row's blockers by number. **Lossy across repositories**: a blocker elsewhere sharing a number
43
+ // with a row here counts as one of its dependents. The listings this ranks are one repository's own,
44
+ // and `backlog:next` re-ranks its offer from the qualified graph (`backlog-rank.ts`), so the cost is a
45
+ // display row placed one slot off rather than a run started early.
46
+ function dependency_row(issue: OpenIssueData): DependencyRow {
47
+ return {
48
+ key: String(issue.number),
49
+ blockers: (issue.blockedBy?.nodes ?? []).map((blocker) => String(blocker.number)),
50
+ }
51
+ }
52
+
53
+ // Every candidate in rank order, uncapped. `backlog:next` takes this rather than `prioritize`, so the
54
+ // `run:solo` gate sees the whole ranking before anything is cut (joshuafolkken/kit#2928).
55
+ function order(
56
+ issues: ReadonlyArray<OpenIssueData>,
57
+ completed_issue_number?: number,
58
+ ): Array<OpenIssueData> {
59
+ const candidates = issues
60
+ .filter((issue) => is_candidate(issue, completed_issue_number))
61
+ .toSorted(compare_newest_first)
62
+ const dependents = issue_rank.count_dependents(candidates.map((issue) => dependency_row(issue)))
63
+
64
+ return issue_rank.rank(candidates, (issue) => ({
65
+ labels: (issue.labels ?? []).map((label) => label.name),
66
+ dependents: dependents.get(String(issue.number)) ?? 0,
67
+ }))
68
+ }
69
+
40
70
  // **This display deliberately does not drop a blocked issue, and the `auto-ok` pickup deliberately
41
71
  // does.** They share this ordering and differ on the set, which reads as an inconsistency and is not
42
72
  // one (joshuafolkken/kit#1005).
@@ -50,10 +80,7 @@ function prioritize(
50
80
  issues: ReadonlyArray<OpenIssueData>,
51
81
  completed_issue_number?: number,
52
82
  ): Array<OpenIssueData> {
53
- return issues
54
- .filter((issue) => is_candidate(issue, completed_issue_number))
55
- .toSorted(compare_newest_first)
56
- .slice(0, DISPLAY_LIMIT)
83
+ return order(issues, completed_issue_number).slice(0, DISPLAY_LIMIT)
57
84
  }
58
85
 
59
86
  // Empty input yields no lines at all — a bare header with nothing under it reads as an error.
@@ -90,6 +117,8 @@ async function fetch_next_issue_lines(completed_issue_number?: number): Promise<
90
117
  }
91
118
 
92
119
  const git_next_issues = {
120
+ DISPLAY_LIMIT,
121
+ order,
93
122
  prioritize,
94
123
  format_lines,
95
124
  fetch_next_issue_lines,
@@ -117,19 +117,37 @@ function refusal(problems: ReadonlyArray<string>): Error {
117
117
  )
118
118
  }
119
119
 
120
- async function check(input: PreflightInput): Promise<void> {
120
+ interface PreflightAnswer {
121
+ pr_target: Target | undefined
122
+ problems: Array<string>
123
+ }
124
+
125
+ async function answer(input: PreflightInput): Promise<PreflightAnswer> {
121
126
  const { target, problems } = await resolve_target(input.cli_input)
122
127
  // The classification and the evidence belong to the pull request, so a `--skip-pr` run asks neither.
123
128
  const pr_target = input.will_open_pr ? target : undefined
124
129
  const pr_problems = pr_target === undefined ? [] : await classification_problems(pr_target)
125
- const all_problems = [...problems, ...pr_problems]
126
130
 
127
- if (all_problems.length > 0) throw refusal(all_problems)
131
+ return { pr_target, problems: [...problems, ...pr_problems] }
132
+ }
133
+
134
+ // joshuafolkken/kit#2946: the unmet preconditions as a list, so `josh ship` can ask them before its
135
+ // review and gate start instead of meeting the refusal at the commit stage.
136
+ async function problems_of(input: PreflightInput): Promise<Array<string>> {
137
+ const { problems } = await answer(input)
138
+
139
+ return problems
140
+ }
141
+
142
+ async function check(input: PreflightInput): Promise<void> {
143
+ const { pr_target, problems } = await answer(input)
144
+
145
+ if (problems.length > 0) throw refusal(problems)
128
146
 
129
147
  if (pr_target !== undefined) await announce_evidence(pr_target.branch_name)
130
148
  }
131
149
 
132
- const git_preflight = { check }
150
+ const git_preflight = { check, problems_of }
133
151
 
134
152
  export type { PreflightInput }
135
153
  export { git_preflight }
@@ -1,6 +1,12 @@
1
1
  import { execa } from 'execa'
2
2
  import { git_utilities } from './constants'
3
- import { create_spawn_error, get_exit_code, has_timed_out } from './git-execa-error'
3
+ import {
4
+ create_spawn_error,
5
+ get_exit_code,
6
+ has_timed_out,
7
+ TIMEOUT_EXIT_CODE,
8
+ } from './git-execa-error'
9
+ import { git_ssh_keepalive } from './git-ssh-keepalive'
4
10
 
5
11
  const MS_PER_SECOND = 1000
6
12
 
@@ -33,65 +39,10 @@ const PUSH_TIMEOUT_LABEL = `${String(PUSH_TIMEOUT_SECONDS)}s`
33
39
  // said no" are different diagnoses, and only the first one is worth retrying.
34
40
  const PUSH_TIMEOUT_MESSAGE = 'git push timed out'
35
41
 
36
- // A timeout has no exit code — the process was killed rather than allowed to exit — so `cause` says
37
- // so in the slot every other git failure puts a number in. `is_upstream_not_set_error` compares
38
- // against '128', which this can never equal, so a timed-out bare push is never mistaken for a
39
- // missing upstream and retried as `--set-upstream`.
40
- const PUSH_TIMEOUT_EXIT_CODE = 'timeout'
41
-
42
- // SSH's own liveness probe: three unanswered checks 15 seconds apart, so a connection that dies
43
- // mid-transfer is noticed in about 45 seconds instead of waiting on the TCP default. The budget
44
- // above bounds the damage either way; this is what makes the common case fail in seconds rather
45
- // than sit out the whole budget.
46
- const SSH_KEEPALIVE_OPTIONS = '-o ServerAliveInterval=15 -o ServerAliveCountMax=3'
47
- const SSH_COMMAND_VARIABLE = 'GIT_SSH_COMMAND'
48
- const SSH_LEGACY_VARIABLE = 'GIT_SSH'
49
- const KEEPALIVE_SSH_COMMAND = `ssh ${SSH_KEEPALIVE_OPTIONS}`
50
- const SSH_COMMAND_CONFIG_KEY = 'core.sshCommand'
42
+ // `TIMEOUT_EXIT_CODE` is never '128', which `is_upstream_not_set_error` compares against, so a
43
+ // timed-out bare push is never mistaken for a missing upstream and retried as `--set-upstream`.
51
44
  const PUSH_SUBCOMMAND = 'push'
52
45
 
53
- // Whatever already decides how ssh is invoked, or an empty string when nothing does. git reads three
54
- // sources in this order — `GIT_SSH_COMMAND`, `core.sshCommand`, then the legacy `GIT_SSH` — and the
55
- // last one has to be asked as well precisely because it loses: setting `GIT_SSH_COMMAND` on top of a
56
- // `GIT_SSH=plink.exe` outranks it, so the push would run an ssh binary the user did not choose.
57
- function to_environment_value(name: string): string {
58
- return process.env[name]?.trim() ?? ''
59
- }
60
-
61
- function to_configured_ssh_command(): string {
62
- const from_command = to_environment_value(SSH_COMMAND_VARIABLE)
63
-
64
- return from_command === '' ? to_environment_value(SSH_LEGACY_VARIABLE) : from_command
65
- }
66
-
67
- // Whether any of those three sources answered. **A hit means hands off entirely.**
68
- //
69
- // Appending the options to a command someone else chose is not safe, because they are OpenSSH's:
70
- // `plink` and `TortoiseGitPlink` are supported ssh commands on the platform `get_git_command_for_spawn`
71
- // goes out of its way to handle, and a wrapper script with a fixed argument list is common on any
72
- // platform — each of them exits on a usage error rather than pushing. Setting the variable while
73
- // `core.sshCommand` is configured is the same mistake from the other side: the environment wins, so
74
- // a per-repository key would be silently replaced by a plain `ssh`.
75
- //
76
- // What those users lose is the keepalive, not the fix: the budget above still bounds their push, and
77
- // `ServerAliveInterval` belongs in their own ssh config where it applies to every tool they run.
78
- async function has_configured_ssh_command(): Promise<boolean> {
79
- if (to_configured_ssh_command() !== '') return true
80
-
81
- try {
82
- const git_command_bin = git_utilities.get_git_command_for_spawn()
83
- // execa runs the binary directly with an argument array and no `shell` option, so CLI
84
- // args cannot break out of a shell sandbox; the git command and args are internally
85
- // controlled, never untrusted input. tssecurity:S8705 is a false positive here.
86
- const { stdout } = await execa(git_command_bin, ['config', '--get', SSH_COMMAND_CONFIG_KEY]) // NOSONAR
87
-
88
- return stdout.trim() !== ''
89
- } catch {
90
- // `git config --get` exits 1 when the key is unset, which is the answer rather than a failure.
91
- return false
92
- }
93
- }
94
-
95
46
  interface PushOptions {
96
47
  stdio: 'inherit'
97
48
  timeout: number
@@ -99,12 +50,10 @@ interface PushOptions {
99
50
  }
100
51
 
101
52
  async function to_push_options(): Promise<PushOptions> {
102
- const should_keep_alive = !(await has_configured_ssh_command())
103
-
104
53
  return {
105
54
  stdio: 'inherit',
106
55
  timeout: PUSH_TIMEOUT_MS,
107
- env: should_keep_alive ? { [SSH_COMMAND_VARIABLE]: KEEPALIVE_SSH_COMMAND } : {},
56
+ env: await git_ssh_keepalive.to_environment(),
108
57
  }
109
58
  }
110
59
 
@@ -142,7 +91,7 @@ function to_retry_notice(arguments_list: Array<string>): string {
142
91
  function create_timeout_error(arguments_list: Array<string>): Error {
143
92
  const message = `${PUSH_TIMEOUT_MESSAGE} twice (${PUSH_TIMEOUT_LABEL} each). Re-run it by hand: ${to_manual_command(arguments_list)}`
144
93
 
145
- return new Error(message, { cause: { exit_code: PUSH_TIMEOUT_EXIT_CODE } })
94
+ return new Error(message, { cause: { exit_code: TIMEOUT_EXIT_CODE } })
146
95
  }
147
96
 
148
97
  // One push, bounded and retried once — and **only** on a timeout. A push the remote rejected (a
@@ -164,10 +113,4 @@ const git_push_transport = {
164
113
  }
165
114
 
166
115
  export { git_push_transport }
167
- export {
168
- KEEPALIVE_SSH_COMMAND,
169
- PUSH_TIMEOUT_MESSAGE,
170
- PUSH_TIMEOUT_MS,
171
- SSH_COMMAND_VARIABLE,
172
- SSH_LEGACY_VARIABLE,
173
- }
116
+ export { PUSH_TIMEOUT_MESSAGE, PUSH_TIMEOUT_MS }
@@ -1,6 +1,13 @@
1
1
  import { execa } from 'execa'
2
2
  import { git_utilities } from './constants'
3
- import { create_spawn_error, get_exit_code } from './git-execa-error'
3
+ import {
4
+ create_spawn_error,
5
+ get_exit_code,
6
+ has_timed_out,
7
+ TIMEOUT_EXIT_CODE,
8
+ } from './git-execa-error'
9
+ import { PUSH_TIMEOUT_MS } from './git-push-transport'
10
+ import { git_ssh_keepalive } from './git-ssh-keepalive'
4
11
 
5
12
  // The one place the ordinary git commands are spawned from (joshuafolkken/kit#1640). It sits in a
6
13
  // module of its own rather than inside `git-command.ts` so that a second command module —
@@ -9,8 +16,8 @@ import { create_spawn_error, get_exit_code } from './git-execa-error'
9
16
  // `CLAUDE.md` prohibits, so a new caller imports this one instead of growing its own.
10
17
  //
11
18
  // **`git-push-transport.ts` is the one deliberate exception among the command modules** and is not a
12
- // second helper of this kind: a push needs a timeout and a transport-fault retry that no other
13
- // command has, so it spawns git itself. Those two files are the spawn sites of the ordinary command
19
+ // second helper of this kind: a push needs a transport-fault retry that no other command has, so it
20
+ // spawns git itself. The timeout it carries is shared here by `read_remote` / `with_output_remote`. Those two files are the spawn sites of the ordinary command
14
21
  // modules — the claim stops there. Other parts of this package spawn git for their own purposes
15
22
  // (`scripts/run/run-progress-clock.ts`, `scripts/propagate/propagate-git.ts`,
16
23
  // `scripts/doctor/doctor-io.ts`, `scripts/git/git-fixture-workspace.ts`), and an audit of how the git
@@ -26,6 +33,73 @@ async function read(arguments_: Array<string>): Promise<string> {
26
33
  return stdout.trimEnd()
27
34
  }
28
35
 
36
+ // The budget for a git call that talks to the remote — `fetch` and `pull` (joshuafolkken/kit#2942).
37
+ // `read` and `with_output` wait without end, and a `git fetch --prune` under `josh main:sync` sat on
38
+ // a dead ssh connection for 37 minutes, stalling the unattended backlog driver whose merge step runs
39
+ // it. The push's budget is reused rather than a second number chosen: both transfer objects over the
40
+ // same transport, and that one is already read off what a healthy transfer costs here.
41
+ const REMOTE_TIMEOUT_MS = PUSH_TIMEOUT_MS
42
+ const MS_PER_SECOND = 1000
43
+
44
+ // A budget kill is reported as a timeout, never as whatever the killed process printed first, and
45
+ // `cause` carries the same `TIMEOUT_EXIT_CODE` a timed-out push does. Unlike the push it is not
46
+ // retried here: what the issue needed is that the wait ends, and a failure then surfaces to the
47
+ // caller — `run:merge` raises a failed `main:sync` — instead of a second budget doubling the wait.
48
+ function to_remote_error(command: string, error: unknown, timeout_ms: number): Error {
49
+ if (!has_timed_out(error)) return create_spawn_error(command, get_exit_code(error))
50
+
51
+ const seconds = String(Math.round(timeout_ms / MS_PER_SECOND))
52
+
53
+ return new Error(`git ${command} timed out after ${seconds}s`, {
54
+ cause: { exit_code: TIMEOUT_EXIT_CODE },
55
+ })
56
+ }
57
+
58
+ interface RemoteOptions {
59
+ stdio?: 'inherit'
60
+ timeout: number
61
+ env: Record<string, string>
62
+ }
63
+
64
+ // One remote call, bounded and kept alive. execa kills git on the budget; the ssh keepalive is what
65
+ // makes ssh — which git does not kill with itself — exit on a dead connection as well.
66
+ async function spawn_remote(arguments_: Array<string>, options: RemoteOptions): Promise<string> {
67
+ const git_cmd = git_utilities.get_git_command_for_spawn()
68
+
69
+ try {
70
+ // execa runs the binary directly with an argument array and no `shell` option, so CLI
71
+ // args cannot break out of a shell sandbox; the git command and args are internally
72
+ // controlled, never untrusted input. tssecurity:S8705 is a false positive here.
73
+ const { stdout } = await execa(git_cmd, arguments_, options) // NOSONAR
74
+
75
+ return typeof stdout === 'string' ? stdout.trimEnd() : ''
76
+ } catch (error) {
77
+ throw to_remote_error(arguments_[0] ?? '', error, options.timeout)
78
+ }
79
+ }
80
+
81
+ // `read` for a call that reaches the remote. The budget is a parameter only so a test can hold it to
82
+ // seconds; every production caller takes the default.
83
+ async function read_remote(
84
+ arguments_: Array<string>,
85
+ timeout_ms: number = REMOTE_TIMEOUT_MS,
86
+ ): Promise<string> {
87
+ return await spawn_remote(arguments_, {
88
+ timeout: timeout_ms,
89
+ env: await git_ssh_keepalive.to_environment(),
90
+ })
91
+ }
92
+
93
+ // `with_output` for a call that reaches the remote — the output stays on the terminal, as a pull's
94
+ // progress and a credential prompt need.
95
+ async function with_output_remote(command: string, arguments_list: Array<string>): Promise<void> {
96
+ await spawn_remote([command, ...arguments_list], {
97
+ stdio: 'inherit',
98
+ timeout: REMOTE_TIMEOUT_MS,
99
+ env: await git_ssh_keepalive.to_environment(),
100
+ })
101
+ }
102
+
29
103
  async function with_output(command: string, arguments_list: Array<string>): Promise<void> {
30
104
  const git_command_bin = git_utilities.get_git_command_for_spawn()
31
105
 
@@ -41,7 +115,9 @@ async function with_output(command: string, arguments_list: Array<string>): Prom
41
115
 
42
116
  const git_spawn = {
43
117
  read,
118
+ read_remote,
44
119
  with_output,
120
+ with_output_remote,
45
121
  }
46
122
 
47
123
  export { git_spawn }
@@ -0,0 +1,74 @@
1
+ import { execa } from 'execa'
2
+ import { git_utilities } from './constants'
3
+
4
+ // The ssh environment every git network call runs under (joshuafolkken/kit#2942). It began inside
5
+ // `git-push-transport.ts` as the push's own (joshuafolkken/kit#1251), and moved here when the fetch
6
+ // and pull of `josh main:sync` turned out to need the same thing: a dead connection under a `git
7
+ // fetch` was waited on for 37 minutes, stalling the unattended backlog driver behind it.
8
+ //
9
+ // SSH's own liveness probe: three unanswered checks 15 seconds apart, so a connection that dies
10
+ // mid-transfer is noticed in about 45 seconds instead of waiting on the TCP default. The caller's
11
+ // timeout bounds the damage either way; this is what makes the common case fail in seconds rather
12
+ // than sit out the whole budget — and, because ssh exits on its own, it is also what leaves no ssh
13
+ // behind: git does not kill its transport child when it is itself killed on a timeout.
14
+ const SSH_KEEPALIVE_OPTIONS = '-o ServerAliveInterval=15 -o ServerAliveCountMax=3'
15
+ const SSH_COMMAND_VARIABLE = 'GIT_SSH_COMMAND'
16
+ const SSH_LEGACY_VARIABLE = 'GIT_SSH'
17
+ const KEEPALIVE_SSH_COMMAND = `ssh ${SSH_KEEPALIVE_OPTIONS}`
18
+ const SSH_COMMAND_CONFIG_KEY = 'core.sshCommand'
19
+
20
+ // Whatever already decides how ssh is invoked, or an empty string when nothing does. git reads three
21
+ // sources in this order — `GIT_SSH_COMMAND`, `core.sshCommand`, then the legacy `GIT_SSH` — and the
22
+ // last one has to be asked as well precisely because it loses: setting `GIT_SSH_COMMAND` on top of a
23
+ // `GIT_SSH=plink.exe` outranks it, so git would run an ssh binary the user did not choose.
24
+ function to_environment_value(name: string): string {
25
+ return process.env[name]?.trim() ?? ''
26
+ }
27
+
28
+ function to_configured_ssh_command(): string {
29
+ const from_command = to_environment_value(SSH_COMMAND_VARIABLE)
30
+
31
+ return from_command === '' ? to_environment_value(SSH_LEGACY_VARIABLE) : from_command
32
+ }
33
+
34
+ // Whether any of those three sources answered. **A hit means hands off entirely.**
35
+ //
36
+ // Appending the options to a command someone else chose is not safe, because they are OpenSSH's:
37
+ // `plink` and `TortoiseGitPlink` are supported ssh commands on the platform `get_git_command_for_spawn`
38
+ // goes out of its way to handle, and a wrapper script with a fixed argument list is common on any
39
+ // platform — each of them exits on a usage error rather than connecting. Setting the variable while
40
+ // `core.sshCommand` is configured is the same mistake from the other side: the environment wins, so
41
+ // a per-repository key would be silently replaced by a plain `ssh`.
42
+ //
43
+ // What those users lose is the keepalive, not the fix: the caller's timeout still bounds the call,
44
+ // and `ServerAliveInterval` belongs in their own ssh config where it applies to every tool they run.
45
+ async function has_configured_ssh_command(): Promise<boolean> {
46
+ if (to_configured_ssh_command() !== '') return true
47
+
48
+ try {
49
+ const git_command_bin = git_utilities.get_git_command_for_spawn()
50
+ // execa runs the binary directly with an argument array and no `shell` option, so CLI
51
+ // args cannot break out of a shell sandbox; the git command and args are internally
52
+ // controlled, never untrusted input. tssecurity:S8705 is a false positive here.
53
+ const { stdout } = await execa(git_command_bin, ['config', '--get', SSH_COMMAND_CONFIG_KEY]) // NOSONAR
54
+
55
+ return stdout.trim() !== ''
56
+ } catch {
57
+ // `git config --get` exits 1 when the key is unset, which is the answer rather than a failure.
58
+ return false
59
+ }
60
+ }
61
+
62
+ // The `env` a git network spawn passes to execa: the keepalive command, or nothing at all.
63
+ async function to_environment(): Promise<Record<string, string>> {
64
+ const should_keep_alive = !(await has_configured_ssh_command())
65
+
66
+ return should_keep_alive ? { [SSH_COMMAND_VARIABLE]: KEEPALIVE_SSH_COMMAND } : {}
67
+ }
68
+
69
+ const git_ssh_keepalive = {
70
+ to_environment,
71
+ }
72
+
73
+ export { git_ssh_keepalive }
74
+ export { KEEPALIVE_SSH_COMMAND, SSH_COMMAND_VARIABLE, SSH_LEGACY_VARIABLE }
@@ -100,9 +100,10 @@ async function delete_each(names: ReadonlyArray<string>): Promise<PruneResult> {
100
100
  }
101
101
 
102
102
  // `fetch --prune` first, because `[gone]` is read from the remote-tracking refs and those outlive the
103
- // remote branch until something prunes them.
103
+ // remote branch until something prunes them. The fetch is the one call here that reaches the remote,
104
+ // so it is the one bounded (joshuafolkken/kit#2942).
104
105
  async function prune(default_branch: string): Promise<PruneResult> {
105
- await git_spawn.read(['fetch', '--prune'])
106
+ await git_spawn.read_remote(['fetch', '--prune'])
106
107
 
107
108
  return await delete_each(await read_candidates(default_branch))
108
109
  }
@@ -19,6 +19,11 @@ const RUN_SOLO_LABEL = 'run:solo'
19
19
  // (joshuafolkken/kit#2779). Without it an issue lacking `run:solo` could mean "judged parallel" or
20
20
  // "never judged", and `backlog:next` withholds the second (`epic-triage.ts`).
21
21
  const RUN_LANE_LABEL = 'run:lane'
22
+ // Puts an issue at the head of the backlog's ranking (joshuafolkken/kit#2928). A person or a run may
23
+ // apply it — a run only where it can cite a deadline or a stated urgency, or a policy a person wrote
24
+ // (`backlogrun-steps.md`) — and **only a person removes it**: a run that could clear it could undo
25
+ // the person's ordering. Read by `issue-rank.ts`, the one place the ranking keys are decided.
26
+ const PRIORITY_HIGH_LABEL = 'priority:high'
22
27
  // Opts one issue outside any epic into unattended execution (joshuafolkken/kit#906). **Only a
23
28
  // person applies it.** `epicrun #<E>` approves the merges inside `#<E>`; this label is the only way
24
29
  // a person extends that approval past the epic's edge, so a label an AI could apply to itself would
@@ -212,6 +217,11 @@ const WORKFLOW_LABELS: ReadonlyArray<LabelDefinition> = [
212
217
  color: '0e8a16',
213
218
  description: 'Judged safe to run beside others in a backlogrun',
214
219
  },
220
+ {
221
+ name: PRIORITY_HIGH_LABEL,
222
+ color: 'e99695',
223
+ description: 'Offered first in a backlogrun; only a person removes it',
224
+ },
215
225
  ...FILING_ROUTE_LABELS,
216
226
  ...DEPTH_LABELS,
217
227
  ]
@@ -322,6 +332,7 @@ const ALL_LABELS: ReadonlySet<string> = new Set([
322
332
  AUTO_OK_LABEL,
323
333
  NEEDS_HUMAN_REVIEW_LABEL,
324
334
  ALREADY_DONE_LABEL,
335
+ PRIORITY_HIGH_LABEL,
325
336
  INTERRUPT_ROUTE_LABEL,
326
337
  REVIEW_CAP_ROUTE_LABEL,
327
338
  REVIEW_ROUND2_SKIPPED_LABEL,
@@ -364,6 +375,7 @@ export {
364
375
  NEEDS_HUMAN_REVIEW_LABEL,
365
376
  NOT_DIRECTLY_RUNNABLE_LABELS,
366
377
  OTHER_CHANGE_LABEL,
378
+ PRIORITY_HIGH_LABEL,
367
379
  RELEASE_CLASSIFICATION_LABELS,
368
380
  RELEASE_CLASSIFICATION_NAMES,
369
381
  REPOSITORY_LABELS,
@@ -0,0 +1,89 @@
1
+ import {
2
+ BUG_LABEL,
3
+ has_label_name,
4
+ INTERRUPT_ROUTE_LABEL,
5
+ PRIORITY_HIGH_LABEL,
6
+ RUN_SOLO_LABEL,
7
+ } from './issue-labels'
8
+
9
+ // The keys that decide which open issue is offered first (joshuafolkken/kit#2928), defined once for
10
+ // every ranking that asks: the `🗒 Next issues` display, the `auto-ok` pickup and `backlog:next`.
11
+ //
12
+ // In order: `priority:high`, then a defect on the verification path (`bug` with `run:solo`, or
13
+ // `route:interrupt`), then how many other open issues wait on it. Below those, the caller's own order
14
+ // stands — the sort is stable, so recency and the defect-rate adjustment survive as the last key.
15
+ //
16
+ // **`run:solo` alone is not a key.** It means "runs alone", not "runs first": tidying that only touches
17
+ // the verification path carries it too, and a low-priority one is not owed a place ahead of the queue.
18
+ // Only a defect on that path is, because `wip-cap.md` holds a batch until it has merged.
19
+
20
+ interface RankRow {
21
+ labels: ReadonlyArray<string>
22
+ dependents: number
23
+ }
24
+
25
+ // One open issue as its key and the keys of the issues it is blocked by. The caller names the key
26
+ // format, so a ranking over listing rows and one over graph children each count in their own terms.
27
+ interface DependencyRow {
28
+ key: string
29
+ blockers: ReadonlyArray<string>
30
+ }
31
+
32
+ const NO_DEPENDENTS = 0
33
+
34
+ function flag(is_set: boolean): number {
35
+ return is_set ? 1 : 0
36
+ }
37
+
38
+ function is_priority(labels: ReadonlyArray<string>): boolean {
39
+ return has_label_name(labels, PRIORITY_HIGH_LABEL)
40
+ }
41
+
42
+ function is_verification_defect(labels: ReadonlyArray<string>): boolean {
43
+ if (has_label_name(labels, INTERRUPT_ROUTE_LABEL)) return true
44
+
45
+ return has_label_name(labels, BUG_LABEL) && has_label_name(labels, RUN_SOLO_LABEL)
46
+ }
47
+
48
+ // Negated so that the larger value sorts first under an ascending comparison.
49
+ function keys_of(row: RankRow): ReadonlyArray<number> {
50
+ return [
51
+ -flag(is_priority(row.labels)),
52
+ -flag(is_verification_defect(row.labels)),
53
+ -row.dependents,
54
+ ]
55
+ }
56
+
57
+ function compare(first: RankRow, second: RankRow): number {
58
+ const second_keys = keys_of(second)
59
+ const differences = keys_of(first).map((key, index) => key - (second_keys[index] ?? 0))
60
+
61
+ return differences.find((difference) => difference !== 0) ?? 0
62
+ }
63
+
64
+ // How many of the rows wait on each key. A blocker outside the rows counts nothing, because only an
65
+ // open issue in the same pool can be held back by it.
66
+ function count_dependents(rows: ReadonlyArray<DependencyRow>): ReadonlyMap<string, number> {
67
+ const counts = new Map<string, number>()
68
+
69
+ for (const row of rows) {
70
+ const blockers = new Set(row.blockers)
71
+
72
+ for (const blocker of blockers) counts.set(blocker, (counts.get(blocker) ?? NO_DEPENDENTS) + 1)
73
+ }
74
+
75
+ return counts
76
+ }
77
+
78
+ // The items in rank order, ties kept in the order they arrived.
79
+ function rank<T>(items: ReadonlyArray<T>, row_of: (item: T) => RankRow): Array<T> {
80
+ return items
81
+ .map((item) => ({ item, row: row_of(item) }))
82
+ .toSorted((first, second) => compare(first.row, second.row))
83
+ .map((entry) => entry.item)
84
+ }
85
+
86
+ const issue_rank = { compare, count_dependents, is_priority, is_verification_defect, rank }
87
+
88
+ export { issue_rank }
89
+ export type { DependencyRow, RankRow }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env tsx
2
+ import { text } from 'node:stream/consumers'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { report_no_payload, run_hook } from './format-edited-file'
5
+
6
+ // The `PostToolUse` hook's entry, kept apart from `format-edited-file.ts` for the reason
7
+ // `pretool-guard-cli.ts` gives (joshuafolkken/kit#2922): `codex-hook-adapter.ts` imports that module,
8
+ // so a self-invoke left in it lands in a shared chunk and the bundled hook never runs.
9
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
10
+ if (process.stdin.isTTY) {
11
+ report_no_payload()
12
+ } else {
13
+ await run_hook(await text(process.stdin))
14
+ }
15
+ }
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { existsSync } from 'node:fs'
3
3
  import path from 'node:path'
4
- import { text } from 'node:stream/consumers'
5
4
  import { fileURLToPath } from 'node:url'
6
5
  import { resolve_local_bin, resolve_package_bin } from '#scripts/build/local-bin'
7
6
  import { ESLINT_EDIT_CACHE_FLAGS } from '#scripts/josh/josh-command-types'
@@ -482,14 +481,6 @@ async function run_hook(payload: string): Promise<void> {
482
481
  if (context !== undefined) process.stdout.write(`${build_envelope(context)}\n`)
483
482
  }
484
483
 
485
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
486
- if (process.stdin.isTTY) {
487
- report_no_payload()
488
- } else {
489
- await run_hook(await text(process.stdin))
490
- }
491
- }
492
-
493
484
  export {
494
485
  build_envelope,
495
486
  compose_context,
@@ -501,7 +492,9 @@ export {
501
492
  plan_commands,
502
493
  plan_daemon_restart,
503
494
  relative_to_root,
495
+ report_no_payload,
504
496
  resolve_invocations,
497
+ run_hook,
505
498
  select_invocation,
506
499
  ESLINT_DAEMON,
507
500
  MAX_DIAGNOSTIC_CHARS,
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env tsx
2
+ import { text } from 'node:stream/consumers'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { hook_decision } from '#scripts/josh/hook-decision'
5
+ import { pretool_outcome_async } from './pretool-guard'
6
+
7
+ // The `PreToolUse` hook's entry, kept apart from the composition in `pretool-guard.ts`
8
+ // (joshuafolkken/kit#2922). The hook bundles are built with `splitting: true`, which moves a module
9
+ // imported by two entries into a shared chunk — and `codex-hook-adapter.ts` imports `pretool-guard.ts`.
10
+ // Its self-invoke went into that chunk, whose `import.meta.url` never equals the launched
11
+ // `dist/hooks/pretool-guard.js`, so the bundled hook ran no guard at all and every refusal failed open.
12
+ // An entry nothing imports keeps the self-invoke in the file that is actually launched.
13
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
14
+ if (process.stdin.isTTY) hook_decision.report_no_payload('pretool:guard')
15
+ else {
16
+ const raw_payload = await text(process.stdin)
17
+
18
+ hook_decision.load_environment_file()
19
+ hook_decision.emit_outcome(await pretool_outcome_async(raw_payload))
20
+ }
21
+ }