jackherd 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/INSTALL.md +4 -2
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +94 -1033
- package/assets/icons/bark-dark.svg +1 -1
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl-dark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide-dark.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear-dark.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes-dark.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan-dark.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +73 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +3 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/legacy-names.ts +8 -0
- package/src/config/read-config.ts +12 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/command.ts +16 -1
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +47 -11
- package/src/watch/appointments.ts +205 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lanes table of a board, read the way the herd reads it (`parseSprintLanes` in the package), which a hook cannot
|
|
3
|
+
* import: the first table under `## <lanes>`, a row naming its lane in backticks or continuing the one above with `↳`,
|
|
4
|
+
* the plan as the link in the third cell. A test compares both on the repository's real boards.
|
|
5
|
+
*/
|
|
6
|
+
import { posix } from 'node:path'
|
|
7
|
+
|
|
8
|
+
export type BoardLanguage = 'de' | 'en'
|
|
9
|
+
|
|
10
|
+
/** The plan vocabulary's words for the lanes section and a closed lane, per plan language. */
|
|
11
|
+
export const BOARD_WORDS: Record<BoardLanguage, { lanes: string; laneClosed: string }> = {
|
|
12
|
+
de: { lanes: 'Spuren', laneClosed: 'abgeräumt' },
|
|
13
|
+
en: { lanes: 'Lanes', laneClosed: 'cleaned up' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A lane's rows: whether any says the lane was cleaned up, and the plans they link, relative to the repository. */
|
|
17
|
+
export type LanePlans = { closed: boolean; plans: string[] }
|
|
18
|
+
|
|
19
|
+
type Row = { lane: string; cells: string[] }
|
|
20
|
+
|
|
21
|
+
const LANE_NAME = /`([^`]+)`/
|
|
22
|
+
const CONTINUATION = '↳'
|
|
23
|
+
const PLAN_LINK = /\[([^\]]+)\]\(([^)]+)\)/
|
|
24
|
+
const SECTION_END = /^##\s/
|
|
25
|
+
const TABLE_SEPARATOR = /^\|[\s|:-]+\|$/
|
|
26
|
+
const PLAN_COLUMN = 2
|
|
27
|
+
|
|
28
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
29
|
+
|
|
30
|
+
const sectionLines = (markdown: string, heading: string): string[] => {
|
|
31
|
+
const lines = markdown.split('\n')
|
|
32
|
+
const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`)
|
|
33
|
+
const start = lines.findIndex((line) => pattern.test(line))
|
|
34
|
+
if (start === -1) return []
|
|
35
|
+
const rest = lines.slice(start + 1)
|
|
36
|
+
const end = rest.findIndex((line) => SECTION_END.test(line))
|
|
37
|
+
return end === -1 ? rest : rest.slice(0, end)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cellsOf = (line: string): string[] =>
|
|
41
|
+
line
|
|
42
|
+
.slice(1, line.endsWith('|') ? -1 : undefined)
|
|
43
|
+
.split('|')
|
|
44
|
+
.map((cell) => cell.trim())
|
|
45
|
+
|
|
46
|
+
/** The body rows of the first table, header and separator rows dropped. */
|
|
47
|
+
const firstTableLines = (lines: string[]): string[] => {
|
|
48
|
+
const start = lines.findIndex((line) => line.startsWith('|'))
|
|
49
|
+
if (start === -1) return []
|
|
50
|
+
const rest = lines.slice(start)
|
|
51
|
+
const end = rest.findIndex((line) => !line.startsWith('|'))
|
|
52
|
+
return (end === -1 ? rest : rest.slice(0, end)).slice(1).filter((line) => !TABLE_SEPARATOR.test(line))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const laneRows = (markdown: string, language: BoardLanguage): Row[] => {
|
|
56
|
+
const rows: Row[] = []
|
|
57
|
+
let previous: string | null = null
|
|
58
|
+
for (const line of firstTableLines(sectionLines(markdown, BOARD_WORDS[language].lanes))) {
|
|
59
|
+
const cells = cellsOf(line)
|
|
60
|
+
const first = cells[0] ?? ''
|
|
61
|
+
const lane: string | null = first.match(LANE_NAME)?.[1] ?? (first.startsWith(CONTINUATION) ? previous : null)
|
|
62
|
+
if (lane === null) continue
|
|
63
|
+
rows.push({ lane, cells })
|
|
64
|
+
previous = lane
|
|
65
|
+
}
|
|
66
|
+
return rows
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The repository path of a plan link, relative to the board's directory. */
|
|
70
|
+
const planPathOf = (boardDirectory: string, cell: string): string[] => {
|
|
71
|
+
const href = cell.match(PLAN_LINK)?.[2]
|
|
72
|
+
return href === undefined ? [] : [posix.normalize(posix.join(boardDirectory, href.split('#')[0]))]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Every lane the lanes table names, in table order. */
|
|
76
|
+
export const laneNamesOf = (markdown: string, language: BoardLanguage): string[] => [
|
|
77
|
+
...new Set(laneRows(markdown, language).map(({ lane }) => lane)),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
/** The rows of `lane` in the board's lanes table; undefined when the table has no row for it. */
|
|
81
|
+
export const lanePlansOf = (
|
|
82
|
+
markdown: string,
|
|
83
|
+
boardDirectory: string,
|
|
84
|
+
lane: string,
|
|
85
|
+
language: BoardLanguage,
|
|
86
|
+
): LanePlans | undefined => {
|
|
87
|
+
const rows = laneRows(markdown, language).filter((row) => row.lane === lane)
|
|
88
|
+
if (rows.length === 0) return undefined
|
|
89
|
+
return {
|
|
90
|
+
closed: rows.some(({ cells }) => (cells[0] ?? '').includes(BOARD_WORDS[language].laneClosed)),
|
|
91
|
+
plans: [...new Set(rows.flatMap(({ cells }) => planPathOf(boardDirectory, cells[PLAN_COLUMN] ?? '')))],
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { isProgramPosition, programName } from './program-position'
|
|
2
|
+
import type { SimpleCommand, Word } from './shell'
|
|
3
|
+
|
|
4
|
+
/** Programs that run text they are given or read from elsewhere, so no parse of the shell sees what runs. */
|
|
5
|
+
const OPAQUE_RUNNERS = new Set([
|
|
6
|
+
'xargs',
|
|
7
|
+
'find',
|
|
8
|
+
'trap',
|
|
9
|
+
'coproc',
|
|
10
|
+
'repeat',
|
|
11
|
+
'script',
|
|
12
|
+
'su',
|
|
13
|
+
'flock',
|
|
14
|
+
'ssh',
|
|
15
|
+
'parallel',
|
|
16
|
+
'ionice',
|
|
17
|
+
'chrt',
|
|
18
|
+
'taskset',
|
|
19
|
+
'strace',
|
|
20
|
+
'unbuffer',
|
|
21
|
+
'chroot',
|
|
22
|
+
'runuser',
|
|
23
|
+
'systemd-run',
|
|
24
|
+
'nsenter',
|
|
25
|
+
'unshare',
|
|
26
|
+
'firejail',
|
|
27
|
+
'pkexec',
|
|
28
|
+
'entr',
|
|
29
|
+
'watchexec',
|
|
30
|
+
'busybox',
|
|
31
|
+
'watch',
|
|
32
|
+
'osascript',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
/** Package runners whose `-c`/`--call` hands a command string to a shell: `npx -c`, `npm exec -c`, `pnpm dlx -c`. */
|
|
36
|
+
const PACKAGE_RUNNERS = new Set(['npx', 'npm', 'pnpm', 'yarn'])
|
|
37
|
+
const CALL_OPTION = /^(-c|--call)(=|$)/
|
|
38
|
+
const AWK_PROGRAMS = new Set(['awk', 'gawk', 'mawk', 'nawk'])
|
|
39
|
+
/** An awk program that starts a command: `system(…)`, a pipe into or out of `getline`, `print … | "cmd"`. */
|
|
40
|
+
const AWK_RUNS_COMMAND = /\bsystem\s*\(|\|\s*getline|\|&|print[^;}]*\|/
|
|
41
|
+
/** sed's `e` command, alone or after an address: `1e cmd`, `/x/e cmd`, `;e`. */
|
|
42
|
+
const SED_EXECUTE_COMMAND = /(^|[\s;{}!=\d$/])e(\s|;|$)/
|
|
43
|
+
/** sed's `s///e` flag, which runs the pattern space as a command. */
|
|
44
|
+
const SED_EXECUTE_FLAG = /^\s*s(.)(?:\\.|(?!\1).)*\1(?:\\.|(?!\1).)*\1[^;\s}]*e/
|
|
45
|
+
|
|
46
|
+
const sedRunsCommand = (rest: Word[]): boolean =>
|
|
47
|
+
rest.some(
|
|
48
|
+
({ value }) => SED_EXECUTE_COMMAND.test(value) || value.split(/[;\n]/).some((piece) => SED_EXECUTE_FLAG.test(piece)),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const runsCommandText = (name: string, rest: Word[]): boolean =>
|
|
52
|
+
OPAQUE_RUNNERS.has(name) ||
|
|
53
|
+
(PACKAGE_RUNNERS.has(name) && rest.some(({ value }) => CALL_OPTION.test(value))) ||
|
|
54
|
+
(name === 'sed' && sedRunsCommand(rest)) ||
|
|
55
|
+
(AWK_PROGRAMS.has(name) && rest.some(({ value }) => AWK_RUNS_COMMAND.test(value)))
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The index of a program that runs command text it is given (`watch '…'`, `script -qc '…'`, `npx -c '…'`, sed's `e`,
|
|
59
|
+
* awk's `system()`), anywhere a program can stand; -1 when there is none.
|
|
60
|
+
*/
|
|
61
|
+
export const commandTextRunnerIndex = (words: SimpleCommand): number =>
|
|
62
|
+
words.findIndex(
|
|
63
|
+
(word, index) => isProgramPosition(words, index) && runsCommandText(programName(word), words.slice(index + 1)),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The text a mention search reads: the script as written and, beside it, every word as the shell decodes it, so a word
|
|
68
|
+
* split by quotes (`pu""sh`) or written in `$'…'` escapes (`pu\x73h`) is found too.
|
|
69
|
+
*/
|
|
70
|
+
export const searchableText = (script: string, commands: SimpleCommand[]): string =>
|
|
71
|
+
[script, ...commands.map((words) => words.map(({ value }) => value).join(' '))].join('\n')
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Long enough for a local `git` or `herdr` answer, well below the deadline of the hook that waits for it. */
|
|
2
|
+
export const COMMAND_TIMEOUT_MS = 3000
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The trimmed stdout of a command run without a shell; throws when it fails or outlives `COMMAND_TIMEOUT_MS`, so the
|
|
6
|
+
* caller gives up. A command past the limit is killed with SIGKILL, which it cannot ignore.
|
|
7
|
+
*/
|
|
2
8
|
export const commandOutput = (command: string[], cwd?: string): string => {
|
|
3
|
-
const result = Bun.spawnSync(command, {
|
|
9
|
+
const result = Bun.spawnSync(command, {
|
|
10
|
+
cwd,
|
|
11
|
+
stdout: 'pipe',
|
|
12
|
+
stderr: 'ignore',
|
|
13
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
14
|
+
killSignal: 'SIGKILL',
|
|
15
|
+
})
|
|
4
16
|
if (result.exitCode !== 0) throw new Error(`${command[0]} failed`)
|
|
5
17
|
return result.stdout.toString().trim()
|
|
6
18
|
}
|
|
@@ -3,6 +3,9 @@ import { isRecord, type JsonObject } from './json'
|
|
|
3
3
|
|
|
4
4
|
export type { DialogAnswer } from './dialog-answer'
|
|
5
5
|
|
|
6
|
+
/** The tool name of the dialog, as hook inputs and transcripts carry it. */
|
|
7
|
+
export const ASK_USER_QUESTION_TOOL = 'AskUserQuestion'
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* The answers of the user's dialogs, read from a session transcript. The transcript is written by the agent's own
|
|
8
11
|
* client, not by the agent: a dialog answer sits in it as the result of an `AskUserQuestion` call, with the question the
|
|
@@ -49,7 +52,7 @@ const askedQuestions = (entries: JsonObject[]): Map<string, AskedQuestion[]> =>
|
|
|
49
52
|
entries
|
|
50
53
|
.filter(isMainThread)
|
|
51
54
|
.flatMap(contentOf)
|
|
52
|
-
.filter((block) => block.type === 'tool_use' && block.name ===
|
|
55
|
+
.filter((block) => block.type === 'tool_use' && block.name === ASK_USER_QUESTION_TOOL && typeof block.id === 'string')
|
|
53
56
|
.map((block): [string, AskedQuestion[]] => [block.id as string, questionsOf(block.input)]),
|
|
54
57
|
)
|
|
55
58
|
|
|
@@ -2,3 +2,13 @@ export type JsonObject = Record<string, unknown>
|
|
|
2
2
|
|
|
3
3
|
export const isRecord = (value: unknown): value is JsonObject =>
|
|
4
4
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
5
|
+
|
|
6
|
+
/** The JSON text as an object; undefined for text that is no JSON or no object (an array included). */
|
|
7
|
+
export const parseJsonObject = (text: string): JsonObject | undefined => {
|
|
8
|
+
try {
|
|
9
|
+
const value: unknown = JSON.parse(text)
|
|
10
|
+
return isRecord(value) ? value : undefined
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
2
|
+
import type { PaneAgent } from './pane-agent'
|
|
3
|
+
|
|
4
|
+
/** herdr's agent names; a worktree whose directory has another name cannot be a lane. */
|
|
5
|
+
const LANE_NAME = /^[a-z][a-z0-9_-]{0,31}$/
|
|
6
|
+
|
|
7
|
+
export type LaneLocation = { cwd: string; main: string; worktreeDirectory: string }
|
|
8
|
+
|
|
9
|
+
export type AgentLocation = {
|
|
10
|
+
/** The agent herdr runs in the pane the hook was called from. */
|
|
11
|
+
agent: PaneAgent
|
|
12
|
+
/** The session the hook was called for. */
|
|
13
|
+
session: string
|
|
14
|
+
main: string
|
|
15
|
+
worktreeDirectory: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The session that merges, against the agent herdr runs in its pane. */
|
|
19
|
+
export type MergingSession = {
|
|
20
|
+
/** The agent of the pane the hook runs in; null outside herdr. */
|
|
21
|
+
agent: PaneAgent | null
|
|
22
|
+
/** The `session_id` of the hook input. */
|
|
23
|
+
session: unknown
|
|
24
|
+
main: string
|
|
25
|
+
/** Whether a herdr name is a lane's, whose worktree exists. */
|
|
26
|
+
isLane: (name: string) => boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The lane whose worktree holds `cwd`: the first directory below the main checkout's worktree directory, else null. */
|
|
30
|
+
export const laneOf = ({ cwd, main, worktreeDirectory }: LaneLocation): string | null => {
|
|
31
|
+
const path = relative(resolve(main, worktreeDirectory), resolve(cwd))
|
|
32
|
+
if (path === '' || path.startsWith('..') || isAbsolute(path)) return null
|
|
33
|
+
const [lane] = path.split(sep)
|
|
34
|
+
return LANE_NAME.test(lane) ? lane : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The lane the agent of this pane is: an agent named after a worktree that it runs in, in the session the hook was
|
|
39
|
+
* called for. The shell's directory has no say: the coordinator's shell may stand in a lane's worktree.
|
|
40
|
+
*/
|
|
41
|
+
export const laneOfAgent = ({ agent, session, main, worktreeDirectory }: AgentLocation): string | null => {
|
|
42
|
+
if (agent.name === null || agent.cwd === null || agent.session === null || agent.session !== session) return null
|
|
43
|
+
return laneOf({ cwd: agent.cwd, main, worktreeDirectory }) === agent.name ? agent.name : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether the session that merges is the coordinator: the agent of its pane, in this very session, started in the main
|
|
48
|
+
* checkout and not under a lane's name. A lane's transcript may lie beside the coordinator's when it was started in the
|
|
49
|
+
* main checkout; its name still tells it apart, so the plan approval never covers a lane's own merge.
|
|
50
|
+
*/
|
|
51
|
+
export const isCoordinatorAgent = ({ agent, session, main, isLane }: MergingSession): boolean =>
|
|
52
|
+
agent !== null &&
|
|
53
|
+
agent.session !== null &&
|
|
54
|
+
agent.session === session &&
|
|
55
|
+
agent.cwd !== null &&
|
|
56
|
+
resolve(agent.cwd) === resolve(main) &&
|
|
57
|
+
(agent.name === null || !isLane(agent.name))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The value of `compute`, computed at the first call and kept for every later one, undefined and throws aside. */
|
|
2
|
+
export const once = <T>(compute: () => T): (() => T) => {
|
|
3
|
+
let kept: { value: T } | undefined
|
|
4
|
+
return () => {
|
|
5
|
+
kept ??= { value: compute() }
|
|
6
|
+
return kept.value
|
|
7
|
+
}
|
|
8
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type PaneAgent, paneAgentOf } from './pane-agent'
|
|
2
|
+
|
|
3
|
+
/** herdr's pane ids (`w2V:p1`); anything else never reaches the command line. */
|
|
4
|
+
const PANE_ID = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,63}$/
|
|
5
|
+
/** Well below the deadlines of the hooks that ask, which still have work to do after it. */
|
|
6
|
+
const HERDR_TIMEOUT_MS = 2000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The agent herdr runs in the pane this process was started in; null outside herdr and whenever herdr cannot be asked.
|
|
10
|
+
*
|
|
11
|
+
* Only the pane is fixed: herdr sets `HERDR_PANE_ID` in the client's environment, and no command the agent runs can
|
|
12
|
+
* change it there. herdr's answer about that pane is not: the agent's name and session are reported to herdr, and any
|
|
13
|
+
* process may report them. Only `cwd` herdr reads from the process itself. A caller trusts a name or a session only
|
|
14
|
+
* where a second fact binds it, such as the session of the hook input or a worktree of that name.
|
|
15
|
+
*/
|
|
16
|
+
export const ownPaneAgent = (env: Record<string, string | undefined> = process.env): PaneAgent | null => {
|
|
17
|
+
const paneId = env.HERDR_PANE_ID
|
|
18
|
+
if (paneId === undefined || !PANE_ID.test(paneId)) return null
|
|
19
|
+
try {
|
|
20
|
+
const result = Bun.spawnSync(['herdr', 'agent', 'get', paneId], {
|
|
21
|
+
stdin: 'ignore',
|
|
22
|
+
stdout: 'pipe',
|
|
23
|
+
stderr: 'ignore',
|
|
24
|
+
timeout: HERDR_TIMEOUT_MS,
|
|
25
|
+
env,
|
|
26
|
+
})
|
|
27
|
+
return result.exitCode === 0 ? paneAgentOf(JSON.parse(result.stdout.toString())) : null
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { isRecord, type JsonObject } from './json'
|
|
2
|
+
|
|
3
|
+
/** The agent herdr runs in a pane: its herdr name, the directory it started in and its client session, each null when unknown. */
|
|
4
|
+
export type PaneAgent = { name: string | null; cwd: string | null; session: string | null }
|
|
5
|
+
|
|
6
|
+
/** A client's session id as a UUID; nothing else may reach a path. */
|
|
7
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
8
|
+
|
|
9
|
+
/** The client session of an agent or a pane herdr reports; null for a shell, or a session id of another shape. */
|
|
10
|
+
export const sessionOf = (pane: JsonObject): string | null => {
|
|
11
|
+
const session = isRecord(pane.agent_session) ? pane.agent_session.value : undefined
|
|
12
|
+
return typeof pane.agent === 'string' && pane.agent !== '' && typeof session === 'string' && SESSION_ID.test(session)
|
|
13
|
+
? session
|
|
14
|
+
: null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const textOrNull = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
|
|
18
|
+
|
|
19
|
+
/** The agent of the answer of `herdr agent get <pane>`; null for an error or an answer of another shape. */
|
|
20
|
+
export const paneAgentOf = (answer: unknown): PaneAgent | null => {
|
|
21
|
+
const agent = isRecord(answer) && isRecord(answer.result) ? answer.result.agent : undefined
|
|
22
|
+
if (!isRecord(agent)) return null
|
|
23
|
+
return { name: textOrNull(agent.name), cwd: textOrNull(agent.cwd), session: sessionOf(agent) }
|
|
24
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a plan still has an open phase, read the way the herd reads phases (`parsePlanPhases` in the package),
|
|
3
|
+
* which a hook cannot import. A test compares both on the repository's real plans.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** A task of a phase, a checkbox or a row with a ⬜/✅ cell: whether it is open, and its text without the state. */
|
|
7
|
+
export type PlanTask = { open: boolean; text: string }
|
|
8
|
+
|
|
9
|
+
type Phase = { id: string; items: boolean[]; deferredOpen: boolean; pointer?: string }
|
|
10
|
+
|
|
11
|
+
/** A phase's id, its heading without the leading hashes, and its lines up to the next heading that ends it. */
|
|
12
|
+
export type PhaseBlock = { id: string; heading: string; lines: string[] }
|
|
13
|
+
|
|
14
|
+
const PHASE_HEADING = /^###\s+Phase\s+(\d+[a-z]?)\b/i
|
|
15
|
+
/** `## 10 — title`, and `## P15 — title` as a board names the phase. */
|
|
16
|
+
const NUMBERED_SECTION = /^##\s+P?(\d+[a-z]?)\s*[—–:-]\s*(.*)$/
|
|
17
|
+
const PHASE_END = /^#{1,3}\s/
|
|
18
|
+
const SECTION_HEADING = /^##\s+(.+?)\s*$/
|
|
19
|
+
const TOP_HEADING = /^#\s/
|
|
20
|
+
const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/
|
|
21
|
+
const DEFERRED_TO = /\s→\s*P(\d+[a-z]?)\s*$/i
|
|
22
|
+
const SECTION_POINTER = /`##\s+([^`]+?)\s*`/
|
|
23
|
+
const STATUS = { done: '✅', open: '⬜' } as const
|
|
24
|
+
|
|
25
|
+
const collapsed = (text: string): string => text.trim().replace(/\s+/g, ' ')
|
|
26
|
+
|
|
27
|
+
/** The task a line holds, null for a line that is none. */
|
|
28
|
+
export const taskOf = (line: string): PlanTask | null => {
|
|
29
|
+
const checkbox = line.match(CHECKBOX)
|
|
30
|
+
if (checkbox) return { open: checkbox[1] === ' ', text: collapsed(checkbox[2]) }
|
|
31
|
+
const trimmed = line.trim()
|
|
32
|
+
if (!trimmed.startsWith('|')) return null
|
|
33
|
+
const cells = trimmed.replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim())
|
|
34
|
+
const status = cells.find((cell) => cell === STATUS.done || cell === STATUS.open)
|
|
35
|
+
if (status === undefined) return null
|
|
36
|
+
const text = cells.filter((cell) => cell !== STATUS.done && cell !== STATUS.open).join(' | ')
|
|
37
|
+
return { open: status === STATUS.open, text: collapsed(text) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An item of a phase: `true` while open, `false` when done, null for a line that is none. */
|
|
41
|
+
const itemOf = (line: string): boolean | null => taskOf(line)?.open ?? null
|
|
42
|
+
|
|
43
|
+
/** An open checkbox moved on to another phase with `→ P<n>` at its end; it stays open, in that phase. */
|
|
44
|
+
const isDeferred = (line: string, phaseId: string): boolean => {
|
|
45
|
+
const checkbox = line.match(CHECKBOX)
|
|
46
|
+
const target = checkbox?.[1] === ' ' ? checkbox[2].match(DEFERRED_TO)?.[1]?.toLowerCase() : undefined
|
|
47
|
+
return target !== undefined && target !== phaseId
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const phaseIdOf = (line: string): string | undefined =>
|
|
51
|
+
(line.match(PHASE_HEADING) ?? line.match(NUMBERED_SECTION))?.[1]?.toLowerCase()
|
|
52
|
+
|
|
53
|
+
const fileLine = (phase: Phase, line: string): void => {
|
|
54
|
+
phase.pointer ??= line.match(SECTION_POINTER)?.[1]
|
|
55
|
+
if (isDeferred(line, phase.id)) {
|
|
56
|
+
phase.deferredOpen = true
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
const item = itemOf(line)
|
|
60
|
+
if (item !== null) phase.items.push(item)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The phases of a plan as they stand, `### Phase <id>` and `## <id> — title` alike. */
|
|
64
|
+
export const phaseBlocksOf = (markdown: string): PhaseBlock[] => {
|
|
65
|
+
const blocks: PhaseBlock[] = []
|
|
66
|
+
let open: PhaseBlock | null = null
|
|
67
|
+
for (const line of markdown.split('\n')) {
|
|
68
|
+
const id = phaseIdOf(line)
|
|
69
|
+
if (id !== undefined) {
|
|
70
|
+
open = { id, heading: collapsed(line.replace(/^#+/, '')), lines: [] }
|
|
71
|
+
blocks.push(open)
|
|
72
|
+
} else if (PHASE_END.test(line)) open = null
|
|
73
|
+
else open?.lines.push(line)
|
|
74
|
+
}
|
|
75
|
+
return blocks
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const phasesOf = (markdown: string): Phase[] =>
|
|
79
|
+
phaseBlocksOf(markdown).map(({ id, lines }) => {
|
|
80
|
+
const phase: Phase = { id, items: [], deferredOpen: false }
|
|
81
|
+
for (const line of lines) fileLine(phase, line)
|
|
82
|
+
return phase
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
/** The items of every `## ` section by heading, sub-headings included; the last of equal headings wins. */
|
|
86
|
+
const sectionItems = (markdown: string): Map<string, boolean[]> => {
|
|
87
|
+
const sections = new Map<string, boolean[]>()
|
|
88
|
+
let open: boolean[] | null = null
|
|
89
|
+
for (const line of markdown.split('\n')) {
|
|
90
|
+
const heading = line.match(SECTION_HEADING)
|
|
91
|
+
if (heading) {
|
|
92
|
+
open = []
|
|
93
|
+
sections.set(heading[1], open)
|
|
94
|
+
} else if (TOP_HEADING.test(line)) open = null
|
|
95
|
+
else {
|
|
96
|
+
const item = itemOf(line)
|
|
97
|
+
if (item !== null) open?.push(item)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return sections
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* True when a phase (`### Phase <id>` or `## <id> — title`) holds an open checkbox or ⬜ row, a deferred one included;
|
|
105
|
+
* a phase without items of its own counts the section it points at (``Siehe `## Dokumentation` ``).
|
|
106
|
+
*/
|
|
107
|
+
export const hasOpenPhase = (markdown: string): boolean => {
|
|
108
|
+
const phases = phasesOf(markdown)
|
|
109
|
+
if (phases.some((phase) => phase.deferredOpen || phase.items.includes(true))) return true
|
|
110
|
+
const pointed = phases.filter((phase) => phase.items.length === 0 && phase.pointer !== undefined)
|
|
111
|
+
if (pointed.length === 0) return false
|
|
112
|
+
const sections = sectionItems(markdown)
|
|
113
|
+
return pointed.some((phase) => (sections.get(phase.pointer ?? '') ?? []).includes(true))
|
|
114
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { posix } from 'node:path'
|
|
2
|
-
import type
|
|
2
|
+
import { isExpanded, type SimpleCommand, type Word } from './shell'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Words Claude Code strips before it matches a Bash rule, so they do not hide the command. Its
|
|
@@ -11,10 +11,24 @@ export const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
|
|
|
11
11
|
const DURATION = /^\d+(\.\d+)?[smhd]?$/
|
|
12
12
|
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'fish'])
|
|
13
13
|
const SHELL_COMMAND_FLAG = /^-[a-z]*c[a-z]*$/
|
|
14
|
+
const FUNCTION_KEYWORD = 'function'
|
|
15
|
+
/** zsh's precommand, `- eval …`; as a word it is also stdin (`cat -`), so it counts only where a command stands. */
|
|
16
|
+
const ZSH_PRECOMMAND = '-'
|
|
17
|
+
/**
|
|
18
|
+
* Shell words that run a command after names or counts of their own: `function` opens a body (`function f g {`),
|
|
19
|
+
* `coproc` may take a name, zsh's `repeat` a count and `nocorrect` none.
|
|
20
|
+
*/
|
|
21
|
+
const SHELL_RUNNERS = [FUNCTION_KEYWORD, 'coproc', 'repeat', 'nocorrect']
|
|
22
|
+
const SHELL_COMMAND_OPENERS = new Set([...SHELL_RUNNERS, ZSH_PRECOMMAND])
|
|
23
|
+
/** The builtin that runs its arguments, joined, as a script in the same shell. */
|
|
24
|
+
const EVAL_BUILTIN = 'eval'
|
|
25
|
+
/** The builtin that runs its action text later in the same shell. */
|
|
26
|
+
const TRAP_BUILTIN = 'trap'
|
|
14
27
|
|
|
15
28
|
/** Programs that run their arguments as a command, so a program right after them is the one that runs. */
|
|
16
29
|
const COMMAND_RUNNERS = new Set([
|
|
17
30
|
...RULE_TRANSPARENT_WRAPPERS,
|
|
31
|
+
...SHELL_RUNNERS,
|
|
18
32
|
'env',
|
|
19
33
|
'sudo',
|
|
20
34
|
'doas',
|
|
@@ -67,16 +81,145 @@ const shellCommandScript = (words: SimpleCommand, shellIndex: number): string |
|
|
|
67
81
|
return words.slice(flag + 1).find((word) => !isOption(word))?.value
|
|
68
82
|
}
|
|
69
83
|
|
|
84
|
+
/** The action of `trap <action> <signal>…`: the first word after the options and `--`; the shell runs it later itself. */
|
|
85
|
+
const trapAction = (words: SimpleCommand, trapIndex: number): string | undefined => {
|
|
86
|
+
const rest = words.slice(trapIndex + 1)
|
|
87
|
+
const start = rest.findIndex((word) => !isOption(word) || word.value === '--')
|
|
88
|
+
const action = rest[start]?.value === '--' ? rest[start + 1] : rest[start]
|
|
89
|
+
return action?.value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a word before a builtin leaves it the command: an option or its value, an assignment, a duration
|
|
94
|
+
* (`sudo -u me`, `env X=1`, `timeout 30`, `repeat 1`), a reserved word, or the name after `function`.
|
|
95
|
+
*/
|
|
96
|
+
const leadsToBuiltin = (words: SimpleCommand, index: number): boolean => {
|
|
97
|
+
const { value } = words[index]
|
|
98
|
+
const previous = words[index - 1]
|
|
99
|
+
return (
|
|
100
|
+
isOption(words[index]) ||
|
|
101
|
+
isOption(previous) ||
|
|
102
|
+
ASSIGNMENT.test(value) ||
|
|
103
|
+
DURATION.test(value) ||
|
|
104
|
+
RESERVED_WORDS.has(value) ||
|
|
105
|
+
(previous !== undefined && programName(previous) === FUNCTION_KEYWORD)
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Where in a command a builtin runs as the command itself: at the rule start, right after a runner that is itself the
|
|
111
|
+
* command (with its options), or anywhere after a shell word that opens a command and stands as the command itself,
|
|
112
|
+
* whatever names or counts follow it (`function f g {`, `coproc NAME {`, `repeat $n`, zsh's `- eval`). No external
|
|
113
|
+
* runner starts a builtin from a later argument, so `timeout 30 rg eval` searches for the word. One pass per command; each word looks back only to the runner before it.
|
|
114
|
+
*/
|
|
115
|
+
const builtinPositions = (words: SimpleCommand): boolean[] => {
|
|
116
|
+
const start = ruleStart(words)
|
|
117
|
+
const positions: boolean[] = []
|
|
118
|
+
let afterOpener = false
|
|
119
|
+
for (let index = 0; index < words.length; index++) {
|
|
120
|
+
let before = index - 1
|
|
121
|
+
while (before >= 0 && !COMMAND_RUNNERS.has(programName(words[before])) && leadsToBuiltin(words, before)) before--
|
|
122
|
+
const afterRunner = before >= 0 && COMMAND_RUNNERS.has(programName(words[before])) && positions[before]
|
|
123
|
+
positions[index] = index === start || afterRunner || afterOpener
|
|
124
|
+
if (positions[index] && SHELL_COMMAND_OPENERS.has(words[index].value)) afterOpener = true
|
|
125
|
+
}
|
|
126
|
+
return positions
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One pass per command, since every word of it is asked: the positions of a command are its own. */
|
|
130
|
+
const positionsByCommand = new WeakMap<SimpleCommand, boolean[]>()
|
|
131
|
+
|
|
132
|
+
const isBuiltinPosition = (words: SimpleCommand, index: number): boolean => {
|
|
133
|
+
const known = positionsByCommand.get(words)
|
|
134
|
+
if (known) return known[index]
|
|
135
|
+
const positions = builtinPositions(words)
|
|
136
|
+
positionsByCommand.set(words, positions)
|
|
137
|
+
return positions[index]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Whether the word runs as `builtin`: named so where a command stands, also quoted (`'trap'`) or escaped (`\trap`). */
|
|
141
|
+
const isBuiltinAt = (words: SimpleCommand, index: number, builtin: string): boolean =>
|
|
142
|
+
programName(words[index]) === builtin && isBuiltinPosition(words, index)
|
|
143
|
+
|
|
70
144
|
export const inlineScript = (words: SimpleCommand, index: number): string | undefined => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
145
|
+
if (isBuiltinAt(words, index, EVAL_BUILTIN))
|
|
146
|
+
return words
|
|
147
|
+
.slice(index + 1)
|
|
148
|
+
.map(({ value }) => value)
|
|
149
|
+
.join(' ')
|
|
150
|
+
if (isBuiltinAt(words, index, TRAP_BUILTIN)) return trapAction(words, index)
|
|
151
|
+
return isShell(words[index]) ? shellCommandScript(words, index) : undefined
|
|
74
152
|
}
|
|
75
153
|
|
|
154
|
+
/** Interpreters that run program text given on the command line or read from their input. */
|
|
155
|
+
const INTERPRETERS = new Set(['python', 'python3', 'node', 'nodejs', 'bun', 'deno', 'perl', 'ruby', 'php', 'lua'])
|
|
156
|
+
const INLINE_PROGRAM_OPTIONS = new Set(['-c', '-e', '-E', '-p', '-pe', '--eval', '--command', '--print', '-r'])
|
|
157
|
+
const VERSIONED_PYTHON = /^python\d+(\.\d+)*$/
|
|
158
|
+
/** A cluster of short options that ends in the one taking the program text: `python3 -Ic`, `perl -lne`. */
|
|
159
|
+
const INLINE_PROGRAM_CLUSTER = /^-[A-Za-z]*[ce]$/
|
|
160
|
+
/** An operand that names the interpreter's own input as the program file. */
|
|
161
|
+
const INPUT_OPERAND = /^(-|\/dev\/stdin|\/dev\/fd\/0|\/proc\/self\/fd\/0)$/
|
|
162
|
+
/** Options that print and exit, so an interpreter without an operand reads no program. */
|
|
163
|
+
const PRINT_AND_EXIT = new Set(['--version', '-v', '-V', '--help', '-h'])
|
|
164
|
+
/** Interpreters that, without an operand, print their help instead of reading a program; they read one after `run`. */
|
|
165
|
+
const SUBCOMMAND_INTERPRETERS = new Set(['bun'])
|
|
166
|
+
const RUN_SUBCOMMAND = 'run'
|
|
167
|
+
/** An operand that names a script file: the words after it are the script's arguments, not the interpreter's options. */
|
|
168
|
+
const SCRIPT_FILE = /\.(py|js|mjs|cjs|ts|mts|cts|jsx|tsx|rb|pl|pm|php|lua)$/i
|
|
169
|
+
|
|
170
|
+
const isInterpreter = (word: Word): boolean => {
|
|
171
|
+
const name = programName(word)
|
|
172
|
+
return INTERPRETERS.has(name) || VERSIONED_PYTHON.test(name)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const isInlineProgramOption = ({ value }: Word): boolean =>
|
|
176
|
+
INLINE_PROGRAM_OPTIONS.has(value.split('=')[0]) || INLINE_PROGRAM_CLUSTER.test(value)
|
|
177
|
+
|
|
178
|
+
/** The operand that names the program: the first one, or for `bun run` the one after `run`; `-` counts as one. */
|
|
179
|
+
const programOperand = (interpreter: Word, rest: Word[]): Word | undefined => {
|
|
180
|
+
const operands = rest.filter((word) => !isOption(word) || word.value === '-')
|
|
181
|
+
const afterRun = SUBCOMMAND_INTERPRETERS.has(programName(interpreter)) && operands[0]?.value === RUN_SUBCOMMAND
|
|
182
|
+
return afterRun ? operands[1] : operands[0]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether the interpreter reads its program from its input (`python3 -`, `node` fed by a pipe, `bun -`) or from an
|
|
187
|
+
* expansion it cannot show (`python3 <(…)`).
|
|
188
|
+
*/
|
|
189
|
+
const readsProgramFromInput = (interpreter: Word, rest: Word[]): boolean => {
|
|
190
|
+
const operand = programOperand(interpreter, rest)
|
|
191
|
+
if (operand === undefined) {
|
|
192
|
+
return (
|
|
193
|
+
!SUBCOMMAND_INTERPRETERS.has(programName(interpreter)) && !rest.some(({ value }) => PRINT_AND_EXIT.has(value))
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
// A quoted operand is still a file name; only an expansion (`$f`, `<(…)`) hides where the program comes from.
|
|
197
|
+
return isExpanded(operand.value) || INPUT_OPERAND.test(operand.value)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The words up to a script file operand. Only a file name ends them: any other word could be an option's value
|
|
202
|
+
* (`python3 -W ignore -c …`), and stopping there would miss the `-c` behind it.
|
|
203
|
+
*/
|
|
204
|
+
const interpreterOptions = (rest: Word[]): Word[] => {
|
|
205
|
+
const scriptFile = rest.findIndex((word) => !isOption(word) && SCRIPT_FILE.test(word.value))
|
|
206
|
+
return scriptFile === -1 ? rest : rest.slice(0, scriptFile)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* An interpreter, anywhere a program can stand, handed program text (`python3 -c`, `node -e`) or reading it from its
|
|
211
|
+
* input (`python3 - <<EOF`, `… | node`): the program can start any command, and no parse of the shell sees it.
|
|
212
|
+
*/
|
|
213
|
+
export const runsInterpreterProgram = (words: SimpleCommand): boolean =>
|
|
214
|
+
words.some((word, index) => {
|
|
215
|
+
if (!isProgramPosition(words, index) || !isInterpreter(word)) return false
|
|
216
|
+
const rest = words.slice(index + 1)
|
|
217
|
+
return interpreterOptions(rest).some(isInlineProgramOption) || readsProgramFromInput(word, rest)
|
|
218
|
+
})
|
|
219
|
+
|
|
76
220
|
/** A shell at the rule start with no script argument reads its script from stdin: a pipe, a here-document. */
|
|
77
221
|
export const readsScriptFromInput = (words: SimpleCommand): boolean => {
|
|
78
222
|
const start = ruleStart(words)
|
|
79
223
|
const shell = words[start]
|
|
80
224
|
return shell !== undefined && isShell(shell) && words.slice(start + 1).every(isOption)
|
|
81
225
|
}
|
|
82
|
-
|