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,17 +1,15 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
-
import { isAbsolute, relative } from 'node:path'
|
|
3
2
|
import { mainCheckoutOf } from '../shared/checkout'
|
|
4
|
-
import { parseTranscript } from '../shared/dialog-answers'
|
|
5
|
-
import {
|
|
3
|
+
import { dialogAnswers, parseTranscript } from '../shared/dialog-answers'
|
|
4
|
+
import { isWithinDirectory } from '../shared/files'
|
|
6
5
|
import { once } from '../shared/once'
|
|
7
|
-
import { ownPaneAgent } from '../shared/own-pane'
|
|
8
6
|
import { readShellDefinitions } from '../shared/shell-snapshots'
|
|
7
|
+
import { ALWAYS_ASK_PATHS } from './always-ask'
|
|
9
8
|
import { approvedBranches } from './approval'
|
|
10
|
-
import {
|
|
9
|
+
import { boardSettingsOf } from './board-settings'
|
|
10
|
+
import { changedFilesOf, isCheckFree } from './check-free'
|
|
11
11
|
import { decideBashCommand, type GuardPolicy, type MergeGuardDecision, type MergeNote, type PlainMerge } from './decision'
|
|
12
|
-
import {
|
|
13
|
-
import { isLaneIn, planApprovalSources, planBaseOf } from './plan-approval-sources'
|
|
14
|
-
import { planPointersDirectory, readPointers } from './plan-pointers'
|
|
12
|
+
import { planOfBranch } from './lane-plan'
|
|
15
13
|
import {
|
|
16
14
|
checksPassed,
|
|
17
15
|
parseBaseBranch,
|
|
@@ -20,12 +18,13 @@ import {
|
|
|
20
18
|
parseRepositoryFacts,
|
|
21
19
|
viewPullRequest,
|
|
22
20
|
} from './pull-request'
|
|
23
|
-
import { type GuardSettings, readGuardSettings } from './settings'
|
|
21
|
+
import { type GuardSettings, readConfigOrNull, readGuardSettings } from './settings'
|
|
22
|
+
import { standingVerdict, type StandingLabels, standingGrants } from './standing-approval'
|
|
24
23
|
|
|
25
|
-
type HookInput = { tool_input?: { command?: unknown }; transcript_path?: unknown; cwd?: unknown
|
|
24
|
+
type HookInput = { tool_input?: { command?: unknown }; transcript_path?: unknown; cwd?: unknown }
|
|
26
25
|
|
|
27
|
-
/** The exact option labels that approve a merge: the one of the pull request question and
|
|
28
|
-
export type ApprovalLabels = { selfMerge: string;
|
|
26
|
+
/** The exact option labels that approve a merge: the one of the pull request question and those of the standing approval. */
|
|
27
|
+
export type ApprovalLabels = { selfMerge: string; standing: StandingLabels }
|
|
29
28
|
|
|
30
29
|
/** What GitHub says about the pull request a merge names; `files` is asked only when a rule needs it. */
|
|
31
30
|
type MergeFacts = {
|
|
@@ -37,44 +36,43 @@ type MergeFacts = {
|
|
|
37
36
|
files: () => readonly string[] | undefined
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
type
|
|
39
|
+
type StandingApproval = (facts: MergeFacts) => boolean | MergeNote
|
|
41
40
|
|
|
42
41
|
/** The ways a merge may pass without a question. */
|
|
43
|
-
type MergeApprovals = {
|
|
42
|
+
type MergeApprovals = {
|
|
43
|
+
branches: ReadonlySet<string>
|
|
44
|
+
withoutChecks: readonly string[]
|
|
45
|
+
/** The paths that always ask, which a `withoutChecks` pattern never opens. */
|
|
46
|
+
alwaysAsk: readonly string[]
|
|
47
|
+
standing: StandingApproval | undefined
|
|
48
|
+
}
|
|
44
49
|
|
|
45
50
|
const PASS: MergeGuardDecision = { kind: 'pass' }
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
entries: parseTranscript(readFileSync(transcriptPath, 'utf8')),
|
|
51
|
-
label,
|
|
52
|
-
now: Date.now(),
|
|
53
|
-
windowMinutes: settings.selfMerge.approvalMinutes,
|
|
54
|
-
})
|
|
55
|
-
}
|
|
52
|
+
/** The transcript of the session that merges, one parsed entry per line; none without one. */
|
|
53
|
+
const transcriptEntries = (transcriptPath: unknown): unknown[] =>
|
|
54
|
+
typeof transcriptPath === 'string' ? parseTranscript(readFileSync(transcriptPath, 'utf8')) : []
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return path === '' || (!path.startsWith('..') && !isAbsolute(path))
|
|
56
|
+
const approvedBranchesFrom = (entries: unknown[], settings: GuardSettings, label: string): Set<string> => {
|
|
57
|
+
if (!settings.selfMerge.offer) return new Set()
|
|
58
|
+
return approvedBranches({ entries, label, now: Date.now(), windowMinutes: settings.selfMerge.approvalMinutes })
|
|
61
59
|
}
|
|
62
60
|
|
|
63
61
|
/**
|
|
64
|
-
* The user approved the branch and its checks passed, every file needs no checks, or
|
|
65
|
-
* branch's lane; each only for a merge bound to the head commit GitHub reports, so a push after the check
|
|
66
|
-
* merged unchecked. A
|
|
62
|
+
* The user approved the branch and its checks passed, every file needs no checks, or a standing approval covers the
|
|
63
|
+
* plan of the branch's lane; each only for a merge bound to the head commit GitHub reports, so a push after the check
|
|
64
|
+
* is not merged unchecked. A standing approval that does not cover a file answers with a note for the question.
|
|
67
65
|
*/
|
|
68
66
|
const approvesMerge = (
|
|
69
|
-
{ branches, withoutChecks,
|
|
67
|
+
{ branches, withoutChecks, alwaysAsk, standing }: MergeApprovals,
|
|
70
68
|
merge: PlainMerge,
|
|
71
69
|
facts: MergeFacts,
|
|
72
70
|
): boolean | MergeNote => {
|
|
73
71
|
const { headCommit } = facts
|
|
74
72
|
if (headCommit === undefined || merge.headCommit !== headCommit) return false
|
|
75
73
|
if (branches.has(facts.branch) && checksPassed(facts.view, undefined)) return true
|
|
76
|
-
if (withoutChecks.length > 0 &&
|
|
77
|
-
return
|
|
74
|
+
if (withoutChecks.length > 0 && isCheckFree(facts.files() ?? [], withoutChecks, alwaysAsk)) return true
|
|
75
|
+
return standing === undefined ? false : standing(facts)
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
/**
|
|
@@ -106,25 +104,25 @@ const mergeFactsOf = (pullRequest: number, view: string, directory: string): Mer
|
|
|
106
104
|
}
|
|
107
105
|
|
|
108
106
|
/**
|
|
109
|
-
* How to tell whether
|
|
110
|
-
*
|
|
111
|
-
*
|
|
107
|
+
* How to tell whether a standing approval in this session's transcript covers a merge, with the boards, worktrees and
|
|
108
|
+
* settings of the main checkout; undefined when none can (no approval still holding, no repository, settings that do
|
|
109
|
+
* not read), so GitHub is not asked.
|
|
112
110
|
*/
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
111
|
+
const standingApprovalFor = (entries: unknown[], labels: StandingLabels, projectDirectory: string): StandingApproval | undefined => {
|
|
112
|
+
const grants = standingGrants(dialogAnswers(entries), labels, Date.now())
|
|
113
|
+
if (grants.length === 0) return undefined
|
|
116
114
|
try {
|
|
117
115
|
const main = mainCheckoutOf(projectDirectory)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
116
|
+
const settings = boardSettingsOf(readConfigOrNull([main]))
|
|
117
|
+
if (settings === null) return undefined
|
|
118
|
+
return ({ branch, baseBranch, view, files }) =>
|
|
119
|
+
standingVerdict(
|
|
120
|
+
{ grants, branch, baseBranch, settingsBaseBranch: settings.baseBranch },
|
|
121
|
+
{
|
|
122
|
+
planOfBranch: (name) => planOfBranch(main, settings, name),
|
|
123
|
+
checksPassed: () => checksPassed(view, settings.requiredChecks),
|
|
124
|
+
files,
|
|
125
|
+
},
|
|
128
126
|
)
|
|
129
127
|
} catch {
|
|
130
128
|
return undefined
|
|
@@ -134,15 +132,22 @@ const planApprovalFor = (input: HookInput, label: string, projectDirectory: stri
|
|
|
134
132
|
const policyFor = (input: HookInput, labels: ApprovalLabels, projectDirectory: string): GuardPolicy => {
|
|
135
133
|
const settings = readGuardSettings(projectDirectory)
|
|
136
134
|
const directory = typeof input.cwd === 'string' && input.cwd !== '' ? input.cwd : projectDirectory
|
|
137
|
-
const
|
|
138
|
-
const
|
|
135
|
+
const entries = once(() => transcriptEntries(input.transcript_path))
|
|
136
|
+
const branches = once(() => approvedBranchesFrom(entries(), settings, labels.selfMerge))
|
|
137
|
+
const standing = once(() => standingApprovalFor(entries(), labels.standing, projectDirectory))
|
|
139
138
|
return {
|
|
140
139
|
commands: settings.commands,
|
|
141
140
|
shellDefinitions: once(() => readShellDefinitions()),
|
|
142
141
|
isMergeApproved: (merge) => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
// the same branch name in another checkout is another pull request
|
|
143
|
+
if (!isWithinDirectory(directory, projectDirectory)) return false
|
|
144
|
+
const approvals = {
|
|
145
|
+
branches: branches(),
|
|
146
|
+
withoutChecks: settings.withoutChecks,
|
|
147
|
+
alwaysAsk: [...ALWAYS_ASK_PATHS, ...settings.alwaysAsk],
|
|
148
|
+
standing: standing(),
|
|
149
|
+
}
|
|
150
|
+
if (approvals.branches.size === 0 && approvals.withoutChecks.length === 0 && approvals.standing === undefined) return false
|
|
146
151
|
// The branch lookup also proves the pull request is open and from this repository; every path needs that.
|
|
147
152
|
const facts = mergeFactsOf(merge.pullRequest, viewPullRequest(merge.pullRequest, directory) ?? '', directory)
|
|
148
153
|
return facts !== undefined && approvesMerge(approvals, merge, facts)
|
|
@@ -8,12 +8,18 @@ export type GuardSettings = {
|
|
|
8
8
|
selfMerge: { offer: boolean; approvalMinutes: number }
|
|
9
9
|
/** Path patterns of a pull request that is merged without checks; empty means every merge waits for them. */
|
|
10
10
|
withoutChecks: string[]
|
|
11
|
+
/**
|
|
12
|
+
* The project's own paths that always ask (`guard.alwaysAsk`, and `guard.planApproval.exceptPaths` of a config from
|
|
13
|
+
* before 1.10.0); they only ever add a question.
|
|
14
|
+
*/
|
|
15
|
+
alwaysAsk: string[]
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export const DEFAULT_SETTINGS: GuardSettings = {
|
|
14
|
-
commands: ['gh pr merge', 'gh workflow run'
|
|
19
|
+
commands: ['gh pr merge', 'gh workflow run'],
|
|
15
20
|
selfMerge: { offer: true, approvalMinutes: 180 },
|
|
16
21
|
withoutChecks: [],
|
|
22
|
+
alwaysAsk: [],
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
const CONFIG_PATH = join('.jackherd', 'config.json')
|
|
@@ -50,6 +56,10 @@ export const readConfigOrNull = (directories: string[]): Record<string, unknown>
|
|
|
50
56
|
|
|
51
57
|
const readConfig = (directories: string[]): Record<string, unknown> => readConfigOrNull(directories) ?? {}
|
|
52
58
|
|
|
59
|
+
/** Every non-empty string of a list; a path only ever adds a question, so no entry is refused for its form. */
|
|
60
|
+
const pathsOf = (value: unknown): string[] =>
|
|
61
|
+
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string' && entry !== '') : []
|
|
62
|
+
|
|
53
63
|
/**
|
|
54
64
|
* The guard settings of the project's `.jackherd/config.json`, else of the home one. Whatever is missing or
|
|
55
65
|
* malformed keeps its default, so a broken config guards more, never less; only an empty list switches the guard off.
|
|
@@ -59,6 +69,7 @@ export const readGuardSettings = (projectDirectory: string, home: string = homed
|
|
|
59
69
|
const section = isRecord(config.guard) ? config.guard : {}
|
|
60
70
|
const merge = isRecord(config.merge) ? config.merge : {}
|
|
61
71
|
const selfMerge = isRecord(section.selfMerge) ? section.selfMerge : {}
|
|
72
|
+
const legacy = isRecord(section.planApproval) ? section.planApproval : {}
|
|
62
73
|
return {
|
|
63
74
|
commands: isCommandList(section.commands) ? section.commands : DEFAULT_SETTINGS.commands,
|
|
64
75
|
selfMerge: {
|
|
@@ -68,5 +79,6 @@ export const readGuardSettings = (projectDirectory: string, home: string = homed
|
|
|
68
79
|
: DEFAULT_SETTINGS.selfMerge.approvalMinutes,
|
|
69
80
|
},
|
|
70
81
|
withoutChecks: isPatternList(merge.withoutChecks) ? merge.withoutChecks : DEFAULT_SETTINGS.withoutChecks,
|
|
82
|
+
alwaysAsk: [...pathsOf(section.alwaysAsk), ...pathsOf(legacy.exceptPaths)],
|
|
71
83
|
}
|
|
72
84
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The standing approval: the user's answer to a dialog whose question carries ``Plan `<path>` <until> <YYYY-MM-DD HH:MM>``
|
|
3
|
+
* lifts the question for the merges of that plan's lanes until that moment, at most 24 hours after the answer. It is
|
|
4
|
+
* read from the transcript of the session that merges, like the self-merge label, so a new session after a hand-over
|
|
5
|
+
* holds none and asks again (`claude --resume` keeps the transcript, so there it holds on, at most 24 hours). The newest
|
|
6
|
+
* answer to a question naming the plan decides; any other label ends the approval, also on a question without an end.
|
|
7
|
+
* A merge passes only into the base branch, with green checks GitHub can merge, from a branch whose lane links exactly
|
|
8
|
+
* that plan, and never with a file of `STANDING_ALWAYS_ASK`. Every doubt answers "no", so the guard asks as before.
|
|
9
|
+
*/
|
|
10
|
+
import type { DialogAnswer } from '../shared/dialog-answer'
|
|
11
|
+
import { MS_PER_MINUTE } from '../shared/fresh'
|
|
12
|
+
import { newestVerdict } from '../shared/newest-verdict'
|
|
13
|
+
import { alwaysAskingFiles, STANDING_ALWAYS_ASK } from './always-ask'
|
|
14
|
+
import type { MergeNote } from './decision'
|
|
15
|
+
import { isPlainPath } from './plain-path'
|
|
16
|
+
|
|
17
|
+
/** The exact option label that approves, and the word before the end in the question, both per response language. */
|
|
18
|
+
export type StandingLabels = { approve: string; until: string }
|
|
19
|
+
|
|
20
|
+
/** A plan whose lanes merge without a question until `until`, in milliseconds. */
|
|
21
|
+
export type StandingGrant = { plan: string; until: number }
|
|
22
|
+
|
|
23
|
+
/** Where the facts of a merge come from; each is asked only once the steps before it said yes. */
|
|
24
|
+
export type StandingApprovalSources = {
|
|
25
|
+
/** The one plan the lane of the branch links on the boards; undefined for none, several, or a branch of no lane. */
|
|
26
|
+
planOfBranch: (branch: string) => string | undefined
|
|
27
|
+
/** Whether GitHub can merge the head commit and its checks passed. */
|
|
28
|
+
checksPassed: () => boolean
|
|
29
|
+
/** The files of the pull request as GitHub lists them, renames with their old name; undefined when not complete. */
|
|
30
|
+
files: () => readonly string[] | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StandingApprovalQuery = {
|
|
34
|
+
grants: readonly StandingGrant[]
|
|
35
|
+
/** The head branch and the branch the pull request merges into, as GitHub says. */
|
|
36
|
+
branch: string
|
|
37
|
+
baseBranch: string | undefined
|
|
38
|
+
/** The project's base branch. */
|
|
39
|
+
settingsBaseBranch: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const MAX_STANDING_MINUTES = 24 * 60
|
|
43
|
+
|
|
44
|
+
const PLAN_PATH = /^[A-Za-z0-9_./-]+\.md$/
|
|
45
|
+
|
|
46
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
47
|
+
|
|
48
|
+
/** A plan path as a board links it: a relative markdown file, no empty, `.` or `..` segment. */
|
|
49
|
+
const isPlanPath = (path: string): boolean =>
|
|
50
|
+
PLAN_PATH.test(path) && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
|
|
51
|
+
|
|
52
|
+
/** A local moment from its parts; undefined for one the calendar or the clock does not have (Feb 30, 24:00, a DST gap). */
|
|
53
|
+
const localMoment = ([year, month, day, hour, minute]: number[]): number | undefined => {
|
|
54
|
+
const date = new Date(year, month - 1, day, hour, minute)
|
|
55
|
+
const exact =
|
|
56
|
+
date.getFullYear() === year &&
|
|
57
|
+
date.getMonth() === month - 1 &&
|
|
58
|
+
date.getDate() === day &&
|
|
59
|
+
date.getHours() === hour &&
|
|
60
|
+
date.getMinutes() === minute
|
|
61
|
+
return exact ? date.getTime() : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The plan and the end a question names as ``Plan `<path>` <until> YYYY-MM-DD HH:MM``; none for none or several marks. */
|
|
65
|
+
export const standingMarkOf = (question: string, until: string): StandingGrant | undefined => {
|
|
66
|
+
if (until === '') return undefined
|
|
67
|
+
const mark = new RegExp(`\\bPlan \`([^\`]*)\` ${escapeRegExp(until)} (\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2})(?!\\d)`, 'g')
|
|
68
|
+
const marks = [...question.matchAll(mark)]
|
|
69
|
+
if (marks.length !== 1) return undefined
|
|
70
|
+
const [, plan, ...parts] = marks[0]
|
|
71
|
+
const end = localMoment(parts.map(Number))
|
|
72
|
+
return end !== undefined && isPlanPath(plan) ? { plan, until: end } : undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const PLAN_MENTION = /\bPlan `([^`]*)`/g
|
|
76
|
+
|
|
77
|
+
/** The one plan a question names as ``Plan `<path>` ``, with or without an end; none for none or several. */
|
|
78
|
+
const namedPlanOf = (question: string): string | undefined => {
|
|
79
|
+
const plans = new Set([...question.matchAll(PLAN_MENTION)].map(([, plan]) => plan))
|
|
80
|
+
const [only] = plans
|
|
81
|
+
return plans.size === 1 && isPlanPath(only) ? only : undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The plan an answer speaks about, and the end its question names; `NaN` for a question that names the plan without
|
|
86
|
+
* an end, which never approves but, answered with anything else, ends the approval: in doubt it ends.
|
|
87
|
+
*/
|
|
88
|
+
const markOf = (question: string, until: string): StandingGrant | undefined => {
|
|
89
|
+
const mark = standingMarkOf(question, until)
|
|
90
|
+
if (mark !== undefined) return mark
|
|
91
|
+
const plan = namedPlanOf(question)
|
|
92
|
+
return plan === undefined ? undefined : { plan, until: Number.NaN }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type MarkedAnswer = DialogAnswer & { mark: StandingGrant }
|
|
96
|
+
|
|
97
|
+
const approves = ({ answer, offered, answeredAt, mark }: MarkedAnswer, label: string): boolean =>
|
|
98
|
+
label !== '' &&
|
|
99
|
+
answer === label &&
|
|
100
|
+
offered.includes(label) &&
|
|
101
|
+
mark.until > answeredAt &&
|
|
102
|
+
mark.until - answeredAt <= MAX_STANDING_MINUTES * MS_PER_MINUTE
|
|
103
|
+
|
|
104
|
+
/** The approval the newest answers about one plan give: all of them approve, and the earliest end is still ahead. */
|
|
105
|
+
const grantOf = (plan: string, answers: readonly MarkedAnswer[], label: string, now: number): StandingGrant[] => {
|
|
106
|
+
const verdict = newestVerdict(answers, now)
|
|
107
|
+
if (verdict.length === 0 || !verdict.every((answer) => approves(answer, label))) return []
|
|
108
|
+
const until = Math.min(...verdict.map(({ mark }) => mark.until))
|
|
109
|
+
return until > now ? [{ plan, until }] : []
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The plans the user approved standing in these dialog answers, and until when; only those still holding at `now`. */
|
|
113
|
+
export const standingGrants = (answers: readonly DialogAnswer[], labels: StandingLabels, now: number): StandingGrant[] => {
|
|
114
|
+
const marked = answers.flatMap((answer): MarkedAnswer[] => {
|
|
115
|
+
const mark = markOf(answer.question, labels.until)
|
|
116
|
+
return mark === undefined ? [] : [{ ...answer, mark }]
|
|
117
|
+
})
|
|
118
|
+
const plans = [...new Set(marked.map(({ mark }) => mark.plan))]
|
|
119
|
+
return plans.flatMap((plan) =>
|
|
120
|
+
grantOf(
|
|
121
|
+
plan,
|
|
122
|
+
marked.filter(({ mark }) => mark.plan === plan),
|
|
123
|
+
labels.approve,
|
|
124
|
+
now,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const exceptedNote = (plan: string, files: readonly string[]): MergeNote => ({
|
|
130
|
+
note: `The standing approval of plan \`${plan}\` does not cover ${files.map((file) => `\`${file}\``).join(', ')}: workflows, migrations, env files and the guards' own files always ask.`,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
/** `true` lifts the question; `false` asks as before; a note asks and says which files the approval does not cover. */
|
|
134
|
+
export const standingVerdict = (query: StandingApprovalQuery, sources: StandingApprovalSources): boolean | MergeNote => {
|
|
135
|
+
if (query.grants.length === 0 || query.baseBranch !== query.settingsBaseBranch) return false
|
|
136
|
+
const plan = sources.planOfBranch(query.branch)
|
|
137
|
+
if (plan === undefined || !query.grants.some((grant) => grant.plan === plan) || !sources.checksPassed()) return false
|
|
138
|
+
const files = sources.files()
|
|
139
|
+
if (files === undefined || files.length === 0 || !files.every(isPlainPath)) return false
|
|
140
|
+
const excepted = alwaysAskingFiles(files, STANDING_ALWAYS_ASK)
|
|
141
|
+
return excepted.length === 0 ? true : exceptedNote(plan, excepted)
|
|
142
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Stop hook of Claude Code, shipped by jackherd: records each `AskUserQuestion` question that ended without an answer
|
|
2
|
+
// (the dialog was rejected or interrupted, or answered with "later") in `.jackherd/questions/` of the main checkout, and
|
|
3
|
+
// closes the open records a later answer to the same question settles. The herd, the heartbeat and `jh waiting` show the
|
|
4
|
+
// open ones as something that waits on the user. The whole transcript is read, because a rejected dialog ends its turn
|
|
5
|
+
// without a Stop hook. It prints nothing and never blocks.
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { mainCheckoutOf } from '../shared/checkout'
|
|
9
|
+
import { runOpenQuestion } from './run'
|
|
10
|
+
import { readQuestionRecords, writeQuestionRecord } from './store'
|
|
11
|
+
|
|
12
|
+
const ID_LENGTH = 12
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
runOpenQuestion(JSON.parse(await Bun.stdin.text()), {
|
|
16
|
+
mainCheckoutOf,
|
|
17
|
+
readTranscript: (path) => readFileSync(path, 'utf8'),
|
|
18
|
+
readRecords: readQuestionRecords,
|
|
19
|
+
writeRecord: writeQuestionRecord,
|
|
20
|
+
idOf: (toolUseId, question) =>
|
|
21
|
+
createHash('sha256').update(`${toolUseId}\n${question}`).digest('hex').slice(0, ID_LENGTH),
|
|
22
|
+
})
|
|
23
|
+
} catch {
|
|
24
|
+
// Unreadable input records nothing.
|
|
25
|
+
}
|
|
26
|
+
process.exit(0)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { ASK_USER_QUESTION_TOOL, contentOf, dialogAnswers, questionsOf } from '../shared/dialog-answers'
|
|
2
|
+
import { isRecord, type JsonObject } from '../shared/json'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A question of a dialog that ended without an answer, as the hook records it: what was asked, when, from where and
|
|
6
|
+
* when it closed (null while it is open). Written by the hook, read by the herd, the watcher and `jh waiting`.
|
|
7
|
+
*/
|
|
8
|
+
export type QuestionRecord = {
|
|
9
|
+
id: string
|
|
10
|
+
question: string
|
|
11
|
+
askedAt: number
|
|
12
|
+
cwd: string
|
|
13
|
+
session: string
|
|
14
|
+
closedAt: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type QuestionContext = {
|
|
18
|
+
cwd: string
|
|
19
|
+
session: string
|
|
20
|
+
/** What the dialogs of the transcript have answered, as `answeredQuestionsOf` finds it. */
|
|
21
|
+
answered: readonly AnsweredQuestion[]
|
|
22
|
+
/** The id of a question; the same call and text give the same id, so a second run finds the record it wrote. */
|
|
23
|
+
idOf: (toolUseId: string, question: string) => string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A question the user answered for real, with the time of the answer. */
|
|
27
|
+
export type AnsweredQuestion = { question: string; answeredAt: number }
|
|
28
|
+
|
|
29
|
+
type Ask = { toolUseId: string; question: string; askedAt: number; position: number; messageId: string | null }
|
|
30
|
+
|
|
31
|
+
const ID = /^[0-9a-f]{12}$/
|
|
32
|
+
const MAX_QUESTION = 200
|
|
33
|
+
const CONTROLS = /[\u0000-\u001f\u007f-\u009f]/g
|
|
34
|
+
/** An answer that puts the question off rather than settling it: later, or not yet. */
|
|
35
|
+
const LATER_WORD = /\b(später|spaeter|later|noch\s+nicht|not\s+yet)\b/i
|
|
36
|
+
|
|
37
|
+
/** The question as one line without control characters, cut to what a row shows; how records and answers are compared. */
|
|
38
|
+
export const questionTextOf = (value: string): string =>
|
|
39
|
+
value.replace(CONTROLS, ' ').replace(/\s+/g, ' ').trim().slice(0, MAX_QUESTION)
|
|
40
|
+
|
|
41
|
+
const messageIdOf = (entry: JsonObject): string | null =>
|
|
42
|
+
isRecord(entry.message) && typeof entry.message.id === 'string' ? entry.message.id : null
|
|
43
|
+
|
|
44
|
+
const mainThread = (entries: readonly unknown[]): JsonObject[] =>
|
|
45
|
+
entries.filter(isRecord).filter((entry) => entry.isSidechain !== true)
|
|
46
|
+
|
|
47
|
+
/** Every question of every `AskUserQuestion` call of the main thread, with where in the transcript it stands. */
|
|
48
|
+
const asksOf = (entries: readonly JsonObject[]): Ask[] =>
|
|
49
|
+
entries.flatMap((entry, position) => {
|
|
50
|
+
const askedAt = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
|
|
51
|
+
if (Number.isNaN(askedAt)) return []
|
|
52
|
+
return contentOf(entry)
|
|
53
|
+
.filter((block) => block.type === 'tool_use' && block.name === ASK_USER_QUESTION_TOOL)
|
|
54
|
+
.flatMap((block) =>
|
|
55
|
+
typeof block.id === 'string'
|
|
56
|
+
? questionsOf(block.input).map(({ question }) => ({
|
|
57
|
+
toolUseId: block.id as string,
|
|
58
|
+
question,
|
|
59
|
+
askedAt,
|
|
60
|
+
position,
|
|
61
|
+
messageId: messageIdOf(entry),
|
|
62
|
+
}))
|
|
63
|
+
: [],
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** The answers of each tool result by tool use id; null for a result without answers (a rejection, an error). */
|
|
68
|
+
const resultsOf = (entries: readonly JsonObject[]): Map<string, JsonObject | null> => {
|
|
69
|
+
const results = new Map<string, JsonObject | null>()
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const answers = isRecord(entry.toolUseResult) && isRecord(entry.toolUseResult.answers) ? entry.toolUseResult.answers : null
|
|
72
|
+
for (const block of contentOf(entry))
|
|
73
|
+
if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') results.set(block.tool_use_id, answers)
|
|
74
|
+
}
|
|
75
|
+
return results
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether the transcript went on after the call, other than with the rest of the message that made it. */
|
|
79
|
+
const wentOnAfter = (ask: Ask, entries: readonly JsonObject[]): boolean =>
|
|
80
|
+
entries
|
|
81
|
+
.slice(ask.position + 1)
|
|
82
|
+
.some((entry) => (entry.type === 'user' || entry.type === 'assistant') && messageIdOf(entry) !== ask.messageId)
|
|
83
|
+
|
|
84
|
+
/** No answer came back, the answer is not a text for this question, or it only puts the question off. */
|
|
85
|
+
const endedUnanswered = (ask: Ask, results: ReadonlyMap<string, JsonObject | null>, entries: readonly JsonObject[]) => {
|
|
86
|
+
if (!results.has(ask.toolUseId)) return wentOnAfter(ask, entries)
|
|
87
|
+
const answer = results.get(ask.toolUseId)?.[ask.question]
|
|
88
|
+
return typeof answer !== 'string' || LATER_WORD.test(answer)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The questions the user answered in a dialog without putting them off, in transcript order. */
|
|
92
|
+
export const answeredQuestionsOf = (entries: readonly unknown[]): AnsweredQuestion[] =>
|
|
93
|
+
dialogAnswers(entries)
|
|
94
|
+
.filter(({ answer }) => !LATER_WORD.test(answer))
|
|
95
|
+
.map(({ question, answeredAt }) => ({ question: questionTextOf(question), answeredAt }))
|
|
96
|
+
|
|
97
|
+
/** The answer to `question` given after `at`, if a dialog gave one. */
|
|
98
|
+
export const answeredSince = (
|
|
99
|
+
answered: readonly AnsweredQuestion[],
|
|
100
|
+
question: string,
|
|
101
|
+
at: number,
|
|
102
|
+
): AnsweredQuestion | undefined => answered.find((done) => done.question === question && done.answeredAt > at)
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The questions of the main thread that stand open: their dialog was rejected or interrupted, or answered with "later",
|
|
106
|
+
* and no dialog answered the same text since. A call that carries its own answers asked nobody; a sidechain is no dialog
|
|
107
|
+
* of the user's.
|
|
108
|
+
*/
|
|
109
|
+
export const openQuestionsOf = (entries: readonly unknown[], context: QuestionContext): QuestionRecord[] => {
|
|
110
|
+
const thread = mainThread(entries)
|
|
111
|
+
const results = resultsOf(thread)
|
|
112
|
+
return asksOf(thread)
|
|
113
|
+
.filter((ask) => endedUnanswered(ask, results, thread))
|
|
114
|
+
.map((ask) => ({ ask, question: questionTextOf(ask.question) }))
|
|
115
|
+
.filter(({ ask, question }) => question !== '' && !answeredSince(context.answered, question, ask.askedAt))
|
|
116
|
+
.map(({ ask, question }) => ({
|
|
117
|
+
id: context.idOf(ask.toolUseId, question),
|
|
118
|
+
question,
|
|
119
|
+
askedAt: ask.askedAt,
|
|
120
|
+
cwd: context.cwd,
|
|
121
|
+
session: context.session,
|
|
122
|
+
closedAt: null,
|
|
123
|
+
}))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A record of the shape the hook writes; the herd reads questions only through this. */
|
|
127
|
+
export const isQuestionRecord = (value: unknown): value is QuestionRecord =>
|
|
128
|
+
isRecord(value) &&
|
|
129
|
+
typeof value.id === 'string' &&
|
|
130
|
+
ID.test(value.id) &&
|
|
131
|
+
typeof value.question === 'string' &&
|
|
132
|
+
typeof value.askedAt === 'number' &&
|
|
133
|
+
typeof value.cwd === 'string' &&
|
|
134
|
+
typeof value.session === 'string' &&
|
|
135
|
+
(value.closedAt === null || typeof value.closedAt === 'number')
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ASK_USER_QUESTION_TOOL, parseTranscript } from '../shared/dialog-answers'
|
|
2
|
+
import { isRecord } from '../shared/json'
|
|
3
|
+
import { answeredQuestionsOf, answeredSince, openQuestionsOf, type QuestionRecord } from './record'
|
|
4
|
+
|
|
5
|
+
/** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
|
|
6
|
+
export type OpenQuestionDependencies = {
|
|
7
|
+
mainCheckoutOf: (cwd: string) => string
|
|
8
|
+
readTranscript: (path: string) => string
|
|
9
|
+
readRecords: (main: string) => QuestionRecord[]
|
|
10
|
+
writeRecord: (main: string, record: QuestionRecord) => void
|
|
11
|
+
idOf: (toolUseId: string, question: string) => string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MARKERS = [ASK_USER_QUESTION_TOOL, '"answers"', '"is_error":true']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The lines of a transcript that can say anything about a dialog, and its last line: a transcript runs to many
|
|
18
|
+
* megabytes, and the last line is how a call without a result shows that the session went on after it.
|
|
19
|
+
*/
|
|
20
|
+
export const relevantLines = (text: string): string => {
|
|
21
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '')
|
|
22
|
+
return lines
|
|
23
|
+
.filter((line, index) => index === lines.length - 1 || MARKERS.some((marker) => line.includes(marker)))
|
|
24
|
+
.join('\n')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Records the questions of the session's dialogs that ended without an answer, and closes the open records of every
|
|
29
|
+
* session that a dialog in this transcript has answered since. The whole transcript is read, not the last turn: a
|
|
30
|
+
* rejected dialog ends its turn without a Stop hook, so it is found at the end of the next turn. Claude Code's hook
|
|
31
|
+
* input: `cwd`, `session_id`, `transcript_path`. Writes nothing on any failure.
|
|
32
|
+
*/
|
|
33
|
+
export const runOpenQuestion = (input: unknown, dependencies: OpenQuestionDependencies): void => {
|
|
34
|
+
try {
|
|
35
|
+
if (!isRecord(input)) return
|
|
36
|
+
const { cwd, session_id: session, transcript_path: transcriptPath } = input
|
|
37
|
+
if (typeof cwd !== 'string' || typeof session !== 'string' || typeof transcriptPath !== 'string') return
|
|
38
|
+
const entries = parseTranscript(relevantLines(dependencies.readTranscript(transcriptPath)))
|
|
39
|
+
const main = dependencies.mainCheckoutOf(cwd)
|
|
40
|
+
const stored = dependencies.readRecords(main)
|
|
41
|
+
const answered = answeredQuestionsOf(entries)
|
|
42
|
+
for (const record of stored.filter(({ closedAt }) => closedAt === null)) {
|
|
43
|
+
const answer = answeredSince(answered, record.question, record.askedAt)
|
|
44
|
+
if (answer) dependencies.writeRecord(main, { ...record, closedAt: answer.answeredAt })
|
|
45
|
+
}
|
|
46
|
+
const known = new Set(stored.map(({ id }) => id))
|
|
47
|
+
for (const record of openQuestionsOf(entries, { cwd, session, answered, idOf: dependencies.idOf }))
|
|
48
|
+
if (!known.has(record.id)) dependencies.writeRecord(main, record)
|
|
49
|
+
} catch {
|
|
50
|
+
// A failed record is no record; the herd shows no question.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { writePrivateRecord } from '../shared/private-directory'
|
|
4
|
+
import { isQuestionRecord, type QuestionRecord } from './record'
|
|
5
|
+
|
|
6
|
+
const RECORD_FILE = /^[0-9a-f]{12}\.json$/
|
|
7
|
+
const MAX_RECORD_BYTES = 16 * 1024
|
|
8
|
+
|
|
9
|
+
/** Where the records lie: in the main checkout beside `.jackherd/sent`, which the herd reads. Ignored by git. */
|
|
10
|
+
export const questionDirectory = (main: string): string => join(main, '.jackherd', 'questions')
|
|
11
|
+
|
|
12
|
+
const recordAt = (path: string): QuestionRecord[] => {
|
|
13
|
+
try {
|
|
14
|
+
const entry = lstatSync(path)
|
|
15
|
+
if (!entry.isFile() || entry.size > MAX_RECORD_BYTES) return []
|
|
16
|
+
const record: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
17
|
+
return isQuestionRecord(record) ? [record] : []
|
|
18
|
+
} catch {
|
|
19
|
+
return []
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every well-formed `<id>.json` record of the directory, like the herd reads them: a link and a file over 16 KiB are
|
|
25
|
+
* skipped. Nothing when the directory is missing or unreadable.
|
|
26
|
+
*/
|
|
27
|
+
export const readQuestionRecords = (main: string): QuestionRecord[] => {
|
|
28
|
+
const directory = questionDirectory(main)
|
|
29
|
+
try {
|
|
30
|
+
return readdirSync(directory)
|
|
31
|
+
.filter((name) => RECORD_FILE.test(name))
|
|
32
|
+
.flatMap((name) => recordAt(join(directory, name)))
|
|
33
|
+
} catch {
|
|
34
|
+
return []
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const writeQuestionRecord = (main: string, record: QuestionRecord): void =>
|
|
39
|
+
writePrivateRecord(questionDirectory(main), record)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// PostToolUse hook of Claude Code for `SendUserFile` and `Artifact`, shipped by jackherd: records each file a session
|
|
2
|
+
// sends to the user and each artifact it publishes for them, with time and caption, in `.jackherd/sent/` of the main
|
|
3
|
+
// checkout. The herd shows the files as badges on the lane they belong to and as something that waits on the user's
|
|
4
|
+
// answer, until the coordinator closes the record. It prints nothing and never blocks.
|
|
5
|
+
import { mainCheckoutOf } from '../shared/checkout'
|
|
6
|
+
import { runSentFile } from './run'
|
|
7
|
+
import { writeSentRecord } from './store'
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
runSentFile(JSON.parse(await Bun.stdin.text()), {
|
|
11
|
+
mainCheckoutOf,
|
|
12
|
+
writeRecord: writeSentRecord,
|
|
13
|
+
now: () => Date.now(),
|
|
14
|
+
randomBytes: () => crypto.getRandomValues(new Uint8Array(6)),
|
|
15
|
+
})
|
|
16
|
+
} catch {
|
|
17
|
+
// Unreadable input records nothing.
|
|
18
|
+
}
|
|
19
|
+
process.exit(0)
|