@joshuafolkken/kit 1.1373.0 → 1.1383.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 (92) hide show
  1. package/.claude/skills/dependency-update/SKILL.md +6 -3
  2. package/.claude/skills/workflow-commands/backlogrun-progress.md +2 -3
  3. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -4
  4. package/.claude/skills/workflow-commands/retrospective.md +1 -2
  5. package/dist/hooks/batch-guard.js +6 -6
  6. package/dist/hooks/{chunk-UTHMB4O2.js → chunk-2X2ART5G.js} +4 -33
  7. package/dist/hooks/{chunk-NZN2UFI6.js → chunk-4MGHBTOM.js} +162 -233
  8. package/dist/hooks/{chunk-YLBBSSTF.js → chunk-6AC4C2IO.js} +91 -67
  9. package/dist/hooks/{chunk-AKGCP4LX.js → chunk-6MAW7BCM.js} +12 -12
  10. package/dist/hooks/{chunk-JCRDBQXB.js → chunk-7VSZ2N4B.js} +3 -3
  11. package/dist/hooks/{chunk-GG72ANKI.js → chunk-KC6LUZZ2.js} +1 -1
  12. package/dist/hooks/{chunk-BZMPVFDY.js → chunk-KCF2YE7O.js} +5 -5
  13. package/dist/hooks/{chunk-7WKYHEGU.js → chunk-LC56R53A.js} +3 -3
  14. package/dist/hooks/{chunk-V4TKZHER.js → chunk-LOJTBU5V.js} +3 -3
  15. package/dist/hooks/{chunk-JGW24KKM.js → chunk-RYP3TSUJ.js} +3 -3
  16. package/dist/hooks/{chunk-SLSFIWTX.js → chunk-TT742SIO.js} +8 -5
  17. package/dist/hooks/{chunk-Z5TMJIPW.js → chunk-Y5R47QHU.js} +1 -1
  18. package/dist/hooks/{chunk-7UBFU3CL.js → chunk-YKUITFT7.js} +2 -2
  19. package/dist/hooks/codex-hook-adapter.js +13 -13
  20. package/dist/hooks/duplicate-read-guard.js +7 -7
  21. package/dist/hooks/format-edited.js +3 -3
  22. package/dist/hooks/inputs.json +190 -191
  23. package/dist/hooks/investigation-guard.js +7 -7
  24. package/dist/hooks/pretool-guard.js +12 -12
  25. package/dist/hooks/session-lang.js +3 -3
  26. package/dist/hooks/stop-guard.js +19 -15
  27. package/dist/josh.js +40 -5
  28. package/package.json +4 -4
  29. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  30. package/scripts/adopt/adopt-logic.ts +4 -1
  31. package/scripts/adopt/adopt.ts +7 -4
  32. package/scripts/backlog/backlog-budget-cli.ts +2 -20
  33. package/scripts/backlog/backlog-budget.ts +72 -109
  34. package/scripts/backlog/backlog-drive-cli.ts +10 -3
  35. package/scripts/backlog/backlog-drive.ts +5 -2
  36. package/scripts/backlog/backlog-offer-cli.ts +1 -15
  37. package/scripts/cost-runtime/context-cut-payback.ts +9 -4
  38. package/scripts/cost-runtime/cost-pricing.ts +94 -33
  39. package/scripts/document/document-byte-budget.ts +1 -1
  40. package/scripts/document/read-set-pricing.ts +7 -5
  41. package/scripts/epic/epic-candidate-confirm.ts +1 -1
  42. package/scripts/epic/epic-cross-repo.ts +4 -4
  43. package/scripts/epic/epic-next.ts +1 -1
  44. package/scripts/git/git-spawn-sync.ts +9 -1
  45. package/scripts/hooks/stop-guard.ts +20 -9
  46. package/scripts/init/init-ai-copy.ts +10 -3
  47. package/scripts/init/init-copy-content.ts +8 -1
  48. package/scripts/init/security-policy.ts +79 -0
  49. package/scripts/josh/josh-commands-ai.ts +1 -1
  50. package/scripts/josh/josh-commands-maintenance.ts +3 -0
  51. package/scripts/josh/josh-commands-project.ts +1 -1
  52. package/scripts/josh/josh-commands-versioning.ts +2 -1
  53. package/scripts/lane/lane-cli.ts +1 -3
  54. package/scripts/propagate/propagate-run.ts +17 -1
  55. package/scripts/propagate/propagate-steps.ts +9 -1
  56. package/scripts/run/board/run-board-header.ts +107 -84
  57. package/scripts/run/board/run-board-labels.ts +18 -17
  58. package/scripts/run/board/run-board-machine.ts +21 -8
  59. package/scripts/run/board/run-board-render.ts +1 -5
  60. package/scripts/run/board/run-board-span.ts +0 -1
  61. package/scripts/run/board/run-board-status.ts +0 -17
  62. package/scripts/run/board/run-board-tick.ts +1 -0
  63. package/scripts/run/carry/run-carry-cli.ts +8 -62
  64. package/scripts/run/carry/run-carry-ended.ts +1 -1
  65. package/scripts/run/carry/run-carry.ts +22 -68
  66. package/scripts/run/cut/run-cut.ts +4 -4
  67. package/scripts/run/event/run-event-render.ts +0 -1
  68. package/scripts/run/event/run-event-scope.ts +4 -8
  69. package/scripts/run/event/run-event-stream-emit.ts +0 -25
  70. package/scripts/run/event/run-event-stream.ts +23 -31
  71. package/scripts/run/merge/run-merge-collect.ts +1 -1
  72. package/scripts/run/merge/run-merge-steps.ts +1 -1
  73. package/scripts/run/progress/run-progress-read.ts +1 -1
  74. package/scripts/run/run-headless.ts +2 -2
  75. package/scripts/run/run-status-cli.ts +1 -5
  76. package/scripts/run/run-step.ts +2 -9
  77. package/scripts/run/run-stop-notify.ts +1 -1
  78. package/scripts/run/run-stranded-detect.ts +3 -3
  79. package/scripts/run/run-stranded.ts +25 -17
  80. package/scripts/run/wake/run-wake-cli.ts +7 -9
  81. package/scripts/run/wake/run-wake-describe.ts +3 -5
  82. package/scripts/run/wake/run-wake-driver.ts +3 -3
  83. package/scripts/run/wake/run-wake-loop.ts +4 -21
  84. package/scripts/run/wake/run-wake-restart.ts +1 -1
  85. package/scripts/run/wake/run-wake.ts +6 -7
  86. package/scripts/sync/sync-ai-files.ts +2 -0
  87. package/scripts/version/latest-update.ts +20 -6
  88. package/scripts/version/peer-floor.ts +99 -0
  89. package/scripts/version/playwright-browsers.ts +109 -0
  90. package/scripts/version/publishable-range-check.ts +41 -4
  91. package/templates/SECURITY.md +23 -0
  92. package/scripts/backlog/backlog-idle.ts +0 -88
