@bradheitmann/odin-sentinel 0.4.9 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,6 +72,216 @@ const MODEL_STATUSES = [
72
72
  "STREAMING_PROTOCOL_MISMATCH",
73
73
  "MODEL_UNREACHABLE"
74
74
  ];
75
+ export const GOVERNED_CONTEXT_PROOF_SCHEMA = "odin.governed_context_proof.v1";
76
+ const GOVERNED_CONTEXT_SOURCE_TYPES = ["native_skill", "static_control_file", "mcp_bootstrap"];
77
+ // User-clear four-state model surfaced by every governed-readiness surface (probe, gate, onboarding).
78
+ const GOVERNED_READINESS_MODEL = {
79
+ states: {
80
+ GOVERNED_READY: "Verified control layer + proven protocol uptake at adequate assurance + required hooks/validators + no unwaivable blocker.",
81
+ FIXABLE_BLOCKED: "Supported harness missing skill/static/MCP control proof, uptake receipt, hook, auth, or liveness — fixable, not yet governed.",
82
+ NON_GOVERNED_ONE_SHOT_ONLY: "Bounded non-authoritative use only: no PM/ODIN/QA acceptance, closure, or team coordination.",
83
+ UNSUPPORTED: "Harness cannot enforce SCP governed context and must not hold a governed role."
84
+ },
85
+ rule: "MCP configured alone, or a skill file on disk alone, never yields GOVERNED_READY. PM/ODIN roles require the highest assurance the harness supports.",
86
+ verifier: "scripts/protocol/verify-governed-context.mjs"
87
+ };
88
+ // Harness category registry: how each harness can carry an enforced SCP control layer. Unknown
89
+ // harnesses default to mcp_only (still fail-closed: no proof ⇒ not governed-ready).
90
+ const HARNESS_CATEGORY_REGISTRY = {
91
+ codex: "native_skill",
92
+ "claude code": "native_skill",
93
+ droid: "mcp_only",
94
+ goose: "mcp_only",
95
+ openhands: "mcp_only",
96
+ kilocode: "mcp_only",
97
+ cursor: "static_control_file",
98
+ zed: "static_control_file",
99
+ aider: "static_control_file",
100
+ crush: "static_control_file",
101
+ pi: "unsupported",
102
+ nanocoder: "unsupported"
103
+ };
104
+ export function harnessCategory(harness) {
105
+ return HARNESS_CATEGORY_REGISTRY[harness.trim().toLowerCase()] ?? "mcp_only";
106
+ }
107
+ const ASSURANCE_RANK = { none: 0, mcp_bootstrap: 1, static_control_file: 2, native_skill: 3 };
108
+ function maxAssuranceForCategory(category) {
109
+ if (category === "native_skill")
110
+ return "native_skill";
111
+ if (category === "static_control_file")
112
+ return "static_control_file";
113
+ if (category === "mcp_only")
114
+ return "mcp_bootstrap";
115
+ return "none";
116
+ }
117
+ function sourceTypeToAssurance(sourceType) {
118
+ if (sourceType === "native_skill")
119
+ return "native_skill";
120
+ if (sourceType === "static_control_file")
121
+ return "static_control_file";
122
+ if (sourceType === "mcp_bootstrap")
123
+ return "mcp_bootstrap";
124
+ return "none";
125
+ }
126
+ function isHighAuthorityRole(role) {
127
+ if (!role)
128
+ return false;
129
+ const n = normalizeRoleName(role);
130
+ return n === "EXEC_PM" || n === "TEAM_PM" || n === "EXEC_ODIN" || n === "TEAM_ODIN";
131
+ }
132
+ /**
133
+ * Pure shape validation of a governed-context proof (no disk access). The authoritative on-disk
134
+ * checksum gate is scripts/protocol/verify-governed-context.mjs; this validates the structure,
135
+ * the stable source marker, the uptake-receipt marker linkage, and zero-secret content so the
136
+ * MCP-visible readiness surfaces can require proof. A self-reported boolean with no stable
137
+ * source marker can never validate here (slice MUST NOT).
138
+ */
139
+ export function validateGovernedContextProof(proof) {
140
+ const reasons = [];
141
+ if (!proof || typeof proof !== "object" || Array.isArray(proof)) {
142
+ return { valid: false, proofAssurance: null, reasons: ["governed-context proof is not an object"] };
143
+ }
144
+ const record = asRecord(proof);
145
+ const serialized = JSON.stringify(record);
146
+ if (redactSecretLikeText(serialized) !== serialized)
147
+ reasons.push("governed-context proof contains a secret-looking value");
148
+ if (record.schema !== GOVERNED_CONTEXT_PROOF_SCHEMA)
149
+ reasons.push(`schema must be ${GOVERNED_CONTEXT_PROOF_SCHEMA}`);
150
+ for (const field of ["role", "harness", "source_type", "generated_at"]) {
151
+ if (typeof record[field] !== "string" || record[field].trim() === "")
152
+ reasons.push(`missing ${field}`);
153
+ }
154
+ const sourceType = record.source_type;
155
+ if (typeof sourceType === "string" && !GOVERNED_CONTEXT_SOURCE_TYPES.includes(sourceType)) {
156
+ reasons.push("invalid source_type");
157
+ }
158
+ const controlSource = asRecord(record.control_source);
159
+ const marker = typeof controlSource.marker === "string" ? controlSource.marker.trim() : "";
160
+ if (marker === "")
161
+ reasons.push("control_source.marker is required (a stable source marker, not a bare boolean)");
162
+ if (typeof controlSource.path === "string" && controlSource.path.trim() !== "" && (typeof controlSource.sha256 !== "string" || controlSource.sha256.trim() === "")) {
163
+ reasons.push("control_source.sha256 is required when control_source.path is present");
164
+ }
165
+ if (!record.uptake_receipt || typeof record.uptake_receipt !== "object" || Array.isArray(record.uptake_receipt)) {
166
+ reasons.push("missing uptake_receipt");
167
+ }
168
+ else {
169
+ const uptake = asRecord(record.uptake_receipt);
170
+ if (uptake.observed !== true)
171
+ reasons.push("uptake_receipt.observed must be true");
172
+ const evidence = typeof uptake.evidence_marker === "string" ? uptake.evidence_marker.trim() : "";
173
+ if (evidence === "")
174
+ reasons.push("uptake_receipt.evidence_marker is required (self-asserted uptake without a stable marker is rejected)");
175
+ else if (marker !== "" && evidence !== marker)
176
+ reasons.push("uptake_receipt.evidence_marker does not match control_source.marker");
177
+ if (typeof uptake.method !== "string" || uptake.method.trim() === "")
178
+ reasons.push("uptake_receipt.method is required");
179
+ }
180
+ return { valid: reasons.length === 0, proofAssurance: reasons.length === 0 ? sourceTypeToAssurance(sourceType) : null, reasons };
181
+ }
182
+ const UNSAFE_DELIVERY_STATES = new Set(["INPUT_BAR_ONLY", "PANE_BLOCKED_ON_PERMISSION"]);
183
+ const UNSAFE_LIVENESS_STATES = new Set(["NO_VISIBLE_PROCESSING", "IDLE_STALLED", "NO_ACK", "STALE_IDLE"]);
184
+ function governedReadinessNextAction(state, requiredAssurance, blockers) {
185
+ if (state === "NON_GOVERNED_ONE_SHOT_ONLY") {
186
+ return "This harness cannot reach the assurance this role requires; use it only for bounded one-shot work, or assign the role to a higher-assurance harness.";
187
+ }
188
+ const top = blockers[0] ?? "no verified governed-context uptake proof";
189
+ if (top.startsWith("no verified governed-context")) {
190
+ if (requiredAssurance === "native_skill")
191
+ return "Install the native sentinel-coordination-protocol skill, load it, then capture a governed-context uptake proof (verify with scripts/protocol/verify-governed-context.mjs).";
192
+ if (requiredAssurance === "static_control_file")
193
+ return "Install/validate the static control file, load it, then capture a governed-context uptake proof (verify with scripts/protocol/verify-governed-context.mjs).";
194
+ return "Load the MCP bootstrap resource, then capture a governed-context uptake proof (verify with scripts/protocol/verify-governed-context.mjs).";
195
+ }
196
+ if (top.startsWith("assurance "))
197
+ return "Provide a higher-assurance governed-context proof; a lower-assurance path does not qualify for this harness/role (PM and ODIN need the highest available).";
198
+ if (top.startsWith("authentication") || top.startsWith("auth blocked"))
199
+ return "Sign in or provision the harness outside chat (never paste secrets), then re-probe.";
200
+ if (top.startsWith("delivery") || top.startsWith("liveness") || top.startsWith("blocked on a permission") || top.startsWith("stale idle"))
201
+ return "Resolve the live blocker: submit with Enter and confirm processing, approve the permission prompt, or restart the stalled occupant; then re-probe.";
202
+ if (top.startsWith("required hooks/validators"))
203
+ return "Deploy and confirm the activation hook + governed-context verifier (scripts/protocol/install-activation-hooks.mjs, scripts/protocol/verify-governed-context.mjs), then re-probe.";
204
+ return "Resolve the listed blocker, then re-run the governed-readiness probe.";
205
+ }
206
+ /**
207
+ * Single source of truth for the four-state governed-readiness taxonomy, reused by the harness
208
+ * probe matrix, the readiness gate, and onboarding so they never emit conflicting statuses.
209
+ */
210
+ export function classifyGovernedReadiness(input) {
211
+ const category = harnessCategory(input.harness);
212
+ const highAuthority = isHighAuthorityRole(input.requestedRole);
213
+ const proofCheck = validateGovernedContextProof(input.governedContextProof);
214
+ const uptakeVerified = proofCheck.valid;
215
+ const proofAssurance = proofCheck.proofAssurance;
216
+ const baseRequired = maxAssuranceForCategory(category);
217
+ // PM/ODIN must clear at least static_control_file assurance; MCP-only never silently qualifies.
218
+ const requiredAssurance = highAuthority
219
+ ? (ASSURANCE_RANK[baseRequired] >= ASSURANCE_RANK.static_control_file ? baseRequired : "static_control_file")
220
+ : baseRequired;
221
+ if (category === "unsupported") {
222
+ return {
223
+ state: "UNSUPPORTED",
224
+ category,
225
+ requiredAssurance,
226
+ proofAssurance,
227
+ uptakeVerified: false,
228
+ blockers: ["harness cannot enforce an SCP governed-context control layer"],
229
+ nextSafeAction: "Use this harness only for bounded, non-governed one-shot help; do not assign it a governed role."
230
+ };
231
+ }
232
+ const blockers = [];
233
+ if (input.installed === false)
234
+ blockers.push("not installed or not provisioned");
235
+ if (input.authenticated === false)
236
+ blockers.push("authentication blocker (sign in or provision outside chat)");
237
+ if (Array.isArray(input.authBlockers) && input.authBlockers.length > 0)
238
+ blockers.push(`auth blocked: ${input.authBlockers.join(", ")}`);
239
+ // Affirmative requirement: hooks/validators must be confirmed available. Unknown or omitted
240
+ // hook availability foreclosed an optional-failure path, so it blocks governed readiness.
241
+ if (input.hooksAvailable !== true)
242
+ blockers.push("required hooks/validators not confirmed available (the governed-context verifier and activation hook must be affirmatively present)");
243
+ if (input.permissionBlocked === true)
244
+ blockers.push("blocked on a permission prompt");
245
+ if (input.idleStalled === true)
246
+ blockers.push("stale idle: no visible progress");
247
+ if (typeof input.deliveryState === "string" && UNSAFE_DELIVERY_STATES.has(input.deliveryState))
248
+ blockers.push("delivery not confirmed (input-bar-only or permission-blocked is not delivery)");
249
+ if (typeof input.livenessState === "string" && UNSAFE_LIVENESS_STATES.has(input.livenessState))
250
+ blockers.push("liveness not confirmed (no visible processing, missing ack, or stale idle)");
251
+ if (Array.isArray(input.otherBlockers))
252
+ for (const b of input.otherBlockers)
253
+ blockers.push(b);
254
+ if (!uptakeVerified) {
255
+ blockers.push("no verified governed-context uptake proof (MCP config or a skill file on disk is not uptake)");
256
+ }
257
+ else if (proofAssurance && ASSURANCE_RANK[proofAssurance] < ASSURANCE_RANK[requiredAssurance]) {
258
+ blockers.push(`assurance ${proofAssurance} is below the ${requiredAssurance} required for this harness/role`);
259
+ }
260
+ if (blockers.length === 0 && uptakeVerified) {
261
+ return {
262
+ state: "GOVERNED_READY",
263
+ category,
264
+ requiredAssurance,
265
+ proofAssurance,
266
+ uptakeVerified,
267
+ blockers: [],
268
+ nextSafeAction: "Governed authority verified. Launch the role; keep the governed-context proof fresh and re-verify on resume."
269
+ };
270
+ }
271
+ // Supported but blocked. If the harness can never reach the required assurance for this role,
272
+ // it is one-shot only for that role; otherwise the blockers are fixable.
273
+ const reachable = ASSURANCE_RANK[maxAssuranceForCategory(category)] >= ASSURANCE_RANK[requiredAssurance];
274
+ const state = reachable ? "FIXABLE_BLOCKED" : "NON_GOVERNED_ONE_SHOT_ONLY";
275
+ return {
276
+ state,
277
+ category,
278
+ requiredAssurance,
279
+ proofAssurance,
280
+ uptakeVerified,
281
+ blockers,
282
+ nextSafeAction: governedReadinessNextAction(state, requiredAssurance, blockers)
283
+ };
284
+ }
75
285
  function roleKind(roleSlot) {
76
286
  return normalizeRoleName(roleSlot);
77
287
  }
