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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions modules/jarvos-runtime-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions modules/jarvos-runtime-kit/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -876,6 +877,7 @@ function scaffoldRuntime(runtimeId, outDir) {
}

module.exports = {
...operatorNotificationLint,
...harnessDispatch,
...stewardshipAdapter,
...stewardshipBootstrap,
Expand Down
165 changes: 165 additions & 0 deletions modules/jarvos-runtime-kit/src/operator-notification-lint.js
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
main@8cb3909 still v0.7.0 shipped while 0.8.0 waits. Please review the candidate.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The published candidate is published and ready as future work in one step. Please approve it.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The future v1.0.0 lane is a publication failure because work is incomplete. Please investigate.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
75 changes: 75 additions & 0 deletions modules/jarvos-runtime-kit/test/operator-notification-lint.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
22 changes: 22 additions & 0 deletions modules/jarvos-skills/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}
}
]
}
Loading
Loading