@codyswann/lisa 4.38.0 → 4.38.2

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 (83) hide show
  1. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +140 -4
  2. package/all/copy-overwrite/scripts/lisa-work-item.mjs +209 -8
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +10 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +13 -6
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +9 -0
  11. package/package.json +4 -4
  12. package/plugins/lisa/.claude-plugin/plugin.json +19 -5
  13. package/plugins/lisa/.codex-plugin/hooks.json +18 -4
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/skills/lisa-git-submit-pr/SKILL.md +8 -3
  16. package/plugins/lisa/hooks/block-direct-issue-create.agy.sh +13 -3
  17. package/plugins/lisa/hooks/block-direct-issue-create.sh +140 -4
  18. package/plugins/lisa/hooks/discharge-work-item-gates.sh +54 -0
  19. package/plugins/lisa/skills/lisa-git-submit-pr/SKILL.md +8 -3
  20. package/plugins/lisa-agy/hooks/block-direct-issue-create.agy.sh +13 -3
  21. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +140 -4
  22. package/plugins/lisa-agy/plugin.json +1 -1
  23. package/plugins/lisa-agy/skills/lisa-git-submit-pr/SKILL.md +8 -3
  24. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  27. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-copilot/.claude-plugin/plugin.json +19 -5
  30. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +140 -4
  31. package/plugins/lisa-copilot/hooks/discharge-work-item-gates.sh +54 -0
  32. package/plugins/lisa-copilot/skills/lisa-git-submit-pr/SKILL.md +8 -3
  33. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +140 -4
  35. package/plugins/lisa-cursor/hooks/discharge-work-item-gates.sh +54 -0
  36. package/plugins/lisa-cursor/hooks/hooks.json +7 -4
  37. package/plugins/lisa-cursor/skills/lisa-git-submit-pr/SKILL.md +8 -3
  38. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  40. package/plugins/lisa-expo-agy/plugin.json +1 -1
  41. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  45. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  46. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  51. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  55. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  56. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  60. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  61. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  65. package/plugins/lisa-rails-agy/plugin.json +1 -1
  66. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  70. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  71. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  75. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  76. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  78. package/plugins/src/base/.claude-plugin/plugin.json +18 -4
  79. package/plugins/src/base/hooks/block-direct-issue-create.agy.sh +13 -3
  80. package/plugins/src/base/hooks/block-direct-issue-create.sh +140 -4
  81. package/plugins/src/base/hooks/discharge-work-item-gates.sh +54 -0
  82. package/plugins/src/base/skills/lisa-git-submit-pr/SKILL.md +8 -3
  83. package/scripts/lib/per-agent-hook-filter.mjs +19 -0
@@ -14,6 +14,15 @@
14
14
  * `output.args.command`. Throwing in `tool.execute.before` cancels the tool
15
15
  * call and surfaces the message to the agent.
16
16
  *
17
+ * SCOPE GAP, recorded rather than silently dropped (AGENTS.md). Since
18
+ * CodySwannGT/lisa#3753 the canonical guard covers a SECOND substrate —
19
+ * structured tool payloads, where a creation arrives as named fields rather
20
+ * than a command line — because the filing contract was enforced on the shell
21
+ * and unenforced on every tool call beside it. This port returns early for any
22
+ * tool other than `bash`, so on OpenCode that substrate is still uncovered.
23
+ * Closing it needs OpenCode's own creation-tool envelope, which is separate
24
+ * work from porting the shell path.
25
+ *
17
26
  * The port matches on the raw command text rather than tokenising it, which
18
27
  * makes it naturally immune to the prefix and tokenisation bypass classes the
19
28
  * shell guard had to be restructured to close — an unrecognised wrapper is just
package/package.json CHANGED
@@ -169,7 +169,7 @@
169
169
  "zod-validation-error": "^4.0.0"
170
170
  },
171
171
  "name": "@codyswann/lisa",
172
- "version": "4.38.0",
172
+ "version": "4.38.2",
173
173
  "description": "Claude Code governance framework that applies guardrails, guidance, and automated enforcement to projects",
174
174
  "main": "dist/index.js",
175
175
  "exports": {
@@ -317,7 +317,7 @@
317
317
  "test": "tests"
318
318
  },
319
319
  "types": "./dist/index.d.ts",
320
- "lisaReleaseCommit": "99484f8dc349184afd44f96a890524223c3ea97c",
321
- "gitHead": "99484f8dc349184afd44f96a890524223c3ea97c",
322
- "lisaReleaseTag": "v4.38.0"
320
+ "lisaReleaseCommit": "62b7f7f6571fa343cf1c31c9e460461f597ee9ac",
321
+ "gitHead": "62b7f7f6571fa343cf1c31c9e460461f597ee9ac",
322
+ "lisaReleaseTag": "v4.38.2"
323
323
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.38.0",
3
+ "version": "4.38.2",
4
4
  "description": "Universal governance — agents, skills, commands, hooks, and rules for all projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -72,6 +72,15 @@
72
72
  }
73
73
  ]
74
74
  },
