jackherd 1.9.0 → 1.11.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 (228) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +65 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
  5. package/dist/config.d.ts +99 -4
  6. package/dist/config.js +105 -29
  7. package/dist/plan-format.d.ts +51 -6
  8. package/dist/plan-format.js +49 -7
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +57 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/rules/rules/kit/pull-requests.md +2 -0
  50. package/kit/workflow/skills/follow/SKILL.md +3 -10
  51. package/kit/workflow/skills/implement/SKILL.md +8 -6
  52. package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
  53. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  54. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  55. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  56. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  57. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  58. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  59. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  60. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  61. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  62. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  64. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  65. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  66. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  67. package/kit/workflow/skills/plan/SKILL.md +6 -5
  68. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  69. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  70. package/package.json +3 -1
  71. package/recipes/README.md +2 -1
  72. package/recipes/sources.md +5 -1
  73. package/schemas/jackherd.schema.json +139 -5
  74. package/src/check/agent-text-reviewers.ts +23 -0
  75. package/src/check/command.ts +2 -0
  76. package/src/check/plan-structure-check.ts +49 -2
  77. package/src/check/plan.ts +8 -1
  78. package/src/check/schema.ts +9 -0
  79. package/src/check/skill-budget-defaults.ts +55 -0
  80. package/src/check/skill-budgets.ts +185 -0
  81. package/src/cli.ts +2 -0
  82. package/src/config/auto-window.ts +8 -0
  83. package/src/config/link-kinds.ts +7 -1
  84. package/src/config/schema.ts +82 -25
  85. package/src/config/tones.ts +27 -0
  86. package/src/lane/close-board.ts +106 -0
  87. package/src/lane/close-check.ts +183 -0
  88. package/src/lane/close-command-line.ts +11 -0
  89. package/src/lane/close-command.ts +217 -0
  90. package/src/lane/close-options.ts +43 -0
  91. package/src/lane/close-survey.ts +83 -0
  92. package/src/lane/close.ts +141 -0
  93. package/src/lane/command.ts +13 -0
  94. package/src/lane-prompt/additions.ts +14 -0
  95. package/src/lane-prompt/build.ts +74 -0
  96. package/src/lane-prompt/command.ts +93 -0
  97. package/src/lane-prompt/input.ts +157 -0
  98. package/src/lane-prompt/options.ts +70 -0
  99. package/src/lane-prompt/texts.ts +101 -0
  100. package/src/mermaid/command.ts +36 -0
  101. package/src/mermaid/problems.ts +48 -0
  102. package/src/plan-format/clock.ts +8 -0
  103. package/src/plan-format/dependencies.ts +9 -6
  104. package/src/plan-format/index.ts +1 -0
  105. package/src/plan-format/short-names.ts +71 -0
  106. package/src/plan-format/waiting.ts +22 -4
  107. package/src/plans/appointment-rows.ts +83 -0
  108. package/src/plans/ask-coordinator.ts +111 -0
  109. package/src/plans/ask-target.ts +36 -0
  110. package/src/plans/board-close.ts +85 -0
  111. package/src/plans/child-stop.ts +51 -0
  112. package/src/plans/closable-records.ts +34 -0
  113. package/src/plans/command.ts +3 -2
  114. package/src/plans/fetch.ts +26 -3
  115. package/src/plans/frame.ts +5 -12
  116. package/src/plans/git.ts +54 -5
  117. package/src/plans/groomed.ts +27 -0
  118. package/src/plans/herd-claim.ts +173 -0
  119. package/src/plans/herd-memory.ts +36 -25
  120. package/src/plans/lane-plans.ts +44 -13
  121. package/src/plans/lane-summary.ts +253 -45
  122. package/src/plans/layout-phase.ts +36 -11
  123. package/src/plans/layout-summary.ts +89 -18
  124. package/src/plans/layout.ts +502 -98
  125. package/src/plans/link-chips.ts +23 -4
  126. package/src/plans/live.ts +178 -23
  127. package/src/plans/next-rows.ts +44 -0
  128. package/src/plans/phase-ids.ts +8 -0
  129. package/src/plans/phase-source.ts +15 -0
  130. package/src/plans/plan-cache.ts +28 -0
  131. package/src/plans/plan-heads.ts +54 -0
  132. package/src/plans/planned.ts +22 -0
  133. package/src/plans/pull-requests.ts +17 -2
  134. package/src/plans/question-records.ts +48 -0
  135. package/src/plans/questions-read.ts +13 -0
  136. package/src/plans/read-cache.ts +8 -0
  137. package/src/plans/record-id.ts +4 -0
  138. package/src/plans/restart-decision.ts +13 -4
  139. package/src/plans/row-actions.ts +52 -0
  140. package/src/plans/row-focus.ts +148 -0
  141. package/src/plans/row-targets.ts +7 -0
  142. package/src/plans/selection.ts +111 -18
  143. package/src/plans/sent-files-read.ts +6 -15
  144. package/src/plans/sent-files.ts +7 -14
  145. package/src/plans/settings.ts +12 -1
  146. package/src/plans/signalled-phase.ts +42 -0
  147. package/src/plans/signals-read.ts +12 -4
  148. package/src/plans/snapshot.ts +162 -81
  149. package/src/plans/source-asked.ts +19 -0
  150. package/src/plans/source-prefix.ts +17 -0
  151. package/src/plans/source-rows.ts +188 -0
  152. package/src/plans/sprint-export.ts +11 -11
  153. package/src/plans/sprint-page.ts +5 -5
  154. package/src/plans/subagent-phases.ts +95 -0
  155. package/src/plans/subagent-tasks.ts +51 -0
  156. package/src/plans/subagents-read.ts +66 -17
  157. package/src/plans/subagents.ts +64 -6
  158. package/src/plans/supervisor.ts +14 -4
  159. package/src/plans/symbols.ts +3 -0
  160. package/src/plans/task-name.ts +34 -0
  161. package/src/plans/texts.ts +48 -6
  162. package/src/plans/tones.ts +30 -0
  163. package/src/plans/view.ts +25 -11
  164. package/src/plans/waiting-click.ts +295 -35
  165. package/src/plans/waiting-records.ts +39 -0
  166. package/src/plans/waiting-window.ts +8 -0
  167. package/src/preview/command.ts +25 -3
  168. package/src/shared/private-location.ts +89 -0
  169. package/src/shared/stat-cache.ts +48 -0
  170. package/src/sync/kit-source.ts +29 -10
  171. package/src/sync/kit.ts +2 -0
  172. package/src/sync/manifest.ts +2 -0
  173. package/src/sync/plan-ask-rules.ts +94 -0
  174. package/src/sync/plan-hooks.ts +2 -0
  175. package/src/sync/render.ts +3 -3
  176. package/src/sync/settings-hooks.ts +4 -1
  177. package/src/sync/sync-plan.ts +1 -0
  178. package/src/sync/sync.ts +24 -3
  179. package/src/task-signals/claude-read.ts +27 -10
  180. package/src/terminal/text.ts +16 -3
  181. package/src/terminal/types.ts +4 -0
  182. package/src/version/skill-diff.ts +46 -0
  183. package/src/vocabulary/index.ts +26 -6
  184. package/src/waiting/command.ts +22 -7
  185. package/src/watch/appointments.ts +1 -2
  186. package/src/watch/asking-lanes.ts +40 -0
  187. package/src/watch/base-tip.ts +11 -0
  188. package/src/watch/command.ts +330 -105
  189. package/src/watch/heartbeat-progress.ts +6 -18
  190. package/src/watch/kit-update.ts +28 -9
  191. package/src/watch/lane-name.ts +6 -0
  192. package/src/watch/lane-progress.ts +40 -8
  193. package/src/watch/lane-report-file.ts +20 -5
  194. package/src/watch/lane-report.ts +32 -4
  195. package/src/watch/lane-sources.ts +31 -3
  196. package/src/watch/main-checkout.ts +34 -0
  197. package/src/watch/plan-badges-read.ts +22 -9
  198. package/src/watch/plan-badges.ts +10 -2
  199. package/src/watch/pull-requests.ts +6 -2
  200. package/src/watch/reference-paths.ts +87 -0
  201. package/src/watch/report-questions.ts +4 -3
  202. package/src/watch/running-subagents.ts +43 -0
  203. package/src/watch/skill-change.ts +124 -32
  204. package/src/watch/source-change.ts +48 -0
  205. package/src/watch/source-entries.ts +55 -0
  206. package/src/watch/source-fields.ts +1 -0
  207. package/src/watch/source-statuses.ts +60 -0
  208. package/src/watch/source-tickets.ts +21 -1
  209. package/src/watch/sources.ts +47 -4
  210. package/src/watch/stream-output.ts +4 -3
  211. package/src/watch/task-parallel-auto.ts +208 -0
  212. package/src/watch/task-parallel-data-paths.ts +46 -0
  213. package/src/watch/task-parallel-input.ts +5 -15
  214. package/src/watch/task-parallel-read.ts +3 -1
  215. package/src/watch/task-parallel.ts +36 -7
  216. package/src/watch/task-signal-missing.ts +69 -0
  217. package/src/watch/task-signal-read.ts +29 -0
  218. package/src/watch/texts.ts +10 -0
  219. package/src/watch/update-available.ts +6 -3
  220. package/src/watch/watch-events.ts +23 -0
  221. package/src/watch/watcher-lock.ts +25 -29
  222. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  223. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  224. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  225. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  226. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  227. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  228. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,32 +1,22 @@
