jackherd 1.16.0 → 1.18.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 (158) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +29 -33
  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 +423 -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 +16 -22
  40. package/kit/rules/rules/kit/code-style.md +26 -54
  41. package/kit/rules/rules/kit/pull-requests.md +17 -40
  42. package/kit/rules/rules/kit/testing.md +6 -12
  43. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  44. package/kit/workflow/skills/follow/SKILL.md +2 -3
  45. package/kit/workflow/skills/implement/SKILL.md +28 -43
  46. package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
  47. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  48. package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
  49. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  50. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  51. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  52. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  53. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  54. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  55. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  56. package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
  57. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  58. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  59. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  60. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  61. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  62. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  63. package/kit/workflow/skills/plan/SKILL.md +29 -48
  64. package/package.json +2 -1
  65. package/recipes/README.md +1 -0
  66. package/recipes/jira-cli.md +1 -1
  67. package/recipes/sources/google-calendar-today.ts +8 -2
  68. package/recipes/sources/jira.ts +10 -2
  69. package/recipes/sources/linear.ts +9 -3
  70. package/recipes/sources/sentry.ts +8 -2
  71. package/recipes/sources.md +4 -2
  72. package/schemas/jackherd.schema.json +67 -0
  73. package/schemas/sources.schema.json +9 -0
  74. package/src/approval/checked.ts +60 -0
  75. package/src/approval/command.ts +16 -0
  76. package/src/check/command.ts +51 -6
  77. package/src/check/lint-pointers.ts +79 -0
  78. package/src/check/lint-shaped.ts +196 -0
  79. package/src/check/plan-structure-check.ts +116 -4
  80. package/src/check/report.ts +9 -3
  81. package/src/check/schema.ts +37 -0
  82. package/src/check/settings.ts +4 -0
  83. package/src/check/skill-budget-defaults.ts +90 -32
  84. package/src/check/skill-budgets.ts +96 -18
  85. package/src/cli.ts +2 -0
  86. package/src/config/personal-sources.ts +46 -10
  87. package/src/config/repository-identity.ts +40 -0
  88. package/src/config/schema.ts +53 -1
  89. package/src/context/command.ts +33 -2
  90. package/src/context/options.ts +33 -8
  91. package/src/context/usage-logs.ts +53 -0
  92. package/src/context/usage.ts +110 -0
  93. package/src/lane/base-command.ts +35 -0
  94. package/src/lane/base.ts +43 -0
  95. package/src/lane/close-check.ts +49 -15
  96. package/src/lane/close-command.ts +71 -11
  97. package/src/lane/close-options.ts +29 -8
  98. package/src/lane/close-survey.ts +13 -1
  99. package/src/lane/close.ts +51 -12
  100. package/src/lane/command.ts +21 -3
  101. package/src/lane/send-command.ts +32 -0
  102. package/src/lane/send-herdr.ts +77 -0
  103. package/src/lane/send-options.ts +23 -0
  104. package/src/lane/send.ts +90 -0
  105. package/src/lane/skill-hint.ts +27 -0
  106. package/src/lane/space-command.ts +30 -0
  107. package/src/lane/space.ts +49 -0
  108. package/src/lane/start-lane-hint.ts +18 -0
  109. package/src/plan-contract/base-check-git.ts +33 -0
  110. package/src/plan-contract/base-check.ts +17 -0
  111. package/src/plan-contract/command.ts +25 -4
  112. package/src/plan-format/plan-structure.ts +17 -5
  113. package/src/plans/ask-coordinator.ts +23 -13
  114. package/src/plans/board-rows.ts +9 -4
  115. package/src/plans/click-hint.ts +4 -0
  116. package/src/plans/command.ts +6 -5
  117. package/src/plans/ensure-panes.ts +14 -3
  118. package/src/plans/git.ts +14 -0
  119. package/src/plans/github.ts +19 -10
  120. package/src/plans/layout.ts +82 -33
  121. package/src/plans/live.ts +8 -12
  122. package/src/plans/plan-panes.ts +21 -4
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/resume/command.ts +213 -0
  126. package/src/resume/decision.ts +102 -0
  127. package/src/resume/keeper.ts +65 -0
  128. package/src/resume/location.ts +22 -0
  129. package/src/resume/log.ts +27 -0
  130. package/src/resume/ports.ts +102 -0
  131. package/src/resume/record.ts +116 -0
  132. package/src/resume/targets.ts +84 -0
  133. package/src/resume/texts.ts +27 -0
  134. package/src/secrets/command.ts +15 -5
  135. package/src/sync/herdr-plugin.ts +2 -11
  136. package/src/sync/plan-ask-rules.ts +11 -0
  137. package/src/sync/render.ts +3 -1
  138. package/src/vocabulary/index.ts +13 -0
  139. package/src/waiting/command.ts +13 -4
  140. package/src/watch/command.ts +177 -78
  141. package/src/watch/config-reload.ts +119 -0
  142. package/src/watch/lane-model.ts +11 -1
  143. package/src/watch/lane-progress.ts +70 -8
  144. package/src/watch/lane-report.ts +66 -11
  145. package/src/watch/main-checkout.ts +5 -1
  146. package/src/watch/merge-duties.ts +33 -0
  147. package/src/watch/merge-overlap-read.ts +69 -0
  148. package/src/watch/plan-views.ts +16 -4
  149. package/src/watch/reference-paths.ts +46 -5
  150. package/src/watch/report-questions.ts +5 -2
  151. package/src/watch/single-report.ts +3 -2
  152. package/src/watch/source-dry-run-command.ts +22 -4
  153. package/src/watch/source-reason.ts +58 -0
  154. package/src/watch/source-reload.ts +21 -17
  155. package/src/watch/source-run.ts +24 -7
  156. package/src/watch/sources.ts +9 -2
  157. package/src/watch/texts.ts +14 -3
  158. package/src/watch/waiting-line.ts +4 -3