75
+ {
76
+ "matcher": "Bash",
77
+ "hooks": [
78
+ {
79
+ "type": "command",
80
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/discharge-work-item-gates.sh"
81
+ }
82
+ ]
83
+ },
75
84
  {
76
85
  "matcher": "Edit|Write|NotebookEdit|Bash",
77
86
  "hooks": [
@@ -129,10 +138,6 @@
129
138
  "type": "command",
130
139
  "command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-instruction-file-edits.sh"
131
140
  },
132
- {
133
- "type": "command",
134
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-direct-issue-create.sh"
135
- },
136
141
  {
137
142
  "type": "command",
138
143
  "command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-blind-automerge.sh"
@@ -169,6 +174,15 @@
169
174
  "command": "${CLAUDE_PLUGIN_ROOT}/hooks/enforce-verification-gate.sh"
170
175
  }
171
176
  ]
177
+ },
178
+ {
179
+ "matcher": "",
180
+ "hooks": [
181
+ {
182
+ "type": "command",
183
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/block-direct-issue-create.sh"
184
+ }
185
+ ]
172
186
  }
173
187
  ],
174
188
  "Stop": [
@@ -30,6 +30,15 @@
30
30
  }
31
31
  ]
32
32
  },
33
+ {
34
+ "matcher": "Bash",
35
+ "hooks": [
36
+ {
37
+ "type": "command",
38
+ "command": "${PLUGIN_ROOT}/hooks/discharge-work-item-gates.sh"
39
+ }
40
+ ]
41
+ },
33
42
  {
34
43
  "matcher": "Edit|Write|NotebookEdit|Bash",
35
44
  "hooks": [
@@ -69,10 +78,6 @@
69
78
  "type": "command",
70
79
  "command": "${PLUGIN_ROOT}/hooks/block-instruction-file-edits.sh"
71
80
  },
72
- {
73
- "type": "command",
74
- "command": "${PLUGIN_ROOT}/hooks/block-direct-issue-create.sh"
75
- },
76
81
  {
77
82
  "type": "command",
78
83
  "command": "${PLUGIN_ROOT}/hooks/block-blind-automerge.sh"
@@ -100,6 +105,15 @@
100
105
  "command": "${PLUGIN_ROOT}/hooks/enforce-verification-gate.sh"
101
106
  }
102
107
  ]
108
+ },
109
+ {
110
+ "matcher": "",
111
+ "hooks": [
112
+ {
113
+ "type": "command",
114
+ "command": "${PLUGIN_ROOT}/hooks/block-direct-issue-create.sh"
115
+ }
116
+ ]
103
117
  }
104
118
  ],
105
119
  "Stop": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa",
3
- "version": "4.38.0",
3
+ "version": "4.38.2",
4
4
  "description": "Universal governance: agents, skills, commands, hooks, and rules for all projects.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -57,6 +57,7 @@ Add provider-appropriate linkage to the PR title and/or body without changing th
57
57
  - **GitHub Issues**:
58
58
  - If `work_item_ref` is a GitHub issue URL, `org/repo#<n>`, or `#<n>`, add a dedicated issue reference line to the PR body.
59
59
  - Always use a non-closing reference such as `Refs #<n>`, so the merge cannot close the issue before the post-merge deploy, remote verification, health check, and terminal `done` label.
60
+ - **This rule is what makes the managed backlink mandatory, and neither rule says so on its own.** `Refs` creates no native development link, so gate 5 has nothing to defer to and the `[lisa-pr-link]` comment becomes the only backlink there is. Anyone following the non-closing rule alone will hit gate 5. Separately, `Refs #<n>` does **not** satisfy gate 4 either: the body needs its own `Work-Item: <ref>` line, and `Closes`/`Fixes` do not satisfy it. One `Refs` line, one `Work-Item:` line, one backlink comment — three different requirements that look like one. `discharge-pr-gates` (below) is what checks the last two together.
60
61
  - **A non-closing reference does not populate the issue's Development / linked pull requests surface, and no non-closing form does.** That surface *is* the closing-reference mechanism: a closing keyword populates the PR's `closingIssuesReferences`, while `Refs` yields only a `CrossReferencedEvent`. Measured on this repository — a `Refs`-only PR reports `closingIssuesReferences: 0`; a `Closes` PR reports 1. So the ticket-side backlink cannot be delegated to GitHub: the managed `[lisa-pr-link]` comment written by `node scripts/lisa-work-item.mjs backlink` — the one producer, which `lisa-github-sync` and the other vendor sync skills call rather than reimplement — is the **required** backlink under this rule, not a fallback for when native linkage happens to be absent. Two-way linkage (`lisa-implement` step 7a) depends on that comment, and so does the Work-Item Traceability check wherever the project declares `workItem.verify: "full"` — there, a PR carrying a correct `Refs` line still fails the check without it. Post it either way: under the default `trailer` level the check does not read the tracker, but the two-way linkage a human follows is still worth having, and a project can raise its level at any time.
61
62
  - For cross-repo issue refs, use the fully qualified non-closing form, for example `Refs CodySwannGT/lisa#614`.
62
63
  - **Linear**:
@@ -76,13 +77,17 @@ When updating an existing PR, preserve any existing linkage line unless the new
76
77
  After creating or updating the PR, always make the reverse link durable on the source work item when `work_item_ref` is available:
77
78
 
78
79
  1. Resolve the live PR URL with `gh pr view <pr-number> --json url --jq .url`.
