@joshuafolkken/kit 1.1373.0 → 1.1379.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 (46) hide show
  1. package/.claude/skills/dependency-update/SKILL.md +6 -3
  2. package/dist/hooks/batch-guard.js +6 -6
  3. package/dist/hooks/{chunk-BZMPVFDY.js → chunk-4RWRP3FF.js} +5 -5
  4. package/dist/hooks/{chunk-V4TKZHER.js → chunk-546X4PX3.js} +3 -3
  5. package/dist/hooks/{chunk-JGW24KKM.js → chunk-7ZM464OW.js} +3 -3
  6. package/dist/hooks/{chunk-GG72ANKI.js → chunk-DHSKBAQ2.js} +1 -1
  7. package/dist/hooks/{chunk-7WKYHEGU.js → chunk-E7LZL5AN.js} +3 -3
  8. package/dist/hooks/{chunk-AKGCP4LX.js → chunk-HGA4EG5S.js} +11 -11
  9. package/dist/hooks/{chunk-Z5TMJIPW.js → chunk-NBEAGPS4.js} +1 -1
  10. package/dist/hooks/{chunk-7UBFU3CL.js → chunk-PV3FWDSQ.js} +2 -2
  11. package/dist/hooks/{chunk-JCRDBQXB.js → chunk-QARPP36N.js} +3 -3
  12. package/dist/hooks/{chunk-NZN2UFI6.js → chunk-RW4XMF47.js} +37 -1
  13. package/dist/hooks/{chunk-YLBBSSTF.js → chunk-TG2AB3W6.js} +50 -16
  14. package/dist/hooks/{chunk-SLSFIWTX.js → chunk-TORRSNDJ.js} +7 -4
  15. package/dist/hooks/{chunk-UTHMB4O2.js → chunk-YSDAH73S.js} +4 -33
  16. package/dist/hooks/codex-hook-adapter.js +13 -13
  17. package/dist/hooks/duplicate-read-guard.js +7 -7
  18. package/dist/hooks/format-edited.js +3 -3
  19. package/dist/hooks/inputs.json +209 -209
  20. package/dist/hooks/investigation-guard.js +7 -7
  21. package/dist/hooks/pretool-guard.js +12 -12
  22. package/dist/hooks/session-lang.js +3 -3
  23. package/dist/hooks/stop-guard.js +6 -6
  24. package/dist/josh.js +39 -4
  25. package/package.json +4 -4
  26. package/scripts/adopt/adopt-logic.ts +4 -1
  27. package/scripts/adopt/adopt.ts +7 -4
  28. package/scripts/cost-runtime/context-cut-payback.ts +9 -4
  29. package/scripts/cost-runtime/cost-pricing.ts +94 -33
  30. package/scripts/document/document-byte-budget.ts +1 -1
  31. package/scripts/document/read-set-pricing.ts +7 -5
  32. package/scripts/git/git-spawn-sync.ts +9 -1
  33. package/scripts/init/init-ai-copy.ts +10 -3
  34. package/scripts/init/init-copy-content.ts +8 -1
  35. package/scripts/init/security-policy.ts +79 -0
  36. package/scripts/josh/josh-commands-maintenance.ts +3 -0
  37. package/scripts/josh/josh-commands-project.ts +1 -1
  38. package/scripts/josh/josh-commands-versioning.ts +2 -1
  39. package/scripts/propagate/propagate-run.ts +17 -1
  40. package/scripts/propagate/propagate-steps.ts +9 -1
  41. package/scripts/sync/sync-ai-files.ts +2 -0
  42. package/scripts/version/latest-update.ts +20 -6
  43. package/scripts/version/peer-floor.ts +99 -0
  44. package/scripts/version/playwright-browsers.ts +109 -0
  45. package/scripts/version/publishable-range-check.ts +41 -4
  46. package/templates/SECURITY.md +23 -0
package/dist/josh.js CHANGED
@@ -46,6 +46,35 @@ var git_utilities = {
46
46
  get_git_command_for_spawn
47
47
  };
48
48
 
49
+ // scripts/git/git-location-environment.ts
50
+ var GIT_LOCATION_VARIABLES = [
51
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
52
+ "GIT_COMMON_DIR",
53
+ "GIT_DIR",
54
+ "GIT_INDEX_FILE",
55
+ "GIT_NAMESPACE",
56
+ "GIT_OBJECT_DIRECTORY",
57
+ "GIT_WORK_TREE"
58
+ ];
59
+ function location_free_environment() {
60
+ return Object.fromEntries(GIT_LOCATION_VARIABLES.map((name) => [name, void 0]));
61
+ }
62
+ function forget(name) {
63
+ Reflect.deleteProperty(process.env, name);
64
+ }
65
+ function restore_one(name, value) {
66
+ if (value === void 0) forget(name);
67
+ else process.env[name] = value;
68
+ }
69
+ function clear_git_location_variables() {
70
+ const previous = GIT_LOCATION_VARIABLES.map((name) => [name, process.env[name]]);
71
+ for (const [name] of previous) forget(name);
72
+ return () => {
73
+ for (const [name, value] of previous) restore_one(name, value);
74
+ };
75
+ }
76
+ var git_location_environment = { clear_git_location_variables, location_free_environment };
77
+
49
78
  // scripts/git/git-spawn-sync.ts
50
79
  var SUCCESS_EXIT_CODE = 0;
