@event4u/agent-config 9.35.0 → 10.0.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 (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +95 -142
  3. package/agents/templates/.ai-council.yml.example +31 -51
  4. package/dist/agent-src/commands/analyze/conformance.md +15 -4
  5. package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
  6. package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
  7. package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
  8. package/dist/agent-src/rules/improve-before-implement.md +1 -1
  9. package/dist/agent-src/rules/session-canary.md +28 -0
  10. package/dist/agent-src/rules/ui-audit-gate.md +35 -0
  11. package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
  12. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
  13. package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
  14. package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
  15. package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
  16. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  17. package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
  18. package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
  19. package/dist/agent-src/templates/AGENTS.md +1 -1
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/cli-delegate/{chunk-2TGMGLBL.js → chunk-2H5SE7DU.js} +35 -5
  22. package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-3JUTT26I.js} +8 -3
  23. package/dist/cli-delegate/{chunk-ZAXH3ZWU.js → chunk-HJSKTMIF.js} +14 -13
  24. package/dist/cli-delegate/{chunk-QEEB4LHO.js → chunk-LRXJK6GN.js} +1 -1
  25. package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
  26. package/dist/cli-delegate/{chunk-H6F2CQFI.js → chunk-NX5AXSLD.js} +3 -3
  27. package/dist/cli-delegate/{chunk-AG47LAGQ.js → chunk-TVGXH7VA.js} +1 -1
  28. package/dist/cli-delegate/{chunk-55RI5UXK.js → chunk-XT2YTJCZ.js} +22 -18
  29. package/dist/cli-delegate/{chunk-JEJABGKB.js → chunk-XTC5ARB6.js} +16 -12
  30. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  31. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  32. package/dist/cli-delegate/cmd_conformance.js +1038 -197
  33. package/dist/cli-delegate/cmd_doctor.js +4 -4
  34. package/dist/cli-delegate/cmd_explain.js +2 -2
  35. package/dist/cli-delegate/cmd_export.js +4 -4
  36. package/dist/cli-delegate/cmd_handoff.js +3 -3
  37. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  38. package/dist/cli-delegate/cmd_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  40. package/dist/cli-delegate/cmd_prune.js +4 -4
  41. package/dist/cli-delegate/cmd_refresh.js +1 -1
  42. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  43. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  44. package/dist/cli-delegate/cmd_session_recycle.js +9 -2
  45. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  46. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  47. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  48. package/dist/cli-delegate/cmd_sync.js +4 -4
  49. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  50. package/dist/cli-delegate/cmd_update.js +1 -1
  51. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  52. package/dist/cli-delegate/cmd_validate.js +4 -4
  53. package/dist/cli-delegate/cmd_versions.js +1 -1
  54. package/dist/discovery/deprecation-report.md +1 -1
  55. package/dist/discovery/discovery-manifest.json +14 -14
  56. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  57. package/dist/discovery/discovery-manifest.summary.md +1 -1
  58. package/dist/discovery/dormancy-report.md +1 -1
  59. package/dist/discovery/orphan-report.md +1 -1
  60. package/dist/discovery/packs.json +2 -2
  61. package/dist/discovery/trust-report.md +1 -1
  62. package/dist/discovery/workspaces.json +2 -2
  63. package/dist/hooks/dispatch.js +654 -411
  64. package/dist/install/install.mjs +39 -29
  65. package/dist/mcp/registry-manifest.json +1 -1
  66. package/dist/mcp/server.json +2 -2
  67. package/dist/mcp/server.mjs +1580 -617
  68. package/dist/router.json +1 -1
  69. package/dist/scripts/_lib/agent_settings.js +39 -25
  70. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  71. package/dist/scripts/condense.js +15 -2
  72. package/dist/scripts/condense.js.map +1 -1
  73. package/dist/server/routes/wizard.js +23 -3
  74. package/dist/server/routes/wizard.js.map +1 -1
  75. package/dist/server/schemas/settings.js +11 -6
  76. package/dist/server/schemas/settings.js.map +1 -1
  77. package/docs/contracts/settings-classes.md +16 -9
  78. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
  79. package/package.json +2 -2
  80. package/src/agent-src/templates/AGENTS.md +1 -1
  81. package/src/config/agent-settings.template.yml +38 -36
  82. package/src/config/ci-local-parity.yml +44 -0
  83. package/src/config/gate-coverage.yml +12 -0
  84. package/src/scripts/_cli/cmd_doctor.ts +32 -0
  85. package/src/scripts/_cli/cmd_migrate.ts +26 -3
  86. package/src/scripts/_cli/cmd_refresh.ts +29 -0
  87. package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
  88. package/src/scripts/_lib/agent_settings.ts +39 -25
  89. package/src/scripts/_lib/environment_detector.ts +52 -1
  90. package/src/scripts/_lib/hook_settings.ts +79 -0
  91. package/src/scripts/_lib/ui_surface.ts +97 -0
  92. package/src/scripts/ai_council/config.ts +62 -24
  93. package/src/scripts/before_complete_hook.ts +39 -0
  94. package/src/scripts/capture_skill_catalogue.ts +483 -0
  95. package/src/scripts/check_bridge_derivation.ts +89 -3
  96. package/src/scripts/check_ci_local_parity.ts +74 -0
  97. package/src/scripts/check_references.ts +15 -1
  98. package/src/scripts/condense.ts +16 -2
  99. package/src/scripts/conformance_scan.ts +200 -9
  100. package/src/scripts/council_cli.ts +95 -3
  101. package/src/scripts/hook_manifest.yaml +30 -9
  102. package/src/scripts/hooks/block_no_verify.ts +82 -9
  103. package/src/scripts/hooks/concern_registry.ts +2 -0
  104. package/src/scripts/hooks/design_slop_hook.ts +1 -1
  105. package/src/scripts/hooks/turn_end_gate_hook.ts +138 -105
  106. package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
  107. package/src/scripts/lint_roadmap_complexity.ts +60 -0
  108. package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
  109. package/src/scripts/probe_promissory_closing.ts +180 -0
  110. package/src/scripts/probe_session_canary.ts +180 -0
  111. package/src/scripts/report_consultation_rate.ts +358 -0
  112. package/src/scripts/report_skill_activation.ts +5 -1
  113. package/src/server/schemas/settings.ts +14 -13
  114. /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
  115. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
  116. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
  117. /package/dist/agent-src/skills/react-shadcn-ui/{reference → references}/registry.md +0 -0