79
- 2. Run the backlink command. It is the executable form of this requirement — it writes the managed `[lisa-pr-link]` comment on the work item, or updates the one already there, for every tracker Lisa supports:
80
+ 2. Discharge the two gates the push could not check. Gate 4 (the `Work-Item:` line in the PR BODY) and gate 5 (the backlink) are both properties of a pull request, so a push before the PR existed reported them as `UNRESOLVED` rather than checked. One command closes both out at the first moment they *are* checkable:
80
81
 
81
82
  ```bash
82
- node scripts/lisa-work-item.mjs backlink --ref <work_item_ref> --pr-url <url>
83
+ node scripts/lisa-work-item.mjs discharge-pr-gates
83
84
  ```
84
85
 
85
- It is idempotent, so run it on every push rather than deciding whether it is needed. It refuses loudly for a tracker it cannot write, and never silently no-ops. Do not hand-post the comment, and do not describe the posting procedure anywhere: the same file that writes it is the file that checks it, which is what keeps producer and consumer from drifting.
86
+ It posts (or refreshes) the managed `[lisa-pr-link]` comment on every item the range names — so gate 5 passes without anyone remembering to — and then evaluates gate 4 against the live body, failing here rather than one CI cycle later. Exit 3 means there is no pull request for this branch yet, which is not a violation; exit 1 is a real unmet requirement and the message names it.
87
+
88
+ It writes no PR body. Gate 4 is the author's declaration of what the change is for; a command that inserted the line would be answering its own question.
89
+
90
+ The narrower `node scripts/lisa-work-item.mjs backlink --ref <work_item_ref> --pr-url <url>` is what it wraps, and remains the right call when you hold a ref and a URL but no checkout of the PR's branch (a sync skill updating a backlink after the fact). Both are idempotent, refuse loudly for a tracker they cannot write, and never silently no-op. Do not hand-post the comment, and do not describe the posting procedure anywhere: the same file that writes it is the file that checks it, which is what keeps producer and consumer from drifting.
86
91
  3. Invoke `lisa-tracker-sync` with the original work item ref, milestone `pr-ready`, `pr_url=<url>`, and `tracker_provider=<provider>` when known. That is the progress-note and status side; it is not what satisfies the traceability check.
87
92
  4. When the PR later merges, invoke `lisa-tracker-sync` again with milestone `pr-merged`, the same `pr_url`, and the merge SHA when available.
88
93
 
@@ -8,9 +8,19 @@
8
8
  # block-direct-issue-create.sh beside it (same delegation pattern as
9
9
  # block-instruction-file-edits.agy.sh).
10
10
  #
11
- # No scope gap here, unlike the instruction-file adapter: the canonical guard is
12
- # Bash-only by construction, and `run_command` is exactly the surface it needs.
13
- # agy therefore receives the whole guard, not an arm of it.
11
+ # SCOPE GAP, recorded rather than silently dropped (AGENTS.md). This used to
12
+ # read "no scope gap here: the canonical guard is Bash-only by construction, and
13
+ # `run_command` is exactly the surface it needs." That stopped being true in
14
+ # CodySwannGT/lisa#3753: the canonical guard now covers a SECOND substrate —
15
+ # structured tool payloads, where a creation arrives as named fields rather than
16
+ # as a command line — because the filing contract was enforced on the shell and
17
+ # unenforced on every tool call beside it.
18
+ #
19
+ # This adapter forwards `run_command` and nothing else, so on agy that second
20
+ # substrate is still uncovered. Closing it needs agy's own tool-call envelope
21
+ # (which tool names its creation surface, and where the arguments land), and
22
+ # that is a different piece of work from translating one protocol. The shell
23
+ # surface agy does expose receives the whole guard, exactly as before.
14
24
  #
15
25
  # Fail-open on missing runtimes: a missed refusal costs one under-declared
16
26
  # ticket, and `lisa-repair-intake` already sweeps for exactly that. Failing
@@ -79,6 +79,36 @@
79
79
  # settings env block, CI config). An escape the governed agent reaches by
80
80
  # typing one more token in front of the command it was just refused is not an
81
81
  # escape hatch — it is the prose problem with extra steps.
82
+ #
83
+ # ## What this hook costs, and the number that would change the decision
84
+ #
85
+ # Registered `matcher: ""` since CodySwannGT/lisa#3753 — it runs on EVERY tool
86
+ # call, not only Bash. Measured on a developer machine at load ~8-26, 50
87
+ # invocations per row:
88
+ #
89
+ # 16.7 ms enforce-team-first.sh already `matcher: ""` today
90
+ # 17.5 ms enforce-verification-gate.sh already `matcher: ""` today
91
+ # 11.5 ms this guard, non-Bash payload what the broad matcher adds
92
+ # 112.3 ms this guard, Bash payload what Bash calls already paid
93
+ #
94
+ # So the broad registration was not novel — two hooks were already there, this
95
+ # one is cheaper than either, and it raises an existing ~34 ms per-call baseline
96
+ # by about a third.
97
+ #
98
+ # THIS IS A THRESHOLD, NOT A PRECEDENT. The argument is about the TOTAL cost of
99
+ # the broad set, so it does not transfer to the next guard: four more hooks
100
+ # moved to `matcher: ""` would put ~90 ms on every tool call and the answer
101
+ # flips. **Before widening any other hook's matcher, re-run the measurement and
102
+ # reconsider if the broad set totals more than ~100 ms per tool call, or if any
103
+ # single broad hook exceeds ~50 ms.** Do not cite these numbers for a fifth
104
+ # hook; measure again, because the number that matters is the sum.
105
+ #
106
+ # The cost is also irreducible rather than sloppy. A 9-line script containing
107
+ # only this file's preamble measured 11.7 ms, and padding it to this file's
108
+ # length changed nothing (11.5 ms) — it is process spawn plus one `jq`, not
109
+ # parsing. Moving the early exit further up the file buys nothing; the exit is
110
+ # already ahead of config resolution, which is what keeps the figure at 11.5
111
+ # rather than at the 112 ms the Bash path pays.
82
112
  set -euo pipefail
