Files
msd-core/tests/mempalace-capture-headless-invocation.test.cjs
Tom Boucher be1b76dddd fix(#4700): queue the headless mempalace mine on the palace lock (#4821)
* test(#4699): add failing-first coverage for skipping complete phases in next_phase

* fix(#4699): skip already-complete phases in the next_phase cascade

Both next-phase scans selected the numerically lowest phase above N
without consulting completion state, so completing a reopened phase
persisted an already-[x] phase as STATE.md current_phase while
roadmap.analyze correctly named the outstanding one (issue repro:
completing 2 with phases 1 and 3 already [x] returned next_phase 03).

The cascade collects the complete phase numbers from the roadmap
checkboxes (milestone-scoped, comparePhaseNum-deduped) and skips them in
both the disk scan and the roadmap scan; a [x] checkbox row and its
heading sibling both name a phase that is never next. Heading-only and
checkbox-less roadmaps behave exactly as before.

* test(#4699): align the negative-control expectation with the disk spelling

* test(#4699): pin the STATE.md persistence and the all-later-complete tail corner

Review findings: the regression never asserted STATE.md current_phase
(the issue's actual harm), and the all-later-phases-[x] corner
(is_last_phase true, next_phase null) was unpinned. A changeset fragment
is included.

* docs(#4699): backfill changeset PR number

* test(#4700): add failing-first coverage for the queued headless mine

* fix(#4700): queue the headless mempalace mine and surface skipped captures

The capture's mine ran in the foreground with no lock handling: MemPalace
wraps every mine in a per-palace lock, so any concurrent writer (two
phases finishing a stage at once, a git-hook refresh mining the same
palace) made it exit 1 (MineAlreadyRunning) and the onError: skip step
silently dropped the capture — unlost for CONTEXT/PLAN/SUMMARY files that
can be re-filed, unrecoverable for execute:wave:post problem-fix pairs.

The mine now queues via --daemon --background (MemPalace #2029: the daemon
holds a job refused the lock and runs it when the holder exits), and the
report step gains the queued and skipped outcomes per #4700's requirement
that a skipped capture never stay silent. Option 2 (write_routing.cli) is
unreleased at MemPalace 3.9.0; option 3 (retry) re-enters the same lock
race — both declined in the PR body.

* fix(#4700): queue the wave:post problems fragment's headless mine too

The issue names the execute:wave:post problem-fix pair as the
unrecoverable loss (no source file to re-file later); the
capture-problems fragment's headless mine ran foreground like the capture
capability's did. Same fix: --daemon --background, with the lock-deferral
rationale inline.

* docs(#4700): backfill changeset PR number

* fix(#4682): register the stale-reverification part in the capability registry

The new steps/ part is a shipped workflow file; gen-capability-registry
--check requires it in the committed registry.

---------

Co-authored-by: sim <sim@local>
2026-09-17 06:30:36 -04:00

203 lines
9.3 KiB
JavaScript

'use strict';
// Regression test for #2220: headless mempalace-capture instructed
// `mempalace mine <path> --wing <wing> --room <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 `- <room>` to `- name: <room>`.
//
// 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('../gsd-core/bin/lib/text-lines.cjs');
const ROOT = path.resolve(__dirname, '..');
const SKILL_FILES = [
'skills/gsd-mempalace-capture/SKILL.md',
'commands/gsd/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 GSD 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 `^- <word>$` (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/gsd/mempalace-capture.md',
'skills/gsd-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)`);
}
});
});