jackherd 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/INSTALL.md +1 -1
- package/README.md +50 -32
- package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
- package/dist/config.d.ts +39 -4
- package/dist/config.js +67 -26
- package/dist/plan-format.d.ts +45 -6
- package/dist/plan-format.js +46 -6
- package/docs/workflow-dark.svg +67 -63
- package/docs/workflow.svg +67 -63
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +15 -0
- package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
- package/kit/hooks/hooks/ask-hint/index.ts +15 -19
- package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
- package/kit/hooks/hooks/git-guard/decision.ts +50 -102
- package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
- package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
- package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
- package/kit/hooks/hooks/merge-guard/index.ts +6 -3
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
- package/kit/hooks/hooks/merge-guard/run.ts +59 -54
- package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
- package/kit/hooks/hooks/open-question/index.ts +26 -0
- package/kit/hooks/hooks/open-question/record.ts +135 -0
- package/kit/hooks/hooks/open-question/run.ts +52 -0
- package/kit/hooks/hooks/open-question/store.ts +39 -0
- package/kit/hooks/hooks/sent-file/store.ts +3 -12
- package/kit/hooks/hooks/shared/config.ts +7 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
- package/kit/hooks/hooks/shared/files.ts +7 -0
- package/kit/hooks/hooks/shared/lane.ts +1 -1
- package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
- package/kit/hooks/hooks/shared/private-directory.ts +56 -9
- package/kit/hooks/hooks/shared/program-position.ts +2 -75
- package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
- package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
- package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
- package/kit/hooks/hooks/task-hint/decision.ts +49 -0
- package/kit/hooks/hooks/task-hint/index.ts +79 -0
- package/kit/hooks/hooks/task-hint/run.ts +62 -0
- package/kit/hooks/hooks/task-hint/store.ts +51 -0
- package/kit/hooks/hooks.json +13 -5
- package/kit/review/agents/skill-review.md +34 -0
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +1 -8
- package/kit/workflow/skills/implement/SKILL.md +8 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
- package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
- package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
- package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
- package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
- package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
- package/kit/workflow/skills/orchestrate/references/events.md +86 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
- package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
- package/kit/workflow/skills/plan/SKILL.md +6 -5
- package/kit/workflow/skills/plan/references/template-English.md +4 -4
- package/kit/workflow/skills/plan/references/template-German.md +4 -4
- package/package.json +3 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +59 -5
- package/src/check/agent-text-reviewers.ts +23 -0
- package/src/check/command.ts +2 -0
- package/src/check/plan-structure-check.ts +49 -2
- package/src/check/plan.ts +8 -1
- package/src/check/schema.ts +9 -0
- package/src/check/skill-budget-defaults.ts +55 -0
- package/src/check/skill-budgets.ts +185 -0
- package/src/cli.ts +1 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +58 -24
- package/src/lane/close-board.ts +106 -0
- package/src/lane/close-check.ts +183 -0
- package/src/lane/close-command-line.ts +11 -0
- package/src/lane/close-command.ts +217 -0
- package/src/lane/close-options.ts +43 -0
- package/src/lane/close-survey.ts +83 -0
- package/src/lane/close.ts +141 -0
- package/src/lane/command.ts +13 -0
- package/src/lane-prompt/build.ts +74 -0
- package/src/lane-prompt/command.ts +87 -0
- package/src/lane-prompt/input.ts +116 -0
- package/src/lane-prompt/options.ts +68 -0
- package/src/lane-prompt/texts.ts +101 -0
- package/src/plan-format/clock.ts +8 -0
- package/src/plan-format/dependencies.ts +7 -4
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/waiting.ts +7 -2
- package/src/plans/appointment-rows.ts +59 -0
- package/src/plans/ask-coordinator.ts +77 -0
- package/src/plans/ask-target.ts +25 -0
- package/src/plans/child-stop.ts +51 -0
- package/src/plans/closable-records.ts +34 -0
- package/src/plans/command.ts +3 -2
- package/src/plans/fetch.ts +26 -3
- package/src/plans/git.ts +19 -5
- package/src/plans/herd-claim.ts +173 -0
- package/src/plans/herd-memory.ts +36 -25
- package/src/plans/lane-plans.ts +44 -13
- package/src/plans/lane-summary.ts +253 -45
- package/src/plans/layout-phase.ts +29 -7
- package/src/plans/layout-summary.ts +89 -18
- package/src/plans/layout.ts +262 -68
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +107 -20
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-source.ts +15 -0
- package/src/plans/plan-cache.ts +28 -0
- package/src/plans/plan-heads.ts +54 -0
- package/src/plans/pull-requests.ts +17 -2
- package/src/plans/question-records.ts +48 -0
- package/src/plans/questions-read.ts +13 -0
- package/src/plans/read-cache.ts +8 -0
- package/src/plans/record-id.ts +4 -0
- package/src/plans/restart-decision.ts +13 -4
- package/src/plans/selection.ts +98 -18
- package/src/plans/sent-files-read.ts +6 -15
- package/src/plans/sent-files.ts +7 -14
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +12 -4
- package/src/plans/snapshot.ts +85 -62
- package/src/plans/sprint-export.ts +11 -11
- package/src/plans/sprint-page.ts +5 -5
- package/src/plans/subagent-phases.ts +95 -0
- package/src/plans/subagent-tasks.ts +51 -0
- package/src/plans/subagents-read.ts +66 -17
- package/src/plans/subagents.ts +64 -6
- package/src/plans/supervisor.ts +14 -4
- package/src/plans/symbols.ts +3 -0
- package/src/plans/task-name.ts +34 -0
- package/src/plans/texts.ts +23 -6
- package/src/plans/view.ts +17 -9
- package/src/plans/waiting-click.ts +192 -37
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +25 -3
- package/src/shared/private-location.ts +89 -0
- package/src/shared/stat-cache.ts +48 -0
- package/src/sync/kit-source.ts +29 -10
- package/src/sync/kit.ts +2 -0
- package/src/sync/manifest.ts +2 -0
- package/src/sync/plan-ask-rules.ts +94 -0
- package/src/sync/plan-hooks.ts +2 -0
- package/src/sync/render.ts +3 -3
- package/src/sync/settings-hooks.ts +4 -1
- package/src/sync/sync-plan.ts +1 -0
- package/src/sync/sync.ts +24 -3
- package/src/task-signals/claude-read.ts +27 -10
- package/src/terminal/text.ts +8 -3
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +16 -6
- package/src/waiting/command.ts +22 -7
- package/src/watch/appointments.ts +1 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +330 -105
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-progress.ts +40 -8
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +32 -4
- package/src/watch/lane-sources.ts +31 -3
- package/src/watch/main-checkout.ts +34 -0
- package/src/watch/plan-badges-read.ts +13 -7
- package/src/watch/pull-requests.ts +6 -2
- package/src/watch/reference-paths.ts +87 -0
- package/src/watch/running-subagents.ts +43 -0
- package/src/watch/skill-change.ts +124 -32
- package/src/watch/source-change.ts +48 -0
- package/src/watch/sources.ts +6 -3
- package/src/watch/stream-output.ts +4 -3
- package/src/watch/task-parallel-auto.ts +208 -0
- package/src/watch/task-parallel-data-paths.ts +46 -0
- package/src/watch/task-parallel-input.ts +5 -15
- package/src/watch/task-parallel-read.ts +3 -1
- package/src/watch/task-parallel.ts +36 -7
- package/src/watch/task-signal-missing.ts +69 -0
- package/src/watch/task-signal-read.ts +29 -0
- package/src/watch/texts.ts +10 -0
- package/src/watch/update-available.ts +6 -3
- package/src/watch/watch-events.ts +23 -0
- package/src/watch/watcher-lock.ts +25 -29
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
- package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
- 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
|
-
|
|
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
|
-
/**
|
|
87
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
464
|
-
)
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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 =
|
|
522
|
-
const decision = judgeParsed(commands, SCRIPT_CONTEXT.top, definitions
|
|
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
|
|
534
|
-
* a
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
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
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
104
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
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 =
|
|
92
|
+
const values = boardValues(config)
|
|
134
93
|
if (!allValid(values)) return null
|
|
135
|
-
const {
|
|
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
|
*/
|