@event4u/agent-config 2.13.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 (64) hide show
  1. package/.agent-src/commands/memory/learn-low-impact.md +143 -0
  2. package/.agent-src/rules/ask-when-uncertain.md +10 -6
  3. package/.agent-src/rules/copilot-routing.md +1 -1
  4. package/.agent-src/rules/devcontainer-routing.md +1 -1
  5. package/.agent-src/rules/external-reference-deep-dive.md +1 -1
  6. package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
  7. package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
  8. package/.agent-src/rules/symfony-routing.md +1 -1
  9. package/.agent-src/skills/ai-council/SKILL.md +208 -8
  10. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/.claude-plugin/marketplace.json +2 -1
  12. package/CHANGELOG.md +299 -124
  13. package/README.md +6 -6
  14. package/config/gitignore-block.txt +6 -0
  15. package/docs/architecture.md +12 -12
  16. package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
  17. package/docs/catalog.md +10 -7
  18. package/docs/contracts/adr-architectural-consensus-mechanism.md +4 -3
  19. package/docs/contracts/adr-level-6-productization.md +7 -9
  20. package/docs/contracts/ai-council-config.md +492 -20
  21. package/docs/contracts/command-clusters.md +1 -1
  22. package/docs/contracts/command-surface-tiers.md +3 -2
  23. package/docs/contracts/cost-profile-defaults.md +5 -0
  24. package/docs/contracts/decision-engine-gates.md +5 -0
  25. package/docs/contracts/decision-trace-v1.md +2 -2
  26. package/docs/contracts/file-ownership-matrix.json +1735 -72
  27. package/docs/contracts/installed-tools-lockfile.md +2 -1
  28. package/docs/contracts/low-impact-corpus-format.md +95 -0
  29. package/docs/contracts/mcp-beta-criteria.md +6 -5
  30. package/docs/contracts/mcp-cloud-scope.md +5 -4
  31. package/docs/contracts/multi-tool-projection-fidelity.md +8 -2
  32. package/docs/contracts/release-trunk-sync.md +4 -3
  33. package/docs/contracts/tier-3-contrib-plugin.md +5 -6
  34. package/docs/getting-started.md +2 -2
  35. package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
  36. package/docs/installation.md +32 -0
  37. package/package.json +1 -1
  38. package/scripts/_cli/cmd_doctor.py +134 -0
  39. package/scripts/ai_council/airgap.py +165 -0
  40. package/scripts/ai_council/cli_hints.py +123 -0
  41. package/scripts/ai_council/clients.py +787 -5
  42. package/scripts/ai_council/compile_corpus.py +178 -0
  43. package/scripts/ai_council/confidence_gate.py +156 -0
  44. package/scripts/ai_council/config.py +1007 -11
  45. package/scripts/ai_council/consensus.py +41 -2
  46. package/scripts/ai_council/events_log.py +137 -0
  47. package/scripts/ai_council/learn_low_impact_preview.py +252 -0
  48. package/scripts/ai_council/low_impact.py +714 -0
  49. package/scripts/ai_council/low_impact_corpus.py +466 -0
  50. package/scripts/ai_council/low_impact_intake.py +163 -0
  51. package/scripts/ai_council/modes.py +6 -1
  52. package/scripts/ai_council/necessity.py +782 -0
  53. package/scripts/ai_council/orchestrator.py +252 -14
  54. package/scripts/ai_council/probation_gate.py +152 -0
  55. package/scripts/ai_council/redact_low_impact_entry.py +155 -0
  56. package/scripts/ai_council/replay.py +155 -0
  57. package/scripts/ai_council/session.py +19 -1
  58. package/scripts/ai_council/shadow_dispatch.py +235 -0
  59. package/scripts/ai_council/solo_dispatch.py +226 -0
  60. package/scripts/audit_cloud_compatibility.py +74 -0
  61. package/scripts/audit_command_surface.py +363 -0
  62. package/scripts/check_council_layout.py +11 -0
  63. package/scripts/council_cli.py +1046 -15
  64. package/scripts/install.sh +12 -0
@@ -57,15 +57,42 @@ class FindingScore:
57
57
  reason: str
58
58
 
59
59
 
60
+ def evidence_quality(mean_score: float) -> str:
61
+ """Classify mean score into a single-letter evidence-quality bucket.
62
+
63
+ H (high) — mean ≥ 8.0; member agreement ran high.
64
+ M (medium) — 6.0 ≤ mean < 8.0; majority support, mixed conviction.
65
+ L (low) — mean < 6.0 or no scorers; weak or contested.
66
+
67
+ Used by Phase 9 to surface a quick "how much did members back this"
68
+ signal next to the raw consensus_strength number.
69
+ """
70
+ if mean_score >= 8.0:
71
+ return "H"
72
+ if mean_score >= 6.0:
73
+ return "M"
74
+ return "L"
75
+
76
+
60
77
  @dataclass(frozen=True)
61
78
  class ConsensusMetadata:
62
- """Aggregate consensus stats for a single finding."""
79
+ """Aggregate consensus stats for a single finding.
80
+
81
+ Phase 9 adds ``concur_count``, ``dissent_reasons`` (per-scorer
82
+ one-line rationales for disagreement), and ``evidence_quality``
83
+ (H/M/L bucket of the mean score) so the renderer can emit
84
+ "N/M members concur; X dissented citing …; mean evidence-quality H"
85
+ without needing the underlying FindingScore list.
86
+ """
63
87
 
64
88
  finding_id: str
65
89
  consensus_strength: float # 0..1
66
90
  dissent_count: int
67
91
  scorers: tuple[str, ...]
68
92
  mean_score: float
93
+ concur_count: int = 0
94
+ dissent_reasons: tuple[tuple[str, str], ...] = () # (scorer, reason)
95
+ evidence_quality: str = "L"
69
96
 
70
97
 
71
98
  @dataclass(frozen=True)
