@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
@@ -82,6 +82,36 @@
82
82
  # settings env block, CI config). An escape the governed agent reaches by
83
83
  # typing one more token in front of the command it was just refused is not an
84
84
  # escape hatch — it is the prose problem with extra steps.
85
+ #
86
+ # ## What this hook costs, and the number that would change the decision
87
+ #
88
+ # Registered `matcher: ""` since CodySwannGT/lisa#3753 — it runs on EVERY tool
89
+ # call, not only Bash. Measured on a developer machine at load ~8-26, 50
90
+ # invocations per row:
91
+ #
92
+ # 16.7 ms enforce-team-first.sh already `matcher: ""` today
93
+ # 17.5 ms enforce-verification-gate.sh already `matcher: ""` today
94
+ # 11.5 ms this guard, non-Bash payload what the broad matcher adds
95
+ # 112.3 ms this guard, Bash payload what Bash calls already paid
96
+ #
97
+ # So the broad registration was not novel — two hooks were already there, this
98
+ # one is cheaper than either, and it raises an existing ~34 ms per-call baseline
99
+ # by about a third.
100
+ #
101
+ # THIS IS A THRESHOLD, NOT A PRECEDENT. The argument is about the TOTAL cost of
102
+ # the broad set, so it does not transfer to the next guard: four more hooks
103
+ # moved to `matcher: ""` would put ~90 ms on every tool call and the answer
104
+ # flips. **Before widening any other hook's matcher, re-run the measurement and
105
+ # reconsider if the broad set totals more than ~100 ms per tool call, or if any
106
+ # single broad hook exceeds ~50 ms.** Do not cite these numbers for a fifth
107
+ # hook; measure again, because the number that matters is the sum.
108
+ #
109
+ # The cost is also irreducible rather than sloppy. A 9-line script containing
110
+ # only this file's preamble measured 11.7 ms, and padding it to this file's
111
+ # length changed nothing (11.5 ms) — it is process spawn plus one `jq`, not
112
+ # parsing. Moving the early exit further up the file buys nothing; the exit is
113
+ # already ahead of config resolution, which is what keeps the figure at 11.5
114
+ # rather than at the 112 ms the Bash path pays.
85
115
  set -euo pipefail
86
116
 
87
117
  input="$(cat)"
@@ -102,13 +132,72 @@ for required in jq python3; do
102
132
  done
103
133
 
104
134
  tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
135
+
136
+ # ── The two substrates ────────────────────────────────────────────────────
137
+ #
138
+ # A creation reaches a tracker two ways, and until CodySwannGT/lisa#3753 this
139
+ # guard saw only one of them. It was registered `matcher: "Bash"` AND gated
140
+ # here on `tool_name != "Bash"`, so an MCP tool call was refused entry twice
141
+ # over. MEASURED before the change, driving the guard with synthetic payloads:
142
+ #
143
+ # ALLOW mcp__linear-server__create_issue, undeclared <- the defect
144
+ # ALLOW mcp__github__create_issue, undeclared <- the defect
145
+ # REFUSE the same creation as `gh issue create` shell text <- control
146
+ # ALLOW an ordinary Read tool call <- control
147
+ #
148
+ # The two controls are what make those ALLOWs mean anything: the refusal proves
149
+ # the guard is live, the Read proves it is not simply refusing everything.
150
+ #
151
+ # **Widening the matcher alone would have changed nothing** — the call would
152
+ # arrive and this gate would still exit 0. Anyone who "fixed" #3753 by editing
153
+ # `plugin.json` would have seen no test fail and shipped a guard that still
154
+ # allows every MCP filing. The registration and this gate had to move together.
155
+ #
156
+ # The SHELL path below is untouched. A structured payload has no command line
157
+ # to tokenise, so it gets its own small classifier rather than being forced
158
+ # through a parser built for shell text.
159
+ structured_call=""
105
160
  if [ "$tool_name" != "Bash" ]; then
