jackherd 1.16.0 → 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.
Files changed (155) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +22 -21
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  40. package/kit/rules/rules/kit/code-style.md +6 -6
  41. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  42. package/kit/workflow/skills/follow/SKILL.md +2 -3
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +120 -299
  45. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  46. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  47. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  48. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  49. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  50. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  51. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  52. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  53. package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
  54. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  55. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  56. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  57. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  58. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  59. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  60. package/kit/workflow/skills/plan/SKILL.md +3 -2
  61. package/package.json +2 -1
  62. package/recipes/README.md +1 -0
  63. package/recipes/jira-cli.md +1 -1
  64. package/recipes/sources/google-calendar-today.ts +8 -2
  65. package/recipes/sources/jira.ts +10 -2
  66. package/recipes/sources/linear.ts +9 -3
  67. package/recipes/sources/sentry.ts +8 -2
  68. package/recipes/sources.md +4 -2
  69. package/schemas/jackherd.schema.json +67 -0
  70. package/schemas/sources.schema.json +9 -0
  71. package/src/approval/checked.ts +60 -0
  72. package/src/approval/command.ts +16 -0
  73. package/src/check/command.ts +51 -6
  74. package/src/check/lint-pointers.ts +79 -0
  75. package/src/check/lint-shaped.ts +196 -0
  76. package/src/check/plan-structure-check.ts +116 -4
  77. package/src/check/report.ts +9 -3
  78. package/src/check/schema.ts +37 -0
  79. package/src/check/settings.ts +4 -0
  80. package/src/check/skill-budget-defaults.ts +77 -26
  81. package/src/check/skill-budgets.ts +58 -10
  82. package/src/cli.ts +2 -0
  83. package/src/config/personal-sources.ts +46 -10
  84. package/src/config/repository-identity.ts +40 -0
  85. package/src/config/schema.ts +53 -1
  86. package/src/context/command.ts +33 -2
  87. package/src/context/options.ts +33 -8
  88. package/src/context/usage-logs.ts +53 -0
  89. package/src/context/usage.ts +110 -0
  90. package/src/lane/base-command.ts +35 -0
  91. package/src/lane/base.ts +43 -0
  92. package/src/lane/close-check.ts +49 -15
  93. package/src/lane/close-command.ts +71 -11
  94. package/src/lane/close-options.ts +29 -8
  95. package/src/lane/close-survey.ts +13 -1
  96. package/src/lane/close.ts +51 -12
  97. package/src/lane/command.ts +21 -3
  98. package/src/lane/send-command.ts +32 -0
  99. package/src/lane/send-herdr.ts +77 -0
  100. package/src/lane/send-options.ts +23 -0
  101. package/src/lane/send.ts +90 -0
  102. package/src/lane/skill-hint.ts +27 -0
  103. package/src/lane/space-command.ts +30 -0
  104. package/src/lane/space.ts +49 -0
  105. package/src/lane/start-lane-hint.ts +18 -0
  106. package/src/plan-contract/base-check-git.ts +33 -0
  107. package/src/plan-contract/base-check.ts +17 -0
  108. package/src/plan-contract/command.ts +25 -4
  109. package/src/plan-format/plan-structure.ts +17 -5
  110. package/src/plans/ask-coordinator.ts +23 -13
  111. package/src/plans/board-rows.ts +9 -4
  112. package/src/plans/click-hint.ts +4 -0
  113. package/src/plans/command.ts +6 -5
  114. package/src/plans/ensure-panes.ts +14 -3
  115. package/src/plans/git.ts +14 -0
  116. package/src/plans/github.ts +19 -10
  117. package/src/plans/layout.ts +82 -33
  118. package/src/plans/live.ts +8 -12
  119. package/src/plans/plan-panes.ts +21 -4
  120. package/src/plans/selection.ts +29 -0
  121. package/src/plans/settings.ts +4 -0
  122. package/src/resume/command.ts +213 -0
  123. package/src/resume/decision.ts +102 -0
  124. package/src/resume/keeper.ts +65 -0
  125. package/src/resume/location.ts +22 -0
  126. package/src/resume/log.ts +27 -0
  127. package/src/resume/ports.ts +102 -0
  128. package/src/resume/record.ts +116 -0
  129. package/src/resume/targets.ts +84 -0
  130. package/src/resume/texts.ts +27 -0
  131. package/src/secrets/command.ts +15 -5
  132. package/src/sync/herdr-plugin.ts +2 -11
  133. package/src/sync/plan-ask-rules.ts +11 -0
  134. package/src/sync/render.ts +3 -1
  135. package/src/vocabulary/index.ts +13 -0
  136. package/src/waiting/command.ts +13 -4
  137. package/src/watch/command.ts +177 -78
  138. package/src/watch/config-reload.ts +119 -0
  139. package/src/watch/lane-model.ts +11 -1
  140. package/src/watch/lane-progress.ts +70 -8
  141. package/src/watch/lane-report.ts +66 -11
  142. package/src/watch/main-checkout.ts +5 -1
  143. package/src/watch/merge-duties.ts +33 -0
  144. package/src/watch/merge-overlap-read.ts +69 -0
  145. package/src/watch/plan-views.ts +16 -4
  146. package/src/watch/reference-paths.ts +46 -5
  147. package/src/watch/report-questions.ts +5 -2
  148. package/src/watch/single-report.ts +3 -2
  149. package/src/watch/source-dry-run-command.ts +22 -4
  150. package/src/watch/source-reason.ts +58 -0
  151. package/src/watch/source-reload.ts +21 -17
  152. package/src/watch/source-run.ts +24 -7
  153. package/src/watch/sources.ts +9 -2
  154. package/src/watch/texts.ts +14 -3
  155. package/src/watch/waiting-line.ts +4 -3
