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
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
//! Closed nine-role registry and stable semantic carriers.
#[cfg(feature = "alloc")]
use alloc::{format, string::String, vec, vec::Vec};
use super::{CapabilityContract, DispatchError, DispatchResult};
#[derive(
Clone,
Copy,
Debug,
Eq,
Hash,
Ord,
PartialEq,
PartialOrd,
strum::AsRefStr,
strum::Display,
strum::EnumCount,
strum::EnumIs,
strum::EnumString,
strum::IntoStaticStr,
strum::VariantArray,
strum::VariantNames,
)]
// Deliberately NOT `ascii_case_insensitive`, unlike the config-facing enums.
// `Role` is never typed by an operator -- it has no clap flag -- and every
// `from_name` caller is a machine boundary: the WIT bridge, the broker, the
// hook payload, and the registry claim. Leniency there bought nothing and cost
// byte-stability on the field the whole flock's authorization keys on: a
// record stored as `"eNgInEeR"` deserialized fine and rewrote itself as
// `"engineer"`.
#[strum(serialize_all = "snake_case")]
pub enum Role {
Auditor,
Coder,
Conductor,
Critic,
Discovery,
Engineer,
Planter,
Shepherd,
Worker,
}
impl Role {
/// The complete closed role registry in deterministic order.
///
/// Kept as a `const` array rather than replaced by `VariantArray`: several
/// callers need it in const context, and the flock being exactly nine is a
/// contract worth stating literally. `role_registry_matches_the_derived_variants`
/// proves the two never diverge.
pub const ALL: [Self; 9] = [
Self::Auditor,
Self::Coder,
Self::Conductor,
Self::Critic,
Self::Discovery,
Self::Engineer,
Self::Planter,
Self::Shepherd,
Self::Worker,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Auditor => "auditor",
Self::Coder => "coder",
Self::Conductor => "conductor",
Self::Critic => "critic",
Self::Discovery => "discovery",
Self::Engineer => "engineer",
Self::Planter => "planter",
Self::Shepherd => "shepherd",
Self::Worker => "worker",
}
}
#[must_use]
pub fn carrier(self) -> String {
format!("shepherd:{}", self.as_str())
}
pub fn from_carrier(value: &str) -> DispatchResult<Self> {
let Some(role) = value.strip_prefix("shepherd:") else {
return Err(DispatchError::InvalidRole(value.into()));
};
Self::from_name(role)
}
pub fn from_name(value: &str) -> DispatchResult<Self> {
// Was a linear scan of `ALL` comparing `as_str`. `EnumString` derives
// the same lookup from the variant names, so the mapping is stated once.
value
.parse()
.map_err(|_| DispatchError::InvalidRole(value.into()))
}
/// Whether this role is a profile the ROOT session adopts, rather than a
/// subagent anyone dispatches.
///
/// `Planter` and `Shepherd` are perspectives one session moves between --
/// `RootSessionBinding::validate` already refuses a root binding that is
/// neither. The other seven are true subagents. Nothing ever dispatches a
/// root profile: doing so would mint a second root for a session that
/// already has one, which is a different thing from the tier rules that
/// stop a LANE LEAD reaching upward.
#[must_use]
pub const fn is_root_profile(self) -> bool {
matches!(self, Self::Planter | Self::Shepherd)
}
/// The write scope a role is entitled to, as repository-relative globs.
///
/// `write_scope` was a caller-supplied field with no default, so a dispatch
/// that named none had no scope, and every write was refused. The scope a
/// role needs is a property of the role, not of whoever dispatched it.
///
/// Two roles write code. `Coder` and `Worker` are the implementers -- their
/// lane brief narrows them further to file-disjoint paths, which is a
/// per-lane decision this cannot make.
///
/// The rest do not write code at all. `Engineer` authors the plan,
/// `Conductor` the lane ledger, `Critic` and `Auditor` their findings,
/// `Discovery` its report -- every one of those is Markdown inside the run
/// they are bound to, so that is exactly what they get. A role that cannot
/// write a `.rs` file cannot silently become an implementer.
///
/// `Planter` holds the operator channel and owns two documents; this states
/// as data what `PLANTER_WRITE_SCOPE` in the compiler has only ever said as
/// prose. `Shepherd` performs integration under its own custody.
#[must_use]
pub fn default_write_scope(self, run: &str) -> Vec<String> {
match self {
Self::Coder | Self::Worker | Self::Shepherd => vec![String::from("**")],
Self::Engineer | Self::Conductor | Self::Critic | Self::Auditor | Self::Discovery => {
vec![format!(".shepherd/runs/{run}/**/*.md")]
}
Self::Planter => vec![
format!(".shepherd/runs/{run}/seed.md"),
format!(".shepherd/runs/{run}/mesh.md"),
],
}
}
/// Closed native dispatch policy. Discovery is an Engineer-owned
/// orientation capability; a Conductor may launch only its three lane
/// specialists and cannot obtain research by changing the payload shape.
#[must_use]
pub const fn may_dispatch_to(self, target: Self) -> bool {
match self {
Self::Engineer => matches!(target, Self::Critic | Self::Auditor | Self::Discovery),
Self::Conductor => matches!(target, Self::Coder | Self::Worker | Self::Auditor),
_ => false,
}
}
pub fn capability_contract(self) -> DispatchResult<CapabilityContract> {
let (required, optional): (&[&str], &[&str]) = match self {
Self::Auditor => (
&[
"read",
"search",
"shell",
"code-intelligence",
"skill-load",
"report-write",
],
&["tool-discovery"],
),
Self::Coder | Self::Worker => (
&["read", "search", "shell", "write", "skill-load"],
&["tool-discovery"],
),
Self::Conductor => (
&[
"read",
"search",
"shell",
"skill-load",
"dispatch",
"message-peer",
"task-tracking",
],
&["schedule-wakeup", "tool-discovery"],
),
Self::Critic => (&["read", "search", "shell", "skill-load"], &[]),
Self::Discovery => (
&["read", "search", "shell", "skill-load", "report-write"],
&["tool-discovery", "web-research"],
),
Self::Engineer => (
&[
"read",
"search",
"shell",
"write",
"skill-load",
"dispatch",
"message-peer",
],
&["tool-discovery"],
),
Self::Planter => (
&[
"read",
"search",
"shell",
"write",
"skill-load",
"ask-operator",
],
&["tool-discovery"],
),
Self::Shepherd => (
&[
"read",
"search",
"shell",
"write",
"skill-load",
"dispatch",
"message-peer",
"task-tracking",
],
&["tool-discovery", "web-research"],
),
};
let forbidden: &[&str] = match self {
Self::Auditor | Self::Critic => &[
"admin",
"sudo",
"write",
"edit",
"dispatch",
"web-research",
"research-specialist",
],
Self::Discovery => &[
"admin",
"sudo",
"write",
"edit",
"dispatch",
"research-specialist",
],
Self::Coder | Self::Worker => &[
"admin",
"sudo",
"dispatch",
"web-research",
"research-specialist",
],
Self::Conductor => &[
"admin",
"sudo",
"write",
"edit",
"web-research",
"research-specialist",
],
Self::Planter => &[
"admin",
"sudo",
"dispatch",
"task-tracking",
"web-research",
"research-specialist",
],
Self::Engineer | Self::Shepherd => &["admin", "sudo"],
};
CapabilityContract::new(required, optional, forbidden)
}
pub fn dispatch_capability_contract(self) -> DispatchResult<CapabilityContract> {
let mut contract = self.capability_contract()?;
contract.required.insert("subagent-provider".into());
contract.validate()?;
Ok(contract)
}
}
impl serde::Serialize for Role {
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for Role {
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::from_name(&value).map_err(serde::de::Error::custom)
}
}