@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
@@ -7,7 +7,7 @@
7
7
  * written in a command's markdown cannot be asserted by anything. So the
8
8
  * detection is here and the command invokes it; the command keeps the narrative.
9
9
  *
10
- * THE ANTI-VANITY RULE IS STRUCTURAL, NOT A STYLE NOTE. Ten dimensions are
10
+ * THE ANTI-VANITY RULE IS STRUCTURAL, NOT A STYLE NOTE. Thirteen dimensions are
11
11
  * graded 0 Absent / 1 Present / 2 Enforced-in-CI / 3 Independent-and-diff-scoped,
12
12
  * and the verdict is `min` over the FOUR knockout dimensions, reported as
13
13
  * `L<n> — bound by <dimension>`. **No aggregate number is ever emitted** — no
@@ -113,6 +113,66 @@ function _ciText(root: string): string {
113
113
  return out;
114
114
  }
115
115
 
116
+ /* -- bounded text corpus, for the Phase 4 dimensions ----------------------- */
117
+
118
+ /** Directories a target scan must never descend into: cost with no signal. */
119
+ const _SKIP_DIRS = new Set([
120
+ 'node_modules', '.git', 'vendor', 'dist', 'build', 'out', 'coverage',
121
+ '.next', '.nuxt', '.venv', 'venv', '__pycache__', 'target', '.terraform',
122
+ ]);
123
+ /** Extensions that can carry a DNS record, a policy doc or an ops runbook. */
124
+ const _CORPUS_RE = /\.(tf|tfvars|hcl|ya?ml|json|txt|zone|dns|ini|conf|cfg|toml|md|env|example|sh)$/i;
125
+ const _CORPUS_MAX_FILES = 2000;
126
+ const _CORPUS_MAX_BYTES = 256 * 1024;
127
+
128
+ /**
129
+ * Read a bounded slice of the target's text, once, for the dimensions that
130
+ * cannot be answered by a root-level `_exists` probe.
131
+ *
132
+ * Three dimensions need to see INSIDE files — a DMARC policy is a string in a
133
+ * zone file or a Terraform record, not a filename — and the existing probes are
134
+ * all filename-shaped. The walk is capped in three directions (skip list, file
135
+ * count, per-file bytes) because an unbounded read of an arbitrary target repo
136
+ * is a cost this grader has no way to predict.
137
+ *
138
+ * Returns the concatenated text AND the relative paths, because a dimension that
139
+ * says WHERE it saw something is auditable and one that only says "somewhere in
140
+ * the tree" is not.
141
+ */
142
+ export function _textCorpus(root: string): { text: string; paths: string[] } {
143
+ const paths: string[] = [];
144
+ let text = '';
145
+ const walk = (dir: string, rel: string): void => {
146
+ if (paths.length >= _CORPUS_MAX_FILES) return;
147
+ let entries: fs.Dirent[];
148
+ try {
149
+ entries = fs.readdirSync(dir, { withFileTypes: true });
150
+ } catch {
151
+ return;
152
+ }
153
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
154
+ if (paths.length >= _CORPUS_MAX_FILES) return;
155
+ const abs = path.join(dir, e.name);
156
+ const r = rel === '' ? e.name : `${rel}/${e.name}`;
157
+ if (e.isDirectory()) {
158
+ if (_SKIP_DIRS.has(e.name)) continue;
159
+ walk(abs, r);
160
+ continue;
161
+ }
162
+ if (!_CORPUS_RE.test(e.name)) continue;
163
+ try {
164
+ if (fs.statSync(abs).size > _CORPUS_MAX_BYTES) continue;
165
+ text += `${fs.readFileSync(abs, 'utf-8')}\n`;
166
+ } catch {
167
+ continue;
168
+ }
169
+ paths.push(r);
170
+ }
171
+ };
172
+ walk(root, '');
173
+ return { text, paths };
174
+ }
175
+
116
176
  /**
117
177
  * Is this a Python-primary target?
118
178
  *
@@ -125,7 +185,7 @@ export function isPythonTarget(root: string): boolean {
125
185
  return py && !other;
126
186
  }
127
187
 
128
- // ── the ten dimensions ─────────────────────────────────────────────────────
188
+ // ── the thirteen dimensions ──
129
189
 
130
190
  /**
131
191
  * Grade one dimension. `ci` decides 1 → 2: a thing that exists is `Present`; a
@@ -149,6 +209,113 @@ function _ciBlocks(ci: string, ...needles: string[]): boolean {
149
209
  return !allSoft;
150
210
  }
151
211
 
212
+ /* -- Phase 4 dimension ladders --------------------------------------------- */
213
+
214
+ interface Scored {
215
+ grade: Grade;
216
+ evidence: string;
217
+ }
218
+
219
+ /** A runbook, a restore procedure, or a rollback path — and whether CI runs it. */
220
+ function _recovery(corpus: { text: string; paths: string[] }, ci: string): Scored {
221
+ const namedFile = corpus.paths.some((p) => /(^|\/)(runbook|runbooks|disaster-recovery|dr)([./-]|$)/i.test(p));
222
+ const procedure = /\b(restore from backup|disaster recovery|rollback plan|pg_restore|pg_dump|restic|velero|RPO|RTO)\b/i.test(corpus.text);
223
+ const present = namedFile || procedure;
224
+ const enforced = _ciBlocks(ci, 'restore', 'rollback', 'disaster-recovery', 'backup');
225
+ // A DRILL is what separates a documented procedure from a proven one. It is
226
+ // the only honest route to 3 here, because "independent" for recovery means
227
+ // the restore was actually exercised rather than described.
228
+ const drill = /\b(restore (drill|test|rehearsal)|recovery drill|game ?day)\b/i.test(corpus.text);
229
+ const seen: string[] = [
230
+ ...(namedFile ? ['a runbook or DR document'] : []),
231
+ ...(procedure ? ['a restore/rollback procedure in the text'] : []),
232
+ ...(enforced ? ['a blocking CI step naming restore/rollback/backup'] : []),
233
+ ...(drill ? ['a restore drill'] : []),
234
+ ];
235
+ return {
236
+ grade: _graded(present, enforced, drill),
237
+ evidence: seen.length > 0 ? seen.join('; ') : 'no runbook, restore procedure or rollback path found',
238
+ };
239
+ }
240
+
241
+ /**
242
+ * SPF, DKIM and DMARC — and the rung that must not be fudged (4.2).
243
+ *
244
+ * 0 Absent — no SPF, DKIM or DMARC RECORD anywhere. Prose about DMARC is
245
+ * not a DMARC record; the signal is the record shape.
246
+ * 1 Present — SPF and/or DKIM published, OR a DMARC record with `p=none`.
247
+ * `p=none` is a MONITORING policy: it requests aggregate reports
248
+ * and instructs receivers to take no action on a failing message.
249
+ * A domain publishing it is measuring the problem, not preventing
250
+ * it, so it is capped here however much else sits alongside it.
251
+ * 2 Enforced — DMARC `p=quarantine` or `p=reject`, with SPF or DKIM to align
252
+ * against. An enforcing policy with nothing to align is a policy
253
+ * over no signal, and stays at 1.
254
+ * 3 Top — `p=reject` with BOTH SPF and DKIM and an `rua=` reporting
255
+ * address, so the enforcement can be observed rather than assumed.
256
+ */
257
+ function _mailAuthenticity(corpus: { text: string }): Scored {
258
+ const t = corpus.text;
259
+ const spf = /v=spf1\b/i.test(t);
260
+ const dkim = /v=DKIM1\b/i.test(t);
261
+ const dmarc = /v=DMARC1\b/i.test(t);
262
+ const policy = /v=DMARC1[^"'\n]*?\bp\s*=\s*(none|quarantine|reject)\b/i.exec(t)?.[1]?.toLowerCase();
263
+ const rua = /v=DMARC1[^"'\n]*?\brua\s*=/i.test(t);
264
+ const aligned = spf || dkim;
265
+ const parts = [
266
+ spf ? 'SPF published' : 'no SPF record',
267
+ dkim ? 'DKIM published' : 'no DKIM record',
268
+ dmarc ? `DMARC published (p=${policy ?? 'unset'})` : 'no DMARC record',
269
+ ];
270
+ if (!spf && !dkim && !dmarc) {
271
+ return { grade: 0, evidence: 'no SPF, DKIM or DMARC record found in the tree' };
272
+ }
273
+ if (policy === 'none') {
274
+ return {
275
+ grade: 1,
276
+ evidence:
277
+ `${parts.join('; ')} — capped at Present: p=none is a monitoring policy and is NOT protection, ` +
278
+ 'because receivers are told to take no action on a message that fails. A domain publishing it ' +
279
+ 'scores below one that enforces.',
280
+ };
281
+ }
282
+ if (policy === 'quarantine' || policy === 'reject') {
283
+ if (!aligned) {
284
+ return {
285
+ grade: 1,
286
+ evidence: `${parts.join('; ')} — an enforcing policy with neither SPF nor DKIM to align against protects nothing yet`,
287
+ };
288
+ }
289
+ if (policy === 'reject' && spf && dkim && rua) {
290
+ return { grade: 3, evidence: `${parts.join('; ')} — enforcing, both mechanisms aligned, and reporting to an rua address` };
291
+ }
292
+ return { grade: 2, evidence: `${parts.join('; ')} — an enforcing DMARC policy with an aligned mechanism` };
293
+ }
294
+ return { grade: 1, evidence: `${parts.join('; ')} — a mail-authenticity record exists, with no enforcing DMARC policy above it` };
295
+ }
296
+
297
+ /** A privacy notice, a retention rule, a consent mechanism, a deletion path. */
298
+ function _privacyObligations(corpus: { text: string; paths: string[] }, ci: string): Scored {
299
+ const notice =
300
+ corpus.paths.some((p) => /(privacy|datenschutz|\bdpa\b|data-processing)/i.test(p)) ||
301
+ /\b(privacy (policy|notice)|Datenschutzerkl)/i.test(corpus.text);
302
+ const retention = /\b(retention (policy|period|schedule)|data retention|storage limitation)\b/i.test(corpus.text);
303
+ const consent = /\b(cookie consent|consent (banner|manager|mode)|cookiebot|usercentrics|klaro|borlabs)\b/i.test(corpus.text);
304
+ const erasure = /\b(right to erasure|data subject request|DSAR|DSR|account deletion|delete my data)\b/i.test(corpus.text);
305
+ const enforced = _ciBlocks(ci, 'privacy', 'gdpr', 'retention', 'consent', 'dsar');
306
+ const seen: string[] = [
307
+ ...(notice ? ['a privacy notice or DPA'] : []),
308
+ ...(retention ? ['a retention rule'] : []),
309
+ ...(consent ? ['a consent mechanism'] : []),
310
+ ...(erasure ? ['an erasure / data-subject-request path'] : []),
311
+ ...(enforced ? ['a blocking CI step naming a privacy obligation'] : []),
312
+ ];
313
+ return {
314
+ grade: _graded(notice || retention || consent || erasure, enforced),
315
+ evidence: seen.length > 0 ? seen.join('; ') : 'no privacy notice, retention rule, consent mechanism or erasure path found',
316
+ };
317
+ }
318
+
152
319
  export function grade(root: string): Matrix {
153
320
  const ci = _ciText(root);
154
321
  const hasCi = ci !== '';
@@ -310,6 +477,41 @@ export function grade(root: string): Matrix {
310
477
  evidence: 'out of scope by design (see the roadmap exclusion table)',
311
478
  });
312
479
 
480
+ // 11. operational recovery — NON-KNOCKOUT
481
+ //
482
+ // New dimensions are never knockouts. A knockout added today would re-bind
483
+ // every existing verdict at L0 on the day it shipped, which changes what the
484
+ // level MEANS while looking like a change to what it measures.
485
+ const corpus = _textCorpus(root);
486
+ const recovery = _recovery(corpus, ci);
487
+ dims.push({
488
+ id: 'operational-recovery',
489
+ label: 'operational recovery',
490
+ knockout: false,
491
+ grade: recovery.grade,
492
+ evidence: recovery.evidence,
493
+ });
494
+
495
+ // 12. mail authenticity — NON-KNOCKOUT, own ladder (4.2)
496
+ const mail = _mailAuthenticity(corpus);
497
+ dims.push({
498
+ id: 'mail-authenticity',
499
+ label: 'mail authenticity',
500
+ knockout: false,
501
+ grade: mail.grade,
502
+ evidence: mail.evidence,
503
+ });
504
+
505
+ // 13. privacy obligations — NON-KNOCKOUT
506
+ const privacy = _privacyObligations(corpus, ci);
507
+ dims.push({
508
+ id: 'privacy-obligations',
509
+ label: 'privacy obligations',
510
+ knockout: false,
511
+ grade: privacy.grade,
512
+ evidence: privacy.evidence,
513
+ });
514
+
313
515
  // ── the verdict: min over knockouts, undetectable binds at L0 ──────────
314
516
  const knockouts = dims.filter((d) => d.knockout);
315
517
  const undetectable = knockouts.find((d) => d.grade === null);
@@ -440,6 +642,23 @@ export function selfTest(): number {
440
642
  return /%|\/100/.test(out) ? 1 : 0;
441
643
  },
442
644
  },
645
+ {
646
+ name: 'DMARC p=none scores strictly below p=quarantine — monitoring is not protection',
647
+ expect: 'accept',
648
+ run: () => {
649
+ const g = (zone: string): number => {
650
+ const d = grade(mk({
651
+ 'dns/zone.txt': zone,
652
+ 'dns/spf.txt': '@ IN TXT "v=spf1 include:_spf.example.net -all"\n',
653
+ 'dns/dkim.txt': 'sel._domainkey IN TXT "v=DKIM1; k=rsa; p=MIIB"\n',
654
+ }));
655
+ return d.dimensions.find((x) => x.id === 'mail-authenticity')?.grade ?? -1;
656
+ };
657
+ const none = g('_dmarc IN TXT "v=DMARC1; p=none; rua=mailto:d@example.com"\n');
658
+ const quarantine = g('_dmarc IN TXT "v=DMARC1; p=quarantine; rua=mailto:d@example.com"\n');
659
+ return none === 1 && quarantine === 2 ? 0 : 1;
660
+ },
661
+ },
443
662
  {
444
663
  name: 'rejects a missing target root rather than grading an empty tree',
445
664
  expect: 'reject',
@@ -42,8 +42,13 @@
42
42
  * measurement runs first.
43
43
  *
44
44
  * Usage:
45
- * ./scripts-run src/scripts/hooks_doctor [--host <name>] [--format text|json]
46
- * [--root <path>] [--limit <n>]
45
+ * ./agent-config hooks:effect [--host <name>] [--format text|json]
46
+ * [--root <path>] [--limit <n>] [--help]
47
+ * ./scripts-run src/scripts/hook_effect_doctor (same flags, in this checkout)
48
+ *
49
+ * `--help` prints the two verdict vocabularies FROM THE MODULE THAT DEFINES
50
+ * THEM. A help text that restated them by hand would be a fifth hand-typed copy
51
+ * of a closed set, which is the drift class this repository gates elsewhere.
47
52
  */
48
53
  import { spawnSync } from 'node:child_process';
49
54
  import * as crypto from 'node:crypto';
@@ -51,7 +56,13 @@ import * as fs from 'node:fs';
51
56
  import * as os from 'node:os';
52
57
  import * as path from 'node:path';
53
58
 
54
- import { buildHostReport, type ConcernProbe, type HostReport } from './_lib/hook_effect_probe.js';
59
+ import {
60
+ HOST_VERDICTS,
61
+ PROBE_STATES,
62
+ buildHostReport,
63
+ type ConcernProbe,
64
+ type HostReport,
65
+ } from './_lib/hook_effect_probe.js';
55
66
 
56
67
  const HERE = path.dirname(new URL(import.meta.url).pathname);
57
68
  const REPO_ROOT = path.resolve(HERE, '..', '..');
@@ -230,6 +241,34 @@ export function renderText(r: HostReport): string {
230
241
  return out.join('\n') + '\n';
231
242
  }
232
243
 
244
+ /**
245
+ * The synopsis, both closed vocabularies, and the scope line.
246
+ *
247
+ * Rendered from `PROBE_STATES` and `HOST_VERDICTS` rather than restated, so the
248
+ * help can never describe a vocabulary the probe no longer has.
249
+ */
250
+ export function renderHelp(): string {
251
+ return [
252
+ 'agent-config hooks:effect — does a bound concern actually FIRE on this host?',
253
+ '',
254
+ 'Usage: agent-config hooks:effect [--host <name>] [--format text|json]',
255
+ ' [--root <path>] [--limit <n>] [--help]',
256
+ '',
257
+ 'Not hooks:doctor. That one reads the MANIFEST — which concerns are declared,',
258
+ 'their posture, whether their scripts and trampolines exist. This one dispatches',
259
+ 'each eligible concern against a scratch root and reports what was observed.',
260
+ '',
261
+ `per-concern states: ${PROBE_STATES.join(' · ')}`,
262
+ `host verdicts: ${HOST_VERDICTS.join(' · ')}`,
263
+ '',
264
+ '`unknown` is never rendered as `effective`: a concern that wrote in the sandbox,',
265
+ 'or that could not be dispatched at all, is reported as unestablished — never as a',
266
+ 'working configuration.',
267
+ '',
268
+ 'Exits 0 on every verdict. An `inert` host is information, not a build failure.',
269
+ ].join('\n') + '\n';
270
+ }
271
+
233
272
  export function main(argv: string[] = process.argv.slice(2)): number {
234
273
  let host = 'claude';
235
274
  let format: 'text' | 'json' = 'text';
@@ -237,6 +276,10 @@ export function main(argv: string[] = process.argv.slice(2)): number {
237
276
  let limit = 200;
238
277
  for (let i = 0; i < argv.length; i++) {
239
278
  const a = argv[i];
279
+ if (a === '--help' || a === '-h') {
280
+ process.stdout.write(renderHelp());
281
+ return 0;
282
+ }
240
283
  if (a === '--host') host = argv[++i] ?? host;
241
284
  else if (a === '--format') format = (argv[++i] as 'text' | 'json') ?? format;
242
285
  else if (a === '--root') root = path.resolve(argv[++i] ?? root);
@@ -246,7 +289,7 @@ export function main(argv: string[] = process.argv.slice(2)): number {
246
289
  try {
247
290
  report = buildReport(root, host, limit);
248
291
  } catch (err) {
249
- process.stderr.write(`❌ hooks_doctor: ${(err as Error).message}\n`);
292
+ process.stderr.write(`❌ hooks:effect: ${(err as Error).message}\n`);
250
293
  return 2;
251
294
  }
252
295
  process.stdout.write(format === 'json' ? JSON.stringify(report, null, 2) + '\n' : renderText(report));
@@ -61,12 +61,16 @@ import path from "node:path";
61
61
  import { fileURLToPath, pathToFileURL } from "node:url";
62
62
 
63
63
  import { readHookStdin } from "./hook_stdin.js";
64
+ import { analyseMergeImpact, describeImpact } from "./merge_impact.js";
64
65
  import { atomic_write_json } from "./state_io.js";
65
66
  import {
67
+ consumeGrantTarget,
66
68
  ledgerFileFor,
67
69
  pendingFileFor,
70
+ readGrants,
68
71
  STATE_FILE,
69
72
  type GitOp,
73
+ type MergeGrant,
70
74
  } from "../git_authorization_hook.js";
71
75
 
72
76
  const EXIT_ALLOW = 0;
@@ -95,6 +99,31 @@ export const BLOCK_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
95
99
  // Enabling auto-merge commits the outcome to a condition the agent does not
96
100
  // control, so it is blocked on the same terms as the merge it schedules.
97
101
  "pr-merge-auto",
102
+
103
+ // ── Added 2026-09-03. Every one is already never-autonomous in the
104
+ // `non-destructive-by-default` prose; the measurement that drove it is in the
105
+ // `GitOp` union's own note — 17 of 25 borderline operations classified as
106
+ // NOTHING, including three worse than anything the guard already caught.
107
+ /** The undo that is more destructive than the act: lockfiles break. */
108
+ "unpublish",
109
+ /** Metadata only, and every install on earth starts warning. */
110
+ "deprecate",
111
+ /** Replaces bytes consumers already hold a checksum for. */
112
+ "release-asset",
113
+ /** Deletes the guard rather than the data — everything after is unguarded. */
114
+ "protection",
115
+ /** No data lost; the gate that protects everything downstream is off. */
116
+ "workflow-toggle",
117
+ /** Reach outside the repository, and mostly one-way. */
118
+ "repo-lifecycle",
119
+ /** No code moves, and an armed auto-merge is released. */
120
+ "review-approve",
121
+ /** Discards commits that landed after the last fetch, on a shared ref. */
122
+ "force-push",
123
+ /** Destroys uncommitted work belonging to a session this one cannot see. */
124
+ "worktree-remove",
125
+ /** `-x` is the flag that takes `.env` and local certificates. */
126
+ "clean-ignored",
98
127
  ]);
99
128
 
100
129
  /** Recoverable operations — warned without a this-turn authorization. */
@@ -103,6 +132,22 @@ export const WARN_OPS: ReadonlySet<GitOp> = new Set<GitOp>([
103
132
  "push",
104
133
  "pr-create",
105
134
  "branch",
135
+
136
+ // ── Added 2026-09-03, deliberately at warn rather than block.
137
+ //
138
+ // Each is destructive in some reading, and each is also routine enough that
139
+ // blocking it would be the friction that gets the whole guard routed around —
140
+ // the failure mode this estate has already recorded twice. `reset-hard` is the
141
+ // sharpest case and stays here for a decidable reason: the Hard Floor's
142
+ // qualifier is "past unpushed work", and the command text cannot say whether
143
+ // the work was pushed.
144
+ "tag-force",
145
+ "rebase",
146
+ "reset-hard",
147
+ "clean",
148
+ "stash-drop",
149
+ "branch-delete",
150
+ "close",
106
151
  ]);
107
152
 
108
153
  /**
@@ -191,6 +236,74 @@ const COMMAND_OPS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
191
236
  op: "pr-create",
192
237
  re: new RegExp(`${P}gh\\s+pr\\s+create\\b|${ghApiWrite("\\/pulls\\b").source}`, "i"),
193
238
  },
239
+ // ── Added 2026-09-03. Placement inside this table is as load-bearing as the
240
+ // patterns: the loop takes the MOST SEVERE match per segment, but within one
241
+ // op the first pattern wins, so anything more specific than a plain `git push`
242
+ // has to sit above it.
243
+
244
+ // `npm unpublish` is NOT reached by the publish pattern — `\bpublish\b` needs
245
+ // a word boundary that `unpublish` does not provide between `n` and `p`. That
246
+ // is why it classified as nothing for as long as it did.
247
+ { op: "unpublish", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}unpublish\\b`, "i") },
248
+ { op: "deprecate", re: new RegExp(`${P}(npm|pnpm|yarn)\\s+${G}deprecate\\b`, "i") },
249
+ {
250
+ op: "release-asset",
251
+ re: new RegExp(`${P}gh\\s+release\\s+(upload|edit|delete)\\b`, "i"),
252
+ },
253
+ {
254
+ op: "protection",
255
+ re: new RegExp(
256
+ `${P}gh\\s+api\\b(?=[^\\n]*(?:protection|rulesets?)\\b)(?=[^\\n]*(?:-X|--method)\\s+(?:DELETE|PUT|PATCH|POST)\\b)|${P}gh\\s+ruleset\\s+delete\\b`,
257
+ "i",
258
+ ),
259
+ },
260
+ {
261
+ op: "workflow-toggle",
262
+ re: new RegExp(`${P}gh\\s+workflow\\s+(disable|enable)\\b`, "i"),
263
+ },
264
+ {
265
+ op: "repo-lifecycle",
266
+ re: new RegExp(
267
+ `${P}gh\\s+repo\\s+(archive|delete|unarchive)\\b|${P}gh\\s+repo\\s+edit\\b(?=[^\\n]*--visibility\\b)`,
268
+ "i",
269
+ ),
270
+ },
271
+ {
272
+ op: "review-approve",
273
+ re: new RegExp(`${P}gh\\s+pr\\s+review\\b(?=[^\\n]*--approve\\b)`, "i"),
274
+ },
275
+ // Above `push`, and both spellings: `--force` and `--force-with-lease`. The
276
+ // "safe force" is in the same class here because what it is safe against is a
277
+ // stale local view, not a collaborator who pushed after your fetch.
278
+ {
279
+ op: "force-push",
280
+ re: new RegExp(`${P}git\\s+${G}push\\b[^\\n;|&]*(--force\\b|--force-with-lease\\b|\\s-f\\b)`, "i"),
281
+ },
282
+ {
283
+ op: "worktree-remove",
284
+ re: new RegExp(`${P}git\\s+worktree\\s+remove\\b[^\\n;|&]*(--force\\b|\\s-f\\b)`, "i"),
285
+ },
286
+ // `-x` (or `-X`) anywhere in the flag cluster is what reaches ignored files.
287
+ {
288
+ op: "clean-ignored",
289
+ re: new RegExp(`${P}git\\s+clean\\b[^\\n;|&]*-[A-Za-z]*x`, "i"),
290
+ },
291
+ { op: "tag-force", re: new RegExp(`${P}git\\s+tag\\b[^\\n;|&]*(-f\\b|--force\\b)`, "i") },
292
+ { op: "rebase", re: new RegExp(`${P}git\\s+${G}rebase\\b`, "i") },
293
+ { op: "reset-hard", re: new RegExp(`${P}git\\s+${G}reset\\b[^\\n;|&]*--hard\\b`, "i") },
294
+ { op: "clean", re: new RegExp(`${P}git\\s+clean\\b`, "i") },
295
+ { op: "stash-drop", re: new RegExp(`${P}git\\s+stash\\s+(drop|clear)\\b`, "i") },
296
+ // Remote deletion and the local `-D` are one op: both remove a ref, and the
297
+ // remote form additionally closes any open PR pointing at it.
298
+ {
299
+ op: "branch-delete",
300
+ re: new RegExp(
301
+ `${P}git\\s+branch\\b[^\\n;|&]*(-D\\b|--delete\\b)|${P}git\\s+${G}push\\b[^\\n;|&]*(--delete\\b|\\s:\\S)`,
302
+ "i",
303
+ ),
304
+ },
305
+ { op: "close", re: new RegExp(`${P}gh\\s+(pr|issue)\\s+close\\b`, "i") },
306
+
194
307
  { op: "push", re: new RegExp(`${P}git\\s+${G}push\\b`, "i") },
195
308
  { op: "commit", re: new RegExp(`${P}git\\s+${G}commit\\b`, "i") },
196
309
  { op: "branch", re: new RegExp(`${P}git\\s+${G}(checkout\\s+-b|switch\\s+-c)\\b`, "i") },
@@ -503,6 +616,55 @@ export function commandOp(command: string): GitOp | null {
503
616
  return found;
504
617
  }
505
618
 
619
+ /**
620
+ * The pull request a merge command acts on, or `null` when the command does not
621
+ * say.
622
+ *
623
+ * `gh pr merge` with no number merges whatever the current branch's PR is — the
624
+ * command text identifies no object, so no grant can match it and the
625
+ * clock-bound path applies unchanged. That is the fail-closed direction: an
626
+ * unnamed target never consumes a grant minted for a named one.
627
+ */
628
+ export function mergeTargetOf(command: string): number | null {
629
+ const gh = /\bgh\s+pr\s+merge\s+(?:-{1,2}\S+\s+)*?(\d{1,7})\b/i.exec(command);
630
+ if (gh) {
631
+ return Number.parseInt(gh[1] as string, 10);
632
+ }
633
+ // The same operation by its REST spelling. `commandOp` already classifies
634
+ // `gh api -X PUT repos/o/r/pulls/1499/merge` as `pr-merge`, so a reader that
635
+ // missed it here would silently fall back to the clock.
636
+ const api = /\/pulls\/(\d{1,7})\/merge\b/i.exec(command);
637
+ return api ? Number.parseInt(api[1] as string, 10) : null;
638
+ }
639
+
640
+ /**
641
+ * Does a standing grant cover this exact merge?
642
+ *
643
+ * Three conditions, all necessary: the grant is for this op, the command named
644
+ * a target, and that target sits in the grant's frozen set unspent.
645
+ *
646
+ * No clock is consulted here, and that is the whole of ADR-252. `authorized`
647
+ * answers "did the user name this operation recently"; a grant answers "did the
648
+ * user name this operation AND this pull request". The second question does not
649
+ * decay with time, because the object it names cannot drift into a different
650
+ * object — a force-push changes the SHA, but the grant is spent on first use, so
651
+ * a merge that already happened can never be replayed against a moved head.
652
+ */
653
+ export function grantCovers(
654
+ grants: MergeGrant[],
655
+ op: GitOp,
656
+ target: number | null,
657
+ ): MergeGrant | null {
658
+ if (op !== "pr-merge" || target === null) {
659
+ return null;
660
+ }
661
+ return (
662
+ grants.find(
663
+ (g) => g.op === "pr-merge" && g.targets.includes(target) && !g.consumed.includes(target),
664
+ ) ?? null
665
+ );
666
+ }
667
+
506
668
  /**
507
669
  * A ledger older than this is not "this turn" under any reading.
508
670
  *
@@ -558,7 +720,15 @@ function _loadLedger(
558
720
  consumer_root: string,
559
721
  session_id: string,
560
722
  now: number,
561
- ): { authorized: Set<GitOp>; present: boolean } {
723
+ ): { authorized: Set<GitOp>; present: boolean; grants: MergeGrant[] } {
724
+ // Grants are read on their OWN path, and the separation is load-bearing.
725
+ // `_readLedgerFile` below discards the entire ledger once `detected_at` is
726
+ // older than `LEDGER_MAX_AGE_MS`, so folding grants into its return value
727
+ // would let the clock silently kill them too — the change would ship, look
728
+ // correct, and do nothing after thirty minutes, with no test going red.
729
+ // `readGrants` performs the session check and no age check, which is exactly
730
+ // the contract: object identity, not recency (ADR-252).
731
+ const grants = readGrants(consumer_root, session_id);
562
732
  // This session's own ledger first; the flat legacy file only as a fallback,
563
733
  // so a ledger written by an older build still counts. Reading the flat file
564
734
  // FIRST was the defect: any concurrent session in the repo overwrites it, and
@@ -570,10 +740,10 @@ function _loadLedger(
570
740
  for (const rel of candidates) {
571
741
  const found = _readLedgerFile(path.join(consumer_root, rel), session_id, now);
572
742
  if (found !== null) {
573
- return found;
743
+ return { ...found, grants };
574
744
  }
575
745
  }
576
- return { authorized: new Set<GitOp>(), present: false };
746
+ return { authorized: new Set<GitOp>(), present: false, grants };
577
747
  }
578
748
 
579
749
  /**
@@ -622,11 +792,21 @@ export interface Decision {
622
792
  * `git_authorization_hook.PENDING_FILE` for why that record exists at all.
623
793
  */
624
794
  blockedOp?: GitOp;
795
+ /**
796
+ * The grant target this decision spent, when a standing grant is what allowed
797
+ * it.
798
+ *
799
+ * `decide` stays pure; `run` turns this into the ledger write that marks the
800
+ * target used. Single use is the property that keeps a grant from being a
801
+ * standing licence: the user authorized merging PR #1499, once.
802
+ */
803
+ consumedTarget?: number;
625
804
  }
626
805
 
627
806
  export function decide(
628
807
  command: string | null,
629
- ledger: { authorized: Set<GitOp>; present: boolean },
808
+ ledger: { authorized: Set<GitOp>; present: boolean; grants?: MergeGrant[] },
809
+ impact?: string,
630
810
  ): Decision {
631
811
  if (command === null) {
632
812
  return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
@@ -636,13 +816,30 @@ export function decide(
636
816
  return { exit: EXIT_ALLOW, stdout: "", stderr: "" };
637
817
  }
638
818
 
819
+ // A standing grant is the SECOND way an operation can be authorized, and it
820
+ // is strictly narrower than the first rather than an escape from it. Only
821
+ // `pr-merge` can hold one, the command must name its pull request, and that
822
+ // number must sit unspent in a set the user's own sentence froze. An unnamed
823
+ // target matches nothing and a spent target matches nothing, so every path
824
+ // that is not exactly "the user named this PR and it has not been merged yet"
825
+ // falls through to the clock-bound decision below unchanged (ADR-252).
826
+ const target = mergeTargetOf(command);
827
+ if (grantCovers(ledger.grants ?? [], op, target) && target !== null) {
828
+ return { exit: EXIT_ALLOW, stdout: "", stderr: "", consumedTarget: target };
829
+ }
830
+
639
831
  const shown = command.length > 120 ? `${command.slice(0, 117)}…` : command;
640
832
 
641
833
  if (BLOCK_OPS.has(op)) {
834
+ // STAGE 2. The refusal stands either way — what the scan changes is whether
835
+ // the user has to go read the diff to answer it. `impact` is injected by
836
+ // `run`, never computed here, so `decide` stays pure and testable without a
837
+ // repository.
838
+ const scan = op === "pr-merge" && target !== null && impact ? ` ${impact}` : "";
642
839
  const reason =
643
840
  `Blocked: \`${op}\` with no authorization in this turn's prompt. ` +
644
841
  `This operation is irreversible and non-destructive-by-default already declares it ` +
645
- `never-autonomous. Command: ${shown}. ` +
842
+ `never-autonomous. Command: ${shown}.${scan} ` +
646
843
  `Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
647
844
  return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}\n`, blockedOp: op };
648
845
  }
@@ -675,10 +872,42 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
675
872
  }
676
873
  }
677
874
  const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
678
- const decision = decide(
679
- extractCommand(envelope),
680
- _loadLedger(options.consumer_root, session_id, Date.now()),
681
- );
875
+ const command = extractCommand(envelope);
876
+ const ledger = _loadLedger(options.consumer_root, session_id, Date.now());
877
+
878
+ // STAGE 2 runs only on the path where it can change something: a merge that is
879
+ // ABOUT to be refused. Not on an allowed merge (nothing to tell the user), not
880
+ // on an op whose target the command does not name, and never on publish or
881
+ // release — a scan costs a diff read, and spending one where it cannot change
882
+ // the outcome is latency in the user's critical path for nothing.
883
+ let impact: string | undefined;
884
+ const stage1 = command === null ? null : commandOp(command);
885
+ const stage2Target = command === null ? null : mergeTargetOf(command);
886
+ if (
887
+ stage1 === "pr-merge" &&
888
+ stage2Target !== null &&
889
+ !ledger.authorized.has("pr-merge") &&
890
+ !grantCovers(ledger.grants ?? [], "pr-merge", stage2Target)
891
+ ) {
892
+ try {
893
+ impact = describeImpact(
894
+ stage2Target,
895
+ analyseMergeImpact(stage2Target, { cwd: options.consumer_root }),
896
+ );
897
+ } catch {
898
+ // The scan is an enrichment, never a gate. A refusal without it is the
899
+ // pre-stage-2 message, which is still correct.
900
+ }
901
+ }
902
+
903
+ const decision = decide(command, ledger, impact);
904
+ if (decision.consumedTarget !== undefined) {
905
+ // Spend the target at the moment the merge is actually let through, never
906
+ // when the grant was minted. A grant the run never reached stays whole, and
907
+ // a pull request that merged can never be replayed — which is what makes a
908
+ // force-push back to an already-merged head harmless here.
909
+ consumeGrantTarget(options.consumer_root, session_id, decision.consumedTarget);
910
+ }
682
911
  if (decision.blockedOp) {
683
912
  // Record WHAT was refused, so the user's answer to the question this
684
913
  // refusal just told the agent to ask can authorize that one operation.