@@ -95,6 +305,35 @@ function scpContextSources(slot) {
95
305
  }
96
306
  return Array.from(sources);
97
307
  }
308
+ // Map a VERIFIED governed-context proof's assurance to the equivalent legacy SCP context-source
309
+ // label. Returns null when the proof does not validate (fail-closed: no valid proof, no source).
310
+ function proofDerivedContextSource(proof) {
311
+ const check = validateGovernedContextProof(proof);
312
+ if (!check.valid)
313
+ return null;
314
+ if (check.proofAssurance === "native_skill")
315
+ return "native sentinel-coordination-protocol skill";
316
+ if (check.proofAssurance === "static_control_file")
317
+ return "full injected SCP protocol text";
318
+ if (check.proofAssurance === "mcp_bootstrap")
319
+ return "odin-sentinel MCP bootstrap resource";
320
+ return null;
321
+ }
322
+ // Reconcile legacy context-source fields with a governed-context proof. A valid proof SUPPLIES the
323
+ // context source when legacy fields are absent (so a proof-only governed slot is not downgraded
324
+ // merely for missing stale fields), but it can never override a contradictory legacy declaration:
325
+ // incongruent legacy/proof sources fail closed as a conflict — never the more permissive source.
326
+ function resolveContextSources(slot) {
327
+ const legacy = scpContextSources(slot);
328
+ const proofSource = proofDerivedContextSource(slot.governedContextProof);
329
+ if (proofSource === null)
330
+ return { sources: legacy, conflict: false };
331
+ if (legacy.length === 0)
332
+ return { sources: [proofSource], conflict: false };
333
+ if (legacy.includes(proofSource))
334
+ return { sources: legacy, conflict: false };
335
+ return { sources: legacy, conflict: true };
336
+ }
98
337
  function defaultSafeOutcomes() {
99
338
  return [
100
339
  "choose a different harness",
@@ -111,7 +350,7 @@ function classifyProbeText(harness, text) {
111
350
  const classes = [];
112
351
  if (/permission|allow|approve|deny|press y|waiting for confirmation/.test(safeText))
113
352
  classes.push("BLOCKED_BY_PERMISSION");
114
- if (/login|sign in|authenticate|kilo auth login|\/connect/.test(safeText))
353
+ if (/login|\bsign[\s-]?in\b|\bnot signed[\s-]?in\b|authenticate|kilo auth login|\/connect/.test(safeText))
115
354
  classes.push("BLOCKED_BY_LOGIN");
116
355
  if (/api key|apikey|credential|inference credential|provider config|openhands/.test(safeText))
117
356
  classes.push("BLOCKED_BY_API_KEY");
@@ -450,6 +689,33 @@ export function getActivationGates() {
450
689
  generated_at: "2026-01-01T00:00:00Z",
451
690
  files: [{ path: "protocol/SCP.md", bytes: 4175, lines: 87, sha256: "<sha256-digest>" }]
452
691
  }
692
+ },
693
+ governedContextProof: {
694
+ requirement: "Governed authority is fail-closed: MCP being configured or an SCP skill existing on disk is NOT enough. Before a persistent governed role acts, prove the control layer was loaded and taken up — a stable source marker actually observed — at an assurance level adequate for the role.",
695
+ fourStateModel: GOVERNED_READINESS_MODEL,
696
+ requiredFields: ["schema", "role", "harness", "source_type", "control_source.marker", "uptake_receipt", "generated_at"],
697
+ sourceTypes: GOVERNED_CONTEXT_SOURCE_TYPES,
698
+ rejects: [
699
+ "self-reported boolean with no stable source marker",
700
+ "MCP configured alone / skill file on disk alone",
701
+ "checksum mismatch when control_source.path is present",
702
+ "missing or stale uptake receipt",
703
+ "secret-looking values"
704
+ ],
705
+ verifierScript: "scripts/protocol/verify-governed-context.mjs",
706
+ installerScript: "scripts/protocol/install-activation-hooks.mjs",
707
+ verifyCommand: "node scripts/protocol/verify-governed-context.mjs <proof.json>",
708
+ recordCommand: "node scripts/protocol/verify-governed-context.mjs --record --source <control-file> --marker <stable-marker> > proof.json",
709
+ surfacedBy: ["odin.get_harness_probe_matrix", "odin.evaluate_readiness_gate", "odin.get_onboarding_plan"],
710
+ example: {
711
+ schema: GOVERNED_CONTEXT_PROOF_SCHEMA,
712
+ role: "B/DEV-1",
713
+ harness: "Claude Code",
714
+ source_type: "native_skill",
715
+ control_source: { path: "~/.claude/skills/sentinel-coordination-protocol/SKILL.md", marker: "SCP_PUBLIC_VERSION: 0.4.x", sha256: "<sha256-digest>" },
716
+ uptake_receipt: { method: "quoted_marker", evidence_marker: "SCP_PUBLIC_VERSION: 0.4.x", observed: true, observed_at: "2026-01-01T00:00:00Z" },
717
+ generated_at: "2026-01-01T00:00:00Z"
718
+ }
453
719
  }
454
720
  };
455
721
  }
