jackherd 1.8.1 → 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 +60 -0
- package/INSTALL.md +3 -1
- package/README.md +75 -64
- package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
- package/dist/config.d.ts +45 -4
- package/dist/config.js +84 -27
- package/dist/plan-format.d.ts +63 -7
- package/dist/plan-format.js +66 -12
- 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/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -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 -140
- 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/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +9 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- 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 +21 -4
- package/kit/hooks/statusline.sh +1 -1
- 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 +10 -6
- package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
- 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 +4 -1
- package/recipes/README.md +2 -1
- package/recipes/sources.md +1 -1
- package/schemas/jackherd.schema.json +85 -7
- 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 +4 -0
- package/src/config/auto-window.ts +8 -0
- package/src/config/link-kinds.ts +7 -1
- package/src/config/schema.ts +80 -26
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- 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 +12 -0
- package/src/plan-format/dependencies.ts +9 -9
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/short-names.ts +71 -0
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +53 -0
- package/src/plans/active-tasks.ts +60 -0
- 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/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- 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-standing.ts +9 -4
- package/src/plans/lane-summary.ts +367 -92
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +80 -19
- package/src/plans/layout-summary.ts +163 -74
- package/src/plans/layout.ts +472 -114
- package/src/plans/link-chips.ts +1 -0
- package/src/plans/live.ts +119 -10
- package/src/plans/phase-ids.ts +8 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -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 +23 -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 +117 -34
- package/src/plans/sent-files-read.ts +13 -0
- package/src/plans/sent-files.ts +114 -0
- package/src/plans/settings.ts +2 -1
- package/src/plans/signalled-phase.ts +42 -0
- package/src/plans/signals-read.ts +62 -0
- package/src/plans/snapshot.ts +279 -60
- 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 +131 -0
- package/src/plans/subagents.ts +190 -0
- 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 +36 -14
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +20 -12
- package/src/plans/waiting-click.ts +231 -0
- package/src/plans/waiting-records.ts +39 -0
- package/src/plans/waiting-window.ts +8 -0
- package/src/preview/command.ts +105 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-location.ts +89 -0
- package/src/shared/private-records.ts +48 -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/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +89 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +16 -4
- package/src/terminal/width.ts +2 -1
- package/src/version/skill-diff.ts +46 -0
- package/src/vocabulary/index.ts +44 -6
- package/src/waiting/command.ts +50 -0
- package/src/watch/appointments.ts +2 -4
- package/src/watch/approvals.ts +2 -2
- package/src/watch/asking-lanes.ts +40 -0
- package/src/watch/base-tip.ts +11 -0
- package/src/watch/command.ts +401 -90
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/heartbeat-progress.ts +6 -18
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/kit-update.ts +28 -9
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-name.ts +6 -0
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +58 -14
- package/src/watch/lane-report-file.ts +20 -5
- package/src/watch/lane-report.ts +37 -5
- package/src/watch/lane-rest.ts +28 -0
- 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/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +8 -3
- 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/source-path.ts +1 -1
- package/src/watch/sources.ts +7 -4
- package/src/watch/stream-output.ts +14 -8
- 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 +108 -0
- package/src/watch/task-parallel-read.ts +151 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +235 -0
- 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/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +36 -1
- package/src/watch/watcher-lock.ts +25 -29
- package/src/watch/workflow-runs.ts +2 -1
- 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,13 +1,5 @@
|
|
|
1
|
-
import { commandTextRunnerIndex, searchableText } from '../shared/command-text-runners'
|
|
2
1
|
import { FULL_COMMIT } from '../shared/commit'
|
|
3
|
-
import {
|
|
4
|
-
inlineScript,
|
|
5
|
-
isOption,
|
|
6
|
-
isProgramPosition,
|
|
7
|
-
isShell,
|
|
8
|
-
programName,
|
|
9
|
-
runsInterpreterProgram,
|
|
10
|
-
} from '../shared/program-position'
|
|
2
|
+
import { inlineScript, isOption, isProgramPosition, programName } from '../shared/program-position'
|
|
11
3
|
import {
|
|
12
4
|
expandingHereDocumentAdvice,
|
|
13
5
|
isExpanded,
|
|
@@ -18,15 +10,13 @@ import {
|
|
|
18
10
|
type Word,
|
|
19
11
|
} from '../shared/shell'
|
|
20
12
|
import {
|
|
21
|
-
definesShellAlias,
|
|
22
|
-
mayRunDefinedFunction,
|
|
23
13
|
NO_DEFINITIONS,
|
|
24
14
|
type ShellDefinitionHit,
|
|
25
15
|
type ShellDefinitions,
|
|
26
16
|
shellDefinitionAt,
|
|
27
17
|
shellDefinitionHint,
|
|
28
18
|
} from '../shared/shell-aliases'
|
|
29
|
-
import { resolvePlainXargs } from '
|
|
19
|
+
import { GLOB, resolvePlainXargs, XARGS } from '../shared/xargs-program'
|
|
30
20
|
|
|
31
21
|
export type MergeGuardDecision = { kind: 'pass' } | { kind: 'ask'; reason: string }
|
|
32
22
|
|
|
@@ -43,8 +33,8 @@ export type GuardPolicy = {
|
|
|
43
33
|
/** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
|
|
44
34
|
commands: readonly string[]
|
|
45
35
|
/**
|
|
46
|
-
* Whether the merge of this pull request may pass without a question: the user approved its branch
|
|
47
|
-
* its lane, or every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
36
|
+
* Whether the merge of this pull request may pass without a question: the user approved its branch, a standing
|
|
37
|
+
* approval covers the plan of its lane, or every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
48
38
|
* A throw counts as not approved; a note is not approved either, and the question carries it.
|
|
49
39
|
*/
|
|
50
40
|
isMergeApproved: (merge: PlainMerge) => boolean | MergeNote
|
|
@@ -54,10 +44,9 @@ export type GuardPolicy = {
|
|
|
54
44
|
|
|
55
45
|
/**
|
|
56
46
|
* A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly; or
|
|
57
|
-
* the shell alias or function that may run one (`definition`, whose name is then the `command`)
|
|
58
|
-
* name is unknown (`note` says why it may run one).
|
|
47
|
+
* the shell alias or function that may run one (`definition`, whose name is then the `command`).
|
|
59
48
|
*/
|
|
60
|
-
type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit
|
|
49
|
+
type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit }
|
|
61
50
|
|
|
62
51
|
/** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
|
|
63
52
|
type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
|
|
@@ -78,11 +67,21 @@ const OPTIONS_WITH_VALUE = new Set(['-R', '--repo', '--hostname'])
|
|
|
78
67
|
const APPROVABLE_FLAGS = new Set(['--squash', '-s', '--merge', '-m', '--rebase', '-r', '--delete-branch', '-d'])
|
|
79
68
|
const PULL_REQUEST_NUMBER = /^[1-9]\d*$/
|
|
80
69
|
const HEAD_COMMIT_FLAG = '--match-head-commit'
|
|
81
|
-
const GLOB = /[*?[]/
|
|
82
|
-
|
|
83
|
-
const SOURCING = new Set(['source', '.'])
|
|
84
70
|
|
|
85
|
-
|
|
71
|
+
/** The option that prints a command's help and runs nothing; gh also takes `-h`, another tool may read it as a host. */
|
|
72
|
+
const HELP_OPTION = '--help'
|
|
73
|
+
const GH_SHORT_HELP = '-h'
|
|
74
|
+
/** The gh verbs that only read: `gh <noun> view`, `gh <noun> list`. */
|
|
75
|
+
const READING_VERBS = new Set(['view', 'list'])
|
|
76
|
+
const GH = 'gh'
|
|
77
|
+
/** What `xargs` appends to the program it runs: words from its input, which the guard cannot know. */
|
|
78
|
+
const XARGS_INPUT: Word = { value: '$xargs-input', literal: false }
|
|
79
|
+
/**
|
|
80
|
+
* Where a command may start in text the parse could not read: an operator, a bracket, a quote, a backtick, a line
|
|
81
|
+
* break. Cut there, a word at a program position of a piece may be one.
|
|
82
|
+
*/
|
|
83
|
+
const COMMAND_BOUNDARY = /[;&|()`'"{}\n]/
|
|
84
|
+
const BLANKS = /\s+/
|
|
86
85
|
|
|
87
86
|
/** `gh api` is guarded only for a write that merges, writes a file, starts or reruns a workflow or moves a ref (D43); every other call passes. */
|
|
88
87
|
const API_COMMAND = 'gh api'
|
|
@@ -126,9 +125,6 @@ const DANGEROUS_ENDINGS = [
|
|
|
126
125
|
/(^|\/)(rerun|rerun-failed-jobs)\/?$/,
|
|
127
126
|
/(^|\/)refs(\/|$)/,
|
|
128
127
|
]
|
|
129
|
-
const DANGEROUS_MENTION = /\b(merges?|contents|dispatches|rerun|refs)\b/
|
|
130
|
-
const EXPANDED_FUNCTION_NOTE =
|
|
131
|
-
'The program is expanded from a variable or a substitution and may name a shell function that runs one of guard.commands in .jackherd/config.json, so the user confirms it.'
|
|
132
128
|
|
|
133
129
|
const PASS: MergeGuardDecision = { kind: 'pass' }
|
|
134
130
|
|
|
@@ -272,23 +268,59 @@ const apiArguments = (words: SimpleCommand, index: number): Word[] => {
|
|
|
272
268
|
return at === -1 ? after : after.slice(at + 1)
|
|
273
269
|
}
|
|
274
270
|
|
|
275
|
-
/** Every guarded command
|
|
271
|
+
/** Every guarded command at a program position counts, except a `gh api` call that does not write dangerously. */
|
|
276
272
|
const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
|
|
277
273
|
command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
|
|
278
274
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
275
|
+
const commandWords = (command: string): string[] => command.split(' ').slice(1)
|
|
276
|
+
|
|
277
|
+
/** `gh <noun> view` or `gh <noun> list`, written plainly: it only reads. */
|
|
278
|
+
const isGhRead = (words: SimpleCommand, index: number): boolean => {
|
|
279
|
+
const [noun, verb] = words.slice(index + 1, index + 3)
|
|
280
|
+
const plainNoun = noun?.literal === true && !isOption(noun)
|
|
281
|
+
return programName(words[index]) === GH && plainNoun && verb?.literal === true && READING_VERBS.has(verb.value)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const isHelpOption = (program: Word, { value }: Word): boolean =>
|
|
285
|
+
value === HELP_OPTION || (value === GH_SHORT_HELP && programName(program) === GH)
|
|
286
|
+
|
|
287
|
+
/** The command's own words, written plainly, then `--help` (or gh's `-h`): it prints the help and runs nothing. */
|
|
288
|
+
const isHelpOf = (words: SimpleCommand, index: number, command: string): boolean => {
|
|
289
|
+
const expected = commandWords(command)
|
|
290
|
+
const after = words.slice(index + 1, index + 2 + expected.length)
|
|
291
|
+
const help = after[expected.length]
|
|
292
|
+
return (
|
|
293
|
+
after.every((word) => word.literal) &&
|
|
294
|
+
expected.every((value, at) => after[at]?.value === value) &&
|
|
295
|
+
help !== undefined &&
|
|
296
|
+
isHelpOption(words[index], help)
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** A call of the program at `index` that only reads: a help text or a gh view or list (D108). */
|
|
301
|
+
const isReading = (words: SimpleCommand, index: number, command: string): boolean =>
|
|
302
|
+
isGhRead(words, index) || isHelpOf(words, index, command)
|
|
303
|
+
|
|
304
|
+
/** An `xargs` before the program hands it words from its input: they may be any of the command words. */
|
|
305
|
+
const isFedByXargs = (words: SimpleCommand, index: number): boolean =>
|
|
306
|
+
words.slice(0, index).some((word, at) => programName(word) === XARGS && isProgramPosition(words, at))
|
|
307
|
+
|
|
308
|
+
/** The words after the program at `index`, with a stand-in for what `xargs` appends. */
|
|
309
|
+
const argumentsOf = (words: SimpleCommand, index: number): Word[] => {
|
|
310
|
+
const after = words.slice(index + 1)
|
|
311
|
+
return isFedByXargs(words, index) ? [...after, XARGS_INPUT] : after
|
|
283
312
|
}
|
|
284
313
|
|
|
314
|
+
/** Whether the words after the program at `index` can be the command's own words. */
|
|
315
|
+
const namesCommandWords = (words: SimpleCommand, index: number, command: string): boolean =>
|
|
316
|
+
operandLists(argumentsOf(words, index)).some((operands) => matchesFrom(operands, commandWords(command)))
|
|
317
|
+
|
|
285
318
|
/** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
|
|
286
319
|
const findingOf = (command: string, words: SimpleCommand, index: number, sole: boolean): Finding | undefined => {
|
|
287
|
-
|
|
288
|
-
if (!operandLists(words.slice(index + 1)).some((operands) => matchesFrom(operands, expected))) return undefined
|
|
320
|
+
if (!namesCommandWords(words, index, command) || isReading(words, index, command)) return undefined
|
|
289
321
|
if (!isGuardedUse(command, words, index)) return undefined
|
|
290
|
-
const plain = sole && words[index].literal && words[index].value ===
|
|
291
|
-
return { command, merge: plain ? plainMerge(words.slice(index),
|
|
322
|
+
const plain = sole && words[index].literal && words[index].value === GH
|
|
323
|
+
return { command, merge: plain ? plainMerge(words.slice(index), commandWords(command)) : undefined }
|
|
292
324
|
}
|
|
293
325
|
|
|
294
326
|
const programsOf = (commands: readonly string[]): string[] => [
|
|
@@ -301,90 +333,37 @@ const definitionFinding = (words: SimpleCommand, index: number, { commands, defi
|
|
|
301
333
|
return definition ? [{ command: definition.name, merge: undefined, definition }] : []
|
|
302
334
|
}
|
|
303
335
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
336
|
+
/**
|
|
337
|
+
* A program from a variable, a substitution or a glob may be a guarded one: it counts when the parsed words after it
|
|
338
|
+
* can be that command's words (`"$tool" pr merge 5`). A variable that holds the whole command (`$X`) is not searched.
|
|
339
|
+
*/
|
|
340
|
+
const expandedProgramFindings = (words: SimpleCommand, index: number, { commands }: Search): Finding[] =>
|
|
341
|
+
commands
|
|
342
|
+
.filter((command) => namesCommandWords(words, index, command) && isGuardedUse(command, words, index))
|
|
343
|
+
.map((command) => ({ command, merge: undefined }))
|
|
344
|
+
|
|
345
|
+
/** A program word from an expansion or a glob; a quoted word (`'gh pr merge'`, a script text) is the name it spells. */
|
|
346
|
+
const isExpandedProgram = ({ value, literal }: Word): boolean => isExpanded(value) || (literal && GLOB.test(value))
|
|
308
347
|
|
|
309
348
|
const callsOfWord = (words: SimpleCommand, index: number, search: Search, sole: boolean): Finding[] => {
|
|
310
349
|
if (!isProgramPosition(words, index)) return []
|
|
350
|
+
if (isExpandedProgram(words[index])) return expandedProgramFindings(words, index, search)
|
|
311
351
|
const program = programName(words[index])
|
|
312
352
|
const guarded = search.commands.filter((command) => command.split(' ')[0] === program)
|
|
313
353
|
if (guarded.length === 0) return definitionFinding(words, index, search)
|
|
314
354
|
return guarded.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
|
|
315
355
|
}
|
|
316
356
|
|
|
317
|
-
const isWordCharacter = (character: string | undefined): boolean => character !== undefined && /\w/.test(character)
|
|
318
|
-
|
|
319
|
-
const findWord = (script: string, word: string, from: number): number => {
|
|
320
|
-
for (let at = script.indexOf(word, from); at !== -1; at = script.indexOf(word, at + 1)) {
|
|
321
|
-
if (!isWordCharacter(script[at - 1]) && !isWordCharacter(script[at + word.length])) return at
|
|
322
|
-
}
|
|
323
|
-
return -1
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/** Whether the script names the words as whole words in this order; a linear search, so a long script cannot stall it. */
|
|
327
|
-
const namesInOrder = (script: string, words: string[]): boolean => {
|
|
328
|
-
let from = 0
|
|
329
|
-
for (const word of words) {
|
|
330
|
-
const at = findWord(script, word, from)
|
|
331
|
-
if (at === -1) return false
|
|
332
|
-
from = at + word.length
|
|
333
|
-
}
|
|
334
|
-
return true
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/** Where the program is hidden or the words are handed over as text, the command words after the program are the sign. */
|
|
338
|
-
const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] =>
|
|
339
|
-
commands
|
|
340
|
-
.filter((command) =>
|
|
341
|
-
command === API_COMMAND ? mentionsDangerousApiCall(script) : namesInOrder(script, command.split(' ').slice(1)),
|
|
342
|
-
)
|
|
343
|
-
.map((command) => ({ command, merge: undefined }))
|
|
344
|
-
|
|
345
|
-
/** A program expanded from a variable or a substitution could be the guarded one. */
|
|
346
|
-
const runsExpandedProgram = (words: SimpleCommand): boolean =>
|
|
347
|
-
words.some((word, index) => !word.literal && isProgramPosition(words, index))
|
|
348
|
-
|
|
349
|
-
const runsOpaquely = (words: SimpleCommand): boolean => commandTextRunnerIndex(words) !== -1
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* A shell, anywhere a program can stand, that reads its script from stdin (no operand, or `-s`), from a process
|
|
353
|
-
* substitution or from a file descriptor; or `source` of such a thing: the text comes from somewhere else.
|
|
354
|
-
*/
|
|
355
|
-
const readsScriptFromElsewhere = (words: SimpleCommand): boolean =>
|
|
356
|
-
words.some((word, index) => {
|
|
357
|
-
if (!isProgramPosition(words, index)) return false
|
|
358
|
-
const shell = isShell(word)
|
|
359
|
-
if (!shell && !SOURCING.has(programName(word))) return false
|
|
360
|
-
const rest = words.slice(index + 1)
|
|
361
|
-
const operands = rest.filter((each) => !isOption(each))
|
|
362
|
-
const fromInput = shell && (operands.length === 0 || rest.some(({ value }) => value === '-s'))
|
|
363
|
-
return fromInput || operands.some((each) => !each.literal || INPUT_PATH.test(each.value))
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
/** Git handed an alias (`git -c alias.m=!…`) runs text the parse cannot see. */
|
|
367
|
-
const definesGitAlias = (words: SimpleCommand): boolean =>
|
|
368
|
-
words.some(
|
|
369
|
-
(word, index) =>
|
|
370
|
-
isProgramPosition(words, index) &&
|
|
371
|
-
programName(word) === 'git' &&
|
|
372
|
-
words.slice(index + 1).some(({ value }) => value.startsWith('alias.')),
|
|
373
|
-
)
|
|
374
|
-
|
|
375
|
-
/** An inline script that is itself an expansion (`eval "$X"`) hides its text from the parse of the call. */
|
|
376
|
-
const runsInlineExpansion = (words: SimpleCommand): boolean =>
|
|
377
|
-
words.some((_word, index) => inlineScript(words, index)?.includes('$') === true)
|
|
378
|
-
|
|
379
357
|
/**
|
|
380
358
|
* Whether the command's words contain the guarded command's words in this order, anything between them: the
|
|
381
359
|
* fallback for a wrapper nobody listed (`mise exec -- gh pr merge 5`), which hides the program from the position test.
|
|
360
|
+
* It reads the parsed words of the command, never text inside them.
|
|
382
361
|
*/
|
|
383
362
|
const containsInOrder = (words: SimpleCommand, command: string): boolean => {
|
|
384
363
|
const [program, ...rest] = command.split(' ')
|
|
385
364
|
const at = words.findIndex((word) => programName(word) === program)
|
|
386
365
|
let from = at + 1
|
|
387
|
-
if (at === -1) return false
|
|
366
|
+
if (at === -1 || isReading(words, at, command)) return false
|
|
388
367
|
for (const expected of rest) {
|
|
389
368
|
const next = words.findIndex((word, index) => index >= from && word.literal && word.value === expected)
|
|
390
369
|
if (next === -1) return false
|
|
@@ -393,45 +372,28 @@ const containsInOrder = (words: SimpleCommand, command: string): boolean => {
|
|
|
393
372
|
return isGuardedUse(command, words, at)
|
|
394
373
|
}
|
|
395
374
|
|
|
396
|
-
|
|
397
|
-
const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean, whole: string, text: string): Finding[] => {
|
|
375
|
+
const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean): Finding[] => {
|
|
398
376
|
const direct = words.flatMap((_word, index) => {
|
|
399
377
|
const script = inlineScript(words, index)
|
|
400
|
-
return script === undefined ? callsOfWord(words, index, search, sole) : callsIn(script, search, false
|
|
378
|
+
return script === undefined ? callsOfWord(words, index, search, sole) : callsIn(script, search, false)
|
|
401
379
|
})
|
|
402
380
|
const found = new Set(direct.map(({ command }) => command))
|
|
403
381
|
const byWords = search.commands
|
|
404
382
|
.filter((command) => !found.has(command) && containsInOrder(words, command))
|
|
405
383
|
.map((command) => ({ command, merge: undefined }))
|
|
406
|
-
return [...direct, ...byWords
|
|
384
|
+
return [...direct, ...byWords]
|
|
407
385
|
}
|
|
408
386
|
|
|
409
387
|
/**
|
|
410
|
-
*
|
|
411
|
-
*
|
|
388
|
+
* The guarded commands in the parsed script: in a program position, in the script of `sh -c`, `eval` or `trap`, in a
|
|
389
|
+
* substitution. Text the script hands to an interpreter, a shell's input, a here-document's data and the words
|
|
390
|
+
* `xargs` reads are not searched (D108).
|
|
412
391
|
*/
|
|
413
|
-
const
|
|
414
|
-
runsExpandedProgram(words) ||
|
|
415
|
-
runsOpaquely(words) ||
|
|
416
|
-
readsScriptFromElsewhere(words) ||
|
|
417
|
-
runsInlineExpansion(words) ||
|
|
418
|
-
runsInterpreterProgram(words) ||
|
|
419
|
-
definesGitAlias(words) ||
|
|
420
|
-
definesShellAlias(words)
|
|
421
|
-
|
|
422
|
-
const callsInScriptWide = (whole: string, parsed: SimpleCommand[], commands: readonly string[]): Finding[] =>
|
|
423
|
-
parsed.some(hidesWhatRuns) ? mentionedCommandWords(searchableText(whole, parsed), commands) : []
|
|
424
|
-
|
|
425
|
-
/** `whole` is the text of the outermost script: a nested script's input comes from there, so the words are searched in it. */
|
|
426
|
-
const callsIn = (script: string, search: Search, top: boolean, whole: string): Finding[] => {
|
|
392
|
+
const callsIn = (script: string, search: Search, top: boolean): Finding[] => {
|
|
427
393
|
const guardedPrograms = programsOf(search.commands)
|
|
428
394
|
const parsed = parseShell(script).map((words) => resolvePlainXargs(words, guardedPrograms))
|
|
429
395
|
const sole = top && parsed.length === 1 && !script.trimStart().startsWith('(')
|
|
430
|
-
|
|
431
|
-
return [
|
|
432
|
-
...parsed.flatMap((words) => callsInCommand(words, search, sole, whole, text)),
|
|
433
|
-
...callsInScriptWide(whole, parsed, search.commands),
|
|
434
|
-
]
|
|
396
|
+
return parsed.flatMap((words) => callsInCommand(words, search, sole))
|
|
435
397
|
}
|
|
436
398
|
|
|
437
399
|
const approvalOf = ({ command, merge }: Finding, policy: GuardPolicy): boolean | MergeNote => {
|
|
@@ -451,9 +413,7 @@ const guardedListSentence = (commands: string[]): string[] =>
|
|
|
451
413
|
]
|
|
452
414
|
|
|
453
415
|
const askBecause = (findings: Finding[], notes: string[], script: string): MergeGuardDecision => {
|
|
454
|
-
const guarded = findings
|
|
455
|
-
.filter(({ definition, note }) => definition === undefined && note === undefined)
|
|
456
|
-
.map(({ command }) => command)
|
|
416
|
+
const guarded = findings.filter(({ definition }) => definition === undefined).map(({ command }) => command)
|
|
457
417
|
const definitions = [
|
|
458
418
|
...new Map(
|
|
459
419
|
findings.flatMap(({ definition }) => (definition ? [[definition.name, definition] as const] : [])),
|
|
@@ -467,7 +427,6 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
|
|
|
467
427
|
(definition) =>
|
|
468
428
|
`${shellDefinitionHint(definition)} It may run a command of guard.commands in .jackherd/config.json, so the user confirms it.`,
|
|
469
429
|
),
|
|
470
|
-
...new Set(findings.flatMap(({ note }) => note ?? [])),
|
|
471
430
|
...notes,
|
|
472
431
|
expandingHereDocumentAdvice(script),
|
|
473
432
|
]
|
|
@@ -476,9 +435,34 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
|
|
|
476
435
|
}
|
|
477
436
|
}
|
|
478
437
|
|
|
438
|
+
/** A word of text the parse could not read: one with an expansion or a backslash may stand for any word. */
|
|
439
|
+
const roughWord = (value: string): Word => ({ value, literal: !/[$\\]/.test(value) })
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Whether text the parse could not read has a guarded program at a program position: the text is cut where a command
|
|
443
|
+
* may start (an operator, a bracket, a quote, a backtick, a line break), and a piece whose word at a program position
|
|
444
|
+
* is a guarded program counts, unless that call only reads. A split program word (`g''h`) is not found, accepted
|
|
445
|
+
* (D108): the guard catches mistakes, not a hidden intent.
|
|
446
|
+
*/
|
|
447
|
+
const namesGuardedProgram = (script: string, commands: readonly string[]): boolean => {
|
|
448
|
+
const programs = programsOf(commands)
|
|
449
|
+
return script.split(COMMAND_BOUNDARY).some((piece) => {
|
|
450
|
+
const words = piece
|
|
451
|
+
.split(BLANKS)
|
|
452
|
+
.filter((value) => value !== '')
|
|
453
|
+
.map(roughWord)
|
|
454
|
+
return words.some(
|
|
455
|
+
(word, index) =>
|
|
456
|
+
isProgramPosition(words, index) &&
|
|
457
|
+
programs.includes(programName(word)) &&
|
|
458
|
+
!commands.some((command) => isReading(words, index, command)),
|
|
459
|
+
)
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
479
463
|
/**
|
|
480
|
-
* Quotes split a word
|
|
481
|
-
*
|
|
464
|
+
* Quotes split a word, an expansion hides a command: in text the parse cannot follow, a guarded program at a program
|
|
465
|
+
* position may run with any words, so the script asks.
|
|
482
466
|
*/
|
|
483
467
|
const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuardDecision => ({
|
|
484
468
|
kind: 'ask',
|
|
@@ -493,7 +477,7 @@ const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuard
|
|
|
493
477
|
|
|
494
478
|
const findingsOrUnreadable = (command: string, search: Search): Finding[] | ShellParseError => {
|
|
495
479
|
try {
|
|
496
|
-
return callsIn(command, search, true
|
|
480
|
+
return callsIn(command, search, true)
|
|
497
481
|
} catch (error) {
|
|
498
482
|
if (error instanceof ShellParseError) return error
|
|
499
483
|
throw error
|
|
@@ -501,20 +485,23 @@ const findingsOrUnreadable = (command: string, search: Search): Finding[] | Shel
|
|
|
501
485
|
}
|
|
502
486
|
|
|
503
487
|
/**
|
|
504
|
-
* Decides on a Bash command before it runs: `ask` when
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
488
|
+
* Decides on a Bash command before it runs: `ask` when a guarded command (`gh pr merge` and `gh workflow run` by
|
|
489
|
+
* default) stands at a program position of the parsed script, also behind options before the verb, a chain, a wrapper,
|
|
490
|
+
* `env`, `sh -c`, `eval`, a substitution or a shell alias or function, or behind a program from a variable whose
|
|
491
|
+
* parsed words are the command's words. Only the parsed script counts (D108): text handed to an interpreter or a
|
|
492
|
+
* shell's input, a here-document's data and what `xargs` reads are not searched, and a call that only reads (`--help`,
|
|
493
|
+
* `-h`, `gh … view`, `gh … list`) passes. A script that is exactly `gh pr merge <number>` with merge-method and
|
|
508
494
|
* delete-branch flags and at most one `--match-head-commit <sha>` passes when the user approved it
|
|
509
|
-
* (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A
|
|
510
|
-
*
|
|
511
|
-
* unless the list is empty.
|
|
495
|
+
* (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A script the parse cannot
|
|
496
|
+
* read asks only when a guarded program stands where a command starts.
|
|
512
497
|
*/
|
|
513
498
|
export const decideBashCommand = (command: string, policy: GuardPolicy): MergeGuardDecision => {
|
|
514
499
|
if (policy.commands.length === 0) return PASS
|
|
515
500
|
const search = { commands: policy.commands, definitions: policy.shellDefinitions ?? (() => NO_DEFINITIONS) }
|
|
516
501
|
const findings = findingsOrUnreadable(command, search)
|
|
517
|
-
if (findings instanceof ShellParseError)
|
|
502
|
+
if (findings instanceof ShellParseError) {
|
|
503
|
+
return namesGuardedProgram(command, policy.commands) ? askUnreadable(findings, command) : PASS
|
|
504
|
+
}
|
|
518
505
|
if (findings.length === 0) return PASS
|
|
519
506
|
const unapproved = findings
|
|
520
507
|
.map((finding) => ({ finding, approval: approvalOf(finding, policy) }))
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// decision on stdout when the command runs a guarded one, or with nothing to leave the call to the
|
|
3
3
|
// permission rules. Any failure asks: a guard that crashes, or takes too long, must not wave the command through.
|
|
4
4
|
// The decision runs in a worker that is ended after DEADLINE_MS, because Claude Code lets a call through when a hook
|
|
5
|
-
// runs into its own timeout. argv[2]
|
|
6
|
-
//
|
|
5
|
+
// runs into its own timeout. argv[2] is the exact option label of the pull request question that approves a merge, argv[3]
|
|
6
|
+
// the one of the standing approval and argv[4] the word before its end, all rendered into the hook entry by `sync`.
|
|
7
7
|
import type { MergeGuardDecision } from './decision'
|
|
8
8
|
import { type ApprovalLabels, failureDecision } from './run'
|
|
9
9
|
|
|
@@ -35,7 +35,10 @@ const decide = async (): Promise<MergeGuardDecision> => {
|
|
|
35
35
|
try {
|
|
36
36
|
const text = await Bun.stdin.text()
|
|
37
37
|
const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
|
|
38
|
-
const labels = {
|
|
38
|
+
const labels = {
|
|
39
|
+
selfMerge: process.argv[2] ?? '',
|
|
40
|
+
standing: { approve: process.argv[3] ?? '', until: process.argv[4] ?? '' },
|
|
41
|
+
}
|
|
39
42
|
return await decideWithin({ text, labels, projectDirectory }, DEADLINE_MS)
|
|
40
43
|
} catch (error) {
|
|
41
44
|
return failureDecision(error)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan a branch belongs to: the lane whose worktree has the branch checked out, and the one plan that lane's rows
|
|
3
|
+
* link on the boards, as the main checkout holds them (the coordinator keeps its board there, committed or not). The
|
|
4
|
+
* git call runs without a shell and with a timeout; any failure answers nothing, so the guard asks.
|
|
5
|
+
*/
|
|
6
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { join, posix, relative, resolve } from 'node:path'
|
|
8
|
+
import { type BoardLanguage, lanePlansOf } from '../shared/board-lanes'
|
|
9
|
+
import { laneOf } from '../shared/lane'
|
|
10
|
+
import type { BoardSettings } from './board-settings'
|
|
11
|
+
|
|
12
|
+
/** A board's directory relative to the repository, and its text. */
|
|
13
|
+
export type Board = { directory: string; text: string }
|
|
14
|
+
|
|
15
|
+
const GIT_TIMEOUT_MS = 3000
|
|
16
|
+
|
|
17
|
+
/** The lane whose worktree, directly below the worktree directory, has `branch` checked out. */
|
|
18
|
+
export const laneInWorktreeList = (
|
|
19
|
+
worktreeList: string,
|
|
20
|
+
main: string,
|
|
21
|
+
worktreeDirectory: string,
|
|
22
|
+
branch: string,
|
|
23
|
+
): string | undefined => {
|
|
24
|
+
const blocks = worktreeList.split('\n\n').map((block) => block.split('\n'))
|
|
25
|
+
const block = blocks.find((lines) => lines.includes(`branch refs/heads/${branch}`))
|
|
26
|
+
const path = block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
|
|
27
|
+
if (path === undefined) return undefined
|
|
28
|
+
const lane = laneOf({ cwd: path, main, worktreeDirectory })
|
|
29
|
+
return lane !== null && relative(resolve(main, worktreeDirectory), resolve(path)) === lane ? lane : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The one plan the lane's open rows link across the boards; none for a cleaned-up lane, no plan or several. */
|
|
33
|
+
export const onlyPlanOf = (boards: readonly Board[], lane: string, language: BoardLanguage): string | undefined => {
|
|
34
|
+
const rows = boards.flatMap(({ directory, text }) => lanePlansOf(text, directory, lane, language) ?? [])
|
|
35
|
+
const plans = new Set(rows.filter(({ closed }) => !closed).flatMap(({ plans }) => plans))
|
|
36
|
+
const [only] = plans
|
|
37
|
+
return plans.size === 1 ? only : undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const worktreeListOf = (main: string): string | undefined => {
|
|
41
|
+
const result = Bun.spawnSync(['git', '-C', main, 'worktree', 'list', '--porcelain'], {
|
|
42
|
+
stdin: 'ignore',
|
|
43
|
+
stdout: 'pipe',
|
|
44
|
+
stderr: 'ignore',
|
|
45
|
+
timeout: GIT_TIMEOUT_MS,
|
|
46
|
+
})
|
|
47
|
+
return result.exitCode === 0 ? result.stdout.toString() : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const readTextOrUndefined = (path: string): string | undefined => {
|
|
51
|
+
try {
|
|
52
|
+
return readFileSync(path, 'utf8')
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The boards under the plan directory of the main checkout whose directory name matches the board pattern. */
|
|
59
|
+
const boardsIn = (main: string, settings: BoardSettings): Board[] => {
|
|
60
|
+
const pattern = new Bun.Glob(settings.board.pattern)
|
|
61
|
+
try {
|
|
62
|
+
return readdirSync(join(main, settings.planDirectory), { withFileTypes: true })
|
|
63
|
+
.filter((entry) => entry.isDirectory() && pattern.match(entry.name))
|
|
64
|
+
.flatMap((entry) => {
|
|
65
|
+
const directory = posix.join(settings.planDirectory, entry.name)
|
|
66
|
+
const text = readTextOrUndefined(join(main, directory, settings.board.file))
|
|
67
|
+
return text === undefined ? [] : [{ directory, text }]
|
|
68
|
+
})
|
|
69
|
+
} catch {
|
|
70
|
+
return []
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The one plan the lane of `branch` links on the boards of the main checkout; undefined when it cannot tell. */
|
|
75
|
+
export const planOfBranch = (main: string, settings: BoardSettings, branch: string): string | undefined => {
|
|
76
|
+
const list = worktreeListOf(main)
|
|
77
|
+
const lane = list === undefined ? undefined : laneInWorktreeList(list, main, settings.worktreeDirectory, branch)
|
|
78
|
+
return lane === undefined ? undefined : onlyPlanOf(boardsIn(main, settings), lane, settings.planLanguage)
|
|
79
|
+
}
|