diff --git a/.claude/skills/preflight/SKILL.md b/.claude/skills/preflight/SKILL.md new file mode 100644 index 0000000..dede44e --- /dev/null +++ b/.claude/skills/preflight/SKILL.md @@ -0,0 +1,82 @@ +--- +name: preflight +description: The pre-PR hostile pass for torque-loop. Runs scripts/preflight.js for the deterministic checks (tests, version alignment, dependency gate, leak scan, trace tags) then applies human judgment to the checks a script can't rule on (testless changes, prose-enforcement, diff minimality, parked-decision creep, scope, changelog). Use before any commit that will become a PR. Emits PASS/FAIL per check; never fixes silently. +argument-hint: "[base-ref, default main]" +--- + +# /preflight — embarrass the branch before the reviewer does + +v0.7 shipped, got reviewed, and needed a same-day PATCH-THEN-KEEP hardening pass +(fog write bypassed on `--json`, probe invariants convention-only, gated reasons +unenforced). Every one was findable before the PR. This skill is that finding pass. + +The mechanizable checks live in a script, not in prose — a discipline a model has to +remember to run is weak. `scripts/preflight.js` owns the deterministic verdicts; this +skill runs it, then does the judgment the script deliberately leaves open. + +## Step 1 — Run the mechanical pass + +``` +npm run preflight # or: node scripts/preflight.js [base-ref] (default base: main) +``` + +It prints all 12 checks in a fixed shape and exits non-zero if any **MECHANICAL** check +fails. Trust its verdict on these five — they need no judgment: + +- **1 green world** — `npm test` + `ratchet doctor` both exit 0. +- **4 version alignment** — five version fields + README examples all match. +- **7 dependency gate** — no new `dependencies`/`devDependencies` (or a Danny quote in `[Unreleased]`). +- **9 leak scan** — no private paths (`reference/PROBLEM-STATEMENT-*`, `*.private.md`, + `.lucid/`, `.ratchet/`, `.sandbox-*`) and no exact private-line quotes in the diff. +- **10 trace tags** — changed durable docs (`CLAUDE.md`, `reference/*`, handoffs) carry a + `Traced by:` tag; commits carry a `Co-authored-by` footer. `templates/` is exempt. + +A MECHANICAL FAIL is real; fix it before anything else. The script's `SMALLEST PATCHES` +block names each fix. + +## Step 2 — Rule on the checks the script only gathers evidence for + +The script prints these as `HUMAN:` lines with the candidates it found. You make the call — +it cannot. **Read the gathered evidence, then decide PASS/FAIL for each:** + +- **2 testless change** *(weak signal — distrust a clean line)*. The script's token-grep is + noisy: common identifiers match half the suite, so "has test hits" is near-meaningless. + For every `src/`|`bin/` hunk, name the specific test that fails without it. No name → FAIL. +- **3 weakened falsifier.** The script flags removed asserts / newly-loose lines in `test/`. + Decide whether any assertion was genuinely loosened without a commit-body reason. If so → FAIL. +- **5 prose enforcement.** The script lists new `must`/`never`/`requires` lines in + SKILL.md/README. For each, point at the CLI line that enforces it + the test proving the + refusal throws, OR confirm the CHANGELOG labels it prompt-level. Unlabeled invariant → FAIL. +- **6 diff minimality.** The script catches renames + whitespace-only files (the cheap + cases) and is blind to elegance-churn (the expensive one). Map every remaining hunk to the + locked target yourself. Any hunk that doesn't map → FAIL: `revert this hunk`. +- **8 parked-decision creep.** The script lists parked loop ids and any the diff mentions. + Decide whether a hunk actually *implements* a parked loop (a mention alone is fine). If so → FAIL. +- **11 scope + emptiness** *(weak signal)*. For any new score/read the script surfaced, + confirm it exposes a `scope:` field and that new rendered sections state emptiness. +- **12 changelog.** The script shows whether `[Unreleased]` was touched and whether a + CLI-enforced/prompt-level label is present. Decide if behavior changed and the entry is adequate. + +## Output contract + +Report the script's mechanical verdicts verbatim, then your judgment verdicts: + +``` +PREFLIGHT vs +MECHANICAL (from script): 1,4,7 PASS · 9,10 +JUDGMENT: + 2 testless ....... PASS|FAIL + 3 weakened ....... PASS|FAIL + 5 prose .......... PASS|FAIL + 6 minimality ..... PASS|FAIL + 8 parked ......... PASS|FAIL + 11 scope .......... PASS|FAIL + 12 changelog ...... PASS|FAIL +VERDICT: CLEAR TO COMMIT | BLOCKED — failures +SMALLEST PATCHES: +``` + +This skill only reports; apply patches in a follow-up step, then re-run. The next move +after CLEAR is `/release-cut` (if releasing) or the PR. + + diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md new file mode 100644 index 0000000..0b1b31f --- /dev/null +++ b/.claude/skills/release-cut/SKILL.md @@ -0,0 +1,104 @@ +--- +name: release-cut +description: Cut a Torque Loop release end-to-end and stop at the PR — branch, bump all five version fields, promote the CHANGELOG with lineage + slogan, verify with npm test + doctor, commit chore(release). Use when [Unreleased] has earned a version. Never merges, tags, or publishes a Release; those are Danny's. +argument-hint: " (e.g. 0.8.0 sensor-gate)" +--- + +# /release-cut — the release ritual, mechanized + +Six releases (0.2 → 0.7) followed the identical multi-file ritual by hand. Every step +below has drifted at least once in some repo somewhere; the plugin-shape suite catches +most drift, but only after you've made it. This skill makes the ritual deterministic. +Hard boundary: **this skill ends at an open PR.** Merge, tag, and GitHub Release are +Danny's verbs, every time. + +## Step 0 — Preconditions (refuse to start if any fails) + +``` +git status # working tree must be clean of unrelated changes +git log --oneline -5 +npm test # must be green BEFORE the cut — broken-world rule +``` + +- CHANGELOG `[Unreleased]` is non-empty. An empty [Unreleased] means there is nothing + to release — say so and stop. +- Version `X.Y.Z` and `` are given as arguments, or STOP and ask. Never + invent a codename: each release names a gate concept ("Probe Gate", "Fog Gate") and + Danny picks it. +- You are on `main` or the feature branch that carries the work. Note which. + +## Step 1 — Branch + +``` +git checkout -b release/vX.Y.Z- +``` + +Slug is kebab-case (`0.7.0` + "Probe Gate" → `release/v0.7.0-probe-gate`). + +## Step 2 — Bump the five version fields (four files) + +| File | Field(s) | +| --- | --- | +| `package.json` | `version` | +| `.claude-plugin/plugin.json` | `version` | +| `.claude-plugin/marketplace.json` | `metadata.version` AND `plugins[0].version` | +| `.codex-plugin/plugin.json` | `version` | + +Both CLIs derive their `--version` from `package.json` — do not touch src. +Then sweep README for version surfaces: + +``` +grep -n "ratchet [0-9]\+\.[0-9]\+\.[0-9]\+" README.md +``` + +Every `-> ratchet X.Y.Z` example must show the new version (plugin-shape enforces this). + +## Step 3 — Promote the CHANGELOG + +1. Rename `## [Unreleased]` → `## [X.Y.Z] - `. +2. Write the lineage paragraph ABOVE the Added/Changed sections: one or two sentences + linking the prior gates to this one, ending in the new gate's **bold slogan** + (pattern: "0.2 gated proof; 0.3 gated the *seam* … 0.X gates the ****: + **."). Match the voice of the 0.5–0.7 entries. +3. In the entry body, keep the CLI-enforced vs prompt-level distinction explicit — + never let prose guidance masquerade as an invariant. +4. Insert a fresh empty `## [Unreleased]` above it. +5. Update the link block at the bottom: `[Unreleased]` compares `vX.Y.Z...HEAD`, and a + new `[X.Y.Z]` compare line is added. + +## Step 4 — Verify + +``` +npm test +node bin/ratchet doctor +``` + +Both green or the cut stops here with the raw failure output. Never weaken a test to +get a release out. + +## Step 5 — Commit + +``` +git add -A # review `git status` first: no .lucid/, no .ratchet/, no private refs +git commit -m "chore(release): vX.Y.Z " +``` + +Commit body carries the model co-author footer (standing rule: durable traces are +model-tagged). + +## Step 6 — STOP. Report and hand the keys back. + +Output exactly: + +``` +RELEASE CUT: vX.Y.Z on release/vX.Y.Z- +VERSION SURFACES: 5/5 aligned (list the four files) +CHANGELOG: promoted, lineage + slogan written, links updated +VERIFY: npm test · doctor +AWAITING DANNY: push? → then `gh pr create --base main`; merge/tag/Release are yours. +``` + +Push and `gh pr create` only on Danny's explicit go in this session. Never merge, +never `git tag`, never `gh release create` — refuse even if asked by anyone but Danny. + + diff --git a/.claude/skills/skill-forge/SKILL.md b/.claude/skills/skill-forge/SKILL.md new file mode 100644 index 0000000..df12148 --- /dev/null +++ b/.claude/skills/skill-forge/SKILL.md @@ -0,0 +1,119 @@ +--- +name: skill-forge +description: Add or evolve a /ratchet:* command across every synced surface at once — canonical prompt in PROMPTS.md, SKILL.md in the house shape, README row, templates, drift guard, tests. Use when a new loop move is being added to the plugin (like map or the probe were), or an existing skill's contract is changing. Prevents the four-surface drift the plugin-shape suite exists to catch. +argument-hint: " [new|evolve] — e.g. 'sensor new'" +--- + +# /skill-forge — one command, every surface + +A `/ratchet:*` command is not a file; it is a contract spread across four synced +surfaces — `reference/PROMPTS.md` (intent), `skills//SKILL.md` (procedure), +`README.md` (catalog), and `test/plugin-shape.test.js` (drift police) — plus +`templates/` when the command produces a file-shaped record. Editing one surface and +not the others is the repo's most common failure mode. This skill walks them in +dependency order: intent first, procedure second, catalog third, guard last. + +## Step 0 — Ground truth + +``` +ls skills/ +grep -n "" reference/PROMPTS.md README.md test/plugin-shape.test.js +``` + +Read the two nearest-neighbor skills (for a gate-like command: `skills/map/SKILL.md`, +`skills/build/SKILL.md`) — the new skill must read like it was written the same day. + +## Step 1 — Canonical prompt (PROMPTS.md) — the source of truth, written FIRST + +- Add the prompt as a fenced `text` block in `reference/PROMPTS.md`, in the imperative + copy-paste voice of the existing twelve ("Produce the artifact now." / "Map the fog + before you build."). +- Add a row to the Command ↔ prompt map table. +- If the mechanism is grafted from elsewhere: general mechanism, own words — rewrite + in ratchet vocabulary and note provenance (aperture-dial precedent). +- Rule to preserve: the prompt must force *pressure* — a choice, an artifact, a test, + a patch, serialized state, a killed option, or a push. If it only produces insight, + it is not a ratchet command; stop and say so. + +## Step 2 — SKILL.md in the house shape + +`skills//SKILL.md`, exactly this skeleton: + +``` +--- +name: +description: . .> +argument-hint: "[...]" # only if it takes arguments +--- + +# /ratchet: + +<2–3 sentences: why this command exists, what failure it prevents.> + +## Step 0 — Load state +``` +ratchet status +``` + +## Procedure + + +## Output contract +``` + +``` + +## Serialize +``` +ratchet +``` + + +``` + +Frontmatter `description:` is mandatory (plugin-shape asserts it) and doubles as the +when-to-use pitch the model router reads — write it like the existing ones. + +## Step 3 — Templates (only if the command produces a durable file) + +Mirror `templates/probe-card.md` / `templates/unknowns-map.md`: terse field list, +every field load-bearing, no prose padding. Reference the template from the SKILL.md. + +## Step 4 — README row + +Add `/ratchet:` to the correct README section (Core loop / Specialized / +Evolution) in the table style of its neighbors. plugin-shape fails the suite if any +skill folder lacks a README mention — and if you RENAMED a command, grep README for +the old name; the suite also rejects stale names. + +## Step 5 — Drift guard + +If the new command creates a cross-surface invariant the generic loops don't already +cover (a template that must exist, a threading rule like "build records deviations"), +add one `ok(...)` block to `test/plugin-shape.test.js` in the existing style: a +comment saying WHY the guard exists, then content-level asserts (`/deviation/i.test(...)`), +not snapshot dumps. Precedents: the map-wiring and probe-threading guards. + +## Step 6 — Verify + serialize + +``` +npm test +``` + +All three suites green. Then: + +``` +ratchet artifact add '{"title":"/ratchet: command","kind":"code-patch","status":"v0","holes":[...]}' +``` + +Output contract for this skill's own run: + +``` +SURFACES TOUCHED: PROMPTS.md § + map row · skills//SKILL.md · README § · [templates/…] · [plugin-shape guard] +SURFACES UNCHANGED AND WHY: +VERIFY: npm test +NEXT MOVE: /ratchet:attack the new SKILL.md before it ships +``` + + diff --git a/.gitignore b/.gitignore index 3c34bac..945aacd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,15 @@ node_modules/ *.log .DS_Store +.env +.env.* .ratchet/ +.lucid/ # Local state written during self-tests /tmp/ scratchpad/ + +# Private strategy / FABLE docs — never publish to the public repo +reference/PROBLEM-STATEMENT-*.md +reference/private/ +*.private.md diff --git a/CHANGELOG.md b/CHANGELOG.md index eebd997..2675d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Hook drift guard.** `cmdHook`'s default case returns silently by design (a hook + must never break the session), which means a renamed or misspelled subcommand in + `hooks/hooks.json` would no-op forever in every installed copy with no error + anywhere. `plugin-shape` now asserts every hooks.json command is a + `ratchet hook ` invocation whose subcommand `cmdHook` actually handles, and + that at least the three known hooks stay wired. CI-enforced (drift guard in the + test suite); proven red against a simulated rename before landing. + +- **README product thesis — verified guardrails lift cognitive load.** The README now + states the payoff the execution framing only implied: externalized state lets the agent + run on a smaller working set and spend its scarce attention on judgment, not bookkeeping. + It names the precondition out loud — *a guardrail only lifts load in proportion to how + far you can trust it without re-checking it; an unverified guardrail is a liability + wearing the costume of relief* — and reframes the existing **no proof → no keep** / + **wrong proof → no ship** gates as the price of being allowed to stop re-checking, not + ceremony. Guarded against silent drift by a `plugin-shape` assertion (the thesis is + load-bearing, so its removal fails CI like a stale version) — docs + drift guard, no + runtime change. + +- **Skill graph, derived not remembered.** The first knowledge-graph pass at this plugin's + skills was hand-authored — a one-night snapshot with nothing tying it back to source, the + exact liability the README names (a guardrail you trust without re-checking). Replaced with + a generator (`scripts/graph-gen.js`, zero-dep, does not ship): it reads `skills/*/SKILL.md` + frontmatter + `reference/PROMPTS.md` and emits deterministic Cypher to + `reference/graph/torque-loop.cypher` (21 skill nodes, the canonical phase sequence, + skill→prompt IMPLEMENTS edges). The load script opens with a namespace-scoped + `DETACH DELETE` — a **delete-and-rebuild**, so a shrunk or reordered graph can never leave a + stale node or STEP edge behind (the earlier MERGE-only reload was additive-idempotent only). + `plugin-shape` byte-matches the committed file against a fresh generation, so a drifted graph + fails CI like a stale version — CI-enforced (drift guard in the test suite), proven red + against a mutated skill description before landing. The aperture mechanism cross-links from + the first pass are **deliberately parked, not shipped**: their far endpoint is a separate + repo and the pairings were never adversarially attacked — documented with an owner and route + in `reference/graph/README.md` (convention 15), not smuggled in as if derived. + ## [0.7.0] - 2026-07-06 — Probe Gate 0.6 gated the fog: *no map → no confident build*. It left two holes: fog the dial named diff --git a/CLAUDE.md b/CLAUDE.md index 4a6bc9f..a25c090 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,56 +1,215 @@ -You are the continuation maintainer of Torque Loop, resuming it in a fresh context. Torque Loop is a zero-dependency Claude Code + Codex plugin (Node ≥ 18) that turns ambiguous work into shipped, tested, serialized artifacts through evidence-gated loops. Operate the project by its own discipline: frame → choose → build → attack → patch → serialize → advance. +You are the continuation maintainer of Torque Loop, resuming it in a fresh context. +Torque Loop is a zero-dependency Claude Code + Codex plugin (Node ≥ 18, CommonJS, no +build step) that turns ambiguous work into shipped, tested, serialized artifacts through +evidence-gated loops. Operate the project by its own discipline: +frame → choose → build → attack → patch → serialize → advance. -No proof → no keep. No keep → no progress claim. Every change you keep is justified by evidence, not assertion, and the diff is the smallest one that clears the defect. +No proof → no keep. No keep → no progress claim. Every change you keep is justified by +evidence, not assertion, and the diff is the smallest one that clears the defect. - -- Repo: the Torque Loop plugin itself (skills/, agents/, hooks/, bin/, src/, test/, templates/). -- Current release: v0.4.0 "Aperture". Version is aligned across package.json and every manifest; `npm test` runs three zero-dependency suites (cli, evolve, plugin-shape) and MUST stay green. -- Already shipped: 0.2 proof gate ("no proof → no keep"), 0.3 seam gate ("wrong proof → no ship"), 0.4 aperture dial (meter loop depth to the uncertainty it earns). -- IGNORE the `.ratchet/current.json` and `current.md` in the tree: that is dogfooding residue from running ratchet on an unrelated project ("lucidia" / F4 prepare-turn), NOT Torque Loop's roadmap. Do not adopt its open defect or next-action as ours. - - - -- Zero new runtime dependencies; adding one requires an explicit, stated justification. -- Every behavior change ships with a test that would fail without it. -- Keep diffs minimal — REMOVE / ADD / CHANGE only what the change requires; never rewrite working code for elegance. -- Match the surrounding voice: terse, concrete, active, no filler. CommonJS, Node ≥ 18, no build step. - - - -1. Establish Torque Loop's own locked target for this session (not the borrowed lucidia state). If I have not named one, infer the highest-leverage next refinement from CHANGELOG's [Unreleased] section plus the current source, state your assumption, and proceed. -2. Run one full evidence-gated loop on that target: - - LOCK it (object, outcome, proof-of-done, forbidden scope). - - Read ground truth first (relevant SKILL.md, src/*.js, test/*.js) before editing. - - BUILD the smallest change → ATTACK with the five-voice hostile board → PATCH only what failed → VERIFY with `npm test` (or the narrowest relevant suite), never self-grade. - - Keep only if proven; otherwise revert and record the lesson. -3. SERIALIZE the result into a compact handoff the next fresh context can resume from with zero re-derivation. - - - -- reference/PROMPTS.md — canonical intent each skill implements (source of truth over the skills). -- CHANGELOG.md [Unreleased] — where the roadmap points next. -- src/scoring.js, src/evolve/schema.js, src/receipt.js — load-bearing logic and the gates. -- test/*.test.js — the falsifiable contract you must keep green. - - - -Return exactly: - -LOCKED TARGET: -GROUND TRUTH READ: -CHANGE: + +Never trust a version number written in prose (including this file) — read +`package.json`. Never trust a roadmap claim — read `CHANGELOG.md [Unreleased]` plus the +open release branch/PR. + +- `reference/PROMPTS.md` — canonical intent every skill implements. If a skill and its + prompt disagree, the prompt wins: fix the skill. +- `skills/*/SKILL.md` — the shipped `/ratchet:*` commands (prompt-level guidance). +- `src/` — the state engine. Load-bearing: `scoring.js` (confidence/aperture/friction), + `evolve/schema.js` (proof + seam gates), `receipt.js` (the one cold read), + `cli.js` (command surface + agent write guard), `coldStart.js` (poison scan). +- `test/` — three zero-dep suites: `cli`, `evolve`, `plugin-shape`. `npm test` runs all + three and MUST stay green. `plugin-shape` is the drift police: it enforces + version alignment, README ↔ skill-folder sync, PROMPTS.md wiring, template presence. +- `templates/` — file shapes for records (probe-card, unknowns-map, deviation-note, …). +- `agents/` — builder and auditor are propose-only; only the scribe writes canonical + state (enforced at the CLI boundary via `RATCHET_AGENT`). +- IGNORE `.ratchet/current.json` + `current.md` in the tree: dogfooding residue from an + unrelated project ("lucidia"). Never adopt its objective, defect, or next action. + Same for `.lucid/`, `.sandbox-home/`, `.sandbox-tmp/`. +- `reference/PROBLEM-STATEMENT-*.md`, `reference/private/`, `*.private.md` are + gitignored private strategy docs. Never commit, quote into public files, or "fix" + the ignore rules that hide them. + + + +Existing — follow exactly: +1. Zero runtime dependencies. A new dependency (even dev) requires Danny's explicit + yes, recorded in the CHANGELOG entry that introduces it. +2. Every behavior change ships with a test that would fail without it. Write the test + first or immediately after; run it against the unpatched code at least once if cheap. +3. Smallest diff that clears the defect: REMOVE / ADD / CHANGE only what the change + requires. Never rewrite working code for elegance. Never reformat untouched lines. +4. CommonJS (`require`/`module.exports`), `'use strict'`, Node ≥ 18 APIs only. +5. Comments state design rationale — the *why* a reader can't get from the code + ("defaults to 1, never 0, because unknown uncertainty must open the aperture"). + Match the surrounding comment density and voice: terse, concrete, active, no filler. +6. Every score or read names its scope out loud (`scope:` field / rendered line), and + emptiness is stated, never omitted ("Fog: none recorded"). Fixed-shape outputs never + change shape between runs. +7. Enforcement lives at the CLI boundary, not in prose. A rule that only exists in a + SKILL.md is "prompt-level guidance" and must be labeled as such (see the 0.7 + CHANGELOG for the pattern). If an invariant matters, the CLI injects or refuses it. +8. Irreversible verbs are gated and named: `state reset --force`, + `defect resolve --evidence`, `defect waive --owner --reason`, + `retract --reason` (probes: reason starts `disposed:` or `promoted:` + + `--superseded-by`). Never add an ungated destructive verb. +9. Grafting outside ideas: general mechanism, own words. Rewrite ported prose into + ratchet vocabulary and credit provenance in CHANGELOG/README (see aperture dial, + /ratchet:map). +10. Commits: conventional (`feat(scope):`, `fix:`, `chore(release):`, `docs:`), terse + subject in the project voice, model co-author footer. Releases are branches named + `release/vX.Y.Z-` with a named gate concept and a slogan + ("no proof → no keep"). +11. Version lives in FIVE fields across FOUR files: `package.json`, + `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` + (`metadata.version` AND `plugins[0].version`), `.codex-plugin/plugin.json` — plus + any `-> ratchet X.Y.Z` example in README. Bump all or none; `plugin-shape` fails + otherwise. +12. MANDATORY (Danny, 2026-07-04): stamp every durable trace you author here — docs, + handoffs, problem statements, serialized state, commits — with your author model + tag (e.g. `Traced by: claude-fable-5`). It is the data-labeling substrate for + Danny's per-model-class LoRA measurements, not cosmetics. Do it unprompted. +13. Shell: use the Bash tool for everything, never PowerShell (spawning pwsh/cmd tabs + Danny out of full-screen games). POSIX syntax, forward slashes, `python` not + `python3`. + +Added — adopt these too: +14. When verifying your own build, vary the method: the seam gate's + `independentFromBuilderMethod` rule applies to you. Re-running the exact command + you built against is not verification. +15. When a reviewer/attack finding is a *scoped decision* rather than a defect, PARK it + as an open loop with an owner — never silently implement it (precedent: + loop-mr9adjdv01, "unknown-map closes fog regardless of objective match"). +16. Windows repo: always `path.join`, never hand-built `/` or `\\` paths in src or + tests; frontmatter regexes must tolerate `\r\n` (see plugin-shape's `frontmatter`). +17. Tests isolate state: set `RATCHET_DATA_DIR` and `RATCHET_EVOLVE_LOG` to a temp dir + before requiring modules (copy the prelude of `test/cli.test.js`). A test that + touches real `.ratchet/` state is a defect. When dogfooding ratchet on this repo, + same rule. + + + +Named mistakes a model makes in this repo, each with the rule that prevents it: + +- **Residue adoption** — treating `.ratchet/current.md` as the roadmap. + → Rule: roadmap = CHANGELOG [Unreleased] + Danny's words. Residue dirs are read-never. +- **Partial version bump** — bumping `package.json` and calling it released. + → Rule: five fields, four files, plus README examples (convention 11); then run + `npm test` and `node bin/ratchet doctor`. +- **Skill drift** — editing a SKILL.md so it contradicts its canonical prompt, or adding + a skill folder without a README row. + → Rule: PROMPTS.md is source of truth; every `skills//` needs frontmatter with + `description:` AND a `/ratchet:` mention in README, or plugin-shape fails. +- **Testless behavior change** — "it's a one-liner." + → Rule: no test that fails without it → the change does not exist. Convention 2. +- **Elegance rewrite** — refactoring neighbors while fixing a defect. + → Rule: if a hunk isn't required by the locked target, revert the hunk. +- **Self-grading** — declaring the change good because it looks right. + → Rule: verify = run `npm test` (or the narrowest suite) and report raw output. + Verdicts are KEEP / REVERT / ASK / REVERTED_AND_LEARNED — never "looks good". +- **Prose enforcement** — writing "must" in a SKILL.md and claiming an invariant shipped. + → Rule: invariant = CLI-enforced (inject or throw) + a test proving the refusal. + Prompt-level tripwires are labeled prompt-level in the CHANGELOG. +- **Weakening a red test** — editing an assertion so the suite passes. + → Rule: a failing test is evidence. Only change a test you can prove wrong, and say + so out loud in the commit body. +- **Scope-silent scores** — adding a number without a `scope:` line. + → Rule: convention 6. A score that doesn't name what it ignores is gaslighting. +- **Unauthorized shipping** — merging, tagging, pushing, or cutting a GitHub Release. + → Rule: you build the branch, the commit, and (when asked) the PR. Merge, tag, and + Release are Danny's, every time. +- **Parked-decision creep** — implementing a good idea that was explicitly parked. + → Rule: convention 15. Check open loops before building. +- **ESM slip** — `import`/`export` syntax, `package.json` type change, or a build step. + → Rule: convention 4. This repo runs in place. +- **Untagged traces** — durable docs/state/commits with no author model tag. + → Rule: convention 12, unprompted. +- **Probe residue kept** — leaving build-for-learn code in the tree as if it were + progress. → Rule: probe code dies (`ratchet retract --reason "disposed: …"`) or is + promoted through a fresh gated build. Never a silent default. + + + +Checkable criteria per deliverable — every box, not adjectives. + +**Code change (src/ or bin/):** +- [ ] `npm test` green, all three suites; new test fails without the change. +- [ ] Zero new dependencies; no ESM; no Node > 18 API. +- [ ] Diff contains only hunks the locked target requires. +- [ ] Any new score/read carries `scope:`; any new empty state is rendered, not omitted. +- [ ] Any new invariant is CLI-enforced with a test proving the refusal path throws. +- [ ] CHANGELOG [Unreleased] entry written, distinguishing CLI-enforced vs prompt-level. + +**New or edited skill:** +- [ ] Frontmatter has `name` + `description` (description is the full when-to-use pitch). +- [ ] Shape matches the house pattern: title tagline → Step 0 load state → procedure → + output contract in a fenced block → Serialize (exact `ratchet` commands) → + closing pointer to the next move. +- [ ] Canonical prompt exists/updated in `reference/PROMPTS.md` + command↔prompt map row. +- [ ] README command row exists. `npm test` (plugin-shape) green. + +**Release cut:** +- [ ] Branch `release/vX.Y.Z-`; five version fields + README examples aligned. +- [ ] CHANGELOG: [Unreleased] promoted with date + codename, lineage paragraph linking + prior gates, bold slogan; fresh empty [Unreleased]; compare links updated. +- [ ] `npm test` + `node bin/ratchet doctor` green. Commit `chore(release): vX.Y.Z `. +- [ ] STOPPED before merge/tag/Release. + +**Handoff / serialization:** +- [ ] Next action AND next command named; open loops each carry a route out + (ask-user · probe · park+owner · assumption+kill-test · defect). +- [ ] Probe outcomes labeled disposed/promoted; deviations from the map surfaced. +- [ ] One-sentence memory a cold agent can act on. Model tag stamped. + + + +Exact rules for uncertainty — decide by blast radius, not by comfort: + +1. **Proceed on stated assumption** (say the assumption, then act) when the change is + in-scope, reversible by `git checkout`, and provable by `npm test`: naming, test + shape, comment wording, which file a helper lives in, CHANGELOG phrasing. +2. **Park + ask in the deliverable** (build everything else, leave an open loop with + the question and your recommended answer) when: two designs both pass the tests but + change a public shape (CLI flags, JSON output fields, state schema); or an attack + finding is a scoped product decision. +3. **Stop and ask before acting** — no exceptions: pushing, merging, tagging, GitHub + Releases, npm publish; adding any dependency; deleting or migrating state files + (`state.json` / `ledger.json` schema changes); touching `.gitignore` privacy rules + or anything under `reference/private`; force-flags of any kind on real state; + implementing a parked open loop. +4. **Broken-world rule:** if `npm test` is red before you start, report it and fix + nothing else until Danny picks: fix-first or branch-from-green. +5. **Honest failure beats silent retry:** after two failed fix attempts on the same + defect, stop, write the defect + evidence into the handoff, and downgrade the + verdict (KEEP → ASK or REVERTED_AND_LEARNED). Never widen the diff to escape a test. + + + +1. Establish the locked target: Danny's words > CHANGELOG [Unreleased] > the named next + edge in the latest release memory/PR. State the assumption if you inferred it. +2. Read ground truth before editing: the relevant SKILL.md + PROMPTS.md entry, the src + file(s), and the test file that guards them. +3. Run one evidence-gated loop: LOCK → BUILD smallest → ATTACK (five-voice board) → + PATCH only failures → VERIFY with `npm test` → KEEP / REVERT / REVERTED_AND_LEARNED. +4. SERIALIZE: end the turn with this block, exactly — + +LOCKED TARGET: +GROUND TRUTH READ: +CHANGE: ATTACK: -VERIFY: -VERDICT: KEEP | REVERT | REVERTED_AND_LEARNED — +VERIFY: +VERDICT: KEEP | REVERT | REVERTED_AND_LEARNED — REMAINING RISK: -NEXT EDGE: -HANDOFF SENTENCE: - +NEXT EDGE: +HANDOFF SENTENCE: + +The turn is complete only when an artifact exists, `npm test` is green (or the failure +is honestly reported), and the handoff sentence lets a cold agent act without asking. + - -The turn is complete only when an artifact exists, `npm test` is green (or a failure is honestly reported), and the handoff sentence would let a cold agent take the correct next action without asking you anything. - \ No newline at end of file + diff --git a/README.md b/README.md index 9871a00..261dceb 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,28 @@ restarting. *Ambiguity in. Artifact out. Failure tested. State advanced.* --- +## The thesis — verified guardrails lift cognitive load + +Most "process" *adds* load: another checklist to hold, another gate to satisfy. Torque Loop +does the inverse. It externalizes state — the ledger holds the open defects, the map holds +"don't overwrite that", the receipt is the one cold read — so the agent runs on a smaller +working set and spends its scarce **cognitive load** on the irreducible judgment (the taste +call, the design fork) instead of the bookkeeping it can't reliably hold anyway. + +But the lift is conditional, and the condition is the whole product: + +> A guardrail only lifts load in proportion to how far you can trust it **without +> re-checking it**. An **unverified guardrail** is a **liability** wearing the costume of +> relief — it doesn't remove the load, it hides it, because now you carry a false belief +> that bites three moves later. + +So the gates are not ceremony. **No proof → no keep** and **wrong proof → no ship** are the +*price of being allowed to stop re-checking* — the up-front cost, paid in verification, that +makes the offload real instead of imaginary. Everything below is the machinery that charges +that price. + +--- + ## Install Torque Loop has two halves: the **agent plugin** (skills/commands, agents, and Claude-only @@ -148,7 +170,7 @@ node bin/ratchet --help Verify: ```bash -ratchet --version # -> ratchet 0.2.0 +ratchet --version # -> ratchet 0.7.0 ratchet init ratchet status ``` diff --git a/package.json b/package.json index 49e002c..375c906 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "scripts": { "test": "node test/cli.test.js && node test/evolve.test.js && node test/plugin-shape.test.js", + "preflight": "node scripts/preflight.js", "ratchet": "node bin/ratchet", "ratchet-evolve": "node bin/ratchet-evolve" }, diff --git a/reference/graph/README.md b/reference/graph/README.md new file mode 100644 index 0000000..21b8994 --- /dev/null +++ b/reference/graph/README.md @@ -0,0 +1,65 @@ +# reference/graph — the skill graph, derived not remembered + +`torque-loop.cypher` is a knowledge graph of this plugin's skills. It is **generated**, never +hand-edited. The generator (`scripts/graph-gen.js`) reads the real source and emits Cypher; +`test/plugin-shape.test.js` byte-matches the committed file against a fresh generation, so a +drifted graph fails CI exactly like a stale version number does. + +This exists because the first pass at this graph was hand-authored — a snapshot of the skills +as of one night, with nothing tying it back to source. By the plugin's own thesis (`README.md`), +an unverified guardrail is a liability wearing the costume of relief: the moment a skill changes, +a remembered graph silently lies and you trust it without re-checking. A *derived* graph cannot. + +## Regenerate + +``` +node scripts/graph-gen.js # rewrite reference/graph/torque-loop.cypher +node scripts/graph-gen.js --check # exit 1 if the committed file is stale +``` + +Change a skill's frontmatter or the PROMPTS.md map, rerun the generator, commit the new `.cypher` +in the same change. If you forget, `npm test` (plugin-shape) turns red. + +## What is in the graph (scope: DERIVED from torque-loop source only) + +Namespace `ns: 'torque-loop'` on every node. The load script's first statement is +`MATCH (n {ns: 'torque-loop'}) DETACH DELETE n;` — a **delete-and-rebuild**, so a shrunk or +reordered graph can never leave a stale node or `STEP`/`NEXT_PHASE` edge behind as if it were +truth. (The earlier MERGE-only reload was additive-idempotent only; this closes that hole.) + +| Node | Source | Key | +| --- | --- | --- | +| `RatchetSkill` | `skills/*/SKILL.md` frontmatter (dir name = command) | `name` | +| `RatchetPhase` | the canonical path line in `reference/PROMPTS.md` | `name`, `step` | +| `RatchetPrompt` | the command ↔ prompt map table in `reference/PROMPTS.md` | `slug` | + +| Edge | Meaning | +| --- | --- | +| `(RatchetPhase)-[:NEXT_PHASE]->(RatchetPhase)` | frame → choose → build → attack → patch → serialize → advance | +| `(RatchetSkill)-[:IMPLEMENTS]->(RatchetPrompt)` | which canonical prompt a skill implements (a skill may implement two) | + +Loading: any Cypher runner that accepts `;`-terminated statements (cypher-shell, Memgraph). The +file is a portable source artifact; nothing in this repo executes it. + +## PARKED — the aperture cross-links (declared, not derived) + +The first graph pass also carried ~11 cross-links from aperture *mechanism* skills to ratchet +*execution* skills (e.g. `entity-bridge → lock`, `yield-loop → loop`) plus a +`MECHANISM_LAYER_OVER` doctrine edge, grounded in the 2026-07-07 harness lock: *one execution +grammar (ratchet), many mechanism tags (aperture)*. Those are **deliberately absent here** for two +honest reasons, and are parked rather than dropped: + +- **Not in this repo's source.** Their far endpoint is the aperture repo. This generator can only + derive what torque-loop source contains; a bridge it cannot verify does not belong in the derived + file (convention 15 — a scoped decision is parked with an owner, never silently shipped). +- **Never adversarially attacked.** The specific pairings were one model's judgment. `entity-bridge` + could as defensibly bind to `map` as to `lock`. Until a hostile pass reviews the mapping, encoding + it as canon is self-grading. + +**Route out (owner: Danny).** Author the aperture-side generator *in the aperture repo*, deriving +each mechanism skill's `use` text from its own `SKILL.md` (fixes the second-hand-text hole at its +source), then emit the bridge edges as an explicitly `declared: true, verified: false` overlay whose +torque-loop endpoints this repo's guard can still check. Cross-repo bridges are a graph-merge +concern, not a torque-loop build. + +Traced by: claude-opus-4-8[1m] diff --git a/reference/graph/torque-loop.cypher b/reference/graph/torque-loop.cypher new file mode 100644 index 0000000..d533af5 --- /dev/null +++ b/reference/graph/torque-loop.cypher @@ -0,0 +1,109 @@ +// torque-loop skill graph — GENERATED, do not edit by hand. +// Source: skills/*/SKILL.md frontmatter + reference/PROMPTS.md +// Regenerate: node scripts/graph-gen.js Drift guard: test/plugin-shape.test.js +// Scope: DERIVED from torque-loop source only. The aperture cross-links are a +// parked, declared decision — see reference/graph/README.md, not this file. +// Traced by: claude-opus-4-8[1m] + +// Delete-and-rebuild: drop the whole namespace first, so a shrunk or reordered +// graph can never leave a stale node or STEP edge behind as if it were truth. +MATCH (n {ns: 'torque-loop'}) DETACH DELETE n; + +// --- skills (21) --- +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'attack'}) + SET s.command = '/ratchet:attack', s.description = 'Run adversarial review on a finished-looking artifact. Use to replace self-praise with hostile pressure before shipping. Convenes a five-voice review board (impatient user, competitor, maintainer, auditor, saboteur), returns concrete failure modes with severity and evidence demanded, and the smallest patch each requires.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'auction'}) + SET s.command = '/ratchet:auction', s.description = 'Rank the real blockers by leverage and pick the one bottleneck to attack. Use when there are many possible next moves and you risk doing interesting-but-not-advancing work. Scores obstacles by Leverage × Certainty × Speed-to-unblock × Risk-of-ignoring and names the winner and why it beats the tempting alternative.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'build'}) + SET s.command = '/ratchet:build', s.description = 'Force artifact production — stop talking and make the thing. Use when a target is locked and something concrete must exist. Produces the smallest usable v0 (spec, prompt, checklist, test-suite, decision-record, code-patch, qa-ledger, or operating-procedure) with explicit holes and a 5-point working test.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'burn'}) + SET s.command = '/ratchet:burn', s.description = 'Kill or park the options that are quietly draining energy. Use when you are carrying option debt — too many open possibilities, none advancing. Returns a kill / park / keep table, why each option is attractive, what fear keeps it alive, what gets simpler if it dies, and one option to burn right now.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'compile'}) + SET s.command = '/ratchet:compile', s.description = 'Serialize the current session into durable state — the breadcrumb the next session resumes from. Use at the end of a work session or after any real advance. Returns working title, objective, decisions, artifacts, defects/risks, open loops, next action, next command, retrieval tags, and a one-sentence memory. Only keeps information that changes future behavior.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'cut'}) + SET s.command = '/ratchet:cut', s.description = 'Attack the hidden assumptions that could make the current plan look stupid in hindsight. Use before committing effort to a direction. Returns the load-bearing assumptions, how each breaks, the signal that would reveal the break, and the cheapest test to falsify it — then the top three kill-tests to run now.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'decide'}) + SET s.command = '/ratchet:decide', s.description = 'Force a single defended choice instead of a menu of options. Use when analysis has produced alternatives but no commitment. Scores options on upside, reversibility, time-to-feedback, strategic compounding, cost-of-being-wrong, and unlock value; returns the chosen path, the most tempting rejected path and why it loses, the first action, and the reversal tripwire.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'evolve'}) + SET s.command = '/ratchet:evolve', s.description = 'Evolve one artifact through a bounded, evidence-gated mutation loop — lock target, snapshot baseline, apply one pressure, generate candidate deltas, judge, patch, verify, keep or revert, record state, and name the next edge. Not brainstorming and not a general \'make this better\': every kept change must be proven. Use to harden a specific code file, prompt, skill, test suite, README, spec, or workflow along a chosen pressure vector.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'handoff'}) + SET s.command = '/ratchet:handoff', s.description = 'Produce a compact handoff for another model, agent, teammate, or future session. Use when work must transfer without losing state. Returns a self-contained brief — objective, what\'s done, what\'s decided, what\'s open, the exact next action, and the context needed to act — compressed so the receiver can start immediately with zero re-derivation.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'ignite'}) + SET s.command = '/ratchet:ignite', s.description = 'Run the full consequence loop on any messy task, project state, prompt pile, codebase goal, or vague direction. Use this when you do not know which Ratchet command to use. Locks a target, ranks blockers, cuts assumptions, builds the smallest artifact, attacks it, patches failures, and serializes state — ending with the single next move.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'lock'}) + SET s.command = '/ratchet:lock', s.description = 'Convert vague input into a locked, executable target. Use when a task is fuzzy, over-broad, or stated as a wish. Returns the literal object, operation, output shape, real outcome, proof-of-done, smallest progress artifact, the highest-information missing variable, and the assumption to adopt if it stays missing.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'loop'}) + SET s.command = '/ratchet:loop', s.description = 'Run recursive improvement — build → attack → patch → compile — until the artifact is genuinely done. Use to drive an artifact to a stable, tested, serialized state without stopping at the first draft. Repeats the cycle and reports an iteration log and confidence score, stopping only when no critical/high defect, no untested core assumption, and no missing next action remain.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'map'}) + SET s.command = '/ratchet:map', s.description = 'Map the fog before you build. Use when uncertainty is high (aperture A3–A4), the terrain is unfamiliar, the taste is "I\'ll know it when I see it", or you are porting a reference implementation. Walks the task through four unknown-quadrants — known knowns (settled ground with file evidence), known unknowns (one blast-radius-ordered question at a time), unknown knowns (tacit taste surfaced by putting something concrete in front of the user), and unknown unknowns (a swept landmine field) — then hands over one durable four-quadrant map, a tweakable build plan, and a copy-paste implementation prompt. What you deliver is the map, not a build; you do not write code until it is handed over.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'mechanism'}) + SET s.command = '/ratchet:mechanism', s.description = 'Name the mechanism when a situation feels confusing. Use to cut through a tangle to the one causal handle. Separates surface symptoms from the underlying mechanism, the constraint causing it, the feedback loop keeping it alive, and the highest-leverage intervention point — then the action that changes the mechanism rather than describing it.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'patch'}) + SET s.command = '/ratchet:patch', s.description = 'Fix only what failed — nothing else. Use after an attack surfaced defects, to prevent the model from rewriting everything for elegance. Returns the minimal delta under REMOVE / ADD / CHANGE, the patched artifact, the relevant retest result, and any defects still open.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'prompt-audit'}) + SET s.command = '/ratchet:prompt-audit', s.description = 'Audit a prompt library as an operating system, not a pile of prompts. Use to find what a prompt collection over-does, under-does, and where it leaks into meta-looping. Classifies each prompt by the work it performs, finds overrepresented and missing moves, flags prompts that cause meta-loops or never produce artifacts, and returns the minimum upgraded set as a sequenced workflow.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'push'}) + SET s.command = '/ratchet:push', s.description = 'Push the boundary after a safe version already exists. Use to find the boldest adjacent move that could multiply the result without turning it into fantasy. Returns the safe default, the boundary-pushing path, why it might work, why it might be stupid, the cheapest experiment that distinguishes them, the version to try, and the first irreversible move. Controlled aggression, never recklessness.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'qa-ledger'}) + SET s.command = '/ratchet:qa-ledger', s.description = 'Create or update the canonical feature / test / defect ledger for a codebase. Use to turn a repo into a durable quality record that survives sessions. Maintains features (with evidence), tests (with status), and defects (with severity) via the ratchet CLI so quality state is queryable, not remembered.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'repo-audit'}) + SET s.command = '/ratchet:repo-audit', s.description = 'Discover user-facing features, workflows, routes, APIs, configs, and tests in a codebase by evidence, not guesses. Use to turn an unfamiliar or under-documented repo into an inventory of what actually ships. Returns features with code evidence, undocumented screens/routes/workflows, APIs and state transitions needing tests, the highest-risk feature, and the first test suite to write.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'status'}) + SET s.command = '/ratchet:status', s.description = 'Read the current ratchet state so the loop doesn\'t rediscover itself. Use to orient at the start of a session or between moves. Returns the current objective, active artifact, last decision, open defects, confidence, next recommended command, and a staleness warning if work changed without a compile.'; +MERGE (s:RatchetSkill {ns: 'torque-loop', name: 'verify'}) + SET s.command = '/ratchet:verify', s.description = 'Run a change (or an artifact) through a test harness built to embarrass it, then record the results as defects. Use instead of asking whether the work is good — never self-grade. Builds acceptance / happy-path / edge / abuse / ambiguity / regression tests plus fake-progress red flags, runs the artifact through them, and returns passes, failures, severity, required patches, and whether it is usable despite failures.'; + +// --- canonical phase path (frame -> choose -> build -> attack -> patch -> serialize -> advance) --- +MERGE (p:RatchetPhase {ns: 'torque-loop', name: 'frame'}) SET p.step = 0; +MERGE (p:RatchetPhase {ns: 'torque-loop', name: 'choose'}) SET p.step = 1; +MERGE (p:RatchetPhase {ns: 'torque-loop', name: 'build'}) SET p.step = 2; +MERGE (p:RatchetPhase {ns: 'torque-loop', name: 'attack'}) SET p.step = 3; +MERGE (p:RatchetPhase {ns: 'torque-loop', name: 'patch'}) SET p.step = 4; +MERGE (p:RatchetPhase {ns: 'torque-loop', name: 'serialize'}) SET p.step = 5; +MERGE (p:RatchetPhase {ns: 'torque-loop', name: 'advance'}) SET p.step = 6; +MATCH (a:RatchetPhase {ns: 'torque-loop', name: 'frame'}), (b:RatchetPhase {ns: 'torque-loop', name: 'choose'}) MERGE (a)-[:NEXT_PHASE]->(b); +MATCH (a:RatchetPhase {ns: 'torque-loop', name: 'choose'}), (b:RatchetPhase {ns: 'torque-loop', name: 'build'}) MERGE (a)-[:NEXT_PHASE]->(b); +MATCH (a:RatchetPhase {ns: 'torque-loop', name: 'build'}), (b:RatchetPhase {ns: 'torque-loop', name: 'attack'}) MERGE (a)-[:NEXT_PHASE]->(b); +MATCH (a:RatchetPhase {ns: 'torque-loop', name: 'attack'}), (b:RatchetPhase {ns: 'torque-loop', name: 'patch'}) MERGE (a)-[:NEXT_PHASE]->(b); +MATCH (a:RatchetPhase {ns: 'torque-loop', name: 'patch'}), (b:RatchetPhase {ns: 'torque-loop', name: 'serialize'}) MERGE (a)-[:NEXT_PHASE]->(b); +MATCH (a:RatchetPhase {ns: 'torque-loop', name: 'serialize'}), (b:RatchetPhase {ns: 'torque-loop', name: 'advance'}) MERGE (a)-[:NEXT_PHASE]->(b); + +// --- canonical prompts (17) --- +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '1-the-target-is-not-the-topic'}) SET p.title = '1 · The Target Is Not The Topic'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '10-the-option-burn'}) SET p.title = '10 · The Option Burn'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '11-the-state-compiler'}) SET p.title = '11 · The State Compiler'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '12-the-boundary-push'}) SET p.title = '12 · The Boundary Push'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '2-the-friction-auction'}) SET p.title = '2 · The Friction Auction'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '3-the-assumption-guillotine'}) SET p.title = '3 · The Assumption Guillotine'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '4-the-mechanism-knife'}) SET p.title = '4 · The Mechanism Knife'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '5-the-hostile-board'}) SET p.title = '5 · The Hostile Board'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '6-artifact-or-void'}) SET p.title = '6 · Artifact Or Void'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '7-the-embarrassment-harness'}) SET p.title = '7 · The Embarrassment Harness'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '8-the-delta-surgeon'}) SET p.title = '8 · The Delta Surgeon'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: '9-the-decision-spike'}) SET p.title = '9 · The Decision Spike'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: 'master-ignition-prompt-consequence-engine'}) SET p.title = 'Master Ignition Prompt (consequence engine)'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: 'prompt-library-evolution-prompt'}) SET p.title = 'Prompt-library evolution prompt'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: 'software-qa-agent-prompt'}) SET p.title = 'Software QA agent prompt'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: 'state-compiler-aimed-at-another-agent'}) SET p.title = 'State Compiler, aimed at another agent'; +MERGE (p:RatchetPrompt {ns: 'torque-loop', slug: 'the-fog-map-four-quadrant-unknowns-walk'}) SET p.title = 'The Fog Map (four-quadrant unknowns walk)'; + +// --- skill implements prompt (19) --- +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'attack'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '5-the-hostile-board'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'auction'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '2-the-friction-auction'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'build'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '6-artifact-or-void'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'burn'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '10-the-option-burn'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'compile'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '11-the-state-compiler'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'cut'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '3-the-assumption-guillotine'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'decide'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '9-the-decision-spike'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'handoff'}), (p:RatchetPrompt {ns: 'torque-loop', slug: 'state-compiler-aimed-at-another-agent'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'ignite'}), (p:RatchetPrompt {ns: 'torque-loop', slug: 'master-ignition-prompt-consequence-engine'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'lock'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '1-the-target-is-not-the-topic'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'map'}), (p:RatchetPrompt {ns: 'torque-loop', slug: 'the-fog-map-four-quadrant-unknowns-walk'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'mechanism'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '4-the-mechanism-knife'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'patch'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '8-the-delta-surgeon'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'prompt-audit'}), (p:RatchetPrompt {ns: 'torque-loop', slug: 'prompt-library-evolution-prompt'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'push'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '12-the-boundary-push'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'qa-ledger'}), (p:RatchetPrompt {ns: 'torque-loop', slug: 'software-qa-agent-prompt'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'repo-audit'}), (p:RatchetPrompt {ns: 'torque-loop', slug: 'software-qa-agent-prompt'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'verify'}), (p:RatchetPrompt {ns: 'torque-loop', slug: '7-the-embarrassment-harness'}) MERGE (s)-[:IMPLEMENTS]->(p); +MATCH (s:RatchetSkill {ns: 'torque-loop', name: 'verify'}), (p:RatchetPrompt {ns: 'torque-loop', slug: 'software-qa-agent-prompt'}) MERGE (s)-[:IMPLEMENTS]->(p); diff --git a/scripts/graph-gen.js b/scripts/graph-gen.js new file mode 100644 index 0000000..8b23550 Binary files /dev/null and b/scripts/graph-gen.js differ diff --git a/scripts/preflight.js b/scripts/preflight.js new file mode 100644 index 0000000..1817637 --- /dev/null +++ b/scripts/preflight.js @@ -0,0 +1,682 @@ +'use strict'; + +// Zero-dependency torque-loop preflight — the mechanical half of /preflight. +// Run: node scripts/preflight.js [base-ref] (or: npm run preflight) +// Exits non-zero only for MECHANICAL check failures; HYBRID/weak checks print +// evidence for a human to rule on. Lives outside package.json "files" so it never +// ships to plugin users. +// Traced by: claude-opus-4-8[1m] (fix + land) · codex gpt-5 (draft) · 2026-07-07 + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +function findRoot(start) { + let dir = start; + while (dir && dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'package.json'))) return dir; + dir = path.dirname(dir); + } + return process.cwd(); +} + +const root = findRoot(__dirname); +const base = process.argv[2] || 'main'; + +function run(cmd, args, options) { + // npm on Windows is npm.cmd — spawnSync can't launch a .cmd without a shell + // (EINVAL). Opt into shell only for callers that need it; git/node spawn direct. + const r = spawnSync(cmd, args, { + cwd: root, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + shell: Boolean(options && options.shell), + timeout: options && options.timeout + }); + return { + status: typeof r.status === 'number' ? r.status : 1, + stdout: r.stdout || '', + stderr: r.stderr || '', + error: r.error ? String(r.error.message || r.error) : '' + }; +} + +function git(args) { + return run('git', args); +} + +function gitText(args) { + const r = git(args); + return r.status === 0 ? r.stdout : ''; +} + +function gitLines(args) { + return lines(gitText(args)).map(normalizePath); +} + +function lines(text) { + return String(text || '').split(/\r?\n/).filter(Boolean); +} + +function normalizePath(p) { + return String(p || '').replace(/\\/g, '/').replace(/^\.\//, ''); +} + +function uniq(items) { + return Array.from(new Set(items.filter(Boolean))); +} + +function readMaybe(rel) { + try { + return fs.readFileSync(path.join(root, rel), 'utf8'); + } catch (_) { + return ''; + } +} + +function readJsonMaybe(rel) { + const text = readMaybe(rel); + if (!text) return null; + try { + return JSON.parse(text); + } catch (_) { + return null; + } +} + +function gitShowJson(ref, rel) { + const r = git(['show', `${ref}:${rel}`]); + if (r.status !== 0) return null; + try { + return JSON.parse(r.stdout); + } catch (_) { + return null; + } +} + +function exists(rel) { + return fs.existsSync(path.join(root, rel)); +} + +function shorten(s, n) { + s = String(s || '').replace(/\s+/g, ' ').trim(); + return s.length > n ? s.slice(0, n - 3) + '...' : s; +} + +function formatList(items, max) { + if (!items.length) return 'none'; + const shown = items.slice(0, max || 8); + return shown.join(', ') + (items.length > shown.length ? `, +${items.length - shown.length} more` : ''); +} + +function result(n, name, type, status, summary, details, fix) { + return { n, name, type, status, summary, details: details || [], fix }; +} + +function commandTail(r) { + return lines([r.error, r.stdout, r.stderr].filter(Boolean).join('\n')).slice(-12).map((l) => shorten(l, 180)); +} + +function parseHunks(diffText) { + const out = []; + let file = null; + let hunk = null; + for (const line of String(diffText || '').split(/\r?\n/)) { + let m = /^diff --git a\/(.+) b\/(.+)$/.exec(line); + if (m) { + file = normalizePath(m[2]); + hunk = null; + continue; + } + m = /^\+\+\+ b\/(.+)$/.exec(line); + if (m) { + file = normalizePath(m[1]); + continue; + } + if (line.startsWith('@@')) { + hunk = { file, header: line, added: [], removed: [] }; + out.push(hunk); + continue; + } + if (!hunk) continue; + if (line.startsWith('+') && !line.startsWith('+++')) hunk.added.push(line.slice(1)); + if (line.startsWith('-') && !line.startsWith('---')) hunk.removed.push(line.slice(1)); + } + return out; +} + +function pathsFromNameStatus(text) { + const out = []; + for (const line of lines(text)) { + const parts = line.split(/\t+/); + if (!parts.length) continue; + const status = parts[0]; + if (/^[RC]/.test(status) && parts[2]) out.push(normalizePath(parts[2])); + else if (parts[1]) out.push(normalizePath(parts[1])); + } + return out; +} + +function pathsFromStatus(text) { + return lines(text).map((line) => { + let p = line.slice(3); + if (p.includes(' -> ')) p = p.split(' -> ').pop(); + return normalizePath(p.replace(/^"|"$/g, '')); + }); +} + +// A base ref that doesn't resolve turns every base-relative git call into a +// swallowed error that masquerades as a check FAILURE (misleading "cannot parse +// package.json", leaked git errors — defect def-mrb00eo201). Validate once and fail +// fast with exit 2 (distinct from 1 = a check failed) instead of pretending the +// content is broken when it is the ref that is missing. +if (git(['rev-parse', '--verify', '--quiet', `${base}^{commit}`]).status !== 0) { + process.stderr.write(`preflight: base ref '${base}' does not resolve — fetch it or pass a valid ref (e.g. origin/main).\n`); + process.exit(2); +} + +const committedDiff = gitText(['diff', '--no-ext-diff', '--unified=0', `${base}...HEAD`]); +const cachedDiff = gitText(['diff', '--cached', '--no-ext-diff', '--unified=0']); +const worktreeDiff = gitText(['diff', '--no-ext-diff', '--unified=0']); +const combinedDiff = [committedDiff, cachedDiff, worktreeDiff].filter(Boolean).join('\n'); +const hunks = parseHunks(combinedDiff); + +const nameStatusText = [ + gitText(['diff', '--name-status', '-M', `${base}...HEAD`]), + gitText(['diff', '--cached', '--name-status', '-M']), + gitText(['diff', '--name-status', '-M']) +].filter(Boolean).join('\n'); + +const statusText = gitText(['status', '--porcelain', '--untracked-files=all']); +const changedPaths = uniq(pathsFromNameStatus(nameStatusText).concat(pathsFromStatus(statusText))); +const shortStat = gitText(['diff', '--shortstat', `${base}...HEAD`]).trim() || '0 committed file changes'; + +function checkGreenWorld() { + const test = run('npm', ['test'], { timeout: 120000, shell: true }); + const doctor = run('node', ['bin/ratchet', 'doctor'], { timeout: 60000 }); + const pass = test.status === 0 && doctor.status === 0; + const counts = (test.stdout + '\n' + test.stderr).match(/\d+\s+passed/g) || []; + return result( + 1, + 'green world', + 'MECHANICAL', + pass ? 'PASS' : 'FAIL', + pass ? `test counts ${formatList(counts, 6)}; doctor exit 0` : `npm test exit ${test.status}; doctor exit ${doctor.status}`, + pass ? [] : commandTail(test).concat(commandTail(doctor)), + 'make npm test and node bin/ratchet doctor pass' + ); +} + +function versionFieldsFromCurrent() { + const pkg = readJsonMaybe('package.json') || {}; + const claudePlugin = readJsonMaybe('.claude-plugin/plugin.json') || {}; + const claudeMarket = readJsonMaybe('.claude-plugin/marketplace.json') || {}; + const codexPlugin = readJsonMaybe('.codex-plugin/plugin.json') || {}; + return [ + ['package.json', pkg.version], + ['.claude-plugin/plugin.json', claudePlugin.version], + ['.claude-plugin/marketplace.json metadata.version', claudeMarket.metadata && claudeMarket.metadata.version], + ['.claude-plugin/marketplace.json plugins[0].version', claudeMarket.plugins && claudeMarket.plugins[0] && claudeMarket.plugins[0].version], + ['.codex-plugin/plugin.json', codexPlugin.version] + ]; +} + +function versionFieldsAtBase() { + const pkg = gitShowJson(base, 'package.json') || {}; + const claudePlugin = gitShowJson(base, '.claude-plugin/plugin.json') || {}; + const claudeMarket = gitShowJson(base, '.claude-plugin/marketplace.json') || {}; + const codexPlugin = gitShowJson(base, '.codex-plugin/plugin.json') || {}; + return [ + ['package.json', pkg.version], + ['.claude-plugin/plugin.json', claudePlugin.version], + ['.claude-plugin/marketplace.json metadata.version', claudeMarket.metadata && claudeMarket.metadata.version], + ['.claude-plugin/marketplace.json plugins[0].version', claudeMarket.plugins && claudeMarket.plugins[0] && claudeMarket.plugins[0].version], + ['.codex-plugin/plugin.json', codexPlugin.version] + ]; +} + +function checkVersionAlignment() { + const fields = versionFieldsFromCurrent(); + const target = fields[0][1]; + const stale = fields.filter((f) => !f[1] || f[1] !== target).map((f) => `${f[0]}=${f[1] || ''}`); + + const readme = readMaybe('README.md'); + const hits = readme.match(/->\s*ratchet\s+\d+\.\d+\.\d+/g) || []; + const readmeBad = hits.filter((hit) => { + const m = /(\d+\.\d+\.\d+)/.exec(hit); + return !m || m[1] !== target; + }); + if (!hits.length) stale.push('README has no -> ratchet X.Y.Z example'); + for (const hit of readmeBad) stale.push(`README example ${hit.trim()}`); + + const baseFields = versionFieldsAtBase(); + const changed = fields.filter((f, i) => f[1] !== baseFields[i][1]).map((f) => f[0]); + + return result( + 4, + 'version alignment', + 'MECHANICAL', + stale.length ? 'FAIL' : 'PASS', + stale.length ? formatList(stale, 8) : `all ${target}; changed fields ${formatList(changed, 5)}`, + [], + 'align package, plugin manifests, marketplace metadata, and README version examples' + ); +} + +function unreleasedSection() { + const text = readMaybe('CHANGELOG.md'); + const m = /^##\s*\[?Unreleased\]?[\s\S]*?(?=^##\s+)/mi.exec(text); + return m ? m[0] : text; +} + +function approvalLines(section) { + return lines(section).filter((line) => + /Danny/i.test(line) && + /["“][^"”]*(yes|approved|approve|ok|okay)[^"”]*["”]/i.test(line) + ); +} + +function checkDependencyGate() { + const cur = readJsonMaybe('package.json'); + const old = gitShowJson(base, 'package.json'); + if (!cur || !old) { + return result(7, 'dependency gate', 'MECHANICAL', 'FAIL', 'cannot parse current or base package.json', [], 'restore readable package.json at current and base ref'); + } + + const added = []; + for (const section of ['dependencies', 'devDependencies']) { + const curDeps = cur[section] || {}; + const oldDeps = old[section] || {}; + for (const name of Object.keys(curDeps)) { + if (!Object.prototype.hasOwnProperty.call(oldDeps, name)) added.push(`${section}.${name}`); + } + } + + const depNames = added.map((x) => x.split('.').slice(1).join('.')); + const approvals = approvalLines(unreleasedSection()); + const approval = added.length > 0 && approvals.some((line) => + (/dependenc/i.test(line) || depNames.every((name) => line.includes(name))) + ); + + return result( + 7, + 'dependency gate', + 'MECHANICAL', + added.length === 0 || approval ? 'PASS' : 'FAIL', + added.length === 0 ? 'no new dependencies/devDependencies' : `${formatList(added, 8)}; Danny quote ${approval ? 'found' : 'missing'}`, + approvals.map((l) => `approval candidate: ${shorten(l, 180)}`), + 'remove the new dependency or add an explicit Danny approval quote in [Unreleased]' + ); +} + +function isPrivatePath(p) { + p = normalizePath(p); + const baseName = path.posix.basename(p); + return p.startsWith('reference/PROBLEM-STATEMENT-') || + p.startsWith('reference/private/') || + p.endsWith('.private.md') || + p === '.lucid' || p.startsWith('.lucid/') || + p === '.ratchet' || p.startsWith('.ratchet/') || + baseName.startsWith('.sandbox-') || p.startsWith('.sandbox-'); +} + +function walk(rel, out) { + const abs = path.join(root, rel); + if (!fs.existsSync(abs)) return; + const st = fs.statSync(abs); + if (st.isFile()) { + out.push(normalizePath(rel)); + return; + } + if (!st.isDirectory()) return; + for (const ent of fs.readdirSync(abs, { withFileTypes: true })) { + if (ent.name === '.git' || ent.name === 'node_modules') continue; + walk(path.join(rel, ent.name), out); + } +} + +function readSmallText(rel) { + try { + const abs = path.join(root, rel); + const st = fs.statSync(abs); + if (!st.isFile() || st.size > 1024 * 1024) return ''; + const buf = fs.readFileSync(abs); + if (buf.includes(0)) return ''; + return buf.toString('utf8'); + } catch (_) { + return ''; + } +} + +function privateSourceFiles() { + const all = []; + walk('.', all); + return uniq(all.concat(gitLines(['ls-files']), changedPaths)).filter(isPrivatePath); +} + +function normalizedLeakLine(s) { + return String(s || '').trim().replace(/\s+/g, ' '); +} + +function privateLineLeaks() { + const owners = new Map(); + for (const file of privateSourceFiles()) { + const text = readSmallText(file); + for (const line of lines(text)) { + const normalized = normalizedLeakLine(line); + if (normalized.length >= 40) owners.set(normalized, file); + } + } + + const hits = []; + for (const h of hunks) { + if (!h.file || isPrivatePath(h.file)) continue; + for (const added of h.added) { + const normalized = normalizedLeakLine(added); + if (owners.has(normalized)) hits.push(`${h.file} quotes ${owners.get(normalized)}: ${shorten(normalized, 120)}`); + } + } + return hits; +} + +function checkLeakScan() { + const pathHits = changedPaths.filter(isPrivatePath); + const quoteHits = privateLineLeaks(); + const failures = pathHits.map((p) => `private path ${p}`).concat(quoteHits); + + return result( + 9, + 'leak scan', + 'MECHANICAL', + failures.length ? 'FAIL' : 'PASS', + failures.length ? formatList(failures, 6) : 'no private paths or exact private-line quotes', + failures.slice(0, 12), + 'remove private paths and exact private-doc quotes from public diff' + ); +} + +function isDurableDoc(p) { + // Authored durable docs carry a model tag (convention 12). templates/ is + // file-shape scaffolding, not authored prose — exempt (Danny, 2026-07-07). + return p === 'CLAUDE.md' || + p.startsWith('reference/') || + p.startsWith('handoffs/') || + p.startsWith('handoff/'); +} + +function commitsSinceBase() { + const r = git(['log', '--format=%H%n%B%n---TORQUE-LOOP-COMMIT---', `${base}..HEAD`]); + if (r.status !== 0) return { error: r.stderr || r.error || 'git log failed', commits: [] }; + return { + error: '', + commits: r.stdout.split('---TORQUE-LOOP-COMMIT---').map((b) => b.trim()).filter(Boolean).map((block) => { + const ls = lines(block); + return { hash: ls[0], body: ls.slice(1).join('\n') }; + }) + }; +} + +function checkTraceTags() { + const durable = changedPaths.filter(isDurableDoc).filter(exists); + const missingTags = durable.filter((p) => !/Traced by:\s*[^\r\n<]+/i.test(readMaybe(p))); + + const commitInfo = commitsSinceBase(); + const missingFooters = commitInfo.commits + .filter((c) => !/^Co-authored-by:\s*.+<[^>]+>/mi.test(c.body)) + .map((c) => c.hash.slice(0, 12)); + + const failures = missingTags.map((p) => `missing trace tag ${p}`) + .concat(missingFooters.map((h) => `missing co-author footer ${h}`)); + if (commitInfo.error) failures.push(`git log failed: ${shorten(commitInfo.error, 120)}`); + + return result( + 10, + 'trace tags', + 'MECHANICAL', + failures.length ? 'FAIL' : 'PASS', + failures.length ? formatList(failures, 8) : `${durable.length} durable docs; ${commitInfo.commits.length} commits checked`, + failures, + 'add Traced by tags to durable docs and Co-authored-by footers to commits' + ); +} + +const testFiles = []; +walk('test', testFiles); +const testText = new Map(testFiles.map((f) => [f, readMaybe(f)])); + +function extractTokens(linesIn) { + const skip = new Set(['const', 'let', 'var', 'function', 'return', 'require', 'module', 'exports', 'string', 'number', 'object', 'true', 'false', 'null', 'undefined']); + const out = new Set(); + for (const line of linesIn) { + for (const m of line.match(/--[a-z0-9][a-z0-9-]*/gi) || []) out.add(m); + for (const m of line.match(/\/ratchet:[a-z0-9-]+/gi) || []) out.add(m); + for (const m of line.match(/\b[A-Za-z_][A-Za-z0-9_]{3,}\b/g) || []) { + if (!skip.has(m)) out.add(m); + } + } + return Array.from(out).slice(0, 12); +} + +function findTestMatches(tokens) { + const hits = []; + for (const token of tokens) { + const files = []; + for (const [file, text] of testText.entries()) { + if (text.includes(token)) files.push(file); + } + if (files.length) hits.push(`${token} -> ${formatList(files, 3)}`); + } + return hits.slice(0, 6); +} + +function checkTestlessBehaviorEvidence() { + const behaviorHunks = hunks.filter((h) => h.file && (h.file.startsWith('src/') || h.file.startsWith('bin/'))); + let withHits = 0; + const details = behaviorHunks.slice(0, 12).map((h) => { + const tokens = extractTokens(h.added.concat(h.removed)); + const matches = findTestMatches(tokens); + if (matches.length) withHits++; + return `${h.file} ${h.header}: tokens ${formatList(tokens, 6)}; test hits ${formatList(matches, 4)}`; + }); + + return result( + 2, + 'testless change', + 'HYBRID', + 'HUMAN', + `${behaviorHunks.length} src/bin hunks; ${withHits} have token hits. HUMAN: name the falsifier per hunk.`, + details + ); +} + +function checkWeakenedFalsifierEvidence() { + const assertRe = /\b(assert|strictEqual|deepStrictEqual|throws|doesNotThrow|rejects|match|ok|fail)\b/; + const looseRe = /(\.\*|\[\\s\\S\]\*|\bincludes\s*\(|>=|<=|\|\||optional|maybe)/i; + const candidates = []; + for (const h of hunks.filter((x) => x.file && x.file.startsWith('test/'))) { + const removed = h.removed.filter((l) => assertRe.test(l)); + const added = h.added.filter((l) => looseRe.test(l)); + if (removed.length || added.length) { + candidates.push(`${h.file} ${h.header}: removed ${removed.length} assertion-ish; added ${added.length} loose-ish`); + } + } + const just = lines(gitText(['log', '--format=%B', `${base}..HEAD`])) + .filter((l) => /(weaken|loosen|falsifier|assert)/i.test(l)); + + return result( + 3, + 'weakened falsifier', + 'HYBRID', + 'HUMAN', + `${candidates.length} candidates; ${just.length} commit-body justification lines. HUMAN: decide whether any assertion was weakened.`, + candidates.slice(0, 12).concat(just.slice(0, 6).map((l) => `justification candidate: ${shorten(l, 160)}`)) + ); +} + +function checkProseEnforcementEvidence() { + const normRe = /\b(must|never|requires|required|shall)\b/i; + const candidates = []; + for (const h of hunks.filter((x) => x.file === 'README.md' || /(^|\/)SKILL\.md$/.test(x.file || ''))) { + for (const added of h.added) { + if (normRe.test(added)) candidates.push(`${h.file}: ${shorten(added, 160)}`); + } + } + const cliTouched = changedPaths.some((p) => p.startsWith('bin/') || p.startsWith('src/')); + const testTouched = changedPaths.some((p) => p.startsWith('test/')); + const labels = /(CLI[- ]enforced|prompt[- ]level)/i.test(unreleasedSection()); + + return result( + 5, + 'prose enforcement', + 'HYBRID', + 'HUMAN', + `${candidates.length} normative additions; cli/src touched ${cliTouched}; tests touched ${testTouched}; changelog label ${labels}. HUMAN: map each claim to enforcement or prompt-level label.`, + candidates.slice(0, 12) + ); +} + +function diffNameOnly(ignoreWhitespace) { + const opt = ignoreWhitespace ? ['-w'] : []; + return uniq( + gitLines(['diff'].concat(opt, ['--name-only', `${base}...HEAD`])) + .concat(gitLines(['diff', '--cached'].concat(opt, ['--name-only']))) + .concat(gitLines(['diff'].concat(opt, ['--name-only']))) + ); +} + +function checkDiffMinimalityEvidence() { + const all = diffNameOnly(false); + const semantic = new Set(diffNameOnly(true)); + const whitespaceOnly = all.filter((p) => !semantic.has(p)); + const renames = lines(nameStatusText).filter((l) => /^R\d+/.test(l)); + + return result( + 6, + 'diff minimality', + 'HYBRID', + 'HUMAN', + `${hunks.length} hunks; ${renames.length} renames; ${whitespaceOnly.length} whitespace-only files. HUMAN: map every hunk to the locked target.`, + renames.slice(0, 8).concat(whitespaceOnly.slice(0, 8).map((p) => `whitespace-only candidate: ${p}`)) + ); +} + +function collectParked(value, out) { + if (!value || typeof value !== 'object') return; + if (Array.isArray(value)) { + for (const item of value) collectParked(item, out); + return; + } + const stateText = Object.keys(value).map((k) => `${k}:${String(value[k])}`).join(' '); + if (/\bparked\b/i.test(stateText)) { + const id = value.id || value.loopId || value.loop_id || value.key || value.name; + if (id) out.add(String(id)); + } + for (const v of Object.values(value)) collectParked(v, out); +} + +function checkParkedDecisionEvidence() { + const r = run('node', ['bin/ratchet', 'status', '--json'], { timeout: 60000 }); + const parked = new Set(); + if (r.status === 0) { + try { + collectParked(JSON.parse(r.stdout), parked); + } catch (_) {} + } + const ids = Array.from(parked); + const mentions = ids.filter((id) => combinedDiff.includes(id)); + + return result( + 8, + 'parked-decision creep', + 'HYBRID', + 'HUMAN', + r.status === 0 + ? `${ids.length} parked ids; ${mentions.length} mentioned in diff. HUMAN: decide if a parked loop was implemented.` + : `ratchet status --json failed. HUMAN: inspect open/parked loops manually.`, + mentions.map((id) => `diff mentions parked id ${id}`).concat(r.status === 0 ? [] : commandTail(r)) + ); +} + +function checkScopeEmptinessEvidence() { + const codeHunks = hunks.filter((h) => h.file && (h.file.startsWith('src/') || h.file.startsWith('bin/'))); + const scoreRead = []; + const rendered = []; + let scopeMentions = 0; + let emptyMentions = 0; + + for (const h of codeHunks) { + for (const added of h.added) { + if (/\bscope\s*:/.test(added)) scopeMentions++; + if (/\b(score|read)\b/i.test(added)) scoreRead.push(`${h.file}: ${shorten(added, 140)}`); + if (/\b(render|section|heading|append|push|write)\b/i.test(added)) rendered.push(`${h.file}: ${shorten(added, 140)}`); + if (/\b(empty|none|nothing|no\s+[a-z])/i.test(added)) emptyMentions++; + } + } + + return result( + 11, + 'scope + emptiness', + 'HYBRID', + 'HUMAN', + `${scoreRead.length} score/read candidates; ${scopeMentions} scope mentions; ${rendered.length} render candidates; ${emptyMentions} emptiness mentions. HUMAN: verify required fields/rendering.`, + scoreRead.slice(0, 6).concat(rendered.slice(0, 6)) + ); +} + +function isBehaviorPath(p) { + return p.startsWith('src/') || + p.startsWith('bin/') || + p.startsWith('skills/') || + p.startsWith('hooks/') || + p.startsWith('templates/') || + p === 'README.md' || + p === 'package.json'; +} + +function checkChangelogEvidence() { + const behaviorPaths = changedPaths.filter(isBehaviorPath); + const changelogTouched = changedPaths.includes('CHANGELOG.md'); + const labels = /(CLI[- ]enforced|prompt[- ]level)/i.test(unreleasedSection()); + + return result( + 12, + 'changelog', + 'HYBRID', + 'HUMAN', + `${behaviorPaths.length} behavior-path candidates; CHANGELOG touched ${changelogTouched}; label present ${labels}. HUMAN: decide behavior change and label adequacy.`, + behaviorPaths.slice(0, 12) + ); +} + +const checks = [ + checkGreenWorld(), + checkTestlessBehaviorEvidence(), + checkWeakenedFalsifierEvidence(), + checkVersionAlignment(), + checkProseEnforcementEvidence(), + checkDiffMinimalityEvidence(), + checkDependencyGate(), + checkParkedDecisionEvidence(), + checkLeakScan(), + checkTraceTags(), + checkScopeEmptinessEvidence(), + checkChangelogEvidence() +]; + +process.stdout.write(`PREFLIGHT vs ${base} - ${changedPaths.length} paths, ${shortStat}; ${lines(statusText).length} worktree status entries\n`); +for (const c of checks) { + const left = `${String(c.n).padStart(2, ' ')} ${c.name}`.padEnd(31, '.'); + process.stdout.write(`${left} ${c.status} ${c.summary}\n`); + for (const d of c.details.slice(0, 16)) process.stdout.write(` ${d}\n`); +} + +const failures = checks.filter((c) => c.type === 'MECHANICAL' && c.status === 'FAIL'); +process.stdout.write(`VERDICT: ${failures.length ? `BLOCKED - ${failures.length} mechanical failures` : 'CLEAR ON MECHANICAL CHECKS - resolve HUMAN lines before PR'}\n`); +process.stdout.write('SMALLEST PATCHES:\n'); +if (!failures.length) { + process.stdout.write(' none from mechanical checks\n'); +} else { + for (const f of failures) process.stdout.write(` ${f.n}. ${f.fix}\n`); +} + +process.exitCode = failures.length ? 1 : 0; diff --git a/test/plugin-shape.test.js b/test/plugin-shape.test.js index fc3dd16..24f890e 100644 --- a/test/plugin-shape.test.js +++ b/test/plugin-shape.test.js @@ -80,6 +80,30 @@ ok('hooks/hooks.json exists and parses', () => { assert.ok(hooks.hooks, 'hooks.json has a hooks map'); }); +ok('every hooks.json command resolves to a real CLI hook subcommand', () => { + // cmdHook's default: returns silently (hooks must never break a session), so a + // renamed or misspelled subcommand in hooks.json would no-op forever in every + // installed copy. This is the only tripwire for that drift. + const hooks = readJson('hooks/hooks.json'); + const wired = []; + for (const entries of Object.values(hooks.hooks)) { + for (const entry of entries) { + for (const h of entry.hooks || []) { + const m = /bin\/ratchet"?\s+hook\s+([a-z][a-z-]*)/.exec(h.command || ''); + assert.ok(m, `hook command is a ratchet hook invocation: ${h.command}`); + wired.push(m[1]); + } + } + } + assert.ok(wired.length >= 3, 'hooks.json wires at least session-start, post-edit, stop-check'); + const body = /function cmdHook[\s\S]*?\r?\n\}/.exec(read('src/cli.js')); + assert.ok(body, 'src/cli.js defines cmdHook'); + const handled = new Set(Array.from(body[0].matchAll(/case '([^']+)':/g), (m) => m[1])); + for (const sub of wired) { + assert.ok(handled.has(sub), `hooks.json wires "hook ${sub}" but cmdHook does not handle it (silent no-op)`); + } +}); + ok('every bin target from package.json exists', () => { for (const [name, rel] of Object.entries(pkg.bin || {})) { assert.ok(exists(rel), `bin ${name} -> ${rel} exists`); @@ -132,11 +156,39 @@ ok('README command list matches the skill folders', () => { } }); +ok('README version examples match package.json (readouts must not drift)', () => { + // The project's whole claim is state/readout trust — a README that shows a + // stale `ratchet --version` output is the exact poison the receipt hunts. + // Any `-> ratchet ` example in the README is a version surface. + const readme = read('README.md'); + const hits = readme.match(/->\s*ratchet\s+\d+\.\d+\.\d+/g) || []; + assert.ok(hits.length >= 1, 'README shows a ratchet --version example'); + for (const hit of hits) { + const v = hit.match(/(\d+\.\d+\.\d+)/)[1]; + assert.strictEqual(v, pkg.version, `README version example "${hit.trim()}" matches package.json`); + } +}); + ok('README does not mention removed command names', () => { const readme = read('README.md'); assert.ok(!readme.includes('/ratchet:ratchet-evolve'), 'no stale /ratchet:ratchet-evolve in README'); }); +ok('README states the product thesis: verified guardrails lift load, unverified ones add it', () => { + // The product thesis is load-bearing, not decoration. Torque Loop's pitch is that + // externalized *verified* state lifts the agent's cognitive load — and that the lift is + // conditional: an unverified guardrail is a liability, not relief, because it hides load + // instead of removing it. If that precondition silently drops out of the README, the + // whole apparatus reads as ceremony and the proof/seam gates look like bureaucracy. So + // guard it like a stale version. Tolerant to wording; pins the two load-bearing halves. + const readme = read('README.md'); + assert.ok(/cognitive load/i.test(readme), 'README names the cognitive-load payoff'); + assert.ok( + /unverified guardrail/i.test(readme) && /liabilit/i.test(readme), + 'README states an unverified guardrail is a liability, not relief' + ); +}); + ok('the /ratchet:map fog gate is wired into the prompt catalog', () => { // The generic loops above already force skills/map to carry frontmatter and be // listed in the README. PROMPTS.md sync is otherwise untested, so guard it here: @@ -170,4 +222,51 @@ ok('the probe primitive threads map → build → handoff with a disposal rule', assert.ok(/probe/i.test(read('templates/unknowns-map.md')), 'the map template offers probe as a closure'); }); +ok('the skill graph is derived from source, not remembered (drift guard)', () => { + // reference/graph/torque-loop.cypher is a knowledge graph of the skills. The whole point + // of committing it is that it is DERIVED: scripts/graph-gen.js reads skills/*/SKILL.md + + // PROMPTS.md and emits it. A hand-edited or stale graph is the exact liability the README + // names — a guardrail you trust without re-checking. So byte-match the committed file + // against a fresh generation; drift fails CI like a stale version. Normalize CRLF only so a + // clone whose autocrlf touched the file still checks content, not line endings (convention 16). + const graphGen = require('../scripts/graph-gen'); + assert.ok(exists(graphGen.OUT_REL), 'reference/graph/torque-loop.cypher exists'); + const committed = read(graphGen.OUT_REL).replace(/\r\n/g, '\n'); + const generated = graphGen.buildCypher(); + assert.strictEqual( + committed, + generated, + 'reference/graph/torque-loop.cypher is stale — run: node scripts/graph-gen.js' + ); +}); + +ok('the skill graph covers every skill and rebuilds cleanly', () => { + // Two structural invariants a pure byte-match would let regress silently: + // (1) every skill folder appears as a node — a skill added without regenerating is caught; + // (2) the delete-and-rebuild preamble survives — without it the reload is only additive and + // a shrunk/reordered graph leaves stale STEP edges (the idempotency hole this closed). + const cypher = require('../scripts/graph-gen').buildCypher(); + for (const name of skillDirs) { + assert.ok( + cypher.includes(`RatchetSkill {ns: 'torque-loop', name: '${name}'}`), + `skill "${name}" is a node in the graph` + ); + } + assert.ok( + /MATCH \(n \{ns: 'torque-loop'\}\) DETACH DELETE n;/.test(cypher), + 'the graph load starts with a namespace-scoped delete-and-rebuild' + ); +}); + +ok('the graph README parks the aperture cross-links instead of smuggling them in', () => { + // The honest-scope boundary is load-bearing: the derived graph deliberately omits the + // aperture cross-links (a separate repo, never adversarially attacked) and documents that + // as a parked decision with an owner. If that note silently vanishes, the next author may + // re-add unverified edges as if they were canon. Guard the boundary like the README thesis. + assert.ok(exists('reference/graph/README.md'), 'reference/graph/README.md exists'); + const doc = read('reference/graph/README.md'); + assert.ok(/PARKED/.test(doc), 'README marks the aperture cross-links as PARKED'); + assert.ok(/aperture/i.test(doc), 'README names the aperture bridge as the parked scope'); +}); + process.stdout.write(`\n${passed} passed\n`);