83
113
 
84
114
  input="$(cat)"
@@ -99,13 +129,72 @@ for required in jq python3; do
99
129
  done
100
130
 
101
131
  tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
132
+
133
+ # ── The two substrates ────────────────────────────────────────────────────
134
+ #
135
+ # A creation reaches a tracker two ways, and until CodySwannGT/lisa#3753 this
136
+ # guard saw only one of them. It was registered `matcher: "Bash"` AND gated
137
+ # here on `tool_name != "Bash"`, so an MCP tool call was refused entry twice
138
+ # over. MEASURED before the change, driving the guard with synthetic payloads:
139
+ #
140
+ # ALLOW mcp__linear-server__create_issue, undeclared <- the defect
141
+ # ALLOW mcp__github__create_issue, undeclared <- the defect
142
+ # REFUSE the same creation as `gh issue create` shell text <- control
143
+ # ALLOW an ordinary Read tool call <- control
144
+ #
145
+ # The two controls are what make those ALLOWs mean anything: the refusal proves
146
+ # the guard is live, the Read proves it is not simply refusing everything.
147
+ #
148
+ # **Widening the matcher alone would have changed nothing** — the call would
149
+ # arrive and this gate would still exit 0. Anyone who "fixed" #3753 by editing
150
+ # `plugin.json` would have seen no test fail and shipped a guard that still
151
+ # allows every MCP filing. The registration and this gate had to move together.
152
+ #
153
+ # The SHELL path below is untouched. A structured payload has no command line
154
+ # to tokenise, so it gets its own small classifier rather than being forced
155
+ # through a parser built for shell text.
156
+ structured_call=""
102
157
  if [ "$tool_name" != "Bash" ]; then
103
- exit 0
158
+ # The cheap shape gate, deliberately BEFORE config resolution.
159
+ #
160
+ # This hook now runs on EVERY tool call, so the cost of the path that does
161
+ # nothing is the cost of the whole change. Config resolution spawns two jq
162
+ # processes per lookup and is what makes the Bash path ~112 ms; putting it
163
+ # ahead of this gate would charge that to every Read and Grep. So the
164
+ # not-a-creation exit happens here, on a shell `case` with no subprocess.
165
+ #
166
+ # Matched on SHAPE, not on an enumerated list of server tool names. Server
167
+ # names are supplied by whoever wrote the server and change when one is
168
+ # added or renamed, so a list would pass every row anyone thought of and
169
+ # miss the first one nobody did — the failure mode this repository's guard
170
+ # suites are written to defeat.
171
+ #
172
+ # RESIDUAL, stated rather than hidden: a server whose creation tool is named
173
+ # without a create-verb or without a tracker noun (`mcp__x__file_work`) is
174
+ # not recognised and is allowed. That is a fail-open, and it is the honest
175
+ # cost of refusing to enumerate. Add shapes here as they are measured.
176
+ case "$tool_name" in
177
+ Bash | Read | Write | Edit | MultiEdit | Glob | Grep | Task | TodoWrite)
178
+ exit 0
179
+ ;;
180
+ esac
181
+ case "$tool_name" in
182
+ *[Cc]reate* | *[Nn]ew* | *[Aa]dd* | *[Ff]ile*) ;;
183
+ *) exit 0 ;;
184
+ esac
185
+ case "$tool_name" in
186
+ *[Ii]ssue* | *[Tt]icket* | *[Tt]ask* | *[Ss]tory* | *[Bb]ug* | *[Ee]pic* | *[Ww]ork*) ;;
187
+ *) exit 0 ;;
188
+ esac
189
+ structured_call="1"
104
190
  fi
105
191
 
106
- command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
107
- if [ -z "$command_str" ]; then
108
- exit 0
192
+ command_str=""
193
+ if [ -z "$structured_call" ]; then
194
+ command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
195
+ if [ -z "$command_str" ]; then
196
+ exit 0
197
+ fi
109
198
  fi
110
199
 
111
200
  project_dir="${CLAUDE_PROJECT_DIR:-}"
@@ -1859,6 +1948,53 @@ print("ALLOW")
1859
1948
  PY
1860
1949
 
1861
1950
  set +e
