@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.
Files changed (107) hide show
  1. package/.agent-src/commands/council/analysis.md +142 -0
  2. package/.agent-src/commands/council/debate.md +129 -0
  3. package/.agent-src/commands/council/default.md +8 -0
  4. package/.agent-src/commands/council/design.md +16 -12
  5. package/.agent-src/commands/council/optimize.md +16 -15
  6. package/.agent-src/commands/council/pr.md +12 -12
  7. package/.agent-src/commands/council.md +48 -2
  8. package/.agent-src/commands/memory/learn-low-impact.md +143 -0
  9. package/.agent-src/personas/advisors/contrarian.md +95 -0
  10. package/.agent-src/personas/advisors/executor.md +99 -0
  11. package/.agent-src/personas/advisors/expansionist.md +98 -0
  12. package/.agent-src/personas/advisors/first-principles.md +98 -0
  13. package/.agent-src/personas/advisors/outsider.md +102 -0
  14. package/.agent-src/rules/ask-when-uncertain.md +10 -6
  15. package/.agent-src/rules/copilot-routing.md +19 -0
  16. package/.agent-src/rules/devcontainer-routing.md +20 -0
  17. package/.agent-src/rules/external-reference-deep-dive.md +1 -1
  18. package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
  19. package/.agent-src/rules/laravel-routing.md +20 -0
  20. package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
  21. package/.agent-src/rules/symfony-routing.md +20 -0
  22. package/.agent-src/skills/ai-council/SKILL.md +388 -10
  23. package/.agent-src/skills/copilot-config/SKILL.md +1 -1
  24. package/.agent-src/skills/devcontainer/SKILL.md +1 -1
  25. package/.agent-src/skills/laravel/SKILL.md +1 -1
  26. package/.agent-src/skills/project-analysis-core/SKILL.md +1 -1
  27. package/.agent-src/skills/project-analyzer/SKILL.md +1 -1
  28. package/.agent-src/skills/symfony-workflow/SKILL.md +1 -1
  29. package/.agent-src/skills/universal-project-analysis/SKILL.md +1 -1
  30. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  31. package/.claude-plugin/marketplace.json +4 -1
  32. package/AGENTS.md +1 -1
  33. package/CHANGELOG.md +346 -124
  34. package/CONTRIBUTING.md +5 -0
  35. package/README.md +6 -6
  36. package/config/agent-settings.template.yml +5 -93
  37. package/config/gitignore-block.txt +6 -0
  38. package/docs/architecture/multi-tool-projection.md +53 -0
  39. package/docs/architecture/{compression.md → source-projection.md} +21 -3
  40. package/docs/architecture.md +15 -15
  41. package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
  42. package/docs/catalog.md +25 -12
  43. package/docs/contracts/adr-architectural-consensus-mechanism.md +68 -0
  44. package/docs/contracts/adr-level-6-productization.md +7 -9
  45. package/docs/contracts/ai-council-config.md +658 -0
  46. package/docs/contracts/command-clusters.md +58 -2
  47. package/docs/contracts/command-surface-tiers.md +3 -2
  48. package/docs/contracts/cost-profile-defaults.md +5 -0
  49. package/docs/contracts/decision-engine-gates.md +5 -0
  50. package/docs/contracts/decision-trace-v1.md +2 -2
  51. package/docs/contracts/file-ownership-matrix.json +1735 -72
  52. package/docs/contracts/installed-tools-lockfile.md +2 -1
  53. package/docs/contracts/low-impact-corpus-format.md +95 -0
  54. package/docs/contracts/mcp-beta-criteria.md +6 -5
  55. package/docs/contracts/mcp-cloud-scope.md +5 -4
  56. package/docs/contracts/multi-tool-projection-fidelity.md +115 -0
  57. package/docs/contracts/release-trunk-sync.md +4 -3
  58. package/docs/contracts/tier-3-contrib-plugin.md +5 -6
  59. package/docs/getting-started.md +2 -2
  60. package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
  61. package/docs/installation.md +32 -0
  62. package/package.json +1 -1
  63. package/scripts/_archive/README.md +59 -0
  64. package/scripts/_cli/cmd_doctor.py +134 -0
  65. package/scripts/ai_council/_default_prices.py +10 -1
  66. package/scripts/ai_council/advisors.py +148 -0
  67. package/scripts/ai_council/airgap.py +165 -0
  68. package/scripts/ai_council/cli_hints.py +123 -0
  69. package/scripts/ai_council/clients.py +959 -5
  70. package/scripts/ai_council/compile_corpus.py +178 -0
  71. package/scripts/ai_council/confidence_gate.py +156 -0
  72. package/scripts/ai_council/config.py +1364 -0
  73. package/scripts/ai_council/consensus.py +329 -0
  74. package/scripts/ai_council/events_log.py +137 -0
  75. package/scripts/ai_council/learn_low_impact_preview.py +252 -0
  76. package/scripts/ai_council/low_impact.py +714 -0
  77. package/scripts/ai_council/low_impact_corpus.py +466 -0
  78. package/scripts/ai_council/low_impact_intake.py +163 -0
  79. package/scripts/ai_council/modes.py +6 -1
  80. package/scripts/ai_council/necessity.py +782 -0
  81. package/scripts/ai_council/orchestrator.py +872 -20
  82. package/scripts/ai_council/probation_gate.py +152 -0
  83. package/scripts/ai_council/prompts.py +335 -0
  84. package/scripts/ai_council/redact_low_impact_entry.py +155 -0
  85. package/scripts/ai_council/replay.py +155 -0
  86. package/scripts/ai_council/session.py +19 -1
  87. package/scripts/ai_council/shadow_dispatch.py +235 -0
  88. package/scripts/ai_council/solo_dispatch.py +226 -0
  89. package/scripts/audit_cloud_compatibility.py +74 -0
  90. package/scripts/audit_command_surface.py +363 -0
  91. package/scripts/check_compressed_paths.py +6 -1
  92. package/scripts/check_council_layout.py +11 -0
  93. package/scripts/ci_time_ratio.py +168 -0
  94. package/scripts/council_cli.py +2005 -30
  95. package/scripts/install.sh +12 -0
  96. package/scripts/measure_projection_bytes.py +159 -0
  97. package/scripts/measure_roadmap_trajectory.py +112 -0
  98. package/scripts/probe_projection_fidelity.py +202 -0
  99. package/scripts/score_skill_selection.py +198 -0
  100. package/scripts/skill_collision_clusters.py +162 -0
  101. /package/scripts/{_backfill_skill_domains.py → _archive/_backfill_skill_domains.py} +0 -0
  102. /package/scripts/{_bootstrap_tier_frontmatter.py → _archive/_bootstrap_tier_frontmatter.py} +0 -0
  103. /package/scripts/{_p43_bodies.py → _archive/_p43_bodies.py} +0 -0
  104. /package/scripts/{_p43_compress.py → _archive/_p43_compress.py} +0 -0
  105. /package/scripts/{_p4_migrate.py → _archive/_p4_migrate.py} +0 -0
  106. /package/scripts/{_phase2_shim_helper.py → _archive/_phase2_shim_helper.py} +0 -0
  107. /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