@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +64 -77
- package/CONTRIBUTING.md +23 -14
- package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
- package/dist/agent-src/commands/roadmap/next.md +37 -5
- package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
- package/dist/agent-src/rules/improve-before-implement.md +1 -1
- package/dist/agent-src/rules/ui-audit-gate.md +35 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
- package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
- package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
- package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
- package/dist/agent-src/templates/AGENTS.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/dist/cli/registry.js +2 -2
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
- package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-JP53LNVT.js} +12 -0
- package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
- package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-NR7HSN3P.js} +2 -2
- package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-SFGARMAB.js} +13 -3
- package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
- package/dist/cli-delegate/cmd_conformance.js +4 -4
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +3 -3
- package/dist/cli-delegate/cmd_refresh.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +8 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +3 -3
- package/dist/cli-delegate/cmd_uninstall.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +14 -14
- 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 +789 -401
- package/dist/install/install.mjs +21 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +99 -7
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +5 -2
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
- package/package.json +2 -2
- package/src/agent-src/scripts/roadmap_gates.ts +119 -4
- package/src/agent-src/templates/AGENTS.md +1 -1
- package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/src/config/agent-settings.template.yml +43 -2
- package/src/config/gate-coverage.yml +12 -0
- package/src/config/quorum-attendance-budget.json +10 -2
- package/src/scripts/_cli/cmd_doctor.ts +32 -0
- package/src/scripts/_cli/cmd_migrate.ts +26 -3
- package/src/scripts/_cli/cmd_refresh.ts +29 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
- package/src/scripts/_dispatch.bash +6 -2
- package/src/scripts/_lib/hook_settings.ts +79 -0
- package/src/scripts/_lib/session_register.ts +67 -1
- package/src/scripts/_lib/ui_surface.ts +97 -0
- package/src/scripts/ai_council/config.ts +60 -0
- package/src/scripts/ai_council/events_log.ts +103 -4
- package/src/scripts/ai_council/quorum.ts +83 -6
- package/src/scripts/capture_skill_catalogue.ts +483 -0
- package/src/scripts/council_cli.ts +36 -1
- package/src/scripts/hook_manifest.yaml +18 -3
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/design_slop_hook.ts +1 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
- package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
- package/src/scripts/lint_framework_leakage.ts +195 -28
- package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
- package/src/scripts/lint_roadmap_complexity.ts +60 -0
- package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
- package/src/scripts/memory_lookup.ts +1 -2
- package/src/scripts/report_consultation_rate.ts +358 -0
- package/src/scripts/report_skill_activation.ts +5 -1
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +4 -0
- package/src/scripts/session_register_hook.ts +238 -18
- package/src/scripts/sessions_cli.ts +377 -12
- package/src/server/schemas/settings.ts +12 -0
- /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
- /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
- /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
- /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
|
-
*
|
|
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
|
|
64
|
-
*
|
|
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
|
|
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
|
|
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 = {
|
|
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,
|
|
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);
|