@event4u/agent-config 14.14.0 → 14.15.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 (72) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +63 -0
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/create.md +16 -1
  7. package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
  8. package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
  9. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli/registry.js +1 -0
  12. package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
  13. package/dist/cli-delegate/cmd_conformance.js +513 -17
  14. package/dist/cli-delegate/cmd_doctor.js +1 -1
  15. package/dist/discovery/deprecation-report.md +1 -1
  16. package/dist/discovery/discovery-manifest.json +5 -5
  17. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  18. package/dist/discovery/discovery-manifest.summary.md +1 -1
  19. package/dist/discovery/dormancy-report.md +1 -1
  20. package/dist/discovery/orphan-report.md +1 -1
  21. package/dist/discovery/packs.json +2 -2
  22. package/dist/discovery/trust-report.md +1 -1
  23. package/dist/discovery/workspaces.json +2 -2
  24. package/dist/hooks/dispatch.js +1043 -390
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/dist/mcp/server.json +2 -2
  27. package/dist/mcp/server.mjs +703 -115
  28. package/package.json +1 -1
  29. package/src/config/assurance-capability-registry.json +1 -1
  30. package/src/config/evaluator-budgets.json +4 -4
  31. package/src/config/gate-coverage.yml +62 -2
  32. package/src/config/gate-reachability-exemptions.json +1 -0
  33. package/src/config/gate-violation-baselines.json +4 -3
  34. package/src/config/rule-enforcement-baseline.json +0 -1
  35. package/src/config/web-launch-readiness.json +4 -2
  36. package/src/scripts/_cli/_doctor_emit.ts +71 -0
  37. package/src/scripts/_cli/_doctor_strict.ts +163 -0
  38. package/src/scripts/_cli/cmd_doctor.ts +16 -37
  39. package/src/scripts/_dispatch.bash +11 -0
  40. package/src/scripts/_lib/branch_convergence.ts +186 -0
  41. package/src/scripts/_lib/context_observation.ts +132 -0
  42. package/src/scripts/_lib/continuation_ladder.ts +188 -0
  43. package/src/scripts/_lib/experiment_binding.ts +95 -0
  44. package/src/scripts/_lib/ignored_blocker.ts +8 -1
  45. package/src/scripts/_lib/outcome_envelope.ts +6 -1
  46. package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
  47. package/src/scripts/_lib/release_highlights.ts +75 -10
  48. package/src/scripts/_lib/repeated_failure.ts +19 -2
  49. package/src/scripts/_lib/runtime_journal.ts +11 -4
  50. package/src/scripts/check_agent_artifact_location.ts +337 -0
  51. package/src/scripts/check_artefact_count_messaging.ts +74 -8
  52. package/src/scripts/check_enforcement_coverage.ts +40 -1
  53. package/src/scripts/check_release_highlights.ts +17 -1
  54. package/src/scripts/check_web_launch_readiness.ts +310 -20
  55. package/src/scripts/evolution_lab.ts +53 -1
  56. package/src/scripts/generate_capability_matrix.ts +16 -9
  57. package/src/scripts/git_authorization_hook.ts +476 -6
  58. package/src/scripts/grade_target_readiness.ts +221 -2
  59. package/src/scripts/hook_effect_doctor.ts +47 -4
  60. package/src/scripts/hooks/block_unauthorized_git.ts +238 -9
  61. package/src/scripts/hooks/merge_impact.ts +207 -0
  62. package/src/scripts/hooks/run_continuation_hook.ts +78 -88
  63. package/src/scripts/hooks/session_eol_hook.ts +12 -1
  64. package/src/scripts/install-hooks.sh +18 -0
  65. package/src/scripts/lint_code_comments.ts +106 -1
  66. package/src/scripts/release.ts +5 -17
  67. package/src/scripts/release_drill.ts +76 -1
  68. package/src/scripts/release_env.ts +31 -0
  69. package/src/scripts/release_publication.ts +125 -1
  70. package/src/scripts/roadmap_context.ts +15 -0
  71. package/src/scripts/skill_scout.ts +633 -0
  72. package/src/scripts/sync_pr_branch.ts +521 -42
@@ -25,11 +25,27 @@
25
25
  * { "session_id": str, "detected_at": iso8601, "authorized": [op, …],
26
26
  * "evidence": { op: "phrase" }, "prompt_chars": int }
27
27
  *
