@event4u/agent-config 2.13.0 → 2.15.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/agents/user/accept.md +117 -0
- package/.agent-src/commands/agents/user/init.md +163 -0
- package/.agent-src/commands/agents/user/review.md +107 -0
- package/.agent-src/commands/agents/user/show.md +109 -0
- package/.agent-src/commands/agents/user/update.md +98 -0
- package/.agent-src/commands/agents/user.md +66 -0
- package/.agent-src/commands/agents.md +2 -0
- package/.agent-src/commands/memory/learn-low-impact.md +143 -0
- package/.agent-src/rules/ask-when-uncertain.md +10 -6
- package/.agent-src/rules/copilot-routing.md +1 -1
- package/.agent-src/rules/devcontainer-routing.md +1 -1
- 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/low-impact-corpus-privacy-floor.md +74 -0
- package/.agent-src/rules/symfony-routing.md +1 -1
- package/.agent-src/skills/ai-council/SKILL.md +208 -8
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.claude-plugin/marketplace.json +8 -1
- package/CHANGELOG.md +328 -124
- package/README.md +21 -6
- package/config/agent-settings.template.yml +4 -0
- package/config/gitignore-block.txt +17 -0
- package/docs/architecture.md +12 -12
- package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
- package/docs/catalog.md +16 -7
- package/docs/contracts/adr-architectural-consensus-mechanism.md +4 -3
- package/docs/contracts/adr-level-6-productization.md +7 -9
- package/docs/contracts/agent-user-schema.md +165 -0
- package/docs/contracts/ai-council-config.md +492 -20
- package/docs/contracts/command-clusters.md +2 -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 +1961 -108
- 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 +8 -2
- package/docs/contracts/release-trunk-sync.md +4 -3
- package/docs/contracts/tier-3-contrib-plugin.md +5 -6
- package/docs/examples/agent-user.example.md +21 -0
- 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/_cli/cmd_doctor.py +134 -0
- package/scripts/ai_council/airgap.py +165 -0
- package/scripts/ai_council/cli_hints.py +123 -0
- package/scripts/ai_council/clients.py +787 -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 +1007 -11
- package/scripts/ai_council/consensus.py +41 -2
- 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 +252 -14
- package/scripts/ai_council/probation_gate.py +152 -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_council_layout.py +11 -0
- package/scripts/council_cli.py +1046 -15
- 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=
|
|
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
|
+
)
|