jackherd 1.15.2 → 1.17.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 (161) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +26 -22
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +438 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  40. package/kit/rules/rules/kit/code-style.md +6 -6
  41. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  42. package/kit/workflow/skills/follow/SKILL.md +18 -11
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
  45. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  46. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  47. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  48. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  49. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  50. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  51. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  52. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  53. package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
  54. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  55. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  56. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  57. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  58. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  59. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  60. package/kit/workflow/skills/plan/SKILL.md +3 -2
  61. package/package.json +3 -1
  62. package/recipes/README.md +4 -2
  63. package/recipes/google-calendar.md +30 -0
  64. package/recipes/jira-cli.md +1 -1
  65. package/recipes/sources/google-calendar-today.ts +38 -0
  66. package/recipes/sources/google-calendar.ts +368 -0
  67. package/recipes/sources/jira.ts +10 -2
  68. package/recipes/sources/linear.ts +9 -3
  69. package/recipes/sources/sentry.ts +8 -2
  70. package/recipes/sources.md +17 -3
  71. package/schemas/jackherd.schema.json +67 -0
  72. package/schemas/sources.schema.json +9 -0
  73. package/src/approval/checked.ts +60 -0
  74. package/src/approval/command.ts +16 -0
  75. package/src/check/command.ts +51 -6
  76. package/src/check/lint-pointers.ts +79 -0
  77. package/src/check/lint-shaped.ts +196 -0
  78. package/src/check/plan-structure-check.ts +116 -4
  79. package/src/check/report.ts +9 -3
  80. package/src/check/schema.ts +37 -0
  81. package/src/check/settings.ts +4 -0
  82. package/src/check/skill-budget-defaults.ts +81 -23
  83. package/src/check/skill-budgets.ts +58 -10
  84. package/src/cli.ts +2 -0
  85. package/src/config/personal-sources.ts +46 -10
  86. package/src/config/repository-identity.ts +40 -0
  87. package/src/config/schema.ts +53 -1
  88. package/src/context/command.ts +33 -2
  89. package/src/context/options.ts +33 -8
  90. package/src/context/usage-logs.ts +53 -0
  91. package/src/context/usage.ts +110 -0
  92. package/src/lane/base-command.ts +35 -0
  93. package/src/lane/base.ts +43 -0
  94. package/src/lane/close-check.ts +49 -15
  95. package/src/lane/close-command.ts +71 -11
  96. package/src/lane/close-options.ts +29 -8
  97. package/src/lane/close-survey.ts +13 -1
  98. package/src/lane/close.ts +51 -12
  99. package/src/lane/command.ts +21 -3
  100. package/src/lane/send-command.ts +32 -0
  101. package/src/lane/send-herdr.ts +77 -0
  102. package/src/lane/send-options.ts +23 -0
  103. package/src/lane/send.ts +90 -0
  104. package/src/lane/skill-hint.ts +27 -0
  105. package/src/lane/space-command.ts +30 -0
  106. package/src/lane/space.ts +49 -0
  107. package/src/lane/start-lane-hint.ts +18 -0
  108. package/src/plan-contract/base-check-git.ts +33 -0
  109. package/src/plan-contract/base-check.ts +17 -0
  110. package/src/plan-contract/command.ts +25 -4
  111. package/src/plan-format/plan-structure.ts +17 -5
  112. package/src/plans/ask-coordinator.ts +23 -13
  113. package/src/plans/board-rows.ts +9 -4
  114. package/src/plans/click-hint.ts +4 -0
  115. package/src/plans/command.ts +6 -5
  116. package/src/plans/ensure-panes.ts +14 -3
  117. package/src/plans/git.ts +14 -0
  118. package/src/plans/github.ts +19 -10
  119. package/src/plans/layout.ts +191 -25
  120. package/src/plans/live.ts +57 -14
  121. package/src/plans/plan-panes.ts +21 -4
  122. package/src/plans/row-actions.ts +17 -10
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/plans/snapshot.ts +8 -3
  126. package/src/plans/source-rows.ts +35 -2
  127. package/src/resume/command.ts +213 -0
  128. package/src/resume/decision.ts +102 -0
  129. package/src/resume/keeper.ts +65 -0
  130. package/src/resume/location.ts +22 -0
  131. package/src/resume/log.ts +27 -0
  132. package/src/resume/ports.ts +102 -0
  133. package/src/resume/record.ts +116 -0
  134. package/src/resume/targets.ts +84 -0
  135. package/src/resume/texts.ts +27 -0
  136. package/src/secrets/command.ts +15 -5
  137. package/src/sync/herdr-plugin.ts +2 -11
  138. package/src/sync/plan-ask-rules.ts +11 -0
  139. package/src/sync/render.ts +3 -1
  140. package/src/vocabulary/index.ts +13 -0
  141. package/src/waiting/command.ts +13 -4
  142. package/src/watch/command.ts +177 -78
  143. package/src/watch/config-reload.ts +119 -0
  144. package/src/watch/lane-model.ts +11 -1
  145. package/src/watch/lane-progress.ts +70 -8
  146. package/src/watch/lane-report.ts +68 -12
  147. package/src/watch/main-checkout.ts +5 -1
  148. package/src/watch/merge-duties.ts +33 -0
  149. package/src/watch/merge-overlap-read.ts +69 -0
  150. package/src/watch/plan-views.ts +16 -4
  151. package/src/watch/reference-paths.ts +46 -5
  152. package/src/watch/report-questions.ts +5 -2
  153. package/src/watch/single-report.ts +3 -2
  154. package/src/watch/source-dry-run-command.ts +22 -4
  155. package/src/watch/source-dry-run.ts +1 -1
  156. package/src/watch/source-reason.ts +58 -0
  157. package/src/watch/source-reload.ts +21 -17
  158. package/src/watch/source-run.ts +24 -7
  159. package/src/watch/sources.ts +9 -2
  160. package/src/watch/texts.ts +15 -4
  161. package/src/watch/waiting-line.ts +4 -3
@@ -68,6 +68,30 @@ const publicContent = z.strictObject({
68
68
  /** A budget in words per path or glob from the project root; a key joining two paths with ` + ` budgets them together. */
69
69
  const skillBudgets = z.record(glob, z.number().int().positive())
70
70
 
71
+ /** What `jh check` does with open boxes in the phase a lane builds: fail, warn or leave the check out. */
72
+ export const OPEN_BOXES_LEVEL = { fail: 'fail', warn: 'warn', off: 'off' } as const
73
+
74
+ export type OpenBoxesLevel = (typeof OPEN_BOXES_LEVEL)[keyof typeof OPEN_BOXES_LEVEL]
75
+
76
+ /** What `jh check` does with a lint-shaped rule block that names no lint rule: warn (the default), fail or leave the check out. */
77
+ export const LINT_POINTERS_LEVEL = { warn: 'warn', fail: 'fail', off: 'off' } as const
78
+
79
+ export type LintPointersLevel = (typeof LINT_POINTERS_LEVEL)[keyof typeof LINT_POINTERS_LEVEL]
80
+
81
+ const lintPointers = z.strictObject({
82
+ /** `warn` reports a block and lets the commit through; `fail` stops it; `off` skips the check. */
83
+ level: z
84
+ .enum([LINT_POINTERS_LEVEL.warn, LINT_POINTERS_LEVEL.fail, LINT_POINTERS_LEVEL.off])
85
+ .default(LINT_POINTERS_LEVEL.warn),
86
+ /** Rule files left unread, as globs from the project root; the kit copies in the lock file are always left out. */
87
+ exclude: z.array(glob).default([]),
88
+ })
89
+
90
+ export type LintPointersConfig = z.infer<typeof lintPointers>
91
+
92
+ /** The check without a `lintPointers` block: warn, nothing excluded. */
93
+ export const DEFAULT_LINT_POINTERS: LintPointersConfig = { level: LINT_POINTERS_LEVEL.warn, exclude: [] }
94
+
71
95
  /**
72
96
  * The `check` block of `.jackherd/config.json`. Test and type commands come from the top-level
73
97
  * `commands` block; this block holds what only the check runner reads.
@@ -89,6 +113,19 @@ export const checkConfigSchema = z.strictObject({
89
113
  * path and sets one for the project's own skills; a file with none is measured against the kit's default.
90
114
  */
91
115
  skillBudgets: skillBudgets.optional(),
116
+ /**
117
+ * What a lane's check does when the phase its board row names still has open boxes without `→ P<n>`: `fail` (the
118
+ * default) stops the commit, `warn` reports, `off` skips. A row carrying the `partialPhase` word (`(Teil)` in German) builds part of the phase and is
119
+ * left alone; a branch whose directory is no lane's is not checked.
120
+ */
121
+ openBoxes: z
122
+ .enum([OPEN_BOXES_LEVEL.fail, OPEN_BOXES_LEVEL.warn, OPEN_BOXES_LEVEL.off])
123
+ .default(OPEN_BOXES_LEVEL.fail),
124
+ /**
125
+ * Whether a new or changed block of a rule file (or `CLAUDE.md`, `AGENTS.md`) that a linter could check, such
126
+ * as a forbidden import path or call, must name its lint rule with `` `lint:<rule id>` `` (or `` `lint:none` `` and a reason).
127
+ */
128
+ lintPointers: lintPointers.optional(),
92
129
  /** Also run tests in directories that hold test files but belong to no package. */
93
130
  looseTests: z.boolean().default(false),
94
131
  /**
@@ -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
+ /** Where the boards live: the directories under `planDirectory` and the file in each. */
29
+ board: { pattern: string; file: string }
28
30
  /** Put before the kit's skill and agent names in this project; empty without one. */
29
31
  skillPrefix: string
30
32
  /** What the lock files (without one, the manifests) decide: one stack, none, or several (ambiguous); `stackOf` reads the stack from it. */
@@ -45,6 +47,7 @@ const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
45
47
  const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
46
48
  const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
47
49
  const skillPrefixSchema = jackherdConfigSchema.shape.skillPrefix
50
+ const boardSchema = jackherdConfigSchema.shape.board
48
51
 
49
52
  /**
50
53
  * The check runner's view of `.jackherd/config.json`: the shared `commands` block, `planDirectory`, `planLanguage` and `baseBranch` plus its own
@@ -62,6 +65,7 @@ export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckS
62
65
  planLanguage: planLanguageSchema.parse(object.planLanguage),
63
66
  baseBranch: baseBranchSchema.parse(object.baseBranch),
64
67
  worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
68
+ board: boardSchema.parse(object.board),
65
69
  skillPrefix: skillPrefixSchema.parse(object.skillPrefix),
66
70
  stack: detectStack(facts.lockFiles, facts.manifests),
67
71
  notes: detected.notes,
@@ -8,44 +8,61 @@ export const KIT_FILE_FALLBACK_BUDGET = 2000
8
8
  export const SUPPLEMENT_BUDGET = 800
9
9
 
10
10
  /**
11
- * The kit's word budgets, by path from the project root. A key joining two paths with ` + ` budgets what is
12
- * loaded together. A budget is the measured size plus at most 10 %, so a file has room for a sentence, not for a
13
- * section; a file that needs more justifies the growth where it raises the number.
11
+ * The kit's word targets, by path from the project root: the size a file should have, not the size it has. A key
12
+ * joining two paths with ` + ` targets what is loaded together. A file above its target carries a ceiling in
13
+ * `KIT_SKILL_CEILINGS`; growth never moves a target.
14
14
  */
15
- export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
15
+ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
16
16
  '.claude/skills/devops/SKILL.md': 321,
17
17
  '.claude/skills/diagnose/SKILL.md': 1521,
18
18
  '.claude/skills/diagnose/references/feedback-loops.md': 636,
19
- '.claude/skills/follow/SKILL.md': 1100,
19
+ // Personal sources: where the source lives, the secret step that keeps the value out of the agent, the personal
20
+ // file and its script path, what a reload prints (+295); skill-review: a personal source stores its secret and is
21
+ // dry-run by name after the entry is written, since a draft runs without the keychain (+103).
22
+ '.claude/skills/follow/SKILL.md': 1498,
20
23
  '.claude/skills/grill/SKILL.md': 685,
21
24
  '.claude/skills/implement/SKILL.md': 2190,
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
- // Plain words: no plan code without its words in a short question.
24
- // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
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,
25
+ '.claude/skills/orchestrate/SKILL.md': 4500,
31
26
  // skill-review: the copy is taken after a mirror, and rejecting together with a remark is a remark (+36).
32
27
  // Cross-check: an adopted plan also gets short names and a no-review-gate line, which `jh check` asks of a new
33
28
  // plan; the proof became a word diff, since a short name goes into an existing line (+74); skill-review: the
34
29
  // done check names `jh check`, the gate and short-name rules as the code reads them (+24).
35
30
  '.claude/skills/orchestrate/references/adopt-plan.md': 680,
36
31
  '.claude/skills/orchestrate/references/acceptance.md': 200,
37
- '.claude/skills/orchestrate/references/close-lane.md': 250,
38
- '.claude/skills/orchestrate/references/coordinator-handover.md': 551,
32
+ // Core cut: the `due` row of the wake-up table moved here (+33).
33
+ '.claude/skills/orchestrate/references/appointments.md': 290,
34
+ // Resume: `--branch` finishes a lane whose worktree is gone, a detached worktree on the base closes, a closed lane
35
+ // is reported (+41). The ask rule is the one question: the coordinator asks no dialog before it (+15).
36
+ '.claude/skills/orchestrate/references/close-lane.md': 309,
37
+ '.claude/skills/orchestrate/references/coordinator-handover.md': 567,
39
38
  '.claude/skills/orchestrate/references/draft-review.md': 300,
40
39
  // P33: a source entry the user must read or decide becomes a row of what waits on them, and when it closes.
41
- '.claude/skills/orchestrate/references/events.md': 1166,
40
+ // Core cut: the `deploy`, `stalled` and lane `warn` rows of the wake-up table moved here (+101), and the checks
41
+ // after `Nothing changed.` from `jh check` (+70 as rendered), and the board patch when `main checkout behind`
42
+ // cannot fast-forward (+60). skill-review: `lane removed`, `no longer listed`, `did not stop` and
43
+ // `herd --ensure failed`, whose lines the watcher already points here (+86).
44
+ '.claude/skills/orchestrate/references/events.md': 1491,
42
45
  '.claude/skills/orchestrate/references/herd.md': 299,
43
- '.claude/skills/orchestrate/references/lane-handover.md': 280,
44
- '.claude/skills/orchestrate/references/lane-session.md': 280,
46
+ '.claude/skills/orchestrate/references/herd-ask.md': 152,
47
+ // Core cut: the request on `lane alert` moved here (+56); ending a lane names `herdr agent prompt <lane> /exit`,
48
+ // the form the merge guard passes (+23).
49
+ '.claude/skills/orchestrate/references/lane-handover.md': 360,
50
+ // Ending the agent names `herdr agent prompt <lane> /exit`, the form the merge guard passes (+25).
51
+ '.claude/skills/orchestrate/references/lane-session.md': 285,
45
52
  '.claude/skills/orchestrate/references/lane-setup.md': 380,
46
53
  // 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,
54
+ // Core cut: the question of § 2 step 0 (the adoption's diff round, the approval it is, `<topic>`, a no) (+60).
55
+ '.claude/skills/orchestrate/references/plan-commit.md': 412,
56
+ // Core cut: § 5 self-merge steps 1 to 6, moved out of the core.
57
+ '.claude/skills/orchestrate/references/self-merge.md': 379,
48
58
  '.claude/skills/orchestrate/references/sensitive-input.md': 269,
59
+ '.claude/skills/orchestrate/references/stage-approval.md': 634,
60
+ '.claude/skills/orchestrate/references/standing-approval.md': 458,
61
+ // Core cut: § 2 steps 0 to 8 and § 3 dev server, moved out of the core.
62
+ '.claude/skills/orchestrate/references/start-lane.md': 712,
63
+ // Core cut: § 4 waiting rows beyond the basic one, moved out of the core.
64
+ '.claude/skills/orchestrate/references/waiting.md': 216,
65
+ '.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
49
66
  '.claude/skills/plan/SKILL.md': 1920,
50
67
  '.claude/skills/plan/references/template-English.md': 536,
51
68
  '.claude/skills/plan/references/template-German.md': 501,
@@ -55,8 +72,7 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
55
72
  // Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
56
73
  '.claude/skills/start/SKILL.md': 630,
57
74
  '.claude/skills/uncle-bob/SKILL.md': 466,
58
- // P33 and open up: grows with the skill above.
59
- '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8880,
75
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 5100,
60
76
  '.claude/agents/devops.md': 104,
61
77
  '.claude/agents/explore.md': 132,
62
78
  '.claude/agents/implement.md': 303,
@@ -66,8 +82,50 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
66
82
  '.claude/agents/skill-review.md': 468,
67
83
  '.claude/agents/spec-review.md': 1137,
68
84
  '.claude/agents/uncle-bob.md': 111,
69
- '.claude/rules/kit/code-style.md': 1664,
85
+ // Lint before prose: the vocabulary section and two one-liners cut, a sentence on `lint:` pointers added (-79).
86
+ '.claude/rules/kit/code-style.md': 1585,
70
87
  // Drafts until the merge: a precondition opens a draft when `merge.openAsDraft` says so (+39).
71
88
  '.claude/rules/kit/pull-requests.md': 640,
72
89
  '.claude/rules/kit/testing.md': 741,
73
90
  }
91
+
92
+ /**
93
+ * The most a file above its target may have, today's size. A ceiling only falls: the plan that cuts the file lowers
94
+ * or deletes it, and a rise needs a reason in the comment above the entry (a test holds every entry to one).
95
+ */
96
+ export const KIT_SKILL_CEILINGS: Readonly<Record<string, number>> = {
97
+ // Orchestrate core plan P1: the reviewer also judges the whole length of a file against its target (+12).
98
+ // Lint before prose: lens 7 for lint-shaped prose and the sentence on the `Lint pointers` result (+33).
99
+ '.claude/agents/skill-review.md': 513,
100
+ // Lint before prose: the quality check that asks of every written rule whether the linter can check it (+44).
101
+ '.claude/skills/plan/SKILL.md': 1964,
102
+ // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
103
+ // Plain words: no plan code without its words in a short question.
104
+ // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
105
+ // Open up: a pointer in § 0 to adopt-plan.md, and § 2 step 8 waits until the lane works on its prompt (+113);
106
+ // step 0 lets the adoption diff stand for its question, step 2 bases on origin only for an unchanged plan.
107
+ // Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
108
+ // Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
109
+ // Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
110
+ // Ready round: a PR of the lane waiting for approval or merge holds only the commit; the self-merge command runs
111
+ // alone, so the guard can check it (+132, the longest rendering of the kit's renderings); skill-review: the commit
112
+ // of a PR awaiting approval stages only its files, a branch over a squash-merged PR moves with `--onto` (+51).
113
+ // Change id: the PR question names the staged change's patch id, which the merge guard compares; skill-review:
114
+ // staging first and the worktree named (+55, to cut again).
115
+ // Bundled board lines: written into the main checkout at once, carried to the base bundled, and the fast-forward
116
+ // that keeps them (+115).
117
+ // Audit P6: commands and hooks carry rules the prose repeated; monitorless watching, appointments, the standing
118
+ // approval and the herd's ask line moved to references. Cut to the measured size plus 5 %.
119
+ // Orchestrate core plan P1: the target is 4500. P5 cut the core to its longest rendering; the rest of the cut is
120
+ // a follow-up, decided by the user after the P6 runs.
121
+ '.claude/skills/orchestrate/SKILL.md': 5938,
122
+ // P33 and open up: grows with the skill above (+132 for the ready round and the lone merge call); measured with
123
+ // this repository's supplement after the skill-review fixes (+153); click merge grows with the skill (+30); change
124
+ // id grows with the skill, staging first and the worktree named after skill-review (+55). Audit P6: cut with the
125
+ // skill, the supplement down to its deviations; measured size plus 5 %.
126
+ // Orchestrate core plan P1: the target is 5100; P5 lowered the ceiling with the skill's to the rendered sum.
127
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 6635,
128
+ }
129
+
130
+ /** What `jh check` measures against: a file's ceiling where it has one, else its target. */
131
+ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = { ...KIT_SKILL_TARGETS, ...KIT_SKILL_CEILINGS }
@@ -11,6 +11,7 @@ import type { SkillBudgets } from './schema'
11
11
  import {
12
12
  KIT_FILE_FALLBACK_BUDGET,
13
13
  KIT_SKILL_BUDGETS,
14
+ KIT_SKILL_TARGETS,
14
15
  OWN_SKILL_BUDGET,
15
16
  SUPPLEMENT_BUDGET,
16
17
  } from './skill-budget-defaults'
@@ -21,8 +22,18 @@ export type BudgetTable = Readonly<SkillBudgets>
21
22
  /** A file with the budget it falls back on when neither the project nor the kit names one. */
22
23
  export type FileWords = { path: string; words: number; fallback: number; foreign?: boolean }
23
24
 
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 }
25
+ /**
26
+ * A `foreign` measurement belongs to a skill `skills-lock.json` lists: it warns, it never fails. `target` is the
27
+ * kit's target for the path, `projectBudget` is the project's own entry for the path.
28
+ */
29
+ export type Measurement = {
30
+ label: string
31
+ words: number
32
+ budget: number
33
+ foreign?: boolean
34
+ target?: number
35
+ projectBudget?: number
36
+ }
26
37
 