@@ -103,17 +130,28 @@ def aggregate_scores(
103
130
  out[fid] = ConsensusMetadata(
104
131
  finding_id=fid, consensus_strength=0.0,
105
132
  dissent_count=0, scorers=(), mean_score=0.0,
133
+ concur_count=0, dissent_reasons=(), evidence_quality="L",
106
134
  )
107
135
  continue
108
136
  mean = sum(s.score for s in fs) / len(fs)
109
137
  agree_rate = sum(1 for s in fs if s.agree) / len(fs)
110
138
  strength = (mean / 10.0) * agree_rate
111
139
  dissent = sum(1 for s in fs if not s.agree)
140
+ concur = sum(1 for s in fs if s.agree)
112
141
  scorers = tuple(s.scorer for s in fs)
142
+ # Phase 9 — collect (scorer, reason) pairs for dissenters only,
143
+ # in scoring order, so the renderer surfaces who pushed back
144
+ # and why without re-walking the FindingScore list.
145
+ dissent_reasons = tuple(
146
+ (s.scorer, s.reason) for s in fs if not s.agree
147
+ )
148
+ mean_rounded = round(mean, 2)
113
149
  out[fid] = ConsensusMetadata(
114
150
  finding_id=fid, consensus_strength=round(strength, 3),
115
151
  dissent_count=dissent, scorers=scorers,
116
- mean_score=round(mean, 2),
152
+ mean_score=mean_rounded,
153
+ concur_count=concur, dissent_reasons=dissent_reasons,
154
+ evidence_quality=evidence_quality(mean_rounded),
117
155
  )
118
156
  return out
119
157
 
@@ -143,6 +181,7 @@ def bucket_by_threshold(
143
181
  m = ConsensusMetadata(
144
182
  finding_id=f.id, consensus_strength=0.0,
145
183
  dissent_count=0, scorers=(), mean_score=0.0,
184
+ concur_count=0, dissent_reasons=(), evidence_quality="L",
146
185
  )
147
186
  if m.consensus_strength > strong:
148
187
  bucket.strong.append((f, m))
@@ -0,0 +1,137 @@
1
+ """Persistent council events log (step-8 phase 3).
2
+
3
+ Single-function module that appends one JSON line per council event to
4
+ ``<project_root>/agents/council-events.log``. Schema v1 carries the
5
+ minimum needed to answer the "why did the council skip / block this?"
6
+ question at retro time without leaking prompt content.
7
+
8
+ Privacy floor:
9
+ ``original_ask`` is never written verbatim — the caller passes the
10
+ raw string, and :func:`append_event` writes ``sha256(value)[:12]``
11
+ as ``original_ask_hash``. Mirrors the privacy floor in
12
+ ``agents/low-impact-decisions.md``.
13
+
14
+ Kill-switch:
15
+ ``AGENT_CONFIG_NO_EVENTS_LOG=1`` short-circuits :func:`append_event`
16
+ to a no-op. Mirrors Step 7's ``AGENT_CONFIG_LEGACY_ANCHOR=1``
17
+ pattern. Tested via env-var override; the agent never reads or
18
+ parses the log itself.
19
+
20
+ See: ``agents/roadmaps/step-8-quota-necessity-transparency.md`` (D3,
21
+ D5) and ``docs/contracts/ai-council-config.md``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import json
28
+ import os
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+ from typing import Any, Literal
32
+
33
+ SCHEMA_VERSION = 1
34
+
35
+ EventAction = Literal["proceed", "skip_necessity", "block_quota"]
36
+
37
+ _VALID_ACTIONS: frozenset[str] = frozenset(
38
+ {"proceed", "skip_necessity", "block_quota"},
39
+ )
40
+
41
+ #: Environment-variable kill-switch. Truthy values disable all writes;
42
+ #: the function silently returns. Designed for CI / sandboxed runs and
43
+ #: privacy-conscious power users.
44
+ _KILL_SWITCH_ENV = "AGENT_CONFIG_NO_EVENTS_LOG"
45
+
46
+ #: Default log path, resolved relative to the package root (two levels
47
+ #: above ``scripts/ai_council/``). Callers can override via
48
+ #: ``log_path=`` for tests.
49
+ _DEFAULT_LOG_PATH = (
50
+ Path(__file__).resolve().parents[2] / "agents" / "council-events.log"
51
+ )
52
+
53
+
54
+ def _hash_original_ask(original_ask: str) -> str:
55
+ """Return sha256(original_ask)[:12] — the privacy-floor hash.
56
+
57
+ Empty / missing input maps to a stable sentinel so the schema field
58
+ is always populated.
59
+ """
60
+ if not original_ask:
61
+ return "0" * 12
62
+ return hashlib.sha256(
63
+ original_ask.encode("utf-8", errors="replace"),
64
+ ).hexdigest()[:12]
65
+
66
+
67
+ def _kill_switch_active() -> bool:
68
+ value = os.environ.get(_KILL_SWITCH_ENV, "")
69
+ return value not in ("", "0", "false", "False")
70
+
71
+
72
+ def append_event(
73
+ event: dict[str, Any], *, log_path: Path | None = None,
74
+ ) -> bool:
75
+ """Append a single JSON event line to the council events log.
76
+
77
+ Args:
78
+ event: Mapping with the v1 schema fields. Required keys:
79
+ ``lens``, ``invocation``, ``action``, ``verdict``,
80
+ ``provider_caps``, ``original_ask``. The function injects
81
+ ``schema_version``, ``ts_utc``, and replaces
82
+ ``original_ask`` with ``original_ask_hash``. Unknown keys
83
+ pass through verbatim — callers should not abuse this for
84
+ free-form payloads (privacy floor).
85
+ log_path: Override for tests. Defaults to
86
+ ``<project_root>/agents/council-events.log``.
87
+
88
+ Returns:
89
+ ``True`` when a line was written; ``False`` when the kill-switch
90
+ suppressed the write. Never raises on missing parent dir — the
91
+ function creates it on demand.
92
+
93
+ Raises:
94
+ ValueError: ``action`` not in :data:`_VALID_ACTIONS`.
95
+ """
96
+ if _kill_switch_active():
97
+ return False
98
+
99
+ action = event.get("action")
100
+ if action not in _VALID_ACTIONS:
101
+ raise ValueError(
102
+ f"events_log: action={action!r} not in "
103
+ f"{sorted(_VALID_ACTIONS)}.",
104
+ )
105
+
106
+ raw_ask = event.pop("original_ask", "") if "original_ask" in event else ""
107
+ record = {
108
+ "schema_version": SCHEMA_VERSION,
109
+ "ts_utc": datetime.now(timezone.utc).isoformat(
110
+ timespec="seconds",
111
+ ).replace("+00:00", "Z"),
112
+ "lens": event.get("lens", ""),
113
+ "invocation": event.get("invocation", ""),
114
+ "action": action,
115
+ "verdict": event.get("verdict", ""),
116
+ "provider_caps": event.get("provider_caps", {}),
117
+ "original_ask_hash": _hash_original_ask(raw_ask),
118
+ }
119
+ # Pass-through for any caller-supplied diagnostic fields that are
120
+ # not in the schema-v1 reserved set (e.g. `category`, `rationale`).
121
+ # The schema-v1 fields above always win on collision.
122
+ reserved = set(record) | {"original_ask"}
123
+ for k, v in event.items():
124
+ if k not in reserved:
125
+ record[k] = v
126
+
127
+ target = Path(log_path) if log_path is not None else _DEFAULT_LOG_PATH
128
+ target.parent.mkdir(parents=True, exist_ok=True)
129
+ line = json.dumps(record, ensure_ascii=False, separators=(",", ":"))
130
+ with target.open("a", encoding="utf-8") as fh:
131
+ fh.write(line + "\n")
132
+ return True
133
+
134
+
135
+ def default_log_path() -> Path:
136
+ """Return the canonical events-log path (callers / tests)."""
137
+ return _DEFAULT_LOG_PATH
@@ -0,0 +1,252 @@
1
+ """Preview builder for ``/memory learn-low-impact`` (step-9 Phase 7).
2
+
3
+ Default invocation is ``--preview``: build a structured plan describing
4
+ which Validated entries would be upstreamed to the package seed without
5
+ opening a PR. ``--apply`` (handled by the agent, not this module) is the
6
+ explicit opt-in that triggers the actual upstream-contribute PR flow.
7
+
8
+ The module is import-light by design — pure parsing + redaction + diff
9
+ rendering. PR creation lives in the ``upstream-contribute`` skill;
10
+ this module only hands the agent the material to surface.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Iterable
19
+
20
+ from scripts.ai_council.low_impact_corpus import (
21
+ CorpusEntry,
22
+ parse_corpus_strict,
23
+ )
24
+ from scripts.ai_council.redact_low_impact_entry import (
25
+ RedactionViolation,
26
+ redact_low_impact_entry,
27
+ )
28
+
29
+
30
+ _PROVENANCE_RE = re.compile(r"^last-upstreamed:\s*([0-9a-f]{6,40}|0+)\s*$",
31
+ re.IGNORECASE | re.MULTILINE)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class PreviewEntry:
36
+ """One Validated bullet that would be upstreamed."""
37
+ phrase: str
38
+ normalised: str
39
+ line_no: int
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RefusedEntry:
44
+ """A Validated bullet the redactor refused — never upstreams."""
45
+ phrase: str
46
+ line_no: int
47
+ violations: tuple[RedactionViolation, ...]
48
+
49
+ def reason(self) -> str:
50
+ return "; ".join(f"{v.category}: {v.snippet}" for v in self.violations)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class LearnLowImpactPreview:
55
+ """Structured preview for ``/memory learn-low-impact --preview``.
56
+
57
+ Consumed by the agent which renders the human-facing preview block,
58
+ then waits for explicit ``--apply`` before invoking
59
+ :doc:`upstream-contribute </skills/upstream-contribute/SKILL>`.
60
+ """
61
+ promoted: tuple[PreviewEntry, ...]
62
+ refused: tuple[RefusedEntry, ...]
63
+ already_seeded: tuple[str, ...]
64
+ last_upstreamed_sha: str
65
+ seed_path: str
66
+ corpus_path: str
67
+ repo_slug: str = ""
68
+ warnings: tuple[str, ...] = field(default_factory=tuple)
69
+
70
+ @property
71
+ def has_work(self) -> bool:
72
+ return bool(self.promoted) or bool(self.refused)
73
+
74
+ @property
75
+ def would_open_pr(self) -> bool:
76
+ """True when ``--apply`` would actually open a PR.
77
+
78
+ Iron Law: any redactor refusal blocks the PR — the author must
79
+ rephrase or drop the offending entry locally and re-run.
80
+ """
81
+ return bool(self.promoted) and not self.refused
82
+
83
+ def render(self) -> str:
84
+ """Human-readable preview block.
85
+
86
+ Mirrors the rendering convention from ``/memory mine-session``:
87
+ a leading title line, then bucketed entries.
88
+ """
89
+ lines: list[str] = []
90
+ lines.append(
91
+ "## learn-low-impact preview"
92
+ + (f" — repo={self.repo_slug}" if self.repo_slug else "")
93
+ )
94
+ lines.append(f"last-upstreamed: {self.last_upstreamed_sha}")
95
+ lines.append(f"seed: {self.seed_path}")
96
+ lines.append("")
97
+ if self.promoted:
98
+ lines.append(f"### Promoted ({len(self.promoted)})")
99
+ for e in self.promoted:
100
+ lines.append(f"- \"{e.phrase}\" (line {e.line_no})")
101
+ lines.append("")
102
+ if self.refused:
103
+ lines.append(f"### Refused ({len(self.refused)}) — redactor blocked")
104
+ for r in self.refused:
105
+ lines.append(
106
+ f"- \"{r.phrase}\" (line {r.line_no}) — {r.reason()}"
107
+ )
108
+ lines.append("")
109
+ if self.already_seeded:
110
+ lines.append(f"### Already seeded ({len(self.already_seeded)})")
111
+ for phrase in self.already_seeded:
112
+ lines.append(f"- \"{phrase}\"")
113
+ lines.append("")
114
+ if not self.has_work:
115
+ lines.append("> No new validated entries to upstream.")
116
+ lines.append("")
117
+ if self.refused:
118
+ lines.append(
119
+ "> Refusals block the PR. Rephrase the entries locally"
120
+ " (or drop them) and re-run."
121
+ )
122
+ elif self.promoted:
123
+ lines.append(
124
+ "> Re-run with `--apply` to open the draft PR via"
125
+ " `upstream-contribute`."
126
+ )
127
+ return "\n".join(lines).rstrip() + "\n"
128
+
129
+ def render_diff(self) -> str:
130
+ """Source-project-stripped diff that ``--apply`` would propose.
131
+
132
+ Emits unified-diff-style ``+`` lines for each promoted phrase
133
+ under the seed file's ``## Validated`` section. The agent uses
134
+ this as the ``upstream-contribute`` patch body.
135
+ """
136
+ if not self.promoted:
137
+ return ""
138
+ lines = [f"--- {self.seed_path}", f"+++ {self.seed_path}"]
139
+ for e in self.promoted:
140
+ lines.append(f'+- "{e.phrase}"')
141
+ return "\n".join(lines) + "\n"
142
+
143
+ def render_pr_body(self) -> str:
144
+ """Draft PR body for the upstream contribute flow."""
145
+ n = len(self.promoted)
146
+ slug = self.repo_slug or "<repo-slug>"
147
+ title = f"feat(low-impact-seed): add {n} validated entries from {slug}"
148
+ body_lines: list[str] = [
149
+ f"# {title}",
150
+ "",
151
+ "Upstream from `/memory learn-low-impact --apply`.",
152
+ "",
153
+ "## Entries",
154
+ "",
155
+ ]
156
+ for e in self.promoted:
157
+ body_lines.append(f'- "{e.phrase}"')
158
+ body_lines.append("")
159
+ body_lines.append(
160
+ f"Provenance baseline: `{self.last_upstreamed_sha}`."
161
+ )
162
+ body_lines.append("")
163
+ body_lines.append(
164
+ "Per `low-impact-corpus-privacy-floor`, every entry above"
165
+ " cleared the redactor on intake and again at upstream."
166
+ )
167
+ return "\n".join(body_lines) + "\n"
168
+
169
+
170
+ def _read_seed_phrases(seed_path: Path) -> set[str]:
171
+ """Return the set of normalised phrases already in the seed file.
172
+
173
+ Missing seed file is not an error — it returns an empty set so the
174
+ first-ever upstream PR can seed the whole corpus. Reuses the
175
+ strict parser so the seed itself is contract-validated.
176
+ """
177
+ if not seed_path.exists():
178
+ return set()
179
+ result = parse_corpus_strict(seed_path)
180
+ return {e.normalised for e in result.validated}
181
+
182
+
183
+ def _read_provenance(corpus_path: Path) -> str:
184
+ if not corpus_path.exists():
185
+ return "0" * 40
186
+ text = corpus_path.read_text(encoding="utf-8")
187
+ m = _PROVENANCE_RE.search(text)
188
+ return m.group(1).lower() if m else "0" * 40
189
+
190
+
191
+ def build_preview(
192
+ corpus_path: "object",
193
+ seed_path: "object",
194
+ *,
195
+ repo_root: str | None = None,
196
+ private_domains: Iterable[str] = (),
197
+ customer_names: Iterable[str] = (),
198
+ sql_identifiers: Iterable[str] = (),
199
+ repo_slug: str = "",
200
+ ) -> LearnLowImpactPreview:
201
+ """Build the preview plan without performing any PR side-effects.
202
+
203
+ Steps mirror the command doc:
204
+
205
+ 1. Parse the local corpus (strict — drift surfaces as ParseError).
206
+ Step-10: the preview deliberately stays on the Markdown parser
207
+ (not the YAML lockfile) because it runs *before* ``task sync``
208
+ rebuilds the lockfile from a user's local corpus edits.
209
+ 2. Diff Validated entries against the upstream seed.
210
+ 3. Run the redactor on every candidate.
211
+ 4. Bucket into promoted / refused / already-seeded.
212
+ """
213
+ corpus_p = Path(str(corpus_path))
214
+ seed_p = Path(str(seed_path))
215
+ parsed = parse_corpus_strict(corpus_p)
216
+ seeded = _read_seed_phrases(seed_p)
217
+ promoted: list[PreviewEntry] = []
218
+ refused: list[RefusedEntry] = []
219
+ already: list[str] = []
220
+ for entry in parsed.validated:
221
+ if entry.normalised in seeded:
222
+ already.append(entry.phrase)
223
+ continue
224
+ result = redact_low_impact_entry(
225
+ entry.phrase,
226
+ repo_root=repo_root,
227
+ private_domains=private_domains,
228
+ customer_names=customer_names,
229
+ sql_identifiers=sql_identifiers,
230
+ )
231
+ if result.ok:
232
+ promoted.append(PreviewEntry(
233
+ phrase=entry.phrase,
234
+ normalised=entry.normalised,
235
+ line_no=entry.line_no,
236
+ ))
237
+ else:
238
+ refused.append(RefusedEntry(
239
+ phrase=entry.phrase,
240
+ line_no=entry.line_no,
241
+ violations=result.violations,
242
+ ))
243
+ return LearnLowImpactPreview(
244
+ promoted=tuple(promoted),
245
+ refused=tuple(refused),
246
+ already_seeded=tuple(already),
247
+ last_upstreamed_sha=_read_provenance(corpus_p),
248
+ seed_path=str(seed_p),
249
+ corpus_path=str(corpus_p),
250
+ repo_slug=repo_slug,
251
+ warnings=parsed.warnings,
252
+ )