@event4u/agent-config 9.34.0 → 9.36.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 (112) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +64 -77
  3. package/CONTRIBUTING.md +23 -14
  4. package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
  5. package/dist/agent-src/commands/roadmap/next.md +37 -5
  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/ui-audit-gate.md +35 -0
  10. package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
  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/skills/testing-anti-patterns/SKILL.md +25 -0
  20. package/dist/agent-src/templates/AGENTS.md +1 -1
  21. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  22. package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
  23. package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
  24. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  25. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  26. package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  27. package/dist/cli/registry.js +2 -2
  28. package/dist/cli/registry.js.map +1 -1
  29. package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
  30. package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-JP53LNVT.js} +12 -0
  31. package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
  32. package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-NR7HSN3P.js} +2 -2
  33. package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-SFGARMAB.js} +13 -3
  34. package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
  35. package/dist/cli-delegate/cmd_conformance.js +4 -4
  36. package/dist/cli-delegate/cmd_doctor.js +3 -3
  37. package/dist/cli-delegate/cmd_export.js +3 -3
  38. package/dist/cli-delegate/cmd_handoff.js +1 -1
  39. package/dist/cli-delegate/cmd_migrate.js +1 -1
  40. package/dist/cli-delegate/cmd_prune.js +3 -3
  41. package/dist/cli-delegate/cmd_refresh.js +1 -1
  42. package/dist/cli-delegate/cmd_session_recycle.js +8 -1
  43. package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
  44. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  45. package/dist/cli-delegate/cmd_sync.js +3 -3
  46. package/dist/cli-delegate/cmd_uninstall.js +3 -3
  47. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  48. package/dist/cli-delegate/cmd_validate.js +3 -3
  49. package/dist/discovery/deprecation-report.md +1 -1
  50. package/dist/discovery/discovery-manifest.json +14 -14
  51. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  52. package/dist/discovery/discovery-manifest.summary.md +1 -1
  53. package/dist/discovery/dormancy-report.md +1 -1
  54. package/dist/discovery/orphan-report.md +1 -1
  55. package/dist/discovery/packs.json +2 -2
  56. package/dist/discovery/trust-report.md +1 -1
  57. package/dist/discovery/workspaces.json +2 -2
  58. package/dist/hooks/dispatch.js +789 -401
  59. package/dist/install/install.mjs +21 -4
  60. package/dist/mcp/registry-manifest.json +2 -2
  61. package/dist/mcp/server.json +2 -2
  62. package/dist/mcp/server.mjs +99 -7
  63. package/dist/router.json +1 -1
  64. package/dist/server/schemas/settings.js +6 -0
  65. package/dist/server/schemas/settings.js.map +1 -1
  66. package/docs/contracts/settings-classes.md +5 -2
  67. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
  68. package/package.json +2 -2
  69. package/src/agent-src/scripts/roadmap_gates.ts +119 -4
  70. package/src/agent-src/templates/AGENTS.md +1 -1
  71. package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
  72. package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
  73. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
  74. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
  75. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
  76. package/src/config/agent-settings.template.yml +43 -2
  77. package/src/config/gate-coverage.yml +12 -0
  78. package/src/config/quorum-attendance-budget.json +10 -2
  79. package/src/scripts/_cli/cmd_doctor.ts +32 -0
  80. package/src/scripts/_cli/cmd_migrate.ts +26 -3
  81. package/src/scripts/_cli/cmd_refresh.ts +29 -0
  82. package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
  83. package/src/scripts/_dispatch.bash +6 -2
  84. package/src/scripts/_lib/hook_settings.ts +79 -0
  85. package/src/scripts/_lib/session_register.ts +67 -1
  86. package/src/scripts/_lib/ui_surface.ts +97 -0
  87. package/src/scripts/ai_council/config.ts +60 -0
  88. package/src/scripts/ai_council/events_log.ts +103 -4
  89. package/src/scripts/ai_council/quorum.ts +83 -6
  90. package/src/scripts/capture_skill_catalogue.ts +483 -0
  91. package/src/scripts/council_cli.ts +36 -1
  92. package/src/scripts/hook_manifest.yaml +18 -3
  93. package/src/scripts/hooks/concern_registry.ts +2 -0
  94. package/src/scripts/hooks/design_slop_hook.ts +1 -1
  95. package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
  96. package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
  97. package/src/scripts/lint_framework_leakage.ts +195 -28
  98. package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
  99. package/src/scripts/lint_roadmap_complexity.ts +60 -0
  100. package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
  101. package/src/scripts/memory_lookup.ts +1 -2
  102. package/src/scripts/report_consultation_rate.ts +358 -0
  103. package/src/scripts/report_skill_activation.ts +5 -1
  104. package/src/scripts/schemas/command.schema.json +4 -0
  105. package/src/scripts/schemas/skill.schema.json +4 -0
  106. package/src/scripts/session_register_hook.ts +238 -18
  107. package/src/scripts/sessions_cli.ts +377 -12
  108. package/src/server/schemas/settings.ts +12 -0
  109. /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
  110. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
  111. /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
  112. /package/dist/agent-src/skills/react-shadcn-ui/{reference → references}/registry.md +0 -0
