diff --git a/security-review/checkers/fixtures/plan-groomer/EXPECTED_PLAN_ITEMS b/security-review/checkers/fixtures/plan-groomer/EXPECTED_PLAN_ITEMS new file mode 100644 index 0000000..7ed6ff8 --- /dev/null +++ b/security-review/checkers/fixtures/plan-groomer/EXPECTED_PLAN_ITEMS @@ -0,0 +1 @@ +5 diff --git a/security-review/checkers/fixtures/plan-groomer/README.md b/security-review/checkers/fixtures/plan-groomer/README.md new file mode 100644 index 0000000..646b045 --- /dev/null +++ b/security-review/checkers/fixtures/plan-groomer/README.md @@ -0,0 +1,39 @@ +# plan-groomer canary fixtures + +Sample sibling-checker reports for `checkers/plan-groomer.sh --canary` (offline, no network/ +token). The planner asserts the groomed-plan **item count** equals `EXPECTED_PLAN_ITEMS` +(anti-complacency floor, design §6.4). If aggregation or dedup regresses, the count drifts +and the canary FAILS (exit 3). + +## How the canary works + +`--canary` points `$REPORT_ROOT_BASE` at `sample-reports/` and writes the groomed plan into a +mode-700 temp dir (so the canary writes nothing under `$HOME`). It reads each source checker's +**latest** `/.json`, normalizes every `.findings[]` into a plan item +`{repo, severity, source, title, action}`, **dedupes** on `repo|source|title`, prioritizes by +severity, and writes the plan into the mode-600 report. + +These are plain report JSON files (no `dotgit/` trick needed — plan-groomer reads sibling +reports, it does not scan git checkouts). + +## Fixture report set + +| Source checker | Date dir | Findings | Contributes to plan | +|---|---|---|---| +| `compliance-drift` | `2026-06-10` (OLD) | 1 | **0** — sentinel: older date MUST be skipped (latest-date selection) | +| `compliance-drift` | `2026-06-17` (latest) | 3 | **2** — two of the three are an exact duplicate (`payments-dashboard` / README) that must dedup to one | +| `dependency-cve` | `2026-06-17` | 2 | **2** — `jinja2` (high) + `lodash` (critical) | +| `doc-drift` | `2026-06-17` | 1 | **1** — stale README arch section | +| `confluence-doc` | (none) | — | **0** — no report present; noted in `missing_sources`, NEVER invented as work | + +Total groomed plan items = **5** (`EXPECTED_PLAN_ITEMS`). + +This exercises four invariants in one run: +1. **latest-date selection** — the `2026-06-10` sentinel must not leak into the plan. +2. **dedup** — the duplicate README finding collapses to one item. +3. **multi-source aggregation** — three different checkers feed one prioritized plan. +4. **no-data discipline** — a missing source (`confluence-doc`) is noted, never fabricated. + +When you add/remove a source checker, a fixture report, or a finding, update the fixture(s) +and `EXPECTED_PLAN_ITEMS` in the same commit (the canary edit is itself caught on the next run +— design §6.4). diff --git a/security-review/checkers/fixtures/plan-groomer/sample-reports/compliance-drift/2026-06-10/compliance-drift.json b/security-review/checkers/fixtures/plan-groomer/sample-reports/compliance-drift/2026-06-10/compliance-drift.json new file mode 100644 index 0000000..d9be0df --- /dev/null +++ b/security-review/checkers/fixtures/plan-groomer/sample-reports/compliance-drift/2026-06-10/compliance-drift.json @@ -0,0 +1,22 @@ +{ + "checker": "compliance-drift", + "generated": "2026-06-10T03:00:00Z", + "org": "Sea-Haven-Industries", + "api_checks_ran": false, + "repos_scanned": 1, + "drift_count": 1, + "repos_with_drift": 1, + "findings": [ + { + "repo": "STALE-repo-should-be-ignored", + "id": "STALE-repo-should-be-ignored-naming-repo", + "title": "This finding is from an OLDER date and MUST NOT appear in the groomed plan", + "severity": "high", + "category": "other", + "check": "naming-repo", + "status": "confirmed", + "proof": {"outcome": "older-date sentinel: latest-date selection must skip this"} + } + ], + "skipped_checks": [] +} diff --git a/security-review/checkers/fixtures/plan-groomer/sample-reports/compliance-drift/2026-06-17/compliance-drift.json b/security-review/checkers/fixtures/plan-groomer/sample-reports/compliance-drift/2026-06-17/compliance-drift.json new file mode 100644 index 0000000..e797ee0 --- /dev/null +++ b/security-review/checkers/fixtures/plan-groomer/sample-reports/compliance-drift/2026-06-17/compliance-drift.json @@ -0,0 +1,42 @@ +{ + "checker": "compliance-drift", + "generated": "2026-06-17T03:00:00Z", + "org": "Sea-Haven-Industries", + "api_checks_ran": false, + "repos_scanned": 2, + "drift_count": 3, + "repos_with_drift": 2, + "findings": [ + { + "repo": "payments-dashboard", + "id": "payments-dashboard-readme-missing", + "title": "No README.md at repo root", + "severity": "high", + "category": "other", + "check": "readme-present", + "status": "confirmed", + "proof": {"outcome": "global CLAUDE.md / github-standards.md: every repo must have a README"} + }, + { + "repo": "payments-dashboard", + "id": "payments-dashboard-readme-missing-dup", + "title": "No README.md at repo root", + "severity": "high", + "category": "other", + "check": "readme-present", + "status": "confirmed", + "proof": {"outcome": "DUPLICATE of the row above (same repo+source+title) — must dedup to one plan item"} + }, + { + "repo": "slack-bot", + "id": "slack-bot-merge-automerge-off", + "title": "allow_auto_merge disabled", + "severity": "low", + "category": "other", + "check": "merge-settings", + "status": "confirmed", + "proof": {"outcome": "github-standards.md: enable auto-merge (allow_auto_merge)"} + } + ], + "skipped_checks": [] +} diff --git a/security-review/checkers/fixtures/plan-groomer/sample-reports/dependency-cve/2026-06-17/dependency-cve.json b/security-review/checkers/fixtures/plan-groomer/sample-reports/dependency-cve/2026-06-17/dependency-cve.json new file mode 100644 index 0000000..1c55f40 --- /dev/null +++ b/security-review/checkers/fixtures/plan-groomer/sample-reports/dependency-cve/2026-06-17/dependency-cve.json @@ -0,0 +1,32 @@ +{ + "checker": "dependency-cve", + "generated": "2026-06-17T03:05:00Z", + "org": "Sea-Haven-Industries", + "advisory_mode": "offline", + "repos_scanned": 2, + "vuln_count": 2, + "repos_with_vulns": 2, + "findings": [ + { + "repo": "payments-dashboard", + "id": "payments-dashboard-vuln-jinja2-2-11-2-GHSA-g3rq-g295-4j3m", + "title": "jinja2 2.11.2 is vulnerable (GHSA-g3rq-g295-4j3m)", + "severity": "high", + "category": "other", + "check": "vulnerable-dependency", + "status": "confirmed", + "proof": {"package": "jinja2", "version": "2.11.2", "advisory_id": "GHSA-g3rq-g295-4j3m", "summary": "Jinja2 ReDoS in the urlize filter", "fixed_version": "2.11.3"} + }, + { + "repo": "slack-bot", + "id": "slack-bot-vuln-lodash-4-17-15-GHSA-p6mc-m468-83gw", + "title": "lodash 4.17.15 is vulnerable (GHSA-p6mc-m468-83gw)", + "severity": "critical", + "category": "other", + "check": "vulnerable-dependency", + "status": "confirmed", + "proof": {"package": "lodash", "version": "4.17.15", "advisory_id": "GHSA-p6mc-m468-83gw", "summary": "Prototype pollution in lodash", "fixed_version": "4.17.19"} + } + ], + "skipped_checks": [] +} diff --git a/security-review/checkers/fixtures/plan-groomer/sample-reports/doc-drift/2026-06-17/doc-drift.json b/security-review/checkers/fixtures/plan-groomer/sample-reports/doc-drift/2026-06-17/doc-drift.json new file mode 100644 index 0000000..ac35d52 --- /dev/null +++ b/security-review/checkers/fixtures/plan-groomer/sample-reports/doc-drift/2026-06-17/doc-drift.json @@ -0,0 +1,21 @@ +{ + "checker": "doc-drift", + "generated": "2026-06-17T03:10:00Z", + "org": "Sea-Haven-Industries", + "repos_scanned": 1, + "drift_count": 1, + "repos_with_drift": 1, + "findings": [ + { + "repo": "payments-dashboard", + "id": "payments-dashboard-readme-stale-arch", + "title": "README architecture section predates the new Lambda; docs lag code", + "severity": "medium", + "category": "other", + "check": "readme-stale", + "status": "confirmed", + "proof": {"outcome": "git log shows handler change after the README's last edit"} + } + ], + "skipped_checks": [] +} diff --git a/security-review/checkers/plan-groomer.sh b/security-review/checkers/plan-groomer.sh new file mode 100755 index 0000000..996afdf --- /dev/null +++ b/security-review/checkers/plan-groomer.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# plan-groomer.sh — Plane-1 planner for the R720 agent-team (REPORT-ONLY). +# +# Design refs: docs/r720-agent-team-design.md §4 (planner roster: plan-groomer — +# "Drafts a groomed weekly plan INTO the mode-600 report for now (D3); auto-write to +# Notion/Jira is a later toggle once trusted") and §7 Phase 4 ("planner + confluence-doc"). +# This mirrors compliance-drift.sh / dependency-cve.sh conventions VERBATIM so the +# coordinator (§5) can drive it identically — BUT its output discipline is different: +# it is REPORT-ONLY, not ALARM-only. +# +# WHAT IT DOES (read-only): +# Aggregates the actionable items the OTHER Plane-1 checkers already produced into a +# single prioritized "groomed weekly plan". It does NOT re-scan repos or hit any network: +# it reads the LATEST per-checker JSON reports under $REPORT_ROOT_BASE///. +# Sources consumed (each optional — a missing checker is noted, never invented as work): +# compliance-drift//compliance-drift.json (.findings[]) +# dependency-cve//dependency-cve.json (.findings[]) +# doc-drift//doc-drift.json (.findings[], if Phase-3 doc-drift exists) +# confluence-doc//confluence-doc.json (.findings[], the Phase-4 sibling) +# Each finding is normalized to a plan item {repo, severity, source, title, action}, +# DEDUPED (same repo+source+title collapses), grouped by severity then repo, and written +# into a prioritized plan in the mode-600 report (JSON + human text). +# +# REPORTING (D3 — REPORT-ONLY, the key difference from the ALARM-only checkers): +# - Writes a per-run JSON + text report under $REPORT_ROOT//, mode 600 (umask 077). +# - Slack: posts NOTHING by default. A groomed plan is a digest, not an alarm — auto-write +# to Notion/Jira (or a Slack digest) is a later toggle once signal quality is trusted (D3). +# There is intentionally NO post_slack_alarm() call in the default path; --notify is a +# future seam left inert here. A clean week (zero items) still writes an (empty) plan. +# - Reuses the substrate's redact() for the in-report digest string (defense-in-depth). +# +# SUBSTRATE REUSE (lib/sweep_substrate.sh, sourced — bash dynamic scoping): +# redact -> mask any secret-shaped value that leaked into an upstream report title. +# (discover_repos/mirror_repo/post_slack_alarm are intentionally NOT used: plan-groomer +# neither clones nor alarms — it only reads sibling reports and writes one mode-600 plan.) +# +# CANARY / DRY-RUN (offline, no network, no token): +# --canary points $REPORT_ROOT_BASE at a fixture set of sample checker reports +# (checkers/fixtures/plan-groomer/sample-reports///.json) and asserts +# the groomed-plan ITEM COUNT equals EXPECTED_PLAN_ITEMS (anti-complacency floor, design §6.4). +# If aggregation/dedup regresses, the count drifts and the canary FAILS (exit 3). --canary +# implies --dry-run. Fully offline + deterministic. --dry-run also suppresses the (inert) +# --notify seam. +# +# SCOPE / SAFETY: +# Read-only. No network, no token, no clones, no agent_team/ writes, no systemd wiring — +# that is provisioning (gated). See "PROVISIONING (NOT DONE HERE)" at the bottom. +# +# Exit: 0 = ran (always, report-only); 2 = setup/usage error; 3 = canary assertion FAILED. +set -euo pipefail +export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" + +log() { echo "[plan-groomer] $*" >&2; } +die() { echo "[plan-groomer] FATAL: $*" >&2; exit 2; } + +# --- Shared substrate --------------------------------------------------------- +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SUBSTRATE="$HERE/../lib/sweep_substrate.sh" +[ -f "$SUBSTRATE" ] || die "shared substrate not found: $SUBSTRATE" +# shellcheck source=../lib/sweep_substrate.sh +. "$SUBSTRATE" + +# --- Config + defaults (env, all optional) ------------------------------------ +GH_ORG="${GH_ORG:-Sea-Haven-Industries}" +# Base under which each checker writes its own // report tree (the parent of +# the per-checker REPORT_ROOTs the other checkers default to: $HOME/sweep-reports). +REPORT_ROOT_BASE="${REPORT_ROOT_BASE:-$HOME/sweep-reports}" +# plan-groomer's OWN report tree (separate from the checkers it reads). +REPORT_ROOT="${REPORT_ROOT:-$HOME/sweep-reports/plan-groomer}" +# Which sibling checkers to aggregate (space-separated; missing ones are noted, never invented). +SOURCE_CHECKERS="${SOURCE_CHECKERS:-compliance-drift dependency-cve doc-drift confluence-doc}" + +DRY_RUN=0 # --dry-run: suppress the (inert) --notify seam (report still written). +CANARY=0 # --canary: read the fixture report set + assert the known plan-item count. +NOTIFY=0 # --notify: INERT future seam (post the digest somewhere). Off by default (D3). +TARGETS_OVERRIDE="" # --targets "a b": restrict the groomed plan to these repo names only. + +usage() { + cat >&2 </dev/null || die "jq is required" + +# --- Report dir (mode 600 reports; matches sweep conventions) ----------------- +umask 077 +UTC_DATE="$(date -u +%Y-%m-%d)" +UTC_STAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +# Canary redirects the SOURCE base at the fixture report set; output stays in a temp area so +# the canary writes nothing under $HOME. +if [ "$CANARY" -eq 1 ]; then + FIXTURE_ROOT="$HERE/fixtures/plan-groomer" + [ -d "$FIXTURE_ROOT/sample-reports" ] || die "canary fixture missing: $FIXTURE_ROOT/sample-reports" + REPORT_ROOT_BASE="$FIXTURE_ROOT/sample-reports" + CANARY_OUT="$(mktemp -d "${TMPDIR:-/tmp}/plan-groomer-canary.XXXXXX")" + trap 'rm -rf "$CANARY_OUT"' EXIT + REPORT_ROOT="$CANARY_OUT/plan-groomer" + # Pin the source list the fixtures were authored against (deterministic regardless of env). + SOURCE_CHECKERS="compliance-drift dependency-cve doc-drift confluence-doc" +fi + +REPORT_DIR="$REPORT_ROOT/$UTC_DATE" +mkdir -p "$REPORT_DIR"; chmod 700 "$REPORT_ROOT" "$REPORT_DIR" 2>/dev/null || true +# shellcheck disable=SC2034 # named for parity with the ALARM-only checkers' substrate contract +SWEEP_LOG="$REPORT_DIR/plan-groomer.log" +REPORT_JSON="$REPORT_DIR/plan-groomer.json" +REPORT_TXT="$REPORT_DIR/plan-groomer.txt" + +log "=== plan-groomer $UTC_STAMP (canary=$CANARY dry_run=$DRY_RUN notify=$NOTIFY) ===" + +# ------------------------------------------------------------------------------ +# Severity ordering: the jq sort below maps severity->rank; no shell helper needed. +# ------------------------------------------------------------------------------ +in_csv() { # needle space-list -> 0 if present + local n="$1" list="$2" t; for t in $list; do [ "$t" = "$n" ] && return 0; done; return 1 +} + +# --- Resolve the LATEST date dir for one checker under $REPORT_ROOT_BASE ------- +latest_report_json() { # checker_name -> path to its latest /.json, or "" if none + local checker="$1" + local base="$REPORT_ROOT_BASE/$checker" d name latest="" + [ -d "$base" ] || { echo ""; return 0; } + # Date dirs are YYYY-MM-DD; lexical sort == chronological. Glob the dirs, keep only + # YYYY-MM-DD names, sort newest-first, pick the newest that actually has the JSON. + for d in $(for p in "$base"/*/; do + [ -d "$p" ] || continue + name="$(basename "$p")" + [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] && echo "$name" + done | sort -r); do + if [ -f "$base/$d/$checker.json" ]; then latest="$base/$d/$checker.json"; break; fi + done + echo "$latest" +} + +# ============================================================================== +# AGGREGATE: pull findings from each sibling checker's latest report into plan items. +# ============================================================================== +declare -a PLAN_ITEMS=() # one normalized JSON plan-item per upstream finding +declare -a MISSING_SOURCES=() # checkers with no report found — noted, NEVER invented as work +note_missing() { MISSING_SOURCES+=( "$1" ); } + +for checker in $SOURCE_CHECKERS; do + rj="$(latest_report_json "$checker")" + if [ -z "$rj" ]; then + note_missing "$checker(no-report-found)" + log " [$checker] no report under $REPORT_ROOT_BASE/$checker — skipped (not invented as work)" + continue + fi + if ! jq -e '.findings | type=="array"' "$rj" >/dev/null 2>&1; then + note_missing "$checker(report-unparseable)" + log " [$checker] report $rj has no .findings[] array — skipped" + continue + fi + cnt="$(jq '.findings | length' "$rj")" + log " [$checker] $rj -> $cnt finding(s)" + # Normalize each finding to a plan item. Title/severity/repo come straight off the finding + # schema the checkers emit (see finding.schema.json spirit). The "action" is a stable, + # source-derived hint (no fabrication — it just labels what kind of remediation this is). + while IFS= read -r item; do + [ -n "$item" ] && PLAN_ITEMS+=( "$item" ) + done < <(jq -c --arg src "$checker" ' + .findings[] + | { repo: (.repo // "unknown"), + severity: (.severity // "medium"), + source: $src, + title: (.title // .id // "untitled"), + action: ( if $src=="dependency-cve" then "upgrade vulnerable dependency" + elif $src=="compliance-drift" then "fix convention drift" + elif $src=="doc-drift" then "reconcile docs with code" + elif $src=="confluence-doc" then "close documentation gap" + else "review finding" end ) } + ' "$rj") +done + +# --- Optional repo-scope restriction (--targets) ------------------------------ +if [ -n "$TARGETS_OVERRIDE" ] && [ "${#PLAN_ITEMS[@]}" -gt 0 ]; then + declare -a FILTERED=() + for item in "${PLAN_ITEMS[@]}"; do + r="$(echo "$item" | jq -r '.repo')" + in_csv "$r" "$TARGETS_OVERRIDE" && FILTERED+=( "$item" ) + done + PLAN_ITEMS=( "${FILTERED[@]+"${FILTERED[@]}"}" ) + log "targets filter '$TARGETS_OVERRIDE' -> ${#PLAN_ITEMS[@]} item(s)" +fi + +# ============================================================================== +# DEDUP + PRIORITIZE: collapse identical (repo|source|title), then sort by severity desc, +# then repo, then source. Add a stable rank int so downstream consumers can re-sort. +# ============================================================================== +if [ "${#PLAN_ITEMS[@]}" -gt 0 ]; then + RAW_JSON="$(printf '%s\n' "${PLAN_ITEMS[@]}" | jq -cs .)" +else + RAW_JSON="[]" +fi + +GROOMED_JSON="$(echo "$RAW_JSON" | jq -c ' + # dedup on repo|source|title + ( reduce .[] as $x ({}; .[($x.repo+"|"+$x.source+"|"+$x.title)] //= $x) ) | [ .[] ] + | map(. + { rank: ( {critical:4, high:3, medium:2, low:1}[.severity] // 0 ) }) + | sort_by([ (-.rank), .repo, .source, .title ]) +')" + +N_ITEMS="$(echo "$GROOMED_JSON" | jq 'length')" +N_CRIT_HIGH="$(echo "$GROOMED_JSON" | jq '[.[]|select(.severity=="critical" or .severity=="high")]|length')" +N_REPOS="$(echo "$GROOMED_JSON" | jq '[.[].repo]|unique|length')" + +if [ "${#MISSING_SOURCES[@]}" -gt 0 ]; then + MISSING_JSON="$(printf '%s\n' "${MISSING_SOURCES[@]}" | jq -R . | jq -cs .)" +else + MISSING_JSON="[]" +fi + +# ============================================================================== +# ASSEMBLE REPORT (JSON + text), mode 600 +# ============================================================================== +jq -n \ + --arg planner "plan-groomer" --arg ts "$UTC_STAMP" --arg org "$GH_ORG" \ + --arg sources "$SOURCE_CHECKERS" \ + --argjson items "$GROOMED_JSON" --argjson missing "$MISSING_JSON" \ + '{planner:$planner, generated:$ts, org:$org, mode:"report-only", + sources_considered:($sources|split(" ")), + plan_item_count:($items|length), + crit_high_count:([$items[]|select(.severity=="critical" or .severity=="high")]|length), + repos_in_plan:([$items[].repo]|unique|length), + plan:$items, missing_sources:$missing}' > "$REPORT_JSON" + +{ + echo "plan-groomer — groomed weekly plan — $UTC_STAMP" + echo "org=$GH_ORG sources=[$SOURCE_CHECKERS] mode=report-only (D3: no auto-write)" + echo "plan items: $N_ITEMS ($N_CRIT_HIGH crit/high) across $N_REPOS repo(s)" + echo + if [ "$N_ITEMS" -gt 0 ]; then + echo "PRIORITIZED PLAN (severity desc, then repo):" + echo "$GROOMED_JSON" | jq -r '.[] | "• [\(.severity)] \(.repo) — \(.title)\n action: \(.action) (source: \(.source))"' + else + echo "No actionable items aggregated this run (clean week, or no upstream reports)." + fi + if [ "$(echo "$MISSING_JSON" | jq 'length')" -gt 0 ]; then + echo; echo "sources with no report (NOT invented as work):" + echo "$MISSING_JSON" | jq -r '.[] | " - \(.)"' + fi +} > "$REPORT_TXT" +chmod 600 "$REPORT_JSON" "$REPORT_TXT" 2>/dev/null || true + +# Defense-in-depth: the digest line that a future --notify seam would push is redacted now. +DIGEST="$(echo "$GROOMED_JSON" | jq -r ' + group_by(.repo)[] | "*\(.[0].repo)*: " + ([.[] | "[\(.severity)] \(.title)"] | join("; "))' \ + | sed 's/^/• /' | redact)" + +log "report: $REPORT_JSON ($N_ITEMS plan item(s), $N_REPOS repo(s))" + +# ============================================================================== +# CANARY ASSERTION (anti-complacency floor, design §6.4) +# ============================================================================== +if [ "$CANARY" -eq 1 ]; then + EXPECT_FILE="$HERE/fixtures/plan-groomer/EXPECTED_PLAN_ITEMS" + [ -f "$EXPECT_FILE" ] || die "canary expected-count file missing: $EXPECT_FILE" + EXPECTED="$(tr -dc '0-9' < "$EXPECT_FILE")" + log "canary assertion: expected plan items=$EXPECTED, got=$N_ITEMS" + if [ "$N_ITEMS" -ne "$EXPECTED" ]; then + echo "[plan-groomer] CANARY FAIL: groomed-plan item count mismatch (expected $EXPECTED, got $N_ITEMS)" >&2 + echo " -> aggregation or dedup regressed, or the fixture changed. See $REPORT_TXT." >&2 + exit 3 + fi + log "canary PASS: groomed plan has all $EXPECTED expected item(s) (dedup intact)." +fi + +# ============================================================================== +# REPORT-ONLY ROUTING (D3): the plan lives in the mode-600 report. Post NOTHING. +# ============================================================================== +if [ "$NOTIFY" -eq 1 ] && [ "$DRY_RUN" -eq 0 ]; then + # INERT future seam: when D3's "once trusted" toggle flips, this is where the digest would + # be pushed to Slack/Notion/Jira. It is intentionally a no-op in this phase — plan-groomer + # is REPORT-ONLY and must not auto-write. The composed digest is available in $DIGEST. + log "--notify requested but inert in this phase (D3: report-only; auto-write is a later toggle). No push." +fi +: "${DIGEST:?}" >/dev/null 2>&1 || true # DIGEST is composed for the future seam; keep it referenced. +log "REPORT-ONLY: groomed plan written to the mode-600 report; nothing posted (D3)." +exit 0 + +# ============================================================================== +# PROVISIONING (NOT DONE HERE — gated, later phases): +# - REPORT-ONLY by design (D3). The auto-write path (push the groomed plan to Notion/Jira, +# or a weekly Slack digest) is a LATER TOGGLE, flipped only once signal quality is trusted. +# The --notify seam above is intentionally inert; wiring a real destination is provisioning. +# - No systemd unit / timer is installed by this script. Wiring it into the weekly schedule +# (alongside the other Plane-1 checkers under the coordinator) is provisioning and is gated. +# - The coordinator (design §5, checker_coordinator.sh) registers + drives this planner; that +# registry edit is done centrally, NOT in this script. +# - Confluence + project_r720_agent_team memory updates are docs-as-you-go obligations for the +# build session, tracked outside this script. +# ==============================================================================