Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions docs/runbooks/shared-skill-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,20 @@ The full scan and mutation lease are authoritative. Healthy recurring runs are
quiet and write no durable state. A new actionable transition gets one redacted
notice; a recovery gets one redacted recovery notice.

Use `jarvos-skills-scheduled-repair` when a scheduler delivers stdout. It emits
exactly `NO_REPLY` for a healthy replay and only emits a count-only message for
new attention, recovery, repair, or safe failure. Pass `--announce-convergence`
once through the configured delivery route after activation, then remove that
flag so subsequent healthy runs remain quiet.
Use `jarvos-skills-scheduled-repair` when a scheduler delivers stdout. It projects
each outcome through the public operator-notification contract:

- Healthy replays, safe automatic holds (for example source safety refusals),
successful automatic repairs, and automatic resolutions emit exactly `NO_REPLY`.
- A concrete owner decision or failed recovery emits one plain-English direct
message that states what happened, what jarvOS did, the decision needed, the
next automatic step, and an opaque event reference.
- Raw reason codes, skill identities, paths, and stack text never appear in
human output. They remain on the shared-skills status/explain surface with
first-seen time and occurrence count.

Pass `--announce-convergence` once through the configured delivery route after
activation, then remove that flag so subsequent healthy runs remain quiet.

For an exact-path proof, bind each higher-precedence project or workspace root
as an absolute `scopeRoots` path and set `scopeRootsComplete: true` in the local
Expand Down
17 changes: 17 additions & 0 deletions modules/jarvos-runtime-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ Runtime adapters should stay thin. Shared jarvOS capabilities live in
`@jarvos/agent-context`; adapter directories translate those capabilities into a
host runtime's native surfaces such as MCP, hooks, skills, or desktop config.

## Operator notifications (unstable)

`@jarvos/runtime-kit` also exports an unstable 0.x, transport-neutral operator
notification contract. Producers submit a versioned semantic event; the contract
validates reviewed fields and deterministically returns either plain-English
text or `NO_REPLY`. It never renders caller-supplied diagnostic prose.

Use `evaluateOperatorNotification(event)` when a host needs the attention
policy, durable-status text, and dedupe identity. Use
`renderOperatorNotification(event)` when it only needs the direct output.
Action-required events include an opaque event reference; detailed codes,
paths, commits, receipts, and process output remain in owner-only evidence.
Safe holds are durable status, while routine safe repairs and resolutions are
quiet. Release-state events separately name published, approval-ready, and
future versions; stale or unknown observations use qualified wording rather
than claiming current publication or review readiness.

## Commands