@@ -9,11 +9,17 @@
9
9
  * is deliberately not another reminder. It is a check at the point of
10
10
  * delivery that can say no.
11
11
  *
12
- * Two detectors ride on one guard, because building the unsafe part twice
12
+ * Three detectors ride on one guard, because building the unsafe part twice
13
13
  * is how a second detector becomes a second outage:
14
14
  *
15
15
  * A — promissory closing (FC-5, 20 measured occurrences)
16
16
  * B — language mismatch (19 measured occurrences, fresh pin present)
17
+ * C — unverified edit (the turn changed a file and ran nothing that could
18
+ * have checked it — verify-before-complete's gate,
19
+ * read off the turn's TOOL CALLS rather than its
20
+ * prose, which is why `readTranscriptTail` collects
21
+ * them: `_messageText` keeps `type === 'text'` blocks
22
+ * only, so tool activity is invisible to A and B.)
17
23
  *
18
24
  * ## Re-entrancy — the shape, stated before registration
19
25
  *
@@ -60,10 +66,11 @@
60
66
  * mechanism exists and soaks before it binds" — two prior hook-severity
61
67
  * mistakes plus a turn-end blast radius made default-on uninsurable in
62
68
  * their reading. Both are satisfied literally: the MASTER switch
63
- * (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it both
64
- * detectors are default ON. A maintainer who opts in gets both without
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
65
71
  * further configuration; until then this concern is a no-op that costs one
66
- * settings-cascade read per turn-end.
72
+ * settings-cascade read per turn-end. Detector C landed under the same rule —
73
+ * the master switch was NOT flipped when it shipped.
67
74
  *
68
75
  * CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
69
76
  * otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
@@ -71,6 +78,16 @@
71
78
  * crash to a block is the outage this whole file is arranged to avoid.
72
79
  * Every unreadable input, missing transcript, absent pin, or malformed
73
80
  * state resolves to silence.
81
+ *
82
+ * The severity mapping was re-probed when C landed, because
83
+ * `dispatch_hook.ts` defines `EXIT_WARN = 2` beside `EXIT_BLOCK = 1` and an
84
+ * advisory finding delivered on the wrong code becomes a hard deny. Result:
85
+ * this file emits ONLY 0 and 1 internally, `concern_block_exit_parity` pins the
86
+ * 1, and the dispatcher translates a stop-slot block to host exit **2** —
87
+ * which a spawned test in `turn_end_gate_hook.test.ts` asserts, because on the
88
+ * stop slot exit 1 would let the turn end anyway. C is a refusal like A and B,
89
+ * not an advisory, so there is no advisory path here to put on the wrong code.
90
+ * Probed, not assumed.
74
91
  */
75
92
  import * as crypto from 'node:crypto';
76
93
  import * as fs from 'node:fs';
@@ -104,7 +121,7 @@ const EXIT_ALLOW = 0;
104
121
  export const TRANSCRIPT_READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
105
122
 
106
123
  /** Detector identity, used in the state marker and the refusal text. */
107
- export type DetectorId = 'promissory' | 'language';
124
+ export type DetectorId = 'promissory' | 'language' | 'verification';
108
125
 
109
126
  export interface Finding {
110
127
  detector: DetectorId;
@@ -360,6 +377,71 @@ export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
360
377
  };
361
378
  }
