jackherd 1.7.1 → 1.8.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 (153) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/INSTALL.md +2 -0
  3. package/{NOTICE → NOTICE.txt} +1 -1
  4. package/README.md +94 -1033
  5. package/assets/icons/bark-dark.svg +1 -1
  6. package/assets/icons/bark.svg +1 -1
  7. package/assets/icons/bowl-dark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide-dark.svg +1 -1
  10. package/assets/icons/decide.svg +1 -1
  11. package/assets/icons/ear-dark.svg +1 -1
  12. package/assets/icons/ear.svg +1 -1
  13. package/assets/icons/lanes-dark.svg +1 -1
  14. package/assets/icons/lanes.svg +1 -1
  15. package/assets/icons/plan-dark.svg +1 -1
  16. package/assets/icons/plan.svg +1 -1
  17. package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
  18. package/dist/config.d.ts +11 -5
  19. package/dist/config.js +53 -21
  20. package/dist/plan-format.d.ts +73 -7
  21. package/dist/plan-format.js +180 -100
  22. package/docs/workflow-dark.svg +1 -1
  23. package/docs/workflow.svg +1 -1
  24. package/herdr-plugin/herdr-plugin.toml +1 -1
  25. package/install.sh +1 -1
  26. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
  27. package/kit/hooks/hooks/approval-gate/index.ts +10 -8
  28. package/kit/hooks/hooks/approval-gate/run.ts +21 -12
  29. package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
  30. package/kit/hooks/hooks/git-guard/decision.ts +189 -15
  31. package/kit/hooks/hooks/git-guard/index.ts +13 -3
  32. package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
  33. package/kit/hooks/hooks/git-guard/repository.ts +63 -0
  34. package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
  35. package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
  36. package/kit/hooks/hooks/merge-guard/index.ts +6 -4
  37. package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
  38. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
  39. package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
  40. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
  41. package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
  42. package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
  43. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
  44. package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
  45. package/kit/hooks/hooks/merge-guard/run.ts +118 -29
  46. package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
  47. package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
  48. package/kit/hooks/hooks/shared/base-branch.ts +23 -0
  49. package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
  50. package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
  51. package/kit/hooks/hooks/shared/command.ts +14 -2
  52. package/kit/hooks/hooks/shared/commit.ts +2 -0
  53. package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
  54. package/kit/hooks/hooks/shared/json.ts +10 -0
  55. package/kit/hooks/hooks/shared/lane.ts +57 -0
  56. package/kit/hooks/hooks/shared/once.ts +8 -0
  57. package/kit/hooks/hooks/shared/own-pane.ts +31 -0
  58. package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
  59. package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
  60. package/kit/hooks/hooks/shared/program-position.ts +148 -5
  61. package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
  62. package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
  63. package/kit/hooks/hooks/shared/shell.ts +167 -20
  64. package/kit/hooks/hooks.json +11 -2
  65. package/kit/hooks/statusline.sh +29 -16
  66. package/kit/review/skills/diagnose/SKILL.md +1 -1
  67. package/kit/review/skills/review/SKILL.md +2 -0
  68. package/kit/rules/rules/kit/testing.md +2 -5
  69. package/kit/rules/rules/kit/writing-for-agents.md +3 -1
  70. package/kit/workflow/agents/spec-review.md +1 -1
  71. package/kit/workflow/skills/follow/SKILL.md +3 -3
  72. package/kit/workflow/skills/grill/SKILL.md +1 -1
  73. package/kit/workflow/skills/implement/SKILL.md +10 -11
  74. package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
  75. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
  76. package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
  77. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
  78. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
  79. package/kit/workflow/skills/plan/SKILL.md +5 -2
  80. package/kit/workflow/skills/plan/references/template-English.md +6 -0
  81. package/kit/workflow/skills/plan/references/template-German.md +6 -0
  82. package/package.json +3 -2
  83. package/recipes/README.md +21 -0
  84. package/recipes/github-issues.md +23 -0
  85. package/recipes/github-projects.md +22 -0
  86. package/recipes/jira-cli.md +26 -0
  87. package/recipes/jira-rest.md +24 -0
  88. package/recipes/linear.md +25 -0
  89. package/recipes/sentry.md +18 -0
  90. package/recipes/sources/jira.ts +51 -0
  91. package/recipes/sources/linear.ts +19 -0
  92. package/recipes/sources/sentry.ts +12 -0
  93. package/recipes/sources.md +80 -0
  94. package/recipes/status-page.md +19 -0
  95. package/schemas/jackherd.schema.json +42 -18
  96. package/src/check/command.ts +2 -2
  97. package/src/check/parallel-report.ts +19 -9
  98. package/src/cli.ts +1 -0
  99. package/src/config/context-shares.ts +130 -0
  100. package/src/config/read-config.ts +12 -4
  101. package/src/config/schema.ts +75 -20
  102. package/src/context/auto-compact.ts +68 -0
  103. package/src/context/command.ts +68 -17
  104. package/src/context/coordinator-name.ts +27 -9
  105. package/src/context/index.ts +0 -1
  106. package/src/context/level.ts +18 -11
  107. package/src/context/report.ts +6 -3
  108. package/src/plan-format/dependencies.ts +144 -32
  109. package/src/plan-format/index.ts +6 -0
  110. package/src/plan-format/lanes.ts +6 -0
  111. package/src/plan-format/progress.ts +19 -8
  112. package/src/plans/command.ts +14 -5
  113. package/src/plans/current-phase.ts +63 -7
  114. package/src/plans/current-task.ts +51 -0
  115. package/src/plans/ensure-panes.ts +25 -8
  116. package/src/plans/frame.ts +99 -0
  117. package/src/plans/git.ts +8 -0
  118. package/src/plans/herd-memory.ts +107 -0
  119. package/src/plans/lane-plans.ts +5 -3
  120. package/src/plans/lane-summary.ts +222 -0
  121. package/src/plans/layout-phase.ts +148 -0
  122. package/src/plans/layout-summary.ts +140 -0
  123. package/src/plans/layout.ts +164 -420
  124. package/src/plans/live.ts +11 -6
  125. package/src/plans/plan-panes.ts +15 -0
  126. package/src/plans/planned.ts +9 -42
  127. package/src/plans/selection.ts +2 -6
  128. package/src/plans/settings.ts +2 -7
  129. package/src/plans/snapshot.ts +38 -39
  130. package/src/plans/symbols.ts +2 -2
  131. package/src/plans/texts.ts +32 -31
  132. package/src/plans/view.ts +0 -1
  133. package/src/plans/worked-phases.ts +2 -2
  134. package/src/settled/board-row.ts +133 -0
  135. package/src/settled/check.ts +129 -0
  136. package/src/settled/command.ts +65 -0
  137. package/src/settled/evidence-version.ts +14 -0
  138. package/src/settled/options.ts +69 -0
  139. package/src/settled/send.ts +27 -0
  140. package/src/settled/settle.ts +87 -0
  141. package/src/settled/topics.ts +56 -0
  142. package/src/sync/render.ts +13 -4
  143. package/src/sync/sync.ts +25 -5
  144. package/src/terminal/herdr.ts +46 -10
  145. package/src/terminal/none.ts +1 -0
  146. package/src/terminal/types.ts +15 -2
  147. package/src/vocabulary/index.ts +47 -11
  148. package/src/watch/appointments.ts +205 -0
  149. package/src/watch/command.ts +62 -19
  150. package/src/watch/lane-report.ts +11 -3
  151. package/src/watch/texts.ts +35 -59
  152. package/src/watch/watch-events.ts +21 -28
  153. package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
