jackherd 1.13.0 → 1.15.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +28 -38
  3. package/assets/icons/skills.svg +1 -0
  4. package/dist/config.d.ts +12 -1
  5. package/dist/config.js +35 -2
  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/pull-request.ts +28 -15
  16. package/kit/hooks/hooks/merge-guard/run.ts +6 -3
  17. package/kit/hooks/hooks/merge-guard/settings.ts +12 -1
  18. package/kit/hooks/hooks/model-log/transcript.ts +4 -7
  19. package/kit/hooks/hooks/open-question/record.ts +4 -3
  20. package/kit/hooks/hooks/shared/dialog-answers.ts +7 -3
  21. package/kit/hooks/hooks/shared/log-vocabulary.ts +13 -0
  22. package/kit/hooks/hooks/shared/transcript.ts +7 -4
  23. package/kit/hooks/hooks/task-hint/decision.ts +3 -2
  24. package/kit/hooks/hooks/task-hint/run.ts +2 -4
  25. package/kit/rules/rules/kit/pull-requests.md +1 -0
  26. package/kit/workflow/skills/orchestrate/SKILL.md +24 -10
  27. package/kit/workflow/skills/orchestrate/handover/index.ts +8 -2
  28. package/kit/workflow/skills/orchestrate/references/adopt-plan.md +51 -0
  29. package/kit/workflow/skills/orchestrate/references/events.md +6 -1
  30. package/kit/workflow/skills/orchestrate/references/plan-commit.md +7 -6
  31. package/kit/workflow/skills/start/SKILL.md +6 -2
  32. package/kit/workflow/skills/start/references/missing-commands.md +17 -0
  33. package/package.json +1 -1
  34. package/schemas/jackherd.schema.json +43 -0
  35. package/src/check/command.ts +53 -7
  36. package/src/check/file-kinds.ts +144 -0
  37. package/src/check/parallel-report.ts +10 -3
  38. package/src/check/plan.ts +118 -16
  39. package/src/check/report.ts +44 -1
  40. package/src/check/roots.ts +9 -0
  41. package/src/check/schema.ts +12 -0
  42. package/src/check/settings.ts +11 -3
  43. package/src/check/skill-budget-defaults.ts +21 -7
  44. package/src/check/skill-budgets.ts +49 -19
  45. package/src/claude/paths.ts +12 -0
  46. package/src/cli.ts +1 -0
  47. package/src/config/schema.ts +27 -0
  48. package/src/context/fill.ts +2 -1
  49. package/src/context/log-vocabulary.ts +22 -0
  50. package/src/context/session-log.ts +2 -1
  51. package/src/lane-prompt/build.ts +10 -5
  52. package/src/lane-prompt/command.ts +6 -1
  53. package/src/lane-prompt/input.ts +4 -4
  54. package/src/lane-prompt/texts.ts +18 -10
  55. package/src/plan-contract/command.ts +69 -0
  56. package/src/plan-contract/contract.ts +144 -0
  57. package/src/plan-format/dependencies.ts +1 -4
  58. package/src/plan-format/index.ts +4 -0
  59. package/src/plan-format/lanes.ts +11 -0
  60. package/src/plan-format/markdown.ts +6 -5
  61. package/src/plan-format/model-table.ts +27 -0
  62. package/src/plans/activity-read.ts +98 -0
  63. package/src/plans/ask-coordinator.ts +20 -11
  64. package/src/plans/lane-plans.ts +2 -1
  65. package/src/plans/layout.ts +49 -4
  66. package/src/plans/live.ts +17 -4
  67. package/src/plans/plan-panes.ts +25 -5
  68. package/src/plans/snapshot.ts +18 -8
  69. package/src/plans/subagents-read.ts +4 -3
  70. package/src/plans/subagents.ts +2 -1
  71. package/src/plans/texts.ts +27 -1
  72. package/src/settled/send.ts +80 -4
  73. package/src/shared/parse-json.ts +8 -0
  74. package/src/sync/command.ts +17 -10
  75. package/src/sync/foreign-skills.ts +40 -0
  76. package/src/sync/global-kit.ts +31 -21
  77. package/src/sync/init.ts +2 -2
  78. package/src/sync/kit-exclude.ts +39 -0
  79. package/src/sync/plan-file.ts +1 -1
  80. package/src/sync/render.ts +2 -1
  81. package/src/sync/report.ts +37 -2
  82. package/src/sync/sync-result.ts +5 -2
  83. package/src/sync/sync.ts +13 -5
  84. package/src/task-signals/claude-signals.ts +6 -5
  85. package/src/watch/command.ts +88 -18
  86. package/src/watch/lane-message.ts +1 -1
  87. package/src/watch/lane-progress.ts +57 -3
  88. package/src/watch/lane-report.ts +67 -2
  89. package/src/watch/lane-watch.ts +2 -8
  90. package/src/watch/plan-missing-read.ts +47 -0
  91. package/src/watch/plan-missing.ts +43 -0
  92. package/src/watch/reference-paths.ts +1 -0
  93. package/src/watch/task-parallel-touched.ts +9 -2
  94. package/src/watch/texts.ts +8 -0
  95. package/src/watch/watch-events.ts +7 -0
@@ -58,7 +58,11 @@ Each file under `references/` is read when its trigger fires: the step or the ev
58
58
  directory to a path from the repository root without `..` (git does not resolve `..` in a tree
59
59
  path). A file missing there exists only in the main checkout, and a worktree made from
60
60
  `origin/{{kit.baseBranch}}` would not have it: § 2 step 0. New rows of a board that is on the base
61
- branch need nothing; the herd shows them. Check a plan that joins the board later the same way.
61
+ branch need nothing; the herd shows them. Check a plan that joins the board later the same way; one
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.
62
66
  - [ ] Tell the user which lanes start now and which wait, before starting any, and whether § 2 step 0
63
67
  is needed.
64
68
 
@@ -83,7 +87,8 @@ stay approval-bound (§ 5, § 6).
83
87
  When the user wants a plan's lanes merged without a question for a while: § 5, **Standing approval**.
84
88
 
85
89
  0. **Plan commit as the base**, when § 0 found the board or a plan missing on
86
- `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
87
92
  documentation branch and open a documentation PR, so the lanes start from that commit? The answer
88
93
  is the approval of that commit, its push and the PR (§ 5). `<topic>` is the plan's file name
89
94
  without date and extension, or the board's directory name when several files are missing. On yes,
@@ -98,7 +103,7 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
98
103
  your judgement.
99
104
  2. **Worktree workspace** from `<base>`: `origin/{{kit.baseBranch}}` when, after
100
105
  `git fetch origin {{kit.baseBranch}}`, `git cat-file -e origin/{{kit.baseBranch}}:<plan>` succeeds
101
- 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
102
107
  the PR, so a later lane gets the right base:
103
108
  ```bash
104
109
  herdr worktree create --cwd <main-checkout> --branch <branch> --base <base> \
@@ -132,12 +137,16 @@ When the user wants a plan's lanes merged without a question for a while: § 5,
132
137
  within a poll or two: `bunx jackherd herd --ensure`.
133
138
  8. **Prompt** from the file of § 1:
134
139
  ```bash
135
- herdr agent prompt <lane> "$(cat <prompt-file>)"
140
+ herdr agent prompt <lane> "$(cat <prompt-file>)" --wait --until working --timeout 30000
136
141
  ```
137
142
  The prompt quotes commands in backticks (`git reset`, `/pre-commit`); typed into the command
138
143
  line inside double quotes, your own shell would run them before the lane sees the text. Text
139
144
  read through `$(cat …)` is not expanded again. Every `herdr agent prompt` in this skill goes
140
- 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.
141
150
 
142
151
  ## 3. Dev server
143
152
 
@@ -241,7 +250,7 @@ On wake-up:
241
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 |
242
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 |
243
252
  | `main checkout on <branch>, not …` | § 0, **The main checkout stays on `{{kit.baseBranch}}`** |
244
- | `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 |
245
254
  | `merged #<n>` | § 6, for every lane |
246
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 |
247
256
  | `checks passed #<n>` | One line to the user: ready to merge, with the board's merge order |
@@ -474,6 +483,9 @@ always goes to the user, whatever the plan settles.
474
483
  permission mode refuses the push, push its branch yourself under the same approval: run
475
484
  `cd <worktree>` as a command of its own, then `git push --force-with-lease origin HEAD:<branch>`,
476
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.
477
489
 
478
490
  ### Self-merge
479
491
 
@@ -481,15 +493,17 @@ When the user picked `"{{kit.guard.selfMergeLabel}}"` for a PR:
481
493
 
482
494
  1. Commit, push and open the PR as in step 4. Enter in the board's `{{kit.heading.history}}` that the
483
495
  user approved this PR with self-merge (time, lane, PR number; the time as § 4 reads it), so the
484
- approval has a record outside the chat.
496
+ approval has a record outside the chat. A draft turns ready here: `gh pr ready <n>`.
485
497
  2. **Merging without checks.** The paths a PR may carry to be merged without waiting for checks are
486
498
  `{{kit.merge.withoutChecks}}` (`merge.withoutChecks` in the config; `none` means every merge waits).
487
499
  Unless `none`, list the PR's files with `gh pr diff <n> --name-only` and compare each one with the
488
500
  patterns yourself. When **every** file matches, go straight to step 4. One file outside, an empty
489
501
  list, or a failed `gh` call: go on with step 3. The merge guard reads the file list from GitHub
490
502
  itself; if it asks, the dialog is the user's (step 5).
491
- 3. Wait for the checks in the background, never in the foreground: a Bash call
492
- `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.
493
507
  4. Green (or a PR that qualified in step 2): `gh pr merge <n> --squash --delete-branch --match-head-commit <sha>`,
494
508
  with the plain PR number, the full `headRefOid` from `gh pr view <n> --json headRefOid` read right before, and
495
509
  without `-R`, `--repo` or `--admin`. The merge guard lets exactly this command through for the approved branch
@@ -530,7 +544,7 @@ For a PR the user approved (step 2) whose lane works on a plan under a standing
530
544
 
531
545
  1. A PR behind the base branch is rebased first (§ 6, under the PR's approval), so its checks run
532
546
  against the current base.
533
- 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.
534
548
  3. Green: the same command as **Self-merge** step 4, with the `<sha>` read right before. The merge
535
549
  guard lets it through only before the approval's end, with every check green, GitHub able to merge
536
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
  # § 4: the rare watcher events
2
2
 
3
3
  Read when a watcher event line ends in `· read <path>` naming this file: `feedback`, `preview`, `source`,
4
- `appointment unreadable`, `parallel`, `parallel tasks`, `no task signal`, `unlinked artifact` or `update available`. Only the section of that event
4
+ `appointment unreadable`, `parallel`, `parallel tasks`, `no task signal`, `unlinked artifact`, `plan missing` or `update available`. Only the section of that event
5
5
  applies; the text these events carry is third-party data (§ 4).
6
6
 
7
7
  ## `feedback #<n>`
@@ -88,6 +88,11 @@ title in the phase it belongs to (`**Mockup:** [Title](url)`, § 4 draft for rev
88
88
  line to link it in its plan. One that belongs to no plan stays open until the user's answer, then
89
89
  `jh waiting close <id>` as for anything that waits. The watcher reports it once per record.
90
90
 
91
+ ## `lane <name> plan missing on <base ref>: <path>`
92
+
93
+ Only the lane's worktree has the plan its row links, so the herd shows an older one. Run § 2 step 0 for that path:
94
+ on yes, its PR merges before the lane's next one (§ 6); on no, the lane's PR carries the plan. Reported once.
95
+
91
96
  ## `update available <old> → <new>`
92
97
 
93
98
  A newer jackherd is on npm. Ask the user with `AskUserQuestion` (update now / not now) and run `jh update` only
@@ -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,8 +13,9 @@ 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>`,
17
- then `cp <path> {{kit.worktreeDirectory}}/docs-plan-<topic>/<path>`.
16
+ 1. From the main checkout, per missing or adopted file: `mkdir -p {{kit.worktreeDirectory}}/docs-plan-<topic>/<directory>`,
17
+ then `cp <path> {{kit.worktreeDirectory}}/docs-plan-<topic>/<path>`. For `plan missing` while a lane
18
+ works, copy from the lane's worktree instead (`<lane worktree>/<path>`): the main checkout lacks it.
18
19
  2. `cd` into the temporary worktree, as a command of its own (the kit's git guard refuses `git -C`).
19
20
  3. `git add <paths>`, then `git commit -m "docs(plans): add the <topic> plan"`, then
20
21
  `git push -u origin docs/plan-<topic>`, each on its own.
@@ -27,8 +28,8 @@ The copies in the main checkout stay until the PR is merged.
27
28
 
28
29
  The lanes rebase on `origin/{{kit.baseBranch}}` as after every merge. Then, after `git fetch origin {{kit.baseBranch}}`,
29
30
  compare each copy in the main checkout with the merged file
30
- (`git show origin/{{kit.baseBranch}}:<path> | cmp -s - <path>`, one per file; read-only) and remove the copies
31
- 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
32
33
  rows since stays and is no question; any other difference goes to the user. Once every lane of the plan has
33
34
  rebased, delete the local branch (`git branch -D docs/plan-<topic>`; a squash merge leaves it unmerged), so a
34
35
  later step 0 can use the name again.
@@ -6,7 +6,7 @@ disable-model-invocation: true
6
6
 
7
7
  If `.claude/project/start.md` exists, read it first. It extends this skill for this project and wins where the two disagree.
8
8
 
9
- You guide a new user through their first minutes with jackherd. You explain, check and offer; you never write or edit a file. You run only the reading commands in step 2; every fix goes to the user as a command or config line to run themselves.
9
+ You guide a new user through their first minutes with jackherd. You explain, check and offer; you write only the `commands` entries the user approves in step 3. You run only the reading commands in step 2; every other fix goes to the user as a command or config line to run themselves.
10
10
 
11
11
  ## 1. The flow, in a few lines
12
12
 
@@ -37,7 +37,11 @@ Report one line per item, present or missing. For each missing item give the fix
37
37
  - Session outside herdr: start Claude Code from a herdr pane.
38
38
  - `gh`: install the GitHub CLI (`gh`), then run `gh auth login`.
39
39
 
40
- ## 3. Offer the first step
40
+ ## 3. Propose the missing commands
41
+
42
+ Only when `jh config` reported `commands.test`, `typecheck`, `lint` or `install` missing: [`references/missing-commands.md`](references/missing-commands.md).
43
+
44
+ ## 4. Offer the first step
41
45
 
42
46
  Ask through `AskUserQuestion`, in {{kit.responseLanguage}}, with these options (the first one recommended):
43
47
 
@@ -0,0 +1,17 @@
1
+ # Step 3: proposing the missing commands
2
+
3
+ Covers the four keys `jh check` and the lane setup run: `commands.test`, `typecheck`, `lint` and `install`.
4
+
5
+ 1. If this repository has no `.jackherd/config.json`, propose `bunx jackherd init`; step 3 ends there.
6
+ 2. Read the CI files once (`.github/workflows/*.yml` and `*.yaml`, `.gitlab-ci.yml`). Without any, read the build file
7
+ (`Cargo.toml`, `pom.xml`, `build.gradle.kts`, `go.mod`, `pyproject.toml`) and propose its toolchain's standard
8
+ command (`cargo test`, `go test ./...`, `mvn test`), with the build file as location.
9
+ 3. For each missing key, propose the CI step that runs it, as it runs from the repository root (fold a step's
10
+ `working-directory` in: `cd backend && cargo test`), with its location as `file:line`; for `install`, the
11
+ dependency install step. Checkout, cache and toolchain setup steps are no proposal. A key the files do not show
12
+ stays unset, and you say so.
13
+ 4. Ask once through `AskUserQuestion`, in {{kit.responseLanguage}}, with one option per proposed key as a
14
+ multiSelect. Write only the chosen ones, under `commands` in this repository's `.jackherd/config.json`.
15
+
16
+ Done when each missing key of the four is written, declined, or named as not shown by the files, and a rerun of
17
+ `jh config` no longer reports the written ones.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
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
@@ -901,6 +920,30 @@
901
920
  "looseTests": {
902
921
  "default": false,
903
922
  "type": "boolean"
923
+ },
924
+ "roots": {
925
+ "minItems": 1,
926
+ "type": "array",
927
+ "items": {
928
+ "type": "string",
929
+ "minLength": 1
930
+ }
931
+ },
932
+ "sourceFiles": {
933
+ "minItems": 1,
934
+ "type": "array",
935
+ "items": {
936
+ "type": "string",
937
+ "minLength": 1
938
+ }
939
+ },
940
+ "testFiles": {
941
+ "minItems": 1,
942
+ "type": "array",
943
+ "items": {
944
+ "type": "string",
945
+ "minLength": 1
946
+ }
904
947
  }
905
948
  },
906
949
  "additionalProperties": false
@@ -10,6 +10,7 @@ import { baseRefOf } from '../plans/settings'
10
10
  import { type ChangedFile, parsePorcelain, STATUS_COMMAND } from './changes'
11
11
  import { deferralArrowCheck } from './deferral-arrows'
12
12
  import { runCheck, runProcess } from './execute'
13
+ import { matchesAnyGlob } from './glob'
13
14
  import { parallelArgument, parallelReportLines } from './parallel-report'
14
15
  import { type CheckPlan, type CheckStack, checkStackOf, type Filesystem, isSlow, planChecks } from './plan'
15
16
  import { planStructureChecks } from './plan-structure-check'
@@ -18,6 +19,9 @@ import {
18
19
  artifactResult,
19
20
  formatPlanHeader,
20
21
  formatResults,
22
+ missingCommandNotes,
23
+ missingTestCommandResult,
24
+ namedTestResult,
21
25
  skippedSlowResult,
22
26
  summarize,
23
27
  testCommandResult,
@@ -25,7 +29,7 @@ import {
25
29
  } from './report'
26
30
  import { type CheckResult, RUN_MODE, type RunMode, runModeOf } from './result'
27
31
  import { isWorkspaceManifest, kitCopyPaths, WORKSPACE_LIST_FILE, WORKSPACE_MANIFEST } from './roots'
28
- import { type CheckSettings, CONFIG_PATH, parseCheckSettings, stackOf } from './settings'
32
+ import { type CheckSettings, CONFIG_PATH, noStackFallbackOf, parseCheckSettings, stackOf } from './settings'
29
33
  import { skillBudgetCheck } from './skill-budgets'
30
34
 
31
35
  type CheckTarget = { root: string; configPath: string }
@@ -88,8 +92,38 @@ export const readJsonIfPresent = (path: string): unknown => {
88
92
  }
89
93
  }
90
94
 
91
- const filesystemAt = (root: string, stack: CheckStack): Filesystem => {
95
+ /** Tracked files and the untracked ones git does not ignore, so a new test file counts before its first commit. */
96
+ const REPOSITORY_FILES_COMMAND = ['ls-files', '--cached', '--others', '--exclude-standard']
97
+
98
+ const repositoryFilesAt = (root: string): (() => readonly string[]) => {
99
+ let files: readonly string[] | null = null
100
+ return () => {
101
+ if (files === null) {
102
+ const result = spawnSync('git', REPOSITORY_FILES_COMMAND, {
103
+ cwd: root,
104
+ encoding: 'utf8',
105
+ maxBuffer: 256 * 1024 * 1024,
106
+ })
107
+ if (result.status !== 0) {
108
+ throw new UnreadableFileError(`git ${REPOSITORY_FILES_COMMAND.join(' ')} failed: ${result.stderr.trim()}`)
109
+ }
110
+ files = result.stdout.split('\n').filter((line) => line !== '')
111
+ }
112
+ return files
113
+ }
114
+ }
115
+
116
+ /** Whether a directory entry is a test file: by `check.testFiles` where set, else by the stack's pattern. */
117
+ const testEntryOf =
118
+ (stack: CheckStack, testFiles: readonly string[] | undefined) =>
119
+ (dir: string, entry: string): boolean =>
120
+ testFiles === undefined
121
+ ? stack.testFile.test(entry)
122
+ : matchesAnyGlob(dir === '.' ? entry : `${dir}/${entry}`, testFiles)
123
+
124
+ const filesystemAt = (root: string, stack: CheckStack, testFiles: readonly string[] | undefined): Filesystem => {
92
125
  const holdsAny = (dir: string, names: readonly string[]) => names.some((name) => existsSync(join(root, dir, name)))
126
+ const isTestEntry = testEntryOf(stack, testFiles)
93
127
  return {
94
128
  isProjectRoot: (dir) => holdsAny(dir, stack.rootMarkers),
95
129
  isWorkspaceRoot: (dir) =>
@@ -97,7 +131,8 @@ const filesystemAt = (root: string, stack: CheckStack): Filesystem => {
97
131
  isWorkspaceManifest(readJsonIfPresent(join(root, dir, WORKSPACE_MANIFEST))),
98
132
  hasTypeConfig: (dir) => holdsAny(dir, stack.typeMarkers),
99
133
  hasTests: (dir) =>
100
- existsSync(join(root, dir)) && readdirSync(join(root, dir)).some((entry) => stack.testFile.test(entry)),
134
+ existsSync(join(root, dir)) && readdirSync(join(root, dir)).some((entry) => isTestEntry(dir, entry)),
135
+ repositoryFiles: repositoryFilesAt(root),
101
136
  }
102
137
  }
103
138
 
@@ -168,7 +203,7 @@ const planRootsOf = (root: string, worktreeDirectory: string): string[] => {
168
203
  const ownChecksOf = (root: string, paths: readonly string[], settings: CheckSettings, home: string) => [
169
204
  publicContentCheck(root, paths, settings.check.publicContent, home),
170
205
  deferralArrowCheck(root, paths, settings.planDirectory),
171
- skillBudgetCheck(root, settings.check.skillBudgets),
206
+ skillBudgetCheck(root, settings.check.skillBudgets, settings.skillPrefix),
172
207
  ...planStructureChecks({
173
208
  root,
174
209
  paths,
@@ -197,16 +232,27 @@ const checkChanges = async (
197
232
  const kitCopies = kitCopyPaths(readJsonIfPresent(join(root, TOOL_DIRECTORY, MANIFEST_FILE)))
198
233
  plan = planChecks(
199
234
  files,
200
- { commands: settings.commands, check: settings.check, stack, kitCopies },
201
- filesystemAt(root, stack),
235
+ {
236
+ commands: settings.commands,
237
+ check: settings.check,
238
+ stack,
239
+ kitCopies,
240
+ noStackFallback: noStackFallbackOf(settings),
241
+ },
242
+ filesystemAt(root, stack, settings.check.testFiles),
202
243
  )
203
244
  } catch (error) {
204
245
  if (error instanceof UnreadableFileError) return error.message
205
246
  throw error
206
247
  }
207
- print(formatPlanHeader(files, plan, settings.stack))
248
+ print([
249
+ ...formatPlanHeader(files, plan, settings.stack),
250
+ ...missingCommandNotes(settings.commands, settings.check.lintFilesCommand, settings.stack),
251
+ ])
208
252
  return executePlan(plan, root, mode, [
209
253
  testCommandResult(settings.commands.test, settings.stack),
254
+ missingTestCommandResult(settings.commands.test, settings.stack),
255
+ namedTestResult(plan.sourcesWithoutNamedTest),
210
256
  ...ownChecksOf(root, plan.paths, settings, home),
211
257
  ])
212
258
  }
@@ -0,0 +1,144 @@
1
+ import { basename, extname } from 'node:path'
2
+
3
+ /**
4
+ * One rule of thumb for test and source files in any language, read where no detected stack has patterns of its
5
+ * own. `check.testFiles` and `check.sourceFiles` replace its answer for their kind.
6
+ */
7
+
8
+ const TEST_DIRECTORIES = new Set(['test', 'tests', 'spec', '__tests__'])
9
+
10
+ /** `test`, `tests` or `spec` as a word of the name: `parser_test`, `test_parser`, `parser.spec`. */
11
+ const TEST_WORD = /(^|[._-])(tests?|spec)(?=$|[._-])/gi
12
+
13
+ /** `Test`, `Tests` or `Spec` as a capitalised word at either end: `ParserTest`, `TestParser`, `ParserSpec`. */
14
+ const CAMEL_TEST_SUFFIX = /(?<=[a-z0-9])(Tests?|Spec)$/
15
+ const CAMEL_TEST_PREFIX = /^Tests?(?=[A-Z])/
16
+
17
+ const DOCUMENT_EXTENSIONS = ['.md', '.mdx', '.rst', '.adoc', '.txt']
18
+ const CONFIG_EXTENSIONS = [
19
+ '.json',
20
+ '.jsonc',
21
+ '.json5',
22
+ '.yaml',
23
+ '.yml',
24
+ '.toml',
25
+ '.ini',
26
+ '.cfg',
27
+ '.conf',
28
+ '.xml',
29
+ '.properties',
30
+ '.env',
31
+ '.gradle',
32
+ '.kts',
33
+ '.csproj',
34
+ '.fsproj',
35
+ '.sln',
36
+ ]
37
+ const LOCK_EXTENSIONS = ['.lock', '.lockb', '.sum']
38
+ const ASSET_EXTENSIONS = [
39
+ '.png',
40
+ '.jpg',
41
+ '.jpeg',
42
+ '.gif',
43
+ '.webp',
44
+ '.avif',
45
+ '.ico',
46
+ '.bmp',
47
+ '.svg',
48
+ '.woff',
49
+ '.woff2',
50
+ '.ttf',
51
+ '.otf',
52
+ '.eot',
53
+ '.css',
54
+ '.scss',
55
+ '.sass',
56
+ '.less',
57
+ '.html',
58
+ '.htm',
59
+ '.mp3',
60
+ '.mp4',
61
+ '.wav',
62
+ '.webm',
63
+ '.mov',
64
+ '.pdf',
65
+ '.zip',
66
+ '.gz',
67
+ '.tar',
68
+ ]
69
+ const NON_SOURCE_EXTENSIONS = new Set([
70
+ ...DOCUMENT_EXTENSIONS,
71
+ ...CONFIG_EXTENSIONS,
72
+ ...LOCK_EXTENSIONS,
73
+ ...ASSET_EXTENSIONS,
74
+ ])
75
+
76
+ /** Build files whose extension reads like code. */
77
+ const CONFIG_FILE_NAMES = new Set(['go.mod', 'go.work'])
78
+
79
+ /** Container files named by their prefix (`Dockerfile.dev`). */
80
+ const CONTAINER_FILE = /^(Dockerfile|Containerfile)(\.|$)/
81
+
82
+ const DOCUMENT_DIRECTORIES = new Set(['docs', 'doc'])
83
+
84
+ const directoriesOf = (path: string): string[] => path.split('/').slice(0, -1)
85
+
86
+ const stemOf = (path: string): string => basename(path, extname(path))
87
+
88
+ const isInTestDirectory = (path: string): boolean =>
89
+ directoriesOf(path).some((directory) => TEST_DIRECTORIES.has(directory.toLowerCase()))
90
+
91
+ const hasTestWord = (stem: string): boolean => new RegExp(TEST_WORD.source, 'i').test(stem)
92
+
93
+ const hasCamelTestWord = (stem: string): boolean => CAMEL_TEST_SUFFIX.test(stem) || CAMEL_TEST_PREFIX.test(stem)
94
+
95
+ export const isTestFile = (path: string): boolean => {
96
+ const stem = stemOf(path)
97
+ return isInTestDirectory(path) || hasTestWord(stem) || hasCamelTestWord(stem)
98
+ }
99
+
100
+ /** Dot directories (`.github`, `.claude`) hold tooling and agent texts; `docs/` holds documents. */
101
+ const isInNonSourceDirectory = (path: string): boolean =>
102
+ directoriesOf(path).some((directory) => directory.startsWith('.') || DOCUMENT_DIRECTORIES.has(directory))
103
+
104
+ /** A file without extension is a build or legal file (`Makefile`, `LICENSE`); a dot file is tooling (`.gitignore`). */
105
+ const isNonSourceName = (name: string): boolean =>
106
+ extname(name) === '' || name.startsWith('.') || CONFIG_FILE_NAMES.has(name) || CONTAINER_FILE.test(name)
107
+
108
+ /** Code a test covers: every file except tests, documents, configs, lock files and assets. */
109
+ export const isSourceFile = (path: string): boolean => {
110
+ const name = basename(path)
111
+ return (
112
+ !isTestFile(path) &&
113
+ !isInNonSourceDirectory(path) &&
114
+ !isNonSourceName(name) &&
115
+ !NON_SOURCE_EXTENSIONS.has(extname(name).toLowerCase())
116
+ )
117
+ }
118
+
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(/[._-]+$/, '')
121
+
122
+ /** The name a test file covers: its stem without the test word (`test_parser.py` → `parser`); null when nothing is left. */
123
+ export const testStemOf = (path: string): string | null => {
124
+ const stem = stemOf(path)
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
+ )
131
+ return stripped === '' ? null : stripped
132
+ }
133
+
134
+ /** The names the test files among `files` cover, built once per run. */
135
+ export const testStemsOf = (files: readonly string[], isTest: (path: string) => boolean): ReadonlySet<string> =>
136
+ new Set(files.filter(isTest).flatMap((file) => testStemOf(file) ?? []))
137
+
138
+ /**
139
+ * Whether a test file of the source's name lies anywhere in the repository: `src/parser.rs` ↔ `tests/parser.rs`,
140
+ * `parser_test.go`, `test_parser.py`, `ParserTest.java`. Only the last extension of the source is dropped, so
141
+ * `foo.module.ts` needs `foo.module.test.ts`.
142
+ */
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
  }