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 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
@@ -43,6 +43,10 @@ declare const jackherdConfigSchema: z.ZodObject<{
43
43
  de: "de";
44
44
  en: "en";
45
45
  }>>;
46
+ responseAddress: z.ZodDefault<z.ZodEnum<{
47
+ informal: "informal";
48
+ formal: "formal";
49
+ }>>;
46
50
  planLanguage: z.ZodDefault<z.ZodEnum<{
47
51
  de: "de";
48
52
  en: "en";
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
- PLAN_LANGUAGE
3
- } from "./chunk-HQAONP4W.js";
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. */
@@ -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 };
@@ -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-HQAONP4W.js";
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,
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.5.0"
3
+ version = "1.6.0"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the plans of a board's lanes in a herdr pane"
6
6
  platforms = ["linux", "macos"]
package/install.sh CHANGED
@@ -9,7 +9,7 @@ set -eu
9
9
 
10
10
  # The default is the version this file ships with. The release bump changes it together with
11
11
  # package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
12
- JACKHERD_VERSION="${JACKHERD_VERSION:-1.5.0}"
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 directory exists in the
154
- worktree. Poll that check every 10 seconds for up to 2 minutes; if it still fails, do the step below yourself.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -13,6 +13,14 @@
13
13
  "en"
14
14
  ]
15
15
  },
16
+ "responseAddress": {
17
+ "default": "informal",
18
+ "type": "string",
19
+ "enum": [
20
+ "informal",
21
+ "formal"
22
+ ]
23
+ },
16
24
  "planLanguage": {
17
25
  "default": "en",
18
26
  "type": "string",
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
- export const main = async (argv: string[], table: CommandTable = COMMANDS): Promise<number> => {
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
@@ -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
 
@@ -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
+ }
@@ -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
@@ -1,5 +1,6 @@
1
1
  import type { JackherdConfig } from '../config'
2
- import { LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
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<void> => {
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
- if (!request.check) {
270
- await applyPlan(directories, plan)
271
- await ensureGitignoreLines(request.projectRoot)
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> =>
@@ -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