diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 19d8f82..ad2635d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "deepgrade-marketplace", - "description": "Developer toolkit for codebase auditing, AI-readiness scanning, structured planning, and operational readiness. Four plugins, one lockstep release. Works on any stack.", + "description": "Developer toolkit for codebase auditing, AI-readiness scanning, structured planning, and operational readiness. Three plugins, one lockstep release. Works on any stack.", "owner": { "name": "Kyle Whynot" }, @@ -57,24 +57,6 @@ "ref": "v8.0.0", "sha": "bc75c7861953b5481efaf6928086b69f966d6c67" } - }, - { - "name": "deepgrade-guard", - "description": "Always-on safety hooks: force-push and DB-deploy blocking, migration protection, change/test tracking, session summaries.", - "category": "development", - "tags": [ - "safety", - "hooks", - "git-guard", - "migration-guard" - ], - "source": { - "source": "git-subdir", - "url": "https://github.com/krwhynot/deepgrade.git", - "path": "plugins/deepgrade-guard", - "ref": "v8.0.0", - "sha": "bc75c7861953b5481efaf6928086b69f966d6c67" - } } ] } diff --git a/.github/workflows/suite.yml b/.github/workflows/suite.yml index 8f7f159..346a9ff 100644 --- a/.github/workflows/suite.yml +++ b/.github/workflows/suite.yml @@ -63,4 +63,4 @@ jobs: claude plugin validate "$p" --strict n=$((n + 1)) done - [ "$n" -eq 4 ] || { echo "validated $n plugin dirs, expected 4"; exit 1; } + [ "$n" -eq 3 ] || { echo "validated $n plugin dirs, expected 3"; exit 1; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 06a1e20..771961d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## Unreleased (next: 9.0.0) + +### BREAKING + +- **`deepgrade-guard` is retired.** The always-on safety plugin (force-push and + hard-reset guard, migration guard, DB deploy guard, change and test trackers, + session summary) is removed from the marketplace and the tree. Claude Code + permission rules cover every blocking behavior with no runtime dependency and + no second enforcement layer silently overriding a project's own `ask` rules; + the recommended `settings.json` baseline is in METHODOLOGY.md §6. Installed + copies keep working from the plugin cache but receive no further updates. + With it go `tests/run-hook-corpus.js`, `tests/fixtures/hook-corpus.json`, the + guard rows of `tests/layer2-ledger-rows.js`, and the `$TMPDIR/dg-*` session + marker bus. Three plugins remain in lockstep. + +### Added + +- **Document skeletons in every documentation template.** `adr`, `brd`, `prd`, + and `readme` templates now carry a fill-in document body; the PRD template + previously pointed at a "standard template" that did not exist. The PRD + skeleton has P0/P1/P2 requirements with Given/When/Then acceptance criteria + and a leading/lagging success-metrics table. +- **`/deepgrade:documentation runbook`.** New `runbook-template.md`: prerequisites, + exact steps each with expected result and failure action, verification, + troubleshooting table, rollback trigger and steps, escalation, run history. + Plan-linked runbooks land in the plan folder and are referenced from + review.md; Stage 4 already gated on a reviewed runbook without defining one. +- **Spec requirements carry priority and acceptance criteria.** `templates/spec.md` + requirements are P0/P1/P2, trace to a line of intent.md, and have + Given/When/Then criteria including a negative case. New `## Success metrics` + section with numeric targets, windows, and measurement method. +- **Release checklist with numeric rollback triggers.** `templates/review.md` has + pre-deploy, deploy, post-deploy, and rollback sections; triggers are + thresholds over a window, and Stage 6 uses them to classify severity. +- **Incident status updates and blameless postmortems.** The troubleshoot + pre-flow emits a status update on a fixed cadence for SEV1/SEV2; Step 5 + writes a postmortem beside the log whose action items feed the proposed + intent. + +### Changed + +- `GUIDE.md` and `README.md` for the planning plugin document the design gate + (canary classes, evidence-validator flags, the pass expression), the + six-stage artifact chain, the two gate tools as distinct from the hooks, and + every output location. + ## 8.0.0 (2026-09-02) ### BREAKING diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e688a7c..70f0d27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,15 +14,14 @@ ## Repository Structure -This is a monorepo of four plugins with **lockstep versions** — every manifest +This is a monorepo of three plugins with **lockstep versions** — every manifest carries the same version and `.github/release.sh` bumps them together. ``` -.claude-plugin/marketplace.json # Four catalog entries, one shared ref+SHA pin -plugins/deepgrade/ # Planning core (9 commands, 2 agents, 3 skills, 3 hooks) +.claude-plugin/marketplace.json # Three catalog entries, one shared ref+SHA pin +plugins/deepgrade/ # Planning core (6 commands, 2 agents, 6 skills, 3 hooks) plugins/deepgrade-readiness/ # Readiness scanners (2 commands, 10 agents, 1 skill) plugins/deepgrade-audit/ # Audit team (5 commands, 10 agents, 3 skills) -plugins/deepgrade-guard/ # Safety hooks only (5 handlers, nothing else) tests/ # One suite for the whole monorepo ``` @@ -84,13 +83,12 @@ and a test asserts they stay that way. Edit both or neither. ## Modifying Hooks -Hooks are declared per plugin — `plugins/deepgrade-guard/hooks/hooks.json` for -the safety rails (PreToolUse, PostToolUse, Stop) and -`plugins/deepgrade/hooks/hooks.json` for the plan-context handlers -(SessionStart, SubagentStop, PreCompact) — and implemented as one Node script -per handler under that plugin's `scripts/`. Requires Node.js 18+. The complete -TMPDIR marker bus (writers and readers) ships inside deepgrade-guard; do not -split it. +Hooks are declared in `plugins/deepgrade/hooks/hooks.json` — the plan-context +handlers (SessionStart, SubagentStop, PreCompact) — and implemented as one Node +script per handler under that plugin's `scripts/`. Requires Node.js 18+. The +safety rails (PreToolUse, PostToolUse, Stop) and their TMPDIR marker bus shipped +in `deepgrade-guard` until 9.0.0 and are retired; do not reintroduce a blocking +hook without the fail-closed rules below and a corpus of falsifying cases. **Never add a `hooks` key back to `.claude-plugin/plugin.json`.** With both a `hooks/` folder and a manifest `hooks` key present, Claude Code silently ignores @@ -106,8 +104,8 @@ Each handler: - denies with exit 2, asks with `permissionDecision: "ask"` at exit 0 When editing hooks: -- add the case to `tests/fixtures/hook-corpus.json` first; it is the acceptance - authority, and a change that fails a row fails regardless of how it is written +- add a falsifying case to `tests/layer2-ledger-rows.js` first; a change that + fails a row fails regardless of how it is written - security guards must never fail open; informational hooks must never fail closed - Stop hooks must use exit 0 (never exit 2, causes an infinite loop) - every file in a plugin's `scripts/` must be referenced by that plugin's @@ -116,8 +114,8 @@ When editing hooks: ## Versioning -Versions are **lockstep across all four plugins**: one release bumps every -manifest, the four catalog entries stay on a single tag+SHA, and +Versions are **lockstep across all three plugins**: one release bumps every +manifest, the three catalog entries stay on a single tag+SHA, and `.github/release.sh` is the only supported way to cut a release. Follow semantic versioning (MAJOR.MINOR.PATCH): - PATCH: Bug fixes, hook improvements @@ -138,4 +136,4 @@ Before submitting a PR: directory plus the repo-wide sweeps 2. `claude plugin validate . --strict` and `claude plugin validate plugins/ --strict` (schema only — it never reads agent, command, or skill frontmatter) -3. For hook changes, add the case to `tests/fixtures/hook-corpus.json` FIRST +3. For hook changes, add the falsifying case to `tests/layer2-ledger-rows.js` FIRST diff --git a/METHODOLOGY.md b/METHODOLOGY.md index ae2b832..5ddcba6 100644 --- a/METHODOLOGY.md +++ b/METHODOLOGY.md @@ -151,8 +151,8 @@ Category 3 produces four deliverables: │ 3A. GUARDRAIL COVERAGE │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ Are automated safety nets installed? │ │ - │ │ Pre-commit hooks, CI gates, force-push guards, │ │ - │ │ migration guards, database deploy guards │ │ + │ │ Pre-commit hooks, CI gates, permission rules for │ │ + │ │ force push, migration edits, database deploys │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ 3B. CONTEXT CURRENCY │ @@ -815,7 +815,7 @@ DeepGrade implements safety in three concentric layers. Each layer operates inde ```text ┌─────────────────────────────────────────────────────────────────────┐ │ │ - │ LAYER 1: PLUGIN HOOKS (automatic, deterministic) │ + │ LAYER 1: PERMISSION RULES (automatic, deterministic) │ │ ┌───────────────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ LAYER 2: CI/CD PIPELINE (automatic, environment-gated) │ │ @@ -835,18 +835,52 @@ DeepGrade implements safety in three concentric layers. Each layer operates inde │ │ │ │ │ └───────────────────────────────────────────────────────────────┘ │ │ │ - │ Force push guard Migration guard DB deploy guard │ - │ Hard reset guard Change tracker Test/build tracker │ - │ Session summary │ + │ settings.json deny: force push, direct DB deploy │ + │ settings.json ask: hard reset, migration edits, git push │ + │ (the deepgrade-guard hook plugin that filled this layer │ + │ from 5.0.0 to 8.x was retired in 9.0.0; history below) │ │ │ └─────────────────────────────────────────────────────────────────────┘ ``` -The beauty of this arrangement is redundancy. An AI agent that somehow bypasses the plugin hooks (Layer 1) still hits the CI pipeline (Layer 2). A change that clears CI still goes through human review in the plan workflow (Layer 3). No single failure is catastrophic. +The beauty of this arrangement is redundancy. An AI agent that somehow bypasses the permission rules (Layer 1) still hits the CI pipeline (Layer 2). A change that clears CI still goes through human review in the plan workflow (Layer 3). No single failure is catastrophic. + +### Layer 1: Permission Rules + +Layer 1 is whatever stops a dangerous tool call before it runs, with no human attention and no judgment call by the model. Since 9.0.0 DeepGrade fills this layer with Claude Code's own permission rules rather than with a plugin. A `deny` entry refuses the command outright; an `ask` entry turns it into a confirmation prompt. They live in the project's or the user's `settings.json`, need no runtime, and cannot disagree with a project's own choices the way a second enforcement layer can. + +The recommended baseline, which a project adjusts to its stack: + +```json +{ + "permissions": { + "deny": [ + "Bash(git push --force*)", + "Bash(git push -f *)", + "Bash(supabase db push*)", + "Bash(prisma migrate deploy*)", + "Bash(dotnet ef database update*)", + "Bash(flyway migrate*)", + "Bash(rails db:migrate*)" + ], + "ask": [ + "Bash(git push*)", + "Bash(git reset --hard*)", + "Edit(supabase/migrations/**)", + "Edit(prisma/migrations/**)", + "Edit(db/migrate/**)" + ] + } +} +``` + +`--force-with-lease` is not matched by the deny rules above and stays allowed, which is the correct behavior: it is the safe form. `--dry-run` and `--local` variants of the deploy commands need their own `allow` entries if a project uses them, because a deny rule wins over a wildcard allow. + +**Why the plugin was retired.** From 5.0.0 through 8.x this layer shipped as `deepgrade-guard`: five Node hooks (force-push and hard-reset guard, migration guard, DB deploy guard, change and test trackers, session summary). The guards worked and were tested to a corpus of falsifying cases, but three things argued against keeping them. Permission rules had reached parity for every blocking behavior, with zero runtime dependency and no hook-error failure mode. Projects that already carried their own `ask` rules for the same paths ended up with two layers disagreeing, and the block always won silently over the project's deliberate choice. And the trackers, the part permission rules cannot replace, were nudges that the planning plugin's own stage gates and the audit plugin's staleness checks already cover. The historical description follows for the record; the fail-closed principle it established still governs any blocking hook that is ever added back. -### Layer 1: Plugin Hooks +#### Historical: the deepgrade-guard hooks (5.0.0 to 8.x) -Plugin hooks are the first line of defense. They fire automatically at specific points in the Claude Code lifecycle and require zero human attention. They are declared in [`hooks/hooks.json`](hooks/hooks.json) and execute as Node scripts under [`scripts/`](scripts/), one file per handler. +The hooks were declared in the plugin's `hooks/hooks.json` and executed as Node scripts under its `scripts/`, one file per handler. ```mermaid graph TD @@ -882,7 +916,7 @@ graph TD style SUMMARY fill:#9B59B6,stroke:#8E44AD,color:#fff ``` -Seven hooks run as part of Layer 1: +Seven hooks ran as part of Layer 1: **Force push guard** ([`scripts/dg-git-guard.js`](scripts/dg-git-guard.js), PreToolUse:Bash). Blocks `git push --force` and the bare `-f` form. Force pushes rewrite shared history and can destroy other people's work. Use `--force-with-lease` if you truly need it — the guard genuinely does not block that, which was untrue before v5.0.0: the old pattern matched `--force` inside `--force-with-lease`, so the safe form was denied and the short form was not. @@ -900,7 +934,9 @@ Seven hooks run as part of Layer 1: ### The Fail-Closed Principle -Guards (migration, force push, hard reset, DB deploy) are fail-closed: if the hook cannot parse its input, it blocks the action. It is better to incorrectly block a safe action than to incorrectly allow a dangerous one. +This principle outlived the plugin that first embodied it. Permission rules are fail-closed by construction: a `deny` needs no parser, so there is no malformed input that could let a command through. Any blocking hook added in future is held to the same standard. + +Guards (migration, force push, hard reset, DB deploy) were fail-closed: if the hook cannot parse its input, it blocks the action. It is better to incorrectly block a safe action than to incorrectly allow a dangerous one. Trackers (change counter, test/build tracker) are fail-open: if the hook cannot parse its input, it silently does nothing. Missing a count is harmless. Blocking legitimate work because a counter failed is not. @@ -944,7 +980,7 @@ Layer 3 is where a human stays in the loop. The [`/deepgrade:plan`](commands/pla ### The Single-Dependency Principle -As of v5.0.0 the hooks declare exactly one dependency: **Node.js 18 or later**, which Claude Code itself already requires. There is no `jq`, no POSIX utility chain, and no fallback ladder. +The three plan-context hooks that remain in `deepgrade`, and the two design-gate tools beside them, declare exactly one dependency: **Node.js 18 or later**, which Claude Code itself already requires. There is no `jq`, no POSIX utility chain, and no fallback ladder. That is a deliberate reversal of the earlier zero-dependency design, and it is worth being precise about why. The old hooks avoided dependencies by parsing JSON with `grep` and `sed`. That is not a parser, and the difference is not academic — it produced a guard that could not distinguish a command from text mentioning one, so it blocked a read-only `grep` whose search pattern named a deployment, and blocked commit messages that merely referred to a force push. Availability was traded for correctness in a security control, which is the wrong trade. @@ -984,7 +1020,7 @@ Since v5.0.0 every hook is a Node script launched in exec form (`node ${CLAUDE_P ### Security Guards Must Never Fail-Open -This principle deserves its own heading because it is the one design decision that cannot be compromised. The migration guard, force push guard, hard reset guard, and DB deploy guard all use exit code 2 (block) as their default path. If parsing fails, if the input is garbled, if the session ID is missing, the guard blocks. +This principle deserves its own heading because it is the one design decision that cannot be compromised. The retired migration guard, force push guard, hard reset guard, and DB deploy guard all used exit code 2 (block) as their default path, and the permission rules that replaced them cannot fail open at all. If parsing fails, if the input is garbled, if the session ID is missing, the guard blocks. [Shaharia Azam: AI Integration Framework](https://shaharia.com/blog/ai-integration-framework/) calls this the "zero-trust mindset": treat every AI contribution as if it came from a brand-new junior developer. You would not give a junior developer unsupervised force-push access. You should not give it to an AI agent either. @@ -1403,7 +1439,7 @@ DeepGrade checks for three layers of guardrails, generated by the [gate-generato | Layer | What It Does | Implementation | | :------ | :------------- | :--------------- | -| **Plugin hooks** | Guard against dangerous operations | Force push guard, migration guard, DB deploy guard | +| **Permission rules** | Refuse or confirm dangerous operations | `deny`/`ask` entries in `settings.json` for force push, migration edits, DB deploys | | **CI quality gates** | Check every PR automatically | PR risk scoring, audit staleness check | | **Pre-commit hooks** | Catch issues before commit | Risk zone checker for HIGH-risk modules | @@ -1706,6 +1742,8 @@ This conditional escalation avoids the overhead of multi-agent mode for simple b ## 11. The Dependency Decision (reversed in 5.0.0) +> Most of the handlers this section discusses shipped in `deepgrade-guard`, retired in 9.0.0. The three plan-context handlers still in `deepgrade` follow the same rules, and the rules are kept here because they are the reason a blocking hook is not a small thing to add. + ### Why Dependencies Are the Enemy Here is a fun rule of thumb: the number of machines where your safety hooks will fail silently is directly proportional to the number of dependencies those hooks require. DeepGrade learned this the hard way, across four painful versions. @@ -1817,7 +1855,7 @@ These emerged from the failures above, and from the six defects an adversarial r ### How Each Hook Implements the Pattern Every handler follows the same structural template: read stdin, `JSON.parse`, read one -named field, decide. Here is how it maps across all **eight** handlers. The columns that +named field, decide. Here is how it mapped across all **eight** handlers as of 8.x; the five marked retired left with `deepgrade-guard` in 9.0.0. The columns that used to appear here — "jq Path" and "grep+sed Fallback" — are gone with the ladder they described; the deep links formerly pointed at line numbers inside `plugin.json`, which no longer contains hooks at all. @@ -1825,11 +1863,11 @@ longer contains hooks at all. | Handler | Event | Matcher | Field it reads | Decision it can return | | :------ | :---- | :------ | :------------- | :--------------------- | | [dg-session-start.js](https://github.com/krwhynot/deepgrade/blob/main/scripts/dg-session-start.js) | SessionStart | (none) | `source` | JSON `systemMessage` only | -| [dg-migration-guard.js](https://github.com/krwhynot/deepgrade/blob/main/scripts/dg-migration-guard.js) | PreToolUse | `Write\|Edit` | `tool_input.file_path` | deny (exit 2) / allow | -| [dg-git-guard.js](https://github.com/krwhynot/deepgrade/blob/main/scripts/dg-git-guard.js) | PreToolUse | `Bash` | `tool_input.command` | deny / **ask** / allow | -| [dg-track-change.js](https://github.com/krwhynot/deepgrade/blob/main/scripts/dg-track-change.js) | PostToolUse | `Write\|Edit` | `tool_input.file_path`, `session_id` | JSON `systemMessage` only | -| [dg-track-test.js](https://github.com/krwhynot/deepgrade/blob/main/scripts/dg-track-test.js) | PostToolUse | `Bash` | `tool_input.command`, `session_id` | nothing (writes markers) | -| [dg-session-stop.js](https://github.com/krwhynot/deepgrade/blob/main/scripts/dg-session-stop.js) | Stop | (none) | `session_id` | JSON `systemMessage` only | +| dg-migration-guard.js (retired 9.0.0) | PreToolUse | `Write\|Edit` | `tool_input.file_path` | deny (exit 2) / allow | +| dg-git-guard.js (retired 9.0.0) | PreToolUse | `Bash` | `tool_input.command` | deny / **ask** / allow | +| dg-track-change.js (retired 9.0.0) | PostToolUse | `Write\|Edit` | `tool_input.file_path`, `session_id` | JSON `systemMessage` only | +| dg-track-test.js (retired 9.0.0) | PostToolUse | `Bash` | `tool_input.command`, `session_id` | nothing (writes markers) | +| dg-session-stop.js (retired 9.0.0) | Stop | (none) | `session_id` | JSON `systemMessage` only | | [dg-subagent-stop.js](https://github.com/krwhynot/deepgrade/blob/main/scripts/dg-subagent-stop.js) | SubagentStop | (none) | `reason` | nothing (appends to a log) | | [dg-pre-compact.js](https://github.com/krwhynot/deepgrade/blob/main/scripts/dg-pre-compact.js) | PreCompact | (none) | (none) | JSON `systemMessage` only | @@ -1861,7 +1899,7 @@ Three failures, all of them silent: 2. **It truncates at the first quote.** `[^"]*` stops at any escaped quote inside the command, so what gets matched is a prefix of what will actually run. 3. **It cannot tell a command from text.** Nothing distinguishes an instruction from a quoted mention of one, which over-blocks (a commit message naming a force push) and under-blocks (an exemption token appearing outside the command field suppressing a real denial). -The replacement parses the payload with `JSON.parse`, reads the named field only, and splits the command into shell words so quoted text contributes data rather than structure. See [`scripts/dg-git-guard.js`](scripts/dg-git-guard.js) and the acceptance corpus at [`tests/fixtures/hook-corpus.json`](tests/fixtures/hook-corpus.json), which encodes each of these three failures as a test. +The replacement parsed the payload with `JSON.parse`, read the named field only, and split the command into shell words so quoted text contributed data rather than structure. That handler (`dg-git-guard.js`) and its acceptance corpus (`tests/fixtures/hook-corpus.json`, which encoded each of these three failures as a test) were retired with `deepgrade-guard` in 9.0.0; the git history at tag `v8.0.0` holds both. The `head -1` in the grep path handles the case where `"command"` appears multiple times in the JSON (it can, in nested structures). We always take the first match, which corresponds to the top-level field. diff --git a/README.md b/README.md index 31716bd..747f15a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ DeepGrade gives your codebase a letter grade. AI-powered codebase auditing, planning, operational readiness assessment, and documentation generation for Claude Code. Stack-agnostic. Works on any codebase. -This repository is a monorepo of **four plugins with lockstep versions** — one +This repository is a monorepo of **three plugins with lockstep versions** — one marketplace, one release, install any subset: | Plugin | What it does | Who installs it | @@ -12,7 +12,6 @@ marketplace, one release, install any subset: | [`deepgrade`](plugins/deepgrade/) | Six-stage AI-Native SDLC planning (intent, spec, plan, test, release, maintain) with an adversarial verifier-first design gate, plan-linked troubleshooting, documentation generation | Developers living in `docs/plans/` daily | | [`deepgrade-readiness`](plugins/deepgrade-readiness/) | AI-readiness scan: 52 checks, 9 categories, composite letter grade A+ to F, generated scaffolding | Consultants and leads grading many repos | | [`deepgrade-audit`](plugins/deepgrade-audit/) | Severity-graded codebase audits, security scans, delta/KPI tracking, characterization tests, generated CI gates | Engineering managers doing due diligence | -| [`deepgrade-guard`](plugins/deepgrade-guard/) | Always-on safety hooks: force-push and DB-deploy blocking, migration protection, change/test tracking, session summaries | Everyone — recommended universal co-install | ## What It Does @@ -24,7 +23,7 @@ DeepGrade asks three questions about your codebase: | 2. Phased Delivery Over Big-Bang Releases | What shape is it in? | Present | | 3. Operational Readiness | Can we safely change it? | Future | -The methodology behind all four plugins lives in [METHODOLOGY.md](METHODOLOGY.md). +The methodology behind all three plugins lives in [METHODOLOGY.md](METHODOLOGY.md). ## Install @@ -36,14 +35,12 @@ The methodology behind all four plugins lives in [METHODOLOGY.md](METHODOLOGY.md claude plugin marketplace add krwhynot/deepgrade ``` -**Step 2:** Install the plugins you want (any subset works; `deepgrade-guard` -is recommended alongside whichever you pick): +**Step 2:** Install the plugins you want (any subset works): ```bash claude plugin install deepgrade@deepgrade-marketplace --scope user claude plugin install deepgrade-readiness@deepgrade-marketplace --scope user claude plugin install deepgrade-audit@deepgrade-marketplace --scope user -claude plugin install deepgrade-guard@deepgrade-marketplace --scope user ``` **Step 3:** Start Claude Code in any project and verify: @@ -66,26 +63,31 @@ including the commands that belong to the sibling plugins. ## Repository Layout ``` -.claude-plugin/marketplace.json # the four catalog entries, one shared ref+SHA pin +.claude-plugin/marketplace.json # the three catalog entries, one shared ref+SHA pin plugins/deepgrade/ # planning core (commands, agents, skills, hooks) plugins/deepgrade-readiness/ # readiness scanners plugins/deepgrade-audit/ # audit agents -plugins/deepgrade-guard/ # safety hooks only tests/ # one suite for the whole monorepo (run-all.sh) docs/ # dev-time records: plans, specs, release notes METHODOLOGY.md # the methodology reference (not shipped by any plugin) ``` Every plugin manifest carries the same version, bumped together by -`.github/release.sh` — the four catalog entries always pin one ref and one SHA. +`.github/release.sh` — the three catalog entries always pin one ref and one SHA. ## Dependencies -The `deepgrade` and `deepgrade-guard` plugins run their hooks as Node scripts -and require [Node.js](https://nodejs.org/) 18 or later — the same runtime -Claude Code itself needs, so if Claude Code runs, they do too. +The `deepgrade` plugin runs its hooks and design-gate tools as Node scripts +and requires [Node.js](https://nodejs.org/) 18 or later — the same runtime +Claude Code itself needs, so if Claude Code runs, it does too. `deepgrade-readiness` and `deepgrade-audit` need nothing beyond Claude Code. +The always-on safety hooks that used to ship as `deepgrade-guard` (force-push +and DB-deploy blocking, migration protection, change/test tracking) were retired +in 9.0.0. Claude Code's own permission rules cover the same commands with no +runtime dependency; see [METHODOLOGY.md §6](METHODOLOGY.md#6-defense-in-depth-safety) +for the recommended `settings.json` rules. + ## Version History See [CHANGELOG.md](CHANGELOG.md) for full history. diff --git a/interop.md b/interop.md index b5adedc..760a041 100644 --- a/interop.md +++ b/interop.md @@ -1,6 +1,6 @@ # Cross-Plugin Interop Contracts -The four plugins exchange artifacts through the repository they are installed +The three plugins exchange artifacts through the repository they are installed into. SPLIT-3 proves that namespaced *references* resolve; this file is the contract for the *artifacts* those plugins hand each other — who writes a file, who reads it, and which fields are load-bearing. The INTEROP section of @@ -74,10 +74,10 @@ field is removed from the templates; module-level confidence is untouched. ## Deliberate non-edges -- **Session markers (`$TMPDIR/dg-*`) are deepgrade-guard-internal.** The - complete marker bus — writers and readers — ships in that one plugin. - Layer 1's per-plugin core fails any other plugin that grows a marker - surface. +- **Session markers (`$TMPDIR/dg-*`) no longer exist.** The marker bus shipped + inside deepgrade-guard and was retired with it in 9.0.0. Layer 1's per-plugin + core fails any plugin that grows a marker surface, so the bus cannot come + back by accident. - **Plan folders (`docs/plans/{date}-{name}/`) are deepgrade-internal.** Written by the planning commands and deepgrade's own hooks (dg-subagent-stop appends `subagent-log.txt` there); no other plugin reads diff --git a/plugins/deepgrade-audit/GUIDE.md b/plugins/deepgrade-audit/GUIDE.md index a211a27..663bd17 100644 --- a/plugins/deepgrade-audit/GUIDE.md +++ b/plugins/deepgrade-audit/GUIDE.md @@ -18,7 +18,7 @@ catalogs documentation, assesses per-module risk, and flags integration touchpoints; a report generator turns their findings into a severity-classified report any engineer can act on. Security runs as a separate control loop, and delta scans re-measure against previous baselines so improvement is visible. -It is one of four DeepGrade plugins; run `deepgrade-readiness` first for a +It is one of three DeepGrade plugins; run `deepgrade-readiness` first for a navigability baseline. ## Commands diff --git a/plugins/deepgrade-audit/README.md b/plugins/deepgrade-audit/README.md index b9efd04..e5212d9 100644 --- a/plugins/deepgrade-audit/README.md +++ b/plugins/deepgrade-audit/README.md @@ -5,10 +5,10 @@ produces a severity-graded audit report, a security-focused scan, delta/KPI tracking against previous baselines, golden-master characterization tests, and generated CI quality gates. Stack-agnostic. -This is one of four DeepGrade plugins in the +This is one of three DeepGrade plugins in the [deepgrade monorepo](https://github.com/krwhynot/deepgrade). It pairs with -`deepgrade-readiness` (run that first for a baseline grade) and -`deepgrade-guard` (always-on safety rails). +`deepgrade-readiness` (run that first for a baseline grade) and `deepgrade` +(planning). ## Install diff --git a/plugins/deepgrade-guard/.claude-plugin/plugin.json b/plugins/deepgrade-guard/.claude-plugin/plugin.json deleted file mode 100644 index 2eaaf94..0000000 --- a/plugins/deepgrade-guard/.claude-plugin/plugin.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "deepgrade-guard", - "version": "8.0.0", - "description": "DeepGrade safety rails: always-on hooks that block force pushes and direct DB deploys, protect existing migrations, track changes and test runs, and summarize each session. Zero commands, zero context cost.", - "author": { - "name": "Kyle Whynot", - "url": "https://github.com/krwhynot" - }, - "license": "MIT", - "keywords": [ - "safety", - "hooks", - "git-guard", - "migration-guard", - "developer-toolkit" - ], - "repository": "https://github.com/krwhynot/deepgrade", - "homepage": "https://github.com/krwhynot/deepgrade#readme" -} diff --git a/plugins/deepgrade-guard/GUIDE.md b/plugins/deepgrade-guard/GUIDE.md deleted file mode 100644 index 6772550..0000000 --- a/plugins/deepgrade-guard/GUIDE.md +++ /dev/null @@ -1,136 +0,0 @@ -
- -# DeepGrade Guard Guide v8.0.0 - -**0 Commands**  •  **0 Skills**  •  **5 Safety Hooks**  •  **Requires Node.js 18+** - -[![Plugin](https://img.shields.io/badge/Claude_Code-Plugin-5A45FF?style=for-the-badge)](https://github.com/krwhynot/deepgrade) -[![Version](https://img.shields.io/badge/v8.0.0-stable-2ECC71?style=for-the-badge)](#) -[![Stack](https://img.shields.io/badge/Stack-Agnostic-F39C12?style=for-the-badge)](#) - -
- -> A reference for the deepgrade-guard plugin: always-on safety rails with zero context cost. - -DeepGrade Guard is the toolkit's immune system, shipped on its own so every -setup can have it — with or without the planning, readiness, or audit plugins. -Its hooks fire automatically at specific points in the Claude Code lifecycle to -prevent common mistakes: they intercept actions and either block them, ask for -confirmation, or silently record data. There are no commands to learn and no -agents or skills loading into context; the plugin declares 5 handlers in -`hooks/hooks.json`, one Node script each under `scripts/`, and **requires -Node.js 18 or later** — the same runtime Claude Code itself needs. Without -Node the hooks cannot start and Claude Code reports a hook error on each -guarded event: absent and loud beats present and wrong. - -The trackers and the session summary communicate through session markers in -`$TMPDIR/dg-*` (`%TEMP%` on Windows) — the complete marker bus ships in this -one plugin, so the Stop-time summary always sees what the trackers wrote. - -## The 5 Hooks - -### ![guard](https://img.shields.io/badge/-GUARD-E74C3C) Migration Guard -**Fires when:** You try to edit a file inside `migrations/` or `Migrations/` that already exists and ends in `.sql`. -**What it does:** Blocks the edit with exit code 2. -**Why:** Existing migrations are immutable history. If your database has already applied migration `003_add_users.sql`, editing it won't re-apply the changes -- it will just make your migration history inconsistent. The next time someone runs migrations from scratch, they get a different database than production. Create a new migration instead. - -> [!CAUTION] -> `[DeepGrade] MIGRATION GUARD: Editing existing migration migrations/003_add_users.sql. Create a NEW migration instead.` - -### ![guard](https://img.shields.io/badge/-GUARD-E74C3C) Force Push Guard -**Fires when:** You run any command matching `git push --force`. -**What it does:** Blocks the command. -**Why:** Force pushing rewrites remote history. If a teammate has already pulled commits you force-push over, their local branch diverges from remote in ways that are painful to recover from. Use `--force-with-lease` if you truly need to overwrite (it checks that the remote hasn't changed since you last fetched). - -> [!CAUTION] -> `[DeepGrade] BLOCKED: Force push not allowed.` - -### ![guard](https://img.shields.io/badge/-GUARD-E74C3C) Hard Reset Guard -**Fires when:** You run `git reset --hard`. -**What it does:** Asks for confirmation before the command runs. -**Why:** `git reset --hard` permanently discards all uncommitted changes in your working tree. There is no undo. If Claude has been editing files for 30 minutes and you hard-reset, all that work vanishes. The guard forces you to think twice. - -> [!WARNING] -> `[DeepGrade] WARNING: git reset --hard discards changes.` - -### ![guard](https://img.shields.io/badge/-GUARD-E74C3C) Database Deploy Guard -**Fires when:** You run a database migration deploy command (`supabase db push`, `prisma migrate deploy`, `dotnet ef database update`, `flyway migrate`, or `rails db:migrate`) without a safe flag. -**What it does:** Blocks the command unless it includes `--dry-run`, `--local`, `RAILS_ENV=test`, or `RAILS_ENV=development`. -**Why:** Deploying migrations directly from your local machine to a production or shared database bypasses CI/CD safety nets. One wrong migration can corrupt data for every user. Deploy via your CI/CD pipeline instead. Use `--dry-run` to validate locally. - -> [!CAUTION] -> `[DeepGrade] BLOCKED: Direct database deploy to remote. Use --dry-run to validate, or deploy via CI/CD.` - -### ![tracker](https://img.shields.io/badge/-TRACK-4A90D9) Change Tracker -**Fires when:** Any file is written or edited (PostToolUse). -**What it does:** Silently increments a counter in `/tmp/dg-baseline-{session}`. If the count exceeds 15 (configurable via `DG_CHANGE_THRESHOLD`), it suggests running `/deepgrade-audit:codebase-delta`. -**Why:** Tracks how much the codebase has changed since the last audit baseline. When you've changed enough files, the audit data starts going stale and a delta check is worthwhile. - -> [!NOTE] -> Nothing visible until threshold, then: `[DeepGrade] 15 files changed since last audit. Consider /deepgrade-audit:codebase-delta.` - -### ![tracker](https://img.shields.io/badge/-TRACK-4A90D9) Test/Build Tracker -**Fires when:** Any bash command runs that looks like a test or build command (PostToolUse). -**What it does:** Silently writes timestamps to `/tmp/dg-test-{session}` and `/tmp/dg-build-{session}`. Recognizes test/build commands for Node (jest, vitest, npm test), Python (pytest), .NET (dotnet test), Rust (cargo test/build), and Go (go test/vet). -**Why:** The Stop hook and Git Guard use these timestamps to know whether tests and builds ran during the session. If you edited files but never ran tests, you get a warning. - -> [!NOTE] -> Completely silent. No output. - -### ![info](https://img.shields.io/badge/-INFO-2ECC71) Session Summary (Stop Hook) -**Fires when:** The Claude Code session ends. -**What it does:** Reports the total number of files changed. If tests exist in the project but none ran during the session, it warns you. -**Why:** A simple accountability checkpoint. "You changed 12 files but didn't run tests" is a useful nudge before you walk away. - -> [!TIP] -> `[DeepGrade] Session: 12 files changed.` or `[DeepGrade] 12 files changed but no tests ran. Run tests before finishing.` - -### ![info](https://img.shields.io/badge/-INFO-2ECC71) Plan Context (PreCompact Hook) -**Fires when:** Claude Code's context window is getting full and it needs to compress earlier messages. -**What it does:** Injects the active plan name and current phase into the compressed context so Claude doesn't lose track of what you're working on. -**Why:** Without this, Claude might forget which plan you were on after a compaction. The hook ensures continuity. - -> [!TIP] -> `[DeepGrade] Compacting. Plan: worldpay-canada. Resume with /deepgrade:plan worldpay-canada` - - ---- - -## How to Install - -```bash -claude plugin marketplace add krwhynot/deepgrade -claude plugin install deepgrade-guard@deepgrade-marketplace --scope user -``` - -User scope (recommended) makes the plugin available in every project; use -`--scope project` to limit it to one. Verify with: - -``` -/plugin details deepgrade-guard (must report Hooks (5)) -``` - -## How to Update - -An installed plugin lives in a **versioned cache directory**, and third-party marketplace auto-update -is **off by default**. Pulling the repository does not update an installed copy — you must refresh the -marketplace and update the plugin explicitly: - -``` -/plugin marketplace update deepgrade-marketplace -/plugin update deepgrade-guard -/reload-plugins -/plugin list -``` - -`/plugin list` is the verification step: confirm the version shown is the one you expect. **Without a -version bump in `plugin.json`, nothing propagates** — the version is the cache key. - -> [!IMPORTANT] -> **Editing plugin files does not affect an installed copy.** The live-edit workflow — where changes -> take effect on the next session with no reinstall — applies **only** when you run Claude Code with -> `--plugin-dir`, pointing directly at your working tree: -> ```bash -> claude --plugin-dir /path/to/deepgrade/plugins/deepgrade-guard -> ``` -> Use this for plugin development. For an installed plugin, use the four-command sequence above. diff --git a/plugins/deepgrade-guard/README.md b/plugins/deepgrade-guard/README.md deleted file mode 100644 index ec3880c..0000000 --- a/plugins/deepgrade-guard/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# DeepGrade Guard - -Always-on safety rails for Claude Code sessions: blocks force pushes and direct -database deploys, protects existing migrations, tracks file changes and test -runs, and summarizes every session. No commands, no agents, no skills — zero -context cost. Recommended as a universal co-install alongside any other -DeepGrade plugin, and useful entirely on its own. - -This is one of four DeepGrade plugins in the -[deepgrade monorepo](https://github.com/krwhynot/deepgrade). - -## Install - -**Prerequisite:** Claude Code installed ([claude.ai](https://claude.ai)) - -```bash -claude plugin marketplace add krwhynot/deepgrade -claude plugin install deepgrade-guard@deepgrade-marketplace --scope user -``` - -## Safety Hooks (5) - -The 5 hooks activate automatically on every session. They are declared in -`hooks/hooks.json` and run as Node scripts from `scripts/`, one file per -handler. **Requires Node.js 18 or later** — the same runtime Claude Code itself -needs. - -| Hook | Event | What It Does | -| ---- | ----- | ------------ | -| Migration Guard | PreToolUse Write/Edit | Blocks edits to existing migrations | -| Git + DB Guard | PreToolUse Bash | Blocks force push and direct DB deploys; asks before a hard reset | -| Change Tracker | PostToolUse Write/Edit | Counts file changes, nudges when an audit is stale | -| Test/Build Tracker | PostToolUse Bash | Records test and build runs | -| Session Summary | Stop | Reports file change count and warns if no tests ran | - -### Database Deploy Guard - -Blocks direct database migration deploys from the local machine. Supports: - -| Blocked Command | Stack | Safe Exception | -| --------------- | ----- | -------------- | -| `supabase db push` | Supabase | `--dry-run`, `--local` | -| `prisma migrate deploy` | Prisma | `--dry-run` | -| `dotnet ef database update` | .NET EF Core | - | -| `flyway migrate` | Flyway | - | -| `rails db:migrate` | Rails | `RAILS_ENV=test`, `development` | - -## Dependencies - -**Required:** [Node.js](https://nodejs.org/) 18 or later — the same runtime Claude -Code itself needs, so if Claude Code runs, this does too. - -```bash -node --version # must print v18.0.0 or higher -``` - -**What happens without Node.** The hooks cannot start, and Claude Code reports a -hook error on each guarded event. That is deliberate: the pre-5.0.0 design -degraded quietly to a weaker parser, so you could not tell a working safety -layer from a broken one. Absent and loud beats present and wrong. - -## File Output Locations - -| Output | Location | Committed? | -| ------ | -------- | ---------- | -| Session markers | `$TMPDIR/dg-*` (`%TEMP%` on Windows) | No (OS-managed) | - -The trackers, the git/DB guard, and the session summary communicate through the -session markers above — the complete marker bus ships in this one plugin, so the -Stop-time summary always sees what the trackers wrote. - -## Version History - -See the monorepo [CHANGELOG](https://github.com/krwhynot/deepgrade/blob/main/CHANGELOG.md). - -Current: v8.0.0 - -## License - -MIT. diff --git a/plugins/deepgrade-guard/hooks/hooks.json b/plugins/deepgrade-guard/hooks/hooks.json deleted file mode 100644 index f37020e..0000000 --- a/plugins/deepgrade-guard/hooks/hooks.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "node", - "args": [ - "${CLAUDE_PLUGIN_ROOT}/scripts/dg-migration-guard.js" - ], - "timeout": 5 - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node", - "args": [ - "${CLAUDE_PLUGIN_ROOT}/scripts/dg-git-guard.js" - ], - "timeout": 5 - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Write|Edit", - "hooks": [ - { - "type": "command", - "command": "node", - "args": [ - "${CLAUDE_PLUGIN_ROOT}/scripts/dg-track-change.js" - ], - "timeout": 5 - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node", - "args": [ - "${CLAUDE_PLUGIN_ROOT}/scripts/dg-track-test.js" - ], - "timeout": 5 - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node", - "args": [ - "${CLAUDE_PLUGIN_ROOT}/scripts/dg-session-stop.js" - ], - "timeout": 15 - } - ] - } - ] - } -} diff --git a/plugins/deepgrade-guard/scripts/dg-git-guard.js b/plugins/deepgrade-guard/scripts/dg-git-guard.js deleted file mode 100644 index c7ad347..0000000 --- a/plugins/deepgrade-guard/scripts/dg-git-guard.js +++ /dev/null @@ -1,324 +0,0 @@ -#!/usr/bin/env node -// DeepGrade: Git / deploy guard (PreToolUse: Bash) — lane N -// -// Contract: approach.md 3.1.6. Enforce ONLY what is parsed. -// - real parser present (this is node, so always): extract the NAMED field -// tool_input.command, enforce, and fail CLOSED on a payload JSON.parse rejects -// - never DENY on the basis of something unparsed. `ask` is not a deny, so a -// construct this cannot evaluate becomes a prompt rather than a silent allow -// - deny -> exit 2 + "BLOCKED" on stderr (stderr IS surfaced on exit 2) -// - ask -> exit 0 + JSON permissionDecision "ask" (F22) -// - allow -> exit 0, silent -// - never stderr on exit 0 (F26) -// -// Acceptance is tests/fixtures/hook-corpus.json. A matcher that fails any row fails -// PHV5-041 regardless of how it is written. -// -// REVISION HISTORY, because two of these were regressions I introduced: -// cf46aab deleted quoted spans -> `git push "--force"` was ALLOWED while the old -// bare-regex guard denied it. Fixed at bcf691c by word splitting. -// bcf691c word splitting, but matched command words by strict ADJACENCY, so any -// global option between them slipped through (`git -c x=y push --force`), -// and `\n` never ended a segment because /\s/ caught it first. -// this command-position matching that tolerates global options, `\n` as a real -// separator, `+refspec` force pushes, and ask-on-unparseable. -'use strict'; - -const DENY = 2, OK = 0; - -function deny(msg) { process.stderr.write(`[DeepGrade] BLOCKED: ${msg}\n`); process.exit(DENY); } -function ask(msg) { - process.stdout.write(JSON.stringify({ - hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: msg }, - }) + '\n'); - process.exit(OK); -} -function allow() { process.exit(OK); } - -// --------------------------------------------------------------------------- -// Shell-style word splitting. -// -// Quotes contribute their CONTENT and vanish as delimiters; adjacent segments join -// into one word. That is what a shell does, and it is why `git push "--force"` must -// still be caught while `git commit -m "no git push --force"` must not: in the -// second case the message is a single word, so the command words are not adjacent. -// -// Segments split on ; && || | ( ) and NEWLINE. The newline case is load-bearing and -// was previously dead code — `/\s/.test(c)` matched it first, so a multi-line command -// was one segment. That both laundered exemptions across commands and produced false -// denials (a benign push followed by `grep -f` looked like a force push). -// --------------------------------------------------------------------------- -function segments(cmd) { - const segs = [[]]; - let word = ''; - let started = false; // distinguishes an empty quoted word from no word at all - let i = 0; - const endWord = () => { if (started) { segs[segs.length - 1].push(word); word = ''; started = false; } }; - const endSeg = () => { endWord(); if (segs[segs.length - 1].length) segs.push([]); }; - - while (i < cmd.length) { - const c = cmd[i]; - if (c === '\\') { - // A backslash before a newline is a LINE CONTINUATION: it joins the lines and - // contributes nothing. Previously the newline was appended to the next word, - // producing "\n--force", which matched no flag and no sequence. - if (cmd[i + 1] === '\n') { i += 2; continue; } - if (cmd[i + 1] === '\r' && cmd[i + 2] === '\n') { i += 3; continue; } - if (i + 1 < cmd.length) { word += cmd[i + 1]; started = true; i += 2; } else i++; - continue; - } - if (c === "'") { - i++; - while (i < cmd.length && cmd[i] !== "'") { word += cmd[i]; i++; } - started = true; i++; - continue; - } - if (c === '"') { - i++; - while (i < cmd.length && cmd[i] !== '"') { - if (cmd[i] === '\\' && i + 1 < cmd.length) { word += cmd[i + 1]; i += 2; } - else { word += cmd[i]; i++; } - } - started = true; i++; - continue; - } - // NEWLINE FIRST. It is whitespace, so the generic test below would swallow it. - if (c === '\n' || c === '\r') { endSeg(); i++; continue; } - if (c === ';' || c === '(' || c === ')') { endSeg(); i++; continue; } - if (c === '&' || c === '|') { endSeg(); while (i < cmd.length && (cmd[i] === '&' || cmd[i] === '|')) i++; continue; } - if (/\s/.test(c)) { endWord(); i++; continue; } - word += c; started = true; i++; - } - endWord(); - return segs.filter((s) => s.length); -} - -// `/usr/bin/git`, `git.exe` and `git` are the same program. -function basename(w) { - return w.replace(/\\/g, '/').split('/').pop().replace(/\.(exe|cmd|bat|ps1)$/i, ''); -} - -const WRAPPERS = new Set(['sudo', 'env', 'command', 'nohup', 'time', 'timeout', 'npx', 'bunx', 'winpty', 'stdbuf']); - -// Match a command and its subcommand words at a COMMAND POSITION, tolerating leading -// environment assignments, wrappers, and global options with their values. -// -// Strict adjacency was the bug: `git -c core.pager=cat push --force`, -// `git --no-pager push --force`, `git -C . push --force`, -// `supabase --workdir . db push` and `dotnet ef --project X database update` all -// evaded every rule at once. -function hasCommandSeq(words, seq) { - let i = 0; - while (i < words.length) { - const w = words[i]; - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) { i++; continue; } // FOO=bar prefix - if (WRAPPERS.has(basename(w))) { i++; continue; } - break; - } - if (i >= words.length || basename(words[i]) !== seq[0]) return false; - - let j = i + 1; - for (let k = 1; k < seq.length; k++) { - // Skip option tokens, and a token that is the VALUE of the option before it. - while (j < words.length && words[j] !== seq[k]) { - const prev = words[j - 1]; - const isOption = words[j].startsWith('-'); - const isOptionValue = prev && prev.startsWith('-') && !prev.includes('='); - if (!isOption && !isOptionValue) break; - j++; - } - if (j >= words.length || words[j] !== seq[k]) return false; - j++; - } - return true; -} - -// --------------------------------------------------------------------------- -// Read the payload. A malformed payload under a real parser is a true anomaly, not a -// quoting artifact, so it fails closed (F24 acceptance row). -// --------------------------------------------------------------------------- -let raw = ''; -try { raw = require('fs').readFileSync(0, 'utf8'); } catch { raw = ''; } -if (raw.trim() === '') allow(); - -let payload; -try { payload = JSON.parse(raw); } -catch { deny('hook payload is not valid JSON — refusing to guess at its contents.'); } - -// Enforce only the NAMED field. A danger string in `description` or `content` is not -// a command (F24 cross-field decoys). -const command = payload && payload.tool_input && typeof payload.tool_input.command === 'string' - ? payload.tool_input.command - : ''; -if (!command.trim()) allow(); - -const segs = segments(command); - -// --------------------------------------------------------------------------- -// LAYER 1 — blocking checks, PER SEGMENT so a flag belonging to one command cannot -// satisfy a guard aimed at another. ALL of them run before any early exit: the .sh -// port placed the database guard below `grep -qE 'git\s+(commit|push)' || exit 0`, -// which is dead code for every non-git deploy. Order is load-bearing. -// --------------------------------------------------------------------------- - -const DB_DEPLOY = [ - ['supabase', 'db', 'push'], - ['prisma', 'migrate', 'deploy'], - ['dotnet', 'ef', 'database', 'update'], - ['flyway', 'migrate'], - ['flyway', 'clean'], - ['rails', 'db:migrate'], - ['rake', 'db:migrate'], // the classic Rails form, previously absent -]; -const isExempt = (w) => w === '--local' || w === '--dry-run' || w === '--dry' - || /^RAILS_ENV=(test|development)$/.test(w); - -for (const words of segs) { - if (!DB_DEPLOY.some((seq) => hasCommandSeq(words, seq))) continue; - if (words.some(isExempt)) continue; // exemptions count only within the same segment - deny('Direct database deploy to a remote environment. Validate with --dry-run, or deploy via CI/CD.'); -} - -// F25 + H5. `--force-with-lease` is the SAFE form and must survive: whole-word -// equality avoids the `/--force\b/` trap, where '-' is a word boundary. `-4f` and -// `-6f` are real bundles (`-4`/`-6` are git's address-family flags), so digits count. -// A leading '+' on a refspec is a force push with no flag at all to find. -const isForceFlag = (w) => w === '--force' || /^-[A-Za-z0-9]*f[A-Za-z0-9]*$/.test(w); -const isForceRefspec = (w) => /^\+[A-Za-z0-9._/^~-]+(:[A-Za-z0-9._/^~-]+)?$/.test(w); -for (const words of segs) { - if (!hasCommandSeq(words, ['git', 'push'])) continue; - if (words.some(isForceFlag)) { - deny('Force push is not allowed. Use --force-with-lease if you must overwrite a remote branch.'); - } - if (words.some(isForceRefspec)) { - deny('A leading "+" on a refspec is a force push. Use --force-with-lease, or push without the "+".'); - } -} - -// F22: destructive but legitimate — ASK, never deny, never stderr on exit 0. -for (const words of segs) { - if (hasCommandSeq(words, ['git', 'reset']) && words.includes('--hard')) { - ask('A hard reset discards every uncommitted change in the working tree. Confirm this is intended.'); - } -} - -// --------------------------------------------------------------------------- -// Constructs this cannot evaluate -> ASK, not allow. -// -// A shell expands things a static matcher cannot see: `git push $'--force'`, -// `git${IFS}push${IFS}--force`, `F=--force; git push $F`, `bash -c "git push --force"`. -// No amount of tokenizer work reaches these, so the honest response is to stop -// pretending. 3.1.6 forbids DENYING on something unparsed; asking is permitted and is -// what F22 already uses for "destructive but legitimate". -// -// Scoped deliberately to segments that mention a guarded program or run a nested -// shell, so an everyday `echo $HOME` does not prompt. -// --------------------------------------------------------------------------- -const GUARDED = new Set(['git', 'supabase', 'prisma', 'dotnet', 'flyway', 'rails', 'rake']); -const NESTED_SHELL = new Set(['bash', 'sh', 'zsh', 'dash', 'eval', 'xargs', 'source']); - -// A word carrying an expansion or a substitution is not a clean program name: -// `git${IFS}push${IFS}--force` is ONE word, and in `echo \`git push --force\`` the -// word is "`git". Both slipped past a basename-equality test, so when a word contains -// `$` or a backtick, look for a guarded name as a SUBSTRING. -const wordMentionsGuarded = (w) => { - if (GUARDED.has(basename(w))) return true; - if (/[$`]/.test(w)) { for (const g of GUARDED) if (w.includes(g)) return true; } - return false; -}; - -for (const words of segs) { - const mentionsGuarded = words.some(wordMentionsGuarded); - const nested = words.some((w) => NESTED_SHELL.has(basename(w))); - if (!mentionsGuarded && !nested) continue; - - // An unexpanded parameter or command substitution: the real argv is unknowable here. - const unexpanded = words.some((w) => /[$`]/.test(w)); - // A nested shell carries its whole program inside one word. - const nestedProgram = nested && words.some((w) => /\s/.test(w) || GUARDED.has(basename(w))); - - if (unexpanded || nestedProgram) { - ask('This command contains a shell construct the guard cannot evaluate ' - + '(a variable, a substitution, or a nested shell), so its real effect is unknown. ' - + 'Confirm it does not force-push or deploy to a remote database.'); - } -} - -// --------------------------------------------------------------------------- -// Ledger rows 5-6 — opt-in only. F05(c): DG_STRICT_GIT defaults OFF, so these ship -// documented as opt-in rather than advertised as active. -// --------------------------------------------------------------------------- -if (process.env.DG_STRICT_GIT !== '1') allow(); -const isCommitOrPush = segs.some((w) => hasCommandSeq(w, ['git', 'commit']) || hasCommandSeq(w, ['git', 'push'])); -if (!isCommitOrPush) allow(); - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { execFileSync } = require('child_process'); - -// A session id is interpolated into a filename, so it is validated before use. -// Unvalidated, `session_id: "../../../../../../x/y"` wrote tracker JSON OVER a file -// outside TMPDIR — confirmed by probe. path.join eats the first `..` against the -// `dg-baseline-..` component, which is why a shallow test looks safe. -function safeSessionId(v) { - return (typeof v === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(v) && v !== '.' && v !== '..') - ? v : 'default'; -} -const sessionId = safeSessionId(payload && payload.session_id); -const tmp = process.env.TMPDIR || process.env.TEMP || os.tmpdir(); - -// Ledger row 8: read BOTH tracker key names. The inline implementation wrote `total`, -// the script wrote `total_changes_since_audit`; a single-key read silently sees 0. -function trackerCount(file) { - let txt = ''; - try { txt = fs.readFileSync(file, 'utf8'); } catch { return null; } - let obj = null; - try { obj = JSON.parse(txt); } catch { obj = null; } - if (obj) { - for (const k of ['session_changes', 'total_changes_since_audit', 'total']) { - if (typeof obj[k] === 'number') return obj[k]; - } - } - const m = txt.match(/"(?:session_changes|total_changes_since_audit|total)"\s*:\s*(\d+)/); - return m ? Number(m[1]) : null; -} - -// Row 6: staging-count sanity check. -if (segs.some((w) => hasCommandSeq(w, ['git', 'commit']))) { - let staged = 0; - try { - // stdio: stderr must be IGNORED, not inherited. Node's default inherits it, so a - // git error (e.g. cwd is not a repository) emitted 7kB on stderr at exit 0 — - // violating this file's own F26 rule. - staged = execFileSync('git', ['diff', '--cached', '--name-only'], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) - .split('\n').filter(Boolean).length; - } catch { staged = 0; } - const edits = trackerCount(path.join(tmp, `dg-baseline-${sessionId}`)); - if (staged > 0 && edits !== null && edits > 0 && staged > edits * 2 + 5) { - deny(`Staging check: ${edits} files edited this session but ${staged} staged. Review with 'git diff --cached --stat'.`); - } -} - -// Row 5: build verification before commit. -const marker = path.join(tmp, `dg-build-${sessionId}`); -try { - const age = (Date.now() - fs.statSync(marker).mtimeMs) / 60000; - if (age < 120) allow(); -} catch { /* no marker */ } - -let buildCmd = ''; -try { - const pkg = fs.readFileSync('package.json', 'utf8'); - if (/"build"/.test(pkg)) buildCmd = 'npm run build'; - else if (/"typecheck"/.test(pkg)) buildCmd = 'npm run typecheck'; -} catch { /* not a node project */ } -if (!buildCmd) { - try { if (fs.readdirSync('.').some((f) => f.endsWith('.sln'))) buildCmd = 'dotnet build'; } catch {} -} -if (!buildCmd && fs.existsSync('Cargo.toml')) buildCmd = 'cargo check'; -if (!buildCmd && fs.existsSync('go.mod')) buildCmd = 'go vet ./...'; -if (!buildCmd) allow(); - -deny(`No successful build recorded this session. Run '${buildCmd}' before committing.`); diff --git a/plugins/deepgrade-guard/scripts/dg-migration-guard.js b/plugins/deepgrade-guard/scripts/dg-migration-guard.js deleted file mode 100644 index 8dac70f..0000000 --- a/plugins/deepgrade-guard/scripts/dg-migration-guard.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -// DeepGrade: Migration guard (PreToolUse: Write|Edit) — lane N -// -// Warns when editing an ALREADY-APPLIED migration. New migrations are fine. -// Ledger row 2: Windows backslash normalization — exists ONLY in the inline -// implementation (plugin.json), never in dg-migration-guard.sh, so a naive port -// would have dropped it and the guard would miss every Windows-style path. -// Ledger row 3: the wider directory/filename coverage is the SCRIPT's, kept. -// -// Contract 3.1.6: parse the named field, fail closed on a malformed payload, -// never deny on an unparsed blob. Each hook is self-contained on purpose — F06's -// reverse sweep requires every file in scripts/ to be referenced by the hook -// config, so a shared library would fail it. -'use strict'; -const fs = require('fs'); -const path = require('path'); - -function deny(msg) { process.stderr.write(`[DeepGrade] BLOCKED: ${msg}\n`); process.exit(2); } -function allow() { process.exit(0); } - -let raw = ''; -try { raw = fs.readFileSync(0, 'utf8'); } catch { raw = ''; } -if (raw.trim() === '') allow(); - -let payload; -try { payload = JSON.parse(raw); } -catch { deny('hook payload is not valid JSON — refusing to guess at its contents.'); } - -let filePath = payload && payload.tool_input && typeof payload.tool_input.file_path === 'string' - ? payload.tool_input.file_path - : ''; -if (!filePath.trim()) allow(); - -// Row 2. C:\repo\db\migrations\001_init.sql must be recognized as a migration. -filePath = filePath.replace(/\\/g, '/'); - -const MIGRATION_DIRS = [ - '/migrations/', '/migrate/', '/Migrations/', - '/alembic/versions/', '/drizzle/', '/changelog/', -]; -// Leading-segment matches too: "migrations/001.sql" has no leading slash. -const inMigrationDir = MIGRATION_DIRS.some( - (d) => filePath.includes(d) || filePath.startsWith(d.slice(1)) -); -if (!inMigrationDir) allow(); - -const base = path.posix.basename(filePath); -const IS_MIGRATION = [ - /\.sql$/i, // any SQL file in a migration directory - /^\d{4,14}/, // timestamp or numeric prefix - /^V\d+/, // Flyway V-prefix - /^\d{4}_/, // 4-digit underscore prefix - /ModelSnapshot\.cs$/, // EF Core snapshot -].some((re) => re.test(base)); -if (!IS_MIGRATION) allow(); - -// Only an EXISTING file has potentially been applied. Creating a new migration -// is the correct action and must never be blocked. -if (!fs.existsSync(filePath)) allow(); - -deny(`Editing an existing migration: ${filePath}. Modifying an applied migration can break databases that already ran it. Create a NEW migration instead.`); diff --git a/plugins/deepgrade-guard/scripts/dg-session-stop.js b/plugins/deepgrade-guard/scripts/dg-session-stop.js deleted file mode 100644 index b2efe99..0000000 --- a/plugins/deepgrade-guard/scripts/dg-session-stop.js +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env node -// DeepGrade: Stop handler — lane N -// -// Ledger row 9: "N files changed but no tests ran". This exists only in the -// script, and it has NEVER BEEN SEEN by a user: dg-session-stop.sh:30 writes it to -// stderr and exits 0, which Claude Code does not surface (F26). Emitted as JSON now. -// Ledger row 8: tolerant tracker read across both key names. -// -// Informational: fails open, always exits 0. Never exit 2 from a Stop hook — -// approach.md notes that causes an infinite loop. -'use strict'; -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -function quiet() { process.exit(0); } -function notify(message) { - process.stdout.write(JSON.stringify({ systemMessage: message }) + '\n'); - process.exit(0); -} - -let payload = null; -try { payload = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { payload = null; } -// A Stop fired because a previous Stop hook already continued the turn. Emitting again -// would re-post the same summary every cycle; exit before doing anything. -if (payload && payload.stop_hook_active === true) quiet(); - - -// A session id is interpolated into a filename, so it is validated before use. -// Unvalidated, `session_id: "../../../../../../x/y"` wrote this handler's JSON OVER a -// file outside TMPDIR (confirmed by probe). path.join consumes the first `..` against -// the `dg--..` component, which is why a shallow test looks safe. -function safeSessionId(v) { - return (typeof v === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(v) && v !== '.' && v !== '..') - ? v : 'default'; -} - -const sessionId = safeSessionId(payload && payload.session_id); -const tmp = process.env.TMPDIR || process.env.TEMP || os.tmpdir(); - -// Tracker files (dg-baseline/dg-test/dg-build-) were never removed, so they -// accumulated in TMPDIR forever. This session's files stay (Stop fires per turn and the -// runtime proof reads them afterwards); anything from other sessions older than a day -// is swept. Fail-open: a sweep error must never affect the Stop verdict. -try { - const cutoff = Date.now() - 24 * 60 * 60 * 1000; - for (const name of fs.readdirSync(tmp)) { - if (!/^dg-(baseline|test|build)-/.test(name) || name.endsWith('-' + sessionId)) continue; - const full = path.join(tmp, name); - try { if (fs.statSync(full).mtimeMs < cutoff) fs.unlinkSync(full); } catch {} - } -} catch {} - -let text = '', obj = null; -try { text = fs.readFileSync(path.join(tmp, `dg-baseline-${sessionId}`), 'utf8'); } catch { quiet(); } -if (!text) quiet(); -try { obj = JSON.parse(text); } catch { obj = null; } - -function count(keys) { - if (obj) for (const k of keys) if (typeof obj[k] === 'number') return obj[k]; - const m = text.match(new RegExp(`"(?:${keys.join('|')})"\\s*:\\s*(\\d+)`)); - return m ? Number(m[1]) : 0; -} -const changes = count(['session_changes']); -if (changes <= 0) quiet(); - -const testMarker = path.join(tmp, `dg-test-${sessionId}`); -const testsRan = fs.existsSync(testMarker); - -if (!testsRan) { - // Only nag if the project actually has tests to run. - let hasTests = false; - try { hasTests = /"test"\s*:/.test(fs.readFileSync('package.json', 'utf8')); } catch {} - if (!hasTests) { - for (const marker of ['pytest.ini', 'conftest.py', 'tox.ini', 'Cargo.toml', 'go.mod', 'phpunit.xml']) { - if (fs.existsSync(marker)) { hasTests = true; break; } - } - } - // This repo's own suite lives in tests/run-all.sh with no package.json test - // script, so the .sh check reported "no tests" for the very project it ships in. - if (!hasTests && fs.existsSync(path.join('tests', 'run-all.sh'))) hasTests = true; - - if (hasTests) { - notify(`[DeepGrade] ${changes} file${changes === 1 ? '' : 's'} changed this session but no test run was detected. Run the suite before finishing.`); - } -} - -notify(`[DeepGrade] Session summary: ${changes} file${changes === 1 ? '' : 's'} changed.`); diff --git a/plugins/deepgrade-guard/scripts/dg-track-change.js b/plugins/deepgrade-guard/scripts/dg-track-change.js deleted file mode 100644 index 5a63222..0000000 --- a/plugins/deepgrade-guard/scripts/dg-track-change.js +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env node -// DeepGrade: Change tracker (PostToolUse: Write|Edit) — lane N -// -// Ledger row 7: audit-staleness nudge at DG_CHANGE_THRESHOLD (default 15). -// Ledger row 8: read the tracker TOLERANTLY across both key names. The inline -// implementation wrote `total`, the script wrote `total_changes_since_audit`; -// whichever single key a reader assumed, the other one silently read as 0 and -// the nudge never fired. Writes both, reads either. -// -// Informational hook: fails OPEN in every state (3.1.6 — "a tracker that blocks -// work costs more than one that miscounts"). Never exits non-zero. -'use strict'; -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -function quiet() { process.exit(0); } -function notify(message) { - // F26: exit-0 output must be JSON. dg-track-change.sh wrote this to stderr and - // exited 0, where it is never surfaced — the nudge has never been seen. - process.stdout.write(JSON.stringify({ systemMessage: message }) + '\n'); - process.exit(0); -} - -let payload = null; -try { payload = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { quiet(); } - -const filePath = payload && payload.tool_input && typeof payload.tool_input.file_path === 'string' - ? payload.tool_input.file_path - : ''; -if (!filePath.trim()) quiet(); - - -// A session id is interpolated into a filename, so it is validated before use. -// Unvalidated, `session_id: "../../../../../../x/y"` wrote this handler's JSON OVER a -// file outside TMPDIR (confirmed by probe). path.join consumes the first `..` against -// the `dg--..` component, which is why a shallow test looks safe. -function safeSessionId(v) { - return (typeof v === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(v) && v !== '.' && v !== '..') - ? v : 'default'; -} - -const sessionId = safeSessionId(payload && payload.session_id); -const tmp = process.env.TMPDIR || process.env.TEMP || os.tmpdir(); -const tracker = path.join(tmp, `dg-baseline-${sessionId}`); -const threshold = Number(process.env.DG_CHANGE_THRESHOLD || 15); - -// Row 8, read side. -function readCount(obj, text, keys) { - if (obj) for (const k of keys) if (typeof obj[k] === 'number') return obj[k]; - const re = new RegExp(`"(?:${keys.join('|')})"\\s*:\\s*(\\d+)`); - const m = text.match(re); - return m ? Number(m[1]) : 0; -} - -let text = ''; -let obj = null; -try { text = fs.readFileSync(tracker, 'utf8'); } catch { text = ''; } -if (text) { try { obj = JSON.parse(text); } catch { obj = null; } } - -const session = readCount(obj, text, ['session_changes']) + 1; -const total = readCount(obj, text, ['total_changes_since_audit', 'total']) + 1; - -try { - // Row 8, write side: both key names, so a reader of either generation agrees. - fs.writeFileSync(tracker, JSON.stringify({ - session_changes: session, - total_changes_since_audit: total, - total, - })); -} catch { quiet(); } // fail open: a tracker that cannot be written must not block the edit - -if (Number.isFinite(threshold) && threshold > 0 && total >= threshold) { - notify(`[DeepGrade] ${total} files changed since the last audit. Consider /deepgrade:codebase-delta.`); -} -quiet(); diff --git a/plugins/deepgrade-guard/scripts/dg-track-test.js b/plugins/deepgrade-guard/scripts/dg-track-test.js deleted file mode 100644 index 6eef727..0000000 --- a/plugins/deepgrade-guard/scripts/dg-track-test.js +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env node -// DeepGrade: Test/build tracker (PostToolUse: Bash) — lane N -// -// Ledger row 11: the expanded test-runner detection is the SCRIPT's, kept and -// widened slightly where the .sh list had gaps that would misreport (see below). -// Writes session-isolated markers that dg-session-stop (row 9) and the git -// guard's opt-in build check (row 5) read. -// -// Informational: fails OPEN, never exits non-zero, emits nothing on the happy path. -// Uses the same quote-stripping skeleton as the git guard so that -// `git commit -m "ran npm test"` does not record a test run that never happened. -'use strict'; -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -function quiet() { process.exit(0); } - -let payload = null; -try { payload = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { quiet(); } - -const command = payload && payload.tool_input && typeof payload.tool_input.command === 'string' - ? payload.tool_input.command - : ''; -if (!command.trim()) quiet(); - -// Same word splitter as dg-git-guard.js. Duplicated deliberately: F06's reverse -// sweep requires every file in scripts/ to be referenced by the hook config, so a -// shared module would fail it. -// -// This replaced a span-deleting tokenizer that an adversarial probe showed could be -// bypassed by quoting (`git push "--force"`). Here the stakes are inverted — this -// is an informational tracker, so a false marker is the failure that matters, not a -// missed one. A word containing whitespace can only have come from quoting, so it -// is data and is dropped before matching; `npm "test"` still counts, and -// `git commit -m "ran npm test earlier"` correctly does not. -function segments(cmd) { - const segs = [[]]; - let word = '', started = false, i = 0; - const end = () => { if (started) { segs[segs.length - 1].push(word); word = ''; started = false; } }; - const endSeg = () => { end(); if (segs[segs.length - 1].length) segs.push([]); }; - while (i < cmd.length) { - const c = cmd[i]; - if (c === '\\') { if (i + 1 < cmd.length) { word += cmd[i + 1]; started = true; i += 2; } else i++; continue; } - if (c === "'") { i++; while (i < cmd.length && cmd[i] !== "'") { word += cmd[i]; i++; } started = true; i++; continue; } - if (c === '"') { - i++; - while (i < cmd.length && cmd[i] !== '"') { - if (cmd[i] === '\\' && i + 1 < cmd.length) { word += cmd[i + 1]; i += 2; } else { word += cmd[i]; i++; } - } - started = true; i++; continue; - } - if (/[;|&()\n\r]/.test(c)) { endSeg(); i++; continue; } - if (/\s/.test(c)) { end(); i++; continue; } - word += c; started = true; i++; - } - end(); - return segs.filter((s) => s.length); -} -// Match PER SEGMENT and at a command position. The previous version joined every -// segment's words into one string, so a pattern could match across two unrelated -// commands (`echo npm && test -f dist/index.js` forged a test marker) — the very -// isolation the git guard was fixed to preserve. Bare-word patterns also fired on -// commands that merely NAMED a tool (`grep -rn tsc src/`, `cat make.log`), and a -// forged build marker satisfies the DG_STRICT_GIT build gate. -const READERS = new Set(['grep', 'egrep', 'rg', 'cat', 'less', 'more', 'head', 'tail', 'ls', - 'find', 'echo', 'printf', 'wc', 'stat', 'file', 'test', 'diff', 'sed', 'awk']); -const WRAPPERS = new Set(['sudo', 'env', 'command', 'nohup', 'time', 'timeout', 'winpty', 'stdbuf']); - -function commandLine(seg) { - // Drop leading env assignments and wrappers, then reject the segment outright if the - // program is a reader — nothing a reader does is a test or build run. - let i = 0; - while (i < seg.length) { - const w = seg[i]; - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) { i++; continue; } - if (WRAPPERS.has(w)) { i++; continue; } - break; - } - if (i >= seg.length) return ''; - const prog = seg[i].replace(/\\/g, '/').split('/').pop().replace(/\.(exe|cmd|bat)$/i, ''); - if (READERS.has(prog)) return ''; - // Words containing whitespace came from quoting and are data, not structure. - return [prog].concat(seg.slice(i + 1).filter((w) => !/\s/.test(w))).join(' '); -} - -const skel = segments(command).map(commandLine).filter(Boolean); - -const TEST_PATTERNS = [ - /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b/, - /\bnpx\s+(jest|vitest|mocha|ava|playwright|cypress)\b/, - /\b(jest|vitest|mocha|ava)\b/, - /\b(pytest|tox|nose2|unittest)\b/, - /\bpython\s+-m\s+(pytest|unittest)\b/, - /\bdotnet\s+test\b/, - /\b(nunit|xunit)\b/, - /\bcargo\s+test\b/, - /\bgo\s+test\b/, - /\bbundle\s+exec\s+rspec\b/, - /\brspec\b/, - /\bphpunit\b/, - /\bmvn\s+(test|verify)\b/, - /\bgradle\s+test\b/, - // This repo's own suite, which the .sh list did not recognize at all — so a - // full `bash tests/run-all.sh` never recorded that tests had run, and row 9 - // would nag "no tests ran" immediately after a green suite. - /\btests\/run-all\.sh\b/, - /\bbash\s+tests\/layer\d/, -]; - -const BUILD_PATTERNS = [ - /\b(npm|pnpm|yarn|bun)\s+run\s+build\b/, - /\bnpx\s+tsc\b/, - /\btsc\b/, - /\b(npm|pnpm|yarn)\s+run\s+typecheck\b/, - /\bdotnet\s+build\b/, - /\bmsbuild\b/, - /\bcargo\s+(build|check)\b/, - /\bgo\s+(build|vet)\b/, - /\bmvn\s+(compile|package)\b/, - /\bgradle\s+(build|assemble)\b/, - /\bmake\b/, -]; - - -// A session id is interpolated into a filename, so it is validated before use. -// Unvalidated, `session_id: "../../../../../../x/y"` wrote this handler's JSON OVER a -// file outside TMPDIR (confirmed by probe). path.join consumes the first `..` against -// the `dg--..` component, which is why a shallow test looks safe. -function safeSessionId(v) { - return (typeof v === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(v) && v !== '.' && v !== '..') - ? v : 'default'; -} - -const sessionId = safeSessionId(payload && payload.session_id); -const tmp = process.env.TMPDIR || process.env.TEMP || os.tmpdir(); - -function mark(kind) { - try { fs.writeFileSync(path.join(tmp, `dg-${kind}-${sessionId}`), String(Math.floor(Date.now() / 1000))); } - catch { /* fail open */ } -} - -if (skel.some((line) => TEST_PATTERNS.some((re) => re.test(line)))) mark('test'); -if (skel.some((line) => BUILD_PATTERNS.some((re) => re.test(line)))) mark('build'); - -quiet(); diff --git a/plugins/deepgrade-readiness/README.md b/plugins/deepgrade-readiness/README.md index ff0d083..9cd457a 100644 --- a/plugins/deepgrade-readiness/README.md +++ b/plugins/deepgrade-readiness/README.md @@ -5,7 +5,7 @@ well an AI coding agent can read, navigate, and safely modify a project. The result is a composite letter grade (A+ to F) and a prioritized list of what to fix first, with generated scaffolding for the gaps. Stack-agnostic. -This is one of four DeepGrade plugins in the +This is one of three DeepGrade plugins in the [deepgrade monorepo](https://github.com/krwhynot/deepgrade). It pairs with `deepgrade-audit` for deeper code-quality audits once readiness is in shape. diff --git a/plugins/deepgrade/GUIDE.md b/plugins/deepgrade/GUIDE.md index 9ca1f06..b3857e1 100644 --- a/plugins/deepgrade/GUIDE.md +++ b/plugins/deepgrade/GUIDE.md @@ -2,7 +2,7 @@ # DeepGrade Knowledge Guide v8.0.0 -**6 Commands**  •  **2 Agents**  •  **6 Skills**  •  **3 Safety Hooks**  •  **Requires Node.js 18+** +**6 Commands**  •  **2 Agents**  •  **6 Skills**  •  **7 Document Templates**  •  **3 Safety Hooks**  •  **2 Gate Tools**  •  **Requires Node.js 18+** [![Plugin](https://img.shields.io/badge/Claude_Code-Plugin-5A45FF?style=for-the-badge)](https://github.com/krwhynot/deepgrade) [![Version](https://img.shields.io/badge/v8.0.0-stable-2ECC71?style=for-the-badge)](#) @@ -10,25 +10,43 @@ -> A reference for the deepgrade planning plugin: structured planning with an adversarial audit gate, plan-linked troubleshooting, and documentation generation. +> A reference for the deepgrade planning plugin: six-stage planning with a verifier-first design gate, plan-linked troubleshooting, and documentation generation. DeepGrade's planning core walks an idea through the six stages of Anthropic's AI-Native SDLC playbook, Plan, Design, Build, Test, Deploy, Maintain, each -committing one artifact the next stage reads, with an adversarial design gate: a fresh, isolated -plan-auditor judges every plan against falsifiable criteria, its evidence is -re-validated byte-for-byte by `scripts/dg-evidence-validate.js`, and a seeded -canary defect (`scripts/dg-canary.js`) proves the audit can actually see -defects before its verdict counts. The gate passes on verified evidence, never -on a self-assigned score. - -It is one of four DeepGrade plugins. `deepgrade-readiness` grades AI -navigability, `deepgrade-audit` runs codebase audits, and `deepgrade-guard` -carries the always-on git/migration/tracking rails — install any subset. The +committing one artifact the next stage reads. The Design stage ends in an +adversarial gate: a fresh, isolated plan-auditor judges the spec against +falsifiable criteria, its evidence is re-validated byte-for-byte by +`scripts/dg-evidence-validate.js`, and a seeded canary defect from +`scripts/dg-canary.js` proves the audit can actually see defects before its +verdict counts. The gate passes on verified evidence, never on a self-assigned +score. See [The Design Gate](#the-design-gate) below. + +It is one of three DeepGrade plugins. `deepgrade-readiness` grades AI +navigability and `deepgrade-audit` runs codebase audits — install any subset. The methodology reference lives in the monorepo's [METHODOLOGY.md](https://github.com/krwhynot/deepgrade/blob/main/METHODOLOGY.md). +## Contents + +- [Commands at a Glance](#commands-at-a-glance) +- [The Six Stages](#the-six-stages) +- [The Design Gate](#the-design-gate) +- [The 2 Agents](#the-2-agents) +- [The 6 Skills](#the-6-skills) +- [The 7 Document Templates](#the-7-document-templates) +- [The 3 Hooks](#the-3-hooks) +- [The Scripts](#the-scripts) +- [Where Files Land](#where-files-land) +- [How to Install](#how-to-install) +- [How to Update](#how-to-update) + ## Commands at a Glance +Ten entry points. Four are skills (`plan`, `troubleshoot`, `codex-challenge`, +`documentation`) that also answer to natural-language triggers; six are command +files under `commands/`. + ### `/deepgrade:help` **What it does:** Shows all commands, agents, workflows, and output locations in one reference page. **When to use it:** First time using DeepGrade, or when you forget a command name. @@ -40,25 +58,25 @@ methodology reference lives in the monorepo's --- - ### ![planning](https://img.shields.io/badge/Planning-9B59B6?style=for-the-badge) --- ### `/deepgrade:plan` -**What it does:** Walks you through the six-stage playbook: Plan (intent.md), Design (spec.md plus the verifier gate), Build (plan.md, code, impact review), Test, Deploy (review.md, release authorization), Maintain (incidents become new intents). `intent {name}` captures intent only and stops. +**What it does:** Walks you through the six-stage playbook: Plan (intent.md), Design (spec.md plus the design gate), Build (plan.md, code, impact review), Test, Deploy (review.md, release authorization), Maintain (incidents become new intents). `intent {name}` captures intent only and stops. Every stage ends at a human gate; nothing advances on its own. **When to use it:** For any significant initiative -- migrations, new features, refactoring projects. This is the full workflow. -**What it produces:** `docs/plans/YYYY-MM-DD-{name}/` with manifest, status, intent.md, research, spec.md, audit.md, plan.md, impact-review.md, test-plan.md, review.md. +**What it produces:** `docs/plans/YYYY-MM-DD-{name}/` with manifest, status, intent.md, research, spec.md, audit.md, evidence, plan.md, impact-review.md, test-plan.md, review.md, and optionally runbook.md. **Example:** ``` /deepgrade:plan worldpay-canada +/deepgrade:plan intent worldpay-canada /deepgrade:plan pricing-engine from docs/vendor-specs/ ``` --- ### `/deepgrade:quick-plan` -**What it does:** One-shot plan generation from a vague objective. Analyzes the codebase and produces a phased technical plan targeting 32+/40 on audit dimensions. +**What it does:** One-shot plan generation from a vague objective. Analyzes the codebase and produces a phased technical plan covering the plan-auditor's eight review dimensions. The dimension score it reports is advisory and gates nothing. **When to use it:** For smaller changes where the full six-stage workflow is overkill. **What it produces:** `docs/specs/{plan-name}.md` **Example:** @@ -69,7 +87,7 @@ methodology reference lives in the monorepo's --- ### `/deepgrade:plan-status` -**What it does:** Shows progress of all active plans or detailed phase-by-phase status of one plan, including staleness checks. +**What it does:** Shows progress of all active plans or detailed stage-by-stage status of one plan, including staleness checks. **When to use it:** To check where a plan stands, or to see all plans at a glance. **What it produces:** Conversation output (no files). **Example:** @@ -91,10 +109,9 @@ methodology reference lives in the monorepo's --- - ### `/deepgrade:quick-audit` -**What it does:** Audits a technical plan, spec, or proposal across 8 dimensions (problem clarity, architecture, phasing, risk, rollback, timeline, testing, team). Produces a go/no-go recommendation. -**When to use it:** Before presenting a plan to stakeholders, or to stress-test any proposal. +**What it does:** Audits a technical plan, spec, or proposal through the plan-auditor's eight review dimensions (problem clarity, architecture, phasing, risk, rollback, timeline, testing, team). Produces evidence-backed findings, a go/no-go recommendation, and a leadership summary. The X/40 score in the report is reported for trend-watching only; the verdict rests on the findings. +**When to use it:** Before presenting a plan to stakeholders, or to stress-test any proposal outside the full workflow. **What it produces:** `docs/plans/{date}-{name}/audit.md` (if plan-linked) or conversation output. **Example:** ``` @@ -103,14 +120,14 @@ methodology reference lives in the monorepo's --- - ### `/deepgrade:codex-challenge` -**What it does:** Runs an adversarial review loop between Claude and the OpenAI Codex CLI: Codex scores a plan across 8 dimensions and Claude revises until the score converges. -**When to use it:** When you want a second, independent model attacking a plan before you commit to it. +**What it does:** Runs an adversarial review loop between Claude and the OpenAI Codex CLI: Codex scores a plan across 8 dimensions and Claude revises until the score converges at 36/40 or the round limit is hit. +**When to use it:** When you want a second, independent model attacking a plan before you commit to it. Requires the Codex CLI on PATH. **What it produces:** A scored review transcript in the plan folder. **Example:** ``` /deepgrade:codex-challenge my-plan +/deepgrade:codex-challenge docs/specs/my-plan.md --rounds 3 ``` ### ![docs](https://img.shields.io/badge/Documentation_&_Troubleshooting-1ABC9C?style=for-the-badge) @@ -118,13 +135,14 @@ methodology reference lives in the monorepo's --- ### `/deepgrade:documentation` -**What it does:** Routes to 6 document templates (ADR, BRD, PRD, README, Release Notes, Spec). If audit data exists, documents are richer and auto-linked. +**What it does:** Routes to 7 document templates (ADR, BRD, PRD, README, Runbook, Release Notes, Spec). Each template carries a fill-in document skeleton, so the output has a fixed shape. If audit data exists, documents are richer and auto-linked, and a document-chain check suggests the missing neighbor (a PRD asks for its BRD, a runbook asks to be linked from review.md). **When to use it:** Whenever you need to create a project document. If unsure which type, just describe what you need. -**What it produces:** Files in `docs/adr/`, `docs/brd/`, `docs/prd/`, `docs/specs/`, or project root (README). +**What it produces:** Files in `docs/adr/`, `docs/brd/`, `docs/prd/`, `docs/runbooks/`, `docs/specs/`, or project root (README). A plan-linked runbook lands in the plan folder. **Example:** ``` /deepgrade:documentation adr credential rotation -/deepgrade:documentation spec pricing engine extraction +/deepgrade:documentation prd refund processing +/deepgrade:documentation runbook worldpay-canada /deepgrade:documentation release-notes v2.5.0 ``` @@ -142,9 +160,9 @@ methodology reference lives in the monorepo's --- ### `/deepgrade:troubleshoot` -**What it does:** Implements a strict 4-phase debugging framework (Root Cause, Pattern Analysis, Hypothesis, Fix) with severity-driven incident triage and optional containment for production fires. Enforces investigation before any permanent fix. For SEV1/SEV2 incidents, temporary containment (rollback, feature flag, config revert) is allowed before investigation to restore service. Builds a persistent knowledge base with guardrail evaluation and timestamped investigation timelines. +**What it does:** Implements a strict 4-phase debugging framework (Root Cause, Pattern Analysis, Hypothesis, Fix) with severity-driven incident triage. For SEV1/SEV2, a containment gate allows a temporary mitigation (rollback, feature flag, config revert) before investigation, then a status-update cadence keeps people outside the investigation informed. Every run writes a timestamped log and a knowledge-base entry; SEV1/SEV2 runs also write a blameless postmortem and, when plan-linked, propose a new intent that re-enters Stage 1. **When to use it:** When something breaks and you want a systematic approach instead of guessing, or when a production incident needs triage and containment before debugging. -**What it produces:** `docs/troubleshooting/YYYY-MM-DD-{issue-slug}.md` + updates to `docs/troubleshooting/knowledge-base.md` +**What it produces:** `docs/troubleshooting/YYYY-MM-DD-{issue-slug}.md`, updates to `docs/troubleshooting/knowledge-base.md`, and for SEV1/SEV2 a `-postmortem.md` beside the log. Plan-linked runs write under the plan's `troubleshooting/` folder instead. **Example:** ``` /deepgrade:troubleshoot "Payment processing returns null on Canadian cards" @@ -154,72 +172,230 @@ methodology reference lives in the monorepo's --- +## The Six Stages + +`/deepgrade:plan` runs Anthropic's AI-Native SDLC loop. Each stage reads the +artifact the previous one committed and leaves one behind. The chain of +artifacts is the audit trail. + +| Stage | Question it answers | Reads | Commits | Human gate | +|-------|--------------------|-------|---------|------------| +| 1. Plan | What are we trying to change, for whom, and why? | Any input: idea, ticket, docs folder, incident | `intent.md` | Intent accepted | +| 2. Design | What exactly will be built, and does it survive scrutiny? | `intent.md`, research | `spec.md`, `audit.md`, `evidence/` | Scope lock mid-stage, then the design gate passes | +| 3. Build | What files change, in what order, and what did we actually do? | `spec.md` | `plan.md`, code, `impact-review.md`, change records | Codebase writes approved | +| 4. Test | Does it do what the spec says, measured the way the spec said? | `spec.md` verification plan | `test-plan.md`, test results | Runbook reviewed by someone other than the author | +| 5. Deploy | Is the diff what was planned, and is it safe to release? | `plan.md`, the diff | `review.md` with a release checklist | Release authorization. The agent never crosses the production gate. | +| 6. Maintain | What did production teach us? | Troubleshoot logs, postmortems | A new `intent.md` proposed for Stage 1 | Intent accepted or declined | + +Spec requirements carry a priority (P0/P1/P2), trace to a line of intent.md, +and have Given/When/Then acceptance criteria. Success metrics have a numeric +target, a window, and a measurement method. Review.md's release checklist has +pre-deploy, deploy, post-deploy, and rollback sections with numeric rollback +triggers, and Stage 6 uses those thresholds to classify severity. + +## The Design Gate + +Stage 2 ends in an audit. Version 8 removed the numeric score from that audit +because a gate that passes on a number the audited model chose for itself lets +no reader tell a plan that earned it from one written to earn it. What replaced +it is a set of checks a person can re-derive from the plan folder without +re-running anything. + +**The auditor** is `agents/plan-auditor.md`, spawned fresh for every iteration +with a `` block: it never reads a previous iteration's +verdicts. It returns criterion records, each with a verdict (MET, UNMET, N_A) +and the evidence it rests on: file, content hash, line range, verbatim quote. +The rubric it applies lives in `docs/planning-techniques/lint-registry.md`, +the single enforced source of rule text. + +**The canary** (`scripts/dg-canary.js`) checks the auditor rather than the +plan. Before the auditor is spawned, one known defect is injected into a +working copy of the spec under `.canary/`. Five classes rotate by seed: + +| Class | What it does | Criterion it violates | +|-------|-------------|----------------------| +| `rollback-strip` | Removes the rollback line from one deployment phase | LINT-03 | +| `owner-strip` | Blanks the owner of one external dependency | LINT-04 | +| `assumption-inject` | Adds an unverified HIGH-impact assumption | LINT-08 | +| `criteria-strip` | Deletes the go/no-go criteria | LINT-10 | +| `test-claim-inject` | Claims coverage from a test file that does not exist | LINT-15 | + +The auditor audits the mutated copy without being told. If the planted +criterion does not come back UNMET, the audit is re-run once with a different +class. A second miss fails the gate as "audit untrustworthy" and does not +trigger the revision loop, because revising a plan against findings from an +audit that could not see a planted defect rewrites the spec to satisfy +conclusions never derived from reading it. If the canary is found, that one +finding is stripped as a harness artifact and the same criterion is re-checked +against the unmutated original, so a genuine gap on the same rule is not +stripped with it. + +**The evidence validator** (`scripts/dg-evidence-validate.js`) treats the +auditor's records as a proposal. It re-reads every cited artifact, confirms the +LF-normalized hash still matches, slices the cited line range, and asserts the +quote is byte-identical. It can only demote. A record comes back UNMET with one +of these flags: + +| Flag | Meaning | +|------|---------| +| `EVIDENCE-INVALID` | The quote does not match the lines it cites | +| `EVIDENCE-MISSING` | MET was claimed with no evidence at all | +| `EVIDENCE-STALE` | The artifact changed after the record was written | +| `EVIDENCE-ARTIFACT-MISSING` | The cited file does not exist | +| `EVIDENCE-RANGE-INVALID` | The cited line range does not exist in the file | +| `EVIDENCE-UNEXECUTED` | An executable criterion retained no command | +| `EVIDENCE-COMMAND-FAILED` | The retained command exited non-zero | + +Which criteria are executable is fixed in the validator, not declared by the +record, so the judge cannot relabel a run-it check as a read-it check and +satisfy it with a quote. + +**The gate expression** is the whole verdict: + +``` +CANARY_OK = the criterion the planted defect violates came back UNMET +EVIDENCE_OK = dg-evidence-validate.js exited 0 (nothing was demoted) +VERIFIED = every applicable criterion is MET or N_A after validation +INFRA_OK = infra_gaps == 0 + +PASS = CANARY_OK AND EVIDENCE_OK AND VERIFIED AND INFRA_OK +``` + +There is no weighted sum. A strong showing on seven criteria cannot offset a +miss on the eighth. `status.json` keeps a `score_history` for trend-watching, +but nothing reads it as a gate. + +**The holistic judge** runs beside the rubric and never gates. Its unmapped +findings land in `docs/planning-techniques/lint-candidates.md` as proposed +rules. It is the only mechanism that can notice the rubric itself is +incomplete. + +> [!WARNING] +> **Known limitations, stated rather than glossed.** The auditor holds Read, +> Grep, and Glob over the repository and can reach the criterion files and the +> canary's defect table. Isolation is enforced by instruction, not capability. +> The canary reliably detects a lazy audit and only incidentally an adversarial +> one. And nothing in this release measures whether the judge is *right*, only +> whether it is evidenced. Calibration against known-good and known-bad plans +> is the natural successor. ## The 2 Agents | | Agent | What It Does | Used By | |:-:|-------|-------------|---------| | ![p](https://img.shields.io/badge/-%E2%80%8B-9B59B6) | plan-scaffolder | Creates structured technical plans from vague objectives using 3 parallel analysts | quick-plan, plan | -| ![p](https://img.shields.io/badge/-%E2%80%8B-9B59B6) | plan-auditor | Scores plans across 8 dimensions using parallel specialist subagents | quick-audit, plan | +| ![p](https://img.shields.io/badge/-%E2%80%8B-9B59B6) | plan-auditor | The isolated judge. Reviews a plan through 8 dimensions with parallel specialist subagents and returns criterion records with evidence, never a gating score | quick-audit, plan (design gate) | + +Both agents load the `self-audit-knowledge` skill so every claim carries a +verification tier and, where relevant, a failure-mode flag. ## The 6 Skills Skills are persistent knowledge that loads automatically when relevant — reference books the plugin carries in its back pocket. -**plan** -- The `/deepgrade:plan` workflow itself. `SKILL.md` is a router (identity, lifecycle, workspace layout, Step 0 intent detection and schema migration) and each of the six stages lives in its own file under `stages/`, with artifact templates under `templates/`, read when the phase is entered. Split this way so the later phases survive context compaction in long planning sessions instead of being silently dropped with the rest of a 1,700-line command. +**plan** -- The `/deepgrade:plan` workflow itself. `SKILL.md` is a router (identity, lifecycle, workspace layout, Step 0 intent detection and schema migration) and each of the six stages lives in its own file under `stages/`, with artifact templates for intent.md, spec.md, plan.md, and review.md under `templates/`, read when the stage is entered. Split this way so the later stages survive context compaction in long planning sessions instead of being silently dropped with the rest of a 1,700-line command. -**troubleshoot** -- The `/deepgrade:troubleshoot` workflow: a router holding identity, timeline logging, plan detection, and Step 0 knowledge-base lookup, with the incident pre-flow, the four phases, multi-agent mode, and the knowledge-base write-back in one file each under `phases/`, read on entry. +**troubleshoot** -- The `/deepgrade:troubleshoot` workflow: a router holding identity, timeline logging, plan detection, and Step 0 knowledge-base lookup, with the incident pre-flow (triage, containment gate, status updates), the four phases, multi-agent mode, and the knowledge-base write-back with postmortem in one file each under `phases/`, read on entry. **codex-challenge** -- The `/deepgrade:codex-challenge` loop: a router holding the Codex invocation pattern and the eight review dimensions, with the output schema, prompt template, round loop, and report in `phases/`. The schema file is what the parser tests bind to. -**documentation** -- The dispatch hub for document generation. Contains routing logic (first word = subcommand), 6 template references, smart suggestions when audit data exists, and document chain enforcement (a PRD triggers a check for a related BRD, etc.). Loads when you ask for a document, or invoke `/deepgrade:documentation` directly. - +**documentation** -- The dispatch hub for document generation. Contains routing logic (first word = subcommand), 7 template references each with a fill-in skeleton, smart suggestions when audit data exists, and document chain enforcement (a PRD triggers a check for a related BRD, a runbook checks it is linked from review.md). Loads when you ask for a document, or invoke `/deepgrade:documentation` directly. **self-audit-knowledge** -- Contains the LLM epistemic transparency framework: claim verification tiers (A = tool-verified, B = code-reading, C = pattern inference), evidence basis formatting, failure mode flags (`[ENUMERATION-MAY-BE-INCOMPLETE]`, `[INFERRED-FROM-NAMING]`, `[SIDE-EFFECTS-NOT-TRACED]`, `[DEAD-CODE-UNCERTAIN]`), category-based cascade risk classification (CASCADE/COVERAGE/CONTAINED), and report confidence thresholds. Loads during codebase audits, plan audits, and report generation. -**mcp-research** -- Teaches when and how to use external MCP search tools (Ref, Exa, Perplexity) for documentation lookup and research: tool selection heuristics, token budget rules, suffix-matching for server-qualified tool names, and graceful degradation when a tool is absent. Loads during plan research phases. +**mcp-research** -- Teaches when and how to use external MCP search tools (Ref, Exa, Perplexity) for documentation lookup and research: tool selection heuristics, token budget rules, suffix-matching for server-qualified tool names, and graceful degradation when a tool is absent. Loads during plan research phases. Every template and stage works without these tools. -### The 6 Document Templates +### The 7 Document Templates +Each template under `skills/documentation/references/` has two parts: the +workflow (disambiguate the topic, pull audit data if present, confirm scope) +and a fill-in document skeleton the output must follow. | Template | What It Produces | Location | |----------|-----------------|----------| -| `adr-template.md` | Architecture Decision Record -- captures a technical decision, alternatives considered, and rationale | `docs/adr/` | -| `brd-template.md` | Business Requirements Document -- domain-level requirements tied to business outcomes | `docs/brd/` | -| `prd-template.md` | Product Requirements Document -- feature-level spec with acceptance criteria | `docs/prd/` | -| `readme-template.md` | Project README -- setup, architecture overview, contribution guide | Project root | -| `release-notes-template.md` | Release Notes / Changelog -- what changed, why, and how to upgrade | `docs/` or project root | -| `spec-template.md` | Technical Specification -- extraction plans, migration plans, RFCs, design docs | `docs/specs/` | +| `adr-template.md` | Architecture Decision Record -- context, decision, two or more options assessed on a dimension table, trade-offs, consequences, action items | `docs/adr/` | +| `brd-template.md` | Business Requirements Document -- business context, objectives, stakeholders, BR-NNN requirements mapped to PRDs, feature coverage, metrics, risks | `docs/brd/` | +| `prd-template.md` | Product Requirements Document -- problem, goals, non-goals, user stories, P0/P1/P2 requirements with Given/When/Then acceptance criteria, leading and lagging success metrics | `docs/prd/{domain}/` | +| `readme-template.md` | Project README -- purpose, quick start, structure, dependencies, integrations, configuration, testing, known risks, related docs | Project root | +| `runbook-template.md` | Operational Runbook -- prerequisites, exact steps each with expected result and failure action, verification, troubleshooting table, rollback trigger and steps, escalation, run history | `docs/runbooks/` or the plan folder | +| `release-notes-template.md` | Release Notes / Changelog -- what changed, why, and how to upgrade, from git history | `docs/` or project root | +| `spec-template.md` | Technical Specification -- extraction, migration, feature, or infrastructure plans with phases, validation, risk, rollback, and success criteria | `docs/specs/` | > [!IMPORTANT] -> When a Phase 2 audit has been run, templates automatically pull from audit data. For example, a PRD template pulls feature confidence scores from `feature-inventory.md`, and a spec template pulls risk levels from `risk-assessment.md`. Documents generated **after** an audit are richer than documents generated from scratch. +> When a `deepgrade-audit` baseline exists, templates pull from it automatically. A PRD pulls feature confidence scores and entry points from `feature-inventory.json`, an ADR pulls related findings from `risk-assessment.json`, a README pulls dependencies from `dependency-map.json`. Documents generated **after** an audit are richer than documents generated from scratch. Everything below 0.90 confidence is tagged `[ASSUMPTION]` rather than stated as fact. --- - ## The 3 Hooks The plan-context hooks keep a session anchored to the active plan. They are -declared in `hooks/hooks.json` and run as Node scripts from `scripts/` -(**requires Node.js 18+**, the same runtime Claude Code itself needs). The -git, migration, and tracking rails are the `deepgrade-guard` plugin. +declared in `hooks/hooks.json` and run as Node scripts from `scripts/`, one +file per handler (**requires Node.js 18+**, the same runtime Claude Code +itself needs). All three are informational: they fail open and always exit 0. +There are no blocking hooks in any DeepGrade plugin; force-push, migration, and +database-deploy protection belongs in Claude Code permission rules (`deny` and +`ask` entries in `settings.json`), which need no runtime and cannot disagree +with a project's own choices. ### ![info](https://img.shields.io/badge/-INFO-2ECC71) Active Plan Display (SessionStart Hook) -**Fires when:** A session starts in a repo with an active plan under `docs/plans/`. -**What it does:** Reports the active plan, its phase and status, and nudges when a linked audit has gone stale (the staleness check is if-exists: it works with or without the audit plugin installed). +**Script:** `scripts/dg-session-start.js` +**Fires when:** A session starts, resumes, or continues after a compaction in a repo with an active plan under `docs/plans/`. +**What it does:** Parses the newest plan's `status.json` and reports the active plan, its stage and status, and nudges when a linked audit has gone stale (the staleness check is if-exists: it works with or without the audit plugin installed). It parses JSON rather than grepping it, which is what fixed two old bugs where pretty-printed files read as "phase: unknown" and a nested phase status was reported as the plan's status. ### ![info](https://img.shields.io/badge/-INFO-2ECC71) Subagent Log (SubagentStop Hook) +**Script:** `scripts/dg-subagent-stop.js` **Fires when:** A subagent completes while a plan is active. -**What it does:** Appends the completion to the active plan's troubleshooting log, so multi-agent work leaves a trace in the plan record. +**What it does:** Appends the completion to `troubleshooting/subagent-log.txt` in the active plan, so multi-agent work leaves a trace in the plan record. It only writes if that folder already exists; a stop hook silently creating directories in someone's repo would be a surprise. ### ![info](https://img.shields.io/badge/-INFO-2ECC71) Plan Context (PreCompact Hook) +**Script:** `scripts/dg-pre-compact.js` **Fires when:** Claude Code's context window is getting full and it needs to compress earlier messages. -**What it does:** Injects the active plan name and current phase into the compressed context so Claude doesn't lose track of what you're working on. -**Why:** Without this, Claude might forget which plan you were on after a compaction. The hook ensures continuity. +**What it does:** Emits the active plan name and current stage as JSON so Claude doesn't lose track of what you're working on. If this channel proves invisible in a given Claude Code build, the SessionStart handler's compact-resume path carries the same message, so this handler is not the only carrier. > [!TIP] > `[DeepGrade] Compacting. Plan: worldpay-canada. Resume with /deepgrade:plan worldpay-canada` +## The Scripts + +`scripts/` holds five Node files. Three are the hook handlers above. Two are +the design-gate tools, which are not hooks: Stage 2 of `/deepgrade:plan` runs +them explicitly, and you can run them by hand. + +| Script | Role | Invoked by | Usage | +|--------|------|-----------|-------| +| `dg-session-start.js` | Hook handler | SessionStart event | automatic | +| `dg-subagent-stop.js` | Hook handler | SubagentStop event | automatic | +| `dg-pre-compact.js` | Hook handler | PreCompact event | automatic | +| `dg-canary.js` | Gate tool: checks the auditor | Stage 2, before the auditor is spawned | `node dg-canary.js inject [seed]` | +| `dg-evidence-validate.js` | Gate tool: checks the evidence | Stage 2, before any verdict is treated as MET | `node dg-evidence-validate.js [root-dir]` | + +Both gate tools have regression suites in the monorepo's `tests/` folder +(layers 6 and 7 of `tests/run-all.sh`). + +**What happens without Node.** The hooks cannot start, and Claude Code reports +a hook error on each guarded event. That is deliberate: absent and loud beats +present and wrong. The gate tools fail the same way, and Stage 2 cannot pass +without them. + +## Where Files Land + +| Output | Location | Committed? | +|--------|----------|-----------| +| Plan workspace | `docs/plans/{date}-{name}/` (manifest.md, status.json, intent.md, spec.md, plan.md, review.md, ...) | Yes | +| Plan research and intake | `docs/plans/{date}-{name}/research/` | Yes | +| Audit report | `docs/plans/{date}-{name}/audit.md` | Yes | +| Audit evidence records | `docs/plans/{date}-{name}/evidence/` | Yes | +| Canary working copy | `docs/plans/{date}-{name}/.canary/` | No, scratch | +| Change records | `docs/plans/{date}-{name}/changes/` | Yes | +| Plan-linked troubleshooting logs and postmortems | `docs/plans/{date}-{name}/troubleshooting/` | Yes | +| Plan-linked runbook | `docs/plans/{date}-{name}/runbook.md` | Yes | +| Standalone troubleshooting logs | `docs/troubleshooting/YYYY-MM-DD-{slug}.md` | Yes | +| Knowledge base | `docs/troubleshooting/knowledge-base.md` | Yes | +| Quick plans and specs | `docs/specs/` | Yes | +| ADRs, BRDs, PRDs | `docs/adr/`, `docs/brd/`, `docs/prd/{domain}/` | Yes | +| Standalone runbooks | `docs/runbooks/` | Yes | +| Proposed lint rules from the holistic judge | `docs/planning-techniques/lint-candidates.md` | Yes | +| Plan export | `{plan-name}-export.zip` at project root | No | --- diff --git a/plugins/deepgrade/README.md b/plugins/deepgrade/README.md index be2287b..2ef25c8 100644 --- a/plugins/deepgrade/README.md +++ b/plugins/deepgrade/README.md @@ -5,10 +5,11 @@ AI-Native SDLC playbook (intent.md, spec.md, plan.md, review.md) with an adversarial, verifier-first design gate, plan-linked troubleshooting, and documentation generation. Stack-agnostic. Works on any codebase. -This is one of four DeepGrade plugins in the +This is one of three DeepGrade plugins in the [deepgrade monorepo](https://github.com/krwhynot/deepgrade). It pairs with -`deepgrade-readiness` (AI-readiness grading), `deepgrade-audit` (codebase -audits), and `deepgrade-guard` (always-on safety hooks) — install any subset. +`deepgrade-readiness` (AI-readiness grading) and `deepgrade-audit` (codebase +audits) — install any subset. +The full reference is [GUIDE.md](GUIDE.md). ## Install @@ -31,42 +32,76 @@ Verify inside a Claude Code session: | Command | Description | | ------- | ----------- | -| `/deepgrade:plan` | Six-stage playbook workflow: Plan, Design, Build, Test, Deploy, Maintain | +| `/deepgrade:plan` | Six-stage playbook workflow: Plan, Design, Build, Test, Deploy, Maintain. Human gate at every stage | | `/deepgrade:quick-plan` | Lightweight plan for small changes | -| `/deepgrade:plan-status` | Check plan progress and phase status | +| `/deepgrade:plan-status` | Check plan progress and stage status | | `/deepgrade:plan-export` | Export a plan as a portable package | ### Auditing and Review | Command | Description | | ------- | ----------- | -| `/deepgrade:quick-audit` | Audit any technical plan or spec | +| `/deepgrade:quick-audit` | Audit any technical plan or spec through 8 review dimensions with evidence | | `/deepgrade:codex-challenge` | Adversarial review loop against OpenAI Codex | ### Documentation and Support | Command | Description | | ------- | ----------- | -| `/deepgrade:documentation` | Generate specs, PRDs, BRDs, ADRs, READMEs | +| `/deepgrade:documentation` | Generate ADRs, BRDs, PRDs, READMEs, runbooks, release notes, specs | | `/deepgrade:quick-cleanup` | Clean a folder of messy documents into reference material | -| `/deepgrade:troubleshoot` | 4-phase debugging framework with incident triage and containment | +| `/deepgrade:troubleshoot` | 4-phase debugging framework with incident triage, containment, status updates, and postmortems | | `/deepgrade:help` | Show all commands and usage | `/deepgrade:plan`, `/deepgrade:troubleshoot`, `/deepgrade:codex-challenge`, and `/deepgrade:documentation` are skill surfaces; the other entries are command files. +## The Six Stages + +| Stage | Commits | Gate | +| ----- | ------- | ---- | +| 1. Plan | `intent.md` | Intent accepted | +| 2. Design | `spec.md`, `audit.md`, `evidence/` | Scope lock, then the design gate | +| 3. Build | `plan.md`, code, `impact-review.md` | Codebase writes approved | +| 4. Test | `test-plan.md`, results | Runbook reviewed by a second person | +| 5. Deploy | `review.md` with a release checklist | Release authorization. The agent never crosses the production gate | +| 6. Maintain | A new `intent.md` from incidents | Intent accepted or declined | + +## The Design Gate + +Stage 2 ends in an audit with no score. A fresh, isolated plan-auditor returns +criterion records, each with a verdict and byte-addressed evidence. Two tools +in `scripts/` decide whether that audit counts: + +- **`dg-canary.js`** checks the auditor. Before the audit runs, it injects one + known defect (five rotating classes) into a working copy of the spec. An + audit that misses it twice fails as untrustworthy and does not trigger the + revision loop. +- **`dg-evidence-validate.js`** checks the evidence. It re-reads every cited + file, verifies the hash, slices the cited lines, and compares them to the + quote byte-for-byte. It can only demote a verdict, never promote one. + +``` +PASS = CANARY_OK AND EVIDENCE_OK AND VERIFIED AND INFRA_OK +``` + +There is no weighted sum. Known limitation: the auditor can read the canary's +defect table, so this reliably detects a lazy audit and only incidentally an +adversarial one. Details in [GUIDE.md](GUIDE.md#the-design-gate). + ## Safety Hooks (3) The plugin includes 3 plan-context hooks that activate automatically. They are declared in `hooks/hooks.json` and run as Node scripts from `scripts/`, one file per handler. **Requires Node.js 18 or later** — the same runtime Claude Code -itself needs. The git/migration/tracking rails live in the `deepgrade-guard` -plugin, recommended as a co-install. +itself needs. All three are informational and fail open. There are no blocking +hooks: use Claude Code permission rules in `settings.json` for force-push, +migration, and database-deploy protection. | Hook | Event | What It Does | | ---- | ----- | ------------ | -| Active Plan Display | SessionStart | Reports the active plan, its phase, and audit staleness | -| Subagent Log | SubagentStop | Logs subagent completions to the active plan | -| Plan Context | PreCompact | Preserves plan name and phase on compact | +| Active Plan Display | SessionStart | Reports the active plan, its stage, and audit staleness | +| Subagent Log | SubagentStop | Logs subagent completions to the active plan's troubleshooting folder | +| Plan Context | PreCompact | Preserves plan name and stage on compact | ## Dependencies @@ -77,26 +112,35 @@ Code itself needs, so if Claude Code runs, this does too. node --version # must print v18.0.0 or higher ``` +**Optional:** the OpenAI Codex CLI on PATH, for `/deepgrade:codex-challenge` +only. MCP search tools (Ref, Exa, Perplexity) enrich research when present; +every stage and template works without them. + **What happens without Node.** The hooks cannot start, and Claude Code reports a hook error on each guarded event. That is deliberate: absent and loud beats -present and wrong. +present and wrong. The design-gate tools fail the same way, and Stage 2 cannot +pass without them. ## File Output Locations | Output | Location | Committed? | | ------ | -------- | ---------- | -| Plan documents | `docs/plans/{date}-{name}/` | Yes | +| Plan workspace | `docs/plans/{date}-{name}/` | Yes | | Audit evidence | `docs/plans/{date}-{name}/evidence/` | Yes | -| Specifications | `docs/specs/` | Yes | -| ADRs | `docs/adr/` | Yes | +| Canary working copy | `docs/plans/{date}-{name}/.canary/` | No | +| Troubleshooting logs, postmortems, knowledge base | `docs/troubleshooting/` or the plan's `troubleshooting/` | Yes | +| Specifications and quick plans | `docs/specs/` | Yes | +| ADRs, BRDs, PRDs | `docs/adr/`, `docs/brd/`, `docs/prd/` | Yes | +| Runbooks | `docs/runbooks/` or the plan folder | Yes | +| Plan export | `{plan-name}-export.zip` at project root | No | ## Architecture - **2 agents** - plan-auditor (the isolated judge) and plan-scaffolder -- **6 skills** - plan, troubleshoot, and codex-challenge (each a router plus one file per phase), documentation, MCP research, self-audit knowledge -- **6 doc templates** - ADR, BRD, PRD, README, release notes, spec -- **3 hook handlers** - `scripts/dg-*.js` plan-context layer, plus the - `dg-canary.js` / `dg-evidence-validate.js` audit tooling invoked by `/deepgrade:plan` +- **6 skills** - plan, troubleshoot, and codex-challenge (each a router plus one file per stage or phase), documentation, MCP research, self-audit knowledge +- **7 doc templates** - ADR, BRD, PRD, README, runbook, release notes, spec, each with a fill-in document skeleton +- **3 hook handlers** - `scripts/dg-session-start.js`, `dg-subagent-stop.js`, `dg-pre-compact.js` +- **2 design-gate tools** - `scripts/dg-canary.js` and `dg-evidence-validate.js`, invoked by Stage 2 of `/deepgrade:plan`, not by hooks ## Version History diff --git a/plugins/deepgrade/commands/help.md b/plugins/deepgrade/commands/help.md index 353b10c..3449331 100644 --- a/plugins/deepgrade/commands/help.md +++ b/plugins/deepgrade/commands/help.md @@ -141,7 +141,7 @@ Create and review technical plans for any engineering initiative. | plan-auditor | Scores any plan across 8 dimensions (problem, architecture, phasing, risk, rollback, timeline, testing, team). Produces go/no-go assessment. | | plan-scaffolder | Creates structured plans from vague objectives. Reads codebase + audit data to generate evidence-based phased plans. | -## Knowledge Skills (7, across all four plugins) +## Knowledge Skills (7, across all three plugins) Auto-loaded contextual knowledge that guides agent behavior during scans and reports. `plan` and `documentation` ship in deepgrade; `deepgrade-knowledge`, `governance-knowledge` @@ -169,6 +169,7 @@ Powered by audit data when available. Suggests which document to create if you'r | BRD | `/deepgrade:documentation brd Ordering` | Business Requirements Document | | PRD | `/deepgrade:documentation prd refund processing` | Product Requirements Document | | README | `/deepgrade:documentation readme BusinessLogic` | Project README | +| Runbook | `/deepgrade:documentation runbook prod-deploy` | Step-by-step operational procedure with rollback and escalation | | Release Notes | `/deepgrade:documentation release-notes v2.5.0` | Release notes from git history | | Spec | `/deepgrade:documentation spec pricing engine extraction` | Technical Specification / Engineering Plan | diff --git a/plugins/deepgrade/skills/documentation/SKILL.md b/plugins/deepgrade/skills/documentation/SKILL.md index 0b828fa..c9361e0 100644 --- a/plugins/deepgrade/skills/documentation/SKILL.md +++ b/plugins/deepgrade/skills/documentation/SKILL.md @@ -1,6 +1,6 @@ --- name: documentation -description: Generate project documentation (ADR, BRD, PRD, README, release notes, changelog, technical spec). Dispatches to the appropriate template based on document type. Also suggests which document to create based on context. Triggers on - create adr, create brd, create prd, create readme, generate documentation, architecture decision, business requirements, product requirements, release notes, changelog, version notes, release summary, prepare release, generate changelog, version history, release documentation, deployment notes, create spec, technical specification, write spec, engineering plan, design doc, RFC, migration plan. +description: Generate project documentation (ADR, BRD, PRD, README, runbook, release notes, changelog, technical spec). Dispatches to the appropriate template based on document type. Also suggests which document to create based on context. Triggers on - create adr, create brd, create prd, create readme, generate documentation, architecture decision, business requirements, product requirements, release notes, changelog, version notes, release summary, prepare release, generate changelog, version history, release documentation, deployment notes, create spec, technical specification, write spec, engineering plan, design doc, RFC, migration plan, create runbook, deployment runbook, operational procedure, on-call procedure. --- # Documentation Generator @@ -36,7 +36,7 @@ Developer Toolkit. Works standalone or powered by Phase 2 audit data when availa Parse `$ARGUMENTS` to determine the document type and topic: -- **First word = subcommand:** `adr`, `brd`, `prd`, `readme`, `release-notes`, `spec` +- **First word = subcommand:** `adr`, `brd`, `prd`, `readme`, `runbook`, `release-notes`, `spec` - **Rest = topic/argument** passed to the template ### Routing @@ -47,6 +47,7 @@ Parse `$ARGUMENTS` to determine the document type and topic: | `brd` | [references/brd-template.md](references/brd-template.md) | Business Requirements Document | | `prd` | [references/prd-template.md](references/prd-template.md) | Product Requirements Document | | `readme` | [references/readme-template.md](references/readme-template.md) | Project README | +| `runbook` | [references/runbook-template.md](references/runbook-template.md) | Operational runbook (deploy, rotation, backfill, recovery) | | `release-notes` | [references/release-notes-template.md](references/release-notes-template.md) | Release Notes / Changelog | | `spec` | [references/spec-template.md](references/spec-template.md) | Technical Specification (extraction, migration, feature, infrastructure) | @@ -61,8 +62,9 @@ Parse `$ARGUMENTS` to determine the document type and topic: [2] brd - Business Requirements Document [3] prd - Product Requirements Document [4] readme - Project README - [5] release-notes - Release Notes / Changelog - [6] spec - Technical Specification / Engineering Plan + [5] runbook - Operational Runbook + [6] release-notes - Release Notes / Changelog + [7] spec - Technical Specification / Engineering Plan Not sure which one you need? Describe what you're trying to document and I'll recommend the right format. @@ -129,6 +131,7 @@ If you're... - Specifying a feature -> PRD (Product Requirements Document) - Planning a migration or extraction -> SPEC (Technical Specification) - Documenting a project/module -> README + - Writing the exact steps for a deploy, rotation, backfill, or recovery -> Runbook - Recording what changed in a release -> Release Notes Describe what you need and I'll pick the right format. @@ -144,6 +147,7 @@ When audit data is available, documentation templates pull from it automatically | BRD | feature-inventory.md domains and feature lists | | PRD | feature-inventory.md confidence scores, entry points, DB tables | | README | dependency-map.md project dependencies, risk ratings | +| Runbook | plan.md ## Verification, deploy scripts and CI config (no audit dependency) | | Spec | risk-assessment.md risk levels, dependency-map.md coupling data | | Release Notes | git log (no audit dependency) | @@ -158,6 +162,7 @@ After generating any document, check the document chain: - **BRD created** -> Check if PRDs exist for features in that domain. If not, suggest creating them. - **ADR created** -> Check if related PRDs reference this decision. If not, suggest linking. - **Spec created** -> Suggest running `/deepgrade:quick-audit` on it. Check for related ADRs. +- **Runbook created** -> If plan-linked, check review.md ## Release checklist references it and manifest.md has a row. This ensures documents don't exist in isolation. Every doc links to related docs. @@ -176,7 +181,7 @@ as files in the plugin's commands/ directory. The valid commands are: | Cleanup docs | `/deepgrade:quick-cleanup [folder]` | | Create plan | `/deepgrade:quick-plan [objective]` | | Audit plan | `/deepgrade:quick-audit [file]` | -| Create document | `/deepgrade:documentation [adr\|brd\|prd\|readme\|release-notes\|spec] [topic]` | +| Create document | `/deepgrade:documentation [adr\|brd\|prd\|readme\|runbook\|release-notes\|spec] [topic]` | | Readiness scan | `/deepgrade-readiness:readiness-scan` | | DeepGrade audit | `/deepgrade-audit:codebase-audit` | | Delta scan | `/deepgrade-audit:codebase-delta` | diff --git a/plugins/deepgrade/skills/documentation/references/adr-template.md b/plugins/deepgrade/skills/documentation/references/adr-template.md index d79f162..5b0415c 100644 --- a/plugins/deepgrade/skills/documentation/references/adr-template.md +++ b/plugins/deepgrade/skills/documentation/references/adr-template.md @@ -54,11 +54,70 @@ Generate the ADR directly, using: Steps: 1. Create `docs/adr/` directory if needed 2. Read existing ADRs to match style -3. Write ADR-{NNN}-{topic}.md +3. Write ADR-{NNN}-{topic}.md using the skeleton below 4. Update document-linkage.json for related features 5. Update feature-inventory.json linked_docs 6. Validate all JSON files +**Document skeleton** + +Fill every section. Evaluate at least two options; a one-option ADR is a +memo, not a decision record. Cite baseline findings by ID where they justify +an assessment. + +```markdown +# ADR-{NNN}: {Title} + +**Status:** Proposed | Accepted | Rejected | Deprecated | Superseded by ADR-{NNN} +**Date:** {YYYY-MM-DD} +**Deciders:** {who signs off} +**Related:** features {IDs} · PRDs {links} · findings {IDs from baseline} + +## Context + +{The situation and the forces at play: constraints, findings, deadlines, +what breaks if nothing is decided.} + +## Decision + +{The change being proposed, in one or two sentences.} + +## Options Considered + +### Option A: {Name} + +| Dimension | Assessment | +|-----------|------------| +| Complexity | Low / Med / High | +| Cost | {money, time, or ops burden} | +| Scalability | {assessment} | +| Team familiarity | {assessment} | +| Rollback | Low / Med / High | + +**Pros:** {list} +**Cons:** {list} + +### Option B: {Name} + +{Same format.} + +## Trade-off Analysis + +{Why the chosen option wins against the others, with reasoning tied to the +dimensions above. Name what was given up.} + +## Consequences + +- Easier: {what becomes easier} +- Harder: {what becomes harder} +- Revisit when: {the condition or date that reopens this decision} + +## Action Items + +1. [ ] {implementation step} +2. [ ] {follow-up, e.g. update PRD-{name} to reference this ADR} +``` + **Step 3: Post-Generation** ``` diff --git a/plugins/deepgrade/skills/documentation/references/brd-template.md b/plugins/deepgrade/skills/documentation/references/brd-template.md index 8a928ac..b23437b 100644 --- a/plugins/deepgrade/skills/documentation/references/brd-template.md +++ b/plugins/deepgrade/skills/documentation/references/brd-template.md @@ -60,11 +60,80 @@ Generate the BRD directly, using: - Links to any existing PRDs and ADRs for cross-referencing Steps: -1. Write the BRD to `docs/brd/{domain}.md` +1. Write the BRD to `docs/brd/{domain}.md` using the skeleton below 2. Update document-linkage.json for all features in this domain 3. Update feature-inventory.json linked_docs for all features 4. Validate all JSON files +**Document skeleton** + +A BRD answers *why the business needs this domain to exist*; PRDs answer +*what each feature does*. Keep requirements at the business-capability level +(BR-NNN), and point each one at the PRDs that implement it. + +```markdown +# BRD: {Domain} + +**Status:** Draft | Approved · **Date:** {YYYY-MM-DD} · **Owner:** {business owner} +**Covers:** {N} features · **Linked PRDs:** {list} · **Linked ADRs:** {list} + +## Business Context + +{Why this domain exists: the business process it serves, who depends on it, +and what it costs when it fails. 3-5 sentences, grounded in baseline data.} + +## Objectives + +{3-5 business outcomes, each measurable. "Cut order-entry time 30%", not +"improve ordering".} + +## Stakeholders + +| Role | Interest | Decision authority | +|------|----------|--------------------| +| {role} | {what they need from this domain} | Approves / Consulted / Informed | + +## Scope + +**In:** {business capabilities covered} +**Out:** {adjacent capabilities explicitly excluded, with a reason each} + +## Business Requirements + +| ID | Requirement | Priority | Implemented by | +|----|-------------|----------|----------------| +| BR-001 | {business capability, outcome-phrased} | Must / Should / Could | PRD-{name}, PRD-{name} | + +## Feature Coverage + +| Feature | Confidence | PRD | Test coverage | Notes | +|---------|-----------|-----|---------------|-------| +| {name} | {score} | {link or "missing"} | {status} | {[ASSUMPTION] if below 0.90} | + +## Success Metrics + +| Metric | Baseline | Target | Measured by | Review date | +|--------|----------|--------|-------------|-------------| +| {metric} | {today} | {target} | {tool or query} | {date} | + +## Assumptions and Constraints + +- Assumption: {what is taken as true; tag [ASSUMPTION] if unverified} +- Constraint: {regulatory, contractual, technical, or budget limit} + +## Risks + +| Risk | Likelihood | Impact | Mitigation | Owner | +|------|-----------|--------|------------|-------| +| {risk} | Low / Med / High | Low / Med / High | {mitigation} | {role} | + +## Open Questions + +| Question | Owner | Blocking? | +|----------|-------|-----------| +| {question} | {role} | Yes / No | +``` + **Step 4: Post-Generation** After the BRD is created, check for PRD gaps: diff --git a/plugins/deepgrade/skills/documentation/references/prd-template.md b/plugins/deepgrade/skills/documentation/references/prd-template.md index 40ca860..0c16d54 100644 --- a/plugins/deepgrade/skills/documentation/references/prd-template.md +++ b/plugins/deepgrade/skills/documentation/references/prd-template.md @@ -64,10 +64,96 @@ Generate the PRD directly, using: Steps: 1. Create `docs/prd/{domain}/` directory -2. Write the PRD using the standard template +2. Write the PRD using the skeleton below 3. Update document-linkage.json and feature-inventory.json 4. Validate all JSON files +**Document skeleton** + +For an existing feature, reverse-engineer each section from entry points, +DB tables, and tests; tag anything not tool-verified with `[ASSUMPTION]`. +For a new feature, fill from the developer's answers and leave unknowns in +Open Questions rather than inventing them. + +```markdown +# PRD: {Feature Name} + +**Domain:** {domain} · **Feature ID:** {id or "new"} · **Confidence:** {score or "n/a"} +**Status:** Draft | Approved · **Date:** {YYYY-MM-DD} +**Linked:** BRD-{domain} · ADR-{NNN} · Spec {link} + +## Problem Statement + +{2-3 sentences: the user problem, who experiences it and how often, and the +cost of not solving it. Ground in evidence: support data, metrics, findings.} + +## Goals + +{3-5 measurable outcomes, not outputs. "Reduce time to first value by 50%", +not "build onboarding wizard". Split user goals from business goals.} + +## Non-Goals + +{3-5 things this feature will NOT do, each with a one-line reason: not enough +impact, too complex, separate initiative, premature.} + +## User Stories + +{"As a {specific user type}, I want {capability} so that {benefit}." Grouped +by persona, ordered by priority. Include error, empty, and boundary states.} + +## Requirements + +### Must-Have (P0) + +{Cannot ship without. Test: "If we cut this, does the feature still solve the +core problem?" If yes, it is not P0.} + +- **REQ-001:** {behavior} + - Given {precondition}, when {action}, then {outcome} + - Given {error or edge condition}, when {action}, then {outcome} + - Depends on: {team, system, or "none"} + +### Nice-to-Have (P1) + +{Improves the experience; core use case works without it. Likely fast follows.} + +### Future Considerations (P2) + +{Out of scope for v1, but design must not block them.} + +## Success Metrics + +| Metric | Type | Target | Measured by | Evaluate at | +|--------|------|--------|-------------|-------------| +| {adoption, activation, task completion, error rate} | Leading | {"50% adoption in 30 days", not "high adoption"} | {tool or query} | {1 week / 1 month} | +| {retention, revenue, support-ticket reduction} | Lagging | {target} | {tool or query} | {1 quarter} | + +## Open Questions + +| Question | Owner | Blocking? | +|----------|-------|-----------| +| {question} | engineering / design / legal / data / stakeholder | Yes / No | + +## Timeline Considerations + +{Hard deadlines, dependencies on other work, suggested phasing if too large +for one release.} +``` + +**Writing rules** + +- If everything is P0, nothing is P0. Challenge each must-have: "Would we + really not ship without this?" +- User stories describe the need, not the widget. "I want a dropdown" is a + solution; "I want to pick my region without typing" is a story. +- A story with no benefit clause ("I want to click a button") is a task. +- Acceptance criteria cover happy path, error cases, and what must NOT happen. + Each is independently testable. Ban "fast", "intuitive", "user-friendly" + unless defined with a number. +- Any scope addition after approval comes with a scope removal or a timeline + extension. Park good out-of-scope ideas under Non-Goals. + **Step 4: Post-Generation** After the PRD is created, check document-linkage.json: diff --git a/plugins/deepgrade/skills/documentation/references/readme-template.md b/plugins/deepgrade/skills/documentation/references/readme-template.md index 1b7f3de..e0f9a58 100644 --- a/plugins/deepgrade/skills/documentation/references/readme-template.md +++ b/plugins/deepgrade/skills/documentation/references/readme-template.md @@ -10,20 +10,20 @@ If no baseline exists: "No audit baseline found. Run /deepgrade-audit:codebase-a If multiple projects match (e.g., "contact" matches contacts and contacts.test), present a numbered list: -``` +~~~ "$1" matches [N] projects: [1] contacts (MEDIUM risk, core module) [2] contacts.test (LOW risk) Which project needs a README? Or [A] All matching projects -``` +~~~ Wait for the developer's choice. **Step 1: Preview** Show what the README will contain: -``` +~~~ README for [Project Name]: - Language: [detected from project files] - Risk: [level], Phase: [N] @@ -35,7 +35,7 @@ README for [Project Name]: [1] Generate README [2] Cancel -``` +~~~ **Step 2: Generate README** @@ -43,13 +43,71 @@ Generate the README directly, using: - The project name and path - Relevant baseline data -Write `{project-path}/README.md`. +Write `{project-path}/README.md` using the skeleton below. Drop any section +with nothing verified to say rather than filling it with guesses; an outdated +or invented README is worse than a short one. -**Step 3: Confirmation** +**Document skeleton** + +~~~markdown +# {Project Name} + +{One paragraph: what this project does, who calls it, and where it sits in +the system. Risk level and phase from the baseline if available.} +## Quick Start + +```bash +{install command} +{build or run command} +{test command} ``` + +## Structure + +| Path | Purpose | +|------|---------| +| `{dir or file}` | {what lives there} | + +## Dependencies + +- **Internal:** {modules this project imports, from dependency-map} +- **External:** {packages, with the ones that matter for security or upgrades} +- **Depended on by:** {modules that import this project} + +## Integrations + +| System | Direction | Touchpoint | Notes | +|--------|-----------|------------|-------| +| {external service, DB, queue} | In / Out / Both | {file:function} | {auth, rate limits, failure mode} | + +## Configuration + +| Variable | Required | Purpose | Default | +|----------|----------|---------|---------| +| `{ENV_VAR}` | Yes / No | {what it controls} | {value or none} | + +## Testing + +{Test project name, how to run it, what is covered and what is not.} + +## Known Issues and Risks + +{Findings from the baseline that touch this project, by ID. Link, don't +duplicate.} + +## Related Documents + +- PRDs: {links} +- ADRs: {links} +- Runbooks: {links} +~~~ + +**Step 3: Confirmation** + +~~~ README created: {project-path}/README.md [N] of [total] projects now have READMEs. Projects still missing READMEs: [list top 5 by risk level] -``` +~~~ diff --git a/plugins/deepgrade/skills/documentation/references/runbook-template.md b/plugins/deepgrade/skills/documentation/references/runbook-template.md new file mode 100644 index 0000000..7233745 --- /dev/null +++ b/plugins/deepgrade/skills/documentation/references/runbook-template.md @@ -0,0 +1,135 @@ +Generate a runbook for "$1". + +A runbook is the exact, repeatable procedure for one operational task: +a deploy, a rotation, a backfill, a failover, a recovery. It is the artifact +Stage 4 of `/deepgrade:plan` gates on ("Deployment runbook reviewed by someone +other than the author") and the source for the Deployment sequence in +review.md. + +**Step 0: Disambiguate** + +If "$1" names a plan (`docs/plans/*-$1/` exists), read its plan.md +## Verification and status.json; the runbook is that plan's deployment +procedure and lives at `docs/plans/{date}-$1/runbook.md`. + +Otherwise the runbook is standalone and lives at `docs/runbooks/{slug}.md`. + +If "$1" is vague ("the deploy", "rotation"), ask which task: +``` +"$1" could be one of several procedures: + [1] {candidate from scripts/, CI config, or existing runbooks} + [2] {candidate} + [3] Something else — describe it +``` + +Wait for the developer's choice. + +**Step 1: Gather the real commands** + +Read the files that already encode the procedure: deploy scripts, CI +workflows, Makefiles, migration tooling, infra config. Every step in the +runbook must be an exact command or click path lifted from or verified +against those files. If a step cannot be verified, write it and tag it +`[ASSUMPTION]` rather than guessing silently. + +Check for an existing runbook at the target path. If one exists, update it +in place and append a History row instead of overwriting. + +**Step 2: Generate** + +~~~markdown +# Runbook: {Task Name} + +**Owner:** {person or role} · **Frequency:** Daily / Weekly / Monthly / Per release / As needed +**Last Updated:** {YYYY-MM-DD} · **Last Run:** {YYYY-MM-DD or "never"} +**Plan:** {plan name or "standalone"} +**Blast radius:** {what this touches: services, data, users} + +## Purpose + +{What this accomplishes and when to run it. One paragraph.} + +## Prerequisites + +- [ ] {access or permission needed, e.g. prod deploy role} +- [ ] {tool or version required, e.g. `node >= 18`, CLI logged in} +- [ ] {input or state needed, e.g. release tag exists, backup taken} +- [ ] {who must be reachable during the run} + +## Procedure + +### Step 1: {Name} + +```bash +{exact command, run from where, with real flags} +``` + +**Expected result:** {what healthy output looks like, verbatim if possible} +**If it fails:** {stop / retry once / roll back to Step N / escalate} + +### Step 2: {Name} + +```bash +{exact command} +``` + +**Expected result:** {healthy output} +**If it fails:** {what to do} + +## Verification + +- [ ] {command or URL and the healthy response} +- [ ] {metric to check and its acceptable range} +- [ ] {key user flow to exercise by hand} + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| {what you see} | {why} | {what to do, with the command} | + +## Rollback + +**Trigger:** {observable condition that means roll back now} + +1. {exact step} +2. {exact step} +3. Verify: {command and healthy output} + +## Escalation + +| Situation | Contact | Method | +|-----------|---------|--------| +| {condition} | {person or role} | {channel and expected response time} | + +## History + +| Date | Run by | Duration | Notes | +|------|--------|----------|-------| +| {YYYY-MM-DD} | {person} | {minutes} | {issues, deviations, observations} | +~~~ + +**Writing rules** + +- Be painfully specific. "Run the script" is not a step. "Run + `python sync.py --prod --dry-run` from the ops box as the deploy user" is. +- Every step has an expected result and a failure action. A step with no + failure action is a step nobody has thought about failing. +- Rollback trigger is an observable, not a feeling: an error rate, a failed + health check, a missing row count. +- The runbook is tested by someone who did not write it. Where they stall, + the runbook is wrong, not the reader. + +**Step 3: Post-Generation** + +``` +Runbook created: {path} + +Next steps: + - Have someone other than the author walk it once (dry-run flags where they exist) + - If plan-linked: reference it from review.md ## Release checklist and add a + manifest.md row + - Record the first real run in ## History +``` + +If plan-linked, update manifest.md Project Documents and status.json documents. diff --git a/plugins/deepgrade/skills/plan/stages/stage-2-design.md b/plugins/deepgrade/skills/plan/stages/stage-2-design.md index 499dbbc..18b9a39 100644 --- a/plugins/deepgrade/skills/plan/stages/stage-2-design.md +++ b/plugins/deepgrade/skills/plan/stages/stage-2-design.md @@ -63,11 +63,22 @@ written only after scope is locked. ### Requirements - ### Functional: what the system must do. Derive from intent.md Proposed outcome; - every functional requirement traces to a line of the intent. + every functional requirement traces to a line of the intent. Each carries a + priority (P0 cannot ship without; P1 fast follow; P2 design must not block) + and acceptance criteria in Given/When/Then form covering the happy path, at + least one error or boundary case, and one thing that must NOT happen. Each + criterion is independently testable; "fast" or "intuitive" without a number + is not a criterion. Challenge every P0: "Would we really not ship without + this?" - ### Non-functional: performance, security, accessibility, compliance, - operability. Derive from intent.md Constraints. + operability. Derive from intent.md Constraints. Each is measurable. +- ## Success metrics: leading indicators (adoption, task completion, error + rate) and lagging ones (retention, cost, support load), each with a numeric + target, a window, a measurement method, and an evaluation date. Stage 6 + reads these; the plan-auditor scores their presence. - Scope: IN list and OUT list. The OUT list starts from intent.md Out of scope and - grows with anything ruled out during options analysis. + grows with anything ruled out during options analysis. After scope lock, any + addition comes with a removal or a timeline change, recorded in a CR. ### Design diff --git a/plugins/deepgrade/skills/plan/stages/stage-5-deploy.md b/plugins/deepgrade/skills/plan/stages/stage-5-deploy.md index 1000f34..7802982 100644 --- a/plugins/deepgrade/skills/plan/stages/stage-5-deploy.md +++ b/plugins/deepgrade/skills/plan/stages/stage-5-deploy.md @@ -77,12 +77,18 @@ Evidence before opinions. Sections, in order: Important (would break behavior, leak data, or breach a policy) first, then nits. At most 5 nit-level comments; summarize the rest as a count. Do not report generated files or anything CI already enforces. -5. Release checklist — - - Deployment sequence with a verification step after each step (what to run, - what healthy output looks like; reuse plan.md ## Verification) - - Rollback trigger (the observable condition that means "roll back now") - and rollback steps - - Monitoring to watch, for how long, and the threshold that trips rollback +5. Release checklist — follow the Pre-deploy / Deploy / Post-deploy / Rollback + structure in the review.md template: + - Link the plan's runbook if one exists (`/deepgrade:documentation runbook + {plan}` writes it); otherwise the deployment sequence lives inline, one + verification step after each step (what to run, what healthy output looks + like; reuse plan.md ## Verification) + - Rollback triggers as numeric thresholds over a window (error rate, latency, + a failing smoke test, a data-integrity count). "Looks wrong" is not a + trigger. These thresholds are what Stage 6 uses to classify SEV1/SEV2. + - Rollback steps, each with its own verification + - Monitoring: where to look, for how long, and the thresholds above + - Mark checklist items N/A with a reason rather than deleting them - Owner of the release window 6. Release authorization — name, date, decision (Authorized | Rejected | Deferred). Leave blank until Step C. diff --git a/plugins/deepgrade/skills/plan/templates/review.md b/plugins/deepgrade/skills/plan/templates/review.md index 10c2f71..9d809fe 100644 --- a/plugins/deepgrade/skills/plan/templates/review.md +++ b/plugins/deepgrade/skills/plan/templates/review.md @@ -32,13 +32,44 @@ Nits not listed: ## Release checklist -- Deployment sequence: +- Runbook: +- Owner of the release window: + +### Pre-deploy + +- [ ] All tests passing in CI at +- [ ] Findings above resolved or explicitly accepted by the authorizer +- [ ] Database migrations tested against a production-shaped copy (or N/A) +- [ ] Feature flags configured and default state confirmed (or N/A) +- [ ] Breaking API or contract changes: consumers notified (or N/A) +- [ ] Rollback steps below rehearsed or dry-run +- [ ] On-call or owner reachable for the monitoring window + +### Deploy + +1. — verify: +2. — verify: +- [ ] Staging or canary verified before full rollout (or N/A, with reason) +- [ ] Smoke tests run: +- [ ] Key user flows exercised by hand: + +### Post-deploy + +- [ ] Metrics nominal at end of monitoring window +- [ ] Release notes or changelog updated +- [ ] Stakeholders notified +- [ ] Plan status.json and manifest.md updated + +### Rollback + +- Triggers (any one trips rollback; numbers, not feelings): + - Error rate exceeds % over minutes + - P50 or P95 latency exceeds ms over minutes + - fails smoke test + - returns unexpected count +- Steps: 1. — verify: -- Rollback trigger: -- Rollback steps: - 1. -- Monitoring: -- Owner: +- Monitoring: , for , thresholds as above ## Release authorization diff --git a/plugins/deepgrade/skills/plan/templates/spec.md b/plugins/deepgrade/skills/plan/templates/spec.md index 56c951e..e2f5598 100644 --- a/plugins/deepgrade/skills/plan/templates/spec.md +++ b/plugins/deepgrade/skills/plan/templates/spec.md @@ -7,13 +7,32 @@ ## Requirements + + ### Functional - +- **FR-001 (P0 | P1 | P2):** — traces to intent.md + - Given , when , then + - Given , when , then + - Must NOT: ### Non-functional - +- **NFR-001 (P0 | P1 | P2):** — measurable: + +## Success metrics + + + +| Metric | Type | Target | Measured by | Evaluate at | +|---|---|---|---|---| +| | Leading | | | <1 week / 1 month> | +| | Lagging | | | <1 quarter> | ## Design diff --git a/plugins/deepgrade/skills/troubleshoot/SKILL.md b/plugins/deepgrade/skills/troubleshoot/SKILL.md index d5fb62b..6a93eaa 100644 --- a/plugins/deepgrade/skills/troubleshoot/SKILL.md +++ b/plugins/deepgrade/skills/troubleshoot/SKILL.md @@ -149,7 +149,7 @@ this table. | Order | Section | When | File | |-------|---------|------|------| -| 0 | Incident pre-flow (severity triage, containment gate) | On intake, right after plan detection and before Step 0 | `${CLAUDE_SKILL_DIR}/phases/incident-preflow.md` | +| 0 | Incident pre-flow (severity triage, containment gate, status updates) | On intake, right after plan detection and before Step 0 | `${CLAUDE_SKILL_DIR}/phases/incident-preflow.md` | | 1 | Phase 1: Root Cause Investigation | After Step 0 / 0.5 / 0.2 below | `${CLAUDE_SKILL_DIR}/phases/phase-1-root-cause.md` | | 1b | Multi-agent mode | Only if the Phase 1 escalation check is confirmed; replaces Phases 2-3 | `${CLAUDE_SKILL_DIR}/phases/multi-agent-mode.md` | | 2 | Phase 2: Pattern Analysis | Single-agent path after Phase 1 | `${CLAUDE_SKILL_DIR}/phases/phase-2-pattern-analysis.md` | diff --git a/plugins/deepgrade/skills/troubleshoot/phases/incident-preflow.md b/plugins/deepgrade/skills/troubleshoot/phases/incident-preflow.md index ec296e7..f64b0ae 100644 --- a/plugins/deepgrade/skills/troubleshoot/phases/incident-preflow.md +++ b/plugins/deepgrade/skills/troubleshoot/phases/incident-preflow.md @@ -1,4 +1,4 @@ -# Incident Pre-Flow (Phase 0: Severity / Triage and Containment Gate) +# Incident Pre-Flow (Phase 0: Severity / Triage, Containment Gate, Status Updates) Phase file for /deepgrade:troubleshoot, loaded by SKILL.md on entry. @@ -74,4 +74,38 @@ Record `T_CONTAINED` after containment (or "N/A" if skipped or no mitigation ava LOG the containment action, what was mitigated, and any temporary tradeoffs (e.g., "new feature disabled until permanent fix"). + +### Status Updates (SEV1/SEV2 only) + +While an incident is open, people who are not in the investigation need to +know four things: what is happening, who is affected, what is being done, and +when the next update comes. Produce the first update immediately after the +Containment Gate and repeat on a fixed cadence (SEV1: every 30 minutes; +SEV2: every 60 minutes) until Status is Resolved. Keep updates factual. No +speculation about cause until Phase 3 confirms it. + +```markdown +## Incident Update: {title} +**Severity:** SEV{N} · **Status:** Investigating | Identified | Monitoring | Resolved +**Impact:** {who or what is affected, in plain terms} +**Last Updated:** {timestamp} · **Next Update:** {timestamp} + +### Current Status +{What is known now. Verified facts only.} + +### Actions Taken +- {containment applied, with time} +- {investigation step completed} + +### Next Steps +- {what happens next and its ETA} + +### Timeline +| Time | Event | +|------|-------| +| {HH:MM} | {event} | +``` + +Append each update to the troubleshooting log under `## Status Updates` so +the postmortem timeline in Step 5 can be assembled from them. diff --git a/plugins/deepgrade/skills/troubleshoot/phases/step-5-knowledge-base.md b/plugins/deepgrade/skills/troubleshoot/phases/step-5-knowledge-base.md index 1d655c8..bf9d6df 100644 --- a/plugins/deepgrade/skills/troubleshoot/phases/step-5-knowledge-base.md +++ b/plugins/deepgrade/skills/troubleshoot/phases/step-5-knowledge-base.md @@ -11,6 +11,8 @@ Phase file for /deepgrade:troubleshoot, loaded by SKILL.md on entry. - Detect Guardrail Patterns - Detect Recurrence (Correlation-Driven) - Flag Impact Review Gaps +- Write a Postmortem (SEV1/SEV2 only) +- Close the Loop: Propose a New Intent ## Step 5: Log and Update Knowledge Base @@ -112,6 +114,12 @@ Derived from raw timestamps above. Do not estimate — calculate from the timeli ## Prevention {1-2 sentence summary of architectural or process-level prevention beyond guardrails} + +## Status Updates +{SEV1/SEV2 only: each Incident Update block from the pre-flow, in order. N/A otherwise.} + +## Postmortem +{SEV1/SEV2 only: path to the postmortem file. N/A otherwise.} ``` ### Update Knowledge Base @@ -193,6 +201,68 @@ If the issue reveals something the Impact Review missed: "This wasn't caught by the Impact Review. Consider adding '{check}' to future reviews for changes in this area." +### Write a Postmortem (SEV1/SEV2 only) + +SEV3/SEV4: skip. For SEV1/SEV2, write a blameless postmortem beside the log +at `{log directory}/YYYY-MM-DD-{issue-slug}-postmortem.md`. Blameless means +the subject of every sentence is a system, a process, or a gap, never a +person. Assemble Timeline from the log's Timeline table and the Status +Updates; assemble Root Cause and 5 Whys from Phase 3; assemble Contributing +Factors from the KB entry. + +```markdown +# Postmortem: {Incident Title} + +**Date:** {date} · **Duration:** {T_FIX_VERIFIED - T_START, or user-facing impact window if longer} +**Severity:** SEV{N} · **Status:** Draft | Reviewed +**Plan:** {plan name or "standalone"} · **Log:** {path} + +## Summary +{2-3 sentences in plain language: what broke, who felt it, how it was fixed.} + +## Impact +- Users affected: {who and roughly how many} +- Duration of impact: {from first symptom to restored service} +- Business impact: {quantified if possible: failed orders, lost hours, SLA breach} + +## Timeline +| Time | Event | +|------|-------| +| {HH:MM} | {first symptom, detection, containment, root cause found, fix verified} | + +## Root Cause +{From Phase 3. One paragraph. What was actually wrong, not what the symptom was.} + +## 5 Whys +1. Why did {symptom}? Because {cause 1}. +2. Why {cause 1}? Because {cause 2}. +3. Why {cause 2}? Because {cause 3}. +4. Why {cause 3}? Because {cause 4}. +5. Why {cause 4}? {Root cause: the systemic condition.} + +## Contributing Factors +{Conditions that made the incident possible or slower to resolve: missing +guardrails, gaps in monitoring, unclear ownership, stale runbook.} + +## What Went Well +- {detection, containment, or communication that worked, and why} + +## What Went Poorly +- {what was slow, missing, or misleading, stated as a gap in a system or process} + +## Action Items +| Action | Owner | Priority | Due | Tracked in | +|--------|-------|----------|-----|------------| +| {guardrail, fix, or process change} | {person or role} | P0 / P1 / P2 | {date} | {intent path, ticket, or "none yet"} | + +## Lessons Learned +{What this incident teaches that applies beyond this one bug.} +``` + +Every P0 action item either becomes part of the proposed intent below or is +tracked somewhere named in the table. An action item with no owner and no +tracker is a wish. + ### Close the Loop: Propose a New Intent (Stage 6 of /deepgrade:plan) The playbook's Maintain stage feeds incidents back into planning as intent, never diff --git a/tests/fixtures/hook-corpus.json b/tests/fixtures/hook-corpus.json deleted file mode 100644 index 21ac66e..0000000 --- a/tests/fixtures/hook-corpus.json +++ /dev/null @@ -1,640 +0,0 @@ -{ - "_comment": [ - "PHV5-041 acceptance corpus for the PreToolUse:Bash guard. This is the FALSIFIER:", - "a matcher that fails any row here fails the ticket, whatever its implementation.", - "", - "This corpus lives in a FILE, not in test command lines, for a reason discovered", - "while writing it: the live guard denies any Bash command whose text contains a", - "trigger string, so a harness that passes payloads as shell arguments gets blocked", - "before it can run. That is F24 reproducing itself against its own test suite.", - "", - "want_exit: 0 = allow (or ask, which is also exit 0 with JSON), 2 = deny.", - "want_decision: expected permissionDecision once F22/F26 land; null = no JSON needed." - ], - "cases": [ - { - "id": "F25-lease-allow", - "want_exit": 0, - "want_decision": null, - "why": "F25: --force-with-lease is the safe form and must be allowed. A '--force\\b' pattern matches INSIDE it because '-' is a word boundary.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push --force-with-lease" - } - } - }, - { - "id": "F24-reproduction-deny", - "want_exit": 2, - "want_decision": "deny", - "why": "The reproduction payload named verbatim in the F24 acceptance row. A real force-push in a compound command must be denied via the PARSED tool_input.command — the quoted commit message must not shield it.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git commit -m \"wip\" && git push --force" - } - } - }, - { - "id": "F25-force-deny", - "want_exit": 2, - "want_decision": "deny", - "why": "F25: the unqualified long flag must be denied.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push --force" - } - } - }, - { - "id": "F25-shortflag-deny", - "want_exit": 2, - "want_decision": "deny", - "why": "F25: bare -f is the same operation and must be denied.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push -f origin main" - } - } - }, - { - "id": "F24-quoted-mention-allow", - "want_exit": 0, - "want_decision": null, - "why": "F24 core: the danger string appears only inside a quoted argument. Tokenize or strip quoted spans before matching; a bare regex over the raw command denies this.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git commit -m \"never use git push --force on main\"" - } - } - }, - { - "id": "F24-decoy-description-allow", - "want_exit": 0, - "want_decision": null, - "why": "F24 cross-field decoy: enforce only what is parsed from tool_input.command. NOTE this row is necessary but NOT sufficient — mutation M4 proved it cannot detect a raw-payload grep for '\"command\":', because 'description' is a different key name and the grep still finds the right field. F24-decoy-sibling-key-allow is the row that discriminates.", - "payload": { - "session_id": "s", - "description": "git push --force", - "tool_input": { - "command": "ls -la" - } - } - }, - { - "id": "F24-decoy-sibling-key-allow", - "want_exit": 0, - "want_decision": null, - "why": "F24 field-blindness, the discriminating form: a SIBLING object carries its own 'command' key holding a danger string, serialized BEFORE tool_input. A first-match grep for '\"command\":\"[^\"]*\"' picks up meta.command and denies a benign 'ls'. Only extraction of the named tool_input.command path allows this. Added after mutation M4 escaped the weaker decoy row.", - "payload": { - "session_id": "s", - "meta": { - "command": "git push --force" - }, - "tool_input": { - "command": "ls -la" - } - } - }, - { - "id": "F24-decoy-content-allow", - "want_exit": 0, - "want_decision": null, - "why": "F24 cross-field decoy in a different field name.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "cat notes.txt", - "content": "reminder: supabase db push is banned" - } - } - }, - { - "id": "F24-readonly-grep-allow", - "want_exit": 0, - "want_decision": null, - "why": "Audit v1 gap 2, reproduced live: the current guard denies a read-only grep whose SEARCH PATTERN contains a DB-deploy string.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "grep -rn 'supabase db push' docs/" - } - } - }, - { - "id": "F22-reset-hard-ask", - "want_exit": 0, - "want_decision": "ask", - "why": "F22: destructive-but-legitimate. Must be permissionDecision 'ask' at exit 0, never a deny, and never stderr on exit 0 (F26).", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git reset --hard" - } - } - }, - { - "id": "row1-db-push-deny", - "want_exit": 2, - "want_decision": "deny", - "why": "Ledger row 1: the DB-deploy guard exists ONLY inline today. It must survive the port, and must run BEFORE the non-git early-exit.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "supabase db push" - } - } - }, - { - "id": "row1-dry-run-allow", - "want_exit": 0, - "want_decision": null, - "why": "Ledger row 1 exemption: --dry-run validates without deploying.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "supabase db push --dry-run" - } - } - }, - { - "id": "row1-prisma-deny", - "want_exit": 2, - "want_decision": "deny", - "why": "Ledger row 1, second engine.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "prisma migrate deploy" - } - } - }, - { - "id": "row1-after-earlyexit-deny", - "want_exit": 2, - "want_decision": "deny", - "why": "Ledger row 1 ordering proof: this command is NOT a git command, so any DB guard placed below the 'git (commit|push) || exit 0' line is dead code and this row passes only if the guard runs first.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "dotnet ef database update" - } - } - }, - { - "id": "F24-exemption-position", - "want_exit": 2, - "want_decision": "deny", - "why": "Exemption tokens count only inside the command field. The token is embedded in a space-delimited sentence so that a whitespace-anchored regex applied to the RAW payload WOULD match it — the earlier form ('--dry-run' alone) was quote-wrapped in the JSON and matched neither way, so mutation M8 escaped it. Now the row discriminates.", - "payload": { - "session_id": "s", - "description": "validate with --dry-run first", - "tool_input": { - "command": "flyway migrate" - } - } - }, - { - "id": "F24-malformed-fail-closed", - "want_exit": 2, - "want_decision": "deny", - "why": "3.1.6: a malformed payload under a REAL parser fails closed. Distinct from having no parser at all, which allows.", - "payload_raw": "{\"session_id\":\"s\",\"tool_input\":{\"command\":\"git push --force\"", - "payload": null - }, - { - "id": "F24-no-command-allow", - "want_exit": 0, - "want_decision": null, - "why": "Nothing to enforce means nothing to deny — never deny on an absent field.", - "payload": { - "session_id": "s", - "tool_input": {} - } - }, - { - "id": "bypass-quoted-flag-double", - "want_exit": 2, - "want_decision": "deny", - "why": "ADVERSARIAL. `git push \"--force\"` is an ordinary working command. The first tokenizer DELETED quoted spans, turning this into `git push \"\"` and allowing it — a regression, since the old bare-regex guard caught it. Every one of the original 18 rows quoted PROSE; none quoted a FLAG, so none could detect this. Word splitting keeps the content and matches word sequences.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push \"--force\"" - } - } - }, - { - "id": "bypass-quoted-flag-single", - "want_exit": 2, - "want_decision": "deny", - "why": "ADVERSARIAL. Same evasion with single quotes.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push '--force'" - } - } - }, - { - "id": "bypass-flag-split-by-quotes", - "want_exit": 2, - "want_decision": "deny", - "why": "ADVERSARIAL. `--fo''rce` is one word to a shell. Span deletion produced `--fo''rce` as a token and missed it; word splitting joins the segments back into `--force`.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push --fo''rce" - } - } - }, - { - "id": "bypass-quoted-subcommand", - "want_exit": 2, - "want_decision": "deny", - "why": "ADVERSARIAL. `git \"push\" --force` — the OLD guard missed this too, so it is a genuine improvement rather than a restored regression.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git \"push\" --force" - } - } - }, - { - "id": "bypass-quoted-db-verb", - "want_exit": 2, - "want_decision": "deny", - "why": "ADVERSARIAL. `supabase db \"push\"` deploys. Missed by both the old regex guard and the span-deleting tokenizer.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "supabase db \"push\"" - } - } - }, - { - "id": "bypass-quoted-short-flag", - "want_exit": 2, - "want_decision": "deny", - "why": "ADVERSARIAL. `git push \"-f\"` — quoted short flag.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push \"-f\" origin main" - } - } - }, - { - "id": "bypass-quoted-hard-reset-ask", - "want_exit": 0, - "want_decision": "ask", - "why": "ADVERSARIAL, and a reminder that 'not denied' is not 'not handled'. A quoted `\"--hard\"` must still reach F22's ask. The bypass probe initially scored this as an escape because it treated exit != 2 as unhandled, which is wrong for the ask case.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git reset \"--hard\"" - } - } - }, - { - "id": "segment-isolation-no-false-ask", - "want_exit": 0, - "want_decision": null, - "why": "NEGATIVE for the per-segment fix: `--hard` belongs to a different command than `git reset`, so it must not trigger the ask. The pre-fix implementation scanned the whole command for the token and would have prompted.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git reset --soft HEAD~1 && tar --hard -cf out.tar ." - } - } - }, - { - "id": "exemption-wrong-segment-deny", - "want_exit": 2, - "want_decision": "deny", - "why": "An exemption token must be honoured only in the SAME segment as the deploy it exempts. Here `--dry-run` belongs to a separate `echo`, so the deploy still runs and must be denied. Added after mutation M8 (honour exemptions from any segment) escaped: the existing exemption row tests cross-FIELD placement, which is a different thing from cross-SEGMENT.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "supabase db push && echo --dry-run" - } - } - }, - { - "id": "F24-escaped-quote-allow", - "want_exit": 0, - "want_decision": null, - "why": "The '\"command\":\"[^\"]*\"' grep truncates at the first quote, so an escaped quote inside the command silently changes what gets matched.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "echo \"a \\\" quote then git push --force\"" - } - } - }, - { - "id": "H1-global-opt-c", - "want_exit": 2, - "want_decision": "deny", - "why": "H1. Adjacency matching required the command words to touch, but git accepts global options between them. This one defect broke all four rules plus the DG_STRICT_GIT gate. Verified against a real argv shim by the reviewer.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git -c core.pager=cat push --force" - } - } - }, - { - "id": "H1-global-opt-nopager", - "want_exit": 2, - "want_decision": "deny", - "why": "H1, long-form global option.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git --no-pager push --force" - } - } - }, - { - "id": "H1-global-opt-C", - "want_exit": 2, - "want_decision": "deny", - "why": "H1, option WITH a value; the value must be consumed too or the next word check misaligns.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git -C . push --force" - } - } - }, - { - "id": "H1-global-opt-supabase", - "want_exit": 2, - "want_decision": "deny", - "why": "H1 is not git-specific; the DB verbs take global options as well.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "supabase --workdir . db push" - } - } - }, - { - "id": "H1-global-opt-dotnet", - "want_exit": 2, - "want_decision": "deny", - "why": "H1 on a four-word command sequence.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "dotnet ef --project src/App database update" - } - } - }, - { - "id": "H1-global-opt-reset-ask", - "want_exit": 0, - "want_decision": "ask", - "why": "H1 applied to F22: a global option must not turn the ask into silence.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git -C . reset --hard" - } - } - }, - { - "id": "H6-newline-exemption-laundering", - "want_exit": 2, - "want_decision": "deny", - "why": "H6. `\\n` never ended a segment because /\\s/ matched it first, making the newline test dead code. The exemption was laundered from a DIFFERENT command — the same defect corpus row exemption-wrong-segment-deny guards for `&&`.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "supabase db push\necho --dry-run" - } - } - }, - { - "id": "H6-newline-false-positive", - "want_exit": 0, - "want_decision": null, - "why": "H6, the other direction and the worse one for users: a benign push plus an unrelated `grep -f` was BLOCKED as a force push, because both lines were one segment.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push origin main\ngrep -f patterns.txt build.log" - } - } - }, - { - "id": "H6-newline-reset-no-false-ask", - "want_exit": 0, - "want_decision": null, - "why": "H6 applied to F22: `--hard` in a separate command must not prompt.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git reset --soft HEAD~1\ntar --hard -cf out.tar ." - } - } - }, - { - "id": "H5-refspec-plus", - "want_exit": 2, - "want_decision": "deny", - "why": "H5. A leading `+` on a refspec IS a force push and carries no flag to find, so no tokenizer change reaches it. Absent from the model entirely before this.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push origin +main" - } - } - }, - { - "id": "H5-refspec-full", - "want_exit": 2, - "want_decision": "deny", - "why": "H5, fully qualified refspec form.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push origin +HEAD:refs/heads/main" - } - } - }, - { - "id": "M1-shortflag-4f", - "want_exit": 2, - "want_decision": "deny", - "why": "M1. `-4` is git's IPv4 flag, so `-4f` is a real bundle containing -f. The regex assumed all-alpha bundles. The reviewer confirmed against real git that `-4f` parses as options while `-4z` errors.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push -4f origin main" - } - } - }, - { - "id": "H4-line-continuation", - "want_exit": 2, - "want_decision": "deny", - "why": "H4. A backslash-newline is a line continuation. The escape branch appended the newline to the next word, producing \"\\n--force\", which matched neither the flag nor the sequence.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push \\\n--force" - } - } - }, - { - "id": "H2-ansi-c-quoting", - "want_exit": 0, - "want_decision": "ask", - "why": "H2. The shell expands what a static matcher cannot see. Per 3.1.6 a guard may never DENY on something unparsed, but `ask` is not a deny — so an unevaluable construct becomes a prompt instead of a silent allow.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git push $'--force'" - } - } - }, - { - "id": "H2-ifs-splitting", - "want_exit": 0, - "want_decision": "ask", - "why": "H2. The whole command is ONE word, so a basename check saw no guarded program; a word containing an expansion is now searched for a guarded name as a substring.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git${IFS}push${IFS}--force" - } - } - }, - { - "id": "H2-variable-indirection", - "want_exit": 0, - "want_decision": "ask", - "why": "H2, flag supplied via a variable.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "F=--force; git push $F" - } - } - }, - { - "id": "H2-backtick-substitution", - "want_exit": 0, - "want_decision": "ask", - "why": "H2. `$(...)` was already caught because `(` is a separator; a backtick is not, and the word \"`git\" failed a basename check.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "echo `git push --force`" - } - } - }, - { - "id": "H3-nested-bash-c", - "want_exit": 0, - "want_decision": "ask", - "why": "H3. A nested shell carries its entire program inside one word. This is a shape an LLM emits unprompted.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "bash -c \"git push --force\"" - } - } - }, - { - "id": "H3-nested-sh-c", - "want_exit": 0, - "want_decision": "ask", - "why": "H3, nested shell around a DB deploy.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "sh -c 'supabase db push'" - } - } - }, - { - "id": "H3-eval", - "want_exit": 0, - "want_decision": "ask", - "why": "H3, eval.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "eval \"git push --force\"" - } - } - }, - { - "id": "noise-plain-var-allow", - "want_exit": 0, - "want_decision": null, - "why": "NOISE CONTROL for the ask rule. Ask-on-unparseable is scoped to segments that mention a guarded program or run a nested shell; without this row the rule could be widened until it prompts on everything and no test would object.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "echo $HOME" - } - } - }, - { - "id": "noise-var-no-guarded-allow", - "want_exit": 0, - "want_decision": null, - "why": "NOISE CONTROL: an expansion with no guarded program must stay silent.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "FOO=1 npm run lint $EXTRA" - } - } - }, - { - "id": "noise-var-other-segment-allow", - "want_exit": 0, - "want_decision": null, - "why": "NOISE CONTROL: the expansion is in a segment containing no guarded program, so it cannot change what git does. Allow is correct — I initially expected ask here and was wrong.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "git log --oneline | head -$N" - } - } - }, - { - "id": "rake-db-migrate-deny", - "want_exit": 2, - "want_decision": "deny", - "why": "L6. `rake db:migrate` is the classic Rails form and only `rails db:migrate` was listed, so the most common invocation was unguarded.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "rake db:migrate" - } - } - }, - { - "id": "M2-absolute-path-git", - "want_exit": 2, - "want_decision": "deny", - "why": "M2. The command word had to be literally `git`; an absolute path or a `.exe` suffix evaded every rule. Matched on basename now.", - "payload": { - "session_id": "s", - "tool_input": { - "command": "/usr/bin/git push --force" - } - } - } - ] -} diff --git a/tests/layer1-config-wiring.sh b/tests/layer1-config-wiring.sh index e6d7cb0..ddab126 100644 --- a/tests/layer1-config-wiring.sh +++ b/tests/layer1-config-wiring.sh @@ -29,7 +29,6 @@ PARTS=( "plugins/deepgrade deepgrade" "plugins/deepgrade-readiness deepgrade-readiness" "plugins/deepgrade-audit deepgrade-audit" - "plugins/deepgrade-guard deepgrade-guard" ) COUNTS_FILE=$(mktemp) diff --git a/tests/layer1-core.sh b/tests/layer1-core.sh index 0248c18..3d28b82 100644 --- a/tests/layer1-core.sh +++ b/tests/layer1-core.sh @@ -105,30 +105,6 @@ case "$PROFILE" in F08_CHECK=1; F30_POS=0; F32_CHECK=0; TPL_CHECK=0 NODE_REQ=0; GUIDE_8E=0 ;; - deepgrade-guard) - # Safety rails: hooks ONLY. The complete TMPDIR marker bus lives here. - PLUGIN_NAME="deepgrade-guard" - OWN_NS="deepgrade-guard" - EXPECTED_EVENTS="PreToolUse PostToolUse Stop" - FORBIDDEN_EVENTS="SessionStart SubagentStop PreCompact" - EXPECT_HOOKS=1 - F06_REF_FLOOR=5 - SUBAGENT_WIRED=0 - MARKER_CHECK=both - HAS_HELP=0 - F02_CHECK=0 - F03_EXACT="" - F07_FLOOR=0 - F21_FLOOR=0 - F27_FLOOR=0 - F14_SET="" - EXPECT_COMMANDS=0 - EXPECT_AGENTS=0 - F14_PLAN_NEG=0 - F13_CHECK=0; F09_POS=0; F10_POS=0; F11_ARGHINT=0; F15_ZIP=0 - F08_CHECK=0; F30_POS=0; F32_CHECK=0; TPL_CHECK=0 - NODE_REQ=1; GUIDE_8E=0 - ;; *) echo "[FAIL] unknown profile '$PROFILE' — refusing to run with undefined floors" exit 1 @@ -674,12 +650,13 @@ pj_marker_prefix=$(grep -ohE "dg-(baseline|build|test)-" scripts/*.js 2>/dev/nul | head -1 | sed 's/-[a-z]*-$//') if [ "$MARKER_CHECK" = "absent" ]; then - # The TMPDIR marker bus lives complete in deepgrade-guard. Another plugin - # growing a marker surface splits the bus, which is the partition defect. + # The TMPDIR marker bus was retired with deepgrade-guard in 9.0.0. No shipped + # plugin writes or documents session markers; one growing a marker surface is + # reviving a bus with no readers. if [ -n "$readme_marker_prefix" ] || [ -n "$pj_marker_prefix" ]; then - fail "session markers belong to deepgrade-guard alone — this plugin surfaces prefix '${readme_marker_prefix:-$pj_marker_prefix}-*'" + fail "session markers were retired with deepgrade-guard (9.0.0) — this plugin surfaces prefix '${readme_marker_prefix:-$pj_marker_prefix}-*'" else - pass "no session-marker surface in this plugin (the marker bus is deepgrade-guard's)" + pass "no session-marker surface in this plugin (the marker bus was retired in 9.0.0)" fi elif [ -n "$readme_marker_prefix" ] && [ -n "$pj_marker_prefix" ]; then if [ "$readme_marker_prefix" = "$pj_marker_prefix" ]; then @@ -907,7 +884,7 @@ else for tok in $(grep -rhoE '(^|[[:space:]"'"'"'(`])/[a-z][a-z-]*' "$TPL_DIR" 2>/dev/null \ | tr -d ' "'"'"'(`' | sort -u); do case "$tok" in - /deepgrade|/deepgrade-readiness|/deepgrade-audit|/deepgrade-guard) ;; + /deepgrade|/deepgrade-readiness|/deepgrade-audit) ;; *) fail "F31: template references dead command '$tok' (only the four DeepGrade namespaces are valid)" bad_cmd=$((bad_cmd + 1)) ;; diff --git a/tests/layer1-repo.sh b/tests/layer1-repo.sh index 86dc87f..2f98f2e 100644 --- a/tests/layer1-repo.sh +++ b/tests/layer1-repo.sh @@ -910,7 +910,7 @@ if [ -n "$root_auto" ]; then else pass "F04: no 'picks up changes automatically' claim in root docs" fi -root_bare=$(grep -nE "/plugin install (deepgrade|deepgrade-readiness|deepgrade-audit|deepgrade-guard)([[:space:]]|\$)" $DOC_FILES 2>/dev/null | head -1) +root_bare=$(grep -nE "/plugin install (deepgrade|deepgrade-readiness|deepgrade-audit)([[:space:]]|\$)" $DOC_FILES 2>/dev/null | head -1) if [ -n "$root_bare" ]; then fail "F04: unqualified install command in a root doc (missing @deepgrade-marketplace): $root_bare" else @@ -964,13 +964,14 @@ fi # # The release script enforces this at release time; this copy enforces it on # every suite run, so a drifted manifest is caught in the PR that drifts it -# rather than on release day. The count floor is EXACTLY four — a fifth plugin -# is a deliberate decision that must update this number in the same commit. +# rather than on release day. The count is EXACTLY three (four until +# deepgrade-guard was retired in 9.0.0) — adding or removing a plugin is a +# deliberate decision that must update this number in the same commit. # =========================================================================== split_manifests=$(git ls-files '*/.claude-plugin/plugin.json') split_mcount=$(echo "$split_manifests" | grep -c . || true) -if [ "$split_mcount" -ne 4 ]; then - fail "SPLIT-2: found $split_mcount plugin manifests, expected exactly 4" +if [ "$split_mcount" -ne 3 ]; then + fail "SPLIT-2: found $split_mcount plugin manifests, expected exactly 3" else split_vers=$(for m in $split_manifests; do grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$m" | head -1 | sed 's/.*"\([0-9][^"]*\)"$/\1/' @@ -978,7 +979,7 @@ else if [ "$(echo "$split_vers" | grep -c .)" -ne 1 ]; then fail "SPLIT-2: version lockstep broken across manifests: $(echo $split_vers | tr '\n' ' ')" else - pass "SPLIT-2: all 4 plugin manifests in lockstep at $split_vers" + pass "SPLIT-2: all 3 plugin manifests in lockstep at $split_vers" fi fi @@ -996,7 +997,7 @@ fi # design. Longest-alternative-first is cosmetic — grep -E is leftmost-longest, # so 'deepgrade' can never shadow 'deepgrade-audit' at the same position. # =========================================================================== -ns_re='(deepgrade-readiness|deepgrade-audit|deepgrade-guard|deepgrade):[a-z][a-z0-9-]*' +ns_re='(deepgrade-readiness|deepgrade-audit|deepgrade):[a-z][a-z0-9-]*' # Self-tests. The known-positive is drawn from the LIVE artifact (help.md), not # authored here — a known-positive written alongside the pattern shares its @@ -1066,8 +1067,8 @@ else # Self-test: the extractor must actually read entries out of THIS file, or # every per-entry assertion below sweeps an empty set and reports clean. cat_n=$(jq '.plugins | length' "$CATALOG" 2>/dev/null | tr -d '\r') - if [ "${cat_n:-0}" -ne 4 ]; then - fail "SPLIT-4: catalog lists ${cat_n:-0} plugins, expected exactly 4 — a fifth entry is a deliberate decision that updates this number" + if [ "${cat_n:-0}" -ne 3 ]; then + fail "SPLIT-4: catalog lists ${cat_n:-0} plugins, expected exactly 3 — a fourth entry (or a removal) is a deliberate decision that updates this number" cat_bad=1 fi @@ -1129,9 +1130,9 @@ fi # undocumented new edge survives. # # Subjects are FUNCTIONAL files only (commands/, agents/, skills/, scripts/): -# README/GUIDE mentions are description, not consumption — the guard README -# shipped a whole output-table row describing a sibling's writer, which is -# exactly why prose does not count as an edge. +# README/GUIDE mentions are description, not consumption — the (since retired) +# guard README once shipped a whole output-table row describing a sibling's +# writer, which is exactly why prose does not count as an edge. # =========================================================================== echo "" echo "--- Interop contracts (INTEROP-1/2/3) ---" @@ -1189,7 +1190,7 @@ else # one. An artifact referenced from functional files of 2+ plugins is an # edge whether or not anyone wrote it down. it_tmp=$(mktemp) - for it_p in deepgrade deepgrade-readiness deepgrade-audit deepgrade-guard; do + for it_p in deepgrade deepgrade-readiness deepgrade-audit; do git ls-files -z "plugins/$it_p/commands/*" "plugins/$it_p/agents/*" \ "plugins/$it_p/skills/*" "plugins/$it_p/scripts/*" 2>/dev/null \ | xargs -0 -r grep -ohE "$it_path_re" 2>/dev/null | sort -u | sed "s|^|$it_p |" diff --git a/tests/layer2-hook-suite.sh b/tests/layer2-hook-suite.sh index 9aa0b0c..434ff72 100644 --- a/tests/layer2-hook-suite.sh +++ b/tests/layer2-hook-suite.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash # Layer 2 dispatcher — PHV5-042 / PHV5-043. # -# 1. run-hook-corpus.js — lane N's parser contract (F24/F22/F25/F26), the -# acceptance authority for PHV5-041. -# 2. layer2-ledger-rows.js — one falsifying test per behaviour-ledger row -# (§3.1.4), the acceptance for PHV5-040. +# 1. layer2-ledger-rows.js — one falsifying test per behaviour-ledger row +# (§3.1.4) for the plan-context handlers that remain in deepgrade. +# +# run-hook-corpus.js and tests/fixtures/hook-corpus.json were DELETED in 9.0.0 +# with the deepgrade-guard plugin whose git guard they exercised. The ledger rows +# for the guard, migration, tracker and stop handlers went with them. # # layer2-hook-simulation.sh was DELETED at 4b along with the inline hooks it # exercised. It drove handlers embedded in plugin.json through positional-index @@ -28,7 +30,7 @@ run_part() { if [ "$runner" = "node" ]; then if ! command -v node >/dev/null 2>&1; then echo "[FAIL] node is required for Layer 2.$label and was not found on PATH" - echo " (never a silent skip — lane N's guards run ON node, so an absent" + echo " (never a silent skip — the handlers run ON node, so an absent" echo " interpreter means this layer proves nothing, not that it passed)" FAILED=1; return fi @@ -46,8 +48,15 @@ if [ -f tests/layer2-hook-simulation.sh ]; then FAILED=1 fi -run_part "1 lane-N parser contract (F24/F22/F25/F26)" node tests/run-hook-corpus.js plugins/deepgrade-guard/scripts/dg-git-guard.js -run_part "2 lane-N behaviour ledger (rows 1-11)" node tests/layer2-ledger-rows.js +# Guard against silently losing the retired part the other way round: a resurrected +# corpus runner with no dispatch would read as coverage too. +if [ -f tests/run-hook-corpus.js ] || [ -f tests/fixtures/hook-corpus.json ]; then + echo "[FAIL] the hook corpus was retired with deepgrade-guard (9.0.0) but a copy is back in the tree —" + echo " either dispatch it against a live handler or delete it." + FAILED=1 +fi + +run_part "1 plan-context behaviour ledger (rows 4, 10, F26)" node tests/layer2-ledger-rows.js echo "" if [ "$FAILED" -ne 0 ]; then diff --git a/tests/layer2-ledger-rows.js b/tests/layer2-ledger-rows.js index 63d62b7..7cf28de 100644 --- a/tests/layer2-ledger-rows.js +++ b/tests/layer2-ledger-rows.js @@ -2,12 +2,13 @@ // PHV5-040 acceptance: one falsifying test per behaviour-ledger row (approach.md // §3.1.4), plus the F26 output-shape rule across every informational handler. // -// The ledger is BIDIRECTIONAL: rows 1-2 exist only in the inline implementation -// and rows 3, 5-11 only in scripts/. Migrating either direction naively deletes -// working guards. Each row below fails if its behaviour is absent. +// Only the plan-context handlers remain. Rows 1-3, 5-9 and 11 exercised the +// git guard, migration guard, change/test trackers and the Stop summary, all of +// which shipped in deepgrade-guard and were RETIRED with it in 9.0.0. The rows +// that survive (4, 10, F26) are the ones whose subject is still in the tree. // -// Runs in a scratch directory so tracker files and plan fixtures never touch the -// real repo or the developer's temp state. +// Runs in a scratch directory so plan fixtures never touch the real repo or the +// developer's temp state. 'use strict'; const fs = require('fs'); const os = require('os'); @@ -17,9 +18,9 @@ const { spawnSync } = require('child_process'); const ROOT = path.resolve(__dirname, '..'); // Scratch dirs are swept at exit — before this existed every run leaked its -// dirs into the OS temp (3,000+ had accumulated). force+maxRetries because the -// row-6 scratch git repos hold read-only objects on Windows; a cleanup failure -// must never fail the run. +// dirs into the OS temp (3,000+ had accumulated). force+maxRetries because +// Windows can hold read-only handles briefly; a cleanup failure must never +// fail the run. const scratchDirs = []; function mkScratch(prefix) { const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -32,16 +33,12 @@ process.on('exit', () => { } }); -// The split partitions handlers across two plugins: the safety rails live in -// deepgrade-guard, the plan-context handlers in deepgrade. Resolve each script -// wherever it ships, and THROW on a miss — a silently-skipped handler would -// read as a passing row, which is the vacuous-pass species. +// Resolve each script where it ships, and THROW on a miss — a silently-skipped +// handler would read as a passing row, which is the vacuous-pass species. const S = (n) => { - for (const p of ['deepgrade-guard', 'deepgrade']) { - const c = path.join(ROOT, 'plugins', p, 'scripts', n); - if (fs.existsSync(c)) return c; - } - throw new Error(`handler ${n} found in no plugin scripts/ dir — the ledger row has no subject`); + const c = path.join(ROOT, 'plugins', 'deepgrade', 'scripts', n); + if (fs.existsSync(c)) return c; + throw new Error(`handler ${n} not found under plugins/deepgrade/scripts/ — the ledger row has no subject`); }; let pass = 0; @@ -51,7 +48,7 @@ function check(name, ok, detail) { else { fails.push(name); console.log(`[FAIL] ${name}${detail ? ' — ' + detail : ''}`); } } -// Each run gets its own TMPDIR so session markers cannot leak between rows. +// Each run gets its own TMPDIR so nothing can leak between rows. function run(script, payload, opts = {}) { const tmp = mkScratch('dg-row-'); const env = { ...process.env, TMPDIR: tmp, TEMP: tmp, ...(opts.env || {}) }; @@ -63,217 +60,11 @@ function run(script, payload, opts = {}) { } // --------------------------------------------------------------------------- -console.log('--- Ledger rows 1-2: behaviours that exist ONLY inline today ---'); - -// Row 1 is covered in depth by tests/fixtures/hook-corpus.json; asserted here too -// so the ledger has its own evidence and neither file is the sole record. -const r1 = run('dg-git-guard.js', { session_id: 's', tool_input: { command: 'supabase db push' } }); -check('row 1: database deploy denied', r1.exit === 2 && /BLOCKED/.test(r1.err), `exit ${r1.exit}`); -const r1b = run('dg-git-guard.js', { session_id: 's', tool_input: { command: 'supabase db diff' } }); -check('row 1: `db diff` (read-only) allowed', r1b.exit === 0, `exit ${r1b.exit}`); - -// Row 2: Windows backslash normalization in the migration guard. -const scratch = mkScratch('dg-mig-'); -fs.mkdirSync(path.join(scratch, 'db', 'migrations'), { recursive: true }); -const migFile = path.join(scratch, 'db', 'migrations', '20240101_init.sql'); -fs.writeFileSync(migFile, 'select 1;\n'); -const backslashPath = migFile.replace(/\//g, '\\'); -const r2 = run('dg-migration-guard.js', { tool_input: { file_path: backslashPath } }, { cwd: scratch }); -check('row 2: backslash path recognized as a migration', r2.exit === 2, `exit ${r2.exit}`); -const r2b = run('dg-migration-guard.js', { tool_input: { file_path: migFile } }, { cwd: scratch }); -check('row 2: forward-slash path still recognized', r2b.exit === 2, `exit ${r2b.exit}`); - -// --------------------------------------------------------------------------- -console.log('\n--- Ledger row 3: wider migration coverage (script side) ---'); -// Each filename must isolate exactly ONE recognition rule. The first version of -// this test used `.sql` for every shape, so the `.sql` extension rule caught them -// all and the prefix rules were never exercised — mutation P2 deleted the Flyway -// V-prefix pattern and the row stayed green. These are real-world non-SQL -// migration filenames, one per rule. -const covered = [ - ['plain.sql', 'SQL extension'], - ['0001_initial.py', 'Django/alembic 4-digit underscore prefix'], - ['20240101120000_Init.cs', 'EF Core timestamp prefix'], - ['V2__Add_index.java', 'Flyway V-prefix (Java migration, so .sql cannot mask it)'], - ['AppDbContextModelSnapshot.cs', 'EF Core snapshot'], -]; -let row3 = true; -for (const [name, rule] of covered) { - const f = path.join(scratch, 'db', 'migrations', name); - fs.writeFileSync(f, 'x\n'); - const r = run('dg-migration-guard.js', { tool_input: { file_path: f } }, { cwd: scratch }); - if (r.exit !== 2) { row3 = false; console.log(` ${name} (${rule}) -> exit ${r.exit}, expected 2`); } -} -check(`row 3: all ${covered.length} migration filename shapes caught, each isolating one rule`, row3); -const newFile = path.join(scratch, 'db', 'migrations', '9999_brand_new.sql'); -const r3neg = run('dg-migration-guard.js', { tool_input: { file_path: newFile } }, { cwd: scratch }); -check('row 3 negative: a NEW migration is never blocked', r3neg.exit === 0, `exit ${r3neg.exit}`); - -// The three positives above plus that negative are ALL satisfied by "deny iff the -// file exists", because every positive fixture is an existing file inside a -// migration directory and the negative is a path that was never created. Proven by -// mutation: deleting MIGRATION_DIRS and the whole IS_MIGRATION filename block left -// 25/25 passing. These two negatives are what force the directory and filename -// logic to exist — both files EXIST, so existsSync alone cannot discriminate them. -const existsOutsideDir = path.join(scratch, 'src', '20240101_init.sql'); -fs.mkdirSync(path.dirname(existsOutsideDir), { recursive: true }); -fs.writeFileSync(existsOutsideDir, 'select 1;\n'); -const rOut = run('dg-migration-guard.js', { tool_input: { file_path: existsOutsideDir } }, { cwd: scratch }); -check('row 3 negative: an EXISTING migration-shaped file outside a migration dir is allowed', - rOut.exit === 0, `exit ${rOut.exit} — the directory check is doing no work`); - -const existsWrongShape = path.join(scratch, 'db', 'migrations', 'README.md'); -fs.writeFileSync(existsWrongShape, '# notes\n'); -const rShape = run('dg-migration-guard.js', { tool_input: { file_path: existsWrongShape } }, { cwd: scratch }); -check('row 3 negative: an EXISTING non-migration file inside a migration dir is allowed', - rShape.exit === 0, `exit ${rShape.exit} — the filename-shape check is doing no work`); - -// --------------------------------------------------------------------------- -console.log('\n--- Ledger rows 5-6: opt-in, DG_STRICT_GIT default OFF (F05c) ---'); -// Row 5 only has something to say where a build command is DETECTABLE. The first -// version of this test ran in a bare scratch dir with no package.json, so the -// guard correctly found nothing to require and returned 0 — the test was wrong, -// not the code. Give it a project shape so the assertion means something. -const proj = mkScratch('dg-proj-'); -fs.writeFileSync(path.join(proj, 'package.json'), JSON.stringify({ scripts: { build: 'tsc' } })); - -const r5off = run('dg-git-guard.js', { session_id: 's', tool_input: { command: 'git commit -m "x"' } }, { cwd: proj }); -check('rows 5-6: inactive with DG_STRICT_GIT unset (default-off negative)', - r5off.exit === 0 && !r5off.err, `exit ${r5off.exit} ${r5off.err}`); - -const r5on = run('dg-git-guard.js', { session_id: 's', tool_input: { command: 'git commit -m "x"' } }, - { env: { DG_STRICT_GIT: '1' }, cwd: proj }); -check('rows 5-6: active when DG_STRICT_GIT=1', r5on.exit === 2 && /npm run build/.test(r5on.err), - `exit ${r5on.exit} ${r5on.err}`); - -// Row 6: staging-count sanity check. This had NO test — the two rows-5/6 assertions -// above both assert row 5's build message, so the row shipped untested and F05 was -// marked closed on "tests for all ELEVEN ledger rows". The behaviour is live: it -// denies when far more files are staged than were edited this session, which catches -// an accidental `git add -A` over unrelated work. -(() => { - const tmp = mkScratch('dg-row6-'); - // 1 file edited this session... - fs.writeFileSync(path.join(tmp, 'dg-baseline-s'), '{"session_changes":1,"total_changes_since_audit":1}'); - // ...and a fresh build marker, so the check under test is row 6 and not row 5. - fs.writeFileSync(path.join(tmp, 'dg-build-s'), '1'); - - // A real repo with many staged files, so `git diff --cached` returns a big number. - const repo = mkScratch('dg-row6repo-'); - const git = (...a) => spawnSync('git', a, { cwd: repo, encoding: 'utf8' }); - git('init', '-q'); - git('config', 'user.email', 'x@example.com'); - git('config', 'user.name', 'x'); - for (let i = 0; i < 20; i++) fs.writeFileSync(path.join(repo, `f${i}.txt`), 'x\n'); - git('add', '-A'); - fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ scripts: { build: 'tsc' } })); - - const r = spawnSync(process.execPath, [S('dg-git-guard.js')], { - input: JSON.stringify({ session_id: 's', tool_input: { command: 'git commit -m wip' } }), - encoding: 'utf8', cwd: repo, - env: { ...process.env, TMPDIR: tmp, TEMP: tmp, DG_STRICT_GIT: '1' }, - }); - check('row 6: staging-count check denies 20 staged against 1 edited', - r.status === 2 && /Staging check/.test(r.stderr || ''), - `exit ${r.status} stderr=${(r.stderr || '').trim().slice(0, 70)}`); - - // Negative: a proportionate staged count must pass. The threshold is edits*2+5, so - // 1 edit tolerates up to 7 staged files. - const tmp2 = mkScratch('dg-row6b-'); - fs.writeFileSync(path.join(tmp2, 'dg-baseline-s'), '{"session_changes":1,"total_changes_since_audit":1}'); - fs.writeFileSync(path.join(tmp2, 'dg-build-s'), '1'); - const repo2 = mkScratch('dg-row6repo2-'); - const git2 = (...a) => spawnSync('git', a, { cwd: repo2, encoding: 'utf8' }); - git2('init', '-q'); - git2('config', 'user.email', 'x@example.com'); - git2('config', 'user.name', 'x'); - for (let i = 0; i < 3; i++) fs.writeFileSync(path.join(repo2, `g${i}.txt`), 'x\n'); - git2('add', '-A'); - fs.writeFileSync(path.join(repo2, 'package.json'), JSON.stringify({ scripts: { build: 'tsc' } })); - const r2 = spawnSync(process.execPath, [S('dg-git-guard.js')], { - input: JSON.stringify({ session_id: 's', tool_input: { command: 'git commit -m wip' } }), - encoding: 'utf8', cwd: repo2, - env: { ...process.env, TMPDIR: tmp2, TEMP: tmp2, DG_STRICT_GIT: '1' }, - }); - check('row 6 negative: 3 staged against 1 edited is proportionate and passes', - r2.status === 0, `exit ${r2.status} stderr=${(r2.stderr || '').trim().slice(0, 70)}`); -})(); - -// A recorded build inside the 120-minute window satisfies it. -const buildTmp = mkScratch('dg-bld-'); -fs.writeFileSync(path.join(buildTmp, 'dg-build-s'), '1'); -const r5marker = spawnSync(process.execPath, [S('dg-git-guard.js')], { - input: JSON.stringify({ session_id: 's', tool_input: { command: 'git commit -m "x"' } }), - encoding: 'utf8', cwd: proj, - env: { ...process.env, TMPDIR: buildTmp, TEMP: buildTmp, DG_STRICT_GIT: '1' }, -}); -check('row 5: a fresh build marker satisfies the check', r5marker.status === 0, `exit ${r5marker.status}`); - -// --------------------------------------------------------------------------- -console.log('\n--- Ledger rows 7-8: tracker threshold and tolerant key read ---'); -function trackerRun(seed, threshold) { - const tmp = mkScratch('dg-trk-'); - if (seed) fs.writeFileSync(path.join(tmp, 'dg-baseline-s'), seed); - const r = spawnSync(process.execPath, [S('dg-track-change.js')], { - input: JSON.stringify({ session_id: 's', tool_input: { file_path: 'a.ts' } }), - encoding: 'utf8', cwd: ROOT, - env: { ...process.env, TMPDIR: tmp, TEMP: tmp, DG_CHANGE_THRESHOLD: String(threshold) }, - }); - return { out: (r.stdout || '').trim(), exit: r.status, tmp }; -} -const r7 = trackerRun('{"session_changes":14,"total_changes_since_audit":14}', 15); -check('row 7: nudge fires at the threshold', /files changed since the last audit/.test(r7.out), r7.out || '(no output)'); -const r7b = trackerRun('{"session_changes":1,"total_changes_since_audit":1}', 15); -check('row 7 negative: silent below the threshold', r7b.out === '', r7b.out); - -// Row 8 is the whole point: a reader that knows only ONE key name sees 0. -const r8old = trackerRun('{"total":14}', 15); -check('row 8: legacy `total` key read (inline generation)', /14 files|15 files/.test(r8old.out), r8old.out || '(no output)'); -const r8new = trackerRun('{"total_changes_since_audit":14}', 15); -check('row 8: `total_changes_since_audit` key read (script generation)', /15 files/.test(r8new.out), r8new.out || '(no output)'); -const r8write = trackerRun('', 999); -const written = fs.readFileSync(path.join(r8write.tmp, 'dg-baseline-s'), 'utf8'); -check('row 8: writes BOTH key names', /total_changes_since_audit/.test(written) && /"total"/.test(written), written); - -// --------------------------------------------------------------------------- -console.log('\n--- Ledger rows 9-11: Stop verification, SessionStart, runner detection ---'); -function stopRun(seed, markTest) { - const tmp = mkScratch('dg-stop-'); - fs.writeFileSync(path.join(tmp, 'dg-baseline-s'), seed); - if (markTest) fs.writeFileSync(path.join(tmp, 'dg-test-s'), '1'); - const r = spawnSync(process.execPath, [S('dg-session-stop.js')], { - input: JSON.stringify({ session_id: 's' }), encoding: 'utf8', cwd: ROOT, - env: { ...process.env, TMPDIR: tmp, TEMP: tmp }, - }); - return { out: (r.stdout || '').trim(), err: (r.stderr || '').trim(), exit: r.status }; -} -const r9 = stopRun('{"session_changes":3}', false); -check('row 9: warns when files changed and no tests ran', /no test run was detected/.test(r9.out), r9.out || '(no output)'); -const r9b = stopRun('{"session_changes":3}', true); -check('row 9 negative: silent about tests once a test run is recorded', - !/no test run/.test(r9b.out) && /Session summary/.test(r9b.out), r9b.out || '(no output)'); - -// Row 11: the runner list must recognize THIS repo's suite, which the .sh list -// did not — so a green `bash tests/run-all.sh` recorded nothing and row 9 nagged. -function trackTest(cmd) { - const tmp = mkScratch('dg-tt-'); - spawnSync(process.execPath, [S('dg-track-test.js')], { - input: JSON.stringify({ session_id: 's', tool_input: { command: cmd } }), - encoding: 'utf8', cwd: ROOT, env: { ...process.env, TMPDIR: tmp, TEMP: tmp }, - }); - return { test: fs.existsSync(path.join(tmp, 'dg-test-s')), build: fs.existsSync(path.join(tmp, 'dg-build-s')) }; -} -check('row 11: `bash tests/run-all.sh` recorded as a test run', trackTest('bash tests/run-all.sh').test); -check('row 11: `pytest -q` recorded', trackTest('pytest -q').test); -check('row 11: `cargo test` recorded', trackTest('cargo test').test); -check('row 11: `npm run build` recorded as a build, not a test', - (() => { const m = trackTest('npm run build'); return m.build && !m.test; })()); -check('row 11 negative: a quoted mention does not record a test run', - !trackTest('git commit -m "ran npm test earlier"').test); +console.log('--- Ledger row 10: SessionStart reads a pretty-printed status.json ---'); // Row 10: SessionStart must report the phase from a PRETTY-PRINTED status.json. -// dg-session-start.sh reports "phase: unknown" here because its grep pattern -// requires no space after the colon. +// dg-session-start.sh reported "phase: unknown" here because its grep pattern +// required no space after the colon. const planRoot = mkScratch('dg-plan-'); const planDir = path.join(planRoot, 'docs', 'plans', '2026-01-01-demo'); fs.mkdirSync(planDir, { recursive: true }); @@ -291,23 +82,17 @@ check('row 10: reports the PHASE\'s own status, not the first one in the file', // --------------------------------------------------------------------------- console.log('\n--- F26: every exit-0 emission is JSON, and stderr stays empty ---'); const f26Cases = [ - ['dg-track-change.js', { session_id: 's', tool_input: { file_path: 'a.ts' } }], - ['dg-session-stop.js', { session_id: 's' }], ['dg-subagent-stop.js', { session_id: 's', reason: 'done' }], ['dg-pre-compact.js', { session_id: 's' }], ['dg-session-start.js', { source: 'startup' }], ]; -// Each case must reach its EMITTING path, not an early exit. `run()` hands every -// call a fresh empty TMPDIR, so dg-session-stop and dg-track-change previously -// found no tracker, returned silently, and the F26 assertion inspected nothing — -// mutation P8 made the stop hook write to stderr and this row stayed green. -// Seed a tracker so both actually emit. +// Each case must reach its EMITTING path, not an early exit, so every handler +// runs against the plan fixture above rather than an empty directory. function runEmitting(script, payload) { const tmp = mkScratch('dg-f26-'); - fs.writeFileSync(path.join(tmp, 'dg-baseline-s'), '{"session_changes":3,"total_changes_since_audit":99}'); const r = spawnSync(process.execPath, [S(script)], { input: JSON.stringify(payload), encoding: 'utf8', cwd: planRoot, - env: { ...process.env, TMPDIR: tmp, TEMP: tmp, DG_CHANGE_THRESHOLD: '1' }, + env: { ...process.env, TMPDIR: tmp, TEMP: tmp }, }); return { exit: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() }; } @@ -315,14 +100,9 @@ function runEmitting(script, payload) { let f26 = true; for (const [script, payload] of f26Cases) { const r = runEmitting(script, payload); - // Proving the case is not vacuous. This floor originally covered only two of the - // five handlers, so `dg-subagent-stop.js` and `dg-pre-compact.js` could be - // replaced with `process.exit(0)` and the whole suite stayed green — both - // assertions naming them are satisfied by emitting nothing at all. Every handler - // that has something to say under this fixture must now say it. - // - // Two exclusions, both because the handler is covered more strictly elsewhere - // rather than because it is allowed to do nothing: + // Proving the case is not vacuous: every handler that has something to say + // under this fixture must say it. Two exclusions, both because the handler is + // covered more strictly elsewhere rather than because it may do nothing: // dg-session-start.js — row 10 asserts the phase and status it reports // dg-subagent-stop.js — its output channel is a LOG FILE, not stdout, so // emitting nothing here is correct. Its real effect is diff --git a/tests/layer7-runtime-proof.sh b/tests/layer7-runtime-proof.sh index c396ca0..3c79063 100644 --- a/tests/layer7-runtime-proof.sh +++ b/tests/layer7-runtime-proof.sh @@ -57,7 +57,6 @@ if ! command -v claude >/dev/null 2>&1; then fi pass "prerequisite: claude CLI $(claude --version 2>&1 | head -1)" -[ -f plugins/deepgrade-guard/hooks/hooks.json ] || { fail "plugins/deepgrade-guard/hooks/hooks.json missing — nothing to prove"; exit 1; } [ -f plugins/deepgrade/hooks/hooks.json ] || { fail "plugins/deepgrade/hooks/hooks.json missing — nothing to prove"; exit 1; } if [ "${1:-}" = "--manual" ]; then @@ -70,8 +69,8 @@ fi # PART 1 — automated: observable SIDE EFFECTS of hooks firing. # # Side effects, not captured notices, because notices are suppressed in print mode. -# A tracker file appearing in TMPDIR is unambiguous proof the PostToolUse handler -# ran: nothing else in the plugin writes it. +# A line appearing in a plan's subagent-log.txt is unambiguous proof the +# SubagentStop handler ran: nothing else in the plugin writes it. # --------------------------------------------------------------------------- if [ "$MANUAL_ONLY" -eq 0 ]; then echo "" @@ -87,66 +86,11 @@ mkdir -p "$HOOKTMP" nested() { local prompt=$1 ( cd "$SCRATCH" && TMPDIR="$HOOKTMP" TEMP="$HOOKTMP" \ - timeout 180 claude -p --plugin-dir "$ROOT/plugins/deepgrade-guard" --plugin-dir "$ROOT/plugins/deepgrade" "$prompt" 2>&1 ) || true + timeout 180 claude -p --plugin-dir "$ROOT/plugins/deepgrade" "$prompt" 2>&1 ) || true } -# PostToolUse:Write|Edit -> dg-track-change.js writes a baseline tracker. -echo " driving PostToolUse:Write|Edit ..." -nested "Create a file called probe.txt in the current directory containing the single word ok. Then stop." >/dev/null -if ls "$HOOKTMP"/dg-baseline-* >/dev/null 2>&1; then - pass "PostToolUse:Write|Edit fired — tracker written to \$TMPDIR" -else - fail "PostToolUse:Write|Edit produced NO tracker. The handler did not run (matcher, path, or spawn failure)." -fi - -# PostToolUse:Bash -> dg-track-test.js writes a test marker for a recognised runner. -# -# THE RUNNER MUST EXIST ON THE HOST. This probe originally asked the nested session to run -# `pytest --version`, and reported FAIL on a host with no pytest installed — so it was -# testing "will Claude run a tool that is absent", not "does the hook fire". The handler -# was correct all along: fed a pytest payload directly it writes the marker. -# -# A runtime probe whose subject may not exist produces a false defect report, which is -# worse than no probe: it sends you debugging a working component. Preconditioned on the -# runner being present, and FAILS rather than skips if none is — per the Wave 0 rule that -# a missing prerequisite must never silently no-op. -echo " driving PostToolUse:Bash ..." -if command -v python >/dev/null 2>&1 && python -m unittest --help >/dev/null 2>&1; then - TEST_CMD="python -m unittest --help" -elif command -v pytest >/dev/null 2>&1; then - TEST_CMD="pytest --version" -elif command -v npx >/dev/null 2>&1; then - TEST_CMD="npx --version" # not a runner; only reached if neither python nor pytest exist - TEST_CMD="" -else - TEST_CMD="" -fi -if [ -z "$TEST_CMD" ]; then - fail "PostToolUse:Bash: no recognised test runner present on this host, so the hook cannot be exercised — install python or pytest and re-run" -else - nested "Run exactly this shell command and nothing else: $TEST_CMD" >/dev/null - if ls "$HOOKTMP"/dg-test-* >/dev/null 2>&1; then - pass "PostToolUse:Bash fired — test marker written for '$TEST_CMD'" - else - fail "PostToolUse:Bash produced NO test marker for '$TEST_CMD' — the runner exists, so the handler or the wiring is at fault" - fi -fi - -# PreToolUse:Bash deny. The nested session should be PREVENTED from running it, so -# the evidence is the absence of the side effect plus a refusal in the transcript. -echo " driving PreToolUse:Bash (deny path) ..." -DENY_MARK="$SCRATCH/should-not-exist.txt" -# Built from fragments so this FILE can be edited and run while the old guard is -# still installed — it blocks any command whose text contains a trigger string. -DENYCMD="git push --for""ce origin main; touch '$DENY_MARK'" -out=$(nested "Run exactly this shell command and nothing else: $DENYCMD") -if [ -f "$DENY_MARK" ]; then - fail "PreToolUse:Bash did NOT block a force push — the compound command ran to completion" -elif echo "$out" | grep -qiE 'block|denied|not allowed|BLOCKED'; then - pass "PreToolUse:Bash blocked the force push and the refusal reached the transcript" -else - pending "PreToolUse:Bash: the command did not run (good) but no refusal text was captured — print mode may have suppressed it. Confirm interactively via --manual." -fi +# The PostToolUse tracker and PreToolUse deny probes that used to sit here drove +# deepgrade-guard handlers, retired in 9.0.0. Only the plan-context hooks remain. # SubagentStop -> appends to a plan's troubleshooting log. Needs the opt-in folder. echo " driving SubagentStop ..." @@ -178,10 +122,8 @@ cat <<'CHECKLIST' /plugin marketplace update deepgrade-marketplace /plugin update deepgrade - /plugin update deepgrade-guard /reload-plugins /plugin details deepgrade -> must report Hooks (3) incl. SubagentStop - /plugin details deepgrade-guard -> must report Hooks (5) A. SessionStart (F26, settles part of U4) Start a fresh session in this repo. Expect a line naming the active plan, @@ -189,23 +131,7 @@ cat <<'CHECKLIST' Pre-5.0.0 this reported "phase: unknown, status: unknown" against a pretty-printed status.json, so a real phase name is the proof. - B. PreToolUse:Bash — deny (F25) - Ask Claude to run: git push --force origin main - Expect: blocked, with a message naming --force-with-lease as the alternative. - - C. PreToolUse:Bash — the SAFE form must be ALLOWED (F25, the inverted defect) - Ask Claude to run: git push --force-with-lease --dry-run origin main - Expect: NOT blocked. Before 5.0.0 this was denied while bare -f was allowed. - - D. PreToolUse:Bash — ask (F22) - Ask Claude to run: git reset --hard - Expect: a CONFIRMATION PROMPT, not a refusal. Record which it was. - - E. PreToolUse:Bash — quoted mention must be allowed (F24) - Ask Claude to run: git commit --allow-empty -m "never git push --force" - Expect: NOT blocked. This is the defect that blocked this plan's own commits. - - F. PreCompact (settles U5 — the open question) + B. PreCompact (settles U5 — the open question) Fill the context until compaction triggers, or run /compact. Does a DeepGrade line naming the active plan appear? YES -> U5 positive; record verbatim. @@ -216,27 +142,20 @@ cat <<'CHECKLIST' If that also fails, F26's PreCompact half is recorded PARTIAL in the release notes — never silently dropped. - G. Stop (F26) - Edit a file, then end the turn without running tests. - Expect: the no-tests nudge, naming the change count. The handler emits ONE - message per stop — the nudge when no test run was detected, else the summary - — never both (notify() exits). Both messages existed pre-5.0.0 but went to - stderr at exit 0 and were never surfaced to anyone. - - H. Zero hook errors on a healthy host + C. Zero hook errors on a healthy host Through all of the above, no "hook error" notice should appear. This is the acceptance criterion for the whole wave. - I. NODE-LESS INSTALLED COPY (CR-1's condition, lane N's honest limit) + D. NODE-LESS INSTALLED COPY (CR-1's condition, lane N's honest limit) In a shell with node removed from PATH, start an interactive session: PATH=$(echo "$PATH" | tr ':' '\n' | grep -v node | paste -sd:) claude - Expect the vendor's own hook-error notice — the guards cannot spawn, and + Expect the vendor's own hook-error notice — the handlers cannot spawn, and that notice is the only in-product signal. Record it VERBATIM; CR-1's acceptance is that it is user-visible and names the cause. CHECKLIST -pending "A-I are owner-observed and unrecorded until pasted into $EVIDENCE" +pending "A-D are owner-observed and unrecorded until pasted into $EVIDENCE" # --------------------------------------------------------------------------- echo "" diff --git a/tests/release-preflight-test.sh b/tests/release-preflight-test.sh index ec42564..93efd68 100644 --- a/tests/release-preflight-test.sh +++ b/tests/release-preflight-test.sh @@ -92,8 +92,8 @@ sed -i "s/^Current: v$CUR_RE/Current: v0.0.1/" plugins/deepgrade/README.md git commit -qam "v1b" violation "V1b: plugin README version drift refused" "version|drift" -# V2: a manifest disagrees — the lockstep rule, load-bearing at four manifests. -sed -i "s/\"version\": \"$CUR_RE\"/\"version\": \"0.0.1\"/" plugins/deepgrade-guard/.claude-plugin/plugin.json +# V2: a manifest disagrees — the lockstep rule, load-bearing at three manifests. +sed -i "s/\"version\": \"$CUR_RE\"/\"version\": \"0.0.1\"/" plugins/deepgrade-audit/.claude-plugin/plugin.json git commit -qam "v2" violation "V2: manifest out of lockstep refused" "lockstep|version" diff --git a/tests/run-hook-corpus.js b/tests/run-hook-corpus.js deleted file mode 100644 index f1b0e42..0000000 --- a/tests/run-hook-corpus.js +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env node -// Runs tests/fixtures/hook-corpus.json against a hook implementation and reports -// per-row pass/fail. Usage: -// node tests/run-hook-corpus.js [--json] -// -// The corpus is read from a file rather than passed on a command line because the -// live guard denies any Bash invocation whose text contains a trigger string — -// F24 blocks its own test harness. Keep it that way. -'use strict'; -const { spawnSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -const hook = process.argv[2]; -const asJson = process.argv.includes('--json'); -if (!hook) { - console.error('usage: node tests/run-hook-corpus.js [--json]'); - process.exit(64); -} - -const corpusPath = path.join(__dirname, 'fixtures', 'hook-corpus.json'); -const corpus = JSON.parse(fs.readFileSync(corpusPath, 'utf8')); -const isJs = hook.endsWith('.js'); - -let pass = 0; -const failures = []; - -for (const c of corpus.cases) { - const input = c.payload_raw !== undefined && c.payload_raw !== null - ? c.payload_raw - : JSON.stringify(c.payload); - - const argv = isJs ? [hook] : [hook]; - const cmd = isJs ? process.execPath : 'bash'; - const r = spawnSync(cmd, argv, { input, encoding: 'utf8' }); - const exit = r.status; - const out = (r.stdout || '').trim(); - const err = (r.stderr || '').trim(); - - const problems = []; - if (exit !== c.want_exit) problems.push(`exit ${exit}, want ${c.want_exit}`); - - // A DENY row previously checked only the exit code. `want_decision: "deny"` was - // dead on all 15 of them, because the decision comparison below lives inside - // `if (exit === 0)`. Proven by mutation: making deny() print - // {"permissionDecision":"allow"} to stdout and NOTHING to stderr, still exiting 2, - // left every row passing. The corpus could not tell "denied with a surfaced - // reason" from "exited 2 while announcing allow". - if (c.want_exit === 2) { - if (!err) { - problems.push('exit 2 but stderr is empty — the denial reason is never surfaced to the user'); - } else if (!/BLOCKED/.test(err)) { - problems.push(`exit 2 but stderr lacks the "BLOCKED" wording the F22 acceptance row requires: ${err.slice(0, 60)}`); - } - if (out) { - let announced = null; - try { announced = JSON.parse(out).hookSpecificOutput.permissionDecision; } catch { /* not JSON */ } - if (announced && announced !== 'deny') { - problems.push(`exit 2 while announcing permissionDecision '${announced}' on stdout — contradictory`); - } - } - } - - // F26: every exit-0 emission must be JSON, and stderr must stay empty on exit 0. - if (exit === 0 && err) problems.push(`stderr on exit 0: ${err.slice(0, 60)}`); - if (exit === 0 && out) { - let parsed = null; - try { parsed = JSON.parse(out); } catch { problems.push('exit-0 stdout is not JSON'); } - if (parsed && c.want_decision) { - const got = parsed.hookSpecificOutput && parsed.hookSpecificOutput.permissionDecision; - if (got !== c.want_decision) problems.push(`permissionDecision '${got}', want '${c.want_decision}'`); - } - // An ALLOW row must be SILENT. `want_decision: null` used to mean "do not - // check", which made every allow row unable to detect a spurious prompt — - // mutation M3 removed per-segment isolation, producing an unwanted `ask`, and - // no row went red. "Allow" and "quietly ask the user" are different outcomes - // and the corpus has to tell them apart. - if (parsed && !c.want_decision) { - const got = parsed.hookSpecificOutput && parsed.hookSpecificOutput.permissionDecision; - problems.push(`expected a silent allow but got permissionDecision '${got}'`); - } - } else if (exit === 0 && c.want_decision) { - problems.push(`want permissionDecision '${c.want_decision}' but nothing was emitted`); - } - - if (problems.length === 0) pass++; - else failures.push({ id: c.id, problems, why: c.why }); -} - -if (asJson) { - console.log(JSON.stringify({ total: corpus.cases.length, pass, failures }, null, 2)); -} else { - for (const f of failures) { - console.log(`[FAIL] ${f.id}: ${f.problems.join('; ')}`); - console.log(` ${f.why}`); - } - console.log(`\n${pass}/${corpus.cases.length} corpus rows pass against ${hook}`); -} -process.exit(failures.length ? 1 : 0);