@joshuafolkken/kit 1.972.0 → 1.983.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 (87) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +6 -5
  2. package/.claude/skills/workflow-commands/SKILL.md +9 -9
  3. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  4. package/.claude/skills/workflow-commands/fullrun-steps.md +8 -8
  5. package/.claude/skills/workflow-commands/fullrun.md +1 -0
  6. package/.claude/skills/workflow-commands/halfrun.md +16 -12
  7. package/.claude/skills/workflow-commands/issue-comments.md +1 -1
  8. package/.claude/skills/workflow-commands/issue-scout.md +21 -22
  9. package/.claude/skills/workflow-commands/kickoff.md +8 -9
  10. package/.claude/skills/workflow-commands/observation-filing.md +9 -10
  11. package/.claude/skills/workflow-commands/prerequisite.md +7 -6
  12. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  13. package/.claude/skills/workflow-commands/rule-residency.md +2 -2
  14. package/.claude/skills/workflow-commands/split-assessment.md +4 -3
  15. package/.claude/skills/workflow-commands/target-repository.md +7 -6
  16. package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
  17. package/README.md +24 -20
  18. package/dist/hooks/batch-guard.js +4 -4
  19. package/dist/hooks/{chunk-ZPLXLJ7O.js → chunk-47SPMGOD.js} +16 -1
  20. package/dist/hooks/{chunk-BVMSPG3M.js → chunk-5JX6HXDX.js} +3 -3
  21. package/dist/hooks/{chunk-KQG4776P.js → chunk-APVOACVT.js} +1 -1
  22. package/dist/hooks/{chunk-2K52BRWV.js → chunk-ASUZGZFT.js} +2 -2
  23. package/dist/hooks/{chunk-CRGJTD6E.js → chunk-ERXMJJOV.js} +1 -1
  24. package/dist/hooks/{chunk-WWTMHHZU.js → chunk-IM3KM25Y.js} +61 -30
  25. package/dist/hooks/{chunk-BNWIOJFU.js → chunk-LBXQHXMC.js} +138 -358
  26. package/dist/hooks/{chunk-NXL3SBXY.js → chunk-P3UX7SFN.js} +2 -2
  27. package/dist/hooks/{chunk-F6OM6SIG.js → chunk-RFPHKU7C.js} +12 -12
  28. package/dist/hooks/{chunk-ZYYSBNR6.js → chunk-ZT65LR3U.js} +4 -4
  29. package/dist/hooks/codex-hook-adapter.js +10 -10
  30. package/dist/hooks/duplicate-read-guard.js +5 -5
  31. package/dist/hooks/format-edited.js +2 -2
  32. package/dist/hooks/investigation-guard.js +5 -5
  33. package/dist/hooks/pretool-guard.js +9 -9
  34. package/dist/hooks/stop-guard.js +4 -4
  35. package/dist/josh.js +16 -1
  36. package/package.json +2 -2
  37. package/prompts/collaboration-workflow/epic-bundle.md +8 -8
  38. package/prompts/collaboration-workflow/issue-template.md +5 -5
  39. package/prompts/collaboration-workflow/rule-delivery.md +43 -30
  40. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  41. package/prompts/collaboration-workflow/wip-cap.md +3 -3
  42. package/prompts/review.md +11 -9
  43. package/scripts/ci/pr-classification.ts +13 -12
  44. package/scripts/delegation/delegation-policy.ts +9 -9
  45. package/scripts/document/document-byte-budget.ts +1 -1
  46. package/scripts/git/issue-labels.ts +52 -5
  47. package/scripts/init/init-bootstrap.ts +92 -0
  48. package/scripts/init/init-install.ts +4 -1
  49. package/scripts/init/init-logic-workspace.ts +1 -1
  50. package/scripts/init/init.ts +23 -5
  51. package/scripts/init/start-plan.ts +1 -1
  52. package/scripts/init/start-steps.ts +5 -28
  53. package/scripts/issue/issue-file-cli.ts +188 -0
  54. package/scripts/issue/issue-file.ts +192 -0
  55. package/scripts/issue/issue-lint-cli.ts +1 -1
  56. package/scripts/issue/issue-scout-cli.ts +41 -97
  57. package/scripts/issue/issue-scout-closed.ts +102 -0
  58. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +9 -1
  59. package/scripts/josh/josh-command-map.ts +1 -0
  60. package/scripts/josh/josh-commands-issue.ts +11 -0
  61. package/scripts/josh/josh-commands-project.ts +5 -1
  62. package/scripts/propagate/propagate-run.ts +1 -0
  63. package/scripts/propagate/propagate-select.ts +72 -0
  64. package/scripts/propagate/propagate-targets.ts +5 -3
  65. package/scripts/propagate/propagate.ts +86 -24
  66. package/scripts/repo/repository-labels.ts +91 -0
  67. package/scripts/rules/bash-triggers.ts +19 -6
  68. package/scripts/rules/delivered-rules.ts +28 -32
  69. package/scripts/rules/direct-filing.ts +38 -0
  70. package/scripts/rules/filing-cap.ts +11 -8
  71. package/scripts/rules/issue-fold.ts +4 -4
  72. package/scripts/rules/oracle-consulted.ts +4 -4
  73. package/scripts/rules/oracle-firing.ts +13 -16
  74. package/scripts/rules/rule-body-guard.ts +6 -9
  75. package/scripts/rules/stop-rules.ts +3 -2
  76. package/scripts/run/run-entry-cli.ts +34 -1
  77. package/scripts/run/run-halfrun-resume.ts +104 -0
  78. package/scripts/run/run-hold-cli.ts +27 -4
  79. package/scripts/run/run-hold.ts +11 -0
  80. package/scripts/security/security-audit.ts +12 -1
  81. package/scripts/sync/sync.ts +6 -0
  82. package/scripts/test/pilot-files.ts +0 -1
  83. package/scripts/version/fresh-metadata-cache.ts +36 -0
  84. package/scripts/version/latest-update.ts +2 -1
  85. package/scripts/rules/issue-bug-label.ts +0 -85
  86. package/scripts/rules/issue-filing-args.ts +0 -50
  87. package/scripts/rules/issue-scout.ts +0 -64