```bash
Expand Down
2 changes: 2 additions & 0 deletions modules/jarvos-runtime-kit/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const stewardshipAdapter = require('./stewardship-adapter.js');
const stewardshipBootstrap = require('./stewardship-bootstrap.js');
const openclawPluginPersistence = require('./openclaw-plugin-persistence.js');
const capabilityDescriptor = require('./capability-descriptor.js');
const operatorNotification = require('./operator-notification.js');

const DEFAULT_AGENT_CONTEXT_MCP = 'modules/jarvos-agent-context/scripts/jarvos-mcp.js';
const REQUIRED_MCP_TOOL = 'jarvos_hydrate';
Expand Down Expand Up @@ -881,6 +882,7 @@ module.exports = {
...stewardshipBootstrap,
...openclawPluginPersistence,
...capabilityDescriptor,
...operatorNotification,
DEFAULT_AGENT_CONTEXT_MCP,
HYDRATION_MODES,
REQUIRED_MCP_TOOL,
Expand Down
180 changes: 180 additions & 0 deletions modules/jarvos-runtime-kit/src/operator-notification.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
'use strict';

const crypto = require('crypto');

// This is intentionally an unstable 0.x contract. Producers must retain their
// detailed diagnostics privately and send this module only reviewed semantics.
const OPERATOR_NOTIFICATION_SCHEMA_VERSION = 'jarvos-operator-notification/v1';
const NO_REPLY = 'NO_REPLY';
const AUDIENCES = new Set(['operator']);
const SEVERITIES = new Set(['info', 'warning', 'error', 'security']);
const AUTOMATION_OUTCOMES = new Set(['none', 'safe-hold', 'repaired', 'resolved', 'failed']);
const FRESHNESS_STATES = new Set(['current', 'stale', 'unknown']);
const ACTIONS = new Set(['none', 'review-release', 'choose-recovery', 'review-safety-hold']);
const NEXT_STATES = new Set(['none', 'continue-monitoring', 'wait-for-fresh-observation', 'resume-after-review']);
const EVENT_CODES = new Set(['release-state', 'safety-hold', 'recovery-failed', 'repair-complete', 'resolution-complete']);
const EVENT_FIELDS = new Set([
'schemaVersion', 'code', 'audience', 'severity', 'automationOutcome',
'actionRequired', 'action', 'nextState', 'eventReference', 'dedupeKey',
'observedAt', 'freshness', 'privateDetailReference', 'release',
]);

const ACTION_TEXT = {
'review-release': 'Review the proposed release before it can publish.',
'choose-recovery': 'Choose how jarvOS should proceed.',
'review-safety-hold': 'Review the held change and choose whether to continue.',
};
const NEXT_TEXT = {
'continue-monitoring': 'jarvOS will continue monitoring safely.',
'wait-for-fresh-observation': 'jarvOS will keep monitoring for a fresh observation.',
'resume-after-review': 'after your review, jarvOS will continue the release process.',
};

function isObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}

function isSemanticVersion(value) {
return typeof value === 'string' && /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
}

function isBoundedIdentifier(value, { min = 1, max = 120 } = {}) {
return typeof value === 'string' && value.length >= min && value.length <= max
&& /^[a-z][a-z0-9-]*$/.test(value);
}

function isOpaqueReference(value) {
// A base64url token of this length has enough entropy to be non-guessable
// when minted by the owner-authorized context. Its contents are never shown
// other than as the correlation reference for an action-required message.
return typeof value === 'string' && /^[A-Za-z0-9_-]{22,128}$/.test(value);
}

function isIsoTime(value) {
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)
&& !Number.isNaN(Date.parse(value));
}

function validateRelease(release, errors) {
if (!isObject(release)) {
errors.push('release must be an object');
return;
}
const allowed = new Set(['publishedVersion', 'approvalReadyVersion', 'futureVersion']);
for (const key of Object.keys(release)) if (!allowed.has(key)) errors.push(`release has unknown field: ${key}`);
for (const field of ['publishedVersion', 'approvalReadyVersion', 'futureVersion']) {
if (!isSemanticVersion(release[field])) errors.push(`release.${field} must be a semantic version`);
}
}

function validateOperatorNotificationEvent(event) {
const errors = [];
if (!isObject(event)) return { ok: false, errors: ['operator notification event must be an object'] };
for (const key of Object.keys(event)) if (!EVENT_FIELDS.has(key)) errors.push(`operator notification event has unknown field: ${key}`);
if (event.schemaVersion !== OPERATOR_NOTIFICATION_SCHEMA_VERSION) errors.push(`event.schemaVersion must be ${OPERATOR_NOTIFICATION_SCHEMA_VERSION}`);
if (!isBoundedIdentifier(event.code)) errors.push('event.code must be a bounded stable identifier');
if (!AUDIENCES.has(event.audience)) errors.push('event.audience must be operator');
if (!SEVERITIES.has(event.severity)) errors.push('event.severity is invalid');
if (!AUTOMATION_OUTCOMES.has(event.automationOutcome)) errors.push('event.automationOutcome is invalid');
if (typeof event.actionRequired !== 'boolean') errors.push('event.actionRequired must be boolean');
if (!ACTIONS.has(event.action)) errors.push('event.action is invalid');
if (!NEXT_STATES.has(event.nextState)) errors.push('event.nextState is invalid');
if (event.actionRequired && event.action === 'none') errors.push('action-required events need a reviewed action');
if (!event.actionRequired && event.action !== 'none') errors.push('non-action events must use action none');
if (!isOpaqueReference(event.eventReference)) errors.push('event.eventReference must be an opaque reference');
if (!isBoundedIdentifier(event.dedupeKey, { max: 180 })) errors.push('event.dedupeKey must be a bounded stable identifier');
if (!isIsoTime(event.observedAt)) errors.push('event.observedAt must be an ISO-8601 UTC timestamp');
if (!FRESHNESS_STATES.has(event.freshness)) errors.push('event.freshness is invalid');
if (event.privateDetailReference !== undefined && !isOpaqueReference(event.privateDetailReference)) errors.push('event.privateDetailReference must be an opaque reference');
if (event.code === 'release-state') validateRelease(event.release, errors);
if (event.code !== 'release-state' && event.release !== undefined) errors.push('release is only valid for release-state events');
if (event.code === 'release-state' && event.freshness !== 'current' && event.actionRequired) errors.push('stale or unknown release evidence cannot request release review');
return { ok: errors.length === 0, errors, value: event };
}

function assertOperatorNotificationEvent(event) {
const validation = validateOperatorNotificationEvent(event);
if (!validation.ok) throw new TypeError(validation.errors.join('; '));
return event;
}

function notificationDedupeIdentity(event) {
assertOperatorNotificationEvent(event);
return crypto.createHash('sha256')
.update(`${event.schemaVersion}\0${event.code}\0${event.dedupeKey}\0${event.actionRequired}\0${event.freshness}`)
.digest('hex');
}

function releaseMessage(event) {
const { publishedVersion, approvalReadyVersion, futureVersion } = event.release;
if (event.freshness === 'current') {
return `jarvOS ${publishedVersion} is currently published. A proposed ${approvalReadyVersion} release has passed checks and is ready for Andrew's review; nothing will publish automatically. The separate ${futureVersion} milestone remains future work.`;
}
const qualifier = event.freshness === 'stale' ? 'stale' : 'unconfirmed';
return `jarvOS last observed ${publishedVersion} as published, but that observation is ${qualifier}. The proposed ${approvalReadyVersion} release needs a fresh check before it can be reviewed. The separate ${futureVersion} milestone remains future work.`;
}

