diff --git a/modules/jarvos-agent-context/scripts/jarvos-mcp.js b/modules/jarvos-agent-context/scripts/jarvos-mcp.js index e7c057b7..468dcc72 100755 --- a/modules/jarvos-agent-context/scripts/jarvos-mcp.js +++ b/modules/jarvos-agent-context/scripts/jarvos-mcp.js @@ -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.' }, }, }, @@ -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') { diff --git a/modules/jarvos-agent-context/test/agent-context.test.js b/modules/jarvos-agent-context/test/agent-context.test.js index df813778..4e629991 100644 --- a/modules/jarvos-agent-context/test/agent-context.test.js +++ b/modules/jarvos-agent-context/test/agent-context.test.js @@ -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); }); @@ -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); @@ -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'); diff --git a/modules/jarvos-runtime-kit/src/operator-notification.js b/modules/jarvos-runtime-kit/src/operator-notification.js index a989e88c..074bd752 100644 --- a/modules/jarvos-runtime-kit/src/operator-notification.js +++ b/modules/jarvos-runtime-kit/src/operator-notification.js @@ -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) { @@ -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'] }; @@ -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 }; } @@ -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.'; @@ -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.'}`); diff --git a/modules/jarvos-runtime-kit/test/operator-notification.test.js b/modules/jarvos-runtime-kit/test/operator-notification.test.js index c7d63406..3e82971e 100644 --- a/modules/jarvos-runtime-kit/test/operator-notification.test.js +++ b/modules/jarvos-runtime-kit/test/operator-notification.test.js @@ -144,3 +144,115 @@ test('stale release evidence cannot request approval', () => { assert.equal(result.ok, false); assert.match(result.errors.join('\n'), /cannot request release review/); }); + +test('skill owner decision names the held skill and gives exact plain-English choices', () => { + const output = renderOperatorNotification({ + schemaVersion: OPERATOR_NOTIFICATION_SCHEMA_VERSION, + code: 'skill-owner-decision', + audience: 'operator', + severity: 'warning', + automationOutcome: 'failed', + actionRequired: true, + action: 'choose-skill-option', + nextState: 'await-owner-decision', + eventReference: EVENT_REFERENCE, + decisionReference: EVENT_REFERENCE, + revision: 1, + optionSetVersion: 'v1', + skillName: 'newsletter-generator', + reasonCode: 'needs_owner_input', + options: ['share', 'keep-local', 'exclude', 'details'], + dedupeKey: 'skill-owner-decision-newsletter-generator', + observedAt: '2026-08-16T12:30:00Z', + freshness: 'current', + privateDetailReference: 'jJ3xbPq7YvmT0n6eC1fKrS9D', + }); + assert.match(output, /found the newsletter-generator skill/); + assert.match(output, /did not share it because it needs your approval/); + assert.match(output, /Nothing changed/); + assert.match(output, /Reply “share”/); + assert.match(output, /reply “keep local”/); + assert.match(output, /reply “exclude”/); + assert.match(output, /reply “details”/); + assert.match(output, /leave the skill unchanged until you choose an option/); + assert.doesNotMatch(output, /needs_owner_input|SKILL\.md|\//); +}); + +test('skill decision summaries are actionable without exposing internal identifiers', () => { + const event = { + schemaVersion: OPERATOR_NOTIFICATION_SCHEMA_VERSION, + code: 'skill-decision-summary', + audience: 'operator', + severity: 'warning', + automationOutcome: 'failed', + actionRequired: true, + action: 'review-decisions', + nextState: 'await-owner-decision', + eventReference: EVENT_REFERENCE, + dedupeKey: 'skill-decision-migration-abc123', + observedAt: '2026-08-16T12:30:00Z', + freshness: 'current', + itemCount: 28, + resolvedCount: 1, + }; + const output = renderOperatorNotification(event); + assert.match(output, /28 skills that still need your decision/); + assert.match(output, /left them unchanged/); + assert.match(output, /confirmed that 1 earlier item is resolved/); + assert.match(output, /Review the pending decisions/); + assert.doesNotMatch(output, /resolvedCount|needs_owner_input|decision-/); +}); + +test('skill notification fields stay scoped to their event kind and options remain strings', () => { + const summaryWithSkillFields = validateOperatorNotificationEvent({ + schemaVersion: OPERATOR_NOTIFICATION_SCHEMA_VERSION, + code: 'skill-decision-summary', + audience: 'operator', + severity: 'warning', + automationOutcome: 'failed', + actionRequired: true, + action: 'review-decisions', + nextState: 'await-owner-decision', + eventReference: EVENT_REFERENCE, + dedupeKey: 'skill-decision-summary-1', + observedAt: '2026-08-16T12:30:00Z', + freshness: 'current', + itemCount: 1, + skillName: '/Users/andrew/private/skill', + }); + assert.equal(summaryWithSkillFields.ok, false); + assert.match(summaryWithSkillFields.errors.join('\n'), /skillName is only valid for individual skill decisions/); + + const individualDecision = { + schemaVersion: OPERATOR_NOTIFICATION_SCHEMA_VERSION, + code: 'skill-owner-decision', + audience: 'operator', + severity: 'warning', + automationOutcome: 'failed', + actionRequired: true, + action: 'choose-skill-option', + nextState: 'await-owner-decision', + eventReference: EVENT_REFERENCE, + decisionReference: EVENT_REFERENCE, + revision: 1, + optionSetVersion: 'v1', + skillName: 'newsletter-generator', + reasonCode: 'needs_owner_input', + options: ['share'], + dedupeKey: 'skill-owner-decision-1', + observedAt: '2026-08-16T12:30:00Z', + freshness: 'current', + itemCount: 1, + }; + const decisionWithSummaryFields = validateOperatorNotificationEvent(individualDecision); + assert.equal(decisionWithSummaryFields.ok, false); + assert.match(decisionWithSummaryFields.errors.join('\n'), /itemCount is only valid for skill decision summaries/); + + const objectOption = validateOperatorNotificationEvent({ + ...individualDecision, + itemCount: undefined, + options: [{ toString: () => 'share' }], + }); + assert.equal(objectOption.ok, false); + assert.match(objectOption.errors.join('\n'), /unsupported or duplicate choice/); +}); diff --git a/modules/jarvos-skills/scripts/dogfood-skills.js b/modules/jarvos-skills/scripts/dogfood-skills.js index 358289dc..65b8a65a 100755 --- a/modules/jarvos-skills/scripts/dogfood-skills.js +++ b/modules/jarvos-skills/scripts/dogfood-skills.js @@ -83,7 +83,16 @@ if (args.has('--matrix') && args.has('--live')) { controlRoot: path.join(temp, 'control'), harnesses, }); - const applied = skills.applyCatalogReconciliation(plan); + const freshDiscovery = ({ harness }) => ({ + fresh: true, + source: 'isolated-native-discovery-fixture', + tuples: plan.pairs.filter((pair) => pair.harness === harness.id).map((pair) => ({ + id: pair.id, + catalogRelease: pair.catalogRelease, + treeDigest: pair.treeDigest, + })), + }); + const applied = skills.applyCatalogReconciliation(plan, { freshDiscovery }); const shadowChecks = harnesses.filter((harness) => harness.adapter.skillProjection.verificationTier === 'exact-path').map((harness) => { const shadowPaths = skills.resolveShadowPaths({ harness, adapter: harness.adapter, effectiveName: 'public-fixture' }); const shadowRoot = shadowPaths.paths[0]; @@ -106,7 +115,10 @@ if (args.has('--matrix') && args.has('--live')) { // Claude's declared interactive proof cannot be fabricated in CI. Its // receipt-owned containment is the strongest truthful isolated result. const satisfied = proof.status === 'model_visible' || (harness.id === 'claude' && proof.status === 'verification_pending'); - return { harness: harness.id, installed: fs.existsSync(path.join(targetPath, 'SKILL.md')), verification: proof.status, satisfied }; + const receipts = plan.pairs.filter((pair) => pair.harness === harness.id).map((pair) => skills.readReceipt(harness.root, pair.effectiveName)); + const observedEqualDesired = receipts.every((receipt) => receipt?.status !== 'model_visible' + || receipt.desiredSetDigest === receipt.observedSetDigest); + return { harness: harness.id, installed: fs.existsSync(path.join(targetPath, 'SKILL.md')), verification: proof.status, observedEqualDesired, satisfied: satisfied && observedEqualDesired }; }); const second = skills.planCatalogReconciliation({ catalog: effective.catalog, publicSourceRoot: fixtureRoot, controlRoot: path.join(temp, 'control'), harnesses }); const result = { mode: 'isolated', catalogDigest: effective.digest, applied: applied.applied.filter((item) => item.applied).length, pairs, shadowChecks, secondRunNoop: second.pairs.every((pair) => pair.status === 'clean') }; diff --git a/modules/jarvos-skills/src/catalog.js b/modules/jarvos-skills/src/catalog.js index 24ac4c48..cb166b93 100644 --- a/modules/jarvos-skills/src/catalog.js +++ b/modules/jarvos-skills/src/catalog.js @@ -272,6 +272,37 @@ function normalizeVerificationPolicy(value, field, allowedHarnesses) { return normalized; } +// These are jarvOS distribution facts. In particular, do not infer either +// from a skill's prose or from the older `requires` field: that field belongs +// to the skill itself and may describe something quite different. +function normalizeSkillDependencies(value, field) { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${field} must be an array`); + const seen = new Set(); + return value.map((dependency, index) => { + const id = assertSkillId(dependency, `${field}[${index}]`); + if (seen.has(id)) throw new Error(`${field} duplicates ${id}`); + seen.add(id); + return id; + }).sort(); +} + +function normalizeRuntimePrerequisites(value, field) { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${field} must be an array`); + const seen = new Set(); + return value.map((prerequisite, index) => { + // Keep the portable contract deliberately small: a prerequisite is an + // opaque, stable identifier. Harness adapters provide the evidence. + if (typeof prerequisite !== 'string' || !/^[a-z][a-z0-9._:-]{0,127}$/i.test(prerequisite)) { + throw new Error(`${field}[${index}] must be a stable prerequisite id`); + } + if (seen.has(prerequisite)) throw new Error(`${field} duplicates ${prerequisite}`); + seen.add(prerequisite); + return prerequisite; + }).sort(); +} + function normalizePublicEntry(entry) { const source = nonEmptyObject(entry, 'public catalog entry'); const id = assertSkillId(source.id || source.name, 'public catalog entry id'); @@ -299,6 +330,8 @@ function normalizePublicEntry(entry) { id, sourceKind: PUBLIC_SOURCE_KIND, allowedHarnesses, + skillDependencies: normalizeSkillDependencies(source.skillDependencies, `skillDependencies for ${id}`), + runtimePrerequisites: normalizeRuntimePrerequisites(source.runtimePrerequisites, `runtimePrerequisites for ${id}`), requiredTools, renderer, verification: normalizeVerificationPolicy(source.verification, `verification for ${id}`, allowedHarnesses), @@ -331,6 +364,8 @@ function normalizeOverlayEntry(entry) { id, sourceKind: LOCAL_OVERLAY_SOURCE_KIND, allowedHarnesses, + skillDependencies: normalizeSkillDependencies(source.skillDependencies, `skillDependencies for ${id}`), + runtimePrerequisites: normalizeRuntimePrerequisites(source.runtimePrerequisites, `runtimePrerequisites for ${id}`), requiredTools: Array.isArray(source.requiredTools) ? source.requiredTools.map((tool, index) => { if (typeof tool !== 'string' || !tool.trim()) throw new Error(`requiredTools[${index}] for ${id} is invalid`); @@ -505,6 +540,8 @@ function redactEffectiveCatalog(result) { id: entry.id, sourceKind: entry.sourceKind, allowedHarnesses: entry.allowedHarnesses, + skillDependencies: entry.skillDependencies || [], + runtimePrerequisites: entry.runtimePrerequisites || [], requiredTools: entry.requiredTools || [], renderer: entry.renderer, verification: entry.verification, diff --git a/modules/jarvos-skills/src/decision-store.js b/modules/jarvos-skills/src/decision-store.js new file mode 100644 index 00000000..b096bc83 --- /dev/null +++ b/modules/jarvos-skills/src/decision-store.js @@ -0,0 +1,277 @@ +'use strict'; + +// Owner-only, local decision ledger. This deliberately contains no transport +// details: a selected runtime may claim an outbox item and acknowledge it, but +// agents and transports cannot manufacture a decision or a resolution. +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { atomicWriteJson, ensureDir } = require('./config'); + +const SCHEMA_VERSION = 'jarvos.skill-owner-decisions/v2'; +const FALLBACK_MS = 24 * 60 * 60 * 1000; +const OPTION_SETS = Object.freeze({ + needs_owner_input: ['share', 'keep-local', 'exclude', 'details'], + semantic_collision: ['keep-local', 'exclude', 'details'], + ambiguous_identity: ['keep-local', 'exclude', 'details'], + capability_unsupported: ['keep-local', 'exclude', 'details'], + source_absent: ['keep-local', 'exclude', 'details'], +}); + +function stable(value) { return JSON.stringify(value); } +function digest(value) { return crypto.createHash('sha256').update(stable(value)).digest('hex'); } +function nowIso(now) { return now || new Date().toISOString(); } +function requireOwner(principal, capability) { + if (principal?.kind !== 'owner' || !principal.capabilities?.includes(capability)) throw new Error('owner authorization is required'); +} +function requireDeliveryPrincipal(principal) { + if (principal?.kind !== 'selected-runtime' || !principal.capabilities?.includes('skills.delivery.ack')) throw new Error('delivery authorization is required'); +} +function optionsFor(skill) { + const options = OPTION_SETS[skill?.disposition?.reasonCode] || OPTION_SETS.needs_owner_input; + return [...options]; +} +function reasonFor(skill) { + const reason = skill?.disposition?.reasonCode; + return Object.prototype.hasOwnProperty.call(OPTION_SETS, reason) ? reason : 'needs_owner_input'; +} +function semanticKey(skill, options) { + return digest({ + skill: skill.logicalId, + treeDigest: skill.treeDigest, + reason: reasonFor(skill), + policyVersion: 1, + options, + }); +} +function validSkill(skill) { + return skill && typeof skill.logicalId === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(skill.logicalId) + && typeof skill.treeDigest === 'string' && /^[a-f0-9]{64}$/i.test(skill.treeDigest) + && skill.attention === 'actionable' && skill.disposition?.kind === 'needs_input'; +} +function safeStatePath(statePath) { + if (typeof statePath !== 'string' || !path.isAbsolute(statePath)) throw new Error('decision state path is required'); + ensureDir(path.dirname(statePath), 'decision state parent'); + return statePath; +} +function load(statePath) { + safeStatePath(statePath); + if (!fs.existsSync(statePath)) return { schemaVersion: SCHEMA_VERSION, decisions: [], migrations: {} }; + const stat = fs.lstatSync(statePath); + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) throw new Error('decision state is unsafe'); + try { + const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')); + if (parsed?.schemaVersion === SCHEMA_VERSION && Array.isArray(parsed.decisions)) { + return { ...parsed, migrations: parsed.migrations && typeof parsed.migrations === 'object' ? parsed.migrations : {} }; + } + } catch { /* fail closed below */ } + throw new Error('decision state is unsupported'); +} +function save(statePath, state) { atomicWriteJson(statePath, { ...state, schemaVersion: SCHEMA_VERSION }); } +function publicDecision(decision) { + return { + id: decision.id, decisionReference: decision.decisionReference, skill: decision.skill, revision: decision.revision, reason: decision.reason, + options: [...decision.options], status: decision.status, deliveryStatus: decision.deliveryStatus, + createdAt: decision.createdAt, updatedAt: decision.updatedAt, + }; +} +function findDecision(state, { decisionId, decisionReference } = {}) { + if (typeof decisionReference === 'string' && decisionReference) { + const byReference = state.decisions.find((item) => item.decisionReference === decisionReference); + if (!byReference || (decisionId && byReference.id !== decisionId)) return null; + return byReference; + } + return typeof decisionId === 'string' && decisionId ? state.decisions.find((item) => item.id === decisionId) : null; +} + +function reconcileLoadedState(state, { skills = [], observedAt, generationId } = {}) { + const at = nowIso(observedAt); const created = []; + const current = new Map(skills.filter(validSkill).map((skill) => [skill.logicalId, skill])); + for (const decision of state.decisions) { + if (decision.status !== 'pending') continue; + const skill = current.get(decision.skill); + if (!skill) { decision.status = 'disappeared'; decision.updatedAt = at; continue; } + const identity = semanticKey(skill, optionsFor(skill)); + if (identity !== decision.semanticKey) { decision.status = 'superseded'; decision.updatedAt = at; } + } + for (const skill of current.values()) { + const options = optionsFor(skill); const key = semanticKey(skill, options); + // A resolution is durable policy for this exact source/policy identity. + // Do not turn a healthy replay into a new owner interruption; a changed + // digest gets a distinct semantic key and therefore a fresh assessment. + if (state.decisions.some((decision) => decision.semanticKey === key + && ['pending', 'resolved'].includes(decision.status))) continue; + const decision = { + id: `decision-${key.slice(0, 24)}`, semanticKey: key, skill: skill.logicalId, treeDigest: skill.treeDigest, + // Transport-safe correlation is intentionally distinct from the stable + // semantic id. It is random, base64url, and survives every retry for + // this decision revision without disclosing the skill or source. + decisionReference: crypto.randomBytes(18).toString('base64url'), + reason: reasonFor(skill), options, revision: 1, status: 'pending', + deliveryStatus: 'pending', attempts: [], generationId: generationId || null, createdAt: at, updatedAt: at, + }; + state.decisions.push(decision); created.push(publicDecision(decision)); + } + return { + created, + pending: state.decisions.filter((d) => d.status === 'pending').map(publicDecision), + changed: created.length > 0 || state.decisions.some((d) => d.updatedAt === at), + }; +} + +function reconcileDecisions({ statePath, skills = [], observedAt, generationId } = {}) { + const state = load(statePath); + const result = reconcileLoadedState(state, { skills, observedAt, generationId }); + if (result.changed) save(statePath, state); + return { created: result.created, pending: result.pending }; +} + +function legacyAttention(attentionPath) { + if (typeof attentionPath !== 'string' || !path.isAbsolute(attentionPath) || !fs.existsSync(attentionPath)) return null; + try { + const stat = fs.lstatSync(attentionPath); + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) return null; + const parsed = JSON.parse(fs.readFileSync(attentionPath, 'utf8')); + if (parsed?.schemaVersion !== 'jarvos.skill-attention/v1' || !Array.isArray(parsed.active)) return null; + return parsed.active.filter((item) => typeof item?.logicalId === 'string' && typeof item?.reasonCode === 'string'); + } catch { return null; } +} + +// The v1 attention file was notification state, not an approval ledger. On +// upgrade we retain only entries that a fresh assessment still calls +// actionable; resolving/absent holds disappear quietly. The return value is +// safe for a batch event: it exposes a random-looking reference and counts, +// never an id, reason, path, or source data. +function migrateV1Attention({ statePath, attentionPath, skills = [], observedAt, generationId } = {}) { + const state = load(statePath); + if (state.migrations?.attentionV1) return { migrated: false, replay: true, summary: state.migrations.attentionV1.summary }; + const legacy = legacyAttention(attentionPath); + if (!legacy) return { migrated: false, replay: false, summary: null }; + const eligible = new Map(skills.filter(validSkill).map((skill) => [`${skill.logicalId}:${skill.disposition.reasonCode}`, skill])); + const current = legacy.map((item) => eligible.get(`${item.logicalId}:${item.reasonCode}`)).filter(Boolean); + const result = reconcileLoadedState(state, { skills: current, observedAt, generationId }); + const summary = { + reference: `batch-${digest({ legacy: legacy.map((item) => item.fingerprint || digest({ id: item.logicalId, reason: item.reasonCode })).sort(), generationId: generationId || null }).slice(0, 24)}`, + pendingCount: result.pending.length, + migratedCount: result.created.length, + }; + state.migrations = { ...state.migrations, attentionV1: { summary, migratedAt: nowIso(observedAt) } }; + save(statePath, state); + return { migrated: true, replay: false, summary }; +} + +function reconcileDecisionsWithMigration({ statePath, attentionPath, skills = [], observedAt, generationId } = {}) { + const state = load(statePath); + let migration = { migrated: false, replay: false, summary: null }; + let changed = false; + + if (state.migrations?.attentionV1) { + migration = { migrated: false, replay: true, summary: state.migrations.attentionV1.summary }; + } else { + const legacy = legacyAttention(attentionPath); + if (legacy) { + const eligible = new Map(skills.filter(validSkill).map((skill) => [`${skill.logicalId}:${skill.disposition.reasonCode}`, skill])); + const current = legacy.map((item) => eligible.get(`${item.logicalId}:${item.reasonCode}`)).filter(Boolean); + const migrated = reconcileLoadedState(state, { skills: current, observedAt, generationId }); + const summary = { + reference: `batch-${digest({ legacy: legacy.map((item) => item.fingerprint || digest({ id: item.logicalId, reason: item.reasonCode })).sort(), generationId: generationId || null }).slice(0, 24)}`, + pendingCount: migrated.pending.length, + migratedCount: migrated.created.length, + }; + state.migrations = { ...state.migrations, attentionV1: { summary, migratedAt: nowIso(observedAt) } }; + migration = { migrated: true, replay: false, summary }; + changed = true; + } + } + + const decisions = reconcileLoadedState(state, { skills, observedAt, generationId }); + if (decisions.changed) changed = true; + if (changed) save(statePath, state); + return { migration, created: decisions.created, pending: decisions.pending }; +} +function listDecisions({ statePath, principal } = {}) { + requireOwner(principal, 'skills.decisions.read'); + return { decisions: load(statePath).decisions.filter((d) => d.status === 'pending').map(publicDecision) }; +} +function explainDecision({ statePath, principal, decisionId, decisionReference } = {}) { + requireOwner(principal, 'skills.decisions.read'); + const decision = findDecision(load(statePath), { decisionId, decisionReference }); + return decision ? { found: true, decision: publicDecision(decision) } : { found: false }; +} +function resolveDecision({ statePath, principal, decisionId, decisionReference, revision, option, currentSkill, mutate } = {}) { + requireOwner(principal, 'skills.decisions.resolve'); + const state = load(statePath); const decision = findDecision(state, { decisionId, decisionReference }); + if (!decision) return { status: 'not_found' }; + if (decision.status === 'resolved') return { status: 'already_resolved', receipt: decision.receipt }; + if (decision.status !== 'pending' || decision.revision !== revision) return { status: 'stale' }; + if (!decision.options.includes(option)) return { status: 'invalid_option' }; + if (!validSkill(currentSkill) || currentSkill.logicalId !== decision.skill || currentSkill.treeDigest !== decision.treeDigest) return { status: 'stale' }; + if (option === 'details') return { status: 'pending', decision: publicDecision(decision) }; + if (typeof mutate !== 'function') throw new Error('resolution mutation is required'); + mutate({ skill: decision.skill, option, decisionId: decision.id, revision: decision.revision }); + const at = new Date().toISOString(); + decision.status = 'resolved'; decision.updatedAt = at; + decision.receipt = Object.freeze({ id: `receipt-${crypto.randomUUID()}`, decisionId: decision.id, decisionReference: decision.decisionReference, revision: decision.revision, option, resolvedAt: at, treeDigest: decision.treeDigest }); + save(statePath, state); + return { status: 'resolved', receipt: decision.receipt }; +} +function claimDelivery({ statePath, decisionId, now } = {}) { + const state = load(statePath); const decision = state.decisions.find((item) => item.id === decisionId); + if (!decision || decision.status !== 'pending' || decision.deliveryStatus === 'delivered' || decision.deliveryStatus === 'delivery_stalled') return null; + const at = new Date(nowIso(now)); const previous = decision.attempts.at(-1); + const active = decision.attempts.find((attempt) => attempt.outcome === 'claimed'); + if (active) { + if (at.getTime() - new Date(active.claimedAt).getTime() < FALLBACK_MS) return null; + // A sender that disappeared after claiming an attempt must not strand the + // decision forever. Treat the abandoned claim as an ambiguous delivery; + // the first abandoned attempt may move to the one bounded fallback, while + // an abandoned fallback becomes stalled and waits for owner attention. + active.outcome = 'ambiguous'; + active.outcomeAt = at.toISOString(); + decision.deliveryStatus = decision.attempts.filter((item) => item.outcome !== 'claimed').length >= 2 + ? 'delivery_stalled' : 'delivery_unknown'; + decision.updatedAt = at.toISOString(); + if (decision.deliveryStatus === 'delivery_stalled') { + save(statePath, state); + return null; + } + } + if (previous && previous.outcome !== 'claimed' && at.getTime() - new Date(previous.claimedAt).getTime() < FALLBACK_MS) return null; + const kind = decision.attempts.length === 0 ? 'initial' : 'fallback'; + const attempt = { id: `attempt-${crypto.randomUUID()}`, kind, claimedAt: at.toISOString(), outcome: 'claimed' }; + decision.attempts.push(attempt); decision.deliveryStatus = 'claimed'; decision.updatedAt = at.toISOString(); save(statePath, state); + return { decisionId: decision.id, decisionReference: decision.decisionReference, revision: decision.revision, attemptId: attempt.id, kind }; +} +function acknowledgeDelivery({ statePath, principal, decisionId, revision, attemptId, outcome, providerMessageId } = {}) { + requireDeliveryPrincipal(principal); + if (!['accepted', 'rejected', 'ambiguous'].includes(outcome)) throw new Error('delivery outcome is invalid'); + const state = load(statePath); const decision = state.decisions.find((item) => item.id === decisionId); + const attempt = decision?.attempts.find((item) => item.id === attemptId); + if (!decision || decision.status !== 'pending' || decision.revision !== revision || !attempt || attempt.outcome !== 'claimed') throw new Error('delivery acknowledgement is stale'); + attempt.outcome = outcome; attempt.outcomeAt = new Date().toISOString(); if (typeof providerMessageId === 'string' && providerMessageId) attempt.providerMessageId = providerMessageId; + decision.updatedAt = new Date().toISOString(); + if (outcome === 'accepted') decision.deliveryStatus = 'delivered'; + else if (decision.attempts.filter((item) => item.outcome !== 'claimed').length >= 2) decision.deliveryStatus = 'delivery_stalled'; + else decision.deliveryStatus = outcome === 'ambiguous' ? 'delivery_unknown' : 'pending'; + save(statePath, state); return publicDecision(decision); +} + +function approvedShareMap({ statePath } = {}) { + return new Map(load(statePath).decisions + .filter((decision) => decision.status === 'resolved' && decision.receipt?.option === 'share') + .map((decision) => [decision.skill, { treeDigest: decision.treeDigest, decisionReference: decision.decisionReference }])); +} + +module.exports = { + SCHEMA_VERSION, + reconcileDecisions, + migrateV1Attention, + reconcileDecisionsWithMigration, + listDecisions, + explainDecision, + resolveDecision, + claimDelivery, + acknowledgeDelivery, + approvedShareMap, + semanticKey, +}; diff --git a/modules/jarvos-skills/src/index.js b/modules/jarvos-skills/src/index.js index 0591c6c7..2747c407 100644 --- a/modules/jarvos-skills/src/index.js +++ b/modules/jarvos-skills/src/index.js @@ -1253,6 +1253,17 @@ module.exports = { planSchedulerUnits: scheduler.planSchedulerUnits, createInventoryWatcher: scheduler.createInventoryWatcher, reconcileAttention: require('./attention').reconcileAttention, + reconcileDecisions: require('./decision-store').reconcileDecisions, + migrateV1Attention: require('./decision-store').migrateV1Attention, + listDecisions: require('./decision-store').listDecisions, + explainDecision: require('./decision-store').explainDecision, + resolveDecision: require('./decision-store').resolveDecision, + decisionStatePath: operator.decisionStatePath, + decisionsOperator: operator.decisionsOperator, + explainDecisionOperator: operator.explainDecisionOperator, + resolveDecisionOperator: operator.resolveDecisionOperator, + claimDelivery: require('./decision-store').claimDelivery, + acknowledgeDelivery: require('./decision-store').acknowledgeDelivery, scheduledRepairMessage: require('./scheduled-repair').scheduledRepairMessage, runScheduledRepair: require('./scheduled-repair').runScheduledRepair, doctorSharedSkills: doctor.doctorSharedSkills, diff --git a/modules/jarvos-skills/src/inventory-contract.js b/modules/jarvos-skills/src/inventory-contract.js index ab0ae1b5..786ad675 100644 --- a/modules/jarvos-skills/src/inventory-contract.js +++ b/modules/jarvos-skills/src/inventory-contract.js @@ -63,6 +63,7 @@ const ALLOWED_REASON_CODES = Object.freeze([ 'ambiguous_identity', 'semantic_collision', 'owner_excluded', + 'owner_keep_local', 'trust_class_insufficient', 'needs_owner_input', 'incomplete_observation', diff --git a/modules/jarvos-skills/src/inventory.js b/modules/jarvos-skills/src/inventory.js index 44c84b97..a1b277b1 100644 --- a/modules/jarvos-skills/src/inventory.js +++ b/modules/jarvos-skills/src/inventory.js @@ -1212,12 +1212,13 @@ function observeInventory(options = {}) { const loadedExclusions = loadExclusionOverlay(layout.exclusionOverlayPath); if (loadedExclusions.status === 'valid' || loadedExclusions.status === 'absent') { exclusions = loadedExclusions.overlay.entries || []; - const excludedIds = new Set(exclusions.map((entry) => entry.logicalId)); + const excludedById = new Map(exclusions.map((entry) => [entry.logicalId, entry])); skills = skills.map((skill) => { - if (!excludedIds.has(skill.logicalId)) return skill; + const exclusion = excludedById.get(skill.logicalId); + if (!exclusion) return skill; return { ...skill, - disposition: { kind: 'blocked', reasonCode: 'owner_excluded' }, + disposition: { kind: 'blocked', reasonCode: exclusion.reasonCode || 'owner_excluded' }, attention: 'quiet', }; }); @@ -1333,6 +1334,7 @@ function observeInventory(options = {}) { harnessRoots, publicCatalog: readJsonSafe(resolved.publicCatalogPath, null), localOverlay: readJsonSafe(resolved.localOverlayPath, null), + ownerApprovedSkills: options.ownerApprovedSkills, reviewer: options.reviewer || null, complete, autoAdmit: options.autoAdmit !== false && complete, @@ -1440,6 +1442,7 @@ function inventoryOperator(options = {}) { // observation-first so discovery never silently mutates accepted state. assess: options.assess === true, autoAdmit: options.autoAdmit !== false, + ownerApprovedSkills: options.ownerApprovedSkills, reviewer: options.reviewer || null, saveConfig: options.saveConfig === true, }); diff --git a/modules/jarvos-skills/src/operator.js b/modules/jarvos-skills/src/operator.js index f564f7ae..088f66ec 100644 --- a/modules/jarvos-skills/src/operator.js +++ b/modules/jarvos-skills/src/operator.js @@ -31,6 +31,7 @@ const { readReceipt } = require('./receipts'); const { verifyHarnessBundle, resolveShadowPaths } = require('./harness-verification'); const { planSchedulerUnits } = require('./scheduler'); const { reconcileAttention, redactedAttention } = require('./attention'); +const decisionStore = require('./decision-store'); const { inventoryOperator, registerAdapterRootsOperator, @@ -540,11 +541,14 @@ function autonomousRepairOperator(options = {}) { if (loaded.config.inventory.enabled !== true) { return { ok: true, ran: false, reason: 'inventory_disabled', mutationDenied: true }; } + const decisionPath = path.join(path.dirname(loaded.resolved.inventory.attentionPath), 'owner-decisions.json'); + const ownerApprovedSkills = decisionStore.approvedShareMap({ statePath: decisionPath }); const assessed = inventoryOperator({ configPath: options.configPath, persist: true, assess: true, autoAdmit: true, + ownerApprovedSkills, saveConfig: true, observedAt: options.observedAt, includeDocument: false, @@ -565,12 +569,26 @@ function autonomousRepairOperator(options = {}) { && current.config.acceptedAliasRevision === planned.aliasRevision ? _repairOperator({ configPath: options.configPath }) : _applyOperator({ configPath: options.configPath }); + // Persist owner-actionable observations independently of notification + // delivery. A transport failure must never erase the decision or let a + // later repair mutate the held skill implicitly. + // Read the legacy file before maintaining it with the v1 compatibility + // writer; otherwise a first v2 run would erase the historic active set + // before it could be migrated. const attention = reconcileAttention({ attentionPath: current.resolved.inventory.attentionPath, status: assessed.status, observedAt: assessed.status?.observedAt, deliver: options.deliver || null, }); + const decisions = decisionStore.reconcileDecisionsWithMigration({ + statePath: decisionPath, + attentionPath: current.resolved.inventory.attentionPath, + skills: assessed.status?.skills || [], + observedAt: assessed.status?.observedAt, + generationId: assessed.generationId, + }); + const migration = decisions.migration; return { ok: reconciliation.ok, ran: true, @@ -579,9 +597,48 @@ function autonomousRepairOperator(options = {}) { status: assessed.status, reconciliation: reconciliation.repaired === false ? { repaired: false } : { repaired: true, applied: reconciliation.applied || [] }, attention, + decisions: { + created: decisions.created.length, + pending: decisions.pending.length, + items: decisions.created, + // Kept inside the local result so the scheduled sender can claim an + // outbox attempt for an older pending decision after its cooldown. The + // CLI envelope never forwards this list unless a single item is being + // rendered through the redacted notification contract. + pendingItems: decisions.pending, + migration: migration.summary + ? { migrated: migration.migrated, replay: migration.replay, reference: migration.summary.reference, pendingCount: migration.summary.pendingCount, migratedCount: migration.summary.migratedCount } + : null, + }, }; } +function decisionStatePath(options = {}) { + const loaded = loadConfig(options.configPath); + return path.join(path.dirname(loaded.resolved.inventory.attentionPath), 'owner-decisions.json'); +} + +function decisionPrincipal(options = {}) { + // Callers must inject the host-bound principal. A library default here + // would turn every direct import into an owner session. + return options.principal || null; +} + +function decisionsOperator(options = {}) { + return decisionStore.listDecisions({ statePath: decisionStatePath(options), principal: decisionPrincipal(options) }); +} + +function explainDecisionOperator(options = {}) { + return decisionStore.explainDecision({ statePath: decisionStatePath(options), principal: decisionPrincipal(options), decisionId: options.decisionId, decisionReference: options.decisionReference }); +} + +function resolveDecisionOperator(options = {}) { + return decisionStore.resolveDecision({ + statePath: decisionStatePath(options), principal: decisionPrincipal(options), decisionId: options.decisionId, decisionReference: options.decisionReference, + revision: options.revision, option: options.option, currentSkill: options.currentSkill, mutate: options.mutate, + }); +} + function schedulerOperator(options = {}) { const loaded = loadConfig(options.configPath); const config = { ...loaded.config }; @@ -897,7 +954,7 @@ function excludeSkillOperator(options = {}) { schemaVersion: EXCLUSION_SCHEMA_VERSION, entries, }, exclusionPath); - const retired = retireGeneratedSkill(loaded, logicalId, record.excludedAt); + const retired = retireGeneratedSkill(loaded, logicalId, record.excludedAt, reasonCode); return { ok: true, mode: 'exclude', @@ -909,7 +966,7 @@ function excludeSkillOperator(options = {}) { }); } -function retireGeneratedSkill(loaded, logicalId, retiredAt) { +function retireGeneratedSkill(loaded, logicalId, retiredAt, reasonCode = 'owner_excluded') { const { readAcceptedGeneration } = require('./source-store'); const acceptedPath = loaded.resolved.inventory.acceptedGenerationPath; const accepted = readAcceptedGeneration(acceptedPath); @@ -931,7 +988,7 @@ function retireGeneratedSkill(loaded, logicalId, retiredAt) { absences: Object.fromEntries(Object.entries(accepted.absences || {}).filter(([id]) => id !== logicalId)), tombstones: [ ...(accepted.tombstones || []).filter((item) => item.logicalId !== logicalId), - { logicalId, retiredAt, reasonCode: 'owner_excluded' }, + { logicalId, retiredAt, reasonCode }, ], }; const overlay = readJson(loaded.resolved.localOverlayPath, { schemaVersion: OVERLAY_SCHEMA_VERSION, entries: [] }); @@ -1081,4 +1138,8 @@ module.exports = { excludeSkillOperator, includeSkillOperator, claudeProofOperator, + decisionsOperator, + decisionStatePath, + explainDecisionOperator, + resolveDecisionOperator, }; diff --git a/modules/jarvos-skills/src/receipts.js b/modules/jarvos-skills/src/receipts.js index 22f8bb2c..11f7df7b 100644 --- a/modules/jarvos-skills/src/receipts.js +++ b/modules/jarvos-skills/src/receipts.js @@ -4,7 +4,7 @@ const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); -const RECEIPT_VERSION = 1; +const RECEIPT_VERSION = 2; const STATE_DIR = '.jarvos-projections'; const SHA256_RE = /^[a-f0-9]{64}$/i; @@ -56,7 +56,9 @@ function readReceipt(skillsRoot, effectiveName) { function validateReceipt(receipt) { if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) return null; - if (receipt.version !== RECEIPT_VERSION) return null; + // v1 ownership receipts remain readable so they can be safely refreshed, + // but are never evidence of dependency-complete native discovery. + if (receipt.version !== 1 && receipt.version !== RECEIPT_VERSION) return null; if (typeof receipt.id !== 'string' || typeof receipt.effectiveName !== 'string') return null; if (typeof receipt.harness !== 'string') return null; if (!SHA256_RE.test(receipt.treeDigest || '')) return null; @@ -78,7 +80,7 @@ function validateReceipt(receipt) { } : null; return { - version: RECEIPT_VERSION, + version: receipt.version, id: receipt.id, effectiveName: receipt.effectiveName, harness: receipt.harness, @@ -90,6 +92,21 @@ function validateReceipt(receipt) { // Optional inventory provenance (U4). Absent on pre-inventory receipts. inventoryGenerationId, sourceIdentity, + catalogRelease: typeof receipt.catalogRelease === 'string' ? receipt.catalogRelease : null, + manifestDigest: typeof receipt.manifestDigest === 'string' && SHA256_RE.test(receipt.manifestDigest) + ? receipt.manifestDigest.toLowerCase() : null, + dependencyComplete: receipt.dependencyComplete === true, + runtimePrerequisites: receipt.runtimePrerequisites && typeof receipt.runtimePrerequisites === 'object' + && !Array.isArray(receipt.runtimePrerequisites) ? receipt.runtimePrerequisites : null, + enrolledRoot: receipt.enrolledRoot && typeof receipt.enrolledRoot === 'object' + && !Array.isArray(receipt.enrolledRoot) ? receipt.enrolledRoot : null, + desiredSetDigest: typeof receipt.desiredSetDigest === 'string' && SHA256_RE.test(receipt.desiredSetDigest) + ? receipt.desiredSetDigest.toLowerCase() : null, + observedSetDigest: typeof receipt.observedSetDigest === 'string' && SHA256_RE.test(receipt.observedSetDigest) + ? receipt.observedSetDigest.toLowerCase() : null, + discovery: receipt.discovery && typeof receipt.discovery === 'object' && !Array.isArray(receipt.discovery) + ? receipt.discovery : null, + status: typeof receipt.status === 'string' ? receipt.status : 'verification_pending', }; } diff --git a/modules/jarvos-skills/src/reconciliation.js b/modules/jarvos-skills/src/reconciliation.js index c6583422..83dd5399 100644 --- a/modules/jarvos-skills/src/reconciliation.js +++ b/modules/jarvos-skills/src/reconciliation.js @@ -16,8 +16,10 @@ const path = require('node:path'); const { attestCatalogBundle, computeBundleTree, LOCAL_OVERLAY_SOURCE_KIND } = require('./catalog'); const { expandHome } = require('./config'); const { resolveCollisionAlias } = require('./collision-alias'); +const { verifyHarnessBundle, resolveShadowPaths } = require('./harness-verification'); const { STATE_DIR, + RECEIPT_VERSION, readReceipt, validateReceipt, atomicWriteReceipt, @@ -27,6 +29,7 @@ const { const ALIAS_FILE = 'shared-skill-aliases.json'; const ALIAS_STATE_VERSION = 1; const JOURNAL_FILE = 'shared-skill-reconcile.journal.json'; +const SHA256_RE = /^[a-f0-9]{64}$/i; function sha256(value) { return crypto.createHash('sha256').update(value).digest('hex'); @@ -53,6 +56,103 @@ function safeRoot(value, { create = true } = {}) { return fs.realpathSync(root); } +function rootIdentity(root) { + if (!fs.existsSync(root)) return null; + const resolved = safeRoot(root, { create: false }); + const stat = fs.lstatSync(resolved); + return { path: resolved, dev: String(stat.dev), ino: String(stat.ino), uid: stat.uid }; +} + +function sameRootIdentity(left, right) { + return left && right && left.path === right.path && left.dev === right.dev && left.ino === right.ino && left.uid === right.uid; +} + +function assertTargetBelowRoot(root, target) { + const relative = path.relative(root, target); + if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error('target is outside enrolled managed skill root'); + } + // Check every existing ancestor without following a link. This protects + // both the planned target and a parent substituted before apply. + let current = root; + for (const part of relative.split(path.sep)) { + current = path.join(current, part); + if (!fs.existsSync(current)) break; + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink()) throw new Error('target contains a symbolic link'); + if (current !== target) assertSafeOwnedDirectory(stat, 'managed skill target ancestor'); + } + return relative.split(path.sep).join('/'); +} + +function desiredTupleDigest(entries, catalogRelease) { + return sha256(JSON.stringify(entries + .map((entry) => ({ id: entry.id, catalogRelease, treeDigest: entry.bundle.treeDigest })) + .sort((left, right) => left.id.localeCompare(right.id)))); +} + +function observedTupleDigest(tuples) { + if (!Array.isArray(tuples)) return null; + const normalized = tuples.map((tuple) => ({ + id: tuple?.id, + catalogRelease: tuple?.catalogRelease, + treeDigest: tuple?.treeDigest, + })); + if (normalized.some((tuple) => typeof tuple.id !== 'string' + || typeof tuple.catalogRelease !== 'string' || !SHA256_RE.test(tuple.treeDigest || ''))) return null; + return sha256(JSON.stringify(normalized.sort((left, right) => left.id.localeCompare(right.id)))); +} + +function receiptNeedsRefresh(receipt, pair) { + return !receipt || receipt.version !== RECEIPT_VERSION + || receipt.catalogRelease !== pair.catalogRelease + || receipt.manifestDigest !== pair.manifestDigest + || receipt.dependencyComplete !== true + || !sameRootIdentity(receipt.enrolledRoot, pair.enrolledRoot) + || receipt.desiredSetDigest !== pair.desiredSetDigest + || !receipt.discovery + || !receipt.observedSetDigest; +} + +function desiredIdsForHarness(options, harnessId, catalog) { + const configured = options.desiredSkillIds || options.desiredSkills || null; + if (!configured) return catalog.entries.filter((entry) => entry.allowedHarnesses.includes(harnessId)).map((entry) => entry.id); + const values = Array.isArray(configured) ? configured : configured[harnessId]; + if (!Array.isArray(values)) return []; + return values.slice(); +} + +function resolveDependencyClosure({ catalog, harnessId, desiredIds, excludedSkillIds }) { + const byId = new Map(catalog.entries.map((entry) => [entry.id, entry])); + const excluded = new Set(Array.isArray(excludedSkillIds) ? excludedSkillIds : (excludedSkillIds?.[harnessId] || [])); + const resolved = new Map(); + const visiting = new Set(); + const visit = (id, chain = []) => { + if (visiting.has(id)) throw new Error(`dependency_cycle:${[...chain, id].join('->')}`); + const entry = byId.get(id); + if (!entry) throw new Error(`dependency_missing:${id}`); + if (excluded.has(id)) throw new Error(`dependency_excluded:${id}`); + if (!entry.allowedHarnesses.includes(harnessId)) throw new Error(`dependency_incompatible:${id}`); + if (resolved.has(id)) return; + visiting.add(id); + for (const dependency of entry.skillDependencies || []) visit(dependency, [...chain, id]); + visiting.delete(id); + resolved.set(id, entry); + }; + for (const id of desiredIds) visit(id); + return [...resolved.values()].sort((left, right) => left.id.localeCompare(right.id)); +} + +function runtimePrerequisiteStatus(entry, harness, verifier) { + const statuses = {}; + for (const prerequisite of entry.runtimePrerequisites || []) { + const result = typeof verifier === 'function' ? verifier({ entry, harness, prerequisite }) : null; + const available = result === true || result?.available === true || result?.status === 'available'; + statuses[prerequisite] = { available, reason: available ? null : (result?.reason || 'runtime_prerequisite_unavailable') }; + } + return { complete: Object.values(statuses).every((status) => status.available), statuses }; +} + function atomicWriteJson(filePath, value) { const parent = path.dirname(filePath); fs.mkdirSync(parent, { recursive: true, mode: 0o700 }); @@ -403,9 +503,11 @@ function planCatalogReconciliation(options = {}) { const readOnly = options.readOnly === true; const harnesses = options.harnesses.map((harness) => { if (!harness || typeof harness.id !== 'string') throw new Error('harness id is required'); + const root = safeRoot(harness.root, { create: !readOnly }); return { id: harness.id, - root: safeRoot(harness.root, { create: !readOnly }), + root, + enrolledRoot: rootIdentity(root), adapter: harness.adapter || null, scopeRoots: harness.scopeRoots || {}, scopeRootsComplete: harness.scopeRootsComplete !== false, @@ -426,6 +528,22 @@ function planCatalogReconciliation(options = {}) { const catalogDigest = options.catalogDigest || catalog.digest || sha256(JSON.stringify(catalog)); const pairs = []; + const closureByHarness = new Map(); + const closureFailures = new Map(); + for (const harness of harnesses) { + try { + closureByHarness.set(harness.id, resolveDependencyClosure({ + catalog, + harnessId: harness.id, + desiredIds: desiredIdsForHarness(options, harness.id, catalog), + excludedSkillIds: options.excludedSkillIds, + })); + } catch (error) { + closureFailures.set(harness.id, error.message); + closureByHarness.set(harness.id, []); + } + } + for (const entry of catalog.entries) { const effectiveName = aliases[entry.id]; if (!effectiveName) { @@ -448,12 +566,13 @@ function planCatalogReconciliation(options = {}) { localSourceRoot: options.localSourceRoot, }); - const enrolled = harnesses.filter((item) => entry.allowedHarnesses.includes(item.id)); + const enrolled = harnesses.filter((item) => closureByHarness.get(item.id)?.some((candidate) => candidate.id === entry.id)); if (enrolled.length === 0) continue; // The source bundle is immutable for the duration of planning. Attest it // once per catalog entry, then retain a fresh attestation in apply. const sourceAttestation = (options.attestCatalogBundle || attestCatalogBundle)(entry, { sourceRoot }); for (const harness of enrolled) { + const prerequisiteStatus = runtimePrerequisiteStatus(entry, harness, options.verifyRuntimePrerequisite); const classified = classifyPair({ entry, harness, @@ -475,6 +594,12 @@ function planCatalogReconciliation(options = {}) { catalogDigest, aliasRevision: aliasState.data.revision, inventoryGenerationId: options.inventoryGenerationId || null, + catalogRelease: options.catalogRelease || catalog.release || catalog.publicCatalogDigest || catalogDigest, + manifestDigest: options.manifestDigest || catalogDigest, + enrolledRoot: harness.enrolledRoot, + desiredSetDigest: desiredTupleDigest(closureByHarness.get(harness.id), options.catalogRelease || catalog.release || catalog.publicCatalogDigest || catalogDigest), + dependencyComplete: prerequisiteStatus.complete, + runtimePrerequisites: prerequisiteStatus.statuses, sourceIdentity: options.sourceIdentities?.[entry.id] || { logicalId: entry.id, sourceKind: entry.sourceKind, @@ -482,15 +607,49 @@ function planCatalogReconciliation(options = {}) { }, ...classified, }; + pair.targetRelativePath = assertTargetBelowRoot(harness.root, pair.target); + if (!prerequisiteStatus.complete) { + pair.status = 'verification_failed'; + pair.action = 'preserve'; + pair.reason = 'runtime_prerequisite_unavailable'; + } else if (options.refreshVerification === true && pair.status === 'clean' && receiptNeedsRefresh(pair.receipt, pair)) { + // A v1 receipt, or a v2 receipt without the complete discovery tuple, + // remains ownership evidence only. Refresh it without replacing bytes. + pair.status = 'verification_stale'; + pair.action = 'refresh'; + pair.reason = 'receipt_verification_stale'; + } pair.generation = pairGeneration(pair); pairs.push(pair); } } + for (const [harnessId, reason] of closureFailures) { + const harness = harnesses.find((item) => item.id === harnessId); + for (const id of desiredIdsForHarness(options, harnessId, catalog)) { + pairs.push({ + id, + harness: harnessId, + effectiveName: null, + status: 'verification_failed', + action: 'preserve', + reason, + enrolledRoot: harness.enrolledRoot, + dependencyComplete: false, + generation: sha256(`${id}:${harnessId}:${reason}`), + }); + } + } + // De-selection also retires a receipt-owned previous alias after an explicit // rename. Locally modified and unsafe copies remain preserved. - const selectedIds = new Set(catalog.entries.map((entry) => entry.id)); for (const harness of harnesses) { + // A failed dependency closure is not a de-selection. Preserve existing + // receipt-owned targets until the closure can be evaluated safely. + if (closureFailures.has(harness.id)) continue; + // The root's desired closure, not catalog membership elsewhere, owns + // cleanup. This preserves a dependency needed by any retained wrapper. + const selectedIds = new Set((closureByHarness.get(harness.id) || []).map((entry) => entry.id)); const stateDir = path.join(harness.root, STATE_DIR); if (!fs.existsSync(stateDir)) continue; for (const file of fs.readdirSync(stateDir)) { @@ -530,6 +689,7 @@ function planCatalogReconciliation(options = {}) { status, action, target, + targetRelativePath: assertTargetBelowRoot(harness.root, target), receipt, observed, catalogDigest, @@ -556,8 +716,8 @@ function planCatalogReconciliation(options = {}) { pairs, inventoryGenerationId: options.inventoryGenerationId || null, incompleteGeneration: false, - mutate: pairs.some((pair) => pair.action === 'install' || pair.action === 'retire' || pair.action === 'adopt'), - ok: pairs.every((pair) => ['clean', 'missing', 'outdated', 'retire', 'unmanaged_exact'].includes(pair.status) + mutate: pairs.some((pair) => ['install', 'retire', 'adopt', 'refresh'].includes(pair.action)), + ok: pairs.every((pair) => ['clean', 'missing', 'outdated', 'retire', 'unmanaged_exact', 'verification_stale'].includes(pair.status) || (pair.action === 'preserve' && ['unmanaged', 'local_modified', 'unsafe', 'conflict'].includes(pair.status))), }; } @@ -764,6 +924,66 @@ function commitAliasesIfNeeded(plan) { return { revision: nextRevision, changed: true }; } +function assertLivePairRoot(plan, pair) { + const harness = (plan.harnesses || []).find((item) => item.id === pair.harness); + if (!harness || !pair.target) throw new Error(`enrolled root is unavailable: ${pair.id}/${pair.harness}`); + const live = rootIdentity(harness.root); + if (!sameRootIdentity(live, pair.enrolledRoot || harness.enrolledRoot)) { + throw new Error(`enrolled root changed since planning: ${pair.id}/${pair.harness}`); + } + const relativePath = assertTargetBelowRoot(live.path, pair.target); + if (pair.targetRelativePath && pair.targetRelativePath !== relativePath) { + throw new Error(`target path changed since planning: ${pair.id}/${pair.harness}`); + } + return { live, relativePath }; +} + +function writeVerificationReceipt(pair, targetAttestation, io, outcome) { + io.writeReceipt(path.dirname(pair.target), { + version: RECEIPT_VERSION, + id: pair.id, + effectiveName: pair.effectiveName, + harness: pair.harness, + treeDigest: pair.source.treeDigest, + catalogDigest: pair.catalogDigest, + aliasRevision: pair.aliasRevision, + targetPath: pair.target, + verificationTier: outcome.verificationTier || 'receipt-owned', + inventoryGenerationId: pair.inventoryGenerationId || null, + sourceIdentity: pair.sourceIdentity || null, + catalogRelease: pair.catalogRelease, + manifestDigest: pair.manifestDigest, + dependencyComplete: pair.dependencyComplete === true, + runtimePrerequisites: pair.runtimePrerequisites || {}, + enrolledRoot: targetAttestation.live, + desiredSetDigest: pair.desiredSetDigest || null, + observedSetDigest: outcome.observedSetDigest || null, + discovery: outcome.discovery || null, + status: outcome.status || 'verification_pending', + }); +} + +function verificationOutcome(plan, pair, targetAttestation, options) { + const harness = plan.harnesses.find((item) => item.id === pair.harness); + const activation = typeof options.activationReceipt === 'function' + ? options.activationReceipt({ harness, pair }) : null; + if (activation?.active === false || activation?.status === 'activation_pending') { + return { status: 'activation_pending', verificationTier: 'activation-receipt', discovery: { activationDependency: activation.dependency || 'harness_activation' } }; + } + const observed = typeof options.freshDiscovery === 'function' + ? options.freshDiscovery({ harness, pair, desiredSetDigest: pair.desiredSetDigest }) : null; + if (!observed || observed.fresh !== true) return { status: 'verification_pending', verificationTier: 'receipt-owned' }; + const observedSetDigest = observedTupleDigest(observed.tuples); + if (!observedSetDigest || observedSetDigest !== pair.desiredSetDigest) { + return { status: 'verification_failed', verificationTier: 'native-discovery', discovery: { fresh: true, source: observed.source || 'native' } }; + } + const shadows = resolveShadowPaths({ harness, adapter: harness.adapter, effectiveName: pair.effectiveName }); + const proof = verifyHarnessBundle({ adapter: harness.adapter, targetPath: pair.target, expectedName: pair.effectiveName, + expectedTreeDigest: pair.source.treeDigest, allowlist: pair.allowlist, shadowPaths: shadows.paths, shadowPathsComplete: shadows.complete }); + if (proof.status !== 'model_visible') return { status: 'verification_failed', verificationTier: proof.tier, discovery: { fresh: true, source: observed.source || 'native', reason: proof.reason } }; + return { status: 'model_visible', verificationTier: proof.tier, observedSetDigest, discovery: { fresh: true, source: observed.source || 'native', observedAt: observed.observedAt || null } }; +} + function applyCatalogReconciliation(plan, options = {}) { if (!plan || !Array.isArray(plan.pairs)) { throw new Error('catalog reconciliation plan is required'); @@ -795,7 +1015,7 @@ function applyCatalogReconciliation(plan, options = {}) { }; const hasActionablePairs = plan.pairs.some((pair) => ( - pair.action === 'install' || pair.action === 'retire' || pair.action === 'adopt' + pair.action === 'install' || pair.action === 'retire' || pair.action === 'adopt' || pair.action === 'refresh' )); if (!aliasCommit.changed && !hasActionablePairs) { return { @@ -832,6 +1052,16 @@ function applyCatalogReconciliation(plan, options = {}) { continue; } + // The root is a security boundary, not a convenient parent directory. + // Re-attest just before every mutation/adoption/retirement. + const targetAttestation = assertLivePairRoot(plan, pair); + + if (pair.action === 'refresh' && pair.status === 'verification_stale') { + writeVerificationReceipt(pair, targetAttestation, io, verificationOutcome(plan, pair, targetAttestation, options)); + applied.push({ id: pair.id, harness: pair.harness, effectiveName: pair.effectiveName, status: 'verification_stale', applied: true, reason: 'receipt_refreshed' }); + continue; + } + if (pair.action === 'adopt' && pair.status === 'unmanaged_exact') { // Ownership evidence only — never rewrite matching bytes. let observed; @@ -859,18 +1089,7 @@ function applyCatalogReconciliation(plan, options = {}) { }); continue; } - io.writeReceipt(path.dirname(pair.target), { - version: 1, - id: pair.id, - effectiveName: pair.effectiveName, - harness: pair.harness, - treeDigest: pair.treeDigest, - catalogDigest: plan.catalogDigest, - aliasRevision, - targetPath: pair.target, - inventoryGenerationId: pair.inventoryGenerationId || plan.inventoryGenerationId || null, - sourceIdentity: pair.sourceIdentity || null, - }); + writeVerificationReceipt(pair, targetAttestation, io, verificationOutcome(plan, pair, targetAttestation, options)); applied.push({ id: pair.id, harness: pair.harness, @@ -931,7 +1150,7 @@ function applyCatalogReconciliation(plan, options = {}) { const retirement = { target: pair.target, backup, receipt: liveReceipt }; // Record the rollback pointer before moving the only managed copy. writeJournal(plan.journalFile, { - version: 1, + version: RECEIPT_VERSION, phase: 'applying', catalogDigest: plan.catalogDigest, aliasRevision, @@ -1001,19 +1220,8 @@ function applyCatalogReconciliation(plan, options = {}) { const staged = stageBundleCopy(freshSource, pair.target); try { - io.writeReceipt(path.dirname(pair.target), { - version: 1, - id: pair.id, - effectiveName: pair.effectiveName, - harness: pair.harness, - treeDigest: freshSource.treeDigest, - catalogDigest: plan.catalogDigest, - aliasRevision, - targetPath: pair.target, - verificationTier: options.verificationTier || 'receipt-owned', - inventoryGenerationId: pair.inventoryGenerationId || plan.inventoryGenerationId || null, - sourceIdentity: pair.sourceIdentity || null, - }); + pair.source.treeDigest = freshSource.treeDigest; + writeVerificationReceipt(pair, targetAttestation, io, verificationOutcome(plan, pair, targetAttestation, options)); } catch (error) { // A receipt is the ownership boundary. Roll back the replacement when // it cannot be committed, preserving the prior target for retry. diff --git a/modules/jarvos-skills/src/scheduled-repair.js b/modules/jarvos-skills/src/scheduled-repair.js index 1407342e..93d1f46b 100644 --- a/modules/jarvos-skills/src/scheduled-repair.js +++ b/modules/jarvos-skills/src/scheduled-repair.js @@ -1,7 +1,8 @@ 'use strict'; const crypto = require('node:crypto'); -const { autonomousRepairOperator, statusOperator } = require('./operator'); +const { autonomousRepairOperator, statusOperator, decisionStatePath } = require('./operator'); +const decisionStore = require('./decision-store'); // The package import is the installed public contract. The relative fallback // keeps the source distribution runnable before its sibling packages are packed. let operatorNotification; @@ -27,9 +28,17 @@ function observedAt(result, now) { return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? value : now; } -function eventFor(result, { now = new Date().toISOString() } = {}) { +function eventFor(result, { now = new Date().toISOString(), deliveryClaims = [] } = {}) { const raised = Array.isArray(result?.attention?.raised) ? result.attention.raised : []; const resolved = Array.isArray(result?.attention?.resolved) ? result.attention.resolved : []; + const createdDecisions = Array.isArray(result?.decisions?.items) ? result.decisions.items : []; + const pendingDecisions = Array.isArray(result?.decisions?.pendingItems) ? result.decisions.pendingItems : []; + // A fresh decision takes precedence over older pending decisions. If this + // run created nothing, an existing single decision may be a retry; more than + // one older item is summarized without pretending one delivery attempt + // acknowledges the whole batch. + const decisionsForNotification = createdDecisions.length > 0 ? createdDecisions : pendingDecisions; + const migration = result?.decisions?.migration; const repaired = result?.reconciliation?.repaired === true && Array.isArray(result.reconciliation.applied) && result.reconciliation.applied.some((item) => item?.applied !== false); @@ -69,6 +78,67 @@ function eventFor(result, { now = new Date().toISOString() } = {}) { }; } + if (migration?.migrated === true && migration.pendingCount > 0) { + const reference = opaqueReference(); + const migrationKey = migration.reference || reference; + return { + ...common, + code: 'skill-decision-summary', + severity: 'warning', + automationOutcome: 'failed', + actionRequired: true, + action: 'review-decisions', + nextState: 'await-owner-decision', + eventReference: reference, + itemCount: migration.migratedCount || migration.pendingCount, + resolvedCount: resolved.length, + dedupeKey: `skill-decision-migration-${migrationKey.replace(/[^A-Za-z0-9-]/g, '').slice(-80)}`, + }; + } + + if (decisionsForNotification.length === 1) { + const decision = decisionsForNotification[0]; + const deliveryAttempt = deliveryClaims.find((claim) => claim.decisionId === decision.id) || null; + return { + ...common, + code: 'skill-owner-decision', + severity: 'warning', + automationOutcome: 'failed', + actionRequired: true, + action: 'choose-skill-option', + nextState: 'await-owner-decision', + eventReference: decision.decisionReference, + decisionReference: decision.decisionReference, + revision: decision.revision, + optionSetVersion: 'v1', + skillName: decision.skill, + reasonCode: decision.reason, + options: decision.options, + ...(deliveryAttempt ? { + deliveryAttemptId: deliveryAttempt.attemptId, + deliveryAttemptKind: deliveryAttempt.kind, + } : {}), + dedupeKey: `skill-owner-decision-${decision.id.replace(/[^A-Za-z0-9-]/g, '-')}`, + }; + } + + if (decisionsForNotification.length > 1) { + const reference = opaqueReference(); + return { + ...common, + code: 'skill-decision-summary', + severity: 'warning', + automationOutcome: 'failed', + actionRequired: true, + action: 'review-decisions', + nextState: 'await-owner-decision', + eventReference: reference, + itemCount: decisionsForNotification.length, + resolvedCount: resolved.length, + dedupeKey: `skill-decision-batch-${decisionsForNotification.map((decision) => decision.id).sort().join('-').slice(0, 140)}`, + }; + } + if (raised.length) { return { ...common, @@ -114,6 +184,30 @@ function eventFor(result, { now = new Date().toISOString() } = {}) { return null; } +function claimPendingDecisionAttempts(result, { configPath, now, claimDelivery = decisionStore.claimDelivery } = {}) { + const createdItems = Array.isArray(result?.decisions?.items) ? result.decisions.items : []; + const pendingItems = Array.isArray(result?.decisions?.pendingItems) ? result.decisions.pendingItems : []; + const candidates = createdItems.length === 1 + ? createdItems + : createdItems.length === 0 && pendingItems.length === 1 + ? pendingItems + : []; + if (candidates.length === 0 || typeof claimDelivery !== 'function') return []; + let statePath; + try { + statePath = decisionStatePath({ configPath }); + } catch { + return []; + } + return candidates.map((decision) => { + try { + return claimDelivery({ statePath, decisionId: decision.id, now }); + } catch { + return null; + } + }).filter(Boolean); +} + const OPERATOR_NOTIFICATION_TRANSPORT_VERSION = 'jarvos-operator-notification-transport/v1'; function scheduledRepairCliOutput(notification) { @@ -171,12 +265,15 @@ function runScheduledRepair({ announceConvergence = false, repair = autonomousRepairOperator, readStatus = statusOperator, + claimDelivery = decisionStore.claimDelivery, + now = new Date().toISOString(), } = {}) { const result = repair({ configPath }); + const deliveryClaims = claimPendingDecisionAttempts(result, { configPath, now, claimDelivery }); const catalogStatus = announceConvergence && result?.ok && result?.ran !== false ? readStatus({ configPath }) : null; - const notification = scheduledRepairNotification(result); + const notification = scheduledRepairNotification(result, { now, deliveryClaims }); return { result, notification, @@ -187,6 +284,7 @@ function runScheduledRepair({ module.exports = { OPERATOR_NOTIFICATION_TRANSPORT_VERSION, eventFor, + claimPendingDecisionAttempts, scheduledRepairCliOutput, scheduledRepairMessage, scheduledRepairNotification, diff --git a/modules/jarvos-skills/src/skill-assessment.js b/modules/jarvos-skills/src/skill-assessment.js index b284378c..a26de969 100644 --- a/modules/jarvos-skills/src/skill-assessment.js +++ b/modules/jarvos-skills/src/skill-assessment.js @@ -343,6 +343,7 @@ function assessInventory({ harnessRoots = [], publicCatalog, localOverlay, + ownerApprovedSkills, reviewer, complete = true, autoAdmit = true, @@ -400,9 +401,9 @@ function assessInventory({ const sources = sourceRootsFor(skill, validated.document.roots); let result = { ...skill, ...stableDisposition('needs_input', 'incomplete_observation') }; // Preserve owner exclusions applied by inventory observation. - if (skill.disposition?.kind === 'blocked' && skill.disposition?.reasonCode === 'owner_excluded') { + if (skill.disposition?.kind === 'blocked' && ['owner_excluded', 'owner_keep_local'].includes(skill.disposition?.reasonCode)) { if (priorEntryFor(prior, skill.logicalId)) retireIds.add(skill.logicalId); - assessed.push({ ...result, ...stableDisposition('blocked', 'owner_excluded', 'quiet') }); + assessed.push({ ...result, ...stableDisposition('blocked', skill.disposition.reasonCode, 'quiet') }); continue; } if (manual.error) { @@ -470,7 +471,11 @@ function assessInventory({ assessed.push({ ...result, ...stableDisposition('blocked', 'unsafe_source', 'actionable') }); continue; } - if (feature.hasNetwork || feature.hasPluginOrInteractive) { + const ownerApproval = ownerApprovedSkills instanceof Map + ? ownerApprovedSkills.get(skill.logicalId) + : ownerApprovedSkills?.[skill.logicalId]; + const explicitlyApproved = ownerApproval?.treeDigest === feature.tree.treeDigest; + if ((feature.hasNetwork || feature.hasPluginOrInteractive) && !explicitlyApproved) { assessed.push({ ...result, ...stableDisposition('needs_input', 'needs_owner_input', 'actionable') }); continue; } diff --git a/modules/jarvos-skills/test/decision-store.test.js b/modules/jarvos-skills/test/decision-store.test.js new file mode 100644 index 00000000..7f5c8f62 --- /dev/null +++ b/modules/jarvos-skills/test/decision-store.test.js @@ -0,0 +1,227 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + reconcileDecisions, + migrateV1Attention, + reconcileDecisionsWithMigration, + listDecisions, + explainDecision, + resolveDecision, + claimDelivery, + acknowledgeDelivery, +} = require('../src/decision-store'); + +function skill(overrides = {}) { + return { + logicalId: 'newsletter-generator', + treeDigest: 'a'.repeat(64), + attention: 'actionable', + disposition: { kind: 'needs_input', reasonCode: 'needs_owner_input' }, + ...overrides, + }; +} + +function owner() { return { kind: 'owner', capabilities: ['skills.decisions.read', 'skills.decisions.resolve'] }; } + +test('decision lifecycle dedupes unchanged observations, rejects stale replies, and mutates only on a valid resolution', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-decision-')); + const statePath = path.join(root, 'decisions.json'); + try { + const first = reconcileDecisions({ statePath, skills: [skill()], observedAt: '2026-08-16T16:00:00.000Z', generationId: 'g1' }); + assert.equal(first.created.length, 1); + assert.equal(first.pending.length, 1); + const decision = first.pending[0]; + assert.deepEqual(decision.options, ['share', 'keep-local', 'exclude', 'details']); + assert.equal(reconcileDecisions({ statePath, skills: [skill()], observedAt: '2026-08-16T16:01:00.000Z', generationId: 'g2' }).created.length, 0); + assert.equal(listDecisions({ statePath, principal: owner() }).decisions[0].skill, 'newsletter-generator'); + assert.throws(() => listDecisions({ statePath, principal: null }), /owner authorization/); + + let mutations = 0; + const stale = resolveDecision({ statePath, principal: owner(), decisionId: decision.id, revision: decision.revision, option: 'share', currentSkill: skill({ treeDigest: 'b'.repeat(64) }), mutate: () => { mutations += 1; } }); + assert.equal(stale.status, 'stale'); + assert.equal(mutations, 0); + + const resolved = resolveDecision({ statePath, principal: owner(), decisionId: decision.id, revision: decision.revision, option: 'keep-local', currentSkill: skill(), mutate: () => { mutations += 1; } }); + assert.equal(resolved.status, 'resolved'); + assert.equal(mutations, 1); + assert.equal(resolveDecision({ statePath, principal: owner(), decisionId: decision.id, revision: decision.revision, option: 'keep-local', currentSkill: skill(), mutate: () => { mutations += 1; } }).status, 'already_resolved'); + assert.equal(mutations, 1); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test('delivery outbox is write-ahead, bounded through fallback, and rejects forged acknowledgements', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-decision-delivery-')); + const statePath = path.join(root, 'decisions.json'); + try { + const decision = reconcileDecisions({ statePath, skills: [skill()], observedAt: '2026-08-16T16:00:00.000Z' }).pending[0]; + const initial = claimDelivery({ statePath, decisionId: decision.id, now: '2026-08-16T16:00:01.000Z' }); + assert.equal(initial.kind, 'initial'); + assert.throws(() => acknowledgeDelivery({ statePath, principal: { kind: 'runtime', capabilities: [] }, decisionId: decision.id, revision: 1, attemptId: initial.attemptId, outcome: 'accepted', providerMessageId: 'p1' }), /delivery authorization/); + acknowledgeDelivery({ statePath, principal: { kind: 'selected-runtime', capabilities: ['skills.delivery.ack'] }, decisionId: decision.id, revision: 1, attemptId: initial.attemptId, outcome: 'ambiguous' }); + assert.equal(claimDelivery({ statePath, decisionId: decision.id, now: '2026-08-16T17:00:00.000Z' }), null); + const fallback = claimDelivery({ statePath, decisionId: decision.id, now: '2026-08-17T16:00:02.000Z' }); + assert.equal(fallback.kind, 'fallback'); + const stalled = acknowledgeDelivery({ statePath, principal: { kind: 'selected-runtime', capabilities: ['skills.delivery.ack'] }, decisionId: decision.id, revision: 1, attemptId: fallback.attemptId, outcome: 'ambiguous' }); + assert.equal(stalled.deliveryStatus, 'delivery_stalled'); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test('a rejected prompt waits for the cooldown, retries once, then stalls safely', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-decision-rejected-delivery-')); + const statePath = path.join(root, 'decisions.json'); + try { + const decision = reconcileDecisions({ statePath, skills: [skill()], observedAt: '2026-08-16T16:00:00.000Z' }).pending[0]; + const initial = claimDelivery({ statePath, decisionId: decision.id, now: '2026-08-16T16:00:00.000Z' }); + const principal = { kind: 'selected-runtime', capabilities: ['skills.delivery.ack'] }; + const rejected = acknowledgeDelivery({ statePath, principal, decisionId: decision.id, revision: 1, attemptId: initial.attemptId, outcome: 'rejected' }); + assert.equal(rejected.deliveryStatus, 'pending'); + assert.equal(claimDelivery({ statePath, decisionId: decision.id, now: '2026-08-16T17:00:00.000Z' }), null); + const fallback = claimDelivery({ statePath, decisionId: decision.id, now: '2026-08-17T16:00:01.000Z' }); + assert.equal(fallback.kind, 'fallback'); + const stalled = acknowledgeDelivery({ statePath, principal, decisionId: decision.id, revision: 1, attemptId: fallback.attemptId, outcome: 'rejected' }); + assert.equal(stalled.deliveryStatus, 'delivery_stalled'); + assert.equal(claimDelivery({ statePath, decisionId: decision.id, now: '2026-08-18T16:00:01.000Z' }), null); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test('an abandoned claimed prompt becomes one bounded fallback instead of remaining claimed forever', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-decision-abandoned-delivery-')); + const statePath = path.join(root, 'decisions.json'); + try { + const decision = reconcileDecisions({ statePath, skills: [skill()], observedAt: '2026-08-16T16:00:00.000Z' }).pending[0]; + const initial = claimDelivery({ statePath, decisionId: decision.id, now: '2026-08-16T16:00:00.000Z' }); + const fallback = claimDelivery({ statePath, decisionId: decision.id, now: '2026-08-17T16:00:01.000Z' }); + assert.equal(fallback.kind, 'fallback'); + assert.equal(initial.decisionReference, decision.decisionReference); + const later = claimDelivery({ statePath, decisionId: decision.id, now: '2026-08-18T16:00:02.000Z' }); + assert.equal(later, null); + const persisted = listDecisions({ statePath, principal: owner() }).decisions; + assert.equal(persisted[0].deliveryStatus, 'delivery_stalled'); + assert.throws(() => acknowledgeDelivery({ + statePath, + principal: { kind: 'selected-runtime', capabilities: ['skills.delivery.ack'] }, + decisionId: decision.id, + revision: decision.revision, + attemptId: fallback.attemptId, + outcome: 'accepted', + }), /delivery acknowledgement is stale/); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test('semantic source changes supersede rather than duplicate a pending decision, and details stays non-mutating', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-decision-supersede-')); + const statePath = path.join(root, 'decisions.json'); + try { + const first = reconcileDecisions({ statePath, skills: [skill()], observedAt: '2026-08-16T16:00:00.000Z' }).pending[0]; + const next = reconcileDecisions({ statePath, skills: [skill({ treeDigest: 'c'.repeat(64) })], observedAt: '2026-08-16T16:01:00.000Z' }); + assert.equal(next.created.length, 1); + assert.notEqual(next.pending[0].id, first.id); + let mutations = 0; + const result = resolveDecision({ statePath, principal: owner(), decisionId: next.pending[0].id, revision: 1, option: 'details', currentSkill: skill({ treeDigest: 'c'.repeat(64) }), mutate: () => { mutations += 1; } }); + assert.equal(result.status, 'pending'); + assert.equal(mutations, 0); + assert.throws(() => resolveDecision({ statePath, principal: { kind: 'owner', capabilities: ['skills.decisions.read'] }, decisionId: next.pending[0].id, revision: 1, option: 'share', currentSkill: skill({ treeDigest: 'c'.repeat(64) }), mutate: () => {} }), /owner authorization/); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test('a resolved semantic decision stays resolved on replay while a changed digest creates a new decision', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-decision-resolved-replay-')); + const statePath = path.join(root, 'decisions.json'); + try { + const decision = reconcileDecisions({ statePath, skills: [skill()], observedAt: '2026-08-16T16:00:00.000Z' }).pending[0]; + assert.equal(resolveDecision({ statePath, principal: owner(), decisionId: decision.id, revision: 1, option: 'keep-local', currentSkill: skill(), mutate: () => {} }).status, 'resolved'); + const replay = reconcileDecisions({ statePath, skills: [skill()], observedAt: '2026-08-16T16:01:00.000Z', generationId: 'later' }); + assert.equal(replay.created.length, 0); + assert.equal(replay.pending.length, 0); + const changed = reconcileDecisions({ statePath, skills: [skill({ treeDigest: 'd'.repeat(64) })], observedAt: '2026-08-16T16:02:00.000Z' }); + assert.equal(changed.created.length, 1); + assert.equal(changed.pending[0].skill, 'newsletter-generator'); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test('v1 attention migration only carries still-actionable holds and is idempotent on replay', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-decision-v1-migration-')); + const statePath = path.join(root, 'decisions.json'); + const attentionPath = path.join(root, 'attention.json'); + try { + fs.writeFileSync(attentionPath, JSON.stringify({ + schemaVersion: 'jarvos.skill-attention/v1', + active: [ + { logicalId: 'newsletter-generator', reasonCode: 'needs_owner_input', fingerprint: 'a'.repeat(64) }, + { logicalId: 'gone-skill', reasonCode: 'needs_owner_input', fingerprint: 'b'.repeat(64) }, + ], + }), { mode: 0o600 }); + const migrated = migrateV1Attention({ statePath, attentionPath, skills: [skill()], observedAt: '2026-08-16T16:00:00.000Z', generationId: 'g1' }); + assert.equal(migrated.migrated, true); + assert.equal(migrated.summary.migratedCount, 1); + assert.equal(migrated.summary.pendingCount, 1); + assert.match(migrated.summary.reference, /^batch-[a-f0-9]{24}$/); + assert.doesNotMatch(JSON.stringify(migrated.summary), /newsletter-generator|needs_owner_input|attention\.json/); + const replay = migrateV1Attention({ statePath, attentionPath, skills: [skill()], observedAt: '2026-08-16T16:01:00.000Z', generationId: 'g2' }); + assert.equal(replay.replay, true); + assert.equal(replay.migrated, false); + assert.equal(reconcileDecisions({ statePath, skills: [skill()] }).created.length, 0); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test('combined migration and reconciliation preserves one-pass decision results', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-decision-combined-')); + const statePath = path.join(root, 'decisions.json'); + const attentionPath = path.join(root, 'attention.json'); + try { + fs.writeFileSync(attentionPath, JSON.stringify({ + schemaVersion: 'jarvos.skill-attention/v1', + active: [{ logicalId: 'newsletter-generator', reasonCode: 'needs_owner_input' }], + }), { mode: 0o600 }); + const first = reconcileDecisionsWithMigration({ + statePath, + attentionPath, + skills: [skill()], + observedAt: '2026-08-16T16:00:00.000Z', + generationId: 'g1', + }); + assert.equal(first.migration.migrated, true); + assert.equal(first.migration.summary.migratedCount, 1); + assert.equal(first.created.length, 0); + assert.equal(first.pending.length, 1); + + const replay = reconcileDecisionsWithMigration({ + statePath, + attentionPath, + skills: [skill()], + observedAt: '2026-08-16T16:01:00.000Z', + generationId: 'g2', + }); + assert.equal(replay.migration.replay, true); + assert.equal(replay.created.length, 0); + assert.equal(replay.pending.length, 1); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); + +test('opaque decision references resolve owner actions without exposing or trusting an internal id', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-decision-reference-')); + const statePath = path.join(root, 'decisions.json'); + try { + const decision = reconcileDecisions({ statePath, skills: [skill()] }).pending[0]; + assert.match(decision.decisionReference, /^[A-Za-z0-9_-]{24}$/); + assert.equal(explainDecision({ statePath, principal: owner(), decisionReference: decision.decisionReference }).decision.id, decision.id); + assert.deepEqual(explainDecision({ statePath, principal: owner(), decisionReference: 'not-a-reference' }), { found: false }); + assert.equal(resolveDecision({ + statePath, principal: owner(), decisionReference: decision.decisionReference, revision: 99, option: 'keep-local', currentSkill: skill(), mutate: () => {}, + }).status, 'stale'); + assert.equal(resolveDecision({ + statePath, principal: owner(), decisionReference: 'not-a-reference', revision: 1, option: 'keep-local', currentSkill: skill(), mutate: () => {}, + }).status, 'not_found'); + const result = resolveDecision({ + statePath, principal: owner(), decisionReference: decision.decisionReference, revision: 1, option: 'keep-local', currentSkill: skill(), mutate: () => {}, + }); + assert.equal(result.status, 'resolved'); + assert.equal(result.receipt.decisionReference, decision.decisionReference); + } finally { fs.rmSync(root, { recursive: true, force: true }); } +}); diff --git a/modules/jarvos-skills/test/reconciliation.test.js b/modules/jarvos-skills/test/reconciliation.test.js index ff6663a1..6ca5f8a5 100644 --- a/modules/jarvos-skills/test/reconciliation.test.js +++ b/modules/jarvos-skills/test/reconciliation.test.js @@ -103,6 +103,176 @@ function harnesses(roots) { return Object.entries(roots).map(([id, root]) => ({ id, root })); } +test('dependency closure refreshes legacy receipts only after fresh desired/observed tuple equality', () => { + const control = temp('jarvos-closure-control-'); + const sourceRoot = temp('jarvos-closure-source-'); + const codex = temp('jarvos-closure-codex-'); + try { + copyFixture(PUBLIC_FIXTURE, path.join(sourceRoot, 'public-fixture')); + const tree = computeBundleTree(path.join(sourceRoot, 'public-fixture'), { allowlist: ['SKILL.md', 'scripts/**', 'assets/**'] }); + const effective = composeEffectiveCatalog({ + publicCatalog: { schemaVersion: CATALOG_SCHEMA_VERSION, release: 'fixture-release', entries: [ + { id: 'grilling', allowedHarnesses: ['codex'], bundle: { root: 'public-fixture', allowlist: ['SKILL.md', 'scripts/**', 'assets/**'], treeDigest: tree.treeDigest } }, + { id: 'grill-me', allowedHarnesses: ['codex'], skillDependencies: ['grilling'], bundle: { root: 'public-fixture', allowlist: ['SKILL.md', 'scripts/**', 'assets/**'], treeDigest: tree.treeDigest } }, + ] }, + localOverlay: { schemaVersion: OVERLAY_SCHEMA_VERSION, entries: [] }, + }); + let plan = planCatalogReconciliation({ catalog: effective.catalog, catalogDigest: effective.digest, catalogRelease: 'fixture-release', publicSourceRoot: sourceRoot, harnesses: harnesses({ codex }), controlRoot: control, desiredSkills: { codex: ['grill-me'] } }); + assert.deepEqual(plan.pairs.map((pair) => pair.id), ['grill-me', 'grilling']); + const tuples = plan.pairs.map((pair) => ({ id: pair.id, catalogRelease: pair.catalogRelease, treeDigest: pair.treeDigest })); + applyCatalogReconciliation(plan, { freshDiscovery: () => ({ fresh: true, source: 'fixture-native-session', tuples }) }); + for (const id of ['grill-me', 'grilling']) { + const receipt = validateReceipt(readReceipt(codex, id)); + assert.equal(receipt.status, 'model_visible'); + assert.equal(receipt.desiredSetDigest, receipt.observedSetDigest); + assert.equal(receipt.dependencyComplete, true); + } + plan = planCatalogReconciliation({ catalog: effective.catalog, catalogDigest: effective.digest, catalogRelease: 'fixture-release', publicSourceRoot: sourceRoot, harnesses: harnesses({ codex }), controlRoot: control, desiredSkills: { codex: ['grill-me'] } }); + assert.equal(plan.pairs.every((pair) => pair.status === 'clean'), true); + assert.equal(applyCatalogReconciliation(plan).noop, true); + } finally { for (const dir of [control, sourceRoot, codex]) fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('runtime prerequisites and mismatched fresh discovery never claim availability', () => { + const control = temp('jarvos-prereq-control-'); const sourceRoot = temp('jarvos-prereq-source-'); const codex = temp('jarvos-prereq-codex-'); + try { + copyFixture(PUBLIC_FIXTURE, path.join(sourceRoot, 'public-fixture')); + const catalog = buildPublicCatalogFrom(sourceRoot).catalog; + catalog.entries[0].runtimePrerequisites = ['fixture-tool']; + const blocked = planCatalogReconciliation({ catalog, publicSourceRoot: sourceRoot, harnesses: harnesses({ codex }), controlRoot: control, verifyRuntimePrerequisite: () => ({ available: false }) }); + assert.equal(blocked.pairs[0].status, 'verification_failed'); + assert.equal(blocked.pairs[0].reason, 'runtime_prerequisite_unavailable'); + const permitted = planCatalogReconciliation({ catalog, publicSourceRoot: sourceRoot, harnesses: harnesses({ codex }), controlRoot: control, verifyRuntimePrerequisite: () => true }); + applyCatalogReconciliation(permitted, { freshDiscovery: () => ({ fresh: true, tuples: [] }) }); + assert.equal(validateReceipt(readReceipt(codex, 'public-fixture')).status, 'verification_failed'); + } finally { for (const dir of [control, sourceRoot, codex]) fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('an inactive harness records activation_pending without claiming discovery', () => { + const control = temp('jarvos-activation-control-'); const sourceRoot = temp('jarvos-activation-source-'); const codex = temp('jarvos-activation-codex-'); + try { + copyFixture(PUBLIC_FIXTURE, path.join(sourceRoot, 'public-fixture')); + const effective = buildPublicCatalogFrom(sourceRoot); + const plan = planCatalogReconciliation({ catalog: effective.catalog, publicSourceRoot: sourceRoot, harnesses: harnesses({ codex }), controlRoot: control }); + applyCatalogReconciliation(plan, { activationReceipt: () => ({ active: false, dependency: 'managed_harness_activation' }) }); + const receipt = validateReceipt(readReceipt(codex, 'public-fixture')); + assert.equal(receipt.status, 'activation_pending'); + assert.equal(receipt.discovery.activationDependency, 'managed_harness_activation'); + } finally { for (const dir of [control, sourceRoot, codex]) fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('root-scoped desired state retires only clean receipt-owned orphan wrappers', () => { + const control = temp('jarvos-orphan-control-'); const sourceRoot = temp('jarvos-orphan-source-'); const codex = temp('jarvos-orphan-codex-'); + try { + copyFixture(PUBLIC_FIXTURE, path.join(sourceRoot, 'public-fixture')); + const tree = computeBundleTree(path.join(sourceRoot, 'public-fixture'), { allowlist: ['SKILL.md', 'scripts/**', 'assets/**'] }); + const catalog = composeEffectiveCatalog({ publicCatalog: { schemaVersion: CATALOG_SCHEMA_VERSION, entries: [ + { id: 'grilling', allowedHarnesses: ['codex'], bundle: { root: 'public-fixture', allowlist: ['SKILL.md', 'scripts/**', 'assets/**'], treeDigest: tree.treeDigest } }, + { id: 'grill-me', allowedHarnesses: ['codex'], skillDependencies: ['grilling'], bundle: { root: 'public-fixture', allowlist: ['SKILL.md', 'scripts/**', 'assets/**'], treeDigest: tree.treeDigest } }, + { id: 'orphan-wrapper', allowedHarnesses: ['codex'], bundle: { root: 'public-fixture', allowlist: ['SKILL.md', 'scripts/**', 'assets/**'], treeDigest: tree.treeDigest } }, + ] }, localOverlay: { schemaVersion: OVERLAY_SCHEMA_VERSION, entries: [] } }).catalog; + applyCatalogReconciliation(planCatalogReconciliation({ catalog, publicSourceRoot: sourceRoot, harnesses: harnesses({ codex }), controlRoot: control })); + const plan = planCatalogReconciliation({ catalog, publicSourceRoot: sourceRoot, harnesses: harnesses({ codex }), controlRoot: control, desiredSkills: { codex: ['grill-me'] } }); + assert.equal(plan.pairs.find((pair) => pair.id === 'orphan-wrapper').action, 'retire'); + assert.equal(plan.pairs.find((pair) => pair.id === 'grilling').action, 'preserve'); + applyCatalogReconciliation(plan); + assert.equal(fs.existsSync(path.join(codex, 'orphan-wrapper')), false); + assert.equal(fs.existsSync(path.join(codex, 'grilling')), true); + } finally { for (const dir of [control, sourceRoot, codex]) fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('dependency closure failure preserves existing managed targets', () => { + const control = temp('jarvos-closure-failure-control-'); + const sourceRoot = temp('jarvos-closure-failure-source-'); + const codex = temp('jarvos-closure-failure-codex-'); + try { + copyFixture(PUBLIC_FIXTURE, path.join(sourceRoot, 'public-fixture')); + const tree = computeBundleTree(path.join(sourceRoot, 'public-fixture'), { allowlist: ['SKILL.md', 'scripts/**', 'assets/**'] }); + const prior = composeEffectiveCatalog({ + publicCatalog: { schemaVersion: CATALOG_SCHEMA_VERSION, entries: [{ + id: 'grill-me', + allowedHarnesses: ['codex'], + bundle: { root: 'public-fixture', allowlist: ['SKILL.md', 'scripts/**', 'assets/**'], treeDigest: tree.treeDigest }, + }] }, + localOverlay: { schemaVersion: OVERLAY_SCHEMA_VERSION, entries: [] }, + }).catalog; + const firstPlan = planCatalogReconciliation({ catalog: prior, publicSourceRoot: sourceRoot, harnesses: harnesses({ codex }), controlRoot: control, desiredSkills: { codex: ['grill-me'] } }); + const tuples = firstPlan.pairs.map((pair) => ({ id: pair.id, catalogRelease: pair.catalogRelease, treeDigest: pair.treeDigest })); + applyCatalogReconciliation(firstPlan, { freshDiscovery: () => ({ fresh: true, tuples }) }); + const target = path.join(codex, 'grill-me'); + assert.equal(fs.existsSync(target), true); + assert.equal(validateReceipt(readReceipt(codex, 'grill-me')).status, 'model_visible'); + + const broken = composeEffectiveCatalog({ + publicCatalog: { schemaVersion: CATALOG_SCHEMA_VERSION, entries: [{ + id: 'grill-me', + allowedHarnesses: ['codex'], + skillDependencies: ['grilling'], + bundle: { root: 'public-fixture', allowlist: ['SKILL.md', 'scripts/**', 'assets/**'], treeDigest: tree.treeDigest }, + }] }, + localOverlay: { schemaVersion: OVERLAY_SCHEMA_VERSION, entries: [] }, + }).catalog; + const blockedPlan = planCatalogReconciliation({ catalog: broken, publicSourceRoot: sourceRoot, harnesses: harnesses({ codex }), controlRoot: control, desiredSkills: { codex: ['grill-me'] } }); + assert.equal(blockedPlan.pairs[0].status, 'verification_failed'); + assert.equal(blockedPlan.pairs[0].dependencyComplete, false); + assert.equal(blockedPlan.pairs[0].action, 'preserve'); + applyCatalogReconciliation(blockedPlan); + assert.equal(fs.existsSync(target), true); + assert.equal(validateReceipt(readReceipt(codex, 'grill-me')).status, 'model_visible'); + } finally { for (const dir of [control, sourceRoot, codex]) fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test('missing, cyclic, excluded, and incompatible dependencies fail closed before projection', () => { + const cases = [ + { name: 'missing', dependencies: { 'grill-me': ['grilling'] } }, + { name: 'cycle', dependencies: { 'grill-me': ['grilling'], grilling: ['grill-me'] } }, + { name: 'excluded', dependencies: { 'grill-me': ['grilling'] }, excluded: ['grilling'] }, + { name: 'incompatible', dependencies: { 'grill-me': ['grilling'] }, grillingHarnesses: ['hermes'] }, + ]; + for (const fixture of cases) { + const control = temp(`jarvos-closure-${fixture.name}-control-`); + const sourceRoot = temp(`jarvos-closure-${fixture.name}-source-`); + const codex = temp(`jarvos-closure-${fixture.name}-codex-`); + try { + copyFixture(PUBLIC_FIXTURE, path.join(sourceRoot, 'public-fixture')); + const tree = computeBundleTree(path.join(sourceRoot, 'public-fixture'), { allowlist: ['SKILL.md', 'scripts/**', 'assets/**'] }); + const entries = [{ + id: 'grill-me', + allowedHarnesses: ['codex'], + skillDependencies: fixture.dependencies['grill-me'], + bundle: { root: 'public-fixture', allowlist: ['SKILL.md', 'scripts/**', 'assets/**'], treeDigest: tree.treeDigest }, + }]; + if (fixture.dependencies.grilling) entries.push({ + id: 'grilling', + allowedHarnesses: fixture.grillingHarnesses || ['codex'], + skillDependencies: fixture.dependencies.grilling, + bundle: { root: 'public-fixture', allowlist: ['SKILL.md', 'scripts/**', 'assets/**'], treeDigest: tree.treeDigest }, + }); + const catalog = composeEffectiveCatalog({ + publicCatalog: { schemaVersion: CATALOG_SCHEMA_VERSION, entries }, + localOverlay: { schemaVersion: OVERLAY_SCHEMA_VERSION, entries: [] }, + }).catalog; + const plan = planCatalogReconciliation({ + catalog, + publicSourceRoot: sourceRoot, + harnesses: harnesses({ codex }), + controlRoot: control, + desiredSkills: { codex: ['grill-me'] }, + excludedSkillIds: { codex: fixture.excluded || [] }, + }); + assert.equal(plan.pairs.length, 1, fixture.name); + assert.equal(plan.pairs[0].status, 'verification_failed', fixture.name); + assert.equal(plan.pairs[0].dependencyComplete, false, fixture.name); + assert.equal(plan.pairs[0].action, 'preserve', fixture.name); + const applied = applyCatalogReconciliation(plan); + assert.equal(applied.applied.some((item) => item.applied === true), false, fixture.name); + assert.equal(fs.existsSync(path.join(codex, 'grill-me')), false, fixture.name); + } finally { + for (const dir of [control, sourceRoot, codex]) fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + test('collision alias prefers canonical, then reviewer, then deterministic fallback', () => { assert.equal(resolveCollisionAlias({ canonicalId: 'transcribe' }).effectiveName, 'transcribe'); const candidates = safeAliasCandidates('transcribe', { occupiedNames: ['transcribe'] }); diff --git a/modules/jarvos-skills/test/scheduled-repair.test.js b/modules/jarvos-skills/test/scheduled-repair.test.js index ef548ccb..ddfc2047 100644 --- a/modules/jarvos-skills/test/scheduled-repair.test.js +++ b/modules/jarvos-skills/test/scheduled-repair.test.js @@ -1,6 +1,9 @@ 'use strict'; const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const test = require('node:test'); const { @@ -11,6 +14,7 @@ const { scheduledRepairNotification, runScheduledRepair, } = require('../src/scheduled-repair'); +const { defaultConfig, saveConfig } = require('../src'); function healthy(overrides = {}) { return { @@ -56,6 +60,124 @@ test('unsafe-source holds are durable, semantic, and quiet on repeats', () => { assert.doesNotMatch(`${first.statusMessage} ${JSON.stringify(first.event)}`, /unsafe_source|private-skill/); }); +test('one new owner decision becomes a plain-English question with a safe answer path', () => { + const notification = scheduledRepairNotification(healthy({ + status: { observedAt: '2026-08-16T12:30:00.000Z', counts: { skills: 1, actionable: 1 } }, + decisions: { + created: 1, + pending: 1, + items: [{ + id: 'decision-0123456789abcdef01234567', + decisionReference: 'AbCdEfGhIjKlMnOpQrStUvWx', + skill: 'newsletter-generator', + revision: 1, + reason: 'needs_owner_input', + options: ['share', 'keep-local', 'exclude', 'details'], + }], + migration: null, + }, + })); + assert.equal(notification.disposition, 'direct-notification'); + assert.match(notification.output, /found the newsletter-generator skill/); + assert.match(notification.output, /did not share it because it needs your approval/); + assert.match(notification.output, /Reply “share”/); + assert.match(notification.output, /reply “keep local”/); + assert.match(notification.output, /Nothing changed/); + assert.doesNotMatch(notification.output, /needs_owner_input|SKILL\.md|\//); + assert.equal(notification.event.eventReference, notification.event.decisionReference); +}); + +test('scheduled repair claims the write-ahead attempt before emitting an owner question', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-scheduled-claim-')); + const configPath = path.join(root, 'config.json'); + try { + const decision = { + id: 'decision-0123456789abcdef01234567', + decisionReference: 'AbCdEfGhIjKlMnOpQrStUvWx', + skill: 'newsletter-generator', + revision: 1, + reason: 'needs_owner_input', + options: ['share', 'keep-local', 'exclude', 'details'], + }; + const config = defaultConfig(); + config.controlRoot = root; + config.publicCatalogPath = path.join(root, 'public-catalog.json'); + config.localOverlayPath = path.join(root, 'local-overlay.json'); + saveConfig(config, configPath); + let claimInput; + const run = runScheduledRepair({ + configPath, + now: '2026-08-16T16:00:00.000Z', + repair: () => healthy({ decisions: { created: 1, pending: 1, pendingItems: [decision], items: [decision], migration: null } }), + claimDelivery: (input) => { + claimInput = input; + return { decisionId: decision.id, decisionReference: decision.decisionReference, revision: 1, attemptId: 'attempt-AbCdEfGhIjKlMnOpQrStUvWx', kind: 'initial' }; + }, + }); + assert.equal(claimInput.decisionId, decision.id); + assert.equal(run.notification.event.deliveryAttemptKind, 'initial'); + assert.equal(run.notification.event.deliveryAttemptId, 'attempt-AbCdEfGhIjKlMnOpQrStUvWx'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('a single older pending decision retry is rendered with its new delivery attempt', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-scheduled-retry-')); + const configPath = path.join(root, 'config.json'); + try { + const decision = { + id: 'decision-abcdef0123456789abcdef01', + decisionReference: 'QrStUvWxYz0123456789abcd', + skill: 'newsletter-generator', + revision: 1, + reason: 'needs_owner_input', + options: ['share', 'keep-local', 'exclude', 'details'], + }; + const config = defaultConfig(); + config.controlRoot = root; + config.publicCatalogPath = path.join(root, 'public-catalog.json'); + config.localOverlayPath = path.join(root, 'local-overlay.json'); + saveConfig(config, configPath); + const run = runScheduledRepair({ + configPath, + now: '2026-08-17T16:00:00.000Z', + repair: () => healthy({ decisions: { created: 0, pending: 1, pendingItems: [decision], items: [], migration: null } }), + claimDelivery: () => ({ + decisionId: decision.id, + decisionReference: decision.decisionReference, + revision: 1, + attemptId: 'attempt-retry-QrStUvWxYz', + kind: 'fallback', + }), + }); + assert.equal(run.notification.event.code, 'skill-owner-decision'); + assert.equal(run.notification.event.deliveryAttemptKind, 'fallback'); + assert.equal(run.notification.event.deliveryAttemptId, 'attempt-retry-QrStUvWxYz'); + assert.match(run.notification.output, /found the newsletter-generator skill/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('legacy migration produces one understandable batch summary and stays quiet on replay', () => { + const result = healthy({ + decisions: { + created: 0, + pending: 2, + items: [], + migration: { migrated: true, replay: false, reference: 'batch-0123456789abcdef01234567', pendingCount: 2, migratedCount: 2 }, + }, + }); + const notification = scheduledRepairNotification(result); + assert.equal(notification.disposition, 'direct-notification'); + assert.match(notification.output, /2 skills that still need your decision/); + assert.match(notification.output, /left them unchanged/); + assert.doesNotMatch(notification.output, /batch-|needs_owner_input|receipt|\//); + const replay = scheduledRepairNotification(healthy({ decisions: { created: 0, pending: 2, items: [], migration: { migrated: false, replay: true, reference: 'batch-0123456789abcdef01234567', pendingCount: 2, migratedCount: 2 } } })); + assert.equal(replay.output, 'NO_REPLY'); +}); + test('missing runner receipt produces an opaque owner-action recovery message', () => { const message = scheduledRepairMessage({ ok: false, diff --git a/modules/jarvos-skills/test/skill-assessment.test.js b/modules/jarvos-skills/test/skill-assessment.test.js index 77697eaf..a85ac866 100644 --- a/modules/jarvos-skills/test/skill-assessment.test.js +++ b/modules/jarvos-skills/test/skill-assessment.test.js @@ -147,6 +147,7 @@ function assessObserved(configPath, { complete, autoAdmit = true, reviewer = null, + ownerApprovedSkills = null, persist = true, } = {}) { const observed = observeInventory({ configPath, persist }); @@ -170,6 +171,7 @@ function assessObserved(configPath, { harnessRoots, publicCatalog: null, localOverlay: null, + ownerApprovedSkills, reviewer, complete: complete === undefined ? observed.complete === true : complete, autoAdmit, @@ -418,6 +420,30 @@ test('owner exclusion blocks without deleting observation', () => { assert.equal((assessment.admissions || []).length, 0); }); +test('keep-local decisions preserve their distinct owner reason in the inventory overlay', () => { + const root = temp('jarvos-keep-local-overlay-'); + writeSkill(path.join(root, 'keep-local'), { name: 'keep-local' }); + const { configPath, control } = seedConfig({ roots: { codex: root }, trustClass: 'markdown-only' }); + const layout = ensureInventoryStateLayout({ + controlRoot: control, + inventory: loadConfig(configPath).config.inventory, + }); + fs.writeFileSync(layout.exclusionOverlayPath, `${JSON.stringify({ + schemaVersion: 'jarvos.skill-exclusions/v1', + entries: [{ + logicalId: 'keep-local', + reasonCode: 'owner_keep_local', + excludedAt: '2026-08-15T12:00:00.000Z', + }], + }, null, 2)}\n`, { mode: 0o600 }); + + const { assessment } = assessObserved(configPath); + const skill = assessment.document.skills.find((item) => item.logicalId === 'keep-local'); + assert.equal(skill.disposition.kind, 'blocked'); + assert.equal(skill.disposition.reasonCode, 'owner_keep_local'); + assert.equal((assessment.admissions || []).length, 0); +}); + test('exclude immediately retires only the generated overlay entry', () => { const root = temp('jarvos-exclude-retire-'); writeSkill(path.join(root, 'generated-skill'), { name: 'generated-skill' }); @@ -1129,6 +1155,26 @@ test('egress + scripts fails closed to needs_input', () => { assert.equal(skill.disposition.reasonCode, 'needs_owner_input'); }); +test('an owner-approved share admits the same network skill digest on replay', () => { + const root = temp('jarvos-approved-share-'); + writeSkill(path.join(root, 'net-skill'), { + name: 'net-skill', + scripts: true, + egress: true, + }); + const { configPath } = seedConfig({ roots: { codex: root }, trustClass: 'portable-bundles' }); + const held = assessObserved(configPath); + const heldSkill = held.assessment.document.skills.find((item) => item.logicalId === 'net-skill'); + assert.equal(heldSkill.disposition.reasonCode, 'needs_owner_input'); + + const approved = assessObserved(configPath, { + ownerApprovedSkills: new Map([['net-skill', { treeDigest: heldSkill.treeDigest }]]), + }); + const admitted = approved.assessment.document.skills.find((item) => item.logicalId === 'net-skill'); + assert.equal(admitted.disposition.kind, 'shared'); + assert.equal(approved.assessment.admissions.some((item) => item.logicalId === 'net-skill'), true); +}); + test('changed source updates even when destinations already have receipts', () => { const root = temp('jarvos-update-'); const bundle = writeSkill(path.join(root, 'update-skill'), { name: 'update-skill', body: 'v1\n' });