Authoring-assist: promote to first-class tool with non-blocking PR advisory

Promotes the authoring-assist prototype to a first-class BCQuality feature that
reviews knowledge-article front-matter and proposes missing routing `signals:`
(raise triggers and `effect: suppress` suppressors). The suggestion is ADVISORY
ONLY: it never fails a build; authors apply it via a normal PR under existing
R29 + CI + CODEOWNERS review.

Feature:
- tools/Suggest-ArticleSignals.ps1: suggestion engine hardened with a stable JSON
  contract (schemaVersion/toolVersion) and a deterministic, effect-sensitive
  per-proposal suggestionId, plus -ChangedFiles scoping for PR-diff runs. Runs
  self-contained: the routing seed is now OPTIONAL (built-in domain map), so this
  does not depend on the separate routing-index tuning thread.
- tools/New-AuthoringAssistComment.ps1: renders one upsertable, feedback-instrumented
  advisory comment (stable anchor + aa:meta/aa:article markers carrying suggestionIds
  + reaction footer) for the downstream acceptance-measurement work.
- .github/workflows/authoring-assist*.yml: unprivileged pull_request intake +
  trusted workflow_run runner (review/publish split) + in-repo self-test.
- tools/Test-SuggestArticleSignals.ps1: 23-check smoke test (contract, determinism,
  scoping, suppressor/prohibition, renderer markers, seed-free graceful degradation).

Dormant schema support:
- .github/scripts/validate_frontmatter.py: adds R29, validating the optional
  `signals` block shape (bare token or mapping with token + optional
  pattern/domain/effect in {raise,suppress}). Reserved & dormant — no production
  pipeline consumes it; the existing PR Reviewer only reads `effect: suppress`
  behind its BCQ_INDEX_V2 flag and falls back safely when absent. Existing rules
  (incl. R24 skill-id uniqueness, R27, R28) are unchanged.

Excludes the routing-index tuning thread entirely (separate work).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 75d852d6-18a6-4c58-986f-ed5ab16618fa
This commit is contained in:
dayland 2026-07-24 10:03:29 +01:00
parent ad8ccde595
commit 94d3d4358d
8 changed files with 1313 additions and 1 deletions

View file

@ -34,6 +34,11 @@ KNOWLEDGE_REQUIRED_KEYS = {
"bc-version", "domain", "keywords", "technologies",
"countries", "application-area",
}
# Optional knowledge keys (do not trigger the R02 closed-key-set error).
# `signals`: OPTIONAL routing-signal declarations. RESERVED & DORMANT — the shape
# is validated (R29 below) so authors and the authoring-assist advisory can
# populate it, but no production pipeline consumes it yet. See tools/authoring-assist.md.
KNOWLEDGE_OPTIONAL_KEYS = {"signals"}
ACTION_SKILL_REQUIRED_KEYS = {
"kind", "id", "version", "title", "description", "inputs", "outputs",
}
@ -203,7 +208,7 @@ def validate_knowledge(path: Path, parsed: Parsed, report: Report) -> None:
# R02 required keys, no extras, none empty
missing = KNOWLEDGE_REQUIRED_KEYS - fm.keys()
extras = fm.keys() - KNOWLEDGE_REQUIRED_KEYS
extras = fm.keys() - KNOWLEDGE_REQUIRED_KEYS - KNOWLEDGE_OPTIONAL_KEYS
if missing:
report.error(path, "R02", f"missing required frontmatter keys: {sorted(missing)}", 1)
if extras:
@ -213,6 +218,35 @@ def validate_knowledge(path: Path, parsed: Parsed, report: Report) -> None:
if v is None or v == "" or v == []:
report.error(path, "R02", f"frontmatter key '{k}' must not be empty", 1)
# R29 optional routing `signals` block. Each entry is either a bare token
# string or a mapping with a required 'token' and optional 'pattern'/'domain'/
# 'effect' (all non-empty strings; effect in {raise, suppress}). Reserved &
# dormant: validated for shape only so the block stays well-formed for the
# authoring-assist advisory; no production pipeline consumes it yet.
if "signals" in fm:
sigs = fm["signals"]
if not isinstance(sigs, list) or not sigs:
report.error(path, "R29", "signals must be a non-empty list", 1)
else:
for entry in sigs:
if isinstance(entry, str):
if not entry.strip():
report.error(path, "R29", "signals token string must not be empty", 1)
elif isinstance(entry, dict):
tok = entry.get("token")
if not isinstance(tok, str) or not tok.strip():
report.error(path, "R29", "signals entry must have a non-empty 'token'", 1)
for opt in ("pattern", "domain"):
if opt in entry and (not isinstance(entry[opt], str) or not entry[opt].strip()):
report.error(path, "R29", f"signals '{opt}' must be a non-empty string", 1)
if "effect" in entry and entry["effect"] not in ("raise", "suppress"):
report.error(path, "R29", "signals 'effect' must be 'raise' or 'suppress'", 1)
unknown = set(entry.keys()) - {"token", "pattern", "domain", "effect"}
if unknown:
report.error(path, "R29", f"signals entry has unknown keys: {sorted(unknown)}", 1)
else:
report.error(path, "R29", "signals entry must be a string or a mapping", 1)
# R03 bc-version
if "bc-version" in fm:
_, err = expand_bc_version(fm["bc-version"])