jackherd 1.18.2 → 1.20.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 +23 -0
- package/INSTALL.md +1 -1
- package/README.md +30 -25
- package/assets/icons/model.svg +1 -0
- package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
- package/dist/config.d.ts +16 -2
- package/dist/config.js +25 -4
- package/dist/plan-format.d.ts +84 -1
- package/dist/plan-format.js +85 -1
- package/docs/plan-contract.md +8 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
- package/kit/hooks/hooks/approval-gate/run.ts +23 -3
- package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
- package/kit/hooks/hooks/browser-guard/index.ts +50 -0
- package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
- package/kit/hooks/hooks/plain-question/card.ts +102 -0
- package/kit/hooks/hooks/plain-question/form.ts +25 -9
- package/kit/hooks/hooks/plain-question/index.ts +20 -25
- package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
- package/kit/hooks/hooks/return-brief/decision.ts +29 -0
- package/kit/hooks/hooks/return-brief/index.ts +37 -0
- package/kit/hooks/hooks/return-brief/settings.ts +11 -0
- package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
- package/kit/hooks/hooks/shared/transcript.ts +9 -0
- package/kit/hooks/hooks.json +18 -1
- package/kit/review/agents/security-audit.md +1 -1
- package/kit/workflow/agents/browser-check.md +29 -0
- package/kit/workflow/skills/implement/SKILL.md +5 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
- package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
- package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
- package/kit/workflow/skills/orchestrate/references/start-lane.md +3 -3
- package/kit/workflow/skills/plan/SKILL.md +2 -0
- package/kit/workflow/skills/plan/references/template-English.md +8 -0
- package/kit/workflow/skills/plan/references/template-German.md +8 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +21 -0
- package/src/brief/command.ts +45 -0
- package/src/brief/lane-brief.ts +51 -0
- package/src/brief/lane-facts.ts +64 -0
- package/src/check/skill-budget-defaults.ts +13 -2
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +24 -0
- package/src/lane/command.ts +3 -1
- package/src/lane/port-command.ts +37 -0
- package/src/lane/port.ts +56 -0
- package/src/lane-prompt/texts.ts +2 -2
- package/src/plan-contract/command.ts +1 -0
- package/src/plan-contract/contract.ts +28 -5
- package/src/plan-format/index.ts +1 -0
- package/src/plan-format/plan-head.ts +131 -0
- package/src/plans/active-tasks.ts +8 -1
- package/src/plans/click-hint.ts +2 -1
- package/src/plans/focus-overlay.ts +5 -2
- package/src/plans/folded-detail.ts +47 -0
- package/src/plans/lane-line.ts +86 -0
- package/src/plans/lane-reason.ts +24 -4
- package/src/plans/layout-phase.ts +4 -20
- package/src/plans/layout-summary.ts +56 -45
- package/src/plans/layout.ts +178 -44
- package/src/plans/port-listening.ts +25 -0
- package/src/plans/selection.ts +11 -0
- package/src/plans/settings.ts +4 -0
- package/src/plans/snapshot.ts +27 -9
- package/src/plans/task-checklist.ts +57 -0
- package/src/plans/texts.ts +34 -9
- package/src/resume/command.ts +40 -6
- package/src/resume/coordinator-read.ts +136 -0
- package/src/resume/state.ts +163 -0
- package/src/resume/texts.ts +6 -0
- package/src/sync/render.ts +5 -1
- package/src/vocabulary/index.ts +74 -0
- package/src/watch/clear-moment.ts +228 -0
- package/src/watch/command.ts +92 -8
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/reference-paths.ts +8 -4
- package/src/watch/watch-events.ts +13 -0
package/dist/plan-format.js
CHANGED
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
ADDRESS_FORM,
|
|
3
3
|
APPOINTMENT_VOCABULARY,
|
|
4
4
|
APPROVAL_TEXTS,
|
|
5
|
+
CARD_FIELDS,
|
|
6
|
+
HEAD_LINE,
|
|
5
7
|
LANE_PROMPT_OPENING,
|
|
6
8
|
LANGUAGE_NAME,
|
|
7
9
|
NEXT_VOCABULARY,
|
|
@@ -9,6 +11,7 @@ import {
|
|
|
9
11
|
PLAN_STATUS,
|
|
10
12
|
PLAN_VOCABULARY,
|
|
11
13
|
RECOMMENDED_MARK,
|
|
14
|
+
REPORT_FIELDS,
|
|
12
15
|
RESPONSE_ADDRESS,
|
|
13
16
|
SELF_MERGE_LABEL,
|
|
14
17
|
STANDING_APPROVAL_TEXTS,
|
|
@@ -18,7 +21,7 @@ import {
|
|
|
18
21
|
phaseHeading,
|
|
19
22
|
sectionHeading,
|
|
20
23
|
terminalSafe
|
|
21
|
-
} from "./chunk-
|
|
24
|
+
} from "./chunk-JDO6O3UO.js";
|
|
22
25
|
|
|
23
26
|
// src/plan-format/lanes.ts
|
|
24
27
|
import { posix } from "path";
|
|
@@ -785,6 +788,81 @@ var tallyPhases = (markdown, language) => {
|
|
|
785
788
|
});
|
|
786
789
|
};
|
|
787
790
|
|
|
791
|
+
// src/plan-format/plan-head.ts
|
|
792
|
+
var HEAD_STATUS = { present: "present", missing: "missing", stale: "stale" };
|
|
793
|
+
var SECTION_HEADING2 = /^##\s/;
|
|
794
|
+
var HEAD_ENTRY = /^\s*(?:[-*]\s+)?\*\*([^*]+?)\s*:?\*\*\s*:?\s*(.*)$/;
|
|
795
|
+
var HEAD_LINES = Object.values(HEAD_LINE);
|
|
796
|
+
var labelOf = (language, line) => PLAN_VOCABULARY[language].head[line].toLowerCase();
|
|
797
|
+
var entryOf = (text, language) => {
|
|
798
|
+
const match = text.match(HEAD_ENTRY);
|
|
799
|
+
if (!match) return null;
|
|
800
|
+
const written = match[1].trim().toLowerCase();
|
|
801
|
+
const label = HEAD_LINES.find((each) => labelOf(language, each) === written);
|
|
802
|
+
return label ? { label, text: match[2].trim() } : null;
|
|
803
|
+
};
|
|
804
|
+
var phaseOf = (statusText, language) => {
|
|
805
|
+
const match = statusText?.match(
|
|
806
|
+
new RegExp(`${escapeRegExp(PLAN_VOCABULARY[language].phase)}\\s+(\\d+[a-z]?)\\s*/\\s*(\\d+)`, "i")
|
|
807
|
+
);
|
|
808
|
+
return match ? { current: match[1].toLowerCase(), total: Number(match[2]) } : null;
|
|
809
|
+
};
|
|
810
|
+
var parsePlanHead = (markdown, language) => {
|
|
811
|
+
const lines = markdown.split("\n");
|
|
812
|
+
const heading = new RegExp(`^##\\s+${escapeRegExp(PLAN_VOCABULARY[language].sections.forYou)}\\s*$`);
|
|
813
|
+
const start = lines.findIndex((text) => heading.test(text));
|
|
814
|
+
if (start === -1) return null;
|
|
815
|
+
const rest = lines.slice(start + 1);
|
|
816
|
+
const end = rest.findIndex((text) => SECTION_HEADING2.test(text));
|
|
817
|
+
const texts = {};
|
|
818
|
+
const lineNumbers = {};
|
|
819
|
+
for (const [offset, text] of (end === -1 ? rest : rest.slice(0, end)).entries()) {
|
|
820
|
+
const entry = entryOf(text, language);
|
|
821
|
+
if (entry && texts[entry.label] === void 0) {
|
|
822
|
+
texts[entry.label] = entry.text;
|
|
823
|
+
lineNumbers[entry.label] = start + offset + 2;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
line: start + 1,
|
|
828
|
+
texts,
|
|
829
|
+
lineNumbers,
|
|
830
|
+
missing: HEAD_LINES.filter((each) => texts[each] === void 0),
|
|
831
|
+
phase: phaseOf(texts[HEAD_LINE.status], language),
|
|
832
|
+
misplaced: lines.slice(0, start).some((text) => SECTION_HEADING2.test(text))
|
|
833
|
+
};
|
|
834
|
+
};
|
|
835
|
+
var verdict = (status, line, message) => ({ status, line, message });
|
|
836
|
+
var phaseStates = (markdown, language) => parsePlanPhases(markdown, language).filter(({ id }, index, all) => all.findIndex((other) => other.id === id) === index).map(({ id, items }) => ({ id: id.toLowerCase(), open: items.some(({ done }) => !done) }));
|
|
837
|
+
var phaseMismatch = ({ current, total }, states) => {
|
|
838
|
+
if (total !== states.length) return `names ${total} phases, the plan has ${states.length}`;
|
|
839
|
+
const running = states.find(({ open }) => open);
|
|
840
|
+
if (running === void 0)
|
|
841
|
+
return current === states.at(-1)?.id ? null : `all phases are done, the head names phase ${current}`;
|
|
842
|
+
return current === running.id ? null : `names phase ${current}, the running phase is ${running.id}`;
|
|
843
|
+
};
|
|
844
|
+
var checkPlanHead = (markdown, language) => {
|
|
845
|
+
const head = parsePlanHead(markdown, language);
|
|
846
|
+
const { sections } = PLAN_VOCABULARY[language];
|
|
847
|
+
if (head === null) return verdict(HEAD_STATUS.missing, null, `no \`## ${sections.forYou}\` section`);
|
|
848
|
+
if (head.misplaced)
|
|
849
|
+
return verdict(
|
|
850
|
+
HEAD_STATUS.stale,
|
|
851
|
+
head.line,
|
|
852
|
+
`the head must stand right after the header lines, before the other sections`
|
|
853
|
+
);
|
|
854
|
+
if (head.missing.length > 0)
|
|
855
|
+
return verdict(
|
|
856
|
+
HEAD_STATUS.stale,
|
|
857
|
+
head.line,
|
|
858
|
+
`no line ${head.missing.map((each) => `"${PLAN_VOCABULARY[language].head[each]}"`).join(", ")}`
|
|
859
|
+
);
|
|
860
|
+
const statusLine2 = head.lineNumbers[HEAD_LINE.status] ?? head.line;
|
|
861
|
+
if (head.phase === null) return verdict(HEAD_STATUS.stale, statusLine2, 'the status line has no "Phase x/y"');
|
|
862
|
+
const mismatch = phaseMismatch(head.phase, phaseStates(markdown, language));
|
|
863
|
+
return mismatch === null ? verdict(HEAD_STATUS.present, head.line, `Phase ${head.phase.current}/${head.phase.total}`) : verdict(HEAD_STATUS.stale, statusLine2, mismatch);
|
|
864
|
+
};
|
|
865
|
+
|
|
788
866
|
// src/plan-format/plan-structure.ts
|
|
789
867
|
var noGatePattern = (language) => new RegExp(`^\\s*\\*\\*${escapeRegExp(PLAN_VOCABULARY[language].noReviewGate)}\\b`, "i");
|
|
790
868
|
var tickCount = (phase) => phase.items.filter((item) => item.done).length;
|
|
@@ -913,6 +991,9 @@ export {
|
|
|
913
991
|
ADDRESS_FORM,
|
|
914
992
|
APPOINTMENT_VOCABULARY,
|
|
915
993
|
APPROVAL_TEXTS,
|
|
994
|
+
CARD_FIELDS,
|
|
995
|
+
HEAD_LINE,
|
|
996
|
+
HEAD_STATUS,
|
|
916
997
|
LANE_PROMPT_OPENING,
|
|
917
998
|
LANGUAGE_NAME,
|
|
918
999
|
NEXT_VOCABULARY,
|
|
@@ -926,6 +1007,7 @@ export {
|
|
|
926
1007
|
PLAN_VOCABULARY,
|
|
927
1008
|
PROSE_REASON,
|
|
928
1009
|
RECOMMENDED_MARK,
|
|
1010
|
+
REPORT_FIELDS,
|
|
929
1011
|
RESPONSE_ADDRESS,
|
|
930
1012
|
SELF_MERGE_LABEL,
|
|
931
1013
|
SHORT_NAME_LIMIT,
|
|
@@ -935,6 +1017,7 @@ export {
|
|
|
935
1017
|
activeItem,
|
|
936
1018
|
boardRowsWithProse,
|
|
937
1019
|
boardRowsWithoutPhase,
|
|
1020
|
+
checkPlanHead,
|
|
938
1021
|
classifyStatus,
|
|
939
1022
|
columnModels,
|
|
940
1023
|
countDecisions,
|
|
@@ -966,6 +1049,7 @@ export {
|
|
|
966
1049
|
parallelGroupsOfPlan,
|
|
967
1050
|
parseDependencyTable,
|
|
968
1051
|
parseModelTable,
|
|
1052
|
+
parsePlanHead,
|
|
969
1053
|
parsePlanPhases,
|
|
970
1054
|
parseSprintLanes,
|
|
971
1055
|
phaseCount,
|
package/docs/plan-contract.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
jackherd reads a plan from its Markdown. This is what a plan has to hold for the readers to find it, and how to check that with one command.
|
|
4
4
|
|
|
5
|
-
## The
|
|
5
|
+
## The parts
|
|
6
6
|
|
|
7
7
|
| Part | Form | Required | Read by |
|
|
8
8
|
| ---- | ---- | -------- | ------- |
|
|
@@ -10,6 +10,7 @@ jackherd reads a plan from its Markdown. This is what a plan has to hold for the
|
|
|
10
10
|
| Tasks | at least one checkbox (`- [ ]`, `- [x]`) or a ✅/⬜ table row in every phase; a phase that points at a section (``See `## Documentation` ``) counts that section's checkboxes | yes | herd (progress), watcher, `jh check` |
|
|
11
11
|
| Model | a table under `### Model recommendations per phase`: the phase in the first cell (`1 — Title`, `P1`, `10c`), the model in the second (`claude-opus-5-5`, `Opus 5.5`); a dash marks a phase that needs no model | yes | lane start (the model a lane runs on), `/implement` |
|
|
12
12
|
| Dependencies | a table under `### Dependencies and parallelization` with the columns `Depends on`, `Writes`, `Serial marker` and one row per phase | from two phases on, optional | `jh check --parallel`, `/implement` |
|
|
13
|
+
| Head | a section `## For you` (de: `## Für dich`) right after the header lines (`**Date:**`, `**Status:**`, `**Scope:**`), before the first other `##` section, with five lines `- **<label>:** <text>` | no, a note | `jh plan check`, the reader who is not an agent |
|
|
13
14
|
|
|
14
15
|
Details that decide a verdict:
|
|
15
16
|
|
|
@@ -17,6 +18,9 @@ Details that decide a verdict:
|
|
|
17
18
|
- A model cell that names no model and is no dash is a gap: the lane would start on a guess.
|
|
18
19
|
- With fewer than two phases the dependency part is reported `present` ("not needed"), never missing.
|
|
19
20
|
- With two or more phases and no dependency table the plan is still valid: the phases run one after another. A table that is there but lacks a row for a phase is a gap.
|
|
21
|
+
- The head's five labels are fixed per language. English: `What changes for you`, `Status`, `Your open decisions`, `Try it now`, `Next step`; German: `Was sich für dich ändert`, `Stand`, `Deine offenen Entscheidungen`, `Schon ausprobieren`, `Nächster Schritt`. The colon may stand outside the bold (`**Status**:`) and the list mark may be left out.
|
|
22
|
+
- The `Status` line carries `Phase x/y`: x the id of the first phase with an open checkbox, as its heading spells it (`3`, `3a`), y the number of phases, each id counted once. When every phase is done, x is the id of the last one.
|
|
23
|
+
- The head is `missing` without the section. It is `stale` when it stands behind another section, lacks a line, has no `Phase x/y`, or names another phase or phase count than the checkboxes show. Both are notes: the contract stays met and the exit code does not change, so plans written before the head keep passing.
|
|
20
24
|
- The headings come from the plan language in `.jackherd/config.json` (`planLanguage`, `de` or `en`); the English ones are shown here.
|
|
21
25
|
|
|
22
26
|
## Minimal valid plan
|
|
@@ -66,6 +70,7 @@ phases: present plan.md:22 2 phases
|
|
|
66
70
|
tasks: present plan.md:22 every phase has tasks
|
|
67
71
|
model: present plan.md:12 every phase has a model
|
|
68
72
|
dependencies: present every phase has a row
|
|
73
|
+
head: missing no `## For you` section
|
|
69
74
|
```
|
|
70
75
|
|
|
71
76
|
If the model table lacked the row for phase 2, and the dependency table were left out, the lines would read:
|
|
@@ -75,11 +80,12 @@ phases: present plan.md:22 2 phases
|
|
|
75
80
|
tasks: present plan.md:22 every phase has tasks
|
|
76
81
|
model: malformed plan.md:22 no model for P2
|
|
77
82
|
dependencies: missing no dependency table: phases run one after another
|
|
83
|
+
head: stale plan.md:5 names phase 1, the running phase is 2
|
|
78
84
|
```
|
|
79
85
|
|
|
80
86
|
| Exit | Meaning |
|
|
81
87
|
| ---- | ------- |
|
|
82
|
-
| 0 | the contract is met (a missing dependency table
|
|
88
|
+
| 0 | the contract is met (a missing dependency table and a missing or stale head are notes, not gaps) |
|
|
83
89
|
| 1 | at least one part is missing or malformed |
|
|
84
90
|
| 2 | wrong command line: no subcommand, unknown subcommand, no file, unreadable file, unreadable config |
|
|
85
91
|
| 3 | the contract is met, but the plan or a board that links it is not on the base branch (a `base:` line names each path); the coordinator commits it first (`/orchestrate` § 2 step 0), after which the check counts as done |
|
package/install.sh
CHANGED
|
@@ -9,7 +9,7 @@ set -eu
|
|
|
9
9
|
|
|
10
10
|
# The default is the version this file ships with. The release bump changes it together with
|
|
11
11
|
# package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
|
|
12
|
-
JACKHERD_VERSION="${JACKHERD_VERSION:-1.
|
|
12
|
+
JACKHERD_VERSION="${JACKHERD_VERSION:-1.20.0}"
|
|
13
13
|
|
|
14
14
|
JACKHERD_PACKAGE='jackherd'
|
|
15
15
|
# Test seam: the terminal the wizard asks its questions on.
|
|
@@ -20,16 +20,16 @@ export type GateQuery = {
|
|
|
20
20
|
const PENDING: GateDecision = { kind: 'pending' }
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* The
|
|
24
|
-
*
|
|
23
|
+
* The request of this call an answer names by id and hash, if the answer is one of the offered labels and was given
|
|
24
|
+
* after it. The hash in the question is the one the coordinator showed; a request rewritten meanwhile has another.
|
|
25
25
|
*/
|
|
26
26
|
const answeredRequest = (
|
|
27
27
|
answer: DialogAnswer,
|
|
28
|
-
|
|
28
|
+
ofCall: ReadonlyMap<string, ApprovalRequest>,
|
|
29
29
|
labels: GateLabels,
|
|
30
30
|
): ApprovalRequest | undefined => {
|
|
31
31
|
const named = namedRequest(answer.question)
|
|
32
|
-
const candidate = named === null ? undefined :
|
|
32
|
+
const candidate = named === null ? undefined : ofCall.get(named.id)
|
|
33
33
|
const request = candidate?.hash === named?.hash ? candidate : undefined
|
|
34
34
|
const isLabel = answer.answer === labels.allow || answer.answer === labels.deny
|
|
35
35
|
return request && isLabel && answer.offered.includes(answer.answer) && answer.answeredAt >= request.createdAt
|
|
@@ -39,22 +39,21 @@ const answeredRequest = (
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* What the user decided about this call: the newest fresh answer, given in the dialog with one of the two labels, to a
|
|
42
|
-
* request of the same call
|
|
43
|
-
*
|
|
42
|
+
* request of the same call. An earlier request of the same call counts, so a retry after the approval passes at once.
|
|
43
|
+
* A spent request still counts as the newest word and leaves the call pending, so an older approval never outruns it,
|
|
44
|
+
* whatever order or crash the claims went through. Anything else leaves the call pending too.
|
|
44
45
|
*/
|
|
45
46
|
export const gateDecision = ({ requests, hash, answers, labels, now, validMinutes }: GateQuery): GateDecision => {
|
|
46
|
-
const
|
|
47
|
-
requests.filter((request) => request.hash === hash && request.usedAt === null).map((request) => [request.id, request]),
|
|
48
|
-
)
|
|
47
|
+
const ofCall = new Map(requests.filter((request) => request.hash === hash).map((request) => [request.id, request]))
|
|
49
48
|
const decided = answers
|
|
50
49
|
.filter((answer) => isFresh(answer.answeredAt, now, validMinutes))
|
|
51
50
|
.sort((first, second) => second.answeredAt - first.answeredAt)
|
|
52
51
|
.flatMap((answer) => {
|
|
53
|
-
const request = answeredRequest(answer,
|
|
52
|
+
const request = answeredRequest(answer, ofCall, labels)
|
|
54
53
|
return request ? [{ answer, request }] : []
|
|
55
54
|
})
|
|
56
55
|
const [newest] = decided
|
|
57
|
-
if (!newest) return PENDING
|
|
56
|
+
if (!newest || newest.request.usedAt !== null) return PENDING
|
|
58
57
|
return newest.answer.answer === labels.allow
|
|
59
58
|
? { kind: 'allow', id: newest.request.id }
|
|
60
59
|
: { kind: 'deny', id: newest.request.id }
|
|
@@ -103,9 +103,25 @@ const passStanding = (
|
|
|
103
103
|
return dependencies.writeHistory(main, entry) ? { kind: 'allow' } : { kind: 'pending' }
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Spends the other open requests of the call before the answered one is claimed. One left open would stay answerable,
|
|
108
|
+
* and its older approval could pass a retry after the user denied the newer request. The answered request goes last:
|
|
109
|
+
* while it stays open, a hook reading meanwhile reaches the same decision, and a crash leaves that answer standing.
|
|
110
|
+
*/
|
|
111
|
+
const settleSiblings = (
|
|
112
|
+
main: string,
|
|
113
|
+
answered: { id: string; hash: string },
|
|
114
|
+
requests: readonly ApprovalRequest[],
|
|
115
|
+
now: number,
|
|
116
|
+
dependencies: GateDependencies,
|
|
117
|
+
): void => {
|
|
118
|
+
const isSibling = ({ id, hash, usedAt }: ApprovalRequest) => hash === answered.hash && usedAt === null && id !== answered.id
|
|
119
|
+
for (const { id } of requests.filter(isSibling)) dependencies.claimRequest(main, id, now)
|
|
120
|
+
}
|
|
121
|
+
|
|
106
122
|
/**
|
|
107
123
|
* Polls the coordinator's transcripts until the user decided, a standing approval with the coordinator's mark lets the
|
|
108
|
-
* call pass, the answer
|
|
124
|
+
* call pass, or the wait is over. An answer another hook claimed first decides nothing here: this hook polls on.
|
|
109
125
|
*/
|
|
110
126
|
const awaitDecision = async (
|
|
111
127
|
main: string,
|
|
@@ -121,8 +137,12 @@ const awaitDecision = async (
|
|
|
121
137
|
const log = dependencies.coordinatorLog(main)
|
|
122
138
|
const { hash } = opened
|
|
123
139
|
const decision = gateDecision({ requests, hash, answers: log.answers, labels: texts, now, validMinutes: VALID_MINUTES })
|
|
124
|
-
if (decision.kind !== 'pending'
|
|
125
|
-
|
|
140
|
+
if (decision.kind !== 'pending') {
|
|
141
|
+
settleSiblings(main, { id: decision.id, hash }, requests, now, dependencies)
|
|
142
|
+
if (dependencies.claimRequest(main, decision.id, now)) {
|
|
143
|
+
return decision.kind === 'allow' ? { kind: 'allow' } : { kind: 'denied' }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
126
146
|
const standing = standingPass(heldRequest(opened, requests), log, texts, settings, now)
|
|
127
147
|
const passed = standing === null ? null : passStanding(main, standing, now, dependencies)
|
|
128
148
|
if (passed !== null) return passed
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { JsonObject } from '../shared/json'
|
|
2
|
+
|
|
3
|
+
export const BROWSER_GUARD_REASON =
|
|
4
|
+
'The coordinator does not open Chrome itself: send the look to the `browser-check` agent, its prompt names the port or address, the path and what to check.'
|
|
5
|
+
|
|
6
|
+
/** A subagent's call carries its `agent_id` in the hook input, a call from the main context carries none. */
|
|
7
|
+
export const isSubagentCall = (input: JsonObject): boolean => typeof input.agent_id === 'string' && input.agent_id !== ''
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// PreToolUse hook for the tools of the Claude in Chrome extension, shipped by jackherd: refuses them in the main context
|
|
2
|
+
// of the coordinator, whose screenshots would stay in its context until the next `/clear`, and names the helper agent.
|
|
3
|
+
// A subagent's call, a lane's and any session outside the herd pass. Any failure lets the call through: the guard
|
|
4
|
+
// repeats a rule of the skill and must not stop a look it cannot judge.
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { boardLocationOf } from '../shared/boards'
|
|
8
|
+
import { mainCheckoutOf } from '../shared/checkout'
|
|
9
|
+
import { readConfig } from '../shared/config'
|
|
10
|
+
import { isRecord } from '../shared/json'
|
|
11
|
+
import { isCoordinatorAgent } from '../shared/lane'
|
|
12
|
+
import { ownPaneAgent } from '../shared/own-pane'
|
|
13
|
+
import { BROWSER_GUARD_REASON, isSubagentCall } from './decision'
|
|
14
|
+
|
|
15
|
+
const HOOK_EVENT = 'PreToolUse'
|
|
16
|
+
|
|
17
|
+
const isCoordinatorCall = (cwd: string, session: unknown): boolean => {
|
|
18
|
+
const main = mainCheckoutOf(cwd)
|
|
19
|
+
const location = boardLocationOf(readConfig([main]))
|
|
20
|
+
if (location === null) return false
|
|
21
|
+
const { worktreeDirectory } = location
|
|
22
|
+
return isCoordinatorAgent({
|
|
23
|
+
agent: ownPaneAgent(),
|
|
24
|
+
session,
|
|
25
|
+
main,
|
|
26
|
+
isLane: (name) => existsSync(join(main, worktreeDirectory, name)),
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const refuses = async (): Promise<boolean> => {
|
|
31
|
+
try {
|
|
32
|
+
const input: unknown = JSON.parse(await Bun.stdin.text())
|
|
33
|
+
if (!isRecord(input) || typeof input.cwd !== 'string' || isSubagentCall(input)) return false
|
|
34
|
+
return isCoordinatorCall(input.cwd, input.session_id)
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (await refuses())
|
|
41
|
+
console.log(
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
hookSpecificOutput: {
|
|
44
|
+
hookEventName: HOOK_EVENT,
|
|
45
|
+
permissionDecision: 'deny',
|
|
46
|
+
permissionDecisionReason: BROWSER_GUARD_REASON,
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
process.exit(0)
|
|
@@ -2,7 +2,20 @@
|
|
|
2
2
|
* The positions of the values `sync` renders into the hook entry's command (`kit/hooks/hooks.json`), after the script
|
|
3
3
|
* path. `arguments.test.ts` checks the entry passes them in this order.
|
|
4
4
|
*/
|
|
5
|
-
export const ARGUMENT = {
|
|
5
|
+
export const ARGUMENT = {
|
|
6
|
+
recommended: 2,
|
|
7
|
+
approvalAllow: 3,
|
|
8
|
+
approvalDeny: 4,
|
|
9
|
+
selfMergeLabel: 5,
|
|
10
|
+
standingApprove: 6,
|
|
11
|
+
cardTopic: 7,
|
|
12
|
+
cardConsequences: 8,
|
|
13
|
+
cardIfUndecided: 9,
|
|
14
|
+
cardReadMore: 10,
|
|
15
|
+
} as const
|
|
6
16
|
|
|
7
17
|
/** The first position of a label a guard reads word for word. */
|
|
8
18
|
export const FIRST_GUARDED_LABEL = ARGUMENT.approvalAllow
|
|
19
|
+
|
|
20
|
+
/** The position after the last guarded label: the card's field names follow them. */
|
|
21
|
+
export const AFTER_GUARDED_LABELS = ARGUMENT.cardTopic
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { labelsOf, shorten, textOf } from './form'
|
|
2
|
+
import { questionsOf, recordsOf } from './run'
|
|
3
|
+
|
|
4
|
+
/** The four field names of the decision card, in the response language; each opens a line of the dialog's own text. */
|
|
5
|
+
export type CardFields = { topic: string; consequences: string; ifUndecided: string; readMore: string }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The words the check needs: the card's field names, and the option labels a guard reads word for word, whose
|
|
9
|
+
* dialogs (approvals) keep a form of their own. An empty field name, from an install that does not pass the names,
|
|
10
|
+
* checks nothing.
|
|
11
|
+
*/
|
|
12
|
+
export type CardWords = { fields: CardFields; guardedLabels: readonly string[] }
|
|
13
|
+
|
|
14
|
+
/** What may stand before a field name at the start of a line: a list mark, a quote, a heading, bold or underline. */
|
|
15
|
+
const LINE_OPENING = String.raw`^[ \t]*(?:(?:[-*+>]|#{1,6})[ \t]+)*(?:\*\*|__)?[ \t]*`
|
|
16
|
+
/** What follows the name: a colon, the end of the bold, or the end of the line (a heading). */
|
|
17
|
+
const NAME_ENDING = String.raw`[ \t]*(?::|\*\*|__|$)`
|
|
18
|
+
/** A web link, a Markdown link, a path with a slash or a file name with its extension. */
|
|
19
|
+
const LINK_OR_PATH = /https?:\/\/\S+|\[[^\]]*\]\([^)]+\)|\S*\w\/[\w.-]|[\w-]{2,}\.[a-z]{2,4}\b/i
|
|
20
|
+
|
|
21
|
+
const escaped = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
22
|
+
|
|
23
|
+
const fieldPattern = (name: string): RegExp => new RegExp(`${LINE_OPENING}${escaped(name)}${NAME_ENDING}`, 'im')
|
|
24
|
+
|
|
25
|
+
const fieldNamesOf = (fields: CardFields): string[] => [
|
|
26
|
+
fields.topic,
|
|
27
|
+
fields.consequences,
|
|
28
|
+
fields.ifUndecided,
|
|
29
|
+
fields.readMore,
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
/** The text after the "read more" field, up to the next field line or the end; null without that field. */
|
|
33
|
+
const readMoreText = (text: string, fields: CardFields): string | null => {
|
|
34
|
+
const match = fieldPattern(fields.readMore).exec(text)
|
|
35
|
+
if (match === null) return null
|
|
36
|
+
const rest = text.slice(match.index + match[0].length)
|
|
37
|
+
const ends = fieldNamesOf(fields)
|
|
38
|
+
.map((name) => fieldPattern(name).exec(rest)?.index ?? rest.length)
|
|
39
|
+
.filter((index) => index > 0)
|
|
40
|
+
return rest.slice(0, Math.min(rest.length, ...ends))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const quoted = (names: readonly string[]): string => names.map((name) => `"${name}"`).join(', ')
|
|
44
|
+
|
|
45
|
+
const optionsOf = (question: Record<string, unknown>): Record<string, unknown>[] => recordsOf(question.options)
|
|
46
|
+
|
|
47
|
+
/** The text the card is read from: the question and the `preview` of every option. */
|
|
48
|
+
const cardTextOf = (question: Record<string, unknown>): string =>
|
|
49
|
+
[textOf(question.question), ...optionsOf(question).map((option) => textOf(option.preview))].join('\n')
|
|
50
|
+
|
|
51
|
+
const missingFieldsReason = (text: string, fields: CardFields, name: string): string | null => {
|
|
52
|
+
const missing = fieldNamesOf(fields).filter((field) => !fieldPattern(field).test(text))
|
|
53
|
+
return missing.length === 0
|
|
54
|
+
? null
|
|
55
|
+
: `${name}: decision card missing in the dialog: ${quoted(missing)}. Open a line with each field name in the "preview" of the options (a multiple choice has none: in the question), in everyday words, with consequences that can be checked (lines, files, an amount, downtime), as in "${fields.topic}: …", "${fields.consequences}: …", "${fields.ifUndecided}: …", "${fields.readMore}: <link or path>".`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const readMoreReason = (text: string, fields: CardFields, name: string): string | null => {
|
|
59
|
+
const readMore = readMoreText(text, fields)
|
|
60
|
+
return readMore === null || LINK_OR_PATH.test(readMore)
|
|
61
|
+
? null
|
|
62
|
+
: `${name}: "${fields.readMore}" without a link or a path: name where to read on, as in "${fields.readMore}: https://…".`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A single choice shows each option's own consequences in its `preview`; a multiple choice has no preview. */
|
|
66
|
+
const consequencesReason = (question: Record<string, unknown>, fields: CardFields, name: string): string | null => {
|
|
67
|
+
if (question.multiSelect === true) return null
|
|
68
|
+
const missing = labelsOf(optionsOf(question).filter((option) => !fieldPattern(fields.consequences).test(textOf(option.preview))))
|
|
69
|
+
return missing.length === 0
|
|
70
|
+
? null
|
|
71
|
+
: `${name}: give each option its own line "${fields.consequences}: …" in its "preview" (missing: ${quoted(missing)}).`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const questionReasons = (question: Record<string, unknown>, fields: CardFields): (string | null)[] => {
|
|
75
|
+
const text = cardTextOf(question)
|
|
76
|
+
const name = `Question "${shorten(textOf(question.question))}"`
|
|
77
|
+
return [
|
|
78
|
+
missingFieldsReason(text, fields, name),
|
|
79
|
+
readMoreReason(text, fields, name),
|
|
80
|
+
consequencesReason(question, fields, name),
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const offersGuardedLabel = (questions: readonly Record<string, unknown>[], guardedLabels: readonly string[]): boolean =>
|
|
85
|
+
questions.some((question) => labelsOf(question.options).some((label) => guardedLabels.includes(label)))
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The reason to send a dialog back because its decision card is missing. The card stands in the dialog itself, since
|
|
89
|
+
* a `PreToolUse` hook does not see the text before it: each of the four field names opens a line in the question or in
|
|
90
|
+
* the `preview` of an option, the "read more" field has a link or a path, and every option of a single choice carries
|
|
91
|
+
* its own consequences line. Every miss is named, so one send-back is enough. Null when the card holds, when a field
|
|
92
|
+
* name is empty, and for a dialog that offers a guarded label.
|
|
93
|
+
*/
|
|
94
|
+
export const cardReasonOf = (toolInput: unknown, { fields, guardedLabels }: CardWords): string | null => {
|
|
95
|
+
const questions = questionsOf(toolInput)
|
|
96
|
+
if (questions.length === 0 || fieldNamesOf(fields).some((name) => name.trim() === '')) return null
|
|
97
|
+
if (offersGuardedLabel(questions, guardedLabels)) return null
|
|
98
|
+
const found = questions
|
|
99
|
+
.flatMap((question) => questionReasons(question, fields))
|
|
100
|
+
.filter((reason): reason is string => reason !== null)
|
|
101
|
+
return found.length === 0 ? null : found.join(' ')
|
|
102
|
+
}
|
|
@@ -6,6 +6,10 @@ const EXAMPLE_HEADER = 'Merge'
|
|
|
6
6
|
const QUESTION_LENGTH = 80
|
|
7
7
|
|
|
8
8
|
export const MAX_QUESTION_WORDS = 15
|
|
9
|
+
/** A multiple choice has no `preview`: its question carries the decision card, four short lines and the question. */
|
|
10
|
+
export const MAX_MULTIPLE_CHOICE_QUESTION_WORDS = 60
|
|
11
|
+
/** The words of an option's `preview`: the four card lines, each a sentence. */
|
|
12
|
+
export const MAX_PREVIEW_WORDS = 70
|
|
9
13
|
export const MAX_LABEL_WORDS = 5
|
|
10
14
|
/** The words of an option's description: one sentence of about 12, with room for a clause. */
|
|
11
15
|
export const MAX_DESCRIPTION_WORDS = 15
|
|
@@ -20,29 +24,39 @@ const LINK = /https?:\/\/\S+/g
|
|
|
20
24
|
|
|
21
25
|
const wordsIn = (text: string): number => text.split(/\s+/).filter(Boolean).length
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
/** A question as a send-back names it: its first `QUESTION_LENGTH` characters. */
|
|
28
|
+
export const shorten = (text: string): string =>
|
|
24
29
|
text.length > QUESTION_LENGTH ? `${text.slice(0, QUESTION_LENGTH - 1)}…` : text
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
/** The labels of a question's options; an option without a text label is skipped. */
|
|
32
|
+
export const labelsOf = (options: unknown): string[] =>
|
|
27
33
|
recordsOf(options).flatMap((option) => (typeof option.label === 'string' ? [option.label] : []))
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
/** The value when it is a string, else `''`. */
|
|
36
|
+
export const textOf = (value: unknown): string => (typeof value === 'string' ? value : '')
|
|
30
37
|
|
|
31
38
|
const headerReason = (header: string, name: string): string | null =>
|
|
32
39
|
header.trimStart().startsWith(PAW)
|
|
33
40
|
? null
|
|
34
41
|
: `${name}: start the header chip with ${PAW}, as in "${PAW} ${header.trim() || EXAMPLE_HEADER}" (${MAX_HEADER_LENGTH} characters at most).`
|
|
35
42
|
|
|
36
|
-
const questionLengthReason = (text: string, name: string): string | null =>
|
|
37
|
-
wordsIn(text) <=
|
|
43
|
+
const questionLengthReason = (text: string, name: string, limit: number): string | null =>
|
|
44
|
+
wordsIn(text) <= limit
|
|
38
45
|
? null
|
|
39
|
-
: `${name}: ask it in ${
|
|
46
|
+
: `${name}: ask it in ${limit} words at most; the context goes in the decision card, in the "preview" of the options (§ 4, Short questions).`
|
|
40
47
|
|
|
41
48
|
const pathReason = (text: string, name: string): string | null =>
|
|
42
49
|
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
|
|
50
|
+
? `${name}: no path, flag or code in the question, say it in everyday words and put the detail in the decision card, in the "preview" of the options.`
|
|
44
51
|
: null
|
|
45
52
|
|
|
53
|
+
const previewLengthReason = (options: unknown, name: string): string | null => {
|
|
54
|
+
const long = recordsOf(options).find((option) => wordsIn(textOf(option.preview)) > MAX_PREVIEW_WORDS)
|
|
55
|
+
return long === undefined
|
|
56
|
+
? null
|
|
57
|
+
: `${name}: option "${textOf(long.label)}": its "preview" has ${MAX_PREVIEW_WORDS} words at most; one sentence per card line.`
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
const descriptionReason = (options: unknown, name: string): string | null => {
|
|
47
61
|
const long = recordsOf(options).find((option) => wordsIn(textOf(option.description)) > MAX_DESCRIPTION_WORDS)
|
|
48
62
|
return long === undefined
|
|
@@ -66,13 +80,15 @@ const questionReasons = (question: Record<string, unknown>, guardedLabels: reado
|
|
|
66
80
|
const name = `Question "${shorten(text)}"`
|
|
67
81
|
const labels = labelsOf(question.options)
|
|
68
82
|
const header = headerReason(textOf(question.header), name)
|
|
83
|
+
const multiple = question.multiSelect === true
|
|
69
84
|
const lengths = labels.some((label) => guardedLabels.includes(label))
|
|
70
85
|
? []
|
|
71
86
|
: [
|
|
72
|
-
questionLengthReason(text, name),
|
|
73
|
-
pathReason(text, name),
|
|
87
|
+
questionLengthReason(text, name, multiple ? MAX_MULTIPLE_CHOICE_QUESTION_WORDS : MAX_QUESTION_WORDS),
|
|
88
|
+
multiple ? null : pathReason(text, name),
|
|
74
89
|
labelLengthReason(labels, name),
|
|
75
90
|
descriptionReason(question.options, name),
|
|
91
|
+
previewLengthReason(question.options, name),
|
|
76
92
|
]
|
|
77
93
|
return [header, ...lengths].filter((reason): reason is string => reason !== null)
|
|
78
94
|
}
|
|
@@ -7,19 +7,22 @@
|
|
|
7
7
|
// question that breaks the form of `form.ts` (🐾 header, short question and labels); argv[3…] are the labels a
|
|
8
8
|
// guard reads word for word, which carry no mark. A PR question that offers the self-merge label (argv[5]) for a branch
|
|
9
9
|
// whose staged change carries a guard file is denied too, since the merge guard never lets that approval cover it
|
|
10
|
-
// (`self-merge-files.ts`; the positions are named in `arguments.ts`).
|
|
11
|
-
|
|
10
|
+
// (`self-merge-files.ts`; the positions are named in `arguments.ts`). Any dialog but an approval is denied when it
|
|
11
|
+
// lacks the decision card (`card.ts`, field names argv[7…10]) in its own question and option previews: at `PreToolUse`
|
|
12
|
+
// the text before the dialog is not in the transcript yet. Anything else, and any failure, answers nothing, so the
|
|
13
|
+
// dialog opens.
|
|
14
|
+
import { statSync } from 'node:fs'
|
|
12
15
|
import { join } from 'node:path'
|
|
13
16
|
import { boardLocationOf, boardsIn } from '../shared/boards'
|
|
14
17
|
import { mainCheckoutOf } from '../shared/checkout'
|
|
15
18
|
import { readConfig } from '../shared/config'
|
|
19
|
+
import { isCoordinatorCall } from '../shared/coordinator-call'
|
|
16
20
|
import { readTextOrNull } from '../shared/files'
|
|
17
21
|
import { isRecord } from '../shared/json'
|
|
18
|
-
import { isCoordinatorAgent } from '../shared/lane'
|
|
19
|
-
import { ownPaneAgent } from '../shared/own-pane'
|
|
20
22
|
import { planCodesOf } from '../shared/plan-codes'
|
|
21
23
|
import { ensurePrivateDirectory, writePrivateFile } from '../shared/private-directory'
|
|
22
|
-
import { ARGUMENT, FIRST_GUARDED_LABEL } from './arguments'
|
|
24
|
+
import { AFTER_GUARDED_LABELS, ARGUMENT, FIRST_GUARDED_LABEL } from './arguments'
|
|
25
|
+
import { type CardFields, cardReasonOf } from './card'
|
|
23
26
|
import { type CodeCache, lookUpCodes, type PlanFileStat, parseCodeCache, serializeCodeCache } from './code-cache'
|
|
24
27
|
import { formReasonOf } from './form'
|
|
25
28
|
import { type RecommendationWords, recommendationReasonOf } from './recommendation'
|
|
@@ -41,10 +44,18 @@ const statOf = (path: string): PlanFileStat | null => {
|
|
|
41
44
|
return stat?.isFile() ? { path, mtimeMs: stat.mtimeMs, size: stat.size } : null
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
/** argv[2] is the recommended mark, argv[3…] the labels a guard reads word for word; an older install passes none. */
|
|
47
|
+
/** argv[2] is the recommended mark, argv[3…6] the labels a guard reads word for word; an older install passes none. */
|
|
45
48
|
const recommendationWordsOf = (argv: readonly string[]): RecommendationWords => ({
|
|
46
49
|
mark: argv[ARGUMENT.recommended] ?? '',
|
|
47
|
-
guardedLabels: argv.slice(FIRST_GUARDED_LABEL),
|
|
50
|
+
guardedLabels: argv.slice(FIRST_GUARDED_LABEL, AFTER_GUARDED_LABELS),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** argv[7…10] are the card's field names; an older install passes none, and an empty name checks no card. */
|
|
54
|
+
const cardFieldsOf = (argv: readonly string[]): CardFields => ({
|
|
55
|
+
topic: argv[ARGUMENT.cardTopic] ?? '',
|
|
56
|
+
consequences: argv[ARGUMENT.cardConsequences] ?? '',
|
|
57
|
+
ifUndecided: argv[ARGUMENT.cardIfUndecided] ?? '',
|
|
58
|
+
readMore: argv[ARGUMENT.cardReadMore] ?? '',
|
|
48
59
|
})
|
|
49
60
|
|
|
50
61
|
/** Keeps the cache for the next dialog; a cache that cannot be written only costs that dialog a full read. */
|
|
@@ -79,24 +90,7 @@ const planCodesFor = (cwd: string): Set<string> | null => {
|
|
|
79
90
|
}
|
|
80
91
|
}
|
|
81
92
|
|
|
82
|
-
/**
|
|
83
|
-
const isCoordinatorCall = (cwd: string, session: unknown): boolean => {
|
|
84
|
-
try {
|
|
85
|
-
const main = mainCheckoutOf(cwd)
|
|
86
|
-
const location = boardLocationOf(readConfig([main]))
|
|
87
|
-
if (location === null) return false
|
|
88
|
-
const { worktreeDirectory } = location
|
|
89
|
-
return isCoordinatorAgent({
|
|
90
|
-
agent: ownPaneAgent(),
|
|
91
|
-
session,
|
|
92
|
-
main,
|
|
93
|
-
isLane: (name) => existsSync(join(main, worktreeDirectory, name)),
|
|
94
|
-
})
|
|
95
|
-
} catch {
|
|
96
|
-
return false
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
93
|
+
/** The form (paw, short question and labels) binds the coordinator's dialog only. */
|
|
100
94
|
const formReasonFor = (cwd: string, session: unknown, toolInput: unknown, guardedLabels: readonly string[]): string | null =>
|
|
101
95
|
isCoordinatorCall(cwd, session) ? formReasonOf(toolInput, guardedLabels) : null
|
|
102
96
|
|
|
@@ -112,6 +106,7 @@ const decide = async (): Promise<string | null> => {
|
|
|
112
106
|
denyReasonOf(texts, planCodesFor(cwd)),
|
|
113
107
|
recommendationReasonOf(input.tool_input, words),
|
|
114
108
|
formReasonFor(cwd, input.session_id, input.tool_input, words.guardedLabels),
|
|
109
|
+
cardReasonOf(input.tool_input, { fields: cardFieldsOf(process.argv), guardedLabels: words.guardedLabels }),
|
|
115
110
|
selfMergeGuardFileReason(input.tool_input, selfMergeLabelOf(process.argv), (branch) =>
|
|
116
111
|
stagedFilesOfBranch(cwd, branch),
|
|
117
112
|
),
|