# P3: Hermes to Pipeline Writeback Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Let Hermes agents reflect verified work back onto the deal, gated by an explicit autonomy policy, confidence thresholds, and an audit trail. **Architecture:** Agents never call HubSpot write tools directly. They emit a `writeback.json` artifact. A deterministic plugin validates each proposed write against `writeback_policy.yaml`, applies low-risk writes (confidence above gate), and queues anything else for human approval. Every attempt writes an `audit_log` row. **Tech Stack:** Hermes plugin (Python), Postgres, HubSpot API, YAML. **Depends on:** P1 (`deal_map`, stage routing). **Spec:** `../specs/2026-09-24-sales-pipeline-hermes-design.md` (SP3, sections 5.3, 11). --- ## Prerequisites - P1 merged. - A HubSpot Private App token with the scopes used by `inbox` (contacts, deals, associations, engagements). - `python3` with `pyyaml`, `requests`. --- ## File Structure | Path | Responsibility | |---|---| | `workspace/shared/writeback_policy.yaml` | Autonomy matrix per agent | | `workspace/shared/writeback_queue.sql` | Queue and audit table for proposed writes | | `plugins/hubspot-writeback/__init__.py` | Deterministic validator and applier | | `plugins/hubspot-writeback/README.md` | Plugin doc | | `workspace/scripts/approve_writeback.sh` | Human approval path | | `workspace//AGENTS.md` | Emit `writeback.json` instead of direct writes | --- ## Task 1: Autonomy policy **Files:** - Create: `workspace/shared/writeback_policy.yaml` - Test: `workspace/shared/test_writeback_policy.py` - [ ] **Step 1: Write the failing test** Create `workspace/shared/test_writeback_policy.py`: ```python import pathlib import yaml HERE = pathlib.Path(__file__).parent def policy(): return yaml.safe_load((HERE / "writeback_policy.yaml").read_text()) def test_default_gate_is_085(): assert policy()["default_confidence_gate"] == 0.85 def test_inbox_can_move_stage_but_not_touch_schema(): p = policy()["agents"]["inbox"] assert "deal.dealstage" in p["allowed"] assert all("property" not in a for a in p["allowed"]) def test_onboarding_is_read_only(): p = policy()["agents"]["onboarding"] assert p["allowed"] == [] def test_no_agent_may_write_closed_stages_autonomously(): for agent, entry in policy()["agents"].items(): blocked = entry.get("blocked_values", {}) assert "closedwon" in blocked.get("deal.dealstage", []), agent assert "closedlost" in blocked.get("deal.dealstage", []), agent ``` - [ ] **Step 2: Run test to verify it fails** Run: `python3 -m pytest workspace/shared/test_writeback_policy.py -v` Expected: FAIL (file missing). - [ ] **Step 3: Create the policy** Create `workspace/shared/writeback_policy.yaml`: ```yaml # writeback_policy.yaml — what each Hermes agent may write back to the CRM, and when. # Writes below the gate, or to a blocked value, queue for human approval instead. version: 1 default_confidence_gate: 0.85 agents: inbox: allowed: - "deal.dealstage" - "deal.hs_next_step" - "contact.*" - "ticket.*" - "engagement.*" blocked_values: deal.dealstage: - closedwon - closedlost require_human_above: [] # writes outside allowed always require human operations: allowed: [] onboarding: allowed: [] contable: allowed: [] ``` - [ ] **Step 4: Run test to verify it passes** Run: `python3 -m pytest workspace/shared/test_writeback_policy.py -v` Expected: 4 passed. - [ ] **Step 5: Commit** ```bash git add workspace/shared/writeback_policy.yaml workspace/shared/test_writeback_policy.py git commit -m "feat(pipeline): writeback autonomy policy" ``` --- ## Task 2: writeback queue table **Files:** - Create: `workspace/shared/writeback_queue.sql` - Test: `workspace/shared/test_writeback_queue_sql.sh` - [ ] **Step 1: Write the failing test** Create `workspace/shared/test_writeback_queue_sql.sh`: ```bash #!/usr/bin/env bash set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)" sql="$here/writeback_queue.sql" [[ -f "$sql" ]] || { echo "FAIL: missing"; exit 1; } grep -qi "create table if not exists writeback_queue" "$sql" || { echo "FAIL: table"; exit 1; } grep -qi "status" "$sql" || { echo "FAIL: status col"; exit 1; } if [[ -n "${DATABASE_URL:-}" ]] && command -v psql >/dev/null 2>&1; then psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$sql" >/dev/null psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$sql" >/dev/null fi echo "OK" ``` - [ ] **Step 2: Run test to verify it fails** Run: `bash workspace/shared/test_writeback_queue_sql.sh` Expected: `FAIL: missing`. - [ ] **Step 3: Create the schema** Create `workspace/shared/writeback_queue.sql`: ```sql -- writeback_queue — proposed CRM writes from Hermes agents, with policy outcome. CREATE TABLE IF NOT EXISTS writeback_queue ( id bigserial PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now(), task_id text, deal_id text, agent text NOT NULL, object_type text NOT NULL, -- deal | contact | ticket | engagement object_id text, field text NOT NULL, -- e.g. deal.dealstage value text, confidence numeric(3,2), status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','applied','rejected','needs_approval')), reason text, applied_at timestamptz ); CREATE INDEX IF NOT EXISTS writeback_queue_status_idx ON writeback_queue (status); ``` - [ ] **Step 4: Run test to verify it passes** Run: `bash workspace/shared/test_writeback_queue_sql.sh` Expected: `OK`. - [ ] **Step 5: Commit** ```bash git add workspace/shared/writeback_queue.sql workspace/shared/test_writeback_queue_sql.sh git commit -m "feat(pipeline): writeback queue schema" ``` --- ## Task 3: hubspot-writeback plugin **Files:** - Create: `plugins/hubspot-writeback/__init__.py` - Create: `plugins/hubspot-writeback/README.md` - Test: `plugins/hubspot-writeback/test_writeback.py` - [ ] **Step 1: Write the failing test** Create `plugins/hubspot-writeback/test_writeback.py`: ```python import importlib.util import pathlib HERE = pathlib.Path(__file__).parent def load(): spec = importlib.util.spec_from_file_location("wb", HERE / "__init__.py") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod POLICY = { "default_confidence_gate": 0.85, "agents": { "inbox": { "allowed": ["deal.dealstage", "contact.*"], "blocked_values": {"deal.dealstage": ["closedwon", "closedlost"]}, } }, } def test_allowed_high_confidence_applies(): mod = load() decision = mod.decide(POLICY, "inbox", "deal.dealstage", "contractsent", 0.9) assert decision == "apply" def test_low_confidence_queues(): mod = load() decision = mod.decide(POLICY, "inbox", "deal.dealstage", "contractsent", 0.5) assert decision == "needs_approval" def test_blocked_value_queues_even_high_confidence(): mod = load() decision = mod.decide(POLICY, "inbox", "deal.dealstage", "closedwon", 0.99) assert decision == "needs_approval" def test_disallowed_field_rejected(): mod = load() decision = mod.decide(POLICY, "inbox", "deal.amount", "100", 0.99) assert decision == "reject" def test_wildcard_object_allowed(): mod = load() decision = mod.decide(POLICY, "inbox", "contact.email", "a@b.com", 0.9) assert decision == "apply" ``` - [ ] **Step 2: Run test to verify it fails** Run: `python3 -m pytest plugins/hubspot-writeback/test_writeback.py -v` Expected: FAIL (module missing). - [ ] **Step 3: Write the plugin** Create `plugins/hubspot-writeback/__init__.py`: ```python """hubspot-writeback — validates proposed CRM writes against the autonomy policy. Deterministic, no LLM. Agents emit writeback.json; this module decides apply, needs_approval, or reject. Applying and queuing are done by the caller. """ import fnmatch def _matches(rule: str, field: str) -> bool: return fnmatch.fnmatch(field, rule) def decide(policy: dict, agent: str, field: str, value, confidence: float) -> str: entry = policy["agents"].get(agent) if not entry: return "reject" allowed = entry.get("allowed", []) if not any(_matches(rule, field) for rule in allowed): return "reject" blocked = entry.get("blocked_values", {}).get(field, []) if str(value) in [str(b) for b in blocked]: return "needs_approval" gate = policy.get("default_confidence_gate", 0.85) if float(confidence) < gate: return "needs_approval" return "apply" def register(ctx): # Hook reserved for wiring decision + apply on task completion. The applier reads # the agent's output//writeback.json and calls decide() per item. def on_completed(task_id=None, profile_name=None, **kw): return None ctx.register_hook("kanban_task_completed", on_completed) ``` - [ ] **Step 4: Run test to verify it passes** Run: `python3 -m pytest plugins/hubspot-writeback/test_writeback.py -v` Expected: 5 passed. - [ ] **Step 5: Write the plugin README and the applier script** Create `plugins/hubspot-writeback/README.md` describing the artifact shape: ```json { "deal_id": "D123", "items": [ { "agent": "inbox", "field": "deal.dealstage", "value": "contractsent", "confidence": 0.91, "object_type": "deal", "object_id": "D123" } ] } ``` Create `workspace/scripts/apply_writeback.py` (the deterministic applier that reads an artifact, calls `decide()` per item, writes a `writeback_queue` row, and applies `apply` items via the HubSpot API). Include a `--dry-run` flag and a unit test `workspace/scripts/test_apply_writeback.py` asserting `apply` items hit the API mock and `needs_approval` items do not. - [ ] **Step 6: Commit** ```bash git add plugins/hubspot-writeback/ workspace/scripts/apply_writeback.py workspace/scripts/test_apply_writeback.py git commit -m "feat(pipeline): hubspot-writeback validator and applier" ``` --- ## Task 4: Human approval path **Files:** - Create: `workspace/scripts/approve_writeback.sh` - Test: `workspace/scripts/test_approve_writeback.sh` - [ ] **Step 1: Write the failing test** Create `workspace/scripts/test_approve_writeback.sh`: ```bash #!/usr/bin/env bash set -uo pipefail here="$(cd "$(dirname "$0")" && pwd)" s="$here/approve_writeback.sh" "$s" >/dev/null 2>&1; [[ $? -eq 2 ]] || { echo "FAIL: usage"; exit 1; } out=$(env -u DATABASE_URL "$s" 1 approve 2>&1); [[ $? -eq 1 ]] || { echo "FAIL: should block"; exit 1; } echo "$out" | grep -q BLOCKED || { echo "FAIL: no BLOCKED"; exit 1; } echo OK ``` - [ ] **Step 2: Run test to verify it fails** Run: `bash workspace/scripts/test_approve_writeback.sh` Expected: FAIL. - [ ] **Step 3: Write the script** Create `workspace/scripts/approve_writeback.sh` that takes ` `, uses `psql` (fail closed like `verify_audit.sh`), flips the row to `applied` or `rejected`, and calls the HubSpot API only on approve. Mirror the fail-closed scaffold from `verify_audit.sh`. - [ ] **Step 4: Run test to verify it passes** Run: `bash workspace/scripts/test_approve_writeback.sh` Expected: `OK`. - [ ] **Step 5: Commit** ```bash git add workspace/scripts/approve_writeback.sh workspace/scripts/test_approve_writeback.sh git commit -m "feat(pipeline): human approval path for writeback" ``` --- ## Task 5: Switch inbox to artifact mode and document **Files:** - Modify: `workspace/inbox/AGENTS.md` - Modify: `.opencode/agents/inbox.md` - Modify: `docs/tech-decisions.md` - [ ] **Step 1: Update inbox contract** Change `inbox` so it writes `output//writeback.json` for CRM changes instead of calling HubSpot write tools directly. Keep HubSpot read tools enabled. State that all writes go through the policy and audit. - [ ] **Step 2: Update the decision log** Append to `docs/tech-decisions.md` the rationale: artifacts plus a deterministic applier, mirroring the gateway's `approve_write` (SF20) pattern, so autonomy is config, not prompt. - [ ] **Step 3: Verify** Confirm a simulated `inbox` task produces a `writeback.json`, that a high-confidence allowed write applies, and that a `closedwon` write lands in `needs_approval` and can be approved with `approve_writeback.sh`. - [ ] **Step 4: Commit** ```bash git add workspace/inbox/AGENTS.md .opencode/agents/inbox.md docs/tech-decisions.md git commit -m "feat(pipeline): inbox writes via policy-gated artifacts" ``` --- ## Definition of done - Writes pass through `writeback_policy.yaml`. - Low-confidence or blocked-value writes queue for approval; disallowed fields are rejected. - Every attempt writes a `writeback_queue` row and an `audit_log` row. - `closedwon` and `closedlost` are never written autonomously.