diff --git a/.gitignore b/.gitignore index c060a32..7e68c7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,20 @@ -# Ephemeral workbench -.tmp/ +# Ephemeral workbench (root-anchored: examples/*-run fixtures may legitimately +# contain same-named dirs — .claude/, .taskmaster/, memory/ — that MUST track) +/.tmp/ # Loop run telemetry (operating-contract state for the self-improvement run; not plugin content) -.loop/ +/.loop/ # Machine-local Claude Code config + GSD audit telemetry (not plugin content) -.claude/ -.gsd/ +/.claude/ +/.gsd/ # Local session memory / private build narrative (not plugin content) -memory/ +/memory/ # Workbench dirs (review scratch + roadmap drafts; not plugin content) -review/ -roadmap/ +/review/ +/roadmap/ # Python __pycache__/ diff --git a/docs/gap-reports/scoreboard.md b/docs/gap-reports/scoreboard.md new file mode 100644 index 0000000..7c4995d --- /dev/null +++ b/docs/gap-reports/scoreboard.md @@ -0,0 +1,296 @@ +# The harness scoreboard — one task, eleven layouts + +> **Provenance.** Every foreign row below is evaluated **only** against a +> vendored fixture under [`examples/`](../../examples/) — fictional, sanitized +> content, checked in, instantiating the *same* small task (a CSV +> deduplication) as a **completed run** in that harness's documented layout, +> pinned to the harness commit named in its row. No version-general claims +> about any project are made or implied: every statement is checkable against +> one directory and one SHA. +> **Date:** 2026-07-09. +> **Reproduce any row:** `python3 -m loop inspect examples/` + +## What this measures — and what it does not + +`loop inspect` is the **advisory** scorecard (the hard gate is `loop doctor`, +which only applies to native contracts). It scores one narrow thing: **the +proof-of-done machinery visible on disk** — can a third party, reading only +the files a finished run leaves behind, verify how the run ended? + +- verifiable success criteria, independently checkable +- an executable verification surface (`verify-*` / task verify commands) +- approval gates declared for side-effects +- a false-completion defense (a holdout / anti-cheat gate that actually ran) +- the 7 canonical terminal states, so a run cannot end in a silent "completed" + +It does **not** measure project quality, adoption, design, or whether the +harness makes agents more effective. Every harness here is popular because it +works for what it optimizes. These systems drive *how an agent works*; the +loop contract proves *how the work ended*. They **compose** — any of these +harnesses can emit the contract at its finish line (four `loop.emit` calls; +worked end-to-end in [`docs/integrations/langgraph.md`](../integrations/langgraph.md)). + +**The signals are deliberately conservative, so a low score is not an absence +claim.** Where a harness has real verification machinery the signals cannot +see — different vocabulary, console-only gates, state in SQLite or on GitHub — +the row's *notes* say so explicitly. Those notes were produced by an +adversarial fairness pass over each harness's actual pinned source, hunting +for exactly the machinery our signals miss. + +## Methodology + +1. One fictional task (`csv-dedupe`, the same fiction as + [`examples/superpowers-run/`](../../examples/superpowers-run/)) is + instantiated as a **completed, success-claiming run** under each harness's + documented on-disk layout, from its pinned source. Structure, filenames, + and headings follow each harness's own conventions; every sentence of + content is original and fictional. +2. `loop/foreign.py` maps each layout's own files (its spec, plan, ledger, + journal) onto the same signal surface — **a mapper, never a scorer**: + scoring stays layout-blind and identical for native and foreign targets. + Without the mapping, a low score would be a parsing artifact; with it, the + remaining gaps are structural. +3. Each row was independently re-derived from the pinned clone by an + adversarial verifier before publication (fixture fidelity, licensing, + fairness notes). + +Harnesses whose run state lives fundamentally off-repo (OpenHands and +SWE-agent trajectories, platform-hosted runs) are out of scope: there is no +on-disk run record for a repo-native inspector to read — which is its own +answer to the question this scoreboard asks. + +## The scoreboard + +| Harness | Stars· | Pinned | Fixture | Score | Verdict | Terminals | +|---|---:|---|---|---:|---|---:| +| *(calibration)* loop-engineer contract | — | this repo | [`flaky-test-triage`](../../examples/flaky-test-triage/) | **90** | strong | 7/7 | +| Superpowers ([obra/superpowers](https://github.com/obra/superpowers)) | 251k | fixture× | [`superpowers-run`](../../examples/superpowers-run/) | **12** | weak | 0/7 | +| Spec Kit ([github/spec-kit](https://github.com/github/spec-kit)) | 119k | `3f7392a` | [`spec-kit-run`](../../examples/spec-kit-run/) | **12** | weak | 0/7 | +| CCPM ([automazeio/ccpm](https://github.com/automazeio/ccpm)) | 8.3k | `7d7e462` | [`ccpm-run`](../../examples/ccpm-run/) | **12** | weak | 0/7 | +| BMAD-METHOD ([bmad-code-org/BMAD-METHOD](https://github.com/bmad-code-org/BMAD-METHOD)) | 50k | `49069b8` | [`bmad-run`](../../examples/bmad-run/) | **0** | weak | 0/7 | +| Task Master ([eyaltoledano/claude-task-master](https://github.com/eyaltoledano/claude-task-master)) | 28k | `c0c98d3` | [`task-master-run`](../../examples/task-master-run/) | **0** | weak | 0/7 | +| OpenSpec ([Fission-AI/OpenSpec](https://github.com/Fission-AI/OpenSpec)) | 60k | `93e27a7` | [`openspec-run`](../../examples/openspec-run/) | **0** | weak | 0/7 | +| ruflo ([ruvnet/ruflo](https://github.com/ruvnet/ruflo), né claude-flow) | 64k | `7ef4d4e` | [`ruflo-run`](../../examples/ruflo-run/) | **0** | weak | 0/7 | +| Agent OS ([buildermethods/agent-os](https://github.com/buildermethods/agent-os)) | 5.0k | `cae8e66` | [`agent-os-run`](../../examples/agent-os-run/) | **0** | weak | 0/7 | +| PRPs ([Wirasm/PRPs-agentic-eng](https://github.com/Wirasm/PRPs-agentic-eng)) | 2.2k | `ada2f5b` | [`prp-run`](../../examples/prp-run/) | **0** | weak | 0/7 | +| *(calibration)* an unstructured agent loop | — | this repo | [`naive-loop`](../../examples/naive-loop/) | **0** | weak | 0/7 | + +·Stars as reported by the GitHub API on 2026-07-09, for scale only. +×The Superpowers row predates this scoreboard's SHA-pinning: its fixture was +built from the layout documented in [`superpowers.md`](superpowers.md) +(evaluated 2026-07-08) and carries no pinned commit. + +## Read the zeros correctly + +Three things the table does *not* say: + +1. **0 vs 12 is a vocabulary match, not a discipline ranking.** The 12-point + rows earn exactly one check — a success-criteria heading the signal + recognizes. BMAD-METHOD carries *more* done-checking machinery than several + 12-point rows (an adversarial review gate, a definition-of-done checklist, + a mandated red-green-refactor test cycle) yet scores 0, because its + vocabulary is "Success Measures" and "Acceptance Criteria" and its gates + are natural-language instructions. The score measures conservative signal + match; the notes carry the truth. +2. **0/7 terminals across every foreign row is the headline finding.** Not one + of the nine harnesses ships a typed failure taxonomy. Every one of them + records "done" — a checkbox, a `status:` string, a dated archive dir, a + prose "complete" — and none of them can record *"this run failed, here is + the typed reason"* in a way a machine can refuse to confuse with success. + A loop that cannot express `FailedUnverifiable` will eventually claim it + succeeded when it didn't. That is the false-completion surface. +3. **Much of the field's real verification is not portable proof.** The + pattern repeats across rows: genuine gates exist, but they run + console-only, live off-disk, or end as self-asserted checkmarks. The + verification happened; a third party cannot prove it from the repo. + +## Per-harness readings + +Each reading is what the fixture proves / cannot prove — with the fairness +notes from the adversarial pass over the pinned source. + +### Superpowers — 12 / weak + +The seed row; the full item-by-item reading is +[`superpowers.md`](superpowers.md). A spec/plan/journal layout that proves +disciplined design-first work and structurally cannot prove how the run ended: +completion is the prose "Marking the work complete." — no typed terminal, no +gate, no evidence trail. + +### Spec Kit — 12 / weak + +Spec-driven discipline: a numbered `specs/NNN-slug/` folder with a +constitution, requirements checklist, and phased tasks. It proves the front of +the loop well — verifiable Success Criteria, a Constitution Check, a +requirements-quality checklist — the surface the contract layer composes with. +What it structurally cannot prove is how the work ended: no typed terminal, no +held-out gate, no machine-readable done flag. + +Fairness notes: `/speckit.implement` runs a real checkbox PASS/FAIL gate and +halts on FAIL — but it is a natural-language instruction that writes no file. +`/speckit.analyze` emits a severity/coverage cross-artifact report — +console-only by design. The Constitution Check is an on-disk PASS gate in +`plan.md`, just under vocabulary no conservative signal matches. Its strongest +verification (a test pinning the exact survivor count, mandated by the +constitution) lives in the code tree, outside any run record. +*Reproduce:* `python3 -m loop inspect examples/spec-kit-run` + +### CCPM — 12 / weak + +A requirement-to-merge traceability spine: PRD → technical epic → per-task +Definition-of-Done and acceptance checkboxes → parallel-stream analysis → +per-issue journals, rolled up to `status: closed / progress: 100%`. What the +on-disk tree cannot prove is how the run ended: no typed terminal, no +persisted test result, no held-out gate. + +Fairness notes: CCPM's real merge-time test run (`sync.md` runs the project's +own suite in the worktree before merge) persists no artifact — its journal +honestly reports counts "from stdout." Its issue-lifecycle audit trail lives +on GitHub, invisible to a repo-only reader. It ships `validate.sh`, a +structural metadata validator — real, but advisory (always exits 0) and aimed +at PM metadata, not code outcome. And it has success rollups but no failure +taxonomy at all. +*Reproduce:* `python3 -m loop inspect examples/ccpm-run` + +### BMAD-METHOD — 0 / weak + +A disciplined, reviewed story lifecycle: PRD and epics decompose into a story +with quantitative acceptance criteria, a red-green-refactor task ledger, an +adversarial code-review pass, a retrospective, and `sprint-status.yaml` +mirroring every state to `done`. What it cannot prove to an external checker +is machine-checkable completion: `done` is a status string; the +definition-of-done and explicit "no lying or cheating" gates are +natural-language; there is no typed failure terminal or evidence bundle. + +Fairness notes: the 0 is a pure vocabulary artifact — success criteria exist +as "## Success Measures" / "## Acceptance Criteria" (with Given/When/Then +thresholds); a real pytest gate is mandated but reports in prose; the +`sprint-status.yaml` ledger has a typed *success* lifecycle +(`ready-for-dev → … → done`) with no failure states. This row carries more +completion discipline than its score can show. +*Reproduce:* `python3 -m loop inspect examples/bmad-run` + +### Task Master — 0 / weak + +A disciplined, resumable run record: a parsed PRD, a complexity analysis, and +a `.taskmaster/tasks/tasks.json` ledger where every task carries a +`testStrategy` and a timestamped journal of verification evidence. What it +structurally cannot prove is that completion was independently gated: the +default status write is unenforced, and there is no typed terminal record. + +Fairness notes: per-task acceptance criteria are real (`testStrategy`), just +not under a recognized heading. Its strongest gate is code-enforced but +off-tree: the autopilot TDD workflow (RED/GREEN/COMMIT, commit blocked until +tests pass) persists its state under the *user home* directory, not the +project — so an in-project read sees status fields, not the gate. Editor +hooks (test-success task completion, PR-readiness) gate completion externally +too. +*Reproduce:* `python3 -m loop inspect examples/task-master-run` + +### OpenSpec — 0 / weak + +An archived-change layout that proves a change was proposed, specified with +WHEN/THEN acceptance scenarios, designed, worked to a fully-checked task +ledger, and reconciled into a living capability spec. What it cannot +structurally prove is that code ran: completion is a dated `archive/` dir plus +an updated spec — a path convention, not a typed terminal — and the checked +`## Verification` boxes are self-asserted. + +Fairness notes: `openspec validate --strict` is a real deterministic gate (it +refuses any requirement without a scenario) — but it checks document +structure, not test runs, and isn't a `verify-*` surface the signals credit. +`/opsx:verify` emits a completeness/correctness/coherence verdict — +console-only, persists nothing. The done-idiom (a checked Verification task +block) is de-facto success criteria under vocabulary the scan misses. +*Reproduce:* `python3 -m loop inspect examples/openspec-run` + +### ruflo — 0 / weak + +The most verification-rich layout on this board, and the least portable: a +multi-phase SPARC run with five recorded quality gates (criteria like +"coverage ≥ 80%, all ACs have tests"), an AC→test→code traceability matrix, a +truth score, and a CVE-clean security badge — held in SQLite memory +(`.swarm/memory.db`, `.hive-mind/hive.db`) and JSON exports rather than a +portable contract. What it cannot give an outside reader is a typed terminal +or a held-out gate they can independently replay: its criteria and verdicts +are self-asserted inside the swarm's own memory. + +Fairness notes: the gate ledger, structured acceptance criteria, coverage and +security gates are all *real and recorded* — no conservative text signal can +point at a binary DB, and self-computed verdicts (truth score 0.97) earn no +credit by the same rule that a self-asserted `false_completion: false` earns +none. The honest weak score measures the portability gap, not ruflo's rigor. +*Reproduce:* `python3 -m loop inspect examples/ruflo-run` + +### Agent OS — 0 / weak + +Agent OS v3.0 is a deliberate refocus onto standards and spec-shaping: a run +leaves a standards tree and a shaped spec (`shape.md`, `plan.md`) in a +timestamped spec dir. It proves careful, standards-anchored specification. +By design, v3.0 retired the implementation/verification phases earlier +versions shipped — so a completed run's on-disk record is a spec, and +questions of how execution *ended* are outside what the layout can express: +no ledger, no journal, no terminal record. + +Fairness notes: v3.0's genuine gates are procedural (plan-mode entry +enforcement on `/shape-spec`) and human (confirm-before-create) — they gate +how work *starts*, not how it ended. Its definition-of-done lives in +`shape.md`'s scope prose, vocabulary no conservative signal matches; git +history and the ephemeral todo are its completion signals, both outside the +spec dir. This row should be read against v3.0's chosen scope, not v1/v2's. +*Reproduce:* `python3 -m loop inspect examples/agent-os-run` + +### PRPs — 0 / weak + +A disciplined prd → plan → implement → review pipeline under `.claude/PRPs/`: +the plan carries a six-level *executable* validation gate ("every command must +exit 0"), a per-change validate-until-green loop, and a machine +`{clean, blocking}` review verdict the loop blocks completion on — all +recorded on disk (run log, `green@2` state history, `verdict.json`). The +layout proves work was planned, validated to green, and reviewed clean. What a +single-repo convention structurally cannot carry is exactly what the contract +scores for: an *independent held-out* gate — the review is the same agent +family reviewing its own PR — and a typed terminal (done is a plan archived +into `plans/completed/`). + +Fairness notes: PRPs comes closest on this board to portable verification +evidence; the 0 measures the delta between "recorded and reviewed done" and +"proven un-fakeable done" — signal-shape (markdown-embedded commands, a +review verdict rather than a named holdout gate), not intent. Verifiable +success criteria exist in the plan and PRD, outside the role the +success-criteria signal reads. +*Reproduce:* `python3 -m loop inspect examples/prp-run` + +## What the whole field is missing + +Across nine popular harnesses — ~590k stars of them — the same three +structural gaps repeat, independent of methodology, maturity, or how much +verification machinery each ships: + +1. **No typed terminal taxonomy (0/7, every row).** "Done" is always + expressible; *"failed, and here is the machine-readable reason"* never is. +2. **No held-out gate.** Every verification surface that exists is run by the + same agent that claims success, and most persist nothing. Nothing prevents + the claim from being wrong — and nothing measures how often it is (no row + has anything a false-completion-rate could be computed from). +3. **Proof is not portable.** The field's real gates end as stdout, GitHub + state, home-dir session files, or SQLite — all invisible to the next tool, + the reviewer, or CI reading the repo. + +None of this is an argument against any harness. It is an argument that the +*contract* — typed terminals, an evidence trail, a held-out gate, derivable +FCR/RP — is a missing **layer**, one every harness on this board could emit +at its finish line. The port is small: four `loop.emit` calls +(`open_contract`, `append_iteration`, `append_receipt`, `terminate`), worked +end-to-end for a real engine in +[`docs/integrations/langgraph.md`](../integrations/langgraph.md) and +[`docs/integrations/temporal.md`](../integrations/temporal.md). + +## Contribute a row + +The method is repeatable and every step is checkable: pin a SHA, vendor a +faithful fictional fixture, map the layout in `loop/foreign.py` (a mapper, +never a scorer), let the inspector read it, and write the fairness notes from +the pinned source. Gap-report contributions are welcome — see +[`docs/contributing/issues/06-help-wanted-gap-reports.md`](../contributing/issues/06-help-wanted-gap-reports.md) +and use [`superpowers.md`](superpowers.md) as the deep-dive template. diff --git a/docs/gap-reports/superpowers.md b/docs/gap-reports/superpowers.md index d8ad251..dfa2e8f 100644 --- a/docs/gap-reports/superpowers.md +++ b/docs/gap-reports/superpowers.md @@ -98,10 +98,11 @@ That path is worked end-to-end for a real engine in ## This is a seed -This is the first entry in an "inspect N public harnesses" scoreboard: read a -foreign layout read-only, name the gaps a contract would close, keep every -claim checkable against a vendored fixture. Contributions of further gap -reports are welcome — this file is the template. See the drafted contributor +This was the first entry in the "inspect N public harnesses" scoreboard — +now live at [`scoreboard.md`](scoreboard.md) with nine harnesses read the same +way: a foreign layout read read-only, the gaps a contract would close named, +every claim checkable against a vendored fixture. Contributions of further gap +reports are welcome — this file is the deep-dive template. See the drafted contributor issue [`docs/contributing/issues/06-help-wanted-gap-reports.md`](../contributing/issues/06-help-wanted-gap-reports.md) (`help wanted: gap reports`, filed on GitHub at release). diff --git a/docs/superpowers/plans/2026-07-09-st5-harness-scoreboard.md b/docs/superpowers/plans/2026-07-09-st5-harness-scoreboard.md new file mode 100644 index 0000000..adc79a4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-st5-harness-scoreboard.md @@ -0,0 +1,39 @@ +# ST5 — harness scoreboard (plan) + +Spec: `../specs/2026-07-09-st5-harness-scoreboard-design.md`. Branch: +`feat/st5-scoreboard`. Clones pinned in the session scratchpad; SHAs recorded +in the spec table. + +## Stages + +1. **Prep (main loop).** Root-anchor `.gitignore` workbench patterns; create + the branch. +2. **Per-harness pipeline (workflow, 8×).** + - *Analyst (opus, read-only):* read the pinned clone; return structured + layout facts — run-artifact paths, detect signature, success-criteria + vocabulary, any verification/terminal machinery (with file evidence), + fixture file plan, caveats. + - *Builder (opus):* write `examples/-run/` — the shared fictional + CSV-dedupe task in the harness's documented layout + provenance README. + Zero verbatim template prose. ≤ 9 files. +3. **Registry (workflow, 1×, barrier).** One engineer rewrites + `loop/foreign.py` as a layout registry (all 9 layouts; superpowers signal + tightened), extends `scripts/test_foreign_inspect.py`, runs the suite. +4. **Score (main loop).** `loop inspect` + `loop doctor` on all fixtures; + capture verbatim JSON. +5. **Adversarial verify (workflow, 8×).** Per row: fixture fidelity vs cloned + docs; no verbatim prose; notes capture machinery our signals miss; row + claims accurate. Fix-or-flag. +6. **Post (main loop).** `docs/gap-reports/scoreboard.md` + HN draft in + `roadmap/launch/`. +7. **Gates + PR.** validate_frontmatter · self_eval · full pytest (≥400 pass, + no new failures) · fixture inspect determinism. Atomic commits; PR without + auto-merge (human review gate). + +## Global constraints + +- Fixtures: content fictional and ours; structure theirs; provenance README + per fixture; no wikilinks needed (constraint currently scoped to skills/). +- No scoring changes in `scripts/inspect_loop.py`. +- Workflow `agent()` calls carry explicit `model:` (HARD CONTRACT); writes are + disjoint per harness; `foreign.py`/tests single-writer. diff --git a/docs/superpowers/specs/2026-07-09-st5-harness-scoreboard-design.md b/docs/superpowers/specs/2026-07-09-st5-harness-scoreboard-design.md new file mode 100644 index 0000000..9d4e22e --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-st5-harness-scoreboard-design.md @@ -0,0 +1,104 @@ +# ST5 — "Inspect N public harnesses" scoreboard (design) + +**Date:** 2026-07-09 · **Slice:** ST5 (follows ST4's contributor funnel; the +Superpowers gap report declared itself "the first entry in an 'inspect N public +harnesses' scoreboard" — this slice builds the scoreboard.) + +## Problem + +`docs/gap-reports/superpowers.md` proved the pattern: read a foreign harness +layout read-only, score what it *structurally cannot prove*, keep every claim +checkable against a vendored fixture. One row is a seed, not a scoreboard. The +launch plan (Show HN, human-gated) needs the N-harness post. + +Scoring foreign repos **without layout mappers would be dishonest**: the +inspector reads contract-owned files (SPEC/WORKFLOW/TASKS/verify-*/RUNLOG), so +a harness whose run leaves `specs/001-x/spec.md` on disk would score near-zero +simply because our tool didn't read its files — a parsing artifact presented +as a finding. `loop/foreign.py`'s own docstring states the principle: **a +mapper, never a scorer.** + +## The 8 new harnesses (pinned 2026-07-09) + +| Harness | Repo | Stars | Pinned SHA | +|---|---|---|---| +| Spec Kit | github/spec-kit | 119k | `3f7392a` | +| Agent OS | buildermethods/agent-os | 5.0k | `cae8e66` | +| BMAD-METHOD | bmad-code-org/BMAD-METHOD | 50k | `49069b8` | +| Task Master | eyaltoledano/claude-task-master | 28k | `c0c98d3` | +| CCPM | automazeio/ccpm | 8.3k | `7d7e462` | +| PRPs | Wirasm/PRPs-agentic-eng | 2.2k | `ada2f5b` | +| OpenSpec | Fission-AI/OpenSpec | 60k | `93e27a7` | +| ruflo (né claude-flow) | ruvnet/ruflo | 64k | `7ef4d4e` | + +Plus the existing Superpowers row (fixture, score 12) and calibration rows +from this repo's own examples (`naive-loop` 0, `flaky-test-triage` 90). + +**Selection criteria:** public, active, substantial adoption, and the harness +prescribes an on-disk layout for a run (specs/plans/tasks/state). Platforms +whose run state lives off-repo (OpenHands, SWE-agent trajectories) are a +methodology note, not scored rows. + +## Deliverables + +1. **`examples/-run/` × 8** — vendored fixtures, each instantiating the + harness's *documented* run layout for the **same fictional CSV-dedupe task** + as `superpowers-run` (comparability: one task, nine layouts). Each fixture + carries a README with provenance (layout per `@`; all content + fictional). **Zero verbatim template prose** — BMAD / Task Master / PRPs are + not MIT-licensed; structure and headings follow their docs, every sentence + is ours. +2. **`loop/foreign.py` → layout registry** — data-driven table of foreign + layouts (signature paths → LoopPaths mapping), superpowers + 8 new. Native + `.loop/state.json` always wins; detection precedence is deterministic; + `doctor` stays unmapped. **Scoring stays layout-blind** — the registry only + points existing signals at foreign files; no scoring changes in this slice. + Includes the roadmap follow-up: tighten the superpowers signature so this + repo's own root (specs/plans but no journal) no longer false-positives. +3. **Tests** — per-fixture detect + advisory + deterministic-score tests in + `scripts/test_foreign_inspect.py`; a repo-root-negative regression. +4. **`docs/gap-reports/scoreboard.md`** — the scoreboard post: table (stars, + SHA, target, score, verdict, terminal coverage), methodology, fairness + rules, per-harness sections with verbatim inspect JSON + a **notes** field + for verification machinery the harness has that our conservative signals + don't credit. +5. **`.gitignore` root-anchoring** — `.loop/`→`/.loop/` etc., so fixture + dot-dirs (`.claude/epics/`, `.taskmaster/`) are trackable; resolves the + `!examples/*/.loop/**` follow-up. +6. **HN post draft** → `roadmap/launch/` (gitignored; publication human-gated). + +## Fairness invariants (the post lives or dies on these) + +- **Advisory, always.** Every foreign row carries `advisory: true`. The score + measures *proof-of-done machinery visible on disk*, never project quality. +- **Composes, doesn't compete.** Same framing as the Superpowers report: each + harness drives *how the agent works*; the contract proves *how the work + ended*. Any harness can emit the contract at its finish line. +- **Version-specific claims only.** Every row is pinned to a SHA and a vendored + fixture; reproduce = `python3 -m loop inspect examples/-run`. +- **Conservative signals must not read as absences.** Where a harness defines + success under a different heading, or has verification machinery our gate + tokens can't see, the row's notes say so explicitly. Verifier agents hunt + for exactly this failure mode before any row ships. +- **Fixtures are faithful.** A fixture must not omit an artifact a real run of + that harness would leave (especially verification artifacts), and must not + add anything the harness doesn't prescribe. + +## Non-goals + +- No scoring/weight changes to `inspect_loop.py`. +- No claims about harness quality, community, or roadmap. +- No mapper for off-disk-state platforms (methodology note instead). +- Publication (HN/blog) is out of scope — human gate. + +## Risks + +- **Reputational:** naming popular projects with "weak" verdicts. Mitigation: + fairness invariants above + per-row adversarial verification + the same + respectful framing that shipped for Superpowers. +- **Fixture infidelity:** a wrong fixture poisons a row. Mitigation: analyst → + builder → independent adversarial verifier per harness, all reading the + pinned clone. +- **Registry false positives:** generic signatures (`docs/prd.md`) colliding. + Mitigation: require distinctive multi-path signatures; explicit precedence; + negative tests. diff --git a/examples/agent-os-run/README.md b/examples/agent-os-run/README.md new file mode 100644 index 0000000..3a4486b --- /dev/null +++ b/examples/agent-os-run/README.md @@ -0,0 +1,52 @@ +# agent-os-run — a vendored foreign-harness fixture + +A minimal, sanitized run directory in the layout that +[Agent OS](https://github.com/buildermethods/agent-os) v3.0 leaves behind +(layout per `buildermethods/agent-os@cae8e664fb59a01869718c3151e0f45b7a06a2fb`, +MIT). All content is fictional and every sentence is original — none of the +harness's own template text is copied. It exists so `python3 -m loop inspect +examples/agent-os-run` can score this layout read-only, holding the *same* +fictional csv-dedupe task the other fixtures use, dated 2026-07-09. + +## The shared task + +Identical scenario to [`examples/superpowers-run`](../superpowers-run/): dedupe +`import_contacts.py` so the two sample files collapse from 57 rows to 41 unique +contacts, a second import inserts 0 new rows, and dropped duplicates are logged +with their source line numbers. The scoreboard compares one task across nine +harness layouts, so the story here matches — only the on-disk shape differs. + +## What a completed run looks like on disk (the differentiating finding) + +Agent OS v3 was refocused onto standards and spec-shaping; it retired the +implementation, orchestration, and verification phases earlier versions shipped, +along with the post-build recap. As a result **it records "done" nowhere on +disk.** The spec folder under `agent-os/specs/2026-07-09-1030-csv-dedupe/` is +authored up front — `plan.md` Task 1 is always "Save Spec Documentation" — and +is never mutated afterward, so a finished run and an abandoned plan are +byte-identical here. Completion for this run lived in two places the fixture +cannot show: the git history of the code changes (outside the `agent-os/` tree +entirely) and Claude Code's ephemeral in-tool todo list (never serialized). +`plan.md` therefore lists tasks as plain `## Task N` headers with no checkboxes +and no status field — faithful to the template, unlike the superpowers fixture's +`- [x]` marks. + +## Composes, not competes + +Agent OS is a **complement**: a standards catalog plus a plan-mode spec-shaping +workflow that governs how an agent starts work. Loop Engineer is the contract +layer that proves how work ended. The honest low score here is not a verdict on +Agent OS — it measures what a standards-and-shaping layout *structurally cannot +prove*: there is no held-out gate, no typed terminal record, and no evidence +trail, so nothing on disk distinguishes a verified finish from a claimed one. +Its only gates are procedural (`/shape-spec` refuses to run outside plan mode) +and human (AskUserQuestion confirm-before-create loops) — neither leaves an +artifact. Emitting the contract is exactly what closes that gap. + +## Reproduce + +```bash +uv run --with pyyaml python3 -B -m loop inspect examples/agent-os-run +``` + +A weak verdict (exit 1) is the expected, faithful result for this layout. diff --git a/examples/agent-os-run/agent-os/product/mission.md b/examples/agent-os-run/agent-os/product/mission.md new file mode 100644 index 0000000..18bb852 --- /dev/null +++ b/examples/agent-os-run/agent-os/product/mission.md @@ -0,0 +1,23 @@ +# Product Mission + +> Fictional sample content for the vendored fixture. Not a real project. + +## Problem + +Small teams import contacts from spreadsheet exports that overlap. The same +person appears in two files with different casing, so the importer writes the +same contact twice and the address book fills with near-duplicates that are +tedious to reconcile by hand. + +## Target Users + +Operators and sales assistants who bulk-load contact lists into a lightweight +CRM and expect the import to be safe to run more than once without doubling +their records. + +## Solution + +Every import normalizes and de-duplicates before it writes. A contact is keyed +on its lowercased email and phone, the first occurrence wins, and later +collisions are dropped and logged. Re-running the same source is a no-op, so +imports become idempotent instead of destructive. diff --git a/examples/agent-os-run/agent-os/product/roadmap.md b/examples/agent-os-run/agent-os/product/roadmap.md new file mode 100644 index 0000000..5d32904 --- /dev/null +++ b/examples/agent-os-run/agent-os/product/roadmap.md @@ -0,0 +1,16 @@ +# Product Roadmap + +> Fictional sample content for the vendored fixture. Not a real project. + +## Phase 1: MVP + +- CSV import from common spreadsheet exports +- **De-duplication on import** — collapse overlapping rows into unique contacts +- A drop log that records every discarded duplicate with its source line +- Manual review of the imported list before it is committed + +## Phase 2: Post-Launch + +- Fuzzy matching on names to catch typo-level duplicates the key misses +- Scheduled re-imports that stay idempotent across runs +- Per-source merge rules so newer fields can override older ones diff --git a/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/plan.md b/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/plan.md new file mode 100644 index 0000000..5e980b1 --- /dev/null +++ b/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/plan.md @@ -0,0 +1,31 @@ +# CSV Dedupe — Plan + +> Fictional sample content for the vendored fixture. Not a real project. +> +> Tasks are plain headers. Agent OS v3 tracks progress in the tool's ephemeral +> todo list, not on disk, so this file is never marked up as tasks complete — +> it reads the same after the run as before it. + +## Task 1: Save Spec Documentation + +Create `agent-os/specs/2026-07-09-1030-csv-dedupe/` holding this plan plus +`shape.md`, `standards.md`, and `references.md`. This runs first so the shaping +work is captured before any code changes begin. + +## Task 2: Normalization key + +Add `normalize_key(email, phone)` returning `(email.strip().lower(), +phone.strip().lower())`. Cover it with mixed-case and surrounding-whitespace +fixtures so equal contacts collapse to one key regardless of how they were typed. + +## Task 3: Idempotent import + +Track seen keys during the load and skip the insert when a key already exists, +counting each skip. Result: the two sample files produce 41 unique contacts from +57 rows read, and a second import of the same files inserts 0 new rows. + +## Task 4: Dedupe log + +Append every dropped duplicate to `dedupe.log` as `: dropped +(kept )`. Result: each discarded row is traceable back to the +source line it came from, and kept-plus-dropped equals rows read. diff --git a/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/references.md b/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/references.md new file mode 100644 index 0000000..1c5279b --- /dev/null +++ b/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/references.md @@ -0,0 +1,23 @@ +# References for CSV Dedupe + +> Fictional sample content for the vendored fixture. Not a real project. + +## Similar Implementations + +### Existing contact importer + +- **Location:** `import_contacts.py` +- **Relevance:** This is the module being changed. Its current load path parses + each CSV row and inserts it directly, with no membership check, which is the + source of the duplicate rows. +- **Key patterns:** Reuse the existing CSV parsing and the row-to-record + mapping; wrap only the raw insert with the new key-membership guard so the + parsing behavior is unchanged. + +### Sample source files + +- **Location:** `fixtures/contacts_a.csv`, `fixtures/contacts_b.csv` +- **Relevance:** The two overlapping exports that motivate the work — together + 57 rows that should collapse to 41 unique contacts. +- **Key patterns:** The overlap is same-person, different-casing, which is why + the key lowercases email and phone rather than comparing raw strings. diff --git a/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/shape.md b/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/shape.md new file mode 100644 index 0000000..b1298b8 --- /dev/null +++ b/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/shape.md @@ -0,0 +1,32 @@ +# CSV Dedupe — Shaping Notes + +> Fictional sample content for the vendored fixture. Not a real project. + +## Scope + +Make `import_contacts.py` de-duplicate as it loads. Today it writes one row per +parsed line, so two overlapping source files produce doubles. After this change, +importing the two sample files yields 41 unique contacts where 57 rows were +read, a second import of the same files inserts 0 new rows, and every dropped +duplicate is written to `dedupe.log` with the line number it came from. Those +three outcomes are the definition of done for this work; Agent OS keeps them +here as scope prose rather than under a separate acceptance heading. + +## Decisions + +- Key each contact on `(lower(email), lower(phone))` after trimming whitespace. +- Keep the first row seen for a key; discard every later collision. +- Log drops to `dedupe.log`; do not fail the import when duplicates appear. +- Guard the existing insert with a key-membership check so re-runs are no-ops. + +## Context + +- **Visuals:** None — this is a backend import path with no UI surface. +- **References:** the current `import_contacts.py` insert loop (see references.md). +- **Product alignment:** the "De-duplication on import" line in Phase 1 of the + product roadmap; imports must be safe to run more than once. + +## Standards Applied + +- backend/data-imports — this is an importer, so its idempotency, normalized-key, + and drop-logging rules govern the whole change. diff --git a/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/standards.md b/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/standards.md new file mode 100644 index 0000000..77e9e7f --- /dev/null +++ b/examples/agent-os-run/agent-os/specs/2026-07-09-1030-csv-dedupe/standards.md @@ -0,0 +1,25 @@ +# Standards for CSV Dedupe + +> Fictional sample content for the vendored fixture. Not a real project. + +These are the standards that govern this change. Their full text is inlined +below so the spec folder stands alone as build context. + +--- + +## backend/data-imports + +Every importer is idempotent and de-duplicates before it writes. + +- Build a normalization key from the lowercased, trimmed identifying fields +- Keep the first row seen for a key; drop any later row that collides +- Never insert a row whose key already exists in the target table +- Append each dropped row to a log with its source file and line number + +```python +key = (email.strip().lower(), phone.strip().lower()) +``` + +- Re-running an import over the same source inserts zero new rows +- Drop-log format: one line per discard — `: dropped (kept )` +- The count of dropped rows plus the count of kept rows equals the rows read diff --git a/examples/agent-os-run/agent-os/standards/backend/data-imports.md b/examples/agent-os-run/agent-os/standards/backend/data-imports.md new file mode 100644 index 0000000..af842a2 --- /dev/null +++ b/examples/agent-os-run/agent-os/standards/backend/data-imports.md @@ -0,0 +1,16 @@ +# Data Imports + +Every importer is idempotent and de-duplicates before it writes. + +- Build a normalization key from the lowercased, trimmed identifying fields +- Keep the first row seen for a key; drop any later row that collides +- Never insert a row whose key already exists in the target table +- Append each dropped row to a log with its source file and line number + +```python +key = (email.strip().lower(), phone.strip().lower()) +``` + +- Re-running an import over the same source inserts zero new rows +- Drop-log format: one line per discard — `: dropped (kept )` +- The count of dropped rows plus the count of kept rows equals the rows read diff --git a/examples/agent-os-run/agent-os/standards/index.yml b/examples/agent-os-run/agent-os/standards/index.yml new file mode 100644 index 0000000..3ee2131 --- /dev/null +++ b/examples/agent-os-run/agent-os/standards/index.yml @@ -0,0 +1,5 @@ +# Agent OS Standards Index + +backend: + data-imports: + description: Idempotent imports, normalized dedupe keys, drop-logging with source line numbers diff --git a/examples/bmad-run/README.md b/examples/bmad-run/README.md new file mode 100644 index 0000000..e5e38bd --- /dev/null +++ b/examples/bmad-run/README.md @@ -0,0 +1,39 @@ +# bmad-run — a vendored foreign-harness fixture + +A minimal, sanitized run directory in the layout that +[BMAD-METHOD](https://github.com/bmad-code-org/BMAD-METHOD) V6 (the BMM module) +leaves behind after `npx bmad-method install`: a `_bmad/` install tree plus the +twin `_bmad-output/planning-artifacts/` and `_bmad-output/implementation-artifacts/` +output folders. Layout follows +`bmad-code-org/BMAD-METHOD@49069b8b5276afd21402bc3b978b69ad78a7d2ef`. BMAD is not +MIT-licensed, so only directory structure, YAML key names, and section headings +follow its documented conventions — **every sentence of prose here is original and +fictional, and no template body text was copied**. + +The run modeled here is a **completed** pass over the shared csv-dedupe task (the +same fiction as `examples/superpowers-run`): `import_contacts.py` emitted duplicate +rows when one contact appeared in two source files with different casing, and the +fix normalizes on a lowercased `(email, phone)` key, keeps the first-seen row, and +logs each dropped duplicate. The fixture walks the flagship sprint-story flow — +`create-story` distills the PRD and epics into one story file, `dev-story` +implements it red-green-refactor, `code-review` gates it in fresh context, and a +`retrospective` closes the epic — so every artifact class appears once. + +BMAD is a **complement**, not a competitor: it is a full-lifecycle agile method +that shapes *how* an agent plans, builds, and reviews a story. Loop Engineer is the +contract layer that proves *how the run ended*. The honest score here is not a +criticism — it measures what a story-file-plus-status-ledger layout structurally +proves about completion. BMAD's done-gate is genuinely strong in intent: a +"NO LYING OR CHEATING" rule, an Enhanced Definition of Done checklist, and an +adversarial `code-review` pass that only flips `Status: done` when no unresolved +high or medium findings remain. But those are natural-language instructions an +agent is asked to follow, and "done" is ultimately a `Status:` string mirrored +across the story file and `sprint-status.yaml`. There is no held-out machine gate, +no typed terminal record, and no persisted evidence bundle — which is exactly what +emitting the contract adds. + +## Reproduce + +```bash +uv run --with pyyaml python3 -B -m loop inspect examples/bmad-run +``` diff --git a/examples/bmad-run/_bmad-output/implementation-artifacts/1-1-deduplicate-csv-rows.md b/examples/bmad-run/_bmad-output/implementation-artifacts/1-1-deduplicate-csv-rows.md new file mode 100644 index 0000000..6db5724 --- /dev/null +++ b/examples/bmad-run/_bmad-output/implementation-artifacts/1-1-deduplicate-csv-rows.md @@ -0,0 +1,83 @@ +--- +baseline_commit: 3af19c2 +--- + +# Story 1.1: Deduplicate CSV rows + +Status: done + +## Story + +As an operator running the nightly merge, +I want the import to treat case-variant rows as one contact and log the rows it drops, +so that the roster reflects real people and re-running the merge changes nothing. + +## Acceptance Criteria + +1. Given the two sample exports (fifty-seven rows), when the import runs once, then the roster holds forty-one distinct contacts and each dropped row is logged with its source file and line number. +2. Given a roster already built from those exports, when the same exports are imported again, then zero new rows are added and the skip count is reported. +3. Given two rows whose email or phone differ only in casing, when the match key is computed, then both resolve to one key and the earlier input line is retained. + +## Tasks / Subtasks + +- [x] Task 1: Compute a case-insensitive match key (AC: 3) + - [x] Add `normalize_key(email, phone)` lowercasing and stripping both fields + - [x] Unit-test mixed-case and whitespace-padded inputs +- [x] Task 2: First-seen retention and dropped-row logging (AC: 1) + - [x] Skip a row when its key was already seen; keep the earliest line + - [x] Append each skipped row to `dedupe.log` with source file and line number +- [x] Task 3: Idempotent re-import (AC: 2) + - [x] Return an import summary carrying kept and skipped counts + - [x] Assert a second import over the same sources adds zero rows + +### Review Findings + +- [x] [Review][Patch] Guard against a blank email and blank phone collapsing every empty-field row into one key [src/import_contacts.py:14] — resolved: empty keys are passed through, never deduped. + +## Dev Notes + +The dedupe boundary lives entirely in `import_contacts.py`; the CSV reader and the +roster writer were left untouched. The match key is the tuple of the lowercased, +stripped email and phone — names are intentionally excluded because upstream spells +them inconsistently and name collisions are not duplicates. + +### Project Structure Notes + +- Code deliverable: `src/import_contacts.py` (project source tree, not under `_bmad-output`). +- Test: `tests/test_import_contacts.py`, run with `pytest`. +- Audit output `dedupe.log` is written beside the roster at run time and is not committed. + +### References + +- [Source: _bmad-output/planning-artifacts/prd.md#Functional Requirements] +- [Source: _bmad-output/planning-artifacts/epics.md#Story 1.1: Deduplicate CSV rows] + +## Dev Agent Record + +### Agent Model Used + +Fictional dev agent (Amelia), running the bmad-dev-story skill. + +### Debug Log References + +- First `test_second_import_adds_nothing` failed as expected before the seen-set was persisted across calls; passed once the summary carried the running key set. + +### Completion Notes List + +- Red-green-refactor followed: failing tests written first, then minimal code. +- Full suite green (5 passed); no regressions in the existing import tests. +- Enhanced Definition of Done checklist: PASS. Story Ready for Review: 1-1-deduplicate-csv-rows. +- code-review run in fresh context raised one low patch (empty-key guard), applied above; no high/medium findings. + +### File List + +- src/import_contacts.py (modified) +- tests/test_import_contacts.py (added) + +## Change Log + +| Date | Version | Description | +|------|---------|-------------| +| 2026-07-09 | 0.1 | Story drafted from Epic 1 and moved to ready-for-dev | +| 2026-07-09 | 0.2 | Implementation complete; DoD PASS; moved to review | +| 2026-07-09 | 1.0 | code-review clean after one applied patch; Status set to done | diff --git a/examples/bmad-run/_bmad-output/implementation-artifacts/epic-1-retro-2026-07-09.md b/examples/bmad-run/_bmad-output/implementation-artifacts/epic-1-retro-2026-07-09.md new file mode 100644 index 0000000..58a9e74 --- /dev/null +++ b/examples/bmad-run/_bmad-output/implementation-artifacts/epic-1-retro-2026-07-09.md @@ -0,0 +1,54 @@ +# Epic 1 Retrospective — Idempotent contact import + +Date: 2026-07-09 +Retrospective status: done + +## Epic Summary + +Epic 1 delivered its single story, 1.1 Deduplicate CSV rows, closing every +functional requirement in the PRD. The import now resolves case-variant rows to one +contact, keeps the first occurrence, logs each drop, and stays stable across +re-runs. + +## Delivery Metrics + +- Stories completed: 1 / 1 +- Blockers encountered: 0 +- Review findings: 1 low (patched), 0 high, 0 medium +- Test coverage: 5 tests over the dedupe path, full suite green + +## What Went Well + +- Scoping the change to one function kept the diff small and the review fast. +- Writing the idempotency test before the code exposed that the seen-set had to + survive across import calls, which shaped the summary return value. +- The code-review pass caught the empty-key edge before it reached the roster. + +## Challenges + +- The sample exports had to be inspected by hand to confirm the true distinct count + of forty-one, since the duplicate spread across both files rather than within one. +- Deciding first-seen versus last-seen retention needed a product call; first-seen + won because the earlier export is the more trusted source. + +## Key Insights + +- A case-insensitive key is the whole fix; fancier matching would have added risk + without evidence that fuzzier duplicates exist in the data. +- Logging the source line number, not just the row, is what makes the drop list + auditable after the fact. + +## Action Items + +- Add a fixture pair with unicode-cased emails to the dedupe test suite — owner: Priya +- Document the first-seen tie-break rule in the import CLI help text — owner: Marcus + +## Next Epic Preparation + +No epic is queued. Should a normalization epic follow, start from the two action +items above and the unicode-casing question they raise. + +## Readiness Assessment + +Epic 1 is closed and ready for release. The story, its status ledger, and this +retrospective agree that the work is done, and the passing suite backs the claim. diff --git a/examples/bmad-run/_bmad-output/implementation-artifacts/sprint-status.yaml b/examples/bmad-run/_bmad-output/implementation-artifacts/sprint-status.yaml new file mode 100644 index 0000000..ea7029b --- /dev/null +++ b/examples/bmad-run/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -0,0 +1,38 @@ +# Sprint status — canonical state ledger for the BMM implementation phase. +# Every implementation skill reads and advances the keys below. + +# generated: 2026-07-09 09:12 +# project: contact-sync-cli +# project_key: NOKEY +# tracking_system: file-system +# story_location: _bmad-output/implementation-artifacts + +# STATUS DEFINITIONS: +# ================== +# Epic: backlog -> in-progress -> done +# Story: backlog -> ready-for-dev -> in-progress -> review -> done +# Retrospective: optional -> done +# Action item: open -> in-progress -> done + +generated: 2026-07-09 09:12 +last_updated: 2026-07-09 15:47 +project: contact-sync-cli +project_key: NOKEY +tracking_system: file-system +story_location: "_bmad-output/implementation-artifacts" + +development_status: + epic-1: done + 1-1-deduplicate-csv-rows: done + epic-1-retrospective: done + +# Action items committed during the Epic 1 retrospective. +action_items: + - epic: 1 + action: "Add a fixture pair with unicode-cased emails to the dedupe test suite" + owner: "Priya" + status: open + - epic: 1 + action: "Document the first-seen tie-break rule in the import CLI help text" + owner: "Marcus" + status: open diff --git a/examples/bmad-run/_bmad-output/planning-artifacts/epics.md b/examples/bmad-run/_bmad-output/planning-artifacts/epics.md new file mode 100644 index 0000000..ce46e09 --- /dev/null +++ b/examples/bmad-run/_bmad-output/planning-artifacts/epics.md @@ -0,0 +1,48 @@ +--- +title: Epic breakdown — idempotent contact import +status: final +created: 2026-07-09 +updated: 2026-07-09 +--- + +# Epics — contact-sync-cli + +## FR Coverage Map + +- FR1, FR2 → Story 1.1 (match key + first-seen retention) +- FR3 → Story 1.1 (dropped-row logging) +- FR4 → Story 1.1 (idempotent re-run) + +## Epic List + +- Epic 1: Idempotent contact import — one story delivers the whole fix. + +## Epic 1: Idempotent contact import + +Make the contact import recognize the same person across case-variant spellings so +the merged roster holds distinct contacts and stays stable across repeated runs. + +### Story 1.1: Deduplicate CSV rows + +As an operator running the nightly merge, +I want the import to treat case-variant rows as one contact and log the rows it +drops, +So that the roster reflects real people and re-running the merge changes nothing. + +**Acceptance Criteria:** + +**Given** the two sample exports totalling fifty-seven rows +**When** the import runs once +**Then** the roster holds forty-one distinct contacts +**And** the sixteen dropped rows are each written to `dedupe.log` with their source +file and line number. + +**Given** a roster already built from those exports +**When** the same exports are imported a second time +**Then** zero new rows are added +**And** the run reports the skip count. + +**Given** two rows whose email or phone differ only in letter casing +**When** the match key is computed +**Then** both rows resolve to the same key +**And** the earlier input line is the one retained. diff --git a/examples/bmad-run/_bmad-output/planning-artifacts/prd.md b/examples/bmad-run/_bmad-output/planning-artifacts/prd.md new file mode 100644 index 0000000..c363c6b --- /dev/null +++ b/examples/bmad-run/_bmad-output/planning-artifacts/prd.md @@ -0,0 +1,48 @@ +--- +title: Idempotent contact import — PRD +status: final +created: 2026-07-09 +updated: 2026-07-09 +--- + +# Idempotent contact import + +## Overview + +The `contact-sync-cli` tool merges contacts from two upstream exports into a +single roster. Operators noticed that a person listed in both exports lands in +the roster twice whenever the two sources spell the same email or phone with +different letter casing. This PRD scopes a small, contained fix: make the import +recognize a contact by a case-insensitive key so a re-run never grows the roster. + +## Problem + +`import_contacts.py` inserts one row per input line without comparing against +rows already imported. Because equality is exact-string, `Ada@Example.com` and +`ada@example.com` are treated as two people. The current sample pair of exports +holds fifty-seven rows describing forty-one distinct contacts. + +## Goals + +- Collapse case-variant duplicates so the roster reflects distinct people. +- Guarantee that importing the same sources twice adds nothing on the second run. +- Leave a readable audit trail of which input lines were dropped and why. + +## Functional Requirements + +- FR1: Derive a match key from the lowercased email and lowercased phone; two rows + sharing that key are the same contact. +- FR2: Keep the first occurrence of a key and discard later ones (first-seen wins). +- FR3: Append every dropped row to `dedupe.log` with its source file and line number. +- FR4: Re-importing already-imported sources yields zero new roster rows. + +## Non-Goals + +- Fuzzy or phonetic name matching, merging of conflicting field values, and any + change to the upstream export format are out of scope for this task. + +## Success Measures + +- Importing the two sample exports produces forty-one roster entries. +- A second import over the same files reports zero additions. +- Each of the sixteen dropped rows is traceable to its origin line in the log. diff --git a/examples/bmad-run/_bmad/bmm/config.yaml b/examples/bmad-run/_bmad/bmm/config.yaml new file mode 100644 index 0000000..33f05de --- /dev/null +++ b/examples/bmad-run/_bmad/bmm/config.yaml @@ -0,0 +1,15 @@ +# Installed BMM module config, written by `npx bmad-method install`. +# Resolves the path variables every skill reads at activation. Fictional values. +user_name: Priya +project_name: contact-sync-cli +communication_language: English +document_output_language: English +languages: + - python +user_skill_level: intermediate +output_folder: _bmad-output +planning_artifacts: "{project-root}/_bmad-output/planning-artifacts" +implementation_artifacts: "{project-root}/_bmad-output/implementation-artifacts" +project_knowledge: docs +tracking_system: file-system +project_key: NOKEY diff --git a/examples/bmad-run/src/import_contacts.py b/examples/bmad-run/src/import_contacts.py new file mode 100644 index 0000000..ef7f542 --- /dev/null +++ b/examples/bmad-run/src/import_contacts.py @@ -0,0 +1,55 @@ +"""Import contacts from CSV exports, collapsing case-variant duplicates. + +Fictional sample deliverable for the vendored bmad-run fixture. +""" + +import csv +from dataclasses import dataclass, field + + +def normalize_key(email, phone): + """Return a case-insensitive match key, or None when both fields are blank.""" + e = (email or "").strip().lower() + p = (phone or "").strip().lower() + if not e and not p: + return None + return (e, p) + + +@dataclass +class ImportSummary: + kept: int = 0 + skipped: int = 0 + dropped: list = field(default_factory=list) + + +def import_contacts(source_paths, seen=None, log_path="dedupe.log"): + """Merge rows from source_paths into a roster, first-seen wins. + + seen carries the keys already in the roster so a repeated import adds nothing. + Returns (roster_rows, ImportSummary). + """ + seen = set() if seen is None else set(seen) + roster = [] + summary = ImportSummary() + drops = [] + + for path in source_paths: + with open(path, newline="", encoding="utf-8") as handle: + for line_no, row in enumerate(csv.DictReader(handle), start=2): + key = normalize_key(row.get("email"), row.get("phone")) + if key is not None and key in seen: + summary.skipped += 1 + drops.append((path, line_no, row)) + continue + if key is not None: + seen.add(key) + roster.append(row) + summary.kept += 1 + + if drops: + with open(log_path, "a", encoding="utf-8") as log: + for path, line_no, row in drops: + log.write(f"{path}:{line_no}\tdropped\t{row.get('email','')}\n") + summary.dropped = drops + return roster, summary diff --git a/examples/bmad-run/tests/test_import_contacts.py b/examples/bmad-run/tests/test_import_contacts.py new file mode 100644 index 0000000..c37b0f9 --- /dev/null +++ b/examples/bmad-run/tests/test_import_contacts.py @@ -0,0 +1,59 @@ +"""Red-green-refactor tests the DoD and code-review gate on. + +Fictional sample verification artifact for the vendored bmad-run fixture. +""" + +import csv + +from src.import_contacts import import_contacts, normalize_key + + +def _write_csv(path, rows): + with open(path, "w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=["email", "phone"]) + writer.writeheader() + writer.writerows(rows) + + +def test_case_variants_share_one_key(): + assert normalize_key("Ada@Example.com", "555-0100") == normalize_key( + "ada@example.com", "555-0100" + ) + + +def test_blank_fields_never_dedupe(): + assert normalize_key("", " ") is None + + +def test_first_seen_is_retained(tmp_path): + src = tmp_path / "a.csv" + _write_csv(src, [ + {"email": "ada@example.com", "phone": "555-0100"}, + {"email": "ADA@example.com", "phone": "555-0100"}, + ]) + roster, summary = import_contacts([str(src)], log_path=str(tmp_path / "d.log")) + assert summary.kept == 1 + assert summary.skipped == 1 + assert roster[0]["email"] == "ada@example.com" + + +def test_dropped_rows_are_logged_with_line_numbers(tmp_path): + src = tmp_path / "a.csv" + log = tmp_path / "dedupe.log" + _write_csv(src, [ + {"email": "ada@example.com", "phone": "555-0100"}, + {"email": "ADA@EXAMPLE.COM", "phone": "555-0100"}, + ]) + import_contacts([str(src)], log_path=str(log)) + assert f"{src}:3" in log.read_text(encoding="utf-8") + + +def test_second_import_adds_nothing(tmp_path): + src = tmp_path / "a.csv" + log = tmp_path / "dedupe.log" + _write_csv(src, [{"email": "ada@example.com", "phone": "555-0100"}]) + roster, first = import_contacts([str(src)], log_path=str(log)) + seen = {normalize_key(r["email"], r["phone"]) for r in roster} + _, second = import_contacts([str(src)], seen=seen, log_path=str(log)) + assert second.kept == 0 + assert second.skipped == 1 diff --git a/examples/ccpm-run/.claude/epics/csv-dedupe/1235-analysis.md b/examples/ccpm-run/.claude/epics/csv-dedupe/1235-analysis.md new file mode 100644 index 0000000..9ad5497 --- /dev/null +++ b/examples/ccpm-run/.claude/epics/csv-dedupe/1235-analysis.md @@ -0,0 +1,51 @@ +--- +issue: 1235 +title: Normalization key +analyzed: 2026-07-09T13:40:55Z +estimated_hours: 4 +parallelization_factor: 1.6 +--- + +# Parallel Work Analysis: Issue #1235 + +## Overview + +The normalization-key task splits cleanly into the helper itself and its test +suite, which live in different files and can be built by two agents at once +once the function signature is agreed. + +## Parallel Streams + +### Stream A: Key helper +**Scope**: Implement `normalize_key(email, phone)` and wire its import path. +**Files**: `import_contacts.py` +**Can Start**: immediately +**Estimated Hours**: 3 +**Dependencies**: none + +### Stream B: Key unit tests +**Scope**: Cover mixed-case, whitespace, and empty-phone fixtures for the key. +**Files**: `tests/test_normalize_key.py` +**Can Start**: after Stream A publishes the signature +**Dependencies**: Stream A + +## Coordination Points +### Shared Files +None — the two streams touch disjoint files. +### Sequential Requirements +Stream B imports the helper, so it lands after Stream A's signature is stable. + +## Conflict Risk Assessment + +Low. The only shared surface is the function signature, which is fixed in the +task description before either stream starts. + +## Parallelization Strategy + +Publish the signature first, then run both streams; the test agent stubs +against the agreed signature until the helper lands. + +## Expected Timeline +- With parallel execution: 3h wall time +- Without: 4h +- Efficiency gain: 25% diff --git a/examples/ccpm-run/.claude/epics/csv-dedupe/1235.md b/examples/ccpm-run/.claude/epics/csv-dedupe/1235.md new file mode 100644 index 0000000..1166895 --- /dev/null +++ b/examples/ccpm-run/.claude/epics/csv-dedupe/1235.md @@ -0,0 +1,43 @@ +--- +name: Normalization key +status: closed +created: 2026-07-09T13:34:12Z +updated: 2026-07-09T17:58:03Z +github: https://github.com/acme-contacts/pipeline/issues/1235 +depends_on: [] +parallel: true +conflicts_with: [] +--- + +# Task: Normalization key + +## Description + +Add the `normalize_key(email, phone)` helper that reduces a contact to a +stable comparison key. It lowercases and trims both fields and joins them, so +that rows differing only in letter case or surrounding whitespace collapse to +the same key. The insert guard in later tasks consults this key. + +## Acceptance Criteria +- [x] `normalize_key` lowercases and strips both email and phone. +- [x] Two rows that differ only in casing produce an identical key. +- [x] An empty phone still yields a usable key from the email alone. + +## Technical Details + +The helper lives beside the CSV reader in `import_contacts.py` and returns a +tuple so it can seed a set directly. It performs no I/O and raises on a missing +email, which the importer already guarantees for well-formed input. + +## Dependencies + +None — this task is the foundation the guard and log build on. + +## Effort Estimate +- Size: S +- Hours: 3 + +## Definition of Done +- [x] Code implemented +- [x] Tests written and passing +- [x] Code reviewed diff --git a/examples/ccpm-run/.claude/epics/csv-dedupe/epic.md b/examples/ccpm-run/.claude/epics/csv-dedupe/epic.md new file mode 100644 index 0000000..86b3831 --- /dev/null +++ b/examples/ccpm-run/.claude/epics/csv-dedupe/epic.md @@ -0,0 +1,79 @@ +--- +name: csv-dedupe +status: completed +created: 2026-07-09T13:20:07Z +updated: 2026-07-09T18:41:55Z +progress: 100% +prd: .claude/prds/csv-dedupe.md +github: https://github.com/acme-contacts/pipeline/issues/1234 +--- + +# Epic: csv-dedupe + +## Overview + +Teach `import_contacts.py` to treat case- and whitespace-variant rows as one +contact, keep the first occurrence, and journal every discard. The change is +contained to the importer module and a new log file; no schema change is +needed. + +## Architecture Decisions + +- Match on a derived key rather than mutating stored values, so the raw rows + remain available for auditing. +- Build the key from `lower(strip(email))` joined with `lower(strip(phone))`, + because those two fields were confirmed to identify a contact. +- Retain the earliest row on collision — first-seen wins — to keep imports + deterministic regardless of file ordering within a run. + +## Technical Approach + +### Frontend Components + +None. This is a batch importer with no user-facing surface. + +### Backend Services + +A `normalize_key(email, phone)` helper and an in-memory seen-key set guard the +insert path. A companion writer appends discarded rows to `dedupe.log`. + +### Infrastructure + +No new services. The log is a plain file written alongside existing importer +output. + +## Implementation Strategy + +Land the key helper and its tests first so the guard has a stable contract, +then wire the idempotent insert path, then attach the discard log. Each slice +is independently verifiable against the two sample exports. + +## Task Breakdown Preview + +- The normalization key and its unit coverage. +- The idempotent insert guard that consults the seen-key set. +- The discard log with source filenames and line numbers. + +## Dependencies + +- The current CSV reader and insert path in `import_contacts.py`. + +## Success Criteria (Technical) + +- 41 unique contacts result from the 57-row sample pair. +- A repeat import inserts zero rows. +- `dedupe.log` names every dropped row's source file and line. + +## Estimated Effort + +Roughly one focused day across three small tasks. + +## Tasks Created +- [x] 1235.md - Normalization key (parallel: true) +- [x] 1236.md - Idempotent import guard (parallel: false) +- [x] 1237.md - Dedupe log (parallel: true) + +Total tasks: 3 +Parallel tasks: 2 +Sequential tasks: 1 +Estimated total effort: 8 hours diff --git a/examples/ccpm-run/.claude/epics/csv-dedupe/execution-status.md b/examples/ccpm-run/.claude/epics/csv-dedupe/execution-status.md new file mode 100644 index 0000000..f7c2537 --- /dev/null +++ b/examples/ccpm-run/.claude/epics/csv-dedupe/execution-status.md @@ -0,0 +1,16 @@ +# Execution Status: csv-dedupe + +## Active Streams + +(none — every stream finished and the epic merged to main) + +## Queued + +(none) + +## Completed + +- Issue #1235 Stream A: key helper — merged +- Issue #1235 Stream B: key unit tests — merged +- Issue #1236: idempotent import guard — merged +- Issue #1237: dedupe log — merged diff --git a/examples/ccpm-run/.claude/epics/csv-dedupe/github-mapping.md b/examples/ccpm-run/.claude/epics/csv-dedupe/github-mapping.md new file mode 100644 index 0000000..1f28457 --- /dev/null +++ b/examples/ccpm-run/.claude/epics/csv-dedupe/github-mapping.md @@ -0,0 +1,7 @@ +# GitHub Issue Mapping +Epic: #1234 - https://github.com/acme-contacts/pipeline/issues/1234 +Tasks: +- #1235: Normalization key - https://github.com/acme-contacts/pipeline/issues/1235 +- #1236: Idempotent import guard - https://github.com/acme-contacts/pipeline/issues/1236 +- #1237: Dedupe log - https://github.com/acme-contacts/pipeline/issues/1237 +Synced: 2026-07-09T13:25:33Z diff --git a/examples/ccpm-run/.claude/epics/csv-dedupe/updates/1235/progress.md b/examples/ccpm-run/.claude/epics/csv-dedupe/updates/1235/progress.md new file mode 100644 index 0000000..96849e0 --- /dev/null +++ b/examples/ccpm-run/.claude/epics/csv-dedupe/updates/1235/progress.md @@ -0,0 +1,30 @@ +--- +issue: 1235 +started: 2026-07-09T13:45:00Z +last_sync: 2026-07-09T17:58:03Z +completion: 100% +--- + + +# Progress: Issue #1235 — Normalization key + +## Summary + +The key helper landed and the guard built on it collapsed the sample pair to +41 unique contacts from 57 raw rows, matching the target. Both streams closed +and the issue was marked complete. + +## Work Log + +- Implemented `normalize_key` with lowercasing and whitespace trimming on both + email and phone, returned as a tuple so it seeds the seen-key set directly. +- Added unit cases for mixed casing, padded whitespace, and an empty phone; + all pass locally. +- Ran the importer over both sample exports: 41 rows inserted, 16 discarded. + +## Verification Notes + +I re-ran the importer a second time over the same inputs and it inserted zero +new rows, which is the idempotency behavior the task asked for. The counts were +read from the run's own stdout; no separate proof artifact is attached to this +journal. diff --git a/examples/ccpm-run/.claude/epics/csv-dedupe/updates/1235/stream-A.md b/examples/ccpm-run/.claude/epics/csv-dedupe/updates/1235/stream-A.md new file mode 100644 index 0000000..910f6a6 --- /dev/null +++ b/examples/ccpm-run/.claude/epics/csv-dedupe/updates/1235/stream-A.md @@ -0,0 +1,18 @@ +--- +issue: 1235 +stream: normalization-key +started: 2026-07-09T13:45:12Z +status: completed +--- + +## Scope + +Implement the `normalize_key(email, phone)` helper in `import_contacts.py` and +expose it for the guard and the test suite to import. + +## Progress + +- Wrote `normalize_key` to lowercase and strip both fields and return them as a + tuple, so a case- or whitespace-variant row maps onto an earlier row's key. +- Confirmed against a mixed-case fixture pair that the two rows share one key. +- Handed the finished signature to Stream B and marked this stream completed. diff --git a/examples/ccpm-run/.claude/prds/csv-dedupe.md b/examples/ccpm-run/.claude/prds/csv-dedupe.md new file mode 100644 index 0000000..635de3a --- /dev/null +++ b/examples/ccpm-run/.claude/prds/csv-dedupe.md @@ -0,0 +1,63 @@ +--- +name: csv-dedupe +description: Collapse duplicate contacts imported from two source files into one canonical row +status: completed +created: 2026-07-09T13:02:44Z +--- + +# PRD: csv-dedupe + +## Executive Summary + +The contact importer ingests two upstream exports and currently stores a +separate row each time the same person is described differently between them. +This work makes the importer recognize when two rows are the same contact and +keep only the first one seen, so downstream tools count real people instead of +formatting artifacts. + +## Problem Statement + +`import_contacts.py` compares raw strings. A contact whose email is capitalized +in one file and lowercased in the other passes the equality check as two +distinct people. Operators noticed the contact total drifting above the true +headcount after every import, and had no record of which rows were dropped. + +## User Stories + +- As a data operator, I import both exports and see one row per real contact, + so my totals match reality. Acceptance: 41 unique contacts from 57 raw rows. +- As a data operator, I re-run the same import without fear, so a retry never + inflates the table. Acceptance: the second run inserts zero new rows. +- As an auditor, I read a log of what was discarded, so a dropped row can be + traced. Acceptance: each dropped duplicate is logged with its source line. + +## Functional Requirements + +- Derive a match key from the lowercased, trimmed email and phone of each row. +- On a key collision, retain the earliest row and discard the later one. +- Record every discarded row, its source filename, and its line number. + +## Non-Functional Requirements + +- The importer stays single-pass over each file and holds only seen keys. +- Re-running the importer is idempotent against an already-populated table. + +## Success Criteria + +- The two sample exports yield exactly 41 unique contacts from 57 input rows. +- A second import over the same inputs inserts zero additional rows. +- Every dropped duplicate appears in `dedupe.log` with source line numbers. + +## Constraints & Assumptions + +- Email and phone together identify a contact; name and address may vary. +- Both source files are already well-formed CSV with a header row. + +## Out of Scope + +- Fuzzy or typo-tolerant matching beyond case and whitespace normalization. +- Merging non-key fields from duplicate rows into the retained contact. + +## Dependencies + +- The existing `import_contacts.py` entry point and its CSV reader. diff --git a/examples/ccpm-run/README.md b/examples/ccpm-run/README.md new file mode 100644 index 0000000..c2cf0f5 --- /dev/null +++ b/examples/ccpm-run/README.md @@ -0,0 +1,45 @@ +# ccpm-run — a vendored foreign-harness fixture + +A minimal, sanitized project directory in the layout the +[CCPM](https://github.com/automazeio/ccpm) agent skill (v2) leaves behind — +a PRD under `.claude/prds/`, a technical epic under `.claude/epics//`, +and the per-issue journal CCPM writes while parallel agents run. It is pinned +to commit `7d7e4623bc6d4c0c9ba66ca6bfecd7e5261dc697`; every field name, path, +and heading follows CCPM's documented conventions, but all prose is fictional +and no template body text is copied. + +To stay small, the tree materializes one representative issue (#1235) in full — +its task file, parallel-stream analysis, and `updates/1235/` progress and +stream journals — while the two sibling tasks (#1236, #1237) appear only at the +epic roll-up level, in `epic.md`'s Tasks-Created list, `github-mapping.md`, and +`execution-status.md`. A real run would carry a `1236.md` and `1237.md` beside +`1235.md`. The epic is also left at the live `.claude/epics/csv-dedupe/` path +rather than moved to `.claude/epics/archived/`, which is where CCPM's merge step +relocates an epic once its archival is run. + +The run modeled here is a **completed** pass over the shared csv-dedupe task +(the same fiction as `examples/superpowers-run`): `import_contacts.py` emitted +duplicate rows when one contact appeared in two source files with different +casing, and the fix normalizes on a lowercased email/phone key. Only the +project-management and traceability metadata lives here — the actual code +deliverable was built in a sibling git worktree, merged to `main`, and its +worktree removed, so no application source is part of this tree. CCPM treats +GitHub Issues as its source of truth, so the closing comments and issue-state +audit trail live off-disk; `github-mapping.md` is the sole on-disk link back to +them. + +CCPM is a **complement**, not a competitor: it organizes long-running feature +work into epics, tasks, and parallel agent streams. Loop Engineer is the +contract layer that proves how a run *ended*. This fixture exists so +`python3 -m loop inspect examples/ccpm-run` can score that layout read-only. +The honest score is not a criticism of CCPM — it measures what an epic/task +metadata layout structurally proves about completion. CCPM ships a structural +metadata validator (`validate.sh`) and a self-attested Definition-of-Done +checklist, but no held-out evidence gate and no persisted test result, so the +on-disk proof of "done" is ticked checkboxes and a progress note. + +## Reproduce + +```bash +uv run --with pyyaml python3 -B -m loop inspect examples/ccpm-run +``` diff --git a/examples/openspec-run/README.md b/examples/openspec-run/README.md new file mode 100644 index 0000000..1560847 --- /dev/null +++ b/examples/openspec-run/README.md @@ -0,0 +1,46 @@ +# openspec-run — a vendored foreign-harness fixture + +A minimal, sanitized run tree in the layout the +[OpenSpec](https://github.com/Fission-AI/OpenSpec) CLI leaves behind for a +`spec-driven` change (proposal → specs → design → tasks, then archive), pinned +at `Fission-AI/OpenSpec@93e27a755ce5386c66be7f3274a35f70018002bc`. All content +is fictional and original — no OpenSpec template text is copied. It exists so +`python3 -m loop inspect examples/openspec-run` can score a foreign layout +read-only, and so a gap report has a stable target to check against. + +OpenSpec is a **complement**, not a competitor: it drives *how* a spec-authoring +agent proposes, refines, and applies a change, then keeps a living capability +spec current. Loop Engineer is the contract layer that proves *how the work +ended*. The score here measures what a proposal/spec/tasks tree +structurally can and cannot prove. OpenSpec has a genuine deterministic gate on +spec-document structure (`openspec validate --strict` requires every Requirement +to carry at least one Scenario), but ships no harness-provided gate that +executes code or tests, and persists no iteration journal — the dated archive +directory plus the updated living spec is the sole durable "it finished" signal. + +This fixture models the **archived (completed)** state of the shared fictional +csv-dedupe task, dated 2026-07-09 — the same task as `examples/superpowers-run`, +rendered in OpenSpec's own completion idiom so the scoreboard compares one task +across nine layouts. + +## File map + +| Path | Role | +|---|---| +| `openspec/config.yaml` | Workspace anchor — declares `schema: spec-driven`, project `context`, per-artifact `rules`. | +| `openspec/specs/csv-dedupe/spec.md` | Living capability spec — the post-sync source of truth an archived run produces. | +| `openspec/changes/archive/2026-07-09-dedupe-csv-rows/proposal.md` | Intent — Why / What Changes / Capabilities / Impact. | +| `.../design.md` | Technical design — Context / Goals · Non-Goals / Decisions. | +| `.../tasks.md` | Task ledger, every box checked, closing with a `## Verification` section. | +| `.../specs/csv-dedupe/spec.md` | Delta spec — `## ADDED Requirements`, the acceptance criteria `openspec validate` gates. | +| `.../.openspec.yaml` | Per-change metadata — `schema` + `created` only; no status field (status is derived on demand). | + +## Reproduce + +```bash +uv run --with pyyaml python3 -B -m loop inspect examples/openspec-run +``` + +A weak verdict (exit 1) is the honest, expected result: the layout has no +held-out gate, no typed terminal state, and no evidence trail — exactly the +proof surface that emitting a Loop Engineer contract adds on top. diff --git a/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/.openspec.yaml b/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/.openspec.yaml new file mode 100644 index 0000000..5cde02e --- /dev/null +++ b/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: "2026-07-09" diff --git a/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/design.md b/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/design.md new file mode 100644 index 0000000..ede663a --- /dev/null +++ b/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/design.md @@ -0,0 +1,49 @@ +# Design: dedupe-csv-rows + +## Context + +`import_contacts.py` reads each source file row by row and inserts +unconditionally. The two operator exports share people but format the same +address inconsistently — `Ada@Site.io` in one, `ada@site.io` in the other — so +identical contacts slip past any exact-string check and land twice. + +## Goals / Non-Goals + +**Goals:** +- Recognize a repeated contact regardless of letter casing or stray whitespace. +- Keep the earliest occurrence so the first file remains the authority. +- Leave an auditable trail of what was dropped and where it came from. +- Make a repeated import produce no new rows. + +**Non-Goals:** +- Fuzzy or typo-tolerant matching beyond case and whitespace folding. +- Merging differing fields across the two rows — the first row is kept whole. +- Changing the source export format or the downstream contact schema. + +## Decisions + +### Decision 1: Fold the key on `(lower(email), lower(phone))` +Casing is the only difference that made true duplicates read as distinct, so +the identity key lowercases and strips both fields before comparison. Folding +both fields keeps two genuinely different people apart while catching the +casing collisions the operators actually hit. + +### Decision 2: First-seen row wins +The importer walks rows in file order and inserts a contact only when its folded +key is unseen. Retaining the first occurrence makes the outcome deterministic +and independent of how later files are ordered. + +### Decision 3: Log drops rather than fail +A collision is expected, not an error, so a skipped row is appended to +`dedupe.log` with its source file and line number and processing continues. +The log lets an operator confirm what collapsed without blocking the import. + +## Risks / Trade-offs + +- Folding only case and whitespace leaves near-duplicates with a mistyped + address (`ada@sight.io` vs `ada@site.io`) reading as separate people → + accepted, since typo-tolerant matching is an explicit Non-Goal; the log makes + any residual duplicate visible for a manual pass. +- First-seen-wins discards a later row even when it carries a field the first + row left blank → accepted as the price of a deterministic, order-stable + outcome; merging fields across rows is out of scope for this change. diff --git a/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/proposal.md b/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/proposal.md new file mode 100644 index 0000000..16cafc7 --- /dev/null +++ b/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/proposal.md @@ -0,0 +1,31 @@ +# Dedupe CSV Rows + +## Why + +Operators export contacts from two systems whose lists overlap, and +`import_contacts.py` stores every raw row. When the same person appears in both +files with different letter casing, the importer writes them twice, so the +contact table carries visible duplicates and the second import inflates it +again. + +## What Changes + +- Rows are matched on a case-folded, whitespace-trimmed `(email, phone)` key. +- On a collision the first row read wins; later rows are dropped, not stored. +- Every dropped row is appended to `dedupe.log` with its source file and line. +- Re-importing the same files becomes a no-op instead of adding rows. + +## Capabilities + +### New Capabilities +- `csv-dedupe`: collapse duplicate contact rows on a case-insensitive identity + key, keep the earliest occurrence, and log what was dropped. + +### Modified Capabilities + + +## Impact + +- `import_contacts.py`: add the normalization key and the skip-on-collision path. +- `dedupe.log`: new append-only artifact recording each skipped duplicate. +- Import runs are now idempotent, so scheduled re-imports are safe to repeat. diff --git a/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/specs/csv-dedupe/spec.md b/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/specs/csv-dedupe/spec.md new file mode 100644 index 0000000..7bf2f7c --- /dev/null +++ b/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/specs/csv-dedupe/spec.md @@ -0,0 +1,37 @@ +# Spec: csv-dedupe + +## ADDED Requirements + +### Requirement: Collapse contacts on a case-folded identity key +The importer SHALL treat two rows as the same contact when their email and +phone match after lowercasing and trimming surrounding whitespace. + +#### Scenario: Same contact, different casing +- **WHEN** two source rows carry `Ada@Site.io` and `ada@site.io` with the same phone +- **THEN** the importer stores a single contact +- **AND** the row read first is the one retained + +#### Scenario: Distinct contacts are preserved +- **WHEN** two rows differ in either the folded email or the folded phone +- **THEN** both are stored as separate contacts + +### Requirement: Import is idempotent across repeated runs +The importer SHALL insert no additional rows when the same sources are imported +a second time. + +#### Scenario: First import collapses the overlap +- **WHEN** the two sample files totalling 57 rows are imported +- **THEN** 41 unique contacts are stored + +#### Scenario: Second run over unchanged inputs +- **WHEN** the same two files are imported again +- **THEN** 0 new contacts are stored + +### Requirement: Dropped duplicates are recorded with provenance +The importer SHALL append every skipped duplicate to `dedupe.log` with the +source file and line number it came from. + +#### Scenario: A duplicate is skipped +- **WHEN** a row collides with an already-stored contact +- **THEN** the importer does not insert it +- **AND** it writes one `dedupe.log` line naming the source file and line number diff --git a/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/tasks.md b/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/tasks.md new file mode 100644 index 0000000..6dcf5ce --- /dev/null +++ b/examples/openspec-run/openspec/changes/archive/2026-07-09-dedupe-csv-rows/tasks.md @@ -0,0 +1,25 @@ +# Tasks: dedupe-csv-rows + +## 1. Normalization key + +- [x] 1.1 Add `normalize_key(email, phone)` that lowercases and strips both fields +- [x] 1.2 Return the pair as a hashable identity key for collision lookups +- [x] 1.3 Unit-test the key with mixed-case and padded-whitespace fixtures + +## 2. Idempotent import + +- [x] 2.1 Track seen keys and skip insert when a row's key is already present +- [x] 2.2 Count skipped rows so the run reports how many collapsed +- [x] 2.3 Re-run the import over unchanged inputs and assert 0 new rows + +## 3. Dedupe log + +- [x] 3.1 Append each skipped row to `dedupe.log` on collision +- [x] 3.2 Record the source file and line number for every logged drop + +## 4. Verification + +- [x] 4.1 Run the unit tests for `normalize_key` and the skip path (`pytest`) +- [x] 4.2 Import the two sample files; confirm 41 unique contacts from 57 raw rows +- [x] 4.3 Manual smoke: import a second time and confirm no rows are added +- [x] 4.4 Inspect `dedupe.log` and confirm each drop names its source line diff --git a/examples/openspec-run/openspec/config.yaml b/examples/openspec-run/openspec/config.yaml new file mode 100644 index 0000000..ea517a1 --- /dev/null +++ b/examples/openspec-run/openspec/config.yaml @@ -0,0 +1,22 @@ +schema: spec-driven + +context: | + Tech stack: Python 3.11, standard-library csv module, pytest. + Data source: two operator-maintained contact export files that overlap. + Product language: describe observable import behavior and the record that + survives a collision, not the internal loop that produces it. + Constraint: the importer must stay re-runnable — a second pass over the + same inputs adds no rows. + +rules: + proposal: + - State the duplicate-row symptom in terms an operator would report. + - Name the surviving record explicitly rather than saying "the correct one". + specs: + - Give every requirement at least one WHEN/THEN scenario. + - Include a scenario for the idempotent second run. + design: + - Record why the collision key is case-folded, not just that it is. + tasks: + - Keep each task independently checkable. + - Close with a Verification section that runs the tests and a manual smoke. diff --git a/examples/openspec-run/openspec/specs/csv-dedupe/spec.md b/examples/openspec-run/openspec/specs/csv-dedupe/spec.md new file mode 100644 index 0000000..65e56a3 --- /dev/null +++ b/examples/openspec-run/openspec/specs/csv-dedupe/spec.md @@ -0,0 +1,41 @@ +# csv-dedupe Specification + +## Purpose +Define how the contact importer collapses duplicate rows that arrive from +overlapping export files so that each real person is stored once, the earliest +occurrence is the record that survives, and re-running the import changes +nothing. This is the living record synced from the 2026-07-09 dedupe-csv-rows +change. + +## Requirements + +### Requirement: Collapse contacts on a case-folded identity key +The importer SHALL treat two rows as the same contact when their email and +phone match after lowercasing and trimming surrounding whitespace. + +#### Scenario: Same contact, different casing +- **WHEN** two source rows carry `Ada@Site.io` and `ada@site.io` with the same phone +- **THEN** the importer stores a single contact +- **AND** the row read first is the one retained + +#### Scenario: Distinct contacts are preserved +- **WHEN** two rows differ in either the folded email or the folded phone +- **THEN** both are stored as separate contacts + +### Requirement: Import is idempotent across repeated runs +The importer SHALL insert no additional rows when the same sources are imported +a second time. + +#### Scenario: Second run over unchanged inputs +- **WHEN** the two sample files are imported and then imported again +- **THEN** the first run stores 41 unique contacts from 57 raw rows +- **AND** the second run stores 0 new contacts + +### Requirement: Dropped duplicates are recorded with provenance +The importer SHALL append every skipped duplicate to `dedupe.log` with the +source file and line number it came from. + +#### Scenario: A duplicate is skipped +- **WHEN** a row collides with an already-stored contact +- **THEN** the importer does not insert it +- **AND** it writes one `dedupe.log` line naming the source file and line number diff --git a/examples/prp-run/.claude/PRPs/plans/completed/csv-dedupe.plan.md b/examples/prp-run/.claude/PRPs/plans/completed/csv-dedupe.plan.md new file mode 100644 index 0000000..b0a6cf5 --- /dev/null +++ b/examples/prp-run/.claude/PRPs/plans/completed/csv-dedupe.plan.md @@ -0,0 +1,98 @@ +# Feature: Contact Import Deduplication + +## Summary + +Route every imported row through a normalized `(lower(email), lower(phone))` key, +keep the first row seen for each key, and append the rows we drop to `dedupe.log`. +The importer becomes idempotent with no schema change; full problem and hypothesis +live in the source PRD (back ref below). + +## Metadata + +| Field | Value | +| ----- | ----- | +| Type | ENHANCEMENT | +| Complexity | LOW | +| Systems Affected | contacts import CLI | +| Dependencies | Python 3.12 standard library only | +| Estimated Tasks | 3 | + +## Lifecycle (append-only) + +- **Created:** 2026-07-09T14:05:03Z +- **Modified:** 2026-07-09T14:48:19Z +- **Commits:** a1b2c3d4 +- **Agent / Session:** claude-opus (prp-loop-2026-07-09T14:01:55Z) +- **Back refs:** `.claude/PRPs/prds/csv-dedupe.prd.md` — source PRD, Phase 1 +- **Forward refs:** - + +## Files to Change + +| File | Action | Justification | +| ---- | ------ | ------------- | +| `contacts/dedupe.py` | CREATE | Merge key + drop-log helper | +| `contacts/import_contacts.py` | UPDATE | Route rows through the key before insert | +| `tests/test_dedupe.py` | CREATE | Cover the key, idempotency, and the log | + +## NOT Building (Scope Limits) + +- No fuzzy or name-based matching — exact normalized email/phone only. +- No database migration — dedupe stays in application code. + +## Step-by-Step Tasks + +Task markers: `[ ]` not started · `[wip]` underway · `[x]` done · `[f]` could not pass. + +### `[x]` Task 1: CREATE `contacts/dedupe.py` +- **ACTION**: Add `normalize_key(email, phone)` (lowercase + strip) and a `DropLog` appender. **VALIDATE**: `mypy contacts`. + +### `[x]` Task 2: UPDATE `contacts/import_contacts.py` +- **ACTION**: Track seen keys; skip repeats and log them with the source line. **GOTCHA**: log before `continue` or the trail loses the row. **VALIDATE**: `ruff check . && mypy contacts`. + +### `[x]` Task 3: CREATE `tests/test_dedupe.py` +- **ACTION**: Cover key normalization, the 57→41 count, and second-run 0 inserts. **VALIDATE**: `pytest tests/test_dedupe.py`. + +## Validation Commands + +Every command below must exit 0 before this plan counts as done. + +- **Level 1: STATIC_ANALYSIS** — `ruff check . && mypy contacts` (no errors). +- **Level 2: UNIT_TESTS** — `pytest tests/test_dedupe.py` (all green). +- **Level 3: FULL_SUITE** — `pytest` (full suite passes). +- **Level 4: DATABASE_VALIDATION** — N/A, flat-file importer, no database. +- **Level 5: BROWSER_VALIDATION** — N/A, command-line tool, no UI. +- **Level 6: MANUAL_VALIDATION** — import both exports → 41, re-run → 0 inserts. + +## Acceptance Criteria + +- [x] First import of both exports yields 41 unique contacts. +- [x] A second identical import inserts 0 rows. +- [x] All 16 dropped rows are logged with their source line numbers. +- [x] Level 1–3 validation commands exit 0. + +## Completion Checklist + +- [x] All tasks complete in order, each validated on completion. +- [x] Level 1 static analysis passes. +- [x] Level 2 unit tests pass. +- [x] Level 3 full suite passes. +- [x] Acceptance criteria met. + +## Success Criteria + +- **CONTEXT_COMPLETE**: Patterns and the one gotcha are captured from the file. +- **IMPLEMENTATION_READY**: Tasks run top to bottom without clarification. +- **PATTERN_FAITHFUL**: The guard mirrors the existing import loop. +- **VALIDATION_DEFINED**: Every task carries an executable check. +- **ONE_PASS_TARGET**: Confidence 8+ signals a likely first-attempt success. + +**Confidence Score**: 9/10 that this lands on the first attempt — the change is +local, stdlib-only, and covered by a deterministic row-count test. + +## Amendments + +
+2026-07-09T14:48:19Z — built, validated, and archived +Implemented all three tasks; Level 1–3 passed after one lint fix on iteration 1. +Moved into `plans/completed/` on the green implement pass. +
diff --git a/examples/prp-run/.claude/PRPs/prds/csv-dedupe.prd.md b/examples/prp-run/.claude/PRPs/prds/csv-dedupe.prd.md new file mode 100644 index 0000000..4802075 --- /dev/null +++ b/examples/prp-run/.claude/PRPs/prds/csv-dedupe.prd.md @@ -0,0 +1,65 @@ +# Contact Import Deduplication + +## Problem Statement + +The nightly contact importer appends every row it reads, so when the same person +appears in two vendor exports with different letter casing the database ends up +holding several near-identical records. Sales reps then waste time reconciling +duplicates by hand, and merge mistakes leak into outbound campaigns. + +## Evidence + +- A single import of the two January exports produced 57 rows for 41 real people. +- Support logged three "why did this lead get two emails" tickets last month. +- `import_contacts.py` has no uniqueness check before its insert call. + +## Proposed Solution + +Compute a normalized merge key of `(lower(email), lower(phone))` for each row and +keep only the first occurrence, appending every dropped row to a `dedupe.log` +audit trail. This lives entirely inside the existing importer — no schema change, +no new service. + +## Key Hypothesis + +A case-insensitive email-and-phone merge key should collapse the duplicate +contacts the ops team keeps hitting down to one record apiece. The falsifiable +check: a fresh import of the two sample exports lands 41 unique contacts, and +running that same import a second time inserts nothing. + +## What We're NOT Building + +- Fuzzy name matching — out of scope; only exact normalized email/phone merges. +- A dedupe UI — the audit log is enough for this pass. + +## Success Metrics + +| Metric | Target | How Measured | +|--------|--------|--------------| +| Unique contacts after import | 41 (from 57 rows) | Row count after loading both exports | +| Second-run inserts | 0 | Re-run the same import, count new rows | +| Dropped rows logged | 16, with source line numbers | `wc -l dedupe.log` and spot-check entries | + +## Open Questions + +- [x] Should phone-only or email-only matches merge? Resolved: require both fields to agree. + +## Implementation Phases + + + +| # | Phase | Description | Status | Parallel | Depends | PRP Plan | +|---|-------|-------------|--------|----------|---------|----------| +| 1 | Dedupe key + idempotent import | Normalize the merge key, skip repeat keys, log drops | complete | - | - | `.claude/PRPs/plans/csv-dedupe.plan.md` | + +### Phase Details + +**Phase 1: Dedupe key + idempotent import** +- **Goal**: One import that is safe to re-run and leaves an auditable drop trail. +- **Scope**: `normalize_key`, the first-seen guard in the import loop, the log writer. +- **Success signal**: 41 unique contacts on the first run, 0 inserts on the second. + +--- + +*Generated: 2026-07-09* +*Status: COMPLETE — Phase 1 shipped via PR #42* diff --git a/examples/prp-run/.claude/PRPs/reports/csv-dedupe-report.md b/examples/prp-run/.claude/PRPs/reports/csv-dedupe-report.md new file mode 100644 index 0000000..b103f7e --- /dev/null +++ b/examples/prp-run/.claude/PRPs/reports/csv-dedupe-report.md @@ -0,0 +1,86 @@ +# Implementation Report + +**Plan**: `.claude/PRPs/plans/csv-dedupe.plan.md` +**Branch**: `feature/csv-dedupe` +**Date**: 2026-07-09 +**Status**: COMPLETE + +--- + +## Summary + +Added a normalized merge key and a first-seen guard to the contact importer, with +a drop log for every skipped row. The importer is now safe to re-run and the two +sample exports load as 41 unique contacts instead of 57 rows. + +--- + +## Assessment vs Reality + +| Metric | Predicted | Actual | Reasoning | +| ------ | --------- | ------ | --------- | +| Complexity | LOW | LOW | The guard slotted into the existing loop as planned. | +| Confidence | 9/10 | 9/10 | Row-count test matched the PRD numbers on the first green pass. | + +No deviation from the plan: the three tasks landed as written. + +--- + +## Tasks Completed + +| # | Task | File | Status | +| - | ---- | ---- | ------ | +| 1 | Merge key + drop-log helper | `contacts/dedupe.py` | [x] | +| 2 | First-seen guard in the import loop | `contacts/import_contacts.py` | [x] | +| 3 | Key, count, and idempotency tests | `tests/test_dedupe.py` | [x] | + +--- + +## Validation Results + +| Check | Result | Details | +| ----- | ------ | ------- | +| Type check | PASS | `mypy contacts` clean | +| Lint | PASS | `ruff check .` — 0 errors (1 fixed on iteration 1) | +| Unit tests | PASS | 7 passed, 0 failed | +| Build | N/A | Interpreted package, nothing to compile | +| Integration | PASS | Double-run against samples: 41 then 0 inserts | + +--- + +## Files Changed + +| File | Action | Lines | +| ---- | ------ | ----- | +| `contacts/dedupe.py` | CREATE | +34 | +| `contacts/import_contacts.py` | UPDATE | +9/-2 | +| `tests/test_dedupe.py` | CREATE | +58 | + +--- + +## Deviations from Plan + +None. + +--- + +## Issues Encountered + +Iteration 1 tripped a `ruff` unused-import warning after the guard refactor; the +import was removed and Level 1 went green on iteration 2. + +--- + +## Tests Written + +| Test File | Test Cases | +| --------- | ---------- | +| `tests/test_dedupe.py` | `test_normalize_key_lowercases_and_strips`, `test_import_collapses_57_to_41`, `test_second_run_inserts_zero`, `test_dropped_rows_logged_with_line_numbers` | + +--- + +## Next Steps + +- [x] Review implementation +- [x] Open PR #42 +- [ ] Merge when approved diff --git a/examples/prp-run/.claude/PRPs/reviews/pr-42-cycle-0.verdict.json b/examples/prp-run/.claude/PRPs/reviews/pr-42-cycle-0.verdict.json new file mode 100644 index 0000000..7e6d515 --- /dev/null +++ b/examples/prp-run/.claude/PRPs/reviews/pr-42-cycle-0.verdict.json @@ -0,0 +1,4 @@ +{ + "clean": true, + "blocking": [] +} diff --git a/examples/prp-run/.claude/PRPs/reviews/pr-42-review.md b/examples/prp-run/.claude/PRPs/reviews/pr-42-review.md new file mode 100644 index 0000000..51e0f0f --- /dev/null +++ b/examples/prp-run/.claude/PRPs/reviews/pr-42-review.md @@ -0,0 +1,95 @@ +--- +pr: 42 +title: "Deduplicate contact imports by a normalized email+phone key" +author: "loop-bot" +reviewed: 2026-07-09T15:18:40Z +recommendation: approve +--- + +# PR Review: #42 - Deduplicate contact imports by a normalized email+phone key + +**Author**: @loop-bot +**Branch**: feature/csv-dedupe -> main +**Files Changed**: 3 (+101/-2) + +--- + +## Summary + +The PR adds a normalized merge key and a first-seen guard to the contact importer +and logs every dropped row. The change matches the plan and the PRD numbers, and +the tests are deterministic. Recommending approve. + +--- + +## Implementation Context + +| Artifact | Path | +| -------- | ---- | +| Implementation Report | `.claude/PRPs/reports/csv-dedupe-report.md` | +| Original Plan | `.claude/PRPs/plans/completed/csv-dedupe.plan.md` | +| Documented Deviations | 0 | + +The report documents one lint fix during implementation; no undocumented drift. + +--- + +## Changes Overview + +| File | Changes | Assessment | +| ---- | ------- | ---------- | +| `contacts/dedupe.py` | +34 | PASS | +| `contacts/import_contacts.py` | +9/-2 | PASS | +| `tests/test_dedupe.py` | +58 | PASS | + +--- + +## Issues Found + +- **Critical**: none. +- **High Priority**: none. +- **Medium Priority**: none. +- **Suggestions**: `normalize_key` could fold Unicode confusables later, but exact + casing covers the vendor exports we actually see, so it is out of scope here. + +--- + +## Validation Results + +| Check | Status | Details | +| ----- | ------ | ------- | +| Type Check | PASS | `mypy contacts` clean | +| Lint | PASS | 0 errors | +| Tests | PASS | 7 passed | +| Build | PASS | N/A — interpreted package | + +--- + +## Pattern Compliance + +- [x] Follows existing import-loop structure +- [x] Type safety maintained +- [x] Naming conventions followed +- [x] Tests added for new code +- [x] Documentation updated (report + plan lifecycle) + +--- + +## What's Good + +The guard lives in one small helper, the drop log is written before the row is +skipped (so nothing is silently lost), and the 57→41 count is asserted directly +rather than mocked. Clean, reviewable, and safe to re-run. + +--- + +## Recommendation + +**APPROVE** + +No Critical or Important issues; all validation is green. Ready to merge. + +--- + +*Reviewed by Claude* +*Report: `.claude/PRPs/reviews/pr-42-review.md`* diff --git a/examples/prp-run/.claude/prp-loop.run.log b/examples/prp-run/.claude/prp-loop.run.log new file mode 100644 index 0000000..e195ce4 --- /dev/null +++ b/examples/prp-run/.claude/prp-loop.run.log @@ -0,0 +1,10 @@ +[prp-loop] STAGE plan +[prp-loop] plan -> .claude/PRPs/plans/csv-dedupe.plan.md +[prp-loop] STAGE implement +[prp-loop] implement iteration 1/10 (cycle 0) +[prp-loop] implement iteration 2/10 (cycle 0) +[prp-loop] STAGE pr +[prp-loop] pr -> #42 https://github.com/example-org/contacts-tool/pull/42 +[prp-loop] STAGE review +[prp-loop] review CLEAN — pipeline complete +[prp-loop] DONE. PR: https://github.com/example-org/contacts-tool/pull/42 diff --git a/examples/prp-run/.claude/prp-loop.state.json b/examples/prp-run/.claude/prp-loop.state.json new file mode 100644 index 0000000..27fe7ad --- /dev/null +++ b/examples/prp-run/.claude/prp-loop.state.json @@ -0,0 +1,30 @@ +{ + "loop_id": "prp-loop-2026-07-09T14:01:55Z", + "feature": "Deduplicate contact imports by a normalized email+phone key", + "stage": "done", + "cycle": 0, + "max_cycles": 3, + "max_implement_iterations": 10, + "clean_bar": "Critical or Important", + "validate_cmd": "pytest -q", + "until": null, + "base": "main", + "status": "done", + "artifacts": { + "plan_path": ".claude/PRPs/plans/csv-dedupe.plan.md", + "branch": "feature/csv-dedupe", + "pr_number": 42, + "pr_url": "https://github.com/example-org/contacts-tool/pull/42", + "review_verdicts": [ + ".claude/PRPs/reviews/pr-42-cycle-0.verdict.json" + ] + }, + "history": [ + { "stage": "plan", "cycle": 0, "result": "ok", "at": "2026-07-09T14:05:03Z" }, + { "stage": "implement", "cycle": 0, "result": "green@2", "at": "2026-07-09T14:48:19Z" }, + { "stage": "pr", "cycle": 0, "result": "#42", "at": "2026-07-09T14:52:40Z" }, + { "stage": "review", "cycle": 0, "result": "clean", "at": "2026-07-09T15:19:07Z" } + ], + "started_at": "2026-07-09T14:01:55Z", + "updated_at": "2026-07-09T15:19:07Z" +} diff --git a/examples/prp-run/README.md b/examples/prp-run/README.md new file mode 100644 index 0000000..aaf0871 --- /dev/null +++ b/examples/prp-run/README.md @@ -0,0 +1,43 @@ +# prp-run — a vendored foreign-harness fixture + +A minimal, sanitized run directory in the layout the +[PRPs-agentic-eng](https://github.com/Wirasm/PRPs-agentic-eng) skill suite +(`prp-prd` → `prp-plan` → `prp-implement` → `prp-review`, driven headlessly by +`prp-loop`) leaves behind: a problem-first PRD, a context-rich plan that carries +its own task ledger, an implementation report, a PR review, and the loop's own +state and verdict files under `.claude/PRPs/`. Directory names and section +headings follow the harness's documented conventions +(Wirasm/PRPs-agentic-eng@ada2f5b867c196b68f17628d4870356e4e8164a4); every +sentence of prose is original and fictional — no template body text is copied. +It instantiates the shared fictional **csv-dedupe** task dated 2026-07-09, the +same task every sibling `examples/*-run` fixture models, so the scoreboard can +compare one job across nine layouts. + +PRPs is a **complement**, not a competitor: it is a spec-plan-implement-review +pipeline that drives how an agent does the work, with a genuine six-level +validation gate, a machine `{clean, blocking}` verdict, and a completion idiom +(the plan is physically moved into `plans/completed/`). Loop Engineer is the +contract layer that names how the work must end — a typed terminal record and an +independent held-out gate that no single-repo convention is obliged to carry. +The honest inspect score below is not a criticism; it measures the small delta +between "recorded done six ways" and "proven un-fakeable done once." + +`.claude/prp-loop.state.json`, `.claude/prp-loop.run.log`, and the +`.claude/PRPs/reviews/*.verdict.json` files are runtime state that a real PRPs +project gitignores; they are vendored here anyway so every artifact class — spec, +plan, task ledger, run journal, verification, machine verdict, loop state — is +represented on disk. + +The loop captures the plan's path at plan time, so `prp-loop.state.json`, the +implementation report, and the PRD's phase row all reference +`.claude/PRPs/plans/csv-dedupe.plan.md`, while `prp-implement` archives the file +itself into `plans/completed/` at the end — which is where the later PR review +resolves it. The fixture keeps that pre-archive/post-archive path split rather +than smoothing it into one location, because the split is exactly what a real +run leaves on disk. + +Reproduce the read-only score: + +```bash +uv run --with pyyaml python3 -B -m loop inspect examples/prp-run +``` diff --git a/examples/ruflo-run/.claude-flow/metrics/swarm-activity.json b/examples/ruflo-run/.claude-flow/metrics/swarm-activity.json new file mode 100644 index 0000000..196d1f8 --- /dev/null +++ b/examples/ruflo-run/.claude-flow/metrics/swarm-activity.json @@ -0,0 +1,18 @@ +{ + "timestamp": "2026-07-09T14:15:44Z", + "processes": { + "agentic_flow": 0, + "mcp_server": 1, + "estimated_agents": 0 + }, + "swarm": { + "active": false, + "agent_count": 8, + "coordination_active": false + }, + "integration": { + "agentic_flow_active": false, + "mcp_active": true + }, + "_initialized": true +} diff --git a/examples/ruflo-run/.claude-flow/metrics/v3-progress.json b/examples/ruflo-run/.claude-flow/metrics/v3-progress.json new file mode 100644 index 0000000..58b2843 --- /dev/null +++ b/examples/ruflo-run/.claude-flow/metrics/v3-progress.json @@ -0,0 +1,26 @@ +{ + "version": "3.0.0", + "initialized": "2026-07-09T14:02:11Z", + "domains": { + "completed": 1, + "total": 1, + "status": "COMPLETE" + }, + "ddd": { + "progress": 100, + "modules": 1, + "totalFiles": 3, + "totalLines": 214 + }, + "swarm": { + "activeAgents": 0, + "maxAgents": 8, + "topology": "hierarchical-mesh" + }, + "learning": { + "status": "READY", + "patternsLearned": 3, + "sessionsCompleted": 1 + }, + "_note": "Snapshot after the csv-dedupe feature closed; agents drained back to idle." +} diff --git a/examples/ruflo-run/.claude-flow/security/audit-status.json b/examples/ruflo-run/.claude-flow/security/audit-status.json new file mode 100644 index 0000000..7a486c0 --- /dev/null +++ b/examples/ruflo-run/.claude-flow/security/audit-status.json @@ -0,0 +1,8 @@ +{ + "initialized": "2026-07-09T14:02:11Z", + "status": "CLEAN", + "cvesFixed": 3, + "totalCves": 3, + "lastScan": "2026-07-09T14:14:52Z", + "_note": "Dependency scan ran after refinement; the statusline reads this file to color the security badge." +} diff --git a/examples/ruflo-run/.hive-mind/sessions/hive-mind-prompt-swarm-2026-07-09.txt b/examples/ruflo-run/.hive-mind/sessions/hive-mind-prompt-swarm-2026-07-09.txt new file mode 100644 index 0000000..758cf33 --- /dev/null +++ b/examples/ruflo-run/.hive-mind/sessions/hive-mind-prompt-swarm-2026-07-09.txt @@ -0,0 +1,46 @@ +HIVE MIND COLLECTIVE INTELLIGENCE SYSTEM +======================================== + +You are this swarm's Queen coordinator. Decompose the objective, delegate to +the workers below, and hold every subtask behind its SPARC gate. + +HIVE MIND CONFIGURATION + Swarm ID: swarm-2026-07-09 + Swarm Name: csv-dedupe + Objective: Deduplicate contact imports in src/import_contacts.py so repeated + people collapse to one row, and prove the collapse is stable. + Queen Type: strategic + Worker Count: 4 + Topology: hierarchical-mesh + Consensus Algorithm: byzantine + Initialized: 2026-07-09T14:02:33Z + +WORKER DISTRIBUTION + researcher: 1 agent + coder: 1 agent + tester: 1 agent + reviewer: 1 agent + +OBJECTIVE DETAIL + The importer currently writes a new row every time the same contact appears + with different casing across the two seed files. Introduce a normalized key on + (lower(email), lower(phone)); keep the first row seen for a key and drop the + rest. Record each dropped row with its source file and line number in + dedupe.log. Definition of done: the two seed files yield 41 unique contacts + from 57 input rows, a second import over the same input inserts zero rows, and + every dropped duplicate is traceable in the log. + +EXECUTION PROTOCOL + 1. Initialization — confirm workers are live and load any prior session state. + 2. Distribution — split the objective into spec, build, test, and review. + 3. Coordination — resolve cross-worker conflicts by byzantine consensus. + 4. Completion — verify each subtask, then run the phase-5 gate check. + +COORDINATION NOTES + Route all orchestration through the ruflo MCP tools; reserve the native file + and shell tools for edits and commands. Persist decisions to shared memory so + a resumed session can pick the objective back up. Advance a phase only after + its gate criteria pass — do not report done on a failed gate. + +Your first action is a hive status check; only once it is healthy do you take +up the objective itself. diff --git a/examples/ruflo-run/.mcp.json b/examples/ruflo-run/.mcp.json new file mode 100644 index 0000000..02e3a83 --- /dev/null +++ b/examples/ruflo-run/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "claude-flow": { + "command": "npx", + "args": ["-y", "ruflo@latest", "mcp", "start"], + "env": { + "npm_config_update_notifier": "false", + "CLAUDE_FLOW_MODE": "v3", + "CLAUDE_FLOW_HOOKS_ENABLED": "true", + "CLAUDE_FLOW_TOPOLOGY": "hierarchical-mesh", + "CLAUDE_FLOW_MAX_AGENTS": "8", + "CLAUDE_FLOW_MEMORY_BACKEND": "agentdb" + }, + "autoStart": true + } + } +} diff --git a/examples/ruflo-run/.swarm/memory-export.json b/examples/ruflo-run/.swarm/memory-export.json new file mode 100644 index 0000000..91da030 --- /dev/null +++ b/examples/ruflo-run/.swarm/memory-export.json @@ -0,0 +1,78 @@ +{ + "schema": "ruflo-memory-export/v1", + "exportedAt": "2026-07-09T14:16:03Z", + "namespace": null, + "count": 7, + "entries": [ + { + "key": "spec-csv-dedupe", + "namespace": "sparc-phases", + "value": "{\"status\":\"complete\",\"requirements\":{\"functional\":[\"FR-1: collapse rows sharing a normalized email+phone key to one retained row\",\"FR-2: keep the first-seen row and discard later duplicates\",\"FR-3: append each dropped row and its source line number to dedupe.log\"],\"nonFunctional\":[\"NFR-1: a re-run over unchanged input inserts zero additional rows\"]},\"acceptanceCriteria\":[\"AC-1: Given the two seed files, when the import runs, then 41 unique contacts remain from 57 input rows\",\"AC-2: Given a completed import, when the same files import again, then zero new rows are written\",\"AC-3: Given a dropped duplicate, when it is skipped, then its source file and line number appear in dedupe.log\"],\"constraints\":[\"case-insensitive match on email and phone only\",\"retention order is first-seen wins\",\"no external service calls during import\"],\"edgeCases\":[\"EC-1: a row missing either email or phone\",\"EC-2: the same contact repeated three times across both files\",\"EC-3: whitespace-padded values that normalize to an existing key\"],\"integrationPoints\":[\"IP-1: src/import_contacts.py write path into the contacts table\"]}", + "createdAt": "2026-07-09T14:04:18Z", + "updatedAt": "2026-07-09T14:04:18Z", + "accessCount": 5, + "hasEmbedding": true, + "size": 1024 + }, + { + "key": "pseudo-csv-dedupe", + "namespace": "sparc-phases", + "value": "{\"status\":\"complete\",\"algorithms\":[\"track a set of seen keys; for each row compute normalize_key(email, phone); insert only when the key is absent, otherwise log and skip\"],\"dataStructures\":[\"seen: set of (email, phone) string tuples\",\"dropped: list of (source_file, line_no) records\"],\"controlFlow\":[\"happy path: unseen key -> insert -> mark seen\",\"duplicate: known key -> append to dedupe.log -> continue\",\"missing field: normalize to an empty component -> still keyed and deduped\"],\"complexity\":{\"time\":\"O(n) over input rows\",\"space\":\"O(u) for u unique keys\"}}", + "createdAt": "2026-07-09T14:06:52Z", + "updatedAt": "2026-07-09T14:06:52Z", + "accessCount": 3, + "hasEmbedding": true, + "size": 612 + }, + { + "key": "arch-csv-dedupe", + "namespace": "sparc-phases", + "value": "{\"status\":\"complete\",\"boundedContexts\":[\"contact-import\"],\"apiContracts\":[\"normalize_key(email: str, phone: str) -> tuple[str, str]\",\"dedupe_rows(rows: Iterable[Row]) -> DedupeResult\"],\"moduleBoundaries\":{\"entry\":\"src/import_contacts.py\",\"noCircularDeps\":true},\"infrastructure\":{\"persistence\":\"append-only dedupe.log plus the contacts table\",\"messaging\":\"none\",\"config\":\"seed file paths passed as arguments\"}}", + "createdAt": "2026-07-09T14:08:40Z", + "updatedAt": "2026-07-09T14:08:40Z", + "accessCount": 3, + "hasEmbedding": true, + "size": 520 + }, + { + "key": "refine-csv-dedupe", + "namespace": "sparc-phases", + "value": "{\"status\":\"complete\",\"reviewFindings\":[{\"severity\":\"medium\",\"note\":\"strip surrounding whitespace before lowercasing so padded values collapse to the same key\",\"resolved\":true}],\"coveragePercent\":92,\"performanceResults\":{\"rows\":57,\"durationMs\":38},\"iterations\":2}", + "createdAt": "2026-07-09T14:11:29Z", + "updatedAt": "2026-07-09T14:12:07Z", + "accessCount": 4, + "hasEmbedding": true, + "size": 336 + }, + { + "key": "complete-csv-dedupe", + "namespace": "sparc-phases", + "value": "{\"status\":\"complete\",\"traceabilityMatrix\":[{\"ac\":\"AC-1\",\"test\":\"test_dedupe_yields_41_unique\",\"code\":\"src/import_contacts.py:44\",\"status\":\"Pass\"},{\"ac\":\"AC-2\",\"test\":\"test_second_run_is_idempotent\",\"code\":\"src/import_contacts.py:71\",\"status\":\"Pass\"},{\"ac\":\"AC-3\",\"test\":\"test_dropped_rows_are_logged\",\"code\":\"src/import_contacts.py:58\",\"status\":\"Pass\"}],\"documentationFiles\":[\"docs/import-contacts.md\"],\"deploymentChecklist\":{\"testsPassing\":true,\"documentationComplete\":true,\"migrationsPrepared\":false,\"rollbackPlanDefined\":true,\"securityReviewed\":true},\"regressionResult\":\"pass\"}", + "createdAt": "2026-07-09T14:14:20Z", + "updatedAt": "2026-07-09T14:14:20Z", + "accessCount": 2, + "hasEmbedding": true, + "size": 704 + }, + { + "key": "current-phase-csv-dedupe", + "namespace": "sparc-state", + "value": "{\"feature\":\"csv-dedupe\",\"currentPhase\":\"completion\",\"phaseNumber\":5,\"status\":\"complete\",\"artifacts\":[\"spec-csv-dedupe\",\"pseudo-csv-dedupe\",\"arch-csv-dedupe\",\"refine-csv-dedupe\",\"complete-csv-dedupe\"]}", + "createdAt": "2026-07-09T14:04:18Z", + "updatedAt": "2026-07-09T14:14:20Z", + "accessCount": 9, + "hasEmbedding": false, + "size": 232 + }, + { + "key": "gates-csv-dedupe", + "namespace": "sparc-gates", + "value": "{\"feature\":\"csv-dedupe\",\"gates\":[{\"phase\":1,\"name\":\"specification\",\"result\":\"pass\",\"criteria\":\"3 acceptance criteria plus constraints and edge cases recorded\"},{\"phase\":2,\"name\":\"pseudocode\",\"result\":\"pass\",\"criteria\":\"all ACs covered, error paths explicit\"},{\"phase\":3,\"name\":\"architecture\",\"result\":\"pass\",\"criteria\":\"typed contracts, no circular dependencies\"},{\"phase\":4,\"name\":\"refinement\",\"result\":\"pass\",\"coverage\":92,\"criteria\":\"all ACs have passing tests, review approved, coverage >= 80%\"},{\"phase\":5,\"name\":\"completion\",\"result\":\"pass\",\"truthScore\":0.97,\"criteria\":\"all tests green, docs complete, deployment checklist verified\"}]}", + "createdAt": "2026-07-09T14:05:02Z", + "updatedAt": "2026-07-09T14:14:31Z", + "accessCount": 6, + "hasEmbedding": false, + "size": 720 + } + ] +} diff --git a/examples/ruflo-run/README.md b/examples/ruflo-run/README.md new file mode 100644 index 0000000..dc17b18 --- /dev/null +++ b/examples/ruflo-run/README.md @@ -0,0 +1,46 @@ +# ruflo-run — a vendored foreign-harness fixture + +A minimal, sanitized run directory in the layout that +[ruflo](https://github.com/ruvnet/ruflo) (ruvnet/ruflo, formerly claude-flow, +v3.5) leaves behind after `npx ruflo init` scaffolds a workspace and a SPARC +swarm drives a feature to done. Layout follows +`ruvnet/ruflo@7ef4d4e655d81c0451f6f40f35729cce6c9928e7`. ruflo is MIT-licensed, +but per this repo's fixture rule only directory names, JSON key names, and +section headings follow its conventions — **every sentence of prose here is +original and fictional**. No template body text is copied. + +The recorded run is the same fictional task used across the other fixtures: a +CSV-dedupe change to `src/import_contacts.py` (normalize on +`(lower(email), lower(phone))`, keep the first-seen row, log dropped rows to +`dedupe.log`, 41 unique from 57 input rows, idempotent on re-run), dated +2026-07-09. + +## What is (and isn't) vendored + +ruflo keeps authoritative run state in **binary SQLite** — `.swarm/memory.db` +(SPARC phase artifacts, gate records, learned patterns) and `.hive-mind/hive.db` +(queen/worker/task rows). Those are not human-readable, so this fixture vendors +the harness's own documented serialization instead: `.swarm/memory-export.json` +is the exact shape produced by `ruflo memory export -o backup.json`, alongside +the JSON metrics and security-gate files that `init` and the hooks write. No +binary database is included. + +## Composes, does not compete + +ruflo is an orchestration and memory substrate — it spawns a swarm, runs the +five SPARC phases behind quality gates, and records completion across a memory +namespace, a gate row, and a session-end git tag. Loop Engineer is the contract +layer that grades whether that end state is *provable*. The honest score this +fixture earns is not a knock on ruflo: it measures what a DB-backed swarm run +structurally leaves outside a portable contract — no typed terminal record and +no held-out anti-cheat gate an outside reader can replay. See +`docs/gap-reports/` for the item-by-item reading. + +## Reproduce + +```bash +uv run --with pyyaml python3 -B -m loop inspect examples/ruflo-run || true +``` + +A weak verdict exits non-zero; the `|| true` keeps that expected result from +failing a shell that chains commands. diff --git a/examples/spec-kit-run/.specify/feature.json b/examples/spec-kit-run/.specify/feature.json new file mode 100644 index 0000000..2ee2446 --- /dev/null +++ b/examples/spec-kit-run/.specify/feature.json @@ -0,0 +1 @@ +{"feature_directory": "specs/001-csv-dedupe"} diff --git a/examples/spec-kit-run/.specify/memory/constitution.md b/examples/spec-kit-run/.specify/memory/constitution.md new file mode 100644 index 0000000..8cf535b --- /dev/null +++ b/examples/spec-kit-run/.specify/memory/constitution.md @@ -0,0 +1,35 @@ +# Contacts Toolkit Constitution + + + +## Core Principles + +### I. Deterministic Output + +Any transformation that reshapes source rows MUST produce the same result for the +same inputs, independent of file order or run count. Re-running an import over +data that is already loaded MUST add nothing new. When a row is discarded, the +tool MUST record which source line it came from, so the outcome can be audited by +hand rather than trusted on faith. + +### II. Test-Backed Change + +No behavior ships without an automated test that would fail if the behavior +regressed. Deduplication logic in particular MUST be covered by a test that pins +the exact surviving-row count for a known fixture, so a silent drift in the key +strategy is caught before it reaches a user's contact list. + +## Governance + +This constitution is the authority the plan's Constitution Check gate is measured +against. A plan that conflicts with a MUST here cannot pass the gate until the +conflict is resolved or the constitution is amended with a recorded reason. +Amendments bump the version and note the rationale in the sync impact report above. + +**Version**: 1.0.0 | **Ratified**: 2026-07-09 | **Last Amended**: 2026-07-09 diff --git a/examples/spec-kit-run/README.md b/examples/spec-kit-run/README.md new file mode 100644 index 0000000..d48abdf --- /dev/null +++ b/examples/spec-kit-run/README.md @@ -0,0 +1,27 @@ +# spec-kit-run — a vendored foreign-harness fixture + +A minimal, sanitized run directory in the layout that +[Spec Kit](https://github.com/github/spec-kit) leaves behind +(`.specify/` scaffold + a numbered `specs/NNN-slug/` feature folder). Layout +follows `github/spec-kit@3f7392ae32131c9cfe6a1f97c5c213311183263a` (MIT); every +sentence of content here is original and fictional — no template body text was +copied. It exists so `python3 -m loop inspect examples/spec-kit-run` can score a +foreign layout read-only against the same fictional CSV-dedupe task the other +example runs use. + +Spec Kit is a **complement**, not a competitor: it is a spec-driven workflow that +shapes *how* an agent plans and builds a feature; Loop Engineer is the contract +layer that proves *how the work ended*. The honest low score here is not a +criticism — it measures what a spec/plan/tasks/checklist layout *structurally +cannot prove*. Spec Kit's quality gates (the checklist PASS/FAIL table, the +`analyze` severity pass, the Constitution Check, the "Done When" list) are +natural-language instructions the agent is asked to follow; the only tool-enforced +check it ships is prerequisite-file existence. There is no held-out gate, no typed +terminal record, and no machine-readable done flag — "done" is the checkbox state +of `tasks.md`. That gap is exactly what emitting the contract adds. + +Reproduce the score: + +```bash +uv run --with pyyaml python3 -B -m loop inspect examples/spec-kit-run +``` diff --git a/examples/spec-kit-run/specs/001-csv-dedupe/checklists/requirements.md b/examples/spec-kit-run/specs/001-csv-dedupe/checklists/requirements.md new file mode 100644 index 0000000..489cf20 --- /dev/null +++ b/examples/spec-kit-run/specs/001-csv-dedupe/checklists/requirements.md @@ -0,0 +1,32 @@ +# Requirements Quality Checklist: CSV Contact Dedupe + +**Purpose**: Validate that the requirements themselves are complete, clear, consistent, and measurable — these are quality gates on the writing, not tests of the code. +**Created**: 2026-07-09 +**Feature**: [spec.md](../spec.md) + +## Completeness + +- [x] CHK001 [Completeness] Every user-facing outcome in the story has a matching functional requirement (FR-001..FR-005). +- [x] CHK002 [Completeness] The audit-log behavior is specified, including that the source line number is captured (FR-003, SC-003). +- [x] CHK003 [Completeness] The idempotent re-run behavior is stated as its own requirement, not left implicit (FR-004, SC-002). + +## Clarity + +- [x] CHK004 [Clarity] The dedupe key is defined without ambiguity — lowercased, trimmed email and phone (FR-001). +- [x] CHK005 [Clarity] "First-seen wins" is stated explicitly so the survivor among duplicates is never in doubt (FR-002). +- [x] CHK006 [Clarity] No requirement carries an unresolved NEEDS CLARIFICATION marker. + +## Consistency + +- [x] CHK007 [Consistency] The 41-from-57 count is identical across the story, FR/SC sections, and tasks — no drift. +- [x] CHK008 [Consistency] The blank-field edge case does not contradict the key definition (empty field, not wildcard). + +## Coverage + +- [x] CHK009 [Coverage] Each success criterion (SC-001..SC-003) is measurable and traces to at least one acceptance scenario. +- [x] CHK010 [Coverage] Both edge cases in the spec are reflected either in a requirement or an acceptance scenario. + +## Notes + +- All items are checked, so the implement-time PASS/FAIL gate reads PASS and no unchecked item blocks the run. +- This checklist grades the requirements document; the code's behavior is proven separately by the tests in `tasks.md`. diff --git a/examples/spec-kit-run/specs/001-csv-dedupe/plan.md b/examples/spec-kit-run/specs/001-csv-dedupe/plan.md new file mode 100644 index 0000000..5f6908d --- /dev/null +++ b/examples/spec-kit-run/specs/001-csv-dedupe/plan.md @@ -0,0 +1,71 @@ +# Implementation Plan: CSV Contact Dedupe + +**Branch**: `001-csv-dedupe` | **Date**: 2026-07-09 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/001-csv-dedupe/spec.md` + +## Summary + +Fold case-varying duplicate contacts into a single first-seen row on import, and +log every discarded row with its source line. The approach is a stdlib CSV read +that builds a normalized `(lower(email), lower(phone))` key per row, keeps the +first occurrence of each key, and appends the rest to `dedupe.log`. + +## Technical Context + +**Language/Version**: Python 3.11 + +**Primary Dependencies**: standard library only (`csv`, `argparse`) — no third-party packages + +**Storage**: flat CSV files on disk; no database + +**Testing**: pytest + +**Target Platform**: Linux/macOS command line + +**Project Type**: single-project CLI + +**Performance Goals**: comfortably handles the sample exports (tens of thousands of rows) in one pass + +**Constraints**: deterministic output regardless of input file order; idempotent on re-run + +**Scale/Scope**: one command, one dedupe pass, one log file + +## Constitution Check + +*This gate is evaluated before Phase 0 research begins and evaluated again once the Phase 1 design settles.* + +- Principle I (Deterministic Output): PASS — first-seen-wins over a normalized key is + order-stable, the re-run adds nothing, and each drop is logged with its source line. +- Principle II (Test-Backed Change): PASS — the plan pins a test that asserts the exact + 41-row survivor count for the sample fixture before the logic is written. + +Initial gate: PASS. Re-checked after Phase 1 design: PASS (no new violations introduced). + +## Project Structure + +### Source Code (repository root) + +```text +src/ +├── cli/ +│ └── import_contacts.py # command entry point +└── lib/ + └── dedupe.py # normalize_key + first-seen dedupe pass + +tests/ +├── unit/ +│ └── test_dedupe.py # key normalization + survivor count +└── integration/ + └── test_import.py # end-to-end import + idempotent re-run +``` + +**Structure Decision**: Single-project CLI layout. There is no web or mobile tier +and no service boundary, so the default `src/` + `tests/` split is used directly. + +## Complexity Tracking + +> Populate this section only when the Constitution Check surfaces a violation that needs a written justification. + +No violations. The design stays within stdlib and a single dedupe pass, so this +table is intentionally empty. diff --git a/examples/spec-kit-run/specs/001-csv-dedupe/quickstart.md b/examples/spec-kit-run/specs/001-csv-dedupe/quickstart.md new file mode 100644 index 0000000..eff60b3 --- /dev/null +++ b/examples/spec-kit-run/specs/001-csv-dedupe/quickstart.md @@ -0,0 +1,38 @@ +# Quickstart: CSV Contact Dedupe + +**Phase 1 validation doc for** `specs/001-csv-dedupe/`. How to run the completed +import and the counts a correct run produces — the evidence behind the +"Run quickstart.md validation" task in `tasks.md`. + +## Run the import + +From the repository root, point the CLI at the two sample exports: + +```bash +python -m src.cli.import_contacts \ + tests/fixtures/contacts_a.csv \ + tests/fixtures/contacts_b.csv \ + --out contacts.csv +``` + +## Expected result + +- The two exports hold 57 rows between them. +- `contacts.csv` holds 41 unique contacts after case-varying duplicates are folded. +- `dedupe.log` names each of the 16 dropped rows with the source file and line it + was read from. + +## Confirm idempotency + +Run the same command a second time over the produced list. A correct run reports +0 new rows and appends nothing further to `dedupe.log`, matching SC-002. + +## Run the tests + +```bash +pytest tests/ +``` + +The unit test pins `normalize_key` on mixed-case input and the integration test +asserts the 41-row survivor count and the zero-new-rows re-run. Both pass on the +completed feature. diff --git a/examples/spec-kit-run/specs/001-csv-dedupe/research.md b/examples/spec-kit-run/specs/001-csv-dedupe/research.md new file mode 100644 index 0000000..f7287c4 --- /dev/null +++ b/examples/spec-kit-run/specs/001-csv-dedupe/research.md @@ -0,0 +1,32 @@ +# Research: CSV Contact Dedupe + +**Phase 0 output for** `specs/001-csv-dedupe/plan.md`. Records the decisions taken +before design and the reasons the conditional Phase 1 documents are omitted. + +## Decision: dedupe key strategy + +**Chosen**: a tuple of the lowercased, whitespace-trimmed email and phone. + +**Rationale**: The observed duplicates differ only by casing and stray spaces, so +normalizing both fields collapses them while leaving genuinely different people +apart. Using the pair rather than email alone avoids merging two people who share a +shared inbox but differ by phone. + +**Alternatives considered**: A hash of the whole row was rejected because it would +treat a re-typed display name as a different person and defeat the dedupe. Fuzzy +matching was rejected as over-scoped for a dataset whose only variance is casing. + +## Decision: first-seen wins + +**Chosen**: keep the first row that resolves to a key; discard and log later ones. + +**Rationale**: First-seen-wins is order-stable given a fixed input order and makes +the re-run trivially idempotent, satisfying Constitution Principle I. + +## Omitted Phase 1 documents + +`data-model.md` and `contracts/` are not produced for this feature. There are no +persisted entities beyond flat CSV rows and no API surface to contract — the +deliverable is a single CLI pass. Per the tasks prerequisites, only the documents +that exist are listed as available design inputs, so their absence is faithful to a +pure-CLI feature rather than a gap. diff --git a/examples/spec-kit-run/specs/001-csv-dedupe/spec.md b/examples/spec-kit-run/specs/001-csv-dedupe/spec.md new file mode 100644 index 0000000..01f79de --- /dev/null +++ b/examples/spec-kit-run/specs/001-csv-dedupe/spec.md @@ -0,0 +1,74 @@ +# Feature Specification: CSV Contact Dedupe + +**Feature Branch**: `001-csv-dedupe` + +**Created**: 2026-07-09 + +**Status**: Draft + +**Input**: User description: "import_contacts.py writes duplicate rows when the same person appears in two source files with different casing — dedupe on import and keep an auditable log of what was dropped." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Merge two contact exports without duplicates (Priority: P1) + +An operator imports two overlapping contact exports where the same person is +written with different capitalization in email and phone fields. They want one +clean contact list, not a pile of near-identical rows, and a record of exactly +which rows were folded away. + +**Why this priority**: This is the entire reason the feature exists; without it the +import is worse than useless because it inflates the list with silent duplicates. + +**Independent Test**: Run the import against the two sample exports and confirm the +resulting list holds one row per person plus a log naming every dropped row. + +**Acceptance Scenarios**: + +1. **Given** two exports totaling 57 rows with case-varying duplicates, **When** the + operator runs the import, **Then** the contact list holds 41 unique rows. +2. **Given** a contact list already produced by a prior import, **When** the operator + runs the same import again, **Then** zero new rows are added. +3. **Given** a row folded into an earlier one, **When** the import finishes, **Then** + `dedupe.log` names the dropped row and the source line it came from. + +### Edge Cases + +- What happens when a row has an email but a blank phone, or the reverse? The + present fields still form the key; a missing field is treated as empty, not as a + wildcard that collapses unrelated people together. +- How does the tool handle surrounding whitespace and mixed casing in the same + field? Both are normalized away before the key is compared. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST derive a dedupe key from the lowercased, trimmed email and + phone of each row before deciding whether to keep it. +- **FR-002**: System MUST keep the first row seen for a given key and discard every + later row that resolves to the same key. +- **FR-003**: System MUST append each discarded row to `dedupe.log` together with the + source file and line number it was read from. +- **FR-004**: System MUST be idempotent — re-importing already-loaded data MUST add + no new rows. +- **FR-005**: System MUST expose the import as a command-line entry point that reads + the source files and reports the surviving-row count on completion. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Importing the two sample exports yields exactly 41 unique contacts from + 57 input rows. +- **SC-002**: A second run over the same result adds 0 rows. +- **SC-003**: Every dropped row appears in `dedupe.log` with a resolvable source line + number, so 100% of removals are auditable by hand. + +## Assumptions + +- The two source files are the canonical inputs; no third source is in scope for v1. +- Email and phone together are enough to identify a person for this dataset; no + external identity service is consulted. +- The output list and `dedupe.log` are written to the working directory the command + is invoked from. diff --git a/examples/spec-kit-run/specs/001-csv-dedupe/tasks.md b/examples/spec-kit-run/specs/001-csv-dedupe/tasks.md new file mode 100644 index 0000000..27aa095 --- /dev/null +++ b/examples/spec-kit-run/specs/001-csv-dedupe/tasks.md @@ -0,0 +1,74 @@ +--- +description: "Task list for CSV Contact Dedupe" +--- + +# Tasks: CSV Contact Dedupe + +**Input**: Design documents from `specs/001-csv-dedupe/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md + +**Tests**: Included — Constitution Principle II requires a test that pins the survivor count. + +**Organization**: Tasks are grouped by user story so the single P1 story is independently verifiable. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: The user story a task serves (US1) + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Lay down the project skeleton the dedupe pass lives in. + +- [X] T001 Create the `src/cli`, `src/lib`, and `tests` tree per the plan +- [X] T002 Add a `pytest` dev dependency and a minimal project config +- [X] T003 [P] Vendor the two fictional sample exports under `tests/fixtures/` + +**Checkpoint**: Skeleton and fixtures in place — dedupe work can begin. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The row-reading and logging primitives every later task depends on. + +- [X] T004 Read CSV rows into dicts and carry each row's source file + line number +- [X] T005 Open `dedupe.log` for append and write one line per dropped row + +**Checkpoint**: Rows can be read with provenance and drops can be recorded. + +--- + +## Phase 3: User Story 1 - Merge two contact exports without duplicates (Priority: P1) 🎯 MVP + +**Goal**: One clean list from the two overlapping exports, plus an audit log. + +**Independent Test**: Import the two sample exports; assert 41 surviving rows and a log naming each drop. + +### Tests for User Story 1 + +> Written FIRST and confirmed failing before the implementation below. + +- [X] T006 [P] [US1] Unit test in `tests/unit/test_dedupe.py` pinning `normalize_key` on mixed-case input +- [X] T007 [P] [US1] Integration test in `tests/integration/test_import.py` asserting 41 rows and idempotent re-run + +### Implementation for User Story 1 + +- [X] T008 [US1] Implement `normalize_key(email, phone)` in `src/lib/dedupe.py` +- [X] T009 [US1] Implement the first-seen dedupe pass over normalized keys +- [X] T010 [US1] Log every discarded row with its source line to `dedupe.log` +- [X] T011 [US1] Wire the `import_contacts.py` CLI entry point and report the survivor count + +**Checkpoint**: User Story 1 fully functional — 41/57 rows, 0 new on re-run, all drops logged. + +--- + +## Phase 4: Polish & Cross-Cutting Concerns + +**Purpose**: Final validation of the completed run. + +- [X] T012 [P] Note the run command and expected counts in `quickstart.md` +- [X] T013 Run `quickstart.md` validation against the sample exports + +**Checkpoint**: All phases complete — every task above is marked `[X]`. diff --git a/examples/task-master-run/.taskmaster/config.json b/examples/task-master-run/.taskmaster/config.json new file mode 100644 index 0000000..3049c21 --- /dev/null +++ b/examples/task-master-run/.taskmaster/config.json @@ -0,0 +1,32 @@ +{ + "models": { + "main": { + "provider": "claude-code", + "modelId": "opus", + "maxTokens": 32000, + "temperature": 0.2 + }, + "research": { + "provider": "claude-code", + "modelId": "sonnet", + "maxTokens": 32000, + "temperature": 0.1 + }, + "fallback": { + "provider": "claude-code", + "modelId": "sonnet", + "maxTokens": 64000, + "temperature": 0.2 + } + }, + "global": { + "logLevel": "info", + "debug": false, + "defaultNumTasks": 10, + "defaultSubtasks": 3, + "defaultPriority": "medium", + "projectName": "csv-dedupe", + "responseLanguage": "English", + "defaultTag": "master" + } +} diff --git a/examples/task-master-run/.taskmaster/docs/prd.txt b/examples/task-master-run/.taskmaster/docs/prd.txt new file mode 100644 index 0000000..e05ed63 --- /dev/null +++ b/examples/task-master-run/.taskmaster/docs/prd.txt @@ -0,0 +1,42 @@ + +# Overview +import_contacts.py loads two contact exports into one table. When the same person +appears in both files with different casing, the loader treats the rows as +distinct and writes duplicates. This PRD scopes a deduplicating import that keeps +one row per real contact and leaves an audit trail of what it dropped. + +# Core Features +- A normalization key that folds email and phone to a casing-insensitive form so + variants of one contact resolve to a single identity. +- An idempotent insert path that skips a row whose key was already written, so + re-running the import changes nothing. +- A dedupe log that records every dropped row with its source file and line. + +# User Experience +The operator runs the import from the command line and reads a short summary plus +dedupe.log. No interface beyond the CLI is in scope. + + +# Technical Architecture +A single Python module keys each row through normalize_key(email, phone), tracks +seen keys in an in-memory set, inserts first-seen rows, and appends skipped rows +to dedupe.log. No schema change and no new dependency are required. + +# Development Roadmap +Phase 1 delivers the normalization key. Phase 2 adds the idempotent insert guard. +Phase 3 adds the dedupe log. Each phase is independently testable and ships in +order. + +# Logical Dependency Chain +The key must exist before the insert guard can compare keys, and the insert guard +must exist before there are skipped rows to log. The chain is therefore key, then +guard, then log. + +# Risks and Mitigations +The main risk is an over-broad key that merges two genuinely different people; +the mitigation is to key on both email and phone rather than either alone, and to +assert the exact expected unique count in the test suite. + +# Appendix +Sample inputs total 57 rows and are expected to reduce to 41 unique contacts. + diff --git a/examples/task-master-run/.taskmaster/reports/task-complexity-report.json b/examples/task-master-run/.taskmaster/reports/task-complexity-report.json new file mode 100644 index 0000000..881ae3d --- /dev/null +++ b/examples/task-master-run/.taskmaster/reports/task-complexity-report.json @@ -0,0 +1,37 @@ +{ + "meta": { + "generatedAt": "2026-07-09T14:05:31.000Z", + "tasksAnalyzed": 3, + "totalTasks": 3, + "analysisCount": 3, + "thresholdScore": 5, + "projectName": "csv-dedupe", + "usedResearch": false + }, + "complexityAnalysis": [ + { + "taskId": 1, + "taskTitle": "Build the normalization key", + "complexityScore": 3, + "recommendedSubtasks": 1, + "expansionPrompt": "Break the normalization key into implementing normalize_key with lowercase and strip, then covering it with mixed-case and whitespace fixtures.", + "reasoning": "A small pure function with a clear contract; the only real work is choosing which fields to fold and writing thorough casing fixtures." + }, + { + "taskId": 2, + "taskTitle": "Make the import idempotent", + "complexityScore": 4, + "recommendedSubtasks": 1, + "expansionPrompt": "Break the idempotency work into guarding the insert on a seen-key set and asserting a second import inserts zero rows.", + "reasoning": "Depends on the key and touches the insert path, but the logic is a single set membership check with a count, so complexity stays low." + }, + { + "taskId": 3, + "taskTitle": "Log dropped duplicates", + "complexityScore": 3, + "recommendedSubtasks": 1, + "expansionPrompt": "Break the logging work into appending one audit line per skipped row and verifying the recorded source line numbers resolve.", + "reasoning": "Straightforward append-only logging; the only care needed is carrying the source file and line number through to the skip point." + } + ] +} diff --git a/examples/task-master-run/.taskmaster/state.json b/examples/task-master-run/.taskmaster/state.json new file mode 100644 index 0000000..41a4b5c --- /dev/null +++ b/examples/task-master-run/.taskmaster/state.json @@ -0,0 +1,9 @@ +{ + "currentTag": "master", + "lastSwitched": "2026-07-09T14:01:12.000Z", + "branchTagMapping": {}, + "migrationNoticeShown": true, + "metadata": { + "exportedTags": {} + } +} diff --git a/examples/task-master-run/.taskmaster/tasks/task_001.txt b/examples/task-master-run/.taskmaster/tasks/task_001.txt new file mode 100644 index 0000000..fb03a87 --- /dev/null +++ b/examples/task-master-run/.taskmaster/tasks/task_001.txt @@ -0,0 +1,17 @@ +# Task ID: 1 +# Title: Build the normalization key +# Status: done +# Dependencies: None +# Priority: high +# Description: Collapse casing and surrounding whitespace so that equal contacts hash to one key. +# Details: +Add normalize_key(email, phone) that lowercases and strips both fields and +returns them as a tuple. Route every row in import_contacts.py through it before +deciding whether to insert, so that casing differences between the two source +files can no longer produce distinct keys for the same contact. + +# Test Strategy: +Run the unit suite over mixed-case fixtures; every casing and padding variant of +a single contact must collapse to the same key. Verified 2026-07-09: all 15 unit +tests pass and the uppercase, title-case, and space-padded variants of one +contact now map to a single key. diff --git a/examples/task-master-run/.taskmaster/tasks/tasks.json b/examples/task-master-run/.taskmaster/tasks/tasks.json new file mode 100644 index 0000000..63e88b0 --- /dev/null +++ b/examples/task-master-run/.taskmaster/tasks/tasks.json @@ -0,0 +1,73 @@ +{ + "master": { + "tasks": [ + { + "id": 1, + "title": "Build the normalization key", + "description": "Collapse casing and surrounding whitespace so that equal contacts hash to one key.", + "status": "done", + "dependencies": [], + "priority": "high", + "details": "Add normalize_key(email, phone) that lowercases and strips both fields and returns them as a tuple. Route every row in import_contacts.py through it before deciding whether to insert.", + "testStrategy": "Run the unit suite over mixed-case fixtures; every casing and padding variant of a single contact must collapse to the same key.", + "subtasks": [ + { + "id": 1, + "title": "Write normalize_key with mixed-case unit tests", + "description": "Implement the key function and cover it with fixtures that vary casing and whitespace.", + "status": "done", + "dependencies": [], + "details": "Implemented the lowercase-and-strip key and added 15 fixtures.\n\nAll 15 unit tests pass. Uppercase, title-case, and space-padded variants of one contact now map to a single key.\n" + } + ] + }, + { + "id": 2, + "title": "Make the import idempotent", + "description": "Skip inserts whose key already exists and prove a second import adds nothing.", + "status": "done", + "dependencies": [1], + "priority": "high", + "details": "Track seen keys in a set; on a collision count the skip instead of inserting. Re-running the import must leave the row count unchanged.", + "testStrategy": "Import the two sample files and assert 41 unique rows from 57 input rows; run the import a second time and assert 0 new rows.", + "subtasks": [ + { + "id": 1, + "title": "Guard the insert on a seen-key set", + "description": "Count skips on collision and verify a re-run is a no-op.", + "status": "done", + "dependencies": [], + "details": "Guarded the insert path on a seen-key set.\n\nThe first import produced 41 unique contacts from 57 rows. A second run inserted 0 rows, confirming idempotency.\n" + } + ] + }, + { + "id": 3, + "title": "Log dropped duplicates", + "description": "Record every dropped duplicate with its source file and line number.", + "status": "done", + "dependencies": [2], + "priority": "medium", + "details": "When a row is skipped, append a line to dedupe.log naming the source file, the source line number, and the key it duplicated.", + "testStrategy": "After an import, confirm dedupe.log lists all dropped rows and that each recorded source line number resolves to a real input row.", + "subtasks": [ + { + "id": 1, + "title": "Append dropped rows to dedupe.log", + "description": "Write one audit line per skipped row and spot-check it against the inputs.", + "status": "done", + "dependencies": [], + "details": "Appended each skipped row to dedupe.log.\n\ndedupe.log recorded all 16 dropped rows (57 minus 41) with source file and line number. Three entries were spot-checked against the input files and matched.\n" + } + ] + } + ], + "metadata": { + "created": "2026-07-09T14:02:00.000Z", + "updated": "2026-07-09T15:39:12.000Z", + "description": "Master tag for the csv-dedupe import fix", + "taskCount": 3, + "completedCount": 3 + } + } +} diff --git a/examples/task-master-run/.taskmaster/templates/example_prd.txt b/examples/task-master-run/.taskmaster/templates/example_prd.txt new file mode 100644 index 0000000..1ef8925 --- /dev/null +++ b/examples/task-master-run/.taskmaster/templates/example_prd.txt @@ -0,0 +1,32 @@ + +# Overview +[State the problem this product solves, who feels it, and why solving it matters.] + +# Core Features +[List each major feature with what it does, why it earns a place, and a high-level +sketch of the mechanics behind it.] + +# User Experience +[Sketch the people who use this and the paths they take through it, including any +interface constraints.] + + +# Technical Architecture +[Name the components, the data they move, the interfaces between them, and what the +system runs on.] + +# Development Roadmap +[Divide the build into phases by scope alone. Spell out precisely what each phase +ships so it can be sliced into work items afterward; leave timelines out entirely.] + +# Logical Dependency Chain +[Order the work so the foundation comes first and something usable appears as early +as possible, with each piece atomic yet extendable.] + +# Risks and Mitigations +[Name the technical risks, the smallest viable slice to prove the approach, and how +constraints will be handled.] + +# Appendix +[Attach supporting research and any precise specifications worth pinning down.] + diff --git a/examples/task-master-run/CLAUDE.md b/examples/task-master-run/CLAUDE.md new file mode 100644 index 0000000..9cf88f3 --- /dev/null +++ b/examples/task-master-run/CLAUDE.md @@ -0,0 +1,57 @@ +# Task Master — Agent Integration Guide + +This file is written by `task-master init` so an agent loads Task Master's usage +context automatically. It marks the directory as a Task Master project. + +## Essential Commands + +```bash +task-master parse-prd .taskmaster/docs/prd.txt # turn the PRD into a task ledger +task-master list # show every task and its status +task-master next # pick the next unblocked task +task-master show # read one task or subtask in full +task-master analyze-complexity # score tasks and recommend expansion +task-master expand --id= # split a task into subtasks +task-master update-subtask --id= --prompt=… # append implementation notes +task-master set-status --id= --status=done # record a task as complete +``` + +## Key Files + +- `.taskmaster/tasks/tasks.json` — the single source of truth for tasks and status; managed by the CLI, never hand-edited. +- `.taskmaster/config.json` — model roles and defaults; change it through `task-master models`. +- `.taskmaster/state.json` — which tag context is active. +- `.taskmaster/docs/prd.txt` — the requirements document that `parse-prd` reads. +- `.taskmaster/tasks/task_NNN.txt` — a human-readable mirror of each task, regenerated from tasks.json. +- `.taskmaster/reports/task-complexity-report.json` — the latest complexity analysis. + +## Directory Structure + +``` +.taskmaster/ +├── config.json +├── state.json +├── docs/ +│ └── prd.txt +├── tasks/ +│ ├── tasks.json +│ └── task_001.txt +├── reports/ +│ └── task-complexity-report.json +└── templates/ + └── example_prd.txt +``` + +## Workflow Loop + +1. Read the next task with `task-master show `. +2. Log the plan and progress with `task-master update-subtask --id= --prompt=…`. +3. Implement against the task's own `testStrategy` field. +4. Run that verification and the automated tests before closing the task. +5. Record completion with `task-master set-status --id= --status=done`, which + also cascades the task's subtasks to done. + +The default status flip is a bookkeeping write, not a test gate, so verifying the +`testStrategy` before marking a task done is the discipline that keeps "done" +honest. The optional autopilot workflow adds a code-enforced RED/GREEN/COMMIT gate +whose run-state lives outside this project directory. diff --git a/examples/task-master-run/README.md b/examples/task-master-run/README.md new file mode 100644 index 0000000..338cd2f --- /dev/null +++ b/examples/task-master-run/README.md @@ -0,0 +1,38 @@ +# task-master-run — a vendored foreign-harness fixture + +A minimal, sanitized project directory in the layout that +[Task Master](https://github.com/eyaltoledano/claude-task-master) leaves behind — +a `.taskmaster/` scaffold holding `config.json`, `state.json`, a parsed PRD under +`docs/`, the `tasks/tasks.json` ledger with its per-task `task_NNN.txt` mirrors, +and an `analyze-complexity` report under `reports/`. It is pinned to commit +`c0c98d367c55296bfe69e65680625b6db437af02`; every field name, path, and heading +follows Task Master's documented conventions, but all prose is fictional and no +template body text is copied. Task Master is licensed MIT with the Commons Clause, +so this fixture mirrors structure and naming only — never verbatim template text. + +The run modeled here is a **completed** pass over the shared csv-dedupe task +(the same fiction as `examples/superpowers-run`): `import_contacts.py` emitted +duplicate rows when one contact appeared in two source files with different +casing, and the fix normalizes on a lowercased email/phone key. All three tasks +carry `status: "done"` in `tasks.json`, each with a `testStrategy` field and an +appended `` journal block recording the verification +evidence (15/15 unit tests, 41 unique contacts from 57 rows, a second import that +inserted 0 rows). + +Task Master is a **complement**, not a competitor: it parses a PRD into a task +ledger and drives the daily implement-and-log loop. Loop Engineer is the contract +layer that proves how a run *ended*. This fixture exists so +`python3 -m loop inspect examples/task-master-run` can score that layout read-only. +The honest score is not a criticism of Task Master — it measures what a task-ledger +layout structurally proves about completion. The default `set-status --status=done` +path is an unenforced status write with no test-pass check, and marking a parent +cascades its subtasks to done. Task Master's only code-enforced test gate is the +opt-in autopilot TDD workflow, whose RED/GREEN/COMMIT run-state persists off-tree +in `~/.taskmaster//sessions/` and is therefore intentionally absent +here; the durable in-project proof of "done" is the status fields plus the journal. + +## Reproduce + +```bash +uv run --with pyyaml python3 -B -m loop inspect examples/task-master-run +``` diff --git a/loop/foreign.py b/loop/foreign.py index 07a43e8..16d0d93 100644 --- a/loop/foreign.py +++ b/loop/foreign.py @@ -1,35 +1,334 @@ -"""Read-only foreign-harness layout mapping (ST4). +"""Read-only foreign-harness layout registry (ST4/ST5). -Recognizes a run directory laid out by a foreign harness (currently: the -Superpowers spec/plan convention) and maps it onto the same ``LoopPaths`` -surface the inspector already consumes. A MAPPER, never a scorer: it points -the existing signals at foreign files and never manufactures credit — a -harness with no holdout gate and no terminal record scores honestly low, -which is the point. Synthesizing gate or verify artifacts here would be -dishonest and is out of scope by design. +Recognizes a run directory laid out by a foreign spec-driven harness and maps +its artifacts onto the same ``LoopPaths`` surface the inspector already +consumes. A MAPPER, never a scorer: it points the existing signals at foreign +files and never manufactures credit — a harness with no holdout gate and no +terminal record scores honestly low, which is the point. Synthesizing gate or +verify artifacts here would be dishonest and is out of scope by design. + +The registry is an ordered tuple of :class:`_Layout` entries. Each entry pairs +a *detection predicate* (distinctive dot-dir / characteristic nested file, kept +mutually exclusive across layouts and inert on a generic repo) with per-role +*path resolvers* for the four mappable roles — ``spec``, ``workflow`` (the +harness's plan), ``tasks``, and ``runlog``. Any role a layout does not resolve +keeps the native default path unchanged, exactly as the original superpowers +mapper did. ``tasks`` is mapped **only** when the harness's task ledger is +genuine JSON (the inspector parses ``tasks`` as JSON); a markdown/YAML task file +is surfaced through the ``workflow`` or ``runlog`` role instead, decided +per-layout and documented on the entry. Used by ``inspect`` only; ``doctor`` stays unmapped (a foreign dir has no -contract to validate, and saying otherwise would be a false completion). +contract to validate, and saying otherwise would be a false completion). A +native contract (``.loop/state.json``) always wins over any foreign signature. """ from __future__ import annotations +from collections.abc import Callable, Iterable +from dataclasses import dataclass from pathlib import Path from .paths import LoopPaths -_SPECS_DIR = "docs/superpowers/specs" -_PLANS_DIR = "docs/superpowers/plans" -_JOURNALS = (".superpowers/sdd/progress.md", "docs/superpowers/journal.md") +Predicate = Callable[[Path], bool] +Resolver = Callable[[Path], "Path | None"] + + +# --- filesystem helpers --------------------------------------------------- + + +def _newest(paths: Iterable[Path]) -> Path | None: + """Newest existing file from ``paths``, chosen by name. + + Foreign harnesses date- or number-prefix their run files/dirs, so + lexicographic order is chronological order (mirrors the original + ``_newest_md`` selection). + """ + files = sorted(p for p in paths if p.is_file()) + return files[-1] if files else None + + +def _newest_dir(parent: Path) -> Path | None: + """Newest immediate subdirectory of ``parent`` by name, or None.""" + if not parent.is_dir(): + return None + dirs = sorted(p for p in parent.iterdir() if p.is_dir()) + return dirs[-1] if dirs else None def _newest_md(directory: Path) -> Path | None: - """Newest markdown file by name — Superpowers files are date-prefixed, so - lexicographic order is chronological order.""" - if not directory.is_dir(): + """Newest markdown file in ``directory`` (kept for the superpowers detect).""" + return _newest(directory.glob("*.md")) if directory.is_dir() else None + + +# --- resolver factories --------------------------------------------------- + + +def _fixed(rel: str) -> Resolver: + """Resolve a file at a fixed workspace-relative path.""" + def resolve(ws: Path) -> Path | None: + p = ws / rel + return p if p.is_file() else None + return resolve + + +def _first_of(*rels: str) -> Resolver: + """First of several fixed relative paths that exists as a file.""" + def resolve(ws: Path) -> Path | None: + for rel in rels: + p = ws / rel + if p.is_file(): + return p return None - files = sorted(p for p in directory.glob("*.md") if p.is_file()) - return files[-1] if files else None + return resolve + + +def _preferred_newest(*globs: tuple[str, str]) -> Resolver: + """Newest match from the first ``(reldir, pattern)`` pair that yields one. + + Ordering the pairs expresses preference (e.g. an archived ``completed/`` + plan before the live one). Patterns may nest a ``/`` for the run-file-in-a + -dated-subdir shape (``*/proposal.md``). + """ + def resolve(ws: Path) -> Path | None: + for reldir, pattern in globs: + hit = _newest((ws / reldir).glob(pattern)) + if hit is not None: + return hit + return None + return resolve + + +def _in_newest_subdir(reldir: str, filename: str) -> Resolver: + """A fixed ``filename`` inside the newest subdirectory of ``reldir``. + + The run-file-in-a-dated/numbered-feature-dir shape (spec-kit's + ``specs/001-slug/spec.md``, agent-os's ``specs//shape.md``). + """ + def resolve(ws: Path) -> Path | None: + sub = _newest_dir(ws / reldir) + if sub is None: + return None + p = sub / filename + return p if p.is_file() else None + return resolve + + +# --- detection-predicate factories ---------------------------------------- + + +def _dir(rel: str) -> Predicate: + return lambda ws: (ws / rel).is_dir() + + +def _file(rel: str) -> Predicate: + return lambda ws: (ws / rel).is_file() + + +def _glob(reldir: str, pattern: str) -> Predicate: + return lambda ws: any((ws / reldir).glob(pattern)) + + +def _has_md(reldir: str) -> Predicate: + return lambda ws: _newest_md(ws / reldir) is not None + + +def _any(*preds: Predicate) -> Predicate: + return lambda ws: any(p(ws) for p in preds) + + +def _all(*preds: Predicate) -> Predicate: + return lambda ws: all(p(ws) for p in preds) + + +# --- the registry --------------------------------------------------------- + + +@dataclass(frozen=True) +class _Layout: + """One foreign-harness layout: how to detect it and where its roles live. + + ``spec`` / ``workflow`` / ``tasks`` / ``runlog`` are per-role resolvers; + ``None`` means the role is unmapped and keeps the native default path. The + ``note`` records the mapping decision (esp. why ``tasks`` is or is not + mapped) so the honesty rationale travels with the data. + """ + + name: str + detect: Predicate + note: str + spec: Resolver | None = None + workflow: Resolver | None = None + tasks: Resolver | None = None + runlog: Resolver | None = None + + +_REGISTRY: tuple[_Layout, ...] = ( + _Layout( + name="superpowers", + # Tightened per the roadmap follow-up: a spec/plan alone is not enough + # (the loop-engineer repo root carries docs/superpowers/{specs,plans} + # with no journal and must NOT read as a foreign run). Require a journal + # AND a spec-or-plan. + detect=_all( + _any( + _file(".superpowers/sdd/progress.md"), + _file("docs/superpowers/journal.md"), + ), + _any(_has_md("docs/superpowers/specs"), _has_md("docs/superpowers/plans")), + ), + note=( + "Superpowers SDD layout. tasks unmapped: the plan's checkboxes are " + "markdown, not a JSON ledger. runlog -> the sdd progress journal." + ), + spec=_preferred_newest(("docs/superpowers/specs", "*.md")), + workflow=_preferred_newest(("docs/superpowers/plans", "*.md")), + runlog=_first_of(".superpowers/sdd/progress.md", "docs/superpowers/journal.md"), + ), + _Layout( + name="spec-kit", + # The .specify/ dot-dir is unique to GitHub Spec Kit; pair it with a + # characteristic install/state file so a bare directory named .specify + # cannot alone trip it. + detect=_all( + _dir(".specify"), + _any( + _file(".specify/feature.json"), + _file(".specify/memory/constitution.md"), + _file(".specify/templates/spec-template.md"), + ), + ), + note=( + "Spec Kit. spec/plan/tasks are the specs// triad. tasks " + "unmapped: tasks.md is a markdown checkbox ledger, not JSON — it is " + "the on-disk progress record, so it maps to runlog instead." + ), + spec=_in_newest_subdir("specs", "spec.md"), + workflow=_in_newest_subdir("specs", "plan.md"), + runlog=_in_newest_subdir("specs", "tasks.md"), + ), + _Layout( + name="agent-os", + # standards/index.yml under the non-dotted agent-os/ root is the single + # strongest Agent OS v3 fingerprint (distinguishes v3 from .agent-os/ v1/v2). + detect=_file("agent-os/standards/index.yml"), + note=( + "Agent OS v3. spec -> shape.md, workflow -> plan.md inside the " + "minute-stamped spec dir. tasks unmapped: the plan doubles as the " + "task list (markdown). runlog unmapped: v3 ships no on-disk journal." + ), + spec=_in_newest_subdir("agent-os/specs", "shape.md"), + workflow=_in_newest_subdir("agent-os/specs", "plan.md"), + ), + _Layout( + name="bmad", + detect=_any( + _file("_bmad-output/implementation-artifacts/sprint-status.yaml"), + _file("_bmad/bmm/config.yaml"), + ), + note=( + "BMAD Method. spec -> planning prd.md, workflow -> epics.md, runlog " + "-> the epic retrospective journal. tasks unmapped: sprint-status " + "is the ledger but it is YAML, not JSON." + ), + spec=_fixed("_bmad-output/planning-artifacts/prd.md"), + workflow=_fixed("_bmad-output/planning-artifacts/epics.md"), + runlog=_preferred_newest( + ("_bmad-output/implementation-artifacts", "epic-*-retro-*.md") + ), + ), + _Layout( + name="task-master", + detect=_file(".taskmaster/tasks/tasks.json"), + note=( + "Task Master. The one layout whose ledger IS genuine JSON, so tasks " + "-> tasks.json (parsed). runlog -> tasks.json too (the run journal " + "is appended into subtasks[].details). spec -> the PRD; workflow -> " + "the newest human-readable per-task file." + ), + spec=_fixed(".taskmaster/docs/prd.txt"), + workflow=_preferred_newest((".taskmaster/tasks", "task_*.txt")), + tasks=_fixed(".taskmaster/tasks/tasks.json"), + runlog=_fixed(".taskmaster/tasks/tasks.json"), + ), + _Layout( + name="ccpm", + # .claude/epics//epic.md or .claude/prds/*.md — CCPM's own subdirs + # under the shared .claude/ root (distinct from prp's PRPs/ and ruflo's + # checkpoints/, so no cross-fire on a plain .claude/ dir). + detect=_any(_glob(".claude/epics", "*/epic.md"), _glob(".claude/prds", "*.md")), + note=( + "CCPM. spec -> the PRD, workflow -> the technical epic.md, runlog -> " + "the per-issue progress journal. tasks unmapped: per-task files are " + "markdown (renamed to the GitHub issue number), not JSON." + ), + spec=_preferred_newest((".claude/prds", "*.md")), + workflow=_preferred_newest((".claude/epics", "*/epic.md")), + runlog=_preferred_newest((".claude/epics", "*/updates/*/progress.md")), + ), + _Layout( + name="prp", + # Capitalized .claude/PRPs/ (plans|prds) or the loop FSM state file — + # 'prds' vs 'PRPs' differ by a real letter (d vs p) so ccpm and prp stay + # exclusive even on a case-insensitive filesystem. + detect=_any( + _dir(".claude/PRPs/plans"), + _dir(".claude/PRPs/prds"), + _file(".claude/prp-loop.state.json"), + ), + note=( + "PRPs-agentic-eng. spec -> the .prd.md, workflow -> the .plan.md " + "(prefer plans/completed/), runlog -> the implementation report. " + "tasks unmapped: the task ledger is a markdown checkbox list " + "embedded in the plan file, not a separate JSON ledger." + ), + spec=_preferred_newest((".claude/PRPs/prds", "*.prd.md")), + workflow=_preferred_newest( + (".claude/PRPs/plans/completed", "*.plan.md"), + (".claude/PRPs/plans", "*.plan.md"), + ), + runlog=_preferred_newest((".claude/PRPs/reports", "*-report.md")), + ), + _Layout( + name="openspec", + detect=_any( + _file("openspec/config.yaml"), + _dir("openspec/changes"), + _dir("openspec/specs"), + ), + note=( + "OpenSpec. spec -> the living capability spec, workflow -> the " + "change proposal, runlog -> the change's tasks.md (its checkbox " + "progress is the only durable on-disk record). tasks unmapped: " + "markdown checkbox ledger, not JSON." + ), + spec=_preferred_newest(("openspec/specs", "*/spec.md")), + workflow=_preferred_newest(("openspec/changes/archive", "*/proposal.md")), + runlog=_preferred_newest(("openspec/changes/archive", "*/tasks.md")), + ), + _Layout( + name="ruflo", + # ruflo (claude-flow v3) runtime dirs. .claude-flow/ and .hive-mind/ are + # both ruflo-specific; its .claude/ only holds checkpoints/ (no epics/PRPs). + detect=_any(_dir(".claude-flow"), _dir(".hive-mind")), + note=( + "ruflo / claude-flow v3. spec -> the on-disk swarm objective prompt, " + "runlog -> the checkpoint session summary. workflow + tasks unmapped: " + "plan and task ledger are SQLite-backed (memory.db / hive.db), not " + "on-disk files this mapper can point a text signal at." + ), + spec=_preferred_newest((".hive-mind/sessions", "hive-mind-prompt-*.txt")), + runlog=_preferred_newest((".claude/checkpoints", "summary-session-*.md")), + ), +) + + +def _find_layout(ws: Path) -> _Layout | None: + for layout in _REGISTRY: + if layout.detect(ws): + return layout + return None def detect_foreign_layout(target: str | Path) -> str | None: @@ -41,29 +340,42 @@ def detect_foreign_layout(target: str | Path) -> str | None: ws = Path(target) if (ws / ".loop" / "state.json").is_file(): return None - if _newest_md(ws / _SPECS_DIR) or _newest_md(ws / _PLANS_DIR): - return "superpowers" - return None + layout = _find_layout(ws) + return layout.name if layout is not None else None def map_foreign_paths(target: str | Path) -> LoopPaths | None: - """A ``LoopPaths`` view of a foreign run dir, or None if not foreign.""" + """A ``LoopPaths`` view of a foreign run dir, or None if not foreign. + + The four mappable roles (spec / workflow / tasks / runlog) are pointed at + the recognized layout's artifacts when its resolvers find them; every other + role — and any role the layout leaves unmapped or that resolves to nothing — + keeps the native default path. + """ ws = Path(target).resolve() - if detect_foreign_layout(ws) != "superpowers": + if (ws / ".loop" / "state.json").is_file(): + return None + layout = _find_layout(ws) + if layout is None: return None - spec = _newest_md(ws / _SPECS_DIR) - plan = _newest_md(ws / _PLANS_DIR) - journal = next((ws / j for j in _JOURNALS if (ws / j).is_file()), None) + loop_dir = ws / ".loop" + + def _resolved(resolver: Resolver | None, default: Path) -> Path: + if resolver is None: + return default + hit = resolver(ws) + return hit if hit is not None else default + return LoopPaths( workspace=ws, loop_dir=loop_dir, manifest=loop_dir / "manifest.yaml", state=loop_dir / "state.json", - tasks=ws / "TASKS.json", - runlog=journal if journal is not None else ws / "RUNLOG.md", + tasks=_resolved(layout.tasks, ws / "TASKS.json"), + runlog=_resolved(layout.runlog, ws / "RUNLOG.md"), terminal=loop_dir / "terminal_state.json", - spec=spec if spec is not None else ws / "SPEC.md", - workflow=plan if plan is not None else ws / "WORKFLOW.md", + spec=_resolved(layout.spec, ws / "SPEC.md"), + workflow=_resolved(layout.workflow, ws / "WORKFLOW.md"), contract=ws / "loop-contract.md", ) diff --git a/scripts/test_foreign_inspect.py b/scripts/test_foreign_inspect.py index fcd5cba..e0bf77b 100644 --- a/scripts/test_foreign_inspect.py +++ b/scripts/test_foreign_inspect.py @@ -1,9 +1,11 @@ -"""ST4: the foreign-harness inspect adapter is a LAYOUT MAPPER, not a scorer -change. It maps a Superpowers-style run dir onto the LoopPaths surface the -inspector already consumes; the M2/M3-hardened scorer is not re-litigated. A -foreign harness with no holdout gate and no terminal record scores honestly -low — the regression tests pin that no run-recorded credit appears without an -on-disk gate, and that doctor does NOT get the mapping (inspect-only).""" +"""ST4/ST5: the foreign-harness inspect adapter is a LAYOUT REGISTRY, not a +scorer change. It maps a foreign spec-driven run dir (Superpowers, Spec Kit, +Agent OS, BMAD, Task Master, CCPM, PRP, OpenSpec, ruflo) onto the LoopPaths +surface the inspector already consumes; the M2/M3-hardened scorer is not +re-litigated. A foreign harness with no holdout gate and no terminal record +scores honestly low — the regression tests pin that no run-recorded credit +appears without an on-disk gate, that every fixture is labeled advisory, and +that doctor does NOT get the mapping (inspect-only).""" from __future__ import annotations @@ -13,6 +15,8 @@ import sys from pathlib import Path +import pytest + _REPO = Path(__file__).resolve().parent.parent if str(_REPO) not in sys.path: sys.path.insert(0, str(_REPO)) @@ -22,6 +26,21 @@ FIXTURE = _REPO / "examples" / "superpowers-run" NATIVE = _REPO / "examples" / "coverage-repair" +# Every vendored foreign fixture and the layout name it must detect as. Each +# fixture instantiates the SAME fictional csv-dedupe task across nine harness +# layouts, so the scoreboard compares one task against nine on-disk shapes. +FOREIGN_FIXTURES: tuple[tuple[str, str], ...] = ( + ("superpowers-run", "superpowers"), + ("spec-kit-run", "spec-kit"), + ("agent-os-run", "agent-os"), + ("bmad-run", "bmad"), + ("task-master-run", "task-master"), + ("ccpm-run", "ccpm"), + ("prp-run", "prp"), + ("openspec-run", "openspec"), + ("ruflo-run", "ruflo"), +) + def _load(name: str): spec = importlib.util.spec_from_file_location(name, Path(__file__).parent / f"{name}.py") @@ -46,6 +65,53 @@ def test_mapping_points_at_superpowers_artifacts(): assert map_foreign_paths(NATIVE) is None +@pytest.mark.parametrize("dirname,layout", FOREIGN_FIXTURES) +def test_every_fixture_detects_its_layout(dirname, layout): + target = _REPO / "examples" / dirname + assert detect_foreign_layout(target) == layout + # A registered layout always yields a mapped LoopPaths view. + assert map_foreign_paths(target) is not None + + +@pytest.mark.parametrize("dirname,layout", FOREIGN_FIXTURES) +def test_every_fixture_inspects_as_advisory_foreign(dirname, layout): + inspect_loop = _load("inspect_loop") + report = inspect_loop.inspect_loop(str(_REPO / "examples" / dirname)) + assert report["foreign_layout"] == layout + assert report["advisory"] is True + # honesty: no foreign fixture manufactures held-out invocation credit. + assert not any("(invoked)" in p for p in report["present"]), report["present"] + + +@pytest.mark.parametrize("dirname,_layout", FOREIGN_FIXTURES) +def test_inspect_score_is_deterministic(dirname, _layout): + inspect_loop = _load("inspect_loop") + target = str(_REPO / "examples" / dirname) + first = inspect_loop.inspect_loop(target) + second = inspect_loop.inspect_loop(target) + assert first == second + assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True) + + +def test_task_master_tasks_map_to_json_ledger(): + # Task Master is the one layout whose ledger IS genuine JSON, so tasks maps. + paths = map_foreign_paths(_REPO / "examples" / "task-master-run") + assert paths is not None + assert paths.tasks.name == "tasks.json" + assert paths.tasks.is_file() + loaded = json.loads(paths.tasks.read_text(encoding="utf-8")) + assert isinstance(loaded, dict) # parseable JSON, not markdown + + +def test_markdown_ledger_layouts_keep_native_tasks_default(): + # spec-kit / bmad / openspec ledgers are markdown/YAML, not JSON, so tasks + # stays the native default and their markdown ledger surfaces via runlog. + for dirname in ("spec-kit-run", "bmad-run", "openspec-run"): + paths = map_foreign_paths(_REPO / "examples" / dirname) + assert paths is not None + assert paths.tasks.name == "TASKS.json" # native default, unmapped + + def test_inspect_scores_fixture_low_and_labels_it_foreign(): inspect_loop = _load("inspect_loop") report = inspect_loop.inspect_loop(str(FIXTURE)) @@ -58,6 +124,42 @@ def test_inspect_scores_fixture_low_and_labels_it_foreign(): assert report["terminal_states_covered"] < 7 +def test_repo_root_superpowers_shape_without_journal_does_not_detect(tmp_path): + # Roadmap follow-up: a spec/plan-only tree (the loop-engineer repo's own + # shape, minus a journal and minus .loop/state.json) must NOT false-positive + # as a foreign superpowers run — the journal requirement is what closes it. + specs = tmp_path / "docs" / "superpowers" / "specs" + specs.mkdir(parents=True) + (specs / "2026-07-09-x-design.md").write_text("# x\n", encoding="utf-8") + plans = tmp_path / "docs" / "superpowers" / "plans" + plans.mkdir(parents=True) + (plans / "2026-07-09-x.md").write_text("# x\n", encoding="utf-8") + + assert detect_foreign_layout(tmp_path) is None + assert map_foreign_paths(tmp_path) is None + + inspect_loop = _load("inspect_loop") + report = inspect_loop.inspect_loop(str(tmp_path)) + assert "foreign_layout" not in report + assert "advisory" not in report + + # Adding the journal flips it to a detected foreign run — proves the journal + # is the discriminating signal, not incidental. + sdd = tmp_path / ".superpowers" / "sdd" + sdd.mkdir(parents=True) + (sdd / "progress.md").write_text("# progress\n", encoding="utf-8") + assert detect_foreign_layout(tmp_path) == "superpowers" + + +def test_generic_repo_does_not_detect_any_layout(tmp_path): + # A plain project tree (README + src, no harness dot-dirs) is not foreign. + (tmp_path / "README.md").write_text("# project\n", encoding="utf-8") + (tmp_path / "src").mkdir() + (tmp_path / "src" / "main.py").write_text("print('hi')\n", encoding="utf-8") + assert detect_foreign_layout(tmp_path) is None + assert map_foreign_paths(tmp_path) is None + + def test_native_reports_carry_no_foreign_label(): inspect_loop = _load("inspect_loop") report = inspect_loop.inspect_loop(str(NATIVE))