@event4u/agent-config 8.6.0 → 8.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/dist/agent-src/skills/skill-writing/SKILL.md +4 -0
  4. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  5. package/dist/discovery/deprecation-report.md +1 -1
  6. package/dist/discovery/discovery-manifest.json +3 -3
  7. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  8. package/dist/discovery/discovery-manifest.summary.md +1 -1
  9. package/dist/discovery/orphan-report.md +1 -1
  10. package/dist/discovery/packs.json +2 -2
  11. package/dist/discovery/trust-report.md +1 -1
  12. package/dist/discovery/workspaces.json +2 -2
  13. package/dist/install/emit_host_rules_cli.js +185 -0
  14. package/dist/install/emit_host_rules_cli.js.map +1 -0
  15. package/dist/install/install.mjs +6 -0
  16. package/dist/install/plan.js +4 -1
  17. package/dist/install/plan.js.map +1 -1
  18. package/dist/install/rule_scope.js +109 -0
  19. package/dist/install/rule_scope.js.map +1 -0
  20. package/dist/install/rule_scope_cli.js +77 -0
  21. package/dist/install/rule_scope_cli.js.map +1 -0
  22. package/dist/install/wizard-plan.js +14 -1
  23. package/dist/install/wizard-plan.js.map +1 -1
  24. package/dist/mcp/registry-manifest.json +1 -1
  25. package/dist/scripts/_lib/agent_settings.js +963 -0
  26. package/dist/scripts/_lib/agent_settings.js.map +1 -0
  27. package/dist/scripts/_lib/agent_src.js +607 -0
  28. package/dist/scripts/_lib/agent_src.js.map +1 -0
  29. package/dist/scripts/_lib/claude_settings_hooks.js +209 -0
  30. package/dist/scripts/_lib/claude_settings_hooks.js.map +1 -0
  31. package/dist/scripts/_lib/model_tier.js +64 -0
  32. package/dist/scripts/_lib/model_tier.js.map +1 -0
  33. package/dist/scripts/_lib/script_output.js +140 -0
  34. package/dist/scripts/_lib/script_output.js.map +1 -0
  35. package/dist/scripts/_lib/user_global_paths.js +297 -0
  36. package/dist/scripts/_lib/user_global_paths.js.map +1 -0
  37. package/dist/scripts/condense.js +2181 -0
  38. package/dist/scripts/condense.js.map +1 -0
  39. package/dist/server/routes/install.js +18 -2
  40. package/dist/server/routes/install.js.map +1 -1
  41. package/dist/server/schemas/settings.js +2 -0
  42. package/dist/server/schemas/settings.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/config/agent-settings.template.yml +4 -2
  45. package/src/install/emit_host_rules_cli.ts +190 -0
  46. package/src/install/plan.ts +11 -1
  47. package/src/install/rule_scope.ts +120 -0
  48. package/src/install/rule_scope_cli.ts +79 -0
  49. package/src/install/wizard-plan.ts +22 -1
  50. package/src/scripts/check_trigger_eval_presence.ts +171 -0
  51. package/src/scripts/install.sh +118 -4
  52. package/src/scripts/trigger_eval_grandfather.json +226 -0
  53. package/src/server/schemas/settings.ts +6 -0
