@joshuafolkken/kit 1.929.0 → 1.947.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 (78) hide show
  1. package/.claude/skills/workflow-commands/background-commands.md +6 -0
  2. package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +4 -3
  4. package/.claude/skills/workflow-commands/observation-filing.md +3 -3
  5. package/.claude/skills/workflow-commands/prerequisite.md +1 -1
  6. package/.github/workflows/ci.yml +26 -8
  7. package/.github/workflows/pr-classification.yml +76 -0
  8. package/README.md +47 -22
  9. package/dist/hooks/batch-guard.js +4 -4
  10. package/dist/hooks/{chunk-GQ3EVWHY.js → chunk-3FWV6CFC.js} +113 -19
  11. package/dist/hooks/{chunk-2PGRQ62K.js → chunk-EYFJ74MJ.js} +11 -6
  12. package/dist/hooks/{chunk-YO43HEPY.js → chunk-GIX5SAAB.js} +237 -235
  13. package/dist/hooks/{chunk-LCJZE42I.js → chunk-IA7WC4NS.js} +1 -1
  14. package/dist/hooks/{chunk-BNFG2IFK.js → chunk-IBFC25IX.js} +4 -4
  15. package/dist/hooks/{chunk-I3JP3D4A.js → chunk-M4U4YNRE.js} +3 -3
  16. package/dist/hooks/{chunk-NJZSO7BK.js → chunk-NKVLBB65.js} +3 -3
  17. package/dist/hooks/{chunk-SXPTRFPG.js → chunk-O3PEDF3M.js} +1 -1
  18. package/dist/hooks/{chunk-7XQ6TK3R.js → chunk-OM5VDM6F.js} +2 -2
  19. package/dist/hooks/{chunk-ROULPSQC.js → chunk-V7RH56HU.js} +2 -2
  20. package/dist/hooks/codex-hook-adapter.js +10 -10
  21. package/dist/hooks/duplicate-read-guard.js +5 -5
  22. package/dist/hooks/format-edited.js +2 -2
  23. package/dist/hooks/investigation-guard.js +5 -5
  24. package/dist/hooks/pretool-guard.js +9 -9
  25. package/dist/hooks/stop-guard.js +9 -3
  26. package/dist/josh.js +11 -6
  27. package/package.json +13 -1
  28. package/prompts/collaboration-workflow/issue-template.md +1 -1
  29. package/scripts/ci/pr-classification.ts +27 -6
  30. package/scripts/document/document-byte-budget.ts +1 -1
  31. package/scripts/gate/hook-gate-reuse.ts +1 -1
  32. package/scripts/gate/project-checks.ts +45 -15
  33. package/scripts/gate/type-check-command.ts +43 -0
  34. package/scripts/git/constants.ts +4 -0
  35. package/scripts/git/git-command.ts +11 -5
  36. package/scripts/git/git-gh-pr-rollup.ts +56 -16
  37. package/scripts/git/git-staging.ts +6 -4
  38. package/scripts/git/git-stash.ts +73 -1
  39. package/scripts/git/stash-orphans.ts +24 -2
  40. package/scripts/git/stash-sweep-lock.ts +141 -0
  41. package/scripts/hooks/stop-guard.ts +2 -0
  42. package/scripts/init/init-actions.ts +3 -2
  43. package/scripts/init/init-logic-migrate.ts +10 -1
  44. package/scripts/init/init-logic-templates.ts +27 -7
  45. package/scripts/init/init-logic-workspace.ts +52 -1
  46. package/scripts/init/init-logic.ts +6 -1
  47. package/scripts/init/init.ts +60 -19
  48. package/scripts/issue/issue-merged.ts +75 -0
  49. package/scripts/issue/measure-rerun-cli.ts +2 -2
  50. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +10 -0
  51. package/scripts/josh/josh-command-map.ts +1 -0
  52. package/scripts/josh/josh-command-types.ts +0 -3
  53. package/scripts/josh/josh-commands-ai.ts +6 -0
  54. package/scripts/josh/josh-commands-development.ts +5 -5
  55. package/scripts/lint/lint-parallel.ts +19 -18
  56. package/scripts/observations/observation-ledger-home.ts +22 -2
  57. package/scripts/observations/observation-ledger-line.ts +11 -0
  58. package/scripts/observations/observation-ledger-migrate.ts +146 -0
  59. package/scripts/observations/observation-ledger.ts +51 -3
  60. package/scripts/observations/observations-flush.ts +38 -7
  61. package/scripts/review/review-finding-ledger.ts +1 -1
  62. package/scripts/review/review-record-cli.ts +1 -7
  63. package/scripts/rules/delivered-rules.ts +10 -0
  64. package/scripts/rules/lane-background.ts +111 -0
  65. package/scripts/rules/stop-rules.ts +15 -2
  66. package/scripts/run/run-carry-flush.ts +2 -7
  67. package/scripts/run/run-hold-cli.ts +10 -2
  68. package/scripts/run/run-tidy-cli.ts +50 -0
  69. package/scripts/run/run-tidy-lanes.ts +110 -0
  70. package/scripts/run/run-tidy-stashes.ts +160 -0
  71. package/scripts/run/run-tidy.ts +101 -0
  72. package/scripts/safe-chain/ci-installer-pin.ts +90 -0
  73. package/scripts/safe-chain/preinstall-command.ts +39 -0
  74. package/scripts/sync/sync.ts +5 -2
  75. package/scripts/test/pilot-files.ts +0 -1
  76. package/scripts/version/preinstall-version-update.ts +2 -0
  77. package/templates/workflows/ci.yml +21 -7
  78. package/tsconfig/base.json +4 -1
