mobile-automator 0.23.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +167 -0
  3. package/bin/mauto-session-daemon.js +101 -0
  4. package/bin/mauto.js +13 -0
  5. package/package.json +49 -0
  6. package/src/assertion/evaluator.js +285 -0
  7. package/src/cli.js +1722 -0
  8. package/src/config/coerce.js +97 -0
  9. package/src/config/manager.js +75 -0
  10. package/src/config/schema.js +124 -0
  11. package/src/device/action-catalog.js +99 -0
  12. package/src/device/bridge.js +139 -0
  13. package/src/device/connection.js +65 -0
  14. package/src/device/device-model.js +59 -0
  15. package/src/device/device-resolver.js +34 -0
  16. package/src/device/element-model.js +82 -0
  17. package/src/device/mobile-mcp-client.js +163 -0
  18. package/src/device/resolve-connection.js +156 -0
  19. package/src/device/selection.js +80 -0
  20. package/src/device/semantic-press/index.js +38 -0
  21. package/src/device/semantic-press/labels.js +39 -0
  22. package/src/device/semantic-press/resolver.js +100 -0
  23. package/src/device/session-client.js +173 -0
  24. package/src/device/session-daemon.js +526 -0
  25. package/src/device/session-paths.js +80 -0
  26. package/src/device/session-protocol.js +60 -0
  27. package/src/device/session-spawn.js +67 -0
  28. package/src/device/tool-args.js +14 -0
  29. package/src/guide/content/execute.agnostic.md +207 -0
  30. package/src/guide/content/execute.aware.md +281 -0
  31. package/src/guide/content/execute.invariants.md +4 -0
  32. package/src/guide/content/generate.agnostic.md +236 -0
  33. package/src/guide/content/generate.aware.md +327 -0
  34. package/src/guide/content/generate.invariants.md +5 -0
  35. package/src/guide/content/setup.agnostic.md +63 -0
  36. package/src/guide/content/setup.aware.md +91 -0
  37. package/src/guide/content/setup.invariants.md +3 -0
  38. package/src/guide/emitter.js +137 -0
  39. package/src/guide/placeholders.js +132 -0
  40. package/src/init/adapters.js +162 -0
  41. package/src/init/skill-meta.js +31 -0
  42. package/src/init/skill-renderer.js +60 -0
  43. package/src/mcp/prompts.js +55 -0
  44. package/src/mcp/server.js +55 -0
  45. package/src/memory/entries.js +61 -0
  46. package/src/memory/history.js +86 -0
  47. package/src/memory/lock.js +5 -0
  48. package/src/memory/paths.js +47 -0
  49. package/src/memory/store.js +230 -0
  50. package/src/output/envelope.js +106 -0
  51. package/src/result/capability-catalog.js +144 -0
  52. package/src/result/flags.js +52 -0
  53. package/src/result/store.js +273 -0
  54. package/src/scenario/validator.js +30 -0
  55. package/src/schemas/config_schema.json +68 -0
  56. package/src/schemas/format-error.js +19 -0
  57. package/src/schemas/result_schema.json +211 -0
  58. package/src/schemas/scenario_schema.json +587 -0
  59. package/src/setup/scaffold.js +53 -0
  60. package/src/util/atomic.js +41 -0
  61. package/src/util/lock.js +85 -0
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ // MCP prompt handlers for the `mauto mcp` server.
4
+ //
5
+ // The workflows are exposed as USER-CONTROLLED MCP prompts (not tools — the
6
+ // device stays behind CLI verbs). Each prompt injects the version-matched
7
+ // `mauto guide <topic>` content as a single user message, so an agent that
8
+ // selects e.g. the `generate` prompt receives the exact same guidance a human
9
+ // would get from `mauto guide generate`.
10
+ //
11
+ // Naming scheme: prompts are named by the BARE topic (`generate`, `execute`,
12
+ // `setup`) — identical to the `mauto guide <topic>` argument, so the
13
+ // two surfaces stay one-to-one and self-documenting.
14
+
15
+ const guideEmitter = require('../guide/emitter');
16
+ const configManager = require('../config/manager');
17
+
18
+ const PROMPT_TOPICS = ['generate', 'execute', 'setup'];
19
+
20
+ const DESCRIPTIONS = {
21
+ generate: 'Author a mobile test scenario by exploring the app with mauto verbs.',
22
+ execute: 'Replay a scenario step-by-step and record the run results.',
23
+ setup: 'Initialise the mobile-automator workspace and config.',
24
+ };
25
+
26
+ // Build the { listPrompts, getPrompt } pair. emitGuide + config are injectable
27
+ // so the handlers can be unit-tested without touching the real guide files.
28
+ function buildPromptHandlers({ projectRoot, emitGuide, config = configManager } = {}) {
29
+ const emit = emitGuide || guideEmitter.emitGuide;
30
+
31
+ function listPrompts() {
32
+ return PROMPT_TOPICS.map((topic) => ({
33
+ name: topic,
34
+ description: DESCRIPTIONS[topic],
35
+ arguments: [],
36
+ }));
37
+ }
38
+
39
+ function getPrompt(name) {
40
+ if (!PROMPT_TOPICS.includes(name)) {
41
+ throw new Error(`unknown prompt: ${name}`);
42
+ }
43
+ const cfg = config.load(projectRoot);
44
+ const mode = config.resolveMode(cfg);
45
+ const text = emit(name, { mode, projectRoot });
46
+ return {
47
+ description: DESCRIPTIONS[name],
48
+ messages: [{ role: 'user', content: { type: 'text', text } }],
49
+ };
50
+ }
51
+
52
+ return { listPrompts, getPrompt };
53
+ }
54
+
55
+ module.exports = { buildPromptHandlers, PROMPT_TOPICS };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ // `mauto mcp` — the MCP prompts server.
4
+ //
5
+ // Advertises the PROMPTS capability only. The four mobile-QA workflows are
6
+ // surfaced as user-controlled prompts (see src/mcp/prompts.js); the device is
7
+ // NOT exposed as MCP tools — it stays behind the `mauto` CLI verbs by design.
8
+ //
9
+ // The SDK exports CJS entry points under ./<sub>; the exact paths were
10
+ // confirmed via require.resolve at build time:
11
+ // @modelcontextprotocol/sdk/server/index.js -> Server
12
+ // @modelcontextprotocol/sdk/server/stdio.js -> StdioServerTransport
13
+ // @modelcontextprotocol/sdk/types.js -> ListPromptsRequestSchema, GetPromptRequestSchema
14
+
15
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
16
+ const {
17
+ ListPromptsRequestSchema,
18
+ GetPromptRequestSchema,
19
+ } = require('@modelcontextprotocol/sdk/types.js');
20
+
21
+ const { buildPromptHandlers } = require('./prompts');
22
+
23
+ // Construct the Server, register the prompt request handlers, but DO NOT
24
+ // connect a transport — separated so it can be exercised in tests without
25
+ // hijacking stdio.
26
+ function buildServer({ projectRoot } = {}) {
27
+ const handlers = buildPromptHandlers({ projectRoot });
28
+
29
+ const server = new Server(
30
+ { name: 'mauto', version: require('../../package.json').version },
31
+ { capabilities: { prompts: {} } }
32
+ );
33
+
34
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
35
+ prompts: handlers.listPrompts(),
36
+ }));
37
+
38
+ server.setRequestHandler(GetPromptRequestSchema, async (request) => {
39
+ return handlers.getPrompt(request.params.name);
40
+ });
41
+
42
+ return { server, handlers };
43
+ }
44
+
45
+ // Long-lived: connect the stdio transport and keep serving. Never called in
46
+ // the test suite (it would seize stdio).
47
+ async function runServer({ projectRoot } = {}) {
48
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
49
+ const { server } = buildServer({ projectRoot });
50
+ const transport = new StdioServerTransport();
51
+ await server.connect(transport);
52
+ return server;
53
+ }
54
+
55
+ module.exports = { runServer, buildServer };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ const { HEADERS } = require('./paths');
4
+
5
+ // Pure model for the agent-authored memory files (app-knowledge.md,
6
+ // preferences.md): a flat list of `- [YYYY-MM-DD][asserted] <text>` bullets
7
+ // under a `# Header`. No I/O here. `[asserted]` marks agent-claimed facts the
8
+ // reader should verify, distinct from run-history's `[observed]`.
9
+
10
+ const MAX_ENTRY_LEN = 500;
11
+ const ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\[asserted\]\s+(.*)$/;
12
+
13
+ function sanitizeEntryText(raw) {
14
+ return String(raw == null ? '' : raw)
15
+ .replace(/[\u0000-\u001F\u007F]+/g, ' ') // control chars / newlines / tabs -> space
16
+ .replace(/\s+/g, ' ')
17
+ .trim();
18
+ }
19
+
20
+ function validateEntryText(raw) {
21
+ const value = sanitizeEntryText(raw);
22
+ if (!value) return { ok: false, reason: 'empty' };
23
+ if (value.length > MAX_ENTRY_LEN) return { ok: false, reason: 'too_long' };
24
+ if (value.includes('{{')) return { ok: false, reason: 'placeholder' };
25
+ return { ok: true, value };
26
+ }
27
+
28
+ function parseEntries(md) {
29
+ const out = [];
30
+ if (!md) return out;
31
+ for (const line of md.split(/\r?\n/)) {
32
+ const m = line.match(ENTRY_RE);
33
+ if (m) out.push({ date: m[1], text: m[2] });
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function renderEntries(kind, entries) {
39
+ const out = [
40
+ HEADERS[kind] || `# ${kind}`,
41
+ "<!-- Managed by `mauto memory`. Edit an entry's text or delete whole bullets freely;",
42
+ ' other lines are not preserved on the next write. [asserted] = agent-claimed; verify. -->',
43
+ '',
44
+ ];
45
+ for (const e of entries) out.push(`- [${e.date}][asserted] ${e.text}`);
46
+ return out.join('\n').replace(/\n+$/, '\n');
47
+ }
48
+
49
+ function hasText(entries, text) {
50
+ return entries.some((e) => e.text === text);
51
+ }
52
+
53
+ module.exports = {
54
+ MAX_ENTRY_LEN,
55
+ ENTRY_RE,
56
+ sanitizeEntryText,
57
+ validateEntryText,
58
+ parseEntries,
59
+ renderEntries,
60
+ hasText,
61
+ };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ // Pure model for run-history.md — a rolling per-scenario aggregate. Bounded by
4
+ // scenario count (not run count): each scenario keeps its last MAX_RUNS pass/fail
5
+ // letters and last MAX_NOTES observation bullets. No I/O here.
6
+
7
+ const MAX_RUNS = 5;
8
+ const MAX_NOTES = 10;
9
+
10
+ const HEADER = '# Run History';
11
+ const HEADER_COMMENT =
12
+ '<!-- Managed by `mauto memory`. Auto-harvested on `result finalize`. -->';
13
+
14
+ // `## <scenarioId> (last N runs: P F ...)` — scenarioId is everything before
15
+ // the two-space gap that precedes the "(last".
16
+ const HEADING_RE = /^##\s+(.+?)\s+\(last \d+ runs:\s*([^)]*)\)\s*$/;
17
+ const NOTE_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\[observed\]\s+(.*)$/;
18
+
19
+ function emptyModel() {
20
+ return { order: [], byScenario: {} };
21
+ }
22
+
23
+ function ensure(model, scenarioId) {
24
+ if (!model.byScenario[scenarioId]) {
25
+ model.byScenario[scenarioId] = { runs: [], notes: [] };
26
+ model.order.push(scenarioId);
27
+ }
28
+ return model.byScenario[scenarioId];
29
+ }
30
+
31
+ function recordInModel(model, { scenarioId, statusLetter, notes = [] }) {
32
+ const entry = ensure(model, scenarioId);
33
+ entry.runs.push(statusLetter);
34
+ if (entry.runs.length > MAX_RUNS) entry.runs = entry.runs.slice(-MAX_RUNS);
35
+ for (const n of notes) entry.notes.push(n);
36
+ if (entry.notes.length > MAX_NOTES) entry.notes = entry.notes.slice(-MAX_NOTES);
37
+ return model;
38
+ }
39
+
40
+ function parseRunHistory(md) {
41
+ const model = emptyModel();
42
+ if (!md) return model;
43
+ let current = null;
44
+ for (const line of md.split(/\r?\n/)) {
45
+ const h = line.match(HEADING_RE);
46
+ if (h) {
47
+ const id = h[1].trim();
48
+ current = ensure(model, id);
49
+ const letters = h[2].trim();
50
+ current.runs = letters ? letters.split(/\s+/).filter(Boolean) : [];
51
+ continue;
52
+ }
53
+ const n = line.match(NOTE_RE);
54
+ if (n && current) {
55
+ current.notes.push({ date: n[1], text: n[2] });
56
+ }
57
+ }
58
+ return model;
59
+ }
60
+
61
+ function renderRunHistory(model) {
62
+ const out = [HEADER, HEADER_COMMENT, ''];
63
+ for (const id of model.order) {
64
+ const e = model.byScenario[id];
65
+ out.push(`## ${id} (last ${MAX_RUNS} runs: ${e.runs.join(' ')})`);
66
+ for (const note of e.notes) {
67
+ out.push(`- [${note.date}][observed] ${note.text}`);
68
+ }
69
+ out.push('');
70
+ }
71
+ return out.join('\n').replace(/\n+$/, '\n');
72
+ }
73
+
74
+ function countEntries(model) {
75
+ return model.order.reduce((sum, id) => sum + model.byScenario[id].notes.length, 0);
76
+ }
77
+
78
+ module.exports = {
79
+ MAX_RUNS,
80
+ MAX_NOTES,
81
+ emptyModel,
82
+ parseRunHistory,
83
+ renderRunHistory,
84
+ recordInModel,
85
+ countEntries,
86
+ };
@@ -0,0 +1,5 @@
1
+ 'use strict';
2
+
3
+ // Re-export shim: the canonical advisory lock now lives in src/util/lock.js.
4
+ // Kept so existing importers of src/memory/lock keep working.
5
+ module.exports = require('../util/lock');
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ // Canonical on-disk names for the three memory files. Keys are the stable
6
+ // `--kind` identifiers used across verbs; values are the markdown filenames.
7
+ const FILES = {
8
+ 'run-history': 'run-history.md',
9
+ 'app-knowledge': 'app-knowledge.md',
10
+ preferences: 'preferences.md',
11
+ };
12
+
13
+ // The stable `--kind` identifiers, derived from FILES so there is a single
14
+ // source of truth for "which memory kinds exist".
15
+ const KINDS = Object.keys(FILES);
16
+
17
+ // The two hand-authorable kinds. `run-history` is machine-owned (auto-harvested
18
+ // on `result finalize`) and is NOT a valid `memory add`/`forget` target.
19
+ const AGENT_KINDS = ['app-knowledge', 'preferences'];
20
+
21
+ // Markdown headers per kind, colocated with FILES/KINDS since they're the
22
+ // same per-kind metadata.
23
+ const HEADERS = {
24
+ 'run-history': '# Run History',
25
+ 'app-knowledge': '# App Knowledge',
26
+ preferences: '# Preferences',
27
+ };
28
+
29
+ function memoryDir(projectRoot) {
30
+ return path.join(projectRoot, 'mobile-automator', 'memory');
31
+ }
32
+
33
+ function memoryFile(projectRoot, name) {
34
+ return path.join(memoryDir(projectRoot), FILES[name] || name);
35
+ }
36
+
37
+ function sessionDir(projectRoot) {
38
+ return path.join(projectRoot, 'mobile-automator', '.session');
39
+ }
40
+
41
+ // The advisory lock guarding read-modify-write of the shared memory files.
42
+ // Lives in .session/ (already gitignored) alongside the device-session state.
43
+ function lockPath(projectRoot) {
44
+ return path.join(sessionDir(projectRoot), 'memory.lock');
45
+ }
46
+
47
+ module.exports = { memoryDir, memoryFile, sessionDir, lockPath, FILES, KINDS, AGENT_KINDS, HEADERS };
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { memoryFile, lockPath, KINDS, HEADERS } = require('./paths');
7
+ const { withLock } = require('../util/lock');
8
+ const { parseRunHistory, renderRunHistory, recordInModel, countEntries } = require('./history');
9
+ const { atomicWrite } = require('../util/atomic');
10
+ const { parseEntries, renderEntries, hasText } = require('./entries');
11
+
12
+ // Cross-session memory store. Mirrors ResultStore's one-shot-process,
13
+ // load-mutate-atomic-write shape, but the memory files are SHARED across runs
14
+ // (not keyed per-runId), so every read-modify-write goes through an advisory
15
+ // lock (see ./lock.js) to avoid lost updates.
16
+
17
+ function todayISO() {
18
+ return new Date().toISOString().slice(0, 10);
19
+ }
20
+
21
+ function statusLetter(status) {
22
+ return status === 'passed' || status === 'pass' ? 'P' : 'F';
23
+ }
24
+
25
+ // Fold a run's typed observations into dated note lines for the aggregate.
26
+ function notesFromResult(result) {
27
+ const date = todayISO();
28
+ return (result.observations || []).map((o) => {
29
+ const where = o.step_id ? ` (${o.step_id})` : '';
30
+ return { date, text: `${o.type}${where}: ${o.message}` };
31
+ });
32
+ }
33
+
34
+ const DEFAULT_CAP = 8000;
35
+
36
+ // Filter a run-history markdown body down to a single scenario's `##` section.
37
+ // Keeps the top-of-file header lines (everything before the first `##`
38
+ // heading) and, if present, the matching scenario's heading + its note lines;
39
+ // drops every other scenario's section.
40
+ //
41
+ // This is DELIBERATELY a string-level line filter, not a re-serialization
42
+ // from the parsed history model, mirroring the raw-preserving unfiltered
43
+ // render path above. Re-serializing from the model would silently drop any
44
+ // hand-edited line that doesn't fit the model's note shape — the files are
45
+ // designed to be hand-editable, so that data loss would be a behavior
46
+ // change, not a simplification. Do not "clean this up" into a model-based
47
+ // render without re-checking that invariant.
48
+ function filterScenario(md, scenario) {
49
+ const lines = md.split('\n');
50
+ const kept = [];
51
+ let sawHeading = false;
52
+ let inTarget = false;
53
+ // `scenario` is user-supplied (the CLI's --scenario flag); escape regex
54
+ // metacharacters so a value like "a(b" filters to zero matches instead of
55
+ // throwing an uncaught SyntaxError out of this read-only render path.
56
+ const safe = scenario.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ const headingRe = new RegExp(`^##\\s+${safe}\\s+\\(last`);
58
+ for (const line of lines) {
59
+ const isHeading = /^##\s+/.test(line);
60
+ if (isHeading) {
61
+ sawHeading = true;
62
+ inTarget = headingRe.test(line);
63
+ if (!inTarget) continue;
64
+ kept.push(line);
65
+ continue;
66
+ }
67
+ if (!sawHeading) {
68
+ // top-of-file header lines, before any scenario heading
69
+ kept.push(line);
70
+ continue;
71
+ }
72
+ if (inTarget) kept.push(line);
73
+ }
74
+ return kept.join('\n');
75
+ }
76
+
77
+ // Counts bullets for any file, reusing the history model for run-history.
78
+ function countEntriesFor(name, raw) {
79
+ if (!raw) return 0;
80
+ if (name === 'run-history') return countEntries(parseRunHistory(raw));
81
+ // app-knowledge / preferences: one bullet per entry.
82
+ return raw.split('\n').filter((l) => /^-\s+\[/.test(l)).length;
83
+ }
84
+
85
+ class MemoryStore {
86
+ constructor({ projectRoot } = {}) {
87
+ if (!projectRoot) throw new Error('MemoryStore requires a projectRoot');
88
+ this.projectRoot = projectRoot;
89
+ this.warnings = [];
90
+ }
91
+
92
+ _file(name) {
93
+ return memoryFile(this.projectRoot, name);
94
+ }
95
+
96
+ // Read a memory file's raw text, or '' if absent. A read failure other than
97
+ // ENOENT is preserved as a sidecar + warning (defensive parity with
98
+ // ResultStore) and treated as empty so a write never clobbers unread bytes.
99
+ _readRaw(name) {
100
+ const file = this._file(name);
101
+ try {
102
+ return fs.readFileSync(file, 'utf8');
103
+ } catch (e) {
104
+ if (e && e.code === 'ENOENT') return '';
105
+ return this._preserveCorrupt(file, e);
106
+ }
107
+ }
108
+
109
+ _preserveCorrupt(file, err) {
110
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
111
+ const sidecar = `${file}.corrupt.${ts}`;
112
+ let preserved = false;
113
+ try {
114
+ fs.renameSync(file, sidecar);
115
+ preserved = true;
116
+ } catch (_) {
117
+ /* best effort */
118
+ }
119
+ const where = preserved ? `preserved as ${path.basename(sidecar)}` : 'COULD NOT be preserved';
120
+ this.warnings.push(
121
+ `memory file ${file} was unreadable (${err.message}); ${where} and a fresh file was started`
122
+ );
123
+ return '';
124
+ }
125
+
126
+ // Delegates to the shared util (see src/util/atomic.js).
127
+ _atomicWrite(file, contents) {
128
+ atomicWrite(file, contents);
129
+ }
130
+
131
+ // Acquire the lock, read this kind's raw bytes, run the edit, and write ONLY when
132
+ // the edit produced new contents. Single owner of the "under lock + write-when-changed"
133
+ // invariant for every memory mutation.
134
+ _editUnderLock(kind, edit) {
135
+ return withLock(lockPath(this.projectRoot), () => {
136
+ const { contents, result } = edit(this._readRaw(kind));
137
+ if (contents != null) this._atomicWrite(this._file(kind), contents);
138
+ return result;
139
+ });
140
+ }
141
+
142
+ // Auto-harvest: fold a finalized result into the rolling run-history aggregate.
143
+ recordRun(result = {}) {
144
+ const scenarioId = result.scenario_id || 'unknown';
145
+ return this._editUnderLock('run-history', (raw) => {
146
+ const model = parseRunHistory(raw);
147
+ recordInModel(model, {
148
+ scenarioId,
149
+ statusLetter: statusLetter(result.status),
150
+ notes: notesFromResult(result),
151
+ });
152
+ return {
153
+ contents: renderRunHistory(model),
154
+ result: { scenarioId, runs: model.byScenario[scenarioId].runs.slice() },
155
+ };
156
+ });
157
+ }
158
+
159
+ // Agent-authored: append a durable [asserted] fact/preference under the lock.
160
+ // Exact-match de-dupe (same text already present → skip). Assumes `text` is
161
+ // already validated/sanitized by the verb handler.
162
+ add(kind, text) {
163
+ return this._editUnderLock(kind, (raw) => {
164
+ const entries = parseEntries(raw);
165
+ if (hasText(entries, text)) {
166
+ return { contents: undefined, result: { kind, added: false, deduped: true } };
167
+ }
168
+ entries.push({ date: todayISO(), text });
169
+ return { contents: renderEntries(kind, entries), result: { kind, added: true, deduped: false } };
170
+ });
171
+ }
172
+
173
+ // The correction path: remove entries whose text contains `match`.
174
+ forget(kind, match) {
175
+ return this._editUnderLock(kind, (raw) => {
176
+ const entries = parseEntries(raw);
177
+ const kept = entries.filter((e) => !e.text.includes(match));
178
+ const removed = entries.length - kept.length;
179
+ return { contents: removed > 0 ? renderEntries(kind, kept) : null, result: { kind, removed } };
180
+ });
181
+ }
182
+
183
+ // Render the raw markdown `mauto memory show` emits: a one-line summary
184
+ // comment (per-file entry counts + last-updated date) followed by the
185
+ // requested file bodies. Read-only — no lock needed.
186
+ render({ kind, scenario, cap = DEFAULT_CAP } = {}) {
187
+ const names = kind ? [kind] : KINDS;
188
+ const summary = [];
189
+ const sections = [];
190
+
191
+ for (const name of KINDS) {
192
+ const raw = this._safeRead(name);
193
+ const count = countEntriesFor(name, raw);
194
+ const updated = this._mtimeDate(name);
195
+ summary.push(`${name}: ${count} entrie(s)${updated ? `, updated ${updated}` : ''}`);
196
+ }
197
+
198
+ for (const name of names) {
199
+ let body = this._safeRead(name);
200
+ if (!body) body = `${HEADERS[name] || `# ${name}`}\n(no entries yet)\n`;
201
+ if (name === 'run-history' && scenario) body = filterScenario(body, scenario);
202
+ sections.push(body.trim());
203
+ }
204
+
205
+ let out = `<!-- mauto memory · ${summary.join(' · ')} -->\n\n${sections.join('\n\n')}\n`;
206
+ if (out.length > cap) {
207
+ out = `${out.slice(0, cap)}\n… (truncated at ${cap} chars — narrow with --kind/--scenario)\n`;
208
+ }
209
+ return out;
210
+ }
211
+
212
+ // Read without the corruption sidecar side effect (render is read-only).
213
+ _safeRead(name) {
214
+ try {
215
+ return fs.readFileSync(this._file(name), 'utf8');
216
+ } catch (_) {
217
+ return '';
218
+ }
219
+ }
220
+
221
+ _mtimeDate(name) {
222
+ try {
223
+ return fs.statSync(this._file(name)).mtime.toISOString().slice(0, 10);
224
+ } catch (_) {
225
+ return null;
226
+ }
227
+ }
228
+ }
229
+
230
+ module.exports = { MemoryStore, todayISO, statusLetter };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ // Stable contract between the `mauto` CLI and any calling agent.
4
+ // Every command prints exactly one envelope on stdout.
5
+
6
+ const SCHEMA_VERSION = '2.1';
7
+
8
+ const EXIT = {
9
+ OK: 0,
10
+ DEVICE: 2,
11
+ INVALID_INPUT: 3,
12
+ TARGET_NOT_FOUND: 4,
13
+ // Filesystem / workspace problem (perms, missing path, read-only FS, no
14
+ // disk) — an environment fault the caller can fix, distinct from exit 1
15
+ // ("the CLI itself broke"). Kept separate so agents/CI can branch on it.
16
+ ENVIRONMENT: 5,
17
+ CANCEL: 130,
18
+ };
19
+
20
+ // error.kind -> process exit code. `internal` (and anything unknown) -> 1.
21
+ const KIND_TO_CODE = {
22
+ ok: EXIT.OK,
23
+ device: EXIT.DEVICE,
24
+ // A daemon per-call timeout is a device-class failure (the action may have
25
+ // partially executed on the device) — exit 2 keeps the contract unchanged for
26
+ // callers branching on 2, while the envelope's error.kind stays 'timeout' so
27
+ // agents can distinguish "no response" from a hard device error.
28
+ timeout: EXIT.DEVICE,
29
+ invalid_input: EXIT.INVALID_INPUT,
30
+ target_not_found: EXIT.TARGET_NOT_FOUND,
31
+ environment: EXIT.ENVIRONMENT,
32
+ cancel: EXIT.CANCEL,
33
+ internal: 1,
34
+ // `init --agent all` partial failure: some hosts wired, some failed. Non-zero
35
+ // so callers/CI treat it as a failure; the envelope's data.agents[] is honest.
36
+ partial: 1,
37
+ };
38
+
39
+ function ok(data, hint = null) {
40
+ const env = { ok: true, data, schema_version: SCHEMA_VERSION };
41
+ // A success can still carry an advisory hint (e.g. recovered corruption).
42
+ // The key is only present when there is something to say, so the common
43
+ // case stays the bare {ok,data,schema_version} envelope.
44
+ if (hint) env.hint = hint;
45
+ return env;
46
+ }
47
+
48
+ function fail(kind, message, hint = null, data = undefined) {
49
+ const env = {
50
+ ok: false,
51
+ error: { kind, message },
52
+ hint,
53
+ schema_version: SCHEMA_VERSION,
54
+ };
55
+ // Some failures carry actionable structured detail (e.g. a per-agent
56
+ // ok/failed map for `init --agent all`); include `data` only when given so
57
+ // the common fail shape is unchanged.
58
+ if (data !== undefined) env.data = data;
59
+ return env;
60
+ }
61
+
62
+ function exitCodeFor(kind) {
63
+ if (Object.prototype.hasOwnProperty.call(KIND_TO_CODE, kind)) {
64
+ return KIND_TO_CODE[kind];
65
+ }
66
+ return 1;
67
+ }
68
+
69
+ function render(envelope, { human = false } = {}) {
70
+ if (!human) {
71
+ return JSON.stringify(envelope);
72
+ }
73
+
74
+ if (envelope.ok) {
75
+ const data = envelope.data;
76
+ let line;
77
+ if (Array.isArray(data)) {
78
+ line = `ok: ${data.length} item(s)`;
79
+ } else if (data && typeof data === 'object') {
80
+ const keys = Object.keys(data);
81
+ line = `ok: ${keys.length ? keys.join(', ') : '(no data)'}`;
82
+ } else {
83
+ line = `ok: ${String(data)}`;
84
+ }
85
+ if (envelope.hint) {
86
+ line += `\nhint: ${envelope.hint}`;
87
+ }
88
+ return line;
89
+ }
90
+
91
+ const { kind, message } = envelope.error || {};
92
+ let out = `error [${kind}]: ${message}`;
93
+ if (envelope.hint) {
94
+ out += `\nhint: ${envelope.hint}`;
95
+ }
96
+ return out;
97
+ }
98
+
99
+ module.exports = {
100
+ SCHEMA_VERSION,
101
+ EXIT,
102
+ ok,
103
+ fail,
104
+ exitCodeFor,
105
+ render,
106
+ };