function eventMessage(event) {
if (event.code === 'release-state') return releaseMessage(event);
if (event.code === 'safety-hold') return 'jarvOS paused an unsafe change and left the existing setup unchanged.';
if (event.code === 'recovery-failed') return 'jarvOS could not complete a safe recovery and preserved the existing state.';
if (event.code === 'repair-complete') return 'jarvOS completed a safe repair.';
if (event.code === 'resolution-complete') return 'jarvOS resolved the condition safely.';
return 'jarvOS needs a reviewed operator decision and preserved the existing state.';
}

function renderMessage(event) {
if (!EVENT_CODES.has(event.code)) {
return `jarvOS needs an operator decision and preserved the existing state. Action required: Review the condition before jarvOS continues. Next: jarvOS will wait for your direction. Reference: ${event.eventReference}.`;
}
const parts = [eventMessage(event)];
if (event.actionRequired) {
parts.push(`Action required: ${ACTION_TEXT[event.action]}`);
parts.push(`Next: ${NEXT_TEXT[event.nextState] || 'jarvOS will wait for your direction.'}`);
parts.push(`Reference: ${event.eventReference}.`);
} else {
parts.push('No action is needed from you.');
if (event.nextState !== 'none') parts.push(`Next: ${NEXT_TEXT[event.nextState]}`);
}
return parts.join(' ');
}

function evaluateOperatorNotification(event) {
assertOperatorNotificationEvent(event);
const knownCode = EVENT_CODES.has(event.code);
const direct = !knownCode || event.actionRequired;
const message = renderMessage(event);
if (direct) {
return {
disposition: 'direct-notification',
output: message,
statusMessage: null,
dedupeIdentity: notificationDedupeIdentity(event),
};
}
const durableStatus = event.automationOutcome === 'safe-hold'
|| (event.code === 'release-state' && event.freshness !== 'current');
return {
disposition: durableStatus ? 'durable-status' : 'quiet',
output: NO_REPLY,
statusMessage: durableStatus ? message : null,
dedupeIdentity: notificationDedupeIdentity(event),
};
}

function renderOperatorNotification(event) {
return evaluateOperatorNotification(event).output;
}

module.exports = {
ACTIONS,
AUTOMATION_OUTCOMES,
FRESHNESS_STATES,
NO_REPLY,
OPERATOR_NOTIFICATION_SCHEMA_VERSION,
assertOperatorNotificationEvent,
evaluateOperatorNotification,
notificationDedupeIdentity,
renderOperatorNotification,
validateOperatorNotificationEvent,
};
146 changes: 146 additions & 0 deletions modules/jarvos-runtime-kit/test/operator-notification.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
'use strict';

const assert = require('assert');
const test = require('node:test');
const {
NO_REPLY,
OPERATOR_NOTIFICATION_SCHEMA_VERSION,
evaluateOperatorNotification,
notificationDedupeIdentity,
renderOperatorNotification,
validateOperatorNotificationEvent,
} = require('../src');

const EVENT_REFERENCE = 'uTQf8DG1p9Ck5Lm3Nw2RzAqB';

function event(overrides = {}) {
return {
schemaVersion: OPERATOR_NOTIFICATION_SCHEMA_VERSION,
code: 'recovery-failed',
audience: 'operator',
severity: 'error',
automationOutcome: 'failed',
actionRequired: true,
action: 'choose-recovery',
nextState: 'continue-monitoring',
eventReference: EVENT_REFERENCE,
dedupeKey: 'recovery-window-42',
observedAt: '2026-08-16T12:30:00Z',
freshness: 'current',
privateDetailReference: 'jJ3xbPq7YvmT0n6eC1fKrS9D',
...overrides,
};
}

test('action-required events answer what happened, what jarvOS did, the action, and next step', () => {
const output = renderOperatorNotification(event());
assert.equal(output, "jarvOS could not complete a safe recovery and preserved the existing state. Action required: Choose how jarvOS should proceed. Next: jarvOS will continue monitoring safely. Reference: uTQf8DG1p9Ck5Lm3Nw2RzAqB.");
});

