diff --git a/plugins/ultraplan-local/commands/ultracontinue-local.md b/plugins/ultraplan-local/commands/ultracontinue-local.md index 85f154e..7562c94 100644 --- a/plugins/ultraplan-local/commands/ultracontinue-local.md +++ b/plugins/ultraplan-local/commands/ultracontinue-local.md @@ -117,6 +117,55 @@ resumable state wins. with that path. (Operators who want a different candidate re-invoke as `/ultracontinue `.) +## 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. `/NEXT-SESSION-PROMPT.local.md` — operator-managed master file + b. `/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 + ``` + + Replace `` and `` 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 ` 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 1 resolved a concrete state-file path. That path is a real string in diff --git a/plugins/ultraplan-local/lib/validators/next-session-prompt-validator.mjs b/plugins/ultraplan-local/lib/validators/next-session-prompt-validator.mjs index 34e2edd..931e0fc 100644 --- a/plugins/ultraplan-local/lib/validators/next-session-prompt-validator.mjs +++ b/plugins/ultraplan-local/lib/validators/next-session-prompt-validator.mjs @@ -122,7 +122,7 @@ export function validateNextSessionPromptConsistency(a, b, opts = {}) { if (producerMismatch && bothFresh) { errors.push(issue( '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.', )); } else if (producerMismatch && (aStale || bStale)) { diff --git a/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs b/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs index 491d413..130974a 100644 --- a/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs +++ b/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs @@ -15,6 +15,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'nod import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; import { runHook } from '../helpers/hook-helper.mjs'; 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'); }); +// --------------------------------------------------------------- +// 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', () => { // (d-2) Pattern D structure test. The fix must eliminate the // {state-file-path} placeholder and any other {anything} curly-brace