362
379
 
380
+ // ---------------------------------------------------------------------------
381
+ // Detector 3 — an edit the turn never verified
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /** Tools that CHANGE the tree. The three write tools, nothing inferred. */
385
+ const _EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
386
+
387
+ /**
388
+ * Shell commands that count as verification.
389
+ *
390
+ * A positive list, and deliberately narrow. The alternative — treating any
391
+ * `Bash` call as verification — was rejected because `ls` would then clear an
392
+ * unverified edit, which is the failure mode `verify-before-complete` names
393
+ * ("relying on partial verification"). The cost of narrowness is a MISSED
394
+ * detection when a project verifies by some command not listed here, and that is
395
+ * the safe direction for a gate that can refuse a turn: a false negative costs
396
+ * one unguarded turn, a false positive teaches the user to switch the gate off.
397
+ */
398
+ const _VERIFY_RE =
399
+ /\b(test|tests|vitest|jest|pytest|phpunit|pest|tsc|eslint|ruff|mypy|pyright|clippy|typecheck|lint|build|ci|preflight|smoke[-_:a-z]*|check[-_:a-z]*|validate[-_:a-z]*)\b|\b(task|npm|pnpm|yarn|composer|cargo|go|make|php artisan|bun)\s+(run\s+)?\S*(test|check|lint|build|ci|typecheck)/i;
400
+
401
+ export function isVerificationCommand(command: string): boolean {
402
+ return _VERIFY_RE.test(command);
403
+ }
404
+
405
+ /**
406
+ * Fire when the turn changed a file and then ran nothing that could have
407
+ * checked it.
408
+ *
409
+ * The window is "after the LAST edit", not "anywhere in the turn": a test run
410
+ * before the final edit demonstrably did not exercise it, which is the same
411
+ * freshness argument `verify-before-complete` makes about trusting an earlier
412
+ * run ("no verification command run in this message → you cannot claim it
413
+ * passes"). Ordering is the only thing that distinguishes the two, so the
414
+ * detector reads the sequence rather than a pair of counts.
415
+ *
416
+ * Silent when the turn edited nothing — a read-only or conversational turn has
417
+ * no claim to verify, and refusing one would make the gate fire on the majority
418
+ * of turns, which is how a guard gets disabled.
419
+ */
420
+ export function detectUnverifiedEdit(toolCalls: readonly ToolCall[]): Finding | null {
421
+ let lastEdit = -1;
422
+ for (let i = 0; i < toolCalls.length; i += 1) {
423
+ if (_EDIT_TOOLS.has(toolCalls[i]!.name)) lastEdit = i;
424
+ }
425
+ if (lastEdit === -1) return null;
426
+ for (let i = lastEdit + 1; i < toolCalls.length; i += 1) {
427
+ const c = toolCalls[i]!;
428
+ if (c.name === 'Bash' && c.command !== undefined && isVerificationCommand(c.command)) {
429
+ return null;
430
+ }
431
+ }
432
+ const edited = toolCalls[lastEdit]!.path;
433
+ return {
434
+ detector: 'verification',
435
+ // The path, never the diff: the evidence span is quoted into a refusal,
436
+ // and `ToolCall` is shaped so a file body cannot reach it.
437
+ evidence: edited ?? toolCalls[lastEdit]!.name,
438
+ reason:
439
+ 'this turn changed a file and then ran no verification command — ' +
440
+ 'no test, type-check, lint or build call follows the last edit ' +
441
+ '(verify-before-complete: a claim without a fresh run is unverified)',
442
+ };
443
+ }
444
+
363
445
  // ---------------------------------------------------------------------------
