jackherd 1.14.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 (83) hide show
  1. package/CHANGELOG.md +15 -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/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 +22 -9
  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/plan-commit.md +5 -5
  30. package/package.json +1 -1
  31. package/schemas/jackherd.schema.json +19 -0
  32. package/src/check/command.ts +1 -1
  33. package/src/check/file-kinds.ts +10 -8
  34. package/src/check/parallel-report.ts +10 -3
  35. package/src/check/settings.ts +4 -0
  36. package/src/check/skill-budget-defaults.ts +17 -5
  37. package/src/check/skill-budgets.ts +49 -19
  38. package/src/claude/paths.ts +12 -0
  39. package/src/cli.ts +1 -0
  40. package/src/config/schema.ts +27 -0
  41. package/src/context/fill.ts +2 -1
  42. package/src/context/log-vocabulary.ts +22 -0
  43. package/src/context/session-log.ts +2 -1
  44. package/src/lane-prompt/build.ts +10 -5
  45. package/src/lane-prompt/command.ts +6 -1
  46. package/src/lane-prompt/input.ts +4 -4
  47. package/src/lane-prompt/texts.ts +18 -10
  48. package/src/plan-contract/command.ts +69 -0
  49. package/src/plan-contract/contract.ts +144 -0
  50. package/src/plan-format/dependencies.ts +1 -4
  51. package/src/plan-format/index.ts +4 -0
  52. package/src/plan-format/lanes.ts +11 -0
  53. package/src/plan-format/markdown.ts +6 -5
  54. package/src/plan-format/model-table.ts +27 -0
  55. package/src/plans/activity-read.ts +98 -0
  56. package/src/plans/ask-coordinator.ts +20 -11
  57. package/src/plans/layout.ts +49 -4
  58. package/src/plans/live.ts +17 -4
  59. package/src/plans/plan-panes.ts +25 -5
  60. package/src/plans/snapshot.ts +18 -8
  61. package/src/plans/subagents-read.ts +4 -3
  62. package/src/plans/subagents.ts +2 -1
  63. package/src/plans/texts.ts +27 -1
  64. package/src/settled/send.ts +80 -4
  65. package/src/shared/parse-json.ts +8 -0
  66. package/src/sync/command.ts +17 -10
  67. package/src/sync/foreign-skills.ts +40 -0
  68. package/src/sync/global-kit.ts +31 -21
  69. package/src/sync/init.ts +2 -2
  70. package/src/sync/kit-exclude.ts +39 -0
  71. package/src/sync/plan-file.ts +1 -1
  72. package/src/sync/render.ts +2 -1
  73. package/src/sync/report.ts +37 -2
  74. package/src/sync/sync-result.ts +5 -2
  75. package/src/sync/sync.ts +13 -5
  76. package/src/task-signals/claude-signals.ts +6 -5
  77. package/src/watch/command.ts +56 -17
  78. package/src/watch/lane-message.ts +1 -1
  79. package/src/watch/lane-progress.ts +51 -2
  80. package/src/watch/lane-report.ts +57 -2
  81. package/src/watch/lane-watch.ts +2 -8
  82. package/src/watch/task-parallel-touched.ts +9 -2
  83. package/src/watch/texts.ts +4 -0
@@ -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.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
@@ -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'),
@@ -46,6 +46,15 @@ export const isModelRole = (value: string): value is ModelRole =>
46
46
  /** Lower-case letters and digits ending in a dash, so a prefixed name stays one plain path segment. */
47
47
  export const SKILL_PREFIX = /^[a-z0-9]+-$/
48
48
 
49
+ /** What a global install, and a project that already has skills of kit names, puts before every kit name. */
50
+ export const DEFAULT_SKILL_PREFIX = 'jh-'
51
+
52
+ /** The skills every lane and the coordinator run on; `kit.exclude` cannot take them away. */
53
+ export const KIT_CORE: readonly string[] = ['orchestrate', 'start']
54
+
55
+ /** A kit skill or agent as the kit names it, before `skillPrefix`. */
56
+ const KIT_ENTRY_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/
57
+
49
58
  const FULL_MODEL_ID = /^claude-[a-z0-9][a-z0-9.-]*$/
50
59
 
51
60
  const language = z.enum(Object.values(PLAN_LANGUAGE))
@@ -56,6 +65,13 @@ const modelId = z
56
65
 
57
66
  const command = z.string().min(1)
58
67
 
68
+ const excludedKitEntry = z
69
+ .string()
70
+ .regex(KIT_ENTRY_NAME, 'a kit skill or agent name as the kit names it, e.g. devops')
71
+ .refine((name) => !KIT_CORE.includes(name), {
72
+ error: (issue) => `${issue.input} is the kit's core and cannot be excluded`,
73
+ })
74
+
59
75
  const tokenCount = z.number().int().positive()
60
76
 
61
77
  /**
@@ -360,6 +376,12 @@ const mergeSchema = z.strictObject({
360
376
  * `--match-head-commit`. Empty means every merge waits for checks.
361
377
  */
362
378
  withoutChecks: z.array(checkFreePattern).default([]),
379
+ /**
380
+ * Whether a lane opens every pull request as a draft (`gh pr create --draft`), so pushes while it is worked on start
381
+ * no workflow that skips drafts. `/orchestrate` runs `gh pr ready` before the merge and waits for the checks that
382
+ * start then; the merge guard never lets a draft through. Off opens pull requests ready, as before.
383
+ */
384
+ openAsDraft: z.boolean().default(false),
363
385
  })
364
386
 
365
387
  const guardFields = z.strictObject({
@@ -480,6 +502,11 @@ export const jackherdConfigSchema = z.strictObject({
480
502
  .union([z.literal(''), z.string().regex(SKILL_PREFIX, 'lower-case letters and digits ending in a dash, e.g. kit-')])
481
503
  .default(''),
482
504
  commands: commandsSchema.prefault({}),
505
+ /**
506
+ * Kit skills and agents `jh sync` leaves out, by the kit's name without `skillPrefix`; a name takes its skill and
507
+ * its agent alike. An unchanged copy already installed is removed, a changed one stays untracked.
508
+ */
509
+ kit: z.strictObject({ exclude: z.array(excludedKitEntry).default([]) }).prefault({}),
483
510
  /** How `/orchestrate` sets up a lane's worktree. */
484
511
  lanes: z
485
512
  .strictObject({
@@ -1,3 +1,4 @@
1
+ import { LOG_ENTRY_TYPE } from './log-vocabulary'
1
2
  import { keptLogEntry } from './session-log'
2
3
 
3
4
  /** How the context window of a model is assumed: by model ID, with a default for the models not listed. */
@@ -26,7 +27,7 @@ const count = (value: unknown): number => (typeof value === 'number' && Number.i
26
27
  /** The answer of a main-chain `assistant` line; null for every other line, a sub-agent's answer and a synthetic one. */
27
28
  const answerOf = (line: string): Answer | null => {
28
29
  const entry = keptLogEntry(line)
29
- if (!entry || entry.type !== 'assistant' || entry.isSidechain === true) return null
30
+ if (!entry || entry.type !== LOG_ENTRY_TYPE.assistant || entry.isSidechain === true) return null
30
31
  const { message } = entry
31
32
  if (!isRecord(message) || typeof message.model !== 'string' || message.model === SYNTHETIC_MODEL) return null
32
33
  const { usage } = message
@@ -0,0 +1,22 @@
1
+ /** The entry types of a Claude Code session log line that a reader tells apart; the log delivers them as strings. */
2
+ export const LOG_ENTRY_TYPE = { assistant: 'assistant' } as const
3
+
4
+ /** The block types inside an entry's `message.content`. */
5
+ export const CONTENT_BLOCK_TYPE = { text: 'text', toolUse: 'tool_use', toolResult: 'tool_result' } as const
6
+
7
+ /** The names of Claude Code's tools a reader looks for in a `tool_use` block. */
8
+ export const CLAUDE_TOOL = {
9
+ taskCreate: 'TaskCreate',
10
+ taskUpdate: 'TaskUpdate',
11
+ todoWrite: 'TodoWrite',
12
+ edit: 'Edit',
13
+ write: 'Write',
14
+ multiEdit: 'MultiEdit',
15
+ notebookEdit: 'NotebookEdit',
16
+ } as const
17
+
18
+ /**
19
+ * What every assistant entry's raw line holds, as the log writes it without spaces: a text search for it finds the
20
+ * answers of a log without parsing every line.
21
+ */
22
+ export const ASSISTANT_ENTRY_MARK = `"type":"${LOG_ENTRY_TYPE.assistant}"`
@@ -4,6 +4,7 @@ import { join } from 'node:path'
4
4
  import { CLAUDE_DIRECTORY, SESSION_LOGS_DIRECTORY } from '../claude/paths'
5
5
  import { boundedMemo } from '../shared/bounded-memo'
6
6
  import { isRecord } from '../shared/is-record'
7
+ import { ASSISTANT_ENTRY_MARK } from './log-vocabulary'
7
8
 
8
9
  /** A tail read before: the file as it was (inode, size, modification time), its lines and the byte offset each starts at. */
9
10
  type FollowedTail = { inode: number; size: number; modifiedAt: number; lines: string[]; starts: number[] }
@@ -159,7 +160,7 @@ export const sessionLog = (
159
160
 
160
161
  export type AgentSession = { session: string | null; cwd: string | null }
161
162
 
162
- const hasAnswer = (lines: readonly string[]): boolean => lines.some((line) => line.includes('"type":"assistant"'))
163
+ const hasAnswer = (lines: readonly string[]): boolean => lines.some((line) => line.includes(ASSISTANT_ENTRY_MARK))
163
164
 
164
165
  /** Claude Code names a project directory after the working directory, `/` and `.` turned into `-`. */
165
166
  export const projectDirectoryOf = (cwd: string): string => cwd.replace(/[/.]/g, '-')
@@ -19,6 +19,8 @@ export type LanePromptInput = {
19
19
  plans: LanePlan[]
20
20
  port: number | null
21
21
  handover: string | null
22
+ /** The project's `skillPrefix` (`''` for none), put before the name of the kit's skill the start names. */
23
+ skillPrefix: string
22
24
  }
23
25
 
24
26
  const writesText = (language: PlanLanguage, texts: LanePromptTexts, writes: string[] | null): string => {
@@ -35,11 +37,12 @@ const planLines = (language: PlanLanguage, texts: LanePromptTexts, plan: LanePla
35
37
  ...plan.phases.map((phase) => ` - ${phaseText(language, texts, phase)}`),
36
38
  ]
37
39
 
38
- const ruleLines = (input: LanePromptInput, texts: LanePromptTexts): string[] => [
39
- input.port === null ? texts.noPort : texts.port(input.port),
40
+ const ruleLines = (input: LanePromptInput, texts: LanePromptTexts, implementSkill: string): string[] => [
41
+ input.port === null ? texts.noPort : texts.port(input.port, implementSkill),
40
42
  texts.blocking,
41
43
  texts.mustNot,
42
44
  texts.commit,
45
+ texts.taskSignals,
43
46
  texts.report(laneReportFile(input.lane)),
44
47
  texts.textBlock,
45
48
  texts.secret,
@@ -51,11 +54,13 @@ const ruleLines = (input: LanePromptInput, texts: LanePromptTexts): string[] =>
51
54
  /**
52
55
  * The prompt that starts a lane, in the plan language: a first line that names the lane (the words the ask hint hook
53
56
  * and the project's lane exception recognise), the plans with their phases as `Phase P<n>` and each phase's write
54
- * set, the rules of `/orchestrate` § 1 that `/implement` does not carry already, and the start: `/implement`, or the
57
+ * set, the rules of `/orchestrate` § 1 and the lane contract (report each task, the report file, questions as text blocks,
58
+ * git through the coordinator), and the start: the phases built with `/implement` or another skill, or the
55
59
  * hand-over file after a restart.
56
60
  */
57
61
  export const buildLanePrompt = (input: LanePromptInput): string => {
58
62
  const texts = LANE_PROMPT_TEXTS[input.language]
63
+ const implementSkill = `/${input.skillPrefix}implement`
59
64
  return [
60
65
  `${LANE_PROMPT_OPENING[input.language]} ${input.lane}. ${texts.answersTo}`,
61
66
  '',
@@ -66,9 +71,9 @@ export const buildLanePrompt = (input: LanePromptInput): string => {
66
71
  texts.editInside,
67
72
  texts.writeTools,
68
73
  '',
69
- ...ruleLines(input, texts).map((line) => `- ${line}`),
74
+ ...ruleLines(input, texts, implementSkill).map((line) => `- ${line}`),
70
75
  '',
71
- input.handover === null ? texts.start : texts.restart(input.handover),
76
+ input.handover === null ? texts.start(implementSkill) : texts.restart(input.handover),
72
77
  '',
73
78
  ].join('\n')
74
79
  }