@@ -60,8 +60,12 @@ concerns:
60
60
  severity: advisory
61
61
  # Phase 5 — Tier-1 hook for the verify-before-complete rule. Records
62
62
  # observable evidence of verification commands (tests, quality tools,
63
- # builds) into agents/runtime/state/verify-before-complete.json. The rule body
64
- # cites that file as the source of truth for "verified this turn?".
63
+ # builds) into agents/state/verify-before-complete.json — the path the code
64
+ # actually writes (`before_complete_hook.STATE_FILE`); this comment said
65
+ # `agents/runtime/state/` until round 7, which is a directory it never used.
66
+ # The rule body cites that file as the source of truth for "verified this
67
+ # turn?". Since round 7 it also carries `ci_last`, the session-scoped CI-settle
68
+ # negative that `turn-end-gate` reads to refuse a premature completion claim.
65
69
  verify-before-complete:
66
70
  script: src/scripts/before_complete_hook.ts
67
71
  args: []
@@ -152,6 +156,21 @@ concerns:
152
156
  args: []
153
157
  fail_closed: false
154
158
  severity: advisory
159
+ # road-to-frontend-skill-application Phase 4 — OPTIONAL PreToolUse UI-route
160
+ # nudge. On a Write/Edit to a UI surface with no design consultation latched
161
+ # this session, WARNS (exit 2) naming the route; a read or search touching a
162
+ # design surface latches consultation and silences it. Anti-loop: at most 2
163
+ # nudges per session. It does NOT read the two UI rules — the UI-surface
164
+ # decision comes from _lib/ui_surface.ts — so it runs parallel to them rather
165
+ # than consuming their triggers, and it is a nudge, never a block, so
166
+ # "enforced_by: none" on those rules stays honest.
167
+ # Default-OFF (hooks.ui_route_nudge.enabled).
168
+ # fail_closed: false.
169
+ ui-route-nudge:
170
+ script: src/scripts/hooks/ui_route_nudge_hook.ts
171
+ args: []
172
+ fail_closed: false
173
+ severity: advisory
155
174
  # ADR-124 Phase 4 — OPTIONAL PreToolUse code-graph nudge. When a code-graph
156
175
  # source is present and the agent is about to search/read source, WARNS
157
176
  # (exit 2) once per session to query the graph first (or rebuild if stale,
@@ -432,10 +451,12 @@ concerns:
432
451
  # mismatch (19 measured) — because building the unsafe part twice is how a
433
452
  # second detector becomes a second outage.
434
453
  #
