@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +63 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/create.md +16 -1
- package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
- package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
- package/dist/cli-delegate/cmd_conformance.js +513 -17
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +5 -5
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1043 -390
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +703 -115
- package/package.json +1 -1
- package/src/config/assurance-capability-registry.json +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +62 -2
- package/src/config/gate-reachability-exemptions.json +1 -0
- package/src/config/gate-violation-baselines.json +4 -3
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/config/web-launch-readiness.json +4 -2
- package/src/scripts/_cli/_doctor_emit.ts +71 -0
- package/src/scripts/_cli/_doctor_strict.ts +163 -0
- package/src/scripts/_cli/cmd_doctor.ts +16 -37
- package/src/scripts/_dispatch.bash +11 -0
- package/src/scripts/_lib/branch_convergence.ts +186 -0
- package/src/scripts/_lib/context_observation.ts +132 -0
- package/src/scripts/_lib/continuation_ladder.ts +188 -0
- package/src/scripts/_lib/experiment_binding.ts +95 -0
- package/src/scripts/_lib/ignored_blocker.ts +8 -1
- package/src/scripts/_lib/outcome_envelope.ts +6 -1
- package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
- package/src/scripts/_lib/release_highlights.ts +75 -10
- package/src/scripts/_lib/repeated_failure.ts +19 -2
- package/src/scripts/_lib/runtime_journal.ts +11 -4
- package/src/scripts/check_agent_artifact_location.ts +337 -0
- package/src/scripts/check_artefact_count_messaging.ts +74 -8
- package/src/scripts/check_enforcement_coverage.ts +40 -1
- package/src/scripts/check_release_highlights.ts +17 -1
- package/src/scripts/check_web_launch_readiness.ts +310 -20
- package/src/scripts/evolution_lab.ts +53 -1
- package/src/scripts/generate_capability_matrix.ts +16 -9
- package/src/scripts/git_authorization_hook.ts +476 -6
- package/src/scripts/grade_target_readiness.ts +221 -2
- package/src/scripts/hook_effect_doctor.ts +47 -4
- package/src/scripts/hooks/block_unauthorized_git.ts +238 -9
- package/src/scripts/hooks/merge_impact.ts +207 -0
- package/src/scripts/hooks/run_continuation_hook.ts +78 -88
- package/src/scripts/hooks/session_eol_hook.ts +12 -1
- package/src/scripts/install-hooks.sh +18 -0
- package/src/scripts/lint_code_comments.ts +106 -1
- package/src/scripts/release.ts +5 -17
- package/src/scripts/release_drill.ts +76 -1
- package/src/scripts/release_env.ts +31 -0
- package/src/scripts/release_publication.ts +125 -1
- package/src/scripts/roadmap_context.ts +15 -0
- package/src/scripts/skill_scout.ts +633 -0
- 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.
|
|
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
|
|
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
|
-
* ./
|
|
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 {
|
|
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(`❌
|
|
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
|
|
679
|
-
|
|
680
|
-
|
|
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.
|