jackherd 1.16.0 → 1.18.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 +28 -0
- package/INSTALL.md +2 -1
- package/README.md +29 -33
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +16 -22
- package/kit/rules/rules/kit/code-style.md +26 -54
- package/kit/rules/rules/kit/pull-requests.md +17 -40
- package/kit/rules/rules/kit/testing.md +6 -12
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +2 -3
- package/kit/workflow/skills/implement/SKILL.md +28 -43
- package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +29 -48
- package/package.json +2 -1
- package/recipes/README.md +1 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +8 -2
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +4 -2
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +90 -32
- package/src/check/skill-budgets.ts +96 -18
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +82 -33
- package/src/plans/live.ts +8 -12
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +66 -11
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +14 -3
- package/src/watch/waiting-line.ts +4 -3
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The adapter that asks herdr what runs in a pane; `pane-input.ts` reads its answer. */
|
|
2
|
+
import { paneAgentOf } from '../shared/pane-agent'
|
|
3
|
+
import { foregroundOf } from './pane-input'
|
|
4
|
+
|
|
5
|
+
const PROCESS_INFO_TIMEOUT_MS = 2000
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The directory the agent `target` runs in, which herdr reads from the process itself (`shared/own-pane.ts`); undefined
|
|
9
|
+
* when herdr does not answer in time, knows no such agent or answers otherwise.
|
|
10
|
+
*/
|
|
11
|
+
export const herdrAgentDirectory = (target: string, directory: string): string | undefined => {
|
|
12
|
+
try {
|
|
13
|
+
const result = Bun.spawnSync(['herdr', 'agent', 'get', target], {
|
|
14
|
+
cwd: directory,
|
|
15
|
+
timeout: PROCESS_INFO_TIMEOUT_MS,
|
|
16
|
+
stdout: 'pipe',
|
|
17
|
+
stderr: 'ignore',
|
|
18
|
+
})
|
|
19
|
+
return result.exitCode === 0 ? (paneAgentOf(JSON.parse(result.stdout.toString()))?.cwd ?? undefined) : undefined
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The foreground of `pane` as herdr names it now; undefined when herdr does not answer in time or answers otherwise. */
|
|
26
|
+
export const herdrForeground = (pane: string, directory: string): string[][] | undefined => {
|
|
27
|
+
try {
|
|
28
|
+
const result = Bun.spawnSync(['herdr', 'pane', 'process-info', '--pane', pane], {
|
|
29
|
+
cwd: directory,
|
|
30
|
+
timeout: PROCESS_INFO_TIMEOUT_MS,
|
|
31
|
+
stdout: 'pipe',
|
|
32
|
+
stderr: 'ignore',
|
|
33
|
+
})
|
|
34
|
+
return result.exitCode === 0 ? foregroundOf(result.stdout.toString()) : undefined
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute } from 'node:path'
|
|
2
3
|
import { mainCheckoutOf } from '../shared/checkout'
|
|
4
|
+
import { commandOutput } from '../shared/command'
|
|
3
5
|
import { dialogAnswers, parseTranscript } from '../shared/dialog-answers'
|
|
4
6
|
import { isWithinDirectory } from '../shared/files'
|
|
5
7
|
import { once } from '../shared/once'
|
|
6
8
|
import { readShellDefinitions } from '../shared/shell-snapshots'
|
|
7
|
-
import { ALWAYS_ASK_PATHS } from './always-ask'
|
|
8
|
-
import {
|
|
9
|
+
import { ALWAYS_ASK_PATHS, alwaysAskingFiles, STANDING_ALWAYS_ASK } from './always-ask'
|
|
10
|
+
import { type ApprovedChange, approvedChanges } from './approval'
|
|
9
11
|
import { boardSettingsOf } from './board-settings'
|
|
12
|
+
import { changeIdOf, isSameChange } from './change-id'
|
|
10
13
|
import { changedFilesOf, isCheckFree } from './check-free'
|
|
11
14
|
import { decideBashCommand, type GuardPolicy, type MergeGuardDecision, type MergeNote, type PlainMerge } from './decision'
|
|
12
15
|
import { planOfBranch } from './lane-plan'
|
|
16
|
+
import { cannotBeHerd } from './pane-input'
|
|
17
|
+
import { herdrAgentDirectory, herdrForeground } from './pane-process-info'
|
|
13
18
|
import {
|
|
14
19
|
checksPassed,
|
|
15
20
|
parseBaseBranch,
|
|
@@ -26,7 +31,7 @@ type HookInput = { tool_input?: { command?: unknown }; transcript_path?: unknown
|
|
|
26
31
|
/** The exact option labels that approve a merge: the one of the pull request question and those of the standing approval. */
|
|
27
32
|
export type ApprovalLabels = { selfMerge: string; standing: StandingLabels }
|
|
28
33
|
|
|
29
|
-
/** What GitHub says about the pull request a merge names; `files`
|
|
34
|
+
/** What GitHub says about the pull request a merge names; `files` and `change` are asked only when a rule needs them. */
|
|
30
35
|
type MergeFacts = {
|
|
31
36
|
branch: string
|
|
32
37
|
baseBranch: string | undefined
|
|
@@ -34,13 +39,15 @@ type MergeFacts = {
|
|
|
34
39
|
/** The `gh pr view` answer, which carries the checks and whether GitHub can merge. */
|
|
35
40
|
view: string
|
|
36
41
|
files: () => readonly string[] | undefined
|
|
42
|
+
/** The patch id of the pull request's diff at the head commit. */
|
|
43
|
+
change: () => string | undefined
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
type StandingApproval = (facts: MergeFacts) => boolean | MergeNote
|
|
40
47
|
|
|
41
48
|
/** The ways a merge may pass without a question. */
|
|
42
49
|
type MergeApprovals = {
|
|
43
|
-
|
|
50
|
+
changes: readonly ApprovedChange[]
|
|
44
51
|
/** The checks a self-merge of an approved branch waits for a successful run of. */
|
|
45
52
|
requiredChecks: readonly string[] | undefined
|
|
46
53
|
withoutChecks: readonly string[]
|
|
@@ -51,47 +58,101 @@ type MergeApprovals = {
|
|
|
51
58
|
|
|
52
59
|
const PASS: MergeGuardDecision = { kind: 'pass' }
|
|
53
60
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
const approvedChangesFrom = (entries: unknown[], settings: GuardSettings, label: string): ApprovedChange[] => {
|
|
62
|
+
if (!settings.selfMerge.offer) return []
|
|
63
|
+
return approvedChanges({ entries, label, now: Date.now(), windowMinutes: settings.selfMerge.approvalMinutes })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const changedSinceApproval = (branch: string, change: string): MergeNote => ({
|
|
67
|
+
note: `The user approved branch \`${branch}\` with change @${change}; the change id of the pull request differs (a push after the approval, or GitHub's diff reads otherwise), so the user confirms the merge.`,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const unboundMerge: MergeNote = {
|
|
71
|
+
note: 'The user approved this branch, but the merge names no head commit: bind the merge with `--match-head-commit <headRefOid>` (from `gh pr view`), so a push after the check is not merged unchecked.',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The note for a merge of an approved branch that names no head commit; false for any other head that differs. */
|
|
75
|
+
const unboundMergeOf = (
|
|
76
|
+
merge: PlainMerge,
|
|
77
|
+
changes: readonly ApprovedChange[],
|
|
78
|
+
branch: string,
|
|
79
|
+
): MergeNote | false =>
|
|
80
|
+
merge.headCommit === undefined && changes.some((change) => change.branch === branch) ? unboundMerge : false
|
|
81
|
+
|
|
82
|
+
const carriesGuardFiles = (files: readonly string[] | undefined): MergeNote => ({
|
|
83
|
+
note:
|
|
84
|
+
files === undefined
|
|
85
|
+
? 'GitHub did not list the files of the pull request, so a guard file may be among them and the user confirms the merge.'
|
|
86
|
+
: `The pull request carries a guard file (${files.map((file) => `\`${file}\``).join(', ')}), which can loosen the guard that merges it, so the user confirms the merge.`,
|
|
87
|
+
})
|
|
57
88
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The guard files a self-merge never covers (`STANDING_ALWAYS_ASK`): a pull request could loosen the guard that merges
|
|
91
|
+
* it. The project's own `guard.alwaysAsk` stays covered, since the user's pick is the approval for that pull request.
|
|
92
|
+
*/
|
|
93
|
+
const guardFilesOf = (files: readonly string[] | undefined): readonly string[] | undefined =>
|
|
94
|
+
files === undefined ? undefined : alwaysAskingFiles(files, STANDING_ALWAYS_ASK)
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The user approved the branch with the change the pull request carries, its checks passed, and it carries no guard
|
|
98
|
+
* file; a change that differs, or a guard file, answers with a note for the question.
|
|
99
|
+
*/
|
|
100
|
+
const approvesChange = (
|
|
101
|
+
changes: readonly ApprovedChange[],
|
|
102
|
+
requiredChecks: readonly string[] | undefined,
|
|
103
|
+
facts: MergeFacts,
|
|
104
|
+
): boolean | MergeNote => {
|
|
105
|
+
const named = changes.filter(({ branch }) => branch === facts.branch)
|
|
106
|
+
if (named.length === 0 || !checksPassed(facts.view, requiredChecks)) return false
|
|
107
|
+
const id = facts.change()
|
|
108
|
+
if (!named.some(({ change }) => isSameChange(change, id))) return changedSinceApproval(facts.branch, named[0].change)
|
|
109
|
+
const guarded = guardFilesOf(facts.files())
|
|
110
|
+
return guarded?.length === 0 || carriesGuardFiles(guarded)
|
|
61
111
|
}
|
|
62
112
|
|
|
63
113
|
/**
|
|
64
|
-
* The user approved the branch and its checks passed, every file needs no checks, or a standing
|
|
65
|
-
* plan of the branch's lane; each only for a merge bound to the head commit GitHub reports, so a
|
|
66
|
-
* is not merged unchecked.
|
|
114
|
+
* The user approved the branch with this change and its checks passed, every file needs no checks, or a standing
|
|
115
|
+
* approval covers the plan of the branch's lane; each only for a merge bound to the head commit GitHub reports, so a
|
|
116
|
+
* push after the check is not merged unchecked. An approval that does not cover the merge answers with a note.
|
|
67
117
|
*/
|
|
68
118
|
const approvesMerge = (
|
|
69
|
-
{
|
|
119
|
+
{ changes, requiredChecks, withoutChecks, alwaysAsk, standing }: MergeApprovals,
|
|
70
120
|
merge: PlainMerge,
|
|
71
121
|
facts: MergeFacts,
|
|
72
122
|
): boolean | MergeNote => {
|
|
73
123
|
const { headCommit } = facts
|
|
74
|
-
if (headCommit === undefined
|
|
75
|
-
if (
|
|
124
|
+
if (headCommit === undefined) return false
|
|
125
|
+
if (merge.headCommit !== headCommit) return unboundMergeOf(merge, changes, facts.branch)
|
|
126
|
+
const selfMerge = approvesChange(changes, requiredChecks, facts)
|
|
127
|
+
if (selfMerge === true) return true
|
|
76
128
|
if (withoutChecks.length > 0 && isCheckFree(facts.files() ?? [], withoutChecks, alwaysAsk)) return true
|
|
77
|
-
|
|
129
|
+
const verdict = standing === undefined ? false : standing(facts)
|
|
130
|
+
return verdict === false ? selfMerge : verdict
|
|
78
131
|
}
|
|
79
132
|
|
|
133
|
+
/** The transcript of the session that merges, one parsed entry per line; none without one. */
|
|
134
|
+
const transcriptEntries = (transcriptPath: unknown): unknown[] =>
|
|
135
|
+
typeof transcriptPath === 'string' ? parseTranscript(readFileSync(transcriptPath, 'utf8')) : []
|
|
136
|
+
|
|
80
137
|
/**
|
|
81
|
-
*
|
|
82
|
-
* calls would
|
|
138
|
+
* A lookup about the pull request, kept only while its head is still the commit `gh pr view` named: a push between
|
|
139
|
+
* the calls would answer for a commit the merge does not name.
|
|
83
140
|
*/
|
|
84
|
-
const
|
|
85
|
-
const facts = parseRepositoryFacts(view)
|
|
141
|
+
const atHead = <T>(pullRequest: number, view: string, directory: string, lookup: () => T | undefined): (() => T | undefined) => {
|
|
86
142
|
const headCommit = parseHeadCommit(view)
|
|
87
143
|
return once(() => {
|
|
88
|
-
if (
|
|
89
|
-
const
|
|
90
|
-
const again =
|
|
91
|
-
return again === headCommit ?
|
|
144
|
+
if (headCommit === undefined) return undefined
|
|
145
|
+
const value = lookup()
|
|
146
|
+
const again = value === undefined ? undefined : parseHeadCommit(viewPullRequest(pullRequest, directory) ?? '')
|
|
147
|
+
return again === headCommit ? value : undefined
|
|
92
148
|
})
|
|
93
149
|
}
|
|
94
150
|
|
|
151
|
+
const filesAtHead = (pullRequest: number, view: string, directory: string): (() => readonly string[] | undefined) => {
|
|
152
|
+
const facts = parseRepositoryFacts(view)
|
|
153
|
+
return atHead(pullRequest, view, directory, () => (facts === undefined ? undefined : changedFilesOf(pullRequest, directory, facts)))
|
|
154
|
+
}
|
|
155
|
+
|
|
95
156
|
/** The facts of the pull request from its `gh pr view`; undefined unless it is open and from this repository. */
|
|
96
157
|
const mergeFactsOf = (pullRequest: number, view: string, directory: string): MergeFacts | undefined => {
|
|
97
158
|
const branch = parseHeadBranch(view)
|
|
@@ -102,6 +163,7 @@ const mergeFactsOf = (pullRequest: number, view: string, directory: string): Mer
|
|
|
102
163
|
headCommit: parseHeadCommit(view),
|
|
103
164
|
view,
|
|
104
165
|
files: filesAtHead(pullRequest, view, directory),
|
|
166
|
+
change: atHead(pullRequest, view, directory, () => changeIdOf(pullRequest, directory)),
|
|
105
167
|
}
|
|
106
168
|
}
|
|
107
169
|
|
|
@@ -131,26 +193,46 @@ const standingApprovalFor = (entries: unknown[], labels: StandingLabels, project
|
|
|
131
193
|
}
|
|
132
194
|
}
|
|
133
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Whether an agent in this directory runs in a linked worktree, a lane's place: its own git directory is not the
|
|
198
|
+
* repository's common one. A main checkout, where the coordinator runs, a directory outside a repository and any
|
|
199
|
+
* doubt answer no, a git that answers no single absolute path too (before 2.31 it echoes `--path-format`).
|
|
200
|
+
*/
|
|
201
|
+
const runsInWorktree = (agentDirectory: string | undefined): boolean => {
|
|
202
|
+
if (agentDirectory === undefined) return false
|
|
203
|
+
try {
|
|
204
|
+
const gitDirectory = (option: string): string =>
|
|
205
|
+
commandOutput(['git', 'rev-parse', '--path-format=absolute', option], agentDirectory)
|
|
206
|
+
const own = gitDirectory('--absolute-git-dir')
|
|
207
|
+
const common = gitDirectory('--git-common-dir')
|
|
208
|
+
return [own, common].every((path) => isAbsolute(path) && !path.includes('\n')) && own !== common
|
|
209
|
+
} catch {
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
134
214
|
const policyFor = (input: HookInput, labels: ApprovalLabels, projectDirectory: string): GuardPolicy => {
|
|
135
215
|
const settings = readGuardSettings(projectDirectory)
|
|
136
216
|
const directory = typeof input.cwd === 'string' && input.cwd !== '' ? input.cwd : projectDirectory
|
|
137
217
|
const entries = once(() => transcriptEntries(input.transcript_path))
|
|
138
|
-
const
|
|
218
|
+
const changes = once(() => approvedChangesFrom(entries(), settings, labels.selfMerge))
|
|
139
219
|
const standing = once(() => standingApprovalFor(entries(), labels.standing, projectDirectory))
|
|
140
220
|
return {
|
|
141
221
|
commands: settings.commands,
|
|
142
222
|
shellDefinitions: once(() => readShellDefinitions()),
|
|
223
|
+
isPaneSafe: (pane) => cannotBeHerd(herdrForeground(pane, directory)),
|
|
224
|
+
isAgentSafe: (target) => runsInWorktree(herdrAgentDirectory(target, directory)),
|
|
143
225
|
isMergeApproved: (merge) => {
|
|
144
226
|
// the same branch name in another checkout is another pull request
|
|
145
227
|
if (!isWithinDirectory(directory, projectDirectory)) return false
|
|
146
228
|
const approvals = {
|
|
147
|
-
|
|
229
|
+
changes: changes(),
|
|
148
230
|
requiredChecks: settings.requiredChecks,
|
|
149
231
|
withoutChecks: settings.withoutChecks,
|
|
150
232
|
alwaysAsk: [...ALWAYS_ASK_PATHS, ...settings.alwaysAsk],
|
|
151
233
|
standing: standing(),
|
|
152
234
|
}
|
|
153
|
-
if (approvals.
|
|
235
|
+
if (approvals.changes.length === 0 && approvals.withoutChecks.length === 0 && approvals.standing === undefined) return false
|
|
154
236
|
// The branch lookup also proves the pull request is open, no draft and from this repository; every path needs that.
|
|
155
237
|
const facts = mergeFactsOf(merge.pullRequest, viewPullRequest(merge.pullRequest, directory) ?? '', directory)
|
|
156
238
|
return facts !== undefined && approvesMerge(approvals, merge, facts)
|
|
@@ -8,14 +8,18 @@
|
|
|
8
8
|
* that plan, and never with a file of `STANDING_ALWAYS_ASK`. Every doubt answers "no", so the guard asks as before.
|
|
9
9
|
*/
|
|
10
10
|
import type { DialogAnswer } from '../shared/dialog-answer'
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
type StandingKind,
|
|
13
|
+
type StandingLabels,
|
|
14
|
+
type StandingMark,
|
|
15
|
+
standingGrants as grantsOfKind,
|
|
16
|
+
standingMarkOf as markOfKind,
|
|
17
|
+
} from '../shared/standing-mark'
|
|
13
18
|
import { alwaysAskingFiles, STANDING_ALWAYS_ASK } from './always-ask'
|
|
14
19
|
import type { MergeNote } from './decision'
|
|
15
20
|
import { isPlainPath } from './plain-path'
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
export type StandingLabels = { approve: string; until: string }
|
|
22
|
+
export type { StandingLabels } from '../shared/standing-mark'
|
|
19
23
|
|
|
20
24
|
/** A plan whose lanes merge without a question until `until`, in milliseconds. */
|
|
21
25
|
export type StandingGrant = { plan: string; until: number }
|
|
@@ -39,92 +43,25 @@ export type StandingApprovalQuery = {
|
|
|
39
43
|
settingsBaseBranch: string
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
export const MAX_STANDING_MINUTES = 24 * 60
|
|
43
|
-
|
|
44
46
|
const PLAN_PATH = /^[A-Za-z0-9_./-]+\.md$/
|
|
45
47
|
|
|
46
|
-
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
47
|
-
|
|
48
48
|
/** A plan path as a board links it: a relative markdown file, no empty, `.` or `..` segment. */
|
|
49
49
|
const isPlanPath = (path: string): boolean =>
|
|
50
50
|
PLAN_PATH.test(path) && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
}
|
|
52
|
+
const PLAN_KIND: StandingKind = { key: 'Plan', isSubject: isPlanPath }
|
|
53
|
+
|
|
54
|
+
const grantOfMark = ({ subject, until }: StandingMark): StandingGrant => ({ plan: subject, until })
|
|
63
55
|
|
|
64
56
|
/** The plan and the end a question names as ``Plan `<path>` <until> YYYY-MM-DD HH:MM``; none for none or several marks. */
|
|
65
57
|
export const standingMarkOf = (question: string, until: string): StandingGrant | undefined => {
|
|
66
|
-
|
|
67
|
-
|
|
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 }] : []
|
|
58
|
+
const mark = markOfKind(question, PLAN_KIND, until)
|
|
59
|
+
return mark === undefined ? undefined : grantOfMark(mark)
|
|
110
60
|
}
|
|
111
61
|
|
|
112
62
|
/** 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
|
-
|
|
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
|
-
}
|
|
63
|
+
export const standingGrants = (answers: readonly DialogAnswer[], labels: StandingLabels, now: number): StandingGrant[] =>
|
|
64
|
+
grantsOfKind(answers, PLAN_KIND, labels, now).map(grantOfMark)
|
|
128
65
|
|
|
129
66
|
const exceptedNote = (plan: string, files: readonly string[]): MergeNote => ({
|
|
130
67
|
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.`,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { questionsOf, recordsOf } from './run'
|
|
2
|
+
|
|
3
|
+
/** The coordinator's mark on the header chip of every question it asks the user. */
|
|
4
|
+
const PAW = '🐾'
|
|
5
|
+
const EXAMPLE_HEADER = 'Merge'
|
|
6
|
+
const QUESTION_LENGTH = 80
|
|
7
|
+
|
|
8
|
+
export const MAX_QUESTION_WORDS = 15
|
|
9
|
+
export const MAX_LABEL_WORDS = 5
|
|
10
|
+
/** The words of an option's description: one sentence of about 12, with room for a clause. */
|
|
11
|
+
export const MAX_DESCRIPTION_WORDS = 15
|
|
12
|
+
const DESCRIPTION_SENTENCE_WORDS = 12
|
|
13
|
+
/** The most the dialog tool takes in a header chip. */
|
|
14
|
+
const MAX_HEADER_LENGTH = 12
|
|
15
|
+
|
|
16
|
+
/** Code in backticks, a flag, a path with an extension or with two slashes; a slash between two words is none. */
|
|
17
|
+
const PATH_OR_FLAG = /`|(?:^|\s)--[a-z]|\S+\/\S+\.\w+|\S+\/\S+\/\S+/
|
|
18
|
+
/** A link stays in the question: the user decides in the dialog, the link of a draft under review included. */
|
|
19
|
+
const LINK = /https?:\/\/\S+/g
|
|
20
|
+
|
|
21
|
+
const wordsIn = (text: string): number => text.split(/\s+/).filter(Boolean).length
|
|
22
|
+
|
|
23
|
+
const shorten = (text: string): string =>
|
|
24
|
+
text.length > QUESTION_LENGTH ? `${text.slice(0, QUESTION_LENGTH - 1)}…` : text
|
|
25
|
+
|
|
26
|
+
const labelsOf = (options: unknown): string[] =>
|
|
27
|
+
recordsOf(options).flatMap((option) => (typeof option.label === 'string' ? [option.label] : []))
|
|
28
|
+
|
|
29
|
+
const textOf = (value: unknown): string => (typeof value === 'string' ? value : '')
|
|
30
|
+
|
|
31
|
+
const headerReason = (header: string, name: string): string | null =>
|
|
32
|
+
header.trimStart().startsWith(PAW)
|
|
33
|
+
? null
|
|
34
|
+
: `${name}: start the header chip with ${PAW}, as in "${PAW} ${header.trim() || EXAMPLE_HEADER}" (${MAX_HEADER_LENGTH} characters at most).`
|
|
35
|
+
|
|
36
|
+
const questionLengthReason = (text: string, name: string): string | null =>
|
|
37
|
+
wordsIn(text) <= MAX_QUESTION_WORDS
|
|
38
|
+
? null
|
|
39
|
+
: `${name}: ask it in ${MAX_QUESTION_WORDS} words at most; the context goes in the chat before the dialog (§ 4, Short questions).`
|
|
40
|
+
|
|
41
|
+
const pathReason = (text: string, name: string): string | null =>
|
|
42
|
+
PATH_OR_FLAG.test(text.replace(LINK, ''))
|
|
43
|
+
? `${name}: no path, flag or code in the question, say it in everyday words and put the detail in the chat before the dialog.`
|
|
44
|
+
: null
|
|
45
|
+
|
|
46
|
+
const descriptionReason = (options: unknown, name: string): string | null => {
|
|
47
|
+
const long = recordsOf(options).find((option) => wordsIn(textOf(option.description)) > MAX_DESCRIPTION_WORDS)
|
|
48
|
+
return long === undefined
|
|
49
|
+
? null
|
|
50
|
+
: `${name}: option "${textOf(long.label)}": describe it in one sentence of about ${DESCRIPTION_SENTENCE_WORDS} words.`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const labelLengthReason = (labels: readonly string[], name: string): string | null => {
|
|
54
|
+
const long = labels.find((label) => wordsIn(label) > MAX_LABEL_WORDS)
|
|
55
|
+
return long === undefined
|
|
56
|
+
? null
|
|
57
|
+
: `${name}: option "${long}": ${MAX_LABEL_WORDS} words at most; the rest goes in its description.`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A question that offers a label a guard reads word for word keeps the branch and plan marks and the self-merge label
|
|
62
|
+
* as written, so only the paw is required of it.
|
|
63
|
+
*/
|
|
64
|
+
const questionReasons = (question: Record<string, unknown>, guardedLabels: readonly string[]): string[] => {
|
|
65
|
+
const text = textOf(question.question)
|
|
66
|
+
const name = `Question "${shorten(text)}"`
|
|
67
|
+
const labels = labelsOf(question.options)
|
|
68
|
+
const header = headerReason(textOf(question.header), name)
|
|
69
|
+
const lengths = labels.some((label) => guardedLabels.includes(label))
|
|
70
|
+
? []
|
|
71
|
+
: [
|
|
72
|
+
questionLengthReason(text, name),
|
|
73
|
+
pathReason(text, name),
|
|
74
|
+
labelLengthReason(labels, name),
|
|
75
|
+
descriptionReason(question.options, name),
|
|
76
|
+
]
|
|
77
|
+
return [header, ...lengths].filter((reason): reason is string => reason !== null)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The reason to send a coordinator's dialog back because a question does not keep the form: the paw at the start of
|
|
82
|
+
* the header chip, at most `MAX_QUESTION_WORDS` words in the question and no path, flag or code in it, at most
|
|
83
|
+
* `MAX_LABEL_WORDS` in a label and `MAX_DESCRIPTION_WORDS` in a description. Every miss of every question is named, so
|
|
84
|
+
* one send-back is enough. Null when every question keeps the form.
|
|
85
|
+
*/
|
|
86
|
+
export const formReasonOf = (toolInput: unknown, guardedLabels: readonly string[]): string | null => {
|
|
87
|
+
const reasons = questionsOf(toolInput).flatMap((question) => questionReasons(question, guardedLabels))
|
|
88
|
+
return reasons.length === 0 ? null : reasons.join(' ')
|
|
89
|
+
}
|
|
@@ -2,18 +2,24 @@
|
|
|
2
2
|
// guard. A dialog that shows a plan code bare (`F9` instead of "the release check (F9)") is denied with one line of
|
|
3
3
|
// reason, so the question is asked again in the user's words. The codes are the ones the plans linked from the boards
|
|
4
4
|
// of the main checkout define, in the main checkout and in the worktrees of the lanes the boards name, else a fallback
|
|
5
|
-
// pattern; a cache keyed by each file's time and size spares reading the unchanged ones.
|
|
6
|
-
//
|
|
7
|
-
|
|
5
|
+
// pattern; a cache keyed by each file's time and size spares reading the unchanged ones. A dialog whose question
|
|
6
|
+
// lacks its recommendation as the first option, marked with argv[2], is denied as well, and so is a coordinator's
|
|
7
|
+
// question that breaks the form of `form.ts` (🐾 header, short question and labels); argv[3…] are the labels a
|
|
8
|
+
// guard reads word for word, which carry no mark. Anything else, and any failure, answers nothing, so the dialog opens.
|
|
9
|
+
import { existsSync, statSync } from 'node:fs'
|
|
8
10
|
import { join } from 'node:path'
|
|
9
11
|
import { boardLocationOf, boardsIn } from '../shared/boards'
|
|
10
12
|
import { mainCheckoutOf } from '../shared/checkout'
|
|
11
13
|
import { readConfig } from '../shared/config'
|
|
12
14
|
import { readTextOrNull } from '../shared/files'
|
|
13
15
|
import { isRecord } from '../shared/json'
|
|
16
|
+
import { isCoordinatorAgent } from '../shared/lane'
|
|
17
|
+
import { ownPaneAgent } from '../shared/own-pane'
|
|
14
18
|
import { planCodesOf } from '../shared/plan-codes'
|
|
15
19
|
import { ensurePrivateDirectory, writePrivateFile } from '../shared/private-directory'
|
|
16
20
|
import { type CodeCache, lookUpCodes, type PlanFileStat, parseCodeCache, serializeCodeCache } from './code-cache'
|
|
21
|
+
import { formReasonOf } from './form'
|
|
22
|
+
import { type RecommendationWords, recommendationReasonOf } from './recommendation'
|
|
17
23
|
import { denyReasonOf, dialogTextsOf, planPathsOfBoards, planRootsOfBoards } from './run'
|
|
18
24
|
|
|
19
25
|
const HOOK_EVENT = 'PreToolUse'
|
|
@@ -27,6 +33,12 @@ const statOf = (path: string): PlanFileStat | null => {
|
|
|
27
33
|
return stat?.isFile() ? { path, mtimeMs: stat.mtimeMs, size: stat.size } : null
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/** argv[2] is the recommended mark, argv[3…] the labels a guard reads word for word; an older install passes none. */
|
|
37
|
+
const recommendationWordsOf = (argv: readonly string[]): RecommendationWords => ({
|
|
38
|
+
mark: argv[2] ?? '',
|
|
39
|
+
guardedLabels: argv.slice(3),
|
|
40
|
+
})
|
|
41
|
+
|
|
30
42
|
/** Keeps the cache for the next dialog; a cache that cannot be written only costs that dialog a full read. */
|
|
31
43
|
const saveCache = (main: string, cache: CodeCache): void => {
|
|
32
44
|
try {
|
|
@@ -59,12 +71,40 @@ const planCodesFor = (cwd: string): Set<string> | null => {
|
|
|
59
71
|
}
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
/** Whether the dialog is the coordinator's: the form (paw, short question and labels) binds nobody else. */
|
|
75
|
+
const isCoordinatorCall = (cwd: string, session: unknown): boolean => {
|
|
76
|
+
try {
|
|
77
|
+
const main = mainCheckoutOf(cwd)
|
|
78
|
+
const location = boardLocationOf(readConfig([main]))
|
|
79
|
+
if (location === null) return false
|
|
80
|
+
const { worktreeDirectory } = location
|
|
81
|
+
return isCoordinatorAgent({
|
|
82
|
+
agent: ownPaneAgent(),
|
|
83
|
+
session,
|
|
84
|
+
main,
|
|
85
|
+
isLane: (name) => existsSync(join(main, worktreeDirectory, name)),
|
|
86
|
+
})
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const formReasonFor = (cwd: string, session: unknown, toolInput: unknown, guardedLabels: readonly string[]): string | null =>
|
|
93
|
+
isCoordinatorCall(cwd, session) ? formReasonOf(toolInput, guardedLabels) : null
|
|
94
|
+
|
|
62
95
|
const decide = async (): Promise<string | null> => {
|
|
63
96
|
try {
|
|
64
97
|
const input: unknown = JSON.parse(await Bun.stdin.text())
|
|
65
98
|
if (!isRecord(input) || typeof input.cwd !== 'string') return null
|
|
66
99
|
const texts = dialogTextsOf(input.tool_input)
|
|
67
|
-
|
|
100
|
+
if (texts.length === 0) return null
|
|
101
|
+
const words = recommendationWordsOf(process.argv)
|
|
102
|
+
const reasons = [
|
|
103
|
+
denyReasonOf(texts, planCodesFor(input.cwd)),
|
|
104
|
+
recommendationReasonOf(input.tool_input, words),
|
|
105
|
+
formReasonFor(input.cwd, input.session_id, input.tool_input, words.guardedLabels),
|
|
106
|
+
].filter((reason): reason is string => reason !== null)
|
|
107
|
+
return reasons.length === 0 ? null : reasons.join(' ')
|
|
68
108
|
} catch {
|
|
69
109
|
return null
|
|
70
110
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { questionsOf, recordsOf } from './run'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The words the check needs, per response language: the mark a recommended option's label ends with, and the option
|
|
5
|
+
* labels a guard reads word for word (approvals of a lane call, the self-merge and the standing approval). An empty
|
|
6
|
+
* mark, from an install that does not pass one, checks nothing.
|
|
7
|
+
*/
|
|
8
|
+
export type RecommendationWords = { mark: string; guardedLabels: readonly string[] }
|
|
9
|
+
|
|
10
|
+
const QUESTION_LENGTH = 80
|
|
11
|
+
|
|
12
|
+
const shorten = (text: string): string =>
|
|
13
|
+
text.length > QUESTION_LENGTH ? `${text.slice(0, QUESTION_LENGTH - 1)}…` : text
|
|
14
|
+
|
|
15
|
+
const labelsOf = (options: unknown): string[] =>
|
|
16
|
+
recordsOf(options).flatMap((option) => (typeof option.label === 'string' ? [option.label] : []))
|
|
17
|
+
|
|
18
|
+
const isMarked = (label: string, mark: string): boolean => label.trimEnd().toLowerCase().endsWith(mark.toLowerCase())
|
|
19
|
+
|
|
20
|
+
const withoutMark = (label: string, mark: string): string =>
|
|
21
|
+
isMarked(label, mark) ? label.trimEnd().slice(0, -mark.length).trimEnd() : label
|
|
22
|
+
|
|
23
|
+
/** A guarded label that carries the mark: the guard would not know the answer, so the dialog goes back. */
|
|
24
|
+
const markedGuardReason = (labels: readonly string[], { mark, guardedLabels }: RecommendationWords): string | null => {
|
|
25
|
+
const label = labels.find(
|
|
26
|
+
(candidate) => isMarked(candidate, mark) && guardedLabels.includes(withoutMark(candidate, mark)),
|
|
27
|
+
)
|
|
28
|
+
return label === undefined
|
|
29
|
+
? null
|
|
30
|
+
: `Option "${label}": a guard reads "${withoutMark(label, mark)}" word for word; drop "${mark}" from it.`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The first option carries the mark; else the reason names a later marked option to move up, or asks for one. */
|
|
34
|
+
const firstMarkReason = (question: string, labels: readonly string[], mark: string): string | null => {
|
|
35
|
+
const [first, ...rest] = labels
|
|
36
|
+
if (first !== undefined && isMarked(first, mark)) return null
|
|
37
|
+
const later = rest.find((label) => isMarked(label, mark))
|
|
38
|
+
return later === undefined
|
|
39
|
+
? `Question "${shorten(question)}": put your recommendation first and end its label with "${mark}".`
|
|
40
|
+
: `Question "${shorten(question)}": the recommendation goes first; move "${later}" to the top.`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A single choice has one recommendation: the first option carries the mark and no other does. */
|
|
44
|
+
const singleChoiceReason = (question: string, labels: readonly string[], mark: string): string | null => {
|
|
45
|
+
const firstReason = firstMarkReason(question, labels, mark)
|
|
46
|
+
const second = labels.slice(1).find((label) => isMarked(label, mark))
|
|
47
|
+
return firstReason ?? (second === undefined
|
|
48
|
+
? null
|
|
49
|
+
: `Question "${shorten(question)}": one recommendation only; drop "${mark}" from "${second}".`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const questionReason = (question: Record<string, unknown>, words: RecommendationWords): string | null => {
|
|
53
|
+
const labels = labelsOf(question.options)
|
|
54
|
+
if (labels.length === 0) return null
|
|
55
|
+
const guarded = markedGuardReason(labels, words)
|
|
56
|
+
if (guarded !== null) return guarded
|
|
57
|
+
if (labels.some((label) => words.guardedLabels.includes(label))) return null
|
|
58
|
+
const text = typeof question.question === 'string' ? question.question : ''
|
|
59
|
+
// A multiSelect question may recommend several options, the first among them.
|
|
60
|
+
const reasonOf = question.multiSelect === true ? firstMarkReason : singleChoiceReason
|
|
61
|
+
return reasonOf(text, labels, words.mark)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The reason to send a dialog back because a question does not carry its recommendation as the first option, marked
|
|
66
|
+
* at the end of its label; null when every question does. A question that offers a label a guard reads word for word
|
|
67
|
+
* needs no mark, and such a label must not carry one.
|
|
68
|
+
*/
|
|
69
|
+
export const recommendationReasonOf = (toolInput: unknown, words: RecommendationWords): string | null => {
|
|
70
|
+
if (words.mark === '') return null
|
|
71
|
+
const [reason] = questionsOf(toolInput).flatMap((question) => questionReason(question, words) ?? [])
|
|
72
|
+
return reason ?? null
|
|
73
|
+
}
|