@@ -0,0 +1,130 @@
1
+ import { isDeepStrictEqual } from 'node:util'
2
+ import { DEFAULT_CONTEXT_WINDOW } from './schema'
3
+
4
+ /** A config with its context shares in tokens, and a line for each block that changed. */
5
+ export type SharesInTokens = { raw: unknown; notes: string[] }
6
+
7
+ type JsonObject = Record<string, unknown>
8
+
9
+ type Pair = { share: string; tokens: string }
10
+
11
+ /** A share turned into tokens, and a share dropped because its token value is already set. */
12
+ type Conversion = Pair & { value: number; result: number }
13
+ type Drop = Pair & { value: number; kept: unknown }
14
+
15
+ type ConvertedBlock = { block: JsonObject; converted: Conversion[]; dropped: Drop[] }
16
+
17
+ const PAIRS: readonly Pair[] = [
18
+ { share: 'warnAt', tokens: 'warnTokens' },
19
+ { share: 'alertAt', tokens: 'alertTokens' },
20
+ ]
21
+
22
+ const ROUNDING = 1_000
23
+
24
+ const SHARE_ENTRY = new RegExp(`"(${PAIRS.map(({ share }) => share).join('|')})"(\\s*:\\s*)(0?\\.\\d+|0)`, 'g')
25
+
26
+ const isRecord = (value: unknown): value is JsonObject =>
27
+ typeof value === 'object' && value !== null && !Array.isArray(value)
28
+
29
+ const isShare = (value: unknown): value is number => typeof value === 'number' && value > 0 && value < 1
30
+
31
+ const sizes = (value: unknown): number[] =>
32
+ isRecord(value) ? Object.values(value).filter((size): size is number => typeof size === 'number' && size > 0) : []
33
+
34
+ /** The window a share was most likely read against: the largest the config names, the default one included. */
35
+ const windowOf = (context: JsonObject): number => {
36
+ const fallback = typeof context.defaultWindow === 'number' ? context.defaultWindow : DEFAULT_CONTEXT_WINDOW
37
+ return Math.max(fallback, ...sizes(context.windows))
38
+ }
39
+
40
+ const shareInTokens = (share: number, window: number): number => Math.round((share * window) / ROUNDING) * ROUNDING
41
+
42
+ const hasShares = (block: JsonObject): boolean => PAIRS.some(({ share }) => isShare(block[share]))
43
+
44
+ /** One block with its shares in tokens, and what changed; null when it has no share. */
45
+ const convertBlock = (block: JsonObject, window: number): ConvertedBlock | null => {
46
+ if (!hasShares(block)) return null
47
+ const next: JsonObject = {}
48
+ const converted: Conversion[] = []
49
+ const dropped: Drop[] = []
50
+ for (const [key, value] of Object.entries(block)) {
51
+ const pair = PAIRS.find(({ share }) => share === key)
52
+ if (!pair || !isShare(value)) {
53
+ next[key] = value
54
+ continue
55
+ }
56
+ if (block[pair.tokens] !== undefined) {
57
+ dropped.push({ ...pair, value, kept: block[pair.tokens] })
58
+ continue
59
+ }
60
+ const result = shareInTokens(value, window)
61
+ next[pair.tokens] = result
62
+ converted.push({ ...pair, value, result })
63
+ }
64
+ return { block: next, converted, dropped }
65
+ }
66
+
67
+ /** The words for what changed in a block: `plans.context: warnAt 0.5 is now warnTokens 100000 (share of a … window).` */
68
+ const blockNote = (label: string, window: number, { converted, dropped }: ConvertedBlock): string => {
69
+ const conversions = converted.map(
70
+ ({ share, value, tokens, result }, index) => `${share} ${value} ${index === 0 ? 'is now ' : ''}${tokens} ${result}`,
71
+ )
72
+ const drops = dropped.map(({ share, value, tokens, kept }) => `${share} ${value} dropped, ${tokens} ${kept} is set`)
73
+ const parts = [
74
+ ...(conversions.length > 0 ? [`${conversions.join(', ')} (share of a ${window} window)`] : []),
75
+ ...drops,
76
+ ]
77
+ return `${label}: ${parts.join('; ')}.`
78
+ }
79
+
80
+ /**
81
+ * Before the thresholds were tokens, `plans.context` and its `coordinator` gave them as a share of the window
82
+ * (`warnAt`, `alertAt`); this turns such a config into tokens, so it never breaks in silence.
83
+ * `raw` with the shares of `plans.context` and `plans.context.coordinator` in tokens of the largest window the
84
+ * config names, rounded to whole thousands, and a line per block that changed. A token value already set wins
85
+ * over the share beside it. Anything else, a share out of range included, stays as it is for the schema to judge.
86
+ */
87
+ export const contextSharesInTokens = (raw: unknown): SharesInTokens => {
88
+ if (!isRecord(raw) || !isRecord(raw.plans) || !isRecord(raw.plans.context)) return { raw, notes: [] }
89
+ const context = raw.plans.context
90
+ const window = windowOf(context)
91
+ const coordinator = isRecord(context.coordinator) ? convertBlock(context.coordinator, window) : null
92
+ const lane = convertBlock(context, window)
93
+ if (!lane && !coordinator) return { raw, notes: [] }
94
+ const nextContext = { ...(lane?.block ?? context), ...(coordinator ? { coordinator: coordinator.block } : {}) }
95
+ const next = { ...raw, plans: { ...raw.plans, context: nextContext } }
96
+ const notes = [
97
+ lane && blockNote('plans.context', window, lane),
98
+ coordinator && blockNote('plans.context.coordinator', window, coordinator),
99
+ ]
100
+ return { raw: next, notes: notes.filter((note): note is string => !!note) }
101
+ }
102
+
103
+ /** The text with every `"warnAt": <share>` and `"alertAt": <share>` renamed and in tokens, in place. */
104
+ const replacedInPlace = (text: string, window: number): string =>
105
+ text.replace(SHARE_ENTRY, (_match, key: string, separator: string, value: string) => {
106
+ const tokensKey = PAIRS.find(({ share }) => share === key)?.tokens ?? key
107
+ return `"${tokensKey}"${separator}${shareInTokens(Number(value), window)}`
108
+ })
109
+
110
+ const parsesAs = (text: string, expected: unknown): boolean => {
111
+ try {
112
+ return isDeepStrictEqual(JSON.parse(text), expected)
113
+ } catch {
114
+ return false
115
+ }
116
+ }
117
+
118
+ /**
119
+ * A config file's text with its shares in tokens (see `contextSharesInTokens`). The keys are replaced in place, so
120
+ * the rest of the file stays byte for byte; when the result would not read as the converted values (a share beside
121
+ * a token value, the key inside a string), the file is written anew from them.
122
+ */
123
+ export const contextSharesTextInTokens = (text: string): { text: string; notes: string[] } => {
124
+ const raw: unknown = JSON.parse(text)
125
+ const { raw: converted, notes } = contextSharesInTokens(raw)
126
+ if (notes.length === 0) return { text, notes }
127
+ const context = (raw as { plans: { context: JsonObject } }).plans.context
128
+ const inPlace = replacedInPlace(text, windowOf(context))
129
+ return { text: parsesAs(inPlace, converted) ? inPlace : `${JSON.stringify(converted, null, 2)}\n`, notes }
130
+ }
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
+ import { CLI_NAME } from '../version/release'
4
+ import { contextSharesInTokens } from './context-shares'
3
5
  import { type RepositoryFacts, withDetected } from './detect'
