@event4u/agent-config 8.4.0 → 8.4.1
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 +6 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/mcp/registry-manifest.json +1 -1
- package/package.json +4 -1
- package/src/agent-src/README.md +64 -0
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +497 -0
- package/src/agent-src/scripts/scan-seeder-violations.php +145 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +1087 -0
- package/src/agent-src/templates/AGENTS.md +34 -0
- package/src/agent-src/templates/scripts/README.md +195 -0
- package/src/agent-src/templates/scripts/check_memory.ts +662 -0
- package/src/agent-src/templates/scripts/check_memory_proposal.ts +370 -0
- package/src/agent-src/templates/scripts/historical-bug-patterns.example.yml +84 -0
- package/src/agent-src/templates/scripts/implement_ticket/__main__.ts +27 -0
- package/src/agent-src/templates/scripts/memory_hash.ts +355 -0
- package/src/agent-src/templates/scripts/memory_lookup.ts +1089 -0
- package/src/agent-src/templates/scripts/memory_report.ts +868 -0
- package/src/agent-src/templates/scripts/memory_signal.ts +444 -0
- package/src/agent-src/templates/scripts/memory_status.ts +215 -0
- package/src/agent-src/templates/scripts/ownership-map.example.yml +87 -0
- package/src/agent-src/templates/scripts/pr-risk-config.example.yml +76 -0
- package/src/agent-src/templates/scripts/pr_review_routing.ts +544 -0
- package/src/agent-src/templates/scripts/pr_risk_review.ts +0 -0
- package/src/agent-src/templates/scripts/telemetry/aggregator.ts +0 -0
- package/src/agent-src/templates/scripts/telemetry/boundary.ts +164 -0
- package/src/agent-src/templates/scripts/telemetry/engagement.ts +479 -0
- package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +394 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +210 -0
- package/src/agent-src/templates/scripts/telemetry_record.ts +276 -0
- package/src/agent-src/templates/scripts/telemetry_report.ts +211 -0
- package/src/agent-src/templates/scripts/telemetry_status.ts +333 -0
- package/src/agent-src/templates/scripts/tier_usage_report.ts +619 -0
- package/src/agent-src/templates/scripts/work_engine/__main__.ts +14 -0
- package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +1261 -0
- package/src/agent-src/templates/scripts/work_engine/_lib/user_global_paths.ts +329 -0
- package/src/agent-src/templates/scripts/work_engine/cli.ts +206 -0
- package/src/agent-src/templates/scripts/work_engine/cli_args.ts +249 -0
- package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +225 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/analyze.ts +125 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +189 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/index.ts +94 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/memory.ts +193 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/plan.ts +267 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +518 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/report.ts +379 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +268 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +258 -0
- package/src/agent-src/templates/scripts/work_engine/directives/index.ts +32 -0
- package/src/agent-src/templates/scripts/work_engine/directives/mixed/contract.ts +243 -0
- package/src/agent-src/templates/scripts/work_engine/directives/mixed/index.ts +108 -0
- package/src/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +259 -0
- package/src/agent-src/templates/scripts/work_engine/directives/mixed/ui.ts +216 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/_passthrough.ts +40 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +241 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +216 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +506 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +325 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/index.ts +102 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +462 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +474 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +352 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.ts +33 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.ts +213 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/index.ts +111 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.ts +126 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/report.ts +112 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/test.ts +164 -0
- package/src/agent-src/templates/scripts/work_engine/dispatcher.ts +515 -0
- package/src/agent-src/templates/scripts/work_engine/emitters.ts +119 -0
- package/src/agent-src/templates/scripts/work_engine/errors.ts +24 -0
- package/src/agent-src/templates/scripts/work_engine/hook_bootstrap.ts +104 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.ts +189 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.ts +41 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.ts +89 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +193 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.ts +304 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.ts +110 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.ts +118 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/index.ts +17 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.ts +161 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.ts +45 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/trace.ts +134 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/context.ts +94 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/events.ts +58 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/exceptions.ts +85 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/index.ts +27 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/registry.ts +66 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/runner.ts +90 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/settings.ts +260 -0
- package/src/agent-src/templates/scripts/work_engine/input_builders.ts +260 -0
- package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +466 -0
- package/src/agent-src/templates/scripts/work_engine/migration/v0_to_v1.ts +531 -0
- package/src/agent-src/templates/scripts/work_engine/orchestration.ts +366 -0
- package/src/agent-src/templates/scripts/work_engine/persona_policy.ts +97 -0
- package/src/agent-src/templates/scripts/work_engine/resolvers/diff.ts +135 -0
- package/src/agent-src/templates/scripts/work_engine/resolvers/file.ts +175 -0
- package/src/agent-src/templates/scripts/work_engine/resolvers/prompt.ts +115 -0
- package/src/agent-src/templates/scripts/work_engine/scoring/confidence.ts +415 -0
- package/src/agent-src/templates/scripts/work_engine/scoring/decision_engine.ts +466 -0
- package/src/agent-src/templates/scripts/work_engine/scoring/decision_trace.ts +298 -0
- package/src/agent-src/templates/scripts/work_engine/scoring/memory_visibility.ts +444 -0
- package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +252 -0
- package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +745 -0
- package/src/agent-src/templates/scripts/work_engine/state.ts +1151 -0
- package/src/agent-src/templates/scripts/work_engine/state_io.ts +413 -0
- package/src/install/atomic.ts +111 -0
- package/src/install/conflict.ts +226 -0
- package/src/install/detect.ts +280 -0
- package/src/install/paths.ts +90 -0
- package/src/install/plan.ts +205 -0
- package/src/install/preflight.ts +210 -0
- package/src/install/selectedTools.ts +52 -0
- package/src/install/toolDetection.ts +120 -0
- package/src/install/txlog.ts +161 -0
- package/src/install/types.ts +175 -0
- package/src/install/wizard-plan.ts +216 -0
- package/src/scripts/prepack-check.mjs +108 -5
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized loader for `.agent-settings.yml` with user-global fallback.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript twin of `src/scripts/_lib/agent_settings.py` (ADR-200,
|
|
5
|
+
* Phase 2 / Wave 2a). Mirrors the Python module's public API exactly —
|
|
6
|
+
* same exported snake_case names, same semantics, same cascade / anchor /
|
|
7
|
+
* whitelist / merge behavior, same YAML load tolerance, same error types.
|
|
8
|
+
* Single source of truth for how scripts read agent settings.
|
|
9
|
+
*
|
|
10
|
+
* Resolution order (deepest wins; user-global is whitelist-filtered only):
|
|
11
|
+
*
|
|
12
|
+
* N. `~/.event4u/agent-config/agent-settings.yml` (user-global; whitelist only)
|
|
13
|
+
* N-1. `<repo-root>/.agent-settings.yml` (project-wide; all keys)
|
|
14
|
+
* N-2. `<intermediate-dir>/.agent-settings.yml` (subsystem-scoped; all keys)
|
|
15
|
+
* 1. `<CWD>/.agent-settings.yml` (deepest, wins; all keys)
|
|
16
|
+
*
|
|
17
|
+
* The user-global path is resolved via the sibling `user_global_paths`
|
|
18
|
+
* twin with a read-fallback to the legacy `~/.config/agent-config/`
|
|
19
|
+
* location so pre-2.4 installs keep working during the migration.
|
|
20
|
+
*
|
|
21
|
+
* `<repo-root>` is the nearest ancestor that anchors the project
|
|
22
|
+
* (closest-leaf wins; tiebreaker `.agent-settings.yml` > `agents/` > `.git`):
|
|
23
|
+
*
|
|
24
|
+
* - `.agent-settings.yml` file,
|
|
25
|
+
* - `agents/` directory containing `roadmaps/`, `settings/.ai-council.yml`,
|
|
26
|
+
* or `roadmaps-progress.md` (bare `agents/` does NOT anchor),
|
|
27
|
+
* - `.git` file or directory (submodule support).
|
|
28
|
+
*
|
|
29
|
+
* Set `AGENT_CONFIG_LEGACY_ANCHOR=1` to revert to the pre-Step-7
|
|
30
|
+
* `.git`-only walk for one minor-version soak. When `cwd` is `null`
|
|
31
|
+
* (default), the loader behaves identically to the pre-cascade
|
|
32
|
+
* contract: project file + user-global only, no ancestor walk.
|
|
33
|
+
*
|
|
34
|
+
* Contract — pure, read-only, tolerant:
|
|
35
|
+
*
|
|
36
|
+
* - YAML parse failure / unreadable file → defaults, logged at WARNING.
|
|
37
|
+
* - Missing files → defaults.
|
|
38
|
+
* - No file is ever created or written by this module.
|
|
39
|
+
*/
|
|
40
|
+
import * as fs from 'node:fs';
|
|
41
|
+
import * as os from 'node:os';
|
|
42
|
+
import * as path from 'node:path';
|
|
43
|
+
|
|
44
|
+
import * as user_global_paths from './user_global_paths.js';
|
|
45
|
+
import type * as YamlModule from 'yaml';
|
|
46
|
+
|
|
47
|
+
/** JSON-ish value type — mirrors Python's `Any` for settings data. */
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
+
export type SettingsValue = any;
|
|
50
|
+
export type SettingsDict = Record<string, SettingsValue>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Module logger sink. Mirrors `logging.getLogger(__name__)` so tests can
|
|
54
|
+
* capture INFO/WARNING records the same way `caplog` does in pytest.
|
|
55
|
+
*
|
|
56
|
+
* Records are appended to `logger.records` and also emitted to the real
|
|
57
|
+
* sink (`console.info` / `console.warn`) so production behavior matches
|
|
58
|
+
* Python's logging. `name` mirrors the Python logger name used by tests.
|
|
59
|
+
*/
|
|
60
|
+
export interface LogRecord {
|
|
61
|
+
level: 'INFO' | 'WARNING';
|
|
62
|
+
message: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class Logger {
|
|
66
|
+
readonly name = 'scripts._lib.agent_settings';
|
|
67
|
+
readonly records: LogRecord[] = [];
|
|
68
|
+
|
|
69
|
+
info(message: string, ...args: SettingsValue[]): void {
|
|
70
|
+
this.records.push({ level: 'INFO', message: _format(message, args) });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
warning(message: string, ...args: SettingsValue[]): void {
|
|
74
|
+
this.records.push({ level: 'WARNING', message: _format(message, args) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** printf-style `%s` interpolation mirroring Python's logging args. */
|
|
79
|
+
function _format(template: string, args: SettingsValue[]): string {
|
|
80
|
+
let i = 0;
|
|
81
|
+
return template.replace(/%s/g, () => {
|
|
82
|
+
if (i < args.length) {
|
|
83
|
+
const value = args[i];
|
|
84
|
+
i += 1;
|
|
85
|
+
return _pystr(value);
|
|
86
|
+
}
|
|
87
|
+
return '%s';
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Python-ish `str()` of a value for log messages (lists render `[...]`). */
|
|
92
|
+
function _pystr(value: SettingsValue): string {
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
return `[${value.map((v) => _pyrepr(v)).join(', ')}]`;
|
|
95
|
+
}
|
|
96
|
+
return String(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _pyrepr(value: SettingsValue): string {
|
|
100
|
+
if (typeof value === 'string') {
|
|
101
|
+
return `'${value}'`;
|
|
102
|
+
}
|
|
103
|
+
return String(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const logger = new Logger();
|
|
107
|
+
|
|
108
|
+
export const DEFAULT_PROJECT_FILE = '.agent-settings.yml';
|
|
109
|
+
/**
|
|
110
|
+
* Per-machine override file. Gitignored. A SINGLE project-level file
|
|
111
|
+
* living under `agents/settings/` — appended as the deepest cascade
|
|
112
|
+
* layer so a developer's local values override every committed
|
|
113
|
+
* `.agent-settings.yml` without ever being committed. Missing file is
|
|
114
|
+
* harmless (read as {}).
|
|
115
|
+
*/
|
|
116
|
+
export const LOCAL_PROJECT_FILE = '.agent-settings.local.yml';
|
|
117
|
+
/** Project-relative directory the local override lives in. */
|
|
118
|
+
export const LOCAL_PROJECT_SUBDIR: readonly string[] = ['agents', 'settings'];
|
|
119
|
+
|
|
120
|
+
/** Single local override: `<root>/agents/settings/.agent-settings.local.yml`. */
|
|
121
|
+
function _local_settings_path(project_root: string): string {
|
|
122
|
+
return path.join(project_root, ...LOCAL_PROJECT_SUBDIR, LOCAL_PROJECT_FILE);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Canonical project settings file:
|
|
127
|
+
* `<root>/agents/settings/.agent-settings.yml`.
|
|
128
|
+
*
|
|
129
|
+
* The project settings file lives in the project's settings layer
|
|
130
|
+
* (`agents/settings/`), NOT at the repo root. The repo-root
|
|
131
|
+
* `.agent-settings.yml` is a legacy location read only as a back-compat
|
|
132
|
+
* fallback (see `project_settings_path`).
|
|
133
|
+
*/
|
|
134
|
+
function _canonical_settings_path(project_root: string): string {
|
|
135
|
+
return path.join(project_root, ...LOCAL_PROJECT_SUBDIR, DEFAULT_PROJECT_FILE);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the project settings file for **reading**.
|
|
140
|
+
*
|
|
141
|
+
* Returns the canonical `agents/settings/.agent-settings.yml` when it
|
|
142
|
+
* exists; otherwise the legacy repo-root `.agent-settings.yml` when that
|
|
143
|
+
* exists; otherwise the canonical path. Existence-checked, never writes.
|
|
144
|
+
*/
|
|
145
|
+
export function project_settings_path(project_root: string): string {
|
|
146
|
+
const canonical = _canonical_settings_path(project_root);
|
|
147
|
+
if (_exists(canonical)) {
|
|
148
|
+
return canonical;
|
|
149
|
+
}
|
|
150
|
+
const legacy = path.join(project_root, DEFAULT_PROJECT_FILE);
|
|
151
|
+
if (_exists(legacy)) {
|
|
152
|
+
return legacy;
|
|
153
|
+
}
|
|
154
|
+
return canonical;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Always the canonical **write** target:
|
|
159
|
+
* `agents/settings/.agent-settings.yml`.
|
|
160
|
+
*
|
|
161
|
+
* Writers (install, sync, migrate) target this unconditionally; the
|
|
162
|
+
* legacy root file is migrated into it, never written afresh.
|
|
163
|
+
*/
|
|
164
|
+
export function canonical_settings_write_path(project_root: string): string {
|
|
165
|
+
return _canonical_settings_path(project_root);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const DEFAULT_TEAM_FILE = '.agent-project-settings.yml';
|
|
169
|
+
export const USER_GLOBAL_FILENAME = 'agent-settings.yml';
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Canonical write target under the new `~/.event4u/agent-config/`
|
|
173
|
+
* namespace. Reads route through `_resolve_user_global_file` so pre-2.4
|
|
174
|
+
* installs are still picked up from `~/.config/agent-config/` until the
|
|
175
|
+
* migration shim copies them across.
|
|
176
|
+
*
|
|
177
|
+
* NOTE: This is a function, not a constant, because tests need it to
|
|
178
|
+
* follow `$HOME` / env overrides at call time (Python recomputes it via
|
|
179
|
+
* the loader, but exposes `DEFAULT_USER_GLOBAL_FILE` as a module
|
|
180
|
+
* attribute that the test for `defaults_resolve_when_neither_argument_given`
|
|
181
|
+
* monkeypatches). The differential pattern uses the function value;
|
|
182
|
+
* tests inject a path directly.
|
|
183
|
+
*/
|
|
184
|
+
export function DEFAULT_USER_GLOBAL_FILE(): string {
|
|
185
|
+
return user_global_paths.write_target(USER_GLOBAL_FILENAME);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Return the active user-global settings path with legacy fallback. */
|
|
189
|
+
function _resolve_user_global_file(): string {
|
|
190
|
+
const found = user_global_paths.resolve_with_fallback(USER_GLOBAL_FILENAME);
|
|
191
|
+
if (found !== null) {
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
return DEFAULT_USER_GLOBAL_FILE();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Exact dotted paths allowed to cascade from user-global into the merged
|
|
199
|
+
* settings. Anything not listed here is silently ignored when present in
|
|
200
|
+
* the user-global file. Adding a key requires an ADR.
|
|
201
|
+
*/
|
|
202
|
+
export const MERGEABLE_KEYS: readonly string[] = [
|
|
203
|
+
'name',
|
|
204
|
+
'ide',
|
|
205
|
+
'rule_loading_tier',
|
|
206
|
+
// Successor knob for the discipline-rule tier (council 2026-07-07,
|
|
207
|
+
// weak-host-lift-tiering + token-program-integration verdicts; ADR
|
|
208
|
+
// records the whitelist addition). Wins over rule_loading_tier.
|
|
209
|
+
'discipline_profile',
|
|
210
|
+
'memory.cadence',
|
|
211
|
+
'personal.bot_icon',
|
|
212
|
+
'personal.autonomy',
|
|
213
|
+
'telegraph.speak_scope',
|
|
214
|
+
// Knowledge-card global cross-project sharing is a USER-GLOBAL setting
|
|
215
|
+
// (ADR-100 / road-to-structure-grounding-v2). Whitelisted so the
|
|
216
|
+
// ~/.event4u/agent-config/agent-settings.yml values are honoured.
|
|
217
|
+
'knowledge.global_sharing.enabled',
|
|
218
|
+
'knowledge.global_sharing.allowed_tiers',
|
|
219
|
+
'knowledge.global_sharing.redaction.enabled',
|
|
220
|
+
'knowledge.global_sharing.redaction.halt_on_trigger',
|
|
221
|
+
'knowledge.global_sharing.auto_promote_threshold',
|
|
222
|
+
'knowledge.global_sharing.freshness.hypothesis_after_days',
|
|
223
|
+
'knowledge.global_sharing.freshness.stale_after_days',
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const _DEFAULTS: SettingsDict = {};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Defaults applied by `get_modules_config` when a key is absent from both
|
|
230
|
+
* the team file and the developer cascade. Lists are returned as fresh
|
|
231
|
+
* copies — callers may mutate the result safely.
|
|
232
|
+
*/
|
|
233
|
+
export const MODULES_DEFAULTS: SettingsDict = {
|
|
234
|
+
enabled: false,
|
|
235
|
+
root_paths: [],
|
|
236
|
+
namespace_template: '',
|
|
237
|
+
agent_folder: 'agents',
|
|
238
|
+
skip_dirs: ['.module-template', '.example'],
|
|
239
|
+
detection_acknowledged: false,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** Anchor identifier returned by `find_project_root_with_anchor`. */
|
|
243
|
+
export const ANCHOR_AGENT_SETTINGS = 'agent-settings';
|
|
244
|
+
export const ANCHOR_AGENTS_DIR = 'agents-dir';
|
|
245
|
+
export const ANCHOR_GIT = 'git';
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Marker subpaths that qualify a bare `agents/` directory as a project
|
|
249
|
+
* anchor (D1). Any one is sufficient.
|
|
250
|
+
*/
|
|
251
|
+
const _AGENTS_DIR_MARKERS: readonly string[] = [
|
|
252
|
+
'roadmaps',
|
|
253
|
+
'settings/.ai-council.yml',
|
|
254
|
+
'roadmaps-progress.md',
|
|
255
|
+
'.event4u-bridge.yml',
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Kill-switch (D5). When set to `"1"`, `find_project_root` /
|
|
260
|
+
* `find_project_root_with_anchor` revert to the pre-Step-7 `.git`-only
|
|
261
|
+
* walk for one minor-version soak.
|
|
262
|
+
*/
|
|
263
|
+
const _LEGACY_ANCHOR_ENV = 'AGENT_CONFIG_LEGACY_ANCHOR';
|
|
264
|
+
|
|
265
|
+
/** `Path.exists()` — true for files, dirs, symlinks (broken or not). */
|
|
266
|
+
function _exists(p: string): boolean {
|
|
267
|
+
try {
|
|
268
|
+
fs.lstatSync(p);
|
|
269
|
+
// lstat does not follow symlinks; Python's exists() does. Re-check
|
|
270
|
+
// through stat so a valid symlink target counts as existing.
|
|
271
|
+
fs.statSync(p);
|
|
272
|
+
return true;
|
|
273
|
+
} catch {
|
|
274
|
+
// lstat may succeed for a broken symlink while stat throws; Python's
|
|
275
|
+
// Path.exists() returns False for broken symlinks, matching here.
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `Path.is_dir()`. */
|
|
281
|
+
function _is_dir(p: string): boolean {
|
|
282
|
+
try {
|
|
283
|
+
return fs.statSync(p).isDirectory();
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `Path.is_file()`. */
|
|
290
|
+
function _is_file(p: string): boolean {
|
|
291
|
+
try {
|
|
292
|
+
return fs.statSync(p).isFile();
|
|
293
|
+
} catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Mirror of `Path.resolve()`: absolutize + normalize + resolve symlinks.
|
|
300
|
+
* Python's `resolve()` is `strict=False` by default — it does NOT raise
|
|
301
|
+
* on a missing path, it normalizes what it can. `fs.realpathSync` raises
|
|
302
|
+
* on a missing path, so fall back to a normalized absolute path.
|
|
303
|
+
*/
|
|
304
|
+
function _resolve(p: string): string {
|
|
305
|
+
const absolute = path.resolve(p);
|
|
306
|
+
try {
|
|
307
|
+
return fs.realpathSync(absolute);
|
|
308
|
+
} catch {
|
|
309
|
+
return absolute;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Ancestor chain `[start, ...parents]` mirroring `[current, *current.parents]`. */
|
|
314
|
+
function _ancestor_chain(start: string): string[] {
|
|
315
|
+
const chain: string[] = [];
|
|
316
|
+
let cursor = start;
|
|
317
|
+
for (;;) {
|
|
318
|
+
chain.push(cursor);
|
|
319
|
+
const parent = path.dirname(cursor);
|
|
320
|
+
if (parent === cursor) {
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
cursor = parent;
|
|
324
|
+
}
|
|
325
|
+
return chain;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Return the boundary-anchor name at `path` or `null`.
|
|
330
|
+
*
|
|
331
|
+
* Boundary anchors stop the walk and define the project root:
|
|
332
|
+
*
|
|
333
|
+
* - `agents/` containing a D1 marker → `"agents-dir"`
|
|
334
|
+
* - `.git` (file or directory) → `"git"`
|
|
335
|
+
*
|
|
336
|
+
* `.agent-settings.yml` is a layer marker, not a boundary anchor — it
|
|
337
|
+
* only anchors when no boundary is found in any ancestor.
|
|
338
|
+
*/
|
|
339
|
+
function _boundary_anchor_at(dir: string): string | null {
|
|
340
|
+
const agents_dir = path.join(dir, 'agents');
|
|
341
|
+
if (_is_dir(agents_dir)) {
|
|
342
|
+
for (const marker of _AGENTS_DIR_MARKERS) {
|
|
343
|
+
if (_exists(path.join(agents_dir, marker))) {
|
|
344
|
+
return ANCHOR_AGENTS_DIR;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (_exists(path.join(dir, '.git'))) {
|
|
349
|
+
return ANCHOR_GIT;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Walk up from `start` and return `[root, anchor_name]` or `null`.
|
|
356
|
+
*
|
|
357
|
+
* Two-tier lookup: boundary pass (first ancestor with `.git` or an
|
|
358
|
+
* `agents/`+marker anchor, `agents/` wins ties at the same level), then a
|
|
359
|
+
* layer fallback returning the **outermost** ancestor carrying
|
|
360
|
+
* `.agent-settings.yml`. When `AGENT_CONFIG_LEGACY_ANCHOR=1`, only `.git`
|
|
361
|
+
* is considered. Pure read-only; never raises on missing paths.
|
|
362
|
+
*/
|
|
363
|
+
export function find_project_root_with_anchor(
|
|
364
|
+
start: string,
|
|
365
|
+
): [string, string] | null {
|
|
366
|
+
const current = _exists(start) ? _resolve(start) : start;
|
|
367
|
+
const legacy = process.env[_LEGACY_ANCHOR_ENV] === '1';
|
|
368
|
+
const chain = _ancestor_chain(current);
|
|
369
|
+
if (legacy) {
|
|
370
|
+
for (const candidate of chain) {
|
|
371
|
+
if (_exists(path.join(candidate, '.git'))) {
|
|
372
|
+
return [candidate, ANCHOR_GIT];
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
// Boundary pass.
|
|
378
|
+
for (const candidate of chain) {
|
|
379
|
+
const anchor = _boundary_anchor_at(candidate);
|
|
380
|
+
if (anchor !== null) {
|
|
381
|
+
return [candidate, anchor];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Layer fallback — outermost .agent-settings.yml wins so the cascade
|
|
385
|
+
// can layer deeper files below it.
|
|
386
|
+
let outermost: string | null = null;
|
|
387
|
+
for (const candidate of chain) {
|
|
388
|
+
if (_exists(path.join(candidate, DEFAULT_PROJECT_FILE))) {
|
|
389
|
+
outermost = candidate;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (outermost !== null) {
|
|
393
|
+
return [outermost, ANCHOR_AGENT_SETTINGS];
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Walk up from `start` and return the project root or `null`.
|
|
400
|
+
*
|
|
401
|
+
* Thin wrapper over `find_project_root_with_anchor` that drops the
|
|
402
|
+
* anchor-name component.
|
|
403
|
+
*/
|
|
404
|
+
export function find_project_root(start: string): string | null {
|
|
405
|
+
const result = find_project_root_with_anchor(start);
|
|
406
|
+
return result !== null ? result[0] : null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** One ancestor probe record emitted by `find_project_root_with_trace`. */
|
|
410
|
+
export interface TraceRecord {
|
|
411
|
+
ancestor: string;
|
|
412
|
+
pass: 'boundary' | 'layer';
|
|
413
|
+
hit: string | null;
|
|
414
|
+
reason: string;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Walk up from `start` and return `[root, anchor, trace]`.
|
|
419
|
+
*
|
|
420
|
+
* Diagnostic variant of `find_project_root_with_anchor`. Returns the same
|
|
421
|
+
* `[root, anchor]` pair (or `[null, null]` when no anchor is found) plus
|
|
422
|
+
* an ordered list of trace records describing every ancestor probed.
|
|
423
|
+
* Pure read-only.
|
|
424
|
+
*/
|
|
425
|
+
export function find_project_root_with_trace(
|
|
426
|
+
start: string,
|
|
427
|
+
): [string | null, string | null, TraceRecord[]] {
|
|
428
|
+
const trace: TraceRecord[] = [];
|
|
429
|
+
const current = _exists(start) ? _resolve(start) : start;
|
|
430
|
+
const legacy = process.env[_LEGACY_ANCHOR_ENV] === '1';
|
|
431
|
+
const chain = _ancestor_chain(current);
|
|
432
|
+
|
|
433
|
+
if (legacy) {
|
|
434
|
+
for (const candidate of chain) {
|
|
435
|
+
const hit = _exists(path.join(candidate, '.git'));
|
|
436
|
+
trace.push({
|
|
437
|
+
ancestor: candidate,
|
|
438
|
+
pass: 'boundary',
|
|
439
|
+
hit: hit ? ANCHOR_GIT : null,
|
|
440
|
+
reason: hit ? 'legacy: .git found' : 'legacy: no .git',
|
|
441
|
+
});
|
|
442
|
+
if (hit) {
|
|
443
|
+
return [candidate, ANCHOR_GIT, trace];
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return [null, null, trace];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Boundary pass — same probes as find_project_root_with_anchor.
|
|
450
|
+
for (const candidate of chain) {
|
|
451
|
+
const agents_dir = path.join(candidate, 'agents');
|
|
452
|
+
if (_is_dir(agents_dir)) {
|
|
453
|
+
for (const marker of _AGENTS_DIR_MARKERS) {
|
|
454
|
+
if (_exists(path.join(agents_dir, marker))) {
|
|
455
|
+
trace.push({
|
|
456
|
+
ancestor: candidate,
|
|
457
|
+
pass: 'boundary',
|
|
458
|
+
hit: ANCHOR_AGENTS_DIR,
|
|
459
|
+
reason: `agents/ has ${marker}`,
|
|
460
|
+
});
|
|
461
|
+
return [candidate, ANCHOR_AGENTS_DIR, trace];
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (_exists(path.join(candidate, '.git'))) {
|
|
466
|
+
trace.push({
|
|
467
|
+
ancestor: candidate,
|
|
468
|
+
pass: 'boundary',
|
|
469
|
+
hit: ANCHOR_GIT,
|
|
470
|
+
reason: '.git present',
|
|
471
|
+
});
|
|
472
|
+
return [candidate, ANCHOR_GIT, trace];
|
|
473
|
+
}
|
|
474
|
+
trace.push({
|
|
475
|
+
ancestor: candidate,
|
|
476
|
+
pass: 'boundary',
|
|
477
|
+
hit: null,
|
|
478
|
+
reason: 'no agents/ marker, no .git',
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Layer fallback — outermost .agent-settings.yml wins.
|
|
483
|
+
let outermost: string | null = null;
|
|
484
|
+
for (const candidate of chain) {
|
|
485
|
+
const present = _exists(path.join(candidate, DEFAULT_PROJECT_FILE));
|
|
486
|
+
trace.push({
|
|
487
|
+
ancestor: candidate,
|
|
488
|
+
pass: 'layer',
|
|
489
|
+
hit: present ? ANCHOR_AGENT_SETTINGS : null,
|
|
490
|
+
reason: present
|
|
491
|
+
? `${DEFAULT_PROJECT_FILE} present`
|
|
492
|
+
: `no ${DEFAULT_PROJECT_FILE}`,
|
|
493
|
+
});
|
|
494
|
+
if (present) {
|
|
495
|
+
outermost = candidate;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (outermost !== null) {
|
|
499
|
+
return [outermost, ANCHOR_AGENT_SETTINGS, trace];
|
|
500
|
+
}
|
|
501
|
+
return [null, null, trace];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Origin tag returned by `resolve_project_root` alongside the anchor
|
|
506
|
+
* names defined above.
|
|
507
|
+
*/
|
|
508
|
+
export const ORIGIN_ROOT_FLAG = 'root-flag'; // --root global flag (Step 8 A3)
|
|
509
|
+
export const ORIGIN_EXPLICIT = 'explicit'; // --project arg on a subcommand
|
|
510
|
+
export const ORIGIN_ENV = 'env'; // AGENT_CONFIG_PROJECT_ROOT (wrapper-pinned)
|
|
511
|
+
export const ORIGIN_CWD_FALLBACK = 'cwd-fallback'; // no anchor found
|
|
512
|
+
|
|
513
|
+
export const PROJECT_ROOT_ENV = 'AGENT_CONFIG_PROJECT_ROOT';
|
|
514
|
+
export const ROOT_OVERRIDE_ENV = 'AGENT_CONFIG_ROOT_OVERRIDE';
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Raised when an explicit project-root override points to an invalid path.
|
|
518
|
+
*
|
|
519
|
+
* `--root <path>` and `AGENT_CONFIG_PROJECT_ROOT` must fail loudly when
|
|
520
|
+
* the target does not exist or is not a directory. Callers translate this
|
|
521
|
+
* into exit code 2 (no silent CWD fallback).
|
|
522
|
+
*/
|
|
523
|
+
export class ProjectRootError extends Error {
|
|
524
|
+
constructor(message: string) {
|
|
525
|
+
super(message);
|
|
526
|
+
this.name = 'ProjectRootError';
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Resolve `path`; throw `ProjectRootError` when invalid.
|
|
532
|
+
*
|
|
533
|
+
* `origin_label` is one of `--root`, `AGENT_CONFIG_PROJECT_ROOT`, or
|
|
534
|
+
* `--project`; surfaced verbatim in the error message.
|
|
535
|
+
*/
|
|
536
|
+
function _validate_root_path(p: string, origin_label: string): string {
|
|
537
|
+
const resolved = _expanduser(p);
|
|
538
|
+
if (!_exists(resolved)) {
|
|
539
|
+
throw new ProjectRootError(
|
|
540
|
+
`${origin_label} points to a path that does not exist: ${resolved}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
if (!_is_dir(resolved)) {
|
|
544
|
+
throw new ProjectRootError(
|
|
545
|
+
`${origin_label} points to a non-directory: ${resolved}`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
return _resolve(resolved);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** `Path.expanduser()` — expand a leading `~`. */
|
|
552
|
+
function _expanduser(p: string): string {
|
|
553
|
+
if (p === '~') {
|
|
554
|
+
return os.homedir();
|
|
555
|
+
}
|
|
556
|
+
if (p.startsWith('~/') || (process.platform === 'win32' && p.startsWith('~\\'))) {
|
|
557
|
+
return path.join(os.homedir(), p.slice(2));
|
|
558
|
+
}
|
|
559
|
+
return p;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Return `[root, origin]` for any `cmd_*` entry point.
|
|
564
|
+
*
|
|
565
|
+
* Resolution order:
|
|
566
|
+
*
|
|
567
|
+
* 1. `AGENT_CONFIG_PROJECT_ROOT` env var with `AGENT_CONFIG_ROOT_OVERRIDE=1`
|
|
568
|
+
* set by the master CLI's `--root` flag → `ORIGIN_ROOT_FLAG`. Fail-loud.
|
|
569
|
+
* 2. Explicit `arg` (`--project`/`--target`) → `ORIGIN_EXPLICIT`. Fail-loud.
|
|
570
|
+
* 3. `AGENT_CONFIG_PROJECT_ROOT` env (wrapper) → `ORIGIN_ENV`. Fail-loud.
|
|
571
|
+
* 4. Anchor walk from `cwd` → anchor name.
|
|
572
|
+
* 5. Fall back to `cwd` itself → `ORIGIN_CWD_FALLBACK`.
|
|
573
|
+
*
|
|
574
|
+
* Throws `ProjectRootError` when any explicit override points to a
|
|
575
|
+
* missing path or non-directory.
|
|
576
|
+
*/
|
|
577
|
+
export function resolve_project_root(
|
|
578
|
+
arg: string | null,
|
|
579
|
+
options: { cwd?: string | null } = {},
|
|
580
|
+
): [string, string] {
|
|
581
|
+
const cwd = options.cwd ?? null;
|
|
582
|
+
if (process.env[ROOT_OVERRIDE_ENV] === '1') {
|
|
583
|
+
const env_value = process.env[PROJECT_ROOT_ENV];
|
|
584
|
+
if (env_value) {
|
|
585
|
+
return [_validate_root_path(env_value, '--root'), ORIGIN_ROOT_FLAG];
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (arg !== null && String(arg) !== '') {
|
|
589
|
+
return [_validate_root_path(arg, '--project'), ORIGIN_EXPLICIT];
|
|
590
|
+
}
|
|
591
|
+
const env_value = process.env[PROJECT_ROOT_ENV];
|
|
592
|
+
if (env_value) {
|
|
593
|
+
return [_validate_root_path(env_value, PROJECT_ROOT_ENV), ORIGIN_ENV];
|
|
594
|
+
}
|
|
595
|
+
const start = _resolve(cwd ?? process.cwd());
|
|
596
|
+
const walked = find_project_root_with_anchor(start);
|
|
597
|
+
if (walked !== null) {
|
|
598
|
+
return walked;
|
|
599
|
+
}
|
|
600
|
+
return [start, ORIGIN_CWD_FALLBACK];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Return the ordered cascade of in-project settings files (shallow → deep).
|
|
605
|
+
*
|
|
606
|
+
* When `cwd` is provided and `find_project_root(cwd)` succeeds, the list
|
|
607
|
+
* contains every `<dir>/.agent-settings.yml` from the repo root down to
|
|
608
|
+
* `cwd`, shallowest first, then the canonical project file under
|
|
609
|
+
* `agents/settings/`, then the per-machine local override. When `cwd` is
|
|
610
|
+
* `null` or no anchor is reached, falls back to the legacy three-path set.
|
|
611
|
+
*/
|
|
612
|
+
function _resolve_cascade_paths(
|
|
613
|
+
cwd: string | null,
|
|
614
|
+
project_path: string | null,
|
|
615
|
+
): string[] {
|
|
616
|
+
if (cwd === null) {
|
|
617
|
+
const legacy = project_path ? project_path : DEFAULT_PROJECT_FILE;
|
|
618
|
+
const parent = path.dirname(legacy);
|
|
619
|
+
return [legacy, _canonical_settings_path(parent), _local_settings_path(parent)];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const root = find_project_root(cwd);
|
|
623
|
+
if (root === null) {
|
|
624
|
+
const legacy = project_path ? project_path : DEFAULT_PROJECT_FILE;
|
|
625
|
+
const parent = path.dirname(legacy);
|
|
626
|
+
return [legacy, _canonical_settings_path(parent), _local_settings_path(parent)];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const cwd_resolved = _resolve(cwd);
|
|
630
|
+
// Build the chain root → … → cwd (shallowest first, deepest last).
|
|
631
|
+
const chain: string[] = [];
|
|
632
|
+
let cursor = cwd_resolved;
|
|
633
|
+
for (;;) {
|
|
634
|
+
chain.push(cursor);
|
|
635
|
+
if (cursor === root) {
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
const parent = path.dirname(cursor);
|
|
639
|
+
if (parent === cursor) {
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
cursor = parent;
|
|
643
|
+
}
|
|
644
|
+
chain.reverse();
|
|
645
|
+
return [
|
|
646
|
+
...chain.map((d) => path.join(d, DEFAULT_PROJECT_FILE)),
|
|
647
|
+
_canonical_settings_path(root),
|
|
648
|
+
_local_settings_path(root),
|
|
649
|
+
];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Return the merged settings dict.
|
|
654
|
+
*
|
|
655
|
+
* `project_path` defaults to `./.agent-settings.yml` (CWD-relative).
|
|
656
|
+
* `user_global_path` defaults to the resolved user-global file. Pass
|
|
657
|
+
* `verbose=true` to log keys present in user-global that are not on the
|
|
658
|
+
* whitelist. `cwd` enables the in-project cascade. When `cwd` is `null`
|
|
659
|
+
* (default), the loader falls back to the single `project_path` behavior.
|
|
660
|
+
*/
|
|
661
|
+
export function load_agent_settings(
|
|
662
|
+
options: {
|
|
663
|
+
project_path?: string | null;
|
|
664
|
+
user_global_path?: string | null;
|
|
665
|
+
verbose?: boolean;
|
|
666
|
+
cwd?: string | null;
|
|
667
|
+
} = {},
|
|
668
|
+
): SettingsDict {
|
|
669
|
+
const project_path = options.project_path ?? null;
|
|
670
|
+
const user_global_path = options.user_global_path ?? null;
|
|
671
|
+
const verbose = options.verbose ?? false;
|
|
672
|
+
const cwd = options.cwd ?? null;
|
|
673
|
+
|
|
674
|
+
const user_global_raw =
|
|
675
|
+
_read_yaml(user_global_path ? user_global_path : _resolve_user_global_file()) ?? {};
|
|
676
|
+
|
|
677
|
+
const [user_global_filtered, ignored] = _filter_whitelist(user_global_raw, MERGEABLE_KEYS);
|
|
678
|
+
if (verbose && ignored.length > 0) {
|
|
679
|
+
logger.info(
|
|
680
|
+
'agent_settings: ignored non-whitelisted user-global keys: %s',
|
|
681
|
+
[...ignored].sort(),
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const cascade = _resolve_cascade_paths(cwd, project_path);
|
|
686
|
+
|
|
687
|
+
const merged: SettingsDict = _deep_copy_defaults(_DEFAULTS);
|
|
688
|
+
_deep_merge(merged, user_global_filtered);
|
|
689
|
+
for (const p of cascade) {
|
|
690
|
+
const layer = _read_yaml(p) ?? {};
|
|
691
|
+
if (Object.keys(layer).length > 0) {
|
|
692
|
+
_deep_merge(merged, layer);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return merged;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Return the merged `modules:` configuration with defaults applied.
|
|
700
|
+
*
|
|
701
|
+
* Three-tier precedence (deepest wins): `MODULES_DEFAULTS` < team file
|
|
702
|
+
* (`<project_root>/.agent-project-settings.yml`) < developer cascade
|
|
703
|
+
* (every `.agent-settings.yml`). The team layer may pin keys via a
|
|
704
|
+
* top-level `locked_keys` list of dotted paths; locked keys discard any
|
|
705
|
+
* matching developer override and emit an INFO record. Pure, read-only.
|
|
706
|
+
*/
|
|
707
|
+
export function get_modules_config(
|
|
708
|
+
options: {
|
|
709
|
+
project_root?: string | null;
|
|
710
|
+
team_path?: string | null;
|
|
711
|
+
project_path?: string | null;
|
|
712
|
+
cwd?: string | null;
|
|
713
|
+
} = {},
|
|
714
|
+
): SettingsDict {
|
|
715
|
+
const project_root = options.project_root ?? null;
|
|
716
|
+
const team_path = options.team_path ?? null;
|
|
717
|
+
const project_path = options.project_path ?? null;
|
|
718
|
+
const cwd = options.cwd ?? null;
|
|
719
|
+
|
|
720
|
+
const cwd_resolved = cwd !== null ? cwd : process.cwd();
|
|
721
|
+
|
|
722
|
+
let team_file: string;
|
|
723
|
+
if (team_path !== null) {
|
|
724
|
+
team_file = team_path;
|
|
725
|
+
} else {
|
|
726
|
+
let root: string;
|
|
727
|
+
if (project_root !== null) {
|
|
728
|
+
root = project_root;
|
|
729
|
+
} else {
|
|
730
|
+
root = find_project_root(cwd_resolved) ?? cwd_resolved;
|
|
731
|
+
}
|
|
732
|
+
team_file = path.join(root, DEFAULT_TEAM_FILE);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const team_raw = _read_yaml(team_file) ?? {};
|
|
736
|
+
const team_modules_candidate = team_raw['modules'];
|
|
737
|
+
const team_modules: SettingsDict = _is_plain_dict(team_modules_candidate)
|
|
738
|
+
? team_modules_candidate
|
|
739
|
+
: {};
|
|
740
|
+
const locked_keys_raw = team_raw['locked_keys'];
|
|
741
|
+
const locked_keys: string[] = Array.isArray(locked_keys_raw)
|
|
742
|
+
? locked_keys_raw.filter((k): k is string => typeof k === 'string')
|
|
743
|
+
: [];
|
|
744
|
+
|
|
745
|
+
const dev_merged = load_agent_settings({
|
|
746
|
+
...(project_path !== null ? { project_path } : {}),
|
|
747
|
+
...(cwd !== null ? { cwd } : {}),
|
|
748
|
+
});
|
|
749
|
+
const dev_modules_candidate = dev_merged['modules'];
|
|
750
|
+
const dev_modules: SettingsDict = _is_plain_dict(dev_modules_candidate)
|
|
751
|
+
? dev_modules_candidate
|
|
752
|
+
: {};
|
|
753
|
+
|
|
754
|
+
const merged: SettingsDict = _deepcopy(MODULES_DEFAULTS);
|
|
755
|
+
if (Object.keys(team_modules).length > 0) {
|
|
756
|
+
_deep_merge(merged, team_modules);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (Object.keys(dev_modules).length > 0) {
|
|
760
|
+
for (const [key, value] of Object.entries(dev_modules)) {
|
|
761
|
+
const dotted = `modules.${key}`;
|
|
762
|
+
if (locked_keys.includes(dotted) && key in team_modules) {
|
|
763
|
+
logger.info(
|
|
764
|
+
'agent_settings: ignoring developer override of locked key %s',
|
|
765
|
+
dotted,
|
|
766
|
+
);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (_is_plain_dict(value) && _is_plain_dict(merged[key])) {
|
|
770
|
+
_deep_merge(merged[key], value);
|
|
771
|
+
} else {
|
|
772
|
+
merged[key] = value;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return merged;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Discovered-module record returned by `enumerate_modules`. */
|
|
781
|
+
export interface ModuleEntry {
|
|
782
|
+
name: string;
|
|
783
|
+
root_path: string;
|
|
784
|
+
module_path: string;
|
|
785
|
+
has_agent_folder: boolean;
|
|
786
|
+
agent_folder_path: string | null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Enumerate every module under `modules.root_paths`.
|
|
791
|
+
*
|
|
792
|
+
* For each path in `modules.root_paths` (resolved relative to
|
|
793
|
+
* `project_root`), lists immediate subdirectories that survive the
|
|
794
|
+
* `modules.skip_dirs` filter and reports whether each module ships a
|
|
795
|
+
* per-module agent folder (`modules.agent_folder`, default `agents`).
|
|
796
|
+
*
|
|
797
|
+
* Returns a list sorted by `(root_path, name)`. `modules.enabled` is NOT
|
|
798
|
+
* consulted. Missing roots are skipped silently (logged at INFO). Hidden
|
|
799
|
+
* directories and `skip_dirs` entries are filtered out. Pure, read-only.
|
|
800
|
+
*/
|
|
801
|
+
export function enumerate_modules(
|
|
802
|
+
options: {
|
|
803
|
+
project_root?: string | null;
|
|
804
|
+
cwd?: string | null;
|
|
805
|
+
modules_config?: SettingsDict | null;
|
|
806
|
+
} = {},
|
|
807
|
+
): ModuleEntry[] {
|
|
808
|
+
const project_root = options.project_root ?? null;
|
|
809
|
+
const cwd = options.cwd ?? null;
|
|
810
|
+
let modules_config = options.modules_config ?? null;
|
|
811
|
+
|
|
812
|
+
const cwd_resolved = cwd !== null ? cwd : process.cwd();
|
|
813
|
+
let root: string;
|
|
814
|
+
if (project_root !== null) {
|
|
815
|
+
root = project_root;
|
|
816
|
+
} else {
|
|
817
|
+
root = find_project_root(cwd_resolved) ?? cwd_resolved;
|
|
818
|
+
}
|
|
819
|
+
root = _resolve(root);
|
|
820
|
+
|
|
821
|
+
if (modules_config === null) {
|
|
822
|
+
modules_config = get_modules_config({ project_root: root, ...(cwd !== null ? { cwd } : {}) });
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const root_paths_raw: SettingsValue[] = modules_config['root_paths'] || [];
|
|
826
|
+
const skip_dirs_raw: SettingsValue[] =
|
|
827
|
+
modules_config['skip_dirs'] || MODULES_DEFAULTS['skip_dirs'];
|
|
828
|
+
const agent_folder = String(modules_config['agent_folder'] || MODULES_DEFAULTS['agent_folder']);
|
|
829
|
+
|
|
830
|
+
const skip_dirs = new Set<string>(
|
|
831
|
+
(Array.isArray(skip_dirs_raw) ? skip_dirs_raw : [])
|
|
832
|
+
.filter((s): s is string => typeof s === 'string'),
|
|
833
|
+
);
|
|
834
|
+
|
|
835
|
+
const discovered: ModuleEntry[] = [];
|
|
836
|
+
for (const raw of Array.isArray(root_paths_raw) ? root_paths_raw : []) {
|
|
837
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
const root_rel = _strip_slashes(raw.trim());
|
|
841
|
+
const root_abs = _resolve(path.join(root, root_rel));
|
|
842
|
+
try {
|
|
843
|
+
// Latent-bug candidate (replicated verbatim, see divergence
|
|
844
|
+
// notes): boundary check uses a raw string prefix match
|
|
845
|
+
// (`startswith`) rather than a path-component boundary check,
|
|
846
|
+
// so a sibling like `<root>-evil` would pass. Python does the
|
|
847
|
+
// same; flagged, not fixed.
|
|
848
|
+
if (!_is_dir(root_abs) || !root_abs.startsWith(root)) {
|
|
849
|
+
logger.info('enumerate_modules: skipping missing/out-of-tree root %s', root_rel);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
} catch {
|
|
853
|
+
logger.info('enumerate_modules: unreadable root %s', root_rel);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
let children: string[];
|
|
858
|
+
try {
|
|
859
|
+
children = fs
|
|
860
|
+
.readdirSync(root_abs)
|
|
861
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
862
|
+
} catch {
|
|
863
|
+
logger.info('enumerate_modules: cannot list %s', root_rel);
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
for (const name of children) {
|
|
868
|
+
if (name.startsWith('.') || skip_dirs.has(name)) {
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const child = path.join(root_abs, name);
|
|
872
|
+
if (!_is_dir(child)) {
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const agent_dir = path.join(child, agent_folder);
|
|
876
|
+
const has_agent = _is_dir(agent_dir);
|
|
877
|
+
let module_rel: string;
|
|
878
|
+
try {
|
|
879
|
+
module_rel = _relative_to(_resolve(child), root);
|
|
880
|
+
} catch {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const entry: ModuleEntry = {
|
|
884
|
+
name,
|
|
885
|
+
root_path: root_rel,
|
|
886
|
+
module_path: module_rel,
|
|
887
|
+
has_agent_folder: has_agent,
|
|
888
|
+
agent_folder_path: has_agent ? _posix_join(module_rel, agent_folder) : null,
|
|
889
|
+
};
|
|
890
|
+
discovered.push(entry);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
discovered.sort((a, b) => {
|
|
895
|
+
if (a.root_path !== b.root_path) {
|
|
896
|
+
return a.root_path < b.root_path ? -1 : 1;
|
|
897
|
+
}
|
|
898
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
899
|
+
});
|
|
900
|
+
return discovered;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Yield `[dotted_key, value, source_path]` for every leaf setting.
|
|
905
|
+
*
|
|
906
|
+
* Walks the same cascade as `load_agent_settings` and emits one tuple per
|
|
907
|
+
* leaf observed at each layer (user-global → repo-root → intermediates →
|
|
908
|
+
* CWD). Never blocks, never raises on missing files.
|
|
909
|
+
*/
|
|
910
|
+
export function* iter_setting_overrides(
|
|
911
|
+
options: {
|
|
912
|
+
project_path?: string | null;
|
|
913
|
+
user_global_path?: string | null;
|
|
914
|
+
cwd?: string | null;
|
|
915
|
+
} = {},
|
|
916
|
+
): Generator<[string, SettingsValue, string]> {
|
|
917
|
+
const project_path = options.project_path ?? null;
|
|
918
|
+
const user_global_path = options.user_global_path ?? null;
|
|
919
|
+
const cwd = options.cwd ?? null;
|
|
920
|
+
|
|
921
|
+
const user_global_path_resolved = user_global_path
|
|
922
|
+
? user_global_path
|
|
923
|
+
: _resolve_user_global_file();
|
|
924
|
+
const user_global_raw = _read_yaml(user_global_path_resolved) ?? {};
|
|
925
|
+
const [user_global_filtered] = _filter_whitelist(user_global_raw, MERGEABLE_KEYS);
|
|
926
|
+
if (Object.keys(user_global_filtered).length > 0) {
|
|
927
|
+
for (const key of _leaf_paths(user_global_filtered)) {
|
|
928
|
+
yield [key, _get_dotted(user_global_filtered, key), user_global_path_resolved];
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
for (const p of _resolve_cascade_paths(cwd, project_path)) {
|
|
933
|
+
const layer = _read_yaml(p);
|
|
934
|
+
if (!layer || Object.keys(layer).length === 0) {
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
for (const key of _leaf_paths(layer)) {
|
|
938
|
+
yield [key, _get_dotted(layer, key), p];
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** Best-effort YAML read; never raises. Returns `null` on any failure. */
|
|
944
|
+
function _read_yaml(p: string): SettingsDict | null {
|
|
945
|
+
if (!_is_file(p)) {
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
let YAML: typeof YamlModule;
|
|
949
|
+
try {
|
|
950
|
+
// Lazy require to mirror Python's lazy `import yaml` — a missing
|
|
951
|
+
// package degrades to `null` (defaults) instead of crashing.
|
|
952
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
953
|
+
YAML = require('yaml') as typeof YamlModule;
|
|
954
|
+
} catch {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
let data: SettingsValue;
|
|
958
|
+
try {
|
|
959
|
+
const text = fs.readFileSync(p, 'utf-8');
|
|
960
|
+
// version '1.1' matches PyYAML's safe_load (yes/no/on/off bools,
|
|
961
|
+
// sexagesimal ints) so the differential corpus stays byte-faithful.
|
|
962
|
+
data = YAML.parse(text, { version: '1.1' });
|
|
963
|
+
if (data === null || data === undefined) {
|
|
964
|
+
data = {};
|
|
965
|
+
}
|
|
966
|
+
} catch (err) {
|
|
967
|
+
// OSError or YAML parse failure → defaults, logged at WARNING.
|
|
968
|
+
if (_is_yaml_error(err) || _is_os_error(err)) {
|
|
969
|
+
logger.warning('agent_settings: unreadable or malformed YAML at %s', p);
|
|
970
|
+
return null;
|
|
971
|
+
}
|
|
972
|
+
throw err;
|
|
973
|
+
}
|
|
974
|
+
return _is_plain_dict(data) ? data : null;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function _is_yaml_error(err: unknown): boolean {
|
|
978
|
+
// The `yaml` package throws YAMLParseError / YAMLWarning subclasses
|
|
979
|
+
// whose names start with "YAML"; also catch generic parse Errors.
|
|
980
|
+
if (err instanceof Error) {
|
|
981
|
+
return err.name.startsWith('YAML') || err.name === 'Error' || err.name === 'TypeError';
|
|
982
|
+
}
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function _is_os_error(err: unknown): boolean {
|
|
987
|
+
return (
|
|
988
|
+
typeof err === 'object' &&
|
|
989
|
+
err !== null &&
|
|
990
|
+
'code' in err &&
|
|
991
|
+
typeof (err as { code: unknown }).code === 'string'
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Return `[filtered_dict, ignored_paths]` from a user-global blob. */
|
|
996
|
+
function _filter_whitelist(
|
|
997
|
+
raw: SettingsDict,
|
|
998
|
+
allowed: readonly string[],
|
|
999
|
+
): [SettingsDict, string[]] {
|
|
1000
|
+
const filtered: SettingsDict = {};
|
|
1001
|
+
for (const dotted of allowed) {
|
|
1002
|
+
const value = _get_dotted(raw, dotted);
|
|
1003
|
+
if (value !== null && value !== undefined) {
|
|
1004
|
+
_set_dotted(filtered, dotted, value);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const ignored = _leaf_paths(raw).filter((p) => !allowed.includes(p));
|
|
1008
|
+
return [filtered, ignored];
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function _get_dotted(data: SettingsDict, dotted: string): SettingsValue {
|
|
1012
|
+
let cursor: SettingsValue = data;
|
|
1013
|
+
for (const part of dotted.split('.')) {
|
|
1014
|
+
if (!_is_plain_dict(cursor) || !(part in cursor)) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
cursor = cursor[part];
|
|
1018
|
+
}
|
|
1019
|
+
return cursor;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function _set_dotted(target: SettingsDict, dotted: string, value: SettingsValue): void {
|
|
1023
|
+
const parts = dotted.split('.');
|
|
1024
|
+
let cursor: SettingsDict = target;
|
|
1025
|
+
for (const part of parts.slice(0, -1)) {
|
|
1026
|
+
let nxt = cursor[part];
|
|
1027
|
+
if (nxt === undefined) {
|
|
1028
|
+
nxt = {};
|
|
1029
|
+
cursor[part] = nxt;
|
|
1030
|
+
}
|
|
1031
|
+
if (!_is_plain_dict(nxt)) {
|
|
1032
|
+
nxt = {};
|
|
1033
|
+
cursor[part] = nxt;
|
|
1034
|
+
}
|
|
1035
|
+
cursor = nxt;
|
|
1036
|
+
}
|
|
1037
|
+
cursor[parts[parts.length - 1] as string] = value;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function _leaf_paths(data: SettingsDict, prefix = ''): string[] {
|
|
1041
|
+
const paths: string[] = [];
|
|
1042
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1043
|
+
const p = prefix ? `${prefix}.${key}` : key;
|
|
1044
|
+
if (_is_plain_dict(value) && Object.keys(value).length > 0) {
|
|
1045
|
+
paths.push(..._leaf_paths(value, p));
|
|
1046
|
+
} else {
|
|
1047
|
+
paths.push(p);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return paths;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** Merge `src` into `dst` in-place; nested dicts merged recursively. */
|
|
1054
|
+
function _deep_merge(dst: SettingsDict, src: SettingsDict): void {
|
|
1055
|
+
for (const [key, value] of Object.entries(src)) {
|
|
1056
|
+
if (_is_plain_dict(value) && _is_plain_dict(dst[key])) {
|
|
1057
|
+
_deep_merge(dst[key], value);
|
|
1058
|
+
} else {
|
|
1059
|
+
dst[key] = value;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function _deep_copy_defaults(src: SettingsDict): SettingsDict {
|
|
1065
|
+
const out: SettingsDict = {};
|
|
1066
|
+
_deep_merge(out, src);
|
|
1067
|
+
return out;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* `copy.deepcopy` for the JSON-shaped values settings hold (dicts, lists,
|
|
1072
|
+
* scalars). Arrays and plain objects are cloned recursively; scalars pass
|
|
1073
|
+
* through.
|
|
1074
|
+
*/
|
|
1075
|
+
function _deepcopy<T extends SettingsValue>(value: T): T {
|
|
1076
|
+
if (Array.isArray(value)) {
|
|
1077
|
+
return value.map((v) => _deepcopy(v)) as unknown as T;
|
|
1078
|
+
}
|
|
1079
|
+
if (_is_plain_dict(value)) {
|
|
1080
|
+
const out: SettingsDict = {};
|
|
1081
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1082
|
+
out[k] = _deepcopy(v);
|
|
1083
|
+
}
|
|
1084
|
+
return out as unknown as T;
|
|
1085
|
+
}
|
|
1086
|
+
return value;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/**
|
|
1090
|
+
* `isinstance(x, dict)` — a plain object, not an array / null / class
|
|
1091
|
+
* instance. Mirrors Python's `isinstance(..., dict)` for settings data,
|
|
1092
|
+
* which is always built from `yaml.safe_load` (plain dicts).
|
|
1093
|
+
*/
|
|
1094
|
+
function _is_plain_dict(value: SettingsValue): value is SettingsDict {
|
|
1095
|
+
return (
|
|
1096
|
+
typeof value === 'object' &&
|
|
1097
|
+
value !== null &&
|
|
1098
|
+
!Array.isArray(value)
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** Strip leading/trailing `/` like Python's `str.strip("/")`. */
|
|
1103
|
+
function _strip_slashes(s: string): string {
|
|
1104
|
+
return s.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* `Path.relative_to(root)` — throws when `child` is not under `root`
|
|
1109
|
+
* (mirrors Python's `ValueError`). Returns the OS-native relative path.
|
|
1110
|
+
*/
|
|
1111
|
+
function _relative_to(child: string, root: string): string {
|
|
1112
|
+
const rel = path.relative(root, child);
|
|
1113
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
1114
|
+
throw new Error(`${child} is not in the subpath of ${root}`);
|
|
1115
|
+
}
|
|
1116
|
+
return rel;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** Join two repo-relative path fragments with forward slashes (posix). */
|
|
1120
|
+
function _posix_join(a: string, b: string): string {
|
|
1121
|
+
return `${a.split(path.sep).join('/')}/${b}`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// ── discipline_profile resolution (weak-host-lift tiering) ─────────────────
|
|
1125
|
+
//
|
|
1126
|
+
// The ONE runtime knob for the discipline-rule tier (council 2026-07-07:
|
|
1127
|
+
// weak-host-lift-tiering + token-program-integration verdicts). Values:
|
|
1128
|
+
// `auto | off | essential | full`; `auto` resolves per session against the
|
|
1129
|
+
// evidence-gated NULL-lift disable-list in `src/config/host-capabilities.yml`.
|
|
1130
|
+
// Legacy `rule_loading_tier` maps when the new key is absent.
|
|
1131
|
+
|
|
1132
|
+
export type DisciplineProfile = 'off' | 'essential' | 'full' | 'custom';
|
|
1133
|
+
|
|
1134
|
+
export type LiftDefault = 'lift_enabled' | 'lift_disabled';
|
|
1135
|
+
|
|
1136
|
+
export interface HostCapabilities {
|
|
1137
|
+
/** Models with a MEASURED null discipline lift (prefix-matched ids). */
|
|
1138
|
+
lift_disabled_models: string[];
|
|
1139
|
+
/**
|
|
1140
|
+
* Resolution for UNMEASURED models, per vendor family (P2-verdict
|
|
1141
|
+
* council 2026-07-07): keys are family names plus a required `default`.
|
|
1142
|
+
* Evidence-structure granularity — anthropic keeps the fail-safe toward
|
|
1143
|
+
* the one measured lift; everything else defaults off after the failed
|
|
1144
|
+
* GPT-weak replication.
|
|
1145
|
+
*/
|
|
1146
|
+
unknown_defaults: Record<string, LiftDefault> & { default: LiftDefault };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const _CAPABILITIES_FALLBACK: HostCapabilities = {
|
|
1150
|
+
lift_disabled_models: [],
|
|
1151
|
+
// File missing/unreadable → mirror the SHIPPED yml (anthropic enabled,
|
|
1152
|
+
// rest disabled) so a broken read never silently widens the lift.
|
|
1153
|
+
unknown_defaults: { anthropic: 'lift_enabled', default: 'lift_disabled' },
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
/** Map a session model id onto a vendor family for unknown_defaults. */
|
|
1157
|
+
export function vendor_family(model_id: string | null): string {
|
|
1158
|
+
if (!model_id) {
|
|
1159
|
+
return 'default';
|
|
1160
|
+
}
|
|
1161
|
+
return model_id.trim().toLowerCase().startsWith('claude-') ? 'anthropic' : 'default';
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Load `host-capabilities.yml` — tolerant like every settings read: a
|
|
1166
|
+
* missing / unreadable / malformed file resolves to the fail-safe fallback
|
|
1167
|
+
* (empty disable-list, `lift_enabled`), never an error. Entries may be
|
|
1168
|
+
* plain strings or `{ id, measured?, extrapolated? }` mappings; only the
|
|
1169
|
+
* id is needed for resolution (the provenance fields are the CLAIMS-grade
|
|
1170
|
+
* audit trail the file contract requires).
|
|
1171
|
+
*/
|
|
1172
|
+
export function load_host_capabilities(p: string): HostCapabilities {
|
|
1173
|
+
const raw = _read_yaml(p);
|
|
1174
|
+
if (raw === null || !_is_plain_dict(raw)) {
|
|
1175
|
+
return { ..._CAPABILITIES_FALLBACK, lift_disabled_models: [] };
|
|
1176
|
+
}
|
|
1177
|
+
const ids: string[] = [];
|
|
1178
|
+
const entries = raw['lift_disabled_models'];
|
|
1179
|
+
if (Array.isArray(entries)) {
|
|
1180
|
+
for (const entry of entries) {
|
|
1181
|
+
if (typeof entry === 'string' && entry.trim()) {
|
|
1182
|
+
ids.push(entry.trim());
|
|
1183
|
+
} else if (_is_plain_dict(entry) && typeof entry['id'] === 'string' && entry['id'].trim()) {
|
|
1184
|
+
ids.push((entry['id'] as string).trim());
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
const unknown_defaults: Record<string, 'lift_enabled' | 'lift_disabled'> & {
|
|
1189
|
+
default: 'lift_enabled' | 'lift_disabled';
|
|
1190
|
+
} = { ..._CAPABILITIES_FALLBACK.unknown_defaults };
|
|
1191
|
+
const granular = raw['unknown_defaults'];
|
|
1192
|
+
if (_is_plain_dict(granular)) {
|
|
1193
|
+
for (const [family, value] of Object.entries(granular)) {
|
|
1194
|
+
if (value === 'lift_enabled' || value === 'lift_disabled') {
|
|
1195
|
+
unknown_defaults[family] = value;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
} else if (raw['unknown_default'] === 'lift_disabled' || raw['unknown_default'] === 'lift_enabled') {
|
|
1199
|
+
// Legacy single-value key (pre-P2 files) maps onto the blanket default.
|
|
1200
|
+
unknown_defaults['default'] = raw['unknown_default'];
|
|
1201
|
+
unknown_defaults['anthropic'] = raw['unknown_default'];
|
|
1202
|
+
}
|
|
1203
|
+
return { lift_disabled_models: ids, unknown_defaults };
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** Prefix match a session model id against the measured disable-list. */
|
|
1207
|
+
export function is_lift_disabled_model(model_id: string | null, caps: HostCapabilities): boolean {
|
|
1208
|
+
if (!model_id) {
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
const normalized = model_id.trim().toLowerCase();
|
|
1212
|
+
return caps.lift_disabled_models.some((entry) => normalized.startsWith(entry.trim().toLowerCase()));
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Resolve the effective discipline profile for a session.
|
|
1217
|
+
*
|
|
1218
|
+
* Precedence: explicit `discipline_profile` wins; absent → legacy
|
|
1219
|
+
* `rule_loading_tier` mapping (minimal→off, balanced→essential, full→full,
|
|
1220
|
+
* custom→custom); both absent → `essential` (the successor of the
|
|
1221
|
+
* documented `balanced` default). `auto` resolves against the capabilities:
|
|
1222
|
+
* measured NULL-lift model → `off`; unmeasured models resolve per
|
|
1223
|
+
* `unknown_defaults[vendor_family(model_id)]` (anthropic → essential, the
|
|
1224
|
+
* one family with a measured lift; everything else — including a missing
|
|
1225
|
+
* model id — → off, per the failed P2 replication).
|
|
1226
|
+
*
|
|
1227
|
+
* Pure function — callers pass the merged settings dict, the session model
|
|
1228
|
+
* id (null when the host does not expose one), and the loaded capabilities.
|
|
1229
|
+
*/
|
|
1230
|
+
export function resolve_discipline_profile(
|
|
1231
|
+
settings: SettingsDict,
|
|
1232
|
+
model_id: string | null,
|
|
1233
|
+
caps: HostCapabilities,
|
|
1234
|
+
): DisciplineProfile {
|
|
1235
|
+
const explicit = settings['discipline_profile'];
|
|
1236
|
+
if (explicit === 'off' || explicit === 'essential' || explicit === 'full') {
|
|
1237
|
+
return explicit;
|
|
1238
|
+
}
|
|
1239
|
+
if (explicit === 'auto') {
|
|
1240
|
+
if (is_lift_disabled_model(model_id, caps)) {
|
|
1241
|
+
return 'off';
|
|
1242
|
+
}
|
|
1243
|
+
const family = vendor_family(model_id);
|
|
1244
|
+
const fallback = caps.unknown_defaults[family] ?? caps.unknown_defaults['default'];
|
|
1245
|
+
return fallback === 'lift_disabled' ? 'off' : 'essential';
|
|
1246
|
+
}
|
|
1247
|
+
const legacy = settings['rule_loading_tier'];
|
|
1248
|
+
if (legacy === 'minimal') {
|
|
1249
|
+
return 'off';
|
|
1250
|
+
}
|
|
1251
|
+
if (legacy === 'balanced') {
|
|
1252
|
+
return 'essential';
|
|
1253
|
+
}
|
|
1254
|
+
if (legacy === 'full') {
|
|
1255
|
+
return 'full';
|
|
1256
|
+
}
|
|
1257
|
+
if (legacy === 'custom') {
|
|
1258
|
+
return 'custom';
|
|
1259
|
+
}
|
|
1260
|
+
return 'essential';
|
|
1261
|
+
}
|