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.
Files changed (158) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +29 -33
  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 +16 -22
  40. package/kit/rules/rules/kit/code-style.md +26 -54
  41. package/kit/rules/rules/kit/pull-requests.md +17 -40
  42. package/kit/rules/rules/kit/testing.md +6 -12
  43. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  44. package/kit/workflow/skills/follow/SKILL.md +2 -3
  45. package/kit/workflow/skills/implement/SKILL.md +28 -43
  46. package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
  47. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  48. package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
  49. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  50. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  51. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  52. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  53. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  54. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  55. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  56. package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
  57. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  58. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  59. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  60. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  61. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  62. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  63. package/kit/workflow/skills/plan/SKILL.md +29 -48
  64. package/package.json +2 -1
  65. package/recipes/README.md +1 -0
  66. package/recipes/jira-cli.md +1 -1
  67. package/recipes/sources/google-calendar-today.ts +8 -2
  68. package/recipes/sources/jira.ts +10 -2
  69. package/recipes/sources/linear.ts +9 -3
  70. package/recipes/sources/sentry.ts +8 -2
  71. package/recipes/sources.md +4 -2
  72. package/schemas/jackherd.schema.json +67 -0
  73. package/schemas/sources.schema.json +9 -0
  74. package/src/approval/checked.ts +60 -0
  75. package/src/approval/command.ts +16 -0
  76. package/src/check/command.ts +51 -6
  77. package/src/check/lint-pointers.ts +79 -0
  78. package/src/check/lint-shaped.ts +196 -0
  79. package/src/check/plan-structure-check.ts +116 -4
  80. package/src/check/report.ts +9 -3
  81. package/src/check/schema.ts +37 -0
  82. package/src/check/settings.ts +4 -0
  83. package/src/check/skill-budget-defaults.ts +90 -32
  84. package/src/check/skill-budgets.ts +96 -18
  85. package/src/cli.ts +2 -0
  86. package/src/config/personal-sources.ts +46 -10
  87. package/src/config/repository-identity.ts +40 -0
  88. package/src/config/schema.ts +53 -1
  89. package/src/context/command.ts +33 -2
  90. package/src/context/options.ts +33 -8
  91. package/src/context/usage-logs.ts +53 -0
  92. package/src/context/usage.ts +110 -0
  93. package/src/lane/base-command.ts +35 -0
  94. package/src/lane/base.ts +43 -0
  95. package/src/lane/close-check.ts +49 -15
  96. package/src/lane/close-command.ts +71 -11
  97. package/src/lane/close-options.ts +29 -8
  98. package/src/lane/close-survey.ts +13 -1
  99. package/src/lane/close.ts +51 -12
  100. package/src/lane/command.ts +21 -3
  101. package/src/lane/send-command.ts +32 -0
  102. package/src/lane/send-herdr.ts +77 -0
  103. package/src/lane/send-options.ts +23 -0
  104. package/src/lane/send.ts +90 -0
  105. package/src/lane/skill-hint.ts +27 -0
  106. package/src/lane/space-command.ts +30 -0
  107. package/src/lane/space.ts +49 -0
  108. package/src/lane/start-lane-hint.ts +18 -0
  109. package/src/plan-contract/base-check-git.ts +33 -0
  110. package/src/plan-contract/base-check.ts +17 -0
  111. package/src/plan-contract/command.ts +25 -4
  112. package/src/plan-format/plan-structure.ts +17 -5
  113. package/src/plans/ask-coordinator.ts +23 -13
  114. package/src/plans/board-rows.ts +9 -4
  115. package/src/plans/click-hint.ts +4 -0
  116. package/src/plans/command.ts +6 -5
  117. package/src/plans/ensure-panes.ts +14 -3
  118. package/src/plans/git.ts +14 -0
  119. package/src/plans/github.ts +19 -10
  120. package/src/plans/layout.ts +82 -33
  121. package/src/plans/live.ts +8 -12
  122. package/src/plans/plan-panes.ts +21 -4
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/resume/command.ts +213 -0
  126. package/src/resume/decision.ts +102 -0
  127. package/src/resume/keeper.ts +65 -0
  128. package/src/resume/location.ts +22 -0
  129. package/src/resume/log.ts +27 -0
  130. package/src/resume/ports.ts +102 -0
  131. package/src/resume/record.ts +116 -0
  132. package/src/resume/targets.ts +84 -0
  133. package/src/resume/texts.ts +27 -0
  134. package/src/secrets/command.ts +15 -5
  135. package/src/sync/herdr-plugin.ts +2 -11
  136. package/src/sync/plan-ask-rules.ts +11 -0
  137. package/src/sync/render.ts +3 -1
  138. package/src/vocabulary/index.ts +13 -0
  139. package/src/waiting/command.ts +13 -4
  140. package/src/watch/command.ts +177 -78
  141. package/src/watch/config-reload.ts +119 -0
  142. package/src/watch/lane-model.ts +11 -1
  143. package/src/watch/lane-progress.ts +70 -8
  144. package/src/watch/lane-report.ts +66 -11
  145. package/src/watch/main-checkout.ts +5 -1
  146. package/src/watch/merge-duties.ts +33 -0
  147. package/src/watch/merge-overlap-read.ts +69 -0
  148. package/src/watch/plan-views.ts +16 -4
  149. package/src/watch/reference-paths.ts +46 -5
  150. package/src/watch/report-questions.ts +5 -2
  151. package/src/watch/single-report.ts +3 -2
  152. package/src/watch/source-dry-run-command.ts +22 -4
  153. package/src/watch/source-reason.ts +58 -0
  154. package/src/watch/source-reload.ts +21 -17
  155. package/src/watch/source-run.ts +24 -7
  156. package/src/watch/sources.ts +9 -2
  157. package/src/watch/texts.ts +14 -3
  158. package/src/watch/waiting-line.ts +4 -3
@@ -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-4LUFR7RC.js";
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,
@@ -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.16.0"
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
- /** 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.18.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
  }