28
- * Each user turn REPLACES the ledger — that is the point. `commit-policy`'s
28
+ * Each user turn REPLACES `authorized` — that is the point. `commit-policy`'s
29
29
  * "one-shot authorization is not a standing license" says an authorization is
30
30
  * spent on the operation it named; carrying it forward is the exact inference
31
31
  * the rule forbids, so a new prompt with no authorization phrase yields an
32
- * empty ledger rather than an inherited one.
32
+ * empty `authorized` list rather than an inherited one.
33
+ *
34
+ * **`grants` are the one exception, and they are narrower, not broader**
35
+ * (ADR-252, 2026-09-03). A bare operation name says *the user wants a merge*;
36
+ * a grant says *the user wants THESE pull requests merged*, with the numbers
37
+ * frozen from the user's own sentence at mint time and each spent on first use.
38
+ * Because the objects are fixed, elapsed time is no longer what protects the
39
+ * user — target identity is — so a grant survives later turns and carries no
40
+ * clock, while everything without frozen objects keeps both the replacement
41
+ * rule above and the guard's 30-minute freshness bound.
42
+ *
43
+ * The measured defect this fixes: a neutral follow-up ("weiter", "fix the CI")
44
+ * replaced the whole ledger and silently erased a merge authorization the user
45
+ * had given two turns earlier, so a multi-PR run the user explicitly ordered
46
+ * became unexecutable without re-typing the order every 30 minutes. Two
47
+ * hand-widenings of the guard's clock (2026-08-21, 2026-08-30) are what that
48
+ * pressure produced instead.
33
49
  *
34
50
  * Never blocks. Exit 0 always. The blocking half lives in the pre_tool_use
35
51
  * concern, which is where the operation is actually observable.
@@ -170,7 +186,51 @@ export type GitOp =
170
186
  | "pr-merge-auto"
171
187
  | "tag"
172
188
  | "release"
173
- | "publish";
189
+ | "publish"
190
+ // ── Added 2026-09-03. Measured gap, not a speculative widening: of 25
191
+ // borderline-destructive operations probed against `commandOp`, 17 classified
192
+ // as NOTHING — the guard was sharp on five spellings and silent on the rest,
193
+ // including three that are strictly worse than the ones it caught (deleting
194
+ // branch protection, approving a review that releases an armed auto-merge,
195
+ // and unpublishing a package every lockfile already resolved).
196
+ //
197
+ // Each entry below is an operation the `non-destructive-by-default` Hard
198
+ // Floor already declares never-autonomous in prose. Naming them here makes an
199
+ // existing prohibition mechanical; it does not create a new one.
200
+ /** `npm unpublish` — the "undo" that breaks every lockfile resolving it. */
201
+ | "unpublish"
202
+ /** `npm deprecate` — no code changes, and every install in the world warns. */
203
+ | "deprecate"
204
+ /** Replacing or removing a published release asset consumers checksummed. */
205
+ | "release-asset"
206
+ /** Removing a branch protection rule or ruleset — deletes the guard itself. */
207
+ | "protection"
208
+ /** Disabling a workflow — no data lost, and the gate below it is gone. */
209
+ | "workflow-toggle"
210
+ /** Archiving, deleting or re-visibility-ing a repository. */
211
+ | "repo-lifecycle"
212
+ /** Approving a review — no code moves, and a required-review gate opens. */
213
+ | "review-approve"
214
+ /** Force-push — discards commits that arrived after your last fetch. */
215
+ | "force-push"
216
+ /** `git worktree remove --force` — destroys work a parallel session holds. */
217
+ | "worktree-remove"
218
+ /** `git clean -x` — takes `.env` and local certificates with the scratch. */
219
+ | "clean-ignored"
220
+ /** Moving an existing tag locally; the push that publishes it is `tag`. */
221
+ | "tag-force"
222
+ /** Rebase — local until pushed, then a history rewrite for everyone. */
223
+ | "rebase"
224
+ /** `git reset --hard` — uncommitted work does not reach the reflog. */
225
+ | "reset-hard"
226
+ /** `git clean` without `-x` — untracked only, ignored files survive. */
227
+ | "clean"
228
+ /** Dropping or clearing a stash. */
229
+ | "stash-drop"
230
+ /** Deleting a branch, locally or on the remote. */
231
+ | "branch-delete"
232
+ /** Closing a pull request or an issue. */
233
+ | "close";
174
234
 
