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
package/dist/plan-format.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
PLAN_LANGUAGE,
|
|
9
9
|
PLAN_STATUS,
|
|
10
10
|
PLAN_VOCABULARY,
|
|
11
|
+
RECOMMENDED_MARK,
|
|
11
12
|
RESPONSE_ADDRESS,
|
|
12
13
|
SELF_MERGE_LABEL,
|
|
13
14
|
STANDING_APPROVAL_TEXTS,
|
|
@@ -17,7 +18,7 @@ import {
|
|
|
17
18
|
phaseHeading,
|
|
18
19
|
sectionHeading,
|
|
19
20
|
terminalSafe
|
|
20
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-N5FAOQZL.js";
|
|
21
22
|
|
|
22
23
|
// src/plan-format/lanes.ts
|
|
23
24
|
import { posix } from "path";
|
|
@@ -830,6 +831,10 @@ var mergedPhasesWithOpenBoxes = (markdown, base, language) => {
|
|
|
830
831
|
};
|
|
831
832
|
return phaseSections(markdown, language).filter(({ id, openLines }) => openLines.length > 0 && isMerged(id)).map(({ line, id, title, openLines }) => ({ line, id, title, openLines }));
|
|
832
833
|
};
|
|
834
|
+
var openBoxesOfPhase = (markdown, language, id) => {
|
|
835
|
+
const section = phaseSections(markdown, language).find((candidate) => candidate.id === id);
|
|
836
|
+
return section && section.openLines.length > 0 ? { line: section.line, id: section.id, title: section.title, openLines: section.openLines } : null;
|
|
837
|
+
};
|
|
833
838
|
var boardRowsWithoutPhase = (markdown, language, previousLines) => changedOpenLaneRows(markdown, language, previousLines).filter(
|
|
834
839
|
({ phases }) => phaseIdsOfCell(phases, language).length === 0
|
|
835
840
|
);
|
|
@@ -920,6 +925,7 @@ export {
|
|
|
920
925
|
PLAN_STATUS,
|
|
921
926
|
PLAN_VOCABULARY,
|
|
922
927
|
PROSE_REASON,
|
|
928
|
+
RECOMMENDED_MARK,
|
|
923
929
|
RESPONSE_ADDRESS,
|
|
924
930
|
SELF_MERGE_LABEL,
|
|
925
931
|
SHORT_NAME_LIMIT,
|
|
@@ -955,6 +961,7 @@ export {
|
|
|
955
961
|
misplacedDeferrals,
|
|
956
962
|
newPhasesWithoutReviewGate,
|
|
957
963
|
onlyDocumentationOpen,
|
|
964
|
+
openBoxesOfPhase,
|
|
958
965
|
parallelGroups,
|
|
959
966
|
parallelGroupsOfPlan,
|
|
960
967
|
parseDependencyTable,
|
package/docs/plan-contract.md
CHANGED
|
@@ -82,3 +82,4 @@ dependencies: missing no dependency table: phases run one after another
|
|
|
82
82
|
| 0 | the contract is met (a missing dependency table is a note, not a gap) |
|
|
83
83
|
| 1 | at least one part is missing or malformed |
|
|
84
84
|
| 2 | wrong command line: no subcommand, unknown subcommand, no file, unreadable file, unreadable config |
|
|
85
|
+
| 3 | the contract is met, but the plan or a board that links it is not on the base branch (a `base:` line names each path); the coordinator commits it first (`/orchestrate` § 2 step 0), after which the check counts as done |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
id = "crossdelta.jackherd"
|
|
2
2
|
name = "jackherd"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.18.0"
|
|
4
4
|
min_herdr_version = "0.8.2"
|
|
5
5
|
description = "Show the herd, the state of a board's lanes, in a herdr pane"
|
|
6
6
|
platforms = ["linux", "macos"]
|
|
@@ -20,3 +20,6 @@ command = ["bun", "open-plans.ts"]
|
|
|
20
20
|
|
|
21
21
|
[[startup]]
|
|
22
22
|
command = ["bun", "restore-plans.ts"]
|
|
23
|
+
|
|
24
|
+
[[startup]]
|
|
25
|
+
command = ["bun", "resume.ts"]
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
+
import { ENSURE_EXIT } from '../src/plans/plan-panes'
|
|
3
4
|
|
|
4
5
|
type Limits = { attempts: number; intervalMs: number }
|
|
5
6
|
|
|
7
|
+
/** What one `herd --ensure` left to do: nothing, wait for the coordinator's session, or try again. */
|
|
8
|
+
export const ENSURE_OUTCOME = { done: 'done', awaitsCoordinator: 'awaits-coordinator', failed: 'failed' } as const
|
|
9
|
+
type EnsureOutcome = (typeof ENSURE_OUTCOME)[keyof typeof ENSURE_OUTCOME]
|
|
10
|
+
|
|
6
11
|
/**
|
|
7
12
|
* Measured on herdr 0.9.0: the hook starts ~85 ms after the server, `pane process-info` answers
|
|
8
13
|
* `pane_not_found` until the terminals spawn at ~0.9 s, and the agents are back at ~3 s. The panes get 10 s, ten
|
|
@@ -16,32 +21,41 @@ export const RESTORE_LIMITS = {
|
|
|
16
21
|
coordinator: { attempts: 30, intervalMs: 2_000 },
|
|
17
22
|
} as const satisfies Record<string, Limits>
|
|
18
23
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Runs `attempt` until `isDone` accepts its value or the attempts are used up, waiting the interval between two of
|
|
26
|
+
* them; the value of the last attempt.
|
|
27
|
+
*/
|
|
28
|
+
export const retriedUntil = <T>(
|
|
29
|
+
attempt: () => T,
|
|
30
|
+
isDone: (value: T) => boolean,
|
|
31
|
+
limits: Limits,
|
|
32
|
+
sleep: (ms: number) => void,
|
|
33
|
+
): T => {
|
|
21
34
|
for (let tried = 1; ; tried++) {
|
|
22
|
-
|
|
23
|
-
if (tried >= limits.attempts) return
|
|
35
|
+
const value = attempt()
|
|
36
|
+
if (isDone(value) || tried >= limits.attempts) return value
|
|
24
37
|
sleep(limits.intervalMs)
|
|
25
38
|
}
|
|
26
39
|
}
|
|
27
40
|
|
|
41
|
+
/** Runs `attempt` until it works or the attempts are used up, waiting the interval between two of them. */
|
|
42
|
+
export const retried = (attempt: () => boolean, limits: Limits, sleep: (ms: number) => void): boolean =>
|
|
43
|
+
retriedUntil(attempt, (worked) => worked, limits, sleep)
|
|
44
|
+
|
|
28
45
|
/**
|
|
29
|
-
*
|
|
30
|
-
* returns the items
|
|
46
|
+
* Runs `attempt` for every pending item in one shared window of `limits` until it gives true for that item, and
|
|
47
|
+
* returns the items it never did.
|
|
31
48
|
*/
|
|
32
49
|
export const awaitEach = <T>(
|
|
33
50
|
items: readonly T[],
|
|
34
|
-
|
|
35
|
-
onReady: (item: T) => void,
|
|
51
|
+
attempt: (item: T) => boolean,
|
|
36
52
|
limits: Limits,
|
|
37
53
|
sleep: (ms: number) => void,
|
|
38
54
|
): T[] => {
|
|
39
55
|
let pending = [...items]
|
|
40
56
|
retried(
|
|
41
57
|
() => {
|
|
42
|
-
|
|
43
|
-
for (const item of ready) onReady(item)
|
|
44
|
-
pending = pending.filter((item) => !ready.includes(item))
|
|
58
|
+
pending = pending.filter((item) => !attempt(item))
|
|
45
59
|
return pending.length === 0
|
|
46
60
|
},
|
|
47
61
|
limits,
|
|
@@ -74,6 +88,16 @@ export const agentDirectoriesOf = (json: string, agentPanes: readonly string[]):
|
|
|
74
88
|
export const hasAgentIn = (root: string, paneList: string, agentPanes: readonly string[]): boolean =>
|
|
75
89
|
agentDirectoriesOf(paneList, agentPanes).includes(root)
|
|
76
90
|
|
|
91
|
+
/** The outcome of one `herd --ensure` from its exit status; null when it did not start. */
|
|
92
|
+
export const ensureOutcomeOf = (status: number | null): EnsureOutcome => {
|
|
93
|
+
if (status === ENSURE_EXIT.done) return ENSURE_OUTCOME.done
|
|
94
|
+
return status === ENSURE_EXIT.noCoordinator ? ENSURE_OUTCOME.awaitsCoordinator : ENSURE_OUTCOME.failed
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The repositories whose first `herd --ensure` did not find the coordinator: the hook keeps waiting for these. */
|
|
98
|
+
export const rootsAwaitingCoordinator = (outcomes: readonly { root: string; outcome: EnsureOutcome }[]): string[] =>
|
|
99
|
+
outcomes.filter((each) => each.outcome === ENSURE_OUTCOME.awaitsCoordinator).map((each) => each.root)
|
|
100
|
+
|
|
77
101
|
/** The arguments for `bun` that run `herd --ensure` with the CLI of the package this plugin ships in. */
|
|
78
102
|
export const ensureArguments = (pluginRoot: string): string[] => [
|
|
79
103
|
join(dirname(pluginRoot), 'src', 'cli.ts'),
|
|
@@ -108,25 +132,34 @@ const herdrRun = (args: string[]) =>
|
|
|
108
132
|
const panesAnswer = (panes: readonly string[]): boolean =>
|
|
109
133
|
panes.every((pane) => herdrRun(['pane', 'process-info', '--pane', pane]).status === 0)
|
|
110
134
|
|
|
111
|
-
const ensureIn = (root: string, pluginRoot: string):
|
|
112
|
-
spawnSync('bun', ensureArguments(pluginRoot), { cwd: root, stdio: 'inherit' }).status
|
|
135
|
+
const ensureIn = (root: string, pluginRoot: string): EnsureOutcome =>
|
|
136
|
+
ensureOutcomeOf(spawnSync('bun', ensureArguments(pluginRoot), { cwd: root, stdio: 'inherit' }).status)
|
|
113
137
|
|
|
114
138
|
const coordinatorBackIn = (root: string): boolean =>
|
|
115
139
|
hasAgentIn(root, herdrRun(['pane', 'list']).stdout ?? '', idsOf(herdrRun(['agent', 'list']).stdout ?? '', 'agents'))
|
|
116
140
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
/** Retries `herd --ensure` while it fails; a run that only misses the coordinator is not retried here. */
|
|
142
|
+
const ensureWithRetries = (root: string, sleep: (ms: number) => void): EnsureOutcome => {
|
|
143
|
+
const outcome = retriedUntil(
|
|
144
|
+
() => ensureIn(root, import.meta.dir),
|
|
145
|
+
(each) => each !== ENSURE_OUTCOME.failed,
|
|
146
|
+
RESTORE_LIMITS.ensure,
|
|
147
|
+
sleep,
|
|
148
|
+
)
|
|
149
|
+
if (outcome === ENSURE_OUTCOME.failed) console.error(`restore-plans: herd --ensure failed in ${root}`)
|
|
150
|
+
return outcome
|
|
120
151
|
}
|
|
121
152
|
|
|
153
|
+
/** Runs `herd --ensure` once the coordinator of `root` is back; whether it found the coordinator's pane. */
|
|
154
|
+
const ensureCoordinatorHerd = (root: string): boolean =>
|
|
155
|
+
coordinatorBackIn(root) && ensureIn(root, import.meta.dir) === ENSURE_OUTCOME.done
|
|
156
|
+
|
|
122
157
|
/**
|
|
123
|
-
* The herd beside each coordinator
|
|
124
|
-
*
|
|
158
|
+
* The herd beside each coordinator whose pane the first `--ensure` did not find, once its session is back, which
|
|
159
|
+
* can take longer than the lanes'; they share one waiting window.
|
|
125
160
|
*/
|
|
126
|
-
const ensureCoordinators = (
|
|
127
|
-
const missing
|
|
128
|
-
const ensure = (root: string) => ensureWithRetries(root, sleep)
|
|
129
|
-
for (const root of awaitEach(missing, coordinatorBackIn, ensure, RESTORE_LIMITS.coordinator, sleep))
|
|
161
|
+
const ensureCoordinators = (missing: readonly string[], sleep: (ms: number) => void) => {
|
|
162
|
+
for (const root of awaitEach(missing, ensureCoordinatorHerd, RESTORE_LIMITS.coordinator, sleep))
|
|
130
163
|
console.error(`restore-plans: no coordinator came back in ${root}`)
|
|
131
164
|
}
|
|
132
165
|
|
|
@@ -135,8 +168,9 @@ const ensureCoordinators = (roots: readonly string[], sleep: (ms: number) => voi
|
|
|
135
168
|
* repository an agent runs in. It parses herdr's answers itself and never throws, unlike the strict parsers of
|
|
136
169
|
* `src/plans/plan-panes.ts`: a hook that fails leaves nothing behind but a log line, and `herd --ensure`, which it
|
|
137
170
|
* calls, reads the panes again strictly. It reads the panes at once, while herdr still names their agents, then waits until
|
|
138
|
-
* herdr answers for every pane and retries `herd --ensure` within `RESTORE_LIMITS`. A repository
|
|
139
|
-
*
|
|
171
|
+
* herdr answers for every pane and retries `herd --ensure` within `RESTORE_LIMITS`. A repository where `herd --ensure`
|
|
172
|
+
* did not find the coordinator (`ENSURE_EXIT.noCoordinator`) gets it again, once an agent runs in its main
|
|
173
|
+
* checkout, until it finds the coordinator or `RESTORE_LIMITS.coordinator` runs out. A repository without a
|
|
140
174
|
* board or config only prints why and is skipped.
|
|
141
175
|
*/
|
|
142
176
|
const main = (): number => {
|
|
@@ -149,8 +183,8 @@ const main = (): number => {
|
|
|
149
183
|
const sleep = (ms: number) => Bun.sleepSync(ms)
|
|
150
184
|
if (!retried(() => panesAnswer(idsOf(listing, 'panes')), RESTORE_LIMITS.panes, sleep))
|
|
151
185
|
console.error('restore-plans: herdr did not answer for every pane in time, trying anyway')
|
|
152
|
-
|
|
153
|
-
ensureCoordinators(
|
|
186
|
+
const outcomes = [...roots].map((root) => ({ root, outcome: ensureWithRetries(root, sleep) }))
|
|
187
|
+
ensureCoordinators(rootsAwaitingCoordinator(outcomes), sleep)
|
|
154
188
|
return 0
|
|
155
189
|
}
|
|
156
190
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** The arguments for `bun` that run `jh resume` with the CLI of the package this plugin ships in. */
|
|
5
|
+
export const resumeArguments = (pluginRoot: string): string[] => [join(dirname(pluginRoot), 'src', 'cli.ts'), 'resume']
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* herdr's second `[[startup]]` hook, beside `restore-plans.ts`: runs `jh resume`, which decides from the watcher's
|
|
9
|
+
* record whether herdr starts after a crash and wakes the coordinator and the interrupted lanes. All logic lives in
|
|
10
|
+
* `src/resume/`; this stays thin and exits 0, a hook leaves nothing behind but the log of `jh resume`.
|
|
11
|
+
*/
|
|
12
|
+
const main = (): number => {
|
|
13
|
+
const result = spawnSync('bun', resumeArguments(import.meta.dir), { stdio: 'inherit' })
|
|
14
|
+
if (result.status !== 0) console.error(`resume: jh resume ended with ${result.status ?? result.signal}`)
|
|
15
|
+
return 0
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (import.meta.main) process.exitCode = main()
|
package/install.sh
CHANGED
|
@@ -9,7 +9,7 @@ set -eu
|
|
|
9
9
|
|
|
10
10
|
# The default is the version this file ships with. The release bump changes it together with
|
|
11
11
|
# package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
|
|
12
|
-
JACKHERD_VERSION="${JACKHERD_VERSION:-1.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.18.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
package/kit/hooks/ask-rules.json
CHANGED
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
"Bash(gh api *dispatches*)",
|
|
7
7
|
"Bash(gh api *rerun*)",
|
|
8
8
|
"Bash(gh api *git/refs*)",
|
|
9
|
+
"Bash(gh api *mergePullRequest*)",
|
|
10
|
+
"Bash(gh api *mergeBranch*)",
|
|
11
|
+
"Bash(gh api *enablePullRequestAutoMerge*)",
|
|
12
|
+
"Bash(gh api *enqueuePullRequest*)",
|
|
13
|
+
"Bash(gh api *createCommitOnBranch*)",
|
|
14
|
+
"Bash(gh api *createRef*)",
|
|
15
|
+
"Bash(gh api *updateRef*)",
|
|
16
|
+
"Bash(gh api *deleteRef*)",
|
|
17
|
+
"Bash(gh api *rerequestCheckSuite*)",
|
|
18
|
+
"Bash(gh api *graphql*query=@*)",
|
|
19
|
+
"Bash(gh api *graphql*--input*)",
|
|
9
20
|
"Bash(jh lane close*)",
|
|
10
21
|
"Bash(jackherd lane close*)",
|
|
11
22
|
"Bash(bunx jackherd* lane close*)",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coordinator's marks that it checked a held call: a `jh approval checked <id> <hash>` it ran as a Bash call and
|
|
3
|
+
* that ended with exit 0, read from its transcript like the dialog answers. The transcript is written by the client,
|
|
4
|
+
* not by the agent; a file a lane could write is never a mark.
|
|
5
|
+
*/
|
|
6
|
+
import { contentOf } from '../shared/dialog-answers'
|
|
7
|
+
import { isRecord, type JsonObject } from '../shared/json'
|
|
8
|
+
import { CONTENT_BLOCK_TYPE } from '../shared/log-vocabulary'
|
|
9
|
+
|
|
10
|
+
/** A call the coordinator marked checked: the request's id and the hash it showed, and when the command returned. */
|
|
11
|
+
export type CheckedMark = { id: string; hash: string; checkedAt: number }
|
|
12
|
+
|
|
13
|
+
type CheckCall = Pick<CheckedMark, 'id' | 'hash'>
|
|
14
|
+
|
|
15
|
+
type BashUse = JsonObject & { id: string }
|
|
16
|
+
|
|
17
|
+
const BASH_TOOL = 'Bash'
|
|
18
|
+
const CHECKED_COMMAND = /^jh approval checked ([0-9a-f]{12}) ([0-9a-f]{16})$/
|
|
19
|
+
|
|
20
|
+
const isMainThread = (entry: JsonObject): boolean => entry.isSidechain !== true
|
|
21
|
+
|
|
22
|
+
const isBashUse = (block: JsonObject): block is BashUse =>
|
|
23
|
+
block.type === CONTENT_BLOCK_TYPE.toolUse && block.name === BASH_TOOL && typeof block.id === 'string'
|
|
24
|
+
|
|
25
|
+
/** The command of a call that ran in the foreground; a background call returns before its exit code is known. */
|
|
26
|
+
const commandOf = (block: JsonObject): string | undefined =>
|
|
27
|
+
isRecord(block.input) && typeof block.input.command === 'string' && block.input.run_in_background !== true
|
|
28
|
+
? block.input.command.trim()
|
|
29
|
+
: undefined
|
|
30
|
+
|
|
31
|
+
/** The id and hash of each `jh approval checked` Bash call of the main thread, by tool use id. */
|
|
32
|
+
const checkCalls = (entries: readonly JsonObject[]): Map<string, CheckCall> =>
|
|
33
|
+
new Map(
|
|
34
|
+
entries
|
|
35
|
+
.flatMap(contentOf)
|
|
36
|
+
.filter(isBashUse)
|
|
37
|
+
.flatMap((block): [string, CheckCall][] => {
|
|
38
|
+
const match = CHECKED_COMMAND.exec(commandOf(block) ?? '')
|
|
39
|
+
return match ? [[block.id, { id: match[1], hash: match[2] }]] : []
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
const marksIn = (entry: JsonObject, calls: Map<string, CheckCall>): CheckedMark[] => {
|
|
44
|
+
const checkedAt = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
|
|
45
|
+
if (Number.isNaN(checkedAt)) return []
|
|
46
|
+
return contentOf(entry)
|
|
47
|
+
.filter((block) => block.type === CONTENT_BLOCK_TYPE.toolResult && block.is_error === false)
|
|
48
|
+
.flatMap((block) => {
|
|
49
|
+
const call = calls.get(String(block.tool_use_id))
|
|
50
|
+
return call ? [{ ...call, checkedAt }] : []
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every call of the main thread the coordinator marked checked with exit 0, in transcript order. */
|
|
55
|
+
export const checkedMarks = (entries: readonly unknown[]): CheckedMark[] => {
|
|
56
|
+
const main = entries.filter(isRecord).filter(isMainThread)
|
|
57
|
+
const calls = checkCalls(main)
|
|
58
|
+
return main.flatMap((entry) => marksIn(entry, calls))
|
|
59
|
+
}
|
|
@@ -2,20 +2,24 @@
|
|
|
2
2
|
// by the herdr agent of the pane and not by the directory of the call, it holds a call that would open a permission
|
|
3
3
|
// dialog, records it for the coordinator and answers with the user's decision from the coordinator's dialog; the same for
|
|
4
4
|
// a subagent of the coordinator, under its own name; elsewhere, and on any failure, it answers nothing and the dialog stays. argv[2..5]: the allow label, the deny label, the text for
|
|
5
|
-
// a call still pending after the wait, the text for a denied call
|
|
6
|
-
|
|
5
|
+
// a call still pending after the wait, the text for a denied call; argv[6..7]: the label and the word before the end
|
|
6
|
+
// of a standing approval, which lets a stage call the coordinator marked checked pass; all rendered by `sync`.
|
|
7
|
+
import { existsSync, writeFileSync } from 'node:fs'
|
|
7
8
|
import { homedir } from 'node:os'
|
|
8
9
|
import { join } from 'node:path'
|
|
10
|
+
import { boardLocationOf, boardsIn } from '../shared/boards'
|
|
9
11
|
import { mainCheckoutOf } from '../shared/checkout'
|
|
10
12
|
import { commandOutput } from '../shared/command'
|
|
11
13
|
import { readConfig } from '../shared/config'
|
|
14
|
+
import { readTextOrNull } from '../shared/files'
|
|
12
15
|
import { isRecord } from '../shared/json'
|
|
13
16
|
import { ownPaneAgent } from '../shared/own-pane'
|
|
14
17
|
import { gateSettingsOf } from './settings'
|
|
15
18
|
import { coordinatorSessions } from './herdr-panes'
|
|
16
|
-
import { type ClientCall, type GateDependencies, type GateOutcome, runGate } from './run'
|
|
19
|
+
import { type ClientCall, type GateDependencies, type GateOutcome, type GateTexts, runGate } from './run'
|
|
20
|
+
import { boardForLane, historyDate, type StandingHistoryEntry, standingHistoryLine, withHistoryRow } from './standing-history'
|
|
17
21
|
import { approvalsDirectory, claimRequest, pruneRequests, readRequests, writeRequest } from './store'
|
|
18
|
-
import {
|
|
22
|
+
import { coordinatorLog } from './transcripts'
|
|
19
23
|
|
|
20
24
|
type ClaudeDecision = { behavior: 'allow' } | { behavior: 'deny'; message: string }
|
|
21
25
|
|
|
@@ -47,6 +51,27 @@ const clientCallOf = (text: string): ClientCall | null => {
|
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Writes the history row into the board that names the lane (the only board when none does), read again just before
|
|
56
|
+
* it is written; false when there is no such board, no history table, or the write fails.
|
|
57
|
+
*/
|
|
58
|
+
const writeHistory = (main: string, entry: StandingHistoryEntry): boolean => {
|
|
59
|
+
try {
|
|
60
|
+
const location = boardLocationOf(readConfig([main]))
|
|
61
|
+
if (location === null) return false
|
|
62
|
+
const board = boardForLane(boardsIn(main, location), entry.lane, location.planLanguage)
|
|
63
|
+
if (board === null) return false
|
|
64
|
+
const path = join(main, board.directory, location.board.file)
|
|
65
|
+
const text = readTextOrNull(path)
|
|
66
|
+
const next = text === null ? null : withHistoryRow(text, location.planLanguage, historyDate(entry.at), standingHistoryLine(entry))
|
|
67
|
+
if (next === null) return false
|
|
68
|
+
writeFileSync(path, next)
|
|
69
|
+
return true
|
|
70
|
+
} catch {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
const dependencies: GateDependencies = {
|
|
51
76
|
paneAgent: () => ownPaneAgent(),
|
|
52
77
|
mainCheckoutOf,
|
|
@@ -57,19 +82,23 @@ const dependencies: GateDependencies = {
|
|
|
57
82
|
writeRequest: (main, request) => writeRequest(approvalsDirectory(main), request),
|
|
58
83
|
claimRequest: (main, id, now) => claimRequest(approvalsDirectory(main), id, now),
|
|
59
84
|
pruneRequests: (main, now, minutes) => pruneRequests(approvalsDirectory(main), now, minutes),
|
|
60
|
-
|
|
85
|
+
coordinatorLog: (main) => coordinatorLog(coordinatorSessions(herdrPanes(), main)),
|
|
86
|
+
writeHistory,
|
|
61
87
|
now: () => Date.now(),
|
|
62
88
|
sleep: (ms) => Bun.sleep(ms),
|
|
63
89
|
randomBytes: () => crypto.getRandomValues(new Uint8Array(6)),
|
|
64
90
|
}
|
|
65
91
|
|
|
66
|
-
const [allow = '', deny = '', pending = '', denied = ''] = process.argv.slice(2)
|
|
92
|
+
const [allow = '', deny = '', pending = '', denied = '', approve = '', until = ''] = process.argv.slice(2)
|
|
93
|
+
|
|
94
|
+
/** The labels; the standing approval's only when the install passes both of its words. */
|
|
95
|
+
const texts: GateTexts = { allow, deny, ...(approve && until ? { standing: { approve, until } } : {}) }
|
|
67
96
|
|
|
68
97
|
const decide = async (): Promise<GateOutcome> => {
|
|
69
98
|
if (!allow || !deny) return null
|
|
70
99
|
try {
|
|
71
100
|
const call = clientCallOf(await Bun.stdin.text())
|
|
72
|
-
return call ? await runGate(call,
|
|
101
|
+
return call ? await runGate(call, texts, dependencies) : null
|
|
73
102
|
} catch {
|
|
74
103
|
return null
|
|
75
104
|
}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
import type { DialogAnswer } from '../shared/dialog-answer'
|
|
2
1
|
import { ASK_USER_QUESTION_TOOL } from '../shared/dialog-answers'
|
|
3
|
-
import { isFresh } from '../shared/fresh'
|
|
2
|
+
import { isFresh, MS_PER_MINUTE } from '../shared/fresh'
|
|
4
3
|
import { isCoordinatorAgent, laneOfAgent } from '../shared/lane'
|
|
5
4
|
import type { PaneAgent } from '../shared/pane-agent'
|
|
5
|
+
import { type StandingLabels, standingGrants } from '../shared/standing-mark'
|
|
6
6
|
import { gateDecision, type GateLabels } from './decision'
|
|
7
7
|
import { type ApprovalRequest, callHash, type GateCall, isStorableInput, newRequest, requestId } from './request'
|
|
8
|
-
import { gateSettingsOf } from './settings'
|
|
8
|
+
import { type GateSettings, gateSettingsOf } from './settings'
|
|
9
|
+
import { queryOf, type StandingHistoryEntry } from './standing-history'
|
|
10
|
+
import { ENVIRONMENT_KIND, standingSqlDecision } from './standing-sql'
|
|
9
11
|
import { type SubagentCall, subagentLabel } from './subagent'
|
|
12
|
+
import type { CoordinatorLog } from './transcripts'
|
|
10
13
|
|
|
11
14
|
/** A call as the client's hook hands it over, before it is known to be a lane's; `subagent` when a subagent makes it. */
|
|
12
15
|
export type ClientCall = { cwd: string; session: string; tool: string; input: unknown; subagent?: SubagentCall }
|
|
13
16
|
|
|
17
|
+
/** The two labels of the coordinator's question about a call, and those of a standing approval when the install passes them. */
|
|
18
|
+
export type GateTexts = GateLabels & { standing?: StandingLabels }
|
|
19
|
+
|
|
14
20
|
/** What the gate decided; null decides nothing, so the client's own dialog stays. */
|
|
15
21
|
export type GateOutcome = { kind: 'allow' } | { kind: 'denied' } | { kind: 'pending' } | null
|
|
16
22
|
|
|
@@ -27,14 +33,16 @@ export type GateDependencies = {
|
|
|
27
33
|
/** Creates the request's claim exclusively; true for the one caller that may act on its answer. */
|
|
28
34
|
claimRequest: (main: string, id: string, now: number) => boolean
|
|
29
35
|
pruneRequests: (main: string, now: number, minutes: number) => void
|
|
30
|
-
answers
|
|
36
|
+
/** The user's dialog answers and the coordinator's checked marks, from the coordinator's transcripts. */
|
|
37
|
+
coordinatorLog: (main: string) => CoordinatorLog
|
|
38
|
+
/** Writes the history row of a call passing under a standing approval into the board; false when it could not. */
|
|
39
|
+
writeHistory: (main: string, entry: StandingHistoryEntry) => boolean
|
|
31
40
|
now: () => number
|
|
32
41
|
sleep: (ms: number) => Promise<void>
|
|
33
42
|
randomBytes: () => Uint8Array
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
const POLL_MS = 2000
|
|
37
|
-
const MS_PER_MINUTE = 60_000
|
|
38
46
|
/** An answer holds this long from the moment it was given (User decision, plan P18). */
|
|
39
47
|
const VALID_MINUTES = 30
|
|
40
48
|
/** A request is kept for its wait and the validity of an answer to it, then removed. */
|
|
@@ -54,22 +62,70 @@ const openRequest = (main: string, call: GateCall, dependencies: GateDependencie
|
|
|
54
62
|
return request
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* The request this hook holds, as it opened it, with only its claim taken from disk: a file rewritten since (another
|
|
67
|
+
* call, another creation time) is no longer this request, and nothing passes by it.
|
|
68
|
+
*/
|
|
69
|
+
const heldRequest = (opened: ApprovalRequest, requests: readonly ApprovalRequest[]): ApprovalRequest | undefined => {
|
|
70
|
+
const current = requests.find(({ id }) => id === opened.id)
|
|
71
|
+
return current?.hash === opened.hash && current.createdAt === opened.createdAt
|
|
72
|
+
? { ...opened, usedAt: current.usedAt }
|
|
73
|
+
: undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The request as it lies now, if a standing approval and the coordinator's mark let it pass; until when that holds. */
|
|
77
|
+
const standingPass = (
|
|
78
|
+
request: ApprovalRequest | undefined,
|
|
79
|
+
log: CoordinatorLog,
|
|
80
|
+
texts: GateTexts,
|
|
81
|
+
settings: GateSettings,
|
|
82
|
+
now: number,
|
|
83
|
+
): { request: ApprovalRequest; until: number } | null => {
|
|
84
|
+
if (request === undefined || texts.standing === undefined) return null
|
|
85
|
+
const grants = standingGrants(log.answers, ENVIRONMENT_KIND, texts.standing, now)
|
|
86
|
+
const pass = standingSqlDecision({ request, grants, checks: log.checks, standingTools: settings.standingTools, now })
|
|
87
|
+
return pass === null ? null : { request, until: pass.until }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Lets a call through under a standing approval: the request is claimed, so no dialog answer is spent on it as well,
|
|
92
|
+
* and its history row is written before the call passes. A row that cannot be written lets nothing through and leaves
|
|
93
|
+
* the request spent, so a later dialog answer cannot pass it either; the lane's retry opens a new request.
|
|
94
|
+
*/
|
|
95
|
+
const passStanding = (
|
|
96
|
+
main: string,
|
|
97
|
+
{ request, until }: { request: ApprovalRequest; until: number },
|
|
98
|
+
now: number,
|
|
99
|
+
dependencies: GateDependencies,
|
|
100
|
+
): GateOutcome => {
|
|
101
|
+
if (!dependencies.claimRequest(main, request.id, now)) return null
|
|
102
|
+
const entry = { id: request.id, lane: request.lane, hash: request.hash, query: queryOf(request.input), until, at: now }
|
|
103
|
+
return dependencies.writeHistory(main, entry) ? { kind: 'allow' } : { kind: 'pending' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Polls the coordinator's transcripts until the user decided, a standing approval with the coordinator's mark lets the
|
|
108
|
+
* call pass, the answer was claimed elsewhere, or the wait is over.
|
|
109
|
+
*/
|
|
58
110
|
const awaitDecision = async (
|
|
59
111
|
main: string,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
112
|
+
opened: ApprovalRequest,
|
|
113
|
+
texts: GateTexts,
|
|
114
|
+
settings: GateSettings,
|
|
63
115
|
dependencies: GateDependencies,
|
|
64
116
|
): Promise<GateOutcome> => {
|
|
65
|
-
const deadline = dependencies.now() + waitMinutes * MS_PER_MINUTE
|
|
117
|
+
const deadline = dependencies.now() + settings.waitMinutes * MS_PER_MINUTE
|
|
66
118
|
for (;;) {
|
|
67
119
|
const now = dependencies.now()
|
|
68
120
|
const requests = dependencies.readRequests(main)
|
|
69
|
-
const
|
|
70
|
-
const
|
|
121
|
+
const log = dependencies.coordinatorLog(main)
|
|
122
|
+
const { hash } = opened
|
|
123
|
+
const decision = gateDecision({ requests, hash, answers: log.answers, labels: texts, now, validMinutes: VALID_MINUTES })
|
|
71
124
|
if (decision.kind !== 'pending' && dependencies.claimRequest(main, decision.id, now))
|
|
72
125
|
return decision.kind === 'allow' ? { kind: 'allow' } : { kind: 'denied' }
|
|
126
|
+
const standing = standingPass(heldRequest(opened, requests), log, texts, settings, now)
|
|
127
|
+
const passed = standing === null ? null : passStanding(main, standing, now, dependencies)
|
|
128
|
+
if (passed !== null) return passed
|
|
73
129
|
if (now >= deadline) return { kind: 'pending' }
|
|
74
130
|
await dependencies.sleep(POLL_MS)
|
|
75
131
|
}
|
|
@@ -106,7 +162,7 @@ const holderOf = (
|
|
|
106
162
|
*/
|
|
107
163
|
export const runGate = async (
|
|
108
164
|
clientCall: ClientCall,
|
|
109
|
-
|
|
165
|
+
texts: GateTexts,
|
|
110
166
|
dependencies: GateDependencies,
|
|
111
167
|
): Promise<GateOutcome> => {
|
|
112
168
|
if (!isHeld(clientCall)) return null
|
|
@@ -114,12 +170,12 @@ export const runGate = async (
|
|
|
114
170
|
const agent = dependencies.paneAgent()
|
|
115
171
|
if (agent === null || agent.cwd === null) return null
|
|
116
172
|
const main = dependencies.mainCheckoutOf(agent.cwd)
|
|
117
|
-
const
|
|
118
|
-
const lane = holderOf(clientCall, agent, main, worktreeDirectory, dependencies)
|
|
173
|
+
const settings = gateSettingsOf(dependencies.config(main))
|
|
174
|
+
const lane = holderOf(clientCall, agent, main, settings.worktreeDirectory, dependencies)
|
|
119
175
|
if (!lane) return null
|
|
120
176
|
const { cwd, tool, input } = clientCall
|
|
121
177
|
const request = openRequest(main, { lane, cwd, tool, input }, dependencies)
|
|
122
|
-
return await awaitDecision(main, request
|
|
178
|
+
return await awaitDecision(main, request, texts, settings, dependencies)
|
|
123
179
|
} catch {
|
|
124
180
|
return null
|
|
125
181
|
}
|
|
@@ -1,18 +1,43 @@
|
|
|
1
|
+
import { isRecord } from '../shared/json'
|
|
1
2
|
import { sectionOf } from '../shared/config'
|
|
2
3
|
import { isRepositoryDirectory } from '../shared/repository-directory'
|
|
3
4
|
|
|
5
|
+
/** The one environment a standing approval may cover; the same as `STANDING_ENVIRONMENT` in the config schema. */
|
|
6
|
+
export const STAGE = 'stage'
|
|
7
|
+
|
|
4
8
|
export type GateSettings = {
|
|
5
9
|
/** How long the hook holds a call for the coordinator; below the 10 minutes of the hook entry. */
|
|
6
10
|
waitMinutes: number
|
|
7
11
|
worktreeDirectory: string
|
|
12
|
+
/** The tools a standing approval may let through, by their full names, each with the environment it writes to. */
|
|
13
|
+
standingTools: Record<string, typeof STAGE>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_GATE_SETTINGS: GateSettings = {
|
|
17
|
+
waitMinutes: 8,
|
|
18
|
+
worktreeDirectory: '.claude/worktrees',
|
|
19
|
+
standingTools: {},
|
|
8
20
|
}
|
|
9
21
|
|
|
10
|
-
|
|
22
|
+
const STANDING_TOOL = /^mcp__[A-Za-z0-9_-]+__execute_sql$/
|
|
23
|
+
const PRODUCTION = /prod/i
|
|
11
24
|
|
|
12
25
|
/** The same bounds as `guard.approvals.waitMinutes` in the config schema, which the hook cannot import. */
|
|
13
26
|
const isWaitMinutes = (value: unknown): value is number =>
|
|
14
27
|
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 9
|
|
15
28
|
|
|
29
|
+
/** The same rule as the schema's standing tool: a full MCP tool name on `__execute_sql` that does not say `prod`. */
|
|
30
|
+
export const isStandingTool = (tool: string): boolean => STANDING_TOOL.test(tool) && !PRODUCTION.test(tool)
|
|
31
|
+
|
|
32
|
+
/** The standing tools of `guard.approvals.standing.tools`; one entry the schema refuses lets none through. */
|
|
33
|
+
const standingToolsOf = (standing: Record<string, unknown>): GateSettings['standingTools'] => {
|
|
34
|
+
const { tools } = standing
|
|
35
|
+
if (!isRecord(tools)) return {}
|
|
36
|
+
const entries = Object.entries(tools)
|
|
37
|
+
const valid = entries.every(([tool, environment]) => environment === STAGE && isStandingTool(tool))
|
|
38
|
+
return valid ? Object.fromEntries(entries.map(([tool]): [string, typeof STAGE] => [tool, STAGE])) : {}
|
|
39
|
+
}
|
|
40
|
+
|
|
16
41
|
/** The gate's settings from a parsed config; whatever is missing or malformed keeps its default. */
|
|
17
42
|
export const gateSettingsOf = (config: Record<string, unknown>): GateSettings => {
|
|
18
43
|
const approvals = sectionOf(sectionOf(config, 'guard'), 'approvals')
|
|
@@ -22,5 +47,6 @@ export const gateSettingsOf = (config: Record<string, unknown>): GateSettings =>
|
|
|
22
47
|
worktreeDirectory: isRepositoryDirectory(worktreeDirectory)
|
|
23
48
|
? worktreeDirectory
|
|
24
49
|
: DEFAULT_GATE_SETTINGS.worktreeDirectory,
|
|
50
|
+
standingTools: standingToolsOf(sectionOf(approvals, 'standing')),
|
|
25
51
|
}
|
|
26
52
|
}
|