1
1
  import { posix } from 'node:path'
2
- import { commandTextRunnerIndex, searchableText } from '../shared/command-text-runners'
3
2
  import {
4
3
  ASSIGNMENT,
5
4
  inlineScript,
6
5
  isOption,
7
6
  isProgramPosition,
8
7
  programName,
9
- readsScriptFromInput,
10
8
  ruleStart,
11
- runsInterpreterProgram,
12
9
  } from '../shared/program-position'
13
10
  import {
14
11
  expandingHereDocumentAdvice,
15
- isExpanded,
16
12
  parseShell,
17
13
  ShellParseError,
18
14
  type SimpleCommand,
19
15
  UNREADABLE_ADVICE,
20
16
  type Word,
21
17
  } from '../shared/shell'
22
- import {
23
- definesShellAlias,
24
- mayRunDefinedFunction,
25
- NO_DEFINITIONS,
26
- type ShellDefinitions,
27
- shellDefinitionAt,
28
- shellDefinitionHint,
29
- } from '../shared/shell-aliases'
18
+ import { NO_DEFINITIONS, type ShellDefinitions, shellDefinitionAt, shellDefinitionHint } from '../shared/shell-aliases'
19
+ import { resolvePlainXargs } from '../shared/xargs-program'
30
20
  import { type PushCall, type PushContext, pushTargetDenial } from './push-target'