1951
+ # ── The structured substrate ──────────────────────────────────────────────
1952
+ #
1953
+ # An MCP call carries named fields, not a command line, so `scan()` — which
1954
+ # tokenises shell text — has nothing to parse. The declaration is read from the
1955
+ # payload instead, and the recogniser is deliberately small: this path decides
1956
+ # ONE question (is a build-ready role or a human-gate marker present anywhere
1957
+ # in the submitted fields), where the shell path has to answer "which of these
1958
+ # tokens is a client, an endpoint, a flag value, or a file it executes".
1959
+ #
1960
+ # Every string in the payload is searched, at any depth, because a role lands
1961
+ # in a different field on every tracker — `labels[]` on GitHub, `stateId` or a
1962
+ # workflow state name on Linear, a transition `id` on JIRA — and enumerating
1963
+ # field names per vendor is the same brittleness as enumerating tool names.
1964
+ # Over-collecting is safe here: the values are compared against ONE configured
1965
+ # role string, so an unrelated field cannot accidentally satisfy it.
1966
+ if [ -n "$structured_call" ]; then
1967
+ # The operator's ambient escape works on both substrates. There is no inline
1968
+ # form to disqualify it here — a structured call has no shell in which to
1969
+ # assign one — so the override is simply honoured.
1970
+ if [ -n "$ambient_override" ]; then
1971
+ exit 0
1972
+ fi
1973
+
1974
+ structured_declaration="$(
1975
+ printf '%s' "$input" |
1976
+ jq -r --arg role "$ready_role" '
1977
+ [(.tool_input // {}) | .. | strings] as $values
1978
+ | if ($values | index($role)) then "role"
1979
+ elif ($values | map(select(contains("[lisa-human-gate]"))) | length) > 0 then "gate"
1980
+ else "" end
1981
+ ' 2>/dev/null || printf 'UNREADABLE'
1982
+ )"
1983
+
1984
+ # Fail closed on a creation-shaped call whose payload cannot be read. Silence
1985
+ # about a filing the guard could not inspect is the defect this whole ticket
1986
+ # is about, one substrate over.
1987
+ if [ "$structured_declaration" = "UNREADABLE" ]; then
1988
+ refuse "an unreadable $tool_name payload that reads as a tracker creation" \
1989
+ "$ready_role" ""
1990
+ fi
1991
+
1992
+ if [ -z "$structured_declaration" ]; then
1993
+ refuse "a tracker creation through $tool_name" "$ready_role" ""
1994
+ fi
1995
+ exit 0
1996
+ fi
1997
+
1862
1998
  verdict="$(
1863
1999
  printf '%s' "$classifier" |
1864
2000
  LISA_GUARD_COMMAND="$command_str" \
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bash
2
+ # This file is managed by Lisa.
3
+ # Do not edit directly — changes will be overwritten on the next `lisa` run.
4
+ # =============================================================================
5
+ # Discharge Deferred Work-Item Gates Hook (PostToolUse - Bash)
6
+ # =============================================================================
7
+ # Two of the five Work-Item Traceability gates live OUTSIDE the commits: gate 4
8
+ # is the `Work-Item:` line in the pull-request BODY, and gate 5 is the managed
9
+ # `[lisa-pr-link]` backlink on the item. A push cannot check either one —
10
+ # both are properties of a pull request, and the push is what makes the pull
11
+ # request possible — so until this hook existed the next thing that looked was
12
+ # CI, one cycle later (CodySwannGT/lisa#3791).
13
+ #
14
+ # This fires the moment a pull request is created or its body edited, which is
15
+ # the FIRST moment both gates are checkable. It evaluates them, and posts the
16
+ # backlink that gate 5 needs, so neither waits for a red CI run to be revealed.
17
+ #
18
+ # Exit 2 blocks and hands the output back to the agent, which is the point: a
19
+ # non-blocking notice here would be the same defect the discharge exists to
20
+ # fix — a finding delivered somewhere nobody has to act on it.
21
+ # =============================================================================
22
+ set -uo pipefail
23
+
24
+ JSON_INPUT="$(cat)"
25
+
26
+ command -v jq >/dev/null 2>&1 || exit 0
27
+ command -v node >/dev/null 2>&1 || exit 0
28
+
29
+ COMMAND="$(printf '%s' "$JSON_INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
30
+ [ -n "$COMMAND" ] || exit 0
31
+
32
+ # `gh pr create` opens one; `gh pr edit` is the other way a body comes to carry
33
+ # — or lose — its declaration. Both change the answer to gate 4.
34
+ case "$COMMAND" in
35
+ *"gh pr create"* | *"gh pr edit"*) ;;
36
+ *) exit 0 ;;
37
+ esac
38
+
39
+ repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
40
+ [ -f "$repo_root/scripts/lisa-work-item.mjs" ] || exit 0
41
+
42
+ output="$(cd "$repo_root" && node scripts/lisa-work-item.mjs discharge-pr-gates 2>&1)"
43
+ status=$?
44
+
45
+ # 0 is a clean discharge. 3 is the validator's own answer for "no pull request
46
+ # to check yet", which PostToolUse reaches on every `gh pr create` that FAILED
47
+ # — the tool call still ran, so the hook still fires. Collapsing 3 into the
48
+ # blocking arm would report a work-item violation on somebody's typo.
49
+ case "$status" in
50
+ 0 | 3) exit 0 ;;
51
+ esac
52
+
53
+ printf '%s\n' "$output" >&2
54
+ exit 2
@@ -57,6 +57,7 @@ Add provider-appropriate linkage to the PR title and/or body without changing th
57
57
  - **GitHub Issues**:
58
58
  - If `work_item_ref` is a GitHub issue URL, `org/repo#<n>`, or `#<n>`, add a dedicated issue reference line to the PR body.
59
59
  - Always use a non-closing reference such as `Refs #<n>`, so the merge cannot close the issue before the post-merge deploy, remote verification, health check, and terminal `done` label.
60
+ - **This rule is what makes the managed backlink mandatory, and neither rule says so on its own.** `Refs` creates no native development link, so gate 5 has nothing to defer to and the `[lisa-pr-link]` comment becomes the only backlink there is. Anyone following the non-closing rule alone will hit gate 5. Separately, `Refs #<n>` does **not** satisfy gate 4 either: the body needs its own `Work-Item: <ref>` line, and `Closes`/`Fixes` do not satisfy it. One `Refs` line, one `Work-Item:` line, one backlink comment — three different requirements that look like one. `discharge-pr-gates` (below) is what checks the last two together.
60
61
  - **A non-closing reference does not populate the issue's Development / linked pull requests surface, and no non-closing form does.** That surface *is* the closing-reference mechanism: a closing keyword populates the PR's `closingIssuesReferences`, while `Refs` yields only a `CrossReferencedEvent`. Measured on this repository — a `Refs`-only PR reports `closingIssuesReferences: 0`; a `Closes` PR reports 1. So the ticket-side backlink cannot be delegated to GitHub: the managed `[lisa-pr-link]` comment written by `node scripts/lisa-work-item.mjs backlink` — the one producer, which `lisa-github-sync` and the other vendor sync skills call rather than reimplement — is the **required** backlink under this rule, not a fallback for when native linkage happens to be absent. Two-way linkage (`lisa-implement` step 7a) depends on that comment, and so does the Work-Item Traceability check wherever the project declares `workItem.verify: "full"` — there, a PR carrying a correct `Refs` line still fails the check without it. Post it either way: under the default `trailer` level the check does not read the tracker, but the two-way linkage a human follows is still worth having, and a project can raise its level at any time.
61
62
  - For cross-repo issue refs, use the fully qualified non-closing form, for example `Refs CodySwannGT/lisa#614`.
62
63
  - **Linear**:
@@ -76,13 +77,17 @@ When updating an existing PR, preserve any existing linkage line unless the new
76
77
  After creating or updating the PR, always make the reverse link durable on the source work item when `work_item_ref` is available:
77
78
 
78
79
  1. Resolve the live PR URL with `gh pr view <pr-number> --json url --jq .url`.
79
- 2. Run the backlink command. It is the executable form of this requirement — it writes the managed `[lisa-pr-link]` comment on the work item, or updates the one already there, for every tracker Lisa supports:
80
+ 2. Discharge the two gates the push could not check. Gate 4 (the `Work-Item:` line in the PR BODY) and gate 5 (the backlink) are both properties of a pull request, so a push before the PR existed reported them as `UNRESOLVED` rather than checked. One command closes both out at the first moment they *are* checkable:
80
81
 
81
82
  ```bash
82
- node scripts/lisa-work-item.mjs backlink --ref <work_item_ref> --pr-url <url>
83
+ node scripts/lisa-work-item.mjs discharge-pr-gates
83
84
  ```
84
85
 
85
- It is idempotent, so run it on every push rather than deciding whether it is needed. It refuses loudly for a tracker it cannot write, and never silently no-ops. Do not hand-post the comment, and do not describe the posting procedure anywhere: the same file that writes it is the file that checks it, which is what keeps producer and consumer from drifting.
86
+ It posts (or refreshes) the managed `[lisa-pr-link]` comment on every item the range names — so gate 5 passes without anyone remembering to — and then evaluates gate 4 against the live body, failing here rather than one CI cycle later. Exit 3 means there is no pull request for this branch yet, which is not a violation; exit 1 is a real unmet requirement and the message names it.
87
+
88
+ It writes no PR body. Gate 4 is the author's declaration of what the change is for; a command that inserted the line would be answering its own question.
89
+
90
+ The narrower `node scripts/lisa-work-item.mjs backlink --ref <work_item_ref> --pr-url <url>` is what it wraps, and remains the right call when you hold a ref and a URL but no checkout of the PR's branch (a sync skill updating a backlink after the fact). Both are idempotent, refuse loudly for a tracker they cannot write, and never silently no-op. Do not hand-post the comment, and do not describe the posting procedure anywhere: the same file that writes it is the file that checks it, which is what keeps producer and consumer from drifting.
86
91
  3. Invoke `lisa-tracker-sync` with the original work item ref, milestone `pr-ready`, `pr_url=<url>`, and `tracker_provider=<provider>` when known. That is the progress-note and status side; it is not what satisfies the traceability check.
87
92
  4. When the PR later merges, invoke `lisa-tracker-sync` again with milestone `pr-merged`, the same `pr_url`, and the merge SHA when available.
88
93
 
@@ -8,9 +8,19 @@
8
8
  # block-direct-issue-create.sh beside it (same delegation pattern as
