diff --git a/docs/runbooks/shared-skill-distribution.md b/docs/runbooks/shared-skill-distribution.md index 06a2145c..b890da03 100644 --- a/docs/runbooks/shared-skill-distribution.md +++ b/docs/runbooks/shared-skill-distribution.md @@ -48,11 +48,20 @@ The full scan and mutation lease are authoritative. Healthy recurring runs are quiet and write no durable state. A new actionable transition gets one redacted notice; a recovery gets one redacted recovery notice. -Use `jarvos-skills-scheduled-repair` when a scheduler delivers stdout. It emits -exactly `NO_REPLY` for a healthy replay and only emits a count-only message for -new attention, recovery, repair, or safe failure. Pass `--announce-convergence` -once through the configured delivery route after activation, then remove that -flag so subsequent healthy runs remain quiet. +Use `jarvos-skills-scheduled-repair` when a scheduler delivers stdout. It projects +each outcome through the public operator-notification contract: + +- Healthy replays, safe automatic holds (for example source safety refusals), + successful automatic repairs, and automatic resolutions emit exactly `NO_REPLY`. +- A concrete owner decision or failed recovery emits one plain-English direct + message that states what happened, what jarvOS did, the decision needed, the + next automatic step, and an opaque event reference. +- Raw reason codes, skill identities, paths, and stack text never appear in + human output. They remain on the shared-skills status/explain surface with + first-seen time and occurrence count. + +Pass `--announce-convergence` once through the configured delivery route after +activation, then remove that flag so subsequent healthy runs remain quiet. For an exact-path proof, bind each higher-precedence project or workspace root as an absolute `scopeRoots` path and set `scopeRootsComplete: true` in the local diff --git a/modules/jarvos-runtime-kit/README.md b/modules/jarvos-runtime-kit/README.md index 1afefedc..eb2fdf52 100644 --- a/modules/jarvos-runtime-kit/README.md +++ b/modules/jarvos-runtime-kit/README.md @@ -6,6 +6,23 @@ Runtime adapters should stay thin. Shared jarvOS capabilities live in `@jarvos/agent-context`; adapter directories translate those capabilities into a host runtime's native surfaces such as MCP, hooks, skills, or desktop config. +## Operator notifications (unstable) + +`@jarvos/runtime-kit` also exports an unstable 0.x, transport-neutral operator +notification contract. Producers submit a versioned semantic event; the contract +validates reviewed fields and deterministically returns either plain-English +text or `NO_REPLY`. It never renders caller-supplied diagnostic prose. + +Use `evaluateOperatorNotification(event)` when a host needs the attention +policy, durable-status text, and dedupe identity. Use +`renderOperatorNotification(event)` when it only needs the direct output. +Action-required events include an opaque event reference; detailed codes, +paths, commits, receipts, and process output remain in owner-only evidence. +Safe holds are durable status, while routine safe repairs and resolutions are +quiet. Release-state events separately name published, approval-ready, and +future versions; stale or unknown observations use qualified wording rather +than claiming current publication or review readiness. + ## Commands ```bash diff --git a/modules/jarvos-runtime-kit/src/index.js b/modules/jarvos-runtime-kit/src/index.js index 8dad5515..a1bc4ae7 100644 --- a/modules/jarvos-runtime-kit/src/index.js +++ b/modules/jarvos-runtime-kit/src/index.js @@ -11,6 +11,7 @@ const stewardshipAdapter = require('./stewardship-adapter.js'); const stewardshipBootstrap = require('./stewardship-bootstrap.js'); const openclawPluginPersistence = require('./openclaw-plugin-persistence.js'); const capabilityDescriptor = require('./capability-descriptor.js'); +const operatorNotification = require('./operator-notification.js'); const DEFAULT_AGENT_CONTEXT_MCP = 'modules/jarvos-agent-context/scripts/jarvos-mcp.js'; const REQUIRED_MCP_TOOL = 'jarvos_hydrate'; @@ -881,6 +882,7 @@ module.exports = { ...stewardshipBootstrap, ...openclawPluginPersistence, ...capabilityDescriptor, + ...operatorNotification, DEFAULT_AGENT_CONTEXT_MCP, HYDRATION_MODES, REQUIRED_MCP_TOOL, diff --git a/modules/jarvos-runtime-kit/src/operator-notification.js b/modules/jarvos-runtime-kit/src/operator-notification.js new file mode 100644 index 00000000..fc2bdba7 --- /dev/null +++ b/modules/jarvos-runtime-kit/src/operator-notification.js @@ -0,0 +1,180 @@ +'use strict'; + +const crypto = require('crypto'); + +// This is intentionally an unstable 0.x contract. Producers must retain their +// detailed diagnostics privately and send this module only reviewed semantics. +const OPERATOR_NOTIFICATION_SCHEMA_VERSION = 'jarvos-operator-notification/v1'; +const NO_REPLY = 'NO_REPLY'; +const AUDIENCES = new Set(['operator']); +const SEVERITIES = new Set(['info', 'warning', 'error', 'security']); +const AUTOMATION_OUTCOMES = new Set(['none', 'safe-hold', 'repaired', 'resolved', 'failed']); +const FRESHNESS_STATES = new Set(['current', 'stale', 'unknown']); +const ACTIONS = new Set(['none', 'review-release', 'choose-recovery', 'review-safety-hold']); +const NEXT_STATES = new Set(['none', 'continue-monitoring', 'wait-for-fresh-observation', 'resume-after-review']); +const EVENT_CODES = new Set(['release-state', 'safety-hold', 'recovery-failed', 'repair-complete', 'resolution-complete']); +const EVENT_FIELDS = new Set([ + 'schemaVersion', 'code', 'audience', 'severity', 'automationOutcome', + 'actionRequired', 'action', 'nextState', 'eventReference', 'dedupeKey', + 'observedAt', 'freshness', 'privateDetailReference', 'release', +]); + +const ACTION_TEXT = { + 'review-release': 'Review the proposed release before it can publish.', + 'choose-recovery': 'Choose how jarvOS should proceed.', + 'review-safety-hold': 'Review the held change and choose whether to continue.', +}; +const NEXT_TEXT = { + 'continue-monitoring': 'jarvOS will continue monitoring safely.', + 'wait-for-fresh-observation': 'jarvOS will keep monitoring for a fresh observation.', + 'resume-after-review': 'after your review, jarvOS will continue the release process.', +}; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isSemanticVersion(value) { + return typeof value === 'string' && /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value); +} + +function isBoundedIdentifier(value, { min = 1, max = 120 } = {}) { + return typeof value === 'string' && value.length >= min && value.length <= max + && /^[a-z][a-z0-9-]*$/.test(value); +} + +function isOpaqueReference(value) { + // A base64url token of this length has enough entropy to be non-guessable + // when minted by the owner-authorized context. Its contents are never shown + // other than as the correlation reference for an action-required message. + return typeof value === 'string' && /^[A-Za-z0-9_-]{22,128}$/.test(value); +} + +function isIsoTime(value) { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) + && !Number.isNaN(Date.parse(value)); +} + +function validateRelease(release, errors) { + if (!isObject(release)) { + errors.push('release must be an object'); + return; + } + const allowed = new Set(['publishedVersion', 'approvalReadyVersion', 'futureVersion']); + for (const key of Object.keys(release)) if (!allowed.has(key)) errors.push(`release has unknown field: ${key}`); + for (const field of ['publishedVersion', 'approvalReadyVersion', 'futureVersion']) { + if (!isSemanticVersion(release[field])) errors.push(`release.${field} must be a semantic version`); + } +} + +function validateOperatorNotificationEvent(event) { + const errors = []; + if (!isObject(event)) return { ok: false, errors: ['operator notification event must be an object'] }; + for (const key of Object.keys(event)) if (!EVENT_FIELDS.has(key)) errors.push(`operator notification event has unknown field: ${key}`); + if (event.schemaVersion !== OPERATOR_NOTIFICATION_SCHEMA_VERSION) errors.push(`event.schemaVersion must be ${OPERATOR_NOTIFICATION_SCHEMA_VERSION}`); + if (!isBoundedIdentifier(event.code)) errors.push('event.code must be a bounded stable identifier'); + if (!AUDIENCES.has(event.audience)) errors.push('event.audience must be operator'); + if (!SEVERITIES.has(event.severity)) errors.push('event.severity is invalid'); + if (!AUTOMATION_OUTCOMES.has(event.automationOutcome)) errors.push('event.automationOutcome is invalid'); + if (typeof event.actionRequired !== 'boolean') errors.push('event.actionRequired must be boolean'); + if (!ACTIONS.has(event.action)) errors.push('event.action is invalid'); + if (!NEXT_STATES.has(event.nextState)) errors.push('event.nextState is invalid'); + if (event.actionRequired && event.action === 'none') errors.push('action-required events need a reviewed action'); + if (!event.actionRequired && event.action !== 'none') errors.push('non-action events must use action none'); + if (!isOpaqueReference(event.eventReference)) errors.push('event.eventReference must be an opaque reference'); + if (!isBoundedIdentifier(event.dedupeKey, { max: 180 })) errors.push('event.dedupeKey must be a bounded stable identifier'); + if (!isIsoTime(event.observedAt)) errors.push('event.observedAt must be an ISO-8601 UTC timestamp'); + if (!FRESHNESS_STATES.has(event.freshness)) errors.push('event.freshness is invalid'); + if (event.privateDetailReference !== undefined && !isOpaqueReference(event.privateDetailReference)) errors.push('event.privateDetailReference must be an opaque reference'); + if (event.code === 'release-state') validateRelease(event.release, errors); + if (event.code !== 'release-state' && event.release !== undefined) errors.push('release is only valid for release-state events'); + if (event.code === 'release-state' && event.freshness !== 'current' && event.actionRequired) errors.push('stale or unknown release evidence cannot request release review'); + return { ok: errors.length === 0, errors, value: event }; +} + +function assertOperatorNotificationEvent(event) { + const validation = validateOperatorNotificationEvent(event); + if (!validation.ok) throw new TypeError(validation.errors.join('; ')); + return event; +} + +function notificationDedupeIdentity(event) { + assertOperatorNotificationEvent(event); + return crypto.createHash('sha256') + .update(`${event.schemaVersion}\0${event.code}\0${event.dedupeKey}\0${event.actionRequired}\0${event.freshness}`) + .digest('hex'); +} + +function releaseMessage(event) { + const { publishedVersion, approvalReadyVersion, futureVersion } = event.release; + if (event.freshness === 'current') { + return `jarvOS ${publishedVersion} is currently published. A proposed ${approvalReadyVersion} release has passed checks and is ready for Andrew's review; nothing will publish automatically. The separate ${futureVersion} milestone remains future work.`; + } + const qualifier = event.freshness === 'stale' ? 'stale' : 'unconfirmed'; + return `jarvOS last observed ${publishedVersion} as published, but that observation is ${qualifier}. The proposed ${approvalReadyVersion} release needs a fresh check before it can be reviewed. The separate ${futureVersion} milestone remains future work.`; +} + +function eventMessage(event) { + if (event.code === 'release-state') return releaseMessage(event); + if (event.code === 'safety-hold') return 'jarvOS paused an unsafe change and left the existing setup unchanged.'; + if (event.code === 'recovery-failed') return 'jarvOS could not complete a safe recovery and preserved the existing state.'; + if (event.code === 'repair-complete') return 'jarvOS completed a safe repair.'; + if (event.code === 'resolution-complete') return 'jarvOS resolved the condition safely.'; + return 'jarvOS needs a reviewed operator decision and preserved the existing state.'; +} + +function renderMessage(event) { + if (!EVENT_CODES.has(event.code)) { + return `jarvOS needs an operator decision and preserved the existing state. Action required: Review the condition before jarvOS continues. Next: jarvOS will wait for your direction. Reference: ${event.eventReference}.`; + } + const parts = [eventMessage(event)]; + if (event.actionRequired) { + parts.push(`Action required: ${ACTION_TEXT[event.action]}`); + parts.push(`Next: ${NEXT_TEXT[event.nextState] || 'jarvOS will wait for your direction.'}`); + parts.push(`Reference: ${event.eventReference}.`); + } else { + parts.push('No action is needed from you.'); + if (event.nextState !== 'none') parts.push(`Next: ${NEXT_TEXT[event.nextState]}`); + } + return parts.join(' '); +} + +function evaluateOperatorNotification(event) { + assertOperatorNotificationEvent(event); + const knownCode = EVENT_CODES.has(event.code); + const direct = !knownCode || event.actionRequired; + const message = renderMessage(event); + if (direct) { + return { + disposition: 'direct-notification', + output: message, + statusMessage: null, + dedupeIdentity: notificationDedupeIdentity(event), + }; + } + const durableStatus = event.automationOutcome === 'safe-hold' + || (event.code === 'release-state' && event.freshness !== 'current'); + return { + disposition: durableStatus ? 'durable-status' : 'quiet', + output: NO_REPLY, + statusMessage: durableStatus ? message : null, + dedupeIdentity: notificationDedupeIdentity(event), + }; +} + +function renderOperatorNotification(event) { + return evaluateOperatorNotification(event).output; +} + +module.exports = { + ACTIONS, + AUTOMATION_OUTCOMES, + FRESHNESS_STATES, + NO_REPLY, + OPERATOR_NOTIFICATION_SCHEMA_VERSION, + assertOperatorNotificationEvent, + evaluateOperatorNotification, + notificationDedupeIdentity, + renderOperatorNotification, + validateOperatorNotificationEvent, +}; diff --git a/modules/jarvos-runtime-kit/test/operator-notification.test.js b/modules/jarvos-runtime-kit/test/operator-notification.test.js new file mode 100644 index 00000000..21a17dfb --- /dev/null +++ b/modules/jarvos-runtime-kit/test/operator-notification.test.js @@ -0,0 +1,146 @@ +'use strict'; + +const assert = require('assert'); +const test = require('node:test'); +const { + NO_REPLY, + OPERATOR_NOTIFICATION_SCHEMA_VERSION, + evaluateOperatorNotification, + notificationDedupeIdentity, + renderOperatorNotification, + validateOperatorNotificationEvent, +} = require('../src'); + +const EVENT_REFERENCE = 'uTQf8DG1p9Ck5Lm3Nw2RzAqB'; + +function event(overrides = {}) { + return { + schemaVersion: OPERATOR_NOTIFICATION_SCHEMA_VERSION, + code: 'recovery-failed', + audience: 'operator', + severity: 'error', + automationOutcome: 'failed', + actionRequired: true, + action: 'choose-recovery', + nextState: 'continue-monitoring', + eventReference: EVENT_REFERENCE, + dedupeKey: 'recovery-window-42', + observedAt: '2026-08-16T12:30:00Z', + freshness: 'current', + privateDetailReference: 'jJ3xbPq7YvmT0n6eC1fKrS9D', + ...overrides, + }; +} + +test('action-required events answer what happened, what jarvOS did, the action, and next step', () => { + const output = renderOperatorNotification(event()); + assert.equal(output, "jarvOS could not complete a safe recovery and preserved the existing state. Action required: Choose how jarvOS should proceed. Next: jarvOS will continue monitoring safely. Reference: uTQf8DG1p9Ck5Lm3Nw2RzAqB."); +}); + +test('safe holds remain durable status with first-seen and occurrence information outside the renderer', () => { + const result = evaluateOperatorNotification(event({ + code: 'safety-hold', + severity: 'warning', + automationOutcome: 'safe-hold', + actionRequired: false, + action: 'none', + nextState: 'continue-monitoring', + })); + assert.equal(result.output, NO_REPLY); + assert.equal(result.disposition, 'durable-status'); + assert.equal(result.statusMessage, 'jarvOS paused an unsafe change and left the existing setup unchanged. No action is needed from you. Next: jarvOS will continue monitoring safely.'); +}); + +test('routine safe repairs and resolutions stay quiet', () => { + const result = evaluateOperatorNotification(event({ + code: 'repair-complete', + severity: 'info', + automationOutcome: 'repaired', + actionRequired: false, + action: 'none', + nextState: 'none', + })); + assert.equal(result.output, NO_REPLY); + assert.equal(result.disposition, 'quiet'); + assert.equal(result.statusMessage, null); +}); + +test('validation rejects free prose and private or raw diagnostic fields before rendering', () => { + for (const unsafe of [ + { diagnostic: 'unsafe_source at /Users/andrew/private' }, + { action: 'call the private skill immediately' }, + { nextState: 'read receipt-123 and retry' }, + { sourceSha: '8cb3909' }, + { stack: 'Error: failed\n at private.js:1:1' }, + ]) { + const result = validateOperatorNotificationEvent(event(unsafe)); + assert.equal(result.ok, false, JSON.stringify(result.errors)); + } +}); + +test('unknown codes render reviewed generic action-required text without the code', () => { + const output = renderOperatorNotification(event({ code: 'new-private-machine-code', actionRequired: false, action: 'none' })); + assert.equal(output, 'jarvOS needs an operator decision and preserved the existing state. Action required: Review the condition before jarvOS continues. Next: jarvOS will wait for your direction. Reference: uTQf8DG1p9Ck5Lm3Nw2RzAqB.'); + assert.equal(output.includes('new-private-machine-code'), false); +}); + +test('evaluation and dedupe identity are deterministic', () => { + const input = event(); + assert.deepEqual(evaluateOperatorNotification(input), evaluateOperatorNotification(input)); + assert.equal(notificationDedupeIdentity(input), notificationDedupeIdentity(input)); +}); + +test('current release evidence distinguishes published, approval-ready, and future lanes', () => { + const output = renderOperatorNotification(event({ + code: 'release-state', + severity: 'info', + automationOutcome: 'none', + actionRequired: true, + action: 'review-release', + nextState: 'resume-after-review', + release: { + publishedVersion: '0.7.0', + approvalReadyVersion: '0.8.0', + futureVersion: 'v1.0.0', + }, + })); + assert.equal(output, "jarvOS 0.7.0 is currently published. A proposed 0.8.0 release has passed checks and is ready for Andrew's review; nothing will publish automatically. The separate v1.0.0 milestone remains future work. Action required: Review the proposed release before it can publish. Next: after your review, jarvOS will continue the release process. Reference: uTQf8DG1p9Ck5Lm3Nw2RzAqB."); + assert.equal(output.includes('8cb3909'), false); +}); + +test('stale or unknown release evidence uses qualified wording and stays quiet when no action is required', () => { + for (const freshness of ['stale', 'unknown']) { + const result = evaluateOperatorNotification(event({ + code: 'release-state', + severity: 'warning', + automationOutcome: 'none', + actionRequired: false, + action: 'none', + nextState: 'wait-for-fresh-observation', + freshness, + release: { + publishedVersion: '0.7.0', + approvalReadyVersion: '0.8.0', + futureVersion: 'v1.0.0', + }, + })); + assert.equal(result.output, NO_REPLY); + assert.equal(result.disposition, 'durable-status'); + assert.match(result.statusMessage, /last observed 0\.7\.0 as published/); + assert.doesNotMatch(result.statusMessage, /currently published|ready for Andrew's review/); + assert.equal(renderOperatorNotification(event({ + code: 'release-state', severity: 'warning', automationOutcome: 'safe-hold', actionRequired: false, + action: 'none', nextState: 'wait-for-fresh-observation', freshness, + release: { publishedVersion: '0.7.0', approvalReadyVersion: '0.8.0', futureVersion: 'v1.0.0' }, + })), NO_REPLY); + } +}); + +test('stale release evidence cannot request approval', () => { + const result = validateOperatorNotificationEvent(event({ + code: 'release-state', action: 'review-release', freshness: 'stale', + release: { publishedVersion: '0.7.0', approvalReadyVersion: '0.8.0', futureVersion: 'v1.0.0' }, + })); + assert.equal(result.ok, false); + assert.match(result.errors.join('\n'), /cannot request release review/); +}); diff --git a/modules/jarvos-skills/README.md b/modules/jarvos-skills/README.md index a76bf1cf..fe965a18 100644 --- a/modules/jarvos-skills/README.md +++ b/modules/jarvos-skills/README.md @@ -197,10 +197,15 @@ never auto-enable live harness gates. Claude interactive proof remains `autonomous-repair` is the scheduler command. It stays inert until inventory is enabled in owner-local configuration and never enables the scheduler itself. -`jarvos-skills-scheduled-repair` wraps that command for delivery-aware schedulers: -healthy repeats print `NO_REPLY`, while new attention, repair, or failure prints -one redacted count-only message. `--announce-convergence` is a one-run activation -option, not a recurring schedule flag. +`jarvos-skills-scheduled-repair` wraps that command for delivery-aware schedulers +and projects outcomes through the public `@jarvos/runtime-kit` operator-notification +contract. Healthy repeats, safe automatic holds (including `unsafe_source`), +successful repairs, and automatic resolutions print `NO_REPLY`. Only a concrete +owner decision or failed recovery becomes a plain-English direct message with an +opaque event reference—never a raw reason code, skill id, path, or stack. +Reason codes remain on the shared-skills durable status/explain surface with +first-seen time and occurrence count. `--announce-convergence` is a one-run +activation option, not a recurring schedule flag. The public preflight is permanently read-only; first live convergence happens only through the installed, merged runtime. See the [architecture](../../docs/architecture/shared-skill-distribution.md) and diff --git a/modules/jarvos-skills/package.json b/modules/jarvos-skills/package.json index 8b69b0d0..767591d7 100644 --- a/modules/jarvos-skills/package.json +++ b/modules/jarvos-skills/package.json @@ -22,6 +22,9 @@ "scripts": { "test": "node --test test/*.test.js" }, + "dependencies": { + "@jarvos/runtime-kit": "0.1.0" + }, "engines": { "node": ">=18" } diff --git a/modules/jarvos-skills/src/attention.js b/modules/jarvos-skills/src/attention.js index 53dcd57d..f9e976c1 100644 --- a/modules/jarvos-skills/src/attention.js +++ b/modules/jarvos-skills/src/attention.js @@ -4,11 +4,14 @@ * Redacted, deduplicated attention state for autonomous inventory runs. * This module deliberately knows no notification transport: an installed * runtime may supply one, while the public package remains local-only. + * + * Reason codes remain in durable status for the shared-skills status/explain + * surface. Human-facing delivery is owned by the scheduled-repair notification + * contract projection and must never print raw codes. */ const crypto = require('node:crypto'); const fs = require('node:fs'); -const path = require('node:path'); const { atomicWriteJson } = require('./config'); const ATTENTION_SCHEMA_VERSION = 'jarvos.skill-attention/v1'; @@ -50,30 +53,113 @@ function loadAttention(filePath) { } } +/** + * Build the durable shared-skills status projection for quiet holds. + * Includes first-seen time and occurrence count; never a transport payload. + */ +function durableHoldStatus(active = [], { observedAt } = {}) { + const byReason = new Map(); + for (const item of active) { + const reason = typeof item?.reasonCode === 'string' ? item.reasonCode : 'needs_owner_input'; + const existing = byReason.get(reason) || { + reasonCode: reason, + occurrenceCount: 0, + firstSeenAt: item.firstSeenAt || observedAt || null, + fingerprints: [], + }; + existing.occurrenceCount += Number(item.occurrenceCount || 1); + if (item.firstSeenAt && (!existing.firstSeenAt || item.firstSeenAt < existing.firstSeenAt)) { + existing.firstSeenAt = item.firstSeenAt; + } + if (item.fingerprint) existing.fingerprints.push(item.fingerprint); + byReason.set(reason, existing); + } + return [...byReason.values()] + .map((entry) => ({ + reasonCode: entry.reasonCode, + occurrenceCount: entry.occurrenceCount, + firstSeenAt: entry.firstSeenAt, + // Fingerprints stay owner-only evidence for explain surfaces. + fingerprintCount: entry.fingerprints.length, + })) + .sort((left, right) => left.reasonCode.localeCompare(right.reasonCode)); +} + /** * Persist only meaningful transitions. Healthy replays are a strict no-op. * `deliver` is optional and receives the redacted transition only. + * + * Active items retain firstSeenAt + occurrenceCount so quiet safety holds stay + * readable on the shared-skills status surface without a Telegram interrupt. + * Occurrence count is the number of distinct active fingerprints per reason at + * write time; pure replays do not rewrite state. */ function reconcileAttention({ attentionPath, status, observedAt, deliver = null } = {}) { if (!attentionPath) throw new Error('attentionPath is required'); + const observed = observedAt || new Date().toISOString(); const prior = loadAttention(attentionPath); const current = redactedAttention(status); const before = new Map(prior.active.map((item) => [item.fingerprint, item])); const after = new Map(current.map((item) => [item.fingerprint, item])); - const raised = current.filter((item) => !before.has(item.fingerprint)); + + const nextActive = current.map((item) => { + const previous = before.get(item.fingerprint); + if (previous) { + return { + ...item, + firstSeenAt: previous.firstSeenAt || observed, + occurrenceCount: Number(previous.occurrenceCount || 1), + lastSeenAt: previous.lastSeenAt || previous.firstSeenAt || observed, + }; + } + return { + ...item, + firstSeenAt: observed, + occurrenceCount: 1, + lastSeenAt: observed, + }; + }); + + const raised = nextActive + .filter((item) => !before.has(item.fingerprint)) + .map((item) => ({ + logicalId: item.logicalId, + reasonCode: item.reasonCode, + attention: 'actionable', + fingerprint: item.fingerprint, + firstSeenAt: item.firstSeenAt, + occurrenceCount: item.occurrenceCount, + })); const resolved = prior.active.filter((item) => !after.has(item.fingerprint)).map((item) => ({ logicalId: item.logicalId, reasonCode: item.reasonCode, attention: 'resolved', fingerprint: item.fingerprint, + firstSeenAt: item.firstSeenAt || null, + occurrenceCount: Number(item.occurrenceCount || 1), })); const transitions = [...raised, ...resolved]; - if (transitions.length === 0) return { wrote: false, raised: [], resolved: [], delivery: [] }; + const durableStatus = durableHoldStatus( + transitions.length === 0 ? prior.active : nextActive, + { observedAt: observed }, + ); + + if (transitions.length === 0) { + return { + wrote: false, + raised: [], + resolved: [], + delivery: [], + durableStatus, + replay: true, + }; + } const next = { schemaVersion: ATTENTION_SCHEMA_VERSION, - updatedAt: observedAt || new Date().toISOString(), - active: current, + updatedAt: observed, + active: nextActive, + durableStatus: durableHoldStatus(nextActive, { observedAt: observed }), }; atomicWriteJson(attentionPath, next); const delivery = []; @@ -90,7 +176,19 @@ function reconcileAttention({ attentionPath, status, observedAt, deliver = null delivery.push({ fingerprint: item.fingerprint, status: 'pending_retry' }); } } - return { wrote: true, raised, resolved, delivery }; + return { + wrote: true, + raised, + resolved, + delivery, + durableStatus: next.durableStatus, + replay: false, + }; } -module.exports = { ATTENTION_SCHEMA_VERSION, redactedAttention, reconcileAttention }; +module.exports = { + ATTENTION_SCHEMA_VERSION, + redactedAttention, + reconcileAttention, + durableHoldStatus, +}; diff --git a/modules/jarvos-skills/src/scheduled-repair.js b/modules/jarvos-skills/src/scheduled-repair.js index 00cf610d..77f0210d 100644 --- a/modules/jarvos-skills/src/scheduled-repair.js +++ b/modules/jarvos-skills/src/scheduled-repair.js @@ -1,13 +1,68 @@ 'use strict'; +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); const { autonomousRepairOperator, statusOperator } = require('./operator'); +/** + * Load the public operator-notification contract with the monorepo source-tree + * fallback used by other jarvOS modules when the package is not installed. + */ +function loadOperatorNotification() { + try { + return require(require.resolve('@jarvos/runtime-kit', { + paths: [path.join(__dirname, '..'), process.cwd()], + })); + } catch { + const fallback = path.join(__dirname, '..', '..', 'jarvos-runtime-kit', 'src', 'index.js'); + if (fs.existsSync(fallback)) return require(fallback); + throw new Error('operator notification contract is unavailable'); + } +} + +const { + NO_REPLY, + OPERATOR_NOTIFICATION_SCHEMA_VERSION, + evaluateOperatorNotification, +} = loadOperatorNotification(); + +/** Reasons that stay quiet as durable safety holds (AE1). */ +const SAFE_HOLD_REASONS = new Set([ + 'unsafe_source', + 'privacy_restricted', + 'owner_excluded', + 'harness_native', + 'vendor_managed', + 'trust_class_insufficient', + 'capability_unsupported', + 'local_modification_preserved', + 'source_absent', + 'source_retired', + 'already_managed_receipt', + 'rule_proven_portable', + 'rule_proven_update', +]); + +/** Reasons that require a concrete owner decision (AE2). */ +const OWNER_DECISION_REASONS = new Set([ + 'needs_owner_input', + 'semantic_collision', + 'ambiguous_identity', + 'incomplete_observation', + 'review_required', +]); + +function normalizeReasonCode(value) { + return typeof value === 'string' && /^[a-z0-9_]{1,64}$/.test(value) + ? value + : 'needs_owner_input'; +} + function countByReason(items = []) { const counts = new Map(); for (const item of items) { - const reason = typeof item?.reasonCode === 'string' && /^[a-z0-9_]{1,64}$/.test(item.reasonCode) - ? item.reasonCode - : 'needs_owner_input'; + const reason = normalizeReasonCode(item?.reasonCode); counts.set(reason, (counts.get(reason) || 0) + 1); } return [...counts.entries()] @@ -17,53 +72,316 @@ function countByReason(items = []) { .join(', '); } -function scheduledRepairMessage(result, { announceConvergence = false, catalogStatus = null } = {}) { - if (!result?.ok) return 'jarvOS skill sync needs attention: scheduled repair did not complete.'; +/** + * Mint a stable opaque reference from private transition material. + * The token is base64url and never embeds the reason code or skill identity. + */ +function mintEventReference(seed) { + return crypto.createHash('sha256') + .update(String(seed || 'jarvos-skill-sync')) + .digest('base64url') + .slice(0, 32); +} + +function mintDedupeKey(parts) { + const raw = parts.filter(Boolean).join('-').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, ''); + const compact = raw.slice(0, 120) || 'skill-sync'; + if (/^[a-z][a-z0-9-]*$/.test(compact)) return compact; + return `skill-sync-${crypto.createHash('sha256').update(compact).digest('hex').slice(0, 16)}`; +} + +function baseEvent(overrides = {}) { + const observedAt = overrides.observedAt || new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); + return { + schemaVersion: OPERATOR_NOTIFICATION_SCHEMA_VERSION, + audience: 'operator', + observedAt, + freshness: 'current', + ...overrides, + }; +} + +function evaluate(event) { + return evaluateOperatorNotification(event); +} + +function repairedCount(result) { + if (result?.reconciliation?.repaired !== true) return 0; + if (!Array.isArray(result.reconciliation.applied)) return 0; + return result.reconciliation.applied.filter((item) => item?.applied !== false).length; +} + +function classifyRaised(raised = []) { + const owner = []; + const holds = []; + for (const item of raised) { + const reason = normalizeReasonCode(item?.reasonCode); + if (OWNER_DECISION_REASONS.has(reason) || !SAFE_HOLD_REASONS.has(reason)) { + // Unknown reason codes fail closed as owner decisions (never raw in output). + owner.push({ ...item, reasonCode: reason }); + } else { + holds.push({ ...item, reasonCode: reason }); + } + } + return { owner, holds }; +} + +/** + * Build durable-status material for quiet holds/resolutions/repairs. + * Reason codes stay here for the shared-skills status surface only. + */ +function buildDurableStatusSummary({ holds = [], resolved = [], repaired = 0, observedAt } = {}) { + const parts = []; + if (holds.length) { + parts.push({ + kind: 'safe-hold', + count: holds.length, + reasons: countByReason(holds), + firstSeenAt: observedAt || null, + occurrenceCount: holds.length, + }); + } + if (resolved.length) { + parts.push({ + kind: 'resolved', + count: resolved.length, + reasons: countByReason(resolved), + }); + } + if (repaired > 0) { + parts.push({ kind: 'repaired', count: repaired }); + } + return parts; +} + +function ownerDecisionMessage(result, raisedOwner, { observedAt } = {}) { + const seed = raisedOwner.map((item) => item.fingerprint || item.logicalId || item.reasonCode).join('|') + || `owner-${observedAt || 'now'}`; + const event = baseEvent({ + code: 'recovery-failed', + severity: 'error', + automationOutcome: 'failed', + actionRequired: true, + action: 'choose-recovery', + nextState: 'continue-monitoring', + eventReference: mintEventReference(seed), + dedupeKey: mintDedupeKey(['skill-sync-owner', seed.slice(0, 48)]), + privateDetailReference: mintEventReference(`private:${seed}`), + observedAt, + }); + // Prefer recovery-failed wording when the run itself failed closed; otherwise + // a raised owner decision is still an action-required recovery choice. + if (result?.ok === false) { + return evaluate(event); + } + // Concrete owner input without a failed automation still needs choose-recovery. + return evaluate(event); +} + +function incompleteInventoryMessage(result, { observedAt } = {}) { + const count = Number(result?.status?.counts?.actionable || 0); + const seed = `incomplete:${count}:${observedAt || ''}`; + return evaluate(baseEvent({ + code: 'recovery-failed', + severity: 'warning', + automationOutcome: 'safe-hold', + actionRequired: true, + action: 'choose-recovery', + nextState: 'continue-monitoring', + eventReference: mintEventReference(seed), + dedupeKey: mintDedupeKey(['skill-sync-incomplete', String(count)]), + privateDetailReference: mintEventReference(`private:${seed}`), + observedAt, + })); +} + +function failureMessage(result, { observedAt } = {}) { + const seed = `failed:${result?.reason || 'scheduled-repair'}:${observedAt || ''}`; + return evaluate(baseEvent({ + code: 'recovery-failed', + severity: 'error', + automationOutcome: 'failed', + actionRequired: true, + action: 'choose-recovery', + nextState: 'continue-monitoring', + eventReference: mintEventReference(seed), + dedupeKey: mintDedupeKey(['skill-sync-failed', result?.reason || 'unknown']), + privateDetailReference: mintEventReference(`private:${seed}`), + observedAt, + })); +} + +function quietHoldEvaluation({ observedAt } = {}) { + return evaluate(baseEvent({ + code: 'safety-hold', + severity: 'warning', + automationOutcome: 'safe-hold', + actionRequired: false, + action: 'none', + nextState: 'continue-monitoring', + eventReference: mintEventReference(`hold:${observedAt || 'now'}`), + dedupeKey: mintDedupeKey(['skill-sync-safe-hold']), + observedAt, + })); +} + +function quietRepairEvaluation({ observedAt } = {}) { + return evaluate(baseEvent({ + code: 'repair-complete', + severity: 'info', + automationOutcome: 'repaired', + actionRequired: false, + action: 'none', + nextState: 'none', + eventReference: mintEventReference(`repair:${observedAt || 'now'}`), + dedupeKey: mintDedupeKey(['skill-sync-repair']), + observedAt, + })); +} + +function quietResolutionEvaluation({ observedAt } = {}) { + return evaluate(baseEvent({ + code: 'resolution-complete', + severity: 'info', + automationOutcome: 'resolved', + actionRequired: false, + action: 'none', + nextState: 'none', + eventReference: mintEventReference(`resolved:${observedAt || 'now'}`), + dedupeKey: mintDedupeKey(['skill-sync-resolved']), + observedAt, + })); +} + +/** + * One-shot activation announcement. Still avoids raw codes and private ids. + * Uses plain English only; not a recurring interrupt path. + */ +function convergenceMessage(result, catalogStatus) { + const inventoryCount = Number(result?.status?.counts?.skills || 0); + const actionableCount = Number(result?.status?.counts?.actionable || 0); + const pairs = Array.isArray(catalogStatus?.pairs) ? catalogStatus.pairs : []; + const cleanPairs = pairs.filter((pair) => pair?.status === 'clean').length; + const parts = [ + `jarvOS skill sync is active: ${inventoryCount} skill${inventoryCount === 1 ? '' : 's'} inventoried`, + `${cleanPairs}/${pairs.length} managed harness projection${pairs.length === 1 ? '' : 's'} clean`, + ]; + parts.push(actionableCount + ? `${actionableCount} item${actionableCount === 1 ? '' : 's'} need review; future repeats stay quiet` + : 'nothing needs your attention; future healthy runs stay quiet'); + return `${parts.join('; ')}.`; +} + +/** + * Project a scheduled-repair result through the public notification contract. + * Direct human output never includes raw reason codes, skill ids, or paths. + */ +function scheduledRepairNotification(result, { + announceConvergence = false, + catalogStatus = null, + observedAt = null, +} = {}) { + const at = observedAt || new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); + const empty = { + message: NO_REPLY, + disposition: 'quiet', + statusMessage: null, + durableStatus: [], + evaluation: null, + }; + + if (!result?.ok) { + const evaluation = failureMessage(result, { observedAt: at }); + return { + message: evaluation.output, + disposition: evaluation.disposition, + statusMessage: evaluation.statusMessage, + durableStatus: [], + evaluation, + }; + } + if (result.ran === false) { - return result.reason === 'inventory_disabled' - ? 'jarvOS skill sync needs attention: machine-wide inventory is disabled.' - : 'jarvOS skill sync needs attention: the scheduled repair did not run.'; + const evaluation = failureMessage({ + ...result, + reason: result.reason || 'did_not_run', + }, { observedAt: at }); + return { + message: evaluation.output, + disposition: evaluation.disposition, + statusMessage: evaluation.statusMessage, + durableStatus: [], + evaluation, + }; } + if (result.mutationDenied === true) { - const count = Number(result.status?.counts?.actionable || 0); - return `jarvOS skill sync needs attention: the inventory was incomplete, so no files were changed${count ? `; ${count} item${count === 1 ? '' : 's'} require review` : ''}.`; + const evaluation = incompleteInventoryMessage(result, { observedAt: at }); + return { + message: evaluation.output, + disposition: evaluation.disposition, + statusMessage: evaluation.statusMessage, + durableStatus: buildDurableStatusSummary({ + holds: [{ reasonCode: 'incomplete_observation' }], + observedAt: at, + }), + evaluation, + }; + } + + if (announceConvergence) { + return { + message: convergenceMessage(result, catalogStatus), + disposition: 'direct-notification', + statusMessage: null, + durableStatus: [], + evaluation: null, + }; } const raised = Array.isArray(result.attention?.raised) ? result.attention.raised : []; const resolved = Array.isArray(result.attention?.resolved) ? result.attention.resolved : []; - const repaired = result.reconciliation?.repaired === true - ? (Array.isArray(result.reconciliation.applied) - ? result.reconciliation.applied.filter((item) => item?.applied !== false).length - : 0) - : 0; + const repaired = repairedCount(result); + const { owner, holds } = classifyRaised(raised); + const durableStatus = buildDurableStatusSummary({ + holds, + resolved, + repaired, + observedAt: at, + }); - if (announceConvergence) { - const inventoryCount = Number(result.status?.counts?.skills || 0); - const actionableCount = Number(result.status?.counts?.actionable || 0); - const pairs = Array.isArray(catalogStatus?.pairs) ? catalogStatus.pairs : []; - const cleanPairs = pairs.filter((pair) => pair?.status === 'clean').length; - const parts = [ - `jarvOS skill sync is active: ${inventoryCount} skill${inventoryCount === 1 ? '' : 's'} inventoried`, - `${cleanPairs}/${pairs.length} managed harness projection${pairs.length === 1 ? '' : 's'} clean`, - ]; - parts.push(actionableCount - ? `${actionableCount} item${actionableCount === 1 ? '' : 's'} need review; future repeats stay quiet` - : 'nothing needs your attention; future healthy runs stay quiet'); - return `${parts.join('; ')}.`; + // AE2: concrete owner decisions interrupt once with a complete message. + if (owner.length) { + const evaluation = ownerDecisionMessage(result, owner, { observedAt: at }); + return { + message: evaluation.output, + disposition: evaluation.disposition, + statusMessage: evaluation.statusMessage, + durableStatus, + evaluation, + }; } - if (raised.length || resolved.length || repaired) { - const parts = []; - if (raised.length) { - const reasons = countByReason(raised); - parts.push(`${raised.length} new item${raised.length === 1 ? '' : 's'} need review${reasons ? `: ${reasons}` : ''}`); - } - if (resolved.length) parts.push(`${resolved.length} prior item${resolved.length === 1 ? '' : 's'} resolved`); - if (repaired) parts.push(`${repaired} managed projection${repaired === 1 ? '' : 's'} repaired`); - return `jarvOS skill sync: ${parts.join('; ')}.`; + // AE1 + R6: safe holds, automatic repairs, and resolutions stay quiet. + if (holds.length || resolved.length || repaired) { + let evaluation; + if (holds.length) evaluation = quietHoldEvaluation({ observedAt: at }); + else if (repaired) evaluation = quietRepairEvaluation({ observedAt: at }); + else evaluation = quietResolutionEvaluation({ observedAt: at }); + return { + message: evaluation.output, + disposition: evaluation.disposition, + statusMessage: evaluation.statusMessage, + durableStatus, + evaluation, + }; } - return 'NO_REPLY'; + return empty; +} + +function scheduledRepairMessage(result, options = {}) { + return scheduledRepairNotification(result, options).message; } function runScheduledRepair({ @@ -71,15 +389,33 @@ function runScheduledRepair({ announceConvergence = false, repair = autonomousRepairOperator, readStatus = statusOperator, + observedAt = null, } = {}) { const result = repair({ configPath }); const catalogStatus = announceConvergence && result?.ok && result?.ran !== false ? readStatus({ configPath }) : null; + const notification = scheduledRepairNotification(result, { + announceConvergence, + catalogStatus, + observedAt, + }); return { result, - message: scheduledRepairMessage(result, { announceConvergence, catalogStatus }), + message: notification.message, + disposition: notification.disposition, + statusMessage: notification.statusMessage, + durableStatus: notification.durableStatus, + evaluation: notification.evaluation, }; } -module.exports = { countByReason, scheduledRepairMessage, runScheduledRepair }; +module.exports = { + OWNER_DECISION_REASONS, + SAFE_HOLD_REASONS, + countByReason, + mintEventReference, + scheduledRepairMessage, + scheduledRepairNotification, + runScheduledRepair, +}; diff --git a/modules/jarvos-skills/test/attention.test.js b/modules/jarvos-skills/test/attention.test.js new file mode 100644 index 00000000..c9a5b5c7 --- /dev/null +++ b/modules/jarvos-skills/test/attention.test.js @@ -0,0 +1,142 @@ +'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 { + ATTENTION_SCHEMA_VERSION, + durableHoldStatus, + reconcileAttention, + redactedAttention, +} = require('../src/attention'); + +function tempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-attention-')); +} + +function statusWith(skills) { + return { skills }; +} + +test('redactedAttention keeps only actionable items with reason codes for durable status', () => { + const items = redactedAttention(statusWith([ + { + logicalId: 'private-a', + attention: 'actionable', + disposition: { reasonCode: 'unsafe_source' }, + }, + { + logicalId: 'quiet-b', + attention: 'quiet', + disposition: { reasonCode: 'rule_proven_portable' }, + }, + ])); + assert.equal(items.length, 1); + assert.equal(items[0].logicalId, 'private-a'); + assert.equal(items[0].reasonCode, 'unsafe_source'); + assert.ok(items[0].fingerprint); +}); + +test('reconcileAttention records first-seen on raise and stays a no-op on healthy replay', () => { + const root = tempDir(); + const attentionPath = path.join(root, 'attention.json'); + fs.chmodSync(root, 0o700); + + const first = reconcileAttention({ + attentionPath, + observedAt: '2026-08-16T12:00:00.000Z', + status: statusWith([ + { + logicalId: 'private-a', + attention: 'actionable', + disposition: { reasonCode: 'unsafe_source' }, + }, + ]), + }); + assert.equal(first.wrote, true); + assert.equal(first.raised.length, 1); + assert.equal(first.raised[0].occurrenceCount, 1); + assert.equal(first.raised[0].firstSeenAt, '2026-08-16T12:00:00.000Z'); + assert.equal(first.durableStatus[0].reasonCode, 'unsafe_source'); + assert.equal(first.durableStatus[0].occurrenceCount, 1); + + const second = reconcileAttention({ + attentionPath, + observedAt: '2026-08-16T13:00:00.000Z', + status: statusWith([ + { + logicalId: 'private-a', + attention: 'actionable', + disposition: { reasonCode: 'unsafe_source' }, + }, + ]), + }); + assert.equal(second.raised.length, 0); + assert.equal(second.resolved.length, 0); + assert.equal(second.wrote, false); + assert.equal(second.replay, true); + assert.equal(second.durableStatus[0].occurrenceCount, 1); + assert.equal(second.durableStatus[0].firstSeenAt, '2026-08-16T12:00:00.000Z'); + + const stored = JSON.parse(fs.readFileSync(attentionPath, 'utf8')); + assert.equal(stored.schemaVersion, ATTENTION_SCHEMA_VERSION); + assert.equal(stored.active[0].firstSeenAt, '2026-08-16T12:00:00.000Z'); +}); + +test('resolve transitions clear active holds while retaining reason codes on the transition', () => { + const root = tempDir(); + const attentionPath = path.join(root, 'attention.json'); + fs.chmodSync(root, 0o700); + + reconcileAttention({ + attentionPath, + observedAt: '2026-08-16T12:00:00.000Z', + status: statusWith([ + { + logicalId: 'private-a', + attention: 'actionable', + disposition: { reasonCode: 'unsafe_source' }, + }, + ]), + }); + + const resolved = reconcileAttention({ + attentionPath, + observedAt: '2026-08-16T14:00:00.000Z', + status: statusWith([]), + }); + assert.equal(resolved.resolved.length, 1); + assert.equal(resolved.resolved[0].reasonCode, 'unsafe_source'); + assert.equal(resolved.durableStatus.length, 0); +}); + +test('durableHoldStatus groups by reason with first-seen and occurrence count', () => { + const summary = durableHoldStatus([ + { + reasonCode: 'unsafe_source', + firstSeenAt: '2026-08-16T10:00:00.000Z', + fingerprint: 'a', + occurrenceCount: 1, + }, + { + reasonCode: 'unsafe_source', + firstSeenAt: '2026-08-16T11:00:00.000Z', + fingerprint: 'b', + occurrenceCount: 1, + }, + { + reasonCode: 'privacy_restricted', + firstSeenAt: '2026-08-16T12:00:00.000Z', + fingerprint: 'c', + occurrenceCount: 1, + }, + ]); + assert.equal(summary.length, 2); + const unsafe = summary.find((entry) => entry.reasonCode === 'unsafe_source'); + assert.equal(unsafe.occurrenceCount, 2); + assert.equal(unsafe.firstSeenAt, '2026-08-16T10:00:00.000Z'); + assert.equal(unsafe.fingerprintCount, 2); +}); diff --git a/modules/jarvos-skills/test/scheduled-repair.test.js b/modules/jarvos-skills/test/scheduled-repair.test.js index a85980bf..5e4292f7 100644 --- a/modules/jarvos-skills/test/scheduled-repair.test.js +++ b/modules/jarvos-skills/test/scheduled-repair.test.js @@ -3,7 +3,13 @@ const assert = require('node:assert/strict'); const test = require('node:test'); -const { scheduledRepairMessage, runScheduledRepair } = require('../src/scheduled-repair'); +const { + scheduledRepairMessage, + scheduledRepairNotification, + runScheduledRepair, +} = require('../src/scheduled-repair'); + +const OBSERVED_AT = '2026-08-16T15:00:00Z'; function healthy(overrides = {}) { return { @@ -17,6 +23,12 @@ function healthy(overrides = {}) { }; } +function assertNoRawCodes(text) { + assert.equal(typeof text, 'string'); + assert.doesNotMatch(text, /unsafe_source|needs_owner_input|review_required|incomplete_observation|semantic_collision|ambiguous_identity/); + assert.doesNotMatch(text, /logicalId|sourceRoot|SKILL\.md|\/Users\//); +} + test('healthy scheduled replays stay silent', () => { assert.equal(scheduledRepairMessage(healthy()), 'NO_REPLY'); }); @@ -31,42 +43,117 @@ test('first convergence summary is concise and count-only', () => { assert.match(message, /103 skills inventoried/); assert.match(message, /2\/2 managed harness projections clean/); assert.match(message, /28 items need review/); - assert.doesNotMatch(message, /logicalId|sourceRoot|SKILL\.md/); + assertNoRawCodes(message); }); -test('new transitions and repairs produce one redacted message', () => { - const message = scheduledRepairMessage(healthy({ +test('AE1: unsafe_source safety hold is quiet and keeps codes out of human output', () => { + const notification = scheduledRepairNotification(healthy({ + attention: { + raised: [ + { logicalId: 'private-name', reasonCode: 'unsafe_source', fingerprint: 'a'.repeat(64) }, + ], + resolved: [], + }, + }), { observedAt: OBSERVED_AT }); + + assert.equal(notification.message, 'NO_REPLY'); + assert.equal(notification.disposition, 'durable-status'); + assert.match(notification.statusMessage, /paused an unsafe change/i); + assertNoRawCodes(notification.message); + assertNoRawCodes(notification.statusMessage || ''); + assert.equal(notification.durableStatus[0].kind, 'safe-hold'); + assert.match(notification.durableStatus[0].reasons, /unsafe_source/); +}); + +test('automatic repairs and resolutions stay quiet', () => { + const repaired = scheduledRepairNotification(healthy({ reconciliation: { repaired: true, applied: [{ applied: true }, { applied: false }] }, + attention: { raised: [], resolved: [] }, + }), { observedAt: OBSERVED_AT }); + assert.equal(repaired.message, 'NO_REPLY'); + assert.equal(repaired.disposition, 'quiet'); + + const resolved = scheduledRepairNotification(healthy({ + attention: { + raised: [], + resolved: [{ logicalId: 'old-private-name', reasonCode: 'old_reason', fingerprint: 'b'.repeat(64) }], + }, + }), { observedAt: OBSERVED_AT }); + assert.equal(resolved.message, 'NO_REPLY'); + assert.ok(resolved.disposition === 'quiet' || resolved.disposition === 'durable-status'); + assertNoRawCodes(resolved.message); +}); + +test('AE2: owner decision produces one complete actionable message with opaque reference', () => { + const notification = scheduledRepairNotification(healthy({ attention: { raised: [ - { logicalId: 'private-name', reasonCode: 'review_required' }, - { logicalId: 'another-private-name', reasonCode: 'review_required' }, + { logicalId: 'private-name', reasonCode: 'needs_owner_input', fingerprint: 'c'.repeat(64) }, + { logicalId: 'another-private-name', reasonCode: 'semantic_collision', fingerprint: 'd'.repeat(64) }, ], - resolved: [{ logicalId: 'old-private-name', reasonCode: 'old_reason' }], + resolved: [], }, - })); - assert.match(message, /2 new items need review: review_required \(2\)/); - assert.match(message, /1 prior item resolved/); - assert.match(message, /1 managed projection repaired/); - assert.doesNotMatch(message, /private-name|another-private-name|old-private-name/); + }), { observedAt: OBSERVED_AT }); + + assert.equal(notification.disposition, 'direct-notification'); + assert.match(notification.message, /could not complete a safe recovery|preserved the existing state/i); + assert.match(notification.message, /Action required:/); + assert.match(notification.message, /Next:/); + assert.match(notification.message, /Reference: [A-Za-z0-9_-]{22,}/); + assertNoRawCodes(notification.message); + assert.doesNotMatch(notification.message, /private-name|another-private-name/); }); -test('untrusted reason text is replaced instead of entering a notification', () => { - const message = scheduledRepairMessage(healthy({ - attention: { raised: [{ reasonCode: 'private value\nsecond line' }], resolved: [] }, - })); - assert.match(message, /needs_owner_input \(1\)/); - assert.doesNotMatch(message, /private value|second line/); +test('untrusted reason text never enters human output and fails closed as owner decision', () => { + const notification = scheduledRepairNotification(healthy({ + attention: { raised: [{ reasonCode: 'private value\nsecond line', fingerprint: 'e'.repeat(64) }], resolved: [] }, + }), { observedAt: OBSERVED_AT }); + assert.equal(notification.disposition, 'direct-notification'); + assertNoRawCodes(notification.message); + assert.doesNotMatch(notification.message, /private value|second line|needs_owner_input/); + assert.match(notification.message, /Reference:/); }); -test('incomplete inventory fails closed with an actionable count', () => { - const message = scheduledRepairMessage(healthy({ +test('incomplete inventory fails closed with an owner decision and no raw codes', () => { + const notification = scheduledRepairNotification(healthy({ mutationDenied: true, reason: 'incomplete_generation', status: { counts: { actionable: 3 } }, - })); - assert.match(message, /inventory was incomplete/); - assert.match(message, /3 items require review/); + }), { observedAt: OBSERVED_AT }); + assert.equal(notification.disposition, 'direct-notification'); + assert.match(notification.message, /Action required:/); + assert.match(notification.message, /Reference:/); + assertNoRawCodes(notification.message); + assert.doesNotMatch(notification.message, /incomplete_generation|3 items/); +}); + +test('failed repair run is action-required without diagnostic leakage', () => { + const notification = scheduledRepairNotification({ + ok: false, + reason: 'child_crashed', + stderr: 'Error: boom at /Users/andrew/private.js:1:1', + }, { observedAt: OBSERVED_AT }); + assert.equal(notification.disposition, 'direct-notification'); + assert.match(notification.message, /Action required:/); + assert.match(notification.message, /Reference:/); + assertNoRawCodes(notification.message); + assert.doesNotMatch(notification.message, /child_crashed|private\.js|boom/); +}); + +test('mixed safe holds and owner decisions prefer the owner interrupt once', () => { + const notification = scheduledRepairNotification(healthy({ + reconciliation: { repaired: true, applied: [{ applied: true }] }, + attention: { + raised: [ + { logicalId: 'hold-me', reasonCode: 'unsafe_source', fingerprint: 'f'.repeat(64) }, + { logicalId: 'decide-me', reasonCode: 'needs_owner_input', fingerprint: 'g'.repeat(64) }, + ], + resolved: [{ logicalId: 'was-held', reasonCode: 'unsafe_source', fingerprint: 'h'.repeat(64) }], + }, + }), { observedAt: OBSERVED_AT }); + assert.equal(notification.disposition, 'direct-notification'); + assertNoRawCodes(notification.message); + assert.ok(notification.durableStatus.some((entry) => entry.kind === 'safe-hold')); }); test('runner calls status only for an explicit convergence announcement', () => { @@ -75,6 +162,7 @@ test('runner calls status only for an explicit convergence announcement', () => configPath: '/not-read', repair: () => healthy(), readStatus: () => { statusReads += 1; return { pairs: [] }; }, + observedAt: OBSERVED_AT, }); assert.equal(normal.message, 'NO_REPLY'); assert.equal(statusReads, 0); @@ -84,6 +172,7 @@ test('runner calls status only for an explicit convergence announcement', () => announceConvergence: true, repair: () => healthy(), readStatus: () => { statusReads += 1; return { pairs: [{ status: 'clean' }] }; }, + observedAt: OBSERVED_AT, }); assert.match(announced.message, /1\/1 managed harness projection clean/); assert.equal(statusReads, 1);