4
6
  import { legacyConfigNote } from './legacy-names'
5
7
  import { globalConfigPath, locateConfigFile, PROJECT_CONFIG_PATH } from './location'
@@ -9,13 +11,19 @@ import { type JackherdConfig, parseConfigFile } from './schema'
9
11
  /** A config with the values detection filled in, and a line for each value the repository did not decide. */
10
12
  export type ResolvedConfig = { config: JackherdConfig; notes: string[] }
11
13
 
14
+ /** The line for context thresholds read as tokens from a file that still gives them as a share. */
15
+ const sharesNote = (path: string, notes: readonly string[]): string[] =>
16
+ notes.length > 0 ? [`${path} gives context thresholds as a share; \`${CLI_NAME} sync\` writes them as tokens.`] : []
17
+
12
18
  /**
13
- * Parses a config file's raw JSON, filling what it leaves out from the repository (see `withDetected`); an invalid
14
- * file throws with its path and the issues in words.
19
+ * Parses a config file's raw JSON, filling what it leaves out from the repository (see `withDetected`) and reading
20
+ * context thresholds still given as a share in tokens (see `contextSharesInTokens`); an invalid file throws with its
21
+ * path and the issues in words.
15
22
  */
16
23
  const resolveConfig = (raw: unknown, facts: RepositoryFacts, path: string): ResolvedConfig => {
17
- const detected = withDetected(raw ?? {}, facts)
18
- return { config: parseConfigFile(detected.raw, path), notes: detected.notes }
24
+ const shares = contextSharesInTokens(raw ?? {})
25
+ const detected = withDetected(shares.raw, facts)
26
+ return { config: parseConfigFile(detected.raw, path), notes: [...sharesNote(path, shares.notes), ...detected.notes] }
19
27
  }
