@bradheitmann/odin-sentinel 0.4.4 → 0.4.6

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.
Files changed (57) hide show
  1. package/AGENTS.md +64 -0
  2. package/CLAUDE.md +43 -0
  3. package/README.md +102 -335
  4. package/dist/src/mcp/server.js +43 -12
  5. package/dist/src/mcp/server.js.map +1 -1
  6. package/dist/src/protocol/schemas.d.ts +2529 -4
  7. package/dist/src/protocol/schemas.js +214 -18
  8. package/dist/src/protocol/schemas.js.map +1 -1
  9. package/dist/src/protocol/service.d.ts +96 -2
  10. package/dist/src/protocol/service.js +516 -4
  11. package/dist/src/protocol/service.js.map +1 -1
  12. package/dist/src/protocol/surface-layout.d.ts +40 -1
  13. package/dist/src/protocol/surface-layout.js +98 -1
  14. package/dist/src/protocol/surface-layout.js.map +1 -1
  15. package/dist/src/protocol/validators.d.ts +3 -0
  16. package/dist/src/protocol/validators.js +28 -0
  17. package/dist/src/protocol/validators.js.map +1 -1
  18. package/dist/src/protocol/version.d.ts +3 -0
  19. package/dist/src/protocol/version.js +3 -0
  20. package/dist/src/protocol/version.js.map +1 -1
  21. package/dist/src/telemetry/config.d.ts +8 -0
  22. package/dist/src/telemetry/config.js +24 -0
  23. package/dist/src/telemetry/config.js.map +1 -1
  24. package/dist/src/telemetry/index.d.ts +5 -5
  25. package/dist/src/telemetry/index.js +3 -3
  26. package/dist/src/telemetry/index.js.map +1 -1
  27. package/dist/src/telemetry/redactor.js +25 -7
  28. package/dist/src/telemetry/redactor.js.map +1 -1
  29. package/dist/src/telemetry/report.d.ts +108 -0
  30. package/dist/src/telemetry/report.js +83 -3
  31. package/dist/src/telemetry/report.js.map +1 -1
  32. package/dist/src/telemetry/submit.d.ts +2 -0
  33. package/dist/src/telemetry/submit.js +79 -6
  34. package/dist/src/telemetry/submit.js.map +1 -1
  35. package/docs/guides/quick-start.md +112 -44
  36. package/docs/guides/quickstart-prompts.md +65 -0
  37. package/docs/guides/recommended-starter-team.md +45 -27
  38. package/docs/reference/client-compatibility.md +20 -43
  39. package/docs/reference/cost-and-privacy.md +26 -23
  40. package/docs/reference/distribution.md +40 -55
  41. package/docs/reference/public-surface-audit.md +35 -114
  42. package/package.json +19 -4
  43. package/protocol/SCP.md +8 -1
  44. package/protocol/bootstrap-skill.md +16 -11
  45. package/protocol/closeout.yaml +7 -1
  46. package/protocol/delegation.yaml +1 -1
  47. package/protocol/model-profiles.yaml +55 -1
  48. package/protocol/receipts/boot-receipt.yaml +42 -0
  49. package/protocol/receipts/team-manifest.yaml +41 -0
  50. package/protocol/roles.yaml +69 -1
  51. package/protocol/topology.yaml +78 -36
  52. package/scripts/audit/public-surface.mjs +48 -19
  53. package/scripts/audit/verify-pack.mjs +294 -27
  54. package/templates/dev-slice-template.md +56 -0
  55. package/templates/pm-role-template.md +61 -0
  56. package/templates/qa-slice-template.md +46 -0
  57. package/templates/team-manifest-template.yaml +163 -0
@@ -1,7 +1,7 @@
1
1
  import YAML from "yaml";
2
2
  import { createFileProtocolRepository } from "./repository.js";
3
- import { asRecord, buildValidationResult, isVisibleRoleSlot, requireRecord, requireStringArray, validateFieldTypes, validateNonEmptyArrays, validateRequiredFields } from "./validators.js";
4
- import { VERSION } from "./version.js";
3
+ import { asRecord, buildValidationResult, isVisibleRoleSlot, isVersionBelow, redactSecretLikeText, requireRecord, requireStringArray, validateFieldTypes, validateNonEmptyArrays, validateRequiredFields } from "./validators.js";
4
+ import { MINIMUM_COMPATIBLE_MCP_VERSION, PROTOCOL_SCHEMA_VERSION, PUBLIC_LATEST_VERSION, VERSION } from "./version.js";
5
5
  const DEFAULT_ROLE_SLOT = "A/EXEC-PM";
