@joshuafolkken/kit 1.947.0 → 1.953.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @joshuafolkken/kit
2
2
 
3
- A development toolkit that sets up a repository for AI-assisted work with one command. `josh init` gives an AI assistant (Claude Code, Codex, Gemini, Cursor) the project's rules, and the `josh` CLI runs the checks and the Git / GitHub Issue workflow the same way in every project. You stop copying config between repositories and re-explaining conventions to the assistant.
3
+ A development toolkit that sets up a repository for AI-assisted work with one command. `josh init` gives an AI assistant (Claude Code, Codex, Gemini, Cursor) the project's rules, and the `josh` CLI runs the checks and the Git / GitHub Issue workflow the same way in every project. You stop copying config between repositories and re-explaining conventions to the assistant. For the problems kit exists to solve, see [why.md](./docs/why.md) (Japanese).
4
4
 
5
5
  ## What your project gets
6
6
 
@@ -34,11 +34,8 @@ It needs [Node.js](https://nodejs.org/) 22.19.0 or later with [pnpm](https://pnp
34
34
 
35
35
  - [getting-started.md](./docs/getting-started.md) — first install for an `index.html` site or a non-Node project (`static`)
36
36
  - [package.md](./docs/package.md) — add kit to a Node project (`node`)
37
+ - [tutorial.md](./docs/tutorial.md) — your first change with an agent, from Issue to merge
37
38
  - [how-to.md](./docs/how-to.md) — step-by-step guides by task: update, release, run Issues, fix a failing gate
38
-
39
- **Understand kit**
40
-
41
- - [why.md](./docs/why.md) — the problems kit exists to solve
42
39
  - [overview.md](./docs/overview.md) — what kit sets up and how it works
43
40
 
44
41
  **Commands and configuration**
@@ -1906,8 +1906,8 @@ function current_owner(repository_path = process.cwd()) {
1906
1906
  var repo_party = { FIRST_PARTY, THIRD_PARTY, UNKNOWN, classify: classify3, current_owner, target_owner };
1907
1907
 
1908
1908
  // scripts/lane/lane-registry.ts
1909
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
1910
- import path2 from "node:path";
1909
+ import { existsSync as existsSync2, readFileSync as readFileSync4 } from "node:fs";
1910
+ import path3 from "node:path";
1911
1911
 
1912
1912
  // scripts/git/git-ls-remote.ts
1913
1913
  var LS_REMOTE_COMMAND = "ls-remote";
@@ -1918,23 +1918,323 @@ function ls_remote_branch_arguments(branch_name) {
1918
1918
  return [LS_REMOTE_COMMAND, HEADS_FLAG, ORIGIN_REMOTE, `${HEADS_REF_PREFIX}${branch_name}`];
1919
1919
  }
1920
1920
 
1921
+ // scripts/git/repository-lock.ts
1922
+ import { randomUUID as randomUUID2 } from "node:crypto";
1923
+ import { linkSync, renameSync, rmSync as rmSync2 } from "node:fs";
1924
+ import { setTimeout as sleep } from "node:timers/promises";
1925
+
1926
+ // scripts/josh/process-identity.ts
1927
+ import { execFileSync as execFileSync2, spawnSync } from "node:child_process";
1928
+ import { randomUUID } from "node:crypto";
1929
+ import { readFileSync as readFileSync3, rmSync } from "node:fs";
1930
+ import { createServer } from "node:net";
1931
+ import path2 from "node:path";
1932
+ var PS_COMMAND = "/bin/ps";
1933
+ var FORMAT_FLAG = "-o";
1934
+ var START_TIME_FORMAT = "lstart=";
1935
+ var PID_FLAG = "-p";
1936
+ var PROBE_ENVIRONMENT = { TZ: "UTC", LC_ALL: "C" };
1937
+ var PROBE_TIMEOUT_MS = 2e3;
1938
+ var LIVENESS_SIGNAL = 0;
1939
+ var LOWEST_REAL_PID = 1;
1940
+ var PROC_START_FIELD_INDEX = 19;
1941
+ var BEACON_SCHEME = "socket:";
1942
+ var BEACON_SUFFIX = ".sock";
1943
+ var BEACON_PREFIX = `josh-process-identity-${String(process.getuid?.() ?? "")}-`;
1944
+ var BEACON_ID_LENGTH = 36;
1945
+ var BEACON_ID_PATTERN = /^[\da-f-]+$/u;
1946
+ var WINDOWS_PLATFORM = "win32";
1947
+ var SOCKET_PROBE_SOURCE = "const net=require('node:net');const socket=net.createConnection(process.argv[1]);socket.once('connect',()=>process.exit(0));socket.once('error',()=>process.exit(1));socket.setTimeout(1000,()=>process.exit(1));";
1948
+ var beacons = /* @__PURE__ */ new Map();
1949
+ var exit_cleanup = { is_registered: false };
1950
+ function is_live_pid(pid) {
1951
+ if (pid < LOWEST_REAL_PID) return false;
1952
+ try {
1953
+ process.kill(pid, LIVENESS_SIGNAL);
1954
+ return true;
1955
+ } catch {
1956
+ return false;
1957
+ }
1958
+ }
1959
+ function read_proc_start(pid) {
1960
+ try {
1961
+ const stat = readFileSync3(`/proc/${String(pid)}/stat`, "utf8");
1962
+ const fields = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/u);
1963
+ const start = fields[PROC_START_FIELD_INDEX];
1964
+ return start === void 0 ? void 0 : `proc:${start}`;
1965
+ } catch {
1966
+ return void 0;
1967
+ }
1968
+ }
1969
+ function read_ps_start(pid) {
1970
+ try {
1971
+ const printed = execFileSync2(
1972
+ PS_COMMAND,
1973
+ [FORMAT_FLAG, START_TIME_FORMAT, PID_FLAG, String(pid)],
1974
+ {
1975
+ encoding: "utf8",
1976
+ timeout: PROBE_TIMEOUT_MS,
1977
+ stdio: ["ignore", "pipe", "ignore"],
1978
+ env: PROBE_ENVIRONMENT
1979
+ }
1980
+ ).trim();
1981
+ return printed.length > 0 ? printed : void 0;
1982
+ } catch {
1983
+ return void 0;
1984
+ }
1985
+ }
1986
+ var START_PROBES = { read_proc_start, read_ps_start };
1987
+ function safe_probe(probe, pid) {
1988
+ try {
1989
+ return probe(pid);
1990
+ } catch {
1991
+ return void 0;
1992
+ }
1993
+ }
1994
+ function read_start(pid, probes = START_PROBES) {
1995
+ return safe_probe(probes.read_proc_start, pid) ?? safe_probe(probes.read_ps_start, pid);
1996
+ }
1997
+ function close_beacon(token) {
1998
+ const server = beacons.get(token);
1999
+ if (server === void 0) return;
2000
+ beacons.delete(token);
2001
+ server.close();
2002
+ rmSync(token.slice(BEACON_SCHEME.length), { force: true });
2003
+ }
2004
+ function close_beacons() {
2005
+ for (const token of beacons.keys()) close_beacon(token);
2006
+ }
2007
+ function ensure_exit_cleanup() {
2008
+ if (exit_cleanup.is_registered) return;
2009
+ exit_cleanup.is_registered = true;
2010
+ process.once("exit", close_beacons);
2011
+ }
2012
+ function ignore_beacon_error() {
2013
+ }
2014
+ function bind_beacon(target) {
2015
+ return createServer().on("error", ignore_beacon_error).listen(target);
2016
+ }
2017
+ function try_bind_beacon(bind, target) {
2018
+ try {
2019
+ const server = bind(target);
2020
+ return server.listening ? server : void 0;
2021
+ } catch {
2022
+ return void 0;
2023
+ }
2024
+ }
2025
+ function open_beacon(bind = bind_beacon) {
2026
+ const target = path2.join(PLATFORM_TEMP_ROOT, `${BEACON_PREFIX}${randomUUID()}${BEACON_SUFFIX}`);
2027
+ const token = `${BEACON_SCHEME}${target}`;
2028
+ const server = try_bind_beacon(bind, target);
2029
+ if (server === void 0) return void 0;
2030
+ server.unref();
2031
+ beacons.set(token, server);
2032
+ ensure_exit_cleanup();
2033
+ return token;
2034
+ }
2035
+ function resolve_own_start(probes = START_PROBES, platform2 = process.platform) {
2036
+ const start = read_start(process.pid, probes);
2037
+ if (start !== void 0 || platform2 === WINDOWS_PLATFORM) return start;
2038
+ return open_beacon();
2039
+ }
2040
+ function is_valid_beacon_name(name) {
2041
+ if (!name.startsWith(BEACON_PREFIX) || !name.endsWith(BEACON_SUFFIX)) return false;
2042
+ const id = name.slice(BEACON_PREFIX.length, -BEACON_SUFFIX.length);
2043
+ return id.length === BEACON_ID_LENGTH && BEACON_ID_PATTERN.test(id);
2044
+ }
2045
+ function beacon_target(token) {
2046
+ if (!token.startsWith(BEACON_SCHEME)) return void 0;
2047
+ const target = token.slice(BEACON_SCHEME.length);
2048
+ if (path2.dirname(target) !== PLATFORM_TEMP_ROOT) return void 0;
2049
+ return is_valid_beacon_name(path2.basename(target)) ? target : void 0;
2050
+ }
2051
+ function is_live_beacon(target) {
2052
+ return spawnSync(process.execPath, ["-e", SOCKET_PROBE_SOURCE, target], {
2053
+ stdio: "ignore",
2054
+ timeout: PROBE_TIMEOUT_MS
2055
+ }).status === 0;
2056
+ }
2057
+ function has_matching_start(pid, recorded_start, read2) {
2058
+ const observed = read2(pid);
2059
+ return observed === void 0 ? void 0 : observed === recorded_start;
2060
+ }
2061
+ function matches_recorded_start(pid, recorded_start, read2) {
2062
+ const target = beacon_target(recorded_start);
2063
+ return target === void 0 ? has_matching_start(pid, recorded_start, read2) : is_live_beacon(target);
2064
+ }
2065
+ var own_probe = /* @__PURE__ */ new Map();
2066
+ function own_start() {
2067
+ if (!own_probe.has(process.pid)) own_probe.set(process.pid, resolve_own_start());
2068
+ return own_probe.get(process.pid);
2069
+ }
2070
+ function own_fields() {
2071
+ const start = own_start();
2072
+ return { pid: process.pid, ...start !== void 0 && { process_start: start } };
2073
+ }
2074
+ function is_same_process(pid, recorded_start, read2 = read_start) {
2075
+ if (pid === void 0 || !is_live_pid(pid)) return false;
2076
+ if (recorded_start === void 0) return void 0;
2077
+ return matches_recorded_start(pid, recorded_start, read2);
2078
+ }
2079
+ function is_own_process(pid, recorded_start) {
2080
+ return pid === process.pid && recorded_start === own_start();
2081
+ }
2082
+ var process_identity = {
2083
+ close_beacon,
2084
+ is_live_pid,
2085
+ is_own_process,
2086
+ is_same_process,
2087
+ open_beacon,
2088
+ own_fields,
2089
+ own_start,
2090
+ read_start,
2091
+ resolve_own_start
2092
+ };
2093
+
2094
+ // scripts/git/git-common-directory.ts
2095
+ import { execFileSync as execFileSync3 } from "node:child_process";
2096
+ var OWN_GIT_DIRECTORY = 0;
2097
+ var COMMON_GIT_DIRECTORY = 1;
2098
+ var PROBE_TIMEOUT_MS2 = 2e3;
2099
+ function read_directories(cwd) {
2100
+ const git = git_utilities.get_git_command_for_spawn();
2101
+ const output = execFileSync3(git, [...git_command.GIT_DIRECTORY_ARGUMENTS], {
2102
+ cwd,
2103
+ encoding: "utf8",
2104
+ stdio: ["ignore", "pipe", "ignore"],
2105
+ timeout: PROBE_TIMEOUT_MS2
2106
+ });
2107
+ return output.trim().split("\n");
2108
+ }
2109
+ function select_linked(directories) {
2110
+ const own = directories[OWN_GIT_DIRECTORY];
2111
+ const common = directories[COMMON_GIT_DIRECTORY];
2112
+ return own !== void 0 && common !== void 0 && own !== common ? common : void 0;
2113
+ }
2114
+ function resolve2(cwd) {
2115
+ try {
2116
+ return select_linked(read_directories(cwd));
2117
+ } catch {
2118
+ return void 0;
2119
+ }
2120
+ }
2121
+ function repository(cwd) {
2122
+ try {
2123
+ return read_directories(cwd)[COMMON_GIT_DIRECTORY];
2124
+ } catch {
2125
+ return void 0;
2126
+ }
2127
+ }
2128
+ var git_common_directory = { repository, resolve: resolve2, select_linked };
2129
+
2130
+ // scripts/git/repository-lock.ts
2131
+ var POLL_INTERVAL_MS = 200;
2132
+ function lock_path(prefix, cwd = process.cwd()) {
2133
+ return stamp_file.stamp_path(prefix, git_common_directory.repository(cwd) ?? cwd);
2134
+ }
2135
+ function parse_owner(raw) {
2136
+ if (raw === void 0) return void 0;
2137
+ try {
2138
+ return JSON.parse(raw);
2139
+ } catch {
2140
+ return void 0;
2141
+ }
2142
+ }
2143
+ function read_owner(target) {
2144
+ return parse_owner(stamp_file.read_stamp_text(target));
2145
+ }
2146
+ function is_owner_gone(owner) {
2147
+ return process_identity.is_same_process(owner.pid, owner.process_start) === false;
2148
+ }
2149
+ function restore(aside, target) {
2150
+ try {
2151
+ linkSync(aside, target);
2152
+ } catch {
2153
+ }
2154
+ }
2155
+ function stale_record(target, is_gone2) {
2156
+ const raw = stamp_file.read_stamp_text(target);
2157
+ const owner = parse_owner(raw);
2158
+ return owner !== void 0 && is_gone2(owner) ? raw : void 0;
2159
+ }
2160
+ function move_aside(target) {
2161
+ const aside = `${target}.${String(process.pid)}.${randomUUID2()}`;
2162
+ try {
2163
+ renameSync(target, aside);
2164
+ return aside;
2165
+ } catch {
2166
+ return void 0;
2167
+ }
2168
+ }
2169
+ function clear_stale(target, is_gone2 = is_owner_gone) {
2170
+ const raw = stale_record(target, is_gone2);
2171
+ if (raw === void 0) return;
2172
+ const aside = move_aside(target);
2173
+ if (aside === void 0) return;
2174
+ if (stamp_file.read_stamp_text(aside) !== raw) restore(aside, target);
2175
+ rmSync2(aside, { force: true });
2176
+ }
2177
+ function claim(target) {
2178
+ clear_stale(target);
2179
+ return stamp_file.create_stamp(target, process_identity.own_fields());
2180
+ }
2181
+ function release(target) {
2182
+ const owner = read_owner(target);
2183
+ if (owner !== void 0 && process_identity.is_own_process(owner.pid, owner.process_start)) {
2184
+ stamp_file.remove_stamp(target);
2185
+ }
2186
+ }
2187
+ async function acquire(target, max_wait_ms) {
2188
+ const deadline = Date.now() + max_wait_ms;
2189
+ while (!claim(target)) {
2190
+ if (Date.now() >= deadline) return false;
2191
+ await sleep(POLL_INTERVAL_MS);
2192
+ }
2193
+ return true;
2194
+ }
2195
+ async function with_lock(work, target, max_wait_ms) {
2196
+ if (!await acquire(target, max_wait_ms)) return void 0;
2197
+ try {
2198
+ return await work();
2199
+ } finally {
2200
+ release(target);
2201
+ }
2202
+ }
2203
+ var repository_lock = { clear_stale, lock_path, with_lock };
2204
+
1921
2205
  // scripts/git/git-worktree.ts
1922
2206
  var WORKTREE = "worktree";
2207
+ var WORKTREE_LOCK_PREFIX = "josh-worktree-lock-";
2208
+ var WORKTREE_LOCK_MAX_WAIT_MS = 12e4;
2209
+ async function locked(arguments_) {
2210
+ const target = repository_lock.lock_path(WORKTREE_LOCK_PREFIX);
2211
+ const output = await repository_lock.with_lock(
2212
+ async () => await git_spawn.read(arguments_),
2213
+ target,
2214
+ WORKTREE_LOCK_MAX_WAIT_MS
2215
+ );
2216
+ if (output === void 0) {
2217
+ throw new Error(
2218
+ `git ${arguments_.join(" ")}: timed out waiting for the work-tree lock ${target}`
2219
+ );
2220
+ }
2221
+ return output;
2222
+ }
1923
2223
  async function worktree_list() {
1924
2224
  return await git_spawn.read([WORKTREE, "list", PORCELAIN_FLAG]);
1925
2225
  }
1926
2226
  async function worktree_add(directory, branch_name, start_point) {
1927
2227
  const flags = start_point === void 0 ? ["add"] : ["add", "--no-track", "-b", branch_name];
1928
- return await git_spawn.read([WORKTREE, ...flags, directory, start_point ?? branch_name]);
2228
+ return await locked([WORKTREE, ...flags, directory, start_point ?? branch_name]);
1929
2229
  }
1930
2230
  async function worktree_add_detached(directory, commit2) {
1931
- return await git_spawn.read([WORKTREE, "add", "--detach", directory, commit2]);
2231
+ return await locked([WORKTREE, "add", "--detach", directory, commit2]);
1932
2232
  }
1933
2233
  async function worktree_remove(directory) {
1934
- return await git_spawn.read([WORKTREE, "remove", "--force", directory]);
2234
+ return await locked([WORKTREE, "remove", "--force", directory]);
1935
2235
  }
1936
2236
  async function worktree_prune() {
1937
- return await git_spawn.read([WORKTREE, "prune"]);
2237
+ return await locked([WORKTREE, "prune"]);
1938
2238
  }
1939
2239
  async function ls_remote_branch(branch_name) {
1940
2240
  return await git_spawn.read(ls_remote_branch_arguments(branch_name));
@@ -2097,7 +2397,7 @@ function held_seat(content) {
2097
2397
  }
2098
2398
  function read_environment(directory) {
2099
2399
  try {
2100
- return readFileSync3(path2.join(directory, ENV_FILE_NAME), "utf8");
2400
+ return readFileSync4(path3.join(directory, ENV_FILE_NAME), "utf8");
2101
2401
  } catch {
2102
2402
  return void 0;
2103
2403
  }
@@ -2463,174 +2763,6 @@ var run_invocation = {
2463
2763
  rebuild
2464
2764
  };
2465
2765
 
2466
- // scripts/josh/process-identity.ts
2467
- import { execFileSync as execFileSync2, spawnSync } from "node:child_process";
2468
- import { randomUUID } from "node:crypto";
2469
- import { readFileSync as readFileSync4, rmSync } from "node:fs";
2470
- import { createServer } from "node:net";
2471
- import path3 from "node:path";
2472
- var PS_COMMAND = "/bin/ps";
2473
- var FORMAT_FLAG = "-o";
2474
- var START_TIME_FORMAT = "lstart=";
2475
- var PID_FLAG = "-p";
2476
- var PROBE_ENVIRONMENT = { TZ: "UTC", LC_ALL: "C" };
2477
- var PROBE_TIMEOUT_MS = 2e3;
2478
- var LIVENESS_SIGNAL = 0;
2479
- var LOWEST_REAL_PID = 1;
2480
- var PROC_START_FIELD_INDEX = 19;
2481
- var BEACON_SCHEME = "socket:";
2482
- var BEACON_SUFFIX = ".sock";
2483
- var BEACON_PREFIX = `josh-process-identity-${String(process.getuid?.() ?? "")}-`;
2484
- var BEACON_ID_LENGTH = 36;
2485
- var BEACON_ID_PATTERN = /^[\da-f-]+$/u;
2486
- var WINDOWS_PLATFORM = "win32";
2487
- var SOCKET_PROBE_SOURCE = "const net=require('node:net');const socket=net.createConnection(process.argv[1]);socket.once('connect',()=>process.exit(0));socket.once('error',()=>process.exit(1));socket.setTimeout(1000,()=>process.exit(1));";
2488
- var beacons = /* @__PURE__ */ new Map();
2489
- var exit_cleanup = { is_registered: false };
2490
- function is_live_pid(pid) {
2491
- if (pid < LOWEST_REAL_PID) return false;
2492
- try {
2493
- process.kill(pid, LIVENESS_SIGNAL);
2494
- return true;
2495
- } catch {
2496
- return false;
2497
- }
2498
- }
2499
- function read_proc_start(pid) {
2500
- try {
2501
- const stat = readFileSync4(`/proc/${String(pid)}/stat`, "utf8");
2502
- const fields = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/u);
2503
- const start = fields[PROC_START_FIELD_INDEX];
2504
- return start === void 0 ? void 0 : `proc:${start}`;
2505
- } catch {
2506
- return void 0;
2507
- }
2508
- }
2509
- function read_ps_start(pid) {
2510
- try {
2511
- const printed = execFileSync2(
2512
- PS_COMMAND,
2513
- [FORMAT_FLAG, START_TIME_FORMAT, PID_FLAG, String(pid)],
2514
- {
2515
- encoding: "utf8",
2516
- timeout: PROBE_TIMEOUT_MS,
2517
- stdio: ["ignore", "pipe", "ignore"],
2518
- env: PROBE_ENVIRONMENT
2519
- }
2520
- ).trim();
2521
- return printed.length > 0 ? printed : void 0;
2522
- } catch {
2523
- return void 0;
2524
- }
2525
- }
2526
- var START_PROBES = { read_proc_start, read_ps_start };
2527
- function safe_probe(probe, pid) {
2528
- try {
2529
- return probe(pid);
2530
- } catch {
2531
- return void 0;
2532
- }
2533
- }
2534
- function read_start(pid, probes = START_PROBES) {
2535
- return safe_probe(probes.read_proc_start, pid) ?? safe_probe(probes.read_ps_start, pid);
2536
- }
2537
- function close_beacon(token) {
2538
- const server = beacons.get(token);
2539
- if (server === void 0) return;
2540
- beacons.delete(token);
2541
- server.close();
2542
- rmSync(token.slice(BEACON_SCHEME.length), { force: true });
2543
- }
2544
- function close_beacons() {
2545
- for (const token of beacons.keys()) close_beacon(token);
2546
- }
2547
- function ensure_exit_cleanup() {
2548
- if (exit_cleanup.is_registered) return;
2549
- exit_cleanup.is_registered = true;
2550
- process.once("exit", close_beacons);
2551
- }
2552
- function ignore_beacon_error() {
2553
- }
2554
- function bind_beacon(target) {
2555
- return createServer().on("error", ignore_beacon_error).listen(target);
2556
- }
2557
- function try_bind_beacon(bind, target) {
2558
- try {
2559
- const server = bind(target);
2560
- return server.listening ? server : void 0;
2561
- } catch {
2562
- return void 0;
2563
- }
2564
- }
2565
- function open_beacon(bind = bind_beacon) {
2566
- const target = path3.join(PLATFORM_TEMP_ROOT, `${BEACON_PREFIX}${randomUUID()}${BEACON_SUFFIX}`);
2567
- const token = `${BEACON_SCHEME}${target}`;
2568
- const server = try_bind_beacon(bind, target);
2569
- if (server === void 0) return void 0;
2570
- server.unref();
2571
- beacons.set(token, server);
2572
- ensure_exit_cleanup();
2573
- return token;
2574
- }
2575
- function resolve_own_start(probes = START_PROBES, platform2 = process.platform) {
2576
- const start = read_start(process.pid, probes);
2577
- if (start !== void 0 || platform2 === WINDOWS_PLATFORM) return start;
2578
- return open_beacon();
2579
- }
2580
- function is_valid_beacon_name(name) {
2581
- if (!name.startsWith(BEACON_PREFIX) || !name.endsWith(BEACON_SUFFIX)) return false;
2582
- const id = name.slice(BEACON_PREFIX.length, -BEACON_SUFFIX.length);
2583
- return id.length === BEACON_ID_LENGTH && BEACON_ID_PATTERN.test(id);
2584
- }
2585
- function beacon_target(token) {
2586
- if (!token.startsWith(BEACON_SCHEME)) return void 0;
2587
- const target = token.slice(BEACON_SCHEME.length);
2588
- if (path3.dirname(target) !== PLATFORM_TEMP_ROOT) return void 0;
2589
- return is_valid_beacon_name(path3.basename(target)) ? target : void 0;
2590
- }
2591
- function is_live_beacon(target) {
2592
- return spawnSync(process.execPath, ["-e", SOCKET_PROBE_SOURCE, target], {
2593
- stdio: "ignore",
2594
- timeout: PROBE_TIMEOUT_MS
2595
- }).status === 0;
2596
- }
2597
- function has_matching_start(pid, recorded_start, read2) {
2598
- const observed = read2(pid);
2599
- return observed === void 0 ? void 0 : observed === recorded_start;
2600
- }
2601
- function matches_recorded_start(pid, recorded_start, read2) {
2602
- const target = beacon_target(recorded_start);
2603
- return target === void 0 ? has_matching_start(pid, recorded_start, read2) : is_live_beacon(target);
2604
- }
2605
- var own_probe = /* @__PURE__ */ new Map();
2606
- function own_start() {
2607
- if (!own_probe.has(process.pid)) own_probe.set(process.pid, resolve_own_start());
2608
- return own_probe.get(process.pid);
2609
- }
2610
- function own_fields() {
2611
- const start = own_start();
2612
- return { pid: process.pid, ...start !== void 0 && { process_start: start } };
2613
- }
2614
- function is_same_process(pid, recorded_start, read2 = read_start) {
2615
- if (pid === void 0 || !is_live_pid(pid)) return false;
2616
- if (recorded_start === void 0) return void 0;
2617
- return matches_recorded_start(pid, recorded_start, read2);
2618
- }
2619
- function is_own_process(pid, recorded_start) {
2620
- return pid === process.pid && recorded_start === own_start();
2621
- }
2622
- var process_identity = {
2623
- close_beacon,
2624
- is_live_pid,
2625
- is_own_process,
2626
- is_same_process,
2627
- open_beacon,
2628
- own_fields,
2629
- own_start,
2630
- read_start,
2631
- resolve_own_start
2632
- };
2633
-
2634
2766
  // scripts/run/run-carry.ts
2635
2767
  import { z as z5 } from "zod";
2636
2768
 
@@ -3404,8 +3536,8 @@ export {
3404
3536
  gh_failure,
3405
3537
  has_stderr_field,
3406
3538
  git_gh_exec,
3407
- lane_registry,
3408
3539
  process_identity,
3540
+ lane_registry,
3409
3541
  run_invocation,
3410
3542
  run_carry,
3411
3543
  run_watcher_guard
@@ -35,7 +35,7 @@ import {
35
35
  run_watcher_guard,
36
36
  shell_segments,
37
37
  tail_commands
38
- } from "./chunk-3FWV6CFC.js";
38
+ } from "./chunk-452OKRQX.js";
39
39
  import {
40
40
  find_local_bin_upwards
41
41
  } from "./chunk-3V37IID6.js";
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env tsx
2
2
  import {
3
3
  pretool_guard
4
- } from "./chunk-GIX5SAAB.js";
5
- import "./chunk-3FWV6CFC.js";
4
+ } from "./chunk-Z5TVZ4AP.js";
5
+ import "./chunk-452OKRQX.js";
6
6
  import {
7
7
  build_envelope,
8
8
  compose_context,
@@ -3,8 +3,8 @@ import {
3
3
  pretool_guard,
4
4
  pretool_outcome,
5
5
  pretool_outcome_async
6
- } from "./chunk-GIX5SAAB.js";
7
- import "./chunk-3FWV6CFC.js";
6
+ } from "./chunk-Z5TVZ4AP.js";
7
+ import "./chunk-452OKRQX.js";
8
8
  import "./chunk-3V37IID6.js";
9
9
  import "./chunk-V7RH56HU.js";
10
10
  import "./chunk-NKVLBB65.js";
@@ -16,7 +16,7 @@ import {
16
16
  run_hold,
17
17
  run_invocation,
18
18
  run_watcher_guard
19
- } from "./chunk-3FWV6CFC.js";
19
+ } from "./chunk-452OKRQX.js";
20
20
  import {
21
21
  session_language
22
22
  } from "./chunk-6V5OPIUU.js";
@@ -163,9 +163,12 @@ var NO_QUARANTINE_MINUTES = 0;
163
163
  var NPMRC_PATH = ".npmrc";
164
164
  var release_times_schema = z2.record(z2.string(), z2.string());
165
165
  var MS_PER_MINUTE = 6e4;
166
- function parse_minimum_release_age(npmrc_content) {
166
+ function parse_declared_minimum_release_age(npmrc_content) {
167
167
  const raw = MINIMUM_RELEASE_AGE_RE.exec(npmrc_content)?.[1];
168
- return raw === void 0 ? NO_QUARANTINE_MINUTES : Number(raw);
168
+ return raw === void 0 ? void 0 : Number(raw);
169
+ }
170
+ function parse_minimum_release_age(npmrc_content) {
171
+ return parse_declared_minimum_release_age(npmrc_content) ?? NO_QUARANTINE_MINUTES;
169
172
  }
170
173
  function is_on_major(version, major) {
171
174
  return major === void 0 || String(semver.major(version)) === major;
@@ -184,8 +187,7 @@ function select_aged_version(times, major, minimum_age_minutes, now_ms) {
184
187
  }
185
188
  function read_declared_minimum_release_age(npmrc_path) {
186
189
  try {
187
- const raw = MINIMUM_RELEASE_AGE_RE.exec(readFileSync2(npmrc_path, "utf8"))?.[1];
188
- return raw === void 0 ? void 0 : Number(raw);
190
+ return parse_declared_minimum_release_age(readFileSync2(npmrc_path, "utf8"));
189
191
  } catch {
190
192
  return void 0;
191
193
  }
@@ -206,6 +208,7 @@ function read_nearest_minimum_release_age(start, boundary) {
206
208
  var release_age = {
207
209
  release_times_schema,
208
210
  parse_minimum_release_age,
211
+ parse_declared_minimum_release_age,
209
212
  read_minimum_release_age,
210
213
  read_nearest_minimum_release_age,
211
214
  select_aged_version
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.947.0",
2
+ "version": "1.953.0",
3
3
  "name": "@joshuafolkken/kit",
4
4
  "description": "Development toolkit for AI-assisted projects: formatting, AI assistant rules and a Git/GitHub Issue workflow for any repository, plus lint, type check, tests and hooks for Node projects",
5
5
  "keywords": [
@@ -1,6 +1,7 @@
1
1
  import { PORCELAIN_FLAG } from './constants'
2
2
  import { ls_remote_branch_arguments } from './git-ls-remote'
3
3
  import { git_spawn } from './git-spawn'
4
+ import { repository_lock } from './repository-lock'
4
5
 
5
6
  // The four worktree reads and writes a lane's lifecycle needs, plus the branch deletion that ends it
6
7
  // (joshuafolkken/kit#1490; split out of `git-command.ts` under joshuafolkken/kit#1640). They live
@@ -19,6 +20,36 @@ import { git_spawn } from './git-spawn'
19
20
  // (joshuafolkken/kit#1641; before it, `lane-start-point.ts` was the only reader and did both).
20
21
  const WORKTREE = 'worktree'
21
22
 
23
+ // **Every call that writes `.git/worktrees/` runs under one repository-wide lock**
24
+ // (joshuafolkken/kit#2736). `git worktree add` is not safe against itself: while one add is still
25
+ // building `.git/worktrees/<id>/` — its `commondir` not yet written — a second add enumerates the
26
+ // existing registrations, reads the half-built one and fails with `fatal: failed to read
27
+ // .git/worktrees/<id>/commondir`. Two lanes opened at once do exactly that, and the per-seat lock in
28
+ // `lane-open.ts` does not serialize them. `remove` and `prune` edit the same directory, so they take
29
+ // the same lock. The lock wraps the git call alone; the slow steps a caller runs afterwards — a lane's
30
+ // dependency install — stay outside it.
31
+ const WORKTREE_LOCK_PREFIX = 'josh-worktree-lock-'
32
+ // A holder runs one local git command, so the wait only bounds a checkout that hung; it is generous
33
+ // because a large repository's checkout is itself several seconds, and several lanes queue behind it.
34
+ const WORKTREE_LOCK_MAX_WAIT_MS = 120_000
35
+
36
+ async function locked(arguments_: Array<string>): Promise<string> {
37
+ const target = repository_lock.lock_path(WORKTREE_LOCK_PREFIX)
38
+ const output = await repository_lock.with_lock(
39
+ async () => await git_spawn.read(arguments_),
40
+ target,
41
+ WORKTREE_LOCK_MAX_WAIT_MS,
42
+ )
43
+
44
+ if (output === undefined) {
45
+ throw new Error(
46
+ `git ${arguments_.join(' ')}: timed out waiting for the work-tree lock ${target}`,
47
+ )
48
+ }
49
+
50
+ return output
51
+ }
52
+
22
53
  // Every registered work tree of this repository, in git's own machine-readable form: one `worktree
23
54
  // <path>` / `HEAD <sha>` / `branch <ref>` block per tree, blocks separated by a blank line. Parsing
24
55
  // it is the caller's, so this module keeps one shape for every reader.
@@ -50,14 +81,14 @@ async function worktree_add(
50
81
  ): Promise<string> {
51
82
  const flags = start_point === undefined ? ['add'] : ['add', '--no-track', '-b', branch_name]
52
83
 
53
- return await git_spawn.read([WORKTREE, ...flags, directory, start_point ?? branch_name])
84
+ return await locked([WORKTREE, ...flags, directory, start_point ?? branch_name])
54
85
  }
55
86
 
56
87
  // A throwaway tree at one commit, on no branch — `josh test:red` checks the merge-base out here, so
57
88
  // the pre-fix tree is built without creating a branch or touching the caller's tree and index
58
89
  // (joshuafolkken/kit#2448).
59
90
  async function worktree_add_detached(directory: string, commit: string): Promise<string> {
60
- return await git_spawn.read([WORKTREE, 'add', '--detach', directory, commit])
91
+ return await locked([WORKTREE, 'add', '--detach', directory, commit])
61
92
  }
62
93
 
63
94
  // **`--force` is the point, not a convenience.** A lane is closed after a park, a failure or an
@@ -65,13 +96,13 @@ async function worktree_add_detached(directory: string, commit: string): Promise
65
96
  // or untracked work. Refusing to remove it there would leave exactly the debris the close exists to
66
97
  // prevent.
67
98
  async function worktree_remove(directory: string): Promise<string> {
68
- return await git_spawn.read([WORKTREE, 'remove', '--force', directory])
99
+ return await locked([WORKTREE, 'remove', '--force', directory])
69
100
  }
70
101
 
71
102
  // Drops the registrations whose directories are already gone — what makes a lane whose directory was
72
103
  // deleted by hand recoverable rather than a permanent `worktree add` refusal on that path.
73
104
  async function worktree_prune(): Promise<string> {
74
- return await git_spawn.read([WORKTREE, 'prune'])
105
+ return await locked([WORKTREE, 'prune'])
75
106
  }
76
107
 
77
108
  // **Asked of the remote itself, because nothing prunes the remote-tracking refs**
@@ -0,0 +1,138 @@
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 holder at a time per repository, for work that every work tree of that repository shares
9
+ // (joshuafolkken/kit#2701 for the stash sweep, joshuafolkken/kit#2736 for `git worktree add`). The
10
+ // record is keyed on the git common directory, so every work tree of one repository contends for one
11
+ // record per lock name, and it lives in the platform temp root beside the other stamps rather than
12
+ // inside `.git`. Each caller names its own lock with a prefix, so two unrelated kinds of work never
13
+ // wait on each other.
14
+
15
+ const POLL_INTERVAL_MS = 200
16
+
17
+ interface LockOwner {
18
+ pid: number
19
+ process_start?: string
20
+ }
21
+
22
+ type IsGone = (owner: LockOwner) => boolean
23
+
24
+ function lock_path(prefix: string, cwd: string = process.cwd()): string {
25
+ return stamp_file.stamp_path(prefix, git_common_directory.repository(cwd) ?? cwd)
26
+ }
27
+
28
+ function parse_owner(raw: string | undefined): LockOwner | undefined {
29
+ if (raw === undefined) return undefined
30
+
31
+ try {
32
+ return JSON.parse(raw) as LockOwner
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ function read_owner(target: string): LockOwner | undefined {
39
+ return parse_owner(stamp_file.read_stamp_text(target))
40
+ }
41
+
42
+ function is_owner_gone(owner: LockOwner): boolean {
43
+ return process_identity.is_same_process(owner.pid, owner.process_start) === false
44
+ }
45
+
46
+ // Put back a record that turned out not to be the stale one. `linkSync` refuses an existing path, so
47
+ // a lock someone claimed in the meantime is never overwritten.
48
+ function restore(aside: string, target: string): void {
49
+ try {
50
+ linkSync(aside, target)
51
+ } catch {
52
+ // Another claim already holds the path; the record set aside is dropped below.
53
+ }
54
+ }
55
+
56
+ // The record's text when its owner is certainly gone, else `undefined`.
57
+ function stale_record(target: string, is_gone: IsGone): string | undefined {
58
+ const raw = stamp_file.read_stamp_text(target)
59
+ const owner = parse_owner(raw)
60
+
61
+ return owner !== undefined && is_gone(owner) ? raw : undefined
62
+ }
63
+
64
+ // The path the record was moved to, or `undefined` when another claimant moved it first.
65
+ function move_aside(target: string): string | undefined {
66
+ const aside = `${target}.${String(process.pid)}.${randomUUID()}`
67
+
68
+ try {
69
+ renameSync(target, aside)
70
+
71
+ return aside
72
+ } catch {
73
+ return undefined
74
+ }
75
+ }
76
+
77
+ // A record whose process is certainly gone is a crashed holder's, and is cleared before the claim; an
78
+ // unreadable record or an uncertain answer is left in place, since clearing a live holder's lock is the
79
+ // very race the lock exists to close. **The clear is a rename, then a compare**: two claimants can
80
+ // both judge the same record stale, and the slower one's plain remove would delete the lock the faster
81
+ // one had just claimed. Renaming moves exactly one file aside atomically, and only a record
82
+ // byte-identical to the one judged stale is discarded — any other is a fresh claim and goes back.
83
+ function clear_stale(target: string, is_gone: IsGone = is_owner_gone): void {
84
+ const raw = stale_record(target, is_gone)
85
+
86
+ if (raw === undefined) return
87
+
88
+ const aside = move_aside(target)
89
+
90
+ if (aside === undefined) return
91
+ if (stamp_file.read_stamp_text(aside) !== raw) restore(aside, target)
92
+ rmSync(aside, { force: true })
93
+ }
94
+
95
+ function claim(target: string): boolean {
96
+ clear_stale(target)
97
+
98
+ return stamp_file.create_stamp(target, process_identity.own_fields())
99
+ }
100
+
101
+ function release(target: string): void {
102
+ const owner = read_owner(target)
103
+
104
+ if (owner !== undefined && process_identity.is_own_process(owner.pid, owner.process_start)) {
105
+ stamp_file.remove_stamp(target)
106
+ }
107
+ }
108
+
109
+ async function acquire(target: string, max_wait_ms: number): Promise<boolean> {
110
+ const deadline = Date.now() + max_wait_ms
111
+
112
+ while (!claim(target)) {
113
+ if (Date.now() >= deadline) return false
114
+ await sleep(POLL_INTERVAL_MS)
115
+ }
116
+
117
+ return true
118
+ }
119
+
120
+ // Runs `work` holding the lock at `target` and answers its result, or `undefined` without running it
121
+ // when another holder kept the lock for the whole wait.
122
+ async function with_lock<T>(
123
+ work: () => Promise<T>,
124
+ target: string,
125
+ max_wait_ms: number,
126
+ ): Promise<T | undefined> {
127
+ if (!(await acquire(target, max_wait_ms))) return undefined
128
+
129
+ try {
130
+ return await work()
131
+ } finally {
132
+ release(target)
133
+ }
134
+ }
135
+
136
+ const repository_lock = { clear_stale, lock_path, with_lock }
137
+
138
+ export { repository_lock }
@@ -1,123 +1,18 @@
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'
1
+ import { repository_lock } from './repository-lock'
7
2
 
8
3
  // One stash sweep at a time per repository (joshuafolkken/kit#2701). The stash is a single stack every
9
4
  // work tree shares, and a `backlogrun` runs one sweep per lane seat at once: two sweeps that each
10
5
  // 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.
6
+ // moved into that position, and two that carry the same ledger lines would both append them. The
7
+ // locking itself is `repository-lock.ts`, shared with the work-tree writes (joshuafolkken/kit#2736).
13
8
 
14
9
  const LOCK_PREFIX = 'josh-stash-sweep-lock-'
15
10
  // The locked phase is local git and one file append per entry, so a holder finishes in well under the
16
11
  // 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
12
  const MAX_WAIT_MS = 30_000
19
13
 
20
- interface LockOwner {
21
- pid: number
22
- process_start?: string
23
- }
24
-
25
14
  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
15
+ return repository_lock.lock_path(LOCK_PREFIX, cwd)
121
16
  }
122
17
 
123
18
  // Runs `work` holding the lock and answers its result, or `undefined` without running it when another
@@ -127,15 +22,9 @@ async function with_lock<T>(
127
22
  target: string = lock_path(),
128
23
  max_wait_ms: number = MAX_WAIT_MS,
129
24
  ): 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
- }
25
+ return await repository_lock.with_lock(work, target, max_wait_ms)
137
26
  }
138
27
 
139
- const stash_sweep_lock = { clear_stale, lock_path, with_lock }
28
+ const stash_sweep_lock = { clear_stale: repository_lock.clear_stale, lock_path, with_lock }
140
29
 
141
30
  export { stash_sweep_lock }
@@ -1,12 +1,16 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { release_age } from '#scripts/version/release-age'
3
4
  import { yaml_document } from '#scripts/yaml/yaml-document'
4
5
  import { dump } from 'js-yaml'
5
6
  import { z } from 'zod'
6
7
 
7
8
  const WORKSPACE_FILE = 'pnpm-workspace.yaml'
8
9
  const PROJECT_CONFIG_FILE = '.aikido'
10
+ const NPMRC_FILE = '.npmrc'
9
11
  const EXCLUSIONS_FIELD = 'minimumPackageAgeExclusions'
12
+ const AGE_FIELD = 'minimumPackageAgeHours'
13
+ const MINUTES_PER_HOUR = 60
10
14
  const workspace_schema = z.object({ minimumReleaseAgeExclude: z.array(z.string()).optional() })
11
15
 
12
16
  function read_mapping(value: unknown, label: string): Record<string, unknown> {
@@ -72,27 +76,68 @@ function replace_safe_chain_block(existing: string, safe_chain: Record<string, u
72
76
  return `${prefix}${prefix ? '\n' : ''}${replacement}${suffix}`
73
77
  }
74
78
 
75
- function merge_project_config(existing: string, workspace: string): string {
76
- const source = workspace_schema.parse(yaml_document.parse_yaml(workspace))
77
- const exclusions = source.minimumReleaseAgeExclude ?? []
78
- const aikido = yaml_document.parse_yaml(existing)
79
- const safe_chain = read_mapping(aikido['safe-chain'], 'safe-chain')
79
+ // Safe Chain enforces its own minimum package age (48 hours by default), so CI refused lockfiles pnpm
80
+ // had resolved under `.npmrc`'s shorter window (kit #2743). `.npmrc` is the source: its minutes are
81
+ // floored to whole hours so Safe Chain is never stricter than pnpm, and an undeclared window writes
82
+ // nothing; an explicit `0` opt-out is written as 0 so an earlier synchronized age cannot outlive it.
83
+ function package_age_hours(npmrc: string): number | undefined {
84
+ const minutes = release_age.parse_declared_minimum_release_age(npmrc)
85
+ if (minutes === undefined) return undefined
86
+
87
+ return Math.floor(minutes / MINUTES_PER_HOUR)
88
+ }
89
+
90
+ function merge_age(safe_chain: Record<string, unknown>, npmrc: string): Record<string, unknown> {
91
+ const hours = package_age_hours(npmrc)
92
+ if (hours === undefined) return safe_chain
93
+
94
+ return { ...safe_chain, [AGE_FIELD]: hours }
95
+ }
96
+
97
+ function merge_exclusions(
98
+ safe_chain: Record<string, unknown>,
99
+ exclusions: ReadonlyArray<string>,
100
+ ): Record<string, unknown> {
80
101
  const npm = read_mapping(safe_chain['npm'], 'safe-chain.npm')
81
- if (JSON.stringify(npm[EXCLUSIONS_FIELD]) === JSON.stringify(exclusions)) return existing
82
- if (exclusions.length === 0 && existing.length === 0) return existing
83
102
 
84
- const merged = { ...safe_chain, npm: { ...npm, [EXCLUSIONS_FIELD]: exclusions } }
103
+ return { ...safe_chain, npm: { ...npm, [EXCLUSIONS_FIELD]: exclusions } }
104
+ }
105
+
106
+ function has_nothing_to_write(
107
+ existing: string,
108
+ exclusions: ReadonlyArray<string>,
109
+ npmrc: string,
110
+ ): boolean {
111
+ return existing.length === 0 && exclusions.length === 0 && package_age_hours(npmrc) === undefined
112
+ }
113
+
114
+ function read_exclusions(workspace: string): ReadonlyArray<string> {
115
+ return workspace_schema.parse(yaml_document.parse_yaml(workspace)).minimumReleaseAgeExclude ?? []
116
+ }
117
+
118
+ function merge_project_config(existing: string, workspace: string, npmrc = ''): string {
119
+ const exclusions = read_exclusions(workspace)
120
+ const aikido = yaml_document.parse_yaml(existing)
121
+ const safe_chain = read_mapping(aikido['safe-chain'], 'safe-chain')
122
+ const merged = merge_age(merge_exclusions(safe_chain, exclusions), npmrc)
123
+ if (JSON.stringify(merged) === JSON.stringify(safe_chain)) return existing
124
+ if (has_nothing_to_write(existing, exclusions, npmrc)) return existing
85
125
 
86
126
  return replace_safe_chain_block(existing, merged)
87
127
  }
88
128
 
129
+ function read_optional(file_path: string): string {
130
+ return existsSync(file_path) ? readFileSync(file_path, 'utf8') : ''
131
+ }
132
+
89
133
  function sync_project_config(root: string): boolean {
90
134
  const workspace_path = path.join(root, WORKSPACE_FILE)
91
135
  if (!existsSync(workspace_path)) return false
92
136
  const config_path = path.join(root, PROJECT_CONFIG_FILE)
93
- const existing = existsSync(config_path) ? readFileSync(config_path, 'utf8') : ''
137
+ const existing = read_optional(config_path)
94
138
  const workspace = readFileSync(workspace_path, 'utf8')
95
- const merged = merge_project_config(existing, workspace)
139
+ const npmrc = read_optional(path.join(root, NPMRC_FILE))
140
+ const merged = merge_project_config(existing, workspace, npmrc)
96
141
  if (merged === existing) return false
97
142
  writeFileSync(config_path, merged)
98
143
 
@@ -371,7 +371,6 @@ function report_repository_settings(name_with_owner: string | undefined): void {
371
371
 
372
372
  function sync_project_artifacts(is_force: boolean): void {
373
373
  sync_ai_copy_all(is_force)
374
- project_config.sync_project_config(PROJECT_ROOT)
375
374
  sync_prettier_config(path.join(PROJECT_ROOT, 'prettier.config.js'))
376
375
  sync_playwright_config(path.join(PROJECT_ROOT, 'playwright.config.ts'))
377
376
  sync_deploy_vps(path.join(PROJECT_ROOT, '.github/workflows/deploy-vps.yml'))
@@ -382,6 +381,9 @@ function sync_project_artifacts(is_force: boolean): void {
382
381
 
383
382
  sync_sonar_with_template(name_with_owner, is_force)
384
383
  sync_config_files()
384
+ // After `sync_config_files`, which may add the `.npmrc` window `.aikido`'s age is derived from
385
+ // (joshuafolkken/kit#2743).
386
+ project_config.sync_project_config(PROJECT_ROOT)
385
387
  sync_package_json_migrations(path.join(PROJECT_ROOT, PACKAGE_JSON))
386
388
  report_repository_settings(name_with_owner)
387
389
  }
@@ -19,12 +19,18 @@ const NPMRC_PATH = '.npmrc'
19
19
  const release_times_schema = z.record(z.string(), z.string())
20
20
  const MS_PER_MINUTE = 60_000
21
21
 
22
+ // The window `.npmrc` content declares, or nothing when it does not declare one — an explicit
23
+ // `minimum-release-age=0` stays 0 rather than collapsing into "not declared".
24
+ function parse_declared_minimum_release_age(npmrc_content: string): number | undefined {
25
+ const raw = MINIMUM_RELEASE_AGE_RE.exec(npmrc_content)?.[1]
26
+
27
+ return raw === undefined ? undefined : Number(raw)
28
+ }
29
+
22
30
  // The quarantine window in minutes from `.npmrc`; a missing or malformed entry means no
23
31
  // quarantine, matching pnpm's own default for the setting.
24
32
  function parse_minimum_release_age(npmrc_content: string): number {
25
- const raw = MINIMUM_RELEASE_AGE_RE.exec(npmrc_content)?.[1]
26
-
27
- return raw === undefined ? NO_QUARANTINE_MINUTES : Number(raw)
33
+ return parse_declared_minimum_release_age(npmrc_content) ?? NO_QUARANTINE_MINUTES
28
34
  }
29
35
 
30
36
  // Stable releases only, on the requested major, published at or before the cutoff. The
@@ -82,9 +88,7 @@ function select_aged_version(
82
88
  // `minimum-release-age=0` opt-out stop the walk instead of falling through to an ancestor's policy.
83
89
  function read_declared_minimum_release_age(npmrc_path: string): number | undefined {
84
90
  try {
85
- const raw = MINIMUM_RELEASE_AGE_RE.exec(readFileSync(npmrc_path, 'utf8'))?.[1]
86
-
87
- return raw === undefined ? undefined : Number(raw)
91
+ return parse_declared_minimum_release_age(readFileSync(npmrc_path, 'utf8'))
88
92
  } catch {
89
93
  return undefined
90
94
  }
@@ -138,6 +142,7 @@ function read_nearest_minimum_release_age(start: string, boundary?: string): num
138
142
  const release_age = {
139
143
  release_times_schema,
140
144
  parse_minimum_release_age,
145
+ parse_declared_minimum_release_age,
141
146
  read_minimum_release_age,
142
147
  read_nearest_minimum_release_age,
143
148
  select_aged_version,