175
235
  export const ALL_OPS: readonly GitOp[] = [
176
236
  "commit",
@@ -182,6 +242,23 @@ export const ALL_OPS: readonly GitOp[] = [
182
242
  "tag",
183
243
  "release",
184
244
  "publish",
245
+ "unpublish",
246
+ "deprecate",
247
+ "release-asset",
248
+ "protection",
249
+ "workflow-toggle",
250
+ "repo-lifecycle",
251
+ "review-approve",
252
+ "force-push",
253
+ "worktree-remove",
254
+ "clean-ignored",
255
+ "tag-force",
256
+ "rebase",
257
+ "reset-hard",
258
+ "clean",
259
+ "stash-drop",
260
+ "branch-delete",
261
+ "close",
185
262
  ];
186
263
 
187
264
  /**
@@ -195,7 +272,17 @@ export const ALL_OPS: readonly GitOp[] = [
195
272
  const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
196
273
  { op: "commit", re: /\b(commit(e|et|te|ten)?|committe|einchecken)\b/i },
197
274
  { op: "push", re: /\b(push(e|en|t)?|hochladen|hochschieben|raufschieben)\b/i },
198
- { op: "branch", re: /\b(branch|feature-branch|zweig)\b/i },
275
+ // A CREATION VERB IS REQUIRED, as for `pr-create` above and for the same
276
+ // reason. The bare noun could not tell an authorization from a mention, and
277
+ // the mention is the dangerous half: "den branch nicht löschen" names the
278
+ // object precisely in order to forbid acting on it, yet authorized branch
279
+ // creation — the negation trails a bare-noun match, so neither the
280
+ // look-behind nor the span check in `negatedBefore` can reach it. Widened
281
+ // 2026-09-03 alongside the destructive-coverage work that measured it.
282
+ {
283
+ op: "branch",
284
+ re: /\b(erstell(e|en)?|mach(e|en)?|leg(e|en)?\s+an|anleg(e|en)?|(er|be)?(ö|oe)ffne|open|create|new|neuer?|starte?|checkout|wechsl?e|switch)\b[^.\n]{0,30}\b(branch|feature-branch|zweig)\b|\b(branch|feature-branch|zweig)\b[^.\n]{0,25}\b(erstellen|anlegen|aufmachen|(ö|oe)ffnen|starten)\b/i,
285
+ },
199
286
  // A creation verb is required — "schau dir den PR an" is not "open a PR".
200
287
  {
201
288
  op: "pr-create",
@@ -233,6 +320,111 @@ const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
233
320
  re: /\b(releasen?|ver(ö|oe)ffentlich(e|en))\b(?!\s*[- ]?(notes?|branch|candidate|pr\b|datum|date|zweig))/i,
234
321
  },
235
322
  { op: "publish", re: /\b(publish(e|en)?|publiziere[n]?)\b/i },
323
+
324
+ // ── Authorization vocabulary for the ops added 2026-09-03.
325
+ //
326
+ // NON-NEGOTIABLE, and the reason it ships in the same change as the new BLOCK
327
+ // ops: a blocked operation with no phrase that authorizes it is a dead end.
328
+ // The guard refuses, the user says "doch, mach das", the classifier records
329
+ // nothing, and the refusal repeats forever. Every op the guard can block must
330
+ // have a sentence that unblocks it, or the user's override is a claim rather
331
+ // than a mechanism.
332
+ //
333
+ // Tight on purpose. Under-matching costs one confirmation; over-matching
334
+ // authorizes something the user did not ask for, so each pattern demands the
335
+ // object noun rather than a bare verb wherever the verb is ordinary German.
336
+ //
337
+ // `unpublish` before nothing in particular — `\bpublish\b` does not match
338
+ // inside "unpublish" (no word boundary between `n` and `p`), so the two never
339
+ // collide.
340
+ // TWO SHAPES EVERY GERMAN PATTERN BELOW MUST CARRY, both learned from tests
341
+ // that went red rather than from inspection:
342
+ //
343
+ // 1. THE IMPERATIVE DROPS ITS `-e`. "entfern die worktree", "archivier das
344
+ // repo", "deaktivier den workflow" are what people type; `entfern(e|en)`
345
+ // matches none of them. Every verb ending is therefore `(e|en)?`.
346
+ // 2. WORD ORDER GOES BOTH WAYS. "archivier das repo" and "das repo
347
+ // archivieren" are one instruction, and a pattern anchored on the noun
348
+ // first sees only the second.
349
+ //
350
+ // The object noun stays REQUIRED in every pattern that has one. That is what
351
+ // keeps the loosened verb endings from over-authorizing: "lösch" alone
352
+ // authorizes nothing, "lösch den branch" authorizes a branch deletion.
353
+ { op: "unpublish", re: /\bunpublish(e|en)?\b/i },
354
+ {
355
+ op: "deprecate",
356
+ re: /\bdeprecate(d|n)?\b|\bals\s+veraltet\s+markier(e|en)?\b/i,
357
+ },
358
+ {
359
+ op: "release-asset",
360
+ re: /\brelease[-\s]?asset\b|\b(clobber|(ü|ue)berschreib(e|en)?|ersetz(e|en)?|replace|austausch(e|en)?)\b[^.\n]{0,25}\b(asset|artefakt|artifact|tarball|binary)\b|\b(asset|artefakt|artifact)\b[^.\n]{0,25}\b((ü|ue)berschreiben|ersetzen|austauschen|replace)\b/i,
361
+ },
362
+ // A VERB IS REQUIRED, like `tag` and `release` above and for the same reason.
363
+ // A bare noun cannot tell an authorization from a mention, and here the
364
+ // mention is the dangerous half: "die branch-protection nicht anfassen" names
365
+ // the object precisely in order to forbid touching it. Measured — this was the
366
+ // one row of the negation corpus that neither the look-behind nor the
367
+ // span check could reach, because the negation trails a bare-noun match.
368
+ {
369
+ op: "protection",
370
+ re: /\b(entfern(e|en)?|l(ö|oe)sch(e|en)?|deaktivier(e|en)?|nimm|weg|remove|delete|disable|drop|lift)\b[^.\n]{0,30}\b(branch[-\s]?protection|protection[-\s]?rule|schutzregel|ruleset)\b|\b(branch[-\s]?protection|protection[-\s]?rule|schutzregel|ruleset)\b[^.\n]{0,30}\b(entfernen|l(ö|oe)schen|deaktivieren|abschalten|remove|delete|disable|drop|lift|weg)\b/i,
371
+ },
372
+ {
373
+ op: "workflow-toggle",
374
+ re: /\bworkflow\b[^.\n]{0,25}\b(deaktivier(e|en)?|aktivier(e|en)?|disable|enable|abschalt(e|en)?|ausschalt(e|en)?)\b|\b(deaktivier(e|en)?|aktivier(e|en)?|disable|enable|abschalt(e|en)?|ausschalt(e|en)?)\b[^.\n]{0,25}\bworkflow\b/i,
375
+ },
376
+ {
377
+ op: "repo-lifecycle",
378
+ re: /\b(repo|repository)\b[^.\n]{0,30}\b(archivier(e|en)?|archive|l(ö|oe)sch(e|en)?|delete)\b|\b(archivier(e|en)?|archive|l(ö|oe)sch(e|en)?|delete)\b[^.\n]{0,25}\b(repo|repository)\b|\b(repo|repository)\b[^.\n]{0,30}\bauf\s+(privat|public|öffentlich|private)\b/i,
379
+ },
380
+ {
381
+ op: "review-approve",
382
+ re: /\b(approve|genehmig(e|en)?|freigeb(e|en)?|abnehm(e|en)?)\b[^.\n]{0,30}\b(review|pr|pull[-\s]request)\b|\b(review|pr|pull[-\s]request)\b[^.\n]{0,20}\b(approve|approven|genehmigen|freigeben)\b/i,
383
+ },
384
+ {
385
+ op: "force-push",
386
+ re: /\bforce[-\s]?push(e|en|st)?\b|--force(-with-lease)?\b|\bmit\s+gewalt\s+push(e|en)?\b/i,
387
+ },
388
+ {
389
+ op: "worktree-remove",
390
+ re: /\bworktree\b[^.\n]{0,25}\b(entfern(e|en)?|l(ö|oe)sch(e|en)?|remove|delete|weg(werfen|r(ä|ae)umen))\b|\b(entfern(e|en)?|l(ö|oe)sch(e|en)?|remove|delete)\b[^.\n]{0,25}\bworktree\b/i,
391
+ },
392
+ // The `-x` flag is the whole distinction: without it `git clean` spares
393
+ // ignored files, with it `.env` and local certificates go too.
394
+ {
395
+ op: "clean-ignored",
396
+ re: /\bgit\s+clean\b[^.\n]{0,20}-[A-Za-z]*x|\b(ignorierte|ignored)\b[^.\n]{0,25}\b(dateien|files)\b[^.\n]{0,25}\b(l(ö|oe)sch(e|en)?|entfern(e|en)?|remove|delete)\b/i,
397
+ },
398
+ {
399
+ op: "tag-force",
400
+ re: /\btag\b[^.\n]{0,25}\b(verschieb(e|en)?|(ü|ue)berschreib(e|en)?|umh(ä|ae)ng(e|en)?|force|move)\b|\b(verschieb(e|en)?|(ü|ue)berschreib(e|en)?)\b[^.\n]{0,20}\btag\b/i,
401
+ },
402
+ { op: "rebase", re: /\brebase(n|st|d)?\b/i },
403
+ {
404
+ op: "reset-hard",
405
+ re: /\breset\s+--hard\b|\bhard\s+reset\b|\bhart\s+zur(ü|ue)cksetz(e|en)?\b/i,
406
+ },
407
+ { op: "clean", re: /\bgit\s+clean\b/i },
408
+ {
409
+ op: "stash-drop",
410
+ re: /\bstash\b[^.\n]{0,25}\b(drop|clear|verwerf(e|en)?|l(ö|oe)sch(e|en)?)\b|\b(verwerf(e|en)?|l(ö|oe)sch(e|en)?|drop)\b[^.\n]{0,20}\bstash\b/i,
411
+ },
412
+ {
413
+ op: "branch-delete",
414
+ re: /\b(l(ö|oe)sch(e|en)?|entfern(e|en)?|delete|remove)\b[^.\n]{0,25}\b(branch|branches|zweig)\b|\b(branch|branches|zweig)\b[^.\n]{0,25}\b(l(ö|oe)schen|entfernen|delete|remove)\b/i,
415
+ },
416
+ {
417
+ op: "close",
418
+ re: /\b(schlie(ß|ss)(e|en)?|close)\b[^.\n]{0,30}\b(pr|pull[-\s]request|issue|ticket)\b|\b(pr|pull[-\s]request|issue|ticket)\b[^.\n]{0,20}\b(schlie(ß|ss)en|close)\b/i,
419
+ },
420
+ // Enabling auto-merge is its own op on the guard side, so it needs its own
421
+ // sentence. Without one it is the dead end this block exists to prevent: the
422
+ // plain merge phrase authorizes `pr-merge` and never `pr-merge-auto`, so the
423
+ // refusal would have repeated forever.
424
+ {
425
+ op: "pr-merge-auto",
426
+ re: /\bauto[-\s]?merge\b|\bmerge\b[^.\n]{0,20}\bautomatisch\b|\bautomatisch\b[^.\n]{0,20}\bmerg(e|en)\b/i,
427
+ },
236
428
  ];
237
429
 
238
430
  /**
@@ -358,12 +550,196 @@ function _commandLines(fence: string): string[] {
358
550
  .filter((l) => /^(git|gh|npm|pnpm|yarn|task)\b/.test(l));
359
551
  }
360
552
 
553
+ /**
554
+ * A standing, target-bound, single-use capability minted from the user's own
555
+ * words — the ADR-252 shape.
556
+ *
557
+ * A `GitOp` in `authorized` says *the user named this operation on this turn*
558
+ * and is governed by the guard's freshness clock. A grant says something
559
+ * strictly narrower: *the user named this operation AND the exact objects it
560
+ * may act on*. Because the objects are frozen at grant time, elapsed time stops
561
+ * being the thing that protects the user — target identity is — so a grant
562
+ * carries no clock.
563
+ *
564
+ * The narrowing is what buys the clock exemption, and it is why only
565
+ * enumerable operations can hold one. `npm publish` names a version, not the
566
+ * bytes that will be published, so no prompt can freeze its effect; it holds no
567
+ * grants and keeps the clock. See ADR-252 § Per-operation verdict.
568
+ */
569
+ export type MergeGrant = {
570
+ /** Replay-resistant id — session slug, mint time, and the frozen target set. */
571
+ id: string;
572
+ /** Only `pr-merge` mints grants today. The field exists so the reader of a
573
+ * stored ledger never has to infer the op from the grant's shape. */
574
+ op: GitOp;
575
+ /** PR numbers named by the human, frozen at mint time. Never a wildcard: a
576
+ * cardinality word with no numbers mints nothing (ADR-252 § What "all" does
577
+ * not buy). */
578
+ targets: number[];
579
+ /** Per-target single-use state. A merged target is spent and cannot be
580
+ * replayed, which is what makes a force-push back to a merged SHA harmless. */
581
+ consumed: number[];
582
+ granted_at: string;
583
+ /** The literal human phrase, for audit. Never the enforcement key. */
584
+ evidence: string;
585
+ };
586
+
361
587
  export interface Ledger extends JsonObject {
362
588
  session_id: string;
363
589
  detected_at: string;
364
590
  authorized: GitOp[];
365
591
  evidence: { [op: string]: string };
366
592
  prompt_chars: number;
593
+ /**
594
+ * Standing grants, carried across the session's human turns.
595
+ *
596
+ * Optional so a ledger written by an older build still parses. Absent reads
597
+ * as "no grants", which is the pre-ADR-252 behaviour exactly.
598
+ */
599
+ grants?: MergeGrant[];
600
+ }
601
+
602
+ /**
603
+ * PR numbers the prompt names, as merge targets.
604
+ *
605
+ * Deliberately narrow. It matches a `#`-prefixed or `PR `-prefixed integer and
606
+ * nothing else, so a bare number in prose ("merge the 3 branches") freezes no
607
+ * target and mints no grant. Over-matching here would hand a clockless
608
+ * capability to a sentence that never identified an object, which is the exact
609
+ * conflation of lexical and object specificity both council seats rejected.
610
+ */
611
+ export function extractMergeTargets(prompt: string): number[] {
612
+ const out = new Set<number>();
613
+ const re = /(?:#|\bpr[- ]?|\bpull[- ]request\s+#?)(\d{1,7})\b/giu;
614
+ let m: RegExpExecArray | null;
615
+ while ((m = re.exec(prompt)) !== null) {
616
+ const n = Number.parseInt(m[1] as string, 10);
617
+ if (Number.isSafeInteger(n) && n > 0) {
618
+ out.add(n);
619
+ }
620
+ }
621
+ return [...out].sort((a, b) => a - b);
622
+ }
623
+
624
+ /**
625
+ * A human turn that withdraws standing merge authority.
626
+ *
627
+ * Revocation must be cheaper to express than authorization, so this matches a
628
+ * bare stop word on its own as well as an explicit "don't merge". It is
629
+ * deliberately over-inclusive in the safe direction: a false revocation costs
630
+ * one prompt, a missed revocation costs an unwanted merge.
631
+ */
632
+ const REVOKE_RE =
633
+ /\b(stop|stopp|halt|abbrechen|abbruch|cancel|revoke|widerruf(en)?|zur(ü|ue)ck(nehmen|ziehen)|hold\s+(everything|on|the\s+merge)|warte|wait)\b|(?:\b(nicht|kein(e|en)?|no|not|dont|don't|never|niemals|nie)\b[^.!?\n]{0,30}\b(merg(e|en|ing)|zusammenf(ü|ue)hren)\b)/iu;
634
+
635
+ /** Does this human turn withdraw standing merge grants? */
636
+ export function isRevocation(prompt: string): boolean {
637
+ return REVOKE_RE.test(prompt);
638
+ }
639
+
640
+ /** Mint a grant id that a replayed ledger cannot collide with. */
641
+ function _grantId(session_id: string, at: string, targets: number[]): string {
642
+ return `${sessionSlug(session_id) || "nosession"}:${at}:${targets.join(",")}`;
643
+ }
644
+
645
+ /**
646
+ * Fold this turn's authorization into the grants the session already holds.
647
+ *
648
+ * Three rules, and the order is the contract:
649
+ * 1. A revocation drops every standing grant. It wins over anything else in
650
+ * the same prompt — "merge #1 but stop if it conflicts" revokes.
651
+ * 2. An explicit `pr-merge` naming PR numbers mints one grant over those
652
+ * numbers.
653
+ * 3. Everything else carries the existing grants forward untouched. This is
654
+ * the clause that fixes the measured defect: before ADR-252 a neutral
655
+ * "weiter" replaced the whole ledger and silently erased authority the
656
+ * user had given two turns earlier.
657
+ *
658
+ * Consumption is NOT done here — the guard consumes a target when it actually
659
+ * lets the merge through, so a grant that was never acted on stays whole.
660
+ */
661
+ export function foldGrants(
662
+ prior: MergeGrant[],
663
+ prompt: string,
664
+ authorized: GitOp[],
665
+ session_id: string,
666
+ now: Date,
667
+ ): MergeGrant[] {
668
+ if (isRevocation(prompt)) {
669
+ return [];
670
+ }
671
+ const carried = prior.filter((g) => g.consumed.length < g.targets.length);
672
+ if (!authorized.includes("pr-merge")) {
673
+ return carried;
674
+ }
675
+ const targets = extractMergeTargets(prompt);
676
+ if (targets.length === 0) {
677
+ return carried;
678
+ }
679
+ const at = now.toISOString();
680
+ const already = new Set(carried.flatMap((g) => g.targets));
681
+ const fresh = targets.filter((t) => !already.has(t));
682
+ if (fresh.length === 0) {
683
+ return carried;
684
+ }
685
+ return [
686
+ ...carried,
687
+ {
688
+ id: _grantId(session_id, at, fresh),
689
+ op: "pr-merge",
690
+ targets: fresh,
691
+ consumed: [],
692
+ granted_at: at,
693
+ evidence: prompt.trim().slice(0, 120),
694
+ },
695
+ ];
696
+ }
697
+
698
+ /**
699
+ * Negation words that turn an instruction into its opposite.
700
+ *
701
+ * ONE vocabulary for the whole file, deliberately. The `pr-merge` pattern
702
+ * already carried this list inline, and its own comment warns that "two
703
+ * negation vocabularies in one tree drift, and the drift is invisible until a
704
+ * prompt lands in the gap between them" — so the list moved here rather than
705
+ * being copied fifteen times.
706
+ */
707
+ const NEGATION_WORD =
708
+ /\b(nicht|nichts|kein(e|en|em|er|es)?|niemals|nie|no|not|dont|don't|never|without|ohne)\b/i;
709
+
710
+ /**
711
+ * Is the match at `index` under a negation?
712
+ *
713
+ * MEASURED, not anticipated. Probed on 2026-09-03 against the fifteen phrases
714
+ * added that day: **15 of 15 leaked** — "nicht unpublishen", "den workflow nicht
715
+ * deaktivieren", "kein force-push bitte" each authorized exactly the operation
716
+ * the sentence forbade. The same probe showed the hole predates them, since
717
+ * "kein force-push bitte" also authorized a plain `push` and "den branch nicht
718
+ * löschen" also authorized `branch`. Only `pr-merge` was protected, by a
719
+ * lookbehind written after the identical defect was found there in isolation.
720
+ *
721
+ * SENTENCE-SCOPED, and that bound is load-bearing rather than decorative.
722
+ * "Do not push. Merge PR #12." is two instructions, and a negation whose reach
723
+ * crossed the full stop would silently stop authorizing merges the user DID
724
+ * order — a failure worse than the defect, because nothing happens and nothing
725
+ * says why. The 30-character window inside the sentence is inherited from the
726
+ * `pr-merge` lookbehind that this generalises.
727
+ */
728
+ export function negatedBefore(text: string, index: number, matched = ""): boolean {
729
+ let start = 0;
730
+ for (const mark of [".", "!", "?", "\n"]) {
731
+ const at = text.lastIndexOf(mark, index - 1);
732
+ if (at + 1 > start) {
733
+ start = at + 1;
734
+ }
735
+ }
736
+ const before = text.slice(Math.max(start, index - 30), index);
737
+ // The negation is often INSIDE the match, not before it, and German word
738
+ // order is why: "das asset nicht ersetzen" is matched by a noun-first pattern
739
+ // that starts at "asset", so a look-behind from the match index sees an empty
740
+ // window. Measured — nine of the fifteen rows in the negation corpus failed
741
+ // exactly this way after the look-behind alone was added.
742
+ return NEGATION_WORD.test(before) || NEGATION_WORD.test(matched);
367
743
  }
368
744
 
369
745
  /** Classify which ops a prompt authorizes. Exported for direct testing. */
@@ -384,7 +760,7 @@ export function classifyAuthorization(prompt: string): {
384
760
 
385
761
  for (const { op, re } of isInterrogative(instruction) ? [] : PHRASES) {
386
762
  const m = re.exec(instruction);
387
- if (m) {
763
+ if (m && !negatedBefore(instruction, m.index, m[0])) {
388
764
  authorized.add(op);
389
765
  evidence[op] = `prose: "${m[0]}"`;
390
766
  }
@@ -418,6 +794,88 @@ export function classifyAuthorization(prompt: string): {
418
794
  return { authorized: [...authorized], evidence };
419
795
  }
420
796
 
797
+ /** Grants the session already holds, or `[]` for any ledger that has none. */
798
+ export function readGrants(consumer_root: string, session_id: string): MergeGrant[] {
799
+ try {
800
+ const raw = fs.readFileSync(path.join(consumer_root, ledgerFileFor(session_id)), "utf8");
801
+ const decoded = JSON.parse(raw) as unknown;
802
+ if (!_isObject(decoded) || !Array.isArray(decoded["grants"])) {
803
+ return [];
804
+ }
805
+ // A grant recorded against a different session is another conversation's
806
+ // consent, exactly as the ledger's own session check treats `authorized`.
807
+ const owner = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
808
+ if (session_id && owner && owner !== session_id) {
809
+ return [];
810
+ }
811
+ return (decoded["grants"] as unknown[]).flatMap((g) => {
812
+ if (!_isObject(g)) return [];
813
+ const op = g["op"];
814
+ if (typeof op !== "string" || !(ALL_OPS as readonly string[]).includes(op)) return [];
815
+ const targets = Array.isArray(g["targets"]) ? g["targets"] : [];
816
+ const consumed = Array.isArray(g["consumed"]) ? g["consumed"] : [];
817
+ return [
818
+ {
819
+ id: String(g["id"] ?? ""),
820
+ op: op as GitOp,
821
+ targets: targets.filter((t): t is number => typeof t === "number"),
822
+ consumed: consumed.filter((t): t is number => typeof t === "number"),
823
+ granted_at: String(g["granted_at"] ?? ""),
824
+ evidence: String(g["evidence"] ?? ""),
825
+ },
826
+ ];
827
+ });
828
+ } catch {
829
+ return [];
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Mark one target spent, in place, on the session's ledger.
835
+ *
836
+ * Called by the guard at the moment it lets a merge through — not by the agent,
837
+ * and not at mint time. A grant the run never reached stays whole, and a target
838
+ * that merged can never be replayed, which is what makes a force-push back to
839
+ * an already-merged SHA harmless.
840
+ */
841
+ export function consumeGrantTarget(
842
+ consumer_root: string,
843
+ session_id: string,
844
+ target: number,
845
+ ): void {
846
+ const file = path.join(consumer_root, ledgerFileFor(session_id));
847
+ let decoded: unknown;
848
+ try {
849
+ decoded = JSON.parse(fs.readFileSync(file, "utf8"));
850
+ } catch {
851
+ return;
852
+ }
853
+ if (!_isObject(decoded) || !Array.isArray(decoded["grants"])) {
854
+ return;
855
+ }
856
+ let touched = false;
857
+ for (const g of decoded["grants"] as unknown[]) {
858
+ if (!_isObject(g) || !Array.isArray(g["targets"]) || !Array.isArray(g["consumed"])) {
859
+ continue;
860
+ }
861
+ const targets = g["targets"] as JsonValue[];
862
+ const consumed = g["consumed"] as JsonValue[];
863
+ if (targets.includes(target) && !consumed.includes(target)) {
864
+ consumed.push(target);
865
+ touched = true;
866
+ }
867
+ }
868
+ if (touched) {
869
+ try {
870
+ atomic_write_json(file, decoded as JsonObject);
871
+ } catch {
872
+ /* Observability only. A failed write leaves the target unspent, which
873
+ re-allows one merge of a PR the user did authorize by name — it fails
874
+ toward the user's stated intent, never toward an unauthorized target. */
875
+ }
876
+ }
877
+ }
878
+
421
879
  /**
422
880
  * Read the refused operation, and consume it in the same breath.
423
881
  *
@@ -525,12 +983,24 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
525
983
  `"${prompt.trim().slice(0, 40)}"`;
526
984
  }
527
985
 
986
+ const now = new Date();
528
987
  const ledger: Ledger = {
529
988
  session_id: typeof envelope["session_id"] === "string" ? envelope["session_id"] : "",
530
- detected_at: new Date().toISOString(),
989
+ detected_at: now.toISOString(),
531
990
  authorized,
532
991
  evidence,
533
992
  prompt_chars: prompt.length,
993
+ // Grants survive this write; `authorized` does not. That asymmetry IS the
994
+ // decision. A bare operation name stays one-shot exactly as `commit-policy`
995
+ // requires, and only an authorization that also froze its objects earns a
996
+ // life longer than the turn. See ADR-252.
997
+ grants: foldGrants(
998
+ readGrants(options.consumer_root, session),
999
+ prompt,
1000
+ authorized,
1001
+ session,
1002
+ now,
1003
+ ),
534
1004
  };
535
1005
 
536
1006
  try {