51
80
  var C_LOCALE = { LC_ALL: "C", LANGUAGE: "C" };
@@ -67,7 +96,10 @@ function read(args, options = {}) {
67
96
  return result.exit_code === SUCCESS_EXIT_CODE ? result.stdout.trim() : void 0;
68
97
  }
69
98
  function origin_url(cwd) {
70
- return read(["config", "--get", "remote.origin.url"], { cwd });
99
+ return read(["config", "--get", "remote.origin.url"], {
100
+ cwd,
101
+ env: git_location_environment.location_free_environment()
102
+ });
71
103
  }
72
104
  function toplevel(timeout = GIT_TIMEOUT_MS) {
73
105
  return run(["rev-parse", "--show-toplevel"], { env: C_LOCALE, timeout });
@@ -2448,7 +2480,10 @@ var MAINTENANCE_COMMANDS = {
2448
2480
  script: "scripts/version/latest-update.ts",
2449
2481
  description: "Update all dependencies to latest",
2450
2482
  category: "Maintenance",
2451
- reference: ["", "developer", ["files", "network"]]
2483
+ reference: ["", "developer", ["files", "network"]],
2484
+ // `JOSH_PLAYWRIGHT_BROWSERS` widens the browsers a Playwright bump installs, and a project
2485
+ // keeps that setting in its `.env`.
2486
+ tsx_arguments: OPTIONAL_ENV_FILE_FLAGS
2452
2487
  }
2453
2488
  };
2454
2489
 
@@ -2536,7 +2571,7 @@ var PROJECT_COMMANDS = {
2536
2571
  },
2537
2572
  adopt: {
2538
2573
  script: "scripts/adopt/adopt.ts",
2539
- description: "Upgrade every installed @joshuafolkken toolkit here and open the pull request",
2574
+ description: "Upgrade the @joshuafolkken toolkits and other dependencies here, open the PR",
2540
2575
  category: "Project",
2541
2576
  reference: ["[--dry-run]", "developer", ["files", "git", "network", "processes"]]
2542
2577
  }