27
38
  type Overrun = typeof CHECK_STATUS.warn | typeof CHECK_STATUS.fail
28
39
 
@@ -63,12 +74,16 @@ export const overrunOf = (words: number, budget: number): Overrun | null => {
63
74
  return words > budget ? CHECK_STATUS.warn : null
64
75
  }
65
76
 
66
- /** The project's entry for the exact path, else its first glob that matches, else the kit's default, else `fallback`. */
67
- export const budgetOf = (path: string, project: BudgetTable, defaults: BudgetTable, fallback: number): number => {
77
+ /** The project's entry for the exact path, else its first glob that matches; undefined when it names neither. */
78
+ const projectBudgetOf = (path: string, project: BudgetTable): number | undefined => {
68
79
  const glob = Object.keys(project).find((key) => isGlob(key) && matchesGlob(path, key))
69
- return project[path] ?? (glob === undefined ? undefined : project[glob]) ?? defaults[path] ?? fallback
80
+ return project[path] ?? (glob === undefined ? undefined : project[glob])
70
81
  }
71
82
 
83
+ /** The project's entry for the exact path, else its first glob that matches, else the kit's default, else `fallback`. */
84
+ export const budgetOf = (path: string, project: BudgetTable, defaults: BudgetTable, fallback: number): number =>
85
+ projectBudgetOf(path, project) ?? defaults[path] ?? fallback
86
+
72
87
  /** Every budget path starts with `.claude/` (the kit-content test holds them to shipped files). */
73
88
  const prefixedPath = (path: string, prefix: string): string =>
74
89
  claudePath(prefixedKitPath(path.slice(CLAUDE_DIRECTORY.length + 1), prefix))
@@ -91,23 +106,39 @@ const sumEntry = (key: string, files: readonly FileWords[]): number | null => {
91
106
  return parts.every((part) => part !== undefined) ? parts.reduce((total, part) => total + part.words, 0) : null
92
107
  }
93
108
 
94
- /** Every file against its budget, then every sum entry of the project and the kit whose files all exist. */
109
+ /**
110
+ * Every file against its budget, then every sum entry of the project and the kit whose files all exist. `targets`
111
+ * are the kit's targets by path; a measurement carries its own when the kit names one.
112
+ */
95
113
  export const measureFiles = (
96
114
  files: readonly FileWords[],
97
115
  project: BudgetTable,
98
116
  defaults: BudgetTable,
117
+ targets: BudgetTable = {},
99
118
  ): Measurement[] => {
100
119
  const single = files.map(({ path, words, fallback, foreign }) => ({
101
120
  label: foreign ? `${path} ${FOREIGN_LABEL}` : path,
102
121
  words,
103
122
  budget: budgetOf(path, project, defaults, fallback),
104
123
  foreign,
124
+ target: targets[path],
125
+ projectBudget: projectBudgetOf(path, project),
105
126
  }))
106
127
  const sums = [...new Set([...Object.keys(project), ...Object.keys(defaults)])]
107
128
  .filter((key) => key.includes(SUM_SEPARATOR))
108
129
  .flatMap((key) => {
109
130
  const words = sumEntry(key, files)
110
- return words === null ? [] : [{ label: key, words, budget: budgetOf(key, project, defaults, 0) }]
131
+ return words === null
132
+ ? []
133
+ : [
134
+ {
135
+ label: key,
136
+ words,
137
+ budget: budgetOf(key, project, defaults, 0),
138
+ target: targets[key],
139
+ projectBudget: projectBudgetOf(key, project),
140
+ },
141
+ ]
111
142
  })
112
143
  return [...single, ...sums]
113
144
  }
@@ -128,17 +159,33 @@ const statusOf = (overruns: readonly MeasuredOverrun[]): CheckStatus => {
128
159
  return overruns.length > 0 ? CHECK_STATUS.warn : CHECK_STATUS.pass
129
160
  }
130
161
 
131
- /** One line per overrun; one line in total when none is over. Warns above a budget, fails above the budget plus 10 %. */
162
+ /** A file within the kit's ceiling but above the kit's target for it: a hint, never a failure. A project budget has its note instead. */
163
+ const targetHint = ({ label, words, budget, target, projectBudget }: Measurement): string[] =>
164
+ target !== undefined && projectBudget === undefined && words > target && budget > target
165
+ ? [`hint: ${label} is over target by ${words - target} words (target ${target}, ceiling ${budget})`]
166
+ : []
167
+
168
+ /** A budget the project set above the kit's target for the path: shown, so the deviation stays visible. */
169
+ const overrideNote = ({ label, target, projectBudget }: Measurement): string[] =>
170
+ target !== undefined && projectBudget !== undefined && projectBudget > target
171
+ ? [`note: ${label} budget ${projectBudget} comes from the project, above the kit target ${target}`]
172
+ : []
173
+
174
+ /**
175
+ * One line per overrun; one line in total when none is over. Warns above a budget, fails above the budget plus 10 %.
176
+ * Lines for a file over its target and for a project budget above the kit target follow, and change no status.
177
+ */
132
178
  export const budgetResult = (measured: readonly Measurement[], durationMs: number): CheckResult => {
133
179
  const overruns = measured.flatMap((entry) => {
134
180
  const overrun = overrunOfMeasurement(entry)
135
181
  return overrun === null ? [] : [{ entry, overrun }]
136
182
  })
183
+ const lines = overruns.length > 0 ? overruns.map(overrunLine) : [withinLine(measured.length)]
137
184
  return {
138
185
  name: 'Skill budgets',
139
186
  command: `${measured.length} measured, words per budget`,
140
187
  status: statusOf(overruns),
141
- detail: overruns.map(overrunLine).join('\n') || withinLine(measured.length),
188
+ detail: [...lines, ...measured.flatMap(targetHint), ...measured.flatMap(overrideNote)].join('\n'),
142
189
  durationMs,
143
190
  }
144
191
  }
@@ -211,5 +258,6 @@ export const skillBudgetCheck = (root: string, project: BudgetTable = {}, skillP
211
258
  ...ownSkillPaths(root, kit).flatMap((path) => fileWordsOf(root, path, OWN_SKILL_BUDGET)),
212
259
  ].map(markForeign(foreign))
213
260
  const defaults = prefixedBudgets(KIT_SKILL_BUDGETS, skillPrefix)
214
- return files.length === 0 ? null : budgetResult(measureFiles(files, project, defaults), Date.now() - started)
261
+ const targets = prefixedBudgets(KIT_SKILL_TARGETS, skillPrefix)
262
+ return files.length === 0 ? null : budgetResult(measureFiles(files, project, defaults, targets), Date.now() - started)
215
263
  }
