jackherd 1.5.0 → 1.6.1
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 +13 -0
- package/README.md +42 -11
- package/dist/{chunk-HQAONP4W.js → chunk-QJSXHHQ3.js} +13 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +11 -2
- package/dist/plan-format.d.ts +12 -1
- package/dist/plan-format.js +5 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/rules/rules/kit/code-style.md +1 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +14 -7
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +8 -0
- package/src/cli.ts +15 -2
- package/src/config/detect.ts +15 -0
- package/src/config/schema.ts +3 -1
- package/src/sync/emptied-directories.ts +19 -0
- package/src/sync/project-fs.ts +28 -2
- package/src/sync/render.ts +8 -4
- package/src/sync/sync.ts +28 -10
- package/src/vocabulary/index.ts +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.6.1
|
|
6
|
+
|
|
7
|
+
- **The README's guard section is split.** The head says the coordinator asks before every commit, push and merge, and "Guarded commands and self-merge" is split into What is guarded, Self-merge, Merging without checks and Limits. Points 4 and 7 of "What the coordinator does" are lists. The behaviour is unchanged.
|
|
8
|
+
- **Dates and times in a record are read from the clock.** The `orchestrate` skill now says in § 4 that a date or time written into a plan, the board or a history entry comes from `date`, run in the same turn, never from the conversation or a guess; the self-merge note, the closing note and the hand-over note in § 5, § 7 and § 8 point to it.
|
|
9
|
+
|
|
10
|
+
## 1.6.0
|
|
11
|
+
|
|
12
|
+
- **Replies address you as you like.** The new setting `responseAddress` (`informal` by default, or `formal`) says whether replies use the familiar or the polite form where the language has both: in German `du` or `Sie`. Set it in `.jackherd/config.json`; `jh sync` writes it into the kit rule `rules/kit/code-style.md`, which every session loads. It follows `responseLanguage`. English has one form, so there the setting only nudges the tone (direct or businesslike). The watcher's and the plans view's own German words use no pronoun and are unchanged.
|
|
13
|
+
- **`jh --version` (and `jh -v`) prints the installed version** and exits 0. The usage text lists it.
|
|
14
|
+
- **Closing a lane marks its rows.** Step 5 of "Close a lane" in the `orchestrate` skill now appends the cleaned-up word (`, abgeräumt <date>` on a German board, `, cleaned up <date>` on an English one) to the name cell of every row of the lane, in the same documentation PR as the history entry. The word follows `planLanguage`. The plans view no longer lists a closed lane without a worktree as `waiting`.
|
|
15
|
+
- **The worktree's dependencies are checked with one command.** When the herdr plugin `zerodice0.worktree-bootstrap` has set up the worktree, the `orchestrate` skill no longer polls for a dependencies directory (some package managers keep a workspace's out of the root, so the poll could keep waiting on an install that was long done). It runs your `commands.install` with the lock file frozen: `bun install --frozen-lockfile`, `pnpm install --frozen-lockfile`, `yarn install --immutable` or `npm ci` (which always reinstalls). Any other install command you set is run exactly as written.
|
|
16
|
+
- **`jh sync` clears the empty directories a prefix change leaves.** When `skillPrefix` changes, the unedited kit files under the old names are removed, and now the directories those removals leave empty go too; an empty directory the run did not touch stays. A directory that still holds a file the kit did not write stays, and `sync` says so (`Kept .claude/skills/<name>: it holds files the kit did not write.`). A symbolic link is not followed, and `sync --check` removes nothing.
|
|
17
|
+
|
|
5
18
|
## 1.5.0
|
|
6
19
|
|
|
7
20
|
- **The plans view lists only the lanes that still have something to do.** Before, every lane without an agent was listed (56 of 61 on one board, nearly all of them finished). Now a lane marked cleaned up, and a lane without a worktree whose named phases are all ticked, are not shown. A lane without a worktree that still has an open phase, or no plan and a "starts after" other than "now", is listed under `· waiting` with what it waits for. A worktree without an agent stays under `· no agent`. The board needs no change. The watcher is unchanged.
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ you lose track of who waits on you. Jack:
|
|
|
15
15
|
|
|
16
16
|
- 📄 **Keeps the plan in your repository.** Your agent writes it as Markdown, phases with a model each, and ticks it off as it builds.
|
|
17
17
|
- ⚡ **Herds the lanes.** Each lane works in its own git worktree, so sessions never share a checkout.
|
|
18
|
-
- ❓ **Brings you only the decisions.** A coordinating session answers what the plan settles, vets review feedback on its pull requests, asks you the rest as a choice, and asks you before every commit and
|
|
18
|
+
- ❓ **Brings you only the decisions.** A coordinating session answers what the plan settles, vets review feedback on its pull requests, asks you the rest as a choice, and asks you before every commit, push and merge.
|
|
19
19
|
- 💬 **Barks when something moves.** One live view of every lane: state, progress, pull request, checks, with the preview link and new feedback as badges; `/follow` brings in tickets, issues and status pages outside GitHub.
|
|
20
20
|
- 🧠 **Watches the context.** It measures each agent session's fill and, before it is full, offers a fresh chat that takes over the state.
|
|
21
21
|
|
|
@@ -185,7 +185,7 @@ bunx jackherd sync # later: updates the kit files
|
|
|
185
185
|
|
|
186
186
|
`init` writes what it [detected](#user-content-detected-per-repository) into the new config, so the project sees it. It asks whether to enter the [status line](#user-content-status-line) in `.claude/settings.json`; `--yes` or `--no` answer every question without asking.
|
|
187
187
|
|
|
188
|
-
**Name clashes.** A project that already has a skill or agent of a kit name (`implement`, `plan`, …): `init` names the clash and offers the prefix `jh-`, so the kit's copies sit beside yours and call each other, not yours. Any project can set `skillPrefix` in `.jackherd/config.json`; `sync` then renames, and removes the unprefixed kit files you never edited.
|
|
188
|
+
**Name clashes.** A project that already has a skill or agent of a kit name (`implement`, `plan`, …): `init` names the clash and offers the prefix `jh-`, so the kit's copies sit beside yours and call each other, not yours. Any project can set `skillPrefix` in `.jackherd/config.json`; `sync` then renames, and removes the unprefixed kit files you never edited, with the directories they leave empty. A directory that holds a file of yours stays, and `sync` names it.
|
|
189
189
|
|
|
190
190
|
**Your edits survive.** `sync` leaves files you have edited alone. When the kit changed one of them too, its version lands beside yours as `<file>.kit-new`: merge it, or take the kit version with `sync --accept <path>`. A kit file you deleted stays deleted. Project-specific additions go into `.claude/project/<skill>.md` (read first by the skill of that name) or a rule beside `.claude/rules/kit/`, so the kit files stay updatable.
|
|
191
191
|
|
|
@@ -497,10 +497,17 @@ Lanes ask Jack, not you: the coordinator checks their questions against the plan
|
|
|
497
497
|
1. **Reads the board.** The `## Lanes` table names each lane, its port, the plan and phases it works on, what it starts after, and the model. It is the only place that says which lane runs which phase. The coordinator adds a row itself when a lane starts without one.
|
|
498
498
|
2. **Starts ready lanes.** A phase is ready when its starts-after entry and the plan's dependencies are met and its `Writes` set and serial markers collide with no running lane; shared paths run one after the other, migrations strictly in order. For each lane it creates a worktree from the base branch, copies the untracked files every worktree needs, installs dependencies, starts the agent on the board's model, checks the banner shows that model, opens a plan pane beside it, and sends the lane prompt: plan, phases, the paths it may write, its port.
|
|
499
499
|
3. **Runs dev servers.** A lane whose phase changes something a user sees gets its dev server in a pane of its own when the lane starts, never in the lane's shell.
|
|
500
|
-
4. **Vets questions.** A lane never opens a dialog
|
|
500
|
+
4. **Vets questions.** A lane never opens a dialog; it ends its turn with a text block. The coordinator checks the premises against the code and the plan and brings the open decisions to you, each as a clickable choice.
|
|
501
|
+
- The block holds the question, the options, the lane's recommendation and the evidence at `file:line`.
|
|
502
|
+
- A question in a lane's report is closed in the same turn, in the lane or with you.
|
|
503
|
+
- A draft you review (a README, a page) goes in rounds: you pick the remarks, the lane changes it, you see it again, and the push waits for your approval.
|
|
504
|
+
- Your decision goes into the plan or the board in the same turn, with the date, so it is visible in the plan view and survives the chat. The date and time come from `date`, never from the chat or a guess.
|
|
501
505
|
5. **Reviews results.** When a lane is `done` or `idle`, it checks the diff against the lane's `Writes` set and that checks are quoted with numbers, not claimed. Then it starts the lane's next ready phase or names the reason to wait.
|
|
502
506
|
6. **Asks once per PR.** A lane works uncommitted until its PR is ready. The coordinator then asks you once, with the diff summary, the checks with numbers, open points and the commit message; for a visible change it first walks the changed path in the browser. That approval covers the commits, the push and the PR. The question has three options: you merge, you approve and let the coordinator merge once the checks are green ([self-merge](#user-content-guarded-commands-and-self-merge)), or you change something first.
|
|
503
|
-
7. **Keeps the merge order.**
|
|
507
|
+
7. **Keeps the merge order.** Merges follow the board's order, the documentation PR of a fresh plan first. After each merge:
|
|
508
|
+
- the remaining lanes fetch and rebase on the base branch and re-run their checks; a lock-file conflict is never resolved by hand.
|
|
509
|
+
- the coordinator removes the copies of the plan files in the main checkout that equal the merged ones, so the next update of the checkout does not stop on them.
|
|
510
|
+
- each lane gets its next phase or a named reason to wait.
|
|
504
511
|
8. **Closes lanes.** After a lane's last merge and your agreement, it ends the agent, proves the worktree holds nothing unmerged, removes the worktree and the branch, and notes the end in the board.
|
|
505
512
|
|
|
506
513
|
#### What happens on its own, and what waits for you
|
|
@@ -516,7 +523,11 @@ Who merges follows the project's own rules: by default you, unless the project s
|
|
|
516
523
|
|
|
517
524
|
#### Guarded commands and self-merge
|
|
518
525
|
|
|
519
|
-
A merge or a release is the step nobody takes back.
|
|
526
|
+
A merge or a release is the step nobody takes back.
|
|
527
|
+
|
|
528
|
+
##### What is guarded
|
|
529
|
+
|
|
530
|
+
In a project install the `merge-guard` hook asks you before `gh pr merge` and `gh workflow run` (the usual production release) run. It recognises them when the command spells them out: behind options before the verb (`gh -R x pr merge`), a chain (`a && gh pr merge 12`), a wrapper, known or not (any command whose words contain the guarded words in this order, such as `mise exec -- gh pr merge 12`), `sh -c` and `eval`. It shows the dialog even in auto mode. A quoted text that names the words, such as a commit message, passes, and so does everything else; the hook prints nothing unless it asks.
|
|
520
531
|
|
|
521
532
|
```json
|
|
522
533
|
{
|
|
@@ -531,7 +542,15 @@ A merge or a release is the step nobody takes back. In a project install the `me
|
|
|
531
542
|
- `guard.selfMerge.offer` (default `true`) lets `/orchestrate` offer "approve, and merge it yourself after green checks" in its PR question. With `false` the question has two options and the hook honours no such answer.
|
|
532
543
|
- `guard.selfMerge.approvalMinutes` (default 180) is how long that answer lets `gh pr merge <number>` pass without a second dialog.
|
|
533
544
|
|
|
534
|
-
|
|
545
|
+
##### Self-merge
|
|
546
|
+
|
|
547
|
+
You pick the second option of the PR question. The coordinator commits, pushes and opens the PR, waits for the checks in the background, merges with `gh pr merge <number> --squash --delete-branch` when they are green and tells you in one line; on a red check it does not merge and reports. The project supplement can block the option per approval tier.
|
|
548
|
+
|
|
549
|
+
The hook does not take the agent's word for your answer. It reads the session's transcript, which Claude Code writes, and looks for a dialog result where you picked that exact option for a question naming one branch. Then it asks GitHub which branch pull request `<number>` carries and lets the merge pass if the two match, the PR is open and not from a fork, and the command is exactly the plain form: `gh pr merge <number>` with `--squash`, `--merge`, `--rebase` and `--delete-branch` as the only flags, run on its own in the directory you work in (no `-R`, no `--admin`, no chain, no wrapper). `gh workflow run` and every other guarded command ask every time. The approval covers the branch, not the commits you saw, for the configured minutes.
|
|
550
|
+
|
|
551
|
+
##### Merging without checks
|
|
552
|
+
|
|
553
|
+
A pull request that only changes documents rarely needs its checks. List the paths in `merge.withoutChecks` (default empty, so every merge waits for the checks as before):
|
|
535
554
|
|
|
536
555
|
```json
|
|
537
556
|
{
|
|
@@ -539,13 +558,23 @@ A merge or a release is the step nobody takes back. In a project install the `me
|
|
|
539
558
|
}
|
|
540
559
|
```
|
|
541
560
|
|
|
542
|
-
A pattern is made of path words with `*` and `**`: no `..`, not absolute, no braces or negation, and at least one whole path segment without a wildcard, so `docs/**/*.md` is fine and `**`, `**/*.md` and `**/*.*` are refused. Keep the folder in the pattern: a `.md` file elsewhere can be an agent instruction (`CLAUDE.md`, a skill) that ships with a package and deserves its checks.
|
|
561
|
+
A pattern is made of path words with `*` and `**`: no `..`, not absolute, no braces or negation, and at least one whole path segment without a wildcard, so `docs/**/*.md` is fine and `**`, `**/*.md` and `**/*.*` are refused. Keep the folder in the pattern: a `.md` file elsewhere can be an agent instruction (`CLAUDE.md`, a skill) that ships with a package and deserves its checks.
|
|
562
|
+
|
|
563
|
+
With the list set, `/orchestrate` lists a pull request's files with `gh pr diff <number> --name-only` before it waits for checks and merges at once when every file matches.
|
|
564
|
+
|
|
565
|
+
The merge guard checks the same itself: for a plain `gh pr merge <number>` it asks GitHub for the pull request and then for its files in that repository (`gh api …/pulls/<number>/files`, which also names the old path of a renamed file). It does not ask you when the pull request is open, from this repository, GitHub lists as many files as the pull request changes (fewer than 3000) and every one matches a pattern. This works without a self-merge answer, also when `guard.selfMerge.offer` is `false`. No file, one file outside, a count that differs, a failed `gh` call or a pattern list it cannot read means it asks as before.
|
|
566
|
+
|
|
567
|
+
##### Limits
|
|
568
|
+
|
|
569
|
+
The hook reads the command as written, so it does not see what a command runs behind its back: a script file or a project script (`bash ./release.sh`, `./release.sh`, `bun run release`, `make release`) that calls `gh workflow run`, a shell alias or function, `gh alias` and git aliases, text that is encoded or assembled at run time, and a wrapper that spells the words differently. Add the script runners you use to `guard.commands`, or protect the repository itself.
|
|
543
570
|
|
|
544
|
-
|
|
571
|
+
The agent runs with your account, so it can also edit a transcript, drop the hook from `.claude/settings.json` or merge through `gh api`. The guard stops a merge or a release by mistake, by a misread instruction or a mistyped command; it does not stop an agent that sets out to get around it. For that, use branch protection and required reviews on the repository.
|
|
545
572
|
|
|
546
|
-
|
|
573
|
+
- The merge-without-checks look is a snapshot: a push between its look and the merge is not seen.
|
|
574
|
+
- A global install ships no hook, so there the skill text alone asks you.
|
|
575
|
+
- If Bun cannot start (a process out of file descriptors, say), the hook cannot run and every Bash call is blocked, on purpose: a guard that cannot run does not wave commands through.
|
|
547
576
|
|
|
548
|
-
|
|
577
|
+
##### Dialogs inside a lane
|
|
549
578
|
|
|
550
579
|
A permission dialog inside a lane the coordinator confirms itself only for the commit and push of the lane's own branch under its PR approval, and for the fetch, rebase and lock-file steps after a merge, always with the one-time option. Every other dialog goes to you.
|
|
551
580
|
|
|
@@ -933,7 +962,7 @@ Decisions never live only in a hand-over note: they are in the plan or the board
|
|
|
933
962
|
|
|
934
963
|
In a repository that ignores `docs/`, plan and board exist only in the main checkout and the plan view reads them there; list the plan in `lanes.copy` so the lane has it to work from and to tick off its own progress.
|
|
935
964
|
|
|
936
|
-
The herdr plugin `zerodice0.worktree-bootstrap`, when enabled and configured in the main checkout, can copy files and run setup commands for new worktrees; the coordinator checks its result and does the step itself when it is missing.
|
|
965
|
+
The herdr plugin `zerodice0.worktree-bootstrap`, when enabled and configured in the main checkout, can copy files and run setup commands for new worktrees; the coordinator checks its result with the install command and a frozen lock file (`bun install --frozen-lockfile`, `pnpm install --frozen-lockfile`, `yarn install --immutable`, `npm ci`, derived from `commands.install`; an install command of your own runs as written) and does the step itself when it is missing.
|
|
937
966
|
|
|
938
967
|
</details>
|
|
939
968
|
|
|
@@ -1077,6 +1106,7 @@ At `warn` a lane offers a fresh chat at its next phase boundary, and the watcher
|
|
|
1077
1106
|
|
|
1078
1107
|
| Command | What it does | Options |
|
|
1079
1108
|
| ------- | ------------ | ------- |
|
|
1109
|
+
| `jh --version` | The installed version, e.g. `1.6.0`; `-v` is the same | none |
|
|
1080
1110
|
| `jh init` | Creates `.jackherd/config.json` and installs the kit | `--global`: into `~/.claude` and `~/.jackherd`; `--yes`/`--no`: answer all its questions (globally the hook and the status line, in a project the prefix and the status line) and take the default [setup](#user-content-setup-questions); a status line of your own is replaced only by a yes in the dialog |
|
|
1081
1111
|
| `jh sync` | Updates the kit files; edited files get a `.kit-new` beside them | `--global`; `--accept <path>` takes the kit's version; `--global --if-changed` syncs only after a package update and names a newer npm release once a day |
|
|
1082
1112
|
| `jh update` | Installs the newest release with the package manager that holds the kit globally, then syncs `~/.claude` and the project with the new code | none; does nothing when the installed version is the latest |
|
|
@@ -1095,6 +1125,7 @@ Without a global install, run any of them as `bunx jackherd <command>`.
|
|
|
1095
1125
|
| Key | Default | What it decides |
|
|
1096
1126
|
| --- | --- | --- |
|
|
1097
1127
|
| `responseLanguage` | `en` | the language the skills speak with you, and the watcher's status words |
|
|
1128
|
+
| `responseAddress` | `informal` | `informal` or `formal`: how replies address you where the language has both (German: `du` or `Sie`). `jh sync` writes it once into the kit rule `rules/kit/code-style.md`, which every session loads; it follows `responseLanguage`; the status words of the watcher and the plans view use no pronoun and stay as they are |
|
|
1098
1129
|
| `planLanguage` | `en` | the language of plans and boards, including their headings |
|
|
1099
1130
|
| `planDirectory` | `docs/plans` | where plans and boards live |
|
|
1100
1131
|
| `board.pattern`, `board.file`, `board.term` | `board-*`, `board.md`, `board` | [Boards](#user-content-boards) |
|
|
@@ -4,6 +4,17 @@ var LANGUAGE_NAME = {
|
|
|
4
4
|
[PLAN_LANGUAGE.en]: "English",
|
|
5
5
|
[PLAN_LANGUAGE.de]: "German"
|
|
6
6
|
};
|
|
7
|
+
var RESPONSE_ADDRESS = { informal: "informal", formal: "formal" };
|
|
8
|
+
var ADDRESS_FORM = {
|
|
9
|
+
[PLAN_LANGUAGE.en]: {
|
|
10
|
+
[RESPONSE_ADDRESS.informal]: "the plain, direct \u201Cyou\u201D, without titles",
|
|
11
|
+
[RESPONSE_ADDRESS.formal]: "a polite, businesslike \u201Cyou\u201D"
|
|
12
|
+
},
|
|
13
|
+
[PLAN_LANGUAGE.de]: {
|
|
14
|
+
[RESPONSE_ADDRESS.informal]: "the familiar \u201Cdu\u201D (du, dir, dein), never \u201CSie\u201D",
|
|
15
|
+
[RESPONSE_ADDRESS.formal]: "the polite \u201CSie\u201D (Sie, Ihnen, Ihr), never \u201Cdu\u201D"
|
|
16
|
+
}
|
|
17
|
+
};
|
|
7
18
|
var SELF_MERGE_LABEL = {
|
|
8
19
|
[PLAN_LANGUAGE.en]: "Approve, and merge it yourself after green checks",
|
|
9
20
|
[PLAN_LANGUAGE.de]: "Freigeben, und nach gr\xFCnen Checks selbst mergen"
|
|
@@ -90,6 +101,8 @@ var sectionHeading = (language, section) => `## ${PLAN_VOCABULARY[language].sect
|
|
|
90
101
|
export {
|
|
91
102
|
PLAN_LANGUAGE,
|
|
92
103
|
LANGUAGE_NAME,
|
|
104
|
+
RESPONSE_ADDRESS,
|
|
105
|
+
ADDRESS_FORM,
|
|
93
106
|
SELF_MERGE_LABEL,
|
|
94
107
|
isPlanLanguage,
|
|
95
108
|
PLAN_STATUS,
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
PLAN_LANGUAGE
|
|
3
|
-
|
|
2
|
+
PLAN_LANGUAGE,
|
|
3
|
+
RESPONSE_ADDRESS
|
|
4
|
+
} from "./chunk-QJSXHHQ3.js";
|
|
4
5
|
|
|
5
6
|
// src/config/json-schema.ts
|
|
6
7
|
import { z as z3 } from "zod";
|
|
@@ -69,6 +70,12 @@ var DERIVED_COMMANDS = {
|
|
|
69
70
|
lintFiles: "npx @biomejs/biome check --no-errors-on-unmatched"
|
|
70
71
|
}
|
|
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
|
+
};
|
|
72
79
|
var BUN_COMMANDS = DERIVED_COMMANDS.bun;
|
|
73
80
|
|
|
74
81
|
// src/check/command-line.ts
|
|
@@ -345,6 +352,8 @@ var contextSchema = z2.strictObject({
|
|
|
345
352
|
var jackherdConfigSchema = z2.strictObject({
|
|
346
353
|
$schema: z2.string().optional(),
|
|
347
354
|
responseLanguage: language.default(PLAN_LANGUAGE.en),
|
|
355
|
+
/** Whether replies address the user with the familiar or the polite form where the language has both (German: du or Sie). */
|
|
356
|
+
responseAddress: z2.enum(Object.values(RESPONSE_ADDRESS)).default(RESPONSE_ADDRESS.informal),
|
|
348
357
|
planLanguage: language.default(PLAN_LANGUAGE.en),
|
|
349
358
|
planDirectory: z2.string().min(1).default("docs/plans"),
|
|
350
359
|
/** Where the boards live and what the project calls them. */
|
package/dist/plan-format.d.ts
CHANGED
|
@@ -5,6 +5,17 @@ declare const PLAN_LANGUAGE: {
|
|
|
5
5
|
type PlanLanguage = (typeof PLAN_LANGUAGE)[keyof typeof PLAN_LANGUAGE];
|
|
6
6
|
/** The language's name as a reply or a question says it. */
|
|
7
7
|
declare const LANGUAGE_NAME: Record<PlanLanguage, string>;
|
|
8
|
+
declare const RESPONSE_ADDRESS: {
|
|
9
|
+
readonly informal: "informal";
|
|
10
|
+
readonly formal: "formal";
|
|
11
|
+
};
|
|
12
|
+
type ResponseAddress = (typeof RESPONSE_ADDRESS)[keyof typeof RESPONSE_ADDRESS];
|
|
13
|
+
/**
|
|
14
|
+
* How a skill's reply addresses the user, as the words a kit rule gives the agent. German and the other languages
|
|
15
|
+
* that tell a familiar from a polite "you" name the pronoun and the possessive; English has one form and only
|
|
16
|
+
* differs in tone.
|
|
17
|
+
*/
|
|
18
|
+
declare const ADDRESS_FORM: Record<PlanLanguage, Record<ResponseAddress, string>>;
|
|
8
19
|
/**
|
|
9
20
|
* The option of the pull request question that approves the PR and a merge by the coordinator once the checks are
|
|
10
21
|
* green. The merge guard hook matches the user's answer against this exact text, so it is fixed per language and not
|
|
@@ -330,4 +341,4 @@ declare const readSprintDocument: (markdown: string, language: PlanLanguage) =>
|
|
|
330
341
|
*/
|
|
331
342
|
declare const readPhaseDependencies: (planMarkdown: string, language: PlanLanguage) => Record<string, string>;
|
|
332
343
|
|
|
333
|
-
export { type ChecklistItem, type DeferredItem, type DriftFinding, LANGUAGE_NAME, type LaneProgress, type MergedPhaseWithOpenBoxes, type MisplacedDeferral, PHASE_ID_PREFIX, PLAN_LANGUAGE, PLAN_STATUS, PLAN_VOCABULARY, type PhaseProgress, type PhaseTally, type PhaseWithoutGate, type PlanHeader, type PlanLanguage, type PlanPhase, type PlanStatus, type PlanStep, type PlanVocabulary, type PlansByHref, type RowWithoutPhase, SELF_MERGE_LABEL, type SprintDocument, type SprintLane, type SprintPlanRow, type SprintPlanTable, type StatusWord, type StepProgress, activeItem, boardRowsWithoutPhase, classifyStatus, countDecisions, currentPhase, currentPhaseOf, deferralTarget, endsPhase, findDrift, hasLanesTable, isCheckboxLine, isOpenCheckboxLine, isPlanLanguage, isReviewGateLine, isTickedUnmerged, keptPhaseIds, laneNameOf, laneProgress, mergedPhasesWithOpenBoxes, misplacedDeferrals, newPhasesWithoutReviewGate, parsePlanPhases, parseSprintLanes, phaseHeading, phaseHeadingPattern, phaseIdsOfCell, phaseIdsOfPlan, readPhaseDependencies, readPlanHeader, readSprintDocument, resolvePlanPath, sectionHeading, shortTitle, sprintTitleOf, sumPhases, tallyPhases, withoutRepeats };
|
|
344
|
+
export { ADDRESS_FORM, type ChecklistItem, type DeferredItem, type DriftFinding, LANGUAGE_NAME, type LaneProgress, type MergedPhaseWithOpenBoxes, type MisplacedDeferral, PHASE_ID_PREFIX, PLAN_LANGUAGE, PLAN_STATUS, PLAN_VOCABULARY, type PhaseProgress, type PhaseTally, type PhaseWithoutGate, type PlanHeader, type PlanLanguage, type PlanPhase, type PlanStatus, type PlanStep, type PlanVocabulary, type PlansByHref, RESPONSE_ADDRESS, type ResponseAddress, type RowWithoutPhase, SELF_MERGE_LABEL, type SprintDocument, type SprintLane, type SprintPlanRow, type SprintPlanTable, type StatusWord, type StepProgress, activeItem, boardRowsWithoutPhase, classifyStatus, countDecisions, currentPhase, currentPhaseOf, deferralTarget, endsPhase, findDrift, hasLanesTable, isCheckboxLine, isOpenCheckboxLine, isPlanLanguage, isReviewGateLine, isTickedUnmerged, keptPhaseIds, laneNameOf, laneProgress, mergedPhasesWithOpenBoxes, misplacedDeferrals, newPhasesWithoutReviewGate, parsePlanPhases, parseSprintLanes, phaseHeading, phaseHeadingPattern, phaseIdsOfCell, phaseIdsOfPlan, readPhaseDependencies, readPlanHeader, readSprintDocument, resolvePlanPath, sectionHeading, shortTitle, sprintTitleOf, sumPhases, tallyPhases, withoutRepeats };
|
package/dist/plan-format.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ADDRESS_FORM,
|
|
2
3
|
LANGUAGE_NAME,
|
|
3
4
|
PLAN_LANGUAGE,
|
|
4
5
|
PLAN_STATUS,
|
|
5
6
|
PLAN_VOCABULARY,
|
|
7
|
+
RESPONSE_ADDRESS,
|
|
6
8
|
SELF_MERGE_LABEL,
|
|
7
9
|
classifyStatus,
|
|
8
10
|
isPlanLanguage,
|
|
9
11
|
phaseHeading,
|
|
10
12
|
sectionHeading
|
|
11
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-QJSXHHQ3.js";
|
|
12
14
|
|
|
13
15
|
// src/plan-format/terminal-text.ts
|
|
14
16
|
var UNSAFE_RANGES = [
|
|
@@ -592,11 +594,13 @@ var readPhaseDependencies = (planMarkdown, language) => {
|
|
|
592
594
|
return Object.fromEntries(entries);
|
|
593
595
|
};
|
|
594
596
|
export {
|
|
597
|
+
ADDRESS_FORM,
|
|
595
598
|
LANGUAGE_NAME,
|
|
596
599
|
PHASE_ID_PREFIX,
|
|
597
600
|
PLAN_LANGUAGE,
|
|
598
601
|
PLAN_STATUS,
|
|
599
602
|
PLAN_VOCABULARY,
|
|
603
|
+
RESPONSE_ADDRESS,
|
|
600
604
|
SELF_MERGE_LABEL,
|
|
601
605
|
activeItem,
|
|
602
606
|
boardRowsWithoutPhase,
|
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.6.1}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -17,6 +17,7 @@ The formatter and linter config is the authority; these are the conventions it e
|
|
|
17
17
|
- **All code in English**: file names, identifiers, type and component names, comments. User-facing strings go through the project's i18n system.
|
|
18
18
|
- **Commit messages in English**, subject and body. The subject is a Conventional Commit (`fix(scope): …`); the body explains cause and consequence in prose.
|
|
19
19
|
- **Plans and ADRs** are written in {{kit.planLanguage}}.
|
|
20
|
+
- **Replies to the user** are in {{kit.responseLanguage}}, addressing the user with {{kit.responseAddress}}. That holds for every answer, every question and every report to the user; it does not change what is written into code, commits or plans.
|
|
20
21
|
|
|
21
22
|
## Naming
|
|
22
23
|
|
|
@@ -150,8 +150,12 @@ stay approval-bound (§ 5, § 6).
|
|
|
150
150
|
- **Plugin in place:** `herdr plugin list` shows `zerodice0.worktree-bootstrap` as `enabled`,
|
|
151
151
|
and the main checkout has `.herdr/worktree-copy.list` (for step 3) or
|
|
152
152
|
`.herdr/worktree-setup.json` (for step 4). Skip the step the plugin's file covers. Before
|
|
153
|
-
step 5, check the result: `cmp -s` per env file, and the dependencies
|
|
154
|
-
|
|
153
|
+
step 5, check the result: `cmp -s` per env file, and the dependencies are all there. Run
|
|
154
|
+
`{{kit.lanes.verifyInstall}}` in the worktree, as a pane command with the `-OK`/`-FAIL` marker of step 4.
|
|
155
|
+
It ends with an exit code, so there is nothing to poll; with bun, pnpm and yarn it returns quickly when nothing
|
|
156
|
+
is missing.
|
|
157
|
+
Never look for a directory such as `node_modules/<package>`: some package managers keep a workspace's
|
|
158
|
+
dependencies out of the root. If the command fails, do the step below yourself; if that fails too, report it.
|
|
155
159
|
- **Plugin missing, disabled, or its file absent:** do the step as written below.
|
|
156
160
|
|
|
157
161
|
Copy the untracked files every lane needs (`lanes.copy` in the jackherd config, else what
|
|
@@ -384,7 +388,8 @@ Dim text in the input line is Claude Code's suggestion, not typed input; check w
|
|
|
384
388
|
**A decision of the user goes into the file at once.** Whatever the user decides about a plan or the
|
|
385
389
|
board (a phase dropped or postponed, the order changed, an approval with its scope, the answer to an
|
|
386
390
|
open question) you write into the plan or the board in the same turn, with the date and
|
|
387
|
-
"User".
|
|
391
|
+
"User". **A date or time in a record is read, never estimated:** run `date` in the same turn and write
|
|
392
|
+
what it prints, never a time taken from the conversation or guessed; an agent has no clock. The plans view reads only these files, so a decision that lives in your memory or hand-over
|
|
388
393
|
note is invisible to the user. Where it goes:
|
|
389
394
|
|
|
390
395
|
- **A lane runs for the plan:** the plan file in that lane's worktree. The view reads it there live;
|
|
@@ -519,8 +524,8 @@ included, goes to the user.
|
|
|
519
524
|
When the user picked `"{{kit.guard.selfMergeLabel}}"` for a PR:
|
|
520
525
|
|
|
521
526
|
1. Commit, push and open the PR as in step 4. Enter in the board's `{{kit.heading.history}}` that the
|
|
522
|
-
user approved this PR with self-merge (time, lane, PR number
|
|
523
|
-
the chat.
|
|
527
|
+
user approved this PR with self-merge (time, lane, PR number; the time as § 4 reads it), so the
|
|
528
|
+
approval has a record outside the chat.
|
|
524
529
|
2. **Merging without checks.** The paths a PR may carry to be merged without waiting for checks are
|
|
525
530
|
`{{kit.merge.withoutChecks}}` (`merge.withoutChecks` in the config; `none` means every merge waits).
|
|
526
531
|
Unless `none`, list the PR's files with `gh pr diff <n> --name-only` and compare each one with the
|
|
@@ -584,7 +589,9 @@ After its last PR is merged and the user agreed:
|
|
|
584
589
|
locked (`git worktree list --porcelain` shows `locked claude session`) needs `git worktree unlock`
|
|
585
590
|
first; check the session has ended.
|
|
586
591
|
4. Delete the local branch; the remote one is usually gone with the merge (`git ls-remote --heads`).
|
|
587
|
-
5. Record the lane's end in the board's `{{kit.heading.history}}
|
|
592
|
+
5. Record the lane's end in the board's `{{kit.heading.history}}`, and mark every row of the lane by appending
|
|
593
|
+
`, {{kit.word.laneClosed}} <date>` to its name cell (the date as § 4 reads it), in the same documentation PR as the history entry. The plans view lists a lane without a worktree as waiting until its
|
|
594
|
+
name cell carries that word and its plan has open phases.
|
|
588
595
|
|
|
589
596
|
Close only lanes you started or the user named.
|
|
590
597
|
|
|
@@ -615,7 +622,7 @@ fill of each lane shows in the plans view and comes from the session log
|
|
|
615
622
|
no value: tell the user in one line and never read it as `ok`. On `coordinator warn` or above, hand yourself over to a fresh chat, at a
|
|
616
623
|
quiet moment (no merge or rebase half done):
|
|
617
624
|
1. **Prepare.** Record your hand-over where the project supplement says, by default in the
|
|
618
|
-
board's `{{kit.heading.history}}
|
|
625
|
+
board's `{{kit.heading.history}}` (dated as § 4 reads it): the state of every lane, what waits on the user, the next
|
|
619
626
|
step, the traps. It holds the hand-over only: a decision of the user that is not yet in a plan or
|
|
620
627
|
the board belongs there first (§ 4), not into the note. Write the prompt for the new chat to `.claude/handover/coordinator.md`: what to
|
|
621
628
|
read first, then the steps in order. It is read, not typed, so it may be long.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import { locateKit } from './sync/kit-source'
|
|
3
4
|
import { CLI_NAME } from './version/release'
|
|
4
5
|
|
|
5
6
|
export type CommandModule = { run: (args: string[]) => Promise<number> | number }
|
|
@@ -8,6 +9,7 @@ export type CommandTable = Record<string, () => Promise<CommandModule>>
|
|
|
8
9
|
|
|
9
10
|
export type Resolution =
|
|
10
11
|
| { kind: 'run'; name: string; args: string[] }
|
|
12
|
+
| { kind: 'version' }
|
|
11
13
|
| { kind: 'usage'; exitCode: number; message: string }
|
|
12
14
|
|
|
13
15
|
/** One line per command; each entry lazily imports the module that exports its `run`. */
|
|
@@ -25,14 +27,25 @@ export const COMMANDS: CommandTable = {
|
|
|
25
27
|
export const resolveCommand = (argv: string[], table: CommandTable): Resolution => {
|
|
26
28
|
const [name, ...args] = argv
|
|
27
29
|
const names = Object.keys(table)
|
|
28
|
-
const usage = `Usage: ${CLI_NAME} <command> [args]\nCommands: ${names.length > 0 ? names.join(', ') : '(none yet)'}`
|
|
30
|
+
const usage = `Usage: ${CLI_NAME} <command> [args]\n ${CLI_NAME} --version\nCommands: ${names.length > 0 ? names.join(', ') : '(none yet)'}`
|
|
31
|
+
if (name === '--version' || name === '-v') return { kind: 'version' }
|
|
29
32
|
if (!name || name === '--help' || name === '-h') return { kind: 'usage', exitCode: name ? 0 : 1, message: usage }
|
|
30
33
|
if (!names.includes(name)) return { kind: 'usage', exitCode: 1, message: `Unknown command: ${name}\n${usage}` }
|
|
31
34
|
return { kind: 'run', name, args }
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
const installedVersion = async (): Promise<string> => (await locateKit()).version
|
|
38
|
+
|
|
39
|
+
export const main = async (
|
|
40
|
+
argv: string[],
|
|
41
|
+
table: CommandTable = COMMANDS,
|
|
42
|
+
readVersion: () => Promise<string> = installedVersion,
|
|
43
|
+
): Promise<number> => {
|
|
35
44
|
const resolution = resolveCommand(argv, table)
|
|
45
|
+
if (resolution.kind === 'version') {
|
|
46
|
+
console.log(await readVersion())
|
|
47
|
+
return 0
|
|
48
|
+
}
|
|
36
49
|
if (resolution.kind === 'usage') {
|
|
37
50
|
console.log(resolution.message)
|
|
38
51
|
return resolution.exitCode
|
package/src/config/detect.ts
CHANGED
|
@@ -88,6 +88,21 @@ const DERIVED_COMMANDS: Record<PackageManager, DerivedCommands> = {
|
|
|
88
88
|
},
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** The install command of each manager that stops at the lock file instead of changing it. */
|
|
92
|
+
const FROZEN_INSTALL: Record<string, string> = {
|
|
93
|
+
[DERIVED_COMMANDS.bun.install]: 'bun install --frozen-lockfile',
|
|
94
|
+
[DERIVED_COMMANDS.yarn.install]: 'yarn install --immutable',
|
|
95
|
+
[DERIVED_COMMANDS.pnpm.install]: 'pnpm install --frozen-lockfile',
|
|
96
|
+
[DERIVED_COMMANDS.npm.install]: 'npm ci',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The check that a worktree's dependencies are all there: the manager's install with the lock file frozen, fast when
|
|
101
|
+
* nothing is missing, fetching what is, failing when the lock file and `package.json` disagree. An install command the
|
|
102
|
+
* project set itself is returned as it is, since its flags are not known.
|
|
103
|
+
*/
|
|
104
|
+
export const frozenInstallCommand = (install: string): string => FROZEN_INSTALL[install] ?? install
|
|
105
|
+
|
|
91
106
|
/** The bun defaults of the schema, so a repository Bun runs and one detection skips agree. */
|
|
92
107
|
export const BUN_COMMANDS = DERIVED_COMMANDS.bun
|
|
93
108
|
|
package/src/config/schema.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { checkConfigSchema } from '../check/schema'
|
|
3
3
|
import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
|
|
4
|
-
import { PLAN_LANGUAGE } from '../vocabulary'
|
|
4
|
+
import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
|
|
5
5
|
import { SOURCE_FIELD } from '../watch/source-fields'
|
|
6
6
|
import { isSourcePath } from '../watch/source-path'
|
|
7
7
|
import { BUN_COMMANDS, DEFAULT_BASE_BRANCH } from './detect'
|
|
@@ -257,6 +257,8 @@ const contextSchema = z
|
|
|
257
257
|
export const jackherdConfigSchema = z.strictObject({
|
|
258
258
|
$schema: z.string().optional(),
|
|
259
259
|
responseLanguage: language.default(PLAN_LANGUAGE.en),
|
|
260
|
+
/** Whether replies address the user with the familiar or the polite form where the language has both (German: du or Sie). */
|
|
261
|
+
responseAddress: z.enum(Object.values(RESPONSE_ADDRESS)).default(RESPONSE_ADDRESS.informal),
|
|
260
262
|
planLanguage: language.default(PLAN_LANGUAGE.en),
|
|
261
263
|
planDirectory: z.string().min(1).default('docs/plans'),
|
|
262
264
|
/** Where the boards live and what the project calls them. */
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const ancestorsOf = (path: string): string[] => {
|
|
2
|
+
const segments = path.split('/').slice(0, -1)
|
|
3
|
+
return segments.map((_, index) => segments.slice(0, index + 1).join('/'))
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const depth = (path: string): number => path.split('/').length
|
|
7
|
+
|
|
8
|
+
const deepestFirst = (left: string, right: string): number => depth(right) - depth(left) || left.localeCompare(right)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The directories a sync may clean up after it removed `removed`: every directory above those files, deepest first,
|
|
12
|
+
* except the ones a file in `kept` still lies in. The lock proves these directories were the kit's, since the kit
|
|
13
|
+
* wrote a file into each of them; whether one is really empty is for the file system to say.
|
|
14
|
+
*/
|
|
15
|
+
export const emptiedDirectories = (removed: readonly string[], kept: readonly string[]): string[] => {
|
|
16
|
+
const stillUsed = new Set(kept.flatMap(ancestorsOf))
|
|
17
|
+
const candidates = new Set(removed.flatMap(ancestorsOf))
|
|
18
|
+
return [...candidates].filter((directory) => !stillUsed.has(directory)).sort(deepestFirst)
|
|
19
|
+
}
|
package/src/sync/project-fs.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
|
-
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { basename, dirname, join, sep } from 'node:path'
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname, join, normalize, sep } from 'node:path'
|
|
4
4
|
import { CLAUDE_DIRECTORY } from '../claude/paths'
|
|
5
5
|
import { TOOL_DIRECTORY } from '../config/location'
|
|
6
6
|
import { checksum } from './checksum'
|
|
@@ -86,6 +86,32 @@ export const removeFileSafely = async (directory: string, path: string): Promise
|
|
|
86
86
|
if (state.kind === 'file') await rm(join(directory, path))
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
export const DIRECTORY_OUTCOME = { removed: 'removed', notEmpty: 'notEmpty', skipped: 'skipped' } as const
|
|
90
|
+
|
|
91
|
+
export type DirectoryOutcome = (typeof DIRECTORY_OUTCOME)[keyof typeof DIRECTORY_OUTCOME]
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Removes a real, empty directory and nothing else: a link, a file or a directory with any entry (a dotfile
|
|
95
|
+
* included) stays, and so does the directory itself that `path` names as `.`, and `rmdir` itself refuses what turned out not to be empty between the check and the call.
|
|
96
|
+
*/
|
|
97
|
+
export const removeEmptyDirectory = async (directory: string, path: string): Promise<DirectoryOutcome> => {
|
|
98
|
+
if (normalize(path) === '.') return DIRECTORY_OUTCOME.skipped
|
|
99
|
+
// `hasPlainAncestors` looks at the directories above a file path, so a child segment stands in for one.
|
|
100
|
+
if (!(await hasPlainAncestors(directory, `${path}/entry`))) return DIRECTORY_OUTCOME.skipped
|
|
101
|
+
const target = join(directory, path)
|
|
102
|
+
const entry = await lstatOrMissing(target)
|
|
103
|
+
if (!entry?.isDirectory()) return DIRECTORY_OUTCOME.skipped
|
|
104
|
+
if ((await readdir(target)).length > 0) return DIRECTORY_OUTCOME.notEmpty
|
|
105
|
+
try {
|
|
106
|
+
await rmdir(target)
|
|
107
|
+
return DIRECTORY_OUTCOME.removed
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (typeof err === 'object' && err !== null && 'code' in err && (err.code === 'ENOTEMPTY' || err.code === 'EEXIST'))
|
|
110
|
+
return DIRECTORY_OUTCOME.notEmpty
|
|
111
|
+
throw err
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
/** Creates the file only if nothing is at the path yet; returns false and writes nothing otherwise. */
|
|
90
116
|
export const createFileExclusively = async (directory: string, path: string, content: string): Promise<boolean> => {
|
|
91
117
|
if ((await readLocalState(directory, path)).kind !== 'missing') return false
|
package/src/sync/render.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { JackherdConfig } from '../config'
|
|
2
|
-
import {
|
|
2
|
+
import { frozenInstallCommand } from '../config/detect'
|
|
3
|
+
import { ADDRESS_FORM, LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
|
|
3
4
|
|
|
4
5
|
const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
|
|
5
6
|
|
|
@@ -21,9 +22,10 @@ const prefixed = (prefix: string, values: Record<string, string>): [string, stri
|
|
|
21
22
|
|
|
22
23
|
/** Every placeholder a kit file may use, with its value for this project. */
|
|
23
24
|
const placeholderValues = (config: JackherdConfig): Map<string, string> => {
|
|
24
|
-
const { header, sections, columns, phase } = PLAN_VOCABULARY[config.planLanguage]
|
|
25
|
+
const { header, sections, columns, phase, laneClosed } = PLAN_VOCABULARY[config.planLanguage]
|
|
25
26
|
return new Map([
|
|
26
27
|
['kit.responseLanguage', LANGUAGE_NAME[config.responseLanguage]],
|
|
28
|
+
['kit.responseAddress', ADDRESS_FORM[config.responseLanguage][config.responseAddress]],
|
|
27
29
|
['kit.planLanguage', LANGUAGE_NAME[config.planLanguage]],
|
|
28
30
|
['kit.planDirectory', config.planDirectory],
|
|
29
31
|
['kit.baseBranch', config.baseBranch],
|
|
@@ -32,6 +34,8 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
|
|
|
32
34
|
...prefixed('command', config.commands),
|
|
33
35
|
['kit.lanes.copyCommand', laneCopyCommand(config.lanes.copy)],
|
|
34
36
|
['kit.lanes.lockFile', config.lanes.lockFile],
|
|
37
|
+
['kit.lanes.verifyInstall', frozenInstallCommand(config.commands.install)],
|
|
38
|
+
['kit.word.laneClosed', laneClosed],
|
|
35
39
|
...prefixed('board', config.board),
|
|
36
40
|
['kit.guard.selfMergeLabel', SELF_MERGE_LABEL[config.responseLanguage]],
|
|
37
41
|
['kit.guard.selfMerge', config.guard.selfMerge.offer ? 'offered' : 'not offered'],
|
|
@@ -46,9 +50,9 @@ export const listPlaceholders = (template: string): string[] =>
|
|
|
46
50
|
|
|
47
51
|
/**
|
|
48
52
|
* Fills the kit placeholders from the project config: `{{kit.model.<role>}}`,
|
|
49
|
-
* `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile>}}`,
|
|
53
|
+
* `{{kit.command.<test|typecheck|lint|format|install|dev>}}`, `{{kit.lanes.<copyCommand|lockFile|verifyInstall>}}`, `{{kit.word.laneClosed}}`,
|
|
50
54
|
* `{{kit.board.<pattern|file|term>}}`, `{{kit.guard.<selfMerge|selfMergeLabel>}}`, `{{kit.merge.withoutChecks}}`, `{{kit.heading.<key>}}` from the plan
|
|
51
|
-
* vocabulary, the two languages, the plan and worktree directory and the base branch. Other
|
|
55
|
+
* vocabulary, the two languages, `{{kit.responseAddress}}`, the plan and worktree directory and the base branch. Other
|
|
52
56
|
* `{{…}}` text is left alone; an unknown `kit.` placeholder throws rather than reaching a project
|
|
53
57
|
* file unrendered. `overrides` win over the config: a global kit renders what each repository decides as
|
|
54
58
|
* `<name>` (`REPOSITORY_TOKENS`).
|
package/src/sync/sync.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from 'node:os'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
3
|
import { CLAUDE_DIRECTORY, SETTINGS_FILE } from '../claude/paths'
|
|
4
4
|
import type { JackherdConfig } from '../config'
|
|
5
5
|
import type { RepositoryFacts } from '../config/detect'
|
|
@@ -7,19 +7,22 @@ import { CONFIG_FILE, MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
|
|
|
7
7
|
import { readRepositoryFacts } from '../config/repository-facts'
|
|
8
8
|
import { REPOSITORY_TOKENS } from '../config/repository-values'
|
|
9
9
|
import { CLI_NAME } from '../version/release'
|
|
10
|
+
import { emptiedDirectories } from './emptied-directories'
|
|
10
11
|
import { ensureGitignoreLines } from './gitignore'
|
|
11
12
|
import { toGlobalKit, toProjectKit } from './global-kit'
|
|
12
13
|
import type { KitSource } from './kit'
|
|
13
14
|
import { kitNewPath, toolRootPath } from './kit-path'
|
|
14
15
|
import { moveGlobalLegacyFiles, moveProjectLegacyFiles, removeEmptyLegacyDirectories } from './legacy-files'
|
|
15
16
|
import { EMPTY_MANIFEST, type Manifest, parseManifest, serializeManifest } from './manifest'
|
|
16
|
-
import { FILE_MODE, type FileEffect, type FilePlan, planFile, type RenderedFile } from './plan-file'
|
|
17
|
+
import { FILE_MODE, FILE_OUTCOME, type FileEffect, type FilePlan, planFile, type RenderedFile } from './plan-file'
|
|
17
18
|
import { type HooksPlan, planHooks } from './plan-hooks'
|
|
18
19
|
import {
|
|
19
20
|
assertNotHomeDirectory,
|
|
20
21
|
assertOwnDirectoriesInProject,
|
|
22
|
+
DIRECTORY_OUTCOME,
|
|
21
23
|
readLocalState,
|
|
22
24
|
readRegularFile,
|
|
25
|
+
removeEmptyDirectory,
|
|
23
26
|
removeFileSafely,
|
|
24
27
|
writeFileSafely,
|
|
25
28
|
} from './project-fs'
|
|
@@ -225,6 +228,21 @@ const applyHooks = async (claudeDirectory: string, hooks: HooksPlan): Promise<vo
|
|
|
225
228
|
if (hooks.kitNew.kind === 'remove') await removeFileSafely(claudeDirectory, SETTINGS_KIT_NEW)
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
/** Takes away the empty directories the removed kit files left; one that holds anything else stays and is named. Housekeeping only: a directory it cannot remove is a note, never a failed sync. */
|
|
232
|
+
const cleanUpDirectories = async (locate: Locate, plan: SyncPlan): Promise<string[]> => {
|
|
233
|
+
const removed = plan.files.filter(({ outcome }) => outcome === FILE_OUTCOME.removed).map(({ path }) => path)
|
|
234
|
+
const notes: string[] = []
|
|
235
|
+
for (const path of emptiedDirectories(removed, Object.keys(plan.manifest.files))) {
|
|
236
|
+
// A directory is located through a file below it, the one thing `Locate` knows how to place.
|
|
237
|
+
const { directory, path: target } = locate(`${path}/entry`)
|
|
238
|
+
const name = `${basename(directory)}/${dirname(target)}`
|
|
239
|
+
const outcome = await removeEmptyDirectory(directory, dirname(target)).catch(() => null)
|
|
240
|
+
if (outcome === null) notes.push(`Could not remove ${name}; remove it by hand if it is empty.`)
|
|
241
|
+
if (outcome === DIRECTORY_OUTCOME.notEmpty) notes.push(`Kept ${name}: it holds files the kit did not write.`)
|
|
242
|
+
}
|
|
243
|
+
return notes
|
|
244
|
+
}
|
|
245
|
+
|
|
228
246
|
/**
|
|
229
247
|
* The manifest is written last: a run that dies halfway leaves files whose content equals the
|
|
230
248
|
* kit version, which the next run adopts as unchanged instead of mistaking them for local edits.
|
|
@@ -232,12 +250,14 @@ const applyHooks = async (claudeDirectory: string, hooks: HooksPlan): Promise<vo
|
|
|
232
250
|
const applyPlan = async (
|
|
233
251
|
{ claudeDirectory, toolDirectory, locate }: Pick<PlanInput, 'claudeDirectory' | 'toolDirectory' | 'locate'>,
|
|
234
252
|
plan: SyncPlan,
|
|
235
|
-
): Promise<
|
|
253
|
+
): Promise<string[]> => {
|
|
236
254
|
for (const { path, effect } of effectsInOrder(plan.files)) await applyFileEffect(locate, path, effect)
|
|
255
|
+
const notes = await cleanUpDirectories(locate, plan)
|
|
237
256
|
await applyHooks(claudeDirectory, plan.hooks)
|
|
238
257
|
const manifestText = serializeManifest(plan.manifest)
|
|
239
258
|
if (manifestText !== plan.manifestText)
|
|
240
259
|
await writeFileSafely(toolDirectory, MANIFEST_FILE, manifestText, FILE_MODE.regular)
|
|
260
|
+
return notes
|
|
241
261
|
}
|
|
242
262
|
|
|
243
263
|
const syncResultOf = (plan: SyncPlan): SyncResult => ({
|
|
@@ -266,11 +286,9 @@ export const syncProjectWithConfig = async (request: SyncRequest): Promise<Confi
|
|
|
266
286
|
accept: request.accept,
|
|
267
287
|
readHooks: projectHooks(claudeDirectory, request.kit, config),
|
|
268
288
|
})
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
return { result: { ...syncResultOf(plan), notes: [...moved, ...notes] }, config }
|
|
289
|
+
const keptDirectoryNotes = request.check ? [] : await applyPlan(directories, plan)
|
|
290
|
+
if (!request.check) await ensureGitignoreLines(request.projectRoot)
|
|
291
|
+
return { result: { ...syncResultOf(plan), notes: [...moved, ...notes, ...keptDirectoryNotes] }, config }
|
|
274
292
|
}
|
|
275
293
|
|
|
276
294
|
export const runSync = async (request: SyncRequest): Promise<SyncResult> =>
|
|
@@ -302,9 +320,9 @@ export const syncGlobalWithConfig = async (request: GlobalSyncRequest): Promise<
|
|
|
302
320
|
readHooks: noHooks,
|
|
303
321
|
overrides: REPOSITORY_TOKENS,
|
|
304
322
|
})
|
|
305
|
-
await applyPlan(directories, plan)
|
|
323
|
+
const keptDirectoryNotes = await applyPlan(directories, plan)
|
|
306
324
|
await removeEmptyLegacyDirectories(claudeDirectory)
|
|
307
|
-
return { result: { ...syncResultOf(plan), notes: moved }, config }
|
|
325
|
+
return { result: { ...syncResultOf(plan), notes: [...moved, ...keptDirectoryNotes] }, config }
|
|
308
326
|
}
|
|
309
327
|
|
|
310
328
|
export const runGlobalSync = async (request: GlobalSyncRequest): Promise<SyncResult> =>
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -8,6 +8,26 @@ export const LANGUAGE_NAME: Record<PlanLanguage, string> = {
|
|
|
8
8
|
[PLAN_LANGUAGE.de]: 'German',
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export const RESPONSE_ADDRESS = { informal: 'informal', formal: 'formal' } as const
|
|
12
|
+
|
|
13
|
+
export type ResponseAddress = (typeof RESPONSE_ADDRESS)[keyof typeof RESPONSE_ADDRESS]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How a skill's reply addresses the user, as the words a kit rule gives the agent. German and the other languages
|
|
17
|
+
* that tell a familiar from a polite "you" name the pronoun and the possessive; English has one form and only
|
|
18
|
+
* differs in tone.
|
|
19
|
+
*/
|
|
20
|
+
export const ADDRESS_FORM: Record<PlanLanguage, Record<ResponseAddress, string>> = {
|
|
21
|
+
[PLAN_LANGUAGE.en]: {
|
|
22
|
+
[RESPONSE_ADDRESS.informal]: 'the plain, direct “you”, without titles',
|
|
23
|
+
[RESPONSE_ADDRESS.formal]: 'a polite, businesslike “you”',
|
|
24
|
+
},
|
|
25
|
+
[PLAN_LANGUAGE.de]: {
|
|
26
|
+
[RESPONSE_ADDRESS.informal]: 'the familiar “du” (du, dir, dein), never “Sie”',
|
|
27
|
+
[RESPONSE_ADDRESS.formal]: 'the polite “Sie” (Sie, Ihnen, Ihr), never “du”',
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
11
31
|
/**
|
|
12
32
|
* The option of the pull request question that approves the PR and a merge by the coordinator once the checks are
|
|
13
33
|
* green. The merge guard hook matches the user's answer against this exact text, so it is fixed per language and not
|