jackherd 1.12.7 → 1.13.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 (47) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +35 -17
  4. package/dist/config.d.ts +32 -11
  5. package/dist/config.js +38 -82
  6. package/herdr-plugin/herdr-plugin.toml +1 -1
  7. package/install.sh +1 -1
  8. package/kit/hooks/hooks/merge-guard/board-settings.ts +7 -32
  9. package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -29
  10. package/kit/hooks/hooks/plain-question/code-cache.ts +70 -0
  11. package/kit/hooks/hooks/plain-question/index.ts +80 -0
  12. package/kit/hooks/hooks/plain-question/run.ts +50 -0
  13. package/kit/hooks/hooks/shared/board-lanes.ts +19 -0
  14. package/kit/hooks/hooks/shared/boards.ts +96 -0
  15. package/kit/hooks/hooks/shared/plan-codes.ts +68 -0
  16. package/kit/hooks/hooks.json +9 -0
  17. package/kit/review/skills/diagnose/SKILL.md +2 -2
  18. package/kit/review/skills/diagnose/references/feedback-loops.md +4 -13
  19. package/kit/rules/rules/kit/code-style.md +15 -18
  20. package/kit/rules/rules/kit/testing.md +7 -28
  21. package/kit/workflow/skills/implement/SKILL.md +3 -3
  22. package/kit/workflow/skills/orchestrate/SKILL.md +2 -0
  23. package/kit/workflow/skills/start/SKILL.md +4 -5
  24. package/package.json +1 -1
  25. package/schemas/jackherd.schema.json +14 -11
  26. package/src/check/command.ts +121 -35
  27. package/src/check/plan-structure-check.ts +34 -1
  28. package/src/check/plan.ts +127 -44
  29. package/src/check/report.ts +67 -2
  30. package/src/check/result.ts +13 -3
  31. package/src/check/roots.ts +38 -10
  32. package/src/check/schema.ts +8 -3
  33. package/src/check/settings.ts +18 -1
  34. package/src/check/skill-budget-defaults.ts +3 -2
  35. package/src/config/base-branch.ts +2 -0
  36. package/src/config/command.ts +32 -13
  37. package/src/config/detect.ts +242 -74
  38. package/src/config/location.ts +6 -0
  39. package/src/config/repository-facts.ts +32 -7
  40. package/src/config/repository-values.ts +162 -5
  41. package/src/config/schema.ts +34 -16
  42. package/src/lane-prompt/texts.ts +2 -2
  43. package/src/plan-format/bare-codes.ts +28 -0
  44. package/src/sync/command-context.ts +4 -2
  45. package/src/sync/herdr-plugin.ts +30 -6
  46. package/src/sync/init.ts +17 -16
  47. package/src/sync/render.ts +4 -6
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  What changed for people who use jackherd, newest first.
4
4
 
5
+ ## 1.13.0
6
+
7
+ - **Repositories without Node.** `jh init` reads the stack from the lock file: next to the Node managers, `composer.lock` writes the tools `composer.json` names (Pest or PHPUnit, PHPStan or Psalm, Pint or PHP-CS-Fixer, each as `vendor/bin/…`) and `composer install`. Before the first install, `composer.json` alone decides Composer, with no `lanes.lockFile`; a `package.json` without a lock file names no package manager, so none is guessed. A repository without a known stack gets no Bun commands any more; `init` leaves them out and says so, and the config gets a `$schema` only where `node_modules` can sit beside `.jackherd/`.
8
+ - **An unset command stays unset.** `commands.*`, `lanes.lockFile` and `check.lintFilesCommand` have no Bun default any more. A kit text shows "not set: `commands.<key>` in `.jackherd/config.json`" in place of the command, and `models` has a default, so a config written by hand without it no longer stops `init`.
9
+ - **`jh config` names what is missing.** A missing command, or one the lock files contradict (`bun test` beside only `composer.lock`), goes to stderr with the line to set; `/jh-start` passes those lines on. `jh config <key>` for an unset value exits 3 instead of printing "not set" as if it were the command.
10
+ - **No green without a test.** `jh check` finds roots by the stack's manifest (`composer.json` as well as `package.json`), counts the repository root unless it gathers workspaces, and lints only the files the stack lints. It fails when a changed source file is reached by no test, judged per file (kit copies are tested where the kit keeps them), and when `commands.test` belongs to another stack than the lock file. A `check.checks` entry with `"kind": "tests"` counts as a test for the files its globs match.
11
+ - **`jh init` in another repository keeps the herdr link.** Run from a checkout of jackherd for a repository that is not the checkout's own, `init` and `sync` leave a working herdr link where it is.
12
+ - **Kit rules without a language.** The kit's rules and the diagnose and implement skills no longer name Bun, Zod, Biome, `tsconfig` or `mock.module()`; a team writes its stack's conventions into a rule of its own beside `.claude/rules/kit/`. The README has a section on other languages.
13
+ - **A dialog that names a plan code bare is sent back.** The new hook `plain-question` (`PreToolUse` on `AskUserQuestion`) reads the codes the plans linked from the boards define (phases as `P<id>`, codes that open a table row, a heading or a bold list item; else `F`/`P`/`D`/`E` plus a number) and denies a question, header or option naming one outside brackets, so the user reads words, not shorthand. `jh check` warns about a changed "Waiting for you" row doing the same. The orchestrate skill and the lane prompt say it too.
14
+
5
15
  ## 1.12.7
6
16
 
