@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.
Files changed (126) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  4. package/dist/discovery/deprecation-report.md +1 -1
  5. package/dist/discovery/discovery-manifest.json +1 -1
  6. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  7. package/dist/discovery/discovery-manifest.summary.md +1 -1
  8. package/dist/discovery/orphan-report.md +1 -1
  9. package/dist/discovery/packs.json +1 -1
  10. package/dist/discovery/trust-report.md +1 -1
  11. package/dist/discovery/workspaces.json +1 -1
  12. package/dist/mcp/registry-manifest.json +1 -1
  13. package/package.json +4 -1
  14. package/src/agent-src/README.md +64 -0
  15. package/src/agent-src/scripts/archive_completed_roadmaps.ts +497 -0
  16. package/src/agent-src/scripts/scan-seeder-violations.php +145 -0
  17. package/src/agent-src/scripts/update_roadmap_progress.ts +1087 -0
  18. package/src/agent-src/templates/AGENTS.md +34 -0
  19. package/src/agent-src/templates/scripts/README.md +195 -0
  20. package/src/agent-src/templates/scripts/check_memory.ts +662 -0
  21. package/src/agent-src/templates/scripts/check_memory_proposal.ts +370 -0
  22. package/src/agent-src/templates/scripts/historical-bug-patterns.example.yml +84 -0
  23. package/src/agent-src/templates/scripts/implement_ticket/__main__.ts +27 -0
  24. package/src/agent-src/templates/scripts/memory_hash.ts +355 -0
  25. package/src/agent-src/templates/scripts/memory_lookup.ts +1089 -0
  26. package/src/agent-src/templates/scripts/memory_report.ts +868 -0
  27. package/src/agent-src/templates/scripts/memory_signal.ts +444 -0
  28. package/src/agent-src/templates/scripts/memory_status.ts +215 -0
  29. package/src/agent-src/templates/scripts/ownership-map.example.yml +87 -0
  30. package/src/agent-src/templates/scripts/pr-risk-config.example.yml +76 -0
  31. package/src/agent-src/templates/scripts/pr_review_routing.ts +544 -0
  32. package/src/agent-src/templates/scripts/pr_risk_review.ts +0 -0
  33. package/src/agent-src/templates/scripts/telemetry/aggregator.ts +0 -0
  34. package/src/agent-src/templates/scripts/telemetry/boundary.ts +164 -0
  35. package/src/agent-src/templates/scripts/telemetry/engagement.ts +479 -0
  36. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +394 -0
  37. package/src/agent-src/templates/scripts/telemetry/settings.ts +210 -0
  38. package/src/agent-src/templates/scripts/telemetry_record.ts +276 -0
  39. package/src/agent-src/templates/scripts/telemetry_report.ts +211 -0
  40. package/src/agent-src/templates/scripts/telemetry_status.ts +333 -0
  41. package/src/agent-src/templates/scripts/tier_usage_report.ts +619 -0
  42. package/src/agent-src/templates/scripts/work_engine/__main__.ts +14 -0
  43. package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +1261 -0
  44. package/src/agent-src/templates/scripts/work_engine/_lib/user_global_paths.ts +329 -0
  45. package/src/agent-src/templates/scripts/work_engine/cli.ts +206 -0
  46. package/src/agent-src/templates/scripts/work_engine/cli_args.ts +249 -0
  47. package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +225 -0
  48. package/src/agent-src/templates/scripts/work_engine/directives/backend/analyze.ts +125 -0
  49. package/src/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +189 -0
  50. package/src/agent-src/templates/scripts/work_engine/directives/backend/index.ts +94 -0
  51. package/src/agent-src/templates/scripts/work_engine/directives/backend/memory.ts +193 -0
  52. package/src/agent-src/templates/scripts/work_engine/directives/backend/plan.ts +267 -0
  53. package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +518 -0
  54. package/src/agent-src/templates/scripts/work_engine/directives/backend/report.ts +379 -0
  55. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +268 -0
  56. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +258 -0
  57. package/src/agent-src/templates/scripts/work_engine/directives/index.ts +32 -0
  58. package/src/agent-src/templates/scripts/work_engine/directives/mixed/contract.ts +243 -0
  59. package/src/agent-src/templates/scripts/work_engine/directives/mixed/index.ts +108 -0
  60. package/src/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +259 -0
  61. package/src/agent-src/templates/scripts/work_engine/directives/mixed/ui.ts +216 -0
  62. package/src/agent-src/templates/scripts/work_engine/directives/ui/_passthrough.ts +40 -0
  63. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +241 -0
  64. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +216 -0
  65. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +506 -0
  66. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +325 -0
  67. package/src/agent-src/templates/scripts/work_engine/directives/ui/index.ts +102 -0
  68. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +462 -0
  69. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +474 -0
  70. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +352 -0
  71. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.ts +33 -0
  72. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.ts +213 -0
  73. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/index.ts +111 -0
  74. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.ts +126 -0
  75. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/report.ts +112 -0
  76. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/test.ts +164 -0
  77. package/src/agent-src/templates/scripts/work_engine/dispatcher.ts +515 -0
  78. package/src/agent-src/templates/scripts/work_engine/emitters.ts +119 -0
  79. package/src/agent-src/templates/scripts/work_engine/errors.ts +24 -0
  80. package/src/agent-src/templates/scripts/work_engine/hook_bootstrap.ts +104 -0
  81. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.ts +189 -0
  82. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.ts +41 -0
  83. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.ts +89 -0
  84. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +193 -0
  85. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.ts +304 -0
  86. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.ts +110 -0
  87. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.ts +118 -0
  88. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/index.ts +17 -0
  89. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.ts +161 -0
  90. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.ts +45 -0
  91. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/trace.ts +134 -0
  92. package/src/agent-src/templates/scripts/work_engine/hooks/context.ts +94 -0
  93. package/src/agent-src/templates/scripts/work_engine/hooks/events.ts +58 -0
  94. package/src/agent-src/templates/scripts/work_engine/hooks/exceptions.ts +85 -0
  95. package/src/agent-src/templates/scripts/work_engine/hooks/index.ts +27 -0
  96. package/src/agent-src/templates/scripts/work_engine/hooks/registry.ts +66 -0
  97. package/src/agent-src/templates/scripts/work_engine/hooks/runner.ts +90 -0
  98. package/src/agent-src/templates/scripts/work_engine/hooks/settings.ts +260 -0
  99. package/src/agent-src/templates/scripts/work_engine/input_builders.ts +260 -0
  100. package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +466 -0
  101. package/src/agent-src/templates/scripts/work_engine/migration/v0_to_v1.ts +531 -0
  102. package/src/agent-src/templates/scripts/work_engine/orchestration.ts +366 -0
  103. package/src/agent-src/templates/scripts/work_engine/persona_policy.ts +97 -0
  104. package/src/agent-src/templates/scripts/work_engine/resolvers/diff.ts +135 -0
  105. package/src/agent-src/templates/scripts/work_engine/resolvers/file.ts +175 -0
  106. package/src/agent-src/templates/scripts/work_engine/resolvers/prompt.ts +115 -0
  107. package/src/agent-src/templates/scripts/work_engine/scoring/confidence.ts +415 -0
  108. package/src/agent-src/templates/scripts/work_engine/scoring/decision_engine.ts +466 -0
  109. package/src/agent-src/templates/scripts/work_engine/scoring/decision_trace.ts +298 -0
  110. package/src/agent-src/templates/scripts/work_engine/scoring/memory_visibility.ts +444 -0
  111. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +252 -0
  112. package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +745 -0
  113. package/src/agent-src/templates/scripts/work_engine/state.ts +1151 -0
  114. package/src/agent-src/templates/scripts/work_engine/state_io.ts +413 -0
  115. package/src/install/atomic.ts +111 -0
  116. package/src/install/conflict.ts +226 -0
  117. package/src/install/detect.ts +280 -0
  118. package/src/install/paths.ts +90 -0
  119. package/src/install/plan.ts +205 -0
  120. package/src/install/preflight.ts +210 -0
  121. package/src/install/selectedTools.ts +52 -0
  122. package/src/install/toolDetection.ts +120 -0
  123. package/src/install/txlog.ts +161 -0
  124. package/src/install/types.ts +175 -0
  125. package/src/install/wizard-plan.ts +216 -0
  126. 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
+ }