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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
id = "crossdelta.jackherd"
|
|
2
2
|
name = "jackherd"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.17.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.17.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
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board's history row a call under a standing approval leaves before it passes: its id, lane, the start of its
|
|
3
|
+
* hash, the kind of statement and its first table, and the end of the approval. The board is committed and SQL may
|
|
4
|
+
* carry customer data in its values, so the row names no value; the whole call stays in the request file.
|
|
5
|
+
*/
|
|
6
|
+
import { type BoardLanguage, openLaneNamesOf } from '../shared/board-lanes'
|
|
7
|
+
import type { Board } from '../shared/boards'
|
|
8
|
+
import { isRecord } from '../shared/json'
|
|
9
|
+
import { STAGE } from './settings'
|
|
10
|
+
|
|
11
|
+
export type StandingHistoryEntry = { id: string; lane: string; hash: string; query: string; until: number; at: number }
|
|
12
|
+
|
|
13
|
+
/** The history section and its table head per plan language, as `PLAN_VOCABULARY` and `withHistoryRow` in the package. */
|
|
14
|
+
const HISTORY_HEADING: Record<BoardLanguage, string> = { de: 'Verlauf', en: 'History' }
|
|
15
|
+
|
|
16
|
+
/** Statement words a history row may name; anything else reads as `sql`, so a first word is never a value. */
|
|
17
|
+
const STATEMENTS = new Set(['select', 'insert', 'update', 'delete', 'merge', 'with', 'truncate', 'alter', 'create', 'drop', 'grant', 'revoke', 'copy', 'call', 'do', 'set', 'begin', 'commit'])
|
|
18
|
+
|
|
19
|
+
/** The word before the table each statement writes to. */
|
|
20
|
+
const TABLE_AFTER: Record<string, RegExp> = {
|
|
21
|
+
update: /^update\s+(?:only\s+)?/i,
|
|
22
|
+
insert: /^insert\s+into\s+/i,
|
|
23
|
+
delete: /^delete\s+from\s+(?:only\s+)?/i,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const IDENTIFIER = /^"?([A-Za-z_][A-Za-z0-9_$]*)"?(?:\."?([A-Za-z_][A-Za-z0-9_$]*)"?)?/
|
|
27
|
+
const LEADING_NOISE = /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/
|
|
28
|
+
const HASH_START = 8
|
|
29
|
+
/** The header row and the separator row of a table. */
|
|
30
|
+
const TABLE_HEAD_LINES = 2
|
|
31
|
+
|
|
32
|
+
const twoDigits = (value: number): string => String(value).padStart(2, '0')
|
|
33
|
+
|
|
34
|
+
const timeOfDay = (at: number): string => {
|
|
35
|
+
const date = new Date(at)
|
|
36
|
+
return `${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The date cell of a history row, `YYYY-MM-DD` in local time. */
|
|
40
|
+
export const historyDate = (at: number): string => {
|
|
41
|
+
const date = new Date(at)
|
|
42
|
+
return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tableOf = (statement: string, sql: string): string | undefined => {
|
|
46
|
+
const after = TABLE_AFTER[statement]
|
|
47
|
+
const match = after?.exec(sql)
|
|
48
|
+
const identifier = match ? IDENTIFIER.exec(sql.slice(match[0].length)) : null
|
|
49
|
+
return identifier ? [identifier[1], identifier[2]].filter(Boolean).join('.') : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The SQL of a call's input (`{ query }`, as an SQL tool takes it); empty for any other input. */
|
|
53
|
+
export const queryOf = (input: unknown): string =>
|
|
54
|
+
isRecord(input) && typeof input.query === 'string' ? input.query : ''
|
|
55
|
+
|
|
56
|
+
/** The kind of statement and the table it writes to, from the start of the SQL: `update orders`, `select`, `sql`. */
|
|
57
|
+
export const sqlHeadOf = (query: string): string => {
|
|
58
|
+
const sql = query.replace(LEADING_NOISE, '')
|
|
59
|
+
const statement = /^[A-Za-z]+/.exec(sql)?.[0].toLowerCase() ?? ''
|
|
60
|
+
if (!STATEMENTS.has(statement)) return 'sql'
|
|
61
|
+
const table = tableOf(statement, sql)
|
|
62
|
+
return table === undefined ? statement : `${statement} ${table}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The row's text: the time, then what passed without asking and under which approval. */
|
|
66
|
+
export const standingHistoryLine = ({ id, lane, hash, query, until, at }: StandingHistoryEntry): string =>
|
|
67
|
+
`${timeOfDay(at)}: ${STAGE} sql without asking ${id} ${lane} ${hash.slice(0, HASH_START)} (${sqlHeadOf(query)}, standing approval ${STAGE} until ${timeOfDay(until)})`
|
|
68
|
+
|
|
69
|
+
const cell = (text: string): string => text.replace(/\s+/g, ' ').trim().replaceAll('|', '\\|')
|
|
70
|
+
|
|
71
|
+
/** The board with a row on top of its history table; null for a board without that table, so nothing passes. */
|
|
72
|
+
export const withHistoryRow = (board: string, language: BoardLanguage, date: string, text: string): string | null => {
|
|
73
|
+
const lines = board.split('\n')
|
|
74
|
+
const heading = new RegExp(`^##\\s+${HISTORY_HEADING[language]}\\s*$`)
|
|
75
|
+
const start = lines.findIndex((line) => heading.test(line))
|
|
76
|
+
if (start === -1) return null
|
|
77
|
+
const rest = lines.slice(start + 1)
|
|
78
|
+
const end = rest.findIndex((line) => /^##\s/.test(line))
|
|
79
|
+
const tableAt = (end === -1 ? rest : rest.slice(0, end)).findIndex((line) => line.startsWith('|'))
|
|
80
|
+
if (tableAt === -1) return null
|
|
81
|
+
const firstRow = start + 1 + tableAt + TABLE_HEAD_LINES
|
|
82
|
+
return [...lines.slice(0, firstRow), `| ${date} | ${cell(text)} |`, ...lines.slice(firstRow)].join('\n')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The board whose lanes table names the lane as open; the only board when none does; else none. */
|
|
86
|
+
export const boardForLane = (boards: readonly Board[], lane: string, language: BoardLanguage): Board | null => {
|
|
87
|
+
const naming = boards.filter(({ text }) => openLaneNamesOf(text, language).includes(lane))
|
|
88
|
+
if (naming.length === 1) return naming[0]
|
|
89
|
+
return naming.length === 0 && boards.length === 1 ? boards[0] : null
|
|
90
|
+
}
|