@bradheitmann/odin-sentinel 0.4.7 → 0.4.8

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 (34) hide show
  1. package/README.md +7 -3
  2. package/dist/src/mcp/server.js +23 -4
  3. package/dist/src/mcp/server.js.map +1 -1
  4. package/dist/src/protocol/index.d.ts +1 -1
  5. package/dist/src/protocol/index.js +1 -1
  6. package/dist/src/protocol/index.js.map +1 -1
  7. package/dist/src/protocol/schemas.d.ts +117 -0
  8. package/dist/src/protocol/schemas.js +41 -10
  9. package/dist/src/protocol/schemas.js.map +1 -1
  10. package/dist/src/protocol/service.d.ts +53 -0
  11. package/dist/src/protocol/service.js +404 -6
  12. package/dist/src/protocol/service.js.map +1 -1
  13. package/dist/src/protocol/version.d.ts +2 -2
  14. package/dist/src/protocol/version.js +2 -2
  15. package/docs/guides/quick-start.md +38 -2
  16. package/docs/guides/quickstart-prompts.md +2 -2
  17. package/docs/reference/client-compatibility.md +27 -0
  18. package/docs/reference/distribution.md +1 -1
  19. package/docs/reference/public-surface-audit.md +2 -2
  20. package/package.json +5 -5
  21. package/protocol/SCP.md +38 -2
  22. package/protocol/bootstrap-skill.md +17 -1
  23. package/protocol/closeout.yaml +1 -1
  24. package/protocol/delegation.yaml +15 -1
  25. package/protocol/model-profiles.yaml +20 -1
  26. package/protocol/receipts/boot-receipt.yaml +18 -0
  27. package/protocol/roles.yaml +1 -1
  28. package/protocol/topology.yaml +9 -1
  29. package/scripts/audit/verify-pack.mjs +14 -4
  30. package/scripts/protocol/install-activation-hooks.mjs +167 -0
  31. package/scripts/protocol/verify-instruction-read.mjs +205 -0
  32. package/templates/dev-slice-template.md +8 -0
  33. package/templates/pm-role-template.md +13 -0
  34. package/templates/qa-slice-template.md +3 -0
@@ -127,6 +127,12 @@ function classifyProbeText(harness, text) {
127
127
  classes.push("BLOCKED_BY_PERMISSION");
128
128
  if (harness.toLowerCase() === "pi" && /role|mcp|skill|runtime proof|fiction/.test(safeText))
129
129
  classes.push("ROLE_COMPATIBILITY_FAILED");
130
+ if (/unauthorized|authentication parameter not received in header|missing auth header|auth header not/.test(safeText))
131
+ classes.push("BLOCKED_BY_AUTH");
132
+ if (harness.toLowerCase() === "crush" && /unauthorized|authentication parameter|auth header/.test(safeText))
133
+ classes.push("AUTH_PROVIDER_BLOCKED");
134
+ if (harness.toLowerCase() === "droid" && /insufficient permission to proceed|re-run with --auto|--auto high/.test(safeText))
135
+ classes.push("AUTO_HIGH_REQUIRED");
130
136
  return [...new Set(classes)];
131
137
  }