test('safe holds remain durable status with first-seen and occurrence information outside the renderer', () => {
const result = evaluateOperatorNotification(event({
code: 'safety-hold',
severity: 'warning',
automationOutcome: 'safe-hold',
actionRequired: false,
action: 'none',
nextState: 'continue-monitoring',
}));
assert.equal(result.output, NO_REPLY);
assert.equal(result.disposition, 'durable-status');
assert.equal(result.statusMessage, 'jarvOS paused an unsafe change and left the existing setup unchanged. No action is needed from you. Next: jarvOS will continue monitoring safely.');
});

test('routine safe repairs and resolutions stay quiet', () => {
const result = evaluateOperatorNotification(event({
code: 'repair-complete',
severity: 'info',
automationOutcome: 'repaired',
actionRequired: false,
action: 'none',
nextState: 'none',
}));
assert.equal(result.output, NO_REPLY);
assert.equal(result.disposition, 'quiet');
assert.equal(result.statusMessage, null);
});

test('validation rejects free prose and private or raw diagnostic fields before rendering', () => {
for (const unsafe of [
{ diagnostic: 'unsafe_source at /Users/andrew/private' },
{ action: 'call the private skill immediately' },
{ nextState: 'read receipt-123 and retry' },
{ sourceSha: '8cb3909' },
{ stack: 'Error: failed\n at private.js:1:1' },
]) {
const result = validateOperatorNotificationEvent(event(unsafe));
assert.equal(result.ok, false, JSON.stringify(result.errors));
}
});

test('unknown codes render reviewed generic action-required text without the code', () => {
const output = renderOperatorNotification(event({ code: 'new-private-machine-code', actionRequired: false, action: 'none' }));
assert.equal(output, 'jarvOS needs an operator decision and preserved the existing state. Action required: Review the condition before jarvOS continues. Next: jarvOS will wait for your direction. Reference: uTQf8DG1p9Ck5Lm3Nw2RzAqB.');
assert.equal(output.includes('new-private-machine-code'), false);
});

test('evaluation and dedupe identity are deterministic', () => {
const input = event();
assert.deepEqual(evaluateOperatorNotification(input), evaluateOperatorNotification(input));
assert.equal(notificationDedupeIdentity(input), notificationDedupeIdentity(input));
});

test('current release evidence distinguishes published, approval-ready, and future lanes', () => {
const output = renderOperatorNotification(event({
code: 'release-state',
severity: 'info',
automationOutcome: 'none',
actionRequired: true,
action: 'review-release',
nextState: 'resume-after-review',
release: {
publishedVersion: '0.7.0',
approvalReadyVersion: '0.8.0',
futureVersion: 'v1.0.0',
},
}));
assert.equal(output, "jarvOS 0.7.0 is currently published. A proposed 0.8.0 release has passed checks and is ready for Andrew's review; nothing will publish automatically. The separate v1.0.0 milestone remains future work. Action required: Review the proposed release before it can publish. Next: after your review, jarvOS will continue the release process. Reference: uTQf8DG1p9Ck5Lm3Nw2RzAqB.");
assert.equal(output.includes('8cb3909'), false);
});

test('stale or unknown release evidence uses qualified wording and stays quiet when no action is required', () => {
for (const freshness of ['stale', 'unknown']) {
const result = evaluateOperatorNotification(event({
code: 'release-state',
severity: 'warning',
automationOutcome: 'none',
actionRequired: false,
action: 'none',
nextState: 'wait-for-fresh-observation',
freshness,
release: {
publishedVersion: '0.7.0',
approvalReadyVersion: '0.8.0',
futureVersion: 'v1.0.0',
},
}));
assert.equal(result.output, NO_REPLY);
assert.equal(result.disposition, 'durable-status');
assert.match(result.statusMessage, /last observed 0\.7\.0 as published/);
assert.doesNotMatch(result.statusMessage, /currently published|ready for Andrew's review/);
assert.equal(renderOperatorNotification(event({
code: 'release-state', severity: 'warning', automationOutcome: 'safe-hold', actionRequired: false,
action: 'none', nextState: 'wait-for-fresh-observation', freshness,
release: { publishedVersion: '0.7.0', approvalReadyVersion: '0.8.0', futureVersion: 'v1.0.0' },
})), NO_REPLY);
}
});

test('stale release evidence cannot request approval', () => {
const result = validateOperatorNotificationEvent(event({
code: 'release-state', action: 'review-release', freshness: 'stale',
release: { publishedVersion: '0.7.0', approvalReadyVersion: '0.8.0', futureVersion: 'v1.0.0' },
}));
assert.equal(result.ok, false);
assert.match(result.errors.join('\n'), /cannot request release review/);
});
Loading
Loading