jackherd 1.14.0 → 1.15.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +27 -19
  3. package/assets/icons/skills.svg +1 -0
  4. package/dist/config.d.ts +9 -1
  5. package/dist/config.js +24 -1
  6. package/dist/plan-format.d.ts +24 -1
  7. package/dist/plan-format.js +25 -5
  8. package/docs/plan-contract.md +84 -0
  9. package/docs/skills-dark.svg +15 -2
  10. package/docs/skills.svg +15 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/herdr-plugin/restore-plans.ts +56 -6
  13. package/install.sh +1 -1
  14. package/kit/hooks/hooks/merge-guard/board-settings.ts +5 -2
  15. package/kit/hooks/hooks/merge-guard/decision.ts +3 -1
  16. package/kit/hooks/hooks/merge-guard/pull-request.ts +28 -15
  17. package/kit/hooks/hooks/merge-guard/run.ts +6 -3
  18. package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
  19. package/kit/hooks/hooks/model-log/transcript.ts +4 -7
  20. package/kit/hooks/hooks/open-question/record.ts +4 -3
  21. package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
  22. package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
  23. package/kit/hooks/hooks/shared/transcript.ts +7 -4
  24. package/kit/hooks/hooks/task-hint/decision.ts +3 -2
  25. package/kit/hooks/hooks/task-hint/run.ts +2 -4
  26. package/kit/rules/rules/kit/pull-requests.md +1 -0
  27. package/kit/workflow/skills/orchestrate/SKILL.md +22 -9
  28. package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
  29. package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
  30. package/kit/workflow/skills/orchestrate/references/plan-commit.md +5 -5
  31. package/package.json +1 -1
  32. package/schemas/jackherd.schema.json +19 -0
  33. package/src/check/command.ts +1 -1
  34. package/src/check/file-kinds.ts +10 -8
  35. package/src/check/parallel-report.ts +10 -3
  36. package/src/check/settings.ts +4 -0
  37. package/src/check/skill-budget-defaults.ts +17 -5
  38. package/src/check/skill-budgets.ts +49 -19
  39. package/src/claude/paths.ts +12 -0
  40. package/src/cli.ts +1 -0
  41. package/src/config/schema.ts +27 -0
  42. package/src/context/fill.ts +2 -1
  43. package/src/context/log-vocabulary.ts +22 -0
  44. package/src/context/session-log.ts +2 -1
  45. package/src/lane-prompt/build.ts +10 -5
  46. package/src/lane-prompt/command.ts +6 -1
  47. package/src/lane-prompt/input.ts +4 -4
  48. package/src/lane-prompt/texts.ts +18 -10
  49. package/src/plan-contract/command.ts +69 -0
  50. package/src/plan-contract/contract.ts +144 -0
  51. package/src/plan-format/dependencies.ts +1 -4
  52. package/src/plan-format/index.ts +4 -0
  53. package/src/plan-format/lanes.ts +11 -0
  54. package/src/plan-format/markdown.ts +6 -5
  55. package/src/plan-format/model-table.ts +27 -0
  56. package/src/plans/activity-read.ts +98 -0
  57. package/src/plans/ask-coordinator.ts +20 -11
  58. package/src/plans/layout.ts +49 -4
  59. package/src/plans/live.ts +17 -4
  60. package/src/plans/plan-panes.ts +25 -5
  61. package/src/plans/snapshot.ts +18 -8
  62. package/src/plans/subagents-read.ts +4 -3
  63. package/src/plans/subagents.ts +2 -1
  64. package/src/plans/texts.ts +27 -1
  65. package/src/settled/send.ts +80 -4
  66. package/src/shared/parse-json.ts +8 -0
  67. package/src/sync/command.ts +17 -10
  68. package/src/sync/foreign-skills.ts +40 -0
  69. package/src/sync/global-kit.ts +31 -21
  70. package/src/sync/init.ts +2 -2
  71. package/src/sync/kit-exclude.ts +39 -0
  72. package/src/sync/plan-file.ts +1 -1
  73. package/src/sync/render.ts +2 -1
  74. package/src/sync/report.ts +37 -2
  75. package/src/sync/sync-result.ts +5 -2
  76. package/src/sync/sync.ts +13 -5
  77. package/src/task-signals/claude-signals.ts +6 -5
  78. package/src/watch/command.ts +56 -17
  79. package/src/watch/lane-message.ts +1 -1
  80. package/src/watch/lane-progress.ts +51 -2
  81. package/src/watch/lane-report.ts +57 -2
  82. package/src/watch/lane-watch.ts +2 -8
  83. package/src/watch/task-parallel-touched.ts +9 -2
  84. package/src/watch/texts.ts +4 -0
@@ -60,6 +60,9 @@ Each file under `references/` is read when its trigger fires: the step or the ev
60
60
  `origin/{{kit.baseBranch}}` would not have it: § 2 step 0. New rows of a board that is on the base
61
61
  branch need nothing; the herd shows them. Check a plan that joins the board later the same way; one
62
62
  only a working lane's worktree has, the watcher reports as `plan missing`.
63
+ - [ ] **Every plan meets the contract.** `jh plan check <plan>` for each plan a starting lane links. Exit 1,
64
+ or a row that links an issue or a `.scratch/` folder instead of a plan file: [`references/adopt-plan.md`](references/adopt-plan.md),
65
+ before § 2 step 0. Exit 2 goes to the user. Done when each check exits 0.
63
66
  - [ ] Tell the user which lanes start now and which wait, before starting any, and whether § 2 step 0
64
67
  is needed.
65
68
 
@@ -84,7 +87,8 @@ stay approval-bound (§ 5, § 6).
84
87
  When the user wants a plan's lanes merged without a question for a while: § 5, **Standing approval**.
85
88
 
86
89
  0. **Plan commit as the base**, when § 0 found the board or a plan missing on
87
- `origin/{{kit.baseBranch}}`. Ask through `AskUserQuestion`: commit the missing files on a
90
+ `origin/{{kit.baseBranch}}`, or adopted a plan (then the diff round of
91
+ [`references/adopt-plan.md`](references/adopt-plan.md) is this question; ask nothing more). Ask through `AskUserQuestion`: commit the missing files on a
88
92
  documentation branch and open a documentation PR, so the lanes start from that commit? The answer
89
93
  is the approval of that commit, its push and the PR (§ 5). `<topic>` is the plan's file name
90
94
  without date and extension, or the board's directory name when several files are missing. On yes,
@@ -99,7 +103,7 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
99
103
  your judgement.
100
104
  2. **Worktree workspace** from `<base>`: `origin/{{kit.baseBranch}}` when, after
101
105
  `git fetch origin {{kit.baseBranch}}`, `git cat-file -e origin/{{kit.baseBranch}}:<plan>` succeeds
102
- for the lane's plan, else `origin/docs/plan-<topic>` of step 0. The file decides, not the state of
106
+ for the lane's plan and `git diff --quiet origin/{{kit.baseBranch}} -- <plan>` exits 0, else `origin/docs/plan-<topic>` of step 0. The file decides, not the state of
103
107
  the PR, so a later lane gets the right base:
104
108
  ```bash
105
109
  herdr worktree create --cwd <main-checkout> --branch <branch> --base <base> \
@@ -133,12 +137,16 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
133
137
  within a poll or two: `bunx jackherd herd --ensure`.
134
138
  8. **Prompt** from the file of § 1:
135
139
  ```bash
136
- herdr agent prompt <lane> "$(cat <prompt-file>)"
140
+ herdr agent prompt <lane> "$(cat <prompt-file>)" --wait --until working --timeout 30000
137
141
  ```
138
142
  The prompt quotes commands in backticks (`git reset`, `/pre-commit`); typed into the command
139
143
  line inside double quotes, your own shell would run them before the lane sees the text. Text
140
144
  read through `$(cat …)` is not expanded again. Every `herdr agent prompt` in this skill goes
141
- this way, from a file you wrote with the `Write` tool or `jh lane prompt` wrote.
145
+ this way, from a file you wrote with the `Write` tool or `jh lane prompt` wrote, with the same
146
+ `--wait` options, so the lane confirms it started. `agent_prompt_stalled` or `timeout` does not prove the
147
+ text is lost: read the screen (`herdr agent read <lane> --source visible --lines 12`). Text still in the input
148
+ line: `herdr agent send-keys <lane> enter`; a lane already at work: done; neither: send the file once more,
149
+ same command. A second stall goes to the user.
142
150
 
143
151
  ## 3. Dev server
144
152
 
@@ -242,7 +250,7 @@ On wake-up:
242
250
  | `blocked` | Read the screen (`--source detection`, or zoom the pane when cut off). A question is vetted (below, **Settled questions stay in the lane**): one the plan or a rule settles you answer in the lane, an open decision goes to the user via `AskUserQuestion`. You never hand the lane's pane to the user. A question dialog in a lane (not a permission dialog) is a gap in the lane prompt (§ 1): send it the text-block rule and the answer by prompt. A permission dialog for a step on the list in § 5 you confirm, after checking the cursor sits on the one-time option; anything else goes to the user. Dim text in the input line is a suggestion, not input (`--format ansi` shows `\e[2m`); in a multi-select dialog `space` toggles an option, `enter` moves on. The watcher prints a duty line for each blocked lane; it is done when the lane has its answer or the user has the question |
243
251
  | `working` with `questions: <n>` | The lane changed the questions of its report while its subagents run. Read `report <path>` and close each new question as the `done` / `idle` row does (`jh settled`, or `AskUserQuestion` with the answer sent on by `herdr agent prompt`). No diff check and no next phase: the lane keeps working |
244
252
  | `main checkout on <branch>, not …` | § 0, **The main checkout stays on `{{kit.baseBranch}}`** |
245
- | `done` / `idle` | Read the report the watcher shows (`report <path>` and its first lines, or the screen); read further only where that is not enough. Check the diff against the `{{kit.heading.writes}}` set (`git -C <worktree> diff --stat <base>...HEAD`, with the ref the lane branched from in § 2 step 2 until it has rebased after the documentation PR's merge) and that checks are quoted with numbers, not claimed. Every blocking question in the report is closed in the same wake-up, also one under open points, one without its mark and also when the `questions: <n>` line is missing: settled by you through `jh settled` (below), or put to the user through `AskUserQuestion` (vetted, below) and the answer sent on to the lane by `herdr agent prompt`. A not blocking one is settled the same way or collected (**Not blocking questions**, below). An answer that stays in your chat with the user is none. Then start the lane's next ready phase (§ 2) or name the reason to wait |
253
+ | `done` / `idle` | Read the report the watcher shows (`report <path>` and its first lines, or the screen); read further only where that is not enough. Check the diff against the `{{kit.heading.writes}}` set (`git -C <worktree> diff --stat <base>...HEAD`, with the ref the lane branched from in § 2 step 2 until it has rebased after the documentation PR's merge) and that checks are quoted with numbers, not claimed. Every blocking question in the report is closed in the same wake-up, also one under open points, one without its mark and also when the `questions: <n>` line is missing: settled by you through `jh settled` (below), or put to the user through `AskUserQuestion` (vetted, below) and the answer sent on to the lane by `herdr agent prompt`. A not blocking one is settled the same way or collected (**Not blocking questions**, below). An answer that stays in your chat with the user is none. Then start the lane's next ready phase (§ 2), at once after a `<lane> ready: …` line, or name the reason to wait; a PR awaiting approval holds only its commit, which stages only its own files |
246
254
  | `merged #<n>` | § 6, for every lane |
247
255
  | `checks failed #<n> (<lane>): <checks> (<k> tests: …)` | Send the finding to the lane with the named checks and tests (data, above). Without names, the lane reads the run itself |
248
256
  | `checks passed #<n>` | One line to the user: ready to merge, with the board's merge order |
@@ -475,6 +483,9 @@ always goes to the user, whatever the plan settles.
475
483
  permission mode refuses the push, push its branch yourself under the same approval: run
476
484
  `cd <worktree>` as a command of its own, then `git push --force-with-lease origin HEAD:<branch>`,
477
485
  then `cd` back (the kit's git guard refuses `git -C`).
486
+ 5. **Drafts** (**{{kit.merge.openAsDraft}}**, `pull-requests.md` → Drafts): when you report a draft PR to the
487
+ user to merge, run `gh pr ready <n>` first; for your own merge, **Self-merge** step 1. The merge guard
488
+ never passes a draft.
478
489
 
479
490
  ### Self-merge
480
491
 
@@ -482,15 +493,17 @@ When the user picked `"{{kit.guard.selfMergeLabel}}"` for a PR:
482
493
 
483
494
  1. Commit, push and open the PR as in step 4. Enter in the board's `{{kit.heading.history}}` that the
484
495
  user approved this PR with self-merge (time, lane, PR number; the time as § 4 reads it), so the
485
- approval has a record outside the chat.
496
+ approval has a record outside the chat. A draft turns ready here: `gh pr ready <n>`.
486
497
  2. **Merging without checks.** The paths a PR may carry to be merged without waiting for checks are
487
498
  `{{kit.merge.withoutChecks}}` (`merge.withoutChecks` in the config; `none` means every merge waits).
488
499
  Unless `none`, list the PR's files with `gh pr diff <n> --name-only` and compare each one with the
489
500
  patterns yourself. When **every** file matches, go straight to step 4. One file outside, an empty
490
501
  list, or a failed `gh` call: go on with step 3. The merge guard reads the file list from GitHub
491
502
  itself; if it asks, the dialog is the user's (step 5).
492
- 3. Wait for the checks in the background, never in the foreground: a Bash call
493
- `gh pr checks <n> --watch` with `run_in_background: true`, then work on until it reports.
503
+ 3. Wait for the checks in the background, never in the foreground. After a ready (step 1), start only
504
+ once every check that counts (each of `guard.requiredChecks`, else each check the draft skipped) has
505
+ a run that started after the ready, as a watch would end on the draft's skipped runs at once. Then a Bash call
506
+ `gh pr checks <n> --watch` with `run_in_background: true`, and work on until it reports.
494
507
  4. Green (or a PR that qualified in step 2): `gh pr merge <n> --squash --delete-branch --match-head-commit <sha>`,
495
508
  with the plain PR number, the full `headRefOid` from `gh pr view <n> --json headRefOid` read right before, and
496
509
  without `-R`, `--repo` or `--admin`. The merge guard lets exactly this command through for the approved branch
@@ -531,7 +544,7 @@ For a PR the user approved (step 2) whose lane works on a plan under a standing
531
544
 
532
545
  1. A PR behind the base branch is rebased first (§ 6, under the PR's approval), so its checks run
533
546
  against the current base.
534
- 2. Wait for the checks as in **Self-merge** step 3.
547
+ 2. Turn a draft ready as in **Self-merge** step 1, then wait for the checks as in its step 3.
535
548
  3. Green: the same command as **Self-merge** step 4, with the `<sha>` read right before. The merge
536
549
  guard lets it through only before the approval's end, with every check green, GitHub able to merge
537
550
  it and no workflow, migration, env file or file of the guards in it; otherwise it asks, and that dialog is the user's
@@ -25,6 +25,9 @@ const EXIT = { done: 0, aborted: 1, usage: 2 } as const
25
25
 
26
26
  const ABORT_TITLE = 'Hand-over aborted'
27
27
 
28
+ /** As SKILL.md § 2 step 8: herdr reports a line left as a paste (`agent_prompt_stalled`) instead of taking it. */
29
+ const CONFIRM_WORKING = ['--wait', '--until', 'working', '--timeout', '30000']
30
+
28
31
  const herdr = async (args: string[]): Promise<HerdrResult> => {
29
32
  try {
30
33
  const child = Bun.spawn(['herdr', ...args], { stdout: 'pipe', stderr: 'pipe' })
@@ -58,7 +61,10 @@ const sleep = (milliseconds: number): Promise<void> => new Promise((resolve) =>
58
61
 
59
62
  type Log = (line: string) => Promise<void>
60
63
 
61
- /** Returns the reason of a failed herdr call, or null once `/clear` went out. */
64
+ /**
65
+ * Returns the reason of a failed herdr call, or null once `/clear` went out. No `--wait`: `/clear` never turns
66
+ * the agent `working`, and the wait for a new session confirms it.
67
+ */
62
68
  const sendClear = async (options: HandoverOptions, log: Log): Promise<string | null> => {
63
69
  const cleared = await herdr(['agent', 'prompt', options.pane, '/clear'])
64
70
  if (!cleared.ok) return `herdr agent prompt /clear failed: ${cleared.error}`
@@ -98,7 +104,7 @@ const sendCommand = async (rename: string | null, options: HandoverOptions, log:
98
104
  const renamed = await herdr(['agent', 'rename', options.pane, rename])
99
105
  await log(renamed.ok ? `renamed the agent to ${rename}` : `rename to ${rename} failed: ${renamed.error}`)
100
106
  }
101
- const sent = await herdr(['agent', 'prompt', options.pane, line.line])
107
+ const sent = await herdr(['agent', 'prompt', options.pane, line.line, ...CONFIRM_WORKING])
102
108
  if (!sent.ok) return `herdr agent prompt failed: ${sent.error}`
103
109
  await log(`sent: ${line.line}`)
104
110
  return null
@@ -0,0 +1,51 @@
1
+ # § 0: adopt a plan that misses the contract
2
+
3
+ Read when `jh plan check <plan>` exits 1 for a plan a starting lane links, or when the lane's row links an issue
4
+ or a `.scratch/<feature>/` folder instead of a plan file. The contract is `docs/plan-contract.md` in the jackherd
5
+ package; the check's lines name each gap with `file:line`.
6
+
7
+ **You add, the author's text stays.** Every word the plan holds stays, in its place: you insert lines, and into
8
+ an open task line only its short name. Done when `jh plan check <plan>` exits 0, `jh check` names no line of
9
+ `<plan>`, and `git diff --no-index --word-diff=porcelain <copy> <plan>` shows no line opening with `-` apart from
10
+ its `--- a/…` header (its exit 1 only means a difference).
11
+
12
+ ## A plan in an issue tracker
13
+
14
+ A plan that lives only in issues (a spec with its tickets in GitHub or GitLab, or Markdown under
15
+ `.scratch/<feature>/`) is mirrored first, to `{{kit.planDirectory}}/<YYYY-MM-DD>-<topic>.md` with the date from
16
+ `date`. Read it with `gh issue view <n> --comments`, `glab issue view <n> --comments` or `Read`, and copy the
17
+ spec and each ticket's text as it stands, under a heading per ticket. The origin line under the title links the
18
+ issue or the folder; done when each ticket's text reads in the file as in the issue. From then on the file is the one source: the board row links it, and nothing is written back
19
+ to the issue. Then adopt the file like any other plan.
20
+
21
+ ## The additions
22
+
23
+ Before the first addition (a mirrored plan: right after the mirror, so the diff shows only additions), run `mktemp`, keep the path it prints as `<copy>`, then `cp <plan> <copy>`. Then add only
24
+ what the check's lines name as missing or malformed, in the plan language's headings, and the short names and the
25
+ no-gate line, which `jh check` asks of a plan the commit adds:
26
+
27
+ - **Phases**: a heading `### {{kit.heading.phase}} <id>: <title>` above each section or ticket that is one
28
+ piece of work, ids counting from 1 in the plan's order. A heading in a form the check does not read keeps its
29
+ line; the readable heading goes on the line below it.
30
+ - **Tasks**: in a phase without checkboxes, a list at its end built from the lists and steps it already has, one
31
+ `- [ ] **Name**: …` per item, the name 40 characters at most and naming the result.
32
+ - **Model**: one row per phase without one, appended to `### {{kit.heading.modelRecommendations}}`, or that
33
+ table placed before the first phase, each row judged as `/plan` judges it (its model recommendations); the
34
+ rationale cell names the error cost.
35
+ - **Dependencies**, from two phases on: `### {{kit.heading.dependencies}}` with `{{kit.heading.dependsOn}}`
36
+ (the previous phase unless the plan says otherwise), `{{kit.heading.writes}}` estimated from the code the phase
37
+ names (search for its modules and files) and `{{kit.heading.serialMarker}}`.
38
+ - **Short names**: an open task `- [ ] <text>` of a phase, before its review gate, without one gets `**<name>**: `
39
+ after its box, the name taken from its text, 40 characters at most.
40
+ - **No review gate**: a phase with checkboxes and no line opening `**Review gate` (`de`: `**Review-Gate`) gets a
41
+ line after its tasks, in the plan's language:
42
+ `**No review gate:** adopted plan, the source planned no review` (`en`), `**Kein Review-Gate:** übernommener
43
+ Plan, die Quelle plante kein Review` (`de`).
44
+
45
+ ## The user sees the diff
46
+
47
+ Write the output of `git diff --no-index <copy> <plan>` into `.claude/handover/adopt-<topic>.md` as a `diff` code block, its fence longer than any run of backticks in it, and show it as
48
+ [`draft-review.md`](draft-review.md) says. The round's question is the one of § 2 step 0: its first option
49
+ approves the additions together with the commit, the push and the documentation PR; each remark is an option of
50
+ its own and leads to another round; one option rejects the additions, and it stops this plan's lanes as in
51
+ step 0. Rejecting chosen together with another option counts as a remark.
@@ -1,7 +1,7 @@
1
1
  # § 2 step 0 and § 6: the plan commit as the base
2
2
 
3
- Read when § 0 finds the board or a plan missing on `origin/{{kit.baseBranch}}` and the user said yes to the
4
- plan commit, and again when its documentation PR has merged (§ 6).
3
+ Read when § 0 finds the board or a plan missing on `origin/{{kit.baseBranch}}`, or adopted one, and the user
4
+ said yes to the plan commit, and again when its documentation PR has merged (§ 6).
5
5
 
6
6
  ## The commit
7
7
 
@@ -13,7 +13,7 @@ Bash call of its own:
13
13
  git worktree add {{kit.worktreeDirectory}}/docs-plan-<topic> -b docs/plan-<topic> origin/{{kit.baseBranch}}
14
14
  ```
15
15
 
16
- 1. From the main checkout, per missing file: `mkdir -p {{kit.worktreeDirectory}}/docs-plan-<topic>/<directory>`,
16
+ 1. From the main checkout, per missing or adopted file: `mkdir -p {{kit.worktreeDirectory}}/docs-plan-<topic>/<directory>`,
17
17
  then `cp <path> {{kit.worktreeDirectory}}/docs-plan-<topic>/<path>`. For `plan missing` while a lane
18
18
  works, copy from the lane's worktree instead (`<lane worktree>/<path>`): the main checkout lacks it.
19
19
  2. `cd` into the temporary worktree, as a command of its own (the kit's git guard refuses `git -C`).
@@ -28,8 +28,8 @@ The copies in the main checkout stay until the PR is merged.
28
28
 
29
29
  The lanes rebase on `origin/{{kit.baseBranch}}` as after every merge. Then, after `git fetch origin {{kit.baseBranch}}`,
30
30
  compare each copy in the main checkout with the merged file
31
- (`git show origin/{{kit.baseBranch}}:<path> | cmp -s - <path>`, one per file; read-only) and remove the copies
32
- that are equal, so the next update of the main checkout does not stop on them. A board that only gained lane
31
+ (`git show origin/{{kit.baseBranch}}:<path> | cmp -s - <path>`, one per file; read-only). Of the copies that are
32
+ equal, a tracked file is restored with `git checkout -- <path>`, an untracked one removed, so the next update of the main checkout does not stop on them. A board that only gained lane
33
33
  rows since stays and is no question; any other difference goes to the user. Once every lane of the plan has
34
34
  rebased, delete the local branch (`git branch -D docs/plan-<topic>`; a squash merge leaves it unmerged), so a
35
35
  later step 0 can use the name again.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.14.0",
3
+ "version": "1.15.1",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -113,6 +113,21 @@
113
113
  },
114
114
  "additionalProperties": false
115
115
  },
116
+ "kit": {
117
+ "default": {},
118
+ "type": "object",
119
+ "properties": {
120
+ "exclude": {
121
+ "default": [],
122
+ "type": "array",
123
+ "items": {
124
+ "type": "string",
125
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
126
+ }
127
+ }
128
+ },
129
+ "additionalProperties": false
130
+ },
116
131
  "lanes": {
117
132
  "default": {},
118
133
  "type": "object",
@@ -726,6 +741,10 @@
726
741
  "type": "string",
727
742
  "pattern": "^[A-Za-z0-9_.*/-]+$"
728
743
  }
744
+ },
745
+ "openAsDraft": {
746
+ "default": false,
747
+ "type": "boolean"
729
748
  }
730
749
  },
731
750
  "additionalProperties": false
@@ -203,7 +203,7 @@ const planRootsOf = (root: string, worktreeDirectory: string): string[] => {
203
203
  const ownChecksOf = (root: string, paths: readonly string[], settings: CheckSettings, home: string) => [
204
204
  publicContentCheck(root, paths, settings.check.publicContent, home),
205
205
  deferralArrowCheck(root, paths, settings.planDirectory),
206
- skillBudgetCheck(root, settings.check.skillBudgets),
206
+ skillBudgetCheck(root, settings.check.skillBudgets, settings.skillPrefix),
207
207
  ...planStructureChecks({
208
208
  root,
209
209
  paths,
@@ -116,17 +116,18 @@ export const isSourceFile = (path: string): boolean => {
116
116
  )
117
117
  }
118
118
 
119
- const LEADING_SEPARATORS = /^[._-]+/
119
+ /** Separators at either end: `_result` (a private Python module) and `test_result` both name `result`. */
120
+ const withoutOuterSeparators = (name: string): string => name.replace(/^[._-]+/, '').replace(/[._-]+$/, '')
120
121
 
121
122
  /** The name a test file covers: its stem without the test word (`test_parser.py` → `parser`); null when nothing is left. */
122
123
  export const testStemOf = (path: string): string | null => {
123
124
  const stem = stemOf(path)
124
- const stripped = stem
125
- .replace(TEST_WORD, (_match, separator: string) => separator)
126
- .replace(CAMEL_TEST_SUFFIX, '')
127
- .replace(CAMEL_TEST_PREFIX, '')
128
- .replace(LEADING_SEPARATORS, '')
129
- .replace(/[._-]+$/, '')
125
+ const stripped = withoutOuterSeparators(
126
+ stem
127
+ .replace(TEST_WORD, (_match, separator: string) => separator)
128
+ .replace(CAMEL_TEST_SUFFIX, '')
129
+ .replace(CAMEL_TEST_PREFIX, ''),
130
+ )
130
131
  return stripped === '' ? null : stripped
131
132
  }
132
133
 
@@ -139,4 +140,5 @@ export const testStemsOf = (files: readonly string[], isTest: (path: string) =>
139
140
  * `parser_test.go`, `test_parser.py`, `ParserTest.java`. Only the last extension of the source is dropped, so
140
141
  * `foo.module.ts` needs `foo.module.test.ts`.
141
142
  */
142
- export const hasNamedTest = (source: string, testStems: ReadonlySet<string>): boolean => testStems.has(stemOf(source))
143
+ export const hasNamedTest = (source: string, testStems: ReadonlySet<string>): boolean =>
144
+ testStems.has(withoutOuterSeparators(stemOf(source)))
@@ -1,4 +1,11 @@
1
- import { type PhaseSchedule, phaseLabels, phasesText, wavesOfPlan } from '../plan-format'
1
+ import {
2
+ NO_DEPENDENCY_TABLE,
3
+ type PhaseSchedule,
4
+ phaseCount,
5
+ phaseLabels,
6
+ phasesText,
7
+ wavesOfPlan,
8
+ } from '../plan-format'
2
9
  import type { PlanLanguage } from '../vocabulary'
3
10
 
4
11
  /** The flag by which `jh check` prints the waves of one plan instead of running the checks. */
@@ -12,7 +19,7 @@ export const parallelArgument = (args: readonly string[]): string | null => {
12
19
 
13
20
  /** `critical path: P1 → P3 → P6 (3 phases)` */
14
21
  const criticalPathLine = (path: readonly string[]): string =>
15
- `critical path: ${phaseLabels(path, ' → ')} (${path.length} ${path.length === 1 ? 'phase' : 'phases'})`
22
+ `critical path: ${phaseLabels(path, ' → ')} (${phaseCount(path.length)})`
16
23
 
17
24
  const scheduleLines = ({ waves, criticalPath, unplaced }: PhaseSchedule): string[] => [
18
25
  ...(waves.length === 0
@@ -29,5 +36,5 @@ const scheduleLines = ({ waves, criticalPath, unplaced }: PhaseSchedule): string
29
36
  */
30
37
  export const parallelReportLines = (markdown: string, language: PlanLanguage): string[] => {
31
38
  const schedule = wavesOfPlan(markdown, language)
32
- return schedule === null ? ['no dependency table'] : scheduleLines(schedule)
39
+ return schedule === null ? [NO_DEPENDENCY_TABLE] : scheduleLines(schedule)
33
40
  }
@@ -25,6 +25,8 @@ export type CheckSettings = {
25
25
  baseBranch: string
26
26
  /** Where the lane worktrees live, below the main checkout: a board's plans are read there too. */
27
27
  worktreeDirectory: string
28
+ /** Put before the kit's skill and agent names in this project; empty without one. */
29
+ skillPrefix: string
28
30
  /** What the lock files (without one, the manifests) decide: one stack, none, or several (ambiguous); `stackOf` reads the stack from it. */
29
31
  stack: StackDetection
30
32
  notes: string[]
@@ -42,6 +44,7 @@ const planDirectorySchema = jackherdConfigSchema.shape.planDirectory
42
44
  const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
43
45
  const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
44
46
  const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
47
+ const skillPrefixSchema = jackherdConfigSchema.shape.skillPrefix
45
48
 
46
49
  /**
47
50
  * The check runner's view of `.jackherd/config.json`: the shared `commands` block, `planDirectory`, `planLanguage` and `baseBranch` plus its own
@@ -59,6 +62,7 @@ export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckS
59
62
  planLanguage: planLanguageSchema.parse(object.planLanguage),
60
63
  baseBranch: baseBranchSchema.parse(object.baseBranch),
61
64
  worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
65
+ skillPrefix: skillPrefixSchema.parse(object.skillPrefix),
62
66
  stack: detectStack(facts.lockFiles, facts.manifests),
63
67
  notes: detected.notes,
64
68
  }
@@ -22,7 +22,17 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
22
22
  // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
23
23
  // Plain words: no plan code without its words in a short question.
24
24
  // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
25
- '.claude/skills/orchestrate/SKILL.md': 7929,
25
+ // Open up: a pointer in § 0 to adopt-plan.md, and § 2 step 8 waits until the lane works on its prompt (+113);
26
+ // step 0 lets the adoption diff stand for its question, step 2 bases on origin only for an unchanged plan.
27
+ // Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
28
+ // Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
29
+ // Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
30
+ '.claude/skills/orchestrate/SKILL.md': 8230,
31
+ // skill-review: the copy is taken after a mirror, and rejecting together with a remark is a remark (+36).
32
+ // Cross-check: an adopted plan also gets short names and a no-review-gate line, which `jh check` asks of a new
33
+ // plan; the proof became a word diff, since a short name goes into an existing line (+74); skill-review: the
34
+ // done check names `jh check`, the gate and short-name rules as the code reads them (+24).
35
+ '.claude/skills/orchestrate/references/adopt-plan.md': 680,
26
36
  '.claude/skills/orchestrate/references/acceptance.md': 200,
27
37
  '.claude/skills/orchestrate/references/close-lane.md': 250,
28
38
  '.claude/skills/orchestrate/references/coordinator-handover.md': 551,
@@ -33,7 +43,8 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
33
43
  '.claude/skills/orchestrate/references/lane-handover.md': 280,
34
44
  '.claude/skills/orchestrate/references/lane-session.md': 280,
35
45
  '.claude/skills/orchestrate/references/lane-setup.md': 380,
36
- '.claude/skills/orchestrate/references/plan-commit.md': 330,
46
+ // Open up: an adopted plan is committed like a missing one, and a tracked copy is restored after the merge.
47
+ '.claude/skills/orchestrate/references/plan-commit.md': 350,
37
48
  '.claude/skills/orchestrate/references/sensitive-input.md': 269,
38
49
  '.claude/skills/plan/SKILL.md': 1920,
39
50
  '.claude/skills/plan/references/template-English.md': 536,
@@ -44,8 +55,8 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
44
55
  // Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
45
56
  '.claude/skills/start/SKILL.md': 630,
46
57
  '.claude/skills/uncle-bob/SKILL.md': 466,
47
- // P33: grows with the skill above.
48
- '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8729,
58
+ // P33 and open up: grows with the skill above.
59
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8880,
49
60
  '.claude/agents/devops.md': 104,
50
61
  '.claude/agents/explore.md': 132,
51
62
  '.claude/agents/implement.md': 303,
@@ -56,6 +67,7 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
56
67
  '.claude/agents/spec-review.md': 1137,
57
68
  '.claude/agents/uncle-bob.md': 111,
58
69
  '.claude/rules/kit/code-style.md': 1664,
59
- '.claude/rules/kit/pull-requests.md': 601,
70
+ // Drafts until the merge: a precondition opens a draft when `merge.openAsDraft` says so (+39).
71
+ '.claude/rules/kit/pull-requests.md': 640,
60
72
  '.claude/rules/kit/testing.md': 741,
61
73
  }
@@ -1,7 +1,9 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { CLAUDE_DIRECTORY, claudePath, SKILLS_DIRECTORY } from '../claude/paths'
3
+ import { CLAUDE_DIRECTORY, claudePath, prefixedKitPath, SKILLS_DIRECTORY } from '../claude/paths'
4
4
  import { projectLockCandidates } from '../config/legacy-names'
5
+ import { parseJsonOrNull } from '../shared/parse-json'
6
+ import { readForeignSkills, SKILLS_LOCK_FILE, skillEntryPath, skillNameOfEntry } from '../sync/foreign-skills'
5
7
  import { parseManifest } from '../sync/manifest'
6
8
  import { matchesGlob } from './glob'
7
9
  import { CHECK_STATUS, type CheckResult, type CheckStatus } from './result'
@@ -17,9 +19,10 @@ import {
17
19
  export type BudgetTable = Readonly<SkillBudgets>
18
20
 
19
21
  /** A file with the budget it falls back on when neither the project nor the kit names one. */
20
- export type FileWords = { path: string; words: number; fallback: number }
22
+ export type FileWords = { path: string; words: number; fallback: number; foreign?: boolean }
21
23
 
22
- export type Measurement = { label: string; words: number; budget: number }
24
+ /** A `foreign` measurement belongs to a skill `skills-lock.json` lists: it warns, it never fails. */
25
+ export type Measurement = { label: string; words: number; budget: number; foreign?: boolean }
23
26
 
24
27
  type Overrun = typeof CHECK_STATUS.warn | typeof CHECK_STATUS.fail
25
28
 
@@ -31,6 +34,7 @@ const KIT_RULE = /^rules\//
31
34
  const PATHS_IN_FRONTMATTER = /^---\r?\n(?:(?!---)[^\n]*\n)*?paths:/
32
35
  const SUM_SEPARATOR = ' + '
33
36
  const FAIL_FACTOR = 1.1
37
+ const FOREIGN_LABEL = `(foreign, ${SKILLS_LOCK_FILE})`
34
38
 
35
39
  const OVERRUN_LABEL: Record<Overrun, string> = { warn: 'warning', fail: 'error' }
36
40
 
@@ -65,6 +69,23 @@ export const budgetOf = (path: string, project: BudgetTable, defaults: BudgetTab
65
69
  return project[path] ?? (glob === undefined ? undefined : project[glob]) ?? defaults[path] ?? fallback
66
70
  }
67
71
 
72
+ /** Every budget path starts with `.claude/` (the kit-content test holds them to shipped files). */
73
+ const prefixedPath = (path: string, prefix: string): string =>
74
+ claudePath(prefixedKitPath(path.slice(CLAUDE_DIRECTORY.length + 1), prefix))
75
+
76
+ /** The kit's budgets under the paths a project with `skillPrefix` installs its skills and agents at; supplements keep theirs. */
77
+ export const prefixedBudgets = (budgets: BudgetTable, prefix: string): BudgetTable =>
78
+ prefix === ''
79
+ ? budgets
80
+ : Object.fromEntries(
81
+ Object.entries(budgets).map(([key, words]) => [
82
+ budgetKeyPaths(key)
83
+ .map((path) => prefixedPath(path, prefix))
84
+ .join(SUM_SEPARATOR),
85
+ words,
86
+ ]),
87
+ )
88
+
68
89
  const sumEntry = (key: string, files: readonly FileWords[]): number | null => {
69
90
  const parts = budgetKeyPaths(key).map((part) => files.find((file) => file.path === part))
70
91
  return parts.every((part) => part !== undefined) ? parts.reduce((total, part) => total + part.words, 0) : null
@@ -76,10 +97,11 @@ export const measureFiles = (
76
97
  project: BudgetTable,
77
98
  defaults: BudgetTable,
78
99
  ): Measurement[] => {
79
- const single = files.map(({ path, words, fallback }) => ({
80
- label: path,
100
+ const single = files.map(({ path, words, fallback, foreign }) => ({
101
+ label: foreign ? `${path} ${FOREIGN_LABEL}` : path,
81
102
  words,
82
103
  budget: budgetOf(path, project, defaults, fallback),
104
+ foreign,
83
105
  }))
84
106
  const sums = [...new Set([...Object.keys(project), ...Object.keys(defaults)])]
85
107
  .filter((key) => key.includes(SUM_SEPARATOR))
@@ -90,6 +112,11 @@ export const measureFiles = (
90
112
  return [...single, ...sums]
91
113
  }
92
114
 
115
+ const overrunOfMeasurement = ({ words, budget, foreign }: Measurement): Overrun | null => {
116
+ const overrun = overrunOf(words, budget)
117
+ return foreign && overrun === CHECK_STATUS.fail ? CHECK_STATUS.warn : overrun
118
+ }
119
+
93
120
  const overrunLine = ({ entry: { label, words, budget }, overrun }: MeasuredOverrun): string =>
94
121
  `${OVERRUN_LABEL[overrun]}: ${label} has ${words} words, budget ${budget}`
95
122
 
@@ -104,7 +131,7 @@ const statusOf = (overruns: readonly MeasuredOverrun[]): CheckStatus => {
104
131
  /** One line per overrun; one line in total when none is over. Warns above a budget, fails above the budget plus 10 %. */
105
132
  export const budgetResult = (measured: readonly Measurement[], durationMs: number): CheckResult => {
106
133
  const overruns = measured.flatMap((entry) => {
107
- const overrun = overrunOf(entry.words, entry.budget)
134
+ const overrun = overrunOfMeasurement(entry)
108
135
  return overrun === null ? [] : [{ entry, overrun }]
109
136
  })
110
137
  return {
@@ -124,19 +151,11 @@ const readText = (path: string): string | null => {
124
151
  }
125
152
  }
126
153
 
127
- const parseJson = (text: string): unknown => {
128
- try {
129
- return JSON.parse(text)
130
- } catch {
131
- return null
132
- }
133
- }
134
-
135
154
  /** The kit documents the first valid lock file lists, the lock under its old name counting while sync has not moved it. */
136
155
  const kitPathsOf = (root: string): string[] => {
137
156
  for (const candidate of projectLockCandidates(root)) {
138
157
  const text = readText(candidate)
139
- const parsed = text === null ? null : parseManifest(parseJson(text))
158
+ const parsed = text === null ? null : parseManifest(parseJsonOrNull(text))
140
159
  if (parsed?.ok) return Object.keys(parsed.manifest.files).filter((key) => KIT_DOCUMENT.test(key))
141
160
  }
142
161
  return []
@@ -159,9 +178,17 @@ const kitFileWordsOf = (root: string, kitPath: string): FileWords[] => {
159
178
 
160
179
  const ownSkillPaths = (root: string, kit: ReadonlySet<string>): string[] =>
161
180
  listDirectory(join(root, CLAUDE_DIRECTORY, SKILLS_DIRECTORY))
162
- .map((name) => claudePath(SKILLS_DIRECTORY, name, 'SKILL.md'))
181
+ .map((name) => claudePath(skillEntryPath(name)))
163
182
  .filter((path) => !kit.has(path))
164
183
 
184
+ /** Marks the entry file of a skill `skills-lock.json` lists; any other file is left as it is. */
185
+ const markForeign =
186
+ (foreign: ReadonlySet<string>) =>
187
+ (file: FileWords): FileWords => {
188
+ const name = skillNameOfEntry(file.path)
189
+ return name !== undefined && foreign.has(name) ? { ...file, foreign: true } : file
190
+ }
191
+
165
192
  const supplementPaths = (root: string): string[] =>
166
193
  listDirectory(join(root, CLAUDE_DIRECTORY, SUPPLEMENTS_DIRECTORY))
167
194
  .filter((name) => name.endsWith('.md'))
@@ -170,16 +197,19 @@ const supplementPaths = (root: string): string[] =>
170
197
  /**
171
198
  * The installed kit skills with their references, kit agents and kit rules that load every session (the lock file
172
199
  * says which files the kit installed), the project's supplements and its own skills, measured against the budgets.
200
+ * `skillPrefix` is the project's: the kit's budgets follow its skills and agents to their prefixed paths.
173
201
  * Null when there is nothing to measure.
174
202
  */
175
- export const skillBudgetCheck = (root: string, project: BudgetTable = {}): CheckResult | null => {
203
+ export const skillBudgetCheck = (root: string, project: BudgetTable = {}, skillPrefix = ''): CheckResult | null => {
176
204
  const started = Date.now()
177
205
  const kitPaths = kitPathsOf(root)
178
206
  const kit = new Set(kitPaths.map((key) => claudePath(key)))
207
+ const foreign = readForeignSkills(root)
179
208
  const files = [
180
209
  ...kitPaths.flatMap((key) => kitFileWordsOf(root, key)),
181
210
  ...supplementPaths(root).flatMap((path) => fileWordsOf(root, path, SUPPLEMENT_BUDGET)),
182
211
  ...ownSkillPaths(root, kit).flatMap((path) => fileWordsOf(root, path, OWN_SKILL_BUDGET)),
183
- ]
184
- return files.length === 0 ? null : budgetResult(measureFiles(files, project, KIT_SKILL_BUDGETS), Date.now() - started)
212
+ ].map(markForeign(foreign))
213
+ const defaults = prefixedBudgets(KIT_SKILL_BUDGETS, skillPrefix)
214
+ return files.length === 0 ? null : budgetResult(measureFiles(files, project, defaults), Date.now() - started)
185
215
  }
@@ -10,5 +10,17 @@ export const HANDOVER_DIRECTORY = 'handover'
10
10
  export const WORKTREES_DIRECTORY = 'worktrees'
11
11
  export const SESSION_LOGS_DIRECTORY = 'projects'
12
12
 
13
+ /** A skill's file and an agent below `.claude/`, by name: `skills/<name>/<file>`, `agents/<name>.md`. */
14
+ export const SKILL_PATH = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/(.+)$`)
15
+ export const AGENT_PATH = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`)
16
+
13
17
  /** `.claude/<directory>` as a path in a project and in a text that names it. */
14
18
  export const claudePath = (...segments: string[]): string => [CLAUDE_DIRECTORY, ...segments].join('/')
19
+
20
+ /** A kit path below `.claude/` as a project with `skillPrefix` installs it: skills and agents prefixed, the rest as it is. */
21
+ export const prefixedKitPath = (path: string, prefix: string): string => {
22
+ const skill = SKILL_PATH.exec(path)
23
+ if (skill) return `${SKILLS_DIRECTORY}/${prefix}${skill[1]}/${skill[2]}`
24
+ const agent = AGENT_PATH.exec(path)
25
+ return agent ? `${AGENTS_DIRECTORY}/${prefix}${agent[1]}.md` : path
26
+ }
package/src/cli.ts CHANGED
@@ -26,6 +26,7 @@ export const COMMANDS: CommandTable = {
26
26
  [HERD_COMMAND.alias]: () => import('./plans/command'),
27
27
  lane: () => import('./lane/command'),
28
28
  mermaid: () => import('./mermaid/command'),
29
+ plan: () => import('./plan-contract/command'),
29
30
  preview: () => import('./preview/command'),
30
31
  settled: () => import('./settled/command'),
31
32
  sync: () => import('./sync/command'),