364
446
  // Re-entrancy — the guard, keyed on the turn, not on the reply
365
447
  // ---------------------------------------------------------------------------
@@ -450,6 +532,24 @@ function markRefusedTurn(
450
532
  // Transcript
451
533
  // ---------------------------------------------------------------------------
452
534
 
535
+ /**
536
+ * One tool call the assistant made, reduced to what a detector can reason
537
+ * about: the tool's name, the shell command when it is a shell call, and the
538
+ * target path when it is a file write.
539
+ *
540
+ * Nothing else is kept. A tool input can hold a whole file body, and this
541
+ * struct is what a refusal quotes back — so it is shaped to be INCAPABLE of
542
+ * carrying one, the same PII-exclusion-by-construction discipline
543
+ * `domain-safety-pii` § Surface 2 asks for in a log line.
544
+ */
545
+ export interface ToolCall {
546
+ name: string;
547
+ /** `Bash` only — the command line, so "did anything verify" is answerable. */
548
+ command?: string;
549
+ /** Edit/Write only — the file the turn changed, used as the evidence span. */
550
+ path?: string;
551
+ }
552
+
453
553
  export interface TranscriptTail {
454
554
  lastAssistant: string;
455
555
  /**
@@ -458,6 +558,14 @@ export interface TranscriptTail {
458
558
  * identity; see `alreadyRefusedTurn` for why the prompt's text is not.
459
559
  */
460
560
  turnOrdinal: number;
561
+ /**
562
+ * The tool calls of the CURRENT turn, in order — reset at every genuine user
563
+ * prompt, so a verification from three turns ago cannot vouch for an edit
564
+ * made now. `_messageText` keeps only `type === 'text'` blocks, which is why
565
+ * this needed its own extraction rather than a reading of `lastAssistant`:
566
+ * tool activity is not in the prose at all.
567
+ */
568
+ toolCalls: ToolCall[];
461
569
  }
462
570
 
463
571
  /**
@@ -475,7 +583,7 @@ export function readTranscriptTail(
475
583
  transcriptPath: string,
476
584
  opts: { homeDir?: string; maxBytes?: number } = {},
477
585
  ): TranscriptTail {
478
- const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0 };
586
+ const empty: TranscriptTail = { lastAssistant: '', turnOrdinal: 0, toolCalls: [] };
479
587
  if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
480
588
  let lines: string[];
481
589
  try {
@@ -494,6 +602,7 @@ export function readTranscriptTail(
494
602
  }
495
603
  let lastAssistant = '';
496
604
  let turnOrdinal = 0;
605
+ let toolCalls: ToolCall[] = [];
497
606
  for (const rawLine of lines) {
498
607
  const line = rawLine.trim();
499
608
  if (!line) continue;
@@ -514,15 +623,55 @@ export function readTranscriptTail(
514
623
  if (role !== 'assistant' && role !== 'user') continue;
515
624
  const msg = entry['message'];
516
625
  if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) continue;
517
- const text = _messageText((msg as Record<string, unknown>)['content']);
626
+ const content = (msg as Record<string, unknown>)['content'];
627
+ // Tool calls are read BEFORE the text guard: an assistant entry that is
628
+ // only a tool_use block has no text at all, so `continue`-ing on a null
629
+ // text would drop exactly the entries this detector exists to see.
630
+ if (role === 'assistant') {
631
+ toolCalls.push(..._toolCalls(content));
632
+ }
633
+ const text = _messageText(content);
518
634
  if (text === null) continue;
519
635
  if (role === 'assistant') {
520
636
  lastAssistant = text;
521
637
  } else if (!isSyntheticPrompt(text)) {
522
638
  turnOrdinal += 1;
639
+ // A genuine user prompt starts a new turn, so the previous turn's
640
+ // tool activity stops counting. Without this reset, a verification
641
+ // run three turns ago would vouch for an edit made now — the
642
+ // "fresh" in edit-without-FRESH-verification is this line.
643
+ toolCalls = [];
644
+ }
645
+ }
646
+ return { lastAssistant: lastAssistant.trim(), turnOrdinal, toolCalls };
647
+ }
648
+
649
+ /**
650
+ * Extract this entry's tool calls, keeping only name, shell command and target
651
+ * path. A tool input can hold a whole file body; nothing but those three fields
652
+ * is carried forward.
653
+ */
654
+ function _toolCalls(content: unknown): ToolCall[] {
655
+ if (!Array.isArray(content)) return [];
656
+ const out: ToolCall[] = [];
657
+ for (const blk of content) {
658
+ if (typeof blk !== 'object' || blk === null || Array.isArray(blk)) continue;
659
+ const b = blk as Record<string, unknown>;
660
+ if (b['type'] !== 'tool_use') continue;
661
+ const name = b['name'];
662
+ if (typeof name !== 'string') continue;
663
+ const input = b['input'];
664
+ const call: ToolCall = { name };
665
+ if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
666
+ const inp = input as Record<string, unknown>;
667
+ const cmd = inp['command'];
668
+ if (typeof cmd === 'string') call.command = cmd;
669
+ const p = inp['file_path'] ?? inp['path'] ?? inp['notebook_path'];
670
+ if (typeof p === 'string') call.path = p;
523
671
  }
672
+ out.push(call);
524
673
  }
525
- return { lastAssistant: lastAssistant.trim(), turnOrdinal };
674
+ return out;
526
675
  }
527
676
 
528
677
  function _messageText(content: unknown): string | null {
@@ -547,6 +696,7 @@ export interface GateSettings {
547
696
  enabled: boolean;
548
697
  promissory: boolean;
549
698
  language: boolean;
699
+ verification: boolean;
550
700
  }
551
701
 
552
702
  /**
@@ -581,7 +731,12 @@ function _isOn(v: unknown): boolean | null {
581
731
  * a turn-end outranks one settings read per turn-end.
582
732
  */
583
733
  export function readGateSettings(workspaceRoot: string): GateSettings {
584
- const off: GateSettings = { enabled: false, promissory: true, language: true };
734
+ const off: GateSettings = {
735
+ enabled: false,
736
+ promissory: true,
737
+ language: true,
738
+ verification: true,
739
+ };
585
740
  let settings: Record<string, unknown>;
586
741
  try {
587
742
  settings = load_agent_settings({ cwd: workspaceRoot }) as Record<string, unknown>;
@@ -601,6 +756,7 @@ export function readGateSettings(workspaceRoot: string): GateSettings {
601
756
  enabled: flag('enabled', false),
602
757
  promissory: flag('promissory', true),
603
758
  language: flag('language', true),
759
+ verification: flag('verification', true),
604
760
  };
605
761
  }
606
762
 
@@ -644,7 +800,7 @@ export function main(): number {
644
800
  // in production while its comment claimed it was enforced — the same
645
801
  // fixed-the-definition-not-the-caller shape as two other findings in that
646
802
  // round, which is why this line exists rather than a default parameter.
647
- const { lastAssistant, turnOrdinal } = readTranscriptTail(transcriptPath, {
803
+ const { lastAssistant, turnOrdinal, toolCalls } = readTranscriptTail(transcriptPath, {
648
804
  maxBytes: TRANSCRIPT_READ_MAX_BYTES,
649
805
  });
650
806
  if (!lastAssistant) return EXIT_ALLOW;
@@ -671,6 +827,10 @@ export function main(): number {
671
827
  const f = detectLanguage(lastAssistant, readLanguagePin(workspaceRoot));
672
828
  if (f) findings.push(f);
673
829
  }
830
+ if (settings.verification) {
831
+ const f = detectUnverifiedEdit(toolCalls);
832
+ if (f) findings.push(f);
833
+ }
674
834
  if (findings.length === 0) return EXIT_ALLOW;
675
835
 
676
836
  markRefusedTurn(workspaceRoot, sessionKey, turnOrdinal, findings[0]!.detector);
@@ -680,7 +840,8 @@ export function main(): number {
680
840
  );
681
841
  process.stderr.write(
682
842
  `turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
683
- ' Do the promised work now, or correct the language, then end the turn.\n' +
843
+ ' Do the promised work now, correct the language, or run the ' +
844
+ 'verification the edit needs, then end the turn.\n' +
684
845
  ' This turn will not be refused a second time.\n' +
685
846
  ' Disable: hooks.turn_end_gate.enabled: false in .agent-settings.yml\n',
686
847
  );
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * PreToolUse UI-route nudge — deterministic, warn-only, default-OFF.
4
+ *
5
+ * The two UI rules have `enforced_by: none` and say so honestly: nothing
6
+ * observes whether a UI write consulted the design surface first. This concern
7
+ * observes it — and it is a nudge, not a gate. It never blocks (dispatcher
8
+ * contract: 0 allow · 2 warn), because the obligation it points at is a
9
+ * judgement call and a block on a judgement call trades an ignore-problem for
10
+ * a friction-problem.
11
+ *
12
+ * IT DOES NOT READ THE RULES. Stated because the first draft of this header
13
+ * claimed it did. The UI-surface decision comes from `_lib/ui_surface.ts`; no
14
+ * code here parses `src/rules/*.md` frontmatter, so the rules' `keyword:`
15
+ * triggers still have no runtime consumer and this predicate is deliberately
16
+ * wider than their `file_pattern` list. The two are kept from drifting by
17
+ * `ui_rule_triggers.test.ts`, which asserts every declared pattern is accepted
18
+ * here — a test, not a dependency.
19
+ *
20
+ * WHAT IT OBSERVES, WHICH IS ALSO THE MEASUREMENT.
21
+ * Every PreToolUse event passes through here. A read or search touching a
22
+ * design surface (`fe-design`, `existing-ui-audit`, `design-review`,
23
+ * `design-intelligence`) latches "consulted" for the session. A Write or Edit
24
+ * to a UI path with no such latch is the case the nudge exists for. That latch
25
+ * is exactly the consultation signal the pre-registered consultation rate
26
+ * counts, so the nudge and the metric read the same event stream rather than
27
+ * two populations that could drift apart.
28
+ *
29
+ * ANTI-LOOP. At most `MAX_NUDGES` per session, then silent — the same
30
+ * degradation valve `design_slop_hook` carries, for the same reason: an agent
31
+ * that has decided to proceed must not be re-prompted into a loop.
32
+ *
33
+ * HOST BOUNDARY, STATED RATHER THAN IMPLIED. `pre_tool_use` exists on three
34
+ * hosts. Everywhere else this concern has nowhere to bind and the obligation
35
+ * stays model-carried, exactly as the two rules already declare. Run
36
+ * `agent-config hooks:status` for the host you are actually on.
37
+ *
38
+ * Default-OFF: no-ops unless `hooks.ui_route_nudge.enabled: true`.
39
+ * fail_closed: false — any error returns allow.
40
+ */
41
+ import * as fs from 'node:fs';
42
+ import * as path from 'node:path';
43
+ import { fileURLToPath, pathToFileURL } from 'node:url';
44
+
45
+ import { hookSectionEnabled } from '../_lib/hook_settings.js';
46
+ import { isUiPath, isUiTreePath } from '../_lib/ui_surface.js';
47
+ import { readHookStdin } from './hook_stdin.js';
48
+
49
+ const EXIT_ALLOW = 0;
50
+ const EXIT_WARN = 2;
51
+ /** Nudges per session before the valve goes silent. */
52
+ export const MAX_NUDGES = 2;
53
+
54
+ /** Reads or searches that count as consulting the design surface. */
55
+ export const DESIGN_SURFACES = [
56
+ 'fe-design',
57
+ 'existing-ui-audit',
58
+ 'design-review',
59
+ 'design-intelligence',
60
+ ] as const;
61
+
62
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
63
+ type JsonObject = { [k: string]: JsonValue };
64
+
65
+ function isObject(v: unknown): v is JsonObject {
66
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
67
+ }
68
+
69
+ function unwrap(envelope: JsonObject): JsonObject {
70
+ const inner = envelope['payload'];
71
+ return isObject(inner) ? inner : envelope;
72
+ }
73
+
74
+ export interface ToolEvent {
75
+ /** Path the tool touches; "" when the tool names none. */
76
+ file: string;
77
+ /** True when the tool proposes new content (Write / Edit). */
78
+ isWrite: boolean;
79
+ }
80
+
81
+ /** Best-effort extraction; mirrors the shape `design_slop_hook` reads. */
82
+ export function extractEvent(outer: JsonObject): ToolEvent | null {
83
+ const envelope = unwrap(outer);
84
+ const ti = envelope['tool_input'] ?? envelope['toolInput'] ?? envelope['input'];
85
+ if (!isObject(ti)) return null;
86
+
87
+ const fileVal = ti['file_path'] ?? ti['path'] ?? ti['filePath'];
88
+ const file = typeof fileVal === 'string' ? fileVal : '';
89
+
90
+ let isWrite = false;
91
+ for (const key of ['content', 'new_string', 'new_str', 'text', 'newText']) {
92
+ if (typeof ti[key] === 'string' && (ti[key] as string).length > 0) isWrite = true;
93
+ }
94
+
95
+ return { file, isWrite };
96
+ }
97
+
98
+ /**
99
+ * True when the event OPENS one of the design surfaces.
100
+ *
101
+ * Deliberately narrow, and the narrowness is the fix for a real defect: an
102
+ * earlier version matched the surface name anywhere in the path OR in any
103
+ * search text the tool carried, including `command`. That silenced the nudge
104
+ * for a whole session on a `git log -- src/skills/fe-design`, on a grep for
105
+ * `design-review` while writing a review artefact, and on a read of
106
+ * `src/rules/design-review-after-ui-write.md` — whose path contains the skill
107
+ * name without being the skill. Since the same latch is the consultation
108
+ * signal the pre-registered rate counts, one false positive corrupted both the
109
+ * gate and the metric.
110
+ *
111
+ * So consultation means: a non-write touching `skills/<surface>/`. A search is
112
+ * not a consultation — reading the skill is.
113
+ */
114
+ export function isConsultation(event: ToolEvent): boolean {
115
+ if (event.isWrite || !event.file) return false;
116
+ const normalized = event.file.replace(/\\/g, '/').toLowerCase();
117
+ return DESIGN_SURFACES.some((surface) => normalized.includes(`skills/${surface}/`));
118
+ }
119
+
120
+ /** True when the event writes a UI surface. */
121
+ export function isUiWrite(event: ToolEvent): boolean {
122
+ if (!event.isWrite || !event.file) return false;
123
+ return isUiPath(event.file) || isUiTreePath(event.file);
124
+ }
125
+
126
+ export interface SessionState {
127
+ consulted: boolean;
128
+ nudges: number;
129
+ }
130
+
131
+ function stateFile(root: string): string {
132
+ return path.join(root, 'agents', 'runtime', 'state', 'ui-route-nudge.json');
133
+ }
134
+
135
+ export function readState(root: string, session: string): SessionState {
136
+ try {
137
+ const all = JSON.parse(fs.readFileSync(stateFile(root), 'utf-8')) as Record<
138
+ string,
139
+ SessionState
140
+ >;
141
+ const entry = all[session];
142
+ if (entry && typeof entry.consulted === 'boolean' && typeof entry.nudges === 'number') {
143
+ return entry;
144
+ }
145
+ } catch {
146
+ /* fresh session */
147
+ }
148
+ return { consulted: false, nudges: 0 };
149
+ }
150
+
151
+ /** Sessions retained in the state file; oldest entries drop past this. */
152
+ export const MAX_SESSIONS = 50;
153
+
154
+ /** Drop the oldest entries so the file cannot grow without bound. */
155
+ export function pruneSessions(
156
+ all: Record<string, SessionState>,
157
+ keep: number = MAX_SESSIONS,
158
+ ): Record<string, SessionState> {
159
+ const keys = Object.keys(all);
160
+ if (keys.length <= keep) return all;
161
+ const survivors = keys.slice(keys.length - keep);
162
+ const pruned: Record<string, SessionState> = {};
163
+ for (const key of survivors) pruned[key] = all[key]!;
164
+ return pruned;
165
+ }
166
+
167
+ export function writeState(root: string, session: string, state: SessionState): void {
168
+ try {
169
+ const file = stateFile(root);
170
+ let all: Record<string, SessionState> = {};
171
+ try {
172
+ all = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<string, SessionState>;
173
+ } catch {
174
+ // Fresh file, or one nothing can parse. There is no other session
175
+ // state to preserve in either case — a corrupt file has already
176
+ // lost it — so starting from empty is recovery, not discard. The
177
+ // cost is bounded: a lost latch means at most MAX_NUDGES extra
178
+ // reminders in the sessions that were in flight.
179
+ }
180
+ if (typeof all !== 'object' || all === null || Array.isArray(all)) all = {};
181
+ all[session] = state;
182
+ fs.mkdirSync(path.dirname(file), { recursive: true });
183
+ fs.writeFileSync(file, JSON.stringify(pruneSessions(all)));
184
+ } catch {
185
+ /* fail-open: a persistence failure must never break a tool call */
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The decision, pure — so the test exercises the logic rather than the
191
+ * filesystem. Returns the next state and whether to warn.
192
+ */
193
+ export function decide(
194
+ event: ToolEvent,
195
+ state: SessionState,
196
+ ): { state: SessionState; warn: boolean } {
197
+ if (isConsultation(event)) {
198
+ return { state: { ...state, consulted: true }, warn: false };
199
+ }
200
+ if (!isUiWrite(event)) return { state, warn: false };
201
+ if (state.consulted) return { state, warn: false };
202
+ if (state.nudges >= MAX_NUDGES) return { state, warn: false };
203
+ return { state: { ...state, nudges: state.nudges + 1 }, warn: true };
204
+ }
205
+
206
+ export function nudgeReason(file: string): string {
207
+ return `Non-trivial UI write to ${file} with no design consultation this session. Run \`existing-ui-audit\` first, then the fe-design loop (brief → build → review). Skip only if this is ui-trivial: <=1 file, <=5 lines, no new component, no new state, no new dependency.`;
208
+ }
209
+
210
+ export function main(): number {
211
+ let envelope: JsonValue;
212
+ try {
213
+ const raw = readHookStdin();
214
+ if (!raw.trim()) return EXIT_ALLOW;
215
+ envelope = JSON.parse(raw) as JsonValue;
216
+ } catch {
217
+ return EXIT_ALLOW;
218
+ }
219
+ if (!isObject(envelope)) return EXIT_ALLOW;
220
+
221
+ const rootVal = envelope['workspace_root'];
222
+ const root = typeof rootVal === 'string' && rootVal ? rootVal : process.cwd();
223
+ if (!hookSectionEnabled(root, 'ui_route_nudge')) return EXIT_ALLOW;
224
+
225
+ const sessionVal = envelope['session_id'];
226
+ const session = typeof sessionVal === 'string' && sessionVal ? sessionVal : 'unknown';
227
+
228
+ const event = extractEvent(envelope);
229
+ if (!event) return EXIT_ALLOW;
230
+
231
+ const before = readState(root, session);
232
+ const { state, warn } = decide(event, before);
233
+ if (state.consulted !== before.consulted || state.nudges !== before.nudges) {
234
+ writeState(root, session, state);
235
+ }
236
+ if (!warn) return EXIT_ALLOW;
237
+
238
+ process.stdout.write(`${JSON.stringify({ reason: nudgeReason(event.file) })}\n`);
239
+ return EXIT_WARN;
240
+ }
241
+
242
+ function isCliEntry(): boolean {
243
+ const entry = process.argv[1];
244
+ if (!entry) return false;
245
+ return pathToFileURL(path.resolve(entry)).href === import.meta.url;
246
+ }
247
+
248
+ if (isCliEntry()) process.exit(main());
249
+
250
+ export const _HOOK_FILE = fileURLToPath(import.meta.url);