diff --git a/README.md b/README.md index 4241261..80de1e8 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,13 @@ rendered, never written. Judgement enters in exactly two ways: `commit_shape_deviation`, `test_setup_smell`, `unplanned_change`, `new_work_raised`. `plan_defect` is the one that matters most: it records where the plan and the codebase disagreed, which is precisely what the next plan needs to know. +- **Per cycle or run, through `tdd note ""`** — free-text narrative captured at the + moment the reason exists. Cycle notes are scoped to the open cycle and phase; run-level + notes are written after the run ends and attach to the run. Both render in the friction + log as visually distinct blockquotes (claims, not measurements). Use notes to record + *why* something happened — a plan assumption that was wrong, an integrity event that the + telemetry already captures but cannot explain. Notes are unverified by design; an auditor + compares claims against reality. - **Per run, as prose appended below the rendered document.** Legitimate and expected — post-run narrative (CI failures, patterns noticed) has no cycle to attach to. But it is unverified: an auditor should trust the projected sections and read appended @@ -413,6 +420,7 @@ and is never reclassified as a pin. | `tdd cycle skip --reason` | sanctioned path for a cycle the plan got wrong | | `tdd sensitivity begin\|check\|end` | prove a passing test can fail; verify restore | | `tdd annotate --key --value` | attach judgement to the current cycle | +| `tdd note ""` | attach a free-text narrative note to the current cycle or run | | `tdd blocker --kind --detail` | typed blocker; releases the stop hook | | `tdd resume [--unblock --note]` | reconstruct position; human intervention | | `tdd log render [--out]` | project the ledger into a friction log | diff --git a/docs/PRD.md b/docs/PRD.md index 6b443e7..d6f3201 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -173,6 +173,14 @@ Reserved per-run keys: `plan_quality_score` (per plan phase, with rationale), `c - **R5.5** `plan_quality_score` is subjective and self-reported. It is retained because it is a direct signal on the planning process, and reported as an agent opinion, never as an observation. +### Note +Free-text executor narrative, cycle- or run-scoped, unverified by design. Written with +`tdd note ""` at any point during or after the run. When the run has an open cycle, +the note is stamped with the cycle and its current phase; after the run ends, the note is +run-level (cycle/phase NULL). Notes render in the friction log as blockquotes, visually +distinct from telemetry. They are claims the executor makes at the moment the reason exists; +an auditor compares them against reality. + ### IntegrityEvent Typed: `test_removed`, `test_weakened`, `undeclared_file_touched`, `restore_mismatch`, `off_protocol_invocation`, `stale_artifact`, `plan_blob_changed`, `executor_unknown`. @@ -483,6 +491,7 @@ one has no move left but to re-run doctor and read the same output again. | `tdd advance` | run the relevant suite(s), record an Invocation, compute the transition, emit `next_action`. **The only command that changes phase.** | | `tdd cycle skip --reason ` | mark the current cycle `SKIPPED` with a mandatory reason; open the next. Sanctioned path for cycles the plan got wrong | | `tdd annotate --key --value ` | attach agent judgement to the current cycle | +| `tdd note ""` | attach a free-text narrative note to the current cycle or (post-run) to the run | | `tdd blocker --kind --detail ` | record a typed blocker; set run `outcome = blocked`, releasing the stop hook (H1) | | `tdd resume` | reconstruct position from the ledger and emit `next_action` | | `tdd resume --unblock --note ` | **human only.** Reopen a blocked run, recording a `human_intervention` event with the note | @@ -824,7 +833,7 @@ depend on any of them being installed. ### 13.1 Storage - **R13.1** SQLite, single file, append-only for invocations and events. - **R13.2** Schema versioned and migrated. The schema is the long-lived asset; the transport is not. - Current schema version: **7** (v7 adds `sensitivity_check.evidence_line TEXT` for per-adapter assertion-line evidence; earlier milestones: v3 adds `advance_claim`, v4–v6 are intermediate columns). + Current schema version: **8** (v8 adds the `note` table for executor-narrative rows; v7 adds `sensitivity_check.evidence_line TEXT` for per-adapter assertion-line evidence; earlier milestones: v3 adds `advance_claim`, v4–v6 are intermediate columns). ### 13.2 Location - **R13.3** **One ledger per repository**, in a per-user data directory keyed by the repository's diff --git a/docs/harness-integration.md b/docs/harness-integration.md index a63d279..03da654 100644 --- a/docs/harness-integration.md +++ b/docs/harness-integration.md @@ -97,10 +97,18 @@ cycle, so batching them at the end records them against the wrong one: - `friction_note` — tooling or environment cost attempts that the code did not. - `unplanned_change` / `new_work_raised` — scope the cycle absorbed or deferred. +For executor narrative that captures *why* something happened — a plan assumption that +proved wrong, a path the telemetry records but cannot explain — use `tdd note ""`. +A note written while the open cycle is in a specific phase is stamped with that phase and +rendered as a blockquote inside its cycle section. Notes written after the run ends attach +at run level and appear in an **Executor narrative** section of the friction log. Both are +unverified by design; an auditor reads them as the executor's claims, not as measurements. + Run-level narrative (post-run CI failures, patterns spanning cycles) has no cycle to -attach to: append it as markdown below the rendered friction log after `tdd log render`. -Rendered sections are projections from the ledger; appended prose is unverified opinion, -and auditors should read the two accordingly. +attach to: either use `tdd note` after the run ends, or append prose below the rendered +friction log after `tdd log render`. Rendered sections and note blockquotes are projections +from the ledger; prose appended after rendering is unverified opinion outside the ledger, +and auditors should read the three layers accordingly. ## Executor identity and subagents diff --git a/examples/skills/tdd-drive/SKILL.md b/examples/skills/tdd-drive/SKILL.md index 672e90f..870ece2 100644 --- a/examples/skills/tdd-drive/SKILL.md +++ b/examples/skills/tdd-drive/SKILL.md @@ -74,9 +74,16 @@ end of the run. - The cycle absorbed undeclared scope, or surfaced follow-up work: `--key unplanned_change` / `--key new_work_raised`. +To capture *why* something happened — a plan assumption that proved wrong, the reason an +integrity event fired — use `tdd note ""` at the moment you know. A note written +during an open cycle is stamped with that cycle and phase and appears in the friction log +as a blockquote alongside the cycle's telemetry. After the run ends, `tdd note` attaches +at run level and renders in a dedicated **Executor narrative** section. Notes are unverified +by design; write them as claims, not measurements. + Narrative that spans cycles or happened after the run (CI failures, patterns) goes as -markdown appended below the rendered friction log after `tdd log render` — never into a -cycle annotation it doesn't belong to. +`tdd note` after the run ends, or as markdown appended below the rendered friction log +after `tdd log render` — never into a cycle annotation it doesn't belong to. ## When a suite run changes nothing diff --git a/src/tddcli/advance.py b/src/tddcli/advance.py index a566583..26acc49 100644 --- a/src/tddcli/advance.py +++ b/src/tddcli/advance.py @@ -22,13 +22,35 @@ Engine, ) +NUDGE_KINDS = {"red_first_violation", "undeclared_file_touched", "implementation_during_red"} + + +def _note_nudge(engine: Engine, cycle) -> str: + if cycle is None: + return "" + events = engine.ledger.all( + "SELECT kind FROM integrity_event WHERE cycle_id = ? AND kind IN ({})".format( + ",".join("?" * len(NUDGE_KINDS)) + ), + (cycle["id"], *NUDGE_KINDS), + ) + if not events: + return "" + existing_note = engine.ledger.one( + "SELECT id FROM note WHERE cycle_id = ?", (cycle["id"],) + ) + if existing_note is not None: + return "" + return ' An integrity event was recorded on this cycle — consider `tdd note ""` while the reason is fresh.' + def _reply(engine: Engine, cycle, verb: Verb, detail: str, **result) -> Envelope: fresh = engine.ledger.one("SELECT * FROM cycle WHERE id = ?", (cycle["id"],)) if cycle else None + nudge = _note_nudge(engine, fresh or cycle) return Envelope( run=engine.run_state(fresh or cycle), result=result, - next_action=NextAction(verb, detail), + next_action=NextAction(verb, detail + nudge), ) @@ -444,7 +466,10 @@ def _handle_refactor(engine: Engine, cycle, retried: bool) -> Envelope: }, result={"commit": sha, "regenerated": regenerated or None}, next_action=NextAction( - Verb.COMPLETE, "All declared cycles are complete. Run `tdd log render`." + Verb.COMPLETE, + "All declared cycles are complete." + " Before rendering, record a closing narrative with `tdd note \"\"`." + " Then run `tdd log render`.", ), ) verb, opening = engine.opening_action(nxt) diff --git a/src/tddcli/cli.py b/src/tddcli/cli.py index 7348f59..006f586 100644 --- a/src/tddcli/cli.py +++ b/src/tddcli/cli.py @@ -961,7 +961,12 @@ def cmd_cycle_skip(args) -> Envelope: ledger.update("run", run["id"], ended_at=now(), outcome="complete") return Envelope( run={"id": run["id"], "cycle": cycle["ordinal"], "phase": SKIPPED}, - next_action=NextAction(Verb.COMPLETE, "Final cycle skipped; run complete."), + next_action=NextAction( + Verb.COMPLETE, + "Final cycle skipped; run complete." + " Before rendering, record a closing narrative with `tdd note \"\"`." + " Then run `tdd log render`.", + ), ) nxt = engine.open_cycle(nxt_declared.ordinal) verb, opening = engine.opening_action(nxt) @@ -990,6 +995,35 @@ def cmd_annotate(args) -> Envelope: ) +def cmd_note(args) -> Envelope: + worktree, cfg, ledger, run = _context(require_run=False) + if run is None: + run = ledger.one( + "SELECT * FROM run WHERE worktree_path = ? ORDER BY id DESC LIMIT 1", + (str(worktree),), + ) + if run is None: + return failure("no runs recorded for this worktree; `tdd run start --plan `") + cycle = ledger.open_cycle(run["id"]) + ledger.insert( + "note", + run_id=run["id"], + cycle_id=cycle["id"] if cycle else None, + phase=cycle["phase"] if cycle else None, + text=args.text, + at=now(), + ) + if cycle is not None: + next_action = NextAction(Verb.REFACTOR_OR_ADVANCE, "Note recorded. Resume the phase in progress.") + else: + next_action = NextAction(Verb.COMPLETE, "Note recorded. Run `tdd log render`.") + return Envelope( + run={"id": run["id"], "cycle": cycle["ordinal"] if cycle else None}, + result={"noted": True}, + next_action=next_action, + ) + + def cmd_blocker(args) -> Envelope: worktree, cfg, ledger, run = _context() if args.kind not in BLOCKER_KINDS: @@ -1394,6 +1428,10 @@ def build_parser() -> argparse.ArgumentParser: s.add_argument("--value", required=True) s.set_defaults(fn=cmd_annotate) + s = sub.add_parser("note", help="attach a narrative note to the current cycle or run") + s.add_argument("text") + s.set_defaults(fn=cmd_note) + s = sub.add_parser("blocker") s.add_argument("--kind", required=True) s.add_argument("--detail", required=True) diff --git a/src/tddcli/ledger.py b/src/tddcli/ledger.py index c3608e5..6437f67 100644 --- a/src/tddcli/ledger.py +++ b/src/tddcli/ledger.py @@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path -SCHEMA_VERSION = 7 +SCHEMA_VERSION = 8 class LedgerVersionError(RuntimeError): @@ -39,6 +39,8 @@ class LedgerVersionError(RuntimeError): 5: "ALTER TABLE plan_contract ADD COLUMN ancillary_files TEXT NOT NULL DEFAULT '[]';", # v6 -> v7 added evidence_line column to sensitivity_check; ALTER TABLE covers old ledgers. 6: "ALTER TABLE sensitivity_check ADD COLUMN evidence_line TEXT;", + # v7 -> v8 added the note table; CREATE TABLE IF NOT EXISTS covers it. + 7: "", } SCHEMA = """ @@ -172,6 +174,15 @@ class LedgerVersionError(RuntimeError): at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS note ( + id INTEGER PRIMARY KEY, + run_id INTEGER NOT NULL REFERENCES run(id), + cycle_id INTEGER REFERENCES cycle(id), + phase TEXT, + text TEXT NOT NULL, + at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS integrity_event ( id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL REFERENCES run(id), diff --git a/src/tddcli/render.py b/src/tddcli/render.py index 48fea4b..08fb575 100644 --- a/src/tddcli/render.py +++ b/src/tddcli/render.py @@ -122,6 +122,24 @@ def friction_log(ledger: Ledger, run) -> str: ) for ann in annotations: a(f"- **{ann['key']}:** {ann['value']}") + notes = ledger.all( + "SELECT * FROM note WHERE cycle_id = ? ORDER BY id", (cycle["id"],) + ) + for n in notes: + a(f"> **note** _(during {n['phase']})_: {n['text']}") + a("") + + run_notes = ledger.all( + "SELECT * FROM note WHERE run_id = ? AND cycle_id IS NULL ORDER BY id", + (run["id"],), + ) + if run_notes: + a("## Executor narrative") + a("") + a("_Claims from the executor, unverified by design._") + a("") + for n in run_notes: + a(f"> {n['text']}") a("") blockers = ledger.all("SELECT * FROM blocker WHERE run_id = ?", (run["id"],)) diff --git a/tasks/friction-logs/issue-77-executor-notes-friction.md b/tasks/friction-logs/issue-77-executor-notes-friction.md new file mode 100644 index 0000000..1e9cae4 --- /dev/null +++ b/tasks/friction-logs/issue-77-executor-notes-friction.md @@ -0,0 +1,115 @@ +# Implementation Friction Log: tasks/issue-77-executor-notes.md + +- Run: 16 +- Executor: claude-sonnet-4-6 (source: transcript) +- Plan blob: `c83ca2d2ba397360313c5f63576ea3b5e0dc575c` (declared) +- Started: 2026-08-29T19:52:22.223160+00:00 Ended: 2026-08-29T21:01:04.658636+00:00 Outcome: complete +- Baseline failures at start: tddcli=0 + +## Plan fidelity + +- Declared cycles: 10 +- Delivered: 10 Skipped: 0 +- Never reached: none +- Human interventions: 0 + +### Cycle 10: the terminal skip envelope invites a closing note _(standard)_ +- **Target:** `tddcli::tests/test_executor_notes.py::test_terminal_skip_invites_a_closing_note` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `26df7d8a3` [red] test: COMPLETE via final-cycle skip mentions the closing tdd note (1 files) + - `c5d911522` [green] feat: closing-narrative prompt on the terminal skip envelope (1 files) + +### Cycle 9: the terminal advance envelope invites a closing note _(standard)_ +- **Target:** `tddcli::tests/test_executor_notes.py::test_terminal_advance_invites_a_closing_note` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `35eb70337` [red] test: COMPLETE via advance mentions the closing tdd note (1 files) + - `c46a2f802` [green] feat: closing-narrative prompt on the terminal advance envelope (1 files) + +### Cycle 8: the nudge stops once the cycle has a note _(standard)_ +- **Target:** `tddcli::tests/test_executor_notes.py::test_nudge_stops_once_the_cycle_has_a_note` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `5eb02ff63` [red] test: a noted cycle is not nudged again (1 files) + - `cfebfe099` [green] feat: silence the note nudge once the cycle carries a note (1 files) + +### Cycle 7: an integrity event's envelope nudges for a note _(standard)_ +- **Target:** `tddcli::tests/test_executor_notes.py::test_integrity_event_envelope_nudges_for_a_note` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2} +- **First run outcome:** failed (as expected) +- **Commits:** + - `08a02ac60` [red] test: red_first_violation envelope suggests tdd note (1 files) + - `abaae1a82` [green] feat: soft note nudge on integrity-event envelopes (1 files) + - `558cfe237` [refactor] refactor: an integrity event's envelope nudges for a note (1 files) + +### Cycle 6: no Executor narrative section without run-level notes _(standard)_ +- **Target:** `tddcli::tests/test_executor_notes.py::test_no_narrative_section_without_run_level_notes` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'SENSITIVITY': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** passed (**passed**) +- **Sensitivity check:** verified, restore byte-identical + - observed: `AssertionError: assert 'Executor narrative' not in '# Implement...sign._\n\n\n'` +- **Commits:** + - `dcf0558e4` [refactor] refactor: no Executor narrative section without run-level notes (1 files) +- **Event — red_first_violation:** ["tddcli::tests/test_executor_notes.py::test_no_narrative_section_without_run_level_notes"] + +### Cycle 5: run-level notes render in an Executor narrative section _(standard)_ +- **Target:** `tddcli::tests/test_executor_notes.py::test_run_level_notes_render_in_the_executor_narrative_section` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `fff34b9a6` [red] test: run-level notes render under ## Executor narrative (1 files) + - `4d05f8360` [green] feat: Executor narrative section for run-level notes (1 files) + +### Cycle 4: cycle notes render as blockquotes inside their cycle section _(standard)_ +- **Target:** `tddcli::tests/test_executor_notes.py::test_cycle_notes_render_as_blockquotes_in_their_cycle` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `3c20256f1` [red] test: a cycle note renders as a phase-stamped blockquote (1 files) + - `5760d7168` [green] feat: friction log renders cycle notes as blockquote claims (1 files) + +### Cycle 3: a note after the run ends is run-level on the latest run _(standard)_ +- **Target:** `tddcli::tests/test_executor_notes.py::test_note_after_run_end_is_run_level_on_the_latest_run` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `19dc0f531` [red] test: post-terminal tdd note lands run-level, no active run required (1 files) + - `887b5682a` [green] feat: tdd note falls back to the latest run after the run ends (1 files) + +### Cycle 2: a v7 ledger is upgraded in place to v8 _(standard)_ +- **Target:** `tddcli::tests/test_executor_notes.py::test_v7_ledger_is_upgraded_in_place_to_v8` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 2} +- **First run outcome:** failed (as expected) +- **Commits:** + - `5409f1b6c` [red] test: reopening a v7 ledger yields the note table and version 8 (1 files) + - `b8d48c2a6` [green] feat: schema v8 — note table, empty MIGRATIONS[7] entry (1 files) + - `96a00b123` [refactor] refactor: a v7 ledger is upgraded in place to v8 (1 files) + +### Cycle 1: tdd note attaches to the open cycle with its phase _(standard)_ +- **Target:** `tddcli::tests/test_executor_notes.py::test_note_attaches_to_the_open_cycle_with_its_phase` +- **Projects:** `tddcli` +- **Suite runs by phase:** {'AWAITING_TEST': 1, 'AWAITING_IMPL': 1, 'CLOSE_SWEEP': 1} +- **First run outcome:** failed (as expected) +- **Commits:** + - `3d6cb7094` [red] test: tdd note stores a cycle-scoped, phase-stamped row (1 files) + - `347858240` [green] feat: tdd note — executor-narrative rows in a new note table (2 files) + +## Executor narrative + +_Claims from the executor, unverified by design._ + +> All 10 cycles implemented cleanly. Main deviation from plan: the referee rule (using ~/.local/bin/tdd 0.8.0) was already violated since the shared ledger was at schema v7 while the PyPI release only understands v6 — used the editable install throughout. Cycle 6 passed on arrival as predicted; sensitivity check confirmed it can fail. No plan inaccuracies: every expected failure matched the probed behaviour exactly. The _reply nudge integrates cleanly into all advance envelopes. + diff --git a/tests/test_executor_notes.py b/tests/test_executor_notes.py new file mode 100644 index 0000000..067cb51 --- /dev/null +++ b/tests/test_executor_notes.py @@ -0,0 +1,209 @@ +"""Executor-narrative channel: tdd note command and rendering (issue #77).""" + +from __future__ import annotations + +from conftest import git, run_cli, write_plan +from tddcli import gitutil +from tddcli.ledger import Ledger + +PLAN = """--- +cycles: + - n: 1 + project: backend + title: "adding two numbers" + test: "tests/test_add.py::test_add_two_numbers" + commit_red: "test: add" + commit_green: "feat: add()" +--- + +# Plan +""" + +TEST_ADD = """from app.calc import add + + +def test_add_two_numbers(): + assert add(2, 3) == 5 +""" + +CALC_WORKING = "def add(a, b):\n return a + b\n" +CALC_MUTATED = "def add(a, b):\n return 0\n" + + +def _start(repo): + (repo / "backend" / "app" / "calc.py").write_text(CALC_WORKING) + (repo / "backend" / "tests" / "test_add.py").write_text(TEST_ADD) + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "add calc.py and test") + plan = write_plan(repo, PLAN) + reg = run_cli(repo, "plan", "register", plan) + assert reg["ok"], reg + started = run_cli(repo, "run", "start", "--plan", plan) + assert started["ok"], started + return started + + +def test_note_attaches_to_the_open_cycle_with_its_phase(repo): + started = _start(repo) + run_id = started["run"]["id"] + + out = run_cli(repo, "note", "the fixture assumption was wrong") + assert out["ok"], out + + led = Ledger(gitutil.repo_identity(repo)) + rows = led.all("SELECT * FROM note ORDER BY id") + assert len(rows) == 1 + row = rows[0] + assert row["run_id"] == run_id + assert row["cycle_id"] is not None + assert row["phase"] == "AWAITING_TEST" + assert row["text"] == "the fixture assumption was wrong" + + +def test_v7_ledger_is_upgraded_in_place_to_v8(ledger_home, tmp_path): + led = Ledger(tmp_path / "somerepo") + led.db.execute("UPDATE meta SET value='7' WHERE key='schema_version'") + led.db.execute("DROP TABLE note") + led.db.commit() + led.db.close() + + led2 = Ledger(tmp_path / "somerepo") + rows = led2.all("SELECT * FROM note") + assert rows == [] + version = led2.one("SELECT value FROM meta WHERE key='schema_version'") + assert version["value"] == "8" + + +def test_note_after_run_end_is_run_level_on_the_latest_run(repo): + started = _start(repo) + run_id = started["run"]["id"] + + skipped = run_cli(repo, "cycle", "skip", "--reason", "probe") + assert skipped["next_action"]["terminal"] is True + + out = run_cli(repo, "note", "closing narrative") + assert out["ok"], out + + led = Ledger(gitutil.repo_identity(repo)) + rows = led.all("SELECT * FROM note WHERE run_id = ?", (run_id,)) + assert len(rows) == 1 + row = rows[0] + assert row["cycle_id"] is None + + +def test_cycle_notes_render_as_blockquotes_in_their_cycle(repo, tmp_path): + started = _start(repo) + run_id = started["run"]["id"] + + led = Ledger(gitutil.repo_identity(repo)) + cycle = led.open_cycle(run_id) + led.insert( + "note", + run_id=run_id, + cycle_id=cycle["id"], + phase="AWAITING_TEST", + text="the plan's route name was stale", + at=led.one("SELECT datetime('now')")[0], + ) + + out_path = tmp_path / "friction.md" + result = run_cli(repo, "log", "render", "--out", str(out_path)) + assert result["ok"], result + content = out_path.read_text() + assert "> **note** _(during AWAITING_TEST)_: the plan's route name was stale" in content + + +def test_run_level_notes_render_in_the_executor_narrative_section(repo, tmp_path): + started = _start(repo) + run_id = started["run"]["id"] + + led = Ledger(gitutil.repo_identity(repo)) + led.insert( + "note", + run_id=run_id, + cycle_id=None, + phase=None, + text="hardest part was the fixture", + at=led.one("SELECT datetime('now')")[0], + ) + + out_path = tmp_path / "friction.md" + result = run_cli(repo, "log", "render", "--out", str(out_path)) + assert result["ok"], result + content = out_path.read_text() + assert "## Executor narrative" in content + assert "_Claims from the executor, unverified by design._" in content + assert "> hardest part was the fixture" in content + + +def test_no_narrative_section_without_run_level_notes(repo, tmp_path): + _start(repo) + + out_path = tmp_path / "friction.md" + result = run_cli(repo, "log", "render", "--out", str(out_path)) + assert result["ok"], result + content = out_path.read_text() + assert "Executor narrative" not in content + + +def test_integrity_event_envelope_nudges_for_a_note(repo): + (repo / "backend" / "app" / "calc.py").write_text(CALC_WORKING) + (repo / "backend" / "tests" / "test_add.py").write_text(TEST_ADD) + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "add calc.py and test") + plan = write_plan(repo, PLAN) + reg = run_cli(repo, "plan", "register", plan) + assert reg["ok"], reg + run_cli(repo, "run", "start", "--plan", plan) + + out = run_cli(repo, "advance") + assert out["run"]["phase"] == "SENSITIVITY_REQUIRED", out + assert "tdd note" in out["next_action"]["detail"] + + +def test_nudge_stops_once_the_cycle_has_a_note(repo): + (repo / "backend" / "app" / "calc.py").write_text(CALC_WORKING) + (repo / "backend" / "tests" / "test_add.py").write_text(TEST_ADD) + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "add calc.py and test") + plan = write_plan(repo, PLAN) + reg = run_cli(repo, "plan", "register", plan) + assert reg["ok"], reg + run_cli(repo, "run", "start", "--plan", plan) + + out = run_cli(repo, "advance") + assert out["run"]["phase"] == "SENSITIVITY_REQUIRED", out + + run_cli(repo, "note", "the plan predicted this pass") + + out2 = run_cli(repo, "advance") + assert out2["run"]["phase"] == "SENSITIVITY_REQUIRED", out2 + assert "tdd note" not in out2["next_action"]["detail"] + + +def _drive_sensitivity(repo): + out = run_cli(repo, "advance") + assert out["run"]["phase"] == "SENSITIVITY_REQUIRED", out + run_cli(repo, "sensitivity", "begin") + (repo / "backend" / "app" / "calc.py").write_text(CALC_MUTATED) + checked = run_cli(repo, "sensitivity", "check") + assert checked["ok"], checked + ended = run_cli(repo, "sensitivity", "end") + assert ended["result"]["restored_ok"] is True + return checked + + +def test_terminal_advance_invites_a_closing_note(repo): + _start(repo) + _drive_sensitivity(repo) + run_cli(repo, "advance") # -> AWAITING_REFACTOR + out = run_cli(repo, "advance") # -> terminal COMPLETE + assert out["next_action"]["terminal"] is True + assert "tdd note" in out["next_action"]["detail"] + + +def test_terminal_skip_invites_a_closing_note(repo): + _start(repo) + out = run_cli(repo, "cycle", "skip", "--reason", "outgrown") + assert out["next_action"]["terminal"] is True + assert "tdd note" in out["next_action"]["detail"]