@@ -0,0 +1,963 @@
1
+ /**
2
+ * Centralized loader for `.agent-settings.yml` with user-global fallback.
3
+ *
4
+ * Ported from the retired Python `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 { createRequire } from 'node:module';
41
+ import * as fs from 'node:fs';
42
+ import * as os from 'node:os';
43
+ import * as path from 'node:path';
44
+ import * as user_global_paths from './user_global_paths.js';
45
+ // ESM has no `require`; `createRequire(import.meta.url)` restores it so the
46
+ // lazy yaml load works under standalone tsx (the production dispatcher path),
47
+ // not just under a require-shimmed test harness. Mirrors config/presets.ts.
48
+ const _require = createRequire(import.meta.url);
49
+ class Logger {
50
+ name = 'scripts._lib.agent_settings';
51
+ records = [];
52
+ info(message, ...args) {
53
+ this.records.push({ level: 'INFO', message: _format(message, args) });
54
+ }
55
+ warning(message, ...args) {
56
+ this.records.push({ level: 'WARNING', message: _format(message, args) });
57
+ }
58
+ }
59
+ /** printf-style `%s` interpolation mirroring Python's logging args. */
60
+ function _format(template, args) {
61
+ let i = 0;
62
+ return template.replace(/%s/g, () => {
63
+ if (i < args.length) {
64
+ const value = args[i];
65
+ i += 1;
66
+ return _pystr(value);
67
+ }
68
+ return '%s';
69
+ });
70
+ }
71
+ /** Python-ish `str()` of a value for log messages (lists render `[...]`). */
72
+ function _pystr(value) {
73
+ if (Array.isArray(value)) {
74
+ return `[${value.map((v) => _pyrepr(v)).join(', ')}]`;
75
+ }
76
+ return String(value);
77
+ }
78
+ function _pyrepr(value) {
79
+ if (typeof value === 'string') {
80
+ return `'${value}'`;
81
+ }
82
+ return String(value);
83
+ }
84
+ export const logger = new Logger();
85
+ export const DEFAULT_PROJECT_FILE = '.agent-settings.yml';
86
+ /**
87
+ * Per-machine override file. Gitignored. A SINGLE project-level file
88
+ * living under `agents/settings/` — appended as the deepest cascade
89
+ * layer so a developer's local values override every committed
90
+ * `.agent-settings.yml` without ever being committed. Missing file is
91
+ * harmless (read as {}).
92
+ */
93
+ export const LOCAL_PROJECT_FILE = '.agent-settings.local.yml';
94
+ /** Project-relative directory the local override lives in. */
95
+ export const LOCAL_PROJECT_SUBDIR = ['agents', 'settings'];
96
+ /** Single local override: `<root>/agents/settings/.agent-settings.local.yml`. */
97
+ function _local_settings_path(project_root) {
98
+ return path.join(project_root, ...LOCAL_PROJECT_SUBDIR, LOCAL_PROJECT_FILE);
99
+ }
100
+ /**
101
+ * Canonical project settings file:
102
+ * `<root>/agents/settings/.agent-settings.yml`.
103
+ *
104
+ * The project settings file lives in the project's settings layer
105
+ * (`agents/settings/`), NOT at the repo root. The repo-root
106
+ * `.agent-settings.yml` is a legacy location read only as a back-compat
107
+ * fallback (see `project_settings_path`).
108
+ */
109
+ function _canonical_settings_path(project_root) {
110
+ return path.join(project_root, ...LOCAL_PROJECT_SUBDIR, DEFAULT_PROJECT_FILE);
111
+ }
112
+ /**
113
+ * Resolve the project settings file for **reading**.
114
+ *
115
+ * Returns the canonical `agents/settings/.agent-settings.yml` when it
116
+ * exists; otherwise the legacy repo-root `.agent-settings.yml` when that
117
+ * exists; otherwise the canonical path. Existence-checked, never writes.
118
+ */
119
+ export function project_settings_path(project_root) {
120
+ const canonical = _canonical_settings_path(project_root);
121
+ if (_exists(canonical)) {
122
+ return canonical;
123
+ }
124
+ const legacy = path.join(project_root, DEFAULT_PROJECT_FILE);
125
+ if (_exists(legacy)) {
126
+ return legacy;
127
+ }
128
+ return canonical;
129
+ }
130
+ /**
131
+ * Always the canonical **write** target:
132
+ * `agents/settings/.agent-settings.yml`.
133
+ *
134
+ * Writers (install, sync, migrate) target this unconditionally; the
135
+ * legacy root file is migrated into it, never written afresh.
136
+ */
137
+ export function canonical_settings_write_path(project_root) {
138
+ return _canonical_settings_path(project_root);
139
+ }
140
+ export const DEFAULT_TEAM_FILE = '.agent-project-settings.yml';
141
+ export const USER_GLOBAL_FILENAME = 'agent-settings.yml';
142
+ /**
143
+ * Canonical write target under the new `~/.event4u/agent-config/`
144
+ * namespace. Reads route through `_resolve_user_global_file` so pre-2.4
145
+ * installs are still picked up from `~/.config/agent-config/` until the
146
+ * migration shim copies them across.
147
+ *
148
+ * NOTE: This is a function, not a constant, because tests need it to
149
+ * follow `$HOME` / env overrides at call time (Python recomputes it via
150
+ * the loader, but exposes `DEFAULT_USER_GLOBAL_FILE` as a module
151
+ * attribute that the test for `defaults_resolve_when_neither_argument_given`
152
+ * monkeypatches). The differential pattern uses the function value;
153
+ * tests inject a path directly.
154
+ */
155
+ export function DEFAULT_USER_GLOBAL_FILE() {
156
+ return user_global_paths.write_target(USER_GLOBAL_FILENAME);
157
+ }
158
+ /** Return the active user-global settings path with legacy fallback. */
159
+ function _resolve_user_global_file() {
160
+ const found = user_global_paths.resolve_with_fallback(USER_GLOBAL_FILENAME);
161
+ if (found !== null) {
162
+ return found;
163
+ }
164
+ return DEFAULT_USER_GLOBAL_FILE();
165
+ }
166
+ /**
167
+ * Exact dotted paths allowed to cascade from user-global into the merged
168
+ * settings. Anything not listed here is silently ignored when present in
169
+ * the user-global file. Adding a key requires an ADR.
170
+ */
171
+ export const MERGEABLE_KEYS = [
172
+ 'name',
173
+ 'ide',
174
+ 'rule_loading_tier',
175
+ 'memory.cadence',
176
+ 'personal.bot_icon',
177
+ 'personal.autonomy',
178
+ 'telegraph.speak_scope',
179
+ // Knowledge-card global cross-project sharing is a USER-GLOBAL setting
180
+ // (ADR-100 / road-to-structure-grounding-v2). Whitelisted so the
181
+ // ~/.event4u/agent-config/agent-settings.yml values are honoured.
182
+ 'knowledge.global_sharing.enabled',
183
+ 'knowledge.global_sharing.allowed_tiers',
184
+ 'knowledge.global_sharing.redaction.enabled',
185
+ 'knowledge.global_sharing.redaction.halt_on_trigger',
186
+ 'knowledge.global_sharing.auto_promote_threshold',
187
+ 'knowledge.global_sharing.freshness.hypothesis_after_days',
188
+ 'knowledge.global_sharing.freshness.stale_after_days',
189
+ ];
190
+ const _DEFAULTS = {};
191
+ /**
192
+ * Defaults applied by `get_modules_config` when a key is absent from both
193
+ * the team file and the developer cascade. Lists are returned as fresh
194
+ * copies — callers may mutate the result safely.
195
+ */
196
+ export const MODULES_DEFAULTS = {
197
+ enabled: false,
198
+ root_paths: [],
199
+ namespace_template: '',
200
+ agent_folder: 'agents',
201
+ skip_dirs: ['.module-template', '.example'],
202
+ detection_acknowledged: false,
203
+ };
204
+ /** Anchor identifier returned by `find_project_root_with_anchor`. */
205
+ export const ANCHOR_AGENT_SETTINGS = 'agent-settings';
206
+ export const ANCHOR_AGENTS_DIR = 'agents-dir';
207
+ export const ANCHOR_GIT = 'git';
208
+ /**
209
+ * Marker subpaths that qualify a bare `agents/` directory as a project
210
+ * anchor (D1). Any one is sufficient.
211
+ */
212
+ const _AGENTS_DIR_MARKERS = [
213
+ 'roadmaps',
214
+ 'settings/.ai-council.yml',
215
+ 'roadmaps-progress.md',
216
+ '.event4u-bridge.yml',
217
+ ];
218
+ /**
219
+ * Kill-switch (D5). When set to `"1"`, `find_project_root` /
220
+ * `find_project_root_with_anchor` revert to the pre-Step-7 `.git`-only
221
+ * walk for one minor-version soak.
222
+ */
223
+ const _LEGACY_ANCHOR_ENV = 'AGENT_CONFIG_LEGACY_ANCHOR';
224
+ /** `Path.exists()` — true for files, dirs, symlinks (broken or not). */
225
+ function _exists(p) {
226
+ try {
227
+ fs.lstatSync(p);
228
+ // lstat does not follow symlinks; Python's exists() does. Re-check
229
+ // through stat so a valid symlink target counts as existing.
230
+ fs.statSync(p);
231
+ return true;
232
+ }
233
+ catch {
234
+ // lstat may succeed for a broken symlink while stat throws; Python's
235
+ // Path.exists() returns False for broken symlinks, matching here.
236
+ return false;
237
+ }
238
+ }
239
+ /** `Path.is_dir()`. */
240
+ function _is_dir(p) {
241
+ try {
242
+ return fs.statSync(p).isDirectory();
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ }
248
+ /** `Path.is_file()`. */
249
+ function _is_file(p) {
250
+ try {
251
+ return fs.statSync(p).isFile();
252
+ }
253
+ catch {
254
+ return false;
255
+ }
256
+ }
257
+ /**
258
+ * Mirror of `Path.resolve()`: absolutize + normalize + resolve symlinks.
259
+ * Python's `resolve()` is `strict=False` by default — it does NOT raise
260
+ * on a missing path, it normalizes what it can. `fs.realpathSync` raises
261
+ * on a missing path, so fall back to a normalized absolute path.
262
+ */
263
+ function _resolve(p) {
264
+ const absolute = path.resolve(p);
265
+ try {
266
+ return fs.realpathSync(absolute);
267
+ }
268
+ catch {
269
+ return absolute;
270
+ }
271
+ }
272
+ /** Ancestor chain `[start, ...parents]` mirroring `[current, *current.parents]`. */
273
+ function _ancestor_chain(start) {
274
+ const chain = [];
275
+ let cursor = start;
276
+ for (;;) {
277
+ chain.push(cursor);
278
+ const parent = path.dirname(cursor);
279
+ if (parent === cursor) {
280
+ break;
281
+ }
282
+ cursor = parent;
283
+ }
284
+ return chain;
285
+ }
286
+ /**
287
+ * Return the boundary-anchor name at `path` or `null`.
288
+ *
289
+ * Boundary anchors stop the walk and define the project root:
290
+ *
291
+ * - `agents/` containing a D1 marker → `"agents-dir"`
292
+ * - `.git` (file or directory) → `"git"`
293
+ *
294
+ * `.agent-settings.yml` is a layer marker, not a boundary anchor — it
295
+ * only anchors when no boundary is found in any ancestor.
296
+ */
297
+ function _boundary_anchor_at(dir) {
298
+ const agents_dir = path.join(dir, 'agents');
299
+ if (_is_dir(agents_dir)) {
300
+ for (const marker of _AGENTS_DIR_MARKERS) {
301
+ if (_exists(path.join(agents_dir, marker))) {
302
+ return ANCHOR_AGENTS_DIR;
303
+ }
304
+ }
305
+ }
306
+ if (_exists(path.join(dir, '.git'))) {
307
+ return ANCHOR_GIT;
308
+ }
309
+ return null;
310
+ }
311
+ /**
312
+ * Walk up from `start` and return `[root, anchor_name]` or `null`.
313
+ *
314
+ * Two-tier lookup: boundary pass (first ancestor with `.git` or an
315
+ * `agents/`+marker anchor, `agents/` wins ties at the same level), then a
316
+ * layer fallback returning the **outermost** ancestor carrying
317
+ * `.agent-settings.yml`. When `AGENT_CONFIG_LEGACY_ANCHOR=1`, only `.git`
318
+ * is considered. Pure read-only; never raises on missing paths.
319
+ */
320
+ export function find_project_root_with_anchor(start) {
321
+ const current = _exists(start) ? _resolve(start) : start;
322
+ const legacy = process.env[_LEGACY_ANCHOR_ENV] === '1';
323
+ const chain = _ancestor_chain(current);
324
+ if (legacy) {
325
+ for (const candidate of chain) {
326
+ if (_exists(path.join(candidate, '.git'))) {
327
+ return [candidate, ANCHOR_GIT];
328
+ }
329
+ }
330
+ return null;
331
+ }
332
+ // Boundary pass.
333
+ for (const candidate of chain) {
334
+ const anchor = _boundary_anchor_at(candidate);
335
+ if (anchor !== null) {
336
+ return [candidate, anchor];
337
+ }
338
+ }
339
+ // Layer fallback — outermost .agent-settings.yml wins so the cascade
340
+ // can layer deeper files below it.
341
+ let outermost = null;
342
+ for (const candidate of chain) {
343
+ if (_exists(path.join(candidate, DEFAULT_PROJECT_FILE))) {
344
+ outermost = candidate;
345
+ }
346
+ }
347
+ if (outermost !== null) {
348
+ return [outermost, ANCHOR_AGENT_SETTINGS];
349
+ }
350
+ return null;
351
+ }
352
+ /**
353
+ * Walk up from `start` and return the project root or `null`.
354
+ *
355
+ * Thin wrapper over `find_project_root_with_anchor` that drops the
356
+ * anchor-name component.
357
+ */
358
+ export function find_project_root(start) {
359
+ const result = find_project_root_with_anchor(start);
360
+ return result !== null ? result[0] : null;
361
+ }
362
+ /**
363
+ * Walk up from `start` and return `[root, anchor, trace]`.
364
+ *
365
+ * Diagnostic variant of `find_project_root_with_anchor`. Returns the same
366
+ * `[root, anchor]` pair (or `[null, null]` when no anchor is found) plus
367
+ * an ordered list of trace records describing every ancestor probed.
368
+ * Pure read-only.
369
+ */
370
+ export function find_project_root_with_trace(start) {
371
+ const trace = [];
372
+ const current = _exists(start) ? _resolve(start) : start;
373
+ const legacy = process.env[_LEGACY_ANCHOR_ENV] === '1';
374
+ const chain = _ancestor_chain(current);
375
+ if (legacy) {
376
+ for (const candidate of chain) {
377
+ const hit = _exists(path.join(candidate, '.git'));
378
+ trace.push({
379
+ ancestor: candidate,
380
+ pass: 'boundary',
381
+ hit: hit ? ANCHOR_GIT : null,
382
+ reason: hit ? 'legacy: .git found' : 'legacy: no .git',
383
+ });
384
+ if (hit) {
385
+ return [candidate, ANCHOR_GIT, trace];
386
+ }
387
+ }
388
+ return [null, null, trace];
389
+ }
390
+ // Boundary pass — same probes as find_project_root_with_anchor.
391
+ for (const candidate of chain) {
392
+ const agents_dir = path.join(candidate, 'agents');
393
+ if (_is_dir(agents_dir)) {
394
+ for (const marker of _AGENTS_DIR_MARKERS) {
395
+ if (_exists(path.join(agents_dir, marker))) {
396
+ trace.push({
397
+ ancestor: candidate,
398
+ pass: 'boundary',
399
+ hit: ANCHOR_AGENTS_DIR,
400
+ reason: `agents/ has ${marker}`,
401
+ });
402
+ return [candidate, ANCHOR_AGENTS_DIR, trace];
403
+ }
404
+ }
405
+ }
406
+ if (_exists(path.join(candidate, '.git'))) {
407
+ trace.push({
408
+ ancestor: candidate,
409
+ pass: 'boundary',
410
+ hit: ANCHOR_GIT,
411
+ reason: '.git present',
412
+ });
413
+ return [candidate, ANCHOR_GIT, trace];
414
+ }
415
+ trace.push({
416
+ ancestor: candidate,
417
+ pass: 'boundary',
418
+ hit: null,
419
+ reason: 'no agents/ marker, no .git',
420
+ });
421
+ }
422
+ // Layer fallback — outermost .agent-settings.yml wins.
423
+ let outermost = null;
424
+ for (const candidate of chain) {
425
+ const present = _exists(path.join(candidate, DEFAULT_PROJECT_FILE));
426
+ trace.push({
427
+ ancestor: candidate,
428
+ pass: 'layer',
429
+ hit: present ? ANCHOR_AGENT_SETTINGS : null,
430
+ reason: present
431
+ ? `${DEFAULT_PROJECT_FILE} present`
432
+ : `no ${DEFAULT_PROJECT_FILE}`,
433
+ });
434
+ if (present) {
435
+ outermost = candidate;
436
+ }
437
+ }
438
+ if (outermost !== null) {
439
+ return [outermost, ANCHOR_AGENT_SETTINGS, trace];
440
+ }
441
+ return [null, null, trace];
442
+ }
443
+ /**
444
+ * Origin tag returned by `resolve_project_root` alongside the anchor
445
+ * names defined above.
446
+ */
447
+ export const ORIGIN_ROOT_FLAG = 'root-flag'; // --root global flag (Step 8 A3)
448
+ export const ORIGIN_EXPLICIT = 'explicit'; // --project arg on a subcommand
449
+ export const ORIGIN_ENV = 'env'; // AGENT_CONFIG_PROJECT_ROOT (wrapper-pinned)
450
+ export const ORIGIN_CWD_FALLBACK = 'cwd-fallback'; // no anchor found
451
+ export const PROJECT_ROOT_ENV = 'AGENT_CONFIG_PROJECT_ROOT';
452
+ export const ROOT_OVERRIDE_ENV = 'AGENT_CONFIG_ROOT_OVERRIDE';
453
+ /**
454
+ * Raised when an explicit project-root override points to an invalid path.
455
+ *
456
+ * `--root <path>` and `AGENT_CONFIG_PROJECT_ROOT` must fail loudly when
457
+ * the target does not exist or is not a directory. Callers translate this
458
+ * into exit code 2 (no silent CWD fallback).
459
+ */
460
+ export class ProjectRootError extends Error {
461
+ constructor(message) {
462
+ super(message);
463
+ this.name = 'ProjectRootError';
464
+ }
465
+ }
466
+ /**
467
+ * Resolve `path`; throw `ProjectRootError` when invalid.
468
+ *
469
+ * `origin_label` is one of `--root`, `AGENT_CONFIG_PROJECT_ROOT`, or
470
+ * `--project`; surfaced verbatim in the error message.
471
+ */
472
+ function _validate_root_path(p, origin_label) {
473
+ const resolved = _expanduser(p);
474
+ if (!_exists(resolved)) {
475
+ throw new ProjectRootError(`${origin_label} points to a path that does not exist: ${resolved}`);
476
+ }
477
+ if (!_is_dir(resolved)) {
478
+ throw new ProjectRootError(`${origin_label} points to a non-directory: ${resolved}`);
479
+ }
480
+ return _resolve(resolved);
481
+ }
482
+ /** `Path.expanduser()` — expand a leading `~`. */
483
+ function _expanduser(p) {
484
+ if (p === '~') {
485
+ return os.homedir();
486
+ }
487
+ if (p.startsWith('~/') || (process.platform === 'win32' && p.startsWith('~\\'))) {
488
+ return path.join(os.homedir(), p.slice(2));
489
+ }
490
+ return p;
491
+ }
492
+ /**
493
+ * Return `[root, origin]` for any `cmd_*` entry point.
494
+ *
495
+ * Resolution order:
496
+ *
497
+ * 1. `AGENT_CONFIG_PROJECT_ROOT` env var with `AGENT_CONFIG_ROOT_OVERRIDE=1`
498
+ * set by the master CLI's `--root` flag → `ORIGIN_ROOT_FLAG`. Fail-loud.
499
+ * 2. Explicit `arg` (`--project`/`--target`) → `ORIGIN_EXPLICIT`. Fail-loud.
500
+ * 3. `AGENT_CONFIG_PROJECT_ROOT` env (wrapper) → `ORIGIN_ENV`. Fail-loud.
501
+ * 4. Anchor walk from `cwd` → anchor name.
502
+ * 5. Fall back to `cwd` itself → `ORIGIN_CWD_FALLBACK`.
503
+ *
504
+ * Throws `ProjectRootError` when any explicit override points to a
505
+ * missing path or non-directory.
506
+ */
507
+ export function resolve_project_root(arg, options = {}) {
508
+ const cwd = options.cwd ?? null;
509
+ if (process.env[ROOT_OVERRIDE_ENV] === '1') {
510
+ const env_value = process.env[PROJECT_ROOT_ENV];
511
+ if (env_value) {
512
+ return [_validate_root_path(env_value, '--root'), ORIGIN_ROOT_FLAG];
513
+ }
514
+ }
515
+ if (arg !== null && String(arg) !== '') {
516
+ return [_validate_root_path(arg, '--project'), ORIGIN_EXPLICIT];
517
+ }
518
+ const env_value = process.env[PROJECT_ROOT_ENV];
519
+ if (env_value) {
520
+ return [_validate_root_path(env_value, PROJECT_ROOT_ENV), ORIGIN_ENV];
521
+ }
522
+ const start = _resolve(cwd ?? process.cwd());
523
+ const walked = find_project_root_with_anchor(start);
524
+ if (walked !== null) {
525
+ return walked;
526
+ }
527
+ return [start, ORIGIN_CWD_FALLBACK];
528
+ }
529
+ /**
530
+ * Return the ordered cascade of in-project settings files (shallow → deep).
531
+ *
532
+ * When `cwd` is provided and `find_project_root(cwd)` succeeds, the list
533
+ * contains every `<dir>/.agent-settings.yml` from the repo root down to
534
+ * `cwd`, shallowest first, then the canonical project file under
535
+ * `agents/settings/`, then the per-machine local override. When `cwd` is
536
+ * `null` or no anchor is reached, falls back to the legacy three-path set.
537
+ */
538
+ function _resolve_cascade_paths(cwd, project_path) {
539
+ if (cwd === null) {
540
+ const legacy = project_path ? project_path : DEFAULT_PROJECT_FILE;
541
+ const parent = path.dirname(legacy);
542
+ return [legacy, _canonical_settings_path(parent), _local_settings_path(parent)];
543
+ }
544
+ const root = find_project_root(cwd);
545
+ if (root === null) {
546
+ const legacy = project_path ? project_path : DEFAULT_PROJECT_FILE;
547
+ const parent = path.dirname(legacy);
548
+ return [legacy, _canonical_settings_path(parent), _local_settings_path(parent)];
549
+ }
550
+ const cwd_resolved = _resolve(cwd);
551
+ // Build the chain root → … → cwd (shallowest first, deepest last).
552
+ const chain = [];
553
+ let cursor = cwd_resolved;
554
+ for (;;) {
555
+ chain.push(cursor);
556
+ if (cursor === root) {
557
+ break;
558
+ }
559
+ const parent = path.dirname(cursor);
560
+ if (parent === cursor) {
561
+ break;
562
+ }
563
+ cursor = parent;
564
+ }
565
+ chain.reverse();
566
+ return [
567
+ ...chain.map((d) => path.join(d, DEFAULT_PROJECT_FILE)),
568
+ _canonical_settings_path(root),
569
+ _local_settings_path(root),
570
+ ];
571
+ }
572
+ /**
573
+ * Return the merged settings dict.
574
+ *
575
+ * `project_path` defaults to `./.agent-settings.yml` (CWD-relative).
576
+ * `user_global_path` defaults to the resolved user-global file. Pass
577
+ * `verbose=true` to log keys present in user-global that are not on the
578
+ * whitelist. `cwd` enables the in-project cascade. When `cwd` is `null`
579
+ * (default), the loader falls back to the single `project_path` behavior.
580
+ */
581
+ export function load_agent_settings(options = {}) {
582
+ const project_path = options.project_path ?? null;
583
+ const user_global_path = options.user_global_path ?? null;
584
+ const verbose = options.verbose ?? false;
585
+ const cwd = options.cwd ?? null;
586
+ const user_global_raw = _read_yaml(user_global_path ? user_global_path : _resolve_user_global_file()) ?? {};
587
+ const [user_global_filtered, ignored] = _filter_whitelist(user_global_raw, MERGEABLE_KEYS);
588
+ if (verbose && ignored.length > 0) {
589
+ logger.info('agent_settings: ignored non-whitelisted user-global keys: %s', [...ignored].sort());
590
+ }
591
+ const cascade = _resolve_cascade_paths(cwd, project_path);
592
+ const merged = _deep_copy_defaults(_DEFAULTS);
593
+ _deep_merge(merged, user_global_filtered);
594
+ for (const p of cascade) {
595
+ const layer = _read_yaml(p) ?? {};
596
+ if (Object.keys(layer).length > 0) {
597
+ _deep_merge(merged, layer);
598
+ }
599
+ }
600
+ return merged;
601
+ }
602
+ /**
603
+ * Return the merged `modules:` configuration with defaults applied.
604
+ *
605
+ * Three-tier precedence (deepest wins): `MODULES_DEFAULTS` < team file
606
+ * (`<project_root>/.agent-project-settings.yml`) < developer cascade
607
+ * (every `.agent-settings.yml`). The team layer may pin keys via a
608
+ * top-level `locked_keys` list of dotted paths; locked keys discard any
609
+ * matching developer override and emit an INFO record. Pure, read-only.
610
+ */
611
+ export function get_modules_config(options = {}) {
612
+ const project_root = options.project_root ?? null;
613
+ const team_path = options.team_path ?? null;
614
+ const project_path = options.project_path ?? null;
615
+ const cwd = options.cwd ?? null;
616
+ const cwd_resolved = cwd !== null ? cwd : process.cwd();
617
+ let team_file;
618
+ if (team_path !== null) {
619
+ team_file = team_path;
620
+ }
621
+ else {
622
+ let root;
623
+ if (project_root !== null) {
624
+ root = project_root;
625
+ }
626
+ else {
627
+ root = find_project_root(cwd_resolved) ?? cwd_resolved;
628
+ }
629
+ team_file = path.join(root, DEFAULT_TEAM_FILE);
630
+ }
631
+ const team_raw = _read_yaml(team_file) ?? {};
632
+ const team_modules_candidate = team_raw['modules'];
633
+ const team_modules = _is_plain_dict(team_modules_candidate)
634
+ ? team_modules_candidate
635
+ : {};
636
+ const locked_keys_raw = team_raw['locked_keys'];
637
+ const locked_keys = Array.isArray(locked_keys_raw)
638
+ ? locked_keys_raw.filter((k) => typeof k === 'string')
639
+ : [];
640
+ const dev_merged = load_agent_settings({
641
+ ...(project_path !== null ? { project_path } : {}),
642
+ ...(cwd !== null ? { cwd } : {}),
643
+ });
644
+ const dev_modules_candidate = dev_merged['modules'];
645
+ const dev_modules = _is_plain_dict(dev_modules_candidate)
646
+ ? dev_modules_candidate
647
+ : {};
648
+ const merged = _deepcopy(MODULES_DEFAULTS);
649
+ if (Object.keys(team_modules).length > 0) {
650
+ _deep_merge(merged, team_modules);
651
+ }
652
+ if (Object.keys(dev_modules).length > 0) {
653
+ for (const [key, value] of Object.entries(dev_modules)) {
654
+ const dotted = `modules.${key}`;
655
+ if (locked_keys.includes(dotted) && key in team_modules) {
656
+ logger.info('agent_settings: ignoring developer override of locked key %s', dotted);
657
+ continue;
658
+ }
659
+ if (_is_plain_dict(value) && _is_plain_dict(merged[key])) {
660
+ _deep_merge(merged[key], value);
661
+ }
662
+ else {
663
+ merged[key] = value;
664
+ }
665
+ }
666
+ }
667
+ return merged;
668
+ }
669
+ /**
670
+ * Enumerate every module under `modules.root_paths`.
671
+ *
672
+ * For each path in `modules.root_paths` (resolved relative to
673
+ * `project_root`), lists immediate subdirectories that survive the
674
+ * `modules.skip_dirs` filter and reports whether each module ships a
675
+ * per-module agent folder (`modules.agent_folder`, default `agents`).
676
+ *
677
+ * Returns a list sorted by `(root_path, name)`. `modules.enabled` is NOT
678
+ * consulted. Missing roots are skipped silently (logged at INFO). Hidden
679
+ * directories and `skip_dirs` entries are filtered out. Pure, read-only.
680
+ */
681
+ export function enumerate_modules(options = {}) {
682
+ const project_root = options.project_root ?? null;
683
+ const cwd = options.cwd ?? null;
684
+ let modules_config = options.modules_config ?? null;
685
+ const cwd_resolved = cwd !== null ? cwd : process.cwd();
686
+ let root;
687
+ if (project_root !== null) {
688
+ root = project_root;
689
+ }
690
+ else {
691
+ root = find_project_root(cwd_resolved) ?? cwd_resolved;
692
+ }
693
+ root = _resolve(root);
694
+ if (modules_config === null) {
695
+ modules_config = get_modules_config({ project_root: root, ...(cwd !== null ? { cwd } : {}) });
696
+ }
697
+ const root_paths_raw = modules_config['root_paths'] || [];
698
+ const skip_dirs_raw = modules_config['skip_dirs'] || MODULES_DEFAULTS['skip_dirs'];
699
+ const agent_folder = String(modules_config['agent_folder'] || MODULES_DEFAULTS['agent_folder']);
700
+ const skip_dirs = new Set((Array.isArray(skip_dirs_raw) ? skip_dirs_raw : [])
701
+ .filter((s) => typeof s === 'string'));
702
+ const discovered = [];
703
+ for (const raw of Array.isArray(root_paths_raw) ? root_paths_raw : []) {
704
+ if (typeof raw !== 'string' || raw.trim() === '') {
705
+ continue;
706
+ }
707
+ const root_rel = _strip_slashes(raw.trim());
708
+ const root_abs = _resolve(path.join(root, root_rel));
709
+ try {
710
+ // Latent-bug candidate (replicated verbatim, see divergence
711
+ // notes): boundary check uses a raw string prefix match
712
+ // (`startswith`) rather than a path-component boundary check,
713
+ // so a sibling like `<root>-evil` would pass. Python does the
714
+ // same; flagged, not fixed.
715
+ if (!_is_dir(root_abs) || !root_abs.startsWith(root)) {
716
+ logger.info('enumerate_modules: skipping missing/out-of-tree root %s', root_rel);
717
+ continue;
718
+ }
719
+ }
720
+ catch {
721
+ logger.info('enumerate_modules: unreadable root %s', root_rel);
722
+ continue;
723
+ }
724
+ let children;
725
+ try {
726
+ children = fs
727
+ .readdirSync(root_abs)
728
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
729
+ }
730
+ catch {
731
+ logger.info('enumerate_modules: cannot list %s', root_rel);
732
+ continue;
733
+ }
734
+ for (const name of children) {
735
+ if (name.startsWith('.') || skip_dirs.has(name)) {
736
+ continue;
737
+ }
738
+ const child = path.join(root_abs, name);
739
+ if (!_is_dir(child)) {
740
+ continue;
741
+ }
742
+ const agent_dir = path.join(child, agent_folder);
743
+ const has_agent = _is_dir(agent_dir);
744
+ let module_rel;
745
+ try {
746
+ module_rel = _relative_to(_resolve(child), root);
747
+ }
748
+ catch {
749
+ continue;
750
+ }
751
+ const entry = {
752
+ name,
753
+ root_path: root_rel,
754
+ module_path: module_rel,
755
+ has_agent_folder: has_agent,
756
+ agent_folder_path: has_agent ? _posix_join(module_rel, agent_folder) : null,
757
+ };
758
+ discovered.push(entry);
759
+ }
760
+ }
761
+ discovered.sort((a, b) => {
762
+ if (a.root_path !== b.root_path) {
763
+ return a.root_path < b.root_path ? -1 : 1;
764
+ }
765
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
766
+ });
767
+ return discovered;
768
+ }
769
+ /**
770
+ * Yield `[dotted_key, value, source_path]` for every leaf setting.
771
+ *
772
+ * Walks the same cascade as `load_agent_settings` and emits one tuple per
773
+ * leaf observed at each layer (user-global → repo-root → intermediates →
774
+ * CWD). Never blocks, never raises on missing files.
775
+ */
776
+ export function* iter_setting_overrides(options = {}) {
777
+ const project_path = options.project_path ?? null;
778
+ const user_global_path = options.user_global_path ?? null;
779
+ const cwd = options.cwd ?? null;
780
+ const user_global_path_resolved = user_global_path
781
+ ? user_global_path
782
+ : _resolve_user_global_file();
783
+ const user_global_raw = _read_yaml(user_global_path_resolved) ?? {};
784
+ const [user_global_filtered] = _filter_whitelist(user_global_raw, MERGEABLE_KEYS);
785
+ if (Object.keys(user_global_filtered).length > 0) {
786
+ for (const key of _leaf_paths(user_global_filtered)) {
787
+ yield [key, _get_dotted(user_global_filtered, key), user_global_path_resolved];
788
+ }
789
+ }
790
+ for (const p of _resolve_cascade_paths(cwd, project_path)) {
791
+ const layer = _read_yaml(p);
792
+ if (!layer || Object.keys(layer).length === 0) {
793
+ continue;
794
+ }
795
+ for (const key of _leaf_paths(layer)) {
796
+ yield [key, _get_dotted(layer, key), p];
797
+ }
798
+ }
799
+ }
800
+ /** Best-effort YAML read; never raises. Returns `null` on any failure. */
801
+ function _read_yaml(p) {
802
+ if (!_is_file(p)) {
803
+ return null;
804
+ }
805
+ let YAML;
806
+ try {
807
+ // Lazy require to mirror Python's lazy `import yaml` — a missing
808
+ // package degrades to `null` (defaults) instead of crashing.
809
+ YAML = _require('yaml');
810
+ }
811
+ catch {
812
+ return null;
813
+ }
814
+ let data;
815
+ try {
816
+ const text = fs.readFileSync(p, 'utf-8');
817
+ // version '1.1' matches PyYAML's safe_load (yes/no/on/off bools,
818
+ // sexagesimal ints) so the differential corpus stays byte-faithful.
819
+ data = YAML.parse(text, { version: '1.1' });
820
+ if (data === null || data === undefined) {
821
+ data = {};
822
+ }
823
+ }
824
+ catch (err) {
825
+ // OSError or YAML parse failure → defaults, logged at WARNING.
826
+ if (_is_yaml_error(err) || _is_os_error(err)) {
827
+ logger.warning('agent_settings: unreadable or malformed YAML at %s', p);
828
+ return null;
829
+ }
830
+ throw err;
831
+ }
832
+ return _is_plain_dict(data) ? data : null;
833
+ }
834
+ function _is_yaml_error(err) {
835
+ // The `yaml` package throws YAMLParseError / YAMLWarning subclasses
836
+ // whose names start with "YAML"; also catch generic parse Errors.
837
+ if (err instanceof Error) {
838
+ return err.name.startsWith('YAML') || err.name === 'Error' || err.name === 'TypeError';
839
+ }
840
+ return false;
841
+ }
842
+ function _is_os_error(err) {
843
+ return (typeof err === 'object' &&
844
+ err !== null &&
845
+ 'code' in err &&
846
+ typeof err.code === 'string');
847
+ }
848
+ /** Return `[filtered_dict, ignored_paths]` from a user-global blob. */
849
+ function _filter_whitelist(raw, allowed) {
850
+ const filtered = {};
851
+ for (const dotted of allowed) {
852
+ const value = _get_dotted(raw, dotted);
853
+ if (value !== null && value !== undefined) {
854
+ _set_dotted(filtered, dotted, value);
855
+ }
856
+ }
857
+ const ignored = _leaf_paths(raw).filter((p) => !allowed.includes(p));
858
+ return [filtered, ignored];
859
+ }
860
+ function _get_dotted(data, dotted) {
861
+ let cursor = data;
862
+ for (const part of dotted.split('.')) {
863
+ if (!_is_plain_dict(cursor) || !(part in cursor)) {
864
+ return null;
865
+ }
866
+ cursor = cursor[part];
867
+ }
868
+ return cursor;
869
+ }
870
+ function _set_dotted(target, dotted, value) {
871
+ const parts = dotted.split('.');
872
+ let cursor = target;
873
+ for (const part of parts.slice(0, -1)) {
874
+ let nxt = cursor[part];
875
+ if (nxt === undefined) {
876
+ nxt = {};
877
+ cursor[part] = nxt;
878
+ }
879
+ if (!_is_plain_dict(nxt)) {
880
+ nxt = {};
881
+ cursor[part] = nxt;
882
+ }
883
+ cursor = nxt;
884
+ }
885
+ cursor[parts[parts.length - 1]] = value;
886
+ }
887
+ function _leaf_paths(data, prefix = '') {
888
+ const paths = [];
889
+ for (const [key, value] of Object.entries(data)) {
890
+ const p = prefix ? `${prefix}.${key}` : key;
891
+ if (_is_plain_dict(value) && Object.keys(value).length > 0) {
892
+ paths.push(..._leaf_paths(value, p));
893
+ }
894
+ else {
895
+ paths.push(p);
896
+ }
897
+ }
898
+ return paths;
899
+ }
900
+ /** Merge `src` into `dst` in-place; nested dicts merged recursively. */
901
+ function _deep_merge(dst, src) {
902
+ for (const [key, value] of Object.entries(src)) {
903
+ if (_is_plain_dict(value) && _is_plain_dict(dst[key])) {
904
+ _deep_merge(dst[key], value);
905
+ }
906
+ else {
907
+ dst[key] = value;
908
+ }
909
+ }
910
+ }
911
+ function _deep_copy_defaults(src) {
912
+ const out = {};
913
+ _deep_merge(out, src);
914
+ return out;
915
+ }
916
+ /**
917
+ * `copy.deepcopy` for the JSON-shaped values settings hold (dicts, lists,
918
+ * scalars). Arrays and plain objects are cloned recursively; scalars pass
919
+ * through.
920
+ */
921
+ function _deepcopy(value) {
922
+ if (Array.isArray(value)) {
923
+ return value.map((v) => _deepcopy(v));
924
+ }
925
+ if (_is_plain_dict(value)) {
926
+ const out = {};
927
+ for (const [k, v] of Object.entries(value)) {
928
+ out[k] = _deepcopy(v);
929
+ }
930
+ return out;
931
+ }
932
+ return value;
933
+ }
934
+ /**
935
+ * `isinstance(x, dict)` — a plain object, not an array / null / class
936
+ * instance. Mirrors Python's `isinstance(..., dict)` for settings data,
937
+ * which is always built from `yaml.safe_load` (plain dicts).
938
+ */
939
+ function _is_plain_dict(value) {
940
+ return (typeof value === 'object' &&
941
+ value !== null &&
942
+ !Array.isArray(value));
943
+ }
944
+ /** Strip leading/trailing `/` like Python's `str.strip("/")`. */
945
+ function _strip_slashes(s) {
946
+ return s.replace(/^\/+/, '').replace(/\/+$/, '');
947
+ }
948
+ /**
949
+ * `Path.relative_to(root)` — throws when `child` is not under `root`
950
+ * (mirrors Python's `ValueError`). Returns the OS-native relative path.
951
+ */
952
+ function _relative_to(child, root) {
953
+ const rel = path.relative(root, child);
954
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
955
+ throw new Error(`${child} is not in the subpath of ${root}`);
956
+ }
957
+ return rel;
958
+ }
959
+ /** Join two repo-relative path fragments with forward slashes (posix). */
960
+ function _posix_join(a, b) {
961
+ return `${a.split(path.sep).join('/')}/${b}`;
962
+ }
963
+ //# sourceMappingURL=agent_settings.js.map