@event4u/agent-config 2.12.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent-src/commands/council/analysis.md +142 -0
- package/.agent-src/commands/council/debate.md +129 -0
- package/.agent-src/commands/council/default.md +8 -0
- package/.agent-src/commands/council/design.md +16 -12
- package/.agent-src/commands/council/optimize.md +16 -15
- package/.agent-src/commands/council/pr.md +12 -12
- package/.agent-src/commands/council.md +48 -2
- package/.agent-src/commands/memory/learn-low-impact.md +143 -0
- package/.agent-src/personas/advisors/contrarian.md +95 -0
- package/.agent-src/personas/advisors/executor.md +99 -0
- package/.agent-src/personas/advisors/expansionist.md +98 -0
- package/.agent-src/personas/advisors/first-principles.md +98 -0
- package/.agent-src/personas/advisors/outsider.md +102 -0
- package/.agent-src/rules/ask-when-uncertain.md +10 -6
- package/.agent-src/rules/copilot-routing.md +19 -0
- package/.agent-src/rules/devcontainer-routing.md +20 -0
- package/.agent-src/rules/external-reference-deep-dive.md +1 -1
- package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
- package/.agent-src/rules/laravel-routing.md +20 -0
- package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
- package/.agent-src/rules/symfony-routing.md +20 -0
- package/.agent-src/skills/ai-council/SKILL.md +388 -10
- package/.agent-src/skills/copilot-config/SKILL.md +1 -1
- package/.agent-src/skills/devcontainer/SKILL.md +1 -1
- package/.agent-src/skills/laravel/SKILL.md +1 -1
- package/.agent-src/skills/project-analysis-core/SKILL.md +1 -1
- package/.agent-src/skills/project-analyzer/SKILL.md +1 -1
- package/.agent-src/skills/symfony-workflow/SKILL.md +1 -1
- package/.agent-src/skills/universal-project-analysis/SKILL.md +1 -1
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.claude-plugin/marketplace.json +4 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +346 -124
- package/CONTRIBUTING.md +5 -0
- package/README.md +6 -6
- package/config/agent-settings.template.yml +5 -93
- package/config/gitignore-block.txt +6 -0
- package/docs/architecture/multi-tool-projection.md +53 -0
- package/docs/architecture/{compression.md → source-projection.md} +21 -3
- package/docs/architecture.md +15 -15
- package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
- package/docs/catalog.md +25 -12
- package/docs/contracts/adr-architectural-consensus-mechanism.md +68 -0
- package/docs/contracts/adr-level-6-productization.md +7 -9
- package/docs/contracts/ai-council-config.md +658 -0
- package/docs/contracts/command-clusters.md +58 -2
- package/docs/contracts/command-surface-tiers.md +3 -2
- package/docs/contracts/cost-profile-defaults.md +5 -0
- package/docs/contracts/decision-engine-gates.md +5 -0
- package/docs/contracts/decision-trace-v1.md +2 -2
- package/docs/contracts/file-ownership-matrix.json +1735 -72
- package/docs/contracts/installed-tools-lockfile.md +2 -1
- package/docs/contracts/low-impact-corpus-format.md +95 -0
- package/docs/contracts/mcp-beta-criteria.md +6 -5
- package/docs/contracts/mcp-cloud-scope.md +5 -4
- package/docs/contracts/multi-tool-projection-fidelity.md +115 -0
- package/docs/contracts/release-trunk-sync.md +4 -3
- package/docs/contracts/tier-3-contrib-plugin.md +5 -6
- package/docs/getting-started.md +2 -2
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
- package/docs/installation.md +32 -0
- package/package.json +1 -1
- package/scripts/_archive/README.md +59 -0
- package/scripts/_cli/cmd_doctor.py +134 -0
- package/scripts/ai_council/_default_prices.py +10 -1
- package/scripts/ai_council/advisors.py +148 -0
- package/scripts/ai_council/airgap.py +165 -0
- package/scripts/ai_council/cli_hints.py +123 -0
- package/scripts/ai_council/clients.py +959 -5
- package/scripts/ai_council/compile_corpus.py +178 -0
- package/scripts/ai_council/confidence_gate.py +156 -0
- package/scripts/ai_council/config.py +1364 -0
- package/scripts/ai_council/consensus.py +329 -0
- package/scripts/ai_council/events_log.py +137 -0
- package/scripts/ai_council/learn_low_impact_preview.py +252 -0
- package/scripts/ai_council/low_impact.py +714 -0
- package/scripts/ai_council/low_impact_corpus.py +466 -0
- package/scripts/ai_council/low_impact_intake.py +163 -0
- package/scripts/ai_council/modes.py +6 -1
- package/scripts/ai_council/necessity.py +782 -0
- package/scripts/ai_council/orchestrator.py +872 -20
- package/scripts/ai_council/probation_gate.py +152 -0
- package/scripts/ai_council/prompts.py +335 -0
- package/scripts/ai_council/redact_low_impact_entry.py +155 -0
- package/scripts/ai_council/replay.py +155 -0
- package/scripts/ai_council/session.py +19 -1
- package/scripts/ai_council/shadow_dispatch.py +235 -0
- package/scripts/ai_council/solo_dispatch.py +226 -0
- package/scripts/audit_cloud_compatibility.py +74 -0
- package/scripts/audit_command_surface.py +363 -0
- package/scripts/check_compressed_paths.py +6 -1
- package/scripts/check_council_layout.py +11 -0
- package/scripts/ci_time_ratio.py +168 -0
- package/scripts/council_cli.py +2005 -30
- package/scripts/install.sh +12 -0
- package/scripts/measure_projection_bytes.py +159 -0
- package/scripts/measure_roadmap_trajectory.py +112 -0
- package/scripts/probe_projection_fidelity.py +202 -0
- package/scripts/score_skill_selection.py +198 -0
- package/scripts/skill_collision_clusters.py +162 -0
- /package/scripts/{_backfill_skill_domains.py → _archive/_backfill_skill_domains.py} +0 -0
- /package/scripts/{_bootstrap_tier_frontmatter.py → _archive/_bootstrap_tier_frontmatter.py} +0 -0
- /package/scripts/{_p43_bodies.py → _archive/_p43_bodies.py} +0 -0
- /package/scripts/{_p43_compress.py → _archive/_p43_compress.py} +0 -0
- /package/scripts/{_p4_migrate.py → _archive/_p4_migrate.py} +0 -0
- /package/scripts/{_phase2_shim_helper.py → _archive/_phase2_shim_helper.py} +0 -0
- /package/scripts/{_pilot_council_question.py → _archive/_pilot_council_question.py} +0 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Hardened parser for ``agents/low-impact-decisions.md`` (step-9 P4).
|
|
2
|
+
|
|
3
|
+
Replaces the silent-skip behaviour of the inline regex in
|
|
4
|
+
``necessity.load_validated_phrases`` with a typed-error contract.
|
|
5
|
+
|
|
6
|
+
Contract: ``docs/contracts/low-impact-corpus-format.md``.
|
|
7
|
+
|
|
8
|
+
Two entry points:
|
|
9
|
+
|
|
10
|
+
- :func:`load_validated_phrases` — back-compat shim used by
|
|
11
|
+
:mod:`scripts.ai_council.necessity` routing. Silently returns the
|
|
12
|
+
successfully-parsed validated phrases (degrades to ``()`` on
|
|
13
|
+
malformed sections so a broken corpus never blocks routing).
|
|
14
|
+
- :func:`parse_corpus_strict` — raises :class:`CorpusParseError` on
|
|
15
|
+
the first structural anomaly. Used by CI lint
|
|
16
|
+
(``task lint-low-impact-corpus``) and the strict-mode test suite.
|
|
17
|
+
|
|
18
|
+
Structural failures (raised in strict mode, dropped silently in
|
|
19
|
+
lenient mode):
|
|
20
|
+
|
|
21
|
+
- ``curly_quotes`` — phrase wrapped in U+201C / U+201D.
|
|
22
|
+
- ``single_quotes`` — phrase wrapped in ``'…'`` instead of ``"…"``.
|
|
23
|
+
- ``non_dash_bullet`` — ``*``, ``+`` or numbered list marker under a
|
|
24
|
+
section that expects ``- "…"`` bullets.
|
|
25
|
+
- ``unclosed_quote`` — opening ``"`` with no matching closing ``"``.
|
|
26
|
+
- ``empty_phrase`` — phrase normalises to empty (whitespace /
|
|
27
|
+
punctuation only).
|
|
28
|
+
- ``heading_drift`` — heading with the section name but the wrong
|
|
29
|
+
level (e.g. ``### Validated``) or trailing punctuation
|
|
30
|
+
(e.g. ``## Validated:``).
|
|
31
|
+
- ``missing_anchor`` — the ``<!-- intake-anchor: validated -->``
|
|
32
|
+
marker is absent (the intake module relies on it to splice new
|
|
33
|
+
probation entries).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import re
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Iterable, Literal
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
Section = Literal["validated", "probation", "anti_examples"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_SECTION_TITLES: dict[Section, str] = {
|
|
48
|
+
"validated": "Validated",
|
|
49
|
+
"probation": "On Probation",
|
|
50
|
+
"anti_examples": "Anti-Examples (Always Ask User)",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#: Heading-detection regex. ``^##\s+<title>\s*$`` accepts the canonical
|
|
54
|
+
#: form; anything else with the title text triggers ``heading_drift``.
|
|
55
|
+
_HEADING_OK = re.compile(r"^##\s+(.+?)\s*$")
|
|
56
|
+
|
|
57
|
+
#: Canonical bullet form: ``- "phrase"`` followed by optional metadata.
|
|
58
|
+
_BULLET_OK = re.compile(r'^\s*-\s*"([^"]+)"\s*(.*)$')
|
|
59
|
+
|
|
60
|
+
#: Non-dash list markers that drift away from the contract.
|
|
61
|
+
_BULLET_BAD_MARKER = re.compile(r'^\s*([*+]|\d+\.)\s+["\u201C\u2018\']')
|
|
62
|
+
|
|
63
|
+
#: Smart-quote and single-quote drift inside an otherwise dash-bulleted line.
|
|
64
|
+
_BULLET_CURLY = re.compile(r'^\s*-\s*[\u201C\u2018]')
|
|
65
|
+
_BULLET_SINGLE_Q = re.compile(r"^\s*-\s*'")
|
|
66
|
+
|
|
67
|
+
#: Phrase-normaliser: drop non-word/space, collapse whitespace, lowercase.
|
|
68
|
+
_NORM_PUNCT = re.compile(r"[^\w\s]")
|
|
69
|
+
_NORM_WS = re.compile(r"\s+")
|
|
70
|
+
|
|
71
|
+
#: Anchor comment per section.
|
|
72
|
+
_ANCHOR = "<!-- intake-anchor: {key} -->"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CorpusParseError(ValueError):
|
|
76
|
+
"""Structural anomaly in the low-impact-decisions corpus.
|
|
77
|
+
|
|
78
|
+
Attributes:
|
|
79
|
+
reason: Stable machine-readable failure tag (see module docstring).
|
|
80
|
+
line: 1-based line number of the offending content, or ``None``
|
|
81
|
+
when the failure is file-level (missing anchor, etc.).
|
|
82
|
+
section: Section the anomaly was found in, when known.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self, reason: str, *,
|
|
87
|
+
line: int | None = None,
|
|
88
|
+
section: Section | None = None,
|
|
89
|
+
detail: str = "",
|
|
90
|
+
) -> None:
|
|
91
|
+
self.reason = reason
|
|
92
|
+
self.line = line
|
|
93
|
+
self.section = section
|
|
94
|
+
self.detail = detail
|
|
95
|
+
loc = f" at line {line}" if line is not None else ""
|
|
96
|
+
sec = f" in section '{section}'" if section else ""
|
|
97
|
+
msg = f"corpus parse failed: {reason}{loc}{sec}"
|
|
98
|
+
if detail:
|
|
99
|
+
msg += f" — {detail}"
|
|
100
|
+
super().__init__(msg)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class CorpusEntry:
|
|
105
|
+
"""One parsed bullet entry from a section."""
|
|
106
|
+
phrase: str
|
|
107
|
+
normalised: str
|
|
108
|
+
section: Section
|
|
109
|
+
line_no: int
|
|
110
|
+
trailing_metadata: str = ""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class CorpusParseResult:
|
|
115
|
+
"""Outcome of :func:`parse_corpus_strict`."""
|
|
116
|
+
validated: tuple[CorpusEntry, ...] = ()
|
|
117
|
+
probation: tuple[CorpusEntry, ...] = ()
|
|
118
|
+
anti_examples: tuple[CorpusEntry, ...] = ()
|
|
119
|
+
warnings: tuple[str, ...] = field(default_factory=tuple)
|
|
120
|
+
|
|
121
|
+
def phrases(self, section: Section) -> tuple[str, ...]:
|
|
122
|
+
entries: Iterable[CorpusEntry] = getattr(self, section)
|
|
123
|
+
return tuple(e.normalised for e in entries)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _normalise(phrase: str) -> str:
|
|
127
|
+
return _NORM_WS.sub(" ", _NORM_PUNCT.sub(" ", phrase.lower())).strip()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _section_bounds(
|
|
131
|
+
text: str, title: str, *, all_titles: tuple[str, ...],
|
|
132
|
+
) -> tuple[int, int] | None:
|
|
133
|
+
"""Return ``(body_start, body_end)`` for the named section, or ``None``."""
|
|
134
|
+
needle = f"## {title}"
|
|
135
|
+
idx = text.find("\n" + needle)
|
|
136
|
+
if idx < 0 and text.startswith(needle):
|
|
137
|
+
idx = 0
|
|
138
|
+
else:
|
|
139
|
+
idx = idx + 1 if idx >= 0 else -1
|
|
140
|
+
if idx < 0:
|
|
141
|
+
return None
|
|
142
|
+
line_end = text.find("\n", idx)
|
|
143
|
+
if line_end < 0:
|
|
144
|
+
return None
|
|
145
|
+
body_start = line_end + 1
|
|
146
|
+
end = len(text)
|
|
147
|
+
for other in all_titles:
|
|
148
|
+
if other == title:
|
|
149
|
+
continue
|
|
150
|
+
j = text.find("\n## " + other, body_start)
|
|
151
|
+
if 0 <= j < end:
|
|
152
|
+
end = j
|
|
153
|
+
return body_start, end
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _line_no_at(text: str, offset: int) -> int:
|
|
158
|
+
return text.count("\n", 0, offset) + 1
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _scan_section(
|
|
162
|
+
text: str,
|
|
163
|
+
section: Section,
|
|
164
|
+
*,
|
|
165
|
+
body_start: int,
|
|
166
|
+
body_end: int,
|
|
167
|
+
strict: bool,
|
|
168
|
+
) -> tuple[tuple[CorpusEntry, ...], tuple[str, ...]]:
|
|
169
|
+
"""Parse one section body into entries; raise or warn per ``strict``."""
|
|
170
|
+
title = _SECTION_TITLES[section]
|
|
171
|
+
entries: list[CorpusEntry] = []
|
|
172
|
+
warnings: list[str] = []
|
|
173
|
+
body = text[body_start:body_end]
|
|
174
|
+
base_line = _line_no_at(text, body_start)
|
|
175
|
+
for offset, raw_line in enumerate(body.splitlines()):
|
|
176
|
+
line_no = base_line + offset
|
|
177
|
+
stripped = raw_line.strip()
|
|
178
|
+
if not stripped or stripped.startswith("<!--") or stripped.startswith("#"):
|
|
179
|
+
continue
|
|
180
|
+
if not stripped.startswith("-") and not _BULLET_BAD_MARKER.match(raw_line):
|
|
181
|
+
# Free-form paragraph text; ignored by both modes.
|
|
182
|
+
continue
|
|
183
|
+
if _BULLET_BAD_MARKER.match(raw_line):
|
|
184
|
+
reason = "non_dash_bullet"
|
|
185
|
+
if strict:
|
|
186
|
+
raise CorpusParseError(
|
|
187
|
+
reason, line=line_no, section=section,
|
|
188
|
+
detail=f"expected '- \"\u2026\"' bullet, got: {stripped[:40]!r}",
|
|
189
|
+
)
|
|
190
|
+
warnings.append(f"line {line_no}: {reason} (section={section})")
|
|
191
|
+
continue
|
|
192
|
+
if _BULLET_CURLY.match(raw_line):
|
|
193
|
+
reason = "curly_quotes"
|
|
194
|
+
if strict:
|
|
195
|
+
raise CorpusParseError(
|
|
196
|
+
reason, line=line_no, section=section,
|
|
197
|
+
detail="use ASCII double quotes (\")",
|
|
198
|
+
)
|
|
199
|
+
warnings.append(f"line {line_no}: {reason} (section={section})")
|
|
200
|
+
continue
|
|
201
|
+
if _BULLET_SINGLE_Q.match(raw_line):
|
|
202
|
+
reason = "single_quotes"
|
|
203
|
+
if strict:
|
|
204
|
+
raise CorpusParseError(
|
|
205
|
+
reason, line=line_no, section=section,
|
|
206
|
+
detail="use ASCII double quotes (\")",
|
|
207
|
+
)
|
|
208
|
+
warnings.append(f"line {line_no}: {reason} (section={section})")
|
|
209
|
+
continue
|
|
210
|
+
m = _BULLET_OK.match(raw_line)
|
|
211
|
+
if not m:
|
|
212
|
+
# Dash-bullet but no closed double-quoted phrase \u2192 unclosed quote.
|
|
213
|
+
if stripped.startswith('- "') or stripped.startswith('-"'):
|
|
214
|
+
reason = "unclosed_quote"
|
|
215
|
+
if strict:
|
|
216
|
+
raise CorpusParseError(
|
|
217
|
+
reason, line=line_no, section=section,
|
|
218
|
+
detail="missing closing quote on bullet",
|
|
219
|
+
)
|
|
220
|
+
warnings.append(f"line {line_no}: {reason} (section={section})")
|
|
221
|
+
continue
|
|
222
|
+
# Dash bullet without any quotes at all \u2192 treat as drift.
|
|
223
|
+
reason = "non_dash_bullet"
|
|
224
|
+
if strict:
|
|
225
|
+
raise CorpusParseError(
|
|
226
|
+
reason, line=line_no, section=section,
|
|
227
|
+
detail=f"expected '- \"\u2026\"' bullet, got: {stripped[:40]!r}",
|
|
228
|
+
)
|
|
229
|
+
warnings.append(f"line {line_no}: {reason} (section={section})")
|
|
230
|
+
continue
|
|
231
|
+
phrase = m.group(1)
|
|
232
|
+
trailing = m.group(2).strip() if m.lastindex and m.lastindex >= 2 else ""
|
|
233
|
+
norm = _normalise(phrase)
|
|
234
|
+
if not norm:
|
|
235
|
+
reason = "empty_phrase"
|
|
236
|
+
if strict:
|
|
237
|
+
raise CorpusParseError(
|
|
238
|
+
reason, line=line_no, section=section,
|
|
239
|
+
detail="phrase normalises to empty",
|
|
240
|
+
)
|
|
241
|
+
warnings.append(f"line {line_no}: {reason} (section={section})")
|
|
242
|
+
continue
|
|
243
|
+
entries.append(
|
|
244
|
+
CorpusEntry(
|
|
245
|
+
phrase=phrase,
|
|
246
|
+
normalised=norm,
|
|
247
|
+
section=section,
|
|
248
|
+
line_no=line_no,
|
|
249
|
+
trailing_metadata=trailing,
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
_ = title # silence linter: title surfaced via section enum.
|
|
253
|
+
return tuple(entries), tuple(warnings)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _check_heading_drift(text: str, title: str) -> tuple[int | None, str | None]:
|
|
258
|
+
"""Return ``(line, detail)`` if a near-miss heading is found, else
|
|
259
|
+
``(None, None)``. Detects ``### Validated``, ``## Validated:``, etc.
|
|
260
|
+
|
|
261
|
+
Only reports drift when no canonical heading is also present.
|
|
262
|
+
"""
|
|
263
|
+
canonical = f"## {title}"
|
|
264
|
+
if "\n" + canonical + "\n" in text or text.startswith(canonical + "\n"):
|
|
265
|
+
return None, None
|
|
266
|
+
# Search for any heading line containing the title text.
|
|
267
|
+
pattern = re.compile(
|
|
268
|
+
rf"^(#+)\s+{re.escape(title)}([^\n]*)$", re.MULTILINE,
|
|
269
|
+
)
|
|
270
|
+
m = pattern.search(text)
|
|
271
|
+
if not m:
|
|
272
|
+
return None, None
|
|
273
|
+
line_no = _line_no_at(text, m.start())
|
|
274
|
+
hashes = m.group(1)
|
|
275
|
+
tail = m.group(2)
|
|
276
|
+
if hashes != "##" or tail.strip():
|
|
277
|
+
return line_no, f"got '{m.group(0).strip()}', expected '{canonical}'"
|
|
278
|
+
return None, None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def parse_corpus_strict(corpus_path: "object") -> CorpusParseResult:
|
|
282
|
+
"""Parse the corpus, raising :class:`CorpusParseError` on anomalies.
|
|
283
|
+
|
|
284
|
+
A missing file is **not** an error \u2014 it returns an empty result.
|
|
285
|
+
A present file with structural drift raises.
|
|
286
|
+
"""
|
|
287
|
+
p = Path(str(corpus_path))
|
|
288
|
+
if not p.exists():
|
|
289
|
+
return CorpusParseResult()
|
|
290
|
+
text = p.read_text(encoding="utf-8")
|
|
291
|
+
all_titles = tuple(_SECTION_TITLES.values())
|
|
292
|
+
result_sections: dict[Section, tuple[CorpusEntry, ...]] = {
|
|
293
|
+
"validated": (), "probation": (), "anti_examples": (),
|
|
294
|
+
}
|
|
295
|
+
all_warnings: list[str] = []
|
|
296
|
+
found_any = False
|
|
297
|
+
for section, title in _SECTION_TITLES.items():
|
|
298
|
+
drift_line, drift_detail = _check_heading_drift(text, title)
|
|
299
|
+
if drift_line is not None:
|
|
300
|
+
raise CorpusParseError(
|
|
301
|
+
"heading_drift", line=drift_line, section=section,
|
|
302
|
+
detail=drift_detail or "",
|
|
303
|
+
)
|
|
304
|
+
bounds = _section_bounds(text, title, all_titles=all_titles)
|
|
305
|
+
if bounds is None:
|
|
306
|
+
continue
|
|
307
|
+
found_any = True
|
|
308
|
+
body_start, body_end = bounds
|
|
309
|
+
entries, warns = _scan_section(
|
|
310
|
+
text, section,
|
|
311
|
+
body_start=body_start, body_end=body_end, strict=True,
|
|
312
|
+
)
|
|
313
|
+
result_sections[section] = entries
|
|
314
|
+
all_warnings.extend(warns)
|
|
315
|
+
# Anchor presence is checked once we have at least one section.
|
|
316
|
+
if found_any:
|
|
317
|
+
for section in ("validated", "probation"):
|
|
318
|
+
anchor = _ANCHOR.format(key=section)
|
|
319
|
+
if anchor not in text:
|
|
320
|
+
raise CorpusParseError(
|
|
321
|
+
"missing_anchor", section=section, # type: ignore[arg-type]
|
|
322
|
+
detail=f"expected marker {anchor!r}",
|
|
323
|
+
)
|
|
324
|
+
return CorpusParseResult(
|
|
325
|
+
validated=result_sections["validated"],
|
|
326
|
+
probation=result_sections["probation"],
|
|
327
|
+
anti_examples=result_sections["anti_examples"],
|
|
328
|
+
warnings=tuple(all_warnings),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def load_validated_phrases(corpus_path: "object") -> tuple[str, ...]:
|
|
333
|
+
"""Back-compat shim used by routing (lenient mode).
|
|
334
|
+
|
|
335
|
+
Step-10: prefers the YAML lockfile (``<corpus>.lock.yaml`` sibling
|
|
336
|
+
of ``corpus_path``) when present. Falls back to lenient Markdown
|
|
337
|
+
parsing when the lockfile is missing (fresh clone before
|
|
338
|
+
``task sync``, or callers that haven't run the compiler).
|
|
339
|
+
|
|
340
|
+
Silently drops malformed lines so a broken corpus never blocks
|
|
341
|
+
classification. Strict-mode contract validation lives in
|
|
342
|
+
:func:`parse_corpus_strict` and the CI lint job.
|
|
343
|
+
"""
|
|
344
|
+
yaml_phrases = _load_section_from_lock(corpus_path, "validated")
|
|
345
|
+
if yaml_phrases is not None:
|
|
346
|
+
return yaml_phrases
|
|
347
|
+
return _load_section_lenient(corpus_path, "validated")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def load_anti_example_phrases(corpus_path: "object") -> tuple[str, ...]:
|
|
351
|
+
"""Lenient loader for the ``Anti-Examples`` section (step-9 P5).
|
|
352
|
+
|
|
353
|
+
Step-10: prefers the YAML lockfile (see :func:`load_validated_phrases`).
|
|
354
|
+
|
|
355
|
+
Mirrors :func:`load_validated_phrases` for the anti-example
|
|
356
|
+
bucket. Consumed by the fuzzy-match classifier to apply the
|
|
357
|
+
anti-example-veto: if the query is at least as similar to an
|
|
358
|
+
anti-example as to a validated phrase, the match is rejected.
|
|
359
|
+
"""
|
|
360
|
+
yaml_phrases = _load_section_from_lock(corpus_path, "anti_examples")
|
|
361
|
+
if yaml_phrases is not None:
|
|
362
|
+
return yaml_phrases
|
|
363
|
+
return _load_section_lenient(corpus_path, "anti_examples")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def load_corpus_lock(yaml_path: "object") -> CorpusParseResult:
|
|
367
|
+
"""Load a step-10 YAML lockfile and re-materialise a :class:`CorpusParseResult`.
|
|
368
|
+
|
|
369
|
+
Returns an empty result if the file does not exist (matches the
|
|
370
|
+
Markdown-source contract). Malformed YAML raises ``yaml.YAMLError``;
|
|
371
|
+
a schema-version mismatch raises :class:`CorpusParseError` so
|
|
372
|
+
consumers see the same typed failure they would from the Markdown
|
|
373
|
+
parser. Lenient callers (:func:`load_validated_phrases`,
|
|
374
|
+
:func:`load_anti_example_phrases`) catch these errors and fall back
|
|
375
|
+
to lenient Markdown parsing.
|
|
376
|
+
"""
|
|
377
|
+
import yaml # local import: parser path stays import-light
|
|
378
|
+
|
|
379
|
+
p = Path(str(yaml_path))
|
|
380
|
+
if not p.exists():
|
|
381
|
+
return CorpusParseResult()
|
|
382
|
+
doc = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
|
383
|
+
schema_version = doc.get("schema_version")
|
|
384
|
+
if schema_version != 1:
|
|
385
|
+
raise CorpusParseError(
|
|
386
|
+
"schema_version_mismatch",
|
|
387
|
+
detail=f"expected schema_version=1, got {schema_version!r}",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
def _entries(key: Section) -> tuple[CorpusEntry, ...]:
|
|
391
|
+
return tuple(
|
|
392
|
+
CorpusEntry(
|
|
393
|
+
phrase=item.get("phrase", ""),
|
|
394
|
+
normalised=item.get("normalised", ""),
|
|
395
|
+
section=key,
|
|
396
|
+
line_no=int(item.get("line_no", 0)),
|
|
397
|
+
trailing_metadata=item.get("trailing_metadata", ""),
|
|
398
|
+
)
|
|
399
|
+
for item in (doc.get(key) or ())
|
|
400
|
+
if item.get("normalised")
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return CorpusParseResult(
|
|
404
|
+
validated=_entries("validated"),
|
|
405
|
+
probation=_entries("probation"),
|
|
406
|
+
anti_examples=_entries("anti_examples"),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _derive_lock_path(corpus_path: Path) -> Path:
|
|
411
|
+
"""Return the sibling lockfile path for a given corpus Markdown path."""
|
|
412
|
+
if corpus_path.suffix == ".yaml":
|
|
413
|
+
return corpus_path
|
|
414
|
+
if corpus_path.name.endswith(".lock.yaml"):
|
|
415
|
+
return corpus_path
|
|
416
|
+
stem = corpus_path.name[: -len(corpus_path.suffix)] if corpus_path.suffix else corpus_path.name
|
|
417
|
+
return corpus_path.with_name(f"{stem}.lock.yaml")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _load_section_from_lock(
|
|
421
|
+
corpus_path: "object", section: Section,
|
|
422
|
+
) -> "tuple[str, ...] | None":
|
|
423
|
+
"""Read ``section`` phrases from the sibling lockfile.
|
|
424
|
+
|
|
425
|
+
Returns ``None`` when the lockfile is absent or malformed so the
|
|
426
|
+
caller can fall back to lenient Markdown parsing.
|
|
427
|
+
"""
|
|
428
|
+
p = Path(str(corpus_path))
|
|
429
|
+
lock_path = _derive_lock_path(p)
|
|
430
|
+
if not lock_path.exists():
|
|
431
|
+
return None
|
|
432
|
+
try:
|
|
433
|
+
result = load_corpus_lock(lock_path)
|
|
434
|
+
except (CorpusParseError, Exception): # noqa: BLE001
|
|
435
|
+
return None
|
|
436
|
+
return result.phrases(section)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _load_section_lenient(corpus_path: "object", section: Section) -> tuple[str, ...]:
|
|
440
|
+
p = Path(str(corpus_path))
|
|
441
|
+
if not p.exists():
|
|
442
|
+
return ()
|
|
443
|
+
text = p.read_text(encoding="utf-8")
|
|
444
|
+
all_titles = tuple(_SECTION_TITLES.values())
|
|
445
|
+
bounds = _section_bounds(
|
|
446
|
+
text, _SECTION_TITLES[section], all_titles=all_titles,
|
|
447
|
+
)
|
|
448
|
+
if bounds is None:
|
|
449
|
+
return ()
|
|
450
|
+
entries, _ = _scan_section(
|
|
451
|
+
text, section,
|
|
452
|
+
body_start=bounds[0], body_end=bounds[1], strict=False,
|
|
453
|
+
)
|
|
454
|
+
return tuple(e.normalised for e in entries)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
__all__ = (
|
|
458
|
+
"CorpusEntry",
|
|
459
|
+
"CorpusParseError",
|
|
460
|
+
"CorpusParseResult",
|
|
461
|
+
"Section",
|
|
462
|
+
"load_anti_example_phrases",
|
|
463
|
+
"load_corpus_lock",
|
|
464
|
+
"load_validated_phrases",
|
|
465
|
+
"parse_corpus_strict",
|
|
466
|
+
)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Intake trigger + dedup for `agents/low-impact-decisions.md` (Phase 12).
|
|
2
|
+
|
|
3
|
+
User signals "leichte Frage" / "low-impact question" / equivalents
|
|
4
|
+
(see :data:`TRIGGER_PHRASES`); the host agent collects the
|
|
5
|
+
most-recently-asked question, translates to English, runs the privacy
|
|
6
|
+
redactor, and routes the result through this module.
|
|
7
|
+
|
|
8
|
+
Behaviour (per Phase 12 § Step 2):
|
|
9
|
+
|
|
10
|
+
- Normalise: lowercase, strip punctuation, collapse whitespace.
|
|
11
|
+
- Match against ``## On Probation`` → append today's UTC date to that
|
|
12
|
+
entry's ``seen`` array (idempotent on same-day re-append).
|
|
13
|
+
- Match against ``## Validated`` → no-op, returns
|
|
14
|
+
:class:`IntakeOutcome` with ``kind="duplicate_validated"``.
|
|
15
|
+
- No match → append a fresh entry under ``## On Probation`` with
|
|
16
|
+
``first-seen <today>`` and ``seen [<today>]``.
|
|
17
|
+
|
|
18
|
+
The promotion / pruning step is :mod:`probation_gate`, called by the
|
|
19
|
+
caller after intake (or at council startup).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Literal
|
|
29
|
+
|
|
30
|
+
#: User trigger phrases (DE + EN) — substring match, lowercase.
|
|
31
|
+
TRIGGER_PHRASES: tuple[str, ...] = (
|
|
32
|
+
# German
|
|
33
|
+
"das ist eine leichte frage",
|
|
34
|
+
"eine leichte frage",
|
|
35
|
+
"mach das selber",
|
|
36
|
+
"lös das selber",
|
|
37
|
+
"löse das im council",
|
|
38
|
+
"frag das council",
|
|
39
|
+
# English
|
|
40
|
+
"low-impact question",
|
|
41
|
+
"low impact question",
|
|
42
|
+
"council should answer this",
|
|
43
|
+
"you should know this yourself",
|
|
44
|
+
"ask the council",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_PROBATION_HEADER = "## On Probation"
|
|
48
|
+
_VALIDATED_HEADER = "## Validated"
|
|
49
|
+
_ANTI_HEADER = "## Anti-Examples (Always Ask User)"
|
|
50
|
+
_NORMALISE_PUNCT_RE = re.compile(r"[^\w\s]")
|
|
51
|
+
_WHITESPACE_RE = re.compile(r"\s+")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class IntakeOutcome:
|
|
56
|
+
kind: Literal[
|
|
57
|
+
"appended_seen", "new_probation", "duplicate_validated", "noop"
|
|
58
|
+
]
|
|
59
|
+
question: str
|
|
60
|
+
today: str
|
|
61
|
+
note: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def matches_trigger(user_text: str) -> bool:
|
|
65
|
+
"""True when ``user_text`` carries any intake trigger phrase."""
|
|
66
|
+
lo = user_text.lower()
|
|
67
|
+
return any(p in lo for p in TRIGGER_PHRASES)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalise(question: str) -> str:
|
|
71
|
+
"""Lowercase, strip punctuation, collapse whitespace."""
|
|
72
|
+
out = question.lower().strip()
|
|
73
|
+
out = _NORMALISE_PUNCT_RE.sub(" ", out)
|
|
74
|
+
out = _WHITESPACE_RE.sub(" ", out)
|
|
75
|
+
return out.strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _today() -> str:
|
|
79
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _split_sections(text: str) -> dict[str, tuple[int, int]]:
|
|
83
|
+
"""Return {header: (body_start, body_end)} char offsets."""
|
|
84
|
+
headers = [_PROBATION_HEADER, _VALIDATED_HEADER, _ANTI_HEADER]
|
|
85
|
+
spans: dict[str, tuple[int, int]] = {}
|
|
86
|
+
for h in headers:
|
|
87
|
+
i = text.find(h)
|
|
88
|
+
if i < 0:
|
|
89
|
+
continue
|
|
90
|
+
body_start = text.find("\n", i) + 1
|
|
91
|
+
next_header_i = len(text)
|
|
92
|
+
for other in headers + ["## Security", "## Provenance"]:
|
|
93
|
+
j = text.find("\n" + other, body_start)
|
|
94
|
+
if 0 <= j < next_header_i:
|
|
95
|
+
next_header_i = j
|
|
96
|
+
spans[h] = (body_start, next_header_i)
|
|
97
|
+
return spans
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_entries(body: str) -> list[tuple[str, str]]:
|
|
101
|
+
"""Return [(quoted_question, full_line)] for ``- "…" — …`` bullets."""
|
|
102
|
+
out: list[tuple[str, str]] = []
|
|
103
|
+
for line in body.splitlines():
|
|
104
|
+
m = re.match(r'^\s*-\s*"([^"]+)"', line)
|
|
105
|
+
if m:
|
|
106
|
+
out.append((m.group(1), line))
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def record_intake(
|
|
111
|
+
corpus_path: Path,
|
|
112
|
+
question: str,
|
|
113
|
+
*,
|
|
114
|
+
today: str | None = None,
|
|
115
|
+
) -> IntakeOutcome:
|
|
116
|
+
"""Append intake signal to the corpus. Pure-text, deterministic."""
|
|
117
|
+
today = today or _today()
|
|
118
|
+
text = corpus_path.read_text(encoding="utf-8")
|
|
119
|
+
norm_q = normalise(question)
|
|
120
|
+
spans = _split_sections(text)
|
|
121
|
+
|
|
122
|
+
if _VALIDATED_HEADER in spans:
|
|
123
|
+
s, e = spans[_VALIDATED_HEADER]
|
|
124
|
+
for q, _ in _parse_entries(text[s:e]):
|
|
125
|
+
if normalise(q) == norm_q:
|
|
126
|
+
return IntakeOutcome("duplicate_validated", question, today,
|
|
127
|
+
"already learned")
|
|
128
|
+
|
|
129
|
+
if _PROBATION_HEADER in spans:
|
|
130
|
+
s, e = spans[_PROBATION_HEADER]
|
|
131
|
+
body = text[s:e]
|
|
132
|
+
for q, line in _parse_entries(body):
|
|
133
|
+
if normalise(q) == norm_q:
|
|
134
|
+
if today in line:
|
|
135
|
+
return IntakeOutcome("noop", question, today,
|
|
136
|
+
"already seen today")
|
|
137
|
+
new_line = _append_seen(line, today)
|
|
138
|
+
new_text = text[:s] + body.replace(line, new_line, 1) + text[e:]
|
|
139
|
+
corpus_path.write_text(new_text, encoding="utf-8")
|
|
140
|
+
return IntakeOutcome("appended_seen", question, today)
|
|
141
|
+
|
|
142
|
+
new_entry = (
|
|
143
|
+
f'- "{question.strip()}" — first-seen {today} '
|
|
144
|
+
f'· seen [{today}]\n'
|
|
145
|
+
)
|
|
146
|
+
new_text = text[:e].rstrip() + "\n\n" + new_entry + "\n" + text[e:]
|
|
147
|
+
corpus_path.write_text(new_text, encoding="utf-8")
|
|
148
|
+
return IntakeOutcome("new_probation", question, today)
|
|
149
|
+
|
|
150
|
+
return IntakeOutcome("noop", question, today, "probation section missing")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _append_seen(line: str, today: str) -> str:
|
|
154
|
+
"""Append ``today`` to the ``seen [...]`` array on a probation line."""
|
|
155
|
+
def _sub(m: re.Match[str]) -> str:
|
|
156
|
+
body = m.group(1).strip()
|
|
157
|
+
if today in body:
|
|
158
|
+
return m.group(0)
|
|
159
|
+
new_body = (body + ", " + today) if body else today
|
|
160
|
+
return f"seen [{new_body}]"
|
|
161
|
+
if "seen [" in line:
|
|
162
|
+
return re.sub(r"seen \[([^\]]*)\]", _sub, line)
|
|
163
|
+
return line.rstrip() + f" · seen [{today}]"
|
|
@@ -4,6 +4,11 @@ Each council member runs in exactly one transport mode per invocation:
|
|
|
4
4
|
|
|
5
5
|
- ``api`` — direct SDK call against the provider's API (billable).
|
|
6
6
|
- ``manual`` — copy-paste loop with the user as transport (free).
|
|
7
|
+
- ``cli`` — shell out to a locally-installed provider CLI under the
|
|
8
|
+
user's subscription auth (Claude Pro/Max, ChatGPT
|
|
9
|
+
Plus/Pro via Codex, Google Gemini, …). Spend is covered
|
|
10
|
+
by the flat-rate subscription, not per-token; the
|
|
11
|
+
``cost_budget`` gate is skipped (``billable=False``).
|
|
7
12
|
|
|
8
13
|
Resolution precedence — first non-empty wins:
|
|
9
14
|
|
|
@@ -23,7 +28,7 @@ from __future__ import annotations
|
|
|
23
28
|
|
|
24
29
|
from typing import Mapping
|
|
25
30
|
|
|
26
|
-
VALID_MODES: frozenset[str] = frozenset({"api", "manual"})
|
|
31
|
+
VALID_MODES: frozenset[str] = frozenset({"api", "manual", "cli"})
|
|
27
32
|
|
|
28
33
|
DEFAULT_MODE: str = "manual"
|
|
29
34
|
|