7
17
  - **Newest first in the herd.** The rows of Your turn that wait on your answer (the board's open questions, a session's questions, the files and Artifacts sent to you) stand newest first; what frees a lane still comes before the rest, newest first within it. A since without a year counts in the year nearest to now, so a row of 31 December stands under one of 1 January. With more than five rows, the oldest are the ones under `▸ n more`. An opened source lists its entries newest first by the time each shows: before, an entry with its own time from the source (say, when an error was last seen) could stand among entries in the order the watcher saw them change. Rows of the same time keep their order. Appointments keep their order, the ones without a time first, then the next due.
package/INSTALL.md CHANGED
@@ -54,7 +54,7 @@ When `init` creates a new config, it asks:
54
54
  | Use it when | the team shares the kit through the repository | the repository must stay untouched, not even untracked files |
55
55
  | Lives in | the project's `.claude/` | `~/.claude/` |
56
56
  | Skill names | `/plan`, `/implement`, `/orchestrate`, … | `/jh-plan`, `/jh-implement`, `/jh-orchestrate`, … |
57
- | Hooks | `git-guard`, `watch-guard`, `merge-guard`, `approval-gate`, `model-gate`, `model-log`, `ask-hint`, `open-question`, `sent-file`, `format-on-edit` | none; `init --global` offers only a `SessionStart` update notice |
57
+ | Hooks | `git-guard`, `watch-guard`, `merge-guard`, `approval-gate`, `model-gate`, `model-log`, `ask-hint`, `open-question`, `plain-question`, `sent-file`, `format-on-edit` | none; `init --global` offers only a `SessionStart` update notice |
58
58
  | Config | `.jackherd/config.json` | `~/.jackherd/config.json`, used where a repository has none |
59
59
  | Update | `jh sync` | `jh update` |
60
60
 
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  <p>
2
- <img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.12.7/assets/branding/jackherd-logo-outline.svg" width="340">
2
+ <img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.13.0/assets/branding/jackherd-logo-outline.svg" width="340">
3
3
  <br>
4
4
  </p>
5
5
 
