jackherd 1.15.2 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/INSTALL.md +2 -1
- package/README.md +26 -22
- 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 +438 -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 +1 -1
- package/kit/rules/rules/kit/code-style.md +6 -6
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +18 -11
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -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 +9 -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 +3 -2
- package/package.json +3 -1
- package/recipes/README.md +4 -2
- package/recipes/google-calendar.md +30 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +38 -0
- package/recipes/sources/google-calendar.ts +368 -0
- 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 +17 -3
- 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 +81 -23
- package/src/check/skill-budgets.ts +58 -10
- 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 +191 -25
- package/src/plans/live.ts +57 -14
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/row-actions.ts +17 -10
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/plans/snapshot.ts +8 -3
- package/src/plans/source-rows.ts +35 -2
- 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 +68 -12
- 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-dry-run.ts +1 -1
- 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 +15 -4
- package/src/watch/waiting-line.ts +4 -3
package/src/check/schema.ts
CHANGED
|
@@ -68,6 +68,30 @@ const publicContent = z.strictObject({
|
|
|
68
68
|
/** A budget in words per path or glob from the project root; a key joining two paths with ` + ` budgets them together. */
|
|
69
69
|
const skillBudgets = z.record(glob, z.number().int().positive())
|
|
70
70
|
|
|
71
|
+
/** What `jh check` does with open boxes in the phase a lane builds: fail, warn or leave the check out. */
|
|
72
|
+
export const OPEN_BOXES_LEVEL = { fail: 'fail', warn: 'warn', off: 'off' } as const
|
|
73
|
+
|
|
74
|
+
export type OpenBoxesLevel = (typeof OPEN_BOXES_LEVEL)[keyof typeof OPEN_BOXES_LEVEL]
|
|
75
|
+
|
|
76
|
+
/** What `jh check` does with a lint-shaped rule block that names no lint rule: warn (the default), fail or leave the check out. */
|
|
77
|
+
export const LINT_POINTERS_LEVEL = { warn: 'warn', fail: 'fail', off: 'off' } as const
|
|
78
|
+
|
|
79
|
+
export type LintPointersLevel = (typeof LINT_POINTERS_LEVEL)[keyof typeof LINT_POINTERS_LEVEL]
|
|
80
|
+
|
|
81
|
+
const lintPointers = z.strictObject({
|
|
82
|
+
/** `warn` reports a block and lets the commit through; `fail` stops it; `off` skips the check. */
|
|
83
|
+
level: z
|
|
84
|
+
.enum([LINT_POINTERS_LEVEL.warn, LINT_POINTERS_LEVEL.fail, LINT_POINTERS_LEVEL.off])
|
|
85
|
+
.default(LINT_POINTERS_LEVEL.warn),
|
|
86
|
+
/** Rule files left unread, as globs from the project root; the kit copies in the lock file are always left out. */
|
|
87
|
+
exclude: z.array(glob).default([]),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
export type LintPointersConfig = z.infer<typeof lintPointers>
|
|
91
|
+
|
|
92
|
+
/** The check without a `lintPointers` block: warn, nothing excluded. */
|
|
93
|
+
export const DEFAULT_LINT_POINTERS: LintPointersConfig = { level: LINT_POINTERS_LEVEL.warn, exclude: [] }
|
|
94
|
+
|
|
71
95
|
/**
|
|
72
96
|
* The `check` block of `.jackherd/config.json`. Test and type commands come from the top-level
|
|
73
97
|
* `commands` block; this block holds what only the check runner reads.
|
|
@@ -89,6 +113,19 @@ export const checkConfigSchema = z.strictObject({
|
|
|
89
113
|
* path and sets one for the project's own skills; a file with none is measured against the kit's default.
|
|
90
114
|
*/
|
|
91
115
|
skillBudgets: skillBudgets.optional(),
|
|
116
|
+
/**
|
|
117
|
+
* What a lane's check does when the phase its board row names still has open boxes without `→ P<n>`: `fail` (the
|
|
118
|
+
* default) stops the commit, `warn` reports, `off` skips. A row carrying the `partialPhase` word (`(Teil)` in German) builds part of the phase and is
|
|
119
|
+
* left alone; a branch whose directory is no lane's is not checked.
|
|
120
|
+
*/
|
|
121
|
+
openBoxes: z
|
|
122
|
+
.enum([OPEN_BOXES_LEVEL.fail, OPEN_BOXES_LEVEL.warn, OPEN_BOXES_LEVEL.off])
|
|
123
|
+
.default(OPEN_BOXES_LEVEL.fail),
|
|
124
|
+
/**
|
|
125
|
+
* Whether a new or changed block of a rule file (or `CLAUDE.md`, `AGENTS.md`) that a linter could check, such
|
|
126
|
+
* as a forbidden import path or call, must name its lint rule with `` `lint:<rule id>` `` (or `` `lint:none` `` and a reason).
|
|
127
|
+
*/
|
|
128
|
+
lintPointers: lintPointers.optional(),
|
|
92
129
|
/** Also run tests in directories that hold test files but belong to no package. */
|
|
93
130
|
looseTests: z.boolean().default(false),
|
|
94
131
|
/**
|
package/src/check/settings.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type CheckSettings = {
|
|
|
25
25
|
baseBranch: string
|
|
26
26
|
/** Where the lane worktrees live, below the main checkout: a board's plans are read there too. */
|
|
27
27
|
worktreeDirectory: string
|
|
28
|
+
/** Where the boards live: the directories under `planDirectory` and the file in each. */
|
|
29
|
+
board: { pattern: string; file: string }
|
|
28
30
|
/** Put before the kit's skill and agent names in this project; empty without one. */
|
|
29
31
|
skillPrefix: string
|
|
30
32
|
/** What the lock files (without one, the manifests) decide: one stack, none, or several (ambiguous); `stackOf` reads the stack from it. */
|
|
@@ -45,6 +47,7 @@ const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
|
|
|
45
47
|
const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
|
|
46
48
|
const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
|
|
47
49
|
const skillPrefixSchema = jackherdConfigSchema.shape.skillPrefix
|
|
50
|
+
const boardSchema = jackherdConfigSchema.shape.board
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
53
|
* The check runner's view of `.jackherd/config.json`: the shared `commands` block, `planDirectory`, `planLanguage` and `baseBranch` plus its own
|
|
@@ -62,6 +65,7 @@ export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckS
|
|
|
62
65
|
planLanguage: planLanguageSchema.parse(object.planLanguage),
|
|
63
66
|
baseBranch: baseBranchSchema.parse(object.baseBranch),
|
|
64
67
|
worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
|
|
68
|
+
board: boardSchema.parse(object.board),
|
|
65
69
|
skillPrefix: skillPrefixSchema.parse(object.skillPrefix),
|
|
66
70
|
stack: detectStack(facts.lockFiles, facts.manifests),
|
|
67
71
|
notes: detected.notes,
|
|
@@ -8,44 +8,61 @@ export const KIT_FILE_FALLBACK_BUDGET = 2000
|
|
|
8
8
|
export const SUPPLEMENT_BUDGET = 800
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* The kit's word
|
|
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,
|
|
19
|
-
|
|
19
|
+
// Personal sources: where the source lives, the secret step that keeps the value out of the agent, the personal
|
|
20
|
+
// file and its script path, what a reload prints (+295); skill-review: a personal source stores its secret and is
|
|
21
|
+
// dry-run by name after the entry is written, since a draft runs without the keychain (+103).
|
|
22
|
+
'.claude/skills/follow/SKILL.md': 1498,
|
|
20
23
|
'.claude/skills/grill/SKILL.md': 685,
|
|
21
24
|
'.claude/skills/implement/SKILL.md': 2190,
|
|
22
|
-
|
|
23
|
-
// Plain words: no plan code without its words in a short question.
|
|
24
|
-
// Any language: a pointer in § 0 and a section in events.md for `plan missing`.
|
|
25
|
-
// Open up: a pointer in § 0 to adopt-plan.md, and § 2 step 8 waits until the lane works on its prompt (+113);
|
|
26
|
-
// step 0 lets the adoption diff stand for its question, step 2 bases on origin only for an unchanged plan.
|
|
27
|
-
// Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
|
|
28
|
-
// Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
|
|
29
|
-
// Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
|
|
30
|
-
'.claude/skills/orchestrate/SKILL.md': 8230,
|
|
25
|
+
'.claude/skills/orchestrate/SKILL.md': 4500,
|
|
31
26
|
// skill-review: the copy is taken after a mirror, and rejecting together with a remark is a remark (+36).
|
|
32
27
|
// Cross-check: an adopted plan also gets short names and a no-review-gate line, which `jh check` asks of a new
|
|
33
28
|
// plan; the proof became a word diff, since a short name goes into an existing line (+74); skill-review: the
|
|
34
29
|
// done check names `jh check`, the gate and short-name rules as the code reads them (+24).
|
|
35
30
|
'.claude/skills/orchestrate/references/adopt-plan.md': 680,
|
|
36
31
|
'.claude/skills/orchestrate/references/acceptance.md': 200,
|
|
37
|
-
|
|
38
|
-
'.claude/skills/orchestrate/references/
|
|
32
|
+
// Core cut: the `due` row of the wake-up table moved here (+33).
|
|
33
|
+
'.claude/skills/orchestrate/references/appointments.md': 290,
|
|
34
|
+
// Resume: `--branch` finishes a lane whose worktree is gone, a detached worktree on the base closes, a closed lane
|
|
35
|
+
// is reported (+41). The ask rule is the one question: the coordinator asks no dialog before it (+15).
|
|
36
|
+
'.claude/skills/orchestrate/references/close-lane.md': 309,
|
|
37
|
+
'.claude/skills/orchestrate/references/coordinator-handover.md': 567,
|
|
39
38
|
'.claude/skills/orchestrate/references/draft-review.md': 300,
|
|
40
39
|
// P33: a source entry the user must read or decide becomes a row of what waits on them, and when it closes.
|
|
41
|
-
|
|
40
|
+
// Core cut: the `deploy`, `stalled` and lane `warn` rows of the wake-up table moved here (+101), and the checks
|
|
41
|
+
// after `Nothing changed.` from `jh check` (+70 as rendered), and the board patch when `main checkout behind`
|
|
42
|
+
// cannot fast-forward (+60). skill-review: `lane removed`, `no longer listed`, `did not stop` and
|
|
43
|
+
// `herd --ensure failed`, whose lines the watcher already points here (+86).
|
|
44
|
+
'.claude/skills/orchestrate/references/events.md': 1491,
|
|
42
45
|
'.claude/skills/orchestrate/references/herd.md': 299,
|
|
43
|
-
'.claude/skills/orchestrate/references/
|
|
44
|
-
|
|
46
|
+
'.claude/skills/orchestrate/references/herd-ask.md': 152,
|
|
47
|
+
// Core cut: the request on `lane alert` moved here (+56); ending a lane names `herdr agent prompt <lane> /exit`,
|
|
48
|
+
// the form the merge guard passes (+23).
|
|
49
|
+
'.claude/skills/orchestrate/references/lane-handover.md': 360,
|
|
50
|
+
// Ending the agent names `herdr agent prompt <lane> /exit`, the form the merge guard passes (+25).
|
|
51
|
+
'.claude/skills/orchestrate/references/lane-session.md': 285,
|
|
45
52
|
'.claude/skills/orchestrate/references/lane-setup.md': 380,
|
|
46
53
|
// Open up: an adopted plan is committed like a missing one, and a tracked copy is restored after the merge.
|
|
47
|
-
|
|
54
|
+
// Core cut: the question of § 2 step 0 (the adoption's diff round, the approval it is, `<topic>`, a no) (+60).
|
|
55
|
+
'.claude/skills/orchestrate/references/plan-commit.md': 412,
|
|
56
|
+
// Core cut: § 5 self-merge steps 1 to 6, moved out of the core.
|
|
57
|
+
'.claude/skills/orchestrate/references/self-merge.md': 379,
|
|
48
58
|
'.claude/skills/orchestrate/references/sensitive-input.md': 269,
|
|
59
|
+
'.claude/skills/orchestrate/references/stage-approval.md': 634,
|
|
60
|
+
'.claude/skills/orchestrate/references/standing-approval.md': 458,
|
|
61
|
+
// Core cut: § 2 steps 0 to 8 and § 3 dev server, moved out of the core.
|
|
62
|
+
'.claude/skills/orchestrate/references/start-lane.md': 712,
|
|
63
|
+
// Core cut: § 4 waiting rows beyond the basic one, moved out of the core.
|
|
64
|
+
'.claude/skills/orchestrate/references/waiting.md': 216,
|
|
65
|
+
'.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
|
|
49
66
|
'.claude/skills/plan/SKILL.md': 1920,
|
|
50
67
|
'.claude/skills/plan/references/template-English.md': 536,
|
|
51
68
|
'.claude/skills/plan/references/template-German.md': 501,
|
|
@@ -55,8 +72,7 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
|
|
|
55
72
|
// Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
|
|
56
73
|
'.claude/skills/start/SKILL.md': 630,
|
|
57
74
|
'.claude/skills/uncle-bob/SKILL.md': 466,
|
|
58
|
-
|
|
59
|
-
'.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8880,
|
|
75
|
+
'.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 5100,
|
|
60
76
|
'.claude/agents/devops.md': 104,
|
|
61
77
|
'.claude/agents/explore.md': 132,
|
|
62
78
|
'.claude/agents/implement.md': 303,
|
|
@@ -66,8 +82,50 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
|
|
|
66
82
|
'.claude/agents/skill-review.md': 468,
|
|
67
83
|
'.claude/agents/spec-review.md': 1137,
|
|
68
84
|
'.claude/agents/uncle-bob.md': 111,
|
|
69
|
-
|
|
85
|
+
// Lint before prose: the vocabulary section and two one-liners cut, a sentence on `lint:` pointers added (-79).
|
|
86
|
+
'.claude/rules/kit/code-style.md': 1585,
|
|
70
87
|
// Drafts until the merge: a precondition opens a draft when `merge.openAsDraft` says so (+39).
|
|
71
88
|
'.claude/rules/kit/pull-requests.md': 640,
|
|
72
89
|
'.claude/rules/kit/testing.md': 741,
|
|
73
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The most a file above its target may have, today's size. A ceiling only falls: the plan that cuts the file lowers
|
|
94
|
+
* or deletes it, and a rise needs a reason in the comment above the entry (a test holds every entry to one).
|
|
95
|
+
*/
|
|
96
|
+
export const KIT_SKILL_CEILINGS: Readonly<Record<string, number>> = {
|
|
97
|
+
// Orchestrate core plan P1: the reviewer also judges the whole length of a file against its target (+12).
|
|
98
|
+
// Lint before prose: lens 7 for lint-shaped prose and the sentence on the `Lint pointers` result (+33).
|
|
99
|
+
'.claude/agents/skill-review.md': 513,
|
|
100
|
+
// Lint before prose: the quality check that asks of every written rule whether the linter can check it (+44).
|
|
101
|
+
'.claude/skills/plan/SKILL.md': 1964,
|
|
102
|
+
// P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
|
|
103
|
+
// Plain words: no plan code without its words in a short question.
|
|
104
|
+
// Any language: a pointer in § 0 and a section in events.md for `plan missing`.
|
|
105
|
+
// Open up: a pointer in § 0 to adopt-plan.md, and § 2 step 8 waits until the lane works on its prompt (+113);
|
|
106
|
+
// step 0 lets the adoption diff stand for its question, step 2 bases on origin only for an unchanged plan.
|
|
107
|
+
// Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
|
|
108
|
+
// Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
|
|
109
|
+
// Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
|
|
110
|
+
// Ready round: a PR of the lane waiting for approval or merge holds only the commit; the self-merge command runs
|
|
111
|
+
// alone, so the guard can check it (+132, the longest rendering of the kit's renderings); skill-review: the commit
|
|
112
|
+
// of a PR awaiting approval stages only its files, a branch over a squash-merged PR moves with `--onto` (+51).
|
|
113
|
+
// Change id: the PR question names the staged change's patch id, which the merge guard compares; skill-review:
|
|
114
|
+
// staging first and the worktree named (+55, to cut again).
|
|
115
|
+
// Bundled board lines: written into the main checkout at once, carried to the base bundled, and the fast-forward
|
|
116
|
+
// that keeps them (+115).
|
|
117
|
+
// Audit P6: commands and hooks carry rules the prose repeated; monitorless watching, appointments, the standing
|
|
118
|
+
// approval and the herd's ask line moved to references. Cut to the measured size plus 5 %.
|
|
119
|
+
// Orchestrate core plan P1: the target is 4500. P5 cut the core to its longest rendering; the rest of the cut is
|
|
120
|
+
// a follow-up, decided by the user after the P6 runs.
|
|
121
|
+
'.claude/skills/orchestrate/SKILL.md': 5938,
|
|
122
|
+
// P33 and open up: grows with the skill above (+132 for the ready round and the lone merge call); measured with
|
|
123
|
+
// this repository's supplement after the skill-review fixes (+153); click merge grows with the skill (+30); change
|
|
124
|
+
// id grows with the skill, staging first and the worktree named after skill-review (+55). Audit P6: cut with the
|
|
125
|
+
// skill, the supplement down to its deviations; measured size plus 5 %.
|
|
126
|
+
// Orchestrate core plan P1: the target is 5100; P5 lowered the ceiling with the skill's to the rendered sum.
|
|
127
|
+
'.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 6635,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What `jh check` measures against: a file's ceiling where it has one, else its target. */
|
|
131
|
+
export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = { ...KIT_SKILL_TARGETS, ...KIT_SKILL_CEILINGS }
|
|
@@ -11,6 +11,7 @@ import type { SkillBudgets } from './schema'
|
|
|
11
11
|
import {
|
|
12
12
|
KIT_FILE_FALLBACK_BUDGET,
|
|
13
13
|
KIT_SKILL_BUDGETS,
|
|
14
|
+
KIT_SKILL_TARGETS,
|
|
14
15
|
OWN_SKILL_BUDGET,
|
|
15
16
|
SUPPLEMENT_BUDGET,
|
|
16
17
|
} from './skill-budget-defaults'
|
|
@@ -21,8 +22,18 @@ export type BudgetTable = Readonly<SkillBudgets>
|
|
|
21
22
|
/** A file with the budget it falls back on when neither the project nor the kit names one. */
|
|
22
23
|
export type FileWords = { path: string; words: number; fallback: number; foreign?: boolean }
|
|
23
24
|
|
|
24
|
-
/**
|
|
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
|
}
|
|
@@ -128,17 +159,33 @@ const statusOf = (overruns: readonly MeasuredOverrun[]): CheckStatus => {
|
|
|
128
159
|
return overruns.length > 0 ? CHECK_STATUS.warn : CHECK_STATUS.pass
|
|
129
160
|
}
|
|
130
161
|
|
|
131
|
-
/**
|
|
162
|
+
/** A file within the kit's ceiling but above the kit's target for it: a hint, never a failure. A project budget has its note instead. */
|
|
163
|
+
const targetHint = ({ label, words, budget, target, projectBudget }: Measurement): string[] =>
|
|
164
|
+
target !== undefined && projectBudget === undefined && words > target && budget > target
|
|
165
|
+
? [`hint: ${label} is over target by ${words - target} words (target ${target}, ceiling ${budget})`]
|
|
166
|
+
: []
|
|
167
|
+
|
|
168
|
+
/** A budget the project set above the kit's target for the path: shown, so the deviation stays visible. */
|
|
169
|
+
const overrideNote = ({ label, target, projectBudget }: Measurement): string[] =>
|
|
170
|
+
target !== undefined && projectBudget !== undefined && projectBudget > target
|
|
171
|
+
? [`note: ${label} budget ${projectBudget} comes from the project, above the kit target ${target}`]
|
|
172
|
+
: []
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One line per overrun; one line in total when none is over. Warns above a budget, fails above the budget plus 10 %.
|
|
176
|
+
* Lines for a file over its target and for a project budget above the kit target follow, and change no status.
|
|
177
|
+
*/
|
|
132
178
|
export const budgetResult = (measured: readonly Measurement[], durationMs: number): CheckResult => {
|
|
133
179
|
const overruns = measured.flatMap((entry) => {
|
|
134
180
|
const overrun = overrunOfMeasurement(entry)
|
|
135
181
|
return overrun === null ? [] : [{ entry, overrun }]
|
|
136
182
|
})
|
|
183
|
+
const lines = overruns.length > 0 ? overruns.map(overrunLine) : [withinLine(measured.length)]
|
|
137
184
|
return {
|
|
138
185
|
name: 'Skill budgets',
|
|
139
186
|
command: `${measured.length} measured, words per budget`,
|
|
140
187
|
status: statusOf(overruns),
|
|
141
|
-
detail:
|
|
188
|
+
detail: [...lines, ...measured.flatMap(targetHint), ...measured.flatMap(overrideNote)].join('\n'),
|
|
142
189
|
durationMs,
|
|
143
190
|
}
|
|
144
191
|
}
|
|
@@ -211,5 +258,6 @@ export const skillBudgetCheck = (root: string, project: BudgetTable = {}, skillP
|
|
|
211
258
|
...ownSkillPaths(root, kit).flatMap((path) => fileWordsOf(root, path, OWN_SKILL_BUDGET)),
|
|
212
259
|
].map(markForeign(foreign))
|
|
213
260
|
const defaults = prefixedBudgets(KIT_SKILL_BUDGETS, skillPrefix)
|
|
214
|
-
|
|
261
|
+
const targets = prefixedBudgets(KIT_SKILL_TARGETS, skillPrefix)
|
|
262
|
+
return files.length === 0 ? null : budgetResult(measureFiles(files, project, defaults, targets), Date.now() - started)
|
|
215
263
|
}
|
package/src/cli.ts
CHANGED
|
@@ -18,6 +18,7 @@ const ALIASES = new Set<string>([HERD_COMMAND.alias])
|
|
|
18
18
|
|
|
19
19
|
/** One line per command; each entry lazily imports the module that exports its `run`. */
|
|
20
20
|
export const COMMANDS: CommandTable = {
|
|
21
|
+
approval: () => import('./approval/command'),
|
|
21
22
|
check: () => import('./check/command'),
|
|
22
23
|
config: () => import('./config/command'),
|
|
23
24
|
context: () => import('./context/command'),
|
|
@@ -28,6 +29,7 @@ export const COMMANDS: CommandTable = {
|
|
|
28
29
|
mermaid: () => import('./mermaid/command'),
|
|
29
30
|
plan: () => import('./plan-contract/command'),
|
|
30
31
|
preview: () => import('./preview/command'),
|
|
32
|
+
resume: () => import('./resume/command'),
|
|
31
33
|
secret: () => import('./secrets/command'),
|
|
32
34
|
settled: () => import('./settled/command'),
|
|
33
35
|
sync: () => import('./sync/command'),
|
|
@@ -4,40 +4,68 @@ import { errorMessage } from '../sync/error-message'
|
|
|
4
4
|
import { SOURCE_ORIGIN, type SourceOrigin, type SourceSettings } from '../watch/sources'
|
|
5
5
|
import { personalSourcesPath } from './location'
|
|
6
6
|
import { readResolvedConfig } from './read-config'
|
|
7
|
+
import { isRepositoryIn } from './repository-identity'
|
|
7
8
|
import { parsePersonalSourcesFile } from './schema'
|
|
8
9
|
|
|
9
|
-
|
|
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`. */
|