132
138
  function normalizePodCount(pods) {
@@ -170,7 +176,9 @@ export function getStartupPacket(input = {}, repository = getDefaultRepository()
170
176
  "use visible role slots only; do not create hidden subagents",
171
177
  `bootstrap executive office plus ${pods} development pod${pods === 1 ? "" : "s"}`,
172
178
  "state SESSION_OBJECTIVES before product dispatch",
173
- "refresh repo status, upstream parity, worktrees, stashes, and topology before lifecycle claims"
179
+ "refresh repo status, upstream parity, worktrees, stashes, and topology before lifecycle claims",
180
+ "before implementation, QA acceptance, or ACTIVE_WATCH: produce full-instruction-read proof (path, byte or line count, and sha256 per file) and verify it with scripts/protocol/verify-instruction-read.mjs",
181
+ "for CMUX dispatch: send text, submit Enter, read the target surface, and confirm processing before treating the message as delivered (input-bar text is not delivery)"
174
182
  ];
175
183
  const modelProfiles = requireRecord(data.modelProfiles.profiles, "model-profiles.profiles");
176
184
  const roleModelProfile = modelProfileKeys(role)
@@ -206,6 +214,7 @@ export function getStartupPacket(input = {}, repository = getDefaultRepository()
206
214
  bootReceiptSchema: getBootReceiptSchema(repository),
207
215
  teamManifestLocator: "odin://protocol/receipts/team-manifest",
208
216
  activeWatch,
217
+ activationGates: getActivationGates(),
209
218
  startupPrompt: [
210
219
  "Use ODIN Sentinel coordination.",
211
220
  "",
@@ -217,6 +226,8 @@ export function getStartupPacket(input = {}, repository = getDefaultRepository()
217
226
  "Before occupant launch, readiness must PASS or be explicitly WAIVED_BY_EXEC_PM / SUBSTITUTION_APPROVED_BY_EXEC_PM.",
218
227
  "Valid SCP context source required for governed occupants: native sentinel-coordination-protocol skill, compatible odin-sentinel MCP, or full injected SCP protocol text.",
219
228
  "Boot receipt schema: use write_scope: [] for no current write assignment; do not use null.",
229
+ "Activation gates: before implementation, QA acceptance, or ACTIVE_WATCH, emit a full-instruction-read proof (path, byte/line count, and sha256 per file) and verify it with scripts/protocol/verify-instruction-read.mjs.",
230
+ "CMUX dispatch is not delivered until you submit with Enter and confirm processing on the target surface; input-bar text is not delivery.",
220
231
  "Team manifest locator: odin://protocol/receipts/team-manifest.",
221
232
  input.repoPath ? `Repository: ${input.repoPath}` : "Repository: discover from current working directory.",
222
233
  `Bootstrap executive office plus ${pods} development pod${pods === 1 ? "" : "s"} unless handoff or user instruction overrides this.`,
@@ -294,6 +305,154 @@ export function getBootReceiptExamples() {
294
305
  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
306
  };
296
307
  }
308
+ export const CMUX_DELIVERY_STATES = [
309
+ "DELIVERED_ACKED",
310
+ "DELIVERED_NO_ACK",
311
+ "INPUT_BAR_ONLY",
312
+ "PANE_BLOCKED_ON_PERMISSION",
313
+ "PANE_STILL_THINKING"
314
+ ];
315
+ const CMUX_DELIVERY_PROOF_FIELDS = [
316
+ "target_surface_locator",
317
+ "submitted",
318
+ "verification_method",
319
+ "observed_processing_state",
320
+ "timestamp",
321
+ "sender_role"
322
+ ];
323
+ const INSTRUCTION_READ_PROOF_REQUIRED_FIELDS = ["role", "generated_at", "files"];
324
+ const ACTIVATION_GATE_ROLE_WORK = ["implementation", "QA acceptance", "ACTIVE_WATCH"];
325
+ /**
326
+ * Validate a CMUX delivery proof. CMUX dispatch is not delivered until the sender submits
327
+ * with Enter and verifies processing on the target surface; text left in an input bar is
328
+ * INPUT_BAR_ONLY, not delivery. A submitted=false proof or an INPUT_BAR_ONLY state fails.
329
+ */
330
+ export function validateCmuxDeliveryProof(proof) {
331
+ const missing = validateRequiredFields(proof, CMUX_DELIVERY_PROOF_FIELDS);
332
+ const invalid = validateFieldTypes(proof, {
333
+ target_surface_locator: "string",
334
+ submitted: "boolean",
335
+ verification_method: "string",
336
+ observed_processing_state: "string",
337
+ timestamp: "string",
338
+ sender_role: "string"
339
+ });
340
+ const warnings = [];
341
+ const state = typeof proof.observed_processing_state === "string" ? proof.observed_processing_state : undefined;
342
+ if (state !== undefined && !CMUX_DELIVERY_STATES.includes(state)) {
343
+ if (!invalid.includes("observed_processing_state"))
344
+ invalid.push("observed_processing_state");
345
+ warnings.push(`observed_processing_state must be one of: ${CMUX_DELIVERY_STATES.join(", ")}`);
346
+ }
347
+ if (proof.submitted === false) {
348
+ invalid.push("submitted");
349
+ warnings.push("CMUX text was not submitted with Enter; this is INPUT_BAR_ONLY, not delivery. Send Enter and re-read the target surface.");
350
+ }
351
+ if (state === "INPUT_BAR_ONLY") {
352
+ if (!invalid.includes("observed_processing_state"))
353
+ invalid.push("observed_processing_state");
354
+ warnings.push("INPUT_BAR_ONLY: text is visible in the target input bar but not processed; not a valid delivery until submitted and confirmed.");
355
+ }
356
+ else if (state === "PANE_BLOCKED_ON_PERMISSION") {
357
+ warnings.push("Target pane received the message but is blocked on a permission/approval prompt; classify and resolve before treating it as actioned.");
358
+ }
359
+ else if (state === "DELIVERED_NO_ACK") {
360
+ warnings.push("Delivered, but no acknowledgement observed yet; revisit on the next poll.");
361
+ }
362
+ else if (state === "PANE_STILL_THINKING") {
363
+ warnings.push("Delivered; target is still processing. Revisit to confirm completion.");
364
+ }
365
+ return buildValidationResult(missing, invalid, warnings);
366
+ }
367
+ /**
368
+ * Validate the shape of a full-instruction-read proof: a role, a generation timestamp, and
369
+ * a non-empty files[] list where each entry carries a path, a byte or line count, and a
370
+ * sha256 digest. Disk verification (does the digest still match the file?) is performed by
371
+ * scripts/protocol/verify-instruction-read.mjs.
372
+ */
373
+ export function validateInstructionReadProof(proof) {
374
+ const missing = validateRequiredFields(proof, INSTRUCTION_READ_PROOF_REQUIRED_FIELDS);
375
+ const invalid = validateFieldTypes(proof, { role: "string", generated_at: "string" });
376
+ const warnings = [];
377
+ const files = Array.isArray(proof.files) ? proof.files : undefined;
378
+ if (files === undefined) {
379
+ if (!missing.includes("files") && !invalid.includes("files"))
380
+ invalid.push("files");
381
+ }
382
+ else if (files.length === 0) {
383
+ invalid.push("files");
384
+ warnings.push("instruction-read proof must list at least one file");
385
+ }
386
+ else {
387
+ files.forEach((entry, index) => {
388
+ const file = asRecord(entry);
389
+ if (!stringFieldPresent(file.path))
390
+ invalid.push(`files.${index}.path`);
391
+ if (!stringFieldPresent(file.sha256)) {
392
+ invalid.push(`files.${index}.sha256`);
393
+ warnings.push(`files.${index} must include a sha256 digest proving full-content coverage`);
394
+ }
395
+ const hasBytes = typeof file.bytes === "number" && Number.isFinite(file.bytes);
396
+ const hasLines = typeof file.lines === "number" && Number.isFinite(file.lines);
397
+ if (!hasBytes && !hasLines) {
398
+ invalid.push(`files.${index}.bytes`);
399
+ warnings.push(`files.${index} must include a byte count or line count`);
400
+ }
401
+ });
402
+ }
403
+ return buildValidationResult(missing, invalid, warnings);
404
+ }
405
+ /**
406
+ * Activation-gate guidance for agents using only ODIN MCP resources: how to satisfy CMUX
407
+ * delivery proof and full-instruction-read proof before acting under SCP.
408
+ */
409
+ export function getActivationGates() {
410
+ return {
411
+ version: VERSION,
412
+ summary: "Before acting under SCP, prove delivery and prove full instruction reads.",
413
+ addressesObservedFailStates: [
414
+ "CMUX standby text left unsubmitted in an input bar (INPUT_BAR_ONLY) mistaken for delivery",
415
+ "agents reading only the first 50-100 lines of instructions instead of the full sources"
416
+ ],
417
+ cmuxDeliveryProof: {
418
+ requirement: "CMUX dispatch is not delivered until the sender submits with Enter and verifies processing on the target surface. Text visible in an input bar is not delivery.",
419
+ requiredFields: CMUX_DELIVERY_PROOF_FIELDS,
420
+ allowedProcessingStates: CMUX_DELIVERY_STATES,
421
+ confirmedDeliveryStates: ["DELIVERED_ACKED", "DELIVERED_NO_ACK"],
422
+ senderSteps: [
423
+ "send the text to the target surface",
424
+ "submit with Enter (send-key enter)",
425
+ "read the target surface",
426
+ "confirm the agent processed or acknowledged the message"
427
+ ],
428
+ validateWith: "odin.validate_cmux_delivery_proof",
429
+ example: {
430
+ target_surface_locator: "workspace:1 pane:b surface:qa-1",
431
+ submitted: true,
432
+ verification_method: "cmux read-screen",
433
+ observed_processing_state: "DELIVERED_ACKED",
434
+ timestamp: "2026-01-01T00:00:00Z",
435
+ sender_role: "A/EXEC-PM"
436
+ }
437
+ },
438
+ instructionReadProof: {
439
+ requirement: `Activated roles must produce full-instruction-read proof before ${ACTIVATION_GATE_ROLE_WORK.join(", ")} work. First-screen, head, or partial reads are insufficient.`,
440
+ requiredFields: INSTRUCTION_READ_PROOF_REQUIRED_FIELDS,
441
+ perFileFields: ["path", "bytes or lines", "sha256"],
442
+ verifierScript: "scripts/protocol/verify-instruction-read.mjs",
443
+ installerScript: "scripts/protocol/install-activation-hooks.mjs",
444
+ verifyCommand: "node scripts/protocol/verify-instruction-read.mjs <proof.json>",
445
+ recordCommand: "node scripts/protocol/verify-instruction-read.mjs --record <file...> > proof.json",
446
+ validateWith: "odin.validate_instruction_read_proof",
447
+ example: {
448
+ schema: "odin.instruction_read_proof.v1",
449
+ role: "B/DEV-1",
450
+ generated_at: "2026-01-01T00:00:00Z",
451
+ files: [{ path: "protocol/SCP.md", bytes: 4175, lines: 87, sha256: "<sha256-digest>" }]
452
+ }
453
+ }
454
+ };
455
+ }
297
456
  export function getDelegationPacket(input) {
298
457
  return {
299
458
  receipt_type: "SCP-DELEGATE",
@@ -314,7 +473,16 @@ export function getDelegationPacket(input) {
314
473
  delivery_proof_required: true
315
474
  },
316
475
  report_back: input.reportBack ?? "Return status, evidence path, blockers, touched files, and next requested action.",
317
- required_delivery_states: ["DELIVERED_ACKED", "DELIVERED_NO_ACK", "INPUT_BAR_ONLY", "PANE_BLOCKED_ON_PERMISSION", "PANE_STILL_THINKING"]
476
+ required_delivery_states: [...CMUX_DELIVERY_STATES],
477
+ delivery_proof_contract: {
478
+ required: true,
479
+ reason: "CMUX dispatch is not delivered until submitted with Enter and confirmed on the target surface; input-bar text is not delivery.",
480
+ required_fields: CMUX_DELIVERY_PROOF_FIELDS,
481
+ confirmed_states: ["DELIVERED_ACKED", "DELIVERED_NO_ACK"],
482
+ sender_steps: ["send text", "submit Enter", "read target surface", "confirm processed or acknowledged"],
483
+ validate_with: "odin.validate_cmux_delivery_proof",
484
+ note: "Attach the resulting proof as delivery_proof on the dispatch record after sending."
485
+ }
318
486
  };
319
487
  }
320
488
  export function validateDelegationPacket(packet, repository = getDefaultRepository()) {
@@ -378,6 +546,18 @@ export function validateDelegationPacket(packet, repository = getDefaultReposito
378
546
  invalid.push("authority.may_qa_accept");
379
547
  warnings.push("same delegation grants implementation and QA acceptance authority");
380
548
  }
549
+ const deliveryProof = packet.delivery_proof;
550
+ if (deliveryProof !== undefined && deliveryProof !== null) {
551
+ const deliveryResult = validateCmuxDeliveryProof(asRecord(deliveryProof));
552
+ for (const field of deliveryResult.missing)
553
+ missing.push(`delivery_proof.${field}`);
554
+ for (const field of deliveryResult.invalid)
555
+ invalid.push(`delivery_proof.${field}`);
556
+ warnings.push(...deliveryResult.warnings);
557
+ }
558
+ else if (packet.cmux_dispatch === true && visibility?.delivery_proof_required !== false) {
559
+ warnings.push("governed-team CMUX dispatch requires delivery proof, but the packet omits delivery_proof; after sending, record target_surface_locator, submitted=true, verification_method, observed_processing_state, timestamp, and sender_role");
560
+ }
381
561
  return buildValidationResult(missing, invalid, warnings);
382
562
  }
383
563
  export function validateBootReceipt(receipt, repository = getDefaultRepository()) {
@@ -547,6 +727,22 @@ export function validateTeamManifest(manifest, repository = getDefaultRepository
547
727
  }
548
728
  return buildValidationResult(missing, invalid, warnings);
549
729
  }
730
+ export function getRoleCompatibilitySmokeTest() {
731
+ return {
732
+ purpose: "Confirm a candidate occupant can hold a governed role before assignment.",
733
+ runBeforeAssignment: true,
734
+ questions: [
735
+ "Do you accept the assigned role contract, its authority limits, and reports-to chain?",
736
+ "Can you emit a valid SCP boot receipt with the required fields?",
737
+ "Can you remain in the assigned lifecycle state (e.g., BOOTSTRAPPED_IDLE or ACTIVE_WATCH) until directed?",
738
+ "Will you treat this protocol as a real governance contract and not reframe it as fictional roleplay?"
739
+ ],
740
+ passRequires: "An affirmative, in-character answer to all four questions plus a valid receipt.",
741
+ mapToInput: "Record the verdict as roleCompatibility: ACCEPTS_ROLE | REFUSES_ROLE | UNPROVEN.",
742
+ failClassification: "ROLE_COMPATIBILITY_FAILED",
743
+ zeroSecretOutput: true
744
+ };
745
+ }
550
746
  export function evaluateReadinessGate(input) {
551
747
  const minimum = input.minimumMcpVersion ?? MINIMUM_COMPATIBLE_MCP_VERSION;
552
748
  const userProvisioningAnswer = input.userProvisioningAnswer ?? "unknown";
@@ -673,6 +869,8 @@ export function evaluateReadinessGate(input) {
673
869
  execPmAuthorized,
674
870
  cmuxAvailable,
675
871
  userPrompt: "Are all intended harnesses provisioned with accounts, plans, API keys, or local inference credentials so they will not malfunction when spun up?",
872
+ supportedSecretProviders: ["Doppler", "1Password CLI (op)", "environment variable names", "direnv", "mise", "dotenv-style file presence", "GitHub auth", "local provider config files"],
873
+ roleCompatibilitySmokeTest: getRoleCompatibilitySmokeTest(),
676
874
  readinessMatrix: rows,
677
875
  zeroSecretOutput: true
678
876
  };
@@ -741,8 +939,10 @@ export function getHarnessProbeMatrix(input = {}) {
741
939
  const providerStatuses = Object.fromEntries(Object.entries(input.providerStatuses ?? {}).map(([name, present]) => [name, { present, value: present ? "present redacted" : "absent" }]));
742
940
  const rows = intended.map((harness) => {
743
941
  const observation = observations.find((item) => item.harness.toLowerCase() === harness.toLowerCase());
942
+ const key = harness.toLowerCase();
943
+ const installedBinary = installed.has(key);
744
944
  const classifications = new Set();
745
- if (!installed.has(harness.toLowerCase()))
945
+ if (!installedBinary)
746
946
  classifications.add("NOT_INSTALLED_OR_UNPROVEN");
747
947
  if (input.userProvisioningAnswer !== "yes")
748
948
  classifications.add("USER_INPUT_REQUIRED");
@@ -756,23 +956,69 @@ export function getHarnessProbeMatrix(input = {}) {
756
956
  classifications.add("MODEL_UNREACHABLE");
757
957
  if ((observation?.elapsedSeconds ?? 0) > timeout && observation?.visibleContent !== true)
758
958
  classifications.add("MODEL_STALLED");
759
- if (harness.toLowerCase() === "goose" && observation?.reasoningContentOnly === true && observation.visibleContent !== true)
959
+ if (key === "goose" && observation?.reasoningContentOnly === true && observation.visibleContent !== true)
760
960
  classifications.add("STREAMING_PROTOCOL_MISMATCH");
961
+ // Structured auth status (zero-secret; status only, never values).
962
+ if (observation?.authStatus === "AUTH_MISSING")
963
+ classifications.add("BLOCKED_BY_API_KEY");
964
+ if (observation?.authStatus === "AUTH_PROVIDER_BLOCKED")
965
+ classifications.add("AUTH_PROVIDER_BLOCKED");
966
+ if (observation?.authStatus === "AUTH_LOGIN_REQUIRED")
967
+ classifications.add("BLOCKED_BY_LOGIN");
968
+ if (observation?.authStatus === "AUTH_UNKNOWN")
969
+ classifications.add("BLOCKED_BY_AUTH");
970
+ // MCP management surface: `droid mcp` exists; Crush has no MCP management command.
971
+ const mcpManagementAvailable = observation?.mcpManagementAvailable;
972
+ if (mcpManagementAvailable === false)
973
+ classifications.add(key === "crush" ? "MCP_UNAVAILABLE" : "MCP_UNPROVEN");
974
+ // Droid: governed readiness needs `droid mcp`; read-only exec is allowed without write
975
+ // authority; mission/high-autonomy recommendations require `--auto high`.
976
+ if (key === "droid") {
977
+ const autonomy = observation?.taskAutonomy ?? "read_only";
978
+ if ((autonomy === "mission" || autonomy === "high_autonomy") && observation?.autoLevel !== "high") {
979
+ classifications.add("AUTO_HIGH_REQUIRED");
980
+ }
981
+ }
982
+ // Governed-context proof: MCP configured, native SCP skill, or full injected protocol text.
983
+ const mcpConfigured = observation?.mcpConfigured === true;
984
+ const scpSkillInstalled = observation?.scpSkillInstalled === true;
985
+ const fullProtocolTextInjected = observation?.fullProtocolTextInjected === true;
986
+ const hasGovernedContext = mcpConfigured || scpSkillInstalled || fullProtocolTextInjected;
987
+ if (installedBinary && !hasGovernedContext)
988
+ classifications.add("NON_GOVERNED_ONE_SHOT_ONLY");
761
989
  const modelStatus = classifications.has("STREAMING_PROTOCOL_MISMATCH") ? "STREAMING_PROTOCOL_MISMATCH" :
762
990
  classifications.has("MODEL_REASONING_ONLY") ? "MODEL_REASONING_ONLY" :
763
991
  classifications.has("MODEL_STALLED") ? "MODEL_STALLED" :
764
992
  classifications.has("MODEL_UNREACHABLE") ? "MODEL_UNREACHABLE" :
765
993
  observation?.visibleContent === true ? "MODEL_READY" : "MODEL_SLOW";
994
+ // Multi-dimensional readiness: never collapse the distinct facts into one boolean.
995
+ const canHydrateAtBoot = ["Codex", "Claude Code", "Droid"].includes(harness);
996
+ const authBlockers = ["BLOCKED_BY_API_KEY", "AUTH_PROVIDER_BLOCKED", "BLOCKED_BY_LOGIN", "BLOCKED_BY_AUTH"];
997
+ const authenticated = observation?.authStatus === "AUTH_READY" ? true :
998
+ authBlockers.some((item) => classifications.has(item)) ? false :
999
+ "unknown";
1000
+ const advisoryClassifications = new Set(["USER_INPUT_REQUIRED", "NON_GOVERNED_ONE_SHOT_ONLY"]);
1001
+ const blockingClassifications = [...classifications].filter((item) => !advisoryClassifications.has(item));
1002
+ const governedRoleReady = installedBinary && authenticated === true && hasGovernedContext && blockingClassifications.length === 0;
766
1003
  return {
767
1004
  harness,
768
- installed: installed.has(harness.toLowerCase()),
1005
+ installed: installedBinary,
769
1006
  classifications: [...classifications],
770
1007
  modelStatus,
771
1008
  visibleOutputTimeoutSeconds: timeout,
772
- canHydrateDeferredMcpToolsAtBoot: ["Codex", "Claude Code", "Droid"].includes(harness),
1009
+ canHydrateDeferredMcpToolsAtBoot: canHydrateAtBoot,
773
1010
  canHydrateDeferredMcpToolsAfterSecondTurn: true,
774
1011
  nativeSkillInvocation: ["Codex", "Claude Code"].includes(harness),
775
1012
  sentinelCoordinationProtocolSkill: "install before governed launch when the harness supports native skills",
1013
+ autoLevel: key === "droid" ? (observation?.autoLevel ?? "unknown") : observation?.autoLevel,
1014
+ readiness: {
1015
+ installed_binary: installedBinary,
1016
+ authenticated,
1017
+ mcp_configured: mcpConfigured,
1018
+ mcp_management_available: mcpManagementAvailable ?? "unknown",
1019
+ mcp_tool_hydration: canHydrateAtBoot ? "AT_BOOT" : "AFTER_SECOND_TURN",
1020
+ governed_role_ready: governedRoleReady
1021
+ },
776
1022
  safeNextActions: classifications.size === 0 ? [] : defaultSafeOutcomes(),
777
1023
  sanitizedObservation: observation?.text ? redactSecretLikeText(observation.text) : undefined
778
1024
  };
@@ -785,6 +1031,154 @@ export function getHarnessProbeMatrix(input = {}) {
785
1031
  rows
786
1032
  };
787
1033
  }
1034
+ const ONBOARDING_ADVISORY_CLASSIFICATIONS = new Set(["USER_INPUT_REQUIRED"]);
1035
+ const DEFAULT_INSTALL_LEDGER_PATH = ".odin/install-ledger.json";
1036
+ const COMPUTER_USE_CANDIDATE_HARNESSES = ["Codex Desktop", "Claude Desktop", "Claude Code"];
1037
+ const ONBOARDING_NO_SECRETS_NOTICE = "Do not paste API keys, tokens, OAuth values, or provider secrets into chat. Report only whether each harness is already provisioned (account, environment, secret manager, or local config). Secret and provider readiness is reported by status only.";
1038
+ function onboardingGuidedSteps() {
1039
+ return [
1040
+ "Confirm Node.js >= 22.13.0 and the installed @bradheitmann/odin-sentinel package version.",
1041
+ "Install the MCP server globally (npm i -g @bradheitmann/odin-sentinel) or use the zero-install npx command inside each MCP config.",
1042
+ "Add the odin-sentinel-mcp stdio command to each selected harness MCP config and restart the harness.",
1043
+ "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
+ "Deploy the activation hooks with `node scripts/protocol/install-activation-hooks.mjs` so the full-instruction-read precheck runs before governed edits.",
1045
+ "Run the MCP smoke test and confirm serverInfo.name = odin-sentinel and a compatible version.",
1046
+ "Probe harness readiness with odin.get_harness_probe_matrix (zero-secret) and clear any auth, login, permission, MCP, or skill blockers.",
1047
+ "Confirm each harness is signed in or configured outside chat without pasting secrets.",
1048
+ "Open CMUX, compute the surface layout, and launch governed role slots only after readiness passes or EXEC PM records a waiver or substitution."
1049
+ ];
1050
+ }
1051
+ function onboardingAssistedSteps() {
1052
+ return [
1053
+ "Choose one available computer-use-capable harness (for example Codex Desktop, Claude Desktop, or Claude Code) to perform setup on your behalf.",
1054
+ "Hand the guided setup steps to that harness as its task; it operates your desktop/GUI, while ODIN's MCP server only supplies this plan.",
1055
+ "Supervise the assisted run: approve permission prompts yourself and never paste secrets into chat.",
1056
+ "After install and configuration complete, re-run odin.get_harness_probe_matrix to confirm governed readiness before launching governed roles."
1057
+ ];
1058
+ }
1059
+ /**
1060
+ * Build a zero-secret, harness-aware onboarding plan. Reuses getHarnessProbeMatrix for
1061
+ * readiness classification (no second taxonomy) and presents two setup choices: guided
1062
+ * manual setup (the safe default) and assisted computer-use setup (offered only when a
1063
+ * computer-use-capable harness is available). The MCP server returns this plan only; actual
1064
+ * GUI/computer-use is performed by an available computer-use-capable harness after the user
1065
+ * chooses assisted setup. This function does not install, write, or delete any harness config
1066
+ * or ledger file.
1067
+ */
1068
+ export function getOnboardingPlan(input = {}) {
1069
+ const probe = getHarnessProbeMatrix({
1070
+ intendedHarnesses: input.intendedHarnesses,
1071
+ installedHarnesses: input.installedHarnesses,
1072
+ userProvisioningAnswer: input.userProvisioningAnswer,
1073
+ observations: input.observations
1074
+ });
1075
+ const probeRows = Array.isArray(probe.rows) ? probe.rows : [];
1076
+ const readinessRows = probeRows.map((row) => {
1077
+ const classifications = Array.isArray(row.classifications) ? row.classifications : [];
1078
+ const blockers = classifications.filter((item) => !ONBOARDING_ADVISORY_CLASSIFICATIONS.has(item));
1079
+ const readiness = asRecord(row.readiness);
1080
+ return {
1081
+ harness: row.harness,
1082
+ installed: row.installed === true,
1083
+ governedRoleReady: readiness.governed_role_ready === true,
1084
+ classifications,
1085
+ blockers,
1086
+ readiness,
1087
+ modelStatus: row.modelStatus,
1088
+ nativeSkillInvocation: row.nativeSkillInvocation === true,
1089
+ scpSkillGuidance: row.sentinelCoordinationProtocolSkill,
1090
+ safeNextActions: Array.isArray(row.safeNextActions) ? row.safeNextActions : [],
1091
+ sanitizedObservation: row.sanitizedObservation
1092
+ };
1093
+ });
1094
+ const blockerSummary = readinessRows
1095
+ .filter((row) => row.blockers.length > 0)
1096
+ .map((row) => ({ harness: row.harness, blockers: row.blockers, governedRoleReady: row.governedRoleReady }));
1097
+ const classifications = [...new Set(readinessRows.flatMap((row) => row.classifications))].sort();
1098
+ const governedReadyHarnesses = readinessRows.filter((row) => row.governedRoleReady).map((row) => row.harness);
1099
+ const computerUseAvailable = input.computerUseAvailable === true;
1100
+ const preferred = input.preferredSetupMode ?? "unset";
1101
+ // Assisted computer-use setup is offered only when a computer-use-capable harness is available.
1102
+ const assistedEligible = computerUseAvailable;
1103
+ let recommendedMode;
1104
+ let modeRationale;
1105
+ if (preferred === "assisted" && assistedEligible) {
1106
+ recommendedMode = "assisted";
1107
+ modeRationale =
1108
+ "Assisted computer-use setup is available and was preferred. A computer-use-capable harness can perform setup on your behalf after you choose it; ODIN's MCP server only returns this plan and never drives the GUI itself.";
1109
+ }
1110
+ else if (preferred === "assisted" && !assistedEligible) {
1111
+ recommendedMode = "guided";
1112
+ modeRationale =
1113
+ "Assisted computer-use setup was requested, but computerUseAvailable is false, so guided manual setup is the safe available path.";
1114
+ }
1115
+ else if (preferred === "guided") {
1116
+ recommendedMode = "guided";
1117
+ modeRationale = "Guided manual setup was preferred; it is the safe, fully reviewable path.";
1118
+ }
1119
+ else {
1120
+ recommendedMode = "guided";
1121
+ modeRationale = assistedEligible
1122
+ ? "Guided manual setup is the safe default. Assisted computer-use setup is available if you prefer convenience; choose it explicitly to let an available computer-use harness perform setup for you."
1123
+ : "Guided manual setup is the safe default and the only available path because no computer-use-capable harness was reported.";
1124
+ }
1125
+ const ledgerPath = stringFieldPresent(input.ledgerPath) ? input.ledgerPath.trim() : DEFAULT_INSTALL_LEDGER_PATH;
1126
+ const platform = input.platform ?? "unknown";
1127
+ const userProvisioningAnswer = input.userProvisioningAnswer ?? "unknown";
1128
+ const guidedSteps = onboardingGuidedSteps();
1129
+ let nextUserAction;
1130
+ if (userProvisioningAnswer !== "yes") {
1131
+ nextUserAction =
1132
+ "Confirm whether each intended harness is already provisioned (signed in or configured outside chat) without pasting secrets, then re-run onboarding.";
1133
+ }
1134
+ else if (blockerSummary.length > 0) {
1135
+ nextUserAction = `Resolve the harness blockers in blockerSummary (auth, login, permission, MCP, or skill) before launching governed roles, then proceed with ${recommendedMode} setup.`;
1136
+ }
1137
+ else {
1138
+ nextUserAction = `All probed harnesses are governed-ready. Proceed with ${recommendedMode} setup, then launch governed role slots in CMUX only after readiness passes.`;
1139
+ }
1140
+ return {
1141
+ version: VERSION,
1142
+ zeroSecretOutput: true,
1143
+ noSecretsNotice: ONBOARDING_NO_SECRETS_NOTICE,
1144
+ userProvisioningPrompt: probe.userPrompt,
1145
+ userProvisioningAnswer,
1146
+ supportedSecretProviders: probe.supportedProviders,
1147
+ secretProviderStatuses: probe.secretProviderStatuses,
1148
+ platform,
1149
+ recommendedMode,
1150
+ modeRationale,
1151
+ setupModes: {
1152
+ guided: {
1153
+ title: "Guided manual setup (safe default)",
1154
+ description: "You run each install, configure, and verify step yourself and review every change. This path is the safest and works without any computer-use capability.",
1155
+ steps: guidedSteps
1156
+ },
1157
+ assisted: {
1158
+ title: "Assisted computer-use setup (convenience)",
1159
+ eligible: assistedEligible,
1160
+ available: computerUseAvailable,
1161
+ requestedButUnavailable: preferred === "assisted" && !assistedEligible,
1162
+ description: "An available computer-use-capable harness performs the guided steps on your behalf after you choose it. ODIN's MCP server only returns this plan; it never controls the GUI or desktop itself.",
1163
+ candidateHarnesses: assistedEligible ? [...COMPUTER_USE_CANDIDATE_HARNESSES] : [],
1164
+ caveat: "MCP returns plans only. Actual GUI or computer-use actions are performed solely by an available computer-use-capable harness after you explicitly choose assisted setup.",
1165
+ steps: assistedEligible
1166
+ ? onboardingAssistedSteps()
1167
+ : ["Assisted setup is unavailable because no computer-use-capable harness was reported (computerUseAvailable is not true). Use guided setup."]
1168
+ }
1169
+ },
1170
+ guidedSetupSteps: guidedSteps,
1171
+ assistedSetupEligible: assistedEligible,
1172
+ readinessRows,
1173
+ classifications,
1174
+ blockerSummary,
1175
+ unresolvedBlockerCount: blockerSummary.length,
1176
+ governedReadyHarnesses,
1177
+ ledgerPath,
1178
+ 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
+ nextUserAction
1180
+ };
1181
+ }
788
1182
  export function getCloseoutChecklist(mode, repository = getDefaultRepository()) {
789
1183
  const data = loadProtocolData(repository);
790
1184
  const modes = requireRecord(data.closeout.modes, "closeout.modes");
@@ -849,7 +1243,11 @@ export function createProtocolService(repository = createFileProtocolRepository(
849
1243
  evaluateReadinessGate,
850
1244
  getActiveWatchPacket,
851
1245
  getHarnessProbeMatrix,
1246
+ getOnboardingPlan,
852
1247
  getDelegationPacket,
1248
+ getActivationGates,
1249
+ validateCmuxDeliveryProof: (proof) => validateCmuxDeliveryProof(proof),
1250
+ validateInstructionReadProof: (proof) => validateInstructionReadProof(proof),
853
1251
  validateDelegationPacket: (packet) => validateDelegationPacket(packet, repository),
854
1252
  validateBootReceipt: (receipt) => validateBootReceipt(receipt, repository),
855
1253
  validateTeamManifest: (manifest) => validateTeamManifest(manifest, repository),