@@ -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
- /** Runs `attempt` until it works or the attempts are used up, waiting the interval between two of them. */
20
- export const retried = (attempt: () => boolean, limits: Limits, sleep: (ms: number) => void): boolean => {
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
- if (attempt()) return true
23
- if (tried >= limits.attempts) return false
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
- * Waits for every item in one shared window of `limits`, calls `onReady` for each as soon as `isReady` holds, and
30
- * returns the items that never got ready.
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
- isReady: (item: T) => boolean,
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
- const ready = pending.filter(isReady)
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): boolean =>
112
- spawnSync('bun', ensureArguments(pluginRoot), { cwd: root, stdio: 'inherit' }).status === 0
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
- const ensureWithRetries = (root: string, sleep: (ms: number) => void) => {
118
- if (!retried(() => ensureIn(root, import.meta.dir), RESTORE_LIMITS.ensure, sleep))
119
- console.error(`restore-plans: herd --ensure failed in ${root}`)
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 once its session is back, which can take longer than the lanes'. A coordinator
124
- * already back had its herd from the first `--ensure`; the others share one waiting window.
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 = (roots: readonly string[], sleep: (ms: number) => void) => {
127
- const missing = roots.filter((root) => !coordinatorBackIn(root))
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 whose coordinator
139
- * is not back yet gets one more `herd --ensure` once an agent runs in its main checkout. A repository without a
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
- for (const root of roots) ensureWithRetries(root, sleep)
153
- ensureCoordinators([...roots], sleep)
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.16.0}"
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.
@@ -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, rendered into the hook entry by `sync`.
6
- import { existsSync } from 'node:fs'
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 { coordinatorAnswers } from './transcripts'
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
- answers: (main) => coordinatorAnswers(coordinatorSessions(herdrPanes(), main)),
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, { allow, deny }, dependencies) : null
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: (main: string) => DialogAnswer[]
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
- /** Polls the coordinator's answers until the user decided, the answer was claimed elsewhere, or the wait is over. */
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
- hash: string,
61
- labels: GateLabels,
62
- waitMinutes: number,
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 answers = dependencies.answers(main)
70
- const decision = gateDecision({ requests, hash, answers, labels, now, validMinutes: VALID_MINUTES })
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
- labels: GateLabels,
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 { worktreeDirectory, waitMinutes } = gateSettingsOf(dependencies.config(main))
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.hash, labels, waitMinutes, dependencies)
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
- export const DEFAULT_GATE_SETTINGS: GateSettings = { waitMinutes: 8, worktreeDirectory: '.claude/worktrees' }
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
+ }