@@ -8,11 +8,11 @@ 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,
@@ -21,49 +21,64 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
21
21
  // dry-run by name after the entry is written, since a draft runs without the keychain (+103).
22
22
  '.claude/skills/follow/SKILL.md': 1498,
23
23
  '.claude/skills/grill/SKILL.md': 685,
24
- '.claude/skills/implement/SKILL.md': 2190,
25
- // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
26
- // Plain words: no plan code without its words in a short question.
27
- // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
28
- // Open up: a pointer in § 0 to adopt-plan.md, and § 2 step 8 waits until the lane works on its prompt (+113);
29
- // step 0 lets the adoption diff stand for its question, step 2 bases on origin only for an unchanged plan.
30
- // Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
31
- // Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
32
- // Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
33
- // Ready round: a PR of the lane waiting for approval or merge holds only the commit; the self-merge command runs
34
- // alone, so the guard can check it (+132, the longest rendering of the kit's renderings); skill-review: the commit
35
- // of a PR awaiting approval stages only its files, a branch over a squash-merged PR moves with `--onto` (+51).
36
- '.claude/skills/orchestrate/SKILL.md': 8413,
24
+ // Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
25
+ '.claude/skills/implement/SKILL.md': 1882,
26
+ '.claude/skills/orchestrate/SKILL.md': 4500,
27
+ // Prose cut, round 2: § 4 Approvals moved here from the core.
28
+ '.claude/skills/orchestrate/references/approvals.md': 266,
37
29
  // skill-review: the copy is taken after a mirror, and rejecting together with a remark is a remark (+36).
38
30
  // Cross-check: an adopted plan also gets short names and a no-review-gate line, which `jh check` asks of a new
39
31
  // plan; the proof became a word diff, since a short name goes into an existing line (+74); skill-review: the
40
32
  // done check names `jh check`, the gate and short-name rules as the code reads them (+24).