@@ -0,0 +1,104 @@
1
+ import { run_hold, type HoldRead, type RunHold } from './run-hold'
2
+
3
+ // joshuafolkken/kit#2796: a `halfrun` stops before its commit and **keeps** its hold, because the
4
+ // uncommitted, hand-verified work is exactly what a second run would trample. The person then types
5
+ // `fullrun #N` to ship it — and `run:entry`'s claim answered `busy` against that very hold, or, once the
6
+ // hold had been cleared, the preflight advised stashing the verified diff away. The hand-off the
7
+ // `halfrun` stop promised could not land, and each run improvised its own way past the guard.
8
+ //
9
+ // **The stop is recorded, never inferred.** A hold naming #N over a dirty tree is also what a `halfrun`
10
+ // still implementing and a `backlogrun` child leave, and adopting either hands a half-written diff to
11
+ // the gate and the merge. So the `halfrun` stop re-writes its own record with a stop mark
12
+ // (`run:hold <N> --halfrun-stop`) — re-keying a `halfrun new`'s unnumbered record to the issue it filed —
13
+ // and only a marked record is adopted. An expired one qualifies too, since a person may verify the next
14
+ // day. The adopted record carries the `fullrun` mark instead, so the implementation-cut guard and
15
+ // `run:cut --resume` read the tree as this `fullrun`'s from here on.
16
+
17
+ const MARKED = 'marked'
18
+ const FOREIGN = 'foreign'
19
+ const UNREADABLE = 'unreadable'
20
+
21
+ type StopMark = typeof FOREIGN | typeof MARKED | typeof UNREADABLE
22
+
23
+ function hold_of(read: HoldRead): RunHold | undefined {
24
+ return read.kind === 'held' || read.kind === 'stale' ? read.hold : undefined
25
+ }
26
+
27
+ function is_halfrun_stop(read: HoldRead, issue: string, is_dirty: boolean): boolean {
28
+ const hold = hold_of(read)
29
+
30
+ return is_dirty && hold?.issue === issue && hold.is_halfrun_stop === true
31
+ }
32
+
33
+ // The stopping run's own record names its issue, or `new` when it entered as `halfrun new`; a free tree
34
+ // is marked as well, since the work to protect is sitting in it either way.
35
+ function is_own_record(read: HoldRead, issue: string): boolean {
36
+ const hold = hold_of(read)
37
+
38
+ return hold === undefined || hold.issue === issue || hold.issue === run_hold.UNNUMBERED_ISSUE
39
+ }
40
+
41
+ function mark_stop_at(target: string, issue: string): StopMark {
42
+ const read = run_hold.read_hold(target)
43
+
44
+ if (read.kind === 'unreadable') return UNREADABLE
45
+
46
+ if (!is_own_record(read, issue)) return FOREIGN
47
+
48
+ run_hold.release_hold(target)
49
+
50
+ return run_hold.create_halfrun_stop_hold(target, issue) ? MARKED : FOREIGN
51
+ }
52
+
53
+ // `false` leaves the record untouched, so the ordinary claim that follows decides the tree exactly as
54
+ // it did before this resume existed.
55
+ function adopt_at(target: string, issue: string, is_dirty: boolean): boolean {
56
+ if (!is_halfrun_stop(run_hold.read_hold(target), issue, is_dirty)) return false
57
+
58
+ run_hold.release_hold(target)
59
+
60
+ return run_hold.create_hold(target, issue, new Date(), true)
61
+ }
62
+
63
+ // **An unreadable git directory is no resume**, never a crash: `run:entry` must still print its one
64
+ // summary line, and the ordinary claim that follows answers `unknown` for the same tree.
65
+ async function hold_target(): Promise<string | undefined> {
66
+ try {
67
+ const directory = await run_hold.worktree_directory()
68
+
69
+ return directory === undefined ? undefined : run_hold.hold_path(directory)
70
+ } catch {
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ // Read-only, so `run:entry` can ask the session budget before it takes anything over.
76
+ async function is_pending(issue: string): Promise<boolean> {
77
+ const target = await hold_target()
78
+
79
+ if (target === undefined) return false
80
+
81
+ return is_halfrun_stop(run_hold.read_hold(target), issue, await run_hold.is_tree_dirty())
82
+ }
83
+
84
+ async function adopt(issue: string): Promise<boolean> {
85
+ const target = await hold_target()
86
+
87
+ if (target === undefined) return false
88
+
89
+ return adopt_at(target, issue, await run_hold.is_tree_dirty())
90
+ }
91
+
92
+ const run_halfrun_resume = {
93
+ FOREIGN,
94
+ MARKED,
95
+ UNREADABLE,
96
+ adopt,
97
+ adopt_at,
98
+ is_halfrun_stop,
99
+ is_pending,
100
+ mark_stop_at,
101
+ }
102
+
103
+ export type { StopMark }
104
+ export { run_halfrun_resume }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { run_halfrun_resume } from './run-halfrun-resume'
3
4
  import { run_hold, type HoldRead, type RunHold } from './run-hold'
4
5
  import { run_preflight, type PreflightDecision } from './run-preflight'
5
6
  import { run_tidy_cli } from './run-tidy-cli'
@@ -46,11 +47,12 @@ const FORCE_FLAG = '--force'
46
47
  // What `fullrun #N`'s entry claims with, so the record says the run is a `fullrun` — the one run whose
47
48
  // implementation cut outside a lane resumes as `fullrun #N` (joshuafolkken/kit#2760).
48
49
  const FULLRUN_FLAG = '--fullrun'
49
- // The most a claim reads: the issue number and the `--fullrun` flag after it.
50
+ const HALFRUN_STOP_FLAG = '--halfrun-stop'
51
+ // The most a claim reads: the issue number and the one flag after it.
50
52
  const MAX_CLAIM_ARGUMENTS = 2
51
53
  const ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/u
52
54
  const USAGE =
53
- 'Usage: josh run:hold [<issue-number> [--fullrun]] | josh run:release [<issue-number> | --force]'
55
+ 'Usage: josh run:hold [<issue-number> [--fullrun | --halfrun-stop]] | josh run:release [<issue-number> | --force]'
54
56
 
55
57
  const HOLD_VERDICT = 'hold'
56
58
  const BUSY_VERDICT = 'busy'
@@ -62,6 +64,7 @@ const NONE_VERDICT = 'none'
62
64
  const HELD_VERDICT = 'held'
63
65
 
64
66
  const FORCE_RELEASE_KIND = 'force-release'
67
+ const HALFRUN_STOP_KIND = 'halfrun-stop'
65
68
 
66
69
  interface ClaimRequest {
67
70
  kind: 'claim'
@@ -70,7 +73,10 @@ interface ClaimRequest {
70
73
  }
71
74
 
72
75
  type HoldRequest =
73
- ClaimRequest | { kind: 'release'; claimant: string } | { kind: typeof FORCE_RELEASE_KIND }
76
+ | ClaimRequest
77
+ | { kind: 'release'; claimant: string }
78
+ | { kind: typeof FORCE_RELEASE_KIND }
79
+ | { kind: typeof HALFRUN_STOP_KIND; issue: string }
74
80
 
75
81
  const FORCE_RELEASE_REQUEST: HoldRequest = { kind: FORCE_RELEASE_KIND }
76
82
 
@@ -78,10 +84,13 @@ function release_request(claimant: string): HoldRequest {
78
84
  return { kind: 'release', claimant }
79
85
  }
80
86
 
81
- // The flag after a numbered claim: absent, or `--fullrun`. Anything else is a usage error.
87
+ // The flag after a numbered claim: absent, `--fullrun` or `--halfrun-stop`. Anything else is a usage
88
+ // error.
82
89
  function numbered_claim(issue: string, flag: string | undefined): HoldRequest | undefined {
83
90
  if (flag === undefined) return { kind: 'claim', issue }
84
91
 
92
+ if (flag === HALFRUN_STOP_FLAG) return { kind: HALFRUN_STOP_KIND, issue }
93
+
85
94
  return flag === FULLRUN_FLAG ? { kind: 'claim', issue, is_fullrun: true } : undefined
86
95
  }
87
96
 
@@ -282,9 +291,23 @@ async function read_worktree(): Promise<string | undefined> {
282
291
  }
283
292
  }
284
293
 
294
+ // **A `halfrun` stop marks its own record** (joshuafolkken/kit#2796) — the positive "this run has ended
295
+ // over its verified diff" that `run:entry` adopts on `fullrun #N`. Another run's record is left alone.
296
+ function mark_halfrun_stop(target: string, issue: string): number {
297
+ const mark = run_halfrun_resume.mark_stop_at(target, issue)
298
+
299
+ if (mark === run_halfrun_resume.MARKED) return report_hold()
300
+
301
+ if (mark === run_halfrun_resume.UNREADABLE) return report_unknown()
302
+
303
+ return report_busy(run_hold.race_message(run_hold.read_hold(target), target))
304
+ }
305
+
285
306
  async function dispatch(request: HoldRequest, target: string, is_linked: boolean): Promise<number> {
286
307
  if (request.kind === 'claim') return await claim(target, request, is_linked)
287
308
 
309
+ if (request.kind === HALFRUN_STOP_KIND) return mark_halfrun_stop(target, request.issue)
310
+
288
311
  if (request.kind === FORCE_RELEASE_KIND) return force_release(target)
289
312
 
290
313
  return release(target, request.claimant)
@@ -48,6 +48,10 @@ interface RunHold {
48
48
  // `backlogrun` child — which hold the tree just the same — must not be read as one. Optional and
49
49
  // `| undefined` because a record written before this field existed is simply not a `fullrun`'s.
50
50
  is_fullrun?: boolean | undefined
51
+ // Set only at a `halfrun`'s stop before commit (`run:hold <N> --halfrun-stop`, joshuafolkken/kit#2796):
52
+ // the positive record that the run has *ended* over its verified diff, which is what lets
53
+ // `fullrun #N` adopt the hold. A running `halfrun` or a `backlogrun` child never carries it.
54
+ is_halfrun_stop?: boolean | undefined
51
55
  }
52
56
 
53
57
  type HoldRead =
@@ -87,6 +91,7 @@ const run_hold_schema = z.object({
87
91
  taken_at: z.string(),
88
92
  pid: z.number(),
89
93
  is_fullrun: z.boolean().optional(),
94
+ is_halfrun_stop: z.boolean().optional(),
90
95
  })
91
96
 
92
97
  // `undefined` for anything that is not a well-formed record, so the caller decides what a broken one
@@ -156,6 +161,11 @@ function create_hold(
156
161
  return stamp_file.create_stamp(target, build_hold(issue, now, is_fullrun))
157
162
  }
158
163
 
164
+ // The claim a `halfrun` stop leaves behind: the same exclusive create, carrying the stop mark.
165
+ function create_halfrun_stop_hold(target: string, issue: string, now: Date = new Date()): boolean {
166
+ return stamp_file.create_stamp(target, { ...build_hold(issue, now), is_halfrun_stop: true })
167
+ }
168
+
159
169
  // **A tree with uncommitted work in it is never handed over, however old its record is.** No age can
160
170
  // be chosen that covers a `halfrun` stop or a `needs-human-review` stop: those are held across a
161
171
  // person's latency rather than a run's, and the thing that must not be trampled is sitting in the
@@ -249,6 +259,7 @@ const run_hold = {
249
259
  RELEASE_COMMAND,
250
260
  UNNUMBERED_ISSUE,
251
261
  classify,
262
+ create_halfrun_stop_hold,
252
263
  create_hold,
253
264
  describe_holder,
254
265
  foreign_release_message,
@@ -86,10 +86,21 @@ function resolve_scanner(project_root: string, platform: string): ScannerChoice
86
86
  return has_path_binary ? { path: BINARY_NAME, is_below_floor: true } : undefined
87
87
  }
88
88
 
89
+ // **Output is forwarded, never handed the terminal itself.** osv-scanner is a Go binary that probes a
90
+ // terminal on its stdout for its background color (an OSC 11 query followed by a cursor-position
91
+ // request) and never reads the answers, so they sat in the terminal's input queue and surfaced at the
92
+ // next shell prompt as `11;rgb:0101/0404/0909;1R` after a push ran this audit from the pre-push hook.
93
+ // A pipe means the scanner sees no terminal and asks nothing; the `inherit` half still prints its report.
94
+ // The fix belongs here, at the one command that asks, rather than on `git push`: piping the push would
95
+ // make its timeout wait on any process a hook left running (joshuafolkken/kit#2801).
96
+ const FORWARDED_OUTPUT = ['inherit', 'pipe'] as const
97
+
89
98
  function run_scanner(scanner_path: string): number {
90
99
  const { LOCKFILE_PATH, build_scanner_arguments } = security_audit_logic
91
100
  const result = execaSync(scanner_path, [...build_scanner_arguments(LOCKFILE_PATH)], {
92
- stdio: 'inherit',
101
+ stdin: 'inherit',
102
+ stdout: FORWARDED_OUTPUT,
103
+ stderr: FORWARDED_OUTPUT,
93
104
  reject: false,
94
105
  })
95
106
 
@@ -8,6 +8,7 @@ import { init_logic } from '#scripts/init/init-logic'
8
8
  import { PACKAGE_DIR, PROJECT_ROOT } from '#scripts/init/init-paths'
9
9
  import { plugin_install_hint_module } from '#scripts/init/plugin-install-hint'
10
10
  import { auto_merge_setting } from '#scripts/repo/auto-merge-setting'
11
+ import { repository_labels } from '#scripts/repo/repository-labels'
11
12
  import { project_config } from '#scripts/safe-chain/project-config'
12
13
  import { security_updates } from '#scripts/security/security-updates'
13
14
  import { sonar_file } from '#scripts/security/sonar-file'
@@ -364,9 +365,14 @@ function sync_package_json_migrations(destination_path: string): void {
364
365
  // unless the repository allows auto-merge (joshuafolkken/kit#834). Neither ever fails the sync: both
365
366
  // are GitHub-side state, not synced artifacts. Unconditional, unlike `init`, because `sync`
366
367
  // overwrites both files on every run.
368
+ //
369
+ // The labels are the same kind of GitHub-side prerequisite, and one kit *can* write: the synced
370
+ // `pr-classification.yml` requires one of the release classification labels, so the missing ones are
371
+ // created here rather than reported (joshuafolkken/kit#2797).
367
372
  function report_repository_settings(name_with_owner: string | undefined): void {
368
373
  security_updates.report_security_updates_section(name_with_owner)
369
374
  auto_merge_setting.report_auto_merge_section(name_with_owner)
375
+ repository_labels.ensure_labels(name_with_owner)
370
376
  }
371
377
 
372
378
  function sync_project_artifacts(is_force: boolean): void {
@@ -320,7 +320,6 @@ const PILOT_FILES: ReadonlyArray<string> = [
320
320
  'scripts/rules/issue-comments-rule.test.ts',
321
321
  'scripts/rules/issue-depth-label.test.ts',
322
322
  'scripts/rules/issue-fold.test.ts',
323
- 'scripts/rules/issue-scout.test.ts',
324
323
  'scripts/rules/josh-git-bare.test.ts',
325
324
  'scripts/rules/lane-interactive-ask-rule.test.ts',
326
325
  'scripts/rules/lane-park-rule.test.ts',
@@ -0,0 +1,36 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ const CACHE_DIRECTORY_PREFIX = 'josh-latest-cache-'
6
+ const CACHE_DIRECTORY_FLAG = '--config.cache-dir='
7
+
8
+ // Points every stage at one metadata cache directory. pnpm keeps registry metadata there and the
9
+ // package store elsewhere, so an empty one costs a metadata refetch, never a tarball download.
10
+ function with_cache_directory(
11
+ stages: ReadonlyArray<ReadonlyArray<string>>,
12
+ cache_directory: string,
13
+ ): Array<Array<string>> {
14
+ return stages.map((stage) => [...stage, `${CACHE_DIRECTORY_FLAG}${cache_directory}`])
15
+ }
16
+
17
+ // A cached metadata file written through safe-chain lacks every version that was under 24 hours old
18
+ // at the time, and with `minimumReleaseAge` set pnpm reuses a cache file younger than that age
19
+ // instead of refetching it — so a version that has since aged in stays invisible for up to a day
20
+ // (joshuafolkken/kit#2805). A fresh directory per run means nothing filtered earlier is ever read.
21
+ function run_in_fresh_cache<T>(
22
+ stages: ReadonlyArray<ReadonlyArray<string>>,
23
+ run_stages: (stages: Array<Array<string>>) => T,
24
+ ): T {
25
+ const cache_directory = mkdtempSync(path.join(tmpdir(), CACHE_DIRECTORY_PREFIX))
26
+
27
+ try {
28
+ return run_stages(with_cache_directory(stages, cache_directory))
29
+ } finally {
30
+ rmSync(cache_directory, { recursive: true, force: true })
31
+ }
32
+ }
33
+
34
+ const fresh_metadata_cache = { with_cache_directory, run_in_fresh_cache }
35
+
36
+ export { fresh_metadata_cache }
@@ -11,6 +11,7 @@ import { lockfile_overrides } from '#scripts/overrides/lockfile-overrides'
11
11
  import { overrides_files } from '#scripts/overrides/overrides-files'
12
12
  import { overrides_check } from '#scripts/overrides/overrides-logic'
13
13
  import { execaSync } from 'execa'
14
+ import { fresh_metadata_cache } from './fresh-metadata-cache'
14
15
  import { latest_regression, type VersionRegression } from './latest-regression'
15
16
  import { preinstall_version_update } from './preinstall-version-update'
16
17
 
@@ -99,7 +100,7 @@ function find_regressions_after_update(snapshot: TreeSnapshot): Array<VersionReg
99
100
  // Exits zero: the tree is left exactly as it was found, nothing is broken, and every workflow that
100
101
  // runs `josh latest` in its preamble would otherwise stop for a situation that resolves itself.
101
102
  function update_without_downgrading(snapshot: TreeSnapshot): UpdateOutcome {
102
- const status = run_update_stages(build_update_commands())
103
+ const status = fresh_metadata_cache.run_in_fresh_cache(build_update_commands(), run_update_stages)
103
104
  if (status !== 0) return { status, is_rolled_back: false }
104
105
 
105
106
  const regressions = find_regressions_after_update(snapshot)
@@ -1,85 +0,0 @@
1
- import { readFileSync } from 'node:fs'
2
- import path from 'node:path'
3
- import { issue_classification } from '#scripts/issue/issue-classification'
4
- import type { GuardRun } from '#scripts/josh/hook-decision'
5
- import { time_density_hook } from '#scripts/time-runtime/time-density-hook'
6
- import { time_shell } from '#scripts/time-runtime/time-shell'
7
- import { bash_triggers } from './bash-triggers'
8
- import { issue_filing_args } from './issue-filing-args'
9
- import { shell_segments } from './shell-segments'
10
- import { tail_commands } from './tail-commands'
11
-
12
- const LINT_NAMES = new Set(['issue:lint'])
13
- const LINT_PATH = /\b(?:issue:lint|iln)\s+(?:'([^']+)'|"([^"]+)"|(\S+))/u
14
-
15
- function lint_argument(segment: string): string | undefined {
16
- const [, single_quoted, double_quoted, bare] = LINT_PATH.exec(segment) ?? []
17
-
18
- return single_quoted ?? double_quoted ?? bare
19
- }
20
-
21
- function latest_lint_path(tail: string): string | undefined {
22
- const segments = tail_commands
23
- .prior_bash_commands(tail)
24
- .flatMap((command) => shell_segments.segments_of(command))
25
- const lint = segments.findLast((segment) => shell_segments.is_josh_command(segment, LINT_NAMES))
26
-
27
- return lint === undefined ? undefined : lint_argument(lint)
28
- }
29
-
30
- function linted_body(body_path: string): string | undefined {
31
- try {
32
- return readFileSync(body_path, 'utf8')
33
- } catch {
34
- return undefined
35
- }
36
- }
37
-
38
- function matches_linted_file(command: string, lint_path: string): boolean {
39
- const filing_path = issue_filing_args.body_file(command)
40
-
41
- return filing_path !== undefined && path.resolve(filing_path) === path.resolve(lint_path)
42
- }
43
-
44
- function needs_classification_labels(command: string, tail: string): boolean {
45
- const lint_path = latest_lint_path(tail)
46
-
47
- if (lint_path === undefined) return false
48
- const body = linted_body(lint_path)
49
-
50
- if (body === undefined) return false
51
-
52
- if (!matches_linted_file(command, lint_path) || issue_classification.problems(body).length > 0) {
53
- return true
54
- }
55
-
56
- const expected = issue_classification.required_labels(body)
57
-
58
- return issue_classification.CLASSIFICATION_LABELS.some(
59
- (label) => expected.includes(label) !== issue_filing_args.has_label(command, label),
60
- )
61
- }
62
-
63
- function decide(call: { input: unknown }, run: GuardRun): boolean {
64
- const command = time_shell.bash_command(call.input)
65
- const tail = time_density_hook.read_tail(run.transcript)
66
-
67
- return needs_classification_labels(command, tail)
68
- }
69
-
70
- const ISSUE_CLASSIFICATION_REASON =
71
- '⛔ issue classification mismatch: use the exact body file checked by `pnpm josh issue:lint` ' +
72
- 'in this creation call (`-F body=@<path>` or `gh issue create --body-file <path>`) and ' +
73
- 'apply exactly the `labels:` it reports (`bug`, `enhancement`, `breaking-change`). See ' +
74
- '`prompts/collaboration-workflow/issue-template.md`.'
75
-
76
- const ROW = {
77
- id: 'issue-bug-label',
78
- is_trigger: bash_triggers.on_bash_command(bash_triggers.is_issue_filing),
79
- reason: ISSUE_CLASSIFICATION_REASON,
80
- decide,
81
- }
82
-
83
- const issue_bug_label_rule = { ROW, latest_lint_path, needs_classification_labels }
84
-
85
- export { issue_bug_label_rule }
@@ -1,50 +0,0 @@
1
- const SHELL_WORD = /(?:[^\s'"]|'[^']*'|"[^"]*")+/gu
2
- const QUOTE = /['"]/gu
3
- const RAW_FIELDS = new Set(['-f', '--raw-field'])
4
- const FILE_FIELDS = new Set(['-F', '--field'])
5
- const LABEL_OPTIONS = new Set(['-l', '--label'])
6
- const BODY_FILE_OPTIONS = new Set(['--body-file'])
7
- const INLINE_BODY_OPTIONS = new Set(['-b', '--body'])
8
- const BODY_FILE_PREFIX = 'body=@'
9
-
10
- function words_of(command: string): Array<string> {
11
- return [...command.matchAll(SHELL_WORD)].map((match) => match[0].replaceAll(QUOTE, ''))
12
- }
13
-
14
- function values_after(words: ReadonlyArray<string>, flags: ReadonlySet<string>): Array<string> {
15
- return words.flatMap((word, index) => {
16
- const value = words[index + 1]
17
-
18
- return value !== undefined && flags.has(word) ? [value] : []
19
- })
20
- }
21
-
22
- function has_inline_body(words: ReadonlyArray<string>): boolean {
23
- const has_raw = values_after(words, RAW_FIELDS).some((value) => value.startsWith('body='))
24
-
25
- return has_raw || values_after(words, INLINE_BODY_OPTIONS).length > 0
26
- }
27
-
28
- function body_file(command: string): string | undefined {
29
- const words = words_of(command)
30
- const fields = values_after(words, FILE_FIELDS).filter((value) => value.startsWith('body='))
31
- const options = values_after(words, BODY_FILE_OPTIONS)
32
-
33
- if (has_inline_body(words) || fields.length + options.length !== 1) return undefined
34
-
35
- return fields[0]?.startsWith(BODY_FILE_PREFIX)
36
- ? fields[0].slice(BODY_FILE_PREFIX.length)
37
- : options[0]
38
- }
39
-
40
- function has_label(command: string, label: string): boolean {
41
- const words = words_of(command)
42
- const fields = [...values_after(words, RAW_FIELDS), ...values_after(words, FILE_FIELDS)]
43
- const options = values_after(words, LABEL_OPTIONS).flatMap((value) => value.split(','))
44
-
45
- return fields.includes(`labels[]=${label}`) || options.includes(label)
46
- }
47
-
48
- const issue_filing_args = { body_file, has_label, words_of }
49
-
50
- export { issue_filing_args }
@@ -1,64 +0,0 @@
1
- import type { GuardedCall } from '#scripts/time-runtime/time-batch-guard'
2
- import { bash_triggers } from './bash-triggers'
3
- import { shell_segments } from './shell-segments'
4
- import { tail_commands } from './tail-commands'
5
-
6
- // The trigger and the delivered text behind the `issue-scout` row of `delivered-rules.ts`
7
- // (joshuafolkken/kit#2119).
8
- //
9
- // **A filing without `pnpm josh issue:scout` in front of it, refused at the filing itself.** §2e
10
- // requires the scout before every `gh api … issues` call, and a duplicate is likeliest for the work
11
- // that just finished — the one case self-restraint is least able to catch. The input is mechanical —
12
- // did the run already run the scout — so it is a delivered rule rather than resident prose.
13
- //
14
- // **It is `issue-comments`'s shape, not the WIP cap's.** Like that row it is once per run with an
15
- // `already_satisfied` stand-down read off the tail, and the refusal hands over the command rather than
16
- // asking the run to "scout first" — a body-only read is refused so the reissue makes the comments
17
- // present, and here a scout-less filing is refused so the reissue makes the scout present. **No new
18
- // delivery path**: the trigger is `bash_triggers.is_issue_filing`, reused verbatim.
19
-
20
- // `pnpm josh issue:scout`, the one act the rule asks for. The alias `isc` is expanded where the
21
- // command is read, so the canonical name matches both spellings (joshuafolkken/kit#1789).
22
- const SCOUT_NAMES: ReadonlySet<string> = new Set(['issue:scout'])
23
-
24
- // Keeping the rule: a call that runs the scout. Segment-wise, so a spelling quoted inside a filing's
25
- // own body is not read as the scout that filing skipped.
26
- function runs_the_scout(command: string): boolean {
27
- return shell_segments
28
- .segments_of(command)
29
- .some((segment) => shell_segments.is_josh_command(segment, SCOUT_NAMES))
30
- }
31
-
32
- // The `already_satisfied` stand-down: the run has already scouted, so this filing asks for nothing new.
33
- // The tool-name gate the row's trigger applies has already passed, so `call` only marks the shape; the
34
- // answer is entirely in the tail.
35
- function already_scouted(tail: string, _call: GuardedCall): boolean {
36
- return tail_commands.prior_bash_commands(tail).some((command) => runs_the_scout(command))
37
- }
38
-
39
- // The instruction in the shape a refusal can carry: what the call skipped, why it matters most here,
40
- // and the command that fixes it. The duplicate risk is named because it is the half that makes the
41
- // rule believable — the filing most likely to duplicate is the one about work that just finished.
42
- const ISSUE_SCOUT_REASON =
43
- '⛔ issue scout: this files an Issue with no `pnpm josh issue:scout` earlier in the run. A ' +
44
- 'duplicate is likeliest for the work that just finished, which is exactly where self-restraint is ' +
45
- 'weakest. Reissue after scouting: `pnpm josh issue:scout "<title>"` (add `--body "<one-line ' +
46
- 'summary, citing #N where the work follows one>"` so the epic half can answer), then read its ' +
47
- '`Duplicates:` — an **open** candidate covering the same work stops the run rather than filing a ' +
48
- 'second Issue, and a `(closed)` one means the work is already done — before reissuing this filing. ' +
49
- 'The procedure is `.claude/skills/workflow-commands/SKILL.md` → §2e. It fires once per run and ' +
50
- 'cannot repeat on the call in hand.'
51
-
52
- // The enumeration row itself, so `delivered-rules.ts` spreads one entry rather than restating the
53
- // trigger and reason it already single-sources here. Once per run with the stand-down above.
54
- const ROW = {
55
- id: 'issue-scout',
56
- is_trigger: bash_triggers.on_bash_command(bash_triggers.is_issue_filing),
57
- reason: ISSUE_SCOUT_REASON,
58
- already_satisfied: already_scouted,
59
- keeps: bash_triggers.on_bash_command(runs_the_scout),
60
- }
61
-
62
- const issue_scout = { ISSUE_SCOUT_REASON, ROW, already_scouted, runs_the_scout }
63
-
64
- export { issue_scout }