106
- exit 0
161
+ # The cheap shape gate, deliberately BEFORE config resolution.
162
+ #
163
+ # This hook now runs on EVERY tool call, so the cost of the path that does
164
+ # nothing is the cost of the whole change. Config resolution spawns two jq
165
+ # processes per lookup and is what makes the Bash path ~112 ms; putting it
166
+ # ahead of this gate would charge that to every Read and Grep. So the
167
+ # not-a-creation exit happens here, on a shell `case` with no subprocess.
168
+ #
169
+ # Matched on SHAPE, not on an enumerated list of server tool names. Server
170
+ # names are supplied by whoever wrote the server and change when one is
171
+ # added or renamed, so a list would pass every row anyone thought of and
172
+ # miss the first one nobody did — the failure mode this repository's guard
173
+ # suites are written to defeat.
174
+ #
175
+ # RESIDUAL, stated rather than hidden: a server whose creation tool is named
176
+ # without a create-verb or without a tracker noun (`mcp__x__file_work`) is
177
+ # not recognised and is allowed. That is a fail-open, and it is the honest
178
+ # cost of refusing to enumerate. Add shapes here as they are measured.
179
+ case "$tool_name" in
180
+ Bash | Read | Write | Edit | MultiEdit | Glob | Grep | Task | TodoWrite)
181
+ exit 0
182
+ ;;
183
+ esac
184
+ case "$tool_name" in
185
+ *[Cc]reate* | *[Nn]ew* | *[Aa]dd* | *[Ff]ile*) ;;
186
+ *) exit 0 ;;
187
+ esac
188
+ case "$tool_name" in
189
+ *[Ii]ssue* | *[Tt]icket* | *[Tt]ask* | *[Ss]tory* | *[Bb]ug* | *[Ee]pic* | *[Ww]ork*) ;;
190
+ *) exit 0 ;;
191
+ esac
192
+ structured_call="1"
107
193
  fi
108
194
 
109
- command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
110
- if [ -z "$command_str" ]; then
111
- exit 0
195
+ command_str=""
196
+ if [ -z "$structured_call" ]; then
197
+ command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
198
+ if [ -z "$command_str" ]; then
199
+ exit 0
200
+ fi
112
201
  fi
113
202
 
114
203
  project_dir="${CLAUDE_PROJECT_DIR:-}"
@@ -1862,6 +1951,53 @@ print("ALLOW")
1862
1951
  PY
1863
1952
 
1864
1953
  set +e
