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,196 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where plan approval reads its facts: git in the main checkout for the base branch, the worktrees and the boards, the
|
|
3
|
-
* pointer directory and the transcripts for the answers. Every git call runs without a shell and with a timeout; any
|
|
4
|
-
* failure answers nothing, so the guard asks.
|
|
5
|
-
*/
|
|
6
|
-
import { existsSync } from 'node:fs'
|
|
7
|
-
import { join, posix, relative, resolve } from 'node:path'
|
|
8
|
-
import { lanePlansOf } from '../shared/board-lanes'
|
|
9
|
-
import { laneOf } from '../shared/lane'
|
|
10
|
-
import type { PlanApprovalSources } from './plan-approval'
|
|
11
|
-
import { FULL_COMMIT } from '../shared/commit'
|
|
12
|
-
import { isPlainPath } from './plain-path'
|
|
13
|
-
import { planAnswers, planPointersDirectory } from './plan-pointers'
|
|
14
|
-
import { checksPassed } from './pull-request'
|
|
15
|
-
import { baseBranchOf } from '../shared/base-branch'
|
|
16
|
-
import { type PlanApprovalSettings, PROJECT_CONFIG, planApprovalSettingsOf } from './plan-approval-settings'
|
|
17
|
-
import { readConfigOrNull } from './settings'
|
|
18
|
-
|
|
19
|
-
/** The base commit plan approval reads, just fetched, and the settings its project config holds. */
|
|
20
|
-
export type PlanBase = { commit: string; settings: PlanApprovalSettings }
|
|
21
|
-
|
|
22
|
-
export type PlanSourceContext = {
|
|
23
|
-
/** The main checkout, where the worktrees, the base branch and the pointers are. */
|
|
24
|
-
main: string
|
|
25
|
-
base: PlanBase
|
|
26
|
-
/** The transcript of the session that merges. */
|
|
27
|
-
transcript: string | undefined
|
|
28
|
-
/** The label that approves a plan. */
|
|
29
|
-
label: string
|
|
30
|
-
files: () => readonly string[] | undefined
|
|
31
|
-
/** The `gh pr view` answer for the pull request, which carries its checks. */
|
|
32
|
-
view: string
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const GIT_TIMEOUT_MS = 3000
|
|
36
|
-
const BASE_REMOTE = 'origin'
|
|
37
|
-
const HEADER = /^[0-9a-f]+ blob (\d+)$/
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The output of `git cat-file --batch`, one entry per requested path in order: the text of a blob, null for a path the
|
|
41
|
-
* commit lacks or that is no blob. Undefined when the output does not hold one entry per path.
|
|
42
|
-
*/
|
|
43
|
-
export const parseCatFileBatch = (output: Buffer, count: number): (string | null)[] | undefined => {
|
|
44
|
-
const texts: (string | null)[] = []
|
|
45
|
-
let at = 0
|
|
46
|
-
while (texts.length < count) {
|
|
47
|
-
const newline = output.indexOf('\n', at)
|
|
48
|
-
if (newline === -1) return undefined
|
|
49
|
-
const size = HEADER.exec(output.subarray(at, newline).toString('utf8'))?.[1]
|
|
50
|
-
if (size === undefined) {
|
|
51
|
-
texts.push(null)
|
|
52
|
-
at = newline + 1
|
|
53
|
-
continue
|
|
54
|
-
}
|
|
55
|
-
const end = newline + 1 + Number(size)
|
|
56
|
-
if (end > output.length) return undefined
|
|
57
|
-
texts.push(output.subarray(newline + 1, end).toString('utf8'))
|
|
58
|
-
at = end + 1
|
|
59
|
-
}
|
|
60
|
-
return texts
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** The lane whose worktree, directly below the worktree directory, has `branch` checked out. */
|
|
64
|
-
export const laneInWorktreeList = (
|
|
65
|
-
worktreeList: string,
|
|
66
|
-
main: string,
|
|
67
|
-
worktreeDirectory: string,
|
|
68
|
-
branch: string,
|
|
69
|
-
): string | undefined => {
|
|
70
|
-
const blocks = worktreeList.split('\n\n').map((block) => block.split('\n'))
|
|
71
|
-
const block = blocks.find((lines) => lines.includes(`branch refs/heads/${branch}`))
|
|
72
|
-
const path = block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
|
|
73
|
-
if (path === undefined) return undefined
|
|
74
|
-
const lane = laneOf({ cwd: path, main, worktreeDirectory })
|
|
75
|
-
return lane !== null && relative(resolve(main, worktreeDirectory), resolve(path)) === lane ? lane : undefined
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Whether `name` is the directory of a lane's worktree below the worktree directory. */
|
|
79
|
-
export const isLaneIn =
|
|
80
|
-
(main: string, worktreeDirectory: string) =>
|
|
81
|
-
(name: string): boolean => {
|
|
82
|
-
const worktree = join(resolve(main, worktreeDirectory), name)
|
|
83
|
-
return laneOf({ cwd: worktree, main, worktreeDirectory }) === name && existsSync(worktree)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Only the base branch into its remote ref: no tags, no FETCH_HEAD written, no maintenance started. */
|
|
87
|
-
export const baseFetchArguments = (baseBranch: string): string[] => [
|
|
88
|
-
'fetch',
|
|
89
|
-
'--quiet',
|
|
90
|
-
'--no-tags',
|
|
91
|
-
'--no-write-fetch-head',
|
|
92
|
-
'--no-auto-maintenance',
|
|
93
|
-
BASE_REMOTE,
|
|
94
|
-
`+refs/heads/${baseBranch}:refs/remotes/${BASE_REMOTE}/${baseBranch}`,
|
|
95
|
-
]
|
|
96
|
-
|
|
97
|
-
/** No prompt for a password or a host key: a fetch that would wait for one fails at once instead. */
|
|
98
|
-
export const NON_INTERACTIVE = { GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
|
|
99
|
-
|
|
100
|
-
const git = (main: string, args: readonly string[], stdin?: string): Buffer | undefined => {
|
|
101
|
-
const result = Bun.spawnSync(['git', '-C', main, ...args], {
|
|
102
|
-
stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
|
|
103
|
-
stdout: 'pipe',
|
|
104
|
-
stderr: 'ignore',
|
|
105
|
-
timeout: GIT_TIMEOUT_MS,
|
|
106
|
-
env: { ...process.env, ...NON_INTERACTIVE },
|
|
107
|
-
})
|
|
108
|
-
return result.exitCode === 0 ? result.stdout : undefined
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const baseCommitOf = (main: string, baseBranch: string): string | undefined => {
|
|
112
|
-
const commit = git(main, ['rev-parse', '--verify', '--quiet', `${BASE_REMOTE}/${baseBranch}^{commit}`])
|
|
113
|
-
?.toString()
|
|
114
|
-
.trim()
|
|
115
|
-
return commit !== undefined && FULL_COMMIT.test(commit) ? commit : undefined
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** The texts of the paths at the commit, in one `git cat-file --batch`; null for each one it lacks or cannot read. */
|
|
119
|
-
const readAtCommit = (main: string, commit: string, paths: readonly string[]): Map<string, string | null> => {
|
|
120
|
-
const asked = paths.filter(isPlainPath)
|
|
121
|
-
const output =
|
|
122
|
-
asked.length === 0
|
|
123
|
-
? undefined
|
|
124
|
-
: git(main, ['cat-file', '--batch'], asked.map((path) => `${commit}:${path}\n`).join(''))
|
|
125
|
-
const texts = output === undefined ? undefined : parseCatFileBatch(output, asked.length)
|
|
126
|
-
return new Map(paths.map((path) => [path, texts?.[asked.indexOf(path)] ?? null]))
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** The board directories under the plan directory at the commit whose name matches the board pattern. */
|
|
130
|
-
const boardDirectories = (main: string, commit: string, settings: PlanApprovalSettings): string[] => {
|
|
131
|
-
const listing = git(main, ['ls-tree', '-d', '--name-only', commit, '--', `${settings.planDirectory}/`])
|
|
132
|
-
const pattern = new Bun.Glob(settings.board.pattern)
|
|
133
|
-
return (listing?.toString() ?? '')
|
|
134
|
-
.split('\n')
|
|
135
|
-
.filter((path) => path !== '' && pattern.match(posix.basename(path)))
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Brings `origin/<baseBranch>` up to date, so a stale ref cannot hold an approval GitHub's base no longer does. */
|
|
139
|
-
const fetchBase = (main: string, baseBranch: string): boolean => git(main, baseFetchArguments(baseBranch)) !== undefined
|
|
140
|
-
|
|
141
|
-
/** The newest first-parent commit of the base from before the time; undefined when there is none. */
|
|
142
|
-
const commitBefore = (main: string, commit: string, time: number): string | undefined => {
|
|
143
|
-
const found = git(main, ['rev-list', '-1', '--first-parent', `--before=@${Math.floor(time / 1000)}`, commit])
|
|
144
|
-
?.toString()
|
|
145
|
-
.trim()
|
|
146
|
-
return found !== undefined && FULL_COMMIT.test(found) ? found : undefined
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* The plan as the base held it at the time of the answer; undefined when the base did not hold it then, or not
|
|
151
|
-
* readably. A plan that was only in a pull request when the user approved it could have grown before it landed, and
|
|
152
|
-
* the state the user saw is nowhere on the base, so the guard asks once more after it landed.
|
|
153
|
-
*/
|
|
154
|
-
const planAtApproval = (main: string, commit: string, plan: string, answeredAt: number): string | undefined => {
|
|
155
|
-
const before = commitBefore(main, commit, answeredAt)
|
|
156
|
-
return (before === undefined ? undefined : readAtCommit(main, before, [plan]).get(plan)) ?? undefined
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The base plan approval reads: `origin/<baseBranch>` right after a fetch, and the settings of the project config at
|
|
161
|
-
* that commit, never of the checkout. The base branch comes from the checkout's config (the base cannot name itself)
|
|
162
|
-
* and must agree with the one the base's config names. Undefined, so the guard asks, when any step fails.
|
|
163
|
-
*/
|
|
164
|
-
export const planBaseOf = (main: string): PlanBase | undefined => {
|
|
165
|
-
const baseBranch = baseBranchOf(readConfigOrNull([main]))
|
|
166
|
-
if (baseBranch === undefined || !fetchBase(main, baseBranch)) return undefined
|
|
167
|
-
const commit = baseCommitOf(main, baseBranch)
|
|
168
|
-
if (commit === undefined) return undefined
|
|
169
|
-
const settings = planApprovalSettingsOf(readAtCommit(main, commit, [PROJECT_CONFIG]).get(PROJECT_CONFIG) ?? null)
|
|
170
|
-
return settings !== null && settings.baseBranch === baseBranch ? { commit, settings } : undefined
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export const planApprovalSources = ({ main, base, transcript, label, files, view }: PlanSourceContext): PlanApprovalSources => {
|
|
174
|
-
const { commit, settings } = base
|
|
175
|
-
return {
|
|
176
|
-
checksPassed: (requiredChecks) => checksPassed(view, requiredChecks),
|
|
177
|
-
containsBase: (headCommit) =>
|
|
178
|
-
FULL_COMMIT.test(headCommit) && git(main, ['merge-base', '--is-ancestor', commit, headCommit]) !== undefined,
|
|
179
|
-
files,
|
|
180
|
-
readAtBase: (paths) => readAtCommit(main, commit, paths),
|
|
181
|
-
laneOfBranch: (branch) => {
|
|
182
|
-
const list = git(main, ['worktree', 'list', '--porcelain'])?.toString()
|
|
183
|
-
return list === undefined ? undefined : laneInWorktreeList(list, main, settings.worktreeDirectory, branch)
|
|
184
|
-
},
|
|
185
|
-
lanePlans: (lane) => {
|
|
186
|
-
const boards = boardDirectories(main, commit, settings)
|
|
187
|
-
const texts = readAtCommit(main, commit, boards.map((directory) => `${directory}/${settings.board.file}`))
|
|
188
|
-
return boards.flatMap((directory) => {
|
|
189
|
-
const text = texts.get(`${directory}/${settings.board.file}`)
|
|
190
|
-
return (text ? lanePlansOf(text, directory, lane, settings.planLanguage) : undefined) ?? []
|
|
191
|
-
})
|
|
192
|
-
},
|
|
193
|
-
answers: (plan) => planAnswers({ directory: planPointersDirectory(main), currentTranscript: transcript, plan, label }),
|
|
194
|
-
readAtCommitBefore: (plan, answeredAt) => (isPlainPath(plan) ? planAtApproval(main, commit, plan, answeredAt) : undefined),
|
|
195
|
-
}
|
|
196
|
-
}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Whether the user's approval of a plan lifts the question for the merge of a pull request: it merges into the base
|
|
3
|
-
* branch, its checks passed, its head contains the base, the pull request comes from a lane whose rows link exactly
|
|
4
|
-
* that plan, the plan still has an open phase, no file matches a path that always asks, none is a package.json and
|
|
5
|
-
* none the CHANGELOG.md of a published package, the newest answer to a plan question naming it approved it, and the
|
|
6
|
-
* plan gained no phase and no undecided task since that answer. Everything is read from the base branch and from
|
|
7
|
-
* GitHub, never from the pull request's own files, and every doubt answers "no", so the guard asks as before. Only a
|
|
8
|
-
* plain `gh pr merge <number>` gets this far.
|
|
9
|
-
*/
|
|
10
|
-
import type { LanePlans } from '../shared/board-lanes'
|
|
11
|
-
import { hasOpenPhase } from '../shared/plan-open-phases'
|
|
12
|
-
import { isRecord } from '../shared/json'
|
|
13
|
-
import type { MergeNote } from './decision'
|
|
14
|
-
import { isPlainPath } from './plain-path'
|
|
15
|
-
import { type NewTask, newPhasesSince, newTasksSince } from './plan-growth'
|
|
16
|
-
|
|
17
|
-
/** The label the user picked in a plan question and when; read from the transcript, not from the pointer. */
|
|
18
|
-
export type PlanAnswer = { answer: string; answeredAt: number }
|
|
19
|
-
|
|
20
|
-
/** Where the facts come from; each is asked only once the steps before it said yes. */
|
|
21
|
-
export type PlanApprovalSources = {
|
|
22
|
-
/** Whether GitHub can merge the head commit and its checks passed, the required ones among them. */
|
|
23
|
-
checksPassed: (requiredChecks: readonly string[] | undefined) => boolean
|
|
24
|
-
/** Whether the head commit contains the base commit plan approval reads. */
|
|
25
|
-
containsBase: (headCommit: string) => boolean
|
|
26
|
-
/** The files of the pull request as GitHub lists them, renames with their old name; undefined when not complete. */
|
|
27
|
-
files: () => readonly string[] | undefined
|
|
28
|
-
/** The texts of these paths at the base branch; null for a path it does not have. */
|
|
29
|
-
readAtBase: (paths: readonly string[]) => ReadonlyMap<string, string | null>
|
|
30
|
-
/** The lane whose worktree has the branch checked out. */
|
|
31
|
-
laneOfBranch: (branch: string) => string | undefined
|
|
32
|
-
/** The lane's rows in each board at the base branch that has any. */
|
|
33
|
-
lanePlans: (lane: string) => LanePlans[]
|
|
34
|
-
/** The answers to the plan questions naming this plan, each backed by its dialog in a transcript. */
|
|
35
|
-
answers: (plan: string) => PlanAnswer[]
|
|
36
|
-
/** The plan as the base held it at the time of the answer; undefined when it held none then or it is unreadable. */
|
|
37
|
-
readAtCommitBefore: (plan: string, answeredAt: number) => string | undefined
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export type PlanApprovalQuery = {
|
|
41
|
-
/** The pull request as GitHub names it: its head branch and commit, and the branch it merges into. */
|
|
42
|
-
pullRequest: { branch: string; headCommit: string; baseBranch: string }
|
|
43
|
-
/** The exact option label that approves a plan. */
|
|
44
|
-
label: string
|
|
45
|
-
/** The time of the check, in milliseconds. */
|
|
46
|
-
now: number
|
|
47
|
-
/** The paths that always ask, the base branch, and the checks that must have passed (undefined: every one listed). */
|
|
48
|
-
settings: { exceptPaths: readonly string[]; baseBranch: string; requiredChecks: readonly string[] | undefined }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** `true` lifts the question; `false` asks as before; a note asks and says why, for a plan that grew since its approval. */
|
|
52
|
-
export type PlanVerdict = boolean | MergeNote
|
|
53
|
-
|
|
54
|
-
const MANIFEST = 'package.json'
|
|
55
|
-
/** How many new tasks a note names before it only counts the rest. */
|
|
56
|
-
const NAMED_TASKS = 5
|
|
57
|
-
const TASK_LENGTH = 80
|
|
58
|
-
const CHANGELOG = 'changelog.md'
|
|
59
|
-
|
|
60
|
-
const directoryOf = (file: string): string => file.slice(0, Math.max(file.lastIndexOf('/'), 0))
|
|
61
|
-
|
|
62
|
-
/** In lower case, as a case-insensitive file system would write it. */
|
|
63
|
-
const fileName = (file: string): string => file.slice(file.lastIndexOf('/') + 1).toLowerCase()
|
|
64
|
-
|
|
65
|
-
const manifestOf = (directory: string): string => (directory === '' ? MANIFEST : `${directory}/${MANIFEST}`)
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Compared in lower case: on a case-insensitive file system git writes `.Claude/x` into the existing `.claude/`, so a
|
|
69
|
-
* pattern must not miss it. Folding only ever adds a question.
|
|
70
|
-
*/
|
|
71
|
-
const matchesAny = (files: readonly string[], patterns: readonly string[]): boolean => {
|
|
72
|
-
const globs = patterns.map((pattern) => new Bun.Glob(pattern.toLowerCase()))
|
|
73
|
-
return files.some((file) => globs.some((glob) => glob.match(file.toLowerCase())))
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The manifests of the packages whose CHANGELOG.md the pull request changes. */
|
|
77
|
-
const changelogManifests = (files: readonly string[]): string[] => [
|
|
78
|
-
...new Set(files.filter((file) => fileName(file) === CHANGELOG).map((file) => manifestOf(directoryOf(file)))),
|
|
79
|
-
]
|
|
80
|
-
|
|
81
|
-
/** A package is private only when its manifest at the base says `"private": true`; a missing or broken one is published. */
|
|
82
|
-
const isPrivate = (manifest: string | null | undefined): boolean => {
|
|
83
|
-
try {
|
|
84
|
-
const parsed: unknown = JSON.parse(manifest ?? '')
|
|
85
|
-
return isRecord(parsed) && parsed.private === true
|
|
86
|
-
} catch {
|
|
87
|
-
return false
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The one plan the lane's open rows link across the boards; none or several gives none. */
|
|
92
|
-
const onlyPlanOf = (rows: readonly LanePlans[]): string | undefined => {
|
|
93
|
-
const plans = new Set(rows.filter(({ closed }) => !closed).flatMap(({ plans }) => plans))
|
|
94
|
-
const [only] = plans
|
|
95
|
-
return plans.size === 1 ? only : undefined
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* When the user approved the plan: the time of the newest answers not from the future, when every one of them is the
|
|
100
|
-
* label. Two answers at the same time (two questions in one dialog) approve only together.
|
|
101
|
-
*/
|
|
102
|
-
const approvedAt = (answers: readonly PlanAnswer[], label: string, now: number): number | undefined => {
|
|
103
|
-
const past = answers.filter(({ answeredAt }) => answeredAt <= now)
|
|
104
|
-
if (past.length === 0) return undefined
|
|
105
|
-
const newest = Math.max(...past.map(({ answeredAt }) => answeredAt))
|
|
106
|
-
const atNewest = past.filter(({ answeredAt }) => answeredAt === newest)
|
|
107
|
-
return atNewest.every(({ answer }) => answer === label) ? newest : undefined
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const shortened = (task: string): string => (task.length > TASK_LENGTH ? `${task.slice(0, TASK_LENGTH - 1)}…` : task)
|
|
111
|
-
|
|
112
|
-
const tasksNote = (tasks: readonly NewTask[]): string => {
|
|
113
|
-
const named = tasks.slice(0, NAMED_TASKS).map(({ phase, task }) => `P${phase} "${shortened(task)}"`)
|
|
114
|
-
const rest = tasks.length > NAMED_TASKS ? ` and ${tasks.length - NAMED_TASKS} more` : ''
|
|
115
|
-
return `${tasks.length} new tasks: ${named.join(', ')}${rest}`
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Why the approval no longer covers the plan: the phases and the undecided tasks it gained; undefined when none. */
|
|
119
|
-
const growthNote = (plan: string, approvedText: string, currentText: string): string | undefined => {
|
|
120
|
-
const phases = newPhasesSince(approvedText, currentText)
|
|
121
|
-
const tasks = newTasksSince(approvedText, currentText)
|
|
122
|
-
const parts = [
|
|
123
|
-
...(phases.length > 0 ? [`new phases: ${phases.map((phase) => `"${phase}"`).join(', ')}`] : []),
|
|
124
|
-
...(tasks.length > 0 ? [tasksNote(tasks)] : []),
|
|
125
|
-
]
|
|
126
|
-
return parts.length === 0 ? undefined : `Plan \`${plan}\` gained since the user's approval ${parts.join('; ')}.`
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* No file of the pull request matches a path that always asks or is a package.json (private or not: it can drop
|
|
131
|
-
* `"private"` or add a dependency), and none is the changelog of a package the base does not call private.
|
|
132
|
-
*/
|
|
133
|
-
const changesOnlyOrdinaryFiles = (files: readonly string[], exceptPaths: readonly string[], sources: PlanApprovalSources) => {
|
|
134
|
-
if (files.length === 0 || !files.every(isPlainPath) || matchesAny(files, exceptPaths)) return false
|
|
135
|
-
if (files.some((file) => fileName(file) === MANIFEST)) return false
|
|
136
|
-
const manifests = changelogManifests(files)
|
|
137
|
-
if (manifests.length === 0) return true
|
|
138
|
-
const texts = sources.readAtBase(manifests)
|
|
139
|
-
return manifests.every((manifest) => isPrivate(texts.get(manifest)))
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** The plan of the branch's lane and its text at the base, while that plan has an open phase there. */
|
|
143
|
-
const openPlanOf = (branch: string, sources: PlanApprovalSources): { plan: string; text: string } | undefined => {
|
|
144
|
-
const lane = sources.laneOfBranch(branch)
|
|
145
|
-
const plan = lane === undefined ? undefined : onlyPlanOf(sources.lanePlans(lane))
|
|
146
|
-
if (plan === undefined) return undefined
|
|
147
|
-
const text = sources.readAtBase([plan]).get(plan)
|
|
148
|
-
return typeof text === 'string' && hasOpenPhase(text) ? { plan, text } : undefined
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** The pull request merges into the base, the head contains it, and its checks passed. */
|
|
152
|
-
const isTestedAgainstBase = ({ pullRequest, settings }: PlanApprovalQuery, sources: PlanApprovalSources): boolean =>
|
|
153
|
-
pullRequest.baseBranch === settings.baseBranch &&
|
|
154
|
-
sources.checksPassed(settings.requiredChecks) &&
|
|
155
|
-
sources.containsBase(pullRequest.headCommit)
|
|
156
|
-
|
|
157
|
-
export const isPlanApproved = (query: PlanApprovalQuery, sources: PlanApprovalSources): PlanVerdict => {
|
|
158
|
-
if (!isTestedAgainstBase(query, sources)) return false
|
|
159
|
-
const files = sources.files()
|
|
160
|
-
if (files === undefined || !changesOnlyOrdinaryFiles(files, query.settings.exceptPaths, sources)) return false
|
|
161
|
-
const open = openPlanOf(query.pullRequest.branch, sources)
|
|
162
|
-
const answeredAt = open === undefined ? undefined : approvedAt(sources.answers(open.plan), query.label, query.now)
|
|
163
|
-
if (open === undefined || answeredAt === undefined) return false
|
|
164
|
-
const approvedText = sources.readAtCommitBefore(open.plan, answeredAt)
|
|
165
|
-
if (approvedText === undefined) return false
|
|
166
|
-
const note = growthNote(open.plan, approvedText, open.text)
|
|
167
|
-
return note === undefined ? true : { note }
|
|
168
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What a plan gained since the user approved it: phases, and tasks in the phases they already saw. Both compare the
|
|
3
|
-
* text of the approved plan with the plan at the base, whatever the boxes say, so ticking a task is no growth.
|
|
4
|
-
*/
|
|
5
|
-
import { phaseBlocksOf, taskOf } from '../shared/plan-open-phases'
|
|
6
|
-
|
|
7
|
-
/** A task of a phase that was there at the approval, which the approved plan did not hold. */
|
|
8
|
-
export type NewTask = { phase: string; task: string }
|
|
9
|
-
|
|
10
|
-
/** A task the user decided, marked `(User <date> …)` with `DD.MM.` or `YYYY-MM-DD`; the user already saw it. */
|
|
11
|
-
const USER_DECIDED = /\(User (?:\d{1,2}\.\d{1,2}\.|\d{4}-\d{2}-\d{2})/
|
|
12
|
-
|
|
13
|
-
const tasksOf = (lines: readonly string[]): string[] =>
|
|
14
|
-
lines.flatMap((line) => {
|
|
15
|
-
const task = taskOf(line)
|
|
16
|
-
return task === null ? [] : [task.text]
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
/** The headings of the current plan's phases the approved plan does not have, by their text: a rename is new. */
|
|
20
|
-
export const newPhasesSince = (approvedText: string, currentText: string): string[] => {
|
|
21
|
-
const approved = new Set(phaseBlocksOf(approvedText).map(({ heading }) => heading))
|
|
22
|
-
return phaseBlocksOf(currentText)
|
|
23
|
-
.map(({ heading }) => heading)
|
|
24
|
-
.filter((heading) => !approved.has(heading))
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The tasks of the current plan that the same phase of the approved plan did not hold, by their text (a rewording is
|
|
29
|
-
* new), leaving out tasks the user decided and every task of a phase that is new itself.
|
|
30
|
-
*/
|
|
31
|
-
export const newTasksSince = (approvedText: string, currentText: string): NewTask[] => {
|
|
32
|
-
const approved = new Map(phaseBlocksOf(approvedText).map(({ id, lines }) => [id, new Set(tasksOf(lines))]))
|
|
33
|
-
return phaseBlocksOf(currentText).flatMap(({ id, lines }) => {
|
|
34
|
-
const known = approved.get(id)
|
|
35
|
-
if (known === undefined) return []
|
|
36
|
-
return tasksOf(lines)
|
|
37
|
-
.filter((task) => !known.has(task) && !USER_DECIDED.test(task))
|
|
38
|
-
.map((task) => ({ phase: id, task }))
|
|
39
|
-
})
|
|
40
|
-
}
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A plan approval outlives the session it was given in: the `PostToolUse` hook on `AskUserQuestion` leaves a pointer
|
|
3
|
-
* (plan, transcript file, `tool_use_id`) under `.jackherd/approvals/plans/`, and the merge guard follows it back into
|
|
4
|
-
* that transcript at every merge. The pointer only says where to look; what counts is the dialog it finds there, the
|
|
5
|
-
* question naming the plan and the label the user picked, so a pointer an agent writes itself gains nothing.
|
|
6
|
-
*
|
|
7
|
-
* The limit, as for the branch approval: an agent that runs as the user could still write a transcript file. This
|
|
8
|
-
* guards against a mistake, not against an agent that sets out to get around it.
|
|
9
|
-
*/
|
|
10
|
-
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
11
|
-
import { dirname, isAbsolute, join } from 'node:path'
|
|
12
|
-
import { ASK_USER_QUESTION_TOOL, dialogAnswers } from '../shared/dialog-answers'
|
|
13
|
-
import { realPathOrNull } from '../shared/files'
|
|
14
|
-
import { isRecord } from '../shared/json'
|
|
15
|
-
import { ensurePrivateDirectory, PRIVATE_FILE } from '../shared/private-directory'
|
|
16
|
-
import type { PlanAnswer } from './plan-approval'
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* `answers`: per plan, the answer the hook input reported, null where it reported none. An approval still needs its
|
|
20
|
-
* dialog in the transcript; any other answer ends the approval at `recordedAt` without one, since it only adds a question.
|
|
21
|
-
*/
|
|
22
|
-
export type PlanPointer = {
|
|
23
|
-
toolUseId: string
|
|
24
|
-
transcript: string
|
|
25
|
-
plans: string[]
|
|
26
|
-
answers: Record<string, string | null>
|
|
27
|
-
recordedAt: number
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type PlanAnswerQuery = {
|
|
31
|
-
/** The pointer directory, `.jackherd/approvals/plans` of the main checkout. */
|
|
32
|
-
directory: string
|
|
33
|
-
/** The transcript of the session that merges: only transcripts beside it, of the same project, are read. */
|
|
34
|
-
currentTranscript: string | undefined
|
|
35
|
-
plan: string
|
|
36
|
-
/** The label that approves a plan; a pointer with any other answer is a revocation. */
|
|
37
|
-
label: string
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const PLAN_MENTION = /\bPlan `([^`]*)`/g
|
|
41
|
-
const PLAN_PATH = /^[A-Za-z0-9_./-]+\.md$/
|
|
42
|
-
const TOOL_USE_ID = /^[A-Za-z0-9_-]{1,128}$/
|
|
43
|
-
const POINTER_FILE = /^([A-Za-z0-9_-]{1,128})\.json$/
|
|
44
|
-
const TRANSCRIPT_FILE = /\.jsonl$/
|
|
45
|
-
|
|
46
|
-
/** A plan path as a board links it: a relative markdown file, no empty, `.` or `..` segment. */
|
|
47
|
-
const isPlanPath = (path: string): boolean =>
|
|
48
|
-
PLAN_PATH.test(path) && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
|
|
49
|
-
|
|
50
|
-
const isTranscriptPath = (value: unknown): value is string =>
|
|
51
|
-
typeof value === 'string' && isAbsolute(value) && TRANSCRIPT_FILE.test(value)
|
|
52
|
-
|
|
53
|
-
const isToolUseId = (value: unknown): value is string => typeof value === 'string' && TOOL_USE_ID.test(value)
|
|
54
|
-
|
|
55
|
-
/** The one plan a question names as ``Plan `path` ``; a question that names none or several names none. */
|
|
56
|
-
export const namedPlan = (question: string): string | undefined => {
|
|
57
|
-
const plans = new Set([...question.matchAll(PLAN_MENTION)].map(([, path]) => path))
|
|
58
|
-
const [only] = plans
|
|
59
|
-
return plans.size === 1 && isPlanPath(only) ? only : undefined
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
type PlanAsked = { plan: string; question: string }
|
|
63
|
-
|
|
64
|
-
/** The plans of the single-choice questions that name one and offer one of the plan labels, with the question text. */
|
|
65
|
-
const plansAsked = (input: unknown, labels: readonly string[]): PlanAsked[] => {
|
|
66
|
-
const questions = isRecord(input) && Array.isArray(input.questions) ? input.questions.filter(isRecord) : []
|
|
67
|
-
return questions.flatMap((question) => {
|
|
68
|
-
const options = question.multiSelect !== true && Array.isArray(question.options) ? question.options : []
|
|
69
|
-
const offersLabel = options.some((option) => isRecord(option) && labels.includes(String(option.label)))
|
|
70
|
-
const text = typeof question.question === 'string' ? question.question : ''
|
|
71
|
-
const plan = namedPlan(text)
|
|
72
|
-
return offersLabel && plan !== undefined ? [{ plan, question: text }] : []
|
|
73
|
-
})
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The answer the hook input reports for a question, keyed by its text; null when it reports none. */
|
|
77
|
-
const reportedAnswer = (response: unknown, question: string): string | null => {
|
|
78
|
-
const answers = isRecord(response) && isRecord(response.answers) ? response.answers : {}
|
|
79
|
-
const answer = answers[question]
|
|
80
|
-
return typeof answer === 'string' ? answer : null
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** The pointer for a `PostToolUse` input of an `AskUserQuestion` call that asked about a plan; undefined for any other. */
|
|
84
|
-
export const pointerOf = (input: unknown, labels: readonly string[], now: number): PlanPointer | undefined => {
|
|
85
|
-
if (!isRecord(input) || input.tool_name !== ASK_USER_QUESTION_TOOL) return undefined
|
|
86
|
-
const { tool_use_id: toolUseId, transcript_path: transcript } = input
|
|
87
|
-
if (!isToolUseId(toolUseId) || !isTranscriptPath(transcript)) return undefined
|
|
88
|
-
const asked = plansAsked(input.tool_input, labels.filter(Boolean))
|
|
89
|
-
if (asked.length === 0) return undefined
|
|
90
|
-
const answers = Object.fromEntries(asked.map(({ plan, question }) => [plan, reportedAnswer(input.tool_response, question)]))
|
|
91
|
-
return { toolUseId, transcript, plans: [...new Set(asked.map(({ plan }) => plan))], answers, recordedAt: now }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** The lines of the file that contain `needle`: a transcript runs to hundreds of megabytes, and only these get parsed. */
|
|
95
|
-
const linesContaining = (text: Buffer, needle: string): string[] => {
|
|
96
|
-
const lines: string[] = []
|
|
97
|
-
for (let at = text.indexOf(needle); at !== -1; ) {
|
|
98
|
-
const start = text.lastIndexOf('\n', at) + 1
|
|
99
|
-
const newline = text.indexOf('\n', at)
|
|
100
|
-
const end = newline === -1 ? text.length : newline
|
|
101
|
-
lines.push(text.subarray(start, end).toString('utf8'))
|
|
102
|
-
at = newline === -1 ? -1 : text.indexOf(needle, end)
|
|
103
|
-
}
|
|
104
|
-
return lines
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const parsedLines = (lines: string[]): unknown[] =>
|
|
108
|
-
lines.flatMap((line) => {
|
|
109
|
-
try {
|
|
110
|
-
return [JSON.parse(line)]
|
|
111
|
-
} catch {
|
|
112
|
-
return []
|
|
113
|
-
}
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
/** The answers of the dialog `toolUseId` in the transcript to questions naming `plan`, free text included. */
|
|
117
|
-
const answersInTranscript = (text: Buffer, toolUseId: string, plan: string): PlanAnswer[] =>
|
|
118
|
-
dialogAnswers(parsedLines(linesContaining(text, JSON.stringify(toolUseId))))
|
|
119
|
-
.filter(({ question }) => namedPlan(question) === plan)
|
|
120
|
-
.map(({ answer, answeredAt }) => ({ answer, answeredAt }))
|
|
121
|
-
|
|
122
|
-
/** A pointer whose reported answer is not the label ends the approval when it was recorded; no transcript needed. */
|
|
123
|
-
const revocationOf = (pointer: PlanPointer, plan: string): PlanAnswer => ({
|
|
124
|
-
answer: pointer.answers[plan] ?? '',
|
|
125
|
-
answeredAt: pointer.recordedAt,
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
/** The pointer directory of a main checkout, beside the approval gate's requests. */
|
|
129
|
-
export const planPointersDirectory = (main: string): string => join(main, '.jackherd', 'approvals', 'plans')
|
|
130
|
-
|
|
131
|
-
export const writePointer = (directory: string, pointer: PlanPointer): void => {
|
|
132
|
-
ensurePrivateDirectory(directory)
|
|
133
|
-
writeFileSync(join(directory, `${pointer.toolUseId}.json`), `${JSON.stringify(pointer)}\n`, { mode: PRIVATE_FILE })
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const pointerIn = (directory: string, name: string): PlanPointer[] => {
|
|
137
|
-
const id = POINTER_FILE.exec(name)?.[1]
|
|
138
|
-
if (id === undefined) return []
|
|
139
|
-
try {
|
|
140
|
-
const value: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
|
|
141
|
-
if (!isRecord(value) || value.toolUseId !== id || !isTranscriptPath(value.transcript)) return []
|
|
142
|
-
const plans = Array.isArray(value.plans) ? value.plans : []
|
|
143
|
-
const valid = plans.length > 0 && plans.every((plan) => typeof plan === 'string' && isPlanPath(plan))
|
|
144
|
-
if (!valid || typeof value.recordedAt !== 'number') return []
|
|
145
|
-
const answers = isRecord(value.answers) ? value.answers : {}
|
|
146
|
-
const reported = (plan: string) => (typeof answers[plan] === 'string' ? answers[plan] : null)
|
|
147
|
-
return [{ ...(value as PlanPointer), answers: Object.fromEntries(plans.map((plan) => [plan, reported(plan)])) }]
|
|
148
|
-
} catch {
|
|
149
|
-
return []
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Every file of the directory that parses as a pointer named after its own id; none for a missing directory. */
|
|
154
|
-
export const readPointers = (directory: string): PlanPointer[] => {
|
|
155
|
-
try {
|
|
156
|
-
return readdirSync(directory).flatMap((name) => pointerIn(directory, name))
|
|
157
|
-
} catch {
|
|
158
|
-
return []
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const readOrNull = (path: string): Buffer | null => {
|
|
163
|
-
try {
|
|
164
|
-
return readFileSync(path)
|
|
165
|
-
} catch {
|
|
166
|
-
return null
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** The answers a pointer's transcript holds for `plan`, when that transcript lies beside the current one. */
|
|
171
|
-
const backedAnswers = (pointer: PlanPointer, plan: string, project: string, texts: Map<string, Buffer | null>) => {
|
|
172
|
-
const path = realPathOrNull(pointer.transcript)
|
|
173
|
-
if (path === null || dirname(path) !== project) return []
|
|
174
|
-
if (!texts.has(path)) texts.set(path, readOrNull(path))
|
|
175
|
-
const text = texts.get(path)
|
|
176
|
-
return text ? answersInTranscript(text, pointer.toolUseId, plan) : []
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* The answers to the plan questions naming `plan`. A pointer that reported the approval label counts only with its
|
|
181
|
-
* dialog in a transcript beside the current one (Claude Code keeps a project's sessions side by side); a pointer that
|
|
182
|
-
* reported anything else, free text or nothing included, is a revocation and counts as it stands.
|
|
183
|
-
*/
|
|
184
|
-
export const planAnswers = ({ directory, currentTranscript, plan, label }: PlanAnswerQuery): PlanAnswer[] => {
|
|
185
|
-
const pointers = readPointers(directory).filter((pointer) => pointer.plans.includes(plan))
|
|
186
|
-
const revocations = pointers.filter((pointer) => pointer.answers[plan] !== label).map((pointer) => revocationOf(pointer, plan))
|
|
187
|
-
const project = currentTranscript === undefined ? null : realPathOrNull(dirname(currentTranscript))
|
|
188
|
-
if (project === null) return revocations
|
|
189
|
-
const texts = new Map<string, Buffer | null>()
|
|
190
|
-
const approvals = pointers
|
|
191
|
-
.filter((pointer) => pointer.answers[plan] === label)
|
|
192
|
-
.flatMap((pointer) => backedAnswers(pointer, plan, project, texts))
|
|
193
|
-
return [...approvals, ...revocations]
|
|
194
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
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')
|