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.
- package/CHANGELOG.md +28 -0
- package/INSTALL.md +2 -1
- package/README.md +29 -33
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +16 -22
- package/kit/rules/rules/kit/code-style.md +26 -54
- package/kit/rules/rules/kit/pull-requests.md +17 -40
- package/kit/rules/rules/kit/testing.md +6 -12
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +2 -3
- package/kit/workflow/skills/implement/SKILL.md +28 -43
- package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +29 -48
- package/package.json +2 -1
- package/recipes/README.md +1 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +8 -2
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +4 -2
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +90 -32
- package/src/check/skill-budgets.ts +96 -18
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +82 -33
- package/src/plans/live.ts +8 -12
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +66 -11
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +14 -3
- 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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
|
|
44
|
-
'.claude/skills/orchestrate/references/
|
|
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
|
-
|
|
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/
|
|
50
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
'.claude/rules/kit/pull-requests.md':
|
|
79
|
-
'.claude/rules/kit/testing.md':
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
|
67
|
-
|
|
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])
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
132
|
-
|
|
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:
|
|
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,
|
|
200
|
-
*
|
|
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
|
|
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
|
-
]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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 {
|
|
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 = (
|
|
83
|
-
|
|
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
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -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:
|
|
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`. */
|