1954
+ # ── The structured substrate ──────────────────────────────────────────────
1955
+ #
1956
+ # An MCP call carries named fields, not a command line, so `scan()` — which
1957
+ # tokenises shell text — has nothing to parse. The declaration is read from the
1958
+ # payload instead, and the recogniser is deliberately small: this path decides
1959
+ # ONE question (is a build-ready role or a human-gate marker present anywhere
1960
+ # in the submitted fields), where the shell path has to answer "which of these
1961
+ # tokens is a client, an endpoint, a flag value, or a file it executes".
1962
+ #
1963
+ # Every string in the payload is searched, at any depth, because a role lands
1964
+ # in a different field on every tracker — `labels[]` on GitHub, `stateId` or a
1965
+ # workflow state name on Linear, a transition `id` on JIRA — and enumerating
1966
+ # field names per vendor is the same brittleness as enumerating tool names.
1967
+ # Over-collecting is safe here: the values are compared against ONE configured
1968
+ # role string, so an unrelated field cannot accidentally satisfy it.
1969
+ if [ -n "$structured_call" ]; then
1970
+ # The operator's ambient escape works on both substrates. There is no inline
1971
+ # form to disqualify it here — a structured call has no shell in which to
1972
+ # assign one — so the override is simply honoured.
1973
+ if [ -n "$ambient_override" ]; then
1974
+ exit 0
1975
+ fi
1976
+
1977
+ structured_declaration="$(
1978
+ printf '%s' "$input" |
1979
+ jq -r --arg role "$ready_role" '
1980
+ [(.tool_input // {}) | .. | strings] as $values
1981
+ | if ($values | index($role)) then "role"
1982
+ elif ($values | map(select(contains("[lisa-human-gate]"))) | length) > 0 then "gate"
1983
+ else "" end
1984
+ ' 2>/dev/null || printf 'UNREADABLE'
1985
+ )"
1986
+
1987
+ # Fail closed on a creation-shaped call whose payload cannot be read. Silence
1988
+ # about a filing the guard could not inspect is the defect this whole ticket
1989
+ # is about, one substrate over.
1990
+ if [ "$structured_declaration" = "UNREADABLE" ]; then
1991
+ refuse "an unreadable $tool_name payload that reads as a tracker creation" \
1992
+ "$ready_role" ""
1993
+ fi
1994
+
1995
+ if [ -z "$structured_declaration" ]; then
1996
+ refuse "a tracker creation through $tool_name" "$ready_role" ""
1997
+ fi
1998
+ exit 0
1999
+ fi
2000
+
1865
2001
  verdict="$(
1866
2002
  printf '%s' "$classifier" |
1867
2003
  LISA_GUARD_COMMAND="$command_str" \
@@ -82,6 +82,14 @@ const NOT_A_DEPLOY_BRANCH = "not a deploy branch";
82
82
  * for a producer that, until this command existed, was prose in a SKILL.md.
83
83
  */
84
84
  const BACKLINK_COMMAND = "node scripts/lisa-work-item.mjs backlink";
85
+ /**
86
+ * The subcommand that closes out the two gates a push had to defer.
87
+ *
88
+ * Named in the push report rather than described, because the push report is
89
+ * the only place an operator learns that two requirements are still open, and
90
+ * a list of open work with no command against it is a complaint.
91
+ */
92
+ const DISCHARGE_COMMAND = "discharge-pr-gates";
85
93
  /**
86
94
  * The prefix only — an anchored, fixed-length literal with no quantifier, so
87
95
  * it cannot backtrack. The ReDoS was never here; it was in the `\s*(.+?)\s*$`
@@ -3053,11 +3061,24 @@ function currentRepository() {
3053
3061
  return safeJson(result.stdout, "GitHub repository").nameWithOwner;
3054
3062
  }
3055
3063
 
3056
- function currentPullRequest(number, repository = currentRepository()) {
3064
+ function currentPullRequest(
3065
+ number,
3066
+ repository = currentRepository(),
3067
+ fields = "url,body,state"
3068
+ ) {
3057
3069
  const args = ["pr", "view"];
3058
3070
  if (number) args.push(String(number));
3059
- if (repository) args.push("--repo", repository);
3060
- args.push("--json", "url,body,state");
3071
+ // `--repo` ONLY alongside a number, because `gh pr view --repo <r>` with no
3072
+ // positional argument is a USAGE ERROR — "argument required when using the
3073
+ // --repo flag" — not a lookup that returns nothing. It exits 1, this
3074
+ // function reads that as "no pull request exists", and the push then takes
3075
+ // the deferral branch on every branch that HAS one. Measured on this
3076
+ // repository: an open pull request, and the push reporting gates 4 and 5 as
3077
+ // unchecked because the flag combination could never have found it. Without
3078
+ // a number `gh` resolves the pull request from the current branch's remote,
3079
+ // which is the answer this caller wants and the only one it can get.
3080
+ if (number && repository) args.push("--repo", repository);
3081
+ args.push("--json", fields);
3061
3082
  const result = run("gh", args, { allowFailure: true });
3062
3083
  return result.status === 0
3063
3084
  ? safeJson(result.stdout, "GitHub pull request")
@@ -4158,6 +4179,61 @@ function pushDeferralNote(result) {
4158
4179
  return `no pull request exists yet, so gates 4 and 5 could not be checked here — CI will verify both.${spanning}`;
4159
4180
  }
4160
4181
 
4182
+ /**
4183
+ * The two unchecked gates as open work, addressed to whoever runs the push.
4184
+ *
4185
+ * The wording is the whole point of this function, so it is worth saying what
4186
+ * it is deliberately NOT: `WORK_ITEM_TRACKING_OK`. This same text used to be
4187
+ * appended to that token, which made a finding arrive inside a success — and a
4188
+ * finding inside a success is not a finding. Nobody reads an unmet requirement
4189
+ * out of a line that opens by declaring the check passed, so the gate did the
4190
+ * hard part (it knows exactly which two requirements it could not reach, and
4191
+ * says so) and then threw the answer away in its own headline
4192
+ * (CodySwannGT/lisa#3791).
4193
+ *
4194
+ * The exit code stays 0, and that is not the same compromise. A push is the
4195
+ * only way to make a branch exist on the remote, and a pull request cannot be
4196
+ * opened for a branch the remote has never seen — so refusing here would be a
4197
+ * refusal with no remedy, and the first push of every branch would be
4198
+ * impossible. What is available at this moment is the OTHER half of a control:
4199
+ * name the unmet requirements as unmet, and name the command that discharges
4200
+ * them the moment they become checkable. `discharge-pr-gates` is that command,
4201
+ * and it is what makes this list closeable rather than merely honest.
4202
+ *
4203
+ * Gate 5 is stated as inapplicable rather than unresolved under the `trailer`
4204
+ * level, because a run that contacted no tracker has not left a backlink
4205
+ * unposted — it is not required at all there. Listing it as open work would
4206
+ * ask an operator to resolve something no check will ever look at.
4207
+ * Exported so the report can be asserted in-process. The push path reaches it
4208
+ * only through a spawned child, where a mutation run records no coverage and
4209
+ * scores every line of this text as unreached — so the subprocess cases prove
4210
+ * the WIRING and the direct cases prove the WORDS.
4211
+ * @param {object} result Commit-side result.
4212
+ * @param {string} label Prefix naming the ref, empty for a single-ref push.
4213
+ * @returns {string} The whole report, replacing the success line.
4214
+ */
4215
+ export function unresolvedPushReport(result, label) {
4216
+ const contract = result.contract;
4217
+ const refs = result.refs ?? [];
4218
+ const declaration = refs.map(ref => `Work-Item: ${ref}`).join("\n ");
4219
+ const backlinkLine =
4220
+ contract.verify === "full"
4221
+ ? ` UNRESOLVED gate 5 — each item needs a managed \`${MARKER}\` backlink comment pointing at the pull request. \`discharge-pr-gates\` posts it; nothing else has to remember to.`
4222
+ : ` n/a gate 5 — the tracker backlink is not required here: workItem.verify is "trailer".`;
4223
+ return [
4224
+ `WORK_ITEM_TRACKING_INCOMPLETE ${label}${result.relevant} commit(s)${alreadyTraced(result)}: gates 1-3 proved here, 2 of 5 gates NOT CHECKED.`,
4225
+ ` This push is not a clean bill of health for ${refs.join(", ")}. ${pushDeferralNote(result)}`,
4226
+ ` UNRESOLVED gate 4 — the pull-request BODY must carry, on its own line${refs.length > 1 ? "s" : ""}:`,
4227
+ ` ${declaration}`,
4228
+ ` \`Refs #n\` and \`Closes #n\` do NOT satisfy it.`,
4229
+ backlinkLine,
4230
+ ` Discharge both the moment the pull request exists — it evaluates them and`,
4231
+ ` posts what it can, so neither waits for CI to reveal it:`,
4232
+ ` node scripts/lisa-work-item.mjs ${DISCHARGE_COMMAND}`,
4233
+ gateSummary(contract),
4234
+ ].join("\n");
4235
+ }
4236
+
4161
4237
  /**
4162
4238
  * Report one pushed ref's outcome, refusing when it did not prove out.
4163
4239
  * @param {{result?: object, error?: Error}} outcome Commit-side outcome.
@@ -4177,9 +4253,20 @@ function reportPushGroup(outcome, pr, label) {
4177
4253
  // CI — so the checklist goes out either way. Saying nothing is precisely
4178
4254
  // what made those two gates separate CI-cycle surprises (#2681).
4179
4255
  if (outcome.error) throw withGateSummary(outcome.error);
4180
- console.log(
4181
- `WORK_ITEM_TRACKING_OK ${label}${outcome.result.relevant} commit(s); ${pushDeferralNote(outcome.result)}${gateSummary(outcome.result.contract)}`
4182
- );
4256
+ // A range naming no work item is a different fact from a range whose
4257
+ // declaration nobody checked, and the two must not share a sentence. Nothing
4258
+ // here is deferred: a release commit and a back-merge have no item for a
4259
+ // pull-request body to declare or a tracker to link, so gates 4 and 5 have
4260
+ // NOTHING to check rather than something unchecked. Reporting those as open
4261
+ // work would put an unresolvable item on every release push, which is how a
4262
+ // checklist teaches the reader to skip it.
4263
+ if ((outcome.result.refs ?? []).length === 0) {
4264
+ console.log(
4265
+ `WORK_ITEM_TRACKING_OK ${label}${outcome.result.relevant} commit(s)${alreadyTraced(outcome.result)}; this range names no work item, so gates 4 and 5 have nothing to check here.`
4266
+ );
4267
+ return;
4268
+ }
4269
+ console.log(unresolvedPushReport(outcome.result, label));
4183
4270
  }
4184
4271
 
4185
4272
  function validatePush(args) {
@@ -4202,6 +4289,116 @@ function validatePush(args) {
4202
4289
  }
4203
4290
  }
4204
4291
 
4292
+ /**
4293
+ * Refuse, but as "there is nothing here to check" rather than "this failed".
4294
+ *
4295
+ * Its own exit code because the two answers ask opposite things of whoever is
4296
+ * reading. `1` says a requirement is unmet and the change must not proceed;
4297
+ * `3` says this command was run somewhere it has no subject — before the pull
4298
+ * request exists, or on a branch that has none. A caller that fires this
4299
+ * automatically (the PostToolUse hook does, after any `gh pr` command,
4300
+ * including ones that failed) must be able to tell them apart without parsing
4301
+ * prose, or every failed `gh pr create` would report a work-item violation
4302
+ * that did not happen.
4303
+ * Exported so the code and the wording can be asserted without a tracker: an
4304
+ * error object is a value, and the whole content of this one is which number
4305
+ * it carries.
4306
+ * @returns {Error} The refusal, carrying its own exit code.
4307
+ */
4308
+ export function noPullRequestToDischarge() {
4309
+ const error = new TrackingError(
4310
+ `${DISCHARGE_COMMAND} found no pull request for this branch, so gates 4 ` +
4311
+ `and 5 have no subject yet. Open the pull request, then run it again.`
4312
+ );
4313
+ error.exitCode = 3;
4314
+ error.selfExplanatory = true;
4315
+ return error;
4316
+ }
4317
+
4318
+ /**
4319
+ * Close out gates 4 and 5 at the first moment they can be checked.
4320
+ *
4321
+ * A push cannot check them: both are properties of a pull request, and the
4322
+ * push is what makes the pull request possible. Until this command existed the
4323
+ * next thing that looked was CI, one cycle later — so a body missing its
4324
+ * `Work-Item:` line, or an item missing its backlink, cost a full red run to
4325
+ * discover something that was knowable the second the pull request opened
4326
+ * (CodySwannGT/lisa#3791).
4327
+ *
4328
+ * It does not merely CHECK gate 5, it satisfies it. The managed backlink is
4329
+ * something a producer can write and no human should have to remember, and the
4330
+ * reason it has to be written at all is a coupling neither rule states on its
4331
+ * own: the convention is to reference an item with `Refs #n` rather than
4332
+ * `Closes #n`, so the merge cannot close it before deploy and verification —
4333
+ * and `Refs` populates no native development link for gate 5 to read. The
4334
+ * non-closing rule is what MAKES the managed comment mandatory.
4335
+ *
4336
+ * Gate 4 is checked and never written. The body is the author's declaration of
4337
+ * what this change is for; a command that inserted the line would be answering
4338
+ * its own question, and the gate would be proving that this command can write.
4339
+ * @param {string[]} args Command arguments.
4340
+ */
4341
+ function dischargePrGates(args) {
4342
+ const remote = option(args, "--remote", "LISA_PR_REMOTE") || "origin";
4343
+ const pr = currentPullRequest(
4344
+ option(args, "--pr-number", "LISA_PR_NUMBER"),
4345
+ undefined,
4346
+ "url,body,state,commits"
4347
+ );
4348
+ if (!pr) throw noPullRequestToDischarge();
4349
+ const configRef = remoteDefaultRef(remote);
4350
+ const commits = (pr.commits ?? []).map(commit => commit.oid).filter(Boolean);
4351
+ const first = commitOutcome(commits, configRef, remote);
4352
+ const contract = first.result?.contract ?? trackerContract();
4353
+ const refs = first.result?.refs ?? [];
4354
+ const wrote = postDischargeBacklinks(refs, pr.url, contract);
4355
+ // A SECOND read when anything actually changed, and the reason is the defect
4356
+ // this whole file exists to avoid. `validatePrData` reads gate 5 out of the
4357
+ // issue payload the FIRST pass cached — fetched before the comment above was
4358
+ // posted — so validating against it would report a backlink missing that
4359
+ // this command had just written, and the operator would be sent to fix
4360
+ // something already fixed. Nothing is re-read when every backlink came back
4361
+ // `unchanged`, because then the cached payload and the tracker agree.
4362
+ const outcome = wrote ? commitOutcome(commits, configRef, remote) : first;
4363
+ validatePrData(outcome, pr.url, pr.body);
4364
+ console.log(
4365
+ `WORK_ITEM_TRACKING_OK ${outcome.result.relevant} commit(s)${alreadyTraced(outcome.result)}, ${provedHere(contract)} — gates 4 and 5 discharged at the pull request, not deferred to CI`
4366
+ );
4367
+ }
4368
+
4369
+ /**
4370
+ * Establish the managed backlink for every item this pull request carries.
4371
+ *
4372
+ * The writer is a parameter for the same reason `assertBacklink` takes its
4373
+ * issue payload as one: it is the only part that touches a tracker, and
4374
+ * without the seam the answer this function returns — did anything actually
4375
+ * change, which is what decides whether the verification re-reads — can only
4376
+ * be observed through a network round trip.
4377
+ * @param {string[]} refs Work items the range names.
4378
+ * @param {string} prUrl Pull request URL.
4379
+ * @param {object} contract Resolved tracker contract.
4380
+ * @param {Function} [post] Backlink writer, defaulting to the real one.
4381
+ * @returns {boolean} True when any tracker write actually changed something.
4382
+ */
4383
+ export function postDischargeBacklinks(
4384
+ refs,
4385
+ prUrl,
4386
+ contract,
4387
+ post = postBacklink
4388
+ ) {
4389
+ // `trailer` contacts no tracker anywhere else in this file, and it may not
4390
+ // start here: a project that keeps no tracker credentials cannot satisfy a
4391
+ // write, and gate 5 is not asked of it.
4392
+ if (contract.verify !== "full") return false;
4393
+ let changed = false;
4394
+ for (const ref of refs) {
4395
+ const outcome = post(ref, prUrl, contract);
4396
+ changed ||= outcome !== "unchanged";
4397
+ console.log(`work-item backlink ${outcome} on ${ref}: ${MARKER} ${prUrl}`);
4398
+ }
4399
+ return changed;
4400
+ }
4401
+
4205
4402
  function validatePr(args) {
4206
4403
  const base = option(args, "--base", "LISA_PR_BASE_SHA");
4207
4404
  const head = option(args, "--head", "LISA_PR_HEAD_SHA") || "HEAD";
@@ -4317,8 +4514,9 @@ function main() {
4317
4514
  if (command === "validate-push-destination")
4318
4515
  return validatePushDestination(args);
4319
4516
  if (command === "validate-pr") return validatePr(args);
4517
+ if (command === DISCHARGE_COMMAND) return dischargePrGates(args);
4320
4518
  throw new TrackingError(
4321
- "Usage: lisa-work-item.mjs link|current|attach-branch|clear|verify-level|contract-version|backlink|complete|sweep|prepare-commit-msg|validate-commit|validate-push|validate-push-destination|validate-pr" +
4519
+ "Usage: lisa-work-item.mjs link|current|attach-branch|clear|verify-level|contract-version|backlink|complete|sweep|prepare-commit-msg|validate-commit|validate-push|validate-push-destination|validate-pr|discharge-pr-gates" +
4322
4520
  "\n(`bind` is accepted as an alias for `link`, but some agent harnesses refuse the token `bind` in a command line.)"
4323
4521
  );
4324
4522
  }
@@ -4343,7 +4541,10 @@ export function runCli() {
4343
4541
  ? `\n❌ ${detail}\n`
4344
4542
  : `\n❌ Work-item tracking blocked this operation: ${detail}\n\n${GUIDANCE}\n`
4345
4543
  );
4346
- process.exitCode = 1;
4544
+ // A refusal may name its own exit code, and one does: `discharge-pr-gates`
4545
+ // answers 3 for "no pull request to check yet", which is not a violation
4546
+ // and must not read as one to a caller that only has the status to go on.
4547
+ process.exitCode = error?.exitCode ?? 1;
4347
4548
  }
4348
4549
  }