@@ -2562,7 +2597,7 @@ var VERSIONING_COMMANDS = {
2562
2597
  // before chaining here, exactly as it already does for `latest:update`.
2563
2598
  ranges: {
2564
2599
  script: "scripts/version/publishable-range-check.ts",
2565
- description: "Check that every published dependency range still resolves for a consumer",
2600
+ description: "Check that every published dependency range still resolves for a consumer and no peer range falls below an installed toolkit",
2566
2601
  category: "Versioning",
2567
2602
  reference: ["", "developer", ["network"]]
2568
2603
  },
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1373.0",
2
+ "version": "1.1379.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": [
@@ -264,10 +264,10 @@
264
264
  "eslint_d": "^15.0.3",
265
265
  "globals": "^17.13.0",
266
266
  "lefthook": "^2.2.1",
267
- "prettier": "^3.9.9",
267
+ "prettier": "^3.9.10",
268
268
  "prettier-plugin-svelte": "^4.1.1",
269
269
  "prettier-plugin-tailwindcss": "^0.8.1",
270
- "rollup": "^4.64.4",
270
+ "rollup": "^4.64.5",
271
271
  "rollup-plugin-dts": "^6.5.1",
272
272
  "secretlint": "^13.0.7",
273
273
  "typescript": "^6.0.3",
@@ -289,7 +289,7 @@
289
289
  "devEngines": {
290
290
  "packageManager": {
291
291
  "name": "pnpm",
292
- "version": "12.11.2+sha512.25a51652a196785af5f28bda6dfe8dc582ebefa4ac1d180221ce1390231c5fb76637822ea4b6e4633ce74acdcb0a77f725af11bad38177fdeddaee818a955cb9",
292
+ "version": "12.12.0+sha512.2ee06ac86db554cf384421461220f0e336a487bfd41b2acd04e17a86b1487832fe45e8d1743f7fc52acc77de12d2356b536fedf8229fc483239e40b13d474449",
293
293
  "onFail": "download"
294
294
  }
295
295
  },
@@ -8,7 +8,10 @@ import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
8
8
  // The decisions `josh adopt` makes before any step runs: whether this repository may adopt at all,
9
9
  // which repository the issue is opened in, and what the run is carrying.
10
10
 
11
- const ADOPT_ORIGIN = 'Opened by `josh adopt` in this repository.'
11
+ // It names `josh latest` because the issue title names only the toolkits: without this sentence the
12
+ // other dependency bumps in the pull request have nothing in the issue that accounts for them.
13
+ const ADOPT_ORIGIN =
14
+ 'Opened by `josh adopt` in this repository, which also ran `josh latest` — the other dependencies and the pnpm pin are updated alongside.'
12
15
  const INCOMPLETE_HEADLINE =
13
16
  'Refusing to adopt: these declared toolkits would fall out of the plan, and the run would sync the rest without them.'
14
17
  const INCOMPLETE_EFFECT =
@@ -13,9 +13,11 @@ import { adopt_toolkits } from './adopt-toolkits'
13
13
  //
14
14
  // It is `josh propagate` seen from the other end: propagation stands in the supplier and pushes one
15
15
  // released version out to every consumer, adoption stands in the consumer and pulls whatever is
16
- // newest in. The step sequence is literally the same one — `propagate_run.STEP_ORDER`, run through
17
- // `propagate_run.run_target` — so the working-tree pre-check, the ordering and the return to the
18
- // default branch cannot drift between the two.
16
+ // newest in. The step sequence is the same one with `josh latest` added — `propagate_run
17
+ // .ADOPT_STEP_ORDER`, derived from `STEP_ORDER` and run through `propagate_run.run_target` — so the
18
+ // working-tree pre-check, the ordering and the return to the default branch cannot drift between the
19
+ // two. `josh latest` is adoption's alone: catching a consumer up means its other dependencies and its
20
+ // pnpm pin too, in the same pull request, where propagation carries one exact release and nothing else.
19
21
  //
20
22
  // It stops at the open pull request. Merging is what `pnpm josh followup` does, under an
21
23
  // authorization a CLI does not have.
@@ -62,7 +64,7 @@ function run_sequence(target: PropagateTarget, plan: ReleasePlan | undefined): n
62
64
  plan === undefined ? propagate_steps.describe_step : propagate_steps.create_step_runner(plan)
63
65
  const reason = plan === undefined ? DRY_RUN_REASON : propagate_run.PROPAGATED_REASON
64
66
 
65
- return report(propagate_run.run_target(target, step, reason))
67
+ return report(propagate_run.run_target(target, step, reason, propagate_run.ADOPT_STEP_ORDER))
66
68
  }
67
69
 
68
70
  // A declared toolkit that cannot be carried stops the run, whichever of the two ways it fell out —
@@ -158,6 +160,7 @@ const adopt = {
158
160
  KNOWN_FLAGS,
159
161
  NOTHING_TO_ADOPT,
160
162
  parse_options,
163
+ run_sequence,
161
164
  run_here,
162
165
  run,
163
166
  }
@@ -26,11 +26,16 @@ const POST_CUT_CONTEXT = 60_000
26
26
  const EXPECTED_REMAINING_REQUESTS = 10
27
27
 
28
28
  // The price of a dropped token relative to a rewritten one, from `cost-pricing.ts`. A cache read bills
29
- // `CACHE_READ_MULTIPLIER` of base input and a 5-minute cache write `CACHE_WRITE_5M_MULTIPLIER`, so a
30
- // preamble rewrite costs this many per-request cache reads. The base input rate is common to both and
31
- // cancels, which is why only the ratio appears.
29
+ // `DEFAULT_CACHE_READ_MULTIPLIER` of base input and a 5-minute cache write `CACHE_WRITE_5M_MULTIPLIER`,
30
+ // so a preamble rewrite costs this many per-request cache reads. The base input rate is common to both
31
+ // and cancels, which is why only the ratio appears.
32
+ //
33
+ // **The standard cache-read rate, deliberately, not the running model's.** Opus 5.5 reads cache at
34
+ // 0.05x and Fable 5.1 at 0.025x, which would move the threshold from 135,000 to 210,000 and 360,000 —
35
+ // outside the 100k–200k band the cut was validated in. That changes when a cut fires, a decision that
36
+ // needs a lane measurement of its own rather than arriving as a side effect of a corrected price list.
32
37
  function write_over_read(): number {
33
- return cost_pricing.CACHE_WRITE_5M_MULTIPLIER / cost_pricing.CACHE_READ_MULTIPLIER
38
+ return cost_pricing.CACHE_WRITE_5M_MULTIPLIER / cost_pricing.DEFAULT_CACHE_READ_MULTIPLIER
34
39
  }
35
40
 
36
41
  // The current per-request context at which a cut breaks even over exactly `remaining` requests. The
@@ -1,68 +1,114 @@
1
1
  import { cost_usage, type UsageTotals } from './cost-usage'
2
2
 
3
- // The current Claude price list, and the three multipliers that make a token's price depend on how
4
- // it was sent.
3
+ // The current Claude price list, and the multipliers that make a token's price depend on how it was
4
+ // sent.
5
5
  //
6
- // A run's cost is not "tokens x one rate". A cache read costs a tenth of base input, a 5-minute
7
- // cache write 1.25x, a 1-hour cache write 2x, and output 5x. On a measured request with
8
- // `cache_read_input_tokens` 97,190 against `input_tokens` 2, an estimate that ignored the
9
- // multipliers would be wrong by more than an order of magnitude in either direction depending on
6
+ // A run's cost is not "tokens x one rate". A cache read costs a fraction of base input that depends
7
+ // on the model, a 5-minute cache write 1.25x, a 1-hour cache write 2x, and output 5x. On a measured
8
+ // request with `cache_read_input_tokens` 97,190 against `input_tokens` 2, an estimate that ignored
9
+ // the multipliers would be wrong by more than an order of magnitude in either direction depending on
10
10
  // which single rate it picked.
11
11
 
12
12
  const CACHE_WRITE_5M_MULTIPLIER = 1.25
13
13
  const CACHE_WRITE_1H_MULTIPLIER = 2
14
- const CACHE_READ_MULTIPLIER = 0.1
14
+ // The standard cache-read rate, a tenth of base input — what a model bills unless its row says
15
+ // otherwise. Named the default because it is not every model's rate: pricing a model reads the row's
16
+ // multiplier through `estimate_cost`, never this constant.
17
+ const DEFAULT_CACHE_READ_MULTIPLIER = 0.1
18
+ // The discounted cache-read rates of the 5.5 generation (platform.claude.com "Model pricing", read
19
+ // 2026-10-12): Opus 5.5 and Sonnet 5.5 bill a cache read at 0.05x base input, Fable 5.1 and Mythos
20
+ // 5.1 at 0.025x.
21
+ const HALVED_CACHE_READ_MULTIPLIER = 0.05
22
+ const QUARTERED_CACHE_READ_MULTIPLIER = 0.025
15
23
  const TOKENS_PER_MILLION = 1_000_000
16
24
 
17
25
  interface ModelPrice {
18
26
  // USD per million tokens.
19
27
  input: number
20
28
  output: number
29
+ // The cache-read rate as a multiple of `input`. Absent on a model billed at the standard
30
+ // `DEFAULT_CACHE_READ_MULTIPLIER`, so a row states a multiplier only where it differs.
31
+ cache_read_multiplier?: number
21
32
  }
22
33
 
34
+ // A model priced by prompt length carries a second row under its id plus this suffix — the rates a
35
+ // request over `LONG_PROMPT_THRESHOLD` pays. The suffixed name is not an id the API reports; it is
36
+ // what `cost_by_model` bills such a request under, so the higher-priced requests show as their own
37
+ // row and resolve to their own price like any other.
38
+ const LONG_PROMPT_SUFFIX = ':long-prompt'
39
+ const LONG_PROMPT_THRESHOLD = 100_000
40
+
23
41
  // Base input/output rates per million tokens. Cache rates are derived from `input` by the
24
42
  // multipliers above rather than listed, because they are defined as multiples of it — writing them
25
43
  // out would let the two drift.
26
44
  /* eslint-disable @typescript-eslint/naming-convention */
27
45
  const MODEL_PRICES: Record<string, ModelPrice> = {
46
+ 'claude-fable-5-1': {
47
+ input: 10,
48
+ output: 50,
49
+ cache_read_multiplier: QUARTERED_CACHE_READ_MULTIPLIER,
50
+ },
51
+ 'claude-mythos-5-1': {
52
+ input: 10,
53
+ output: 50,
54
+ cache_read_multiplier: QUARTERED_CACHE_READ_MULTIPLIER,
55
+ },
28
56
  'claude-fable-5': { input: 10, output: 50 },
29
57
  'claude-mythos-5': { input: 10, output: 50 },
58
+ 'claude-opus-5-5': { input: 4, output: 20, cache_read_multiplier: HALVED_CACHE_READ_MULTIPLIER },
30
59
  'claude-opus-5': { input: 5, output: 25 },
31
60
  'claude-opus-4-8': { input: 5, output: 25 },
32
61
  'claude-opus-4-7': { input: 5, output: 25 },
33
62
  'claude-opus-4-6': { input: 5, output: 25 },
63
+ 'claude-sonnet-5-5': {
64
+ input: 2,
65
+ output: 10,
66
+ cache_read_multiplier: HALVED_CACHE_READ_MULTIPLIER,
67
+ },
34
68
  'claude-sonnet-5': { input: 2, output: 10 },
35
69
  'claude-sonnet-4-6': { input: 3, output: 15 },
70
+ 'claude-haiku-5-5': { input: 0.1, output: 0.5 },
71
+ [`claude-haiku-5-5${LONG_PROMPT_SUFFIX}`]: { input: 0.5, output: 2.5 },
36
72
  'claude-haiku-4-5': { input: 1, output: 5 },
37
73
  }
38
74
  /* eslint-enable @typescript-eslint/naming-convention */
39
75
 
40
76
  const CONTEXT_MARKER = '['
77
+ // A deployment's date suffix — `claude-haiku-4-5-20251001`. Eight digits and nothing else, so a
78
+ // version segment (the last `-5` of `claude-opus-5-5`) is never read as one.
79
+ const DATE_SUFFIX = /-\d{8}$/u
41
80
 
42
81
  // Claude Code appends a context-window marker to the model id it records — `claude-opus-5[1m]` —
43
82
  // and a deployment may carry a date suffix. Both name the same priced model, so the id is reduced
44
- // to its longest prefix that the table knows rather than reported as unknown.
83
+ // to the table's id by removing exactly those two decorations.
84
+ //
85
+ // **Nothing else is removed.** The reduction was once "the longest prefix the table knows", which
86
+ // priced `claude-opus-5-5` at `claude-opus-5`'s rates and `claude-fable-5-1` at `claude-fable-5`'s —
87
+ // another model's price list, reported with no warning. A version the table does not carry is
88
+ // unpriced, which `total_cost` names, rather than billed as the closest id it resembles.
45
89
  //
46
90
  // The `[1m]` marker is the 1M-context beta, and stripping it to the base rate is confirmed correct,
47
- // not an approximation. The published price list — platform.claude.com
48
- // "Long context pricing", read 2026-09 — states that Claude 4.6 and later models "include the full
49
- // 1M token context window at standard pricing (a 900k-token request is billed at the same per-token
50
- // rate as a 9k-token request)". Every id in `MODEL_PRICES` is 4.6-or-later, so no request has an
51
- // input-size surcharge band to account for, whatever its size. (Fast mode is a separate premium, but
52
- // it is signalled by `speed: "fast"` rather than by the context marker, so it is not this marker's
53
- // concern.) Were a future model to reintroduce a >200K band, the marker could no longer be stripped
54
- // blindly and this function would branch on the input size instead.
55
- function resolve_price(model: string): ModelPrice | undefined {
91
+ // not an approximation. The published price list — platform.claude.com "Long context pricing", read
92
+ // 2026-10-12 — states that Claude 4.6 and later models other than Haiku 5.5 "include the full 1M
93
+ // token context window at standard pricing (a 900k-token request is billed at the same per-token
94
+ // rate as a 9k-token request)". The one model with an input-size band is priced from the request's
95
+ // own size by `billed_model`, not from the marker. (Fast mode is a separate premium, but it is
96
+ // signalled by `speed: "fast"` rather than by the context marker, so it is not this marker's
97
+ // concern.)
98
+ function known_id(model: string): string | undefined {
56
99
  const base = model.split(CONTEXT_MARKER)[0] ?? model
57
- const direct = MODEL_PRICES[base]
58
100
 
59
- if (direct !== undefined) return direct
101
+ return [base, base.replace(DATE_SUFFIX, '')].find((id) => Object.hasOwn(MODEL_PRICES, id))
102
+ }
103
+
104
+ function resolve_price(model: string): ModelPrice | undefined {
105
+ const id = known_id(model)
60
106
 
61
- const [matched] = Object.keys(MODEL_PRICES)
62
- .filter((id) => base.startsWith(id))
63
- .toSorted((left, right) => right.length - left.length)
107
+ return id === undefined ? undefined : MODEL_PRICES[id]
108
+ }
64
109
 
65
- return matched === undefined ? undefined : MODEL_PRICES[matched]
110
+ function cache_read_multiplier(price: ModelPrice): number {
111
+ return price.cache_read_multiplier ?? DEFAULT_CACHE_READ_MULTIPLIER
66
112
  }
67
113
 
68
114
  function estimate_cost(totals: UsageTotals, price: ModelPrice): number {
@@ -70,16 +116,16 @@ function estimate_cost(totals: UsageTotals, price: ModelPrice): number {
70
116
  totals.input_tokens +
71
117
  totals.cache_write_5m_tokens * CACHE_WRITE_5M_MULTIPLIER +
72
118
  totals.cache_write_1h_tokens * CACHE_WRITE_1H_MULTIPLIER +
73
- totals.cache_read_tokens * CACHE_READ_MULTIPLIER
119
+ totals.cache_read_tokens * cache_read_multiplier(price)
74
120
 
75
121
  return (weighted_input * price.input + totals.output_tokens * price.output) / TOKENS_PER_MILLION
76
122
  }
77
123
 
78
124
  // The five dollar terms `estimate_cost` sums, kept apart so a report can say what a run's dollars
79
- // were spent on rather than only their total. Each is priced by the same
80
- // multipliers `estimate_cost` uses — the exported constants above — so the five always sum to
81
- // `estimate_cost(totals, price)`. It is a decomposition of the same arithmetic, not a second price
82
- // list, which is why the weights live in the constants both read rather than in either function.
125
+ // were spent on rather than only their total. Each is priced by the same multipliers `estimate_cost`
126
+ // uses — the write constants above and the price's own cache-read multiplier — so the five always
127
+ // sum to `estimate_cost(totals, price)`. It is a decomposition of the same arithmetic, not a second
128
+ // price list, which is why the weights live where both read them rather than in either function.
83
129
  interface CostComposition {
84
130
  input_usd: number
85
131
  cache_write_5m_usd: number
@@ -103,7 +149,7 @@ function estimate_composition(totals: UsageTotals, price: ModelPrice): CostCompo
103
149
  totals.cache_write_1h_tokens * CACHE_WRITE_1H_MULTIPLIER,
104
150
  price.input,
105
151
  ),
106
- cache_read_usd: priced(totals.cache_read_tokens * CACHE_READ_MULTIPLIER, price.input),
152
+ cache_read_usd: priced(totals.cache_read_tokens * cache_read_multiplier(price), price.input),
107
153
  output_usd: priced(totals.output_tokens, price.output),
108
154
  }
109
155
  }
@@ -129,15 +175,29 @@ interface ModelUsage {
129
175
  totals: UsageTotals
130
176
  }
131
177
 
178
+ // The id a request is billed under: its own, unless the model is priced by prompt length and this
179
+ // request is over the threshold, in which case the model's long-prompt row. The prompt length is
180
+ // everything the request sent — cache reads and writes included — and each request is priced on its
181
+ // own (platform.claude.com "Long context pricing", read 2026-10-12), which is why this reads one
182
+ // request's totals and cannot be asked of a sum.
183
+ function billed_model(usage: ModelUsage): string {
184
+ const long_id = `${known_id(usage.model) ?? usage.model}${LONG_PROMPT_SUFFIX}`
185
+ const is_long = cost_usage.billed_input(usage.totals) > LONG_PROMPT_THRESHOLD
186
+
187
+ return is_long && Object.hasOwn(MODEL_PRICES, long_id) ? long_id : usage.model
188
+ }
189
+
132
190
  // Per-model, because a session routinely mixes tiers — a subagent on Haiku inside an Opus run — and
133
- // one blended rate would be wrong for both.
191
+ // one blended rate would be wrong for both. Grouped by `billed_model`, so each record is one request:
192
+ // a model priced by prompt length is split into the rows its requests were billed at.
134
193
  function cost_by_model(records: ReadonlyArray<ModelUsage>): Array<ModelCost> {
135
194
  const grouped = new Map<string, UsageTotals>()
136
195
 
137
196
  for (const record of records) {
138
- const running = grouped.get(record.model) ?? cost_usage.EMPTY_TOTALS
197
+ const model = billed_model(record)
198
+ const running = grouped.get(model) ?? cost_usage.EMPTY_TOTALS
139
199
 
140
- grouped.set(record.model, cost_usage.add_totals(running, record.totals))
200
+ grouped.set(model, cost_usage.add_totals(running, record.totals))
141
201
  }
142
202
 
143
203
  return [...grouped].map(([model, totals]) => to_model_cost(model, totals))
@@ -162,7 +222,8 @@ function cost_of(records: ReadonlyArray<ModelUsage>): number {
162
222
  const cost_pricing = {
163
223
  CACHE_WRITE_5M_MULTIPLIER,
164
224
  CACHE_WRITE_1H_MULTIPLIER,
165
- CACHE_READ_MULTIPLIER,
225
+ DEFAULT_CACHE_READ_MULTIPLIER,
226
+ LONG_PROMPT_THRESHOLD,
166
227
  resolve_price,
167
228
  estimate_cost,
168
229
  estimate_composition,
@@ -76,7 +76,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
76
76
  { path: 'docs/josh-commands-automation.md', bytes: 53_248 },
77
77
  { path: 'docs/josh-commands-run.md', bytes: 65_536 },
78
78
  { path: 'docs/josh-commands-backlog.md', bytes: 57_344 },
79
- { path: 'docs/josh-commands.md', bytes: 45_056 },
79
+ { path: 'docs/josh-commands.md', bytes: 49_152 },
80
80
  { path: 'docs/labels-and-run-states.md', bytes: 8192 },
81
81
  { path: 'prompts/coding-standards.md', bytes: 8192 },
82
82
  { path: 'prompts/collaboration-workflow.md', bytes: 8192 },
@@ -9,11 +9,12 @@
9
9
  // was worth moving off the entry read.
10
10
  //
11
11
  // **The rate is not defined here.** The per-million-token input rate and the cache-read multiplier
12
- // come from `cost-pricing.ts` — the same constants `josh cost` and `josh time` price a run with — so
12
+ // come from `cost-pricing.ts` — the same price table `josh cost` and `josh time` price a run with — so
13
13
  // there is no second price list to drift. This file is the model (a token rides every request as a
14
14
  // cache read) and the one assumption the model needs (how many requests a run makes); the arithmetic
15
15
  // is `cost_pricing.estimate_cost`'s.
16
16
 
17
+ import { agent_role_profile } from '#scripts/agent/agent-role-profile'
17
18
  import { cost_pricing } from '#scripts/cost-runtime/cost-pricing'
18
19
  import { cost_usage } from '#scripts/cost-runtime/cost-usage'
19
20
 
@@ -23,10 +24,10 @@ import { cost_usage } from '#scripts/cost-runtime/cost-usage'
23
24
  // much per entry-read token, so the figure is "per run of this size", never an absolute.
24
25
  const ASSUMED_REQUESTS = 118
25
26
 
26
- // The model these runs execute on. Its base input rate and the cache-read multiplier are read from
27
- // the price table rather than written here, so the dollar figure tracks any price change with no edit
28
- // to this file.
29
- const REFERENCE_MODEL = 'claude-opus-4-8'
27
+ // The model these runs execute on — the worker's default, read from the profile that launches a lane
28
+ // rather than written here, so a model migration moves the dollar figure with no edit to this file.
29
+ // Its base input rate and cache-read multiplier come from the price table in the same way.
30
+ const REFERENCE_MODEL = agent_role_profile.DEFAULT_PROFILES.worker.model
30
31
 
31
32
  // A token read at the entry is written to cache once and then read back on every request. Modelled as
32
33
  // a pure cache read across the run — the one-time write is under 1% of the total and folding it in
@@ -45,6 +46,7 @@ function dollars_per_run(tokens: number): number {
45
46
 
46
47
  const read_set_pricing = {
47
48
  ASSUMED_REQUESTS,
49
+ REFERENCE_MODEL,
48
50
  dollars_per_run,
49
51
  }
50
52
 
@@ -2,6 +2,7 @@ import { read_spawn_stderr, read_spawn_stdout } from '#scripts/lib/spawn-exit'
2
2
  import { GIT_TIMEOUT_MS } from '#scripts/lib/timeouts'
3
3
  import { execaSync } from 'execa'
4
4
  import { git_utilities } from './constants'
5
+ import { git_location_environment } from './git-location-environment'
5
6
 
6
7
  // The one place a synchronous git command is spawned from — the sync
7
8
  // counterpart of `git-spawn.ts`. Every caller here wants an answer rather than an exception, so a
@@ -51,8 +52,15 @@ function read(args: ReadonlyArray<string>, options: GitSyncOptions = {}): string
51
52
  }
52
53
 
53
54
  // The configured `origin` URL, or undefined when the repository has none.
55
+ //
56
+ // **The git location variables are cleared, so `cwd` is the repository that answers.** A hook exports
57
+ // `GIT_DIR`, which beats `cwd` outright — a caller reached from inside `pre-push` would otherwise be
58
+ // told the `origin` of the repository being pushed rather than of the directory it named.
54
59
  function origin_url(cwd: string): string | undefined {
55
- return read(['config', '--get', 'remote.origin.url'], { cwd })
60
+ return read(['config', '--get', 'remote.origin.url'], {
61
+ cwd,
62
+ env: git_location_environment.location_free_environment(),
63
+ })
56
64
  }
57
65
 
58
66
  // The absolute repository (or worktree) root, asked under the C locale so a caller can tell "not a
@@ -16,6 +16,7 @@ import { init_logic } from './init-logic'
16
16
  import { package_path, PROJECT_ROOT } from './init-paths'
17
17
  import { init_sonar } from './init-sonar'
18
18
  import type { ProjectShape } from './project-profile'
19
+ import { security_policy } from './security-policy'
19
20
 
20
21
  const WORKSPACE_YAML = 'pnpm-workspace.yaml'
21
22
  const CLAUDE_MD_FILENAME = 'CLAUDE.md'
@@ -23,7 +24,7 @@ const BASIC_CLAUDE_IMPORT = '@node_modules/@joshuafolkken/kit/dist/CLAUDE.basic.
23
24
  const PRETTIER_IGNORE = '.prettierignore'
24
25
  const GIT_ATTRIBUTES = '.gitattributes'
25
26
  const CODE_OF_CONDUCT = 'CODE_OF_CONDUCT.md'
26
- const SECURITY_MD = 'SECURITY.md'
27
+ const { SECURITY_MD } = security_policy
27
28
  const BASIC_AI_FILES = [
28
29
  'AGENTS.md',
29
30
  'GEMINI.md',
@@ -38,6 +39,10 @@ const BASIC_SOURCES: Readonly<Record<string, string>> = {
38
39
  [PRETTIER_IGNORE]: 'templates/prettierignore.basic',
39
40
  [WORKSPACE_YAML]: 'templates/pnpm-workspace.basic.yaml',
40
41
  }
42
+ // Written from a template of the consumer's own on every profile, not from kit's same-named file.
43
+ const CONSUMER_SOURCES: Readonly<Record<string, string>> = {
44
+ [SECURITY_MD]: security_policy.SECURITY_TEMPLATE,
45
+ }
41
46
  const GITHUB_FILES = new Set([CODE_OF_CONDUCT, SECURITY_MD])
42
47
  const BASIC_POINTER_MAPPING = { src: 'templates/cursorrules.basic', dest: '.cursorrules' }
43
48
  const PULL_REQUEST_TEMPLATE = '.github/pull_request_template.md'
@@ -143,14 +148,16 @@ function did_skip_workspace_yaml_copy(source_path: string, destination_path: str
143
148
  }
144
149
 
145
150
  // The package file an AI file is copied from: a basic project gets its own template for a few of
146
- // them, every other file is copied from the path it is written to.
151
+ // them, a few are a consumer template on every profile, and every other file is copied from the
152
+ // path it is written to.
147
153
  function ai_file_source(filename: string, shape?: ProjectShape): string {
148
154
  const basic_source = shape?.profile === 'basic' ? BASIC_SOURCES[filename] : undefined
149
155
 
150
- return basic_source ?? filename
156
+ return basic_source ?? CONSUMER_SOURCES[filename] ?? filename
151
157
  }
152
158
 
153
159
  function did_skip_ai_file_copy(filename: string, shape?: ProjectShape): boolean {
160
+ if (security_policy.did_skip_unresolved(filename, PROJECT_ROOT)) return false
154
161
  const source_path = package_path(ai_file_source(filename, shape))
155
162
  const destination_path = path.join(PROJECT_ROOT, filename)
156
163
 
@@ -5,6 +5,7 @@ import { claude_kit_only_settings } from './claude-kit-only-settings'
5
5
  import { claude_plugin_config } from './claude-plugin-config'
6
6
  import { hook_command_rewrite } from './hook-command-rewrite'
7
7
  import { init_logic } from './init-logic'
8
+ import { security_policy } from './security-policy'
8
9
 
9
10
  /**
10
11
  * The single transform applied to every file kit copies verbatim into a consumer
@@ -29,6 +30,10 @@ import { init_logic } from './init-logic'
29
30
  * Keeping both on this one function is the same guarantee as the workflow passes — a copy path that
30
31
  * skipped the plugin block would ship a settings file that never enables the plugin, and one that
31
32
  * skipped the rewrite would leave every consumer hook slower than it has to be.
33
+ *
34
+ * The consumer's `SECURITY.md` gets one further pass: the repository it lands in is filled into the
35
+ * template, so its advisory and release links name that repository and not kit (see
36
+ * security-policy.ts).
32
37
  */
33
38
  function transform_copied_content(destination_path: string, content: string): string {
34
39
  const with_paths = init_logic.transform_distributed_paths(content)
@@ -47,10 +52,12 @@ function transform_copied_content(destination_path: string, content: string): st
47
52
  with_kit_only_stripped,
48
53
  )
49
54
 
50
- return hook_command_rewrite.apply_hook_command_rewrite_for_destination(
55
+ const with_hooks = hook_command_rewrite.apply_hook_command_rewrite_for_destination(
51
56
  destination_path,
52
57
  with_plugin,
53
58
  )
59
+
60
+ return security_policy.apply_repository_for_destination(destination_path, with_hooks)
54
61
  }
55
62
 
56
63
  export { transform_copied_content }
@@ -0,0 +1,79 @@
1
+ import path from 'node:path'
2
+ import { repo_origin } from '#scripts/discovery/repo-origin'
3
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
4
+ import { file_reader } from '#scripts/lib/read-file'
5
+
6
+ // A consumer's `SECURITY.md` is its own policy, not kit's: the advisory and release links have to
7
+ // name the repository the file lands in, and kit's account of its own published package is not a
8
+ // fact about that repository. So the consumer copy is written from a template of its own, with the
9
+ // repository filled in, and kit's root `SECURITY.md` stays kit's.
10
+ const SECURITY_MD = 'SECURITY.md'
11
+ const SECURITY_TEMPLATE = 'templates/SECURITY.md'
12
+ const REPOSITORY_PLACEHOLDER = '{{REPOSITORY}}'
13
+ const KIT_ADVISORY_URL = 'https://github.com/joshuafolkken/kit/security/advisories/new'
14
+ const UNRESOLVED_REASON = '`origin` is not a GitHub repository `owner/repo` can be read from'
15
+ const LINK_DELIMITER = /[\s()<>[\]]+/u
16
+
17
+ // Read from the local `origin` rather than asked of GitHub: the basic profile decides whether to
18
+ // write the file from the same remote, and a policy file must not depend on `gh` being signed in.
19
+ function repository_at(root: string): string | undefined {
20
+ const url = git_spawn_sync.origin_url(root)
21
+ const identity = url === undefined ? undefined : repo_origin.parse_origin_url(url)
22
+
23
+ return identity === undefined ? undefined : `${identity.owner}/${identity.repo}`
24
+ }
25
+
26
+ function apply_repository(content: string, repository: string): string {
27
+ return content.replaceAll(REPOSITORY_PLACEHOLDER, () => repository)
28
+ }
29
+
30
+ // Throws rather than writing the placeholder through: both copy paths ask `did_skip_unresolved`
31
+ // first, so reaching here without a repository is a caller that skipped the question.
32
+ function apply_repository_for_destination(destination_path: string, content: string): string {
33
+ if (path.basename(destination_path) !== SECURITY_MD) return content
34
+ const repository = repository_at(path.dirname(destination_path))
35
+
36
+ if (repository === undefined) {
37
+ throw new Error(`${SECURITY_MD} needs a GitHub origin to name the repository it reports to`)
38
+ }
39
+
40
+ return apply_repository(content, repository)
41
+ }
42
+
43
+ // A full project synced before the template existed holds kit's own policy under this name, and the
44
+ // skip leaves that file on disk — still sending its readers to kit's advisory form. The form is
45
+ // matched as a whole link target, not as a substring: a longer address that merely starts with it
46
+ // is another address.
47
+ function does_report_to_kit(root: string): boolean {
48
+ const existing = file_reader.read_if_readable(path.join(root, SECURITY_MD)) ?? ''
49
+
50
+ return new Set(existing.split(LINK_DELIMITER)).has(KIT_ADVISORY_URL)
51
+ }
52
+
53
+ function unresolved_warning(root: string): string {
54
+ if (!does_report_to_kit(root)) return ` ⚠ skipped ${SECURITY_MD} (${UNRESOLVED_REASON})`
55
+
56
+ return ` ⚠ stale ${SECURITY_MD} still reports vulnerabilities to joshuafolkken/kit — replace it with your own policy or delete it (${UNRESOLVED_REASON})`
57
+ }
58
+
59
+ // Without a GitHub `origin` there is no repository for the advisory link to name, so the file is
60
+ // left unwritten — the next `josh sync` after the remote is added writes it.
61
+ function did_skip_unresolved(filename: string, root: string): boolean {
62
+ if (filename !== SECURITY_MD || repository_at(root) !== undefined) return false
63
+
64
+ console.warn(unresolved_warning(root))
65
+
66
+ return true
67
+ }
68
+
69
+ const security_policy = {
70
+ SECURITY_MD,
71
+ SECURITY_TEMPLATE,
72
+ REPOSITORY_PLACEHOLDER,
73
+ KIT_ADVISORY_URL,
74
+ repository_at,
75
+ apply_repository_for_destination,
76
+ did_skip_unresolved,
77
+ }
78
+
79
+ export { security_policy }
@@ -80,6 +80,9 @@ const MAINTENANCE_COMMANDS: Record<string, CommandEntry> = {
80
80
  description: 'Update all dependencies to latest',
81
81
  category: 'Maintenance',
82
82
  reference: ['', 'developer', ['files', 'network']],
83
+ // `JOSH_PLAYWRIGHT_BROWSERS` widens the browsers a Playwright bump installs, and a project
84
+ // keeps that setting in its `.env`.
85
+ tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
83
86
  },
84
87
  }
85
88
  /* eslint-enable @typescript-eslint/naming-convention */
@@ -88,7 +88,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
88
88
  },
89
89
  adopt: {
90
90
  script: 'scripts/adopt/adopt.ts',
91
- description: 'Upgrade every installed @joshuafolkken toolkit here and open the pull request',
91
+ description: 'Upgrade the @joshuafolkken toolkits and other dependencies here, open the PR',
92
92
  category: 'Project',
93
93
  reference: ['[--dry-run]', 'developer', ['files', 'git', 'network', 'processes']],
94
94
  },