20
28
 
21
29
  /**
@@ -45,7 +45,10 @@ const modelId = z
45
45
 
46
46
  const command = z.string().min(1)
47
47
 
48
- const share = z.number().gt(0).lt(1)
48
+ const tokenCount = z.number().int().positive()
49
+
50
+ /** The window `plans.context.defaultWindow` assumes when the config leaves it out. */
51
+ export const DEFAULT_CONTEXT_WINDOW = 200_000
49
52
 
50
53
  /** A file below the main checkout, written into shell commands: relative, never leaving it, never an option, no quote or control character. */
51
54
  const laneCopyPath = z.string().regex(
@@ -54,9 +57,10 @@ const laneCopyPath = z.string().regex(
54
57
  'a relative path inside the checkout: no leading / or -, no .. segment, no quote or control character',
55
58
  )
56
59
 
57
- const warnBelowAlert = ({ warnAt, alertAt }: { warnAt: number; alertAt: number }): boolean => warnAt < alertAt
60
+ const warnBelowAlert = ({ warnTokens, alertTokens }: { warnTokens: number; alertTokens: number }): boolean =>
61
+ warnTokens < alertTokens
58
62
 
59
- const WARN_BELOW_ALERT = { message: 'warnAt must be below alertAt', path: ['warnAt'] }
63
+ const WARN_BELOW_ALERT = { message: 'warnTokens must be below alertTokens', path: ['warnTokens'] }
60
64
 
61
65
  /** The lock file, written unquoted into git commands: a plain relative path, never an option. */
62
66
  const lockFilePath = z
@@ -70,8 +74,8 @@ const lockFilePath = z
70
74
  const branchName = z
71
75
  .string()
72
76
  .regex(
73
- /^(?![-/.])(?!.*(?:\.\.|\/\/|@\{|\.lock(?:\/|$)))[A-Za-z0-9._/-]+(?<![./])$/,
74
- 'a branch name such as main: no leading - / or ., no .. and no spaces',
77
+ /^(?!HEAD$)(?![-/.])(?!.*(?:\.\.|\/\/|@\{|\.lock(?:\/|$)))[A-Za-z0-9._/-]+(?<![./])$/,
78
+ 'a branch name such as main: not HEAD, no leading - / or ., no .. and no spaces',
75
79
  )
76
80
 
77
81
  /** A workflow file name as `gh run list --workflow` takes it: no path, no option, a YAML file. */
@@ -223,10 +227,10 @@ const hasUniqueNames = (sources: readonly { name: string }[]): boolean =>
223
227
 
224
228
  const coordinatorSchema = z
225
229
  .strictObject({
226
- /** Share of the coordinator's context window from which the watcher reports `coordinator warn`. */
227
- warnAt: share.default(0.5),
228
- /** Share of the coordinator's context window from which the watcher reports `coordinator alert`. */
229
- alertAt: share.default(0.7),
230
+ /** Tokens in the coordinator's context from which the watcher reports `coordinator warn`. */
231
+ warnTokens: tokenCount.default(200_000),
232
+ /** Tokens in the coordinator's context from which the watcher reports `coordinator alert`. */
233
+ alertTokens: tokenCount.default(300_000),
230
234
  })
231
235
  .refine(warnBelowAlert, WARN_BELOW_ALERT)
232
236
 
@@ -253,10 +257,38 @@ const checkFreePattern = z
253
257
  'a pattern with a whole path segment that is no wildcard, such as docs/**/*.md',
254
258
  )
255
259
 
260
+ /**
261
+ * A path pattern that always asks before a merge under a plan approval: plain path words with wildcards, relative, no
262
+ * empty, `.` or `..` segment (GitHub lists no file with one, so such a pattern would never ask). Broad on purpose (`.github/**`, `**`), since a pattern here only ever adds a question; braces,
263
+ * classes and negation stay out, so the hook, which has no schema library, reads exactly what this accepts.
264
+ */
265
+ const alwaysAskPattern = z
266
+ .string()
267
+ .regex(/^[A-Za-z0-9_.*/-]+$/, 'path words with * and ** only, such as .github/** or apps/*/src/**')
268
+ .refine(
269
+ (pattern) => pattern.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'),
270
+ 'a relative path that stays inside the repository, without an empty or . segment',
271
+ )
272
+
273
+ /**
274
+ * The name of a check as GitHub lists it for a commit (a job name such as `test` or `build (ubuntu, 22)`, or a status
275
+ * context such as `ci/legacy`): a visible character, no control character, at most 200 characters.
276
+ */
277
+ const checkName = z
278
+ .string()
279
+ .max(200)
280
+ .refine(
281
+ (name) =>
282
+ name.trim() !== '' &&
283
+ ![...name].some((character) => character.charCodeAt(0) < 0x20 || character.charCodeAt(0) === 0x7f),
284
+ 'a check name as GitHub lists it, such as test',
285
+ )
286
+
256
287
  const mergeSchema = z.strictObject({
257
288
  /**
258
289
  * Paths of pull requests that are merged without waiting for checks: when every file of the pull request matches one
259
- * of them, `/orchestrate` merges at once and the merge guard does not ask. Empty means every merge waits for checks.
290
+ * of them, `/orchestrate` merges at once and the merge guard does not ask, for a merge bound to the head commit with
291
+ * `--match-head-commit`. Empty means every merge waits for checks.
260
292
  */
261
293
  withoutChecks: z.array(checkFreePattern).default([]),
262
294
  })
@@ -264,9 +296,10 @@ const mergeSchema = z.strictObject({
264
296
  const guardSchema = z.strictObject({
265
297
  /**
266
298
  * The commands the merge guard hook asks the user about before they run, also behind options before the verb, a chain,
267
- * a wrapper or `sh -c`. An empty list switches the guard off.
299
+ * a wrapper or `sh -c`. `gh api` asks only for a write to a merge, a file, a dispatch, a rerun or a ref. An empty list switches the
300
+ * guard off.
268
301
  */
269
- commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run']),
302
+ commands: z.array(guardedCommand).default(['gh pr merge', 'gh workflow run', 'gh run rerun', 'gh api']),
270
303
  selfMerge: z
271
304
  .strictObject({
272
305
  /** Whether `/orchestrate` offers "merge it yourself after green checks" in the pull request question; off also stops the hook honouring such an answer. */
@@ -281,6 +314,25 @@ const guardSchema = z.strictObject({
281
314
  waitMinutes: z.number().int().min(1).max(9).default(8),
282
315
  })
283
316
  .prefault({}),
317
+ planApproval: z
318
+ .strictObject({
319
+ /**
320
+ * Paths that always ask, on top of the ones jackherd brings (`.github/**`, `**\/migrations/**`, `**\/.env*`,
321
+ * `.claude/**`, `.jackherd/**`, `bunfig.toml`, `**\/CLAUDE.md`, `**\/AGENTS.md`, `.agents/**`, `.mcp.json`,
322
+ * `.husky/**`, `lefthook.yml`, the lockfiles of bun, npm, pnpm and yarn, `**\/.npmrc`, `**\/CODEOWNERS`, every
323
+ * package.json, and the CHANGELOG.md of every package without `"private": true`):
324
+ * a pull request that changes one is never merged on a plan approval alone. They add to that list, never replace
325
+ * it; an invalid entry turns plan approval off, so every merge asks.
326
+ */
327
+ exceptPaths: z.array(alwaysAskPattern).default([]),
328
+ /**
329
+ * Checks that must be among the head commit's and green before a plan approval lets a merge pass, by their names
330
+ * on GitHub. Unset, the merge needs at least one check and every one green; set, also each of these. Either way
331
+ * GitHub must be able to merge it and the head must contain the base branch.
332
+ */
333
+ requiredChecks: z.array(checkName).optional(),
334
+ })
335
+ .prefault({}),
284
336
  })
285
337
 
286
338
  const hintsSchema = z.strictObject({
@@ -290,15 +342,18 @@ const hintsSchema = z.strictObject({
290
342
 
291
343
  const contextSchema = z
292
344
  .strictObject({
293
- /** Share of the context window from which a lane's fill shows yellow and `/implement` offers a new chat. */
294
- warnAt: share.default(0.6),
295
- /** Share of the context window from which a lane's fill shows red. */
296
- alertAt: share.default(0.8),
297
- /** Context window in tokens by full model ID; the log does not say which window a session ran with. */
298
- windows: z.record(modelId, z.number().int().positive()).default({}),
345
+ /** Tokens in a lane's context from which its fill shows yellow and `/implement` offers a new chat. */
346
+ warnTokens: tokenCount.default(250_000),
347
+ /** Tokens in a lane's context from which its fill shows red and the lane hands over at the next phase boundary. */
348
+ alertTokens: tokenCount.default(350_000),
349
+ /**
350
+ * Context window in tokens by full model ID, only the upper bound of the share a view shows (the thresholds are
351
+ * tokens); the log does not say which window a session ran with.
352
+ */
353
+ windows: z.record(modelId, tokenCount).default({}),
299
354
  /** The window assumed for a model not listed in `windows`. */
300
- defaultWindow: z.number().int().positive().default(200_000),
301
- /** The coordinator's own thresholds, used by `jh context --coordinator`; the lanes keep `warnAt` and `alertAt`. */
355
+ defaultWindow: tokenCount.default(DEFAULT_CONTEXT_WINDOW),
356
+ /** The coordinator's own thresholds, used by `jh context --coordinator`; the lanes keep `warnTokens` and `alertTokens`. */
302
357
  coordinator: coordinatorSchema.prefault({}),
303
358
  })
304
359
  .refine(warnBelowAlert, WARN_BELOW_ALERT)
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * Claude Code's auto-compact window: how full the context may get before it compacts. Set by
6
+ * `CLAUDE_CODE_AUTO_COMPACT_WINDOW` (a plain token count, ahead of everything else), by `--autocompact` for one
7
+ * launch (not readable here), or in a settings file as `modelSettings.<model>.autoCompactWindow` or
8
+ * `autoCompactWindow`, a token count or `auto`.
9
+ */
10
+ export const AUTO_COMPACT_ENV = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW'
11
+
12
+ /** The settings files of a project and of the user, highest precedence first; managed settings stay out. */
13
+ const PROJECT_SETTINGS = ['.claude/settings.local.json', '.claude/settings.json']
14
+
15
+ const USER_SETTINGS = 'settings.json'
16
+
17
+ const AUTO = 'auto'
18
+
19
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
20
+ typeof value === 'object' && value !== null && !Array.isArray(value)
21
+
22
+ const isTokenCount = (value: unknown): value is number =>
23
+ typeof value === 'number' && Number.isInteger(value) && value > 0
24
+
25
+ /** A file's window for `model`: a count, `auto` (the window tuned for the model), or undefined when it sets none. */
26
+ const windowIn = (settings: unknown, model: string): number | typeof AUTO | undefined => {
27
+ if (!isRecord(settings)) return undefined
28
+ const perModel = isRecord(settings.modelSettings) ? settings.modelSettings[model] : undefined
29
+ const candidates = [isRecord(perModel) ? perModel.autoCompactWindow : undefined, settings.autoCompactWindow]
30
+ return candidates.find((value): value is number | typeof AUTO => isTokenCount(value) || value === AUTO)
31
+ }
32
+
33
+ /**
34
+ * The auto-compact window a session of `model` runs with, from the variable or else the first of `settings`
35
+ * (highest precedence first) that sets one; null when none does or the first that does says `auto`.
36
+ */
37
+ export const autoCompactWindow = (
38
+ model: string,
39
+ env: string | undefined,
40
+ settings: readonly unknown[],
41
+ ): number | null => {
42
+ if (env && /^\d+$/.test(env) && Number(env) > 0) return Number(env)
43
+ for (const file of settings) {
44
+ const window = windowIn(file, model)
45
+ if (window !== undefined) return window === AUTO ? null : window
46
+ }
47
+ return null
48
+ }
49
+
50
+ /** One line when Claude Code would compact before the context reaches `alertTokens`; null otherwise. */
51
+ export const compactsBeforeAlertNote = (window: number | null, alertTokens: number): string | null =>
52
+ window !== null && window <= alertTokens
53
+ ? `auto-compact window ${window} is not above alertTokens ${alertTokens}: Claude Code compacts before the hand-over`
54
+ : null
55
+
56
+ const readJson = (path: string): unknown[] => {
57
+ try {
58
+ return [JSON.parse(readFileSync(path, 'utf8'))]
59
+ } catch {
60
+ return []
61
+ }
62
+ }
63
+
64
+ /** The settings of `projectRoot` and of the user's Claude directory, highest precedence first; unreadable ones left out. */
65
+ export const readClaudeSettings = (projectRoot: string, userDirectory: string): unknown[] => [
66
+ ...PROJECT_SETTINGS.flatMap((path) => readJson(join(projectRoot, path))),
67
+ ...readJson(join(userDirectory, USER_SETTINGS)),
68
+ ]
@@ -1,17 +1,68 @@
1
- import { basename } from 'node:path'
1
+ import { homedir } from 'node:os'
2
+ import { basename, join } from 'node:path'
3
+ import { CLAUDE_DIRECTORY } from '../claude/paths'
2
4
  import { mainCheckout, worktreeRoot } from '../plans/git'
3
- import { readSettings } from '../plans/settings'
4
- import { laneStatusSourceFor } from '../terminal'
5
- import { defaultCoordinatorName } from './coordinator-name'
6
- import { coordinatorSettings, parseContextOptions } from './options'
7
- import { contextReport, noValueNote, unnamedPaneNote } from './report'
5
+ import { type PlansSettings, readSettings } from '../plans/settings'
6
+ import { type LaneAgent, laneStatusSourceFor } from '../terminal'
7
+ import { AUTO_COMPACT_ENV, autoCompactWindow, compactsBeforeAlertNote, readClaudeSettings } from './auto-compact'
8
+ import { resolveCoordinatorName } from './coordinator-name'
9
+ import { type ContextOptions, coordinatorSettings, parseContextOptions } from './options'
10
+ import { type ContextReport, contextReport, noValueNote, unnamedPaneNote } from './report'
8
11
  import { agentLog } from './session-log'
9
12
 
13
+ type ResolvedAgents = { lane: string; named: LaneAgent[]; agents: LaneAgent[] }
14
+
15
+ /**
16
+ * The line when the session's auto-compact window is not above the alert threshold, read from the settings of
17
+ * `projectRoot` and the user. `env` is the session's auto-compact variable, known only for the session the command
18
+ * runs in, since another agent's environment is not readable.
19
+ */
20
+ const autoCompactNote = (
21
+ report: ContextReport | null,
22
+ alertTokens: number,
23
+ projectRoot: string,
24
+ env: string | undefined,
25
+ ): string | null => {
26
+ if (!report) return null
27
+ const settings = readClaudeSettings(projectRoot, join(homedir(), CLAUDE_DIRECTORY))
28
+ return compactsBeforeAlertNote(autoCompactWindow(report.model, env, settings), alertTokens)
29
+ }
30
+
31
+ /**
32
+ * The lane the command reports on and the agents to look for it in. With `--coordinator` and no agent of that name,
33
+ * the pane the command runs in is adopted under it.
34
+ */
35
+ const resolveAgents = (
36
+ options: ContextOptions,
37
+ settings: PlansSettings,
38
+ root: string,
39
+ worktree: string,
40
+ ): ResolvedAgents => {
41
+ const source = laneStatusSourceFor(settings.adapter)
42
+ const named = source.listAgents()
43
+ const coordinatorName = resolveCoordinatorName(
44
+ basename(root),
45
+ named.map((agent) => agent.name),
46
+ )
47
+ const lane = options.lane ?? (options.coordinator ? coordinatorName : basename(worktree))
48
+ const pane = process.env.HERDR_PANE_ID
49
+ const adopt =
50
+ options.coordinator && pane && !named.some((agent) => agent.name === lane) ? { pane, name: lane } : undefined
51
+ return { lane, named, agents: adopt ? source.listAgents(adopt) : named }
52
+ }
53
+
54
+ const printNotes = ({ lane, named, agents }: ResolvedAgents, report: ContextReport | null): void => {
55
+ if (agents.length > named.length) console.error(unnamedPaneNote(lane))
56
+ if (report === null) console.error(noValueNote(agents, lane))
57
+ }
58
+
10
59
  /**
11
60
  * `jh context [<name>] [--coordinator]`: prints how full the context of an agent is as one line of JSON
12
61
  * (`tokens`, `window`, `share`, `model`, `level`), or `null` when there is no value. The name defaults to the
13
- * worktree the command runs in, with `--coordinator` to `orchestrator-<repository>`. `--coordinator` also takes
62
+ * worktree the command runs in, with `--coordinator` to `jack-<repository>`, or to `orchestrator-<repository>` while an
63
+ * agent still carries that old name. `--coordinator` also takes
14
64
  * the level from the coordinator's thresholds (`plans.context.coordinator`). Reads the session log, never writes it.
65
+ * One more line on stderr when Claude Code's auto-compact window is not above the alert threshold.
15
66
  */
16
67
  export const run = async (args: string[]): Promise<number> => {
17
68
  try {
@@ -19,18 +70,18 @@ export const run = async (args: string[]): Promise<number> => {
19
70
  const root = mainCheckout(process.cwd())
20
71
  const worktree = worktreeRoot(process.cwd())
21
72
  const settings = readSettings([worktree, root])
22
- const lane = options.lane ?? (options.coordinator ? defaultCoordinatorName(basename(root)) : basename(worktree))
73
+ const resolved = resolveAgents(options, settings, root, worktree)
23
74
  const context = options.coordinator ? coordinatorSettings(settings.context) : settings.context
24
- const source = laneStatusSourceFor(settings.adapter)
25
- const named = source.listAgents()
26
- const pane = process.env.HERDR_PANE_ID
27
- const adopt =
28
- options.coordinator && pane && !named.some((agent) => agent.name === lane) ? { pane, name: lane } : undefined
29
- const agents = adopt ? source.listAgents(adopt) : named
30
- const report = contextReport(agents, lane, agentLog, context)
75
+ const report = contextReport(resolved.agents, resolved.lane, agentLog, context)
31
76
  console.log(JSON.stringify(report))
32
- if (agents.length > named.length) console.error(unnamedPaneNote(lane))
33
- if (report === null) console.error(noValueNote(agents, lane))
77
+ printNotes(resolved, report)
78
+ const compactNote = autoCompactNote(
79
+ report,
80
+ context.alertTokens,
81
+ options.coordinator ? root : worktree,
82
+ options.lane ? undefined : process.env[AUTO_COMPACT_ENV],
83
+ )
84
+ if (compactNote) console.error(compactNote)
34
85
  return 0
35
86
  } catch (error) {
36
87
  console.error(error instanceof Error ? error.message : String(error))
@@ -1,16 +1,34 @@
1
- const PREFIX = 'orchestrator'
1
+ /** The prefix of the coordinator's herdr name, and the one it had before, kept as an alias for running sessions. */
2
+ export const COORDINATOR_NAME_PREFIX = { current: 'jack', legacy: 'orchestrator' } as const
3
+
4
+ type CoordinatorNamePrefix = (typeof COORDINATOR_NAME_PREFIX)[keyof typeof COORDINATOR_NAME_PREFIX]
5
+
2
6
  const MAX_LENGTH = 32
3
7
 
4
- /**
5
- * The herdr name a coordinator takes when none is given: `orchestrator-<repository>`. herdr names are unique
6
- * across all workspaces, so two repositories cannot both run a coordinator called `orchestrator`. The name
7
- * fits `[a-z][a-z0-9_-]{0,31}`; a repository name that leaves nothing usable gives plain `orchestrator`.
8
- */
9
- export const defaultCoordinatorName = (repository: string): string => {
8
+ const nameWith = (prefix: CoordinatorNamePrefix, repository: string): string => {
10
9
  const slug = repository
11
10
  .toLowerCase()
12
11
  .replace(/[^a-z0-9_-]+/g, '-')
13
12
  .replace(/^-+|-+$/g, '')
14
- if (!slug) return PREFIX
15
- return `${PREFIX}-${slug}`.slice(0, MAX_LENGTH).replace(/-+$/, '')
13
+ if (!slug) return prefix
14
+ return `${prefix}-${slug}`.slice(0, MAX_LENGTH).replace(/-+$/, '')
15
+ }
16
+
17
+ /**
18
+ * The herdr name a coordinator takes when none is given: `jack-<repository>`. herdr names are unique across all
19
+ * workspaces, so two repositories cannot both run a coordinator called `jack`. The name fits
20
+ * `[a-z][a-z0-9_-]{0,31}`; a repository name that leaves nothing usable gives plain `jack`.
21
+ */
22
+ export const defaultCoordinatorName = (repository: string): string =>
23
+ nameWith(COORDINATOR_NAME_PREFIX.current, repository)
24
+
25
+ /**
26
+ * The coordinator's name among the agents' `names`: `jack-<repository>`, else the old `orchestrator-<repository>` of a
27
+ * session started before the rename, else `jack-<repository>` for none. The new name wins over the old one: a new
28
+ * start always takes it, so an agent carrying it is the newer coordinator, and the old one is a leftover.
29
+ */
30
+ export const resolveCoordinatorName = (repository: string, names: readonly string[]): string => {
31
+ const current = defaultCoordinatorName(repository)
32
+ const legacy = nameWith(COORDINATOR_NAME_PREFIX.legacy, repository)
33
+ return names.includes(current) || !names.includes(legacy) ? current : legacy
16
34
  }
@@ -6,5 +6,4 @@ export {
6
6
  type ContextThresholds,
7
7
  contextLabel,
8
8
  contextLevel,
9
- contextReading,
10
9
  } from './level'
@@ -2,21 +2,28 @@ export const CONTEXT_LEVEL = { ok: 'ok', warn: 'warn', alert: 'alert' } as const
2
2
 
3
3
  export type ContextLevel = (typeof CONTEXT_LEVEL)[keyof typeof CONTEXT_LEVEL]
4
4
 
5
- export type ContextThresholds = { warnAt: number; alertAt: number }
5
+ /** Thresholds in tokens in the context, whatever the window. */
6
+ export type ContextThresholds = { warnTokens: number; alertTokens: number }
6
7
 
7
- /** A fill as a view shows it: the share of the window and which threshold it has passed. */
8
+ /** A fill as a view shows it: the share of the window and which threshold its tokens have passed. */
8
9
  export type ContextReading = { share: number; level: ContextLevel }
9
10
 
10
- /** `ok` below `warnAt`, `warn` from there, `alert` from `alertAt` on. */
11
- export const contextLevel = (share: number, { warnAt, alertAt }: ContextThresholds): ContextLevel => {
12
- if (share >= alertAt) return CONTEXT_LEVEL.alert
13
- return share >= warnAt ? CONTEXT_LEVEL.warn : CONTEXT_LEVEL.ok
14
- }
11
+ /** The highest share of the window a threshold may stand at, so a small window still hands over before it is full. */
12
+ export const WARN_SHARE_CAP = 0.6
13
+ export const ALERT_SHARE_CAP = 0.85
15
14
 
16
- export const contextReading = (share: number, thresholds: ContextThresholds): ContextReading => ({
17
- share,
18
- level: contextLevel(share, thresholds),
19
- })
15
+ /**
16
+ * `ok` below `warnTokens`, `warn` from there, `alert` from `alertTokens` on. With a `window`, each threshold stops at
17
+ * 60 % or 85 % of it.
18
+ */
19
+ export const contextLevel = (
20
+ tokens: number,
21
+ { warnTokens, alertTokens }: ContextThresholds,
22
+ window = Number.POSITIVE_INFINITY,
23
+ ): ContextLevel => {
24
+ if (tokens >= Math.min(alertTokens, window * ALERT_SHARE_CAP)) return CONTEXT_LEVEL.alert
25
+ return tokens >= Math.min(warnTokens, window * WARN_SHARE_CAP) ? CONTEXT_LEVEL.warn : CONTEXT_LEVEL.ok
26
+ }
20
27
 
21
28
  /** `62 %` */
22
29
  export const contextLabel = (share: number): string => `${Math.round(share * 100)} %`
@@ -4,17 +4,20 @@ import { type ContextLevel, type ContextThresholds, contextLevel } from './level
4
4
 
5
5
  export type ContextReport = ContextFill & { level: ContextLevel }
6
6
 
7
+ /** What a report reads its fill against: the windows and the thresholds. */
8
+ export type ReportSettings = ContextWindows & ContextThresholds
9
+
7
10
  export type AgentLogReader = (agent: Pick<LaneAgent, 'session' | 'cwd'>) => string[] | null
8
11
 
9
12
  /** The fill of one agent with its level; null without an agent, a log or an answer in it. */
10
13
  export const contextOfAgent = (
11
14
  agent: Pick<LaneAgent, 'session' | 'cwd'> | undefined,
12
15
  readLog: AgentLogReader,
13
- settings: ContextWindows & ContextThresholds,
16
+ settings: ReportSettings,
14
17
  ): ContextReport | null => {
15
18
  const lines = agent ? readLog(agent) : null
16
19
  const fill = lines && contextFill(lines, settings)
17
- return fill ? { ...fill, level: contextLevel(fill.share, settings) } : null
20
+ return fill ? { ...fill, level: contextLevel(fill.tokens, settings, fill.window) } : null
18
21
  }
19
22
 
20
23
  /**
@@ -25,7 +28,7 @@ export const contextReport = (
25
28
  agents: readonly LaneAgent[],
26
29
  lane: string,
27
30
  readLog: AgentLogReader,
28
- settings: ContextWindows & ContextThresholds,
31
+ settings: ReportSettings,
29
32
  ): ContextReport | null =>
30
33
  contextOfAgent(
31
34
  agents.find((agent) => agent.name === lane),