Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c568480
refactor(phase-1): decouple routing keys into canonical smell keys
claude Jun 15, 2026
a90b2dd
feat(phase-2a): add Sensor contract and SensorRunner (leaf-only)
claude Jun 15, 2026
24ce972
feat(phase-2b): route detection through the SensorRunner
claude Jun 15, 2026
e8da01a
fix(phase-2b): gate sensors on active rules to preserve suppression p…
claude Jun 15, 2026
40d67e4
feat(phase-3a): add smell-keyed `smells` config field + `fix`
claude Jun 15, 2026
156d5f5
feat(phase-3b): add the mapper with the fix resolution chain
claude Jun 15, 2026
4111044
feat(phase-4a): Nunjucks render + guide module
claude Jun 15, 2026
060e628
feat(phase-4b): wire sensor -> mapper -> guide; retire the reporter
claude Jun 15, 2026
39494e5
fix(phase-4b): enforced smells without a tuned prompt still exit 1
claude Jun 15, 2026
635f879
feat(phase-5): declarative adapter + Python preset (ruff/jscpd/deptry)
claude Jun 15, 2026
d44d30c
feat(phase-6): language selection + Python e2e fixture
claude Jun 15, 2026
f13ad30
test(phase-6): CLI clean-run banner + violation output, both languages
claude Jun 15, 2026
a6876be
docs+chore: document unused-import + Python mappings; allow knip source
claude Jun 15, 2026
44d1054
fix(jscpd): make report tmp-root injectable so cleanup tests don't race
devill Jun 16, 2026
9b07177
feat(init): accept `init [language]`; report-only when language omitted
devill Jun 16, 2026
09c29be
feat(init): model Python tools (ruff/deptry) — detection + install co…
devill Jun 16, 2026
baacd30
feat(init): onboard Python projects — ruff/jscpd scaffolders, pip+nod…
devill Jun 16, 2026
2353144
feat(init): drift detection + "Setup incomplete — to finish:" report
devill Jun 16, 2026
5f66153
feat(init): --accept-recommendations to install tools + apply settings
devill Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# DECISIONS

Reversible design calls made by the autonomous loop while driving GOAL.md.
Each is labelled _agent decision_ per the working agreement.

## Phase 1 — Smell-key decoupling (TS, internal)

- **Routing keys become canonical smell keys.** _(agent decision)_ Each `Rule.id`
and each `Violation.ruleId` is now a kebab-case smell key (e.g.
`too-many-parameters`) instead of a tool-prefixed key (`eslint:max-params`).
Tool-prefixed strings survive **only** as (a) the raw→smell translation tables
owned by each wrap and (b) provenance written to `Violation.source`.

- **`Violation.source` added for provenance.** _(agent decision)_ Carries the raw
tool key (`eslint:max-params`, `knip:classMembers`, `jscpd:duplication`,
`comment:non-essential`). It is shown in the reporter's _Uncoached_ section so
today's behaviour — surfacing the originating tool/rule for unknown findings —
is preserved. Maps cleanly onto the future `Issue.details.source` (docs).

- **Unmapped raw keys pass through as their bare key.** _(agent decision)_ An
eslint rule with no smell mapping (e.g. `no-console`) routes as `no-console`;
an unknown knip issue type (e.g. `unlistedPeerDependencies`) routes under its
bare key. No tool prefix leaks into the routing key; the finding still surfaces
as uncoached with full provenance in `source`.

- **Catalogue content preserved verbatim.** _(agent decision)_ Titles,
descriptions, and severities are copied unchanged from the old tool-keyed rules
to guarantee output parity. Aligning titles with `docs/smell-vocabulary.md`
(e.g. "Non-essential comment") is deferred as a separate content change.

- **`parse-error` stays a supplemental prompt** (no default `Rule`), exactly as
the old `eslint:fatal` did — it has a tuned template but no catalogue entry.

## Phase 2 — Sensor contract + runner (leaf-only)

- **Phase 2 split into 2a + 2b.** *(agent decision)* 2a introduces the `Sensor` /
`Issue` / `SensorContext` contract (`src/sensors/types.ts`) and a `SensorRunner`
(`src/sensors/runner.ts`) that registers sensors, orders them by dependency, and
merges issues — fully unit-tested in isolation, no pipeline integration yet. 2b
makes the four wraps registered sensor plugins and wires them into `run()` with
`Issue` <-> `Violation` conversion, preserving golden parity. The split keeps
each commit small and reviewable, and de-risks the parity-sensitive integration.

