@event4u/agent-config 14.14.1 → 14.16.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 +88 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +7 -5
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +20 -2
- package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +99 -99
- package/dist/agent-src/skills/fe-design/SKILL.md +6 -3
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +28 -0
- package/dist/agent-src/skills/iconography/SKILL.md +37 -6
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +655 -34
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +7 -7
- 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 +669 -45
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +661 -35
- package/dist/router.json +1 -1
- package/docs/guidelines/design-antipatterns.md +10 -2
- package/package.json +1 -1
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/scripts/_lib/release_highlights.ts +63 -7
- package/src/scripts/design_slop_rules.ts +19 -9
- package/src/scripts/git_authorization_hook.ts +719 -27
- package/src/scripts/hooks/block_unauthorized_git.ts +250 -9
- package/src/scripts/hooks/merge_impact.ts +275 -0
- package/src/scripts/release.ts +9 -34
- package/src/scripts/release_drill.ts +180 -8
- package/src/scripts/release_env.ts +31 -0
- package/src/scripts/release_publication.ts +158 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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",
|
|
@@ -209,20 +296,22 @@ const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
|
|
|
209
296
|
// "never auto-merge" each returned ["pr-merge"] — a prompt saying DO NOT
|
|
210
297
|
// MERGE authorized a merge. The noun-sense lookahead was never a negator.
|
|
211
298
|
//
|
|
212
|
-
// The lookBEHIND
|
|
213
|
-
//
|
|
214
|
-
// its negated-claim window (`:490-493`). A sentence boundary ends a
|
|
215
|
-
// negation's reach: "Do not push. Merge PR #12." is two instructions, and a
|
|
216
|
-
// guard that let the first suppress the second would silently stop
|
|
217
|
-
// authorizing merges the user DID order — the failure mode that is worse
|
|
218
|
-
// than the defect, because nothing happens and nothing says why.
|
|
219
|
-
//
|
|
220
|
-
// Reusing that vocabulary rather than inventing a third is deliberate: two
|
|
299
|
+
// The inline negative lookBEHIND that used to live in this pattern was
|
|
300
|
+
// REMOVED on 2026-09-03, and its own comment is why. It warned that "two
|
|
221
301
|
// negation vocabularies in one tree drift, and the drift is invisible until
|
|
222
|
-
// a prompt lands in the gap between them
|
|
302
|
+
// a prompt lands in the gap between them" — and by then they had drifted:
|
|
303
|
+
// `negatedBefore` became clause-scanned in both directions while this
|
|
304
|
+
// lookbehind stayed a blind 30-character window with no forward half. The
|
|
305
|
+
// prompt that landed in the gap is `"nicht pushen, aber mergen"`, which the
|
|
306
|
+
// lookbehind refused (13 characters, inside its window, across a contrast
|
|
307
|
+
// cue it could not see) while the clause scan correctly authorizes it.
|
|
308
|
+
//
|
|
309
|
+
// Every phrase match already passes through `negatedBefore` at the call
|
|
310
|
+
// site, so the guard is not weakened by the removal — it is applied once,
|
|
311
|
+
// by the one vocabulary this file's comment asked for.
|
|
223
312
|
{
|
|
224
313
|
op: "pr-merge",
|
|
225
|
-
re:
|
|
314
|
+
re: /\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i,
|
|
226
315
|
},
|
|
227
316
|
// A tag is an ACTION here — bare "Tag" is the German word for day, and
|
|
228
317
|
// "Version" is an ordinary noun. Both authorized a BLOCK op before this.
|
|
@@ -233,6 +322,111 @@ const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
|
|
|
233
322
|
re: /\b(releasen?|ver(ö|oe)ffentlich(e|en))\b(?!\s*[- ]?(notes?|branch|candidate|pr\b|datum|date|zweig))/i,
|
|
234
323
|
},
|
|
235
324
|
{ op: "publish", re: /\b(publish(e|en)?|publiziere[n]?)\b/i },
|
|
325
|
+
|
|
326
|
+
// ── Authorization vocabulary for the ops added 2026-09-03.
|
|
327
|
+
//
|
|
328
|
+
// NON-NEGOTIABLE, and the reason it ships in the same change as the new BLOCK
|
|
329
|
+
// ops: a blocked operation with no phrase that authorizes it is a dead end.
|
|
330
|
+
// The guard refuses, the user says "doch, mach das", the classifier records
|
|
331
|
+
// nothing, and the refusal repeats forever. Every op the guard can block must
|
|
332
|
+
// have a sentence that unblocks it, or the user's override is a claim rather
|
|
333
|
+
// than a mechanism.
|
|
334
|
+
//
|
|
335
|
+
// Tight on purpose. Under-matching costs one confirmation; over-matching
|
|
336
|
+
// authorizes something the user did not ask for, so each pattern demands the
|
|
337
|
+
// object noun rather than a bare verb wherever the verb is ordinary German.
|
|
338
|
+
//
|
|
339
|
+
// `unpublish` before nothing in particular — `\bpublish\b` does not match
|
|
340
|
+
// inside "unpublish" (no word boundary between `n` and `p`), so the two never
|
|
341
|
+
// collide.
|
|
342
|
+
// TWO SHAPES EVERY GERMAN PATTERN BELOW MUST CARRY, both learned from tests
|
|
343
|
+
// that went red rather than from inspection:
|
|
344
|
+
//
|
|
345
|
+
// 1. THE IMPERATIVE DROPS ITS `-e`. "entfern die worktree", "archivier das
|
|
346
|
+
// repo", "deaktivier den workflow" are what people type; `entfern(e|en)`
|
|
347
|
+
// matches none of them. Every verb ending is therefore `(e|en)?`.
|
|
348
|
+
// 2. WORD ORDER GOES BOTH WAYS. "archivier das repo" and "das repo
|
|
349
|
+
// archivieren" are one instruction, and a pattern anchored on the noun
|
|
350
|
+
// first sees only the second.
|
|
351
|
+
//
|
|
352
|
+
// The object noun stays REQUIRED in every pattern that has one. That is what
|
|
353
|
+
// keeps the loosened verb endings from over-authorizing: "lösch" alone
|
|
354
|
+
// authorizes nothing, "lösch den branch" authorizes a branch deletion.
|
|
355
|
+
{ op: "unpublish", re: /\bunpublish(e|en)?\b/i },
|
|
356
|
+
{
|
|
357
|
+
op: "deprecate",
|
|
358
|
+
re: /\bdeprecate(d|n)?\b|\bals\s+veraltet\s+markier(e|en)?\b/i,
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
op: "release-asset",
|
|
362
|
+
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,
|
|
363
|
+
},
|
|
364
|
+
// A VERB IS REQUIRED, like `tag` and `release` above and for the same reason.
|
|
365
|
+
// A bare noun cannot tell an authorization from a mention, and here the
|
|
366
|
+
// mention is the dangerous half: "die branch-protection nicht anfassen" names
|
|
367
|
+
// the object precisely in order to forbid touching it. Measured — this was the
|
|
368
|
+
// one row of the negation corpus that neither the look-behind nor the
|
|
369
|
+
// span check could reach, because the negation trails a bare-noun match.
|
|
370
|
+
{
|
|
371
|
+
op: "protection",
|
|
372
|
+
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,
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
op: "workflow-toggle",
|
|
376
|
+
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,
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
op: "repo-lifecycle",
|
|
380
|
+
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,
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
op: "review-approve",
|
|
384
|
+
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,
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
op: "force-push",
|
|
388
|
+
re: /\bforce[-\s]?push(e|en|st)?\b|--force(-with-lease)?\b|\bmit\s+gewalt\s+push(e|en)?\b/i,
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
op: "worktree-remove",
|
|
392
|
+
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,
|
|
393
|
+
},
|
|
394
|
+
// The `-x` flag is the whole distinction: without it `git clean` spares
|
|
395
|
+
// ignored files, with it `.env` and local certificates go too.
|
|
396
|
+
{
|
|
397
|
+
op: "clean-ignored",
|
|
398
|
+
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,
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
op: "tag-force",
|
|
402
|
+
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,
|
|
403
|
+
},
|
|
404
|
+
{ op: "rebase", re: /\brebase(n|st|d)?\b/i },
|
|
405
|
+
{
|
|
406
|
+
op: "reset-hard",
|
|
407
|
+
re: /\breset\s+--hard\b|\bhard\s+reset\b|\bhart\s+zur(ü|ue)cksetz(e|en)?\b/i,
|
|
408
|
+
},
|
|
409
|
+
{ op: "clean", re: /\bgit\s+clean\b/i },
|
|
410
|
+
{
|
|
411
|
+
op: "stash-drop",
|
|
412
|
+
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,
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
op: "branch-delete",
|
|
416
|
+
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,
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
op: "close",
|
|
420
|
+
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,
|
|
421
|
+
},
|
|
422
|
+
// Enabling auto-merge is its own op on the guard side, so it needs its own
|
|
423
|
+
// sentence. Without one it is the dead end this block exists to prevent: the
|
|
424
|
+
// plain merge phrase authorizes `pr-merge` and never `pr-merge-auto`, so the
|
|
425
|
+
// refusal would have repeated forever.
|
|
426
|
+
{
|
|
427
|
+
op: "pr-merge-auto",
|
|
428
|
+
re: /\bauto[-\s]?merge\b|\bmerge\b[^.\n]{0,20}\bautomatisch\b|\bautomatisch\b[^.\n]{0,20}\bmerg(e|en)\b/i,
|
|
429
|
+
},
|
|
236
430
|
];
|
|
237
431
|
|
|
238
432
|
/**
|
|
@@ -258,15 +452,26 @@ export function isInterrogative(prose: string): boolean {
|
|
|
258
452
|
if (!t) {
|
|
259
453
|
return false;
|
|
260
454
|
}
|
|
455
|
+
// An explicit trailing question mark OUTRANKS the imperative escape below.
|
|
456
|
+
// Measured 2026-09-03: the escape ran first, so "Can you do the release
|
|
457
|
+
// now?" needed `now` to stay OUT of the imperative list to be read as a
|
|
458
|
+
// question — which left `now` absent while its German counterpart `jetzt`
|
|
459
|
+
// was present, and "Do the release now." was therefore classified a question
|
|
460
|
+
// and authorized nothing while "Do the release jetzt." authorized `release`.
|
|
461
|
+
// Ordering the `?` first is what makes closing that parity gap safe: it is a
|
|
462
|
+
// NARROWING (more prompts read as questions), and it lets the widening below
|
|
463
|
+
// be a one-word list addition rather than a trade.
|
|
464
|
+
if (/\?\s*$/.test(t)) {
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
261
467
|
const hasImperative =
|
|
262
|
-
/\b(mach|mache|bitte|leg|lege|erstell|erstelle|f(ü|ue)hr|f(ü|ue)hre|setz|setze|starte|los|jetzt|go ahead|do it|ja[,.]?\s|ok[,.]?\s)\b/i.test(
|
|
468
|
+
/\b(mach|mache|bitte|leg|lege|erstell|erstelle|f(ü|ue)hr|f(ü|ue)hre|setz|setze|starte|los|jetzt|now|asap|go ahead|do it|ja[,.]?\s|ok[,.]?\s)\b/i.test(
|
|
263
469
|
t,
|
|
264
470
|
);
|
|
265
471
|
if (hasImperative) {
|
|
266
472
|
return false;
|
|
267
473
|
}
|
|
268
474
|
return (
|
|
269
|
-
/\?\s*$/.test(t) ||
|
|
270
475
|
/^(was|wie|warum|wieso|weshalb|wann|wer|welche[rs]?|wo|ist|sind|kann|kannst|k(ö|oe)nnen|soll|sollen|darf|d(ü|ue)rfen|why|what|how|when|which|who|is|are|can|could|should|does|do)\b/i.test(
|
|
271
476
|
t,
|
|
272
477
|
)
|
|
@@ -358,12 +563,367 @@ function _commandLines(fence: string): string[] {
|
|
|
358
563
|
.filter((l) => /^(git|gh|npm|pnpm|yarn|task)\b/.test(l));
|
|
359
564
|
}
|
|
360
565
|
|
|
566
|
+
/**
|
|
567
|
+
* A standing, target-bound, single-use capability minted from the user's own
|
|
568
|
+
* words — the ADR-252 shape.
|
|
569
|
+
*
|
|
570
|
+
* A `GitOp` in `authorized` says *the user named this operation on this turn*
|
|
571
|
+
* and is governed by the guard's freshness clock. A grant says something
|
|
572
|
+
* strictly narrower: *the user named this operation AND the exact objects it
|
|
573
|
+
* may act on*. Because the objects are frozen at grant time, elapsed time stops
|
|
574
|
+
* being the thing that protects the user — target identity is — so a grant
|
|
575
|
+
* carries no clock.
|
|
576
|
+
*
|
|
577
|
+
* The narrowing is what buys the clock exemption, and it is why only
|
|
578
|
+
* enumerable operations can hold one. `npm publish` names a version, not the
|
|
579
|
+
* bytes that will be published, so no prompt can freeze its effect; it holds no
|
|
580
|
+
* grants and keeps the clock. See ADR-252 § Per-operation verdict.
|
|
581
|
+
*/
|
|
582
|
+
export type MergeGrant = {
|
|
583
|
+
/** Replay-resistant id — session slug, mint time, and the frozen target set. */
|
|
584
|
+
id: string;
|
|
585
|
+
/** Only `pr-merge` mints grants today. The field exists so the reader of a
|
|
586
|
+
* stored ledger never has to infer the op from the grant's shape. */
|
|
587
|
+
op: GitOp;
|
|
588
|
+
/** PR numbers named by the human, frozen at mint time. Never a wildcard: a
|
|
589
|
+
* cardinality word with no numbers mints nothing (ADR-252 § What "all" does
|
|
590
|
+
* not buy). */
|
|
591
|
+
targets: number[];
|
|
592
|
+
/** Per-target single-use state. A merged target is spent and cannot be
|
|
593
|
+
* replayed, which is what makes a force-push back to a merged SHA harmless. */
|
|
594
|
+
consumed: number[];
|
|
595
|
+
granted_at: string;
|
|
596
|
+
/** The literal human phrase, for audit. Never the enforcement key. */
|
|
597
|
+
evidence: string;
|
|
598
|
+
};
|
|
599
|
+
|
|
361
600
|
export interface Ledger extends JsonObject {
|
|
362
601
|
session_id: string;
|
|
363
602
|
detected_at: string;
|
|
364
603
|
authorized: GitOp[];
|
|
365
604
|
evidence: { [op: string]: string };
|
|
366
605
|
prompt_chars: number;
|
|
606
|
+
/**
|
|
607
|
+
* Standing grants, carried across the session's human turns.
|
|
608
|
+
*
|
|
609
|
+
* Optional so a ledger written by an older build still parses. Absent reads
|
|
610
|
+
* as "no grants", which is the pre-ADR-252 behaviour exactly.
|
|
611
|
+
*/
|
|
612
|
+
grants?: MergeGrant[];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* PR numbers the prompt names, as merge targets.
|
|
617
|
+
*
|
|
618
|
+
* Deliberately narrow. It matches a `#`-prefixed or `PR `-prefixed integer and
|
|
619
|
+
* nothing else, so a bare number in prose ("merge the 3 branches") freezes no
|
|
620
|
+
* target and mints no grant. Over-matching here would hand a clockless
|
|
621
|
+
* capability to a sentence that never identified an object, which is the exact
|
|
622
|
+
* conflation of lexical and object specificity both council seats rejected.
|
|
623
|
+
*/
|
|
624
|
+
/** One-line, bounded rendering of a caught value for a diagnostic. */
|
|
625
|
+
function _errText(err: unknown): string {
|
|
626
|
+
const t = err instanceof Error ? err.message : String(err);
|
|
627
|
+
return t.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The largest pull-request number GitHub can issue: a signed 32-bit integer.
|
|
632
|
+
*
|
|
633
|
+
* Taken from the API contract rather than estimated, and shared by the mint
|
|
634
|
+
* site here and the consume site in `hooks/block_unauthorized_git.ts` so the
|
|
635
|
+
* two cannot drift — the failure this file already learned once with its
|
|
636
|
+
* negation vocabulary.
|
|
637
|
+
*/
|
|
638
|
+
export const PR_NUMBER_MAX = 2147483647;
|
|
639
|
+
|
|
640
|
+
export function extractMergeTargets(prompt: string): number[] {
|
|
641
|
+
const out = new Set<number>();
|
|
642
|
+
// The BOUND comes from the API contract, not from a guess: GitHub issue and
|
|
643
|
+
// pull-request numbers are a signed 32-bit integer, so ten digits is the
|
|
644
|
+
// lexical ceiling and `PR_NUMBER_MAX` is the real one. The old `\d{1,7}` was
|
|
645
|
+
// neither — an eight-digit number simply failed to match, and since the
|
|
646
|
+
// `\b` sits after digit seven it failed SILENTLY rather than out of range.
|
|
647
|
+
//
|
|
648
|
+
// Widening here is required for the consume-side widening to do anything at
|
|
649
|
+
// all: this is where a grant's target set is frozen, so a target that cannot
|
|
650
|
+
// be MINTED can never be matched however well it resolves later.
|
|
651
|
+
//
|
|
652
|
+
// The `#` / `PR ` prefix requirement is deliberately unchanged. Over-matching
|
|
653
|
+
// here would hand a clockless capability to a sentence that never identified
|
|
654
|
+
// an object, which is the conflation of lexical and object specificity both
|
|
655
|
+
// council seats rejected.
|
|
656
|
+
const re = /(?:#|\bpr[- ]?|\bpull[- ]request\s+#?)(\d{1,10})\b/giu;
|
|
657
|
+
let m: RegExpExecArray | null;
|
|
658
|
+
while ((m = re.exec(prompt)) !== null) {
|
|
659
|
+
const n = Number.parseInt(m[1] as string, 10);
|
|
660
|
+
if (Number.isSafeInteger(n) && n > 0 && n <= PR_NUMBER_MAX) {
|
|
661
|
+
out.add(n);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return [...out].sort((a, b) => a - b);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* A human turn that withdraws standing merge authority.
|
|
669
|
+
*
|
|
670
|
+
* Revocation must be cheaper to express than authorization, so this matches a
|
|
671
|
+
* bare stop word on its own as well as an explicit "don't merge". It is
|
|
672
|
+
* deliberately over-inclusive in the safe direction: a false revocation costs
|
|
673
|
+
* one prompt, a missed revocation costs an unwanted merge.
|
|
674
|
+
*/
|
|
675
|
+
const REVOKE_RE =
|
|
676
|
+
/\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;
|
|
677
|
+
|
|
678
|
+
/** Does this human turn withdraw standing merge grants? */
|
|
679
|
+
export function isRevocation(prompt: string): boolean {
|
|
680
|
+
return REVOKE_RE.test(prompt);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Mint a grant id that a replayed ledger cannot collide with. */
|
|
684
|
+
function _grantId(session_id: string, at: string, targets: number[]): string {
|
|
685
|
+
return `${sessionSlug(session_id) || "nosession"}:${at}:${targets.join(",")}`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Fold this turn's authorization into the grants the session already holds.
|
|
690
|
+
*
|
|
691
|
+
* Three rules, and the order is the contract:
|
|
692
|
+
* 1. A revocation drops every standing grant. It wins over anything else in
|
|
693
|
+
* the same prompt — "merge #1 but stop if it conflicts" revokes.
|
|
694
|
+
* 2. An explicit `pr-merge` naming PR numbers mints one grant over those
|
|
695
|
+
* numbers.
|
|
696
|
+
* 3. Everything else carries the existing grants forward untouched. This is
|
|
697
|
+
* the clause that fixes the measured defect: before ADR-252 a neutral
|
|
698
|
+
* "weiter" replaced the whole ledger and silently erased authority the
|
|
699
|
+
* user had given two turns earlier.
|
|
700
|
+
*
|
|
701
|
+
* Consumption is NOT done here — the guard consumes a target when it actually
|
|
702
|
+
* lets the merge through, so a grant that was never acted on stays whole.
|
|
703
|
+
*/
|
|
704
|
+
export function foldGrants(
|
|
705
|
+
prior: MergeGrant[],
|
|
706
|
+
prompt: string,
|
|
707
|
+
authorized: GitOp[],
|
|
708
|
+
session_id: string,
|
|
709
|
+
now: Date,
|
|
710
|
+
): MergeGrant[] {
|
|
711
|
+
if (isRevocation(prompt)) {
|
|
712
|
+
return [];
|
|
713
|
+
}
|
|
714
|
+
const carried = prior.filter((g) => g.consumed.length < g.targets.length);
|
|
715
|
+
if (!authorized.includes("pr-merge")) {
|
|
716
|
+
return carried;
|
|
717
|
+
}
|
|
718
|
+
const targets = extractMergeTargets(prompt);
|
|
719
|
+
if (targets.length === 0) {
|
|
720
|
+
return carried;
|
|
721
|
+
}
|
|
722
|
+
const at = now.toISOString();
|
|
723
|
+
const already = new Set(carried.flatMap((g) => g.targets));
|
|
724
|
+
const fresh = targets.filter((t) => !already.has(t));
|
|
725
|
+
if (fresh.length === 0) {
|
|
726
|
+
return carried;
|
|
727
|
+
}
|
|
728
|
+
return [
|
|
729
|
+
...carried,
|
|
730
|
+
{
|
|
731
|
+
id: _grantId(session_id, at, fresh),
|
|
732
|
+
op: "pr-merge",
|
|
733
|
+
targets: fresh,
|
|
734
|
+
consumed: [],
|
|
735
|
+
granted_at: at,
|
|
736
|
+
evidence: prompt.trim().slice(0, 120),
|
|
737
|
+
},
|
|
738
|
+
];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Negation words that turn an instruction into its opposite.
|
|
743
|
+
*
|
|
744
|
+
* ONE vocabulary for the whole file, deliberately. The `pr-merge` pattern
|
|
745
|
+
* already carried this list inline, and its own comment warns that "two
|
|
746
|
+
* negation vocabularies in one tree drift, and the drift is invisible until a
|
|
747
|
+
* prompt lands in the gap between them" — so the list moved here rather than
|
|
748
|
+
* being copied fifteen times.
|
|
749
|
+
*/
|
|
750
|
+
const NEGATION_WORD =
|
|
751
|
+
/\b(nicht|nichts|kein(e|en|em|er|es)?|niemals|nie|no|not|dont|don't|never|without|ohne)\b/i;
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Is the match at `index` under a negation?
|
|
755
|
+
*
|
|
756
|
+
* MEASURED, not anticipated. Probed on 2026-09-03 against the fifteen phrases
|
|
757
|
+
* added that day: **15 of 15 leaked** — "nicht unpublishen", "den workflow nicht
|
|
758
|
+
* deaktivieren", "kein force-push bitte" each authorized exactly the operation
|
|
759
|
+
* the sentence forbade. The same probe showed the hole predates them, since
|
|
760
|
+
* "kein force-push bitte" also authorized a plain `push` and "den branch nicht
|
|
761
|
+
* löschen" also authorized `branch`. Only `pr-merge` was protected, by a
|
|
762
|
+
* lookbehind written after the identical defect was found there in isolation.
|
|
763
|
+
*
|
|
764
|
+
* SENTENCE-SCOPED, and that bound is load-bearing rather than decorative.
|
|
765
|
+
* "Do not push. Merge PR #12." is two instructions, and a negation whose reach
|
|
766
|
+
* crossed the full stop would silently stop authorizing merges the user DID
|
|
767
|
+
* order — a failure worse than the defect, because nothing happens and nothing
|
|
768
|
+
* says why. The 30-character window inside the sentence is inherited from the
|
|
769
|
+
* `pr-merge` lookbehind that this generalises.
|
|
770
|
+
*/
|
|
771
|
+
const CONTRAST_CUE = /\b(aber|sondern|jedoch|but|however|instead)\b/gi;
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Abbreviation and decimal dots are NOT sentence boundaries.
|
|
775
|
+
*
|
|
776
|
+
* MEASURED. `"Du sollst nicht z.B. den PR #12 mergen."` authorized `pr-merge`
|
|
777
|
+
* at 022c0d240: the boundary scan took the second dot of `z.B.` as the sentence
|
|
778
|
+
* start, which put the window at `" den PR #12 "` and left `nicht` out of
|
|
779
|
+
* scope. `"Nach Release 1.5 bitte nicht mergen."` fails the same way on the
|
|
780
|
+
* decimal point.
|
|
781
|
+
*
|
|
782
|
+
* Two rules, both narrow on purpose. A dot is not a boundary when it sits
|
|
783
|
+
* between digits, or when the word-ish token ending at it is a known
|
|
784
|
+
* abbreviation — including a single letter, which is what makes `z.B.` two
|
|
785
|
+
* non-boundaries rather than one.
|
|
786
|
+
*/
|
|
787
|
+
const ABBREVIATION_BEFORE_DOT =
|
|
788
|
+
/(?:^|[\s("'\u2018\u201c])(?:[a-z]|z\.b|bzw|ca|usw|d\.h|u\.a|evtl|ggf|inkl|max|min|nr|abb|e\.g|i\.e|etc|vs|approx|fig|no|st|dr|prof)\.$/i;
|
|
789
|
+
|
|
790
|
+
function isSentenceBoundary(text: string, at: number): boolean {
|
|
791
|
+
const mark = text[at];
|
|
792
|
+
if (mark !== ".") {
|
|
793
|
+
return true;
|
|
794
|
+
}
|
|
795
|
+
if (/\d/.test(text[at - 1] ?? "") && /\d/.test(text[at + 1] ?? "")) {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
return !ABBREVIATION_BEFORE_DOT.test(text.slice(0, at + 1));
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Bounds of the sentence containing `index`, abbreviation- and decimal-aware. */
|
|
802
|
+
export function sentenceBounds(text: string, index: number): { start: number; end: number } {
|
|
803
|
+
let start = 0;
|
|
804
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
805
|
+
const c = text[i] as string;
|
|
806
|
+
if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
|
|
807
|
+
start = i + 1;
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
let end = text.length;
|
|
812
|
+
for (let i = index; i < text.length; i += 1) {
|
|
813
|
+
const c = text[i] as string;
|
|
814
|
+
if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
|
|
815
|
+
end = i;
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return { start, end };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Split prose into sentences on the same boundaries `sentenceBounds` uses, so
|
|
824
|
+
* an abbreviation or a decimal point cannot split one instruction into two.
|
|
825
|
+
*/
|
|
826
|
+
export function _sentences(text: string): string[] {
|
|
827
|
+
const out: string[] = [];
|
|
828
|
+
let from = 0;
|
|
829
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
830
|
+
const c = text[i] as string;
|
|
831
|
+
if ((c === "." || c === "!" || c === "?" || c === "\n") && isSentenceBoundary(text, i)) {
|
|
832
|
+
out.push(text.slice(from, i + 1));
|
|
833
|
+
from = i + 1;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (from < text.length) {
|
|
837
|
+
out.push(text.slice(from));
|
|
838
|
+
}
|
|
839
|
+
return out.filter((t) => t.trim() !== "");
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Last clause boundary at or before `to`, within `[from, to)`. */
|
|
843
|
+
function clauseStart(text: string, from: number, to: number): number {
|
|
844
|
+
let start = from;
|
|
845
|
+
const seg = text.slice(from, to);
|
|
846
|
+
for (let i = seg.length - 1; i >= 0; i -= 1) {
|
|
847
|
+
const c = seg[i] as string;
|
|
848
|
+
if (c === "," || c === ";" || c === ":") {
|
|
849
|
+
start = from + i + 1;
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
CONTRAST_CUE.lastIndex = 0;
|
|
854
|
+
let m: RegExpExecArray | null;
|
|
855
|
+
while ((m = CONTRAST_CUE.exec(seg)) !== null) {
|
|
856
|
+
const after = from + m.index + m[0].length;
|
|
857
|
+
if (after > start) {
|
|
858
|
+
start = after;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return start;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** First clause boundary at or after `from`, within `(from, to]`. */
|
|
865
|
+
function clauseEnd(text: string, from: number, to: number): number {
|
|
866
|
+
const seg = text.slice(from, to);
|
|
867
|
+
let end = to;
|
|
868
|
+
const punct = seg.search(/[,;:]/);
|
|
869
|
+
if (punct !== -1) {
|
|
870
|
+
end = from + punct;
|
|
871
|
+
}
|
|
872
|
+
CONTRAST_CUE.lastIndex = 0;
|
|
873
|
+
const m = CONTRAST_CUE.exec(seg);
|
|
874
|
+
if (m !== null && from + m.index < end) {
|
|
875
|
+
end = from + m.index;
|
|
876
|
+
}
|
|
877
|
+
return end;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Is the match at `index` under a negation?
|
|
882
|
+
*
|
|
883
|
+
* MEASURED, not anticipated. Probed on 2026-09-03 against the fifteen phrases
|
|
884
|
+
* added that day: **15 of 15 leaked** — "nicht unpublishen", "den workflow nicht
|
|
885
|
+
* deaktivieren", "kein force-push bitte" each authorized exactly the operation
|
|
886
|
+
* the sentence forbade. The same probe showed the hole predates them, since
|
|
887
|
+
* "kein force-push bitte" also authorized a plain `push` and "den branch nicht
|
|
888
|
+
* löschen" also authorized `branch`. Only `pr-merge` was protected, by a
|
|
889
|
+
* lookbehind written after the identical defect was found there in isolation.
|
|
890
|
+
*
|
|
891
|
+
* SENTENCE-SCOPED, and that bound is load-bearing rather than decorative.
|
|
892
|
+
* "Do not push. Merge PR #12." is two instructions, and a negation whose reach
|
|
893
|
+
* crossed the full stop would silently stop authorizing merges the user DID
|
|
894
|
+
* order — a failure worse than the defect, because nothing happens and nothing
|
|
895
|
+
* says why.
|
|
896
|
+
*
|
|
897
|
+
* CLAUSE-SCANNED IN BOTH DIRECTIONS since 2026-09-03, replacing a fixed
|
|
898
|
+
* 30-character lookbehind inherited from the `pr-merge` pattern. Four shapes
|
|
899
|
+
* leaked past that window, all measured at 022c0d240:
|
|
900
|
+
*
|
|
901
|
+
* "Merge PR #12 auf keinen Fall." negation AFTER the match
|
|
902
|
+
* "Merge #12 under no circumstances." negation AFTER the match
|
|
903
|
+
* "Du sollss nicht z.B. den PR #12 mergen." abbreviation dot cut the sentence
|
|
904
|
+
* "Bitte unter keinen Umstaenden … mergen" 38-44 chars, past the 30 window
|
|
905
|
+
*
|
|
906
|
+
* The scan runs to the nearest CLAUSE boundary rather than to the sentence
|
|
907
|
+
* bound, and that half is what keeps the widening from over-denying: a comma,
|
|
908
|
+
* a semicolon, a colon or a contrast cue ends the scan. Without it,
|
|
909
|
+
* "I do not want to wait any longer, please merge PR #12" would read as
|
|
910
|
+
* negated and silently refuse a merge the user ordered — the failure the
|
|
911
|
+
* sentence bound above exists to prevent, reintroduced one level down. With
|
|
912
|
+
* it, "Merge PR #123, but do not push to production." keeps authorizing the
|
|
913
|
+
* merge and keeps denying the push, which is the only reading a human gives it.
|
|
914
|
+
*/
|
|
915
|
+
export function negatedBefore(text: string, index: number, matched = ""): boolean {
|
|
916
|
+
const { start, end } = sentenceBounds(text, index);
|
|
917
|
+
const before = text.slice(clauseStart(text, start, index), index);
|
|
918
|
+
const afterFrom = index + matched.length;
|
|
919
|
+
const after =
|
|
920
|
+
afterFrom < end ? text.slice(afterFrom, clauseEnd(text, afterFrom, end)) : "";
|
|
921
|
+
// The negation is often INSIDE the match, not before it, and German word
|
|
922
|
+
// order is why: "das asset nicht ersetzen" is matched by a noun-first pattern
|
|
923
|
+
// that starts at "asset", so a look-behind from the match index sees an empty
|
|
924
|
+
// window. Measured — nine of the fifteen rows in the negation corpus failed
|
|
925
|
+
// exactly this way after the look-behind alone was added.
|
|
926
|
+
return NEGATION_WORD.test(before) || NEGATION_WORD.test(matched) || NEGATION_WORD.test(after);
|
|
367
927
|
}
|
|
368
928
|
|
|
369
929
|
/** Classify which ops a prompt authorizes. Exported for direct testing. */
|
|
@@ -382,11 +942,30 @@ export function classifyAuthorization(prompt: string): {
|
|
|
382
942
|
.filter((l) => !OUTPUT_LINE.test(l))
|
|
383
943
|
.join("\n");
|
|
384
944
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
945
|
+
// PER SENTENCE, not per prompt. `isInterrogative` reads a leading "Do " as a
|
|
946
|
+
// question, so `"Do not push. Merge PR #12."` classified the WHOLE block as
|
|
947
|
+
// interrogative and authorized nothing — a merge the user ordered in the
|
|
948
|
+
// second sentence, silently dropped because of the first word of the first.
|
|
949
|
+
// Measured at 022c0d240, and the archived roadmap that added the check had
|
|
950
|
+
// already recorded it as pre-existing. `"Do the release now. Merge PR #12."`
|
|
951
|
+
// and `"Is everything green. Merge PR #12."` fail the same way.
|
|
952
|
+
//
|
|
953
|
+
// The bound this restores is the one `question-not-instruction` states: a
|
|
954
|
+
// question sentence authorizes nothing, and an instruction sentence beside
|
|
955
|
+
// it still does. A genuine question is still refused whole —
|
|
956
|
+
// `"was macht npm publish eigentlich genau?"` is one sentence and stays
|
|
957
|
+
// denied.
|
|
958
|
+
for (const { op, re } of PHRASES) {
|
|
959
|
+
for (const sentence of _sentences(instruction)) {
|
|
960
|
+
if (isInterrogative(sentence)) {
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const m = re.exec(sentence);
|
|
964
|
+
if (m && !negatedBefore(sentence, m.index, m[0])) {
|
|
965
|
+
authorized.add(op);
|
|
966
|
+
evidence[op] = `prose: "${m[0]}"`;
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
390
969
|
}
|
|
391
970
|
}
|
|
392
971
|
|
|
@@ -418,6 +997,98 @@ export function classifyAuthorization(prompt: string): {
|
|
|
418
997
|
return { authorized: [...authorized], evidence };
|
|
419
998
|
}
|
|
420
999
|
|
|
1000
|
+
/** Grants the session already holds, or `[]` for any ledger that has none. */
|
|
1001
|
+
export function readGrants(consumer_root: string, session_id: string): MergeGrant[] {
|
|
1002
|
+
try {
|
|
1003
|
+
const raw = fs.readFileSync(path.join(consumer_root, ledgerFileFor(session_id)), "utf8");
|
|
1004
|
+
const decoded = JSON.parse(raw) as unknown;
|
|
1005
|
+
if (!_isObject(decoded) || !Array.isArray(decoded["grants"])) {
|
|
1006
|
+
return [];
|
|
1007
|
+
}
|
|
1008
|
+
// A grant recorded against a different session is another conversation's
|
|
1009
|
+
// consent, exactly as the ledger's own session check treats `authorized`.
|
|
1010
|
+
const owner = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
|
|
1011
|
+
if (session_id && owner && owner !== session_id) {
|
|
1012
|
+
return [];
|
|
1013
|
+
}
|
|
1014
|
+
return (decoded["grants"] as unknown[]).flatMap((g) => {
|
|
1015
|
+
if (!_isObject(g)) return [];
|
|
1016
|
+
const op = g["op"];
|
|
1017
|
+
if (typeof op !== "string" || !(ALL_OPS as readonly string[]).includes(op)) return [];
|
|
1018
|
+
const targets = Array.isArray(g["targets"]) ? g["targets"] : [];
|
|
1019
|
+
const consumed = Array.isArray(g["consumed"]) ? g["consumed"] : [];
|
|
1020
|
+
return [
|
|
1021
|
+
{
|
|
1022
|
+
id: String(g["id"] ?? ""),
|
|
1023
|
+
op: op as GitOp,
|
|
1024
|
+
targets: targets.filter((t): t is number => typeof t === "number"),
|
|
1025
|
+
consumed: consumed.filter((t): t is number => typeof t === "number"),
|
|
1026
|
+
granted_at: String(g["granted_at"] ?? ""),
|
|
1027
|
+
evidence: String(g["evidence"] ?? ""),
|
|
1028
|
+
},
|
|
1029
|
+
];
|
|
1030
|
+
});
|
|
1031
|
+
} catch {
|
|
1032
|
+
return [];
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Mark one target spent, in place, on the session's ledger.
|
|
1038
|
+
*
|
|
1039
|
+
* Called by the guard at the moment it lets a merge through — not by the agent,
|
|
1040
|
+
* and not at mint time. A grant the run never reached stays whole, and a target
|
|
1041
|
+
* that merged can never be replayed, which is what makes a force-push back to
|
|
1042
|
+
* an already-merged SHA harmless.
|
|
1043
|
+
*/
|
|
1044
|
+
export function consumeGrantTarget(
|
|
1045
|
+
consumer_root: string,
|
|
1046
|
+
session_id: string,
|
|
1047
|
+
target: number,
|
|
1048
|
+
): void {
|
|
1049
|
+
const file = path.join(consumer_root, ledgerFileFor(session_id));
|
|
1050
|
+
let decoded: unknown;
|
|
1051
|
+
try {
|
|
1052
|
+
decoded = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1053
|
+
} catch {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
if (!_isObject(decoded) || !Array.isArray(decoded["grants"])) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
let touched = false;
|
|
1060
|
+
for (const g of decoded["grants"] as unknown[]) {
|
|
1061
|
+
if (!_isObject(g) || !Array.isArray(g["targets"]) || !Array.isArray(g["consumed"])) {
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
const targets = g["targets"] as JsonValue[];
|
|
1065
|
+
const consumed = g["consumed"] as JsonValue[];
|
|
1066
|
+
if (targets.includes(target) && !consumed.includes(target)) {
|
|
1067
|
+
consumed.push(target);
|
|
1068
|
+
touched = true;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
if (touched) {
|
|
1072
|
+
try {
|
|
1073
|
+
atomic_write_json(file, decoded as JsonObject);
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
/* The FAILURE DIRECTION is unchanged and still defensible: a failed write
|
|
1076
|
+
leaves the target unspent, which re-allows one merge of a PR the user
|
|
1077
|
+
did authorize by name — it fails toward the user's stated intent, never
|
|
1078
|
+
toward an unauthorized target. What changed on 2026-09-03 is that it is
|
|
1079
|
+
no longer INVISIBLE. "Observability only" was the reason given for
|
|
1080
|
+
writing nothing, which inverted the word: a grant that silently fails
|
|
1081
|
+
to be consumed is a capability that outlives its single use, and the
|
|
1082
|
+
operator has no way to know. */
|
|
1083
|
+
process.stderr.write(
|
|
1084
|
+
`git-authorization: grant-consumption write failed (${_errText(err)}) — ` +
|
|
1085
|
+
"the target stays UNSPENT, so one further merge of a PR the user named " +
|
|
1086
|
+
"will still be authorized. Authorization outcome unchanged.\n",
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
421
1092
|
/**
|
|
422
1093
|
* Read the refused operation, and consume it in the same breath.
|
|
423
1094
|
*
|
|
@@ -525,20 +1196,41 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
525
1196
|
`"${prompt.trim().slice(0, 40)}"`;
|
|
526
1197
|
}
|
|
527
1198
|
|
|
1199
|
+
const now = new Date();
|
|
528
1200
|
const ledger: Ledger = {
|
|
529
1201
|
session_id: typeof envelope["session_id"] === "string" ? envelope["session_id"] : "",
|
|
530
|
-
detected_at:
|
|
1202
|
+
detected_at: now.toISOString(),
|
|
531
1203
|
authorized,
|
|
532
1204
|
evidence,
|
|
533
1205
|
prompt_chars: prompt.length,
|
|
1206
|
+
// Grants survive this write; `authorized` does not. That asymmetry IS the
|
|
1207
|
+
// decision. A bare operation name stays one-shot exactly as `commit-policy`
|
|
1208
|
+
// requires, and only an authorization that also froze its objects earns a
|
|
1209
|
+
// life longer than the turn. See ADR-252.
|
|
1210
|
+
grants: foldGrants(
|
|
1211
|
+
readGrants(options.consumer_root, session),
|
|
1212
|
+
prompt,
|
|
1213
|
+
authorized,
|
|
1214
|
+
session,
|
|
1215
|
+
now,
|
|
1216
|
+
),
|
|
534
1217
|
};
|
|
535
1218
|
|
|
536
1219
|
try {
|
|
537
1220
|
atomic_write_json(path.join(options.consumer_root, ledgerFileFor(session)), ledger);
|
|
538
|
-
} catch {
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
// The SIBLING of the swallow in `consumeGrantTarget`, and named here
|
|
1223
|
+
// because AC-5 describes a behavioural class rather than one call site:
|
|
1224
|
+
// fixing one and leaving the other would have made the acceptance claim
|
|
1225
|
+
// half true. Same reasoning, different degradation — a failed write here
|
|
1226
|
+
// degrades the gate to "no ledger", which the pre_tool_use concern treats
|
|
1227
|
+
// as "not authorized" for the irreversible subset and as a warn for the
|
|
1228
|
+
// rest, so the direction is fail-closed and still unchanged.
|
|
1229
|
+
process.stderr.write(
|
|
1230
|
+
`git-authorization: session-ledger write failed (${_errText(err)}) — ` +
|
|
1231
|
+
"the gate degrades to \"no ledger\" for this session, which denies the " +
|
|
1232
|
+
"irreversible subset and warns on the rest. Authorization outcome unchanged.\n",
|
|
1233
|
+
);
|
|
542
1234
|
}
|
|
543
1235
|
return EXIT_ALLOW;
|
|
544
1236
|
}
|