diff --git a/modules/jarvos-runtime-kit/README.md b/modules/jarvos-runtime-kit/README.md index 1afefedc..534a3243 100644 --- a/modules/jarvos-runtime-kit/README.md +++ b/modules/jarvos-runtime-kit/README.md @@ -31,3 +31,11 @@ The kit validates the manifest shape and checks the adapter directory for common drift: missing shared MCP wiring, undocumented unsupported MCP targets, missing `jarvos_hydrate`, setup scripts that edit config without backup behavior, and hook-based adapters that do not fail open. + +## Operator message lint + +`lintOperatorMessage` / `lintOperatorMessages` provide a bounded outbound-message +lint for fixtures and producers. They reject raw internal codes, absolute paths, +stack-like text, ambiguous “needs attention” copy, and common release-state +authoring mistakes. Pair with the public `operator-communication` skill for +agent guidance; the lint is the deterministic enforcement surface. diff --git a/modules/jarvos-runtime-kit/src/index.js b/modules/jarvos-runtime-kit/src/index.js index 8dad5515..717d0146 100644 --- a/modules/jarvos-runtime-kit/src/index.js +++ b/modules/jarvos-runtime-kit/src/index.js @@ -10,6 +10,7 @@ const { isSha256 } = harnessDispatch; const stewardshipAdapter = require('./stewardship-adapter.js'); const stewardshipBootstrap = require('./stewardship-bootstrap.js'); const openclawPluginPersistence = require('./openclaw-plugin-persistence.js'); +const operatorNotificationLint = require('./operator-notification-lint.js'); const capabilityDescriptor = require('./capability-descriptor.js'); const DEFAULT_AGENT_CONTEXT_MCP = 'modules/jarvos-agent-context/scripts/jarvos-mcp.js'; @@ -876,6 +877,7 @@ function scaffoldRuntime(runtimeId, outDir) { } module.exports = { + ...operatorNotificationLint, ...harnessDispatch, ...stewardshipAdapter, ...stewardshipBootstrap, diff --git a/modules/jarvos-runtime-kit/src/operator-notification-lint.js b/modules/jarvos-runtime-kit/src/operator-notification-lint.js new file mode 100644 index 00000000..57013b26 --- /dev/null +++ b/modules/jarvos-runtime-kit/src/operator-notification-lint.js @@ -0,0 +1,165 @@ +'use strict'; + +/** + * Bounded outbound-message lint for operator-facing text and fixtures. + * Guidance lives in the operator-communication skill; this module is the + * deterministic enforcement surface for codes, paths, stacks, ambiguous + * actions, and release-state authoring mistakes. + */ + +const SNAKE_CODE = /\b[a-z][a-z0-9]*(?:_[a-z0-9]+){1,}\b/g; +const ABSOLUTE_PATH = /(?:^|[\s"'`(])(\/(?:Users|home|var|tmp|private|opt|etc|root)\/\S+|\/Users\/\S+|~\/\S+|file:\/\/\S+)/g; +const WINDOWS_PATH = /(?:^|[\s"'`(])([A-Za-z]:\\(?:[^\s"'`)]+))/g; +const STACK_LIKE = /(?:^\s*at\s+\S+\s+\([^)]+\)\s*$)|(?:Error:\s.+\n\s+at\s+)/m; +const STACK_FRAME = /^\s*at\s+\S+/m; +const BARE_SHA = /\b[0-9a-f]{7,40}\b/i; +const RECEIPTISH = /\b(?:receipt|run|event)[_-]?id\b\s*[:=]\s*\S+/i; +const AMBIGUOUS_ATTENTION = /\bneeds\s+(?:attention|input|review)\b/i; +const ACTION_CUE = /\b(?:please|reply|confirm|approve|choose|decide|open|run|send|review|provide|set|enable|disable|restart|merge|sign\s*off)\b/i; +const NO_ACTION_CUE = /\b(?:no action(?:\s+needed|\s+required)?|nothing for you to do|no user action|automatically(?:\s+held|\s+retrying)?|will retry|staying quiet)\b/i; +const RELEASE_FAILURE_FUTURE = /\b(?:future|v?\d+\.\d+\.\d+)\b[^.!?\n]{0,80}\b(?:publication failure|failed to publish|publish(?:ed)? failed)\b/i; + +const ALLOWED_SNAKE = new Set([ + 'no_reply', + // common English compounds that are not event codes + 'plain_english', +]); + +function unique(list) { + return [...new Set(list.filter(Boolean))]; +} + +function findSnakeCodes(text) { + const hits = []; + for (const match of String(text).matchAll(SNAKE_CODE)) { + const token = match[0]; + if (ALLOWED_SNAKE.has(token)) continue; + // Allow semantic versions adjacent patterns already excluded by regex. + // Allow template placeholders like what_happened only if explicitly listed? reject. + hits.push(token); + } + return unique(hits); +} + +function findPaths(text) { + const hits = []; + for (const match of String(text).matchAll(ABSOLUTE_PATH)) hits.push(match[1] || match[0].trim()); + for (const match of String(text).matchAll(WINDOWS_PATH)) hits.push(match[1] || match[0].trim()); + return unique(hits.map((value) => value.trim())); +} + +function hasStack(text) { + const value = String(text); + return STACK_LIKE.test(value) || STACK_FRAME.test(value); +} + +function hasBareSha(text) { + const value = String(text); + if (!BARE_SHA.test(value)) return false; + // Allow SHA when freshness/context words appear nearby in the same sentence. + const sentences = value.split(/(?<=[.!?])\s+/); + return sentences.some((sentence) => { + if (!BARE_SHA.test(sentence)) return false; + const explains = /\b(?:establishes|observed|as of|freshness|current checkout|source commit)\b/i.test(sentence); + return !explains; + }); +} + +function ambiguousAction(text) { + const value = String(text); + if (!AMBIGUOUS_ATTENTION.test(value)) return false; + if (ACTION_CUE.test(value) && /\b(?:andrew|owner|you)\b/i.test(value)) return false; + if (NO_ACTION_CUE.test(value)) return false; + // concrete action patterns: "Reply with X", "Approve the 0.8.0 release" + if (/\b(?:reply with|approve the|choose whether|decide if|open the|provide the)\b/i.test(value)) return false; + return true; +} + +function releaseAuthoringIssues(text, options = {}) { + const value = String(text); + const issues = []; + const releaseMode = options.mode === 'release' || options.release === true + || /\b(?:published|release candidate|future (?:lane|milestone)|v\d+\.\d+\.\d+)\b/i.test(value); + if (!releaseMode) return issues; + + if (RELEASE_FAILURE_FUTURE.test(value)) { + issues.push('future_lane_marked_publication_failure'); + } + + const staleCue = /\b(?:stale|unknown observation|observation missing|not observed|last seen days ago)\b/i.test(value); + const currentOrReady = /\b(?:currently published|ready for(?: Andrew'?s)? review|ready to publish)\b/i.test(value); + if (staleCue && currentOrReady) { + issues.push('current_or_ready_from_stale_observation'); + } + + if (/\bmain@[0-9a-f]{7,}\b/i.test(value) && !/\b(?:establishes|observed|as of)\b/i.test(value)) { + issues.push('bare_commit_in_release_text'); + } + + if (/\b(?:is published and ready|published candidate|candidate is published|future .* is currently published)\b/i.test(value)) { + issues.push('conflated_release_states'); + } + + return issues; +} + +function lintOperatorMessage(text, options = {}) { + if (typeof text !== 'string') { + return { ok: false, errors: ['message must be a string'], findings: {} }; + } + const findings = { + snakeCaseCodes: findSnakeCodes(text), + absolutePaths: findPaths(text), + stackLike: hasStack(text), + bareCommit: options.allowBareSha ? false : hasBareSha(text), + receiptLeak: RECEIPTISH.test(text), + ambiguousAction: ambiguousAction(text), + releaseIssues: releaseAuthoringIssues(text, options), + missingActionGuidance: false, + }; + + // Action guidance: if message is non-empty and not explicitly quiet, require action or no-action. + const trimmed = text.trim(); + if (trimmed && !NO_ACTION_CUE.test(trimmed) && !ACTION_CUE.test(trimmed) && !/\b(?:no action|nothing to do)\b/i.test(trimmed)) { + // Allow pure informational four-question blocks that include "Your action:" slot. + if (!/\b(?:your action|action required|must act|no user action)\b/i.test(trimmed)) { + // Only flag when attention-ish or failure-ish language is present. + if (/\b(?:failed|blocked|error|warning|degraded|attention|input|broken)\b/i.test(trimmed)) { + findings.missingActionGuidance = true; + } + } + } + + const errors = []; + for (const code of findings.snakeCaseCodes) errors.push(`raw_internal_code:${code}`); + for (const p of findings.absolutePaths) errors.push(`absolute_path:${p}`); + if (findings.stackLike) errors.push('stack_like_text'); + if (findings.bareCommit) errors.push('bare_commit'); + if (findings.receiptLeak) errors.push('receipt_or_run_id_leak'); + if (findings.ambiguousAction) errors.push('ambiguous_action'); + if (findings.missingActionGuidance) errors.push('missing_action_guidance'); + for (const issue of findings.releaseIssues) errors.push(issue); + + return { ok: errors.length === 0, errors, findings }; +} + +function lintOperatorMessages(messages, options = {}) { + const results = (messages || []).map((entry, index) => { + if (typeof entry === 'string') return { index, ...lintOperatorMessage(entry, options) }; + const text = entry?.text ?? entry?.body ?? entry?.message ?? ''; + const localOptions = { ...options, ...(entry?.options || {}), mode: entry?.mode || options.mode }; + return { index, id: entry?.id || null, ...lintOperatorMessage(text, localOptions) }; + }); + return { + ok: results.every((result) => result.ok), + results, + errors: results.flatMap((result) => (result.errors || []).map((error) => `messages[${result.index}]:${error}`)), + }; +} + +module.exports = { + lintOperatorMessage, + lintOperatorMessages, + findSnakeCodes, + findPaths, +}; diff --git a/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-bare-commit.txt b/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-bare-commit.txt new file mode 100644 index 00000000..402d8a9c --- /dev/null +++ b/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-bare-commit.txt @@ -0,0 +1 @@ +main@8cb3909 still v0.7.0 shipped while 0.8.0 waits. Please review the candidate. diff --git a/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-conflated.txt b/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-conflated.txt new file mode 100644 index 00000000..6b28195b --- /dev/null +++ b/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-conflated.txt @@ -0,0 +1 @@ +The published candidate is published and ready as future work in one step. Please approve it. diff --git a/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-future-failure.txt b/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-future-failure.txt new file mode 100644 index 00000000..399b8737 --- /dev/null +++ b/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-future-failure.txt @@ -0,0 +1 @@ +The future v1.0.0 lane is a publication failure because work is incomplete. Please investigate. diff --git a/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-stale-current.txt b/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-stale-current.txt new file mode 100644 index 00000000..8bd5549a --- /dev/null +++ b/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-bad-stale-current.txt @@ -0,0 +1 @@ +From a stale unknown observation last seen days ago, 0.9.0 is currently published and ready for Andrew’s review. Please ignore freshness. diff --git a/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-good-ae5.txt b/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-good-ae5.txt new file mode 100644 index 00000000..02d09844 --- /dev/null +++ b/modules/jarvos-runtime-kit/test/fixtures/operator-notification-lint/release-good-ae5.txt @@ -0,0 +1 @@ +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. Please approve or reject the 0.8.0 candidate when ready. diff --git a/modules/jarvos-runtime-kit/test/operator-notification-lint.test.js b/modules/jarvos-runtime-kit/test/operator-notification-lint.test.js new file mode 100644 index 00000000..2ea5cd43 --- /dev/null +++ b/modules/jarvos-runtime-kit/test/operator-notification-lint.test.js @@ -0,0 +1,75 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const test = require('node:test'); + +const { + lintOperatorMessage, + lintOperatorMessages, +} = require('../src/index.js'); + +const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'operator-notification-lint'); + +function readFixture(name) { + return fs.readFileSync(path.join(FIXTURE_DIR, name), 'utf8').trim(); +} + +test('accepts concise no-action and action-required messages', () => { + const noAction = lintOperatorMessage( + 'Skill sync finished cleanly. jarvOS recorded the healthy run. No action needed. Next automatic check stays on schedule.', + ); + assert.equal(noAction.ok, true, noAction.errors.join('\n')); + + const action = lintOperatorMessage( + 'Shared skill repair paused on a machine-wide inventory hold. jarvOS left local files unchanged. Please reply with approve-inventory or keep-hold. jarvOS will stay paused until you choose.', + ); + assert.equal(action.ok, true, action.errors.join('\n')); +}); + +test('rejects raw codes, paths, stacks, and ambiguous actions', () => { + const code = lintOperatorMessage('skill_sync_failed and needs attention'); + assert.equal(code.ok, false); + assert.ok(code.errors.some((error) => error.startsWith('raw_internal_code:'))); + + const filePath = lintOperatorMessage('Repair failed under /Users/andrew/clawd/skills/secret. Please restart the agent.'); + assert.equal(filePath.ok, false); + assert.ok(filePath.errors.some((error) => error.startsWith('absolute_path:'))); + + const stack = lintOperatorMessage('Error: boom\n at run (/tmp/x.js:1:1)\nPlease restart the agent.'); + assert.equal(stack.ok, false); + assert.ok(stack.errors.includes('stack_like_text')); + + const ambiguous = lintOperatorMessage('jarvOS skill sync needs attention.'); + assert.equal(ambiguous.ok, false); + assert.ok(ambiguous.errors.includes('ambiguous_action') || ambiguous.errors.includes('missing_action_guidance')); +}); + +test('release-monitor authoring fixtures reject conflation, bare commits, and false publication failures', () => { + const badConflate = readFixture('release-bad-conflated.txt'); + const badCommit = readFixture('release-bad-bare-commit.txt'); + const badFuture = readFixture('release-bad-future-failure.txt'); + const badStale = readFixture('release-bad-stale-current.txt'); + const good = readFixture('release-good-ae5.txt'); + + assert.equal(lintOperatorMessage(badConflate, { mode: 'release' }).ok, false); + assert.equal(lintOperatorMessage(badCommit, { mode: 'release' }).ok, false); + assert.ok(lintOperatorMessage(badCommit, { mode: 'release' }).errors.includes('bare_commit') + || lintOperatorMessage(badCommit, { mode: 'release' }).errors.includes('bare_commit_in_release_text')); + assert.ok(lintOperatorMessage(badFuture, { mode: 'release' }).errors.includes('future_lane_marked_publication_failure')); + assert.ok(lintOperatorMessage(badStale, { mode: 'release' }).errors.includes('current_or_ready_from_stale_observation')); + + const goodResult = lintOperatorMessage(good, { mode: 'release' }); + assert.equal(goodResult.ok, true, goodResult.errors.join('\n')); +}); + +test('batch helper reports per-message errors', () => { + const batch = lintOperatorMessages([ + { id: 'ok', text: 'Recovery finished. jarvOS restored the last good projection. No action needed. Next scan runs on the hour.' }, + { id: 'bad', text: 'inventory_incomplete needs attention' }, + ]); + assert.equal(batch.ok, false); + assert.equal(batch.results[0].ok, true); + assert.equal(batch.results[1].ok, false); +}); diff --git a/modules/jarvos-skills/manifest.json b/modules/jarvos-skills/manifest.json index 83a36118..347be172 100644 --- a/modules/jarvos-skills/manifest.json +++ b/modules/jarvos-skills/manifest.json @@ -143,6 +143,28 @@ "codex": { "path": "{skillsRoot}/session-wait/SKILL.md", "renderer": "raw-skill-md" } } } + }, + { + "name": "operator-communication", + "path": "skills/operator-communication/SKILL.md", + "purpose": "Guide agents to write plain-English operator messages with clear action guidance and no private diagnostic leaks.", + "source": { + "revision": "jarvos-skills-v0.5.0", + "digest": "acab315dd830d4909686c50841b7dddb63413fb3fb714299efd1e20137be6270", + "license": "MIT", + "provenance": "jarvOS reviewed source" + }, + "supportedHarnesses": ["generic", "claude-code", "codex", "openclaw", "hermes"], + "projection": { + "mode": "copy", + "targets": { + "generic": { "path": "{skillsRoot}/operator-communication/SKILL.md", "renderer": "raw-skill-md" }, + "claude-code": { "path": "{skillsRoot}/operator-communication/SKILL.md", "renderer": "raw-skill-md" }, + "codex": { "path": "{skillsRoot}/operator-communication/SKILL.md", "renderer": "raw-skill-md" }, + "openclaw": { "path": "{skillsRoot}/operator-communication/SKILL.md", "renderer": "raw-skill-md" }, + "hermes": { "path": "{skillsRoot}/operator-communication/SKILL.md", "renderer": "raw-skill-md" } + } + } } ] } diff --git a/modules/jarvos-skills/skills/operator-communication/SKILL.md b/modules/jarvos-skills/skills/operator-communication/SKILL.md new file mode 100644 index 00000000..afe97b81 --- /dev/null +++ b/modules/jarvos-skills/skills/operator-communication/SKILL.md @@ -0,0 +1,135 @@ +--- +name: operator-communication +description: Write plain-English operator messages that state what happened, what jarvOS did, whether action is required, and what happens next without leaking private diagnostics. +triggers: + - operator notification + - plain English alert + - user-facing error message + - blocked state message + - recovery notification +metadata: + jarvos: + bundle: operating-system-skills + portability: generic + class: full +--- + +# Operator Communication + +Use this skill whenever you write or review a user-facing operator message: +failure, warning, blocked work, recovery, completion, or release-status text. + +This skill is guidance. Runtime enforcement lives in the public notification +contract and the outbound-message lint. Do not treat a well-written skill draft +as proof that production delivery is safe. + +## Contract + +A delivered operator message is complete only when: + +- it answers the four R1 questions in plain English +- it either names a concrete user action or explicitly states that no action is required +- it stays quiet when delivery is not warranted (`NO_REPLY` / no direct send) +- it never exposes raw reason codes, stack traces, absolute paths, private skill + names, receipt identifiers, bare commits without freshness context, or + unreviewed diagnostic prose +- release text distinguishes published state, approval-ready candidate, and + separate future work +- private evidence remains available to owners outside the public text + +## Four questions (R1) + +Every delivered message must make these answers obvious: + +1. **What happened?** One concrete outcome in ordinary language. +2. **What did jarvOS do?** The automatic response already taken. +3. **Must the user act?** Yes with a specific action, or an explicit no-action line. +4. **What happens next?** The next automatic step, hold, or review path. + +## Attention policy + +- Prefer silence when nothing changed and no action is required. +- "Needs attention" and "needs input" are incomplete unless they name the + exact decision, owner, and next action. +- Safe automatic holds may stay readable in status surfaces with first-seen time + and count while direct delivery stays quiet. +- Unknown or unreviewed event codes must fail closed to a generic reviewed + action-required message, never by printing the code. + +## Privacy boundary + +Keep these out of Telegram and other operator channels: + +- snake_case or stable internal reason codes +- absolute filesystem paths +- stack traces and exception dumps +- private skill names and inventory logical ids +- receipt ids, run ids, and unguessable event references meant for owners +- bare git SHAs without what they establish and how fresh the observation is +- free-form diagnostic dumps or unreviewed template slots + +Owners still need precision. Put codes, paths, SHAs, and detail behind +owner-only evidence, not in the public sentence. + +## Release-state wording (AE5) + +When projecting release monitor evidence: + +- Name the **currently published** version only from current observations. +- Describe an approval-ready candidate as ready for human review and say that + nothing publishes automatically. +- Keep a separate future lane as future work, not as a publication failure. +- Do not call normal incompleteness in a future lane a failure. +- Do not claim "current" or "ready" from stale or unknown observations. +- Keep commits, stable codes, and lane detail in owner-only evidence unless the + public sentence explains what the commit establishes and how fresh it is. + +Example shape: + +> 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. + +## Workflow + +1. **Classify the audience.** Operator channel vs owner-only evidence. +2. **Collect facts.** Outcome, automation already attempted, action requirement, + next automatic step, observation time, freshness. +3. **Draft the four answers** in plain English with reviewed wording. +4. **Redact.** Remove codes, paths, stacks, private names, and bare commits. +5. **Check action clarity.** Concrete action or explicit no-action statement. +6. **Validate.** Run the outbound-message lint / contract tests on fixtures or + producers in scope. +7. **Deliver or stay quiet.** Send only when the message earns attention. + +## Output format + +Prefer short prose or a tight bullet block: + +```text +What happened: ... +What jarvOS did: ... +Your action: ... (or "No action needed.") +Next: ... +``` + +Completion and recovery messages follow the same four answers. Do not celebrate +internally; state the outcome and residual risk if any. + +## Anti-patterns + +- Printing `skill_sync_failed` or other snake_case codes to Telegram +- "Needs attention" with no owner, decision, or next step +- Pasting stack traces, absolute paths, or receipt ids "for context" +- Calling a future milestone incomplete state a publish failure +- Claiming ready/current from stale evidence +- Using an LLM at notification time for wording +- Treating this skill alone as runtime enforcement + +## Tests / evals + +- Package: `modules/jarvos-skills/test/operator-communication-skill.test.js` +- Lint: `modules/jarvos-runtime-kit/test/operator-notification-lint.test.js` +- Discovery fixtures: managed projection for Codex, Claude Code, OpenClaw, Hermes +- Invocation proof is claimed only when a contained non-mutating adapter exists; + otherwise report `verification_pending` truthfully diff --git a/modules/jarvos-skills/test/operator-communication-skill.test.js b/modules/jarvos-skills/test/operator-communication-skill.test.js new file mode 100644 index 00000000..0b6abe78 --- /dev/null +++ b/modules/jarvos-skills/test/operator-communication-skill.test.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); +const { + assertProjectionManifest, + getManifest, + getSkill, + planSkillProjection, + applySkillProjection, + validateBundle, +} = require('../src'); + +const HARNESSES = ['claude-code', 'codex', 'openclaw', 'hermes']; +const SKILL = 'operator-communication'; + +test('operator-communication is registered with digest and four-harness projection', () => { + const manifest = getManifest(); + const entry = manifest.skills.find((skill) => skill.name === SKILL); + assert.ok(entry, 'skill must be in manifest'); + assert.equal(assertProjectionManifest(manifest), true); + const validation = validateBundle(); + assert.equal(validation.ok, true, validation.errors.join('\n')); + + for (const harness of HARNESSES) { + assert.ok(entry.supportedHarnesses.includes(harness), `missing harness ${harness}`); + assert.ok(entry.projection.targets[harness], `missing target ${harness}`); + } + + const skill = getSkill(SKILL); + assert.ok(skill, 'getSkill returns skill'); + assert.match(skill.content, /name:\s*operator-communication/); + assert.match(skill.content, /Four questions/); + assert.match(skill.content, /What happened/); + assert.match(skill.content, /Privacy boundary/); + assert.match(skill.content, /Release-state wording/); + assert.match(skill.content, /needs attention/i); +}); + +test('offline conformance fixtures cover the four-question checklist', () => { + const skill = getSkill(SKILL); + for (const needle of [ + 'What happened', + 'What did jarvOS do', + 'Must the user act', + 'What happens next', + 'No action', + 'stack traces', + 'absolute paths', + 'currently published', + ]) { + assert.match(skill.content, new RegExp(needle, 'i'), `missing guidance: ${needle}`); + } +}); + +test('managed projection discovers the skill for all four harnesses', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-operator-communication-')); + try { + for (const harness of HARNESSES) { + const harnessRoot = path.join(root, harness); + fs.mkdirSync(harnessRoot, { recursive: true }); + const plan = planSkillProjection({ harness, skillsRoot: harnessRoot, skills: [SKILL] }); + assert.equal(plan.entries.length, 1, harness); + assert.notEqual(plan.entries[0].status, 'unsupported', `${harness} must support projection`); + const applied = applySkillProjection(plan); + assert.equal(applied.applied[0].applied, true, harness); + const target = plan.entries[0].targetPath; + assert.ok(fs.existsSync(target), `${harness} target missing`); + const body = fs.readFileSync(target, 'utf8'); + assert.match(body, /operator-communication/); + const clean = planSkillProjection({ harness, skillsRoot: harnessRoot, skills: [SKILL] }); + assert.equal(clean.entries[0].status, 'clean', harness); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('unsupported harness stays truthful instead of fake parity', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'jarvos-operator-communication-unsup-')); + try { + const plan = planSkillProjection({ harness: 'not-a-harness', skillsRoot: root, skills: [SKILL] }); + assert.equal(plan.entries[0].status, 'unsupported'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/modules/jarvos-skills/test/skills.test.js b/modules/jarvos-skills/test/skills.test.js index 687d5cad..c898e65e 100755 --- a/modules/jarvos-skills/test/skills.test.js +++ b/modules/jarvos-skills/test/skills.test.js @@ -32,7 +32,7 @@ assert.equal(manifest.defaultSkills.includes('qmd'), false); const validation = validateBundle(); assert.equal(validation.ok, true, validation.errors.join('\n')); -assert.equal(listSkills().length, 6); +assert.equal(listSkills().length, 7); assert.equal(getSkill('session-wait').supportedHarnesses.includes('codex'), true); for (const name of manifest.defaultSkills) { diff --git a/runtimes/claude/adapter.json b/runtimes/claude/adapter.json index 2599f97a..705e3b1b 100644 --- a/runtimes/claude/adapter.json +++ b/runtimes/claude/adapter.json @@ -154,6 +154,7 @@ "verification": [ "claude mcp get jarvos", "node runtimes/claude/jarvos-session-start-hook.js", - "node modules/jarvos-runtime-kit/scripts/jarvos-runtime-kit.js check claude" + "node modules/jarvos-runtime-kit/scripts/jarvos-runtime-kit.js check claude", + "operator-communication skill projection (manifest-backed)" ] } diff --git a/runtimes/codex/adapter.json b/runtimes/codex/adapter.json index 90163e5c..d717d341 100644 --- a/runtimes/codex/adapter.json +++ b/runtimes/codex/adapter.json @@ -160,6 +160,7 @@ "verification": [ "codex mcp get jarvos", "node runtimes/codex/jarvos-session-start-hook.js", - "node modules/jarvos-runtime-kit/scripts/jarvos-runtime-kit.js check codex" + "node modules/jarvos-runtime-kit/scripts/jarvos-runtime-kit.js check codex", + "operator-communication skill projection (manifest-backed)" ] } diff --git a/runtimes/hermes/adapter.json b/runtimes/hermes/adapter.json index 09810049..b51923bc 100644 --- a/runtimes/hermes/adapter.json +++ b/runtimes/hermes/adapter.json @@ -18,7 +18,7 @@ "releaseRevision": "291c334-route-capability", "generationDigest": "85781875304f9010b19c1f1c20571565a84f0e96bafed889aec29261e2b75118", "components": [ - { "name": "skills-manifest", "path": "modules/jarvos-skills/manifest.json", "digest": "8eca8860c4d9d90c1a0dfe67f4bf1ea87398b6823caf88e243b7278b00b6ef49" }, + { "name": "skills-manifest", "path": "modules/jarvos-skills/manifest.json", "digest": "3cbc2cf72db7738627d7a7b58bafde2191e09f4d53fa0d33a2a8b75d842cfbd1" }, { "name": "context-plugin-metadata", "path": "runtimes/hermes/plugins/jarvos-context/plugin.yaml", "digest": "74f3bc53308a43b119773cf0a0fa5f47019761038d1bada4cc2fd5a1b13370bb" }, { "name": "context-plugin-code", "path": "runtimes/hermes/plugins/jarvos-context/__init__.py", "digest": "7f05fd28a036af20f26ac46a5d44b60514c51b4da4ad4b01511bc5d6371d3dbf" }, { "name": "coding-host-adapter", "path": "modules/jarvos-coding/src/adapters/hosts.js", "digest": "a8e24f668cb39ecf50400b5eaca122fdec93aad6df956446ea5f9def22b0f9af" } @@ -121,6 +121,7 @@ }, "unsupportedCapabilities": [], "verification": [ - "node modules/jarvos-runtime-kit/scripts/jarvos-runtime-kit.js check hermes" + "node modules/jarvos-runtime-kit/scripts/jarvos-runtime-kit.js check hermes", + "operator-communication skill projection (manifest-backed)" ] } diff --git a/runtimes/openclaw/adapter.json b/runtimes/openclaw/adapter.json index 9aed9969..7ae8e70c 100644 --- a/runtimes/openclaw/adapter.json +++ b/runtimes/openclaw/adapter.json @@ -114,6 +114,7 @@ }, "unsupportedCapabilities": [], "verification": [ - "node modules/jarvos-runtime-kit/scripts/jarvos-runtime-kit.js check openclaw" + "node modules/jarvos-runtime-kit/scripts/jarvos-runtime-kit.js check openclaw", + "operator-communication skill projection (manifest-backed)" ] }