@@ -16,7 +16,7 @@ import {
16
16
  run_event_stream,
17
17
  run_event_stream_emit,
18
18
  run_hold
19
- } from "./chunk-YLBBSSTF.js";
19
+ } from "./chunk-6AC4C2IO.js";
20
20
  import {
21
21
  session_language
22
22
  } from "./chunk-6V5OPIUU.js";
@@ -26,22 +26,22 @@ import {
26
26
  issue_cite,
27
27
  lane_registry,
28
28
  run_headless
29
- } from "./chunk-UTHMB4O2.js";
29
+ } from "./chunk-2X2ART5G.js";
30
30
  import {
31
31
  timed_fetch
32
32
  } from "./chunk-FZ6FUSBM.js";
33
33
  import "./chunk-CDY4NFQG.js";
34
34
  import {
35
35
  hook_decision
36
- } from "./chunk-7WKYHEGU.js";
36
+ } from "./chunk-LC56R53A.js";
37
37
  import {
38
38
  time_density_hook,
39
39
  time_hook_transcript,
40
40
  time_reported_failure
41
- } from "./chunk-SLSFIWTX.js";
41
+ } from "./chunk-TT742SIO.js";
42
42
  import {
43
43
  lane_child_marker
44
- } from "./chunk-Z5TMJIPW.js";
44
+ } from "./chunk-Y5R47QHU.js";
45
45
  import {
46
46
  backlog_budget,
47
47
  issue_number_shape,
@@ -50,7 +50,7 @@ import {
50
50
  process_owner_schema,
51
51
  run_carry,
52
52
  run_invocation
53
- } from "./chunk-NZN2UFI6.js";
53
+ } from "./chunk-4MGHBTOM.js";
54
54
  import {
55
55
  PROJECT_ROOT,
56
56
  stamp_file
@@ -981,7 +981,6 @@ var PENDING_DECISION = { kind: "pending" };
981
981
  var FAILED_DECISION = { kind: "failed" };
982
982
  var STOP_REASONS = {
983
983
  none: "ended",
984
- expired: "expired",
985
984
  unreadable: "unreadable"
986
985
  };
987
986
  var run_wake_schema = process_owner_schema.extend({
@@ -1157,14 +1156,17 @@ function supervisor_liveness(wake, same_process = process_identity.is_same_proce
1157
1156
  if (is_same === void 0) return UNKNOWN;
1158
1157
  return is_same ? LIVE : GONE;
1159
1158
  }
1159
+ function had_driver(carry) {
1160
+ return carry.is_handed_off === true || carry.owner_pid !== void 0;
1161
+ }
1160
1162
  function is_stranded(input) {
1161
- if (input.read.kind !== "carried" || input.read.carry.is_handed_off !== true || input.is_owner_live) {
1163
+ if (input.read.kind !== "carried" || input.is_owner_live || !had_driver(input.read.carry)) {
1162
1164
  return false;
1163
1165
  }
1164
1166
  return input.supervisor === GONE;
1165
1167
  }
1166
1168
  function describe2(carry) {
1167
- return `Run stranded \u2014 ${run_carry.describe_carry(carry)}. The owner is gone, no cut successor claimed the budget, and no supervisor is watching.`;
1169
+ return `Run stranded \u2014 ${run_carry.describe_carry(carry)}. The owner is gone, no successor holds the budget, and no supervisor is watching.`;
1168
1170
  }
1169
1171
  function verdict_of(is_run_stranded) {
1170
1172
  return is_run_stranded ? STRANDED : OK2;
@@ -1295,18 +1297,20 @@ async function outcome_of(raw_payload) {
1295
1297
  return stop_rules.NO_OUTCOME;
1296
1298
  }
1297
1299
  }
1298
- async function has_run_record() {
1300
+ async function carried_owner_live() {
1299
1301
  try {
1300
1302
  const directory = await run_carry.repository_directory();
1301
- if (directory === void 0) return false;
1302
- return run_carry.read_carry(run_carry.carry_path(directory)).kind === "carried";
1303
+ if (directory === void 0) return void 0;
1304
+ const read = run_carry.read_carry(run_carry.carry_path(directory));
1305
+ return read.kind === "carried" ? run_carry.is_owner_live(read.carry) : void 0;
1303
1306
  } catch {
1304
- return false;
1307
+ return void 0;
1305
1308
  }
1306
1309
  }
1307
1310
  async function run_report_checks() {
1308
- if (!await has_run_record()) return;
1309
- await backlog_stalled_detect.run_stall_check(backlog_ready.DEFAULT_PORTS);
1311
+ const is_owner_live = await carried_owner_live();
1312
+ if (is_owner_live === void 0) return;
1313
+ if (is_owner_live) await backlog_stalled_detect.run_stall_check(backlog_ready.DEFAULT_PORTS);
1310
1314
  await run_stranded_detect.run_stranded_check();
1311
1315
  }
1312
1316
  async function write_stop_decision(raw_payload) {
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 });
@@ -1835,7 +1867,7 @@ var AI_COMMANDS = {
1835
1867
  // `.env` rather than the ambient environment: the strand notification needs the Telegram
1836
1868
  // credentials, the same reason `run:wake` carries these flags.
1837
1869
  tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
1838
- description: "Report whether the run is stranded \u2014 budget handed off, owner gone, and no supervisor watching",
1870
+ description: "Report whether the run is stranded \u2014 owner gone, no successor holding the budget, and no supervisor watching",
1839
1871
  category: "AI tools",
1840
1872
  reference: ["", "automation", ["processes", "notifications"]]
1841
1873
  },
@@ -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.1383.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
  },
@@ -18,7 +18,7 @@
18
18
  1. **起票する** — `pnpm josh issue:file "<title>" --body-file <path> --depth <n> --route interrupt --label priority:high`。上限は効かない(`wip-cap.md` →「割り込み起票 — 上限が効かない側」の条件 2)
19
19
  2. **止まらずに直す** — 壊れた機構を迂回しない(回避策の禁止と、検証ゲートを緩めない規則はここでも変わらない)。`backlogrun` の中では、その割り込みを次の子にし、修正が着地してから塞がれていた子へ戻る。プールから選ぶランでは `priority:high` が先頭に並べ、epic の下では `wip-cap.md` の手順 3 で挿入する。名前を指定したランのドライバーは指定された Issue しか出さないので、`pnpm josh lane:launch <N>` で自分で dispatch する。バッチの外では `wip-cap.md` の手順 4 が変わらず効く — 起票まで済ませ、打つべき `fullrun #<N>` を報告して止まる
20
20
  3. **再開できるなら自分で再開し、動くことを確かめる** — 修正のマージ後、再開の手段がある答えには人の打ち直しを求めない。手渡し済みの記録、所有者が死んだ記録、監督プロセスの停止(`warning` Telegram)には `pnpm josh run:wake --start`、`run:carry` の `standing` には `pnpm josh run:carry --resume "<invocation>" --owner "$PPID"` を自分で打つ。そのあと `pnpm josh run:wake --list` が監督プロセスを示し、そのログに次の子の dispatch が現れることを確かめて報告する
21
- 4. **人に打ち直しを求めるのは再開の手段が無いときだけ** — `expired`(8 時間の上限を使い切った)、`mismatch`(別の起動の記録)、別の生きた所有者の `busy`。新しい承認を宣言するのは人だけ、という規則も変わらない
21
+ 4. **人に打ち直しを求めるのは再開の手段が無いときだけ** — `mismatch`(別の起動の記録)、別の生きた所有者の `busy`。新しい承認を宣言するのは人だけ、という規則も変わらない
22
22
  5. **`needs-decision` を付けない** — ランが自分で直せる不具合は人の判断を要さない(`.claude/skills/workflow-commands/backlogrun-park.md` → "Only a person's judgement carries `needs-decision`")
23
23
 
24
24
  手順:
@@ -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
  }
@@ -8,7 +8,6 @@ import {
8
8
  type BudgetDecision,
9
9
  type BudgetInput,
10
10
  } from './backlog-budget'
11
- import { backlog_idle } from './backlog-idle'
12
11
 
13
12
  // The thin half of `josh backlog:budget` (joshuafolkken/kit#1632): read the loop's state off the
14
13
  // command line, hand it to the pure decision, print the verdict on stdout and the reason on stderr
@@ -18,7 +17,6 @@ const ARGV_OFFSET = 2
18
17
  const SUCCESS_EXIT_CODE = 0
19
18
  const FAILURE_EXIT_CODE = 1
20
19
  const JSON_KEY = 'budget'
21
- const EXHAUSTED_ANSWER: BacklogAnswer = 'exhausted'
22
20
 
23
21
  const USAGE = `Usage: josh backlog:budget --answer <${backlog_budget.ANSWERS.join('|')}> --started <ISO-8601> [--active <ISO-8601>, required unless the watch is off] [--merged <count>] [--running <count>] [--max <count>] [--idle <minutes>, 0 to turn the watch off] [--json]`
24
22
 
@@ -71,7 +69,7 @@ function to_count(raw: string | undefined): number | undefined {
71
69
 
72
70
  // A flag that was given but could not be read makes the whole invocation unreadable. Falling back to
73
71
  // the default instead would answer a question nobody asked — a mistyped `--idle 0` would read as the
74
- // default 30-minute watch and hold a run open that was asked to finish at its first empty backlog.
72
+ // default watch and hold a run open that was asked to finish at its first empty backlog.
75
73
  function is_readable(raw: string | undefined, parsed: number | undefined): boolean {
76
74
  return raw === undefined || parsed !== undefined
77
75
  }
@@ -165,27 +163,11 @@ function build_input(values: ParsedValues, now_ms: number): BudgetInput | undefi
165
163
  return input_of(values, { answer, now_ms, started_at_ms })
166
164
  }
167
165
 
168
- // The idle window, carried only on the watch an empty backlog opens — the one `idle_decision` reaches,
169
- // not the draining watch a reached bound returns while children still run — so `backlog:offer` can
170
- // record when that wait ends (joshuafolkken/kit#3430).
171
- function idle_of(input: BudgetInput, verdict: string): string | undefined {
172
- const is_idle_watch =
173
- verdict === backlog_budget.WATCH_VERDICT &&
174
- input.answer === EXHAUSTED_ANSWER &&
175
- backlog_budget.is_finish(input)
176
- const window = is_idle_watch ? backlog_idle.window_of(input) : undefined
177
-
178
- return window === undefined ? undefined : backlog_idle.text_of(window)
179
- }
180
-
181
166
  function print_json(input: BudgetInput, decision: BudgetDecision): void {
182
167
  const { verdict, reason } = decision
183
- const idle = idle_of(input, verdict)
184
168
  const is_finish = backlog_budget.is_finish(input)
185
169
 
186
- console.info(
187
- JSON.stringify({ [JSON_KEY]: verdict, reason, is_finish, ...(idle !== undefined && { idle }) }),
188
- )
170
+ console.info(JSON.stringify({ [JSON_KEY]: verdict, reason, is_finish }))
189
171
  }
190
172
 
191
173
  function refuse(): number {
@@ -1,12 +1,17 @@
1
1
  // `josh backlog:budget` — whether a `backlogrun` may start more work, keep watching, or finish.
2
2
  //
3
3
  // `backlog:next` says what may start; it says nothing about when the run itself should end. Without
4
- // this a run would finish the moment the backlog read empty, so an issue a person opted in minutes
4
+ // this a run would finish the moment nothing could proceed, so an issue a person opted in minutes
5
5
  // later would need a whole new session.
6
6
  //
7
7
  // **The idle watch is on by default and the maximum is not.** A `backlogrun` with neither flag given
8
- // watches an empty backlog for `DEFAULT_IDLE_MINUTES` before it finishes, and takes as many issues as
9
- // the backlog holds. `--idle 0` is how the watch is turned off.
8
+ // watches for `IDLE_WAIT_HOURS` once nothing can proceed before it finishes, and takes as many issues
9
+ // as the backlog holds. `--idle 0` is how the watch is turned off.
10
+ //
11
+ // **Every reason nothing can proceed is watched, not only an empty backlog.** Everything opted in
12
+ // waiting on a person, and a listing that could not be read, both change without this run doing
13
+ // anything — a person opts the prerequisite in, GitHub answers again — so ending on either would miss
14
+ // the issue opted in minutes later, which is the one thing the watch exists to catch.
10
15
  //
11
16
  // The decision is a command's rather than the loop's own arithmetic, for the reason `josh delegate`
12
17
  // and `josh review:level` are commands: a count and an elapsed time kept in an agent's head are a
@@ -14,29 +19,23 @@
14
19
 
15
20
  const MS_PER_MINUTE = 60_000
16
21
  const MINUTES_PER_HOUR = 60
17
- // The whole-run bound, decided here like the idle watch and the maximum rather than by an agent reading
18
- // a clock.
19
- const WHOLE_RUN_BUDGET_HOURS = 8
20
- const WHOLE_RUN_BUDGET_MINUTES = WHOLE_RUN_BUDGET_HOURS * MINUTES_PER_HOUR
21
- const WHOLE_RUN_BUDGET_MS = WHOLE_RUN_BUDGET_MINUTES * MS_PER_MINUTE
22
22
 
23
- // **The default idle watch, and why it is this number.**
24
- //
25
- // - **Below it the watch is a coin flip.** What it waits for is a person noticing the run has gone
26
- // quiet, filing an issue and applying `auto-ok`. Ten minutes does not reliably outlast that.
27
- // - **Above it the run pays for nothing.** At `IDLE_POLL_MINUTES` a 30-minute watch is six asks —
28
- // about one child's worth of turns, spent while the run holds no working tree and no lane.
29
- // - **It is about the length of one child** — 12 to 28 minutes measured — so a run that has emptied its backlog waits roughly as long as one more issue would have taken.
30
- const DEFAULT_IDLE_MINUTES = 30
23
+ // **How long a run with nothing to do waits, and the only time bound a run has.** It is counted from
24
+ // when the run last had work, never from its start: a run still working, or still inside this wait,
25
+ // is not ended by its age. Eight hours is one unattended night — a person who opts an issue in before
26
+ // they sleep, or after they wake, finds the run still there to pick it up.
27
+ const IDLE_WAIT_HOURS = 8
28
+ const DEFAULT_IDLE_MINUTES = IDLE_WAIT_HOURS * MINUTES_PER_HOUR
31
29
  const DEFAULT_IDLE_MS = DEFAULT_IDLE_MINUTES * MS_PER_MINUTE
32
30
 
33
- // **What a watching run sleeps between asks, and it is not the loop's 60-second polling interval.**
34
- // That one is sized to a child's `fullrun`, which finishes in minutes; a watch is waiting on a person
35
- // to file an issue and opt it in, which happens on human timescales. Polling a watch every minute
36
- // would spend thirty of the parent session's own requests — each one billing the whole session
37
- // history — to learn nothing thirty times. `backlogrun-progress.md` → "Waiting, and never waiting forever" holds
31
+ // **The one interval between two asks for work, whether lanes are running or nothing is.** The driver
32
+ // and the wake supervisor both read it from here. The loop is a process, so an ask spends no AI turn,
33
+ // and a watch is polled as often as a running batch; a child collected in between is asked about at
34
+ // once, without waiting it out. `backlogrun-progress.md` → "Waiting, and never waiting forever" holds
38
35
  // the row; the figure is here because `idle_watch_reason` below has to say it.
39
- const IDLE_POLL_MINUTES = 5
36
+ const OFFER_INTERVAL_SECONDS = 60
37
+ const MS_PER_SECOND = 1000
38
+ const OFFER_INTERVAL_MS = OFFER_INTERVAL_SECONDS * MS_PER_SECOND
40
39
 
41
40
  const RUN_VERDICT = 'run'
42
41
  const WATCH_VERDICT = 'watch'
@@ -69,7 +68,7 @@ interface BudgetInput {
69
68
  // otherwise be measured against a maximum that cannot see it.
70
69
  running: number
71
70
  started_at_ms: number
72
- // When this run last had work — the most recent ask that was not `exhausted`, or the run's start.
71
+ // When this run last had work — its most recent launch or collected child, or the run's start.
73
72
  // Refreshing it is what restarts the idle watch when a candidate appears during one.
74
73
  active_at_ms: number
75
74
  now_ms: number
@@ -87,22 +86,28 @@ interface BudgetDecision {
87
86
  reason: string
88
87
  }
89
88
 
90
- const WHOLE_RUN_REASON = `The whole-run bound of ${String(WHOLE_RUN_BUDGET_HOURS)} hours has passed. An unattended run that has not finished overnight needs a person, not more waiting.`
91
-
92
- const PARKED_REASON =
93
- 'Nothing opted in can proceed without a person. Report the parked issues and finish.'
94
-
95
- const UNREADABLE_REASON =
96
- 'The backlog listing could not be read, so nothing was offered. Report what the command printed and finish — it is not an empty backlog.'
97
-
98
89
  const BLOCKED_REASON =
99
90
  'Everything opted in is blocked or already running, so waiting can still change the answer. Poll again at the polling interval.'
100
91
 
101
92
  const TRIAGE_REASON =
102
93
  'An opted-in candidate carries neither `run:solo` nor `run:lane`, so nothing starts until it is judged. Read each issue named above, record its order and one of the two labels with a comment, then ask again.'
103
94
 
104
- const NO_IDLE_WATCH_REASON =
105
- 'The backlog is empty and the idle watch was turned off with `--idle 0`, so the run finishes here.'
95
+ // What the run is waiting on, by the answer that opened the watch — the opening of every sentence a
96
+ // watch or its ending prints, so a person reads which of the three it was.
97
+ const EMPTY_BACKLOG = 'The backlog is empty'
98
+ const WAITING_ON: Readonly<Partial<Record<BacklogAnswer, string>>> = {
99
+ exhausted: EMPTY_BACKLOG,
100
+ parked: 'Nothing opted in can proceed without a person',
101
+ unreadable: 'The backlog listing could not be read',
102
+ }
103
+
104
+ function waiting_on(answer: BacklogAnswer): string {
105
+ return WAITING_ON[answer] ?? EMPTY_BACKLOG
106
+ }
107
+
108
+ function no_idle_watch_reason(answer: BacklogAnswer): string {
109
+ return `${waiting_on(answer)} and the idle watch was turned off with \`--idle 0\`, so the run finishes here.`
110
+ }
106
111
 
107
112
  function to_minutes(duration_ms: number): string {
108
113
  return String(Math.ceil(duration_ms / MS_PER_MINUTE))
@@ -119,39 +124,29 @@ function draining_reason(running: number, reason: string): string {
119
124
  return `Start nothing more and poll until the running children merge (running: ${String(running)}). Then report and finish — ${reason}`
120
125
  }
121
126
 
122
- function idle_expired_reason(idle_budget_ms: number): string {
123
- return `The backlog stayed empty for the whole ${to_minutes(idle_budget_ms)}-minute idle watch. Report what was done, including how many were picked up during the watch, and finish.`
124
- }
125
-
126
- // Never longer than the watch has left: "about 2 minutes left, ask again in 5 minutes" would overrun
127
- // the budget a person declared, and a sentence that contradicts itself is read as neither half.
128
- function next_ask_minutes(left_ms: number): string {
129
- const left_minutes = Number(to_minutes(left_ms))
130
-
131
- return String(Math.min(IDLE_POLL_MINUTES, left_minutes))
127
+ function idle_expired_reason(answer: BacklogAnswer, idle_budget_ms: number): string {
128
+ return `${waiting_on(answer)}, and stayed so for the whole ${to_minutes(idle_budget_ms)}-minute idle watch. Report what was done, including how many were picked up during the watch, and finish.`
132
129
  }
133
130
 
134
- function idle_watch_reason(left_ms: number): string {
135
- return `The backlog is empty and the idle watch has about ${to_minutes(left_ms)} minutes left. Release the working tree, ask again in ${next_ask_minutes(left_ms)} minutes, and restart the watch the moment a candidate appears.`
131
+ function idle_watch_reason(answer: BacklogAnswer, left_ms: number): string {
132
+ return `${waiting_on(answer)} and the idle watch has about ${to_minutes(left_ms)} minutes left. Release the working tree, ask again in ${String(OFFER_INTERVAL_SECONDS)} seconds, and restart the watch the moment a candidate appears.`
136
133
  }
137
134
 
138
135
  // **A watch that opens while children are still in lanes is not the idle watch's ordinary shape**: the
139
- // backlog can answer `exhausted` while this run's own children are still merging. Two things the
140
- // sentence above would get wrong there — the working tree is still held, because the hold is released
141
- // at the *last* child's merge, and a five-minute poll would leave a merge unnoticed for five minutes.
136
+ // backlog can offer nothing while this run's own children are still merging. The sentence above
137
+ // would get one thing wrong there — the working tree is still held, because the hold is released at
138
+ // the *last* child's merge.
142
139
  //
143
- // **The watch runs down while they drain, and the sentence does not promise otherwise.** Pausing it
144
- // would mean moving `active_at` on an ask that answered `exhausted`, which is the one thing that
145
- // moment is defined not to do — so a drain longer than the budget ends the run at the drain. That is
146
- // the honest ending: the backlog was empty at every ask, which is exactly what the watch measures.
147
- function running_watch_reason(running: number, left_ms: number): string {
148
- return `The backlog is empty and the idle watch has about ${to_minutes(left_ms)} minutes left, with ${String(running)} still running. Keep the working tree until they merge and poll at the polling interval, not the idle poll — the watch is running down meanwhile.`
140
+ // **The watch runs down while they drain, and restarts at each one collected.** A collected child is
141
+ // work, so it moves `active_at`; an ask that offered nothing never does.
142
+ function running_watch_reason(answer: BacklogAnswer, running: number, left_ms: number): string {
143
+ return `${waiting_on(answer)} and the idle watch has about ${to_minutes(left_ms)} minutes left, with ${String(running)} still running. Keep the working tree until they merge and poll at the polling interval — the watch is running down meanwhile.`
149
144
  }
150
145
 
151
146
  function watching_reason(input: BudgetInput, left_ms: number): string {
152
- if (input.running === 0) return idle_watch_reason(left_ms)
147
+ if (input.running === 0) return idle_watch_reason(input.answer, left_ms)
153
148
 
154
- return running_watch_reason(input.running, left_ms)
149
+ return running_watch_reason(input.answer, input.running, left_ms)
155
150
  }
156
151
 
157
152
  // What the maximum has already taken: merged issues and the children still running. Counting only
@@ -169,32 +164,10 @@ function run_reason(input: BudgetInput): string {
169
164
  return `Start at most ${String(max_issues - taken_by(input))} more: this run's maximum is ${String(max_issues)}, with ${String(input.merged)} merged and ${String(input.running)} running.`
170
165
  }
171
166
 
172
- // The two answers that end a run whatever the budgets say. Neither is a budget decision — the graph
173
- // said so — but both reach this command, because the loop asks one question rather than branching on
174
- // `backlog:next`'s word itself and then asking a second.
175
- const ANSWER_STOP_REASONS: Readonly<Partial<Record<BacklogAnswer, string>>> = {
176
- parked: PARKED_REASON,
177
- unreadable: UNREADABLE_REASON,
178
- }
179
-
180
- function whole_run_reason(input: BudgetInput): string | undefined {
181
- if (input.now_ms - input.started_at_ms < WHOLE_RUN_BUDGET_MS) return undefined
182
-
183
- return WHOLE_RUN_REASON
184
- }
185
-
186
- // The answer-level stops come first, ahead of every budget. Both of them say something the person
187
- // has to act on — issues are parked, or the listing could not be read — and the reason printed here
188
- // is quoted verbatim into the completion report, so a budget reason in front of one would report a
189
- // tidy ending for a run whose backlog actually broke.
190
- function stop_reason(input: BudgetInput): string | undefined {
191
- return ANSWER_STOP_REASONS[input.answer] ?? whole_run_reason(input)
192
- }
193
-
194
167
  // Every ending goes through here, so no ending abandons a lane. Children already started have to
195
- // merge and be reported whatever ended the run — the maximum, a parked backlog, a listing that could
196
- // not be read, or the whole-run bound — so a `stop` while any of them is still running would leave
197
- // open lanes behind and merged work unmentioned.
168
+ // merge and be reported whatever ended the run — the maximum, or a watch that ran out or was turned
169
+ // off — so a `stop` while any of them is still running would leave open lanes behind and merged work
170
+ // unmentioned.
198
171
  function stopping(input: BudgetInput, reason: string): BudgetDecision {
199
172
  if (input.running === 0) return { verdict: STOP_VERDICT, reason }
200
173
 
@@ -212,30 +185,31 @@ function max_decision(input: BudgetInput): BudgetDecision | undefined {
212
185
  return stopping(input, max_reached_reason(input.merged, max_issues))
213
186
  }
214
187
 
215
- // An empty backlog is watched rather than acted on, which is now the default — and the watch lives
216
- // *inside* the whole-run bound, which `stop_reason` has already applied above. Only `--idle 0` takes
217
- // the other branch, where an empty backlog is the run's ending.
188
+ // Nothing can proceed, so the run watches rather than ends — whichever of the three answers said so.
189
+ // The watch is the run's only time bound: it is counted from `active_at_ms`, so a run is never ended
190
+ // by how long ago it started. Only `--idle 0` takes the other branch, where the first such answer is
191
+ // the run's ending.
218
192
  function idle_decision(input: BudgetInput): BudgetDecision {
219
193
  const { idle_budget_ms } = input
220
194
 
221
- if (idle_budget_ms === undefined) return stopping(input, NO_IDLE_WATCH_REASON)
195
+ if (idle_budget_ms === undefined) return stopping(input, no_idle_watch_reason(input.answer))
222
196
 
223
197
  const left_ms = idle_budget_ms - (input.now_ms - input.active_at_ms)
224
198
 
225
- if (left_ms <= 0) return stopping(input, idle_expired_reason(idle_budget_ms))
199
+ if (left_ms > 0) return { verdict: WATCH_VERDICT, reason: watching_reason(input, left_ms) }
226
200
 
227
- return { verdict: WATCH_VERDICT, reason: watching_reason(input, left_ms) }
201
+ return stopping(input, idle_expired_reason(input.answer, idle_budget_ms))
228
202
  }
229
203
 
230
- // Whether a `stop` for this input is the run finishing — the idle watch ending on an empty backlog —
231
- // rather than stopping on something a person must act on: a parked backlog, an unreadable listing, the
232
- // maximum or the whole-run bound. The two end the carry record differently (`--end` against
233
- // `--end --stopped`), and `backlog:drive` reads which one from here.
204
+ // Whether a `stop` for this input is the run finishing — the watch ending on an empty backlog —
205
+ // rather than stopping on something a person must act on: the maximum, issues still waiting on a
206
+ // person, or a listing that never became readable. The two end the carry record differently (`--end`
207
+ // against `--end --stopped`), and `backlog:drive` reads which one from here.
234
208
  function is_finish(input: BudgetInput): boolean {
235
- return stop_reason(input) === undefined && max_decision(input) === undefined
209
+ return input.answer === 'exhausted' && max_decision(input) === undefined
236
210
  }
237
211
 
238
- // The answers whose decision no count changes, once the bounds above have been applied.
212
+ // The answers whose decision no count changes, once the maximum above has been applied.
239
213
  const FIXED_DECISIONS: Readonly<Partial<Record<BacklogAnswer, BudgetDecision>>> = {
240
214
  blocked: { verdict: WATCH_VERDICT, reason: BLOCKED_REASON },
241
215
  untriaged: { verdict: TRIAGE_VERDICT, reason: TRIAGE_REASON },
@@ -248,15 +222,7 @@ function answer_decision(input: BudgetInput): BudgetDecision {
248
222
  }
249
223
 
250
224
  function decide(input: BudgetInput): BudgetDecision {
251
- const reason = stop_reason(input)
252
-
253
- if (reason !== undefined) return stopping(input, reason)
254
-
255
- const capped = max_decision(input)
256
-
257
- if (capped !== undefined) return capped
258
-
259
- return answer_decision(input)
225
+ return max_decision(input) ?? answer_decision(input)
260
226
  }
261
227
 
262
228
  const backlog_budget = {
@@ -264,25 +230,22 @@ const backlog_budget = {
264
230
  BLOCKED_REASON,
265
231
  DEFAULT_IDLE_MINUTES,
266
232
  DEFAULT_IDLE_MS,
267
- IDLE_POLL_MINUTES,
233
+ IDLE_WAIT_HOURS,
268
234
  MS_PER_MINUTE,
269
- NO_IDLE_WATCH_REASON,
270
- PARKED_REASON,
235
+ OFFER_INTERVAL_MS,
271
236
  RUN_VERDICT,
272
237
  STOP_VERDICT,
273
238
  TRIAGE_REASON,
274
239
  TRIAGE_VERDICT,
275
- UNREADABLE_REASON,
240
+ WAITING_ON,
276
241
  WATCH_VERDICT,
277
- WHOLE_RUN_BUDGET_HOURS,
278
- WHOLE_RUN_BUDGET_MS,
279
- WHOLE_RUN_REASON,
280
242
  decide,
281
243
  draining_reason,
282
244
  idle_expired_reason,
283
245
  idle_watch_reason,
284
246
  is_finish,
285
247
  max_reached_reason,
248
+ no_idle_watch_reason,
286
249
  run_reason,
287
250
  running_watch_reason,
288
251
  taken_by,