'use strict'; // Regression test for #2220: headless mempalace-capture instructed // `mempalace mine --wing --room `, but `mine` has no // --room flag (only `search` does). The fix replaced the flag with a // detect_room()-based staging approach. // // #2414 extension: the staging block also embeds a `rooms:` YAML example. // mempalace's miner (`detect_room()` and `_mine_impl`) requires each entry // to be a DICT with at least a `name` key — a bare-string list crashes the // first `mine` invocation with `TypeError: string indices must be integers, // not 'str'`. The fix converts each `- ` to `- name: `. // // Docs sources: // CLI reference: https://mempalaceofficial.com/reference/cli.html // Mining guide: https://mempalaceofficial.com/guide/mining.html // Config guide: https://mempalaceofficial.com/guide/configuration.html const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); const { splitLines } = require('../msd-core/bin/lib/text-lines.cjs'); const ROOT = path.resolve(__dirname, '..'); const SKILL_FILES = [ 'skills/msd-mempalace-capture/SKILL.md', 'commands/msd/mempalace-capture.md', ]; // Matches actual shell command lines that invoke `mempalace mine` // (line starts with optional whitespace + "mempalace mine"). Excludes // prose/instruction text that merely mentions the command. const MINE_CMD_RE = /^\s*mempalace\s+mine\b/; describe('#2220 — headless mempalace mine has no --room flag', () => { for (const rel of SKILL_FILES) { test(`${rel}: no "mine ... --room" command line`, () => { const content = fs.readFileSync(path.join(ROOT, rel), 'utf8'); const mineCmdLines = content .split(/\r?\n/) .filter((l) => MINE_CMD_RE.test(l)); for (const line of mineCmdLines) { assert.doesNotMatch( line, /--room/, `${rel}: "mempalace mine" command must not include --room (mine has no such flag per CLI reference). Offending line: ${line.trim()}`, ); } }); test(`${rel}: staging instructions present (mempalace.yaml + detect_room)`, () => { const content = fs.readFileSync(path.join(ROOT, rel), 'utf8'); assert.ok(content.includes('mempalace.yaml'), `${rel}: must reference mempalace.yaml for room taxonomy`); assert.ok(content.includes('detect_room'), `${rel}: must reference detect_room() as the room-assignment mechanism`); assert.ok(content.includes('.mempalace-stage'), `${rel}: must use the .mempalace-stage staging directory`); }); } test('capture-problems.md: references detect_room staging, not mine --room', () => { const rel = 'capabilities/mempalace/fragments/capture-problems.md'; const content = fs.readFileSync(path.join(ROOT, rel), 'utf8'); const mineCmdLines = content .split(/\r?\n/) .filter((l) => MINE_CMD_RE.test(l)); for (const line of mineCmdLines) { assert.doesNotMatch( line, /--room/, `${rel}: "mempalace mine" command must not include --room. Offending line: ${line.trim()}`, ); } }); test('.gitignore: .mempalace-stage/ is excluded', () => { const content = fs.readFileSync(path.join(ROOT, '.gitignore'), 'utf8'); assert.ok(content.includes('.mempalace-stage/'), '.gitignore must exclude .planning/.mempalace-stage/'); }); }); // ─── #2414: rooms: entries MUST be dicts with a `name` key ─────────────────── // // The skill files embed a `rooms:` YAML example inside a bash heredoc. The // miner's `detect_room()` and `_mine_impl` index `room["name"]` — a bare-string // list crashes the first `mine` invocation with // `TypeError: string indices must be integers, not 'str'`. // // This describe block extracts the YAML block from each file, parses it with // js-yaml, and asserts every entry under `rooms:` is a dict carrying a `name` // key. It also forbids the pre-fix shape (`- decisions` etc.) so a future // reversion can't slip back in silently. describe('#2414 — rooms: entries are dicts with a name key (not bare strings)', () => { // Extract the YAML `rooms:` block from a markdown file. The block lives // inside a bash heredoc that ends with `YAML` on its own line. We capture // from the line beginning with `rooms:` through the closing `YAML` sentinel. function extractRoomsYaml(content) { const lines = content.split(/\r?\n/); const start = lines.findIndex((l) => /^\s*rooms:\s*$/.test(l)); if (start === -1) return null; let end = -1; for (let i = start + 1; i < lines.length; i++) { // The heredoc terminator in the embedded bash is `YAML` on its own line // (possibly with leading whitespace from the markdown fence indentation). if (/^\s*YAML\s*$/.test(lines[i])) { end = i; break; } } if (end === -1) return null; return lines.slice(start, end).join('\n'); } for (const rel of SKILL_FILES) { test(`${rel}: every rooms: entry is a dict with a \`name\` key`, () => { const content = fs.readFileSync(path.join(ROOT, rel), 'utf8'); const yamlBlock = extractRoomsYaml(content); assert.ok(yamlBlock, `${rel}: could not locate a rooms: YAML block`); const parsed = yaml.load(yamlBlock); assert.ok(parsed && Array.isArray(parsed.rooms), `${rel}: rooms: must parse to an array`); assert.ok(parsed.rooms.length >= 5, `${rel}: expected the MSD room taxonomy (≥5 rooms), got ${parsed.rooms.length}`); for (const [i, entry] of parsed.rooms.entries()) { assert.ok( entry !== null && typeof entry === 'object' && !Array.isArray(entry), `${rel}: rooms:[${i}] must be a dict, got ${JSON.stringify(entry)} (a bare-string list crashes mempalace miner's detect_room with TypeError: string indices must be integers — #2414)`, ); assert.ok( typeof entry.name === 'string' && entry.name.length > 0, `${rel}: rooms:[${i}] must have a non-empty name string, got ${JSON.stringify(entry)}`, ); } }); test(`${rel}: no bare-string rooms: entries remain (forbid the pre-fix shape)`, () => { // Direct guard against the pre-fix shape — any line under rooms: that // matches `^- $` (no colon) is the broken form. Belt-and-suspenders // alongside the parse-and-shape check above; keeps the assertion readable // when the YAML parser is removed/refactored in the future. const content = fs.readFileSync(path.join(ROOT, rel), 'utf8'); const yamlBlock = extractRoomsYaml(content); assert.ok(yamlBlock, `${rel}: could not locate a rooms: YAML block`); const bareStringEntries = yamlBlock .split(/\r?\n/) .filter((l) => /^\s*-\s+[A-Za-z][A-Za-z0-9_-]*\s*$/.test(l)); assert.strictEqual( bareStringEntries.length, 0, `${rel}: rooms: must not contain bare-string entries (crashes mempalace miner — #2414). Offenders: ${JSON.stringify(bareStringEntries)}`, ); }); } }); // ── #4700 — the headless mine must queue on the palace lock, and a skipped // capture must be visible in the report. MemPalace wraps every mine in a // per-palace lock; a concurrent writer makes a plain foreground `mine` exit 1 // (MineAlreadyRunning) and the capability's onError: skip silently dropped the // capture. The shipped command queues via --daemon --background (MemPalace // #2029), and the report step names the queued/skipped outcome. describe('#4700 — headless mine queues on the palace lock', () => { const SURFACES = [ 'commands/msd/mempalace-capture.md', 'skills/msd-mempalace-capture/SKILL.md', ]; test('every mempalace mine command line queues via --daemon --background (#4700)', () => { for (const rel of SURFACES) { const lines = splitLines(fs.readFileSync(path.join(ROOT, rel), 'utf8')); let sawMine = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!MINE_CMD_RE.test(line)) continue; sawMine = true; assert.match( line, /--daemon\s+--background/, `${rel}:${i + 1}: "mempalace mine" must queue via --daemon --background so a held palace lock defers the write instead of dropping it (#4700). Offending line: ${line.trim()}`, ); } assert.ok(sawMine, `${rel}: expected at least one mempalace mine command line`); } }); test('the wave:post problems fragment queues its headless mine too (#4700)', () => { const frag = 'capabilities/mempalace/fragments/capture-problems.md'; const content = fs.readFileSync(path.join(ROOT, frag), 'utf8'); assert.match( content, /mempalace mine --daemon --background/, `${frag}: the headless mine must queue via --daemon --background — the issue names the execute:wave:post problem-fix pair as the unrecoverable loss (#4700)`, ); }); test('the report step names queued and skipped captures (#4700)', () => { for (const rel of SURFACES) { const content = fs.readFileSync(path.join(ROOT, rel), 'utf8'); assert.match(content, /queued/, `${rel}: the report step must surface the queued outcome (#4700)`); assert.match(content, /skipped/i, `${rel}: the report step must name skipped captures rather than staying silent (#4700)`); } }); });