package/src/cli.ts CHANGED
@@ -18,6 +18,7 @@ const ALIASES = new Set<string>([HERD_COMMAND.alias])
18
18
 
19
19
  /** One line per command; each entry lazily imports the module that exports its `run`. */
20
20
  export const COMMANDS: CommandTable = {
21
+ approval: () => import('./approval/command'),
21
22
  check: () => import('./check/command'),
22
23
  config: () => import('./config/command'),
23
24
  context: () => import('./context/command'),
@@ -28,6 +29,7 @@ export const COMMANDS: CommandTable = {
28
29
  mermaid: () => import('./mermaid/command'),
29
30
  plan: () => import('./plan-contract/command'),
30
31
  preview: () => import('./preview/command'),
32
+ resume: () => import('./resume/command'),
31
33
  secret: () => import('./secrets/command'),
32
34
  settled: () => import('./settled/command'),
33
35
  sync: () => import('./sync/command'),
@@ -4,40 +4,68 @@ import { errorMessage } from '../sync/error-message'
4
4
  import { SOURCE_ORIGIN, type SourceOrigin, type SourceSettings } from '../watch/sources'
5
5
  import { personalSourcesPath } from './location'
6
6
  import { readResolvedConfig } from './read-config'
7
+ import { isRepositoryIn } from './repository-identity'
7
8
  import { parsePersonalSourcesFile } from './schema'
8
9
 
9
- export type MergedSources = { sources: SourceSettings[]; notes: string[] }
10
+ /** Why a personal source with `repos` does not run in this repository. */
11
+ export const EXCLUDED_REASON = { otherRepository: 'not for this repo', noOrigin: 'no origin' } as const
12
+
13
+ export type ExcludedReason = (typeof EXCLUDED_REASON)[keyof typeof EXCLUDED_REASON]
14
+
15
+ /** A personal source left out because of its `repos`, kept so `jh secret list` and the dry run can still name it. */
16
+ export type ExcludedSource = { source: SourceSettings; reason: ExcludedReason }
17
+
18
+ export type MergedSources = { sources: SourceSettings[]; notes: string[]; excluded: ExcludedSource[] }
10
19
 
11
20
  /** What a note starts with when an invalid personal file was left out. */
12
21
  export const PERSONAL_IGNORED = 'personal sources ignored:'
13
22
 
14
23
  const skippedPrefix = (name: string): string => `personal source ${name} in `
15
24
 
25
+ const excludedPrefix = (name: string): string => `${name}: `
26
+
16
27
  const skippedNote = (name: string, path: string): string =>
17
28
  `${skippedPrefix(name)}${path} skipped: the repository has a source of that name`
18
29
 
19
- /** Whether a merge note concerns the source `name`: the personal source of that name was skipped, or the file was ignored. */
30
+ /** The note for a source left out for its `repos`: `<name>: not for this repo` or `<name>: no origin`. */
31
+ export const excludedNote = ({ source, reason }: ExcludedSource): string => `${excludedPrefix(source.name)}${reason}`
32
+
33
+ /** Whether a merge note concerns the source `name`: the personal source of that name was skipped or left out for its `repos`, or the file was ignored. */
20
34
  export const isNoteAbout = (note: string, name: string): boolean =>
21
- note.startsWith(skippedPrefix(name)) || note.startsWith(PERSONAL_IGNORED)
35
+ note.startsWith(skippedPrefix(name)) || note.startsWith(excludedPrefix(name)) || note.startsWith(PERSONAL_IGNORED)
22
36
 
23
37
  const withOrigin = (sources: readonly SourceSettings[], origin: SourceOrigin): SourceSettings[] =>
24
38
  sources.map((source) => ({ ...source, origin }))
25
39
 
40
+ const exclusionOf = (source: SourceSettings, repository: string | null): ExcludedReason | null => {
41
+ if (source.repos === undefined || isRepositoryIn(repository, source.repos)) return null
42
+ return repository === null ? EXCLUDED_REASON.noOrigin : EXCLUDED_REASON.otherRepository
43
+ }
44
+
26
45
  /**
27
46
  * The repository's sources, then the personal ones, each marked with its origin. A personal source whose name a
28
- * repository source has is left out and named in a note; `path` is how the notes spell the personal file.
47
+ * repository source has is left out and named in a note, and so is one whose `repos` do not list `repository`
48
+ * (`owner/name` of this checkout's origin, null without one); `path` is how the notes spell the personal file.
29
49
  */
30
50
  export const mergedSources = (
31
51
  repo: readonly SourceSettings[],
32
52
  personal: readonly SourceSettings[],
33
53
  path: string,
54
+ repository: string | null,
34
55
  ): MergedSources => {
35
56
  const repoNames = new Set(repo.map(({ name }) => name))
36
- const kept = personal.filter(({ name }) => !repoNames.has(name))
57
+ const unclashed = personal.filter(({ name }) => !repoNames.has(name))
37
58
  const skipped = personal.filter(({ name }) => repoNames.has(name))
59
+ const excluded = withOrigin(unclashed, SOURCE_ORIGIN.personal).flatMap((source): ExcludedSource[] => {
60
+ const reason = exclusionOf(source, repository)
61
+ return reason === null ? [] : [{ source, reason }]
62
+ })
63
+ const left = new Set(excluded.map(({ source }) => source.name))
64
+ const kept = unclashed.filter(({ name }) => !left.has(name))
38
65
  return {
39
66
  sources: [...withOrigin(repo, SOURCE_ORIGIN.repo), ...withOrigin(kept, SOURCE_ORIGIN.personal)],
40
- notes: skipped.map(({ name }) => skippedNote(name, path)),
67
+ notes: [...skipped.map(({ name }) => skippedNote(name, path)), ...excluded.map(excludedNote)],
68
+ excluded,
41
69
  }
42
70
  }
43
71
 
@@ -64,14 +92,19 @@ export const readPersonalSources = (
64
92
  */
65
93
  export const sourcesWithPersonal = (
66
94
  repo: readonly SourceSettings[],
95
+ repository: string | null,
67
96
  home: string = homedir(),
68
97
  exists: (path: string) => boolean = existsSync,
69
98
  readText?: (path: string) => string,
70
99
  ): MergedSources => {
71
100
  try {
72
- return mergedSources(repo, readPersonalSources(home, exists, readText), personalSourcesPath(home))
101
+ return mergedSources(repo, readPersonalSources(home, exists, readText), personalSourcesPath(home), repository)
73
102
  } catch (error) {
74
- return { sources: withOrigin(repo, SOURCE_ORIGIN.repo), notes: [`${PERSONAL_IGNORED} ${errorMessage(error)}`] }
103
+ return {
104
+ sources: withOrigin(repo, SOURCE_ORIGIN.repo),
105
+ notes: [`${PERSONAL_IGNORED} ${errorMessage(error)}`],
106
+ excluded: [],
107
+ }
75
108
  }
76
109
  }
77
110
 
@@ -79,5 +112,8 @@ export const sourcesWithPersonal = (
79
112
  * The sources of the project config of the first of `roots` that has one (`readResolvedConfig`), with the personal
80
113
  * ones of `home` merged in; throws like `readResolvedConfig` when the project config is missing or invalid.
81
114
  */
82
- export const configuredSources = (roots: string[], home: string = homedir()): MergedSources =>
83
- sourcesWithPersonal(readResolvedConfig(roots, home).config.watch.sources, home)
115
+ export const configuredSources = (
116
+ roots: string[],
117
+ repository: string | null,
118
+ home: string = homedir(),
119
+ ): MergedSources => sourcesWithPersonal(readResolvedConfig(roots, home).config.watch.sources, repository, home)
@@ -0,0 +1,40 @@
1
+ /** `owner/name` as `repos` spells a repository. */
2
+ export const REPOSITORY_NAME = /^[\w.-]+\/[\w.-]+$/
3
+
4
+ const SCP_REMOTE = /^[\w.-]+@[\w.-]+:([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/
5
+
6
+ /** `owner/name` of a URL path with exactly those two segments; a deeper or shorter path names no repository of ours. */
7
+ const ownerAndName = (path: string): string | null => {
8
+ const segments = path
9
+ .replace(/\.git\/?$/, '')
10
+ .split('/')
11
+ .filter((segment) => segment !== '')
12
+ return segments.length === 2 ? segments.join('/') : null
13
+ }
14
+
15
+ /**
16
+ * `owner/name` of a git remote URL: `git@host:owner/name(.git)`, `https://host/owner/name(.git)` (credentials
17
+ * allowed) and `ssh://git@host(:port)/owner/name(.git)`. Null for anything else: a local path, a `file://` URL, a
18
+ * path with more or fewer than two segments.
19
+ */
20
+ export const repositoryOf = (remoteUrl: string): string | null => {
21
+ const remote = remoteUrl.trim()
22
+ const scp = SCP_REMOTE.exec(remote)
23
+ if (scp) return `${scp[1]}/${scp[2]}`
24
+ try {
25
+ const url = new URL(remote)
26
+ return ['https:', 'http:', 'ssh:'].includes(url.protocol) ? ownerAndName(url.pathname) : null
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
32
+ /** Whether `repository` is one of `repos`; GitHub names compare without regard to case. A repository nobody could name is in none. */
33
+ export const isRepositoryIn = (repository: string | null, repos: readonly string[]): boolean =>
34
+ repository !== null && repos.some((repo) => repo.toLowerCase() === repository.toLowerCase())
35
+
36
+ /** The repository the checkout at `root` pushes to (`origin`), null without a git repository, an origin or a URL `repositoryOf` knows. */
37
+ export const readRepositoryIdentity = (root: string): string | null => {
38
+ const result = Bun.spawnSync(['git', '-C', root, 'remote', 'get-url', 'origin'], { stdout: 'pipe', stderr: 'ignore' })
39
+ return result.exitCode === 0 ? repositoryOf(result.stdout.toString()) : null
40
+ }
@@ -12,12 +12,16 @@ import { DEFAULT_BASE_BRANCH } from './base-branch'
12
12
  import { DEFAULT_LINK_KINDS, isRegularExpression } from './link-kinds'
13
13
  import { hasNestedQuantifier } from './nested-quantifier'
14
14
  import { isRepositoryDirectory } from './repository-directory'
15
+ import { REPOSITORY_NAME } from './repository-identity'
15
16
  import { DEFAULT_TONES, TONE_NAMES } from './tones'
16
17
 
17
18
  export const TERMINAL_ADAPTER = { none: 'none', herdr: 'herdr' } as const
18
19
 
19
20
  export type TerminalAdapter = (typeof TERMINAL_ADAPTER)[keyof typeof TERMINAL_ADAPTER]
20
21
 
22
+ /** The environments a standing approval may cover; production is never one. */
23
+ export const STANDING_ENVIRONMENT = { stage: 'stage' } as const
24
+
21
25
  // TODO: draft role list taken from the agents the kit ships; confirm it before more kit files render model
22
26
  // IDs per role.
23
27
  export const MODEL_ROLE = {
@@ -302,6 +306,16 @@ const sourceSchema = z
302
306
  .optional(),
303
307
  /** `entries` shows the source's entries open, in a herd block of its own; not for a ticket source, whose entries are badges. */
304
308
  show: z.literal(SOURCE_SHOW.entries).optional(),
309
+ /**
310
+ * `owner/name` of the repositories a personal source runs in (matched without regard to case); without it every
311
+ * repository. Only in the personal sources file: the repository's own config is for that repository already.
312
+ */
313
+ repos: z
314
+ .array(z.string().regex(REPOSITORY_NAME, 'owner/name, such as acme/tool'))
315
+ .min(1)
316
+ .max(20)
317
+ .refine((repos) => new Set(repos.map((repo) => repo.toLowerCase())).size === repos.length, 'repos must be unique')
318
+ .optional(),
305
319
  })
306
320
  .refine(canChange, {
307
321
  message: 'map needs updatedAt, a content field (title, author, body, url) or reportOn',
@@ -327,6 +341,22 @@ const sourceSchema = z
327
341
  const hasUniqueNames = (sources: readonly { name: string }[]): boolean =>
328
342
  new Set(sources.map(({ name }) => name)).size === sources.length
329
343
 
344
+ /** The sources of the repository's own config: none may name `repos`, which only a personal source has. */
345
+ const repositorySources = z
346
+ .array(sourceSchema)
347
+ .refine(hasUniqueNames, 'source names must be unique')
348
+ .superRefine((sources, context) => {
349
+ sources.forEach((source, index) => {
350
+ if (source.repos === undefined) return
351
+ context.addIssue({
352
+ code: 'custom',
353
+ path: [index, 'repos'],
354
+ message:
355
+ 'repos belongs in the personal sources file (~/.jackherd/sources.json): this config is for one repository',
356
+ })
357
+ })
358
+ })
359
+
330
360
  const coordinatorSchema = z
331
361
  .strictObject({
332
362
  /** Tokens in the coordinator's context from which the watcher reports `coordinator warn`. */
@@ -336,6 +366,18 @@ const coordinatorSchema = z
336
366
  })
337
367
  .refine(warnBelowAlert, WARN_BELOW_ALERT)
338
368
 
369
+ /**
370
+ * An SQL tool of an MCP server by its full name (`mcp__<server>__execute_sql`), never one whose name says `prod`. The
371
+ * approval gate hook applies the same rule (`kit/hooks/hooks/approval-gate/settings.ts`), which cannot import this.
372
+ */
373
+ const standingTool = z
374
+ .string()
375
+ .regex(
376
+ /^mcp__[A-Za-z0-9_-]+__execute_sql$/,
377
+ 'a full MCP tool name ending in execute_sql, such as mcp__<server>__execute_sql',
378
+ )
379
+ .refine((name) => !/prod/i.test(name), 'a tool whose name says prod never runs under a standing approval')
380
+
339
381
  /** A guarded command as words: a program and one to three plain words after it, never an option, a quote or a shell character. */
340
382
  const guardedCommand = z
341
383
  .string()
@@ -420,6 +462,14 @@ const guardFields = z.strictObject({
420
462
  .strictObject({
421
463
  /** How long the approval gate hook holds a lane's call for the coordinator's dialog before it denies it; below the hook's 10 minutes. */
422
464
  waitMinutes: z.number().int().min(1).max(9).default(8),
465
+ /**
466
+ * The calls a standing approval of the user may let through once the coordinator marked them checked: each tool
467
+ * by its full name and the environment it writes to, only `stage`. A tool whose name says `prod` is refused, as the
468
+ * hook refuses it under a broken config; which project a server reaches is the project's start script's to say.
469
+ */
470
+ standing: z
471
+ .strictObject({ tools: z.record(standingTool, z.enum(Object.values(STANDING_ENVIRONMENT))) })
472
+ .optional(),
423
473
  })
424
474
  .prefault({}),
425
475
  /**
@@ -531,6 +581,8 @@ export const jackherdConfigSchema = z.strictObject({
531
581
  copy: z.array(laneCopyPath).default([]),
532
582
  /** The dependency lock file a rebase takes from the base branch and regenerates, never merges by hand; detected from the repository when left out. */
533
583
  lockFile: lockFilePath.optional(),
584
+ /** Free gigabytes the disk must hold before `jh lane space` lets a new lane install its dependencies; 5 when left out. */
585
+ minFreeGb: z.number().positive().optional(),
534
586
  })
535
587
  .prefault({}),
536
588
  models: z.record(z.enum(Object.values(MODEL_ROLE)), modelId).default(DEFAULT_MODELS),
@@ -582,7 +634,7 @@ export const jackherdConfigSchema = z.strictObject({
582
634
  parallelTasks: parallelTasksSchema.prefault({}),
583
635
  taskSignalMinutes: z.number().int().positive().default(DEFAULT_TASK_SIGNAL_MINUTES),
584
636
  previews: previewsSchema.optional(),
585
- sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
637
+ sources: repositorySources.default([]),
586
638
  })
587
639
  .prefault({}),
588
640
  /** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */