@event4u/agent-config 2.19.0 → 2.20.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 (92) hide show
  1. package/.agent-src/commands/agent-status.md +29 -0
  2. package/.agent-src/commands/onboard.md +221 -81
  3. package/.agent-src/packs/README.md +49 -0
  4. package/.agent-src/packs/agency-delivery.yml +63 -0
  5. package/.agent-src/packs/content-engine.yml +53 -0
  6. package/.agent-src/packs/founder-mvp.yml +51 -0
  7. package/.agent-src/presets/README.md +26 -0
  8. package/.agent-src/presets/balanced.yml +34 -0
  9. package/.agent-src/presets/fast.yml +31 -0
  10. package/.agent-src/presets/strict.yml +38 -0
  11. package/.agent-src/profiles/README.md +29 -0
  12. package/.agent-src/profiles/agency.yml +27 -0
  13. package/.agent-src/profiles/content_creator.yml +25 -0
  14. package/.agent-src/profiles/developer.yml +26 -0
  15. package/.agent-src/profiles/finance.yml +24 -0
  16. package/.agent-src/profiles/founder.yml +25 -0
  17. package/.agent-src/profiles/ops.yml +25 -0
  18. package/.agent-src/rules/no-cheap-questions.md +25 -17
  19. package/.agent-src/skills/adr-create/SKILL.md +78 -68
  20. package/.agent-src/skills/subagent-orchestration/SKILL.md +33 -0
  21. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  22. package/.agent-src/templates/skill-archive-note.md +101 -0
  23. package/.claude-plugin/marketplace.json +1 -1
  24. package/CHANGELOG.md +52 -30
  25. package/README.md +68 -72
  26. package/config/agent-settings.template.yml +22 -0
  27. package/docs/adrs/caveman/0001-default-off-until-bench.md +93 -0
  28. package/docs/adrs/caveman/README.md +9 -0
  29. package/docs/adrs/cost/0001-hard-stop-hook.md +114 -0
  30. package/docs/adrs/cost/README.md +9 -0
  31. package/docs/adrs/memory/0001-consumer-side-snapshot.md +111 -0
  32. package/docs/adrs/memory/README.md +9 -0
  33. package/docs/adrs/router/0001-three-tier-routing.md +119 -0
  34. package/docs/adrs/router/README.md +9 -0
  35. package/docs/adrs/schema/0001-json-schema-frontmatter.md +102 -0
  36. package/docs/adrs/schema/README.md +9 -0
  37. package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +99 -0
  38. package/docs/adrs/smoke/README.md +9 -0
  39. package/docs/architecture/current-onboard-baseline.md +126 -0
  40. package/docs/architecture/current-safety-behavior.md +137 -0
  41. package/docs/archive/CHANGELOG-pre-2.16.0.md +48 -0
  42. package/docs/contracts/adr-layout.md +108 -0
  43. package/docs/contracts/benchmark-corpus-spec.md +97 -0
  44. package/docs/contracts/benchmark-report-schema.md +111 -0
  45. package/docs/contracts/command-clusters.md +1 -0
  46. package/docs/contracts/command-taxonomy.md +137 -0
  47. package/docs/contracts/compression-default-kill-criterion.md +69 -0
  48. package/docs/contracts/config-presets.md +144 -0
  49. package/docs/contracts/cost-dashboard.md +143 -0
  50. package/docs/contracts/cost-enforcement.md +134 -0
  51. package/docs/contracts/file-ownership-matrix.json +0 -7
  52. package/docs/contracts/mcp-tool-inventory.md +53 -0
  53. package/docs/contracts/measurement-baseline.md +102 -0
  54. package/docs/contracts/namespace.md +125 -0
  55. package/docs/contracts/profile-system.md +142 -0
  56. package/docs/contracts/safety-model.md +129 -0
  57. package/docs/contracts/smoke-contracts.md +144 -0
  58. package/docs/contracts/workflow-packs.md +121 -0
  59. package/docs/decisions/ADR-010-profile-pack-preset-boundary.md +132 -0
  60. package/docs/decisions/INDEX.md +1 -0
  61. package/docs/featured-commands.md +27 -0
  62. package/docs/parity/bench-ruflo.json +58 -0
  63. package/docs/parity/bench.json +41 -0
  64. package/docs/parity/ruflo.md +46 -0
  65. package/docs/profiles.md +91 -0
  66. package/package.json +1 -1
  67. package/scripts/_cli/cmd_explain.py +250 -0
  68. package/scripts/_lib/bench_cost.py +138 -0
  69. package/scripts/_lib/bench_quality.py +118 -0
  70. package/scripts/_lib/bench_report.py +150 -0
  71. package/scripts/agent-config +13 -0
  72. package/scripts/audit_adr_coverage.py +175 -0
  73. package/scripts/audit_mcp_tools.py +146 -0
  74. package/scripts/bench_baseline_ready.py +108 -0
  75. package/scripts/bench_drift_check.py +151 -0
  76. package/scripts/bench_per_tool.py +216 -0
  77. package/scripts/bench_run.py +155 -0
  78. package/scripts/config/__init__.py +9 -0
  79. package/scripts/config/presets.py +206 -0
  80. package/scripts/config/profiles.py +173 -0
  81. package/scripts/cost/budget.mjs +73 -12
  82. package/scripts/cost/preflight.mjs +89 -0
  83. package/scripts/lint_archived_skills.py +143 -0
  84. package/scripts/lint_bench_corpus.py +161 -0
  85. package/scripts/lint_namespace.py +135 -0
  86. package/scripts/skill_overlap.py +204 -0
  87. package/scripts/skill_usage_collect.py +191 -0
  88. package/scripts/skill_usage_report.py +162 -0
  89. package/scripts/smoke/kernel.sh +101 -0
  90. package/scripts/smoke/router.sh +129 -0
  91. package/scripts/smoke/schema.sh +71 -0
  92. package/scripts/smoke/skills.sh +101 -0
@@ -0,0 +1,206 @@
1
+ """Preset loader — step-15 Phase 1 item 4.
2
+
3
+ Resolves the active ``preset.id`` and merged knob set from the chain
4
+ documented in :mod:`docs.contracts.config-presets`. Pure, read-only,
5
+ lazy-PyYAML.
6
+
7
+ Resolution chain (last writer wins for any single knob):
8
+
9
+ 1. ``pack.preset_id`` — set ``preset.id`` (Phase 2; ``None`` until packs
10
+ land).
11
+ 2. ``profile.preset_id`` — set ``preset.id`` if not pack-set.
12
+ 3. ``preset.<id>.yml`` — fill all knobs from the seed file.
13
+ 4. ``.agent-settings.yml`` user keys under ``preset:`` — override per-knob.
14
+ 5. Environment variables (``AGENT_CONFIG_PRESET_*``) — override per-knob,
15
+ structured keys mapped from the schema (see :data:`ENV_KNOB_MAP`).
16
+ 6. Runtime CLI overrides — caller passes a flat ``runtime_overrides`` map.
17
+
18
+ Profile-aware overlay is **not** done here — callers that need
19
+ profile-specific reads of preset knobs (e.g. ``block_on_risk.code_paths``
20
+ for ``developer`` vs ``block_on_risk.financial_paths`` for ``founder``)
21
+ read the merged knob bag returned by :func:`resolve_preset`.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ import os
27
+ from copy import deepcopy
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ PRESET_ID_ENV = "AGENT_CONFIG_PRESET_ID"
35
+ SEED_PRESET_IDS: tuple[str, ...] = ("fast", "balanced", "strict")
36
+ DEFAULT_PRESET_ID = "balanced"
37
+ PRESETS_DIRNAME = ".agent-src.uncompressed/presets"
38
+
39
+ SOURCE_PACK = "pack"
40
+ SOURCE_PROFILE = "profile"
41
+ SOURCE_USER = "user-settings"
42
+ SOURCE_ENV = "env"
43
+ SOURCE_RUNTIME = "runtime"
44
+ SOURCE_DEFAULT = "default"
45
+
46
+ ENV_KNOB_MAP: dict[str, tuple[str, ...]] = {
47
+ "AGENT_CONFIG_PRESET_COST_DAILY_MAX_USD": ("cost", "daily_max_usd"),
48
+ "AGENT_CONFIG_PRESET_COST_WEEKLY_MAX_USD": ("cost", "weekly_max_usd"),
49
+ "AGENT_CONFIG_PRESET_COST_MONTHLY_MAX_USD": ("cost", "monthly_max_usd"),
50
+ "AGENT_CONFIG_PRESET_MCP_PER_CALL_MAX_USD": ("mcp", "per_call_max_usd"),
51
+ "AGENT_CONFIG_PRESET_MCP_PER_SESSION_MAX_USD": ("mcp", "per_session_max_usd"),
52
+ "AGENT_CONFIG_PRESET_COUNCIL_CAP_PER_CONSULT_USD": (
53
+ "council",
54
+ "cap_per_consult_usd",
55
+ ),
56
+ "AGENT_CONFIG_PRESET_AUTONOMY_DEFAULT": ("autonomy", "default"),
57
+ "AGENT_CONFIG_PRESET_CONFIDENCE_MIN_BAND": ("confidence", "min_band"),
58
+ }
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ResolvedPreset:
63
+ """Outcome of :func:`resolve_preset`. See config-presets contract."""
64
+
65
+ id: str
66
+ knobs: dict[str, Any] = field(default_factory=dict)
67
+ source: str = SOURCE_DEFAULT
68
+ overrides: tuple[str, ...] = ()
69
+ warning: str | None = None
70
+
71
+
72
+ class PresetError(Exception):
73
+ """Raised when a preset id is referenced but its YAML cannot load."""
74
+
75
+
76
+ def _load_yaml(path: Path) -> dict[str, Any]:
77
+ try:
78
+ import yaml # type: ignore[import-not-found]
79
+ except ImportError:
80
+ logger.info("PyYAML unavailable; preset %s returned empty", path)
81
+ return {}
82
+ try:
83
+ text = path.read_text(encoding="utf-8")
84
+ except OSError as exc:
85
+ logger.warning("preset read failed for %s: %s", path, exc)
86
+ return {}
87
+ try:
88
+ data = yaml.safe_load(text) or {}
89
+ except yaml.YAMLError as exc:
90
+ logger.warning("preset parse failed for %s: %s", path, exc)
91
+ return {}
92
+ return data if isinstance(data, dict) else {}
93
+
94
+
95
+ def _preset_file(project_root: Path, preset_id: str) -> Path:
96
+ return project_root / PRESETS_DIRNAME / f"{preset_id}.yml"
97
+
98
+
99
+ def _coerce_scalar(raw: str) -> Any:
100
+ try:
101
+ return int(raw)
102
+ except ValueError:
103
+ pass
104
+ try:
105
+ return float(raw)
106
+ except ValueError:
107
+ pass
108
+ if raw.lower() in {"true", "false"}:
109
+ return raw.lower() == "true"
110
+ return raw
111
+
112
+
113
+ def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> list[str]:
114
+ """Merge ``override`` into ``base`` in place; return dotted-override paths."""
115
+ paths: list[str] = []
116
+
117
+ def walk(b: dict[str, Any], o: dict[str, Any], prefix: str) -> None:
118
+ for key, value in o.items():
119
+ dotted = f"{prefix}{key}"
120
+ if isinstance(value, dict) and isinstance(b.get(key), dict):
121
+ walk(b[key], value, f"{dotted}.")
122
+ else:
123
+ b[key] = deepcopy(value)
124
+ paths.append(dotted)
125
+
126
+ walk(base, override, "")
127
+ return paths
128
+
129
+
130
+ def _pick_id(
131
+ pack_preset_id: str | None,
132
+ profile_preset_id: str | None,
133
+ user_settings: dict[str, Any],
134
+ runtime_id: str | None,
135
+ ) -> tuple[str | None, str]:
136
+ if runtime_id:
137
+ return runtime_id, SOURCE_RUNTIME
138
+ env_id = os.environ.get(PRESET_ID_ENV)
139
+ if env_id:
140
+ return env_id, SOURCE_ENV
141
+ block = user_settings.get("preset") if isinstance(user_settings, dict) else None
142
+ if isinstance(block, dict) and block.get("id"):
143
+ return str(block["id"]), SOURCE_USER
144
+ if pack_preset_id:
145
+ return pack_preset_id, SOURCE_PACK
146
+ if profile_preset_id:
147
+ return profile_preset_id, SOURCE_PROFILE
148
+ return None, SOURCE_DEFAULT
149
+
150
+
151
+ def resolve_preset(
152
+ *,
153
+ project_root: Path,
154
+ user_settings: dict[str, Any] | None = None,
155
+ pack_preset_id: str | None = None,
156
+ profile_preset_id: str | None = None,
157
+ runtime_id: str | None = None,
158
+ runtime_overrides: dict[tuple[str, ...], Any] | None = None,
159
+ ) -> ResolvedPreset:
160
+ """Return the active :class:`ResolvedPreset` for the current session."""
161
+ settings = user_settings or {}
162
+ preset_id, source = _pick_id(
163
+ pack_preset_id, profile_preset_id, settings, runtime_id,
164
+ )
165
+ if preset_id is None:
166
+ preset_id = DEFAULT_PRESET_ID
167
+ source = SOURCE_DEFAULT
168
+ yaml_path = _preset_file(project_root, preset_id)
169
+ if not yaml_path.exists():
170
+ raise PresetError(
171
+ f"preset.id={preset_id!r} ({source}) but {yaml_path} not found",
172
+ )
173
+ raw = _load_yaml(yaml_path)
174
+ knobs = raw.get("preset") or {}
175
+ if not isinstance(knobs, dict):
176
+ raise PresetError(f"{yaml_path} has no top-level 'preset:' mapping")
177
+ knobs = deepcopy(knobs)
178
+ knobs.pop("id", None)
179
+ overrides: list[str] = []
180
+ user_block = settings.get("preset") if isinstance(settings.get("preset"), dict) else None
181
+ if isinstance(user_block, dict):
182
+ user_overrides = {k: v for k, v in user_block.items() if k != "id"}
183
+ if user_overrides:
184
+ overrides.extend(_deep_merge(knobs, user_overrides))
185
+ for env_key, path in ENV_KNOB_MAP.items():
186
+ raw_value = os.environ.get(env_key)
187
+ if raw_value is None:
188
+ continue
189
+ cursor = knobs
190
+ for part in path[:-1]:
191
+ cursor = cursor.setdefault(part, {})
192
+ cursor[path[-1]] = _coerce_scalar(raw_value)
193
+ overrides.append(".".join(path))
194
+ if runtime_overrides:
195
+ for path, value in runtime_overrides.items():
196
+ cursor = knobs
197
+ for part in path[:-1]:
198
+ cursor = cursor.setdefault(part, {})
199
+ cursor[path[-1]] = value
200
+ overrides.append(".".join(path))
201
+ return ResolvedPreset(
202
+ id=preset_id,
203
+ knobs=knobs,
204
+ source=source,
205
+ overrides=tuple(overrides),
206
+ )
@@ -0,0 +1,173 @@
1
+ """Profile loader — step-15 Phase 1 item 1.
2
+
3
+ Resolves the active ``profile.id`` from the chain documented in
4
+ :mod:`docs.contracts.profile-system` and returns a structured
5
+ :class:`ResolvedProfile`. Pure, read-only, lazy-PyYAML.
6
+
7
+ Resolution chain (last writer wins):
8
+
9
+ 1. Pack-supplied ``profile_id`` (Phase 2 item 7 — pack loader passes it
10
+ in via ``pack_profile_id``; ``None`` until packs land).
11
+ 2. ``.agent-settings.yml`` top-level ``profile.id`` (and any user
12
+ overrides for ``audience`` / ``defaults`` / ``surface``).
13
+ 3. Environment variable ``AGENT_CONFIG_PROFILE_ID``.
14
+ 4. Runtime CLI flag — caller passes ``runtime_id``.
15
+
16
+ Falls back to ``developer`` **only** when no settings file exists yet
17
+ (fresh install before ``/onboard``). With a settings file present but
18
+ no ``profile`` block, the loader returns a structured warning state so
19
+ ``/onboard`` can surface "audience not yet picked".
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ import os
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from scripts._lib import agent_settings
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ PROFILE_ID_ENV = "AGENT_CONFIG_PROFILE_ID"
34
+ SEED_PROFILE_IDS: tuple[str, ...] = (
35
+ "founder",
36
+ "developer",
37
+ "content_creator",
38
+ "agency",
39
+ "finance",
40
+ "ops",
41
+ )
42
+ DEFAULT_PROFILE_ID = "developer"
43
+ PROFILES_DIRNAME = ".agent-src.uncompressed/profiles"
44
+
45
+ SOURCE_PACK = "pack"
46
+ SOURCE_USER = "user-settings"
47
+ SOURCE_ENV = "env"
48
+ SOURCE_RUNTIME = "runtime"
49
+ SOURCE_DEFAULT = "default"
50
+ SOURCE_MISSING = "missing"
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ResolvedProfile:
55
+ """Outcome of :func:`resolve_profile`. See profile-system contract."""
56
+
57
+ id: str
58
+ audience: dict[str, str] = field(default_factory=dict)
59
+ preset_id: str | None = None
60
+ personas: tuple[str, ...] = ()
61
+ skills_hint: tuple[str, ...] = ()
62
+ commands_hint: tuple[str, ...] = ()
63
+ docs_first_pointer: str | None = None
64
+ source: str = SOURCE_DEFAULT
65
+ warning: str | None = None
66
+
67
+
68
+ class ProfileError(Exception):
69
+ """Raised when a profile id is referenced but its YAML cannot load."""
70
+
71
+
72
+ def _load_yaml(path: Path) -> dict[str, Any]:
73
+ try:
74
+ import yaml # type: ignore[import-not-found]
75
+ except ImportError:
76
+ logger.info("PyYAML unavailable; profile %s returned empty", path)
77
+ return {}
78
+ try:
79
+ text = path.read_text(encoding="utf-8")
80
+ except OSError as exc:
81
+ logger.warning("profile read failed for %s: %s", path, exc)
82
+ return {}
83
+ try:
84
+ data = yaml.safe_load(text) or {}
85
+ except yaml.YAMLError as exc:
86
+ logger.warning("profile parse failed for %s: %s", path, exc)
87
+ return {}
88
+ return data if isinstance(data, dict) else {}
89
+
90
+
91
+ def _profile_file(project_root: Path, profile_id: str) -> Path:
92
+ return project_root / PROFILES_DIRNAME / f"{profile_id}.yml"
93
+
94
+
95
+ def _build_resolved(
96
+ profile_id: str,
97
+ raw: dict[str, Any],
98
+ *,
99
+ source: str,
100
+ warning: str | None = None,
101
+ ) -> ResolvedProfile:
102
+ block = raw.get("profile") or {}
103
+ audience_raw = block.get("audience") or {}
104
+ defaults = block.get("defaults") or {}
105
+ surface = block.get("surface") or {}
106
+ audience = {str(k): str(v) for k, v in audience_raw.items()}
107
+ personas = tuple(str(p) for p in (defaults.get("personas") or []))
108
+ skills_hint = tuple(str(s) for s in (defaults.get("skills_hint") or []))
109
+ commands_hint = tuple(str(c) for c in (surface.get("commands_hint") or []))
110
+ docs_pointer = surface.get("docs_first_pointer")
111
+ return ResolvedProfile(
112
+ id=profile_id,
113
+ audience=audience,
114
+ preset_id=defaults.get("preset_id"),
115
+ personas=personas,
116
+ skills_hint=skills_hint,
117
+ commands_hint=commands_hint,
118
+ docs_first_pointer=str(docs_pointer) if docs_pointer else None,
119
+ source=source,
120
+ warning=warning,
121
+ )
122
+
123
+
124
+ def _pick_id(
125
+ pack_profile_id: str | None,
126
+ user_settings: dict[str, Any],
127
+ runtime_id: str | None,
128
+ ) -> tuple[str | None, str]:
129
+ if runtime_id:
130
+ return runtime_id, SOURCE_RUNTIME
131
+ env_id = os.environ.get(PROFILE_ID_ENV)
132
+ if env_id:
133
+ return env_id, SOURCE_ENV
134
+ block = user_settings.get("profile") if isinstance(user_settings, dict) else None
135
+ if isinstance(block, dict) and block.get("id"):
136
+ return str(block["id"]), SOURCE_USER
137
+ if pack_profile_id:
138
+ return pack_profile_id, SOURCE_PACK
139
+ return None, SOURCE_MISSING
140
+
141
+
142
+ def resolve_profile(
143
+ *,
144
+ project_root: Path,
145
+ user_settings: dict[str, Any] | None = None,
146
+ pack_profile_id: str | None = None,
147
+ runtime_id: str | None = None,
148
+ ) -> ResolvedProfile:
149
+ """Return the active :class:`ResolvedProfile` for the current session."""
150
+ settings = user_settings or {}
151
+ settings_file = project_root / agent_settings.DEFAULT_PROJECT_FILE
152
+ profile_id, source = _pick_id(pack_profile_id, settings, runtime_id)
153
+ if profile_id is None:
154
+ if settings_file.exists():
155
+ return ResolvedProfile(
156
+ id=DEFAULT_PROFILE_ID,
157
+ source=SOURCE_MISSING,
158
+ warning=(
159
+ "no profile.id in .agent-settings.yml — run /onboard to "
160
+ "pick an audience deliberately"
161
+ ),
162
+ )
163
+ return _build_resolved(
164
+ DEFAULT_PROFILE_ID,
165
+ _load_yaml(_profile_file(project_root, DEFAULT_PROFILE_ID)),
166
+ source=SOURCE_DEFAULT,
167
+ )
168
+ yaml_path = _profile_file(project_root, profile_id)
169
+ if not yaml_path.exists():
170
+ raise ProfileError(
171
+ f"profile.id={profile_id!r} ({source}) but {yaml_path} not found",
172
+ )
173
+ return _build_resolved(profile_id, _load_yaml(yaml_path), source=source)
@@ -14,10 +14,62 @@ import { dirname } from 'node:path';
14
14
 
15
15
  const STORE = process.env.BUDGET_STORE || 'agents/cost-tracking/sessions.jsonl';
16
16
  const CONFIG = process.env.BUDGET_CONFIG || 'agents/cost-tracking/budget.json';
17
+ const SETTINGS = process.env.AGENT_SETTINGS || '.agent-settings.yml';
18
+
19
+ // Minimal YAML reader for the `cost:` block — avoids a yaml dep. Reads
20
+ // only the keys this script needs (cost.budgets.{daily,weekly,monthly},
21
+ // cost.enforcement) from the well-formed two-space-indent template.
22
+ function loadSettingsCost() {
23
+ if (!existsSync(SETTINGS)) return null;
24
+ let inCost = false, inBudgets = false;
25
+ const out = { budgets: {}, enforcement: null };
26
+ for (const raw of readFileSync(SETTINGS, 'utf-8').split('\n')) {
27
+ const line = raw.replace(/\s+$/, '');
28
+ if (!line || line.startsWith('#')) continue;
29
+ if (/^[a-z_]+:/.test(line)) inCost = inBudgets = false;
30
+ if (line === 'cost:') { inCost = true; continue; }
31
+ if (!inCost) continue;
32
+ if (/^ budgets:/.test(line)) { inBudgets = true; continue; }
33
+ if (inBudgets && /^ [a-z]+:/.test(line)) {
34
+ const [k, v] = line.trim().split(':').map((s) => s.trim());
35
+ const n = parseFloat(v);
36
+ if (Number.isFinite(n) && n > 0) out.budgets[k] = n;
37
+ continue;
38
+ }
39
+ if (/^ enforcement:/.test(line)) {
40
+ out.enforcement = line.split(':')[1].trim().replace(/['"]/g, '');
41
+ inBudgets = false;
42
+ }
43
+ }
44
+ const hasAny = Object.keys(out.budgets).length || out.enforcement;
45
+ return hasAny ? out : null;
46
+ }
17
47
 
18
48
  function loadConfig() {
49
+ // Settings file wins when it carries any cost.* values.
50
+ const fromSettings = loadSettingsCost();
51
+ if (fromSettings) {
52
+ const period = process.env.BUDGET_PERIOD || 'all';
53
+ const periodKey = ({ today: 'daily', week: 'weekly', month: 'monthly' })[period];
54
+ const budget_usd = periodKey ? fromSettings.budgets[periodKey] : (
55
+ fromSettings.budgets.monthly || fromSettings.budgets.weekly || fromSettings.budgets.daily
56
+ );
57
+ if (Number.isFinite(budget_usd) && budget_usd > 0) {
58
+ return {
59
+ budget_usd,
60
+ enforcement: fromSettings.enforcement || 'advisory',
61
+ source: 'agent-settings.yml',
62
+ setAt: null,
63
+ };
64
+ }
65
+ }
19
66
  if (!existsSync(CONFIG)) return null;
20
- try { return JSON.parse(readFileSync(CONFIG, 'utf-8')); } catch { return null; }
67
+ try {
68
+ const cfg = JSON.parse(readFileSync(CONFIG, 'utf-8'));
69
+ cfg.source = cfg.source || 'budget.json';
70
+ cfg.enforcement = cfg.enforcement || 'advisory';
71
+ return cfg;
72
+ } catch { return null; }
21
73
  }
22
74
 
23
75
  function saveConfig(cfg) {
@@ -123,18 +175,27 @@ function cmdCheck() {
123
175
  threshold: alert.threshold,
124
176
  recommended_action: recommendedAction(alert.level),
125
177
  sessionCount: filtered.length,
178
+ enforcement: cfg.enforcement || 'advisory',
179
+ source: cfg.source || 'budget.json',
126
180
  };
127
- if (process.env.BUDGET_QUIET === '1') return console.log(JSON.stringify(out));
128
- console.log(`# Budget check (period: ${period})\n`);
129
- console.log('| Metric | Value |\n|---|---:|');
130
- console.log(`| Budget | $${cfg.budget_usd.toFixed(2)} |`);
131
- console.log(`| Spent | $${totalSpend.toFixed(2)} |`);
132
- console.log(`| Remaining | $${out.remaining_usd.toFixed(2)} |`);
133
- console.log(`| Utilization | ${out.utilization_pct.toFixed(1)}% |`);
134
- console.log(`| Sessions counted | ${filtered.length} |`);
135
- console.log(`| **Alert** | **${alert.emoji} ${alert.level}** |`);
136
- console.log(`\nAction: ${out.recommended_action}`);
137
- if (alert.level === 'HARD_STOP') process.exit(1);
181
+ const hardStop = alert.level === 'HARD_STOP' && out.enforcement === 'hard-stop';
182
+ if (process.env.BUDGET_QUIET === '1') {
183
+ console.log(JSON.stringify(out));
184
+ } else {
185
+ console.log(`# Budget check (period: ${period})\n`);
186
+ console.log('| Metric | Value |\n|---|---:|');
187
+ console.log(`| Budget | $${cfg.budget_usd.toFixed(2)} |`);
188
+ console.log(`| Spent | $${totalSpend.toFixed(2)} |`);
189
+ console.log(`| Remaining | $${out.remaining_usd.toFixed(2)} |`);
190
+ console.log(`| Utilization | ${out.utilization_pct.toFixed(1)}% |`);
191
+ console.log(`| Sessions counted | ${filtered.length} |`);
192
+ console.log(`| **Alert** | **${alert.emoji} ${alert.level}** |`);
193
+ console.log(`| Enforcement | ${out.enforcement} (source: ${out.source}) |`);
194
+ console.log(`\nAction: ${out.recommended_action}`);
195
+ }
196
+ // Only fail closed when enforcement is hard-stop; advisory mode reports
197
+ // the breach but exits clean so wrappers keep working.
198
+ if (hardStop) process.exit(1);
138
199
  }
139
200
 
140
201
  function main() {
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // Cost preflight hook — process-entry gate per ADR docs/adrs/cost/0001-hard-stop-hook.md.
3
+ //
4
+ // Wraps scripts/cost/budget.mjs check. Reads cost.enforcement from
5
+ // .agent-settings.yml. Exits non-zero only when:
6
+ // - enforcement: hard-stop, AND
7
+ // - level: HARD_STOP
8
+ //
9
+ // Default behaviour without any budget configured: exit 0 (fail-open).
10
+ // Designed to be invoked by shell / CI wrappers BEFORE composing a
11
+ // turn: `task cost:preflight` or `node scripts/cost/preflight.mjs`.
12
+
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ const QUIET = process.env.PREFLIGHT_QUIET === '1';
16
+
17
+ function runBudgetCheck() {
18
+ const env = { ...process.env, BUDGET_QUIET: '1' };
19
+ const r = spawnSync('node', ['scripts/cost/budget.mjs', 'check'], { env, encoding: 'utf-8' });
20
+ if (r.error) return { ok: false, fatal: true, msg: String(r.error) };
21
+ const out = (r.stdout || '').trim();
22
+ if (!out) return { ok: true, unbudgeted: true };
23
+ // budget.mjs prints a single JSON line under BUDGET_QUIET=1 when a
24
+ // budget is set; otherwise it prints a no-budget plaintext notice.
25
+ if (!out.startsWith('{')) return { ok: true, unbudgeted: true };
26
+ try {
27
+ const data = JSON.parse(out);
28
+ // budget.mjs JSON shape carries `error: 'no budget configured'`
29
+ // when cost.budgets are all 0 / absent — treat as unbudgeted.
30
+ if (data.error || !Number.isFinite(data.budget_usd)) return { ok: true, unbudgeted: true };
31
+ return { ok: true, data, childExit: r.status ?? 0 };
32
+ } catch (e) {
33
+ return { ok: false, fatal: true, msg: `parse: ${e.message}`, raw: out };
34
+ }
35
+ }
36
+
37
+ function refuse(data) {
38
+ if (QUIET) {
39
+ console.log(JSON.stringify({
40
+ preflight: 'refused',
41
+ reason: 'cost-hard-stop',
42
+ level: data.level,
43
+ utilization_pct: data.utilization_pct,
44
+ budget_usd: data.budget_usd,
45
+ spent_usd: data.spent_usd,
46
+ enforcement: data.enforcement,
47
+ source: data.source,
48
+ }));
49
+ return;
50
+ }
51
+ console.error('# 🛑 Cost preflight — HARD STOP\n');
52
+ console.error('| Metric | Value |');
53
+ console.error('|---|---:|');
54
+ console.error(`| Budget | $${data.budget_usd.toFixed(2)} |`);
55
+ console.error(`| Spent | $${data.spent_usd.toFixed(2)} |`);
56
+ console.error(`| Utilization | ${data.utilization_pct.toFixed(1)}% |`);
57
+ console.error(`| Enforcement | ${data.enforcement} (source: ${data.source}) |`);
58
+ console.error('\nBypass (pick one — see docs/contracts/cost-enforcement.md):');
59
+ console.error(' 1. Raise the budget: edit .agent-settings.yml § cost.budgets.<period>');
60
+ console.error(' 2. Reset the ledger: node scripts/cost/track.mjs reset --confirm');
61
+ console.error(' 3. Disable enforcement: set cost.enforcement: advisory');
62
+ }
63
+
64
+ function main() {
65
+ const r = runBudgetCheck();
66
+ if (!r.ok && r.fatal) {
67
+ // Fail-open on infra error — never block work because the hook itself broke.
68
+ if (!QUIET) console.error(`# cost-preflight: skipped (${r.msg})`);
69
+ process.exit(0);
70
+ }
71
+ if (r.unbudgeted) {
72
+ if (!QUIET) console.log('# cost-preflight: no budget configured — pass.');
73
+ process.exit(0);
74
+ }
75
+ const d = r.data;
76
+ const hardStop = d.level === 'HARD_STOP' && d.enforcement === 'hard-stop';
77
+ if (hardStop) {
78
+ refuse(d);
79
+ process.exit(1);
80
+ }
81
+ if (!QUIET) {
82
+ console.log(`# cost-preflight: ${d.level} (${d.utilization_pct.toFixed(1)}% of $${d.budget_usd.toFixed(2)}, enforcement=${d.enforcement})`);
83
+ } else {
84
+ console.log(JSON.stringify({ preflight: 'pass', level: d.level, enforcement: d.enforcement, utilization_pct: d.utilization_pct }));
85
+ }
86
+ process.exit(0);
87
+ }
88
+
89
+ main();