41
33
  '.claude/skills/orchestrate/references/adopt-plan.md': 680,
42
34
  '.claude/skills/orchestrate/references/acceptance.md': 200,
43
- '.claude/skills/orchestrate/references/close-lane.md': 250,
44
- '.claude/skills/orchestrate/references/coordinator-handover.md': 551,
35
+ // Core cut: the `due` row of the wake-up table moved here (+33).
36
+ '.claude/skills/orchestrate/references/appointments.md': 290,
37
+ // Resume: `--branch` finishes a lane whose worktree is gone, a detached worktree on the base closes, a closed lane
38
+ // is reported (+41). The ask rule is the one question: the coordinator asks no dialog before it (+15).
39
+ '.claude/skills/orchestrate/references/close-lane.md': 309,
40
+ '.claude/skills/orchestrate/references/coordinator-handover.md': 567,
45
41
  '.claude/skills/orchestrate/references/draft-review.md': 300,
46
42
  // P33: a source entry the user must read or decide becomes a row of what waits on them, and when it closes.
47
- '.claude/skills/orchestrate/references/events.md': 1166,
43
+ // Core cut: the `deploy`, `stalled` and lane `warn` rows of the wake-up table moved here (+101), and the checks
44
+ // after `Nothing changed.` from `jh check` (+70 as rendered), and the board patch when `main checkout behind`
45
+ // cannot fast-forward (+60). skill-review: `lane removed`, `no longer listed`, `did not stop` and
46
+ // `herd --ensure failed`, whose lines the watcher already points here (+86).
47
+ '.claude/skills/orchestrate/references/events.md': 1491,
48
48
  '.claude/skills/orchestrate/references/herd.md': 299,
49
- '.claude/skills/orchestrate/references/lane-handover.md': 280,
50
- '.claude/skills/orchestrate/references/lane-session.md': 280,
49
+ '.claude/skills/orchestrate/references/herd-ask.md': 152,
50
+ // Core cut: the request on `lane alert` moved here (+56); ending a lane names `herdr agent prompt <lane> /exit`,
51
+ // the form the merge guard passes (+23).
52
+ '.claude/skills/orchestrate/references/lane-handover.md': 360,
53
+ // Ending the agent names `herdr agent prompt <lane> /exit`, the form the merge guard passes (+25).
54
+ '.claude/skills/orchestrate/references/lane-session.md': 285,
51
55
  '.claude/skills/orchestrate/references/lane-setup.md': 380,
52
56
  // Open up: an adopted plan is committed like a missing one, and a tracked copy is restored after the merge.
53
- '.claude/skills/orchestrate/references/plan-commit.md': 350,
57
+ // Core cut: the question of § 2 step 0 (the adoption's diff round, the approval it is, `<topic>`, a no) (+60).
58
+ // Prose cut, round 2: the question itself moved here from § 2 step 0; measured size plus 5 %.
59
+ '.claude/skills/orchestrate/references/plan-commit.md': 475,
60
+ // Core cut: § 5 self-merge steps 1 to 6, moved out of the core.
61
+ '.claude/skills/orchestrate/references/self-merge.md': 379,
54
62
  '.claude/skills/orchestrate/references/sensitive-input.md': 269,
55
- '.claude/skills/plan/SKILL.md': 1920,
63
+ '.claude/skills/orchestrate/references/stage-approval.md': 634,
64
+ '.claude/skills/orchestrate/references/standing-approval.md': 458,
65
+ // Core cut: § 2 steps 0 to 8 and § 3 dev server, moved out of the core.
66
+ '.claude/skills/orchestrate/references/start-lane.md': 712,
67
+ // Core cut: § 4 waiting rows beyond the basic one, moved out of the core.
68
+ '.claude/skills/orchestrate/references/waiting.md': 216,
69
+ '.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
70
+ // Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
71
+ '.claude/skills/plan/SKILL.md': 1717,
56
72
  '.claude/skills/plan/references/template-English.md': 536,
