fix(ultraplan-local): Bug 3 — wire frontmatter consistency check into /ultracontinue Phase 1.5
Step 8 of v3.4.1 plan. commands/ultracontinue-local.md: - New Phase 1.5 between Phase 1 and Phase 2 — runs the next-session-prompt-validator in --consistency mode when both candidates exist (plugin-root + project-dir). Refuses on producer mismatch with fresh candidates, downgrades stale candidate to a warning, downgrades >24h wall-clock drift to a soft warning. - Anti-substitution rule applies — paths emitted as concrete tokens, not template placeholders. lib/validators/next-session-prompt-validator.mjs: - Sharpen NEXT_SESSION_PROMPT_PRODUCER_MISMATCH error message to include the literal "produced_by" field name so consumers (and operators) can trace the disagreement back to the YAML key. tests/commands/ultracontinue.test.mjs: - Test (Bug 3 prose) — Phase 1.5 header present, references validator, appears between Phase 1 and Phase 2 in document order. - Test (Bug 3 e) — tmp project dir with state file + two prompt files with mismatched producers, both fresh relative to state.updated_at; CLI consistency mode exits non-zero, JSON stdout surfaces NEXT_SESSION_PROMPT_PRODUCER_MISMATCH with both paths and the "produced_by" token in the message. Tests 346 -> 348 (+2). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
512ae322bd
commit
37108ae899
3 changed files with 125 additions and 1 deletions
|
|
@ -117,6 +117,55 @@ resumable state wins.
|
||||||
with that path. (Operators who want a different candidate re-invoke as
|
with that path. (Operators who want a different candidate re-invoke as
|
||||||
`/ultracontinue <project-dir>`.)
|
`/ultracontinue <project-dir>`.)
|
||||||
|
|
||||||
|
## Phase 1.5 — Frontmatter consistency check
|
||||||
|
|
||||||
|
Bug 3 contract: producers (`/ultraexecute-local`, `/ultraplan-end-session-local`)
|
||||||
|
write `NEXT-SESSION-PROMPT.local.md` with YAML frontmatter (`produced_by:`,
|
||||||
|
`produced_at:`). Multiple producers may have written candidates in different
|
||||||
|
locations; this phase refuses ambiguity before validating the state file.
|
||||||
|
|
||||||
|
After resolving the project directory and state-file path, look for two
|
||||||
|
`NEXT-SESSION-PROMPT.local.md` candidates:
|
||||||
|
|
||||||
|
a. `<plugin-root>/NEXT-SESSION-PROMPT.local.md` — operator-managed master file
|
||||||
|
b. `<project-dir>/NEXT-SESSION-PROMPT.local.md` — producer-written sibling
|
||||||
|
|
||||||
|
**If both exist:**
|
||||||
|
|
||||||
|
- Read both via the **Read tool** (NOT Bash — same anti-substitution rule
|
||||||
|
as Phase 2).
|
||||||
|
- Invoke the consistency validator with both paths emitted as concrete
|
||||||
|
literal tokens (no template substitution at the Bash boundary):
|
||||||
|
|
||||||
|
```
|
||||||
|
node lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B>
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<RESOLVED-PATH-A>` and `<RESOLVED-PATH-B>` with the two concrete
|
||||||
|
filesystem paths you have in your working context. The validator emits
|
||||||
|
`{valid, errors, warnings}` JSON on stdout.
|
||||||
|
|
||||||
|
- **If `valid: false`** (typically `NEXT_SESSION_PROMPT_PRODUCER_MISMATCH`):
|
||||||
|
print the structured `errors[]` (each `[code] message` on its own line),
|
||||||
|
list both candidate paths, and exit non-zero. Do NOT proceed to Phase 2.
|
||||||
|
Resolve the conflict by deleting the stale candidate (run
|
||||||
|
`/ultracontinue-local --cleanup --confirm <project-dir>` after the
|
||||||
|
current session closes, or remove by hand).
|
||||||
|
|
||||||
|
- **If `valid: true` with a `NEXT_SESSION_PROMPT_WALL_CLOCK_DRIFT` warning**
|
||||||
|
(one of the candidates is more than 24h old): print the warning to stderr
|
||||||
|
but continue — long pauses (weekend, vacation) are not failures.
|
||||||
|
|
||||||
|
- **If `valid: true` with a `NEXT_SESSION_PROMPT_STALE_IGNORED` warning**
|
||||||
|
(one candidate is older than the state file's `updated_at`): print the
|
||||||
|
warning and continue. The state-anchored check is the primary refusal
|
||||||
|
signal; staleness simply rejects the older candidate.
|
||||||
|
|
||||||
|
**If only one exists:** continue to Phase 2. No comparison needed.
|
||||||
|
|
||||||
|
**If neither exists:** continue to Phase 2. Legacy projects and first-run
|
||||||
|
flows have no NEXT-SESSION-PROMPT files.
|
||||||
|
|
||||||
## Phase 2 — Validate the state file
|
## Phase 2 — Validate the state file
|
||||||
|
|
||||||
Phase 1 resolved a concrete state-file path. That path is a real string in
|
Phase 1 resolved a concrete state-file path. That path is a real string in
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ export function validateNextSessionPromptConsistency(a, b, opts = {}) {
|
||||||
if (producerMismatch && bothFresh) {
|
if (producerMismatch && bothFresh) {
|
||||||
errors.push(issue(
|
errors.push(issue(
|
||||||
'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH',
|
'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH',
|
||||||
`Producers disagree: "${aFm.produced_by}" (${a.path}) vs "${bFm.produced_by}" (${b.path})`,
|
`Frontmatter "produced_by" disagrees: "${aFm.produced_by}" (${a.path}) vs "${bFm.produced_by}" (${b.path})`,
|
||||||
'One file is stale or producers wrote conflicting frontmatter. Resolve manually.',
|
'One file is stale or producers wrote conflicting frontmatter. Resolve manually.',
|
||||||
));
|
));
|
||||||
} else if (producerMismatch && (aStale || bStale)) {
|
} else if (producerMismatch && (aStale || bStale)) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'nod
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
import { runHook } from '../helpers/hook-helper.mjs';
|
import { runHook } from '../helpers/hook-helper.mjs';
|
||||||
|
|
||||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -170,6 +171,80 @@ test('ultracontinue Bug 2 — pre-bash-executor ALLOWS resolved validator invoca
|
||||||
assert.strictEqual(code, 0, 'pre-bash-executor must not block resolved validator invocations');
|
assert.strictEqual(code, 0, 'pre-bash-executor must not block resolved validator invocations');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Step 8 — Bug 3 regression test (Phase 1.5 consistency wire-up)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
test('ultracontinue Bug 3 — Phase 1.5 documents consistency check between Phase 1 and Phase 2', () => {
|
||||||
|
const cmd = readCommand();
|
||||||
|
// Phase 1.5 must exist literally in the prose between Phase 1 and Phase 2.
|
||||||
|
assert.match(cmd, /## Phase 1\.5 /, 'Phase 1.5 header must be present');
|
||||||
|
assert.match(cmd, /next-session-prompt-validator/, 'Phase 1.5 must invoke next-session-prompt-validator');
|
||||||
|
|
||||||
|
const phase15Idx = cmd.indexOf('## Phase 1.5 ');
|
||||||
|
const phase2Idx = cmd.indexOf('## Phase 2 ');
|
||||||
|
assert.ok(phase15Idx !== -1 && phase2Idx !== -1 && phase15Idx < phase2Idx,
|
||||||
|
'Phase 1.5 must appear before Phase 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ultracontinue Bug 3 (e) — CLI consistency mode flags producer mismatch in JSON output', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'ultracontinue-fm-'));
|
||||||
|
try {
|
||||||
|
const projectDir = join(root, '.claude', 'projects', '2026-05-04-fixture-c');
|
||||||
|
mkdirSync(projectDir, { recursive: true });
|
||||||
|
|
||||||
|
// State file (status: in_progress, updated_at = T-base)
|
||||||
|
const stateUpdatedAt = '2026-05-04T15:00:00.000Z';
|
||||||
|
writeFileSync(
|
||||||
|
join(projectDir, '.session-state.local.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
schema_version: 1,
|
||||||
|
project: projectDir,
|
||||||
|
next_session_brief_path: join(projectDir, 'brief.md'),
|
||||||
|
next_session_label: 'Session 2',
|
||||||
|
status: 'in_progress',
|
||||||
|
updated_at: stateUpdatedAt,
|
||||||
|
}, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Project-dir prompt: produced_by ultraexecute-local at T-1
|
||||||
|
const projectPrompt = join(projectDir, 'NEXT-SESSION-PROMPT.local.md');
|
||||||
|
writeFileSync(projectPrompt,
|
||||||
|
'---\nproduced_by: ultraexecute-local\nproduced_at: 2026-05-04T15:30:00.000Z\n---\n\n# Session 2\n');
|
||||||
|
|
||||||
|
// Plugin-root prompt: produced_by graceful-handoff at T-0 (newer)
|
||||||
|
const pluginPrompt = join(root, 'NEXT-SESSION-PROMPT.local.md');
|
||||||
|
writeFileSync(pluginPrompt,
|
||||||
|
'---\nproduced_by: graceful-handoff\nproduced_at: 2026-05-04T15:31:00.000Z\n---\n\n# A2 master\n');
|
||||||
|
|
||||||
|
// Both fresh relative to state.updated_at → producer mismatch must hard-fail.
|
||||||
|
let exitCode = 0;
|
||||||
|
let stdout = '';
|
||||||
|
try {
|
||||||
|
stdout = execFileSync(process.execPath, [
|
||||||
|
join(ROOT, 'lib', 'validators', 'next-session-prompt-validator.mjs'),
|
||||||
|
'--json',
|
||||||
|
'--consistency',
|
||||||
|
projectPrompt,
|
||||||
|
pluginPrompt,
|
||||||
|
], { encoding: 'utf-8', cwd: ROOT });
|
||||||
|
} catch (e) {
|
||||||
|
exitCode = e.status;
|
||||||
|
stdout = e.stdout ? e.stdout.toString() : '';
|
||||||
|
}
|
||||||
|
assert.notEqual(exitCode, 0, 'consistency CLI must exit non-zero on producer mismatch');
|
||||||
|
const parsed = JSON.parse(stdout);
|
||||||
|
assert.equal(parsed.valid, false);
|
||||||
|
const mismatch = parsed.errors.find(e => e.code === 'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH');
|
||||||
|
assert.ok(mismatch, 'must surface NEXT_SESSION_PROMPT_PRODUCER_MISMATCH error');
|
||||||
|
assert.match(mismatch.message, new RegExp(projectPrompt.replace(/[/\\]/g, '.')), 'error message must reference project-dir prompt path');
|
||||||
|
assert.match(mismatch.message, new RegExp(pluginPrompt.replace(/[/\\]/g, '.')), 'error message must reference plugin-root prompt path');
|
||||||
|
assert.match(mismatch.message, /produced_by/i, 'error message must mention produced_by');
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('ultracontinue Bug 2 — Phase 2 contains no {state-file-path} or any {curly-template} placeholder', () => {
|
test('ultracontinue Bug 2 — Phase 2 contains no {state-file-path} or any {curly-template} placeholder', () => {
|
||||||
// (d-2) Pattern D structure test. The fix must eliminate the
|
// (d-2) Pattern D structure test. The fix must eliminate the
|
||||||
// {state-file-path} placeholder and any other {anything} curly-brace
|
// {state-file-path} placeholder and any other {anything} curly-brace
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue