@event4u/agent-config 9.25.0 → 9.27.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 +58 -143
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +21 -2
- package/dist/agent-src/commands/review/changes.md +6 -5
- package/dist/agent-src/commands/roadmap/next.md +256 -0
- package/dist/agent-src/commands/roadmap.md +12 -4
- package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
- package/dist/agent-src/rules/active-remediation.md +2 -0
- package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
- package/dist/agent-src/rules/architecture.md +2 -0
- package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
- package/dist/agent-src/rules/artifact-engagement-recording.md +2 -0
- package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
- package/dist/agent-src/rules/autonomous-execution.md +2 -0
- package/dist/agent-src/rules/brand-consistency.md +2 -0
- package/dist/agent-src/rules/brand-source-of-truth.md +2 -0
- package/dist/agent-src/rules/broken-access-control.md +2 -0
- package/dist/agent-src/rules/cli-output-handling.md +2 -0
- package/dist/agent-src/rules/code-comment-discipline.md +2 -0
- package/dist/agent-src/rules/code-provenance.md +2 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
- package/dist/agent-src/rules/commit-conventions.md +2 -0
- package/dist/agent-src/rules/communication-through-line.md +2 -0
- package/dist/agent-src/rules/content-quoting-floor.md +2 -0
- package/dist/agent-src/rules/context-hygiene.md +10 -0
- package/dist/agent-src/rules/copilot-routing.md +2 -0
- package/dist/agent-src/rules/cross-source-consistency.md +2 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/delegation-policy.md +5 -2
- package/dist/agent-src/rules/design-fidelity.md +19 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +120 -0
- package/dist/agent-src/rules/devcontainer-routing.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
- package/dist/agent-src/rules/docker-commands.md +2 -0
- package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
- package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
- package/dist/agent-src/rules/domain-safety-pii.md +2 -0
- package/dist/agent-src/rules/domain-safety-retention.md +2 -0
- package/dist/agent-src/rules/downstream-changes.md +2 -0
- package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
- package/dist/agent-src/rules/evaluator-independence.md +19 -3
- package/dist/agent-src/rules/external-code-graph-interop.md +2 -0
- package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
- package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
- package/dist/agent-src/rules/finance-safety-floor.md +2 -0
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
- package/dist/agent-src/rules/git-history-discipline.md +3 -1
- package/dist/agent-src/rules/guidelines.md +2 -0
- package/dist/agent-src/rules/history-discipline.md +2 -0
- package/dist/agent-src/rules/icon-consistency.md +2 -0
- package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
- package/dist/agent-src/rules/improve-before-implement.md +2 -0
- package/dist/agent-src/rules/invite-challenge.md +2 -0
- package/dist/agent-src/rules/laravel-routing.md +2 -0
- package/dist/agent-src/rules/laravel-translations.md +2 -0
- package/dist/agent-src/rules/legal-safety-floor.md +2 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
- package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
- package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
- package/dist/agent-src/rules/media-governance-routing.md +2 -0
- package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/missing-tool-handling.md +2 -0
- package/dist/agent-src/rules/model-recommendation.md +2 -0
- package/dist/agent-src/rules/no-attribution-footers.md +2 -0
- package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
- package/dist/agent-src/rules/no-pr-progress-comments.md +2 -0
- package/dist/agent-src/rules/no-roadmap-references.md +2 -0
- package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
- package/dist/agent-src/rules/onboarding-gate.md +2 -0
- package/dist/agent-src/rules/output-discipline.md +2 -0
- package/dist/agent-src/rules/package-ci-checks.md +2 -0
- package/dist/agent-src/rules/persona-governance.md +2 -0
- package/dist/agent-src/rules/php-coding.md +2 -0
- package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
- package/dist/agent-src/rules/preservation-guard.md +2 -0
- package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
- package/dist/agent-src/rules/question-not-instruction.md +2 -0
- package/dist/agent-src/rules/reviewer-awareness.md +2 -0
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/role-mode-adherence.md +2 -0
- package/dist/agent-src/rules/rule-type-governance.md +2 -0
- package/dist/agent-src/rules/runtime-safety.md +2 -0
- package/dist/agent-src/rules/scale-discipline.md +2 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
- package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
- package/dist/agent-src/rules/session-canary.md +63 -19
- package/dist/agent-src/rules/settings-ask-protocol.md +180 -0
- package/dist/agent-src/rules/size-enforcement.md +2 -0
- package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +2 -0
- package/dist/agent-src/rules/source-discovery-gate.md +2 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
- package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
- package/dist/agent-src/rules/symfony-routing.md +2 -0
- package/dist/agent-src/rules/think-before-action.md +2 -0
- package/dist/agent-src/rules/token-budget-discipline.md +8 -3
- package/dist/agent-src/rules/token-efficiency.md +2 -0
- package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
- package/dist/agent-src/rules/tool-safety.md +2 -0
- package/dist/agent-src/rules/ui-audit-gate.md +7 -0
- package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
- package/dist/agent-src/rules/upstream-proposal.md +2 -0
- package/dist/agent-src/rules/user-interaction.md +5 -2
- package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
- package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-3KJIMFTB.js} +1 -1
- package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-BCPIOAGV.js} +148 -133
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +4 -1
- package/dist/cli-delegate/cmd_route_explain.js +4 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +191 -120
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +11 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +9 -6
- package/dist/hooks/dispatch.js +1148 -349
- package/dist/install/install.mjs +1 -1
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +571 -417
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +33 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/io/yamlIO.js +85 -0
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/nicknameResolver.js +116 -0
- package/dist/server/nicknameResolver.js.map +1 -0
- package/dist/server/routes/ping.js +29 -11
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/server/routes/settings.js +1 -12
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/wizard.js +52 -2
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/interactiveContext.js +73 -0
- package/dist/shared/interactiveContext.js.map +1 -0
- package/dist/shared/nicknamePrefill.js +59 -0
- package/dist/shared/nicknamePrefill.js.map +1 -0
- package/dist/shared/settingsAsks.js +128 -0
- package/dist/shared/settingsAsks.js.map +1 -0
- package/dist/shared/settingsCarveOut.js +118 -0
- package/dist/shared/settingsCarveOut.js.map +1 -0
- package/dist/shared/settingsConsent.js +93 -0
- package/dist/shared/settingsConsent.js.map +1 -0
- package/dist/shared/settingsSurface.js +7 -1
- package/dist/shared/settingsSurface.js.map +1 -1
- package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
- package/package.json +1 -1
- package/src/config/agents-paths.yml +15 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +29 -1
- package/src/config/gate-violation-baselines.json +13 -7
- package/src/scripts/_cli/cmd_doctor.ts +6 -21
- package/src/scripts/_cli/cmd_route_audit.ts +4 -1
- package/src/scripts/_cli/cmd_route_explain.ts +9 -1
- package/src/scripts/_dispatch.bash +18 -0
- package/src/scripts/_lib/git_common_dir.ts +128 -0
- package/src/scripts/_lib/obligation_frequency.ts +391 -0
- package/src/scripts/_lib/session_register.ts +361 -0
- package/src/scripts/ai-video/stitch.sh +27 -9
- package/src/scripts/before_complete_hook.ts +23 -4
- package/src/scripts/bench_ab_v2_run.ts +272 -16
- package/src/scripts/bench_ab_v2_stats.ts +156 -3
- package/src/scripts/check_completion_review.ts +80 -3
- package/src/scripts/check_corpus_staleness.ts +692 -0
- package/src/scripts/check_enforcement_coverage.ts +192 -7
- package/src/scripts/check_gate_coverage.ts +67 -0
- package/src/scripts/check_hook_bundle_freshness.ts +183 -0
- package/src/scripts/check_rule_projection_integrity.ts +364 -0
- package/src/scripts/condense.ts +36 -2
- package/src/scripts/conformance_scan.ts +82 -17
- package/src/scripts/dispatch_r2_reviewer.ts +31 -2
- package/src/scripts/generate_settings_reference.ts +232 -0
- package/src/scripts/git_authorization_hook.ts +75 -10
- package/src/scripts/hook_manifest.yaml +87 -27
- package/src/scripts/hooks/block_no_verify.ts +59 -2
- package/src/scripts/hooks/block_unauthorized_git.ts +199 -16
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/design_slop_hook.ts +13 -1
- package/src/scripts/hooks/dispatch_hook.ts +97 -2
- package/src/scripts/hooks/evidence_independence.ts +40 -6
- package/src/scripts/language_mirror_hook.ts +256 -17
- package/src/scripts/lint_abstraction_thresholds.ts +41 -8
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_hook_manifest.ts +29 -0
- package/src/scripts/memory_learn_hook.ts +109 -6
- package/src/scripts/orchestration_backfill.ts +354 -0
- package/src/scripts/pr_url_reminder_hook.ts +17 -2
- package/src/scripts/release.ts +180 -37
- package/src/scripts/schemas/review-findings.schema.json +99 -0
- package/src/scripts/schemas/rule.schema.json +5 -0
- package/src/scripts/session_canary_hook.ts +35 -3
- package/src/scripts/session_register_hook.ts +288 -0
- package/src/scripts/sessions_cli.ts +140 -0
- package/src/scripts/validate_frontmatter.ts +68 -0
- package/src/server/io/yamlIO.ts +87 -0
- package/src/shared/interactiveContext.ts +98 -0
- package/src/shared/nicknamePrefill.ts +96 -0
- package/src/shared/settingsAsks.ts +176 -0
- package/src/shared/settingsCarveOut.ts +143 -0
- package/src/shared/settingsConsent.ts +136 -0
- package/src/shared/settingsSurface.ts +7 -1
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
*
|
|
8
8
|
* WHY THIS EXISTS — the measured defect, not a general reminder.
|
|
9
9
|
*
|
|
10
|
-
* A conformance audit of 30 sessions (2026-08-06) found
|
|
10
|
+
* A conformance audit of 30 sessions (2026-08-06) found assistant turns
|
|
11
11
|
* answering a German prompt in English, across 11 sessions, three of them a
|
|
12
|
-
* 100 % English run. The
|
|
13
|
-
*
|
|
12
|
+
* 100 % English run. The count this comment first carried, ~470, was an early
|
|
13
|
+
* intermediate figure and is superseded: that audit's own final baseline is
|
|
14
|
+
* **626**, and conformance round 5 (2026-08-07) measures **641** over an
|
|
15
|
+
* overlapping-but-not-identical 30-session window. Both are published side by
|
|
16
|
+
* side rather than treated as a trend, because the windows differ. The obvious
|
|
17
|
+
* reading is "the model ignored a rule". The transcripts say otherwise:
|
|
14
18
|
*
|
|
15
19
|
* **47 skill- and slash-command bodies arrive in the `user` role** across 21
|
|
16
20
|
* of the 30 sessions. In the worst session the last user-role content before
|
|
@@ -33,15 +37,35 @@
|
|
|
33
37
|
* context does not otherwise contain — which language the human wrote in.
|
|
34
38
|
*
|
|
35
39
|
* State: `agents/state/language-mirror.json`
|
|
36
|
-
* { "language": "de"|"en"|"und", "
|
|
37
|
-
* "
|
|
38
|
-
* "session_id": str }
|
|
40
|
+
* { "language": "de"|"en"|"und", "source": "prompt"|"system-locale",
|
|
41
|
+
* "detected_at": iso8601, "prompt_chars": int, "de_markers": int,
|
|
42
|
+
* "en_markers": int, "session_id": str }
|
|
39
43
|
*
|
|
40
44
|
* Undetermined prompts (a bare "1", "ok", a pasted URL) leave a previous pin
|
|
41
45
|
* UNTOUCHED rather than clearing it — a short continuation does not change the
|
|
42
46
|
* conversation's language, and clearing on every terse turn would reproduce the
|
|
43
47
|
* exact drift this hook removes.
|
|
44
48
|
*
|
|
49
|
+
* FIRST TURN OF A SESSION — the system-locale fallback.
|
|
50
|
+
*
|
|
51
|
+
* The keep-previous-pin rule has nothing to keep on the very first prompt. If
|
|
52
|
+
* that prompt is terse ("weiter", "1", a pasted URL) the verdict is `und`, no
|
|
53
|
+
* pin is written, and the turn runs with no pin at all — which is the drift
|
|
54
|
+
* this hook exists to remove, occurring precisely where the conversation has
|
|
55
|
+
* the least other evidence. So when there is no previous pin AND the prompt is
|
|
56
|
+
* undetermined, the environment's locale supplies a starting language, recorded
|
|
57
|
+
* as `source: "system-locale"` so the weaker provenance is legible rather than
|
|
58
|
+
* indistinguishable from a read of the user's own words.
|
|
59
|
+
*
|
|
60
|
+
* A locale pin is a floor, never a ceiling: the FIRST prompt carrying real
|
|
61
|
+
* markers replaces it, because `source: "prompt"` always wins. That is what
|
|
62
|
+
* makes "any later explicit statement by the user overrides it" true without a
|
|
63
|
+
* second mechanism — the ordinary path already outranks the fallback.
|
|
64
|
+
*
|
|
65
|
+
* Locales outside the classifier's two languages stay `und` on purpose. Mapping
|
|
66
|
+
* `fr_FR` to English because the classifier has no French would pin a language
|
|
67
|
+
* the user never wrote, which is worse than carrying no pin.
|
|
68
|
+
*
|
|
45
69
|
* Never blocks (`fail_closed: false`, `severity: advisory`). Exit 0 always.
|
|
46
70
|
*/
|
|
47
71
|
import fs from "node:fs";
|
|
@@ -52,6 +76,13 @@ import { atomic_write_json } from "./hooks/state_io.js";
|
|
|
52
76
|
import { readHookStdin } from "./hooks/hook_stdin.js";
|
|
53
77
|
|
|
54
78
|
const EXIT_ALLOW = 0;
|
|
79
|
+
// Severity is taken from the EXIT CODE, not from the `decision` field in the
|
|
80
|
+
// stdout payload. This hook shipped returning EXIT_ALLOW while writing
|
|
81
|
+
// {"decision":"warn", …}, so the dispatcher reduced it to `allow`, emitted
|
|
82
|
+
// nothing, and the pin reached the model on no path — a state write with no
|
|
83
|
+
// delivery, for the audit's largest failure class (626 turns). Round 2, found
|
|
84
|
+
// by tracing the delivery rather than re-reading the unit tests.
|
|
85
|
+
const EXIT_WARN = 2;
|
|
55
86
|
|
|
56
87
|
export const STATE_FILE = path.join("agents", "state", "language-mirror.json");
|
|
57
88
|
|
|
@@ -68,7 +99,7 @@ function _isObject(v: unknown): v is JsonObject {
|
|
|
68
99
|
* ("fixe die ci", "mach weiter") where content words carry no signal.
|
|
69
100
|
*/
|
|
70
101
|
const DE_MARKERS =
|
|
71
|
-
/\b(der|die|das|den|dem|und|ist|sind|nicht|kein|keine|mach|mache|bitte|warum|wieso|weshalb|weiter|kannst|sollst|soll|musst|muss|hast|habe|haben|wir|ich|du|dann|noch|auch|schon|wenn|aber|alle|mit|ohne|damit|dass|wurde|werden|gibt|nochmal|erstell|erstelle|erstelle|fehler|regeln|arbeite|analysiere|fixe|nimm|lege|prüfe|pruefe|schau|zeig|mir|dir|sich|eine|einen|einem|einer|vom|zum|zur|beim|im
|
|
102
|
+
/\b(der|die|das|den|dem|und|ist|sind|nicht|kein|keine|mach|mache|bitte|warum|wieso|weshalb|weiter|kannst|sollst|soll|musst|muss|hast|habe|haben|wir|ich|du|dann|noch|auch|schon|wenn|aber|alle|mit|ohne|damit|dass|wurde|werden|gibt|nochmal|erstell|erstelle|erstelle|fehler|regeln|arbeite|analysiere|fixe|nimm|lege|prüfe|pruefe|schau|zeig|mir|dir|sich|eine|einen|einem|einer|vom|zum|zur|beim|im)\b|[äöüÄÖÜß]/gi;
|
|
72
103
|
|
|
73
104
|
/**
|
|
74
105
|
* English function words that a German sentence would not contain. Chosen to
|
|
@@ -96,9 +127,45 @@ export interface Classification {
|
|
|
96
127
|
* Below `MIN_MARKERS` on both sides the verdict is `und` (undetermined) and the
|
|
97
128
|
* caller keeps any previous pin.
|
|
98
129
|
*/
|
|
130
|
+
/**
|
|
131
|
+
* Strip the parts of a prompt that are PASTED rather than written.
|
|
132
|
+
*
|
|
133
|
+
* Without this the classifier reproduced the very defect it exists to fix: one
|
|
134
|
+
* German instruction plus a pasted English stack trace scored `en=18 / de=2`
|
|
135
|
+
* and pinned English — then asserted emphatically that English was the mirror
|
|
136
|
+
* target. The maintainer's most common prompt shape is exactly that.
|
|
137
|
+
*/
|
|
138
|
+
export function instructionText(prompt: string): string {
|
|
139
|
+
const OUTPUT_HEAD =
|
|
140
|
+
/^\s*(To\s+\S+|remote:|error:|fatal:|hint:|warning:|npm ERR!|Error:|Traceback|Exception|[A-Za-z]*Error\b|at\s+\S+:\d+|\s{4,}\S|[-+]{3}\s|@@\s|\$\s|>\s|\||\d+\s*\|)/i;
|
|
141
|
+
const kept: string[] = [];
|
|
142
|
+
let inPaste = false;
|
|
143
|
+
for (const line of prompt.replace(/```[\s\S]*?```/g, "\n\n").split("\n")) {
|
|
144
|
+
if (!line.trim()) {
|
|
145
|
+
// A blank line ends a pasted block — this is how pastes actually look,
|
|
146
|
+
// and it is what makes the filter work on a MULTI-LINE trace whose
|
|
147
|
+
// continuation lines carry no marker of their own.
|
|
148
|
+
inPaste = false;
|
|
149
|
+
kept.push(line);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (OUTPUT_HEAD.test(line)) {
|
|
153
|
+
inPaste = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (inPaste) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
kept.push(line);
|
|
160
|
+
}
|
|
161
|
+
return kept.join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
99
165
|
export function classify(prompt: string): Classification {
|
|
100
|
-
const
|
|
101
|
-
const
|
|
166
|
+
const text = instructionText(prompt);
|
|
167
|
+
const de = (text.match(DE_MARKERS) ?? []).length;
|
|
168
|
+
const en = (text.match(EN_MARKERS) ?? []).length;
|
|
102
169
|
if (de < MIN_MARKERS && en < MIN_MARKERS) {
|
|
103
170
|
return { language: "und", de_markers: de, en_markers: en };
|
|
104
171
|
}
|
|
@@ -110,9 +177,97 @@ const LANGUAGE_NAME: Record<Exclude<Verdict, "und">, string> = {
|
|
|
110
177
|
en: "English",
|
|
111
178
|
};
|
|
112
179
|
|
|
180
|
+
/**
|
|
181
|
+
* How a pin came to be — the provenance the roadmap's Phase 4 asks for.
|
|
182
|
+
*
|
|
183
|
+
* `prompt` means the user's own words carried the markers. `system-locale`
|
|
184
|
+
* means they did not and the environment answered instead. The distinction is
|
|
185
|
+
* load-bearing rather than decorative: a locale pin is the one a user may
|
|
186
|
+
* legitimately want to contradict, and the notice says which it is so that
|
|
187
|
+
* contradicting it does not require guessing where it came from.
|
|
188
|
+
*/
|
|
189
|
+
export type PinSource = "prompt" | "system-locale";
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The env vars that carry a POSIX locale, most specific first.
|
|
193
|
+
*
|
|
194
|
+
* `LC_ALL` overrides everything by definition; `LC_MESSAGES` governs the
|
|
195
|
+
* language of messages specifically, which is exactly this question; `LANG` is
|
|
196
|
+
* the fallback for both. `LANGUAGE` is deliberately last — it is a GNU
|
|
197
|
+
* extension holding a colon-separated PREFERENCE LIST, so it answers a
|
|
198
|
+
* different question and only its first entry is comparable.
|
|
199
|
+
*/
|
|
200
|
+
const LOCALE_ENV_KEYS = ["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] as const;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Map an environment locale onto the classifier's verdict space.
|
|
204
|
+
*
|
|
205
|
+
* Returns `und` for a locale the classifier cannot mirror, for `C`/`POSIX`
|
|
206
|
+
* (which name no human language), and for an unset environment. Only the
|
|
207
|
+
* primary subtag is read: `de_AT.UTF-8`, `de-CH`, and `de` are one answer.
|
|
208
|
+
*/
|
|
209
|
+
export function systemLocaleVerdict(env: NodeJS.ProcessEnv = process.env): Verdict {
|
|
210
|
+
for (const key of LOCALE_ENV_KEYS) {
|
|
211
|
+
const raw = (env[key] ?? "").trim();
|
|
212
|
+
if (!raw) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// `LANGUAGE` may hold `de:en:fr`; the leading entry is the preference.
|
|
216
|
+
const first = raw.split(":")[0] ?? "";
|
|
217
|
+
// Strip the territory and codeset: `de_AT.UTF-8@euro` → `de`.
|
|
218
|
+
const primary = (first.split(/[._@-]/)[0] ?? "").toLowerCase();
|
|
219
|
+
if (primary === "c" || primary === "posix" || !primary) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (primary === "de") {
|
|
223
|
+
return "de";
|
|
224
|
+
}
|
|
225
|
+
if (primary === "en") {
|
|
226
|
+
return "en";
|
|
227
|
+
}
|
|
228
|
+
// A locale the classifier has no markers for. Stop rather than fall
|
|
229
|
+
// through to a less specific variable: the user HAS stated a language and
|
|
230
|
+
// it is neither of the two, so the honest answer is "no pin".
|
|
231
|
+
return "und";
|
|
232
|
+
}
|
|
233
|
+
return "und";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The one-line notice the dispatcher surfaces for this turn.
|
|
238
|
+
*
|
|
239
|
+
* A locale-sourced pin says so and says how to override it. A prompt-sourced
|
|
240
|
+
* pin keeps its original wording, because that line is what the conformance
|
|
241
|
+
* audit measured and a reworded pin is a changed instrument.
|
|
242
|
+
*/
|
|
243
|
+
export function noticeText(language: Exclude<Verdict, "und">, source: PinSource): string {
|
|
244
|
+
const name = LANGUAGE_NAME[language];
|
|
245
|
+
if (source === "system-locale") {
|
|
246
|
+
return (
|
|
247
|
+
`Reply language for this turn: ${name} — the prompt carried no language ` +
|
|
248
|
+
`markers, so this fell back to the system locale. Write in another ` +
|
|
249
|
+
`language and the next turn follows the prompt instead.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return `Reply language for this turn: ${name}.`;
|
|
253
|
+
}
|
|
254
|
+
|
|
113
255
|
/** The context block handed back to the model for this turn. */
|
|
114
|
-
export function pinText(language: Exclude<Verdict, "und"
|
|
256
|
+
export function pinText(language: Exclude<Verdict, "und">, source: PinSource = "prompt"): string {
|
|
115
257
|
const name = LANGUAGE_NAME[language];
|
|
258
|
+
if (source === "system-locale") {
|
|
259
|
+
return (
|
|
260
|
+
`<language-pin>\n` +
|
|
261
|
+
`No language markers were found in this turn's prompt and no language was ` +
|
|
262
|
+
`pinned yet, so the mirror target falls back to the environment's locale: ` +
|
|
263
|
+
`${name}. Use it for EVERY user-visible token this turn.\n\n` +
|
|
264
|
+
`This is the WEAKER of the two provenances — it reflects the machine, not ` +
|
|
265
|
+
`the user's own words. The first prompt that carries real markers replaces ` +
|
|
266
|
+
`it. Do not treat it as a standing preference, and do not argue with the ` +
|
|
267
|
+
`user about it if they write in something else.\n` +
|
|
268
|
+
`</language-pin>`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
116
271
|
return (
|
|
117
272
|
`<language-pin>\n` +
|
|
118
273
|
`The user submitted this turn's prompt in ${name}. That is the mirror target ` +
|
|
@@ -121,15 +276,16 @@ export function pinText(language: Exclude<Verdict, "und">): string {
|
|
|
121
276
|
`lines, and the recommendation label under any numbered-options block.\n\n` +
|
|
122
277
|
`This pin exists because the trigger is not observable from the transcript ` +
|
|
123
278
|
`alone: slash-command and skill bodies arrive in the user role and can be ` +
|
|
124
|
-
`long and English, which is what drove
|
|
125
|
-
`
|
|
126
|
-
`injected skill body are NOT the trigger — this pin is.\n` +
|
|
279
|
+
`long and English, which is what drove the 626 wrong-language turns measured ` +
|
|
280
|
+
`in the 30-session audit this hook was built from. Tool output, file ` +
|
|
281
|
+
`contents, and an injected skill body are NOT the trigger — this pin is.\n` +
|
|
127
282
|
`</language-pin>`
|
|
128
283
|
);
|
|
129
284
|
}
|
|
130
285
|
|
|
131
286
|
export interface PinState extends JsonObject {
|
|
132
287
|
language: Verdict;
|
|
288
|
+
source: PinSource;
|
|
133
289
|
detected_at: string;
|
|
134
290
|
prompt_chars: number;
|
|
135
291
|
de_markers: number;
|
|
@@ -157,13 +313,30 @@ export function nextState(
|
|
|
157
313
|
prompt_chars: number,
|
|
158
314
|
session_id: string,
|
|
159
315
|
now: string,
|
|
316
|
+
locale: Verdict = "und",
|
|
160
317
|
): PinState | null {
|
|
161
318
|
if (classification.language === "und") {
|
|
162
319
|
// Terse continuation ("1", "ok", "weiter") — keep whatever was pinned.
|
|
163
|
-
|
|
320
|
+
if (previous.language === "de" || previous.language === "en") {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
// FIRST turn and nothing to keep: the locale is better than no pin at all.
|
|
324
|
+
if (locale === "und") {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
language: locale,
|
|
329
|
+
source: "system-locale",
|
|
330
|
+
detected_at: now,
|
|
331
|
+
prompt_chars,
|
|
332
|
+
de_markers: classification.de_markers,
|
|
333
|
+
en_markers: classification.en_markers,
|
|
334
|
+
session_id,
|
|
335
|
+
};
|
|
164
336
|
}
|
|
165
337
|
return {
|
|
166
338
|
language: classification.language,
|
|
339
|
+
source: "prompt",
|
|
167
340
|
detected_at: now,
|
|
168
341
|
prompt_chars,
|
|
169
342
|
de_markers: classification.de_markers,
|
|
@@ -182,7 +355,10 @@ function _extractPrompt(payload: JsonObject): string {
|
|
|
182
355
|
return "";
|
|
183
356
|
}
|
|
184
357
|
|
|
185
|
-
export function run(
|
|
358
|
+
export function run(
|
|
359
|
+
stdin_text: string,
|
|
360
|
+
options: { consumer_root: string; env?: NodeJS.ProcessEnv },
|
|
361
|
+
): number {
|
|
186
362
|
let envelope: JsonObject = {};
|
|
187
363
|
if (stdin_text.trim()) {
|
|
188
364
|
try {
|
|
@@ -200,14 +376,36 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
200
376
|
if (!prompt) {
|
|
201
377
|
return EXIT_ALLOW;
|
|
202
378
|
}
|
|
379
|
+
// A harness-generated turn is not a chat message. Leave the pin untouched —
|
|
380
|
+
// the same reasoning as an undetermined prompt below, and for the same reason
|
|
381
|
+
// this hook exists at all.
|
|
382
|
+
if (isSyntheticPrompt(prompt)) {
|
|
383
|
+
return EXIT_ALLOW;
|
|
384
|
+
}
|
|
203
385
|
|
|
204
386
|
const session_id = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
205
387
|
const target = path.join(options.consumer_root, STATE_FILE);
|
|
206
388
|
const previous = _loadState(target);
|
|
207
389
|
const classification = classify(prompt);
|
|
208
|
-
const next = nextState(
|
|
390
|
+
const next = nextState(
|
|
391
|
+
previous,
|
|
392
|
+
classification,
|
|
393
|
+
prompt.length,
|
|
394
|
+
session_id,
|
|
395
|
+
new Date().toISOString(),
|
|
396
|
+
// Injectable so the suite can pin a locale instead of inheriting the
|
|
397
|
+
// machine's — a test whose verdict depends on the developer's `LANG` is a
|
|
398
|
+
// test that passes here and fails in CI for reasons unrelated to the diff.
|
|
399
|
+
systemLocaleVerdict(options.env ?? process.env),
|
|
400
|
+
);
|
|
209
401
|
|
|
210
402
|
const effective = next?.language ?? (previous.language as Verdict | undefined);
|
|
403
|
+
// A state file written before `source` existed carries only what the old
|
|
404
|
+
// version could produce — a prompt reading. Absent is therefore `prompt`,
|
|
405
|
+
// never "unknown": treating it as unknown would reword the audited notice
|
|
406
|
+
// for every pre-existing session.
|
|
407
|
+
const effectiveSource: PinSource =
|
|
408
|
+
next?.source ?? (previous.source === "system-locale" ? "system-locale" : "prompt");
|
|
211
409
|
if (next) {
|
|
212
410
|
try {
|
|
213
411
|
atomic_write_json(target, next);
|
|
@@ -218,12 +416,53 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
218
416
|
|
|
219
417
|
if (effective === "de" || effective === "en") {
|
|
220
418
|
process.stdout.write(
|
|
221
|
-
`${JSON.stringify({ decision: "warn", reason:
|
|
419
|
+
`${JSON.stringify({ decision: "warn", reason: noticeText(effective, effectiveSource), additional_context: pinText(effective, effectiveSource) })}\n`,
|
|
222
420
|
);
|
|
421
|
+
return EXIT_WARN;
|
|
223
422
|
}
|
|
224
423
|
return EXIT_ALLOW;
|
|
225
424
|
}
|
|
226
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Is this "prompt" a harness-generated turn rather than something the human typed?
|
|
428
|
+
*
|
|
429
|
+
* MEASURED, IN THIS HOOK'S OWN AUDIT (round 5, 2026-08-07). The state file read
|
|
430
|
+
* mid-session:
|
|
431
|
+
*
|
|
432
|
+
* { "language": "en", "source": "prompt", "prompt_chars": 6627,
|
|
433
|
+
* "de_markers": 0, "en_markers": 63 }
|
|
434
|
+
*
|
|
435
|
+
* No human wrote those 6,627 characters. They were a background-task completion
|
|
436
|
+
* notification, injected as a user turn, which `user_prompt_submit` sees and this
|
|
437
|
+
* hook classified as the trigger — flipping a German session to `en` for every
|
|
438
|
+
* later turn, and recording `source: "prompt"`, a false provenance claim.
|
|
439
|
+
*
|
|
440
|
+
* This is the SAME defect the hook was built to remove: non-chat content read as
|
|
441
|
+
* the trigger. The header above explains at length why a skill body must not set
|
|
442
|
+
* the pin; `user_prompt_submit` was chosen because "a skill body never reaches
|
|
443
|
+
* it". Synthetic turns do reach it, so the event alone was never sufficient — the
|
|
444
|
+
* content test has to exist too.
|
|
445
|
+
*
|
|
446
|
+
* The markers are structural, not linguistic: the harness stamps these turns
|
|
447
|
+
* with fixed envelope tags. Matching prose would misfire on a human quoting a
|
|
448
|
+
* notification; matching the tags cannot, because a human typing `<task-notification>`
|
|
449
|
+
* is quoting, and a quote does not open at character zero of the turn.
|
|
450
|
+
*
|
|
451
|
+
* Deliberately conservative: an unrecognised synthetic shape falls through and
|
|
452
|
+
* pins as before. Under-filtering keeps today's behaviour; over-filtering would
|
|
453
|
+
* silently stop pinning real prompts, which is the worse failure.
|
|
454
|
+
*/
|
|
455
|
+
export function isSyntheticPrompt(prompt: string): boolean {
|
|
456
|
+
const head = prompt.trimStart().slice(0, 400);
|
|
457
|
+
return (
|
|
458
|
+
head.startsWith("[SYSTEM NOTIFICATION - NOT USER INPUT]") ||
|
|
459
|
+
head.startsWith("<task-notification>") ||
|
|
460
|
+
head.startsWith("<system-reminder>") ||
|
|
461
|
+
head.startsWith("<local-command-caveat>") ||
|
|
462
|
+
/^\[SYSTEM NOTIFICATION\b/.test(head)
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
227
466
|
export function main(argv?: string[]): number {
|
|
228
467
|
const args = argv ?? process.argv.slice(2);
|
|
229
468
|
let consumer_root = process.cwd();
|
|
@@ -16,10 +16,18 @@
|
|
|
16
16
|
* `abstraction-thresholds` (the canon citation). A bare number is drift.
|
|
17
17
|
* A heading is its own block; a threshold-stating heading is satisfied
|
|
18
18
|
* when the block immediately below it carries the citation.
|
|
19
|
-
* 2. PINNING —
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* 2. PINNING — every deliberate site is pinned to its current scope
|
|
20
|
+
* (SITES), and the canon's four table rows are pinned (CANON_ROWS).
|
|
21
|
+
* Changing either side without the other is a finding: the canon and
|
|
22
|
+
* its citing sites move together or not at all.
|
|
23
|
+
*
|
|
24
|
+
* The site COUNT is deliberately not written here. This comment and
|
|
25
|
+
* the one above `check_pins` both said "six" while `SITES` held seven
|
|
26
|
+
* (two entries share `component-oriented-and-oop-development.md`, so
|
|
27
|
+
* six distinct files became seven pins). Nothing pinned the literal —
|
|
28
|
+
* the test derives its expectation from `SITES.length` — so the number
|
|
29
|
+
* was free to rot, and did. Read the count off `SITES`; do not restate
|
|
30
|
+
* it in prose that no gate checks.
|
|
23
31
|
*
|
|
24
32
|
* ## Regex tuning record (2026-08-03, measured against the real tree)
|
|
25
33
|
*
|
|
@@ -65,12 +73,37 @@ export const CANON_CITATION = 'abstraction-thresholds';
|
|
|
65
73
|
export const THRESHOLD_VERB = /\b(extract\w*|componenti[sz]\w*|abstraction)\b/i;
|
|
66
74
|
|
|
67
75
|
/**
|
|
68
|
-
* … and (b) states a numeric repetition bar.
|
|
76
|
+
* … and (b) states a numeric repetition bar. Four branches:
|
|
69
77
|
* cardinal count + repetition-noun · ordinal (repetition|occurrence only —
|
|
70
|
-
* see the tuning record above) · `≥ N` + repetition-noun
|
|
78
|
+
* see the tuning record above) · `≥ N` + repetition-noun · a repetition VERB
|
|
79
|
+
* carrying its count as a word-form adverb (`once` / `twice` / `thrice`).
|
|
80
|
+
*
|
|
81
|
+
* The fourth branch closes a measured hole: "extract when duplicated twice"
|
|
82
|
+
* states a real numeric bar and matched NOTHING. The cardinal branch needs a
|
|
83
|
+
* numeral from its own alternation immediately before a repetition-noun, and in
|
|
84
|
+
* that phrasing the count IS the adverb — there is no numeral and no noun. So
|
|
85
|
+
* the most natural English phrasing of the bar was the one shape that passed
|
|
86
|
+
* the gate silently.
|
|
87
|
+
*
|
|
88
|
+
* It is verb-anchored on purpose. A bare `\b(once|twice|thrice)\b` would fire on
|
|
89
|
+
* any block that happens to say "run it twice" near the word "extract"; binding
|
|
90
|
+
* the adverb to a duplication verb keeps the qualitative prose the tuning record
|
|
91
|
+
* protects finding-free, and adds no allowlist.
|
|
92
|
+
*
|
|
93
|
+
* The verb set is narrow because a wider one was MEASURED and rejected. A first
|
|
94
|
+
* cut also accepted `appears|occurs|used`, ran against the real tree, and
|
|
95
|
+
* returned exactly one hit: `gated-reach/SKILL.md`'s reddit-parsing gotcha, "one
|
|
96
|
+
* id **appears twice**, once as the real comment and once as a … stub". Its block
|
|
97
|
+
* is the whole Gotcha bullet list, which separately contains "extraction" — in
|
|
98
|
+
* the yt-dlp sense, an unrelated meaning of the word. Both halves fired on
|
|
99
|
+
* vocabulary coincidence. Dropping the three weak verbs kills that hit without an
|
|
100
|
+
* allowlist and still catches the reported phrasing (`duplicat\w+` covers
|
|
101
|
+
* "duplicated twice"). Widening the verb set again means re-measuring: the block
|
|
102
|
+
* granularity is blank-line-coarse, so a weak verb only has to co-occur with a
|
|
103
|
+
* homonym of "extract" somewhere in the same paragraph run.
|
|
71
104
|
*/
|
|
72
105
|
export const NUMERIC_REPETITION =
|
|
73
|
-
/(\b(one|two|three|four|five|\d+)\s*(\+|×|x)?\s*(real\s+)?(repetitions?|repeats?|times|uses?|duplications?|occurrences?)\b)|(\b(second|third|fourth)\s+(real\s+)?(repetitions?|occurrences?)\b)|(≥\s*\d+\s*(real\s+)?(repetitions?|repeats?|times|uses?|duplications?|occurrences?))/i;
|
|
106
|
+
/(\b(one|two|three|four|five|\d+)\s*(\+|×|x)?\s*(real\s+)?(repetitions?|repeats?|times|uses?|duplications?|occurrences?)\b)|(\b(second|third|fourth)\s+(real\s+)?(repetitions?|occurrences?)\b)|(≥\s*\d+\s*(real\s+)?(repetitions?|repeats?|times|uses?|duplications?|occurrences?))|(\b(duplicat\w+|repeat\w+|recur\w+)\s+(only\s+)?(once|twice|thrice)\b)/i;
|
|
74
107
|
|
|
75
108
|
/** The canon's four table rows — all must match CANON_FILE. */
|
|
76
109
|
export const CANON_ROWS: ReadonlyArray<{ row: RegExp; why: string }> = [
|
|
@@ -249,7 +282,7 @@ export function scan_citations(repoRoot: string): { findings: Finding[]; scanned
|
|
|
249
282
|
return { findings, scanned };
|
|
250
283
|
}
|
|
251
284
|
|
|
252
|
-
/** Bidirectional pins: canon rows +
|
|
285
|
+
/** Bidirectional pins: canon rows + every citing site in SITES. */
|
|
253
286
|
export function check_pins(repoRoot: string): Finding[] {
|
|
254
287
|
const findings: Finding[] = [];
|
|
255
288
|
|
|
@@ -63,6 +63,7 @@ const ALLOWED_SOURCE_DIRS: ReadonlySet<string> = new Set([
|
|
|
63
63
|
'notes', // legacy; new files go to evidence/notes/
|
|
64
64
|
'reports', // legacy; new files go to evidence/reports/ (see agents-layout.md)
|
|
65
65
|
'overrides', // * consumer
|
|
66
|
+
'proposals', // * consumer — curated self-improvement proposals, tracked
|
|
66
67
|
'recruit-sessions',
|
|
67
68
|
'reference',
|
|
68
69
|
'roadmap-assets',
|
|
@@ -88,6 +89,7 @@ const CONSUMER_EXPECTED_ENTRIES: ReadonlySet<string> = new Set([
|
|
|
88
89
|
// Optional but legitimate consumer entries (will not trigger warnings)
|
|
89
90
|
'knowledge',
|
|
90
91
|
'memory',
|
|
92
|
+
'proposals',
|
|
91
93
|
'roadmaps',
|
|
92
94
|
'tmp',
|
|
93
95
|
'tmp.old',
|
|
@@ -148,6 +148,35 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
|
|
|
148
148
|
if (!_isFile(path.join(REPO_ROOT, script))) {
|
|
149
149
|
errors.push(`concerns.${name}: script not found at '${script}'`);
|
|
150
150
|
}
|
|
151
|
+
// `tools:` — the optional per-concern tool filter the dispatcher applies
|
|
152
|
+
// in-process (`_concern_matches_tool`). Validated because the failure mode
|
|
153
|
+
// is silent: the dispatcher fails toward RUNNING the concern on anything
|
|
154
|
+
// malformed, so a typo (`tool:`, a bare string, `[]`) would look like a
|
|
155
|
+
// working filter while filtering nothing. An unvalidated key is worse than
|
|
156
|
+
// no key.
|
|
157
|
+
if ("tools" in spec) {
|
|
158
|
+
const tools = spec["tools"];
|
|
159
|
+
if (!Array.isArray(tools)) {
|
|
160
|
+
errors.push(
|
|
161
|
+
`concerns.${name}: 'tools' must be a list of tool names ` +
|
|
162
|
+
`(got ${pyTypeName(tools)}) — omit the key for "every event"`,
|
|
163
|
+
);
|
|
164
|
+
} else if (tools.length === 0) {
|
|
165
|
+
errors.push(
|
|
166
|
+
`concerns.${name}: 'tools' is an empty list — omit the key, or use ` +
|
|
167
|
+
`["*"], rather than a filter that reads as "no tools"`,
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
for (const t of tools) {
|
|
171
|
+
if (typeof t !== "string" || t.trim() === "") {
|
|
172
|
+
errors.push(
|
|
173
|
+
`concerns.${name}: 'tools' entries must be non-empty strings, ` +
|
|
174
|
+
`got ${pyTypeName(t)}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
151
180
|
names.add(name);
|
|
152
181
|
}
|
|
153
182
|
return names;
|
|
@@ -29,6 +29,20 @@ import fs from 'node:fs';
|
|
|
29
29
|
import path from 'node:path';
|
|
30
30
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
31
|
|
|
32
|
+
// This hook is the first in the tree to import from `src/shared/`, and its
|
|
33
|
+
// contract is fail-open — but the `try/catch` that delivers that lives in
|
|
34
|
+
// `main()`, so an import-time throw would escape it. The concern is real and
|
|
35
|
+
// the answer is measured rather than assumed: esbuild INLINES both modules into
|
|
36
|
+
// `dist/hooks/dispatch.js` (verified by building the bundle to a scratch path
|
|
37
|
+
// and grepping for `consentVerdict` and `withheld-machine-inferred`), so the
|
|
38
|
+
// installed path performs no runtime module resolution at all. Both modules are
|
|
39
|
+
// also pure declarations with zero top-level side effects, so there is nothing
|
|
40
|
+
// at import time that can throw. If either fact stops holding — a dynamic
|
|
41
|
+
// import, a module-level read — the fail-open guarantee weakens and this
|
|
42
|
+
// import has to move inside the guard.
|
|
43
|
+
import type { SettingsClass } from '../shared/settingsClasses.js';
|
|
44
|
+
import { consentVerdict, type ConsentVerdict } from '../shared/settingsConsent.js';
|
|
45
|
+
|
|
32
46
|
import {
|
|
33
47
|
buildSidecar,
|
|
34
48
|
LESSONS_NAME,
|
|
@@ -40,18 +54,54 @@ const SETTINGS_FILE = '.agent-settings.yml';
|
|
|
40
54
|
/** Wall-clock budget for the whole aggregation (ms). */
|
|
41
55
|
export const BUDGET_MS = 2000;
|
|
42
56
|
|
|
57
|
+
/** The key this hook is gated on. */
|
|
58
|
+
export const LEARN_KEY = 'memory.learn_on_session_end';
|
|
59
|
+
|
|
43
60
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
61
|
+
* The key's class, pinned rather than parsed.
|
|
62
|
+
*
|
|
63
|
+
* `classOfPath` would give the same answer, but only after reading and parsing
|
|
64
|
+
* `docs/contracts/settings-classes.md` — a multi-hundred-row markdown file — on
|
|
65
|
+
* every session teardown, which is exactly the cost the 2 s budget above exists
|
|
66
|
+
* to avoid. The drift risk a hardcoded constant carries is neutralised the
|
|
67
|
+
* cheap way instead: `tests/scripts/memory_learn_hook.test.ts` asserts the
|
|
68
|
+
* contract still classifies this key `B`, so a reclassification reds CI rather
|
|
69
|
+
* than silently unbinding the gate.
|
|
47
70
|
*/
|
|
48
|
-
export
|
|
71
|
+
export const LEARN_KEY_CLASS: SettingsClass = 'B';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether `memory.learn_on_session_end` reads as enabled, or `undefined` when
|
|
75
|
+
* the file or the key is absent (same hand-rolled mini-parser pattern as the
|
|
76
|
+
* sibling hooks — no YAML dependency in the hook hot path).
|
|
77
|
+
*
|
|
78
|
+
* Normalised strictly: the literal `true` and nothing else enables the hook. A
|
|
79
|
+
* crude parser must not let `yes` / `1` / `maybe` read as a permission, and
|
|
80
|
+
* `isConservativeDefault` would treat any non-empty string as permissive — so
|
|
81
|
+
* the normalisation happens here, before the consent check, not inside it.
|
|
82
|
+
*
|
|
83
|
+
* WHAT THE TRI-STATE DOES AND DOES NOT SAY. `undefined` means "no answer was
|
|
84
|
+
* found" — the key is absent, the file is absent, OR the file could not be read
|
|
85
|
+
* at all (permissions, a directory at the path, an I/O error). `false` means
|
|
86
|
+
* EITHER a deliberate `false` OR a present-but-malformed scalar. So there are
|
|
87
|
+
* two collapses, not one: a user who writes `learn_on_session_end: yes` and a
|
|
88
|
+
* user whose settings file is unreadable both get a silent, permanent no-op
|
|
89
|
+
* with no diagnostic.
|
|
90
|
+
*
|
|
91
|
+
* The fail-safe direction is deliberate in both cases — a hook that writes
|
|
92
|
+
* files must not be enabled by a value nobody typed on purpose, nor by a file
|
|
93
|
+
* nobody could read. The ambiguity is the cost, and it is stated here rather
|
|
94
|
+
* than implied by a "raw scalar" this function does not actually return.
|
|
95
|
+
* Diagnosing either shape belongs to `settings:check`, not to a fail-open
|
|
96
|
+
* teardown hook.
|
|
97
|
+
*/
|
|
98
|
+
export function readLearnValue(root: string): boolean | undefined {
|
|
49
99
|
const p = path.join(root, SETTINGS_FILE);
|
|
50
100
|
let text: string;
|
|
51
101
|
try {
|
|
52
102
|
text = fs.readFileSync(p, 'utf8');
|
|
53
103
|
} catch {
|
|
54
|
-
return
|
|
104
|
+
return undefined;
|
|
55
105
|
}
|
|
56
106
|
let inMemory = false;
|
|
57
107
|
for (const rawLine of text.split('\n')) {
|
|
@@ -69,7 +119,60 @@ export function enabled(root: string): boolean {
|
|
|
69
119
|
if (m) return m[1] === 'true';
|
|
70
120
|
}
|
|
71
121
|
}
|
|
72
|
-
return
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The consent verdict for running the aggregation.
|
|
127
|
+
*
|
|
128
|
+
* This is the first production caller of `consentVerdict` — Phase 5 step 4 of
|
|
129
|
+
* `road-to-zero-ceremony-settings` asks the consent-gated action to verify the
|
|
130
|
+
* recorded DECISION rather than read the bare value, and this session_end
|
|
131
|
+
* concern is the only action in the tree that a class-B key actually gates.
|
|
132
|
+
*
|
|
133
|
+
* `handEdited: true` is a fact about the path, not a shortcut: the file read
|
|
134
|
+
* here is the PROJECT-LOCAL `.agent-settings.yml`, which
|
|
135
|
+
* `docs/contracts/settings-classes.md` names as a file only a human writes —
|
|
136
|
+
* `settings:set` and the GUI both write the user-global file instead. So on
|
|
137
|
+
* this path a permissive value IS the recorded decision and the verdict is
|
|
138
|
+
* `granted`.
|
|
139
|
+
*
|
|
140
|
+
* WHAT THIS CHANGES TODAY: nothing observable. Stated plainly because the
|
|
141
|
+
* opposite was claimed first and it was wrong. With `cls` a constant,
|
|
142
|
+
* `handEdited` a constant `true`, and the value already reduced to a boolean by
|
|
143
|
+
* `readLearnValue`, every discriminating input to `consentVerdict` is fixed, so
|
|
144
|
+
* `enabled()` returns exactly what the previous `=== 'true'` read returned —
|
|
145
|
+
* including for `yes` / `1` / `on`, which that read already rejected. The
|
|
146
|
+
* normalisation is not new and this call did not narrow it.
|
|
147
|
+
*
|
|
148
|
+
* WHAT IT DOES BUY, which is smaller than it sounds and worth being exact
|
|
149
|
+
* about: the obligation is expressed in code instead of prose, so
|
|
150
|
+
* `consentVerdict` has a real caller and stops being a library nobody invokes;
|
|
151
|
+
* the class binding is asserted against the contract by a test, so a
|
|
152
|
+
* reclassification reds CI (it does NOT re-route the hook at runtime — the
|
|
153
|
+
* class is a hardcoded constant); and the day this hook learns to read the
|
|
154
|
+
* user-global file, the sidecar check is already the thing standing in front of
|
|
155
|
+
* it rather than a change someone must remember to make.
|
|
156
|
+
*
|
|
157
|
+
* KNOWN GAP, recorded rather than fixed here: the hook reads only the
|
|
158
|
+
* project-local file, so a user who enables this in the GUI — which writes the
|
|
159
|
+
* user-global file plus the provenance sidecar — never triggers it. Reading the
|
|
160
|
+
* user-global file is where the sidecar's `auto-detected`-refusing power would
|
|
161
|
+
* actually discriminate, but it is also a behaviour change (a hook that writes
|
|
162
|
+
* files would start firing for users it does not fire for today), so it belongs
|
|
163
|
+
* to a decision rather than to this step. Filed in the roadmap.
|
|
164
|
+
*/
|
|
165
|
+
export function learnConsent(root: string): ConsentVerdict {
|
|
166
|
+
return consentVerdict({
|
|
167
|
+
cls: LEARN_KEY_CLASS,
|
|
168
|
+
value: readLearnValue(root) ?? false,
|
|
169
|
+
handEdited: true,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `true` only when the recorded decision permits the aggregation. */
|
|
174
|
+
export function enabled(root: string): boolean {
|
|
175
|
+
return learnConsent(root) === 'granted';
|
|
73
176
|
}
|
|
74
177
|
|
|
75
178
|
/** Run the aggregation. Returns the one-line marker to emit, or null. */
|