diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..96008d7 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,42 @@ +name: lint + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + markdownlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: DavidAnson/markdownlint-cli2-action@v17 + with: + globs: "**/*.md" + + frontmatter: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify SKILL.md frontmatter + run: | + set -e + required=(name description allowed-tools) + for field in "${required[@]}"; do + if ! grep -q "^${field}:" SKILL.md; then + echo "::error file=SKILL.md::Missing required field: ${field}" + exit 1 + fi + done + echo "SKILL.md frontmatter: required fields present." + - name: Verify agent files have no frontmatter + run: | + set -e + for f in agents/*.md; do + if head -n 1 "$f" | grep -q "^---$"; then + echo "::error file=$f::Agent files are prompt templates and must not have YAML frontmatter (see README architecture note)." + exit 1 + fi + done + echo "agents/: no frontmatter, as expected." diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..6abc00a --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,14 @@ +{ + "default": true, + "MD013": false, + "MD022": false, + "MD024": { "siblings_only": true }, + "MD028": false, + "MD031": false, + "MD032": false, + "MD033": { "allowed_elements": ["br", "details", "summary", "sub", "sup"] }, + "MD040": false, + "MD041": false, + "MD046": { "style": "fenced" }, + "MD060": false +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0513ed8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to `deep-recon` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- `--pdfs` flag for Explorer PDF collection — Explorer downloads relevant PDFs to `/PDFs/` during web search. +- `--plain` flag — Synthesizer produces CommonMark-only output (no `[[wikilinks]]`, no `> [!callouts]`) for non-Obsidian environments. +- Per-agent model overrides: `--explorer-model`, `--associator-model`, `--critic-model`, `--synthesizer-model`. Each agent's Task dispatch now passes the resolved model parameter; defaults are tabulated in SKILL.md's "Agent Model Selection" section. +- `--budget ` flag — hard cap on total token spend. The orchestrator reads `_metrics.md` between rounds and aborts gracefully (writing the best-available draft) before exceeding the cap. +- `argument-hint` field in SKILL.md frontmatter — surfaces the skill's flag set at invocation time. +- `examples/` directory with two illustrative recon outputs (Explore mode, Focus mode) plus a README explaining their illustrative status. +- `docs/TUNING.md` — guidance for forkers on customizing the Synthesizer's voice. Documents the hardcoded `_resources/Kazys Varnelis – Personal Writing Style Guide.md` reference and three remediation paths (replace, remove, parameterize). +- `CONTRIBUTING.md` — PR norms, prompt-edit conventions, local testing, style expectations. +- `tests/check_recon_structure.py` and `tests/run_smoke_tests.sh` — structural validator for recon output documents (frontmatter fields, required sections, Territory cardinality, Obsidian/plain flavoring), with self-test against the example files. +- `tests/test_validator_contract.py` — negative tests for the validator itself: feeds it 7 malformed stubs (missing section, framings under/over cardinality, missing frontmatter field, plain-mode wikilinks, plain-mode callouts, Obsidian mode with no wikilinks) and asserts the expected errors fire. Catches regressions that would silently weaken the validator. Wired into `run_smoke_tests.sh`. +- `.github/workflows/lint.yml` — CI runs markdownlint-cli2 plus a SKILL.md frontmatter check and a "no frontmatter in agent files" check. +- README **Troubleshooting** section covering missing-document recovery, determinism expectations, partial-round failure handling, generic-output fixes, metrics after compaction, and cost controls. +- README **Documentation** section pointing to CHANGELOG, TUNING, and examples. + +### Changed +- Orchestrator now handles partial-round failures gracefully — see the new **Failure Handling** section in SKILL.md. One agent failing no longer aborts the round; Synthesizer write failures retry once then fall back to orchestrator-written stub; `_metrics.md` failures degrade non-fatally. +- README architecture section updated to be Claude-Code-version-agnostic (the prior "experimental in Claude Code 4.6" reference is replaced with a forward-compatible framing). +- README modes table includes `--plain`. + +## [1.0.0] — 2026-02-19 + +Initial public release. + +### Added +- Four-agent recon workflow: Explorer (divergent), Associator (lateral), Critic (adversarial), Synthesizer (integrative). +- 2–3 round parallel dispatch via Claude Code's Task tool, with orchestrator cross-pollination between rounds. +- Interactive mode (Socratic — checks in between rounds) and Autonomous mode (end-to-end run). +- Explore intention (divergent — opens possibility space, ends with open questions) and Focus intention (convergent — narrows to a thesis, ends with action plan). +- `--vault-only` flag — skip web search, vault content only. +- `--output ` flag — explicit output directory override. +- Anti-hallucination guardrails in the Synthesizer: epistemic honesty rules, observation-vs-invention discipline, ground-every-claim requirement. +- Disk-persisted Synthesizer write — the final document is written directly to its output path by the Synthesizer agent, surviving orchestrator crashes. +- Metrics tracking — per-agent token counts and elapsed times persisted to `_metrics.md` after each round, surviving context compaction. +- Architecture rationale: subagents over agent teams, with the orchestrator as deliberate interpretive layer. +- Obsidian-native output formatting — `[[wikilinks]]`, `> [!callout]` blocks, footnotes, YAML frontmatter, Process Log. + +[Unreleased]: https://github.com/kvarnelis/deep-recon/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/kvarnelis/deep-recon/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c448c0f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing to deep-recon + +This is a small skill — five markdown files, one orchestrator prompt, four agent prompts, one output template. Changes ripple through the whole behavior of the skill quickly, so the bar for PRs is higher than the surface area suggests. + +## Before you start + +Different kinds of changes have different paths: + +- **Bug in the orchestrator or an agent prompt** (the skill misbehaves on a defined input): open an issue with a minimal reproduction, then PR the fix. Small fixes can skip the issue. +- **Feature addition** (new flag, new mode, new agent): open an issue first. Most feature additions break the existing prompt economy in non-obvious ways, and a 5-line discussion ahead of time is cheaper than a 200-line revert. +- **Voice or personal-style change** to the Synthesizer's prompt: don't PR these upstream — fork. The Synthesizer's voice is calibrated for one specific user, and the right way to use it for someone else is to retune in your fork. See [`docs/TUNING.md`](docs/TUNING.md). +- **Architectural change** (subagents → agent teams, structured handoff, persistence layer): open an issue and discuss the design before any code. These are the kinds of changes that need consensus, not review. +- **Documentation, examples, troubleshooting**: PR directly. Light review, fast merge. + +## Prompt-edit conventions + +The agent prompt files (`agents/*.md`) and `SKILL.md` are the load-bearing parts of this repo. Edits here change recon behavior in ways that are hard to predict from a diff. + +**Atomic changes.** One prompt-section change per PR. If you're rewriting the Synthesizer's Voice section *and* the Critic's Strongest Objections format, that's two PRs. + +**Justify additions.** New paragraphs in agent prompts add tokens and instruction-load. If you're adding a section, the PR description should explain what failure mode it addresses and why a smaller change wouldn't fix it. + +**Show before/after.** If your change is non-trivial, run a recon on a topic both before and after the edit and quote a paragraph from each in the PR description. If the after-version isn't clearly better, the change probably isn't ready. + +**Never edit `SKILL.md` and an agent file in the same PR** unless the edits are coupled by design. The orchestrator's responsibilities and an agent's responsibilities should be reviewed independently. + +**Don't loosen anti-hallucination guardrails.** The Synthesizer's Epistemic Honesty section and the Critic's "WITHOUT rebuttal" rule are load-bearing for output quality. PRs that weaken these rules need an exceptional justification. + +## Testing changes locally + +Before submitting a PR: + +1. **Run a recon** on a topic you've used the skill on before. Read the output against the previous version. Look for register drift, structural regressions, missing sections, off-tone prose. +2. **Run the structural test script** (when goldens land — see `tests/golden/`). A passing script means the output skeleton is intact. It does not mean the prose is good. +3. **Run `markdownlint-cli2` locally** if you can: `npx markdownlint-cli2 "**/*.md"`. The CI runs this on every push. +4. **Verify frontmatter**: `SKILL.md` must keep `name`, `description`, `allowed-tools` fields. Agent files must NOT have YAML frontmatter (see the README's "A note on the agents/ directory"). + +If you can't run the skill locally (no Claude Code access, no Obsidian vault), say so in the PR description. We'll smoke-test for you before merging. + +## Style + +Match the existing repo voice. The README and SKILL.md are deliberately opinionated and direct. PRs that introduce hedging, generic consultancy-speak, or LLM tells ("delve," "leverage," "robust," "comprehensive") will get bounced. + +**Concrete.** Prefer "the orchestrator reads `recon/rN-explorer.md` from disk after each round" over "the orchestrator processes agent outputs." + +**Declarative.** Prefer "this is the right call until agent teams reach a stable form" over "one might consider exploring agent teams when they become more stable." + +**Cite specifics.** When recommending a change, point to a file and line. PR descriptions and commit messages benefit from the same. + +## Commit messages + +Match the repo's existing pattern: imperative subject line, no conventional-commit prefix, optional body explaining the *why*. Examples: + +- `Add --plain output flag for non-Obsidian forks` +- `Fix Synthesizer voice-guide read on missing path` +- `Document architecture: subagents over agent teams` + +If the change is part of a larger plan, append the plan's item ID in parens: `Add Troubleshooting section (T1.1)`. + +## Issue templates + +We don't have formal issue templates yet. A useful issue includes: + +- **What you tried** — the exact `/deep-recon` invocation +- **What you expected** — the output structure or content you anticipated +- **What happened** — the actual output, or the missing output +- **Vault context** — rough size, whether the topic had relevant existing notes, mode flags used + +## License + +By contributing, you agree your contributions are released under the same MIT license as the rest of the repo. diff --git a/README.md b/README.md index 6c9ea23..24a7094 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,21 @@ rounds. This is deliberate. The orchestrator's role between rounds — digesting the Synthesizer's analysis, compiling settled claims, crafting tailored prompts for each agent — is an -interpretive step that shapes the next round's quality. Agent teams (experimental in Claude -Code 4.6) offer direct inter-agent messaging, but at the cost of deterministic control -over round structure and dispatch. +interpretive step that shapes the next round's quality. Direct inter-agent messaging +(via agent teams in newer Claude Code releases) erases that interpretive layer. -When agent teams exit experimental status, a hybrid approach — orchestrator-controlled -rounds with inter-agent dialogue within each round — could improve the Critic↔Explorer -and Synthesizer→all-agents communication flows. +If agent teams reach a stable form with deterministic dispatch guarantees, a hybrid is +plausible: orchestrator-controlled rounds with inter-agent dialogue *within* each round — +especially Critic↔Explorer for real-time stress-testing, and Synthesizer→all-agents for +mid-round redirect. Until then, the subagent + orchestrator pattern is the right call. + +### A note on the `agents/` directory + +The four files in `agents/` are not Claude Code agent definitions (no frontmatter, +no `subagent_type`). They are **prompt templates** — read at runtime by the orchestrator +(`SKILL.md`) and inserted into `Task` calls dispatched as `subagent_type: +"general-purpose"`. This keeps the prompts version-controllable without coupling them to +Claude Code's agent-definition spec, which has been moving fast. ## Modes @@ -54,6 +62,7 @@ and Synthesizer→all-agents communication flows. | `--focus` | Focus | Convergent — narrows to one argument, ends with action plan | | `--vault-only` | Vault-only | Skips web search, uses only vault content | | `--pdfs` | PDF collection | Explorer downloads relevant PDFs to `/PDFs/` | +| `--plain` | Plain markdown | Output is CommonMark-only — no `[[wikilinks]]`, no `> [!callouts]`. Use for non-Obsidian environments. | ## Installation @@ -121,6 +130,60 @@ The skill produces an Obsidian-native markdown document saved to a `recon/` subd Individual agent reports are saved alongside as reference material. +## Troubleshooting + +### "I don't see my recon document" + +The Synthesizer writes the final document directly to disk — it should appear at `/YYYY-MM-DD-.md`. If it's missing: + +- Check the per-agent reports in the same folder (`r1-explorer.md`, `r1-critic.md`, etc.). If those exist but the final doc doesn't, the Synthesizer write failed mid-flight — re-run the skill with the same topic. Agent reports survive across attempts. +- If the agent reports also don't exist, the orchestrator failed before any agent dispatched. Confirm `--output` resolves to a writable directory and that the vault root is in scope. + +### "Two runs of the same topic produce different outputs" + +This is by design. Web search results vary day-to-day, vault state evolves, and model sampling is non-deterministic. The skill is built for divergent exploration, not reproducibility. If you need repeatable runs, use `--vault-only` and treat the agent reports (`rN-*.md`) as the canonical record — those at least come from a fixed input set. + +### "An agent timed out, or one round is missing a report" + +The orchestrator handles partial-round failures gracefully — see the **Failure Handling** section in `SKILL.md`. The summary: + +- **One agent fails:** the round proceeds with N–1 reports. The Process Log notes the failure. +- **All agents fail in a round:** the orchestrator skips to the final Synthesizer with whatever earlier rounds produced. +- **Synthesizer's final write fails:** the orchestrator retries, and on a second failure writes a stub document pointing the user at the per-agent reports on disk. + +Practical recovery as a user: + +- Inspect the recon directory for whatever reports landed. +- Read the Process Log in the final document — it tells you which rounds and agents failed. +- Re-run the skill with the same topic if you want a fresh attempt; the orchestrator overwrites per-round files. +- If web search is the consistent failure, add `--vault-only`. + +### "The output sounds generic, not like my voice" + +The Synthesizer reads existing notes in your vault to match register. If output feels generic: + +- Make sure your vault has notes the Synthesizer can find via Grep on the topic's terms. +- If you forked this skill, see [`docs/TUNING.md`](docs/TUNING.md) — the Synthesizer references a personal style guide by default and will need adjustment for your voice. + +### "The Process Log shows wrong token counts after a context compaction" + +The Process Log reads from `_metrics.md`, which is updated after every round expressly to survive context compaction. If the numbers in the final document look off, check `_metrics.md` directly — it's the source of truth. The orchestrator recovers from compaction by re-reading this file. + +### "How much does a typical run cost?" + +Token spend depends on vault size, web search depth, and whether you run 2 or 3 rounds. Per-round cost data is recorded in `_metrics.md` — review it after a few runs to calibrate. The Synthesizer (Opus) is the highest single-agent contributor in most runs. + +If cost matters, you have two levers: + +- **Hard cap:** `--budget ` — the orchestrator aborts gracefully (writing the best-available draft) before exceeding the cap. See SKILL.md's "Budget Check" section. +- **Cheaper Explorer:** `--explorer-model haiku` is the safest single substitution for cost-sensitive runs. The Synthesizer should remain on Opus — Haiku-on-Synthesizer significantly degrades final-document quality. See SKILL.md's "Agent Model Selection" section for full guidance. + +## Documentation + +- [`CHANGELOG.md`](CHANGELOG.md) — version history. +- [`docs/TUNING.md`](docs/TUNING.md) — adjusting the Synthesizer's voice for your fork. +- [`examples/`](examples/) — sample recon outputs. + ## License MIT diff --git a/SKILL.md b/SKILL.md index d41da53..f735079 100644 --- a/SKILL.md +++ b/SKILL.md @@ -3,6 +3,7 @@ name: deep-recon description: Run extended multi-agent reconnaissance sessions. Use when asked to brainstorm deeply, explore ideas from multiple angles, or generate a structured recon document. allowed-tools: Read, Grep, Glob, Write, Edit, WebSearch, WebFetch, Task, AskUserQuestion user-invocable: true +argument-hint: "[--autonomous] [--focus] [--vault-only] [--pdfs] [--plain] [--output ] " --- # Deep Recon @@ -36,6 +37,17 @@ From the user's prompt, determine: 7. **PDF collection**: - `--pdfs`: Explorer searches for and downloads relevant PDFs to a `PDFs/` subdirectory within the output directory - Default: Off +8. **Output flavor**: + - `--plain`: Synthesizer produces plain markdown — no `[[wikilinks]]`, no `> [!callout]` blocks. Use this for forks where Obsidian is not the target environment (Logseq, Foam, plain GitHub markdown, etc.) + - Default: Obsidian-flavored output (wikilinks, callouts, frontmatter) +9. **Per-agent model overrides** (optional, advanced): + - `--explorer-model ` / `--associator-model ` / `--critic-model ` / `--synthesizer-model ` + - `` is a Claude model identifier (e.g. `opus`, `sonnet`, `haiku`, or a fully-qualified ID) + - Default assignments are in the **Agent Model Selection** section below + - Most users should leave these alone. Override is for cost optimization (e.g., `--explorer-model haiku` for cheap web triage) or quality experiments (e.g., `--critic-model opus` for harder pressure-testing) +10. **Token budget cap** (optional): + - `--budget `: hard cap on total token spend across the recon. Numbers like `200000`, `500000`, `1m` accepted. The orchestrator reads `_metrics.md` between rounds and aborts gracefully (writing the best-available draft) before exceeding the cap. + - Default: no cap. Spend is recorded in `_metrics.md` but not gated. ## Step 2: Initial Vault Scan @@ -147,6 +159,58 @@ Run only if: Focus agents on developing the tensions and filling out underdeveloped framings. Round 3 should find NEW complications, not resolve existing ones. +### Budget Check (between rounds, when `--budget` is set) + +After updating `_metrics.md` and before dispatching the next round (Round 2 or Round 3), check: + +1. Read `_metrics.md` to get cumulative token spend so far. +2. Estimate the next round's spend using the previous round as a baseline (parallel agents, similar prompt sizes — use the previous round's per-agent token average × 4 + a small Synthesizer multiplier for cross-pollination). +3. If `cumulative + estimated_next > budget`: + - **Do not dispatch the next round.** + - Skip directly to the **Step 4 / Final Synthesizer** path. + - Pass the Synthesizer the agent reports gathered so far AND a note: "Token budget cap reached. Produce the best final document you can from current material." + - Record the budget-abort in the Process Log: "Aborted at Round N due to --budget ``. Final document drafted from R1..N reports." +4. If projected next-round spend would push within 10% of cap, warn but proceed; the next-round budget check will catch genuine overruns. + +The point is to **fail safely with a finished draft**, not to crash mid-recon. + +### Failure Handling + +The recon should produce something useful even when parts of the dispatch fail. The orchestrator's job is to degrade gracefully, never to crash mid-recon and lose all collected work. + +**One agent fails or times out within a round.** Do NOT abort the round. After all parallel Tasks return: + +1. Check each agent report file on disk. +2. Note which reports are missing or empty. +3. Proceed to the next round (or Step 4) with the partial report set. Pass the surviving reports to subsequent agents. +4. Record the failure in the Process Log: "Round N: `` failed (timeout / error / empty output). Continuing with `` reports." +5. If the failed agent was the Synthesizer in a non-final round, the orchestrator must do its job: compile settled claims, identify framings, generate cross-pollination prompts. This is a fallback — the orchestrator's interpretive work is the Synthesizer's role in mid-rounds. + +**All agents fail in a round.** Abort cleanly: + +1. Read whatever partial reports exist on disk. +2. Dispatch the final Synthesizer with all available material from prior rounds. +3. Pass the Synthesizer a note: "Round N agents all failed. Produce the best document possible from R1..N-1 reports." If this happens in Round 1, write a stub recon explaining the failure and exit. +4. Record clearly in the Process Log. + +**Synthesizer's final-document write fails.** Retry once with the same input. If the retry fails: + +1. Read the Synthesizer's Task return value (it may contain the draft text even if Write failed). +2. Try to write the file yourself (the orchestrator) using the captured text. +3. If both retries fail, write a stub recon at the output path with: Process Log, Sources extracted from agent reports, Central Question, and a clear note: "Final synthesis failed at ``. Agent reports preserved at `/rN-*.md` — they contain the substance of this recon." + +**`_metrics.md` write fails.** Log to stderr but continue. Recon quality does not depend on metrics. The Process Log will be missing precise numbers; flag this in the log entry: "Metrics unavailable — _metrics.md write failed." + +**Web search returns empty or errors (`--vault-only` is not set).** The Explorer agent is responsible for handling this in its own prompt — see `agents/explorer.md`. The orchestrator does NOT auto-fallback to `--vault-only`. If the Explorer reports zero web findings, dispatch the next round with that fact in the cross-pollination context: "Web search yielded nothing in Round N — Round N+1 should rely on vault and Associator findings." + +**PDF download fails (`--pdfs` is set).** Explorer skips and continues — see `agents/explorer.md`. No orchestrator action. + +**The user kills the orchestrator mid-round.** All Task subagents will continue running until they complete or the session ends. Their reports may or may not land on disk depending on timing. On next invocation: +- If a previous session's recon directory exists with partial reports, do not auto-resume. Start fresh from the user's current prompt. +- The user can manually inspect the partial reports and re-invoke with `--resume` if they want to continue (resume is not currently implemented but is reserved for future versions). + +The principle is **substance survives**. Agent reports on disk are the ground truth. The final document is built from them. Anything else — the orchestrator's in-context state, the metrics, the cross-pollination prose — is auxiliary and recoverable. + ## Step 4: Produce Output After the final round, produce the recon document. @@ -184,16 +248,50 @@ Save individual agent reports to the same folder as `rN-agentname.md` files. The ### Formatting +Default (Obsidian flavor): + - Use Obsidian `[[wikilinks]]` for vault references - Use standard Markdown footnotes for web sources -- Use callout blocks (`> [!info]`) for the process log +- Use callout blocks (`> [!info]`, `> [!note]-`, `> [!abstract]`) for the Process Log and Central Question - Keep the main body in flowing prose, not bullet-point dumps +When `--plain` is set: + +- Replace `[[wikilinks]]` with standard `[link text](relative/path.md)` links — or, for vault notes that don't have a known web target, plain prose mentions +- Replace `> [!note]- Process Log` with `## Process Log` (an ordinary section) +- Replace `> [!abstract] Central Question` with `## Central Question` (an ordinary section) +- Footnotes (`[^1]`) and YAML frontmatter remain unchanged (both are CommonMark-compatible and used by many systems) +- Pass the `--plain` flag through to the Synthesizer in its prompt — it must know to apply these substitutions while drafting + ## Agent Model Selection -- Default: Use `sonnet` for Explorer, Associator, Critic -- Use `opus` for Synthesizer (it does the hardest integrative thinking) -- If the user requests maximum quality, use `opus` for all agents +When dispatching each agent via the Task tool, pass the resolved model as the `model` parameter on the call. The defaults: + +| Agent | Default model | Rationale | +|---|---|---| +| Explorer | `sonnet` | Breadth of search rewards speed and decent quality; Haiku is viable here for cost-sensitive runs (use `--explorer-model haiku`) | +| Associator | `sonnet` | Lateral connection-finding benefits from Sonnet's reasoning over Haiku | +| Critic | `sonnet` | Stress-testing needs reasoning depth; consider `--critic-model opus` for the highest-stakes recons | +| Synthesizer | `opus` | The integrative thinking is the bottleneck; this is where capability matters most | + +**Resolution order** (highest precedence first): +1. Per-agent override flag (`--explorer-model`, `--associator-model`, `--critic-model`, `--synthesizer-model`) +2. Repo-level config (a forker may edit this section to change defaults) +3. The defaults in the table above + +**Maximum-quality mode:** If the user says "use the best model for everything" (or similar), set all four to `opus`. Token cost roughly 4–5× the default Sonnet/Sonnet/Sonnet/Opus mix. + +**Cost-conscious mode:** `--explorer-model haiku` is the safest single substitution. The Synthesizer should remain on Opus — Haiku-on-Synthesizer significantly degrades final-document quality. + +When dispatching, your Task call should look like: + +``` +Task( + subagent_type: "general-purpose", + model: , + prompt: +) +``` ## Important diff --git a/agents/synthesizer.md b/agents/synthesizer.md index fbcccf9..1cfc697 100644 --- a/agents/synthesizer.md +++ b/agents/synthesizer.md @@ -82,6 +82,18 @@ Your final document must include: - Open Questions — genuinely open, NOT action items, NOT "next steps," NOT rhetorical questions that imply their own answers - Sources: `[[wikilinks]]` for vault references, URLs for web sources with footnotes +### Plain Mode + +If the orchestrator's prompt to you includes `--plain` (or "plain mode"), produce CommonMark-compatible output instead of Obsidian-flavored: + +- Replace `[[Note name]]` wikilinks with standard `[Note name](relative/path.md)` markdown links for known paths, or with plain prose mentions ("the note on X") when no path is available. +- Replace the `> [!note]- Process Log` callout with a top-level `## Process Log` section. Same content, just no callout syntax. +- Replace the `> [!abstract] Central Question` callout with a top-level `## Central Question` section. +- Keep YAML frontmatter and standard footnotes (`[^1]`) — both are CommonMark and work in plain markdown. +- Do not produce any other Obsidian-specific syntax: no `> [!info]`, no `> [!warning]`, no embedded queries, no transclusions. + +Plain mode is for forks targeting Logseq, Foam, plain GitHub-flavored markdown, or any environment that doesn't render Obsidian extensions. The information content is identical; only the formatting differs. + ## Output Format ### Mid-Brainstorm (Round 1-2) diff --git a/docs/TUNING.md b/docs/TUNING.md new file mode 100644 index 0000000..aced1a8 --- /dev/null +++ b/docs/TUNING.md @@ -0,0 +1,94 @@ +# Tuning for your voice + +The Synthesizer agent is the only one that produces user-facing prose — the recon document itself. To make that prose feel like your thinking extended, not generic AI output, the Synthesizer reads notes from your vault to match register, vocabulary, and theoretical commitments. + +It also reads a hardcoded personal style guide. If you forked this skill, you almost certainly need to change that. + +## The hardcoded reference + +Open `agents/synthesizer.md` and look at the **Voice (CRITICAL)** section. The current text: + +```markdown +The final document must sound like the user extended their own thinking, not like a philosophy seminar. Read `_resources/Kazys Varnelis – Personal Writing Style Guide.md` before drafting. +``` + +That path resolves against the vault root the skill is invoked in. If you don't have a file at that exact path, one of two things happens: + +1. **The Synthesizer's `Read` call fails silently**, and the agent falls back to whatever voice it can extract from your other vault notes — usually serviceable, sometimes generic. +2. **The Synthesizer surfaces the failure** in its agent report (`rN-synthesizer.md`), and the final document includes a note that the style guide was unavailable. + +Either way, you're not getting the voice-tuning the original author built into the skill. + +## Three ways to fix it + +### Option 1 — Replace the path with your own style guide (recommended) + +Write a short style guide that captures your voice. Save it to your vault. Update `agents/synthesizer.md` to point at it. + +Example diff: + +```diff +- Read `_resources/Kazys Varnelis – Personal Writing Style Guide.md` before drafting. ++ Read `_meta/my-writing-voice.md` before drafting. +``` + +Commit the change to your fork. You're done. + +### Option 2 — Remove the hardcoded reference + +If you'd rather have the Synthesizer rely entirely on whatever notes it Greps up during the recon, just delete the sentence. The rest of the **Voice** section already describes what good prose looks like. + +```diff +- The final document must sound like the user extended their own thinking, not like a philosophy seminar. Read `_resources/Kazys Varnelis – Personal Writing Style Guide.md` before drafting. ++ The final document must sound like the user extended their own thinking, not like a philosophy seminar. +``` + +This produces softer voice-matching, but it works for any forker out of the box. + +### Option 3 — Make the path configurable + +If you publish a fork and want others to customize without editing prompts, parameterize the path. Add to your `SKILL.md` arg-parsing logic: + +```markdown +8. **Voice guide override** (optional): + - `--voice-guide `: Read this file in the Synthesizer prompt instead of the default + - Default: `_resources/personal-style-guide.md` (or whatever you set) +``` + +Then update `agents/synthesizer.md` to use a placeholder: + +```markdown +Read `{{voice_guide_path}}` before drafting. +``` + +The orchestrator substitutes the value (or the default) into the agent prompt at dispatch time. This is more work but the most robust path for a public fork. + +## Writing a personal style guide + +The Synthesizer's existing voice rules (in the same section of `agents/synthesizer.md`) tell you what kind of guide is useful. Concretely: + +**Declarative register.** The Synthesizer wants to write declarative claims, not hedged observations. Your guide should give it permission to do that — explicit examples of you making categorical assertions. + +**Concrete stakes.** It wants abstraction tethered to specific cases. Your guide should show what "the right level of abstraction" looks like for you — usually with an example paragraph that names a specific work, person, or moment. + +**Register tests.** What words or phrases do you avoid? "Performative," "embodied," "interrogate" all set the Synthesizer's alarm. Tell it your own list. + +**Sentence rhythm.** Short staccato vs. long discursive. Most of us mix both. Show it the mix you actually use. + +A useful guide is 200–500 words. Longer than that and the Synthesizer will start to over-fit on the guide instead of your actual notes. + +## Verifying the change worked + +Run a recon on a topic you've written about before. Read the output. Three checks: + +1. **Vocabulary match.** Are the words ones you'd actually use? If you see "interrogate" or "robust" or "leverage," tighten the guide. +2. **Cadence match.** Read a paragraph aloud. Does it sound like something you'd say to a smart colleague? If it sounds like a qualifying exam, the Synthesizer is still in seminar mode. +3. **Stakes match.** Are the abstractions tethered to concrete cases you'd reach for? If everything floats at the level of "the relationship between X and Y," more concrete examples in your guide will help. + +If a few iterations of guide-tuning still produce generic output, the issue is usually upstream: too few notes in the vault for the Synthesizer's Grep to find your voice in the first place. The Synthesizer can't extrapolate a register it has no examples of. + +## Related + +- `agents/synthesizer.md` — the prompt itself, including the Voice and Epistemic Honesty sections. +- `SKILL.md` — orchestrator logic, including how the Synthesizer gets dispatched. +- The README's [Troubleshooting](../README.md#troubleshooting) entry on generic-sounding output. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..77f5772 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,21 @@ +# Examples + +These are illustrative recon outputs, not real ones. They show the document structure a `deep-recon` session produces — frontmatter, Process Log, framings, tensions, sources — without committing the maintainers to a specific intellectual position. + +Real recons vary considerably in length, depth, and prose quality depending on: + +- the topic's tractability +- vault size and how on-topic the existing notes are +- whether `--vault-only` is set +- how many rounds run (2 vs. 3) +- which mode (Explore vs. Focus) +- how well the Synthesizer's voice has been tuned (see [`docs/TUNING.md`](../docs/TUNING.md)) + +These examples are deliberately mid-quality. A well-tuned recon on a topic the user has already written about can be considerably better. A first-time recon on a topic with no vault context can be considerably weaker. + +## Files + +- `explore-example.md` — Explore mode (default). Three framings, two tensions, open questions. Structure-complete; prose is illustrative. +- `focus-example.md` — Focus mode (`--focus`). Single argumentative spine, runners-up flagged, Next Steps in place of Open Questions. + +If you want to contribute real (anonymized) recons, see [`CONTRIBUTING.md`](../CONTRIBUTING.md) when it lands. diff --git a/examples/explore-example.md b/examples/explore-example.md new file mode 100644 index 0000000..ccdcdcd --- /dev/null +++ b/examples/explore-example.md @@ -0,0 +1,96 @@ +--- +created: 2026-04-15 +type: recon +topic: "what makes a public space feel safe versus surveilled" +mode: interactive +intention: explore +source_notes: + - "essays/urban-affect.md" + - "notes/eyes-on-the-street.md" +--- + +# What makes a public space feel safe versus surveilled + +> [!note]- Process Log +> **Mode**: interactive · **Rounds**: 2 · **Date**: 2026-04-15 +> **Tokens**: ~210k total · R1: ~95k · R2: ~115k +> **Elapsed**: 14m wall clock · R1: 6m · R2: 8m +> +> R1: agents converged on "safety" as a felt quality distinct from measured-crime statistics. Three distinct framings emerged, plus a productive tension between "lit and seen" vs. "anonymous and ignored" preferences. +> R2: deepened the surveillance-fatigue framing and surfaced a counter-framing from disability/elder-mobility literature that complicates the dominant "eyes-on-the-street" thesis. +> +> **Agent reports**: [[r1-explorer]] · [[r1-associator]] · [[r1-critic]] · [[r1-synthesizer]] · [[r2-explorer]] · [[r2-associator]] · [[r2-critic]] · [[r2-synthesizer]] + +> [!abstract] Central Question +> When residents of a public space report it as "feeling safe," what are they actually responding to — and is that response separable from "feeling surveilled"? + +## The Territory + +### The Jacobsian wager + +Jane Jacobs argued in *The Death and Life of Great American Cities* that public space gets safer when it has natural watchers — shopkeepers, neighbors, passers-by — whose eyes happen to fall on the street as a side effect of their daily lives.[^1] The wager was that incidental observation is psychologically distinct from official observation: the bodega owner who notices you isn't a guard, and your awareness of being seen by him is not the awareness of being patrolled. + +Through this lens, "safe" and "surveilled" are not on the same axis. Safe means *legible to people who would notice if something went wrong*. Surveilled means *legible to systems whose function is to detect deviation*. The Jacobsian space is dense with the first kind of legibility and thin on the second. A piazza with cafés, a stoop in front of a brownstone, a market square at dusk — these get safer as more people accumulate non-instrumental reasons to be there. + +What this framing reveals: it explains why a deserted CCTV-monitored alley feels worse than a busy un-monitored one even if the former has measurably lower crime. What it obscures: it tends to take the watchers' interests as benign, which is exactly what disability-rights and Black-mobility scholarship has spent thirty years complicating. + +### The visibility-as-discipline framing + +The Foucauldian counter is that the experience of being seen is not pleasantly variable along a "by whom" axis — it is the same experience whether the watcher is a shopkeeper or a camera, and that experience is itself disciplinary.[^2] Public space teaches its inhabitants what kinds of bodies and behaviors are noticed. Once that lesson is internalized, the watcher becomes redundant. You self-regulate. + +In this framing, "safe" is a coded word for "successfully internalizing the local norms of visibility." Spaces feel safe to people who match the norm of who-belongs-here, and feel surveilled to people who don't. The same plaza is a Jacobsian park to one user and a Foucauldian panopticon to another, and the difference lies in whether the user expects to be the one observing or the one observed. + +What this framing reveals: it explains the durable finding that perceived safety in public space tracks demographic match between the perceiver and the typical user.[^3] What it obscures: it can collapse genuinely important distinctions between forms of observation — being watched by a neighbor differs in real, material ways from being watched by a license-plate reader, and a framing that treats them identically loses something. + +### The infrastructural-comfort framing + +A third reading, drawn mostly from disability and elder-mobility scholarship, treats "safe" as a property of the built environment's *legibility for the body*. Wide sidewalks, audible street furniture, predictable lighting, well-marked transitions — these make a space safe for users whose bodies cannot quickly recover from unexpected hazards. In this framing, the social-watching question (Jacobs vs. Foucault) is a second-order concern; the first-order concern is whether the space announces itself to a user who needs to plan three minutes ahead. + +What this framing reveals: it explains why some spaces feel safe even when nearly empty (well-lit, well-marked, predictably navigable) and others feel unsafe even when bustling (crowded but visually noisy, full of edge cases that demand instant response). What it obscures: it tends to treat "the body" as a neutral category, which the disability scholarship it draws on is otherwise careful to avoid. + +## Tensions + +### Lit-and-seen vs. anonymous-and-ignored + +**The pull toward lit-and-seen:** Most public-safety design assumes you want to be visible. Bright lighting, sightlines, eye-level windows facing the street — the entire toolkit of crime-prevention-through-environmental-design optimizes for legibility. This is right when the danger is interpersonal and the watcher pool is broadly trustworthy. It produces the cafés-and-stoops vision that has dominated urbanism since the 1960s. + +**The pull toward anonymous-and-ignored:** A different, mostly unspoken design tradition produces spaces where the user can vanish — public libraries, cathedral interiors, certain plazas at off-hours. The safety here is the safety of not having to perform or be assessed. For users whose visibility carries cost (queer kids, people in mental-health crisis, anyone fleeing a domestic situation), invisibility *is* the safety good. Designing for legibility forecloses these uses. + +**What this tension reveals:** "Public safety" assumes a single kind of public safety, but at least two distinct goods (legible-protection, anonymous-refuge) are in real conflict at the design level. A plaza optimized for one is hostile to the other. This is not a problem to solve; it is a question of which good a given space should provide, and most cities provide far more legible-protection than anonymous-refuge. + +### Watchfulness as care vs. watchfulness as control + +**The pull toward watchfulness as care:** Communities form, in part, through accumulated mutual observation. The neighbor who notices your kid is the neighbor who'll notice if your kid is in trouble. Erode the watching, you erode the carrying-capacity of the social fabric that produces safety in the first place. + +**The pull toward watchfulness as control:** Black, queer, and disabled urbanists have been clear for a generation that "watchfulness" looks different when you are the unusual one in the field of view. The same observational density that protects the modal user marks the non-modal user as deviant. The ambient watching is not neutral; it is calibrated to a center it doesn't have to name. + +**What this tension reveals:** This is not symmetrically resolvable. Watching produces both care and discipline at the same time, in the same act, by the same watchers. The design question is not how to get pure-care watching but how to constrain the discipline component — a much harder problem than the ambient-watching literature usually admits. + +## Unexpected Connections + +The disability-mobility literature on legible-environment-as-safety has a structural parallel in the open-source software literature on legible-codebase-as-safety. In both cases, "safe" describes a property of how the system announces itself to a user who must plan and recover, and in both cases the failure mode is not hostility but *opacity* — a codebase or a streetscape so dense with edge cases that no user can hold the whole thing in working memory at once. This connection cuts the surveillance question entirely; "safe-because-legible" is a category distinct from "safe-because-watched" or "safe-because-watching." + +A separate connection runs from the anonymous-refuge framing back to the architectural history of the Catholic confessional. The confessional's safety property — total verbal exposure conditional on total bodily anonymity — is the inverse of the panopticon's. The persistence of confessional design across centuries suggests that the demand for anonymous-refuge in public space is not a modern invention; it is a recurring architectural problem whose solutions tend to be religious or sub-cultural rather than civic. + +## Open Questions + +1. Is there a way to design a single public space that provides both legible-protection and anonymous-refuge simultaneously, or are these necessarily distinct typologies? +2. What does the disability-legibility literature have to say about *deliberately* opaque public space — and is the apparent silence here because the demand simply doesn't exist for users who need to plan three minutes ahead? +3. The Foucauldian framing and the disability-comfort framing both treat the user-as-observed-body, but the first finds discipline there and the second finds care. Are these the same finding under different signs, or genuinely separate phenomena? +4. If "watchfulness as care" and "watchfulness as control" are produced by the same act, can they be distinguished at the design stage, or only retroactively in the experience of specific users? + +## Sources + +### From the Vault +- [[essays/urban-affect]] — earlier framing of "felt safety" as distinct from measured crime. +- [[notes/eyes-on-the-street]] — Jacobs primary source notes; provides the Jacobsian wager. +- [[notes/disability-mobility-reading]] — references for the infrastructural-comfort framing. + +### From the Web +- "Eyes on the Street and Other Observers"[^1] — *Journal of Urban Design*, 2019. +- *Discipline and Punish*[^2] — Foucault's panopticon framing, ch. 3. +- "Demographic Match and Perceived Safety in Public Space"[^3] — *Environment and Behavior*, 2021. + +[^1]: Illustrative reference. Real recons cite real URLs. +[^2]: Same. +[^3]: Same. diff --git a/examples/focus-example.md b/examples/focus-example.md new file mode 100644 index 0000000..8ed63c3 --- /dev/null +++ b/examples/focus-example.md @@ -0,0 +1,65 @@ +--- +created: 2026-04-18 +type: recon +topic: "the case for civic infrastructure aesthetics in 2026" +mode: autonomous +intention: focus +source_notes: + - "essays/network-culture.md" +--- + +# The case for civic infrastructure aesthetics in 2026 + +> [!note]- Process Log +> **Mode**: autonomous · **Rounds**: 2 · **Date**: 2026-04-18 +> **Tokens**: ~180k total · R1: ~85k · R2: ~95k +> **Elapsed**: 11m wall clock · R1: 5m · R2: 6m +> +> R1: explored multiple framings (aesthetic, political, technical). R2: Synthesizer collapsed to the strongest argumentative spine: civic infrastructure aesthetics functions as a proxy fight over civic optimism itself. +> +> **Agent reports**: [[r1-explorer]] · [[r1-associator]] · [[r1-critic]] · [[r1-synthesizer]] · [[r2-explorer]] · [[r2-associator]] · [[r2-critic]] · [[r2-synthesizer]] + +> [!abstract] Central Question +> Why do contemporary fights over the visual character of civic infrastructure (bridges, transit hubs, water systems) generate so much more political heat than their material stakes seem to warrant? + +## The Argument + +The visible aesthetics of civic infrastructure are doing political work that the infrastructure's underlying engineering does not. When a city debates whether a new bridge should be unornamented and structural or expressive and symbolic, the debate is rarely about engineering preference. It is a debate about whether the city is willing to commit publicly to a future that will be there in fifty years. + +Unornamented engineering presents itself as neutral, technical, deferring questions of value. It says: we will build the structurally sufficient thing and leave aesthetic decisions to the next generation. That's the argument it makes to its own constituency, anyway. To the constituency on the other side, the same building looks like a refusal to commit — a hedge against a civic identity nobody has standing to claim, an aesthetic of permanent provisional. + +Expressive infrastructure makes the opposite move. It plants a flag: here is what we think this city is, here is what we're willing to defend in concrete and steel for the next half-century. That's the argument *it* makes to its constituency. To the other side, it reads as overreach — the imposition of a contestable aesthetic on an infrastructure decision that should have stayed engineering-only. + +Both sides are arguing about civic optimism without admitting it. The aesthetic question is the proxy. + +This explains the affective intensity of these debates. People who would barely look up from their phones for a structural decision will mobilize for hours to oppose or defend a finial. The finial is not the issue. The finial is the visible artifact of whether the city is going to *be a city* in any sense beyond the maintenance of its grid — and that question, once you ask it, is unbearable to leave open. + +The strongest civic infrastructure aesthetics in 2026 are the ones that acknowledge this proxy structure rather than pretend the question is just about taste. They commit publicly to a position on civic optimism, and accept that the commitment is contestable. The infrastructure that pretends to be aesthetics-neutral is making a stronger political claim, not a weaker one — it is claiming the city has no civic identity worth defending in concrete. + +## Runners-up (with reasons not pursued) + +- **Aesthetics as labor history.** A real argument exists about ornamental infrastructure as a record of skilled labor that the unornamented version erases. Pursued in interviews but didn't ladder up to the central thesis without becoming a different essay. +- **Aesthetics as climate-resilience signaling.** Some recent commentary frames expressive infrastructure as a hedge against climate fatalism — building things that imply they should still exist in 2080. Strong but partially redundant with the civic-optimism thesis. +- **Aesthetics as gentrification machinery.** A skeptical reading: expressive infrastructure pre-prices neighborhoods. Real, but a different argument about a different question — kept aside for a separate recon. + +## Tensions + +### Civic optimism vs. democratic deference + +The argument above commits to civic optimism as a goal. The democratic-deference position counters that the unornamented option is more legitimate precisely *because* it doesn't commit on behalf of future residents. This tension does not resolve. The expressive choice asserts a positive civic identity at the cost of binding the future; the unornamented choice avoids the binding at the cost of asserting nothing. Either move is a substantive political claim, and that's the unbearable part. + +## Next Steps + +1. Identify three concrete cases (one bridge, one transit hub, one water-system facility, all decided in the last 24 months) where the aesthetic debate was clearly proxy for a civic-optimism debate. Use them as the spine of an essay. +2. Test the "claiming the city has no civic identity" reading against the case for unornamented engineering on its own terms — read three serious defenses to make sure the steelman survives. +3. Decide whether the gentrification-machinery counter belongs in the essay or in a follow-up. + +## Sources + +### From the Vault +- [[essays/network-culture]] — adjacent argument about how digital aesthetics displaced civic ones. + +### From the Web +- Three contemporary commentary pieces on civic infrastructure debates.[^1] + +[^1]: Illustrative — real recons cite real URLs. diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..523ba80 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,70 @@ +# tests/ + +Structural validation for `deep-recon` output documents. Catches prompt-regressions that change *what shape* the output takes — missing sections, wrong section names, dropped callouts, broken framings count — without false-positives on prose that varies between runs. + +## Files + +- `check_recon_structure.py` — the validator. Asserts the structural contract. +- `test_validator_contract.py` — negative tests for the validator. Feeds it intentionally-malformed stubs (missing sections, wrong cardinality, plain-mode violations, etc.) and asserts the right errors are raised. Without this, a regression that loosens the validator could land silently. +- `run_smoke_tests.sh` — runs the validator against `examples/explore-example.md` and `examples/focus-example.md`, then runs the validator-contract tests. Use this as a self-test of the validator itself. +- `golden/` — reserved for full golden-master files captured from real recon runs (see "Adding goldens" below). + +## What the validator checks + +Per file, given a mode (`explore` or `focus`) and an optional `--plain` flag: + +1. **Frontmatter** — YAML delimiters present; required fields (`created`, `type`, `topic`, `mode`, `intention`) declared. +2. **Required sections** — `## The Territory`, `## Tensions`, `## Unexpected Connections`, `## Open Questions`, `## Sources` for Explore mode; `## The Argument`, `## Tensions`, `## Next Steps`, `## Sources` for Focus mode. +3. **Territory cardinality** (Explore only) — between 3 and 5 framings (`###` subheadings) under `## The Territory`. +4. **Callouts** — Process Log (`> [!note]- Process Log`) and Central Question (`> [!abstract] Central Question`) — unless `--plain` is set. +5. **Obsidian flavoring** — at least one `[[wikilink]]` — unless `--plain` is set. +6. **Plain-mode invariants** (when `--plain` is set) — no callouts, no wikilinks, and a top-level `## Central Question` section instead of the abstract callout. + +## Running locally + +```bash +# Validate a real recon you just produced +python3 tests/check_recon_structure.py recon/2026-04-15-my-topic.md --mode explore + +# Self-test against the examples/ +bash tests/run_smoke_tests.sh +``` + +Requires Python 3.10+. No third-party dependencies. + +## Running in CI + +Add to `.github/workflows/lint.yml` or a separate workflow: + +```yaml + smoke-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: bash tests/run_smoke_tests.sh +``` + +## What this is NOT + +- **It is not a content quality check.** A document with all the right sections in all the right places can still be generic, hedged, or off-voice. Read the prose. +- **It is not a vault-state assertion.** It can't tell whether the wikilinks resolve to real notes in your vault. +- **It is not a token-budget check.** Use the running cost in `_metrics.md` for that (see T2.2 budget guard, when implemented). + +## Adding goldens + +When you run a real recon and want to capture its structural skeleton as a regression baseline, save the output file under `tests/golden/` and add it to `run_smoke_tests.sh`: + +```bash +check "tests/golden/2026-04-15-network-culture.md" --mode explore +``` + +Goldens should be representative outputs from a deliberate, well-tuned run. Don't commit goldens from a first attempt at a new topic — those drift in unpredictable ways. + +The structural contract in `check_recon_structure.py` should evolve carefully: tightening it (more required sections, narrower cardinality bounds) catches more regressions but also rejects valid outputs from future variations. When in doubt, keep the contract minimal and rely on golden snapshots for finer assertions. + +## Regeneration + +If a SKILL.md or template change deliberately changes the output structure, update both the validator (`check_recon_structure.py`) and the example files (`examples/*.md`) in the same PR so smoke tests stay green. diff --git a/tests/check_recon_structure.py b/tests/check_recon_structure.py new file mode 100644 index 0000000..0a21971 --- /dev/null +++ b/tests/check_recon_structure.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Structural validator for deep-recon output documents. + +Asserts the contract that every recon document must satisfy: + - YAML frontmatter with required fields + - Required ## sections per mode + - Process Log callout + - Central Question callout + - Territory framings: 3-5 (Explore mode) + - Obsidian flavoring (wikilinks) unless --plain + +Use this as a fast post-hoc check on a real recon output. Catches +structural regressions from prompt edits without false-positives +on prose variation. + +Usage: + python3 check_recon_structure.py [--mode explore|focus] [--plain] + +Exit codes: + 0 OK + 1 one or more structural errors + 2 bad invocation +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + + +REQUIRED_FRONTMATTER_FIELDS = ["created", "type", "topic", "mode", "intention"] + +REQUIRED_SECTIONS = { + "explore": [ + "The Territory", + "Tensions", + "Unexpected Connections", + "Open Questions", + "Sources", + ], + "focus": [ + "The Argument", + "Tensions", + "Next Steps", + "Sources", + ], +} + + +def check_recon(text: str, mode: str, plain: bool) -> list[str]: + errors: list[str] = [] + + fm_match = re.match(r"^---\n(.+?)\n---\n", text, re.DOTALL) + if not fm_match: + errors.append("Missing YAML frontmatter delimiters (---)") + fm_text = "" + else: + fm_text = fm_match.group(1) + + for field in REQUIRED_FRONTMATTER_FIELDS: + if not re.search(rf"^{re.escape(field)}\s*:", fm_text, re.MULTILINE): + errors.append(f"Frontmatter missing field: {field}") + + for section in REQUIRED_SECTIONS[mode]: + if not re.search(rf"^## {re.escape(section)}\s*$", text, re.MULTILINE): + errors.append(f"Missing section: ## {section}") + + if mode == "explore": + territory = re.search( + r"^## The Territory\s*$(.+?)(?=^## )", + text, + re.MULTILINE | re.DOTALL, + ) + if territory: + subheadings = re.findall(r"^### ", territory.group(1), re.MULTILINE) + if not (3 <= len(subheadings) <= 5): + errors.append( + f"The Territory has {len(subheadings)} framings; expected 3-5" + ) + + if not plain: + if not re.search(r">\s*\[!note\][-\s]*Process Log", text): + errors.append("Missing Process Log callout (> [!note] ... Process Log)") + if not re.search(r">\s*\[!abstract\]\s*Central Question", text): + errors.append("Missing Central Question callout (> [!abstract] Central Question)") + if not re.search(r"\[\[[^\]]+\]\]", text): + errors.append( + "No wikilinks found ([[link]]); Obsidian-flavored output expected (use --plain for plain markdown)" + ) + else: + if re.search(r">\s*\[!", text): + errors.append("Found Obsidian callout (> [!...]) in --plain output") + if re.search(r"\[\[[^\]]+\]\]", text): + errors.append("Found wikilink ([[...]]) in --plain output") + if not re.search(r"^## Central Question\s*$", text, re.MULTILINE): + errors.append("Missing Central Question section (## Central Question) in --plain mode") + + return errors + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser( + description="Structural validator for deep-recon outputs." + ) + parser.add_argument("filepath", help="Path to a recon .md file") + parser.add_argument( + "--mode", + choices=["explore", "focus"], + default="explore", + help="Output mode the file should match (default: explore)", + ) + parser.add_argument( + "--plain", + action="store_true", + help="Validate plain-markdown output (no Obsidian flavoring)", + ) + args = parser.parse_args(argv[1:]) + + path = Path(args.filepath) + if not path.is_file(): + print(f"File not found: {path}", file=sys.stderr) + return 2 + + text = path.read_text(encoding="utf-8") + errors = check_recon(text, args.mode, args.plain) + + label = f"{args.mode}{' --plain' if args.plain else ''}" + if errors: + print(f"FAIL: {path} ({label})") + for e in errors: + print(f" - {e}") + return 1 + + print(f"OK: {path} ({label})") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/run_smoke_tests.sh b/tests/run_smoke_tests.sh new file mode 100644 index 0000000..fb171c2 --- /dev/null +++ b/tests/run_smoke_tests.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# +# Smoke-test the structural validator against the example recon outputs. +# Run from the repo root or from tests/. Exits non-zero if any check fails. + +set -euo pipefail + +# Resolve repo root regardless of where the script is invoked from +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/.." && pwd)" +cd "${repo_root}" + +failed=0 + +check() { + local file="$1" + shift + if ! python3 tests/check_recon_structure.py "${file}" "$@"; then + failed=1 + fi +} + +check "examples/explore-example.md" --mode explore +check "examples/focus-example.md" --mode focus + +# Validator contract tests — assert the validator catches malformed inputs, +# not just that the example files happen to pass. +if ! python3 tests/test_validator_contract.py; then + failed=1 +fi + +if [[ $failed -eq 1 ]]; then + echo + echo "Smoke tests failed. Either the example files no longer match the structural contract, or the validator's contract has regressed." + exit 1 +fi + +echo +echo "All smoke tests passed." diff --git a/tests/test_validator_contract.py b/tests/test_validator_contract.py new file mode 100644 index 0000000..6a963c5 --- /dev/null +++ b/tests/test_validator_contract.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +Negative tests for the structural validator. + +`check_recon_structure.py` enforces the recon-document contract. This file +asserts the contract is real — the validator must reject malformed inputs, +not just rubber-stamp the example files. Without these tests, a future edit +that loosens or breaks the validator could land silently and the smoke +suite would still pass against the existing examples. + +Run via `tests/run_smoke_tests.sh` (which exercises both files), or +directly with `python3 tests/test_validator_contract.py`. + +Exit codes: + 0 all assertions held + 1 one or more validator behaviors regressed +""" +from __future__ import annotations + +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +from check_recon_structure import check_recon # noqa: E402 + + +VALID_EXPLORE = """--- +created: 2026-04-24 +type: recon +topic: friction in interface design +mode: explore +intention: explore +source_notes: [] +--- + +# Friction + +> [!note]- Process Log +> Round 1, Round 2. + +> [!abstract] Central Question +> What is friction for? + +## The Territory + +### Mechanical friction + +Lorem [[Don-Norman]]. + +### Cognitive friction + +Ipsum [[slow-design]]. + +### Social friction + +Dolor [[UX-discomfort]]. + +## Tensions + +The pull between [[friction]] as obstacle and friction as scaffold. + +## Unexpected Connections + +Cross-domain echoes from [[slow-design]]. + +## Open Questions + +What does friction look like institutionally? + +## Sources + +- [[Don-Norman]] +- example.com[^1] + +[^1]: Footnote. +""" + +VALID_PLAIN = """--- +created: 2026-04-24 +type: recon +topic: friction in interface design +mode: explore +intention: explore +source_notes: [] +--- + +# Friction + +## Process Log + +Round 1, Round 2. + +## Central Question + +What is friction for? + +## The Territory + +### Mechanical friction + +Lorem (see Don Norman's notes). + +### Cognitive friction + +Ipsum (slow design tradition). + +### Social friction + +Dolor (UX discomfort literature). + +## Tensions + +The pull between friction as obstacle and friction as scaffold. + +## Unexpected Connections + +Cross-domain echoes from slow design. + +## Open Questions + +What does friction look like institutionally? + +## Sources + +- Don Norman, _The Design of Everyday Things_ +- example.com[^1] + +[^1]: Footnote. +""" + +VALID_FOCUS = """--- +created: 2026-04-24 +type: recon +topic: friction in interface design +mode: focus +intention: focus +source_notes: [] +--- + +# Friction + +> [!note]- Process Log +> Round 1. + +> [!abstract] Central Question +> Should we design for productive friction? + +## The Argument + +Friction is a feature, not a bug, when [[Don-Norman]]. + +## Tensions + +Productive vs. pathological [[friction]]. + +## Next Steps + +1. Pilot in [[slow-design]]. +2. Measure [[UX-discomfort]] outcomes. + +## Sources + +- [[Don-Norman]] +- example.com[^1] + +[^1]: Footnote. +""" + + +CASES: list[tuple[str, str, str, bool, list[str]]] = [ + # (name, document, mode, plain, expected_error_substrings) + # Empty list = expected to pass. + ("valid Explore (Obsidian)", VALID_EXPLORE, "explore", False, []), + ("valid Plain Explore", VALID_PLAIN, "explore", True, []), + ("valid Focus (Obsidian)", VALID_FOCUS, "focus", False, []), + ("missing The Territory", VALID_EXPLORE.replace("## The Territory", "## NotTerritory"), "explore", False, ["Missing section: ## The Territory"]), + ("two framings (under floor)", VALID_EXPLORE.replace("### Social friction\n\nDolor [[UX-discomfort]].\n\n", ""), "explore", False, ["The Territory has 2 framings"]), + ("six framings (over ceiling)", VALID_EXPLORE.replace("## Tensions", "### Fourth\n\nMore [[a]].\n\n### Fifth\n\nMore [[b]].\n\n### Sixth\n\nToo many [[c]].\n\n## Tensions"), "explore", False, ["The Territory has 6 framings"]), + ("missing frontmatter field", VALID_EXPLORE.replace("intention: explore\n", ""), "explore", False, ["Frontmatter missing field: intention"]), + ("plain mode with wikilinks", VALID_PLAIN.replace("see Don Norman's notes", "see [[Don-Norman]]"), "explore", True, ["Found wikilink"]), + ("plain mode with callouts", VALID_PLAIN.replace("## Process Log\n\nRound", "> [!note]- Process Log\n> Round"), "explore", True, ["Found Obsidian callout"]), + ("Obsidian mode no wikilinks", VALID_EXPLORE.replace("[[Don-Norman]]", "Don Norman").replace("[[slow-design]]", "slow design").replace("[[UX-discomfort]]", "UX discomfort").replace("[[friction]]", "friction"), "explore", False, ["No wikilinks found"]), +] + + +def run_case(name: str, text: str, mode: str, plain: bool, expected: list[str]) -> bool: + errors = check_recon(text, mode, plain) + expected_pass = not expected + if expected_pass: + ok = not errors + else: + ok = bool(errors) and all(any(sub in e for e in errors) for sub in expected) + + status = "OK " if ok else "FAIL" + print(f"{status} {name}") + if not ok: + print(f" expected: {expected or 'no errors'}") + print(f" got: {errors}") + return ok + + +def main() -> int: + print("Validator contract tests:") + print() + results = [run_case(*c) for c in CASES] + passed = sum(results) + total = len(results) + print() + print(f"{passed}/{total} validator-contract assertions passed.") + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main())