435
- # DEFAULT OFF (`hooks.turn_end_gate.enabled`), per the council
436
- # recorded in round 6: the mechanism exists and soaks before it binds. Both
437
- # detectors are default ON *within* the master switch, which is round 5
438
- # § 3.6's ask read so that both hold at once.
454
+ # ALWAYS ARMED — no settings surface (2026-08-12). It shipped default-OFF on
455
+ # the round-6 council's "soak before it binds", and the maintainer removed the
456
+ # switch on the ground that a concern which is off cannot soak: the flag made
457
+ # the soak it protected unreachable. Whether the gate fires is decided by each
458
+ # detector's own trigger conditions, which is the same shape `delegation-nudge`
459
+ # uses — no signal, no output. `hooks.turn_end_gate.*` is in REMOVED_KEYS.
439
460
  #
440
461
  # fail_closed: FALSE, deliberately and unlike every other blocking concern
441
462
  # here: a crash in a turn-end gate must let the turn END. Failing closed on
@@ -528,7 +549,7 @@ platforms:
528
549
  session_start: [chat-history, hot-context, handoff-context, session-canary, council-availability, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness, surface-probe, session-register]
529
550
  session_end: [chat-history, memory-learn, session-register]
530
551
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
531
- pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge, reread-guard]
552
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard]
532
553
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape]
533
554
 
534
555
  claude:
@@ -536,7 +557,7 @@ platforms:
536
557
  session_end: [chat-history, memory-learn, session-register]
537
558
  stop: [chat-history, hot-context, verify-before-complete, team-review-gate, end-review-nudge, turn-end-gate, self-repair, session-register, session-eol]
538
559
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, delegation-nudge, git-authorization, session-canary, self-repair, session-register]
539
- pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge, reread-guard]
560
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard]
540
561
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape, language-mirror]
541
562
  # round-5 § 6.1 — the ONLY binding on this slot in the tree. `pre_compact`
542
563
  # sets a pin-lost marker; the `post_tool_use` entry above re-emits the pin
@@ -575,7 +596,7 @@ platforms:
575
596
  # still costs the dispatch, and its entire output is thrown away.
576
597
  stop: [chat-history, hot-context, verify-before-complete, self-repair, session-register]
577
598
  user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff, language-mirror, git-authorization, session-canary, self-repair, session-register]
578
- pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, code-graph-nudge, reread-guard]
599
+ pre_tool_use: [block-no-verify, block-unauthorized-git, evidence-independence, block-kernel-rule-writes, block-config-weakening, rtk-wrap, design-slop, ui-route-nudge, code-graph-nudge, reread-guard]
579
600
  post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan, pr-url-reminder, orchestration-record, edit-shape]
580
601
 
581
602
  # Phase 7.5 — Cursor. `.cursor/hooks.json` (project) is read by the