- **`SensorRunner.run` returns `Issue[]`** per docs. *(agent decision)* Dependency
ordering uses a stable topological sort (registration order preserved among
ready sensors); unsatisfiable `dependsOn` smells and cycles throw at
construction (startup error), per docs/sensors.md. Leaf-only is exercised by the
preset; multi-sensor ordering/`ctx.deps` is implemented and tested with fakes but
no multi sensor ships (out of scope).

- **2b integration: gated detect over all files, filter per smell afterwards.**
*(agent decision)* `run()` runs each *active* preset sensor over the full
discovered file set via `SensorRunner`, then `filterViolations` keeps a
violation only if its smell's rule allows the file (uncoached smells with no
rule are never file-filtered). A sensor is **active** iff at least one smell it
`produces` has a rule resolving to a non-empty file set — reproducing the old
"a tool runs iff its source has an active in-scope rule" gate, so disabling or
empty-scoping a sensor's smells suppresses the whole tool (and its uncoached
sibling smells), not just its coached output. This replaces the old per-source
dispatch (eslint union + `filterEslintViolations`, group-by-file-set for the
rest) and lets the sensor stage stay a pure detector — rule-scoped filtering is
the seam the Phase 3 mapper will own. Verified parity: CLI golden byte-identical,
full suite green, plus new gating tests. `src/eslint-runner.ts` deleted.

- **Known, accepted divergence: knip's coached findings now respect the baseline.**
*(agent decision)* The old code never file-filtered knip output (knip runs
whole-project and its violations bypassed filtering); the new uniform filter
drops an `unused-class-member` finding for a **baseline-snoozed** file (that
rule has `changedFilesOnly: false`, so scope can't drop it — only the baseline
can). Unreachable by any existing test and not promised by the docs; treating a
snoozed file as snoozed for every sensor is the more consistent behaviour
(arguably a latent bugfix), so it is accepted.

## Phase 3 — Mapper smells config + fix resolution

- **3a: `smells` is the canonical config field; `rules` kept as a transitional
alias.** *(agent decision)* The config now reads `smells` (smell-keyed, per
docs/mapper.md) and still accepts `rules`; both merge with `smells` last so it
wins on conflict. Default and canonical configs use `smells`. This introduces
the canonical field without a sweeping rename of every test fixture; removing
the `rules` alias is a later cleanup. Added the `fix` field to the schema/`Rule`
and threaded it through merge so the Phase 3b mapper can resolve it.

- **Fixed a latent Phase-1 miss: the repo's own `habit-hooks.config.js`** still
keyed an override under the old `comment:non-essential`, which now matches no
rule and would throw (`missing 'source'`) if habit-hooks ran on itself. Migrated
it to `smells: { 'non-essential-comment': ... }`.

- **3b: the mapper is a standalone, tested module (`src/mapper/mapper.ts`),
integrated in Phase 4.** *(agent decision)* `mapIssues` groups the bag by smell
and resolves each group to one `GuideAction` (severity + a `Fix`), with leftover
smells in an uncoached bucket. `resolveFix` implements the chain — explicit
`fix` setting, then `<smell>.md` (prompt), then a `<smell>` script (command),
else uncoached — looking up override dir before packaged, and throwing a config
error when an explicit `fix` names a missing file. Like the Phase 2a runner, it
ships tested-but-unwired; Phase 4 builds the Nunjucks guide that consumes
`GuideAction[]` and retires the reporter. (knip flags the output types as unused
until then — expected.)

## Phase 4 — Nunjucks guide

- **4a: Nunjucks render + guide module (`src/guide/`), tested then integrated in
4b.** *(agent decision)* `render.ts` builds a Nunjucks `Environment`
(autoescape off — output is agent-facing markdown, not HTML; a `FileSystemLoader`
over the override+packaged dirs lets templates `{% include %}` partials).
`guide.ts` renders each `GuideAction`'s template against `{ smell, issues }`,
lists the uncoached bucket, and computes the exit code (an `enforced` smell with
any issue -> exit 1; uncoached never escalates). Per-smell grouping over
multiple issues is proven with the `groupby("details.file")` filter (dot-paths
work). Command fixes render nothing (out of scope). Reviewer's Phase-4 seam — a
`routingFor` that folds in the supplemental seeds (e.g. `parse-error` at
`enforced`) — is handled in 4b's runner wiring as `rule ?? lookupPrompt(smell)`.