31
21
 
32
22
  export type GitGuardDecision = { kind: 'pass' } | { kind: 'deny'; reason: string }
@@ -83,8 +73,11 @@ type SplitArguments = { options: string[]; operands: string[] }
83
73
 
84
74
  const PASS: GitGuardDecision = { kind: 'pass' }
85
75
 
86
- /** The programs whose alias or function the guard refuses: one that runs git or gh hides it from the rules (D42). */
87
- const DEFINITION_PROGRAMS = ['git', 'gh']
76
+ /**
77
+ * The programs the guard watches: an alias or function that runs one is refused, since it hides the call from the
78
+ * rules (D42), and `xargs` stays opaque in front of one (D77).
79
+ */
80
+ const GUARDED_PROGRAMS = ['git', 'gh']
88
81
 
89
82
  const FORCE_PUSH =
90
83
  'A force push is refused. Use `--force-with-lease` instead, and only after the user agreed to rewrite the remote branch.'
@@ -99,8 +92,6 @@ const BRACE_HINT =
99
92
  'Refused: an option of this git call is written as a brace expansion, so neither the permission rules nor this hook can tell what git receives. Write the option plainly.'
100
93
  const INLINE_EXPANSION_HINT =
101
94
  'Refused: `eval` or `sh -c` runs a script held in a variable or a substitution, so neither the permission rules nor this hook can read it. Write the commands out.'
102
- const EXPANDED_FUNCTION_HINT =
103
- 'Refused: the program is expanded from a variable or a substitution, and it may name a shell function that runs git or gh, which hides the command from the permission rules. Run the command written out instead.'
104
95
  const ALIAS_HINT =
105
96
  'Refused: `-c alias.…` defines a git alias inline, which hides the verb from the permission rules. Run the git command it stands for directly.'
106
97
 
@@ -188,7 +179,6 @@ const BRANCH_MOVING_VERBS = new Set([
188
179
  'update-ref',
189
180
  'worktree',
190
181
  ])
191
- const PROTECTED_MENTION = new RegExp(`\\bgit\\b[\\s\\S]*\\b(${Object.values(PROTECTED_VERB).join('|')})\\b`)
192
182
 
193
183
  const isProtectedVerb = (value: string): value is ProtectedVerb =>
194
184
  (Object.values(PROTECTED_VERB) as string[]).includes(value)