9
9
  # block-instruction-file-edits.agy.sh).
10
10
  #
11
- # No scope gap here, unlike the instruction-file adapter: the canonical guard is
12
- # Bash-only by construction, and `run_command` is exactly the surface it needs.
13
- # agy therefore receives the whole guard, not an arm of it.
11
+ # SCOPE GAP, recorded rather than silently dropped (AGENTS.md). This used to
12
+ # read "no scope gap here: the canonical guard is Bash-only by construction, and
13
+ # `run_command` is exactly the surface it needs." That stopped being true in
14
+ # CodySwannGT/lisa#3753: the canonical guard now covers a SECOND substrate —
15
+ # structured tool payloads, where a creation arrives as named fields rather than
16
+ # as a command line — because the filing contract was enforced on the shell and
17
+ # unenforced on every tool call beside it.
18
+ #
19
+ # This adapter forwards `run_command` and nothing else, so on agy that second
20
+ # substrate is still uncovered. Closing it needs agy's own tool-call envelope
21
+ # (which tool names its creation surface, and where the arguments land), and
22
+ # that is a different piece of work from translating one protocol. The shell
23
+ # surface agy does expose receives the whole guard, exactly as before.
14
24
  #
15
25
  # Fail-open on missing runtimes: a missed refusal costs one under-declared
16
26
  # ticket, and `lisa-repair-intake` already sweeps for exactly that. Failing
@@ -79,6 +79,36 @@
79
79
  # settings env block, CI config). An escape the governed agent reaches by
80
80
  # typing one more token in front of the command it was just refused is not an
81
81
  # escape hatch — it is the prose problem with extra steps.
82
+ #
83
+ # ## What this hook costs, and the number that would change the decision
84
+ #
85
+ # Registered `matcher: ""` since CodySwannGT/lisa#3753 — it runs on EVERY tool
86
+ # call, not only Bash. Measured on a developer machine at load ~8-26, 50
87
+ # invocations per row:
88
+ #
89
+ # 16.7 ms enforce-team-first.sh already `matcher: ""` today
90
+ # 17.5 ms enforce-verification-gate.sh already `matcher: ""` today
91
+ # 11.5 ms this guard, non-Bash payload what the broad matcher adds
92
+ # 112.3 ms this guard, Bash payload what Bash calls already paid
93
+ #
94
+ # So the broad registration was not novel — two hooks were already there, this
95
+ # one is cheaper than either, and it raises an existing ~34 ms per-call baseline
96
+ # by about a third.
97
+ #
98
+ # THIS IS A THRESHOLD, NOT A PRECEDENT. The argument is about the TOTAL cost of
99
+ # the broad set, so it does not transfer to the next guard: four more hooks
100
+ # moved to `matcher: ""` would put ~90 ms on every tool call and the answer
101
+ # flips. **Before widening any other hook's matcher, re-run the measurement and
102
+ # reconsider if the broad set totals more than ~100 ms per tool call, or if any
103
+ # single broad hook exceeds ~50 ms.** Do not cite these numbers for a fifth
104
+ # hook; measure again, because the number that matters is the sum.
105
+ #
106
+ # The cost is also irreducible rather than sloppy. A 9-line script containing
107
+ # only this file's preamble measured 11.7 ms, and padding it to this file's
108
+ # length changed nothing (11.5 ms) — it is process spawn plus one `jq`, not
109
+ # parsing. Moving the early exit further up the file buys nothing; the exit is
110
+ # already ahead of config resolution, which is what keeps the figure at 11.5
111
+ # rather than at the 112 ms the Bash path pays.
82
112
  set -euo pipefail
83
113
 
84
114
  input="$(cat)"
@@ -99,13 +129,72 @@ for required in jq python3; do
99
129
  done
100
130
 
101
131
  tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
132
+
133
+ # ── The two substrates ────────────────────────────────────────────────────
134
+ #
135
+ # A creation reaches a tracker two ways, and until CodySwannGT/lisa#3753 this
136
+ # guard saw only one of them. It was registered `matcher: "Bash"` AND gated
137
+ # here on `tool_name != "Bash"`, so an MCP tool call was refused entry twice
138
+ # over. MEASURED before the change, driving the guard with synthetic payloads:
139
+ #
140
+ # ALLOW mcp__linear-server__create_issue, undeclared <- the defect
141
+ # ALLOW mcp__github__create_issue, undeclared <- the defect
142
+ # REFUSE the same creation as `gh issue create` shell text <- control
143
+ # ALLOW an ordinary Read tool call <- control
144
+ #
145
+ # The two controls are what make those ALLOWs mean anything: the refusal proves
146
+ # the guard is live, the Read proves it is not simply refusing everything.
147
+ #
148
+ # **Widening the matcher alone would have changed nothing** — the call would
149
+ # arrive and this gate would still exit 0. Anyone who "fixed" #3753 by editing
150
+ # `plugin.json` would have seen no test fail and shipped a guard that still
151
+ # allows every MCP filing. The registration and this gate had to move together.
152
+ #
153
+ # The SHELL path below is untouched. A structured payload has no command line
154
+ # to tokenise, so it gets its own small classifier rather than being forced
155
+ # through a parser built for shell text.
156
+ structured_call=""
102
157
  if [ "$tool_name" != "Bash" ]; then