4349
4550
 
@@ -1 +1 @@
1
- {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAo/BjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAu5BjC,CAAC"}
1
+ {"version":3,"file":"lisa-owned-hash-ledger.d.ts","sourceRoot":"","sources":["../../src/core/lisa-owned-hash-ledger.ts"],"names":[],"mappings":"AACA,6EAA6E;AAE7E;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,QAAQ,CAC3C,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAy/BjC,CAAC;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,+BAA+B,EAAE,QAAQ,CACpD,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CA45BjC,CAAC"}
@@ -598,11 +598,13 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
598
598
  "22184f1dd1b96e818741bd4ae659446299535d7ce594817155edebac4172a7ba",
599
599
  "2832037d6c8d7930a111519dee0cca0ed0110deba3988f8e713bb9203de254df",
600
600
  "2e030d5a394d3edb48c391db49661fb3d45545c85f88ea03d1ab07ad3787c089",
601
+ "412209ea2329a64737eeffa4ffc9b6f9371f14cf86419fd0626361a99188a3f8",
601
602
  "5b3bd8a3460524457435f51a0994fb12009c97fffb921375bc79db3798acbabf",
602
603
  "5c5a9f1c39f99e493a575784f70379eb857c4963da5d400b6ee86f0e69fc5402",
603
604
  "60a4f94ec671b2372f7492680ab327d97aff686588ce85791f54e0fbd098b0ca",
604
605
  "63c2f9b54ea30997905bc6e17709dd1300436204ac82ad16717d7cc215af262b",
605
606
  "698131242f9ea27ea00e864d4b302f4ccc4a24d1383cb8ca9f0b17b565796197",
607
+ "6edfeec55433192c89b955a026ca3b5a25ade8e04c3267542d1cd884cd0bb3bb",
606
608
  "73e94b73830d8961026947d99bf4ac5f959b5bca025203b913d8a6363cbe2b9f",
607
609
  "803752f85a9f013014be5d04e444c65f7d3eea476569c5d7048d4d13b8e0bb9e",
608
610
  "847dfca48823a495fc3de47dfdb4b26e49d6808c6570c46d024a99793df1d370",
@@ -879,6 +881,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
879
881
  "2c678c75d6de9b029f27fa6eec45b8421217a4fb5ef3412ad39883e10b213987",
880
882
  "2d9ca0841db9e75ddfd95e3a4d485fd01aa639a512ef648996f565b7991d8e2b",
881
883
  "3a9ec1ff863693a0fa0fef0ade6fa5534485d0249fb78302d37cdac3781348f1",
884
+ "3f8c59e0f4ad43211f6964b4889f3fc673bee0496ec3613bdbdc4544424340f6",
882
885
  "41b967f736d7fcf9d8928ad7c8cc767c5beb9544e2fbfa3317c65734aa02fcfd",
883
886
  "4372872a448d3b943dea5541167ed5be6155703b57b9f8224d19ddefd3b80898",
884
887
  "475cb9ef86c2ac54c8fd8cb6d63e9a55ea8cd0017e26efbe3c44391a4aeeab41",
@@ -892,6 +895,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
892
895
  "637741a9e5d2904aed24d48efd6bec1326c5573f718a13152807aa0e4b42f0a1",
893
896
  "653252b0bbfd19bdffa046a2173c053e566ac6bf605c8d9060f9cf71ad7386de",
894
897
  "6a8be8577242588f14ab52b25b2a22b6def53aefa9513b4d3d7c49f4d8027079",
898
+ "6afe796f5a69a68a026c79a64aa451eb2bf7907a0ace0d9079f8b4add16b886b",
895
899
  "6dadc065016e741568b6ae1ebcd0378085005c076fdb93346bad7c1fd9e3b8b8",
896
900
  "71ff42c2cb8ac7022e0066132476764dd745fb92bf19ccd395000b1fe319abc7",
897
901
  "7573b5170fcba45ccd6123cd0712baba8faa52f063b639140be829d9ce87327d",
@@ -916,6 +920,7 @@ export const LISA_OWNED_HASH_LEDGER = Object.freeze({
916
920
  "bdeab1cb7cbb427a69bc8450075b5d36d364a7b71e69666fef6966097084b0a5",
917
921
  "c78df9d5f0f3fc388ff60a7db5898478a2a4d6d7325833f10384b5a8c62bb34a",
918
922
  "cdea136456d2b9880c3abd2bbd402fb08051822b9e92484c66cd184ec7ed7d0f",
923
+ "ce1520dba31d32856d470cf744700e0636518dd3a767627d42ac0a77007d6813",
919
924
  "ce5ff040b1f75925bc286027dcb893649edfb408021916920b07105baa2d9949",
920
925
  "d0321aa197d748813a2451eaa1c509cc74aced19a7f887b14a4bb15a7a73a525",
921
926
  "d1b07166efb5534c1d9b0fd09032e311854477160244f5318925035392318d36",
@@ -1559,11 +1564,13 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1559
1564
  "22184f1dd1b96e818741bd4ae659446299535d7ce594817155edebac4172a7ba",
1560
1565
  "2832037d6c8d7930a111519dee0cca0ed0110deba3988f8e713bb9203de254df",
1561
1566
  "2e030d5a394d3edb48c391db49661fb3d45545c85f88ea03d1ab07ad3787c089",
1567
+ "412209ea2329a64737eeffa4ffc9b6f9371f14cf86419fd0626361a99188a3f8",
1562
1568
  "5b3bd8a3460524457435f51a0994fb12009c97fffb921375bc79db3798acbabf",
1563
1569
  "5c5a9f1c39f99e493a575784f70379eb857c4963da5d400b6ee86f0e69fc5402",
1564
1570
  "60a4f94ec671b2372f7492680ab327d97aff686588ce85791f54e0fbd098b0ca",
1565
1571
  "63c2f9b54ea30997905bc6e17709dd1300436204ac82ad16717d7cc215af262b",
1566
1572
  "698131242f9ea27ea00e864d4b302f4ccc4a24d1383cb8ca9f0b17b565796197",
1573
+ "6edfeec55433192c89b955a026ca3b5a25ade8e04c3267542d1cd884cd0bb3bb",
1567
1574
  "73e94b73830d8961026947d99bf4ac5f959b5bca025203b913d8a6363cbe2b9f",
1568
1575
  "803752f85a9f013014be5d04e444c65f7d3eea476569c5d7048d4d13b8e0bb9e",
1569
1576
  "847dfca48823a495fc3de47dfdb4b26e49d6808c6570c46d024a99793df1d370",
@@ -1817,6 +1824,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1817
1824
  "2c678c75d6de9b029f27fa6eec45b8421217a4fb5ef3412ad39883e10b213987",
1818
1825
  "2d9ca0841db9e75ddfd95e3a4d485fd01aa639a512ef648996f565b7991d8e2b",
1819
1826
  "3a9ec1ff863693a0fa0fef0ade6fa5534485d0249fb78302d37cdac3781348f1",
1827
+ "3f8c59e0f4ad43211f6964b4889f3fc673bee0496ec3613bdbdc4544424340f6",
1820
1828
  "41b967f736d7fcf9d8928ad7c8cc767c5beb9544e2fbfa3317c65734aa02fcfd",
1821
1829
  "4372872a448d3b943dea5541167ed5be6155703b57b9f8224d19ddefd3b80898",
1822
1830
  "475cb9ef86c2ac54c8fd8cb6d63e9a55ea8cd0017e26efbe3c44391a4aeeab41",
@@ -1829,6 +1837,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1829
1837
  "62f53156d425198609564e996f3844f52ab110e63f99f22e8338fcf7a83f316d",
1830
1838
  "653252b0bbfd19bdffa046a2173c053e566ac6bf605c8d9060f9cf71ad7386de",
1831
1839
  "6a8be8577242588f14ab52b25b2a22b6def53aefa9513b4d3d7c49f4d8027079",
1840
+ "6afe796f5a69a68a026c79a64aa451eb2bf7907a0ace0d9079f8b4add16b886b",
1832
1841
  "6dadc065016e741568b6ae1ebcd0378085005c076fdb93346bad7c1fd9e3b8b8",
1833
1842
  "71ff42c2cb8ac7022e0066132476764dd745fb92bf19ccd395000b1fe319abc7",
1834
1843
  "7573b5170fcba45ccd6123cd0712baba8faa52f063b639140be829d9ce87327d",
@@ -1853,6 +1862,7 @@ export const LISA_OWNED_HASH_HISTORY_DERIVED = Object.freeze({
1853
1862
  "bdeab1cb7cbb427a69bc8450075b5d36d364a7b71e69666fef6966097084b0a5",
1854
1863
  "c78df9d5f0f3fc388ff60a7db5898478a2a4d6d7325833f10384b5a8c62bb34a",
1855
1864
  "cdea136456d2b9880c3abd2bbd402fb08051822b9e92484c66cd184ec7ed7d0f",
1865
+ "ce1520dba31d32856d470cf744700e0636518dd3a767627d42ac0a77007d6813",
1856
1866
  "ce5ff040b1f75925bc286027dcb893649edfb408021916920b07105baa2d9949",
1857
1867
  "d0321aa197d748813a2451eaa1c509cc74aced19a7f887b14a4bb15a7a73a525",
1858
1868
  "d1b07166efb5534c1d9b0fd09032e311854477160244f5318925035392318d36",