@@ -354,41 +344,23 @@ const judgeExpandedProgram = (words: SimpleCommand): GitGuardDecision => {
354
344
  const firstDenial = (decisions: GitGuardDecision[]): GitGuardDecision =>
355
345
  decisions.find((decision) => decision.kind === 'deny') ?? PASS
356
346
 
357
- const denyIfMentionsProtectedVerb = (script: string, reason: string): GitGuardDecision => {
358
- const verb = script.match(PROTECTED_MENTION)?.[1]
359
- return verb ? deny(`Refused: ${reason}, and it mentions \`git ${verb}\`. ${plainInstead(verb)}`) : PASS
360
- }
361
-
362
347
  const judgeDefinition = (
363
348
  words: SimpleCommand,
364
349
  index: number,
365
350
  definitions: () => ShellDefinitions,
366
351
  ): GitGuardDecision => {
367
- const definition = shellDefinitionAt(words, index, definitions, DEFINITION_PROGRAMS)
352
+ const definition = shellDefinitionAt(words, index, definitions, GUARDED_PROGRAMS)
368
353
  return definition ? deny(`Refused: ${shellDefinitionHint(definition)}`) : PASS
369
354
  }
370
355
 
371
- /**
372
- * A program that runs command text it is given (`watch '…'`, sed's `e`) is refused when that text names a protected
373
- * verb; when the text is itself an expansion, the searchable script is searched instead.
374
- */
375
- const judgeCommandText = (words: SimpleCommand, searchable: string): GitGuardDecision => {
376
- const runner = commandTextRunnerIndex(words)
377
- if (runner === -1) return PASS
378
- const given = words.slice(runner + 1)
379
- const text = given.some(({ value }) => isExpanded(value)) ? searchable : given.map(({ value }) => value).join(' ')
380
- return denyIfMentionsProtectedVerb(text, 'a program runs command text it is given, which no rule inspects')
381
- }
382
-
383
356
  const judgeWord = (
384
357
  words: SimpleCommand,
385
358
  index: number,
386
359
  context: ScriptContext,
387
360
  definitions: () => ShellDefinitions,
388
- searchable: string,
389
361
  ): GitGuardDecision => {
390
362
  const script = inlineScript(words, index)
391
- if (script !== undefined) return judgeScript(script, SCRIPT_CONTEXT.nested, definitions, searchable)
363
+ if (script !== undefined) return judgeScript(script, SCRIPT_CONTEXT.nested, definitions)
392
364
  if (!isGit(words[index])) return judgeDefinition(words, index, definitions)
393
365
  const invocation = readInvocation(words, index)
394
366
  return invocation ? judgeInvocation(invocation, gitPosition(words, index, context)) : PASS
@@ -398,83 +370,58 @@ const judgeWord = (
398
370
  const judgeInlineExpansion = (words: SimpleCommand, context: ScriptContext): GitGuardDecision =>
399
371
  context === SCRIPT_CONTEXT.nested && runsExpandedProgram(words) ? deny(INLINE_EXPANSION_HINT) : PASS
400
372
 
401
- const judgeExpandedFunction = (
402
- words: SimpleCommand,
403
- definitions: () => ShellDefinitions,
404
- searchable: string,
405
- ): GitGuardDecision =>
406
- mayRunDefinedFunction(words, definitions, DEFINITION_PROGRAMS, searchable) ? deny(EXPANDED_FUNCTION_HINT) : PASS
407
-
408
373
  const judgeCommand = (
409
374
  words: SimpleCommand,
410
375
  context: ScriptContext,
411
376
  definitions: () => ShellDefinitions,
412
- searchable: string,
413
377
  ): GitGuardDecision =>
414
378
  firstDenial([
415
- ...words.map((_word, index) => judgeWord(words, index, context, definitions, searchable)),
379
+ ...words.map((_word, index) => judgeWord(words, index, context, definitions)),
416
380
  judgeInlineExpansion(words, context),
417
381
  judgeExpandedProgram(words),
418
- judgeCommandText(words, searchable),
419
- judgeExpandedFunction(words, definitions, searchable),
420
382
  ])
421
383
 
384
+ /** The first protected verb a parsed git call of the script runs, where a set git variable may reach it. */
385
+ const protectedVerbRun = (commands: SimpleCommand[]): ProtectedVerb | undefined =>
386
+ commands
387
+ .flatMap((words) =>
388
+ words.flatMap((word, index) =>
389
+ isGit(word) && isProgramPosition(words, index) ? [readInvocation(words, index)?.verb.value ?? ''] : [],
390
+ ),
391
+ )
392
+ .find(isProtectedVerb)
393
+
422
394
  /**
423
- * What no single command shows. Each check reads the whole script, so a harmless git call beside
424
- * a shell that reads stdin, a set git variable or an expanded program is refused too: an accepted
425
- * over-refusal for rare shapes.
395
+ * A `HUSKY`, `LEFTHOOK`, `SKIP` or `GIT_*` variable set anywhere in the script may reach a later git call, not only
396
+ * the one it precedes: refused when a parsed git call of the script runs a protected verb. Text that only names the
397
+ * verb is no call (D108).
426
398
  */
427
- const judgeScriptWide = (script: string, commands: SimpleCommand[]): GitGuardDecision => {
428
- if (commands.some(readsScriptFromInput)) {
429
- return denyIfMentionsProtectedVerb(script, 'a shell reads its script from input here, which no rule inspects')
430
- }
431
- if (commands.some(runsInterpreterProgram)) {
432
- return denyIfMentionsProtectedVerb(
433
- script,
434
- 'an interpreter runs program text given inline or on its input, which no rule inspects',
435
- )
436
- }
437
- if (commands.some(definesShellAlias)) {
438
- return denyIfMentionsProtectedVerb(
439
- script,
440
- 'the script defines a shell alias, which hides its command from the rules',
441
- )
442
- }
443
- if (commands.some(setsGitVariable)) {
444
- return denyIfMentionsProtectedVerb(
445
- script,
446
- 'a `HUSKY`, `LEFTHOOK`, `SKIP` or `GIT_*` variable is set, which changes the git calls',
447
- )
448
- }
449
- if (commands.some(runsExpandedProgram)) {
450
- return denyIfMentionsProtectedVerb(
451
- script,
452
- 'a program is expanded from a variable or a substitution and could be git',
453
- )
454
- }
455
- return PASS
399
+ const judgeGitVariables = (commands: SimpleCommand[]): GitGuardDecision => {
400
+ const verb = commands.some(setsGitVariable) ? protectedVerbRun(commands) : undefined
401
+ return verb
402
+ ? deny(
403
+ `Refused: a \`HUSKY\`, \`LEFTHOOK\`, \`SKIP\` or \`GIT_*\` variable is set, which changes the git calls, and the script runs \`git ${verb}\`. ${plainInstead(verb)}`,
404
+ )
405
+ : PASS
456
406
  }
457
407
 
458
- /** `whole` is the text the mention search reads at the outer level: a nested script's variables are set there. */
408
+ /**
409
+ * Only the parsed script counts (D108): text handed to an interpreter, a shell's input, a program's command text
410
+ * (`watch '…'`, sed's `e`) and a definition in the script are not searched.
411
+ */
459
412
  const judgeParsed = (
460
413
  commands: SimpleCommand[],
461
414
  context: ScriptContext,
462
415
  definitions: () => ShellDefinitions,
463
- whole: string,
464
- ): GitGuardDecision => {
465
- const text = searchableText(whole, commands)
466
- return firstDenial([
467
- ...commands.map((words) => judgeCommand(words, context, definitions, text)),
468
- judgeScriptWide(text, commands),
469
- ])
470
- }
416
+ ): GitGuardDecision =>
417
+ firstDenial([...commands.map((words) => judgeCommand(words, context, definitions)), judgeGitVariables(commands)])
471
418
 
472
- const judgeScript = (
473
- script: string,
474
- context: ScriptContext,
475
- definitions: () => ShellDefinitions,
476
- whole: string,
477
- ): GitGuardDecision => judgeParsed(parseShell(script), context, definitions, whole)
419
+ /** The script's commands, with `xargs` and a plain program read as that program's call (D77, D108). */
420
+ const parseScript = (script: string): SimpleCommand[] =>
421
+ parseShell(script).map((words) => resolvePlainXargs(words, GUARDED_PROGRAMS))
422
+
423
+ const judgeScript = (script: string, context: ScriptContext, definitions: () => ShellDefinitions): GitGuardDecision =>
424
+ judgeParsed(parseScript(script), context, definitions)
478
425
 
479
426
  /** A `cd` or a git call that switches, renames or reconfigures the branch: `HEAD` at a later push is another one. */
480
427
  const movesHead = (words: SimpleCommand): boolean =>
@@ -518,8 +465,8 @@ const withHereDocumentAdvice = (decision: GitGuardDecision, script: string): Git
518
465
 
519
466
  const judgeTop = (command: string, push: PushContext, definitions: () => ShellDefinitions): GitGuardDecision => {
520
467
  try {
521
- const commands = parseShell(command)
522
- const decision = judgeParsed(commands, SCRIPT_CONTEXT.top, definitions, command)
468
+ const commands = parseScript(command)
469
+ const decision = judgeParsed(commands, SCRIPT_CONTEXT.top, definitions)
523
470
  return decision.kind === 'deny' ? decision : judgePushes(commands, push)
524
471
  } catch (error) {
525
472
  if (error instanceof ShellParseError) return denyUnreadable(error)
@@ -530,11 +477,12 @@ const judgeTop = (command: string, push: PushContext, definitions: () => ShellDe
530
477
  /**
531
478
  * Decides on a Bash command before it runs. Denies a protected git verb that a permission rule
532
479
  * written as `git <verb> …` would not match (options or `GIT_*` variables before the verb, a
533
- * wrapper, a path to git, a quoted verb, a line continuation, `sh -c`, `eval`, a script piped into
534
- * a shell, an interpreter's program text), and denies a force push, a skipped hook and a hard reset in any
535
- * form. Denies a push that updates the base branch, or whose target it cannot tell, every script it cannot read,
536
- * and a shell alias or function (`definitions`, read only for a word that is no known program) that runs git or gh.
537
- * Everything else passes on to the project's own permission rules.
480
+ * wrapper, a path to git, a quoted verb, a line continuation, `sh -c`, `eval`), and denies a force push, a skipped
481
+ * hook and a hard reset in any form the parse shows. Denies a push that updates the base branch, or whose target it
482
+ * cannot tell, every script it cannot read, and a shell alias or function (`definitions`, read only for a word that
483
+ * is no known program) that runs git or gh. Only the parsed script counts (D108): an interpreter's program text, a
484
+ * script piped into a shell and a program's command text are not searched. Everything else passes on to the
485
+ * project's own permission rules.
538
486
  */
539
487
  export const decideBashCommand = (
540
488
  command: string,
@@ -0,0 +1,63 @@
1
+ /** Workflows and the actions they run. */
2
+ const WORKFLOW_PATHS: readonly string[] = ['.github/**']
3
+
4
+ /** Migrations, in a `migrations` folder or as SQL in a `drizzle` folder. */
5
+ export const MIGRATION_PATHS: readonly string[] = ['**/migrations/**', '**/drizzle/*.sql']
6
+
7
+ /** Env files: `.env`, `.env.local`, `prod.env`, `.dev.vars`. */
8
+ export const ENV_PATHS: readonly string[] = ['**/.env*', '**/*.env', '**/.dev.vars']
9
+
10
+ /**
11
+ * The guards and what runs before them: the project's hooks and settings, the jackherd config, git hooks and
12
+ * `bunfig.toml`, whose `preload` runs before every hook (they start in the project root, so only the root one).
13
+ */
14
+ const GUARD_PATHS: readonly string[] = [
15
+ '.claude/hooks/**',
16
+ '.claude/settings*.json',
17
+ '.jackherd/**',
18
+ '.husky/**',
19
+ 'lefthook.yml',
20
+ 'bunfig.toml',
21
+ ]
22
+
23
+ /**
24
+ * The paths a standing approval never covers: workflows, migrations, env files, and the guards themselves, so a pull
25
+ * request cannot loosen the guard that merges it. Skills, agents and rules stay covered.
26
+ */
27
+ export const STANDING_ALWAYS_ASK: readonly string[] = [...WORKFLOW_PATHS, ...MIGRATION_PATHS, ...ENV_PATHS, ...GUARD_PATHS]
28
+
29
+ /**
30
+ * The paths that always ask, whatever the project adds (`guard.alwaysAsk`): workflows, migrations, env files, the
31
+ * agent setup and its instructions, the jackherd config, `bunfig.toml`, git hooks, lockfiles, registry config and code
32
+ * owners. A `merge.withoutChecks` pattern never opens them, and the watcher never starts parallel work on them by itself.
33
+ */
34
+ export const ALWAYS_ASK_PATHS: readonly string[] = [
35
+ ...WORKFLOW_PATHS,
36
+ ...MIGRATION_PATHS,
37
+ ...ENV_PATHS,
38
+ '.claude/**',
39
+ '.jackherd/**',
40
+ 'bunfig.toml',
41
+ '**/CLAUDE.md',
42
+ '**/AGENTS.md',
43
+ '.agents/**',
44
+ '.mcp.json',
45
+ '.husky/**',
46
+ 'lefthook.yml',
47
+ '**/bun.lock',
48
+ '**/bun.lockb',
49
+ '**/package-lock.json',
50
+ '**/pnpm-lock.yaml',
51
+ '**/yarn.lock',
52
+ '**/.npmrc',
53
+ '**/CODEOWNERS',
54
+ ]
55
+
56
+ /**
57
+ * The files that lie on one of the patterns, compared in lower case: on a case-insensitive file system git writes
58
+ * `.GitHub/x` into the existing `.github/`, so a pattern must not miss it. Folding only ever adds a question.
59
+ */
60
+ export const alwaysAskingFiles = (files: readonly string[], patterns: readonly string[]): string[] => {
61
+ const globs = patterns.map((pattern) => new Bun.Glob(pattern.toLowerCase()))
62
+ return files.filter((file) => globs.some((glob) => glob.match(file.toLowerCase())))
63
+ }
@@ -1,15 +1,14 @@
1
1
  import { baseBranchOf } from '../shared/base-branch'
2
2
  import { BOARD_WORDS, type BoardLanguage } from '../shared/board-lanes'
3
- import { isRecord, parseJsonObject } from '../shared/json'
3
+ import { isRecord } from '../shared/json'
4
4
  import { isRepositoryDirectory } from '../shared/repository-directory'
5
5
  import { isControlCharacter } from './plain-path'
6
6
 
7
7
  /**
8
- * What plan approval needs: the paths that always ask (the defaults plus the project's own) and where plans, boards
9
- * and lanes live. `null` from `readPlanApprovalSettings` means plan approval is off and every merge asks.
8
+ * Where plans, boards and lanes live, the base branch, and the checks a standing approval waits for. `null` from
9
+ * `boardSettingsOf` means the standing approval is off and every merge asks.
10
10
  */
11
- export type PlanApprovalSettings = {
12
- exceptPaths: readonly string[]
11
+ export type BoardSettings = {
13
12
  planLanguage: BoardLanguage
14
13
  planDirectory: string
15
14
  board: { pattern: string; file: string }
@@ -19,46 +18,6 @@ export type PlanApprovalSettings = {
19
18
  requiredChecks: readonly string[] | undefined
20
19
  }
21
20
 
22
- /**
23
- * The paths that always ask, whatever the project adds: workflows, migrations, env files, the agent setup and its
24
- * instructions, the jackherd config, `bunfig.toml`, whose `preload` runs before every hook (they start in the project
25
- * root, so only the root one), git hooks, lockfiles, registry config and code owners.
26
- */
27
- export const PLAN_APPROVAL_ALWAYS_ASK: readonly string[] = [
28
- '.github/**',
29
- '**/migrations/**',
30
- '**/.env*',
31
- '.claude/**',
32
- '.jackherd/**',
33
- 'bunfig.toml',
34
- '**/CLAUDE.md',
35
- '**/AGENTS.md',
36
- '.agents/**',
37
- '.mcp.json',
38
- '.husky/**',
39
- 'lefthook.yml',
40
- '**/bun.lock',
41
- '**/bun.lockb',
42
- '**/package-lock.json',
43
- '**/pnpm-lock.yaml',
44
- '**/yarn.lock',
45
- '**/.npmrc',
46
- '**/CODEOWNERS',
47
- ]
48
-
49
- /** Where a project keeps its jackherd config, as a path inside the repository. */
50
- export const PROJECT_CONFIG = '.jackherd/config.json'
51
-
52
- /**
53
- * The same rules as `alwaysAskPattern` in the config schema: path words with wildcards, relative, no empty, `.` or
54
- * `..` segment (GitHub lists no file with one, so such a pattern would never ask). Broad on purpose (`.github/**`,
55
- * `**`): a pattern here only ever adds a question.
56
- */
57
- const isAlwaysAskPattern = (value: unknown): value is string =>
58
- typeof value === 'string' &&
59
- /^[A-Za-z0-9_.*/-]+$/.test(value) &&
60
- value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
61
-
62
21
  const MAX_CHECK_NAME = 200
63
22
  const BOARD_PATTERN = /^(?![-.])[A-Za-z0-9._*?-]+$/
64
23
  const BOARD_FILE = /^(?![-.])[A-Za-z0-9._-]+\.md$/
@@ -84,8 +43,6 @@ const isSectionOrAbsent = (config: Record<string, unknown>, key: string): boolea
84
43
  const sectionAt = (config: Record<string, unknown>, key: string): Record<string, unknown> =>
85
44
  isRecord(config[key]) ? config[key] : {}
86
45
 
87
- const isPathList = (value: unknown): value is string[] => Array.isArray(value) && value.every(isAlwaysAskPattern)
88
-
89
46
  /** The same rules as `checkName` in the config schema: a name with a visible character, no control character. */
90
47
  const isCheckName = (value: unknown): value is string =>
91
48
  typeof value === 'string' &&
@@ -100,13 +57,17 @@ const sectionsAreObjects = (config: Record<string, unknown>): boolean =>
100
57
  isSectionOrAbsent(sectionAt(config, 'guard'), 'planApproval') &&
101
58
  isSectionOrAbsent(config, 'board')
102
59
 
103
- /** Every value plan approval reads, with its default where absent; `undefined` stands for a present but invalid one. */
104
- const planApprovalValues = (config: Record<string, unknown>) => {
60
+ /** `guard.requiredChecks`, else where configs before 1.10.0 kept it, `guard.planApproval.requiredChecks`. */
61
+ const requiredChecksOf = (guard: Record<string, unknown>) =>
62
+ 'requiredChecks' in guard
63
+ ? optionalValue(guard, 'requiredChecks', isCheckList)
64
+ : optionalValue(sectionAt(guard, 'planApproval'), 'requiredChecks', isCheckList)
65
+
66
+ /** Every value the standing approval reads, with its default where absent; `undefined` stands for a present but invalid one. */
67
+ const boardValues = (config: Record<string, unknown>) => {
105
68
  const board = sectionAt(config, 'board')
106
- const planApproval = sectionAt(sectionAt(config, 'guard'), 'planApproval')
107
69
  return {
108
- exceptPaths: valueOr(planApproval, 'exceptPaths', isPathList, []),
109
- requiredChecks: optionalValue(planApproval, 'requiredChecks', isCheckList),
70
+ requiredChecks: requiredChecksOf(sectionAt(config, 'guard')),
110
71
  planLanguage: valueOr(config, 'planLanguage', isPlanLanguage, 'en'),
111
72
  planDirectory: valueOr(config, 'planDirectory', isRepositoryDirectory, 'docs/plans'),
112
73
  pattern: valueOr(board, 'pattern', matches(BOARD_PATTERN), 'board-*'),
@@ -116,27 +77,20 @@ const planApprovalValues = (config: Record<string, unknown>) => {
116
77
  }
117
78
  }
118
79
 
119
- type Values = ReturnType<typeof planApprovalValues>
80
+ type Values = ReturnType<typeof boardValues>
120
81
  type ValidValues = { [Key in keyof Values]: Exclude<Values[Key], undefined> }
121
82
 
122
83
  const allValid = (values: Values): values is ValidValues => Object.values(values).every((value) => value !== undefined)
123
84
 
124
85
  /**
125
- * The plan approval settings of the project's `.jackherd/config.json` as the base commit holds it (`null`: it holds
126
- * none). Unlike the other guard settings, a missing config, one that is no JSON object, or any invalid value they read
127
- * turns plan approval off (`null`), so neither a mistyped project path nor a home config can shrink the list silently.
128
- * The project's paths add to `PLAN_APPROVAL_ALWAYS_ASK`.
86
+ * The board settings of the project's `.jackherd/config.json` (`null`: none could be read). Unlike the other guard
87
+ * settings, a config that is no JSON object or any invalid value it reads turns the standing approval off (`null`), so
88
+ * a mistyped path cannot point it at another board, nor a mistyped check name drop the checks it waits for.
129
89
  */
130
- export const planApprovalSettingsOf = (text: string | null): PlanApprovalSettings | null => {
131
- const config = text === null ? null : (parseJsonObject(text) ?? null)
90
+ export const boardSettingsOf = (config: Record<string, unknown> | null): BoardSettings | null => {
132
91
  if (config === null || !sectionsAreObjects(config)) return null
133
- const values = planApprovalValues(config)
92
+ const values = boardValues(config)
134
93
  if (!allValid(values)) return null
135
- const { exceptPaths, requiredChecks, pattern, file, ...where } = values
136
- return {
137
- exceptPaths: [...PLAN_APPROVAL_ALWAYS_ASK, ...exceptPaths],
138
- board: { pattern, file },
139
- ...where,
140
- requiredChecks: requiredChecks.value,
141
- }
94
+ const { requiredChecks, pattern, file, ...where } = values
95
+ return { ...where, board: { pattern, file }, requiredChecks: requiredChecks.value }
142
96
  }
@@ -4,6 +4,7 @@
4
4
  * "no", so the guard asks as before.
5
5
  */
6
6
 
7
+ import { alwaysAskingFiles } from './always-ask'
7
8
  import { runGh } from './gh'
8
9
  import { isPlainPath } from './plain-path'
9
10
  import type { RepositoryFacts } from './pull-request'
@@ -46,6 +47,25 @@ export const allFilesMatch = (files: readonly string[], patterns: readonly strin
46
47
  return files.every((file) => isPlainPath(file) && globs.some((glob) => glob.match(file)))
47
48
  }
48
49
 
50
+ /** Files that never merge unchecked: a manifest can drop `"private"` or add a dependency, a changelog can cut a release. */
51
+ const NEVER_CHECK_FREE = ['package.json', 'changelog.md']
52
+
53
+ const fileName = (file: string): string => file.slice(file.lastIndexOf('/') + 1).toLowerCase()
54
+
55
+ /**
56
+ * Whether a pull request merges without a question and without checks: every file matches `patterns`
57
+ * (`allFilesMatch`), none lies on a path that always asks (`alwaysAsk`, the defaults plus the project's own), and none
58
+ * is a `package.json` or a changelog. A pattern that reaches such a path only ever adds the question back.
59
+ */
60
+ export const isCheckFree = (
61
+ files: readonly string[],
62
+ patterns: readonly string[],
63
+ alwaysAsk: readonly string[],
64
+ ): boolean => {
65
+ if (!allFilesMatch(files, patterns)) return false
66
+ return files.every((file) => !NEVER_CHECK_FREE.includes(fileName(file))) && alwaysAskingFiles(files, alwaysAsk).length === 0
67
+ }
68
+
49
69
  /**
50
70
  * Asks `gh api` for the files of pull request <number> of `facts.repository`, named in full; any failure answers nothing.
51
71
  */