jackherd 1.18.2 → 1.19.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +30 -25
  4. package/assets/icons/model.svg +1 -0
  5. package/dist/{chunk-N5FAOQZL.js → chunk-JDO6O3UO.js} +52 -2
  6. package/dist/config.d.ts +15 -2
  7. package/dist/config.js +22 -3
  8. package/dist/plan-format.d.ts +84 -1
  9. package/dist/plan-format.js +85 -1
  10. package/docs/plan-contract.md +8 -2
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/hooks/approval-gate/decision.ts +10 -11
  14. package/kit/hooks/hooks/approval-gate/run.ts +23 -3
  15. package/kit/hooks/hooks/browser-guard/decision.ts +7 -0
  16. package/kit/hooks/hooks/browser-guard/index.ts +50 -0
  17. package/kit/hooks/hooks/plain-question/arguments.ts +14 -1
  18. package/kit/hooks/hooks/plain-question/card.ts +102 -0
  19. package/kit/hooks/hooks/plain-question/form.ts +25 -9
  20. package/kit/hooks/hooks/plain-question/index.ts +20 -25
  21. package/kit/hooks/hooks/plain-question/recommendation.ts +6 -6
  22. package/kit/hooks/hooks/return-brief/decision.ts +29 -0
  23. package/kit/hooks/hooks/return-brief/index.ts +37 -0
  24. package/kit/hooks/hooks/return-brief/settings.ts +11 -0
  25. package/kit/hooks/hooks/shared/coordinator-call.ts +25 -0
  26. package/kit/hooks/hooks/shared/transcript.ts +9 -0
  27. package/kit/hooks/hooks.json +18 -1
  28. package/kit/review/agents/security-audit.md +1 -1
  29. package/kit/workflow/agents/browser-check.md +29 -0
  30. package/kit/workflow/skills/implement/SKILL.md +5 -3
  31. package/kit/workflow/skills/orchestrate/SKILL.md +36 -14
  32. package/kit/workflow/skills/orchestrate/references/acceptance.md +10 -9
  33. package/kit/workflow/skills/orchestrate/references/coordinator-clear.md +45 -0
  34. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +15 -12
  35. package/kit/workflow/skills/plan/SKILL.md +2 -0
  36. package/kit/workflow/skills/plan/references/template-English.md +8 -0
  37. package/kit/workflow/skills/plan/references/template-German.md +8 -0
  38. package/package.json +1 -1
  39. package/schemas/jackherd.schema.json +16 -0
  40. package/src/brief/command.ts +45 -0
  41. package/src/brief/lane-brief.ts +51 -0
  42. package/src/brief/lane-facts.ts +64 -0
  43. package/src/check/skill-budget-defaults.ts +13 -2
  44. package/src/cli.ts +1 -0
  45. package/src/config/schema.ts +22 -0
  46. package/src/lane-prompt/texts.ts +2 -2
  47. package/src/plan-contract/command.ts +1 -0
  48. package/src/plan-contract/contract.ts +28 -5
  49. package/src/plan-format/index.ts +1 -0
  50. package/src/plan-format/plan-head.ts +131 -0
  51. package/src/plans/active-tasks.ts +8 -1
  52. package/src/plans/click-hint.ts +2 -1
  53. package/src/plans/focus-overlay.ts +5 -2
  54. package/src/plans/folded-detail.ts +47 -0
  55. package/src/plans/lane-line.ts +86 -0
  56. package/src/plans/lane-reason.ts +24 -4
  57. package/src/plans/layout-phase.ts +4 -20
  58. package/src/plans/layout-summary.ts +56 -45
  59. package/src/plans/layout.ts +178 -44
  60. package/src/plans/port-listening.ts +25 -0
  61. package/src/plans/selection.ts +11 -0
  62. package/src/plans/snapshot.ts +27 -9
  63. package/src/plans/task-checklist.ts +57 -0
  64. package/src/plans/texts.ts +34 -9
  65. package/src/resume/command.ts +40 -6
  66. package/src/resume/coordinator-read.ts +136 -0
  67. package/src/resume/state.ts +163 -0
  68. package/src/resume/texts.ts +6 -0
  69. package/src/sync/render.ts +5 -1
  70. package/src/vocabulary/index.ts +74 -0
  71. package/src/watch/clear-moment.ts +228 -0
  72. package/src/watch/command.ts +92 -8
  73. package/src/watch/lane-report.ts +5 -1
  74. package/src/watch/reference-paths.ts +8 -4
  75. package/src/watch/watch-events.ts +13 -0
@@ -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-N5FAOQZL.js";
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,
@@ -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 four parts
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 is a note, not a gap) |
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 |
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.18.2"
3
+ version = "1.19.0"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the herd, the state of a board's lanes, in a herdr pane"
6
6
  platforms = ["linux", "macos"]
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.18.2}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.19.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 open request of this call an answer names by id and hash, if the answer is one of the offered labels and was
24
- * given after it. The hash in the question is the one the coordinator showed; a request rewritten meanwhile has another.
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
- open: ReadonlyMap<string, ApprovalRequest>,
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 : open.get(named.id)
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 that no answer was spent on yet. An earlier request of the same call counts, so a retry
43
- * after the approval passes at once. Anything else leaves the call pending.
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 open = new Map(
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, open, labels)
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 was claimed elsewhere, or the wait is over.
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' && dependencies.claimRequest(main, decision.id, now))
125
- return decision.kind === 'allow' ? { kind: 'allow' } : { kind: 'denied' }
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 = { recommended: 2, approvalAllow: 3, approvalDeny: 4, selfMergeLabel: 5, standingApprove: 6 } as const
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
- const shorten = (text: string): string =>
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
- const labelsOf = (options: unknown): string[] =>
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
- const textOf = (value: unknown): string => (typeof value === 'string' ? value : '')
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) <= MAX_QUESTION_WORDS
43
+ const questionLengthReason = (text: string, name: string, limit: number): string | null =>
44
+ wordsIn(text) <= limit
38
45
  ? null
39
- : `${name}: ask it in ${MAX_QUESTION_WORDS} words at most; the context goes in the chat before the dialog (§ 4, Short questions).`
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 chat before the dialog.`
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`). Anything else, and any failure, answers nothing, so the dialog opens.
11
- import { existsSync, statSync } from 'node:fs'
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
- /** Whether the dialog is the coordinator's: the form (paw, short question and labels) binds nobody else. */
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
  ),