103
- exit 0
158
+ # The cheap shape gate, deliberately BEFORE config resolution.
159
+ #
160
+ # This hook now runs on EVERY tool call, so the cost of the path that does
161
+ # nothing is the cost of the whole change. Config resolution spawns two jq
162
+ # processes per lookup and is what makes the Bash path ~112 ms; putting it
163
+ # ahead of this gate would charge that to every Read and Grep. So the
164
+ # not-a-creation exit happens here, on a shell `case` with no subprocess.
165
+ #
166
+ # Matched on SHAPE, not on an enumerated list of server tool names. Server
167
+ # names are supplied by whoever wrote the server and change when one is
168
+ # added or renamed, so a list would pass every row anyone thought of and
169
+ # miss the first one nobody did — the failure mode this repository's guard
170
+ # suites are written to defeat.
171
+ #
172
+ # RESIDUAL, stated rather than hidden: a server whose creation tool is named
173
+ # without a create-verb or without a tracker noun (`mcp__x__file_work`) is
174
+ # not recognised and is allowed. That is a fail-open, and it is the honest
175
+ # cost of refusing to enumerate. Add shapes here as they are measured.
176
+ case "$tool_name" in
177
+ Bash | Read | Write | Edit | MultiEdit | Glob | Grep | Task | TodoWrite)
178
+ exit 0
179
+ ;;
180
+ esac
181
+ case "$tool_name" in
182
+ *[Cc]reate* | *[Nn]ew* | *[Aa]dd* | *[Ff]ile*) ;;
183
+ *) exit 0 ;;
184
+ esac
185
+ case "$tool_name" in
186
+ *[Ii]ssue* | *[Tt]icket* | *[Tt]ask* | *[Ss]tory* | *[Bb]ug* | *[Ee]pic* | *[Ww]ork*) ;;
187
+ *) exit 0 ;;
188
+ esac
189
+ structured_call="1"
104
190
  fi
105
191
 
106
- command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
107
- if [ -z "$command_str" ]; then
108
- exit 0
192
+ command_str=""
193
+ if [ -z "$structured_call" ]; then
194
+ command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
195
+ if [ -z "$command_str" ]; then
196
+ exit 0
197
+ fi
109
198
  fi
110
199
 
111
200
  project_dir="${CLAUDE_PROJECT_DIR:-}"
@@ -1859,6 +1948,53 @@ print("ALLOW")
1859
1948
  PY
1860
1949
 
1861
1950
  set +e
1951
+ # ── The structured substrate ──────────────────────────────────────────────
1952
+ #
1953
+ # An MCP call carries named fields, not a command line, so `scan()` — which
1954
+ # tokenises shell text — has nothing to parse. The declaration is read from the
1955
+ # payload instead, and the recogniser is deliberately small: this path decides
1956
+ # ONE question (is a build-ready role or a human-gate marker present anywhere
1957
+ # in the submitted fields), where the shell path has to answer "which of these
1958
+ # tokens is a client, an endpoint, a flag value, or a file it executes".
1959
+ #
1960
+ # Every string in the payload is searched, at any depth, because a role lands
1961
+ # in a different field on every tracker — `labels[]` on GitHub, `stateId` or a
1962
+ # workflow state name on Linear, a transition `id` on JIRA — and enumerating
1963
+ # field names per vendor is the same brittleness as enumerating tool names.
1964
+ # Over-collecting is safe here: the values are compared against ONE configured
1965
+ # role string, so an unrelated field cannot accidentally satisfy it.
1966
+ if [ -n "$structured_call" ]; then
1967
+ # The operator's ambient escape works on both substrates. There is no inline
1968
+ # form to disqualify it here — a structured call has no shell in which to
1969
+ # assign one — so the override is simply honoured.
1970
+ if [ -n "$ambient_override" ]; then
1971
+ exit 0
1972
+ fi
1973
+
1974
+ structured_declaration="$(
1975
+ printf '%s' "$input" |
1976
+ jq -r --arg role "$ready_role" '
1977
+ [(.tool_input // {}) | .. | strings] as $values
1978
+ | if ($values | index($role)) then "role"
1979
+ elif ($values | map(select(contains("[lisa-human-gate]"))) | length) > 0 then "gate"
1980
+ else "" end
1981
+ ' 2>/dev/null || printf 'UNREADABLE'
1982
+ )"
1983
+
1984
+ # Fail closed on a creation-shaped call whose payload cannot be read. Silence
1985
+ # about a filing the guard could not inspect is the defect this whole ticket
1986
+ # is about, one substrate over.
1987
+ if [ "$structured_declaration" = "UNREADABLE" ]; then
1988
+ refuse "an unreadable $tool_name payload that reads as a tracker creation" \
1989
+ "$ready_role" ""
1990
+ fi
1991
+
1992
+ if [ -z "$structured_declaration" ]; then
1993
+ refuse "a tracker creation through $tool_name" "$ready_role" ""
1994
+ fi
1995
+ exit 0
1996
+ fi
1997
+
1862
1998
  verdict="$(
1863
1999
  printf '%s' "$classifier" |
1864
2000
  LISA_GUARD_COMMAND="$command_str" \