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
45 changes: 44 additions & 1 deletion modules/jarvos-agent-context/scripts/jarvos-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,12 @@ const TOOLS = [
required: ['operation'],
additionalProperties: false,
properties: {
operation: { type: 'string', enum: ['status', 'explain', 'inventory', 'plan', 'repair', 'exclude', 'include'] },
operation: { type: 'string', enum: ['status', 'explain', 'inventory', 'plan', 'repair', 'exclude', 'include', 'decisions', 'explain-decision', 'resolve-decision'] },
id: { type: 'string', description: 'Owner-known canonical skill id for explain, exclude, or include.' },
decisionId: { type: 'string', description: 'Owner-visible decision reference for decision operations.' },
decisionReference: { type: 'string', description: 'Opaque transport correlation reference for an owner decision.' },
revision: { type: 'number', description: 'Current decision revision for a resolution.' },
option: { type: 'string', description: 'One option listed by explain-decision.' },
reasonCode: { type: 'string', description: 'Optional exclusion reason code.' },
},
},
Expand Down Expand Up @@ -602,6 +606,45 @@ async function callTool(name, args = {}) {
if (operation === 'include') {
return textResult(JSON.stringify(redactSharedSkillMutation(skills.includeSkillOperator({ configPath, id: args.id }), skills.opaqueSkillId), null, 2));
}
const principal = { kind: 'owner', capabilities: ['skills.decisions.read', 'skills.decisions.resolve'] };
if (operation === 'decisions') {
return textResult(JSON.stringify(skills.decisionsOperator({ configPath, principal }), null, 2));
}
if (operation === 'explain-decision') {
return textResult(JSON.stringify(skills.explainDecisionOperator({ configPath, principal, decisionId: args.decisionId, decisionReference: args.decisionReference }), null, 2));
}
if (operation === 'resolve-decision') {
// The decision store verifies the source digest immediately before this
// callback. Details is intentionally a no-op; exclusion is delegated to
// the established single-skill operator, never a caller-provided path.
const inventory = skills.inventoryAssessOperator({ configPath, persist: false, includeDocument: true });
const decision = skills.explainDecisionOperator({ configPath, principal, decisionId: args.decisionId, decisionReference: args.decisionReference }).decision;
const currentSkill = (inventory.document?.skills || []).find((skill) => skill.logicalId === decision?.skill) || null;
const result = skills.resolveDecisionOperator({
configPath, principal, decisionId: args.decisionId, decisionReference: args.decisionReference, revision: args.revision, option: args.option, currentSkill,
mutate: ({ skill, option }) => {
if (option === 'exclude') skills.excludeSkillOperator({ configPath, id: skill, reasonCode: 'owner_excluded' });
if (option === 'keep-local') skills.excludeSkillOperator({ configPath, id: skill, reasonCode: 'owner_keep_local' });
// share is authorized by the resolved decision. The follow-up repair
// reads that receipt and admits only the same source digest.
},
});
if (result.status !== 'resolved') {
return textResult(JSON.stringify(result, null, 2), result.status === 'invalid_option' || result.status === 'stale');
}
let postResolution = { status: 'pending' };
try {
const followup = skills.autonomousRepairOperator({ configPath });
postResolution = {
status: followup.ok && followup.mutationDenied !== true ? 'completed' : 'pending',
reason: followup.reason || null,
};
} catch {
// The durable resolution receipt is authoritative; a later scheduled
// repair can safely retry if this immediate follow-up is unavailable.
}
return textResult(JSON.stringify({ ...result, postResolution }, null, 2), false);
}
return textResult('unsupported shared-skill operation', true);
}
if (name === 'jarvos_current_work') {
Expand Down
85 changes: 84 additions & 1 deletion modules/jarvos-agent-context/test/agent-context.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ test('MCP tool list includes jarvOS tools', () => {
const shared = TOOLS.find((tool) => tool.name === 'jarvos_shared_skills');
assert.deepEqual(shared.inputSchema.properties.operation.enum, [
'status', 'explain', 'inventory', 'plan', 'repair', 'exclude', 'include',
'decisions', 'explain-decision', 'resolve-decision',
]);
assert.equal('credential' in shared.inputSchema.properties, false);
});
Expand Down Expand Up @@ -656,7 +657,7 @@ test('shared-skill MCP mutation operations fail closed without a host-bound owne
delete process.env.JARVOS_CONTROL_PLANE_CREDENTIAL;
delete process.env.JARVOS_CONTROL_PLANE_CREDENTIAL_FILE;
try {
for (const operation of ['inventory', 'plan', 'repair', 'exclude', 'include']) {
for (const operation of ['inventory', 'plan', 'repair', 'exclude', 'include', 'decisions', 'explain-decision', 'resolve-decision']) {
const result = await callTool('jarvos_shared_skills', { operation, id: 'private-skill' });
assert.equal(result.isError, true);
assert.match(result.content[0].text, /owner session is not configured/i);
Expand Down Expand Up @@ -705,6 +706,88 @@ test('shared-skill MCP status and explain match redacted operator behavior', asy
}
});

test('shared-skill MCP decision operations use opaque references and reject stale resolutions', async () => {
const skills = require('../../jarvos-skills/src');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-mcp-decision-'));
fs.chmodSync(root, 0o700);
const harnessRoot = path.join(root, 'codex-skills');
const bundle = path.join(harnessRoot, 'newsletter-generator');
fs.mkdirSync(bundle, { recursive: true, mode: 0o700 });
fs.writeFileSync(path.join(bundle, 'SKILL.md'), [
'---',
'name: newsletter-generator',
'description: owner decision fixture',
'---',
'',
'Use the approved network endpoint: https://example.test/submit',
'',
].join('\n'), { mode: 0o600 });
const tree = skills.computeBundleTree(bundle, {
allowlist: ['SKILL.md', 'scripts/**', 'assets/**', 'references/**', 'templates/**'],
});
const configPath = path.join(root, 'config.json');
const config = skills.defaultConfig();
config.controlRoot = root;
config.publicCatalogPath = path.join(root, 'public-catalog.json');
config.localOverlayPath = path.join(root, 'local-overlay.json');
config.inventory.enabled = true;
config.inventory.registeredRoots = [{
rootId: 'codex-decision-fixture', harness: 'codex', root: harnessRoot, trustClass: 'markdown-only', lifecycle: 'available',
}];
skills.saveConfig(config, configPath);
const statePath = skills.decisionStatePath({ configPath });
const seeded = skills.reconcileDecisions({
statePath,
skills: [{
logicalId: 'newsletter-generator',
treeDigest: tree.treeDigest,
attention: 'actionable',
disposition: { kind: 'needs_input', reasonCode: 'needs_owner_input' },
}],
}).pending[0];
const previousConfig = process.env.JARVOS_SHARED_SKILLS_CONFIG_PATH;
const previousCredential = process.env.JARVOS_CONTROL_PLANE_CREDENTIAL;
process.env.JARVOS_SHARED_SKILLS_CONFIG_PATH = configPath;
process.env.JARVOS_CONTROL_PLANE_CREDENTIAL = 'test-owner-session';
try {
const listed = await callTool('jarvos_shared_skills', { operation: 'decisions' });
assert.equal(listed.isError, false);
const listedPayload = JSON.parse(listed.content[0].text);
assert.equal(listedPayload.decisions[0].decisionReference, seeded.decisionReference);
assert.doesNotMatch(listed.content[0].text, /absolutePath|SKILL\.md/);

const explained = await callTool('jarvos_shared_skills', {
operation: 'explain-decision', decisionReference: seeded.decisionReference,
});
assert.equal(explained.isError, false);
assert.equal(JSON.parse(explained.content[0].text).found, true);

const stale = await callTool('jarvos_shared_skills', {
operation: 'resolve-decision', decisionReference: seeded.decisionReference,
revision: 99, option: 'share',
});
assert.equal(stale.isError, true);
assert.match(stale.content[0].text, /stale/i);

const valid = await callTool('jarvos_shared_skills', {
operation: 'resolve-decision', decisionReference: seeded.decisionReference,
revision: 1, option: 'keep-local',
});
assert.equal(valid.isError, false);
const validPayload = JSON.parse(valid.content[0].text);
assert.equal(validPayload.status, 'resolved');
assert.equal(validPayload.receipt.option, 'keep-local');
assert.match(valid.content[0].text, /postResolution/);
assert.equal(skills.loadExclusionOverlay(path.join(root, 'inventory', 'exclusions.json')).status, 'valid');
} finally {
if (previousConfig === undefined) delete process.env.JARVOS_SHARED_SKILLS_CONFIG_PATH;
else process.env.JARVOS_SHARED_SKILLS_CONFIG_PATH = previousConfig;
if (previousCredential === undefined) delete process.env.JARVOS_CONTROL_PLANE_CREDENTIAL;
else process.env.JARVOS_CONTROL_PLANE_CREDENTIAL = previousCredential;
fs.rmSync(root, { recursive: true, force: true });
}
});

test('MCP journal actions expose closed empty-object schemas and safe lifecycle results', () => {
withTempVault(({ journal }) => {
const indexPath = path.join(journal, 'Journaling.md');
Expand Down
107 changes: 104 additions & 3 deletions modules/jarvos-runtime-kit/src/operator-notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,55 @@ 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 ACTIONS = new Set(['none', 'review-release', 'choose-recovery', 'review-safety-hold', 'choose-skill-option', 'review-decisions']);
const NEXT_STATES = new Set(['none', 'continue-monitoring', 'wait-for-fresh-observation', 'resume-after-review', 'await-owner-decision']);
const EVENT_CODES = new Set([
'release-state', 'safety-hold', 'recovery-failed', 'repair-complete', 'resolution-complete',
'skill-owner-decision', 'skill-decision-summary',
]);
const EVENT_FIELDS = new Set([
'schemaVersion', 'code', 'audience', 'severity', 'automationOutcome',
'actionRequired', 'action', 'nextState', 'eventReference', 'dedupeKey',
'observedAt', 'freshness', 'privateDetailReference', 'release',
'skillName', 'reasonCode', 'options', 'decisionReference', 'revision',
'optionSetVersion', 'deliveryAttemptId', 'deliveryAttemptKind', 'itemCount', 'resolvedCount',
]);

const SKILL_REASON_CODES = new Set([
'needs_owner_input', 'semantic_collision', 'ambiguous_identity',
'capability_unsupported', 'source_absent',
]);
const SKILL_OPTIONS = new Set(['share', 'keep-local', 'exclude', 'details']);
const SKILL_DECISION_FIELDS = ['skillName', 'reasonCode', 'options', 'decisionReference', 'revision', 'optionSetVersion', 'deliveryAttemptId', 'deliveryAttemptKind'];
const SKILL_SUMMARY_FIELDS = ['itemCount', 'resolvedCount'];

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.',
'choose-skill-option': 'Choose one of the listed options for this skill.',
'review-decisions': 'Review the pending skill decisions in jarvOS shared skills.',
};
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.',
'await-owner-decision': 'jarvOS will leave the skill unchanged until you choose an option.',
};

const SKILL_REASON_TEXT = {
needs_owner_input: 'it needs your approval before jarvOS can share it',
semantic_collision: 'its name conflicts with another skill',
ambiguous_identity: 'jarvOS found more than one possible source for it',
capability_unsupported: 'its capabilities do not match every target harness',
source_absent: 'its source is no longer available',
};

const SKILL_OPTION_TEXT = {
share: 'Reply “share” to copy it to compatible AI tools',
'keep-local': 'reply “keep local” to leave it where it is',
exclude: 'reply “exclude” to stop offering it',
details: 'reply “details” to review more information without changing anything',
};

function isObject(value) {
Expand Down Expand Up @@ -67,6 +98,41 @@ function validateRelease(release, errors) {
}
}

function validateSkillDecision(event, errors) {
if (!isBoundedIdentifier(event.skillName, { max: 80 })) errors.push('skill-owner-decision.skillName is invalid');
if (!SKILL_REASON_CODES.has(event.reasonCode)) errors.push('skill-owner-decision.reasonCode is invalid');
if (!isOpaqueReference(event.decisionReference)) errors.push('skill-owner-decision.decisionReference is invalid');
if (event.eventReference !== event.decisionReference) errors.push('skill-owner-decision.eventReference must match decisionReference');
if (!Number.isInteger(event.revision) || event.revision < 1 || event.revision > 1000000) errors.push('skill-owner-decision.revision is invalid');
if (typeof event.optionSetVersion !== 'string' || !/^v[0-9]+$/.test(event.optionSetVersion)) errors.push('skill-owner-decision.optionSetVersion is invalid');
if (event.deliveryAttemptId !== undefined && !isOpaqueReference(event.deliveryAttemptId)) errors.push('skill-owner-decision.deliveryAttemptId is invalid');
if (event.deliveryAttemptKind !== undefined && !['initial', 'fallback'].includes(event.deliveryAttemptKind)) errors.push('skill-owner-decision.deliveryAttemptKind is invalid');
if ((event.deliveryAttemptId === undefined) !== (event.deliveryAttemptKind === undefined)) errors.push('skill-owner-decision delivery attempt fields must be provided together');
if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 4) {
errors.push('skill-owner-decision.options must contain one to four choices');
} else {
const choices = event.options;
if (choices.some((option) => typeof option !== 'string')
|| new Set(choices).size !== choices.length
|| choices.some((option) => !SKILL_OPTIONS.has(option))) {
errors.push('skill-owner-decision.options contains an unsupported or duplicate choice');
}
}
if (event.action !== 'choose-skill-option' || event.nextState !== 'await-owner-decision' || event.actionRequired !== true) {
errors.push('skill-owner-decision must require a skill option and await the owner decision');
}
}

function validateSkillDecisionSummary(event, errors) {
if (!Number.isInteger(event.itemCount) || event.itemCount < 1 || event.itemCount > 10000) errors.push('skill-decision-summary.itemCount is invalid');
if (event.resolvedCount !== undefined && (!Number.isInteger(event.resolvedCount) || event.resolvedCount < 0 || event.resolvedCount > 10000)) {
errors.push('skill-decision-summary.resolvedCount is invalid');
}
if (event.action !== 'review-decisions' || event.nextState !== 'await-owner-decision' || event.actionRequired !== true) {
errors.push('skill-decision-summary must require decision review and await the owner');
}
}

function validateOperatorNotificationEvent(event) {
const errors = [];
if (!isObject(event)) return { ok: false, errors: ['operator notification event must be an object'] };
Expand All @@ -90,6 +156,23 @@ function validateOperatorNotificationEvent(event) {
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');
if (event.code === 'skill-owner-decision') validateSkillDecision(event, errors);
if (event.code === 'skill-decision-summary') validateSkillDecisionSummary(event, errors);
if (event.code === 'skill-owner-decision') {
for (const field of SKILL_SUMMARY_FIELDS) {
if (event[field] !== undefined) errors.push(`${field} is only valid for skill decision summaries`);
}
}
if (event.code === 'skill-decision-summary') {
for (const field of SKILL_DECISION_FIELDS) {
if (event[field] !== undefined) errors.push(`${field} is only valid for individual skill decisions`);
}
}
if (!['skill-owner-decision', 'skill-decision-summary'].includes(event.code)) {
for (const field of [...SKILL_DECISION_FIELDS, ...SKILL_SUMMARY_FIELDS]) {
if (event[field] !== undefined) errors.push(`${field} is only valid for skill decision events`);
}
}
return { ok: errors.length === 0, errors, value: event };
}

Expand All @@ -115,8 +198,23 @@ function releaseMessage(event) {
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 skillDecisionMessage(event) {
const reason = SKILL_REASON_TEXT[event.reasonCode];
return `jarvOS found the ${event.skillName} skill but did not share it because ${reason}. Nothing changed.`;
}

function skillDecisionSummaryMessage(event) {
const count = `${event.itemCount} skill${event.itemCount === 1 ? '' : 's'}`;
const resolved = event.resolvedCount > 0
? ` It also confirmed that ${event.resolvedCount} earlier item${event.resolvedCount === 1 ? '' : 's'} ${event.resolvedCount === 1 ? 'is' : 'are'} resolved.`
: '';
return `jarvOS found ${count} that still need your decision. It left them unchanged.${resolved} Review the pending decisions in jarvOS shared skills; nothing will be shared automatically until you choose.`;
}

function eventMessage(event) {
if (event.code === 'release-state') return releaseMessage(event);
if (event.code === 'skill-owner-decision') return skillDecisionMessage(event);
if (event.code === 'skill-decision-summary') return skillDecisionSummaryMessage(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.';
Expand All @@ -126,6 +224,9 @@ function eventMessage(event) {

function renderMessage(event) {
const parts = [eventMessage(event)];
if (event.code === 'skill-owner-decision') {
parts.push(`Choices: ${event.options.map((option) => SKILL_OPTION_TEXT[option]).join('; ')}.`);
}
if (event.actionRequired) {
parts.push(`Action required: ${ACTION_TEXT[event.action]}`);
parts.push(`Next: ${NEXT_TEXT[event.nextState] || 'jarvOS will wait for your direction.'}`);
Expand Down
Loading
Loading