6
6
  const DEFAULT_HANDOFF_PATHS = [
7
7
  "docs/handoffs/",
@@ -41,6 +41,94 @@ function safeErrorText(value) {
41
41
  return "<empty>";
42
42
  return sanitized.length > 120 ? `${sanitized.slice(0, 117)}...` : sanitized;
43
43
  }
44
+ const GOVERNED_LAUNCH_PHASES = [
45
+ "SURFACE_PROVISIONED",
46
+ "OCCUPANT_READINESS",
47
+ "OCCUPANT_LAUNCH",
48
+ "BOOT_RECEIPT_VALIDATION",
49
+ "TEAM_ACTIVATION",
50
+ "ACTIVE_WATCH"
51
+ ];
52
+ const ACTIVE_WATCH_TERMINAL_STATES = [
53
+ "RELEASED_BY_OPERATOR",
54
+ "HANDED_OFF",
55
+ "PARKED_IDLE",
56
+ "FAILED",
57
+ "WATCH_UNSUPPORTED"
58
+ ];
59
+ const AUTH_STATUSES = [
60
+ "AUTH_READY",
61
+ "AUTH_PRESENT_UNVERIFIED",
62
+ "AUTH_MISSING",
63
+ "AUTH_PROVIDER_BLOCKED",
64
+ "AUTH_LOGIN_REQUIRED",
65
+ "AUTH_UNKNOWN"
66
+ ];
67
+ const MODEL_STATUSES = [
68
+ "MODEL_READY",
69
+ "MODEL_SLOW",
70
+ "MODEL_STALLED",
71
+ "MODEL_REASONING_ONLY",
72
+ "STREAMING_PROTOCOL_MISMATCH",
73
+ "MODEL_UNREACHABLE"
74
+ ];
75
+ function roleKind(roleSlot) {
76
+ return normalizeRoleName(roleSlot);
77
+ }
78
+ function isOdinRole(role) {
79
+ const normalized = roleKind(role);
80
+ return normalized === "EXEC_ODIN" || normalized === "TEAM_ODIN";
81
+ }
82
+ function isGovernedRole(role) {
83
+ return ["EXEC_PM", "EXEC_ODIN", "EXEC_ASST", "EXEC_RSCH", "EXEC_QA", "TEAM_PM", "TEAM_ODIN", "DEV_WORKER", "QA_WORKER", "SHADOW_REVIEWER"].includes(roleKind(role));
84
+ }
85
+ function scpContextSources(slot) {
86
+ const sources = new Set();
87
+ if (slot.scpSkillAvailable === true || slot.protocolTextSource === "native_skill") {
88
+ sources.add("native sentinel-coordination-protocol skill");
89
+ }
90
+ if (slot.fullProtocolTextInjected === true || slot.protocolTextSource === "injected_full_text") {
91
+ sources.add("full injected SCP protocol text");
92
+ }
93
+ if (slot.protocolTextSource === "mcp_resource") {
94
+ sources.add("odin-sentinel MCP bootstrap resource");
95
+ }
96
+ return Array.from(sources);
97
+ }
98
+ function defaultSafeOutcomes() {
99
+ return [
100
+ "choose a different harness",
101
+ "receive setup guidance without pasting secrets",
102
+ "mark slot VACANT_ROLE_SLOT",
103
+ "request EXEC PM-approved substitution"
104
+ ];
105
+ }
106
+ function stringFieldPresent(value) {
107
+ return typeof value === "string" && value.trim().length > 0;
108
+ }
109
+ function classifyProbeText(harness, text) {
110
+ const safeText = redactSecretLikeText(text).toLowerCase();
111
+ const classes = [];
112
+ if (/permission|allow|approve|deny|press y|waiting for confirmation/.test(safeText))
113
+ classes.push("BLOCKED_BY_PERMISSION");
114
+ if (/login|sign in|authenticate|kilo auth login|\/connect/.test(safeText))
115
+ classes.push("BLOCKED_BY_LOGIN");
116
+ if (/api key|apikey|credential|inference credential|provider config|openhands/.test(safeText))
117
+ classes.push("BLOCKED_BY_API_KEY");
118
+ if (/permission denied|eacces|operation not permitted/.test(safeText))
119
+ classes.push("BLOCKED_BY_PERMISSION");
120
+ if (/roleplay|fiction|cannot accept role|not a real protocol/.test(safeText))
121
+ classes.push("ROLE_COMPATIBILITY_FAILED");
122
+ if (harness.toLowerCase() === "kilocode" && /auth login|\/connect|login/.test(safeText))
123
+ classes.push("BLOCKED_BY_LOGIN");
124
+ if (harness.toLowerCase() === "openhands" && /api|credential|provider/.test(safeText))
125
+ classes.push("AUTH_PROVIDER_BLOCKED");
126
+ if (harness.toLowerCase() === "crush" && /permission|denied|approve/.test(safeText))
127
+ classes.push("BLOCKED_BY_PERMISSION");
128
+ if (harness.toLowerCase() === "pi" && /role|mcp|skill|runtime proof|fiction/.test(safeText))
129
+ classes.push("ROLE_COMPATIBILITY_FAILED");
130
+ return [...new Set(classes)];
131
+ }
44
132
  function normalizePodCount(pods) {
45
133
  const value = pods ?? 1;
46
134
  if (!Number.isInteger(value) || value < 0 || value > 25) {
@@ -91,26 +179,121 @@ export function getStartupPacket(input = {}, repository = getDefaultRepository()
91
179
  if (!roleModelProfile) {
92
180
  throw new Error(`No model profile found for role: ${safeErrorText(role)}`);
93
181
  }
182
+ const governedMode = input.governedMode ?? "GOVERNED_TEAM";
183
+ const layoutProfile = input.layoutProfile ?? "human_cmux_quad";
184
+ const activeWatch = getActiveWatchPacket({
185
+ role,
186
+ parkedMode: input.parkedMode,
187
+ manifest: {
188
+ executive_office: ["A/EXEC-PM", "A/EXEC-ODIN", "A/EXEC-ASST", "A/EXEC-RSCH", "A/EXEC-QA"],
189
+ development_pods: ["B", "C"]
190
+ }
191
+ });
94
192
  return {
95
193
  version: VERSION,
194
+ protocolVersion: PROTOCOL_SCHEMA_VERSION,
195
+ publicLatestVersion: PUBLIC_LATEST_VERSION,
196
+ minimumCompatibleMcpVersion: MINIMUM_COMPATIBLE_MCP_VERSION,
96
197
  role,
97
198
  pods,
199
+ governedMode,
200
+ layoutProfile,
98
201
  resourcesToRead,
99
202
  requiredActions,
100
203
  defaultTopology: data.topology.default_topology,
101
204
  modelProfile: roleModelProfile,
102
205
  bootReceiptRequiredFields: data.bootReceipt.required_fields,
206
+ bootReceiptSchema: getBootReceiptSchema(repository),
207
+ teamManifestLocator: "odin://protocol/receipts/team-manifest",
208
+ activeWatch,
103
209
  startupPrompt: [
104
210
  "Use ODIN Sentinel coordination.",
105
211
  "",
106
212
  `You are ${role}.`,
213
+ `Governed mode: ${governedMode}.`,
214
+ `ODIN Sentinel MCP version: ${VERSION}; public/latest target: ${PUBLIC_LATEST_VERSION}; minimum compatible child MCP version: ${MINIMUM_COMPATIBLE_MCP_VERSION}.`,
215
+ `Expected layout profile: ${layoutProfile}. A/EXEC-PM stays in the same CMUX workspace as governed teams.`,
107
216
  "Load startup requirements from the odin-sentinel MCP resources and tools. Do not assume external local extensions exist.",
217
+ "Before occupant launch, readiness must PASS or be explicitly WAIVED_BY_EXEC_PM / SUBSTITUTION_APPROVED_BY_EXEC_PM.",
218
+ "Valid SCP context source required for governed occupants: native sentinel-coordination-protocol skill, compatible odin-sentinel MCP, or full injected SCP protocol text.",
219
+ "Boot receipt schema: use write_scope: [] for no current write assignment; do not use null.",
220
+ "Team manifest locator: odin://protocol/receipts/team-manifest.",
108
221
  input.repoPath ? `Repository: ${input.repoPath}` : "Repository: discover from current working directory.",
109
222
  `Bootstrap executive office plus ${pods} development pod${pods === 1 ? "" : "s"} unless handoff or user instruction overrides this.`,
223
+ isOdinRole(role) ? String(activeWatch.promptText) : "Non-ODIN role: no ODIN active-watch authority.",
110
224
  input.userInstruction ? `User instruction: ${input.userInstruction}` : "Ask for objectives if no handoff supplies them."
111
225
  ].join("\n")
112
226
  };
113
227
  }
228
+ export function getVersionMetadata() {
229
+ return {
230
+ name: "odin-sentinel",
231
+ version: VERSION,
232
+ serverVersion: VERSION,
233
+ protocolVersion: PROTOCOL_SCHEMA_VERSION,
234
+ publicLatestVersion: PUBLIC_LATEST_VERSION,
235
+ minimumCompatibleMcpVersion: MINIMUM_COMPATIBLE_MCP_VERSION,
236
+ driftChecks: {
237
+ observedTooOldExample: "0.2.1",
238
+ tooOldClass: "MCP_VERSION_TOO_OLD",
239
+ publicLatestDiffersFromProtocol: PUBLIC_LATEST_VERSION !== PROTOCOL_SCHEMA_VERSION
240
+ }
241
+ };
242
+ }
243
+ export function getBootReceiptSchema(repository = getDefaultRepository()) {
244
+ const data = loadProtocolData(repository);
245
+ return {
246
+ receipt_types: ["SCP_BOOT_RECEIPT", "SCP_MIN_BOOT_RECEIPT"],
247
+ minimumCompatibleMcpVersion: MINIMUM_COMPATIBLE_MCP_VERSION,
248
+ required_fields: data.bootReceipt.required_fields,
249
+ field_types: {
250
+ role: "string",
251
+ authority_layer: "string",
252
+ team: "string",
253
+ terminal_locator: "string",
254
+ branch: "string",
255
+ cwd: "string",
256
+ model_harness: "string",
257
+ permission_mode: "string",
258
+ may_implement: "boolean",
259
+ may_qa_accept: "boolean",
260
+ reports_to: "string",
261
+ write_scope: "string[]; use [] when unassigned; null is invalid",
262
+ evidence_path: "string",
263
+ current_task: "string"
264
+ },
265
+ allowed_lifecycle_states: [
266
+ "SURFACE_PROVISIONED",
267
+ "BOOTSTRAPPED_IDLE",
268
+ "ACTIVE_WATCH",
269
+ "VACANT_ROLE_SLOT",
270
+ "AGENT_SUBSTITUTION_REQUIRED",
271
+ "RELEASED_BY_OPERATOR",
272
+ "HANDED_OFF",
273
+ "PARKED_IDLE",
274
+ "FAILED",
275
+ "WATCH_UNSUPPORTED"
276
+ ],
277
+ examples: getBootReceiptExamples()
278
+ };
279
+ }
280
+ export function getBootReceiptExamples() {
281
+ const base = {
282
+ branch: "main",
283
+ cwd: "/path/to/repo",
284
+ permission_mode: "workspace-write",
285
+ evidence_path: ".odin/audit/session",
286
+ current_task: "bootstrap",
287
+ write_scope: []
288
+ };
289
+ return {
290
+ pm: { ...base, role: "A/EXEC-PM", authority_layer: "executive", team: "A", terminal_locator: "workspace:1 pane:a surface:pm", model_harness: "Codex CLI", may_implement: false, may_qa_accept: false, reports_to: "operator" },
291
+ odin: { ...base, role: "A/EXEC-ODIN", authority_layer: "meta_control", team: "A", terminal_locator: "workspace:1 pane:a surface:odin", model_harness: "Codex CLI", may_implement: false, may_qa_accept: false, reports_to: "operator", lifecycle_state: "ACTIVE_WATCH" },
292
+ dev_waiting_for_scope: { ...base, role: "B/DEV-1", authority_layer: "implementation", team: "B", terminal_locator: "workspace:1 pane:b surface:dev-1", model_harness: "Droid", may_implement: true, may_qa_accept: false, reports_to: "B/TEAM-PM", lifecycle_state: "BOOTSTRAPPED_IDLE", staffed_by: "A/EXEC-PM", parent_surface_ref: "pane:b", column_index: 1, team_letter: "B" },
293
+ qa: { ...base, role: "B/QA-1", authority_layer: "quality", team: "B", terminal_locator: "workspace:1 pane:b surface:qa-1", model_harness: "Crush", may_implement: false, may_qa_accept: true, reports_to: "B/TEAM-PM", staffed_by: "A/EXEC-PM", parent_surface_ref: "pane:b", column_index: 1, team_letter: "B" },
294
+ shadow: { ...base, role: "B/SHADOW-1", authority_layer: "review", team: "B", terminal_locator: "workspace:1 pane:b surface:shadow-1", model_harness: "Droid", may_implement: false, may_qa_accept: false, reports_to: "B/TEAM-PM", staffed_by: "A/EXEC-PM", parent_surface_ref: "pane:b", column_index: 1, team_letter: "B" }
295
+ };
296
+ }
114
297
  export function getDelegationPacket(input) {
115
298
  return {
116
299
  receipt_type: "SCP-DELEGATE",
@@ -221,6 +404,34 @@ export function validateBootReceipt(receipt, repository = getDefaultRepository()
221
404
  team_letter: "string"
222
405
  });
223
406
  const warnings = [];
407
+ if (receipt.write_scope === null) {
408
+ invalid.push("write_scope");
409
+ warnings.push("write_scope: null is invalid; use write_scope: [] for unassigned or no-write roles");
410
+ }
411
+ else if (typeof receipt.write_scope === "string") {
412
+ invalid.push("write_scope");
413
+ warnings.push("write_scope must be an array of strings; use [] when no write scope is assigned");
414
+ }
415
+ if (typeof receipt.lifecycle_state === "string" &&
416
+ ![
417
+ "SURFACE_PROVISIONED",
418
+ "BOOTSTRAPPED_IDLE",
419
+ "ACTIVE_WATCH",
420
+ "VACANT_ROLE_SLOT",
421
+ "AGENT_SUBSTITUTION_REQUIRED",
422
+ "RELEASED_BY_OPERATOR",
423
+ "HANDED_OFF",
424
+ "PARKED_IDLE",
425
+ "FAILED",
426
+ "WATCH_UNSUPPORTED"
427
+ ].includes(receipt.lifecycle_state)) {
428
+ invalid.push("lifecycle_state");
429
+ warnings.push("lifecycle_state is not one of the canonical ODIN/SCP states");
430
+ }
431
+ if (typeof receipt.mcp_version === "string" && isVersionBelow(receipt.mcp_version, MINIMUM_COMPATIBLE_MCP_VERSION)) {
432
+ invalid.push("mcp_version");
433
+ warnings.push(`MCP_VERSION_TOO_OLD: minimum compatible odin-sentinel MCP version is ${MINIMUM_COMPATIBLE_MCP_VERSION}`);
434
+ }
224
435
  if (receipt.may_implement === true && receipt.may_qa_accept === true) {
225
436
  invalid.push("may_qa_accept");
226
437
  warnings.push("same receipt grants both implementation and QA acceptance authority");
@@ -257,6 +468,11 @@ export function validateBootReceipt(receipt, repository = getDefaultRepository()
257
468
  export function validateTeamManifest(manifest, repository = getDefaultRepository()) {
258
469
  const data = loadProtocolData(repository);
259
470
  const required = requireStringArray(data.teamManifest.required_fields, "team-manifest.required_fields");
471
+ const roleSlotSchema = requireRecord(data.teamManifest.role_slot_schema, "team-manifest.role_slot_schema");
472
+ const requiredRoleSlotFields = requireStringArray(roleSlotSchema.required_fields, "team-manifest.role_slot_schema.required_fields");
473
+ const layoutLocatorFields = requireStringArray(roleSlotSchema.layout_locator_fields, "team-manifest.role_slot_schema.layout_locator_fields");
474
+ const readinessStatuses = requireStringArray(roleSlotSchema.readiness_statuses, "team-manifest.role_slot_schema.readiness_statuses");
475
+ const scpContextSources = requireStringArray(data.teamManifest.scp_context_sources, "team-manifest.scp_context_sources");
260
476
  const missing = validateRequiredFields(manifest, required);
261
477
  const invalid = validateFieldTypes(manifest, {
262
478
  session_id: "string",
@@ -282,8 +498,293 @@ export function validateTeamManifest(manifest, repository = getDefaultRepository
282
498
  }
283
499
  }
284
500
  }
501
+ if (Array.isArray(manifest.role_slots)) {
502
+ for (const [index, slotValue] of manifest.role_slots.entries()) {
503
+ const slot = asRecord(slotValue);
504
+ const roleSlot = typeof slot.role_slot === "string" ? slot.role_slot : undefined;
505
+ const governed = slot.governed !== false && (!roleSlot || isGovernedRole(roleSlot));
506
+ const layoutLocator = asRecord(slot.layout_locator);
507
+ for (const field of requiredRoleSlotFields) {
508
+ if (slot[field] === undefined || slot[field] === null || (typeof slot[field] === "string" && slot[field].trim() === "")) {
509
+ invalid.push(`role_slots.${index}.${field}`);
510
+ }
511
+ }
512
+ if (!roleSlot || !isVisibleRoleSlot(roleSlot))
513
+ invalid.push(`role_slots.${index}.role_slot`);
514
+ if (!stringFieldPresent(slot.harness))
515
+ invalid.push(`role_slots.${index}.harness`);
516
+ if (!stringFieldPresent(slot.readiness_status) || !readinessStatuses.includes(String(slot.readiness_status))) {
517
+ invalid.push(`role_slots.${index}.readiness_status`);
518
+ }
519
+ if (governed && Object.keys(layoutLocator).length === 0) {
520
+ invalid.push(`role_slots.${index}.layout_locator`);
521
+ }
522
+ for (const field of layoutLocatorFields) {
523
+ if (governed && !stringFieldPresent(layoutLocator[field])) {
524
+ invalid.push(`role_slots.${index}.layout_locator.${field}`);
525
+ }
526
+ }
527
+ const contextSource = typeof slot.scp_context_source === "string" ? slot.scp_context_source.trim() : "";
528
+ const recognizedContextSource = scpContextSources.includes(contextSource);
529
+ const hasContext = recognizedContextSource ||
530
+ slot.scp_skill_available === true ||
531
+ slot.full_protocol_text_injected === true ||
532
+ slot.mcp_available === true;
533
+ if (governed && !recognizedContextSource) {
534
+ invalid.push(`role_slots.${index}.scp_context_source`);
535
+ }
536
+ if (governed && !hasContext) {
537
+ warnings.push(`role_slots.${index} lacks SCP context source proof`);
538
+ }
539
+ if (typeof slot.mcp_version === "string" && isVersionBelow(slot.mcp_version, MINIMUM_COMPATIBLE_MCP_VERSION)) {
540
+ invalid.push(`role_slots.${index}.mcp_version`);
541
+ warnings.push(`role_slots.${index} MCP_VERSION_TOO_OLD: minimum compatible version is ${MINIMUM_COMPATIBLE_MCP_VERSION}`);
542
+ }
543
+ if (roleSlot && isOdinRole(roleSlot) && !slot.watches) {
544
+ warnings.push(`role_slots.${index} ODIN slot should declare watcher assignments`);
545
+ }
546
+ }
547
+ }
285
548
  return buildValidationResult(missing, invalid, warnings);
286
549
  }
550
+ export function evaluateReadinessGate(input) {
551
+ const minimum = input.minimumMcpVersion ?? MINIMUM_COMPATIBLE_MCP_VERSION;
552
+ const userProvisioningAnswer = input.userProvisioningAnswer ?? "unknown";
553
+ const execPmAuthorized = input.execPmAuthorized === true;
554
+ const cmuxAvailable = input.cmuxAvailable === true;
555
+ const hasSlots = input.slots.length > 0;
556
+ const rows = input.slots.map((slot) => {
557
+ const harness = slot.harness ?? "unknown";
558
+ const classifications = [];
559
+ const notes = [];
560
+ const sources = scpContextSources(slot);
561
+ let status = "PASS";
562
+ if (!execPmAuthorized)
563
+ classifications.push("EXEC_PM_AUTHORIZATION_REQUIRED");
564
+ if (!cmuxAvailable)
565
+ classifications.push("CMUX_UNAVAILABLE");
566
+ const vacantSlot = slot.occupantState === "VACANT_ROLE_SLOT";
567
+ if (vacantSlot) {
568
+ classifications.push("VACANT_ROLE_SLOT");
569
+ notes.push("Vacant role slot is provisioned without an occupant. It may launch as a surface placeholder; activation is allowed only when the slot is not marked required.");
570
+ if (slot.required !== false)
571
+ classifications.push("VACANT_SLOT_REQUIREMENT_UNVERIFIED");
572
+ }
573
+ else {
574
+ if (slot.mcpAvailable === true && typeof slot.mcpVersion !== "string")
575
+ classifications.push("MCP_VERSION_UNVERIFIED");
576
+ if (slot.mcpAvailable === true && typeof slot.mcpVersion === "string" && isVersionBelow(slot.mcpVersion, minimum))
577
+ classifications.push("MCP_VERSION_TOO_OLD");
578
+ if (slot.mcpAvailable === false)
579
+ classifications.push("MCP_UNAVAILABLE");
580
+ if (slot.scpSkillAvailable === false)
581
+ classifications.push("SCP_SKILL_MISSING");
582
+ if (sources.length === 0 && isGovernedRole(slot.roleSlot))
583
+ classifications.push("NON_GOVERNED_ONE_SHOT_ONLY");
584
+ if (slot.firstRunPermissionStatus === "PROMPT_WAITING" || slot.firstRunPermissionStatus === "DENIED")
585
+ classifications.push("BLOCKED_BY_PERMISSION");
586
+ if (slot.authStatus === "AUTH_LOGIN_REQUIRED")
587
+ classifications.push("BLOCKED_BY_LOGIN");
588
+ if (slot.authStatus === "AUTH_MISSING")
589
+ classifications.push("BLOCKED_BY_API_KEY");
590
+ if (slot.authStatus === "AUTH_PROVIDER_BLOCKED")
591
+ classifications.push("AUTH_PROVIDER_BLOCKED");
592
+ if (slot.authStatus === "AUTH_UNKNOWN")
593
+ classifications.push("BLOCKED_BY_AUTH");
594
+ if (slot.modelStatus === "MODEL_STALLED")
595
+ classifications.push("MODEL_STALLED");
596
+ if (slot.modelStatus === "STREAMING_PROTOCOL_MISMATCH" || slot.modelStatus === "MODEL_REASONING_ONLY")
597
+ classifications.push("STREAMING_PROTOCOL_MISMATCH");
598
+ if (slot.modelStatus === "MODEL_UNREACHABLE")
599
+ classifications.push("MODEL_UNREACHABLE");
600
+ if (slot.roleCompatibility === "REFUSES_ROLE" || slot.roleCompatibility === "UNPROVEN")
601
+ classifications.push("ROLE_COMPATIBILITY_FAILED");
602
+ if (userProvisioningAnswer !== "yes" && ["AUTH_MISSING", "AUTH_UNKNOWN", undefined].includes(slot.authStatus))
603
+ classifications.push("USER_INPUT_REQUIRED");
604
+ if (isOdinRole(slot.roleSlot) && slot.canHydrateDeferredMcpToolsAtBoot !== true && slot.fullProtocolTextInjected !== true && slot.scpSkillAvailable !== true) {
605
+ classifications.push("UNSUITABLE_FOR_ODIN_ROLE");
606
+ }
607
+ }
608
+ const uniqueClassifications = [...new Set(classifications)];
609
+ const failureClassifications = uniqueClassifications.filter((item) => item !== "SCP_SKILL_MISSING" && item !== "NON_GOVERNED_ONE_SHOT_ONLY" && item !== "VACANT_ROLE_SLOT");
610
+ const unwaivableLaunchBlockers = new Set([
611
+ "MCP_VERSION_UNVERIFIED",
612
+ "MCP_VERSION_TOO_OLD",
613
+ "MCP_UNAVAILABLE",
614
+ "BLOCKED_BY_PERMISSION",
615
+ "MODEL_STALLED",
616
+ "STREAMING_PROTOCOL_MISMATCH",
617
+ "MODEL_UNREACHABLE",
618
+ "ROLE_COMPATIBILITY_FAILED",
619
+ "UNSUITABLE_FOR_ODIN_ROLE"
620
+ ]);
621
+ const hasUnwaivableLaunchBlocker = uniqueClassifications.some((item) => unwaivableLaunchBlockers.has(item));
622
+ const approvedWaiver = execPmAuthorized && slot.waiver === "WAIVED_BY_EXEC_PM" && !hasUnwaivableLaunchBlocker;
623
+ const approvedSubstitution = execPmAuthorized &&
624
+ slot.waiver === "SUBSTITUTION_APPROVED_BY_EXEC_PM" &&
625
+ typeof slot.fallbackHarness === "string" &&
626
+ slot.fallbackHarness.trim().length > 0 &&
627
+ failureClassifications.length === 0;
628
+ if (uniqueClassifications.includes("NON_GOVERNED_ONE_SHOT_ONLY")) {
629
+ status = status === "PASS" ? "NON_GOVERNED_ONE_SHOT_ONLY" : status;
630
+ notes.push("Non-governed fallback constraints: one deterministic assignment, no ongoing authority, no management role, no cross-agent coordination, command/test/QA-style verification preferred, context cleared after response.");
631
+ }
632
+ if (failureClassifications.length > 0 && status === "PASS")
633
+ status = "FAIL";
634
+ if (approvedWaiver)
635
+ status = "WAIVED_BY_EXEC_PM";
636
+ if (approvedSubstitution)
637
+ status = "SUBSTITUTION_APPROVED_BY_EXEC_PM";
638
+ const occupantState = slot.occupantState ?? "";
639
+ const vacantSlotExplicitlyOptional = vacantSlot && slot.required === false;
640
+ const readyOccupant = ["BOOTSTRAPPED_IDLE", "ACTIVE_WATCH"].includes(occupantState) || vacantSlotExplicitlyOptional;
641
+ const substitutionActivationReady = vacantSlotExplicitlyOptional;
642
+ const launchAllowed = status === "PASS" || status === "WAIVED_BY_EXEC_PM" || status === "SUBSTITUTION_APPROVED_BY_EXEC_PM";
643
+ const activationAllowed = status === "PASS"
644
+ ? readyOccupant
645
+ : status === "WAIVED_BY_EXEC_PM"
646
+ ? readyOccupant && failureClassifications.length === 0
647
+ : status === "SUBSTITUTION_APPROVED_BY_EXEC_PM"
648
+ ? substitutionActivationReady
649
+ : false;
650
+ return {
651
+ roleSlot: slot.roleSlot,
652
+ harness,
653
+ status,
654
+ launchAllowed,
655
+ activationAllowed,
656
+ classifications: uniqueClassifications,
657
+ safeOutcomes: status === "PASS" ? [] : defaultSafeOutcomes(),
658
+ scpContextSources: sources,
659
+ mcpVersion: slot.mcpVersion,
660
+ deferredMcpHydration: slot.canHydrateDeferredMcpToolsAtBoot === true ? "AT_BOOT" : slot.canHydrateDeferredMcpToolsAfterSecondTurn === true ? "AFTER_SECOND_TURN" : "UNPROVEN",
661
+ nativeSkillInvocation: slot.nativeSkillInvocation === true,
662
+ notes
663
+ };
664
+ });
665
+ const overallStatus = hasSlots && rows.every((row) => row.launchAllowed) && cmuxAvailable && execPmAuthorized ? "PASS" : "FAIL";
666
+ const activationStatus = hasSlots && rows.every((row) => row.activationAllowed) && cmuxAvailable && execPmAuthorized ? "TEAM_ACTIVATION_ALLOWED" : "TEAM_ACTIVATION_BLOCKED";
667
+ return {
668
+ version: VERSION,
669
+ minimumMcpVersion: minimum,
670
+ phases: GOVERNED_LAUNCH_PHASES,
671
+ overallStatus,
672
+ activationStatus,
673
+ execPmAuthorized,
674
+ cmuxAvailable,
675
+ userPrompt: "Are all intended harnesses provisioned with accounts, plans, API keys, or local inference credentials so they will not malfunction when spun up?",
676
+ readinessMatrix: rows,
677
+ zeroSecretOutput: true
678
+ };
679
+ }
680
+ function manifestWatchTargets(role, manifest) {
681
+ const explicitSlots = Array.isArray(manifest?.role_slots)
682
+ ? manifest.role_slots.flatMap((slotValue) => {
683
+ const slot = asRecord(slotValue);
684
+ const watches = Array.isArray(slot.watches) ? slot.watches : [];
685
+ return typeof slot.role_slot === "string" && slot.role_slot === role
686
+ ? watches.filter((item) => typeof item === "string")
687
+ : [];
688
+ })
689
+ : [];
690
+ if (explicitSlots.length > 0)
691
+ return explicitSlots;
692
+ if (roleKind(role) === "EXEC_ODIN") {
693
+ return ["A/EXEC-PM", "A/EXEC-ASST", "A/EXEC-RSCH", "A/EXEC-QA", "B/ODIN", "C/ODIN"];
694
+ }
695
+ const team = role.includes("/") ? role.split("/")[0] : "B";
696
+ return [`${team}/TEAM-PM`, `${team}/DEV-1`, `${team}/QA-1`, `${team}/SHADOW-1`];
697
+ }
698
+ export function getActiveWatchPacket(input) {
699
+ const pollIntervalSeconds = input.pollIntervalSeconds ?? 30;
700
+ const targets = manifestWatchTargets(input.role, input.manifest);
701
+ const odinRole = isOdinRole(input.role);
702
+ const state = odinRole && input.parkedMode !== true ? "ACTIVE_WATCH" : input.parkedMode === true ? "PARKED_IDLE" : "WATCH_UNSUPPORTED";
703
+ const promptText = odinRole
704
+ ? [
705
+ `You are ${input.role}. After a valid boot receipt, transition to ${state}.`,
706
+ `Poll every ${pollIntervalSeconds} seconds unless explicitly released, parked, or handed off.`,
707
+ `Watch targets from the team manifest: ${targets.join(", ") || "NONE"}.`,
708
+ "Classify permission prompts immediately as BLOCKED_BY_PERMISSION.",
709
+ "Classify auth/login/API-key screens as BLOCKED_BY_AUTH, BLOCKED_BY_LOGIN, or BLOCKED_BY_API_KEY.",
710
+ "Classify local inference stalls separately: MODEL_STALLED or STREAMING_PROTOCOL_MISMATCH.",
711
+ "WATCH_WARN after 5 minutes without meaningful visible progress; STALLED after 10 minutes without heartbeat/status unless a known long-running operation is declared.",
712
+ "Allowed interventions: corrective prompts for scope drift, authority drift, secret mishandling, stopped polling, blocked panes, stale proof, missing receipts, and context exhaustion.",
713
+ "Forbidden actions: implement product work, QA-accept work, route business priorities, or override EXEC PM launch/activation authority.",
714
+ "ODIN is a meta-control peer layer: not DEV/QA, not equal to EXEC PM for launch authority, and not too subordinate to object to PM or agent drift.",
715
+ "A turn ending without re-arming the watch loop, starting an approved monitor, or handing off to a named successor is a protocol violation.",
716
+ input.planMode === true ? "Plan-mode/read-only carve-out: if persistent polling is unavailable, emit a named re-arm instruction and explicit next wake condition." : "Persistent watch is expected."
717
+ ].join("\n")
718
+ : "This role is not an ODIN role and has no ODIN active-watch authority.";
719
+ return {
720
+ role: input.role,
721
+ state,
722
+ pollIntervalSeconds,
723
+ watchWarnSeconds: 300,
724
+ stalledSeconds: 600,
725
+ targets,
726
+ terminalStates: ACTIVE_WATCH_TERMINAL_STATES,
727
+ classifications: ["BLOCKED_BY_PERMISSION", "BLOCKED_BY_AUTH", "BLOCKED_BY_LOGIN", "BLOCKED_BY_API_KEY", "MODEL_STALLED", "STREAMING_PROTOCOL_MISMATCH"],
728
+ mayIntervene: odinRole,
729
+ mayImplement: false,
730
+ mayQaAccept: false,
731
+ authority: { mayImplement: false, mayQaAccept: false },
732
+ promptText
733
+ };
734
+ }
735
+ export function getHarnessProbeMatrix(input = {}) {
736
+ const knownHarnesses = ["Codex", "Claude Code", "Droid", "Goose", "Crush", "OpenHands", "KiloCode", "Pi", "Aider", "NanoCoder"];
737
+ const intended = input.intendedHarnesses ?? knownHarnesses;
738
+ const installed = new Set((input.installedHarnesses ?? []).map((value) => value.toLowerCase()));
739
+ const observations = input.observations ?? [];
740
+ const timeout = input.visibleOutputTimeoutSeconds ?? 60;
741
+ const providerStatuses = Object.fromEntries(Object.entries(input.providerStatuses ?? {}).map(([name, present]) => [name, { present, value: present ? "present redacted" : "absent" }]));
742
+ const rows = intended.map((harness) => {
743
+ const observation = observations.find((item) => item.harness.toLowerCase() === harness.toLowerCase());
744
+ const classifications = new Set();
745
+ if (!installed.has(harness.toLowerCase()))
746
+ classifications.add("NOT_INSTALLED_OR_UNPROVEN");
747
+ if (input.userProvisioningAnswer !== "yes")
748
+ classifications.add("USER_INPUT_REQUIRED");
749
+ for (const item of classifyProbeText(harness, observation?.text ?? ""))
750
+ classifications.add(item);
751
+ if (observation?.reasoningContentOnly === true && observation.visibleContent !== true)
752
+ classifications.add("MODEL_REASONING_ONLY");
753
+ if (observation?.httpReachable === false)
754
+ classifications.add("MODEL_UNREACHABLE");
755
+ if (observation?.modelLoaded === false)
756
+ classifications.add("MODEL_UNREACHABLE");
757
+ if ((observation?.elapsedSeconds ?? 0) > timeout && observation?.visibleContent !== true)
758
+ classifications.add("MODEL_STALLED");
759
+ if (harness.toLowerCase() === "goose" && observation?.reasoningContentOnly === true && observation.visibleContent !== true)
760
+ classifications.add("STREAMING_PROTOCOL_MISMATCH");
761
+ const modelStatus = classifications.has("STREAMING_PROTOCOL_MISMATCH") ? "STREAMING_PROTOCOL_MISMATCH" :
762
+ classifications.has("MODEL_REASONING_ONLY") ? "MODEL_REASONING_ONLY" :
763
+ classifications.has("MODEL_STALLED") ? "MODEL_STALLED" :
764
+ classifications.has("MODEL_UNREACHABLE") ? "MODEL_UNREACHABLE" :
765
+ observation?.visibleContent === true ? "MODEL_READY" : "MODEL_SLOW";
766
+ return {
767
+ harness,
768
+ installed: installed.has(harness.toLowerCase()),
769
+ classifications: [...classifications],
770
+ modelStatus,
771
+ visibleOutputTimeoutSeconds: timeout,
772
+ canHydrateDeferredMcpToolsAtBoot: ["Codex", "Claude Code", "Droid"].includes(harness),
773
+ canHydrateDeferredMcpToolsAfterSecondTurn: true,
774
+ nativeSkillInvocation: ["Codex", "Claude Code"].includes(harness),
775
+ sentinelCoordinationProtocolSkill: "install before governed launch when the harness supports native skills",
776
+ safeNextActions: classifications.size === 0 ? [] : defaultSafeOutcomes(),
777
+ sanitizedObservation: observation?.text ? redactSecretLikeText(observation.text) : undefined
778
+ };
779
+ });
780
+ return {
781
+ zeroSecretOutput: true,
782
+ userPrompt: "Are all intended harnesses provisioned with accounts, plans, API keys, or local inference credentials so they will not malfunction when spun up?",
783
+ secretProviderStatuses: providerStatuses,
784
+ supportedProviders: ["Doppler", "1Password CLI (op)", "environment variable names", "direnv", "mise", "dotenv-style file presence", "GitHub auth", "local provider config files"],
785
+ rows
786
+ };
787
+ }
287
788
  export function getCloseoutChecklist(mode, repository = getDefaultRepository()) {
288
789
  const data = loadProtocolData(repository);
289
790
  const modes = requireRecord(data.closeout.modes, "closeout.modes");
@@ -297,14 +798,19 @@ export function getRuntimeNotice() {
297
798
  return {
298
799
  inferenceProvider: "none",
299
800
  hostedService: false,
300
- telemetry: false,
301
- networkCalls: false,
801
+ telemetry: "local_optional",
802
+ networkCalls: "none_by_default_optional_on_submit",
302
803
  maintainerPaysForUserInference: false,
303
804
  userPaysForHarnessInference: true,
304
805
  externalOrchestrationBundled: false,
806
+ telemetryAutomaticCollection: false,
807
+ telemetrySubmissionRequiresEndpoint: true,
808
+ telemetrySubmissionRequiresExplicitInvocation: true,
305
809
  notes: [
306
810
  "ODIN Sentinel is a local stdio MCP server.",
307
811
  "It serves protocol resources and validation tools; it does not proxy model calls.",
812
+ "Session reports are compiled locally; there is no automatic telemetry collection.",
813
+ "The submit_session_report tool may make a network call only after an endpoint is configured and the user explicitly invokes submission for that session.",
308
814
  "Model and harness profiles are capability preferences, not hosted inference.",
309
815
  "ODIN Sentinel is standalone and does not require any external orchestration repo."
310
816
  ]
@@ -337,6 +843,12 @@ export function createProtocolService(repository = createFileProtocolRepository(
337
843
  loadProtocolData: () => loadProtocolData(repository),
338
844
  getRoleProfile: (role) => getRoleProfile(role, repository),
339
845
  getStartupPacket: (input = {}) => getStartupPacket(input, repository),
846
+ getVersionMetadata,
847
+ getBootReceiptSchema: () => getBootReceiptSchema(repository),
848
+ getBootReceiptExamples,
849
+ evaluateReadinessGate,
850
+ getActiveWatchPacket,
851
+ getHarnessProbeMatrix,
340
852
  getDelegationPacket,
341
853
  validateDelegationPacket: (packet) => validateDelegationPacket(packet, repository),
342
854
  validateBootReceipt: (receipt) => validateBootReceipt(receipt, repository),