57
73
  '.claude/skills/plan/references/template-German.md': 501,
58
- '.claude/skills/pre-commit/SKILL.md': 1003,
74
+ // Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
75
+ '.claude/skills/pre-commit/SKILL.md': 825,
59
76
  '.claude/skills/review/SKILL.md': 319,
60
77
  '.claude/skills/security-audit/SKILL.md': 519,
61
78
  // Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
62
79
  '.claude/skills/start/SKILL.md': 630,
63
80
  '.claude/skills/uncle-bob/SKILL.md': 466,
64
- // P33 and open up: grows with the skill above (+132 for the ready round and the lone merge call); measured with
65
- // this repository's supplement after the skill-review fixes (+153).
66
- '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 9165,
81
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 5100,
67
82
  '.claude/agents/devops.md': 104,
68
83
  '.claude/agents/explore.md': 132,
69
84
  '.claude/agents/implement.md': 303,
@@ -73,8 +88,51 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
73
88
  '.claude/agents/skill-review.md': 468,
74
89
  '.claude/agents/spec-review.md': 1137,
75
90
  '.claude/agents/uncle-bob.md': 111,
76
- '.claude/rules/kit/code-style.md': 1664,
77
- // Drafts until the merge: a precondition opens a draft when `merge.openAsDraft` says so (+39).
78
- '.claude/rules/kit/pull-requests.md': 640,
79
- '.claude/rules/kit/testing.md': 741,
91
+ // Prose cut, round 2: the three rules cut to their mandatory rules, measured size plus 5 %.
92
+ '.claude/rules/kit/code-style.md': 1290,
93
+ '.claude/rules/kit/pull-requests.md': 459,
94
+ '.claude/rules/kit/testing.md': 506,
80
95
  }
96
+
97
+ /**
98
+ * The most a file above its target may have, today's size. A ceiling only falls: the plan that cuts the file lowers
99
+ * or deletes it, and a rise needs a reason in the comment above the entry (a test holds every entry to one).
100
+ */
101
+ export const KIT_SKILL_CEILINGS: Readonly<Record<string, number>> = {
102
+ // Orchestrate core plan P1: the reviewer also judges the whole length of a file against its target (+12).
103
+ // Lint before prose: lens 7 for lint-shaped prose and the sentence on the `Lint pointers` result (+33).
104
+ '.claude/agents/skill-review.md': 513,
105
+ // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
106
+ // Plain words: no plan code without its words in a short question.
107
+ // Any language: a pointer in § 0 and a section in events.md for `plan missing`.
108
+ // Open up: a pointer in § 0 to adopt-plan.md, and § 2 step 8 waits until the lane works on its prompt (+113);
109
+ // step 0 lets the adoption diff stand for its question, step 2 bases on origin only for an unchanged plan.
110
+ // Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
111
+ // Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
112
+ // Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
113
+ // Ready round: a PR of the lane waiting for approval or merge holds only the commit; the self-merge command runs
114
+ // alone, so the guard can check it (+132, the longest rendering of the kit's renderings); skill-review: the commit
115
+ // of a PR awaiting approval stages only its files, a branch over a squash-merged PR moves with `--onto` (+51).
116
+ // Change id: the PR question names the staged change's patch id, which the merge guard compares; skill-review:
117
+ // staging first and the worktree named (+55, to cut again).
118
+ // Bundled board lines: written into the main checkout at once, carried to the base bundled, and the fast-forward
119
+ // that keeps them (+115).
120
+ // Audit P6: commands and hooks carry rules the prose repeated; monitorless watching, appointments, the standing
121
+ // approval and the herd's ask line moved to references. Cut to the measured size plus 5 %.
122
+ // Orchestrate core plan P1: the target is 4500. P5 cut the core to its longest rendering; the rest of the cut is
123
+ // a follow-up, decided by the user after the P6 runs.
124
+ // Prose cut, round 2: cut to the phrases the kit-content test holds word for word, § 4 Approvals moved to a
125
+ // reference; measured size plus 5 %. Then checked sentence by sentence (skill-review, uncle-bob): what a
126
+ // reference, a hook message or a watcher line already carries left the core; measured size plus 5 %.
127
+ '.claude/skills/orchestrate/SKILL.md': 5163,
128
+ // P33 and open up: grows with the skill above (+132 for the ready round and the lone merge call); measured with
129
+ // this repository's supplement after the skill-review fixes (+153); click merge grows with the skill (+30); change
130
+ // id grows with the skill, staging first and the worktree named after skill-review (+55). Audit P6: cut with the
131
+ // skill, the supplement down to its deviations; measured size plus 5 %.
132
+ // Orchestrate core plan P1: the target is 5100; P5 lowered the ceiling with the skill's to the rendered sum.
133
+ // Prose cut, round 2: lowered with the skill's, this repository's sum plus 5 %; again after the sentence check.
134
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 5611,
135
+ }
136
+
137
+ /** What `jh check` measures against: a file's ceiling where it has one, else its target. */
138
+ 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
  }
@@ -123,22 +154,46 @@ const overrunLine = ({ entry: { label, words, budget }, overrun }: MeasuredOverr
123
154
  const withinLine = (count: number): string =>
124
155
  count === 1 ? '1 file within its word budget' : `${count} files within their word budget`
125
156
 
126
- const statusOf = (overruns: readonly MeasuredOverrun[]): CheckStatus => {
157
+ const missingLine = (path: string): string => `warning: ${path} is named in check.skillBudgets but missing`
158
+
159
+ const statusOf = (overruns: readonly MeasuredOverrun[], missing: readonly string[]): CheckStatus => {
127
160
  if (overruns.some(({ overrun }) => overrun === CHECK_STATUS.fail)) return CHECK_STATUS.fail
128
- return overruns.length > 0 ? CHECK_STATUS.warn : CHECK_STATUS.pass
161
+ return overruns.length > 0 || missing.length > 0 ? CHECK_STATUS.warn : CHECK_STATUS.pass
129
162
  }
130
163
 
131
- /** One line per overrun; one line in total when none is over. Warns above a budget, fails above the budget plus 10 %. */
132
- export const budgetResult = (measured: readonly Measurement[], durationMs: number): CheckResult => {
164
+ /** 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. */
165
+ const targetHint = ({ label, words, budget, target, projectBudget }: Measurement): string[] =>
166
+ target !== undefined && projectBudget === undefined && words > target && budget > target
167
+ ? [`hint: ${label} is over target by ${words - target} words (target ${target}, ceiling ${budget})`]
168
+ : []
169
+
170
+ /** A budget the project set above the kit's target for the path: shown, so the deviation stays visible. */
171
+ const overrideNote = ({ label, target, projectBudget }: Measurement): string[] =>
172
+ target !== undefined && projectBudget !== undefined && projectBudget > target
173
+ ? [`note: ${label} budget ${projectBudget} comes from the project, above the kit target ${target}`]
174
+ : []
175
+
176
+ /**
177
+ * One line per overrun and per `missing` path the project budget names; one line in total when none is over. Warns
178
+ * above a budget and on a missing path, fails above the budget plus 10 %. Lines for a file over its target and for a
179
+ * project budget above the kit target follow, and change no status.
180
+ */
181
+ export const budgetResult = (
182
+ measured: readonly Measurement[],
183
+ durationMs: number,
184
+ missing: readonly string[] = [],
185
+ ): CheckResult => {
133
186
  const overruns = measured.flatMap((entry) => {
134
187
  const overrun = overrunOfMeasurement(entry)
135
188
  return overrun === null ? [] : [{ entry, overrun }]
136
189
  })
190
+ const problems = [...overruns.map(overrunLine), ...missing.map(missingLine)]
191
+ const lines = problems.length > 0 ? problems : [withinLine(measured.length)]
137
192
  return {
138
193
  name: 'Skill budgets',
139
194
  command: `${measured.length} measured, words per budget`,
140
- status: statusOf(overruns),
141
- detail: overruns.map(overrunLine).join('\n') || withinLine(measured.length),
195
+ status: statusOf(overruns, missing),
196
+ detail: [...lines, ...measured.flatMap(targetHint), ...measured.flatMap(overrideNote)].join('\n'),
142
197
  durationMs,
143
198
  }
144
199
  }
@@ -194,10 +249,28 @@ const supplementPaths = (root: string): string[] =>
194
249
  .filter((name) => name.endsWith('.md'))
195
250
  .map((name) => claudePath(SUPPLEMENTS_DIRECTORY, name))
196
251
 
252
+ /** The entries of the project budget that name one file by its exact path: no glob, no sum. */
253
+ const namedProjectFiles = (project: BudgetTable): [path: string, words: number][] =>
254
+ Object.entries(project).filter(([key]) => !isGlob(key) && !key.includes(SUM_SEPARATOR))
255
+
256
+ /** The files the project budget names that `found` does not hold yet, and the named paths that cannot be read. */
257
+ const namedFilesOf = (
258
+ root: string,
259
+ project: BudgetTable,
260
+ found: readonly FileWords[],
261
+ ): { files: FileWords[]; missing: string[] } => {
262
+ const measured = new Set(found.map(({ path }) => path))
263
+ const named = namedProjectFiles(project).filter(([path]) => !measured.has(path))
264
+ const files = named.flatMap(([path, words]) => fileWordsOf(root, path, words))
265
+ const missing = named.map(([path]) => path).filter((path) => !files.some((file) => file.path === path))
266
+ return { files, missing }
267
+ }
268
+
197
269
  /**
198
270
  * The installed kit skills with their references, kit agents and kit rules that load every session (the lock file
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.
271
+ * says which files the kit installed), the project's supplements and its own skills, and every other file the project
272
+ * budget names by its exact path, measured against the budgets; a named file that is missing warns. `skillPrefix` is
273
+ * the project's: the kit's budgets follow its skills and agents to their prefixed paths.
201
274
  * Null when there is nothing to measure.
202
275
  */
203
276
  export const skillBudgetCheck = (root: string, project: BudgetTable = {}, skillPrefix = ''): CheckResult | null => {
@@ -205,11 +278,16 @@ export const skillBudgetCheck = (root: string, project: BudgetTable = {}, skillP
205
278
  const kitPaths = kitPathsOf(root)
206
279
  const kit = new Set(kitPaths.map((key) => claudePath(key)))
207
280
  const foreign = readForeignSkills(root)
208
- const files = [
281
+ const found = [
209
282
  ...kitPaths.flatMap((key) => kitFileWordsOf(root, key)),
210
283
  ...supplementPaths(root).flatMap((path) => fileWordsOf(root, path, SUPPLEMENT_BUDGET)),
211
284
  ...ownSkillPaths(root, kit).flatMap((path) => fileWordsOf(root, path, OWN_SKILL_BUDGET)),
212
- ].map(markForeign(foreign))
285
+ ]
286
+ const named = namedFilesOf(root, project, found)
287
+ const files = [...found, ...named.files].map(markForeign(foreign))
213
288
  const defaults = prefixedBudgets(KIT_SKILL_BUDGETS, skillPrefix)
214
- return files.length === 0 ? null : budgetResult(measureFiles(files, project, defaults), Date.now() - started)
289
+ const targets = prefixedBudgets(KIT_SKILL_TARGETS, skillPrefix)
290
+ return files.length === 0 && named.missing.length === 0
291
+ ? null
292
+ : budgetResult(measureFiles(files, project, defaults, targets), Date.now() - started, named.missing)
215
293
  }
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`. */