@@ -753,7 +1019,9 @@ export function evaluateReadinessGate(input) {
753
1019
  const harness = slot.harness ?? "unknown";
754
1020
  const classifications = [];
755
1021
  const notes = [];
756
- const sources = scpContextSources(slot);
1022
+ // A valid governed-context proof can supply the legacy context-source when legacy fields are
1023
+ // absent; a legacy/proof contradiction fails closed (CONTEXT_SOURCE_CONFLICT), never permissive.
1024
+ const { sources, conflict: contextSourceConflict } = resolveContextSources(slot);
757
1025
  let status = "PASS";
758
1026
  if (!execPmAuthorized)
759
1027
  classifications.push("EXEC_PM_AUTHORIZATION_REQUIRED");
@@ -777,6 +1045,10 @@ export function evaluateReadinessGate(input) {
777
1045
  classifications.push("SCP_SKILL_MISSING");
778
1046
  if (sources.length === 0 && isGovernedRole(slot.roleSlot))
779
1047
  classifications.push("NON_GOVERNED_ONE_SHOT_ONLY");
1048
+ if (contextSourceConflict) {
1049
+ classifications.push("CONTEXT_SOURCE_CONFLICT");
1050
+ notes.push("Legacy SCP context-source field contradicts the governed-context proof source; failing closed. Remove the stale legacy field or supply a congruent proof — do not rely on the more permissive source.");
1051
+ }
780
1052
  if (slot.firstRunPermissionStatus === "PROMPT_WAITING" || slot.firstRunPermissionStatus === "DENIED")
781
1053
  classifications.push("BLOCKED_BY_PERMISSION");
782
1054
  if (slot.authStatus === "AUTH_LOGIN_REQUIRED")
@@ -812,7 +1084,8 @@ export function evaluateReadinessGate(input) {
812
1084
  "STREAMING_PROTOCOL_MISMATCH",
813
1085
  "MODEL_UNREACHABLE",
814
1086
  "ROLE_COMPATIBILITY_FAILED",
815
- "UNSUITABLE_FOR_ODIN_ROLE"
1087
+ "UNSUITABLE_FOR_ODIN_ROLE",
1088
+ "CONTEXT_SOURCE_CONFLICT"
816
1089
  ]);
817
1090
  const hasUnwaivableLaunchBlocker = uniqueClassifications.some((item) => unwaivableLaunchBlockers.has(item));
818
1091
  const approvedWaiver = execPmAuthorized && slot.waiver === "WAIVED_BY_EXEC_PM" && !hasUnwaivableLaunchBlocker;
@@ -836,36 +1109,70 @@ export function evaluateReadinessGate(input) {
836
1109
  const readyOccupant = ["BOOTSTRAPPED_IDLE", "ACTIVE_WATCH"].includes(occupantState) || vacantSlotExplicitlyOptional;
837
1110
  const substitutionActivationReady = vacantSlotExplicitlyOptional;
838
1111
  const launchAllowed = status === "PASS" || status === "WAIVED_BY_EXEC_PM" || status === "SUBSTITUTION_APPROVED_BY_EXEC_PM";
839
- const activationAllowed = status === "PASS"
1112
+ const activationAllowedBase = status === "PASS"
840
1113
  ? readyOccupant
841
1114
  : status === "WAIVED_BY_EXEC_PM"
842
1115
  ? readyOccupant && failureClassifications.length === 0
843
1116
  : status === "SUBSTITUTION_APPROVED_BY_EXEC_PM"
844
1117
  ? substitutionActivationReady
845
1118
  : false;
1119
+ // Fail-closed governed readiness for this slot, using the shared four-state taxonomy.
1120
+ const slotAuthenticated = slot.authStatus === "AUTH_READY"
1121
+ ? true
1122
+ : ["BLOCKED_BY_API_KEY", "AUTH_PROVIDER_BLOCKED", "BLOCKED_BY_LOGIN", "BLOCKED_BY_AUTH"].some((item) => uniqueClassifications.includes(item))
1123
+ ? false
1124
+ : "unknown";
1125
+ const governed = classifyGovernedReadiness({
1126
+ harness,
1127
+ authenticated: slotAuthenticated,
1128
+ requestedRole: slot.role ?? slot.roleSlot,
1129
+ governedContextProof: slot.governedContextProof,
1130
+ hooksAvailable: slot.hooksAvailable,
1131
+ deliveryState: slot.deliveryState,
1132
+ livenessState: slot.livenessState,
1133
+ permissionBlocked: uniqueClassifications.includes("BLOCKED_BY_PERMISSION") || slot.permissionBlocked === true,
1134
+ idleStalled: slot.idleStalled,
1135
+ otherBlockers: failureClassifications
1136
+ });
1137
+ // Hard fail-closed gate: a governed-role occupant may not ACTIVATE (begin governed work)
1138
+ // unless governedReadiness is GOVERNED_READY. Launch/provisioning may still proceed. This
1139
+ // closes the legacy readyOccupant / failureClassifications path where an MCP-configured or
1140
+ // skill-on-disk slot with no verified protocol uptake could reach TEAM_ACTIVATION_ALLOWED.
1141
+ const governedOccupant = isGovernedRole(slot.roleSlot) && !vacantSlot;
1142
+ const activationAllowed = governedOccupant ? activationAllowedBase && governed.state === "GOVERNED_READY" : activationAllowedBase;
1143
+ if (governedOccupant && activationAllowedBase && governed.state !== "GOVERNED_READY") {
1144
+ notes.push("Governed activation hard-blocked: occupant is not GOVERNED_READY (verified protocol uptake required). Launch/provisioning may proceed, but the occupant must not begin governed work until a governed-context proof verifies.");
1145
+ }
846
1146
  return {
847
1147
  roleSlot: slot.roleSlot,
848
1148
  harness,
849
1149
  status,
850
1150
  launchAllowed,
851
1151
  activationAllowed,
1152
+ governedActivationBlocked: governedOccupant && governed.state !== "GOVERNED_READY",
852
1153
  classifications: uniqueClassifications,
853
1154
  safeOutcomes: status === "PASS" ? [] : defaultSafeOutcomes(),
854
1155
  scpContextSources: sources,
855
1156
  mcpVersion: slot.mcpVersion,
856
1157
  deferredMcpHydration: slot.canHydrateDeferredMcpToolsAtBoot === true ? "AT_BOOT" : slot.canHydrateDeferredMcpToolsAfterSecondTurn === true ? "AFTER_SECOND_TURN" : "UNPROVEN",
857
1158
  nativeSkillInvocation: slot.nativeSkillInvocation === true,
1159
+ governedReadiness: governed.state,
1160
+ governedReadinessNextAction: governed.nextSafeAction,
858
1161
  notes
859
1162
  };
860
1163
  });
