@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.
- package/README.md +7 -3
- package/dist/src/mcp/server.js +23 -4
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/protocol/index.d.ts +1 -1
- package/dist/src/protocol/index.js +1 -1
- package/dist/src/protocol/index.js.map +1 -1
- package/dist/src/protocol/schemas.d.ts +117 -0
- package/dist/src/protocol/schemas.js +41 -10
- package/dist/src/protocol/schemas.js.map +1 -1
- package/dist/src/protocol/service.d.ts +53 -0
- package/dist/src/protocol/service.js +404 -6
- package/dist/src/protocol/service.js.map +1 -1
- package/dist/src/protocol/version.d.ts +2 -2
- package/dist/src/protocol/version.js +2 -2
- package/docs/guides/quick-start.md +38 -2
- package/docs/guides/quickstart-prompts.md +2 -2
- package/docs/reference/client-compatibility.md +27 -0
- package/docs/reference/distribution.md +1 -1
- package/docs/reference/public-surface-audit.md +2 -2
- package/package.json +5 -5
- package/protocol/SCP.md +38 -2
- package/protocol/bootstrap-skill.md +17 -1
- package/protocol/closeout.yaml +1 -1
- package/protocol/delegation.yaml +15 -1
- package/protocol/model-profiles.yaml +20 -1
- package/protocol/receipts/boot-receipt.yaml +18 -0
- package/protocol/roles.yaml +1 -1
- package/protocol/topology.yaml +9 -1
- package/scripts/audit/verify-pack.mjs +14 -4
- package/scripts/protocol/install-activation-hooks.mjs +167 -0
- package/scripts/protocol/verify-instruction-read.mjs +205 -0
- package/templates/dev-slice-template.md +8 -0
- package/templates/pm-role-template.md +13 -0
- 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: [
|
|
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 (!
|
|
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 (
|
|
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:
|
|
1005
|
+
installed: installedBinary,
|
|
769
1006
|
classifications: [...classifications],
|
|
770
1007
|
modelStatus,
|
|
771
1008
|
visibleOutputTimeoutSeconds: timeout,
|
|
772
|
-
canHydrateDeferredMcpToolsAtBoot:
|
|
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),
|