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.
- package/CHANGELOG.md +10 -0
- package/INSTALL.md +1 -1
- package/README.md +35 -17
- package/dist/config.d.ts +32 -11
- package/dist/config.js +38 -82
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/board-settings.ts +7 -32
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +2 -29
- package/kit/hooks/hooks/plain-question/code-cache.ts +70 -0
- package/kit/hooks/hooks/plain-question/index.ts +80 -0
- package/kit/hooks/hooks/plain-question/run.ts +50 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +19 -0
- package/kit/hooks/hooks/shared/boards.ts +96 -0
- package/kit/hooks/hooks/shared/plan-codes.ts +68 -0
- package/kit/hooks/hooks.json +9 -0
- package/kit/review/skills/diagnose/SKILL.md +2 -2
- package/kit/review/skills/diagnose/references/feedback-loops.md +4 -13
- package/kit/rules/rules/kit/code-style.md +15 -18
- package/kit/rules/rules/kit/testing.md +7 -28
- package/kit/workflow/skills/implement/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +2 -0
- package/kit/workflow/skills/start/SKILL.md +4 -5
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +14 -11
- package/src/check/command.ts +121 -35
- package/src/check/plan-structure-check.ts +34 -1
- package/src/check/plan.ts +127 -44
- package/src/check/report.ts +67 -2
- package/src/check/result.ts +13 -3
- package/src/check/roots.ts +38 -10
- package/src/check/schema.ts +8 -3
- package/src/check/settings.ts +18 -1
- package/src/check/skill-budget-defaults.ts +3 -2
- package/src/config/base-branch.ts +2 -0
- package/src/config/command.ts +32 -13
- package/src/config/detect.ts +242 -74
- package/src/config/location.ts +6 -0
- package/src/config/repository-facts.ts +32 -7
- package/src/config/repository-values.ts +162 -5
- package/src/config/schema.ts +34 -16
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-format/bare-codes.ts +28 -0
- package/src/sync/command-context.ts +4 -2
- package/src/sync/herdr-plugin.ts +30 -6
- package/src/sync/init.ts +17 -16
- 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.
|
|
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
|
-
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd?activeTab=dependencies)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.
|
|
6
|
+
[](https://www.npmjs.com/package/jackherd) [](https://www.npmjs.com/package/jackherd) [](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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
81
|
-
typecheck: z.
|
|
82
|
-
lint: z.
|
|
83
|
-
format: z.
|
|
84
|
-
install: z.
|
|
85
|
-
dev: z.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
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,
|
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
|
+
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 {
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 {
|
|
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
|
+
}
|