861
1164
  const overallStatus = hasSlots && rows.every((row) => row.launchAllowed) && cmuxAvailable && execPmAuthorized ? "PASS" : "FAIL";
862
1165
  const activationStatus = hasSlots && rows.every((row) => row.activationAllowed) && cmuxAvailable && execPmAuthorized ? "TEAM_ACTIVATION_ALLOWED" : "TEAM_ACTIVATION_BLOCKED";
1166
+ const governedReadinessStatus = hasSlots && rows.every((row) => row.governedReadiness === "GOVERNED_READY") ? "ALL_GOVERNED_READY" : "GOVERNED_READINESS_INCOMPLETE";
863
1167
  return {
864
1168
  version: VERSION,
865
1169
  minimumMcpVersion: minimum,
866
1170
  phases: GOVERNED_LAUNCH_PHASES,
867
1171
  overallStatus,
868
1172
  activationStatus,
1173
+ governedReadinessStatus,
1174
+ governedActivationBlockedCount: rows.filter((row) => row.governedActivationBlocked).length,
1175
+ governedReadinessModel: GOVERNED_READINESS_MODEL,
869
1176
  execPmAuthorized,
870
1177
  cmuxAvailable,
871
1178
  userPrompt: "Are all intended harnesses provisioned with accounts, plans, API keys, or local inference credentials so they will not malfunction when spun up?",
@@ -999,7 +1306,25 @@ export function getHarnessProbeMatrix(input = {}) {
999
1306
  "unknown";
1000
1307
  const advisoryClassifications = new Set(["USER_INPUT_REQUIRED", "NON_GOVERNED_ONE_SHOT_ONLY"]);
1001
1308
  const blockingClassifications = [...classifications].filter((item) => !advisoryClassifications.has(item));
1002
- const governedRoleReady = installedBinary && authenticated === true && hasGovernedContext && blockingClassifications.length === 0;
1309
+ // Fail-closed governed readiness: presence (mcp_configured / skill on disk) is necessary
1310
+ // context but never sufficient. GOVERNED_READY requires a verified governed-context uptake
1311
+ // proof at adequate assurance, plus no blocking classification, auth, or liveness issue.
1312
+ const authBlockerList = ["BLOCKED_BY_API_KEY", "AUTH_PROVIDER_BLOCKED", "BLOCKED_BY_LOGIN", "BLOCKED_BY_AUTH"].filter((item) => classifications.has(item));
1313
+ const governed = classifyGovernedReadiness({
1314
+ harness,
1315
+ installed: installedBinary,
1316
+ authenticated,
1317
+ requestedRole: observation?.requestedRole,
1318
+ governedContextProof: observation?.governedContextProof,
1319
+ hooksAvailable: observation?.hooksAvailable,
1320
+ deliveryState: observation?.deliveryState,
1321
+ livenessState: observation?.livenessState,
1322
+ permissionBlocked: classifications.has("BLOCKED_BY_PERMISSION") || observation?.permissionBlocked === true,
1323
+ idleStalled: observation?.idleStalled,
1324
+ authBlockers: authBlockerList,
1325
+ otherBlockers: blockingClassifications.filter((item) => !authBlockerList.includes(item))
1326
+ });
1327
+ const governedRoleReady = governed.state === "GOVERNED_READY";
1003
1328
  return {
1004
1329
  harness,
1005
1330
  installed: installedBinary,
@@ -1011,12 +1336,22 @@ export function getHarnessProbeMatrix(input = {}) {
1011
1336
  nativeSkillInvocation: ["Codex", "Claude Code"].includes(harness),
1012
1337
  sentinelCoordinationProtocolSkill: "install before governed launch when the harness supports native skills",
1013
1338
  autoLevel: key === "droid" ? (observation?.autoLevel ?? "unknown") : observation?.autoLevel,
1339
+ governedReadiness: governed.state,
1340
+ governedContext: {
1341
+ category: governed.category,
1342
+ requiredAssurance: governed.requiredAssurance,
1343
+ proofAssurance: governed.proofAssurance,
1344
+ uptakeVerified: governed.uptakeVerified,
1345
+ blockers: governed.blockers
1346
+ },
1347
+ nextSafeAction: governed.nextSafeAction,
1014
1348
  readiness: {
1015
1349
  installed_binary: installedBinary,
1016
1350
  authenticated,
1017
1351
  mcp_configured: mcpConfigured,
1018
1352
  mcp_management_available: mcpManagementAvailable ?? "unknown",
1019
1353
  mcp_tool_hydration: canHydrateAtBoot ? "AT_BOOT" : "AFTER_SECOND_TURN",
1354
+ governed_context_uptake_verified: governed.uptakeVerified,
1020
1355
  governed_role_ready: governedRoleReady
1021
1356
  },
1022
1357
  safeNextActions: classifications.size === 0 ? [] : defaultSafeOutcomes(),
@@ -1028,6 +1363,9 @@ export function getHarnessProbeMatrix(input = {}) {
1028
1363
  userPrompt: "Are all intended harnesses provisioned with accounts, plans, API keys, or local inference credentials so they will not malfunction when spun up?",
1029
1364
  secretProviderStatuses: providerStatuses,
1030
1365
  supportedProviders: ["Doppler", "1Password CLI (op)", "environment variable names", "direnv", "mise", "dotenv-style file presence", "GitHub auth", "local provider config files"],
1366
+ governedReadinessModel: GOVERNED_READINESS_MODEL,
1367
+ governedContextNote: "Presence is not authority. MCP being configured or an SCP skill existing on disk does not make a harness GOVERNED_READY; protocol uptake must be verified. Each row's governedReadiness is one of GOVERNED_READY, FIXABLE_BLOCKED, NON_GOVERNED_ONE_SHOT_ONLY, or UNSUPPORTED.",
1368
+ governedContextVerifier: "scripts/protocol/verify-governed-context.mjs",
1031
1369
  rows
1032
1370
  };
1033
1371
  }
@@ -1038,7 +1376,7 @@ const ONBOARDING_NO_SECRETS_NOTICE = "Do not paste API keys, tokens, OAuth value
1038
1376
  function onboardingGuidedSteps() {
1039
1377
  return [
1040
1378
  "Confirm Node.js >= 22.13.0 and the installed @bradheitmann/odin-sentinel package version.",
1041
- "Prefer the pinned pnpm command (pnpm dlx --package @bradheitmann/odin-sentinel@0.4.9 odin-sentinel-mcp); npm global install and npx are supported when pinned to the same release.",
1379
+ "Prefer the pinned pnpm command (pnpm dlx --package @bradheitmann/odin-sentinel@0.4.10 odin-sentinel-mcp); npm global install and npx are supported when pinned to the same release.",
1042
1380
  "Add the odin-sentinel-mcp stdio command to each selected harness MCP config and restart the harness.",
1043
1381
  "Provide SCP context: install the native sentinel-coordination-protocol skill where supported, otherwise inject full protocol text via odin.get_bootstrap_skill, or export a snapshot via odin.export_protocol_snapshot for non-MCP clients.",
1044
1382
  "Deploy the activation hooks with `node scripts/protocol/install-activation-hooks.mjs` so the full-instruction-read precheck runs before governed edits.",
@@ -1077,10 +1415,14 @@ export function getOnboardingPlan(input = {}) {
1077
1415
  const classifications = Array.isArray(row.classifications) ? row.classifications : [];
1078
1416
  const blockers = classifications.filter((item) => !ONBOARDING_ADVISORY_CLASSIFICATIONS.has(item));
1079
1417
  const readiness = asRecord(row.readiness);
1418
+ const governedReadiness = typeof row.governedReadiness === "string" ? row.governedReadiness : "FIXABLE_BLOCKED";
1080
1419
  return {
1081
1420
  harness: row.harness,
1082
1421
  installed: row.installed === true,
1083
- governedRoleReady: readiness.governed_role_ready === true,
1422
+ governedReadiness,
1423
+ governedRoleReady: governedReadiness === "GOVERNED_READY",
1424
+ governedContext: row.governedContext,
1425
+ governedNextSafeAction: row.nextSafeAction,
1084
1426
  classifications,
1085
1427
  blockers,
1086
1428
  readiness,
@@ -1093,11 +1435,16 @@ export function getOnboardingPlan(input = {}) {
1093
1435
  });
1094
1436
  const blockerSummary = readinessRows
1095
1437
  .filter((row) => row.blockers.length > 0)
1096
- .map((row) => ({ harness: row.harness, blockers: row.blockers, governedRoleReady: row.governedRoleReady }));
1438
+ .map((row) => ({ harness: row.harness, blockers: row.blockers, governedReadiness: row.governedReadiness, governedRoleReady: row.governedRoleReady }));
1097
1439
  const classifications = [...new Set(readinessRows.flatMap((row) => row.classifications))].sort();
1098
1440
  const governedReadyHarnesses = readinessRows.filter((row) => row.governedRoleReady).map((row) => row.harness);
1441
+ const governedReadinessByHarness = readinessRows.map((row) => ({ harness: row.harness, governedReadiness: row.governedReadiness, nextSafeAction: row.governedNextSafeAction }));
1099
1442
  const computerUseAvailable = input.computerUseAvailable === true;
1100
- const preferred = input.preferredSetupMode ?? "unset";
1443
+ // Accept the prose alias "assisted_computer_use" as canonical "assisted". The alias is a
1444
+ // compatibility spelling only; it never bypasses computerUseAvailable (assistedEligible below
1445
+ // still gates assisted on it), so an alias request with computer use off stays guided.
1446
+ const preferredRaw = input.preferredSetupMode ?? "unset";
1447
+ const preferred = preferredRaw === "assisted_computer_use" ? "assisted" : preferredRaw;
1101
1448
  // Assisted computer-use setup is offered only when a computer-use-capable harness is available.
1102
1449
  const assistedEligible = computerUseAvailable;
1103
1450
  let recommendedMode;
@@ -1174,6 +1521,8 @@ export function getOnboardingPlan(input = {}) {
1174
1521
  blockerSummary,
1175
1522
  unresolvedBlockerCount: blockerSummary.length,
1176
1523
  governedReadyHarnesses,
1524
+ governedReadinessByHarness,
1525
+ governedReadinessModel: GOVERNED_READINESS_MODEL,
1177
1526
  ledgerPath,
1178
1527
  ledgerNote: "This onboarding plan only reports where ODIN-owned artifacts will be tracked. It does not create, write, validate, or delete the install ledger or any harness config file; ledger-aware install behavior is a separate step.",
1179
1528
  nextUserAction
@@ -1244,10 +1593,13 @@ export function createProtocolService(repository = createFileProtocolRepository(
1244
1593
  getActiveWatchPacket,
1245
1594
  getHarnessProbeMatrix,
1246
1595
  getOnboardingPlan,
1596
+ classifyGovernedReadiness,
1597
+ harnessCategory,
1247
1598
  getDelegationPacket,
1248
1599
  getActivationGates,
1249
1600
  validateCmuxDeliveryProof: (proof) => validateCmuxDeliveryProof(proof),
1250
1601
  validateInstructionReadProof: (proof) => validateInstructionReadProof(proof),
1602
+ validateGovernedContextProof: (proof) => validateGovernedContextProof(proof),
1251
1603
  validateDelegationPacket: (packet) => validateDelegationPacket(packet, repository),
1252
1604
  validateBootReceipt: (receipt) => validateBootReceipt(receipt, repository),
1253
1605
  validateTeamManifest: (manifest) => validateTeamManifest(manifest, repository),