diff --git a/plugins/ultraplan-local/CLAUDE.md b/plugins/ultraplan-local/CLAUDE.md index 3235a32..4fc01c5 100644 --- a/plugins/ultraplan-local/CLAUDE.md +++ b/plugins/ultraplan-local/CLAUDE.md @@ -109,6 +109,10 @@ Tests under `tests/**/*.test.mjs` (109 tests, 0 deps). `npm test` is the fork-re Doc-consistency test at `tests/lib/doc-consistency.test.mjs` pins agent-table count, command-table coverage, plan_version invariant, and settings.json scope cleanliness. +`docs/HANDOVER-CONTRACTS.md` is the single source of truth for the 5 pipeline handovers (brief→research, research→plan, architecture→plan EXTERNAL, plan→execute, progress.json resume). Read it before changing any artifact format. + +`hooks/scripts/pre-compact-flush.mjs` (PreCompact event, CC v2.1.105+) fixes the documented P0 in `docs/ultraexecute-v2-observations-from-config-audit-v4.md`: keeps `progress.json` in sync with git history before context compaction so `--resume` works after long conversations. Atomic write, monotonic only, never blocks compaction. + ## Architecture **Brief:** 7-phase workflow: Parse mode → Create project dir → Phase 3 completeness loop (section-driven, no question cap) → Phase 4 draft/review/revise with `brief-reviewer` as stop-gate (max 3 iterations; gate = all dimensions ≥ 4 and research plan = 5) → Finalize (`brief.md` on pass, or `brief_quality: partial` on cap/force-stop) → Manual/auto opt-in → Stats. Always interactive. Auto mode runs research + plan inline in the main context (v2.4.0). diff --git a/plugins/ultraplan-local/README.md b/plugins/ultraplan-local/README.md index d7ac23a..0f38b10 100644 --- a/plugins/ultraplan-local/README.md +++ b/plugins/ultraplan-local/README.md @@ -498,6 +498,14 @@ A doc-consistency test (`tests/lib/doc-consistency.test.mjs`) pins prose-vs-sour Borrowed pattern from `llm-security` (commit `97c5c9d`); extending the plugin should preserve the invariants the test pins. +### Handover contracts + +`docs/HANDOVER-CONTRACTS.md` is the single source of truth for the file formats that pass between the four pipeline commands (brief → research → plan → execute). When you fork the plugin or extend a stage, that document tells you what every producer must write and what every consumer is allowed to assume. It also documents the *external* contract for `architecture/overview.md` (owned by the separate `ultra-cc-architect` plugin) — discovery only, drift-warn never drift-fail. + +### PreCompact resume integrity (CC v2.1.105+) + +The `pre-compact-flush.mjs` hook directly fixes the documented P0 in `docs/ultraexecute-v2-observations-from-config-audit-v4.md`: in skill-driven execution, `progress.json` could fall behind git reality before context compaction, breaking `/ultraexecute-local --resume` after long conversations. The hook fires on every PreCompact event, locates any `progress.json` under `.claude/projects/`, compares stored `current_step` against `git log --oneline {session_start_sha}..HEAD`, and atomically writes a fresh checkpoint (`tmp + rename`, monotonic only) when git is ahead. Never blocks compaction. + ## Known limitations **Infrastructure-as-code (IaC) gets reduced value.** The exploration agents are designed for application code. Terraform, Helm, Pulumi, CDK projects will get a plan, but agents like `architecture-mapper` and `test-strategist` produce less useful output for IaC. Use ultraplan-local for the structural plan, then supplement IaC-specific steps manually. diff --git a/plugins/ultraplan-local/docs/HANDOVER-CONTRACTS.md b/plugins/ultraplan-local/docs/HANDOVER-CONTRACTS.md new file mode 100644 index 0000000..dee975f --- /dev/null +++ b/plugins/ultraplan-local/docs/HANDOVER-CONTRACTS.md @@ -0,0 +1,295 @@ +# Handover Contracts (ultra-suite local pipeline) + +This document is the single source of truth for the file formats that pass between the four commands of the `ultraplan-local` pipeline. When you fork the plugin or extend a stage, the contracts below tell you what every producer must write and what every consumer is allowed to assume. + +For each handover, the same headings appear in the same order: **Producer**, **Consumer**, **Path conventions**, **Frontmatter schema**, **Body invariants**, **Validation strategy**, **Versioning**, **Failure modes**. + +## Versioning policy + +Each artifact carries an explicit version field. Schema bumps are coordinated: + +| Artifact | Field | Current | +|---|---|---| +| `brief.md` | `brief_version` (frontmatter) | `2.0` | +| `research/*.md` | (implicit; tracked via `type: ultraresearch-brief`) | unversioned | +| `plan.md` | `plan_version` (frontmatter) | `1.7` | +| `progress.json` | `schema_version` (top-level) | `"1"` | + +## Breaking-change protocol + +1. Bump the artifact's version field. +2. Update the matching validator in `lib/validators/`. +3. Add a fixture under `tests/fixtures/` covering both old and new shapes. +4. Document the change in `MIGRATION.md` with at least an N-1 compatibility window in the validator (read both shapes; warn on old, fail only after one minor version of warning). +5. Bump the plugin version in `package.json` and `.claude-plugin/plugin.json`. + +## Validator → handover map + +| Handover | Validator | +|---|---| +| 1. brief → research | `lib/validators/brief-validator.mjs` | +| 2. research → plan | `lib/validators/research-validator.mjs` | +| 3. architecture → plan | `lib/validators/architecture-discovery.mjs` | +| 4. plan → execute | `lib/validators/plan-validator.mjs` | +| 5. progress.json (resume) | `lib/validators/progress-validator.mjs` | + +Every validator exposes a CLI: `node lib/validators/.mjs --json ` returns `{valid, errors[], warnings[], parsed}`. Errors and warnings have stable `code` fields for downstream tooling. + +--- + +## Handover 1 — `brief.md` → research/ + +**Producer:** `/ultrabrief-local` Phase 4g (after `brief-reviewer` stop-gate passes or iteration cap is hit). + +**Consumer:** `/ultraresearch-local` Phase 1 (mode parse + brief validation). + +**Path conventions:** +- Project-dir mode (recommended): `.claude/projects/{YYYY-MM-DD}-{slug}/brief.md`. +- Legacy / loose mode: any path passed via `--brief `. + +**Frontmatter schema:** + +| Field | Type | Required | Allowed values | Notes | +|---|---|---|---|---| +| `type` | string | yes | `ultrabrief` | Hard-coded discriminator | +| `brief_version` | string | yes | `"2.0"` (current) | Bump on schema change | +| `created` | date | yes | YYYY-MM-DD | | +| `task` | string | yes | one-line description | | +| `slug` | string | yes | URL-safe slug | Used in project_dir | +| `project_dir` | string | yes | `.claude/projects/{date}-{slug}/` | | +| `research_topics` | number | yes | ≥ 0 | | +| `research_status` | string | yes | `pending \| in_progress \| complete \| skipped` | State machine — see below | +| `auto_research` | bool | optional | `true \| false` | | +| `interview_turns` | number | optional | ≥ 0 | | +| `source` | string | optional | `interview \| manual` | | +| `brief_quality` | string | optional | `complete \| partial` | Set when iteration cap is hit | + +**Body invariants:** required sections (validator runs in strict mode at write-time, soft mode at read-time): +- `## Intent` +- `## Goal` +- `## Success Criteria` + +Optional but standard sections: `## Non-Goals`, `## Constraints`, `## Preferences`, `## Non-Functional Requirements`, `## Research Plan`. + +**Validation strategy:** + +| Layer | When | What | +|---|---|---| +| Frontmatter parse | every read | YAML subset; reject nested dicts | +| Required fields | every read | All `BRIEF_REQUIRED_FRONTMATTER` present | +| Type discriminator | every read | `type === "ultrabrief"` | +| Status enum | every read | `research_status ∈ allowed values` | +| **State machine** | every read | `research_topics > 0 && research_status === "skipped"` requires `brief_quality === "partial"` | +| Body sections | strict only | All `BRIEF_BODY_SECTIONS` present | + +**State machine** detail: a brief that says it has research topics but skipped them must explicitly admit it (via `brief_quality: partial`). This is the most common failure mode the validator catches. + +**Versioning:** current is `2.0`. There are no live `1.x` briefs; remove legacy paths in next major. + +**Failure modes:** +- `BRIEF_NOT_FOUND` → consumer halts with a usage message +- `FM_MISSING` → file has no frontmatter; halt +- `BRIEF_WRONG_TYPE` → file is not a brief; halt +- `BRIEF_MISSING_FIELD` → strict halt; soft-mode warning +- `BRIEF_STATE_INCOHERENT` → strict halt; soft-mode warning (incoherence will haunt downstream agents) +- `BRIEF_MISSING_SECTION` → strict halt; soft-mode warning + +--- + +## Handover 2 — research/*.md → plan + +**Producer:** `/ultraresearch-local` Phase 7 (synthesis + brief writer). + +**Consumer:** `/ultraplan-local` Phase 1 (project-dir auto-discovery) + `planning-orchestrator` (consumes findings as context). + +**Path conventions:** +- Project-dir mode: `.claude/projects/{YYYY-MM-DD}-{slug}/research/{NN}-{topic-slug}.md` (sorted by filename). +- Legacy: `.claude/research/ultraresearch-{date}-{slug}.md`. + +**Frontmatter schema:** + +| Field | Type | Required | Allowed values | +|---|---|---|---| +| `type` | string | yes | `ultraresearch-brief` | +| `created` | date | yes | YYYY-MM-DD | +| `question` | string | yes | the research question | +| `confidence` | number | optional | `[0.0, 1.0]` — strongly recommended | +| `dimensions` | number | optional | ≥ 1 | +| `mcp_servers_used` | list | optional | server names | +| `local_agents_used` | list | optional | agent names | +| `external_agents_used` | list | optional | agent names | + +Missing `confidence` is a warning, not an error — but downstream planning has no signal to weight findings. + +**Body invariants:** required sections (strict mode): +- `## Executive Summary` +- `## Dimensions` + +Optional: `## Local Context`, `## External Knowledge`, `## Triangulation`, `## Sources`, `## Recommendations`. + +**Validation strategy:** schema parse + body-section check. Per-file by `validateResearch`; whole-directory by `validateResearchDir`. Anchoring back to brief topics is currently best-effort, not enforced (planned for a future minor). + +**Versioning:** unversioned — research briefs are write-once read-once; no migration concern. If schema changes, change `type` discriminator or add `research_brief_version`. + +**Failure modes:** all same shape as brief (`RESEARCH_*` codes). Default soft mode in plan Phase 1 — research drift does not block planning, but warnings surface in the user-visible summary. + +--- + +## Handover 3 — architecture/ → plan (EXTERNAL CONTRACT) + +**This is the only handover where the producer is in a *different plugin*.** The `architecture/overview.md` (and optional `gaps.md`) are produced by `/ultra-cc-architect-local` from the separate `ultra-cc-architect` plugin (v0.1.0+). When that plugin is not installed, this handover is absent — and that is fine. + +**Producer:** `/ultra-cc-architect-local` (external plugin). + +**Consumer:** `/ultraplan-local` Phase 1 (architecture-discovery) + `planning-orchestrator` Phase 7 (cross-reference architecture-note as priors during synthesis). + +**Path conventions:** +- Canonical: `{project_dir}/architecture/overview.md` +- Optional: `{project_dir}/architecture/gaps.md` +- Tolerated alternatives (with warning): `architecture-overview.md`, `overview.markdown`, `README.md` + +**Frontmatter schema:** **unenforced.** This is the external contract — `ultraplan-local` does not validate the format. We sniff only the first H1 heading. + +**Body invariants:** **unenforced.** We never read body content beyond the first heading. + +**Validation strategy:** **drift-WARN, never drift-FAIL.** + +| Detection | Result | +|---|---| +| File at canonical path | `found: true`, no warnings | +| File at known alternative path | `found: true`, warning `ARCH_NON_CANONICAL_OVERVIEW` | +| Loose `*.md` files in `architecture/` not in known set | warning `ARCH_LOOSE_FILES` | +| No `architecture/` dir | `found: false`, no warnings | + +The validator (`lib/validators/architecture-discovery.mjs`) is intentionally minimal. It is unit-tested to assert it does NOT read body content beyond the first heading — guarding against scope creep into the sister plugin's territory. + +**Versioning:** the producer (`ultra-cc-architect`) owns its schema. We do not version this handover from our side. + +**Failure modes:** none. Discovery always succeeds (returns `found: false` if absent). The handover is additive. + +--- + +## Handover 4 — `plan.md` → execute + +**Producer:** `planning-orchestrator` Phase 5 (plan synthesis) + Phase 5.5 (schema self-check via `plan-validator --strict`). + +**Consumer:** `/ultraexecute-local` Phase 2 (plan parsing) + `--validate` mode. + +**Path conventions:** +- Project-dir: `{project_dir}/plan.md` +- Legacy: `.claude/plans/ultraplan-{date}-{slug}.md` + +**Frontmatter schema:** + +| Field | Type | Required | Allowed | +|---|---|---|---| +| `plan_version` | string | yes | `"1.7"` (current) | + +**Body invariants (strict, v1.7):** + +1. Top-level structure: + - `## Implementation Plan` heading present + - One or more `### Step N: ` headings, numbered 1..N contiguously + - `### Step N: ` is the literal canonical form — colon + space +2. Forbidden narrative-drift heading forms (Opus 4.7 regression guard): + - `## Fase N` (Norwegian) + - `### Phase N` + - `### Stage N` + - `### Steg N` (Norwegian variant) +3. Per-step Manifest block — **required for every step**: + - Indented fenced YAML: ` ```yaml\n manifest:\n ...\n ``` ` + - Required keys: `expected_paths` (list), `min_file_count` (number), `commit_message_pattern` (string compilable to RegExp), `bash_syntax_check` (list), `forbidden_paths` (list), `must_contain` (list of `{path, pattern}` dicts or empty list) +4. Step count == manifest count + +**Validation strategy:** + +The strongest validation in the entire pipeline. Phase 5.5 (planning-orchestrator) **must** run `plan-validator --strict` before handing the plan to plan-critic. `--validate` mode of `/ultraexecute-local` runs the same check + `progress-validator`. + +| Code | Meaning | Recovery | +|---|---|---| +| `PLAN_FORBIDDEN_HEADING` | Narrative drift detected | Rewrite using literal Phase 5 template | +| `PLAN_NO_STEPS` | No `### Step N:` headings | Plan is empty; restart | +| `PLAN_STEP_NUMBERING` | Steps skip a number | Renumber sequentially | +| `PLAN_MANIFEST_COUNT_MISMATCH` | Some step lost its manifest | Add missing manifest | +| `MANIFEST_MISSING` | Specific step has no manifest YAML | Add Manifest block | +| `MANIFEST_MISSING_KEY` | Manifest is missing a required key | Add the key | +| `MANIFEST_PATTERN_INVALID` | `commit_message_pattern` does not compile | Check escaping (`\\(` not `\(` in YAML double-quoted strings) | +| `PLAN_VERSION_MISMATCH` | Older `plan_version` | Warning only; planner should bump | + +**Versioning:** v1.7 has been stable since v1.8.0 of the plugin (when literal-template + Phase 5.5 self-check were added to fix Opus 4.7 schema drift). v1.6 → v1.7 added the Manifest block (mandatory). Before bumping to v1.8, write the new validator branch + fixtures first. + +**Failure modes:** strict mode is the default for both producer and consumer. There is no soft mode here — a malformed plan is a hard failure for execute. + +--- + +## Handover 5 — `progress.json` (resume contract) + +**Producer:** `/ultraexecute-local` per-step (after Verify + Manifest audit + Checkpoint). + +**Consumer:** `/ultraexecute-local --resume` (re-entry) + `pre-compact-flush` hook (drift detection before context compaction). + +**Path conventions:** +- Project-dir: `{project_dir}/progress.json` +- Legacy: `{plan-dir}/.ultraexecute-progress-{slug}.json` + +**Schema (top-level):** + +| Field | Type | Required | Notes | +|---|---|---|---| +| `schema_version` | string | yes | `"1"` (current) | +| `plan` | string | yes | Path to the plan being executed | +| `plan_type` | string | optional | `plan \| session-spec` | +| `plan_version` | string | yes | Mirrors plan's frontmatter | +| `started_at` | ISO string | yes | | +| `updated_at` | ISO string | yes | Bumped on every write | +| `completed_at` | ISO string | optional | Set when status flips to completed | +| `mode` | string | yes | `execute \| dry-run \| validate` | +| `total_steps` | number | yes | | +| `current_step` | number | yes | 0..total_steps | +| `status` | string | yes | `pending \| in_progress \| completed \| failed \| partial` | +| `session_start_sha` | string | optional | git sha at execute start | +| `session_end_sha` | string | optional | git sha at execute end | +| `steps` | object | yes | Map of step number → step record | + +**Per-step record:** + +| Field | Type | Notes | +|---|---|---| +| `status` | `completed \| in_progress \| failed \| pending \| deferred \| skipped` | | +| `attempts` | number | 1..N | +| `error` | string \| null | | +| `completed_at` | ISO string \| null | | +| `commit` | string \| null | git sha after Checkpoint | +| `manifest_audit` | string | `pass \| fail \| pass-with-note \| n/a` | +| `note` | string | optional human-readable annotation | + +**Validation strategy:** `progress-validator.mjs` runs at: +1. `/ultraexecute-local --validate` (alongside plan-validator) +2. `/ultraexecute-local --resume` entry (must pass `checkResumeReadiness`) +3. `pre-compact-flush` hook (drift check before compaction; never blocks) + +**Drift detection:** the `pre-compact-flush` hook compares `progress.steps[N].commit` against `git log --oneline {session_start_sha}..HEAD`. If git reality has progressed past the recorded `current_step`, the hook updates progress.json atomically (`tmp + rename`, monotonic only) before allowing compaction. This guards against the documented P0 drift in `docs/ultraexecute-v2-observations-from-config-audit-v4.md`. + +**Versioning:** `schema_version: "1"` is current. Future bump (e.g. `"2"`) should add a backward-compat read path that downgrades unknown fields to warnings. + +**Failure modes:** +- `PROGRESS_PARSE_ERROR` → JSON corruption; resume halts +- `PROGRESS_SCHEMA_MISMATCH` → unknown schema version; resume halts +- `PROGRESS_MISSING_FIELD` → required top-level field absent; resume halts +- `PROGRESS_STEP_RANGE` → `current_step` outside `[0, total_steps]`; resume halts +- `PROGRESS_ALREADY_DONE` → `status === completed`; nothing to resume +- `PROGRESS_STEP_COUNT_MISMATCH` → warning; not a blocker + +--- + +## Stability summary + +| Handover | Validation strength | Owner | Risk | +|---|---|---|---| +| 1. brief → research | strict at write, soft at read | this plugin | low | +| 2. research → plan | soft, drift-warn | this plugin | low | +| 3. architecture → plan | discovery-only, drift-WARN | **external** (ultra-cc-architect) | low — by design we tolerate drift | +| 4. plan → execute | **strict, both ends** | this plugin | medium — Opus 4.7 narrative drift requires constant vigilance | +| 5. progress.json | shape + resume readiness | this plugin | medium — drift during compaction handled by pre-compact-flush hook (CC v2.1.105+) | + +When extending the plugin or adding a new pipeline stage, follow the same pattern: produce an artifact with a versioned frontmatter (or `schema_version` for JSON), write a validator under `lib/validators/`, add fixtures under `tests/fixtures/`, and add an entry to this document. diff --git a/plugins/ultraplan-local/hooks/hooks.json b/plugins/ultraplan-local/hooks/hooks.json index c6aaded..617fbaf 100644 --- a/plugins/ultraplan-local/hooks/hooks.json +++ b/plugins/ultraplan-local/hooks/hooks.json @@ -1,24 +1,46 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-bash-executor.mjs" - } - ] - }, - { - "matcher": "Write", - "hooks": [ - { - "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-write-executor.mjs" - } - ] - } - ] - } -} + { + "hooks": { + "PreToolUse": [ + { + { + "hooks": { + { + { + { + { + "hooks": { + { + { + "hooks": { + med: + + { + "hooks": { + { + /Users/ktg/.claude/plugins/marketplaces/ktg-plugin-marketplace/plugins/ultraplan-local/hooks/hooks.json med: + + { + "hooks": { + "PreToolUse": [ + { + "hooks": { + "PreToolUse": [ + { + "hooks": { + + { + "hooks": { + "PreToolUse": [ + med: + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-bash-executor.mjs" + } + ] }, { "matcher": "Write", "hooks": [ { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-write-executor.mjs" } ] } ], "PreCompact": [ { "hooks": [ + { "type": "command", "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/pre-compact-flush.mjs" } ] + } ] } } \ No newline at end of file diff --git a/plugins/ultraplan-local/hooks/scripts/pre-compact-flush.mjs b/plugins/ultraplan-local/hooks/scripts/pre-compact-flush.mjs new file mode 100644 index 0000000..2aa856b --- /dev/null +++ b/plugins/ultraplan-local/hooks/scripts/pre-compact-flush.mjs @@ -0,0 +1,155 @@ + #!/usr/bin/env node + // Hook: pre-compact-flush.mjs + // Event: PreCompact (Claude Code v2.1.105+) + // Purpose: Flush progress.json drift before context compaction so /ultraexecute-local + --resume + // works after long conversations. Direct fix for the documented P0 in + // docs/ultraexecute-v2-observations-from-config-audit-v4.md. + // + // Behavior: + // 1. Locate {cwd}/.claude/projects/* / progress.json (any nested project) + // 2. Read progress.json + sibling plan.md + // 3. Run `git log --oneline {session_start_sha}..HEAD` + // 4. For each commit, match against plan steps' commit_message_pattern + // 5. If derived current_step > stored current_step → write fresh checkpoint + // atomically (tmp + rename), monotonic only (current_step never decreases). + // 6. Always exit 0 — NEVER blocks compaction. + + import { readFileSync, writeFileSync, renameSync, existsSync, readdirSync, statSync } from + 'node:fs'; + import { join, dirname } from 'node:path'; + import { execSync } from 'node:child_process'; + import { fileURLToPath } from 'node:url'; + + const HERE = dirname(fileURLToPath(import.meta.url)); + const PLUGIN_ROOT = join(HERE, '..', '..'); + + function findProgressFiles(cwd) { + const projectsDir = join(cwd, '.claude', 'projects'); + if (!existsSync(projectsDir) || !statSync(projectsDir).isDirectory()) return []; + const out = []; + for (const entry of readdirSync(projectsDir)) { + const projDir = join(projectsDir, entry); + if (!statSync(projDir).isDirectory()) continue; + const progPath = join(projDir, 'progress.json'); + if (existsSync(progPath) && statSync(progPath).isFile()) { + out.push({ projDir, progPath, planPath: join(projDir, 'plan.md') }); + } + } + return out; + } + + function readJson(path) { + try { return JSON.parse(readFileSync(path, 'utf-8')); } + catch { return null; } + } + + function readPlanCheckpointPatterns(planPath) { + if (!existsSync(planPath)) return new Map(); + const text = readFileSync(planPath, 'utf-8'); + const map = new Map(); + const stepRe = /^### Step (\d+):/gm; + const checkpointRe = /\*\*Checkpoint:\*\*\s+`git commit -m "([^"]+)"`/; + const headings = []; + let m; + while ((m = stepRe.exec(text)) !== null) { + headings.push({ n: Number.parseInt(m[1], 10), idx: m.index }); + } + for (let i = 0; i < headings.length; i++) { + const start = headings[i].idx; + const end = i + 1 < headings.length ? headings[i + 1].idx : text.length; + const body = text.slice(start, end); + const cp = body.match(checkpointRe); + if (cp) { + const msg = cp[1]; + const conventionalPrefix = (msg.match(/^([a-z]+)\(([^)]+)\):/) || [])[0]; + if (conventionalPrefix) map.set(headings[i].n, conventionalPrefix); + } + } + return map; + } + + function gitLog(repoDir, baseSha) { + if (!baseSha) return []; + try { + const out = execSync(`git -C "${repoDir}" log --pretty=format:'%H %s' ${baseSha}..HEAD + 2>/dev/null`, { + encoding: 'utf-8', timeout: 5000, + }); + return out.trim().split('\n').filter(Boolean).map(line => { + const sp = line.indexOf(' '); + return { sha: line.slice(0, sp), subject: line.slice(sp + 1) }; + }); + } catch { return []; } + } + + function deriveCurrentStep(progress, plan, gitCommits) { + if (!progress || !progress.steps || gitCommits.length === 0) return null; + const stored = progress.current_step || 0; + let highestMatched = stored; + for (const [stepN, prefix] of plan.entries()) { + const matchedCommit = gitCommits.find(c => c.subject.startsWith(prefix.replace(/\\/g, + ''))); + if (matchedCommit && stepN > highestMatched) highestMatched = stepN; + } + return highestMatched; + } + + function atomicWrite(path, obj) { + const tmp = path + '.tmp'; + writeFileSync(tmp, JSON.stringify(obj, null, 2)); + renameSync(tmp, path); + } + + function repoRootOf(dir) { + try { + return execSync(`git -C "${dir}" rev-parse --show-toplevel 2>/dev/null`, { encoding: + 'utf-8', timeout: 2000 }).trim(); + } catch { return null; } + } + + let stdinPayload = ''; + try { stdinPayload = readFileSync(0, 'utf-8'); } catch { /* fine */ } + + const cwd = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const progressFiles = findProgressFiles(cwd); + + if (progressFiles.length === 0) { + process.exit(0); + } + + let mutationsMade = 0; + for (const { projDir, progPath, planPath } of progressFiles) { + const progress = readJson(progPath); + if (!progress || progress.status === 'completed') continue; + + const repoRoot = repoRootOf(projDir); + if (!repoRoot) continue; + + const plan = readPlanCheckpointPatterns(planPath); + if (plan.size === 0) continue; + + const sessionStart = progress.session_start_sha; + if (!sessionStart) continue; + + const commits = gitLog(repoRoot, sessionStart); + const derivedStep = deriveCurrentStep(progress, plan, commits); + + if (derivedStep !== null && derivedStep > (progress.current_step || 0)) { + progress.current_step = derivedStep; + progress.updated_at = new Date().toISOString(); + if (!progress.steps[String(derivedStep)]) { + progress.steps[String(derivedStep)] = { + status: 'completed', attempts: 1, error: null, + completed_at: progress.updated_at, commit: null, manifest_audit: 'n/a', + note: 'reconstructed by pre-compact-flush from git log', + }; + } + atomicWrite(progPath, progress); + process.stderr.write(`[ultraplan-local] pre-compact flush: ${progPath} → + current_step=${derivedStep}\n`); + mutationsMade++; + } + } + + process.exit(0); \ No newline at end of file