jackherd 1.5.0 → 1.6.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 +8 -0
- package/README.md +4 -2
- 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 +9 -3
- 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,14 @@
|
|
|
2
2
|
|
|
3
3
|
What changed for people who use jackherd, newest first.
|
|
4
4
|
|
|
5
|
+
## 1.6.0
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **`jh --version` (and `jh -v`) prints the installed version** and exits 0. The usage text lists it.
|
|
9
|
+
- **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`.
|
|
10
|
+
- **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.
|
|
11
|
+
- **`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.
|
|
12
|
+
|
|
5
13
|
## 1.5.0
|
|
6
14
|
|
|
7
15
|
- **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
|
@@ -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
|
|
|
@@ -933,7 +933,7 @@ Decisions never live only in a hand-over note: they are in the plan or the board
|
|
|
933
933
|
|
|
934
934
|
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
935
|
|
|
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.
|
|
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 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
937
|
|
|
938
938
|
</details>
|
|
939
939
|
|
|
@@ -1077,6 +1077,7 @@ At `warn` a lane offers a fresh chat at its next phase boundary, and the watcher
|
|
|
1077
1077
|
|
|
1078
1078
|
| Command | What it does | Options |
|
|
1079
1079
|
| ------- | ------------ | ------- |
|
|
1080
|
+
| `jh --version` | The installed version, e.g. `1.6.0`; `-v` is the same | none |
|
|
1080
1081
|
| `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
1082
|
| `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
1083
|
| `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 +1096,7 @@ Without a global install, run any of them as `bunx jackherd <command>`.
|
|
|
1095
1096
|
| Key | Default | What it decides |
|
|
1096
1097
|
| --- | --- | --- |
|
|
1097
1098
|
| `responseLanguage` | `en` | the language the skills speak with you, and the watcher's status words |
|
|
1099
|
+
| `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
1100
|
| `planLanguage` | `en` | the language of plans and boards, including their headings |
|
|
1099
1101
|
| `planDirectory` | `docs/plans` | where plans and boards live |
|
|
1100
1102
|
| `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.0}"
|
|
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
|
|
@@ -584,7 +588,9 @@ After its last PR is merged and the user agreed:
|
|
|
584
588
|
locked (`git worktree list --porcelain` shows `locked claude session`) needs `git worktree unlock`
|
|
585
589
|
first; check the session has ended.
|
|
586
590
|
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}}
|
|
591
|
+
5. Record the lane's end in the board's `{{kit.heading.history}}`, and mark every row of the lane by appending
|
|
592
|
+
`, {{kit.word.laneClosed}} <date>` to its name cell, in the same documentation PR as the history entry. The plans view lists a lane without a worktree as waiting until its
|
|
593
|
+
name cell carries that word and its plan has open phases.
|
|
588
594
|
|
|
589
595
|
Close only lanes you started or the user named.
|
|
590
596
|
|
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
|