1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
//! §7 plugin wire payloads — what balls hands a plugin on stdin.
//!
//! Plugins get meaning directly on the wire — content + intent, not hashes to
//! reverse-engineer. There is **no return channel** (§7): a plugin contributes
//! by editing the change worktree, never by printing values back; its stdout is
//! diagnostics. So these types are *output only* — balls serializes them with
//! `serde_json` and never deserializes a payload (a plugin self-describe is the
//! one thing read back, and that lives in [`crate::plugin`]).
//!
//! One [`OpContext`] holds everything that does not change between a plugin's
//! `pre` and `post` invocation (the verb layer, bl-dfbd, computes it). A `phase`
//! plus the optional post-seal [`SealFacts`] pick the rest: `pre`, `post`, and
//! `rollback` payloads differ only in which fields are present, so a single
//! [`Payload`] with `serde` skipping the `None`s is the whole shape (§7
//! "post payload: same plus …").
use serde::Serialize;
use crate::message::Metadata;
use crate::task::Task;
/// §7 binding — where the op is happening. `store`/`landing` are the two checkout
/// paths (§1 — the `tasks_branch` store and the `balls/config` landing);
/// `tasks_branch` names the store branch (§4); `invocation_path` is where `bl`
/// was invoked (the project-repo root the delivery plugin needs, §11). `remote`
/// is absent in a no-remote (stealth) repo. `stealth` is the §12 DECLARED
/// consent opt-out — derived per op from the landing `task_remote` sentinel
/// (bl-9df0), riding the wire so the tracker takes its stealth
/// path even where a project `origin` is discoverable; `false` (the ordinary
/// tracked or inferred case) is never serialized, so every existing payload
/// shape is unchanged.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Binding {
#[serde(skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub stealth: bool,
pub tasks_branch: String,
pub store: String,
pub landing: String,
pub invocation_path: String,
}
/// §7 command — the op plus its intended body. `body_change` is the new markdown
/// body when the op rewrites it. There is no field-level changeset on the wire:
/// the op's field diff has one authoritative home — the change worktree plus the
/// op-start state to diff against (§14 derive-don't-store; bl-3bfd, §15).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Command {
pub op: String,
/// The BALL this op is about — on EVERY payload, `pre` included (§0
/// obligation 4: identity is CARRIED, not re-derived). It is op-constant and
/// core always knows it: the verb names it as its positional, `create` mints
/// it. So a plugin never reads WHICH ball an op is about back out of the
/// change worktree, whose staged diff is mutable scratch — it is empty on a
/// clean-tree abort and on a FAILED SEAL (committed, not integrated, no seal
/// record), where the old scan reported "found 0" and turned a healthy
/// unwind into a FAILED ROLLBACK (bl-a5f3). `None` only on the §16 bulk
/// `import`, which authors a whole stream rather than one ball.
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_change: Option<String>,
/// The op's `-m` note. A close reads it as the FULL delivery message
/// override (bl-b9a6); it is the SAME input that seals the §5 archive
/// commit, threaded onto the pre wire where the squash (close.pre) can see
/// it before the seal. Absent without `-m`.
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
/// The §11 DELIVERY TARGET (bl-7b71): the **id** of the ball whose branch
/// this op's delivery forks from and folds back into — `None` (the common
/// case) meaning the integration branch. Derived per op from the graph, never
/// stored ([`crate::target::derive`]): a ball that close-gates its live
/// parent delivers into that parent, so an epic accumulates its children and
/// lands whole. It carries an ID, not a branch name — `work/<id>` is the
/// delivery plugin's own formula
/// ([`crate::delivery_path::work_branch`]), and core spelling it would be a
/// second home for the naming. `None` is never serialized, so every existing
/// payload shape is byte-identical (the `stealth` precedent, bl-9df0).
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
/// The op-constant §7 wire data: everything identical across a plugin's `pre`
/// and `post` calls. The verb layer authors it once per op; [`OpContext::wire`]
/// stamps a per-phase [`Payload`] from it.
///
/// Not `Eq` (it holds a [`Task`], which is only `PartialEq`).
#[derive(Debug, Clone, PartialEq)]
pub struct OpContext {
pub actor: String,
pub binding: Binding,
/// The op + its intended diff. `None` on a diffless checkout-lifecycle op
/// (`sync`/`prime`): those author no ball, so the §13 wire omits `command`
/// entirely — a plugin gets meaning from its `binding`, not a command.
pub command: Option<Command>,
/// The ball at op-start, before the base change. `None` on create (no ball
/// yet). This is `pre`'s `current_state` and `post`'s `previous_state` (§7).
/// There is no after-state companion: a `post` reactor derives the LANDED
/// ball from git (the §14 derive-don't-store rule), never the wire — so §7
/// carries no post `current_state` (bl-667e, §15).
pub before: Option<Task>,
}
/// Post facts threaded onto a `post`/`rollback` payload: the new commit, the tip
/// it landed on, and the §5 trailers parsed from the seal message (incl. the
/// now-assigned `bl-id`). `None` of these is on a `pre` payload — the id is not
/// sealed yet (§7). `metadata` is `None` on a diffless op (§13): sync/prime
/// author no §5 message, so the wire carries the commit pair alone.
#[derive(Debug, Clone, Copy)]
pub struct SealFacts<'a> {
pub commit: &'a str,
pub previous_commit: &'a str,
pub metadata: Option<&'a Metadata>,
}
/// One fully-assembled §7 payload, serialized to a plugin's stdin. Built by
/// [`OpContext::wire`]; `serde` omits every `None`, so the same struct renders
/// the `pre` shape (states only), the `post` shape (+ commits, + metadata on a
/// sealing op, commits-only on a diffless one — §13), and a `rollback` shape
/// (+ `rolling_back`).
#[derive(Debug, Serialize)]
pub struct Payload<'a> {
pub protocol: u32,
pub op: &'a str,
pub phase: &'a str,
pub plugin_name: &'a str,
pub actor: &'a str,
pub binding: &'a Binding,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<&'a Command>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_state: Option<&'a Task>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_state: Option<&'a Task>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_commit: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<&'a Metadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rolling_back: Option<&'a str>,
}
impl OpContext {
/// The op-constant wire for a DIFFLESS checkout-lifecycle op (`sync`/`prime`,
/// §13): a plugin gets meaning from its `binding` alone, so `command` and
/// both task states are absent (these ops author no ball). `actor` is the
/// invoking identity (`--as`).
#[must_use]
pub fn diffless(actor: String, binding: Binding) -> Self {
Self { actor, binding, command: None, before: None }
}
/// Stamp the §7 payload for `plugin_name` in `phase`. `sealed` is `Some` once
/// the op has sealed (every `post` call and any `post`-phase rollback),
/// which selects the after-state and adds the commit facts; `pre` passes
/// `None`. `rolling_back` is `Some("pre"|"post")` only on a rollback call.
#[must_use]
pub fn wire<'a>(
&'a self,
plugin_name: &'a str,
op: &'a str,
phase: &'a str,
sealed: Option<SealFacts<'a>>,
rolling_back: Option<&'a str>,
) -> Payload<'a> {
let mut p = Payload {
protocol: crate::message::PROTOCOL,
op,
phase,
plugin_name,
actor: &self.actor,
binding: &self.binding,
command: self.command.as_ref(),
current_state: self.before.as_ref(),
previous_state: None,
commit: None,
previous_commit: None,
metadata: None,
rolling_back,
};
if let Some(s) = sealed {
// post shape: the op-start state slides from current_state into
// previous_state, and the commit pair lands. There is NO post
// current_state — the landed ball is derived from git, not the wire
// (§14 derive-don't-store; bl-667e, §15). `metadata` follows only when
// the op sealed a §5 message — a diffless op (§13) carries the commit
// pair alone.
p.current_state = None;
p.previous_state = self.before.as_ref();
p.commit = Some(s.commit);
p.previous_commit = Some(s.previous_commit);
p.metadata = s.metadata;
}
p
}
/// Stamp the §6 READ-OP payload for `plugin_name`: the §7 wire minus the
/// task-op fields — no `command`, no states, no commit pair (a read authors
/// and seals nothing) — in the single phase `"read"` (a read has no
/// `pre`/`post` split). `metadata` carries the named ball's `bl-id`, the same
/// id channel a sealed post wire uses; there is still no return channel —
/// balls folds the plugin's stdout into the HUMAN render verbatim and parses
/// nothing back (§6/§7).
#[must_use]
pub fn read_wire<'a>(&'a self, plugin_name: &'a str, op: &'a str, metadata: &'a Metadata) -> Payload<'a> {
Payload {
protocol: crate::message::PROTOCOL,
op,
phase: "read",
plugin_name,
actor: &self.actor,
binding: &self.binding,
command: None,
current_state: None,
previous_state: None,
commit: None,
previous_commit: None,
metadata: Some(metadata),
rolling_back: None,
}
}
}
#[cfg(test)]
#[path = "wire_tests.rs"]
mod tests;