@@ -409,31 +409,104 @@ export function _looks_like_git_invocation(cmd: string, depth = 0): boolean {
409
409
  * Heredoc bodies are removed before extraction: a backtick or `$(` inside a
410
410
  * commit message being written to a file is data, and feeding it back through
411
411
  * this function is exactly the false positive the round-5 repair closed.
412
+ *
413
+ * ROUND 7 — THREAT MODEL for extending that removal to the shlex path (§ 2.1).
414
+ * Until round 7 the stripped string fed only `substitutionPayloads`; `shlexSplit`
415
+ * still received the RAW command. Both halves of that were measured, on this
416
+ * branch, before anything changed:
417
+ *
418
+ * `git commit -F - <<'EOF' … maintainer's … EOF` → BLOCKED (false positive)
419
+ * `bash <<EOF … git commit --no-verify … EOF` → ALLOWED (real bypass)
420
+ *
421
+ * So this is not the round-6 trade of one false positive for two false negatives.
422
+ * The bypass PRE-EXISTS: with the body inline, `shlexSplit` succeeds, the tokens
423
+ * are `bash <<EOF git commit --no-verify EOF`, command position is `bash`, and
424
+ * `_git_base` never sees git. Stripping before shlex fixes the false positive;
425
+ * scanning the bodies fixes the bypass. Both directions verified by probe.
426
+ *
427
+ * What an attacker can hide in a heredoc body, and how each is handled:
428
+ *
429
+ * - a body consumed by a SHELL (`sh`/`bash`/`zsh`/`ksh`, with or without a path
430
+ * prefix or an `env` prefix) IS a command list → scanned recursively.
431
+ * - a body consumed by anything else (`git commit -F -`, `cat > f`, `tee`) is
432
+ * DATA. Scanning it would re-open the exact false positive above, since a
433
+ * commit message may legitimately contain the words `--no-verify`.
434
+ * - an UNTERMINATED heredoc never matches the delimiter, so nothing is stripped
435
+ * and the raw string still reaches shlex — the fail-closed branch keeps it.
436
+ */
437
+
438
+ /** One heredoc: its body, and whether a shell is the thing consuming it. */
439
+ interface Heredoc {
440
+ body: string;
441
+ shellConsumed: boolean;
442
+ }
443
+
444
+ /**
445
+ * Command position on the line that opens the heredoc is a shell interpreter, so
446
+ * the body is a command list rather than data. Anchored to the segment start or a
447
+ * shell separator, exactly like `_looks_like_git_invocation` does one level up —
448
+ * a bare mention of `bash` in an argument must not promote a message to code.
412
449
  */
450
+ const _SHELL_CONSUMER_RE =
451
+ /(?:^|[|&;]|\$\(|`)\s*(?:env\s+(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*)?(?:\S*\/)?(?:ba|z|k)?sh\b/;
452
+
453
+ export function _heredocs(cmd: string): { stripped: string; docs: Heredoc[] } {
454
+ const re = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1([\s\S]*?)^\s*\2\s*$/gm;
455
+ const docs: Heredoc[] = [];
456
+ let stripped = '';
457
+ let last = 0;
458
+ let m: RegExpExecArray | null;
459
+ while ((m = re.exec(cmd)) !== null) {
460
+ const lineStart = cmd.lastIndexOf('\n', m.index) + 1;
461
+ const prefix = cmd.slice(lineStart, m.index);
462
+ docs.push({ body: m[3] ?? '', shellConsumed: _SHELL_CONSUMER_RE.test(prefix) });
463
+ stripped += cmd.slice(last, m.index) + '<<HEREDOC';
464
+ last = m.index + m[0].length;
465
+ }
466
+ stripped += cmd.slice(last);
467
+ return { stripped, docs };
468
+ }
469
+
413
470
  function _check_command(cmd: string, depth = 0): [boolean, string] {
471
+ // Round 7 § 2.2 — the stripped string now feeds shlex as well, not only the
472
+ // substitution scan. `<<HEREDOC` is a plain token, so an apostrophe in a
473
+ // commit message can no longer abort the parse of the command carrying it.
474
+ const { stripped, docs } = depth <= 3 ? _heredocs(cmd) : { stripped: cmd, docs: [] };
414
475
  if (depth <= 3) {
415
- const withoutHeredocs = cmd.replace(
416
- /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?^\s*\2\s*$/gm,
417
- '<<HEREDOC',
418
- );
419
- for (const payload of substitutionPayloads(withoutHeredocs)) {
476
+ for (const payload of substitutionPayloads(stripped)) {
420
477
  const [blocked, reason] = _check_command(payload, depth + 1);
421
478
  if (blocked) {
422
479
  return [true, reason];
423
480
  }
424
481
  }
482
+ // A shell-consumed body is a command list, so it is checked like one.
483
+ for (const doc of docs) {
484
+ if (!doc.shellConsumed) {
485
+ continue;
486
+ }
487
+ const [blocked, reason] = _check_command(doc.body, depth + 1);
488
+ if (blocked) {
489
+ return [true, reason];
490
+ }
491
+ }
425
492
  }
426
493
  let tokens: string[];
427
494
  try {
428
- tokens = shlexSplit(cmd);
495
+ tokens = shlexSplit(stripped);
429
496
  } catch (e) {
430
497
  if (e instanceof ShlexError) {
431
- if (_looks_like_git_invocation(cmd)) {
498
+ // Tested against the string that actually failed to parse. Round 7
499
+ // § 2.4: the message no longer prescribes the message-in-a-file
500
+ // workaround, because a TERMINATED heredoc is now stripped before
501
+ // this point and can no longer be the cause. What reaches here is
502
+ // genuinely unbalanced quoting — an unterminated heredoc, or an odd
503
+ // quote outside one — which bash itself refuses to run.
504
+ if (_looks_like_git_invocation(stripped)) {
432
505
  return [
433
506
  true,
434
507
  'command parse failed (shlex) on a git-containing command — fail-closed (git-history-discipline). \n'
435
- + 'Most common cause: an apostrophe or backtick inside a heredoc commit message in the SAME compound command as `git`. \n'
436
- + 'Fix: write the message to a file and use `git commit -F <file>` — do not retry the heredoc.',
508
+ + 'The quoting is unbalanced OUTSIDE any terminated heredoc — an unterminated heredoc, or an odd quote. \n'
509
+ + 'bash refuses this command too; fix the quoting rather than working around the guard.',
437
510
  ];
438
511
  }
439
512
  return [false, ''];
@@ -54,6 +54,7 @@ import { main as blockConfigWeakeningMain } from './block_config_weakening.js';
54
54
  import { main as rtkWrapMain } from './rtk_wrap_hook.js';
55
55
  import { main as designSlopMain } from './design_slop_hook.js';
56
56
  import { main as codeGraphNudgeMain } from './code_graph_nudge_hook.js';
57
+ import { main as uiRouteNudgeMain } from './ui_route_nudge_hook.js';
57
58
  import { main as orchestrationRecordMain } from './orchestration_record_hook.js';
58
59
  import { main as delegationNudgeMain } from './delegation_nudge_hook.js';
59
60
  import { main as endReviewNudgeMain } from './end_review_nudge_hook.js';
@@ -97,6 +98,7 @@ export const CONCERN_REGISTRY: Readonly<Record<string, ConcernMain>> = {
97
98
  'src/scripts/hooks/rtk_wrap_hook.ts': rtkWrapMain as ConcernMain,
98
99
  'src/scripts/hooks/design_slop_hook.ts': designSlopMain as ConcernMain,
99
100
  'src/scripts/hooks/code_graph_nudge_hook.ts': codeGraphNudgeMain as ConcernMain,
101
+ 'src/scripts/hooks/ui_route_nudge_hook.ts': uiRouteNudgeMain as ConcernMain,
100
102
  'src/scripts/hooks/orchestration_record_hook.ts': orchestrationRecordMain as ConcernMain,
101
103
  'src/scripts/hooks/delegation_nudge_hook.ts': delegationNudgeMain as ConcernMain,
102
104
  'src/scripts/hooks/end_review_nudge_hook.ts': endReviewNudgeMain as ConcernMain,
@@ -23,6 +23,7 @@ import fs from "node:fs";
23
23
  import path from "node:path";
24
24
  import { fileURLToPath, pathToFileURL } from "node:url";
25
25
  import { type DesignContext } from "../design_slop_rules.js";
26
+ import { UI_EXT } from "../_lib/ui_surface.js";
26
27
  import { loadDesignContext, scanFile } from "../lint_design_slop.js";
27
28
  import { readHookStdin } from "./hook_stdin.js";
28
29
 
@@ -30,7 +31,6 @@ const SETTINGS_FILE = ".agent-settings.yml";
30
31
  const EXIT_ALLOW = 0;
31
32
  const EXIT_WARN = 2;
32
33
  const DEGRADE_AFTER = 3; // surfaces of the same file::rule signature before going silent
33
- const UI_EXT = /\.(html|htm|css|scss|sass|less|vue|svelte|astro|jsx|tsx)$/i;
34
34
 
35
35
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
36
36
  type JsonObject = { [k: string]: JsonValue };
@@ -58,19 +58,41 @@
58
58
  * matching ordinal reads as already-refused, so on such a host layer 2
59
59
  * degrades to "may under-refuse" and layer 1 is the real guard.
60
60
  *
61
- * ## Default OFF — and why, given the roadmap says otherwise
62
- *
63
- * `road-to-conformance-round5.md` § 3.6 asks for a kill-switch per
64
- * detector, "default on". The council recorded in round 6 (2026-08-08)
65
- * asked for the mechanism to be opt-in, "defaulting to off, so the
66
- * mechanism exists and soaks before it binds" — two prior hook-severity
67
- * mistakes plus a turn-end blast radius made default-on uninsurable in
68
- * their reading. Both are satisfied literally: the MASTER switch
69
- * (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it every
70
- * detector is default ON. A maintainer who opts in gets all of them without
71
- * further configuration; until then this concern is a no-op that costs one
72
- * settings-cascade read per turn-end. Detector C landed under the same rule —
73
- * the master switch was NOT flipped when it shipped.
61
+ * ## Always armed — the switch was removed, and why
62
+ *
63
+ * This gate shipped behind `hooks.turn_end_gate.enabled`, default OFF, on the
64
+ * council's round-6 reading: "the mechanism exists and soaks before it binds."
65
+ * The maintainer removed the switch on 2026-08-12, and the decisive argument
66
+ * was that the soak the switch was protecting could never happen — a concern
67
+ * that is off does not run, so "merged in its own PR with its own soak period"
68
+ * (the second condition of `blocker: stop-refusal-decision`) was unsatisfiable
69
+ * for as long as the condition's own mechanism stayed disabled. A default-off
70
+ * safety gate is not a soaking gate; it is an absent one.
71
+ *
72
+ * What replaces the switch is not "always refuse". It is the detectors' own
73
+ * trigger conditions, which is where the judgement belonged in the first
74
+ * place: A fires only on a closing paragraph that promises work, B only when a
75
+ * language pin exists AND the reply misses it, C only when the turn edited a
76
+ * file and then ran nothing. Silence is the default on every ordinary turn.
77
+ * `delegation-nudge` is the sibling precedent — no structural signal, no
78
+ * output at all.
79
+ *
80
+ * The cost of removing the switch, stated once because it is real: a
81
+ * false-positive detector can no longer be turned off by configuration, only
82
+ * by a revert. That is bounded rather than open-ended — the two re-entrancy
83
+ * layers below cap a misfire at ONE extra turn, never a wedge — and it is the
84
+ * reason a new detector ships only with a measured false-positive corpus.
85
+ *
86
+ * MEASURE IT ON YOUR OWN CORPUS, and the instruction moved here on purpose:
87
+ * it used to live in the settings description that this change deleted, and
88
+ * `src/config/pack-size-budget.json` cites that description as the reason
89
+ * `measure_turn_end_gate.ts` is shipped rather than excluded from the pack. So
90
+ * the pointer is restated rather than dropped —
91
+ * `./scripts-run src/scripts/measure_turn_end_gate --store <transcript dir>`
92
+ * re-derives each detector's fire rate against your own transcripts. No rate is
93
+ * quoted in this file: the round-5 number moved twice while the instrument
94
+ * itself was being corrected, and a fixed figure in a shipped comment would be
95
+ * a moving target presented as a constant.
74
96
  *
75
97
  * CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
76
98
  * otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
@@ -94,13 +116,15 @@ import * as fs from 'node:fs';
94
116
  import * as path from 'node:path';
95
117
  import { fileURLToPath, pathToFileURL } from 'node:url';
96
118
 
97
- import { load_agent_settings } from '../_lib/agent_settings.js';
98
119
  import {
99
120
  classify,
100
121
  isSyntheticPrompt,
101
122
  STATE_FILE as LANGUAGE_STATE_FILE,
102
123
  type Verdict,
103
124
  } from '../language_mirror_hook.js';
125
+ // Round 7 § Phase 1 — the CI-settle producer. Imported for its STATE_FILE only,
126
+ // so the consumer cannot read a path the producer does not write.
127
+ import { STATE_FILE as CI_STATE_FILE } from '../before_complete_hook.js';
104
128
  import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
105
129
  import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
106
130
  import { readHookStdin } from './hook_stdin.js';
@@ -121,7 +145,7 @@ const EXIT_ALLOW = 0;
121
145
  export const TRANSCRIPT_READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
122
146
 
123
147
  /** Detector identity, used in the state marker and the refusal text. */
124
- export type DetectorId = 'promissory' | 'language' | 'verification';
148
+ export type DetectorId = 'promissory' | 'language' | 'verification' | 'completion';
125
149
 
126
150
  export interface Finding {
127
151
  detector: DetectorId;
@@ -323,7 +347,84 @@ export interface LanguagePin {
323
347
  language: Verdict;
324
348
  }
325
349
 
326
- /** Read the pin the language-mirror hook wrote. Absent ⇒ no obligation. */
350
+ /**
351
+ * Round 7 § Phase 1 — where the CI-settle fact comes from, and why not from here.
352
+ *
353
+ * The obvious implementation reads the unsettled state out of the transcript this
354
+ * hook already parses. It cannot: `_toolCalls` keeps only name, command and target
355
+ * path, so a tool RESULT — where the pending count lives — is never read; and
356
+ * `toolCalls` is reset at every genuine user prompt by design, while the measured
357
+ * failure is a completion claim in a LATER turn than the poll. Building it that way
358
+ * meant reversing an invariant this file argues for two functions down.
359
+ *
360
+ * So the producer is `before_complete_hook`, which already sees tool output on
361
+ * `post_tool_use` and already owns the `pendingCount` predicate. It writes
362
+ * `ci_last` into `agents/state/verify-before-complete.json` and this reads it.
363
+ *
364
+ * NO network call, deliberately. Asking `gh pr checks` here would put a network
365
+ * round-trip on every turn-end; `road-to-hook-latency-repair` exists because that
366
+ * cost is real. The rejected alternative is named so the next author does not
367
+ * re-derive it.
368
+ *
369
+ * ASYMMETRY that makes the cross-turn read legitimate where the reader refuses it
370
+ * for verification: a stale POSITIVE ("I verified") wrongly vouches for work it
371
+ * never saw. A stale NEGATIVE ("CI was not settled") only ever refuses more often.
372
+ * Same freshness invariant, opposite failure direction.
373
+ */
374
+ export function readCiSettled(workspaceRoot: string): { seen: boolean; settled: boolean } {
375
+ try {
376
+ const raw = fs.readFileSync(path.join(workspaceRoot, CI_STATE_FILE), 'utf-8');
377
+ const decoded: unknown = JSON.parse(raw);
378
+ if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
379
+ const ci = (decoded as Record<string, unknown>)['ci_last'];
380
+ if (typeof ci === 'object' && ci !== null && !Array.isArray(ci)) {
381
+ return { seen: true, settled: (ci as Record<string, unknown>)['settled'] === true };
382
+ }
383
+ }
384
+ } catch {
385
+ // absent, unreadable, or malformed — all mean "no CI was observed", and a
386
+ // session that never polled CI must never be refused for it.
387
+ }
388
+ return { seen: false, settled: false };
389
+ }
390
+
391
+ /**
392
+ * A completion claim in the delivered reply. Deliberately narrow: the German and
393
+ * English closings the corpus actually produced, anchored to a line so a mid-reply
394
+ * "fertig" inside a sentence about something else does not fire.
395
+ *
396
+ * Measured shapes it must catch (round 7, § Phase 1): "Fertig, Matze." ·
397
+ * "Damit ist alles erledigt." · "Aufgabe erledigt." · "der komplette Auftrag ist
398
+ * durch".
399
+ */
400
+ const _COMPLETION_RE =
401
+ /(^|\n)\s*(?:\*\*)?(?:fertig\b|damit ist alles erledigt|aufgabe erledigt|alles erledigt\b|komplett(?:er)? (?:auftrag|abgearbeitet)|der komplette auftrag ist durch|done[.!]|all done\b|task complete)/i;
402
+
403
+ export function detectCompletionClaim(
404
+ reply: string,
405
+ ci: { seen: boolean; settled: boolean },
406
+ ): Finding | null {
407
+ // A session that never read CI has nothing to be premature about.
408
+ if (!ci.seen || ci.settled) return null;
409
+ const prose = visibleProse(reply);
410
+ const m = _COMPLETION_RE.exec(prose);
411
+ if (!m) return null;
412
+ const at = m.index;
413
+ return {
414
+ detector: 'completion',
415
+ evidence: prose.slice(at, at + 120).trim(),
416
+ reason:
417
+ 'a completion claim while the last CI read was not settled — ' +
418
+ 'read the verdict, then claim it (verify-before-complete)',
419
+ };
420
+ }
421
+
422
+ /**
423
+ * Read the pin the language-mirror hook wrote. Absent ⇒ no obligation.
424
+ *
425
+ * (R2 finding 8: this line was left 73 lines above, where inserting `readCiSettled`
426
+ * had stranded it — so it documented the wrong function and this one had none.)
427
+ */
327
428
  export function readLanguagePin(workspaceRoot: string): Verdict {
328
429
  try {
329
430
  const raw = fs.readFileSync(path.join(workspaceRoot, LANGUAGE_STATE_FILE), 'utf-8');
@@ -688,78 +789,6 @@ function _messageText(content: unknown): string | null {
688
789
  return parts.length > 0 ? parts.join('\n') : null;
689
790
  }
690
791
 
691
- // ---------------------------------------------------------------------------
692
- // Settings — master OFF, detectors ON within it
693
- // ---------------------------------------------------------------------------
694
-
695
- export interface GateSettings {
696
- enabled: boolean;
697
- promissory: boolean;
698
- language: boolean;
699
- verification: boolean;
700
- }
701
-
702
- /**
703
- * YAML spellings a human writes for "on". The `yaml` package parses to the 1.2
704
- * core schema, where `yes` is the STRING "yes" rather than a boolean — so a
705
- * maintainer who writes `enabled: yes` means it, and a reader that silently
706
- * treats it as off is the R2 finding-6b defect wearing a parser instead of a
707
- * regex. Anything not in this set is off; there is no truthiness guessing.
708
- */
709
- function _isOn(v: unknown): boolean | null {
710
- if (typeof v === 'boolean') return v;
711
- if (typeof v !== 'string') return null;
712
- const s = v.trim().toLowerCase();
713
- if (s === 'true' || s === 'yes' || s === 'on') return true;
714
- if (s === 'false' || s === 'no' || s === 'off') return false;
715
- return null;
716
- }
717
-
718
- /**
719
- * Read the three switches through the real settings cascade.
720
- *
721
- * The previous version hand-walked `<workspaceRoot>/.agent-settings.yml` for
722
- * speed, and R2 finding 6 found three defects in that trade: it ignored the
723
- * user-global layer entirely (so a maintainer who opts in globally got no gate
724
- * AND no diagnostic), its end-of-line-anchored regex read
725
- * `enabled: true # soak opt-in` as FALSE, and it accepted a `turn_end_gate:`
726
- * block under ANY parent as ON because a text walker has no notion of a parent.
727
- *
728
- * The stated reason for skipping the cascade does not survive contact with the
729
- * sibling concerns: `delegation-nudge` calls `load_agent_settings` on
730
- * `user_prompt_submit`, i.e. every turn. Correctness on a gate that can refuse
731
- * a turn-end outranks one settings read per turn-end.
732
- */
733
- export function readGateSettings(workspaceRoot: string): GateSettings {
734
- const off: GateSettings = {
735
- enabled: false,
736
- promissory: true,
737
- language: true,
738
- verification: true,
739
- };
740
- let settings: Record<string, unknown>;
741
- try {
742
- settings = load_agent_settings({ cwd: workspaceRoot }) as Record<string, unknown>;
743
- } catch {
744
- return off;
745
- }
746
- const hooks = settings['hooks'];
747
- if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) return off;
748
- const block = (hooks as Record<string, unknown>)['turn_end_gate'];
749
- if (typeof block !== 'object' || block === null || Array.isArray(block)) return off;
750
- const gate = block as Record<string, unknown>;
751
- const flag = (name: string, dflt: boolean): boolean => {
752
- const v = _isOn(gate[name]);
753
- return v === null ? dflt : v;
754
- };
755
- return {
756
- enabled: flag('enabled', false),
757
- promissory: flag('promissory', true),
758
- language: flag('language', true),
759
- verification: flag('verification', true),
760
- };
761
- }
762
-
763
792
  // ---------------------------------------------------------------------------
764
793
  // main
765
794
  // ---------------------------------------------------------------------------
@@ -782,9 +811,6 @@ export function main(): number {
782
811
 
783
812
  const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined) || process.cwd();
784
813
 
785
- const settings = readGateSettings(workspaceRoot);
786
- if (!settings.enabled) return EXIT_ALLOW;
787
-
788
814
  const transcriptPath = str(
789
815
  (payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
790
816
  );
@@ -818,17 +844,24 @@ export function main(): number {
818
844
  );
819
845
  if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
820
846
 
847
+ // Every detector runs on every turn-end. The gating is INSIDE each one —
848
+ // no promise, no pin mismatch, no unverified edit ⇒ no finding ⇒ the turn
849
+ // ends. That is the whole of "fires when it is warranted"; there is no
850
+ // second, configurable notion of warranted layered on top of it.
821
851
  const findings: Finding[] = [];
822
- if (settings.promissory) {
823
- const f = detectPromissory(lastAssistant);
824
- if (f) findings.push(f);
825
- }
826
- if (settings.language) {
827
- const f = detectLanguage(lastAssistant, readLanguagePin(workspaceRoot));
828
- if (f) findings.push(f);
829
- }
830
- if (settings.verification) {
831
- const f = detectUnverifiedEdit(toolCalls);
852
+ for (const f of [
853
+ detectPromissory(lastAssistant),
854
+ detectLanguage(lastAssistant, readLanguagePin(workspaceRoot)),
855
+ detectUnverifiedEdit(toolCalls),
856
+ // Round 7 § Phase 1 — detector D, in the same unconditional list as the
857
+ // other three. It shipped with its own settings flag one commit earlier
858
+ // and that flag is gone: `hooks.turn_end_gate.*` was deleted on
859
+ // 2026-08-12 because a default-off safety gate is an absent one. Its
860
+ // gating is where the comment above says gating belongs — inside the
861
+ // detector: no CI observed, or a settled read, or no completion claim
862
+ // ⇒ no finding.
863
+ detectCompletionClaim(lastAssistant, readCiSettled(workspaceRoot)),
864
+ ]) {
832
865
  if (f) findings.push(f);
833
866
  }
834
867
  if (findings.length === 0) return EXIT_ALLOW;
@@ -840,10 +873,10 @@ export function main(): number {
840
873
  );
841
874
  process.stderr.write(
842
875
  `turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
843
- ' Do the promised work now, correct the language, or run the ' +
844
- 'verification the edit needs, then end the turn.\n' +
845
- ' This turn will not be refused a second time.\n' +
846
- ' Disable: hooks.turn_end_gate.enabled: false in .agent-settings.yml\n',
876
+ ' Do the promised work now, correct the language, run the ' +
877
+ 'verification the edit needs, or read the CI verdict before claiming ' +
878
+ 'it — then end the turn.\n' +
879
+ ' This turn will not be refused a second time.\n',
847
880
  );
848
881
  return EXIT_BLOCK;
849
882
  }