6
- [![npm](https://img.shields.io/npm/v/jackherd)](https://www.npmjs.com/package/jackherd) [![license: MIT](https://img.shields.io/npm/l/jackherd)](https://www.npmjs.com/package/jackherd) [![2 dependencies](https://img.shields.io/badge/dependencies-2-brightgreen)](https://www.npmjs.com/package/jackherd?activeTab=dependencies)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.7/assets/icons/space.svg">
6
+ [![npm](https://img.shields.io/npm/v/jackherd)](https://www.npmjs.com/package/jackherd) [![license: MIT](https://img.shields.io/npm/l/jackherd)](https://www.npmjs.com/package/jackherd) [![2 dependencies](https://img.shields.io/badge/dependencies-2-brightgreen)](https://www.npmjs.com/package/jackherd?activeTab=dependencies)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.13.0/assets/icons/space.svg">
7
7
 
8
8
  # Jack herds your coding agents.
9
9
 
@@ -15,25 +15,25 @@ For developers who'd rather decide than supervise.
15
15
  curl -fsSL https://unpkg.com/jackherd/install.sh | sh
16
16
  ```
17
17
 
18
- Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.7/assets/icons/space.svg">
18
+ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.13.0/assets/icons/space.svg">
19
19
 
20
20
  <p>
21
- <img src="https://unpkg.com/jackherd@1.12.7/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
21
+ <img src="https://unpkg.com/jackherd@1.13.0/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
22
22
  <br>
23
- <sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.7/assets/icons/space.svg">
23
+ <sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.13.0/assets/icons/space.svg">
24
24
  </p>
25
25
 
26
- <p><img src="https://unpkg.com/jackherd@1.12.7/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
26
+ <p><img src="https://unpkg.com/jackherd@1.13.0/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
27
27
 
28
- <p><img src="https://unpkg.com/jackherd@1.12.7/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A blocked lane, a red check, a review comment: Jack wakes, and the herd view shows where every lane stands.</p>
28
+ <p><img src="https://unpkg.com/jackherd@1.13.0/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A blocked lane, a red check, a review comment: Jack wakes, and the herd view shows where every lane stands.</p>
29
29
 
30
- <p><img src="https://unpkg.com/jackherd@1.12.7/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments go to the lane that fixes them, with a test. <code>/jh-follow</code> brings anything with a CLI or an API into the herd: tickets, error logs, status pages, deploys, and whatever else you point it at.</p>
30
+ <p><img src="https://unpkg.com/jackherd@1.13.0/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments go to the lane that fixes them, with a test. <code>/jh-follow</code> brings anything with a CLI or an API into the herd: tickets, error logs, status pages, deploys, and whatever else you point it at.</p>
31
31
 
32
- <p><img src="https://unpkg.com/jackherd@1.12.7/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>Picks up where you left off.</b><br>Plan and board hold the state, not the chat.</p>
32
+ <p><img src="https://unpkg.com/jackherd@1.13.0/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>Picks up where you left off.</b><br>Plan and board hold the state, not the chat.</p>
33
33
 
34
- <p><img src="https://unpkg.com/jackherd@1.12.7/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.</p>
34
+ <p><img src="https://unpkg.com/jackherd@1.13.0/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.</p>
35
35
 
36
- <p><img src="https://unpkg.com/jackherd@1.12.7/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Nothing is committed, pushed or merged without your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.7/assets/icons/space.svg"></p>
36
+ <p><img src="https://unpkg.com/jackherd@1.13.0/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Nothing is committed, pushed or merged without your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.13.0/assets/icons/space.svg"></p>
37
37
 
38
38
  ## Quickstart
39
39
 
@@ -69,22 +69,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
69
69
  /jh-orchestrate docs/plans/board-<name>/board.md
70
70
  ```
71
71
 
72
- By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.7/assets/icons/space.svg">
72
+ By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.13.0/assets/icons/space.svg">
73
73
 
74
74
  ## Follow anything outside GitHub
75
75
 
76
76
  Whatever you check by hand, Jack can watch: tickets, error logs, status pages, deploys. One `/jh-follow`, one yes, and every change comes to you like this:
77
77
 
78
- <img src="https://unpkg.com/jackherd@1.12.7/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
78
+ <img src="https://unpkg.com/jackherd@1.13.0/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
79
79
 
80
- The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.12.7/assets/icons/space.svg">
80
+ The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.13.0/assets/icons/space.svg">
81
81
 
82
82
  ## In detail
83
83
 
84
84
  <details>
85
85
  <summary><b>What Jack does on his own, and what waits for you</b></summary>
86
86
 
87
- <img src="https://unpkg.com/jackherd@1.12.7/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
87
+ <img src="https://unpkg.com/jackherd@1.13.0/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
88
88
 
89
89
  | On his own | After your approval | Never |
90
90
  | --- | --- | --- |
@@ -117,7 +117,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
117
117
 
118
118
  A **plan** is Markdown with phases, checkbox tasks, a model and a review gate per phase. A **board** lists the lanes of one or more plans: which phases each lane builds, on which model, after what. A **lane** is one agent session in its own git worktree (a second checkout of your repository), and **Jack** is the session in the main checkout that runs `/jh-orchestrate` on a board.
119
119
 
120
- <img src="https://unpkg.com/jackherd@1.12.7/docs/skills-dark.svg" alt="/jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
120
+ <img src="https://unpkg.com/jackherd@1.13.0/docs/skills-dark.svg" alt="/jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
121
121
 
122
122
  | Skill | Does |
123
123
  | --- | --- |
@@ -222,7 +222,7 @@ Each phase in the plan names its model: the planning model (Opus by default) whe
222
222
  | `jh preview <file>` | renders a Markdown file through the GitHub API into a preview in GitHub and npm style |
223
223
  | `jh waiting` | the files and Artifacts sent to you that you have not answered yet (`close <id>…` or `close --all` once you have) |
224
224
  | `jh context` | how full an agent's context is, as JSON |
225
- | `jh config` | the commands and the base branch the repository uses |
225
+ | `jh config` | the commands and the base branch the repository uses; a missing value or one the lock files contradict goes to stderr with the line to set, and `jh config <key>` for an unset value exits 3 |
226
226
 
227
227
  Without a global install, run them as `bunx jackherd <command>`. The config is `.jackherd/config.json` in a project and `~/.jackherd/config.json` globally, checked against [one schema](https://unpkg.com/jackherd/schemas/jackherd.schema.json), which your editor completes in the project config. What usually needs setting:
228
228
 
@@ -242,6 +242,24 @@ Without a global install, run them as `bunx jackherd <command>`. The config is `
242
242
 
243
243
  </details>
244
244
 
245
+ <details>
246
+ <summary><b>Other languages</b></summary>
247
+
248
+ jackherd runs on Bun; your project does not need Node. `jh init` reads the lock file in the repository root and writes the commands of that stack:
249
+
250
+ | Lock file | Commands |
251
+ | --- | --- |
252
+ | `bun.lock`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml` | the manager's own (`bun test`, `npm test`, …), TypeScript and Biome |
253
+ | `composer.lock`, or `composer.json` before the first install | from `composer.json`: Pest or PHPUnit, PHPStan or Psalm, Pint or PHP-CS-Fixer, each as `vendor/bin/…`; `composer install` |
254
+
255
+ A tool the manifest does not name gets no command, and a repository without a known lock file or a `composer.json` gets none at all: jackherd never guesses one, so a `package.json` without a lock file names no package manager. `jh config` names each missing value with the line to set, and `/jh-start` passes those lines on. Set a command yourself under `commands` in `.jackherd/config.json`; a value there always wins.
256
+
257
+ `jh check` tests from the nearest directory with the stack's manifest (`package.json`, `composer.json`), type-checks where a type config is (`tsconfig.json`, `phpstan.neon`), and lints only the files the stack lints. It fails when a changed source file is reached by no test, and when `commands.test` belongs to another stack than the lock file (`bun test` beside `composer.lock`), since such a run finds no test. A suite that runs some other way is a `check.checks` entry with `"kind": "tests"`.
258
+
259
+ The kit rules name no language, framework or test runner. Put your team's conventions (PSR-12, the PHPStan level, how Pest tests are written) into a rule of your own beside `.claude/rules/kit/`; `jh sync` never touches it.
260
+
261
+ </details>
262
+
245
263
  <details>
246
264
  <summary><b>Troubleshooting</b></summary>
247
265
 
package/dist/config.d.ts CHANGED
@@ -15,10 +15,27 @@ declare const MODEL_ROLE: {
15
15
  readonly orchestrate: "orchestrate";
16
16
  };
17
17
  type ModelRole = (typeof MODEL_ROLE)[keyof typeof MODEL_ROLE];
18
+ /** The model of each role when the config names none: a new project's, and one written by hand without `models`. */
19
+ declare const DEFAULT_MODELS: Record<ModelRole, string>;
18
20
  declare const isTerminalAdapter: (value: string) => value is TerminalAdapter;
19
21
  declare const isModelRole: (value: string) => value is ModelRole;
20
22
  /** Lower-case letters and digits ending in a dash, so a prefixed name stays one plain path segment. */
21
23
  declare const SKILL_PREFIX: RegExp;
24
+ /**
25
+ * The project's own commands. Each is optional: detection fills what the repository's stack decides, and a command
26
+ * nobody set stays out instead of a guessed one (a kit text then says where to set it).
27
+ */
28
+ declare const commandsSchema: z.ZodObject<{
29
+ test: z.ZodOptional<z.ZodString>;
30
+ typecheck: z.ZodOptional<z.ZodString>;
31
+ lint: z.ZodOptional<z.ZodString>;
32
+ format: z.ZodOptional<z.ZodString>;
33
+ install: z.ZodOptional<z.ZodString>;
34
+ dev: z.ZodOptional<z.ZodString>;
35
+ }, z.core.$strict>;
36
+ type CommandKey = keyof z.infer<typeof commandsSchema>;
37
+ /** Every key of the `commands` block, as the schema names them. */
38
+ declare const COMMAND_KEYS: CommandKey[];
22
39
  /** The window `plans.context.defaultWindow` assumes when the config leaves it out. */
23
40
  declare const DEFAULT_CONTEXT_WINDOW = 200000;
24
41
  declare const sourceSchema: z.ZodObject<{
@@ -77,24 +94,24 @@ declare const jackherdConfigSchema: z.ZodObject<{
77
94
  baseBranch: z.ZodDefault<z.ZodString>;
78
95
  skillPrefix: z.ZodDefault<z.ZodUnion<readonly [z.ZodLiteral<"">, z.ZodString]>>;
79
96
  commands: z.ZodPrefault<z.ZodObject<{
80
- test: z.ZodDefault<z.ZodString>;
81
- typecheck: z.ZodDefault<z.ZodString>;
82
- lint: z.ZodDefault<z.ZodString>;
83
- format: z.ZodDefault<z.ZodString>;
84
- install: z.ZodDefault<z.ZodString>;
85
- dev: z.ZodDefault<z.ZodString>;
97
+ test: z.ZodOptional<z.ZodString>;
98
+ typecheck: z.ZodOptional<z.ZodString>;
99
+ lint: z.ZodOptional<z.ZodString>;
100
+ format: z.ZodOptional<z.ZodString>;
101
+ install: z.ZodOptional<z.ZodString>;
102
+ dev: z.ZodOptional<z.ZodString>;
86
103
  }, z.core.$strict>>;
87
104
  lanes: z.ZodPrefault<z.ZodObject<{
88
105
  copy: z.ZodDefault<z.ZodArray<z.ZodString>>;
89
- lockFile: z.ZodDefault<z.ZodString>;
106
+ lockFile: z.ZodOptional<z.ZodString>;
90
107
  }, z.core.$strict>>;
91
- models: z.ZodRecord<z.ZodEnum<{
108
+ models: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
92
109
  plan: "plan";
93
110
  implement: "implement";
94
111
  review: "review";
95
112
  explore: "explore";
96
113
  orchestrate: "orchestrate";
97
- }>, z.ZodString>;
114
+ }>, z.ZodString>>;
98
115
  terminal: z.ZodPrefault<z.ZodObject<{
99
116
  adapter: z.ZodDefault<z.ZodEnum<{
100
117
  none: "none";
@@ -277,11 +294,15 @@ declare const jackherdConfigSchema: z.ZodObject<{
277
294
  taskSignal: z.ZodDefault<z.ZodBoolean>;
278
295
  }, z.core.$strict>>;
279
296
  check: z.ZodOptional<z.ZodObject<{
280
- lintFilesCommand: z.ZodDefault<z.ZodString>;
297
+ lintFilesCommand: z.ZodOptional<z.ZodString>;
281
298
  checks: z.ZodDefault<z.ZodArray<z.ZodObject<{
282
299
  name: z.ZodString;
283
300
  command: z.ZodString;
284
301
  globs: z.ZodOptional<z.ZodArray<z.ZodString>>;
302
+ kind: z.ZodOptional<z.ZodEnum<{
303
+ types: "types";
304
+ tests: "tests";
305
+ }>>;
285
306
  }, z.core.$strict>>>;
286
307
  generatorOwned: z.ZodDefault<z.ZodArray<z.ZodObject<{
287
308
  glob: z.ZodString;
@@ -309,4 +330,4 @@ declare const parseConfigFile: (raw: unknown, label: string) => JackherdConfig;
309
330
  /** One `watch.sources` entry outside the config, such as a draft to try; invalid throws with `label` and the issues. */
310
331
  declare const parseSourceDraft: (raw: unknown, label: string) => z.infer<typeof sourceSchema>;
311
332
 
312
- export { DEFAULT_CONTEXT_WINDOW, type JackherdConfig, MODEL_ROLE, type ModelRole, SKILL_PREFIX, TERMINAL_ADAPTER, type TerminalAdapter, isModelRole, isTerminalAdapter, jackherdConfigSchema, parseConfigFile, parseJackherdConfig, parseSourceDraft, renderConfigJsonSchema };
333
+ export { COMMAND_KEYS, type CommandKey, DEFAULT_CONTEXT_WINDOW, DEFAULT_MODELS, type JackherdConfig, MODEL_ROLE, type ModelRole, SKILL_PREFIX, TERMINAL_ADAPTER, type TerminalAdapter, isModelRole, isTerminalAdapter, jackherdConfigSchema, parseConfigFile, parseJackherdConfig, parseSourceDraft, renderConfigJsonSchema };
package/dist/config.js CHANGED
@@ -12,72 +12,6 @@ import { z as z2 } from "zod";
12
12
  // src/check/schema.ts
13
13
  import { z } from "zod";
14
14
 
15
- // src/version/release.ts
16
- var PACKAGE_NAME = "jackherd";
17
-
18
- // src/config/location.ts
19
- import { dirname, join, resolve } from "path";
20
- var TOOL_DIRECTORY = ".jackherd";
21
- var CONFIG_FILE = "config.json";
22
- var CONFIG_SCHEMA_REFERENCE = `../node_modules/${PACKAGE_NAME}/schemas/${PACKAGE_NAME}.schema.json`;
23
- var PROJECT_CONFIG_PATH = `${TOOL_DIRECTORY}/${CONFIG_FILE}`;
24
-
25
- // src/config/detect.ts
26
- var PACKAGE_MANAGER = { bun: "bun", yarn: "yarn", pnpm: "pnpm", npm: "npm" };
27
- var LOCK_FILES = {
28
- "bun.lock": PACKAGE_MANAGER.bun,
29
- "bun.lockb": PACKAGE_MANAGER.bun,
30
- "yarn.lock": PACKAGE_MANAGER.yarn,
31
- "pnpm-lock.yaml": PACKAGE_MANAGER.pnpm,
32
- "package-lock.json": PACKAGE_MANAGER.npm
33
- };
34
- var DEFAULT_BASE_BRANCH = "main";
35
- var DERIVED_COMMANDS = {
36
- bun: {
37
- test: "bun test",
38
- typecheck: "bunx -p typescript tsc --noEmit",
39
- lint: "bunx @biomejs/biome check .",
40
- format: "bunx @biomejs/biome format --write .",
41
- install: "bun install",
42
- dev: "bun run dev",
43
- lintFiles: "bunx @biomejs/biome check --no-errors-on-unmatched"
44
- },
45
- yarn: {
46
- test: "yarn test",
47
- typecheck: "yarn tsc --noEmit",
48
- lint: "yarn biome check .",
49
- format: "yarn biome format --write .",
50
- install: "yarn install",
51
- dev: "yarn dev",
52
- lintFiles: "yarn biome check --no-errors-on-unmatched"
53
- },
54
- pnpm: {
55
- test: "pnpm test",
56
- typecheck: "pnpm exec tsc --noEmit",
57
- lint: "pnpm exec biome check .",
58
- format: "pnpm exec biome format --write .",
59
- install: "pnpm install",
60
- dev: "pnpm dev",
61
- lintFiles: "pnpm exec biome check --no-errors-on-unmatched"
62
- },
63
- npm: {
64
- test: "npm test",
65
- typecheck: "npx tsc --noEmit",
66
- lint: "npx @biomejs/biome check .",
67
- format: "npx @biomejs/biome format --write .",
68
- install: "npm install",
69
- dev: "npm run dev",
70
- lintFiles: "npx @biomejs/biome check --no-errors-on-unmatched"
71
- }
72
- };
73
- var FROZEN_INSTALL = {
74
- [DERIVED_COMMANDS.bun.install]: "bun install --frozen-lockfile",
75
- [DERIVED_COMMANDS.yarn.install]: "yarn install --immutable",
76
- [DERIVED_COMMANDS.pnpm.install]: "pnpm install --frozen-lockfile",
77
- [DERIVED_COMMANDS.npm.install]: "npm ci"
78
- };
79
- var BUN_COMMANDS = DERIVED_COMMANDS.bun;
80
-
81
15
  // src/check/command-line.ts
82
16
  var TOKEN = /(?:"[^"]*"|'[^']*'|\S)+/g;
83
17
  var QUOTED = /"([^"]*)"|'([^']*)'/g;
@@ -86,6 +20,9 @@ var tokenize = (command3) => (command3.match(TOKEN) ?? []).map((token) => {
86
20
  return token.replace(QUOTED, "$1$2");
87
21
  });
88
22
 
23
+ // src/check/result.ts
24
+ var CHECK_KIND = { lint: "lint", project: "project", types: "types", tests: "tests" };
25
+
89
26
  // src/check/schema.ts
90
27
  var nonEmpty = z.string().min(1);
91
28
  var isTokenizable = (command3) => {
@@ -115,7 +52,12 @@ var projectCheck = z.strictObject({
115
52
  name: nonEmpty,
116
53
  command,
117
54
  /** Without globs the check runs on every change; with them, only when a changed path matches. */
118
- globs: globs.optional()
55
+ globs: globs.optional(),
56
+ /**
57
+ * `tests` for a check that runs the project's tests, `types` for its type check; without one, a plain project check.
58
+ * A test run counts towards "no green without a test", and both run with the slow checks.
59
+ */
60
+ kind: z.enum([CHECK_KIND.tests, CHECK_KIND.types]).optional()
119
61
  });
120
62
  var generatorOwned = z.strictObject({
121
63
  glob,
@@ -133,9 +75,9 @@ var skillBudgets = z.record(glob, z.number().int().positive());
133
75
  var checkConfigSchema = z.strictObject({
134
76
  /**
135
77
  * Run once over the changed files, which are appended as arguments. Separate from `commands.lint`,
136
- * which lints the whole project.
78
+ * which lints the whole project. Detected from the repository when left out; without one, no lint runs.
137
79
  */
138
- lintFilesCommand: command.default(BUN_COMMANDS.lintFiles),
80
+ lintFilesCommand: command.optional(),
139
81
  /** Project checks, run alongside lint; add repository-specific scripts here. */
140
82
  checks: z.array(projectCheck).default([]),
141
83
  generatorOwned: z.array(generatorOwned).default([]),
@@ -196,6 +138,9 @@ var DAY_MS = 864e5;
196
138
  var MAX_AUTO_WINDOW_DAYS = 7;
197
139
  var windowDays = (since, until) => Math.round((Date.parse(`${until}T00:00:00Z`) - Date.parse(`${since}T00:00:00Z`)) / DAY_MS);
198
140
 
141
+ // src/config/base-branch.ts
142
+ var DEFAULT_BASE_BRANCH = "main";
143
+
199
144
  // src/config/link-kinds.ts
200
145
  var ARTIFACT_LINK_KIND = {
201
146
  pattern: "^https://claude\\.ai/(code/)?artifact/",
@@ -310,6 +255,13 @@ var MODEL_ROLE = {
310
255
  explore: "explore",
311
256
  orchestrate: "orchestrate"
312
257
  };
258
+ var DEFAULT_MODELS = {
259
+ [MODEL_ROLE.plan]: "claude-opus-5-5",
260
+ [MODEL_ROLE.implement]: "claude-sonnet-5-5",
261
+ [MODEL_ROLE.review]: "claude-sonnet-5-5",
262
+ [MODEL_ROLE.explore]: "claude-sonnet-5-5",
263
+ [MODEL_ROLE.orchestrate]: "claude-opus-5-5"
264
+ };
313
265
  var isTerminalAdapter = (value) => Object.values(TERMINAL_ADAPTER).includes(value);
314
266
  var isModelRole = (value) => Object.values(MODEL_ROLE).includes(value);
315
267
  var SKILL_PREFIX = /^[a-z0-9]+-$/;
@@ -318,6 +270,17 @@ var language = z2.enum(Object.values(PLAN_LANGUAGE));
318
270
  var modelId = z2.string().regex(FULL_MODEL_ID, "Use a full model ID such as claude-opus-5-5, not an alias like opus or sonnet");
319
271
  var command2 = z2.string().min(1);
320
272
  var tokenCount = z2.number().int().positive();
273
+ var commandsSchema = z2.strictObject({
274
+ test: command2.optional(),
275
+ typecheck: command2.optional(),
276
+ lint: command2.optional(),
277
+ format: command2.optional(),
278
+ /** Installs a new lane's dependencies. */
279
+ install: command2.optional(),
280
+ /** Starts a lane's dev server, run as `PORT=<port> <dev>`; a project supplement may name another. */
281
+ dev: command2.optional()
282
+ });
283
+ var COMMAND_KEYS = Object.keys(commandsSchema.shape);
321
284
  var DEFAULT_CONTEXT_WINDOW = 2e5;
322
285
  var laneCopyPath = z2.string().regex(
323
286
  // biome-ignore lint/suspicious/noControlCharactersInRegex: the class exists to refuse control characters
@@ -587,24 +550,15 @@ var jackherdConfigSchema = z2.strictObject({
587
550
  * kit name (`implement`, `plan`, …) apart from the kit's.
588
551
  */
589
552
  skillPrefix: z2.union([z2.literal(""), z2.string().regex(SKILL_PREFIX, "lower-case letters and digits ending in a dash, e.g. kit-")]).default(""),
590
- commands: z2.strictObject({
591
- test: command2.default(BUN_COMMANDS.test),
592
- typecheck: command2.default(BUN_COMMANDS.typecheck),
593
- lint: command2.default(BUN_COMMANDS.lint),
594
- format: command2.default(BUN_COMMANDS.format),
595
- /** Installs a new lane's dependencies. */
596
- install: command2.default(BUN_COMMANDS.install),
597
- /** Starts a lane's dev server, run as `PORT=<port> <dev>`; a project supplement may name another. */
598
- dev: command2.default(BUN_COMMANDS.dev)
599
- }).prefault({}),
553
+ commands: commandsSchema.prefault({}),
600
554
  /** How `/orchestrate` sets up a lane's worktree. */
601
555
  lanes: z2.strictObject({
602
556
  /** Untracked files copied into every new worktree; empty means the files `.worktreeinclude` lists. */
603
557
  copy: z2.array(laneCopyPath).default([]),
604
- /** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand. */
605
- lockFile: lockFilePath.default("bun.lock")
558
+ /** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand; detected from the repository when left out. */
559
+ lockFile: lockFilePath.optional()
606
560
  }).prefault({}),
607
- models: z2.record(z2.enum(Object.values(MODEL_ROLE)), modelId),
561
+ models: z2.record(z2.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
608
562
  terminal: z2.strictObject({ adapter: z2.enum(Object.values(TERMINAL_ADAPTER)).default(TERMINAL_ADAPTER.none) }).prefault({}),
609
563
  /** How the coordinator shows a draft for review (`orchestrate` § 4). */
610
564
  preview: z2.strictObject({
@@ -669,7 +623,9 @@ var parseSourceDraft = (raw, label) => parseLabelled(sourceSchema, raw, label);
669
623
  var renderConfigJsonSchema = () => `${JSON.stringify(z3.toJSONSchema(jackherdConfigSchema, { io: "input" }), null, 2)}
670
624
  `;
671
625
  export {
626
+ COMMAND_KEYS,
672
627
  DEFAULT_CONTEXT_WINDOW,
628
+ DEFAULT_MODELS,
673
629
  MODEL_ROLE,
674
630
  SKILL_PREFIX,
675
631
  TERMINAL_ADAPTER,
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.12.7"
3
+ version = "1.13.0"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the herd, the state of a board's lanes, in a herdr pane"
6
6
  platforms = ["linux", "macos"]
package/install.sh CHANGED
@@ -9,7 +9,7 @@ set -eu
9
9
 
10
10
  # The default is the version this file ships with. The release bump changes it together with
11
11
  # package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
12
- JACKHERD_VERSION="${JACKHERD_VERSION:-1.12.7}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.13.0}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -1,42 +1,24 @@
1
1
  import { baseBranchOf } from '../shared/base-branch'
2
- import { BOARD_WORDS, type BoardLanguage } from '../shared/board-lanes'
2
+ import { type BoardLocation, boardLocationValues } from '../shared/boards'
3
3
  import { isRecord } from '../shared/json'
4
- import { isRepositoryDirectory } from '../shared/repository-directory'
5
4
  import { isControlCharacter } from './plain-path'
6
5
 
7
6
  /**
8
7
  * Where plans, boards and lanes live, the base branch, and the checks a standing approval waits for. `null` from
9
8
  * `boardSettingsOf` means the standing approval is off and every merge asks.
10
9
  */
11
- export type BoardSettings = {
12
- planLanguage: BoardLanguage
13
- planDirectory: string
14
- board: { pattern: string; file: string }
15
- worktreeDirectory: string
10
+ export type BoardSettings = BoardLocation & {
16
11
  baseBranch: string
17
12
  /** The checks that must be among the head commit's and green; undefined: at least one check, every one green. */
18
13
  requiredChecks: readonly string[] | undefined
19
14
  }
20
15
 
21
16
  const MAX_CHECK_NAME = 200
22
- const BOARD_PATTERN = /^(?![-.])[A-Za-z0-9._*?-]+$/
23
- const BOARD_FILE = /^(?![-.])[A-Za-z0-9._-]+\.md$/
24
-
25
- /** The value at `key`, its default when absent, `undefined` when present but invalid. */
26
- const valueOr = <T>(section: Record<string, unknown>, key: string, isValid: (value: unknown) => value is T, fallback: T) =>
27
- !(key in section) ? fallback : isValid(section[key]) ? section[key] : undefined
28
17
 
29
18
  /** An optional value: `{ value }` with `value` undefined when absent, `undefined` when present but invalid. */
30
19
  const optionalValue = <T>(section: Record<string, unknown>, key: string, isValid: (value: unknown) => value is T) =>
31
20
  !(key in section) ? { value: undefined } : isValid(section[key]) ? { value: section[key] } : undefined
32
21
 
33
- const matches =
34
- (pattern: RegExp) =>
35
- (value: unknown): value is string =>
36
- typeof value === 'string' && pattern.test(value)
37
-
38
- const isPlanLanguage = (value: unknown): value is BoardLanguage => typeof value === 'string' && Object.hasOwn(BOARD_WORDS, value)
39
-
40
22
  const isSectionOrAbsent = (config: Record<string, unknown>, key: string): boolean =>
41
23
  !(key in config) || isRecord(config[key])
42
24
 
@@ -64,18 +46,11 @@ const requiredChecksOf = (guard: Record<string, unknown>) =>
64
46
  : optionalValue(sectionAt(guard, 'planApproval'), 'requiredChecks', isCheckList)
65
47
 
66
48
  /** Every value the standing approval reads, with its default where absent; `undefined` stands for a present but invalid one. */
67
- const boardValues = (config: Record<string, unknown>) => {
68
- const board = sectionAt(config, 'board')
69
- return {
70
- requiredChecks: requiredChecksOf(sectionAt(config, 'guard')),
71
- planLanguage: valueOr(config, 'planLanguage', isPlanLanguage, 'en'),
72
- planDirectory: valueOr(config, 'planDirectory', isRepositoryDirectory, 'docs/plans'),
73
- pattern: valueOr(board, 'pattern', matches(BOARD_PATTERN), 'board-*'),
74
- file: valueOr(board, 'file', matches(BOARD_FILE), 'board.md'),
75
- worktreeDirectory: valueOr(config, 'worktreeDirectory', isRepositoryDirectory, '.claude/worktrees'),
76
- baseBranch: baseBranchOf(config),
77
- }
78
- }
49
+ const boardValues = (config: Record<string, unknown>) => ({
50
+ requiredChecks: requiredChecksOf(sectionAt(config, 'guard')),
51
+ ...boardLocationValues(config, sectionAt(config, 'board')),
52
+ baseBranch: baseBranchOf(config),
53
+ })
79
54
 
80
55
  type Values = ReturnType<typeof boardValues>
81
56
  type ValidValues = { [Key in keyof Values]: Exclude<Values[Key], undefined> }
@@ -3,15 +3,12 @@
3
3
  * link on the boards, as the main checkout holds them (the coordinator keeps its board there, committed or not). The
4
4
  * git call runs without a shell and with a timeout; any failure answers nothing, so the guard asks.
5
5
  */
6
- import { readdirSync, readFileSync } from 'node:fs'
7
- import { join, posix, relative, resolve } from 'node:path'
6
+ import { relative, resolve } from 'node:path'
8
7
  import { type BoardLanguage, lanePlansOf } from '../shared/board-lanes'
8
+ import { type Board, boardsIn } from '../shared/boards'
9
9
  import { laneOf } from '../shared/lane'
10
10
  import type { BoardSettings } from './board-settings'
11
11
 
12
- /** A board's directory relative to the repository, and its text. */
13
- export type Board = { directory: string; text: string }
14
-
15
12
  const GIT_TIMEOUT_MS = 3000
16
13
 
17
14
  /** The lane whose worktree, directly below the worktree directory, has `branch` checked out. */
@@ -47,30 +44,6 @@ const worktreeListOf = (main: string): string | undefined => {
47
44
  return result.exitCode === 0 ? result.stdout.toString() : undefined
48
45
  }
49
46
 
50
- const readTextOrUndefined = (path: string): string | undefined => {
51
- try {
52
- return readFileSync(path, 'utf8')
53
- } catch {
54
- return undefined
55
- }
56
- }
57
-
58
- /** The boards under the plan directory of the main checkout whose directory name matches the board pattern. */
59
- const boardsIn = (main: string, settings: BoardSettings): Board[] => {
60
- const pattern = new Bun.Glob(settings.board.pattern)
61
- try {
62
- return readdirSync(join(main, settings.planDirectory), { withFileTypes: true })
63
- .filter((entry) => entry.isDirectory() && pattern.match(entry.name))
64
- .flatMap((entry) => {
65
- const directory = posix.join(settings.planDirectory, entry.name)
66
- const text = readTextOrUndefined(join(main, directory, settings.board.file))
67
- return text === undefined ? [] : [{ directory, text }]
68
- })
69
- } catch {
70
- return []
71
- }
72
- }
73
-
74
47
  /** The one plan the lane of `branch` links on the boards of the main checkout; undefined when it cannot tell. */
75
48
  export const planOfBranch = (main: string, settings: BoardSettings, branch: string): string | undefined => {
76
49
  const list = worktreeListOf(main)
@@ -0,0 +1,70 @@
1
+ import { isRecord } from '../shared/json'
2
+
3
+ /** A plan file as `stat` sees it: its absolute path, modification time and size, the cache's key. */
4
+ export type PlanFileStat = { path: string; mtimeMs: number; size: number }
5
+
6
+ /** The codes of each plan file the last dialog read, by absolute path, with the time and size they were read at. */
7
+ export type CodeCache = Record<string, { mtimeMs: number; size: number; codes: string[] }>
8
+
9
+ /** The codes the files define together (null when none could be read), the cache to keep, and whether it changed. */
10
+ export type CodeLookup = { codes: Set<string> | null; cache: CodeCache; changed: boolean }
11
+
12
+ const CACHE_VERSION = 1
13
+
14
+ const isEntry = (value: unknown): value is CodeCache[string] =>
15
+ isRecord(value) &&
16
+ typeof value.mtimeMs === 'number' &&
17
+ typeof value.size === 'number' &&
18
+ Array.isArray(value.codes) &&
19
+ value.codes.every((code) => typeof code === 'string')
20
+
21
+ /** The cache file's text as a cache; a missing, broken or foreign file, and each entry of the wrong shape, read as empty. */
22
+ export const parseCodeCache = (text: string | null): CodeCache => {
23
+ try {
24
+ const parsed: unknown = text === null ? null : JSON.parse(text)
25
+ if (!isRecord(parsed) || parsed.version !== CACHE_VERSION || !isRecord(parsed.entries)) return {}
26
+ return Object.fromEntries(
27
+ Object.entries(parsed.entries).flatMap(([path, entry]): [string, CodeCache[string]][] =>
28
+ isEntry(entry) ? [[path, entry]] : [],
29
+ ),
30
+ )
31
+ } catch {
32
+ return {}
33
+ }
34
+ }
35
+
36
+ export const serializeCodeCache = (cache: CodeCache): string =>
37
+ `${JSON.stringify({ version: CACHE_VERSION, entries: cache })}\n`
38
+
39
+ const isFresh = (entry: CodeCache[string] | undefined, { mtimeMs, size }: PlanFileStat): entry is CodeCache[string] =>
40
+ entry !== undefined && entry.mtimeMs === mtimeMs && entry.size === size
41
+
42
+ /**
43
+ * The codes of the plan files: a file whose time and size match its cache entry is not read, the others are read and
44
+ * parsed, copies with the same text once. The cache keeps exactly the files read or reused now, so an unlinked plan
45
+ * leaves it.
46
+ */
47
+ export const lookUpCodes = (
48
+ stats: readonly PlanFileStat[],
49
+ cache: CodeCache,
50
+ readText: (path: string) => string | null,
51
+ codesOf: (text: string) => string[],
52
+ ): CodeLookup => {
53
+ const parsed = new Map<string, string[]>()
54
+ const parse = (text: string): string[] => parsed.get(text) ?? parsed.set(text, codesOf(text)).get(text) ?? []
55
+ let missed = false
56
+ const entries = stats.flatMap((stat): [string, CodeCache[string]][] => {
57
+ const entry = cache[stat.path]
58
+ if (isFresh(entry, stat)) return [[stat.path, entry]]
59
+ missed = true
60
+ const text = readText(stat.path)
61
+ return text === null ? [] : [[stat.path, { mtimeMs: stat.mtimeMs, size: stat.size, codes: parse(text) }]]
62
+ })
63
+ const next = Object.fromEntries(entries)
64
+ const dropped = Object.keys(cache).some((path) => !(path in next))
65
+ return {
66
+ codes: entries.length === 0 ? null : new Set(entries.flatMap(([, entry]) => entry.codes)),
67
+ cache: next,
68
+ changed: missed || dropped,
69
+ }
70
+ }