- **4b: `run()` is now sensor -> mapper -> guide; the reporter is retired.**
*(agent decision)* `run()` detects via sensors, filters per smell, converts to
the `Issue` bag, maps to `GuideAction[]` (routing = merged rule ?? `lookupPrompt`
so `parse-error` keeps `enforced`), and renders with the guide. `src/reporter.ts`
and its test are deleted. The guide composes each section — `❌ {title}` /
description / the prompt template / an issue list — so the output stays close to
the old reporter format (titles, `file:line - message`, banner all preserved),
keeping the substring-based integration tests green. Two changes are intentional
new snapshots: section **order** now follows issue arrival (sensor order) rather
than catalogue order, and the per-rule "(N more …)" cap is gone (templates list
all issues). `oversized-function` ships a `.issues.njk` that groups by file —
the real per-smell grouping the DoD asks for. Also fixed a second latent Phase-1
miss: `packaged-dir.ts` probed the renamed `eslint-max-params.md` (worked only
via the src fallback); now probes `too-many-parameters.md`. `.njk` partials are
added to the published `files`.

- **4b fix: a routed smell with no tuned template falls back to `uncoached.md`,
not the uncoached bucket.** *(agent decision)* Reviewer found an exit-code
regression: seven enabled `enforced` smells ship no `<smell>.md`, so they were
dropping to the uncoached bucket (exit 0), whereas the old reporter exited 1 for
any enforced smell with a violation (docs/guide.md: "enforced smell with an
unresolved issue -> 1"). Fix: a smell that **has routing** (known severity from
config or catalogue) always becomes an action — its `<smell>.md` if present,
else the generic `uncoached.md` body — keeping its severity so it renders as a
section and escalates the exit. Only a smell with **no routing at all** (truly
unknown, e.g. eslint `no-console`) goes to the uncoached bucket (never
escalates). Matches the old reporter exactly. Guarded by a runner test isolating
an enforced-but-untemplated smell (`loose-equality` -> exit 1).

## Phase 5 — Declarative adapter + Python preset

- **De-risk passed (validated in code).** *(agent decision)* Captured live
`ruff --output-format=json` (flat array) and live `eslint -f json` (nested
`messages[]` per file); `src/sensors/adapter.ts extractIssues` handles both via
the two-level `group`/`items`/`fields`/`map` model — `adapter.test.ts` pins both
shapes. The model expresses the two toolchains, so the preset is built on it.

- **`declarativeSensor` runs a JSON-emitting tool as a leaf sensor.** Splits the
`command` on whitespace, expands `${files}`, runs it, parses stdout JSON, and
extracts. Spawn failure -> stderr notice + zero issues (sensor contract).

- **Python preset (`src/sensors/python-preset.ts`): ruff + jscpd + deptry.**
ruff `C901/PLR0913/PLR0915/F841` -> `high-complexity/too-many-parameters/
oversized-function/unused-variable` per the GOAL table; jscpd reuses the shared
`checkLeafSensor` for `duplicated-code` on `.py`; deptry runs
`deptry . --json-output /dev/stdout` (flat array) and maps `DEP002` ->
`unused-dependency`.

- **Agent decisions on the mapping:** added ruff `F401` -> **`unused-import`** (a
new general smell — cheap and useful, per the GOAL's allowance). **Deferred
`oversized-file` for Python** — no clean ruff rule (GOAL says best-effort or
defer). deptry's `/dev/stdout` JSON is POSIX-only, fine for the Linux e2e.

- The live-ruff preset test skips when ruff is not on PATH (CI without the Python
toolchain) so the suite stays green everywhere; it runs in the provisioned env.

## Phase 6 — init language selection + e2e

- **Language selection wired into `run()`.** *(agent decision)* `config.language`
(`typescript` | `python`, default typescript) drives both file discovery
(`**/*.{ts,tsx,js,mjs,cjs}` vs `**/*.py`) and the active preset
(`buildPresetSensors` vs `buildPythonPresetSensors`). The activeness gate keeps
only that language's sensors running. `init` detects the language from a Python
manifest (`pyproject.toml`/`setup.py`, else typescript) and writes it to the
scaffolded config.

- **Catalogue gap fixed: added `unused-file`/`unused-export`/`unused-dependency`
(per docs/smell-vocabulary.md) and the new `unused-import`.** *(agent decision)*
These were vocabulary smells with no `defaultRules` entry, so the activeness
gate marked deptry (whose only smell is `unused-dependency`) inactive and it
never ran. Cataloguing them coaches the smells and lets their sensors activate.
The catalogue moved to `src/config/catalogue.ts` (line budget); `changedFilesOnly`
is now optional on `Rule` (defaults false) to keep the catalogue compact.

- **Whole-project artifacts bypass source-file filtering.** *(agent decision)*
`filterViolations` now keeps a violation whose file is not a discovered source
file — a project-level artifact like `pyproject.toml`/`package.json` reported by
deptry/knip can't be source-file-scoped (it was being dropped, hiding
unused-dependency).

- **deptry uses the temp-report pattern, not the stdout adapter.** *(agent
decision)* `deptry --json-output /dev/stdout` writes nothing when stdout is a
pipe (only the human report reaches stderr), so the deptry sensor writes JSON to
a temp file and feeds it through the adapter's `extractIssues`.

- **Python e2e fixture** (`tests/fixtures/python-project`) exercises all six
Python smells; `src/python-acceptance.test.ts` asserts the expected counts and
exit 1 (skips without ruff+deptry). The fixture's `pyproject.toml` configures
ruff `mccabe.max-complexity` (like the TS fixture configures ESLint) so `C901`
fires — preset thresholds come from the consumer's tool config.
23 changes: 23 additions & 0 deletions docs/smell-vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@ exits 0. The mapper config can override it per project.
| `unused-file` | Unused file | enforced |
| `unused-export` | Unused export | enforced |
| `unused-dependency` | Unused dependency | enforced |
| `unused-import` | Unused import | enforced |
| `parse-error` | Parse / config error | enforced |

`unused-import` was added as a general smell (agent decision) so ruff `F401`
has a canonical home; see `DECISIONS.md`.

## TypeScript/JavaScript preset translation

The raw rule IDs the TS/JS preset sensors emit, and the smell key each maps
Expand Down Expand Up @@ -72,6 +76,25 @@ to.
| `knip:dependencies` | `unused-dependency` |
| `eslint:fatal` | `parse-error` |

## Python preset translation

The raw rule IDs the Python preset sensors emit, and the smell key each maps
to (the rest of the catalogue is shared — only the sensor layer differs).

| Raw key (tool:rule) | Smell key |
|---------------------|-----------------------|
| `ruff:C901` | `high-complexity` |
| `ruff:PLR0913` | `too-many-parameters` |
| `ruff:PLR0915` | `oversized-function` |
| `ruff:F841` | `unused-variable` |
| `ruff:F401` | `unused-import` |
| `jscpd:duplication` | `duplicated-code` |
| `deptry:DEP002` | `unused-dependency` |

TS-only smells (`explicit-any`, `var-declaration`, …) simply do not appear in
the Python preset. `oversized-file` has no clean ruff rule and is deferred (see
`DECISIONS.md`).

## Uncoached smells

A smell with no configured guidance falls through to an **uncoached** bucket
Expand Down
4 changes: 2 additions & 2 deletions habit-hooks.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export default {
rules: {
'comment:non-essential': {
smells: {
'non-essential-comment': {
exclude: ['tests/fixtures/**', 'habit-hooks.config.*'],
},
},
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"files": [
"dist",
"src/prompts/*.md",
"src/prompts/*.njk",
"!src/prompts/REVIEW.md",
"src/skills"
],
Expand All @@ -24,11 +25,14 @@
"lint": "eslint .",
"format": "prettier -w .",
"typecheck": "tsc --noEmit",
"prepublishOnly": "pnpm typecheck && pnpm lint && pnpm test && pnpm build"
"prepublishOnly": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
"habit-hooks": "habit-hooks",
"ci": "npm run lint && npm run test && npm run build && npm run habit-hooks"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^25.9.1",
"@types/nunjucks": "^3.2.6",
"@types/picomatch": "^4.0.3",
"prettier": "^3.8.3",
"typescript": "^6.0.3",
Expand All @@ -41,6 +45,7 @@
"jiti": "^2.7.0",
"jscpd": "4.2.4",
"knip": "5.88.1",
"nunjucks": "^3.2.4",
"picomatch": "^4.0.4",
"ts-morph": "^28.0.0",
"typescript-eslint": "^8.60.0"
Expand Down
32 changes: 32 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading