-
-
Notifications
You must be signed in to change notification settings - Fork 38.4k
feat(control-pane): live control-plane view with 2D projection and static-threshold advisories #3091
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(control-pane): live control-plane view with 2D projection and static-threshold advisories #3091
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| # TCAS hook: pre-merge deconfliction (slice b, design) | ||
|
|
||
| Status: design only. Nothing in this document is implemented. Slice (a), the live view and the advisory feed it reads, shipped in `VIEW-CONTRACT.md`. | ||
|
|
||
| ## Goal | ||
|
|
||
| Stop two agents from finishing overlapping edits and meeting at the merge. The scan already knows when two working sets converge; the hook is what turns that knowledge into a maneuver inside the harness, before either agent commits. | ||
|
|
||
| Push plan wording: "a PreToolUse/Edit hook that reads the advisory feed and returns steer, pause or wait for the lower-priority agent, logged to the capsule." | ||
|
|
||
| ## Inputs | ||
|
|
||
| 1. The event feed: `GET /api/control-plane/events` on the local control pane, or the same document written to a file by `scripts/proximity-tick.js --json` for sessions without a pane. Events of kind `proximity.advisory` with `action.type` `transmit` or `steer` and a deterministic `id`. | ||
| 2. The hook's own session id. Claude Code passes `session_id` on stdin; the ECC session adapter maps it to the ECC2 `sessions.id` the scan uses. Codex and Hermes use the instruction-backed equivalent (see below). | ||
| 3. The tool call: `tool_name` and `tool_input.file_path` for Edit, Write and MultiEdit. Bash is out of scope for v1. | ||
|
|
||
| ## Decision | ||
|
|
||
| For each advisory event whose `subject` includes this session: | ||
|
|
||
| | Event | This session is | Maneuver | Hook result | | ||
| |---|---|---|---| | ||
| | `traffic`, action `transmit` | either side | **transmit**: inject the other agent's working set as a system message | exit 0, message on stderr (warn, never block) | | ||
| | `resolution`, action `steer` | `hold` | **hold**: continue | exit 0, short note | | ||
| | `resolution`, action `steer` | `steer`, and `file_path` is in the other agent's working set | **pause**: stop editing that file until the other agent's diff lands | exit 2 with the reason (blocks this one tool call) | | ||
| | `resolution`, action `steer` | `steer`, and `file_path` is not in the other agent's working set | **wait**: allowed, but told to keep to non-overlapping files | exit 0, message on stderr | | ||
| | `resolution`, action `steer` | `steer`, and a `steer` target exists | **steer**: suggest the disjoint files or subtree the agent should move to | exit 0, message; exit 2 only if the edit is on the shared file | | ||
|
|
||
| The maneuver is deterministic: both agents read the same event, `hold` and `steer` are named in it, so the two sides never pick the same move. This is the TCAS coordination property and it is why the view computes right-of-way once, centrally, rather than each hook deciding. | ||
|
|
||
| `pause` blocks a single tool call, not the session. The agent sees the reason and can pick another file. Blocking is bounded by the event's `at`: an event older than the pane's poll interval times three is stale and the hook does not block on it. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Add an explicit stale window for the file-backed feed. The documented no-pane path writes 🤖 Prompt for AI Agents |
||
|
|
||
| ## Priority | ||
|
|
||
| Right-of-way comes from the event (`action.hold`, `action.steer`). The view computes it as more progress, then earlier start, then stable id (`rightOfWay` in `scripts/lib/agent-proximity/distance.js`). The hook never recomputes it. | ||
|
|
||
| ## Logging to the capsule | ||
|
|
||
| Every decision is one entry in the session's capsule journal (`scripts/lib/eval-harness/capsule.js`, hash-linked NDJSON): | ||
|
|
||
| ```json | ||
| { | ||
| "kind": "tcas.decision", | ||
| "event_id": "proximity.advisory:session-a|session-b:resolution", | ||
| "session": "session-b", | ||
| "tool": "Edit", | ||
| "file": "src/api/users.js", | ||
| "maneuver": "pause", | ||
| "blocked": true, | ||
| "risk": 1, | ||
| "threshold": { "ta": 0.35, "ra": 0.7, "source": "static" }, | ||
| "at": "2026-09-11T20:01:03.000Z" | ||
| } | ||
| ``` | ||
|
Comment on lines
+39
to
+54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Document the actual capsule envelope.
🤖 Prompt for AI Agents |
||
|
|
||
| The capsule is the baseline counter for the 85 percent goal: rebase and merge-conflict triage incidents per week are counted from these entries plus `git rerere` and conflict markers, two weeks before and two weeks after the hook is on. No percentage is claimed before that. | ||
|
|
||
| ## Where it plugs in | ||
|
|
||
| - **Claude Code**: a `PreToolUse` entry in `hooks/hooks.json` with matcher `Edit|Write|MultiEdit`, routed through `scripts/hooks/run-with-flags.js` so `ECC_HOOK_PROFILE` and `ECC_DISABLED_HOOKS` gate it. Script under `scripts/hooks/tcas-pre-edit.js`, helpers in `scripts/lib/control-pane/tcas.js`. Budget: under 200 ms, no network beyond loopback, exit 0 on any parse or fetch error. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Define enforceable deadlines for the complete hook path. The documented path includes both event-feed I/O and 🤖 Prompt for AI Agents |
||
| - **Codex**: no PreToolUse. The instruction-backed equivalent is the `proximity_steer` / `proximity_hold` message the tick already writes into the ECC2 `messages` table, surfaced on the next turn. `pause` degrades to a strong instruction. | ||
| - **Hermes**: gateway hook on the tool-call path, same decision table, same capsule entry. | ||
|
|
||
| ## Off switch and safety | ||
|
|
||
| - Disabled by default. On with `ECC_TCAS_HOOK=1` or the hook profile. | ||
| - Read-only against the pane. It never writes to the sessions or messages tables. | ||
| - No lease is acquired. Durable leases are slice (c), the worktree lease table in ecc2 `session/store.rs` next to `messages`; until then a `pause` is a per-call block, not a lock, and two hooks racing on the same file is possible but harmless (both see the same event and the same `steer`). | ||
| - Fails open. Any error is exit 0 with a `[TCAS]` line on stderr. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Separate feed failures from journal failures. The document requires every decision to produce a capsule entry, but it also makes every error fail open. If journaling fails, the hook proceeds without an entry and the 85 percent baseline becomes incomplete. Define separate handling for feed errors and journal errors. Record audit loss explicitly or make the journal failure visible to the measurement process. 🤖 Prompt for AI Agents |
||
|
|
||
| ## Tests to write with it | ||
|
|
||
| - Decision table: one test per row above, driven by a fixture event feed and a stdin payload. | ||
| - Staleness: an event older than the window does not block. | ||
| - Fail-open: unreachable pane, malformed JSON, missing session id. | ||
| - Capsule: one entry per decision, hash chain intact, replay reproduces the same bytes. | ||
| - Integration: two fake sessions with overlapping working sets, the lower-priority one gets exit 2 on the shared file and exit 0 on a disjoint file. | ||
|
|
||
| ## Out of scope for (b) | ||
|
|
||
| Learned thresholds, closure-rate escalation, mesh mode, cross-machine airspace, the `x_sem`, `x_vec`, `x_freq` channels (slice g), and the lease table (slice c). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| # ECC control-plane live view: `ecc.control-plane.view.v1` | ||
|
|
||
| Status: shipped with the control pane (`scripts/lib/control-pane/control-plane-view.js`). Read-only. Advisory only. | ||
|
|
||
| The view joins three things the repo already computes separately and serves them as one JSON document shaped as tasks, lanes and events, so another control plane (the Ito ops board, a Hermes or Codex reader, a hook) can consume it without knowing ECC internals. | ||
|
|
||
| | Input | Where it comes from | | ||
| |---|---| | ||
| | Sessions | `scripts/lib/control-pane/state.js`, the ECC2 `sessions` table | | ||
| | Pairwise proximity | `scripts/lib/agent-proximity/` (noisy-OR over `x_tree`, `x_overlap`, `x_dep`) via `scripts/lib/control-pane/proximity.js` | | ||
| | 2D projection | `scripts/lib/agent-proximity/projection.js` (rolling z-score, tails clipped at 2.5 / 97.5, PCA) | | ||
| | Coordination inventory | `scripts/lib/coordination-inventory.js` (PR #3028): declared tasks and sessions, heartbeat freshness, lease conflicts | | ||
|
|
||
| ## Endpoints | ||
|
|
||
| Served by `node scripts/control-pane.js` (loopback only, same Host and Origin gate as the rest of the pane): | ||
|
|
||
| | Route | Returns | | ||
| |---|---| | ||
| | `GET /control-plane` | Self-contained HTML page: 2D projection canvas, lanes and tasks, event feed. No external scripts. | | ||
| | `GET /api/control-plane` | The full view document below. | | ||
| | `GET /api/control-plane/events` | `{ schemaVersion, generatedAt, thresholds, events, counts }` only, for hooks and pollers. | | ||
|
|
||
| The server keeps one projection window per process, so z-scores roll across polls. Options on `createControlPaneServer`: `projection` (`windowSize`, `clipPercentiles`, `minWindowForZscore`), `viewOptions` (`thresholds`, `manifest`, `channelWeights`), `proximityOptions` (passed to the scan). | ||
|
|
||
| ## Document | ||
|
|
||
| ```json | ||
| { | ||
| "schemaVersion": "ecc.control-plane.view.v1", | ||
| "generatedAt": "2026-09-11T20:01:00.000Z", | ||
| "source": { "snapshotSchema": "ecc.control-pane.snapshot.v1", "repoRoot": "...", "dbPath": "..." }, | ||
| "thresholds": { "ta": 0.35, "ra": 0.7, "source": "static" }, | ||
| "lanes": [ { "id": "harness:codex", "label": "codex", "kind": "harness", "taskIds": ["session-a"] } ], | ||
| "tasks": [ { "...": "see Task" } ], | ||
| "pairs": [ { "...": "see Pair" } ], | ||
| "events": [ { "...": "see Event" } ], | ||
| "projection": { "...": "see Projection" }, | ||
| "inventory": { "...": "see Inventory" }, | ||
| "counts": { "lanes": 1, "tasks": 1, "agents": 1, "pairs": 0, "events": 0, "advisories": 0, "resolutions": 0 }, | ||
| "limits": [ "..." ] | ||
| } | ||
| ``` | ||
|
|
||
| ### Task | ||
|
|
||
| One task per session. A session with no changed files is still a task; it has no projection point and no pairs. | ||
|
|
||
| | Field | Meaning | | ||
| |---|---| | ||
| | `id` | Session id, unchanged. | | ||
| | `lane` | Lane id this task belongs to. | | ||
| | `label` | Session task text, or the id. | | ||
| | `harness`, `agentType`, `state`, `pid` | From the session row. | | ||
| | `worktree` | `{ path, branch, base }` or `null`. | | ||
| | `heartbeatAt`, `updatedAt` | ISO timestamps or `null`. | | ||
| | `workingSet` | `{ fileCount, files }`: the worktree diff against its base. | | ||
| | `projection` | `{ point, pairs, maxRisk }` where `point` is `[x, y]` or `null`. `point` is the risk-weighted centroid of the task's pair points in PCA space. | | ||
| | `inventory` | `{ id, heartbeat, process, authority: "declared-only" }`. `id` is the sanitized identifier used in the inventory manifest; `heartbeat` and `process` are the #3028 observations. | | ||
|
|
||
| ### Lane | ||
|
|
||
| A grouping of tasks. Precedence: `task-group` (session `task_group`), then `project`, then `harness`. Ids are prefixed (`group:`, `project:`, `harness:`) so a consumer can tell the kinds apart without reading `kind`. | ||
|
|
||
| ### Pair | ||
|
|
||
| One row per agent pair from the airspace scan (only sessions with edits participate). | ||
|
|
||
| | Field | Meaning | | ||
| |---|---| | ||
| | `a`, `b` | Session ids. | | ||
| | `risk`, `level` | Noisy-OR risk and the scan's level (`clear`, `advisory`, `resolution`) at the scan's thresholds. | | ||
| | `channels` | Raw `{ x_tree, x_overlap, x_dep }` in [0, 1]. | | ||
| | `normalized` | The same after z-score, clip and map-back, or equal to `channels` while the window is cold. | | ||
| | `point` | `[pc1, pc2]` PCA scores. | | ||
|
|
||
| ### Event | ||
|
|
||
| Something an operator or a hook may act on. Ids are deterministic across polls so a consumer can dedupe. | ||
|
|
||
| ```json | ||
| { | ||
| "id": "proximity.advisory:session-a|session-b:resolution", | ||
| "kind": "proximity.advisory", | ||
| "level": "resolution", | ||
| "severity": "critical", | ||
| "at": "2026-09-11T20:01:00.000Z", | ||
| "subject": { "a": "session-a", "b": "session-b", "aLabel": "...", "bLabel": "..." }, | ||
| "risk": 1, | ||
| "distance": 0, | ||
| "channels": { "x_tree": 1, "x_overlap": 1, "x_dep": 0 }, | ||
| "threshold": { "ta": 0.35, "ra": 0.7, "crossed": "ra", "source": "static" }, | ||
| "action": { "type": "steer", "steer": "session-b", "hold": "session-a" }, | ||
| "message": "Resolution advisory: session-b steers, session-a holds (risk 100%, static threshold 0.7)." | ||
| } | ||
| ``` | ||
|
|
||
| | Kind | Levels | Action types | Source | | ||
| |---|---|---|---| | ||
| | `proximity.advisory` | `traffic` (risk at or above `ta`), `resolution` (at or above `ra`) | `transmit` (both agents share intent), `steer` (`steer` moves, `hold` keeps course) | Every pair link, evaluated against the view's thresholds. Right-of-way: more progress, then earlier start, then stable id. | | ||
| | `inventory.lease-conflict` | `conflict` | `review` | #3028 `leaseConflicts`. Declared-only, never a lock. | | ||
|
|
||
| Thresholds are static per view (`source: "static"`). A learned threshold, closure-rate escalation, and the `pause` and `wait` maneuvers are slice (b), see `TCAS-HOOK.md`. | ||
|
|
||
| ### Projection | ||
|
|
||
| ```json | ||
| { | ||
| "method": "pca", | ||
| "channels": ["x_tree", "x_overlap", "x_dep"], | ||
| "weights": { "x_tree": 0.25, "x_overlap": 1, "x_dep": 0.9 }, | ||
| "normalization": "zscore-clipped", | ||
| "window": { "samples": 12, "percentiles": [2.5, 97.5], "channels": [ { "channel": "x_tree", "mean": 0.39, "stddev": 0.42, "clipLow": -0.92, "clipHigh": 1.45 } ] }, | ||
| "pca": { "loadings": [ { "x_tree": 0.12, "x_overlap": 0.87, "x_dep": -0.47 }, { "...": "..." } ], "explainedVariance": [0.6, 0.39] }, | ||
| "agents": [ { "agentId": "session-a", "point": [0.18, 0.41], "pairs": 3, "maxRisk": 1 } ] | ||
| } | ||
| ``` | ||
|
|
||
| Pipeline per poll: every pair's channel vector is pushed into a rolling window (default 512 samples). Once the window holds at least 8 samples, each channel is z-scored against the window, clipped to the window's 2.5th and 97.5th percentile (in z units), mapped back to [0, 1], multiplied by the static channel weight, and the weighted matrix goes through PCA (Jacobi on the 3x3 covariance). Below 8 samples the raw channel values are used and `normalization` says `raw`. A channel with zero variance maps to 0.5. Degenerate inputs (fewer than two pairs, zero total variance) give zero scores, never NaN. | ||
|
|
||
| The projection is a display. It never changes `risk`, the advisory level, or right-of-way. | ||
|
|
||
| ### Inventory | ||
|
|
||
| The #3028 report with the per-task rows folded into `tasks[].inventory`. Kept at the top level: `status` (`ok` or `unavailable` with `reason`), `truncated` (more than 64 sessions), `observedAt`, `mode: "read-only"`, `activity`, `leaseConflicts`, `warnings`, `coverage`, `limits`. The manifest is built from the live sessions (ids sanitized to the inventory alphabet, paths from the working set, heartbeat from the session row, declared session status `open` for running/pending/idle, `closed` for completed/failed/stopped). An external manifest (`viewOptions.manifest`) can add `goals`, `leases`, `repositories` and extra `tasks`; the inventory then reports lease conflicts and goal activity for them. | ||
|
|
||
| ## Reuse in the Ito ops control plane | ||
|
|
||
| The shape to copy is `task`, `lane`, `event`: | ||
|
|
||
| - a **task** has an `id`, a `lane`, a `state`, an optional position, and an observation block whose `authority` says how much to trust it; | ||
| - a **lane** is a named group with ordered `taskIds`; | ||
| - an **event** has a stable `id`, a `kind`, a `level`, a `severity`, an `at`, a `subject`, an `action` with a `type`, and a human `message`. | ||
|
|
||
| Nothing in the shape is ECC-specific except the event kinds. An ops board that renders lanes of tasks and a feed of events can render this document as-is, and can emit its own kinds (`deal.stalled`, `bridge.down`) into the same feed. | ||
|
|
||
| ## What this does not do | ||
|
|
||
| - No leases are acquired, no agent is paused or steered. Consumers act; the view reports. | ||
| - No conflict-reduction percentage is claimed. The 85 percent goal in the push plan is measured two weeks before and after slice (b), not here. | ||
| - No semantic, call-graph or frequency channel yet (slice (g)). PCA picks new channels up automatically when they land in the scan. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Correct the future-channel compatibility claim. PCA does not pick up new channels automatically. State that each new channel requires updates to the projection order, labels, weights, and contract. 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -270,6 +270,21 @@ function agentPriority(agent) { | |
| return { progress, ageMs: startedAt ? Date.now() - startedAt : 0 }; | ||
| } | ||
|
|
||
| /** | ||
| * Right-of-way between two agents: more progress wins; tie goes to the earlier | ||
| * start (greater age); final deterministic tiebreak on agentId so the maneuver | ||
| * is coordinated. Returns { hold, steer } as agentIds. | ||
| */ | ||
| function rightOfWay(a, b) { | ||
| const pa = agentPriority(a); | ||
| const pb = agentPriority(b); | ||
|
Comment on lines
+279
to
+280
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Use one time sample for both priority calculations.
Capture one timestamp and pass it to both calculations, or compare parsed start timestamps directly. Add a regression test for equal progress and equal start times. 🤖 Prompt for AI Agents |
||
| let aHasPriority; | ||
| if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress; | ||
| else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs; | ||
| else aHasPriority = String(a.agentId) < String(b.agentId); | ||
| return { hold: aHasPriority ? a.agentId : b.agentId, steer: aHasPriority ? b.agentId : a.agentId }; | ||
| } | ||
|
|
||
| /** | ||
| * TCAS-style advisory between two agents given their collision risk. | ||
| * Returns { level: 'clear'|'advisory'|'resolution', risk, transmit, steer, hold }. | ||
|
|
@@ -284,17 +299,7 @@ function advise(a, b, graph = {}, options = {}) { | |
| return { level: 'clear', risk, distance, channels, transmit: false, steer: null, hold: null }; | ||
| } | ||
|
|
||
| const pa = agentPriority(a); | ||
| const pb = agentPriority(b); | ||
| // Right-of-way: more progress wins; tie → earlier start (greater age) wins; | ||
| // final deterministic tiebreak on agentId so the maneuver is coordinated. | ||
| let aHasPriority; | ||
| if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress; | ||
| else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs; | ||
| else aHasPriority = String(a.agentId) < String(b.agentId); | ||
|
|
||
| const hold = aHasPriority ? a.agentId : b.agentId; | ||
| const steer = aHasPriority ? b.agentId : a.agentId; | ||
| const { hold, steer } = rightOfWay(a, b); | ||
|
|
||
| if (risk < thresholds.ra) { | ||
| // Traffic advisory: exchange intent, no one has to move yet. | ||
|
|
@@ -324,6 +329,7 @@ module.exports = { | |
| treeRisk, | ||
| collisionRisk, | ||
| agentPriority, | ||
| rightOfWay, | ||
| advise, | ||
| closureRate, | ||
| _internal: { normalizePath, segments, jaccard } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: affaan-m/ECC
Length of output: 12218
🤖 get_repo_knowledge executed:
get_repo_knowledge affaan-m/ECC /tmp/coderabbit-repo-knowledge/affaan-m-ecc-92a2c4b0/learnings /tmp/coderabbit-repo-knowledge/affaan-m-ecc-92a2c4b0/conventionsLength of output: 47829
🏁 Script executed:
Repository: affaan-m/ECC
Length of output: 27995
🏁 Script executed:
Repository: affaan-m/ECC
Length of output: 16255
🏁 Script executed:
Repository: affaan-m/ECC
Length of output: 27522
Define a canonical repository-relative path identity before the membership check.
Working-set entries come from Git as repository-relative paths, while
tool_input.file_pathmay use another representation. The existing path normalizer does not resolve absolute paths or./..segments. Define separator, root, symlink, and case rules, then test equivalent paths. Otherwise, a shared file may be treated as disjoint and returnwaitinstead ofpause.🤖 Prompt for AI Agents