@@ -1,4 +1,4 @@
1
- import { PORCELAIN_FLAG } from './constants'
1
+ import { PORCELAIN_FLAG, UNTRACKED_FILES_FLAG } from './constants'
2
2
  import { git_push_transport } from './git-push-transport'
3
3
  import { git_spawn } from './git-spawn'
4
4
 
@@ -6,15 +6,21 @@ async function branch(): Promise<string> {
6
6
  return await git_spawn.read(['rev-parse', '--abbrev-ref', 'HEAD'])
7
7
  }
8
8
 
9
- // **`--untracked-files=normal` is passed rather than inherited** (joshuafolkken/kit#1381). Every
9
+ // **The untracked-files mode is passed rather than inherited** (joshuafolkken/kit#1381). Every
10
10
  // reader of this output depends on the `??` lines being there: `git-staging.ts` stages exactly those,
11
11
  // and `hook-gate-reuse.ts` reads an empty output as "this push carries the recorded tree". A person
12
12
  // with `status.showUntrackedFiles=no` in their git config — a common setting on large repositories —
13
13
  // gets porcelain output with those lines silently absent, so untracked files go unstaged and the
14
- // pre-push hook reuses a record for a commit that does not contain them. Naming git's own default
15
- // makes the reading answer to this codebase rather than to whoever ran it.
14
+ // pre-push hook reuses a record for a commit that does not contain them. Naming the mode makes the
15
+ // reading answer to this codebase rather than to whoever ran it.
16
+ //
17
+ // **`all` rather than `normal`, so a new file is named even inside an untracked directory**
18
+ // (joshuafolkken/kit#2724). `normal` collapses it to `?? docs/maintainers/`, and every reader that
19
+ // recognizes the observation ledger by its path then misses it: `git-staging.ts` stages the directory
20
+ // — ledger included — into an unrelated pull request, and the flush refuses the directory as "other
21
+ // changes".
16
22
  async function status(): Promise<string> {
17
- return await git_spawn.read(['status', PORCELAIN_FLAG, '--untracked-files=normal'])
23
+ return await git_spawn.read(['status', PORCELAIN_FLAG, UNTRACKED_FILES_FLAG])
18
24
  }
19
25
 
20
26
  // The absolute path every other git command's output is relative to. Asking git rather than reading
@@ -29,7 +29,9 @@ const check_runs_page_schema = z.looseObject({
29
29
  // answer an empty rollup — which `git-pr-followup.ts` reports as "this branch has no checks".
30
30
  check_runs: z.array(rollup_element_schema),
31
31
  })
32
- const check_run_app_schema = z.object({ id: z.number() })
32
+
33
+ // Both `app` and `check_suite` are objects carrying a numeric `id`, and so is a check run itself.
34
+ const id_holder_schema = z.object({ id: z.number() })
33
35
 
34
36
  const status_page_schema = z.looseObject({
35
37
  statuses: z.array(rollup_element_schema),
@@ -51,37 +53,75 @@ function read_pages<T>(raw_json: string, schema: z.ZodType<T>, message: string):
51
53
  return pages
52
54
  }
53
55
 
56
+ const NO_SUITE = -1
57
+
58
+ interface RunRank {
59
+ suite: number
60
+ run: number
61
+ }
62
+
63
+ function read_id(value: unknown): number | undefined {
64
+ const parsed = id_holder_schema.safeParse(value)
65
+
66
+ return parsed.success ? parsed.data.id : undefined
67
+ }
68
+
54
69
  function check_run_group_key(run: RollupElement): string | undefined {
55
- const { name, app: raw_app } = run
70
+ const { name, app } = run
56
71
  if (typeof name !== 'string') return undefined
57
- const app = check_run_app_schema.safeParse(raw_app)
58
72
 
59
- return JSON.stringify([name, app.success ? app.data.id : undefined])
73
+ return JSON.stringify([name, read_id(app)])
74
+ }
75
+
76
+ function read_rank(run: RollupElement): RunRank | undefined {
77
+ const id = read_id(run)
78
+ if (id === undefined) return undefined
79
+
80
+ return { suite: read_id(run['check_suite']) ?? NO_SUITE, run: id }
81
+ }
82
+
83
+ // **The newest check suite decides a name, not the newest check run** — the rule GitHub's merge box
84
+ // applies. The endpoint's default `filter=latest` already keeps only the newest run of each name
85
+ // *within* a suite (a re-run job lands in the suite it re-runs), so what is left is one run per
86
+ // suite. Across suites GitHub takes the one from the newer suite: PR #2717's cancelled suite was the
87
+ // older one and GitHub read the pull request `CLEAN`, while PR #2698's cancelled suite was the newer
88
+ // one and GitHub held it `BLOCKED`. Ranking by run id alone (joshuafolkken/kit#2644) picked #2698's
89
+ // older, successful suite — its jobs had queued later — so `followup` read green beside a `BLOCKED`
90
+ // pull request and waited out its whole budget (joshuafolkken/kit#2700).
91
+ function is_ranked_above(candidate: RunRank, current: RunRank): boolean {
92
+ if (candidate.suite !== current.suite) return candidate.suite > current.suite
93
+
94
+ return candidate.run > current.run
95
+ }
96
+
97
+ function should_replace(previous: RollupElement | undefined, rank: RunRank): boolean {
98
+ const previous_rank = previous === undefined ? undefined : read_rank(previous)
99
+
100
+ return previous_rank === undefined || is_ranked_above(rank, previous_rank)
60
101
  }
61
102
 
62
- function remember_newest_run(run: RollupElement, latest_ids: Map<string, number>): void {
103
+ function remember_newest_run(run: RollupElement, newest: Map<string, RollupElement>): void {
63
104
  const key = check_run_group_key(run)
64
- const { id } = run
65
- if (key === undefined || typeof id !== 'number') return
66
- const previous_id = latest_ids.get(key)
67
- if (previous_id === undefined || id > previous_id) latest_ids.set(key, id)
105
+ const rank = read_rank(run)
106
+ if (key === undefined || rank === undefined) return
107
+ if (should_replace(newest.get(key), rank)) newest.set(key, run)
68
108
  }
69
109
 
70
- function is_newest_run(run: RollupElement, latest_ids: Map<string, number>): boolean {
110
+ // A run with no name or id cannot be ranked, so it is kept rather than guessed to be superseded.
111
+ function is_newest_run(run: RollupElement, newest: Map<string, RollupElement>): boolean {
71
112
  const key = check_run_group_key(run)
72
- const { id } = run
73
- if (key === undefined || typeof id !== 'number') return true
113
+ if (key === undefined || read_rank(run) === undefined) return true
74
114
 
75
- return id === latest_ids.get(key)
115
+ return newest.get(key) === run
76
116
  }
77
117
 
78
118
  function to_check_run_items(check_runs_json: string): Array<RollupElement> {
79
119
  const pages = read_pages(check_runs_json, check_runs_page_schema, NOT_A_CHECK_RUNS_LISTING)
80
120
  const runs = pages.flatMap((page) => page.check_runs)
81
- const latest_ids = new Map<string, number>()
82
- for (const run of runs) remember_newest_run(run, latest_ids)
121
+ const newest = new Map<string, RollupElement>()
122
+ for (const run of runs) remember_newest_run(run, newest)
83
123
 
84
- return runs.filter((run) => is_newest_run(run, latest_ids))
124
+ return runs.filter((run) => is_newest_run(run, newest))
85
125
  }
86
126
 
87
127
  function to_status_context_items(status_json: string): Array<RollupElement> {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  observation_ledger,
3
- OBSERVATION_LEDGER_PATH,
3
+ OBSERVATION_LEDGER_PATHS,
4
4
  } from '#scripts/observations/observation-ledger'
5
5
  import { git_command } from './git-command'
6
6
  import { git_prompt } from './git-prompt'
@@ -73,10 +73,12 @@ async function stage_untracked_files(files: ReadonlyArray<string>): Promise<void
73
73
  // entirely — a diff a reviewer has no reason to question. Excluding it here covers every entry
74
74
  // point, because this is the one staging step all of them go through;
75
75
  // `pnpm josh observations:flush` is what commits it, on a branch of its own.
76
- const PATHS_EXCLUDED_FROM_STAGING: ReadonlyArray<string> = [OBSERVATION_LEDGER_PATH]
76
+ // Both of its paths are excluded (joshuafolkken/kit#2724): a run on the old code still appends at
77
+ // the old one until `observation-ledger-migrate.ts` moves it.
78
+ const PATHS_EXCLUDED_FROM_STAGING: ReadonlyArray<string> = OBSERVATION_LEDGER_PATHS
77
79
 
78
80
  function is_stageable(file_path: string): boolean {
79
- return !PATHS_EXCLUDED_FROM_STAGING.includes(file_path)
81
+ return !observation_ledger.is_ledger_path(file_path)
80
82
  }
81
83
 
82
84
  // **Said out loud, because the alternative is an unexplained failure.** With the ledger the only
@@ -84,7 +86,7 @@ function is_stageable(file_path: string): boolean {
84
86
  // step dies on git's empty index with `Failed to commit changes` — a message naming nothing that
85
87
  // caused it. This line is what turns that into a diagnosis.
86
88
  //
87
- // **The paths are parsed rather than matched as substrings**: `docs/observations.md.bak` contains the
89
+ // **The paths are parsed rather than matched as substrings**: `docs/maintainers/observations.md.bak` contains the
88
90
  // ledger's path, and a hint naming a file the exclusion never touched is a hint that teaches the
89
91
  // reader to ignore it.
90
92
  function report_excluded_paths(status_output: string): void {
@@ -14,6 +14,12 @@ const FIELD_SEPARATOR = '\u{0}'
14
14
  // stash pushed with `-m`); `%x00` emits the NUL the fields are split on.
15
15
  const LIST_FORMAT = '--format=%gd%x00%gs'
16
16
  const SUBJECT_MESSAGE_SEPARATOR = ': '
17
+ // `%H` is the entry's commit hash; the second format pairs it with the position a drop needs.
18
+ const HASH_FORMAT = '--format=%H%x00%gs'
19
+ const HASH_POSITION_FORMAT = '--format=%H%x00%gd'
20
+ const NAME_ONLY_FLAG = '--name-only'
21
+ const ADDED_PREFIX = '+'
22
+ const FILE_HEADER_PREFIX = '+++'
17
23
 
18
24
  interface StashEntry {
19
25
  selector: string
@@ -84,7 +90,7 @@ async function pop(selector: string, directory?: string): Promise<void> {
84
90
  // by the unmerged paths it leaves: `--diff-filter=U` lists exactly those, empty on any other failure.
85
91
  async function has_conflict(directory?: string): Promise<boolean> {
86
92
  const unmerged = await git_spawn.read(
87
- git_args(directory, ['diff', '--name-only', '--diff-filter=U']),
93
+ git_args(directory, ['diff', NAME_ONLY_FLAG, '--diff-filter=U']),
88
94
  )
89
95
 
90
96
  return unmerged !== ''
@@ -102,6 +108,68 @@ async function push(message: string, directory?: string): Promise<void> {
102
108
  await git_spawn.read(git_args(directory, ['stash', 'push', '-u', '-m', message]))
103
109
  }
104
110
 
111
+ // The stack keyed by commit rather than position (joshuafolkken/kit#2701): a sweep that reads the
112
+ // stack, asks GitHub about each entry and only then drops one would drop whatever another lane pushed
113
+ // on top in the meantime if it kept the `stash@{n}` it read first. The `selector` of each entry here is
114
+ // its commit hash, which never moves.
115
+ async function list_by_hash(directory?: string): Promise<Array<StashEntry>> {
116
+ return parse_entries(await git_spawn.read(git_args(directory, ['stash', 'list', HASH_FORMAT])))
117
+ }
118
+
119
+ // The paths an entry carries, untracked files included — `push` stashes with `-u`.
120
+ async function changed_paths(hash: string, directory?: string): Promise<Array<string>> {
121
+ const raw = await git_spawn.read(
122
+ git_args(directory, ['stash', 'show', '--include-untracked', NAME_ONLY_FLAG, hash]),
123
+ )
124
+
125
+ return raw === '' ? [] : raw.split(ENTRY_SEPARATOR)
126
+ }
127
+
128
+ // A path untracked when the entry was pushed with `-u` lives in its third parent, not in its diff, so
129
+ // every line of it is an added one; a path absent from that parent reads as none.
130
+ async function untracked_lines(
131
+ hash: string,
132
+ file: string,
133
+ directory?: string,
134
+ ): Promise<Array<string>> {
135
+ try {
136
+ const raw = await git_spawn.read(git_args(directory, ['show', `${hash}^3:${file}`]))
137
+
138
+ return raw === '' ? [] : raw.split(ENTRY_SEPARATOR)
139
+ } catch {
140
+ return []
141
+ }
142
+ }
143
+
144
+ // The lines an entry adds to one path, as a unified diff against the commit it was pushed on — or, for
145
+ // a path that was untracked then, the whole file.
146
+ async function added_lines(hash: string, file: string, directory?: string): Promise<Array<string>> {
147
+ const diff = await git_spawn.read(git_args(directory, ['diff', `${hash}^1`, hash, '--', file]))
148
+
149
+ if (diff === '') return await untracked_lines(hash, file, directory)
150
+
151
+ return diff
152
+ .split(ENTRY_SEPARATOR)
153
+ .filter((line) => line.startsWith(ADDED_PREFIX) && !line.startsWith(FILE_HEADER_PREFIX))
154
+ .map((line) => line.slice(ADDED_PREFIX.length))
155
+ }
156
+
157
+ // The position is resolved from the hash immediately before the drop, so an entry pushed since the
158
+ // sweep's read cannot be dropped in its place; an entry already gone answers `false` rather than
159
+ // throwing. Sweeps are serialized by `stash-sweep-lock`, but a plain `git stash push` or `pop` from
160
+ // another command landing between the resolve and the drop still shifts the position — that window is
161
+ // the length of two local git calls.
162
+ async function drop_by_hash(hash: string, directory?: string): Promise<boolean> {
163
+ const raw = await git_spawn.read(git_args(directory, ['stash', 'list', HASH_POSITION_FORMAT]))
164
+ const entry = parse_entries(raw).find((candidate) => candidate.selector === hash)
165
+
166
+ if (entry === undefined) return false
167
+
168
+ await git_spawn.read(git_args(directory, ['stash', 'drop', entry.subject]))
169
+
170
+ return true
171
+ }
172
+
105
173
  // The message uncommitted work is pushed with, keyed on the issue so `josh stash:pop` can target it and
106
174
  // on the occasion so two pushes for one issue — a lane close and an `already-done` read — stay apart.
107
175
  function work_message(issue: string, occasion: string): string {
@@ -109,9 +177,13 @@ function work_message(issue: string, occasion: string): string {
109
177
  }
110
178
 
111
179
  const git_stash = {
180
+ added_lines,
181
+ changed_paths,
182
+ drop_by_hash,
112
183
  has_changes,
113
184
  has_conflict,
114
185
  list,
186
+ list_by_hash,
115
187
  matches,
116
188
  parse_entries,
117
189
  pop,
@@ -13,7 +13,11 @@ import type { StashEntry } from './git-stash'
13
13
  // opens with `N: ` is `git_stash.work_message`'s; and a lane branch (`On N-lane: …`) names it by where
14
14
  // the work was pushed from. A subject is `On <branch>: <message>` (or `WIP on <branch>: …` without
15
15
  // `-m`), and a branch name carries no `:`, so `(?:^|: )` anchors the message start either way.
16
- const MESSAGE_PATTERNS: ReadonlyArray<RegExp> = [/#(?<issue>\d+)\b/u, /(?:^|: )(?<issue>\d+): /u]
16
+ const HASH_PATTERN = /#(?<issue>\d+)\b/u
17
+ const LEADING_PATTERN = /(?:^|: )(?<issue>\d+): /u
18
+ const MESSAGE_PATTERNS: ReadonlyArray<RegExp> = [HASH_PATTERN, LEADING_PATTERN]
19
+ // Every `#N` in the message rather than the first, for `issues_named`.
20
+ const ALL_ISSUE_PATTERN = new RegExp(HASH_PATTERN.source, 'gu')
17
21
  const BRANCH_PATTERN = /^(?:WIP on|On) (?<issue>\d+)-lane: /u
18
22
  // A stash pushed without `-m` lists as `WIP on <branch>: <sha> <HEAD's commit subject>` — text no run
19
23
  // wrote. This repository's commit subjects end in `#N` and merges read `Merge pull request #N`, so a
@@ -61,6 +65,24 @@ function issues_of(entries: ReadonlyArray<StashEntry>): Array<string> {
61
65
  return [...new Set(issues.filter((issue): issue is string => issue !== undefined))]
62
66
  }
63
67
 
68
+ function message_issues(subject: string): Array<string | undefined> {
69
+ const hashed = [...subject.matchAll(ALL_ISSUE_PATTERN)].map((match) => match.groups?.['issue'])
70
+
71
+ return [...hashed, LEADING_PATTERN.exec(subject)?.groups?.['issue']]
72
+ }
73
+
74
+ // Every issue a subject names, not just its owner (joshuafolkken/kit#2701): `backlogrun: paused #M for
75
+ // prerequisite #N` is tied to both, and the `#N` of a `run:hold reclaimed before #N` entry — which
76
+ // `issue_of` reads as no owner — still names the run the work was found by. A `WIP on` subject is
77
+ // read by its branch alone, for the reason `WIP_PREFIX` records.
78
+ function issues_named(subject: string): Array<string> {
79
+ const branch = BRANCH_PATTERN.exec(subject)?.groups?.['issue']
80
+ const named = subject.startsWith(WIP_PREFIX) ? [] : message_issues(subject)
81
+ const found = [...named, branch].filter((issue): issue is string => issue !== undefined)
82
+
83
+ return [...new Set(found)]
84
+ }
85
+
64
86
  // An entry is reported when its issue is in `closed`, or when it names no issue at all — an entry with
65
87
  // no owner is exactly the one no run will ever pop. An open issue's entry is still some run's to resume.
66
88
  function orphans(
@@ -85,7 +107,7 @@ function format_report(found: ReadonlyArray<OrphanStash>): string | undefined {
85
107
  return [HEADER, ...found.map((orphan) => format_line(orphan)), FOOTER].join(LINE_SEPARATOR)
86
108
  }
87
109
 
88
- const stash_orphans = { format_report, issue_of, issues_of, orphans }
110
+ const stash_orphans = { format_report, issue_of, issues_named, issues_of, orphans }
89
111
 
90
112
  export type { OrphanStash }
91
113
  export { stash_orphans }
@@ -0,0 +1,141 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { linkSync, renameSync, rmSync } from 'node:fs'
3
+ import { setTimeout as sleep } from 'node:timers/promises'
4
+ import { process_identity } from '#scripts/josh/process-identity'
5
+ import { stamp_file } from '#scripts/josh/stamp-file'
6
+ import { git_common_directory } from './git-common-directory'
7
+
8
+ // One stash sweep at a time per repository (joshuafolkken/kit#2701). The stash is a single stack every
9
+ // work tree shares, and a `backlogrun` runs one sweep per lane seat at once: two sweeps that each
10
+ // resolve the same entry to `stash@{n}` and drop it would have the second drop land on whichever entry
11
+ // moved into that position, and two that carry the same ledger lines would both append them. The lock
12
+ // is keyed on the git common directory, so every work tree of one repository contends for one record.
13
+
14
+ const LOCK_PREFIX = 'josh-stash-sweep-lock-'
15
+ // The locked phase is local git and one file append per entry, so a holder finishes in well under the
16
+ // wait; the cap only bounds a holder that hung, and a sweep that gives up keeps its entries.
17
+ const POLL_INTERVAL_MS = 200
18
+ const MAX_WAIT_MS = 30_000
19
+
20
+ interface LockOwner {
21
+ pid: number
22
+ process_start?: string
23
+ }
24
+
25
+ function lock_path(cwd: string = process.cwd()): string {
26
+ return stamp_file.stamp_path(LOCK_PREFIX, git_common_directory.repository(cwd) ?? cwd)
27
+ }
28
+
29
+ type IsGone = (owner: LockOwner) => boolean
30
+
31
+ function parse_owner(raw: string | undefined): LockOwner | undefined {
32
+ if (raw === undefined) return undefined
33
+
34
+ try {
35
+ return JSON.parse(raw) as LockOwner
36
+ } catch {
37
+ return undefined
38
+ }
39
+ }
40
+
41
+ function read_owner(target: string): LockOwner | undefined {
42
+ return parse_owner(stamp_file.read_stamp_text(target))
43
+ }
44
+
45
+ function is_owner_gone(owner: LockOwner): boolean {
46
+ return process_identity.is_same_process(owner.pid, owner.process_start) === false
47
+ }
48
+
49
+ // Put back a record that turned out not to be the stale one. `linkSync` refuses an existing path, so
50
+ // a lock someone claimed in the meantime is never overwritten.
51
+ function restore(aside: string, target: string): void {
52
+ try {
53
+ linkSync(aside, target)
54
+ } catch {
55
+ // Another claim already holds the path; the record set aside is dropped below.
56
+ }
57
+ }
58
+
59
+ // The record's text when its owner is certainly gone, else `undefined`.
60
+ function stale_record(target: string, is_gone: IsGone): string | undefined {
61
+ const raw = stamp_file.read_stamp_text(target)
62
+ const owner = parse_owner(raw)
63
+
64
+ return owner !== undefined && is_gone(owner) ? raw : undefined
65
+ }
66
+
67
+ // The path the record was moved to, or `undefined` when another sweep moved it first.
68
+ function move_aside(target: string): string | undefined {
69
+ const aside = `${target}.${String(process.pid)}.${randomUUID()}`
70
+
71
+ try {
72
+ renameSync(target, aside)
73
+
74
+ return aside
75
+ } catch {
76
+ return undefined
77
+ }
78
+ }
79
+
80
+ // A record whose process is certainly gone is a crashed sweep's, and is cleared before the claim; an
81
+ // unreadable record or an uncertain answer is left in place, since clearing a live holder's lock is the
82
+ // very race the lock exists to close. **The clear is a rename, then a compare**: two sweeps can both
83
+ // judge the same record stale, and the slower one's plain remove would delete the lock the faster one
84
+ // had just claimed. Renaming moves exactly one file aside atomically, and only a record byte-identical
85
+ // to the one judged stale is discarded — any other is a fresh claim and goes back.
86
+ function clear_stale(target: string, is_gone: IsGone = is_owner_gone): void {
87
+ const raw = stale_record(target, is_gone)
88
+
89
+ if (raw === undefined) return
90
+
91
+ const aside = move_aside(target)
92
+
93
+ if (aside === undefined) return
94
+ if (stamp_file.read_stamp_text(aside) !== raw) restore(aside, target)
95
+ rmSync(aside, { force: true })
96
+ }
97
+
98
+ function claim(target: string): boolean {
99
+ clear_stale(target)
100
+
101
+ return stamp_file.create_stamp(target, process_identity.own_fields())
102
+ }
103
+
104
+ function release(target: string): void {
105
+ const owner = read_owner(target)
106
+
107
+ if (owner !== undefined && process_identity.is_own_process(owner.pid, owner.process_start)) {
108
+ stamp_file.remove_stamp(target)
109
+ }
110
+ }
111
+
112
+ async function acquire(target: string, max_wait_ms: number): Promise<boolean> {
113
+ const deadline = Date.now() + max_wait_ms
114
+
115
+ while (!claim(target)) {
116
+ if (Date.now() >= deadline) return false
117
+ await sleep(POLL_INTERVAL_MS)
118
+ }
119
+
120
+ return true
121
+ }
122
+
123
+ // Runs `work` holding the lock and answers its result, or `undefined` without running it when another
124
+ // sweep held the lock for the whole wait.
125
+ async function with_lock<T>(
126
+ work: () => Promise<T>,
127
+ target: string = lock_path(),
128
+ max_wait_ms: number = MAX_WAIT_MS,
129
+ ): Promise<T | undefined> {
130
+ if (!(await acquire(target, max_wait_ms))) return undefined
131
+
132
+ try {
133
+ return await work()
134
+ } finally {
135
+ release(target)
136
+ }
137
+ }
138
+
139
+ const stash_sweep_lock = { clear_stale, lock_path, with_lock }
140
+
141
+ export { stash_sweep_lock }
@@ -8,6 +8,7 @@ import { hook_decision } from '#scripts/josh/hook-decision'
8
8
  import { session_language } from '#scripts/josh/session-language'
9
9
  import { lane_child_marker } from '#scripts/lane/lane-child-marker'
10
10
  import { filing_cap } from '#scripts/rules/filing-cap'
11
+ import { lane_background } from '#scripts/rules/lane-background'
11
12
  import { lane_park } from '#scripts/rules/lane-park'
12
13
  import { stop_rules, type StopContext, type StopOutcome } from '#scripts/rules/stop-rules'
13
14
  import { run_cut } from '#scripts/run/run-cut'
@@ -76,6 +77,7 @@ async function build_context(
76
77
  headless_waiting: await run_headless.must_keep_waiting(),
77
78
  headless_refusals: stop_rules.count_headless_refusals(tail),
78
79
  lane_child: lane_child_marker.is_child_of(process.cwd()),
80
+ background_pending: lane_background.pending_background_ids(tail).length > 0,
79
81
  session_lang: session_lang(),
80
82
  }
81
83
  }
@@ -218,8 +218,9 @@ function build_file_actions(shape?: ProjectShape): ReadonlyArray<FileAction> {
218
218
  build_eslint_action(),
219
219
  build_action(
220
220
  PRETTIER_CONFIG_JS,
221
- () => init_logic.generate_prettier_config(),
222
- (existing) => init_logic.merge_prettier_config(existing),
221
+ () =>
222
+ init_logic.generate_prettier_config(init_logic.detect_tailwind_stylesheet(PROJECT_ROOT)),
223
+ (existing) => init_logic.merge_prettier_config(existing, PROJECT_ROOT),
223
224
  ),
224
225
  build_playwright_action(),
225
226
  ...build_config_file_actions().filter(
@@ -1,3 +1,6 @@
1
+ import { safe_chain_preinstall } from '#scripts/safe-chain/preinstall-command'
2
+
3
+ const PREINSTALL_KEY = 'preinstall'
1
4
  const JF_PREFIX = 'jf-'
2
5
  const JOSH_PREFIX = 'josh '
3
6
 
@@ -47,9 +50,15 @@ function migrate_jf_value(value: string): string {
47
50
  return `${JOSH_PREFIX}${value.slice(JF_PREFIX.length)}`
48
51
  }
49
52
 
53
+ function migrate_script(key: string, value: string): string {
54
+ const migrated = migrate_jf_value(value)
55
+
56
+ return key === PREINSTALL_KEY ? safe_chain_preinstall.migrate_preinstall(migrated) : migrated
57
+ }
58
+
50
59
  function apply_jf_migrations(scripts: Record<string, string>): Record<string, string> {
51
60
  return Object.fromEntries(
52
- Object.entries(scripts).map(([key, value]) => [key, migrate_jf_value(value)]),
61
+ Object.entries(scripts).map(([key, value]) => [key, migrate_script(key, value)]),
53
62
  )
54
63
  }
55
64
 
@@ -1,4 +1,5 @@
1
- import { readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import path from 'node:path'
2
3
  import { package_path } from './init-paths'
3
4
 
4
5
  const ESLINT_VANILLA = `import { create_vanilla_config } from '@joshuafolkken/kit/eslint/vanilla'
@@ -111,20 +112,38 @@ function merge_eslint_config(existing: string): string {
111
112
  return apply_rules_template(rules_blocks.join(',\n'))
112
113
  }
113
114
 
114
- function generate_prettier_config(stylesheet: string = DEFAULT_TAILWIND_STYLESHEET): string {
115
+ // The default is SvelteKit's stylesheet, and prettier-plugin-tailwindcss aborts every format when
116
+ // the named file is missing — so it is written only for a project that has it (joshuafolkken/kit#2710).
117
+ function detect_tailwind_stylesheet(project_root: string): string | undefined {
118
+ return existsSync(path.join(project_root, DEFAULT_TAILWIND_STYLESHEET))
119
+ ? DEFAULT_TAILWIND_STYLESHEET
120
+ : undefined
121
+ }
122
+
123
+ function generate_prettier_config(stylesheet?: string): string {
124
+ const stylesheet_line = stylesheet === undefined ? '' : `\ttailwindStylesheet: '${stylesheet}',\n`
125
+
115
126
  return `import { config } from '@joshuafolkken/kit/prettier'
116
127
 
117
128
  export default {
118
129
  \t...config,
119
- \ttailwindStylesheet: '${stylesheet}',
120
- }
130
+ ${stylesheet_line}}
121
131
  `
122
132
  }
123
133
 
124
- function merge_prettier_config(existing: string): string {
125
- const match = TAILWIND_STYLESHEET_PATTERN.exec(existing)
134
+ // A named stylesheet is kept only while it exists: kit ≤1.929 wrote the SvelteKit default into every
135
+ // node project, and keeping that line would leave `josh sync` unable to repair the aborting format.
136
+ function existing_stylesheet(existing: string, project_root: string): string | undefined {
137
+ const named = TAILWIND_STYLESHEET_PATTERN.exec(existing)?.[1]
138
+
139
+ return named !== undefined && existsSync(path.resolve(project_root, named)) ? named : undefined
140
+ }
141
+
142
+ function merge_prettier_config(existing: string, project_root: string): string {
143
+ const stylesheet =
144
+ existing_stylesheet(existing, project_root) ?? detect_tailwind_stylesheet(project_root)
126
145
 
127
- return generate_prettier_config(match?.[1] ?? DEFAULT_TAILWIND_STYLESHEET)
146
+ return generate_prettier_config(stylesheet)
128
147
  }
129
148
 
130
149
  function generate_playwright_config(): string {
@@ -134,6 +153,7 @@ function generate_playwright_config(): string {
134
153
  const init_logic_templates = {
135
154
  generate_eslint_config,
136
155
  merge_eslint_config,
156
+ detect_tailwind_stylesheet,
137
157
  generate_prettier_config,
138
158
  merge_prettier_config,
139
159
  generate_playwright_config,
@@ -75,10 +75,61 @@ function answer_build_placeholders(existing: string, template: string): string {
75
75
  })
76
76
  }
77
77
 
78
+ const BUILD_ENTRY_KEY_PATTERN = /^[ \t]+(?<key>[^\s:#][^:#]*):/u
79
+
80
+ function build_entry_key(line: string): string | undefined {
81
+ const key = BUILD_ENTRY_KEY_PATTERN.exec(line)?.groups?.['key']
82
+
83
+ return key === undefined ? undefined : unquote(key)
84
+ }
85
+
86
+ function build_entry_keys(block: string): Set<string> {
87
+ return new Set(block.split('\n').flatMap((line) => build_entry_key(line) ?? []))
88
+ }
89
+
90
+ // The key-level merge keeps an existing `allowBuilds` whole, and `pnpm add -D @joshuafolkken/kit`
91
+ // before `josh init` always leaves one (esbuild's), so without this the template's other approvals
92
+ // never landed and the first `pnpm install` failed on the next build script, e.g. unrs-resolver's
93
+ // (joshuafolkken/kit#2710). Entries the project already answered keep their value.
94
+ // Only a block mapping can take appended entry lines; a flow map (`allowBuilds: { … }`) is left whole.
95
+ const BLOCK_MAPPING_HEADER_PATTERN = /^allowBuilds:[ \t]*(?:#[^\n]*)?(?:\n|$)/u
96
+ const LEADING_INDENT_PATTERN = /^[ \t]+/u
97
+
98
+ // Appended lines take the indent of the block's own entries: a mapping whose items start at
99
+ // different columns is invalid YAML, so a four-space block must not gain two-space lines.
100
+ function block_entry_indent(block: string): string | undefined {
101
+ const entry = block.split('\n').find((line) => build_entry_key(line) !== undefined)
102
+
103
+ return entry === undefined ? undefined : LEADING_INDENT_PATTERN.exec(entry)?.[0]
104
+ }
105
+
106
+ function reindent(line: string, indent: string | undefined): string {
107
+ return indent === undefined ? line : line.replace(LEADING_INDENT_PATTERN, () => indent)
108
+ }
109
+
110
+ function add_missing_build_entries(existing: string, template: string): string {
111
+ const block = extract_yaml_block(existing, 'allowBuilds')
112
+ if (!BLOCK_MAPPING_HEADER_PATTERN.test(block)) return existing
113
+ const present = build_entry_keys(block)
114
+ const indent = block_entry_indent(block)
115
+ const missing = extract_yaml_block(template, 'allowBuilds')
116
+ .split('\n')
117
+ .filter((line) => {
118
+ const key = build_entry_key(line)
119
+
120
+ return key !== undefined && !present.has(key)
121
+ })
122
+ .map((line) => reindent(line, indent))
123
+ if (missing.length === 0) return existing
124
+
125
+ return existing.replace(block, () => [block, ...missing].join('\n'))
126
+ }
127
+
78
128
  function merge_workspace_yaml(existing: string, template: string): string {
79
129
  if (!existing.trim()) return template
80
130
  const normalized = existing.endsWith('\n') ? existing : `${existing}\n`
81
- const cleaned = answer_build_placeholders(remove_deprecated_yaml_keys(normalized), template)
131
+ const answered = answer_build_placeholders(remove_deprecated_yaml_keys(normalized), template)
132
+ const cleaned = add_missing_build_entries(answered, template)
82
133
  if (!cleaned.trim()) return template
83
134
  const existing_keys = new Set(extract_yaml_top_level_keys(cleaned))
84
135
  const new_keys = extract_yaml_top_level_keys(template).filter((k) => !existing_keys.has(k))