Full-project review — July 2026
Scope: entire repository at commit a9ecfa1 — src/project_init/ (engine, wizard, upgrade),
the full templates/ tree, plugins/, tools/, tests/, .github/workflows/, this repo's
own .agents/ infrastructure, install.sh, and top-level docs. Every finding below was
verified against the actual code (several by executing the code path); speculative findings
were dropped. The full test suite was run: 1795 passed, 1 skipped, 2 failed (the failures
are themselves finding M5).
Verdict
This is an unusually well-engineered project for its size and niche. The engine is deterministic and stdlib-only as promised, nearly every non-obvious branch carries an issue/ADR provenance comment, the 1,798-test suite is genuinely isolated (no network, no global state, xdist-clean), and CI is layered sensibly (job dependencies, gitleaks full-history scan, shellcheck over rendered scaffolds, OIDC trusted publishing, SBOM + SLSA provenance on release). The weaknesses concentrate in three places:
- The re-scaffold path bypasses all of the data-loss protection the upgrade path has (C1) — the single worst defect found.
- The DAG command guard is presented as enforcement but is bypassable with ordinary command variants (M4) — fine as agent steering, misleading as a stated boundary.
- Template layers that were never exercised together break at the seams (M1, M2, M3) — the per-layer quality is high; the combination coverage is not.
Overall: top-decile hygiene, with a handful of high-impact integration bugs. Roughly an 8/10 — the fixes below are mostly cheap relative to the machinery already in place.
Critical
C1 — Re-running the scaffolder on a scaffolded project silently destroys user edits and the metadata that would recover them
scaffold.py:502 (_protected_as_sibling) engages overwrite protection only when
first_scaffold is true or a .new sibling is still pending. On a re-run over a recorded
project every rendered file is written directly — no clean-tree guard (that exists only for
upgrade --apply), no consultation of the manifest hashes that exist at that point. Then
write_scaffold_record (__main__.py:2135) rewrites the manifest and the
.upgrade-base.json merge-base sidecar from the freshly clobbered bytes, so even the
upgrade engine's 3-way merge can no longer recover the user's edits.
Scenario: scaffold → hand-edit .agents/settings.json and justfile → re-run
project-init . --non-interactive … to add an MCP → both edited files are replaced and the
drift record is reset. Unrecoverable without git.
Fix direction: make a re-run over a recorded project either (a) delegate to the upgrade
engine, or (b) refuse without --force unless the tree is clean and hashes show no user
edits. The machinery to detect edits already exists — it just isn't consulted on this path.
Related protection gaps on the same path:
- read_preserve_globs (scaffold.py:390): the greedy regex breaks on a trailing comment
ending in ] (preserve: ["a"] # keep [ci]) — json.loads fails and all user
preserve globs are silently dropped.
- _ALWAYS_OVERWRITE (README.md) takes precedence over user preserve globs
(scaffold.py:428), and diverges from upgrade._is_preserved (upgrade.py:323) which
has no such exception — two engines give two answers for the same file.
Major
M1 — --language rust --delivery service produces a broken justfile → broken CI
templates/base/justfile.tmpl defines build: in both the {{#if rust}} block (line 216,
cargo build) and the {{#if delivery_service}} block (line 284, docker compose build).
just hard-errors on duplicate recipes at parse time, so every recipe fails —
and the scaffolded ci.yml runs just lint / just test-cov, so CI is red out of the box
for that combination. (Verified: both lines present; just rejects duplicates.)
M2 — the scaffolded .gitignore ignores the scaffolded Codex wiring
templates/base/dot_gitignore.tmpl:2 contains .codex, while the codex overlay ships
.codex/hooks.json (guard wiring) and the surface emitter writes .codex/config.toml
(MCP). Teammates cloning a codex-enabled repo silently get no Codex guards or MCP config.
The antigravity equivalent (.agents/) is correctly not ignored.
M3 — monitor_pr.sh --merge admin-merges through its own branch protection
templates/lifecycle/dot_agents/scripts/monitor_pr.sh:313-315: after the review gate
passes, a BLOCKED merge state triggers an immediate --admin merge on cycle 0. But
setup_github.sh --protect provisions required_conversation_resolution: true — so one
unresolved review thread puts the PR in BLOCKED, and the monitor force-merges past the
protection its sibling script created. Only the org profile refuses admin merges.
M4 — the DAG command guard is trivially bypassed by ordinary git variants
.agents/hooks/dag_workflow.py (and its template copy) anchors rules on
\bgit\s+push\b etc. Verified live against guard:
- git -C /tmp/x push origin main → allowed (should block)
- git -c core.pager=cat push origin main → allowed
- gh api graphql with a mergePullRequest mutation → allowed (merge gate missed)
- bash <<'EOF' … git push origin main … EOF → allowed (_strip_heredocs removes
bodies that actually execute)
CLAUDE.md/README present this as enforced lifecycle automation. Either parse the command
with shlex and match on argv words (skipping -C/-c/--git-dir style options), or
document the guard explicitly as advisory steering with git hooks + CI as the real
boundary (which ADR-007 already says — the guard's marketing overshoots it).
M5 — two tests fail on any machine without gh installed
tests/integration/test_issue_metadata_workflow.py:148,202 invoke the scaffolded
create_issue.sh, which hard-requires gh (templates/lifecycle/dot_agents/scripts/create_issue.sh:17)
before parsing --help or validating flags. No skip guard. Secondary defect: --help
should not require the tool to be installed — move arg parsing ahead of the gh check.
M6 — wizard robustness: late validation, ignored flags, no EOF/interrupt handling
- The wizard's own Description default (
"") is rejected only after all ~15 prompts, viaparser.error("--description must not be empty…")— an argparse usage block referencing a flag the interactive user never typed, discarding every answer (__main__.py:1177vs1961). - In interactive mode,
--name,--description,--language,--owner,--license,--mcps,--browser,--agents,--mise,--vscode,--devcontainerare silently ignored (only some flags pre-seed the wizard; the rest are read only on the non-interactive path). No warning, no help-text note. - Zero
EOFError/KeyboardInterrupthandling anywhere in the package:project-initin a non-TTY context (or Ctrl-C mid-wizard) exits with a raw traceback. - Typos in language/license prompts silently coerce to
"none"instead of re-prompting (__main__.py:1178-1202); the MCP chooser silently drops non-numeric tokens (:432), including the literal MCP ids the--mcpshelp teaches.
Minor
Upgrade engine (the strongest module — these are edges):
- --decline-new on a dry run prints "declined" but persists nothing; the next --apply
blocks on the same group (upgrade.py:1501 vs help text at __main__.py:1567).
- -i/--interactive without --apply is silently ignored (upgrade.py:1499).
- 3-way merge rewrites a CRLF user file to LF wholesale (subprocess.run(text=True) +
newline="\n", upgrade.py:237-254, 889-900) — full-file diff for Windows users.
- User-deleted managed files are resurrected on every --apply with no per-file opt-out
besides preserve: globs (upgrade.py:1478).
- A rendered path colliding with an existing directory raises an uncaught
IsADirectoryError (upgrade.py:737; same class of gap at scaffold.py:502,617 and
surfaces.py:311).
- Non-UTF-8 config.yaml crashes upgrade with a raw UnicodeDecodeError
(upgrade.py:654 — only UpgradeError is caught).
Engine consistency:
- Strict and non-strict modes resolve multi-layer collisions on preserved paths in opposite
orders (first-layer-wins vs last-layer-wins) — latent today, a trap for the next overlay
(scaffold.py:704 vs :611).
- _emit_generated_files (surfaces/capabilities/governance) runs outside the strict-mode
stage/validate/commit envelope (scaffold.py:751) — an exception there leaves a
half-finished scaffold after "validation passed". It also ignores user preserve globs.
- created/conflicts accumulate duplicates when layers share output paths (codex +
antigravity + amp ship 14 byte-identical .agents/skills/* files) (scaffold.py:724).
- Same missing memory_stack key defaults to "obsidian-only" in one function and
"none" in another within capabilities.py (:160 vs :174).
- mcps.servers_for_ids silently drops unknown ids (mcps.py:63) — a renamed catalog
entry vanishes from every surface config with no diagnostic.
- _version_tuple maps any non-X.Y.Z string to (0,0,0), silently disabling preset
compat checks (scaffold.py:78); version parsing is also triplicated across
scaffold.py, upgrade.py, migration_notes.py.
- generate_preset writes into the installed package's templates/presets/ — read-only
and wiped on upgrade under a uv/pipx tool install (scaffold.py:181).
- marketplace_source_vars builds …/repo/.git from a trailing-slash repo URL
(scaffold.py:334).
Templates:
- lint_memory.sh:84-88 hard-errors on any external URL link in MEMORY.md (no scheme
filter) and greps filenames as regex.
- pre_commit_gate.sh / post_edit_lint.sh never cd "$ROOT", so a session launched from
a subdirectory lints root-relative paths that don't resolve → false deny on commit.
- workflow_state_reminder.sh:70-73 (lifecycle_fallback) hardcodes PI-<n> as the naming
rule in every scaffolded project (file is not a .tmpl), contradicting the repo-specific
project_key that start_issue.sh derives.
- copilot-instructions.md.tmpl:26 gates vault guidance on {{#if memory}}, which is true
for memory_stack=auto — a stack that has no vault; should gate on obsidian.
- commit-msg:3 references nonexistent install-hooks.sh (real: install_hooks.sh) and
claims it "runs automatically" (it's manual).
- observability.sh --open without a subcommand dies on a usage error instead of
defaulting to report.
- prod_guard.py rm rule misses split flags (rm -r -f /).
CI / supply chain:
- All GitHub Actions are tag-pinned, not SHA-pinned (checkout@v6, gitleaks-action@v3,
gh-action-pypi-publish@release/v1, …). Renovate's helpers:pinGitHubActionDigests is
enabled but hasn't landed pins. release.yml (contents: write) is the highest-value
target. Applies to both this repo's workflows and the scaffolded ci.yml.tmpl.
- ci.yml:35 — pytest -n auto … 2>/dev/null || pytest … reruns the whole suite on any
failure (not just missing xdist) and hides first-pass stderr.
- Unverified curl|bash and binary downloads in scaffolded artifacts:
post-create.sh.tmpl (just/uv/bun installers, no checksum), ci.yml.tmpl:127 (shfmt
binary, version-pinned but unchecksummed).
- review-status.yml:48 interpolates ${{ github.event.review.html_url }} inline in
run: — the one departure from the otherwise consistent env: discipline (low risk,
GitHub-generated value).
- board-automation.yml:55 uses GNU-only grep -oP.
Repo hygiene:
- CLAUDE.md's layout table lists 4 template overlays; the tree has 16 (lifecycle/,
multi_model/, governance/, observability/, rag/, amp/, antigravity/, codex/,
junie/, lifecycle_fallback/, auto/ are missing). README's copy is current —
CLAUDE.md drifted.
- tools/sync_plugin.py has no argument parsing — sync_plugin.py --help silently runs
the sync.
- tools/check_third_party_updates.py apply() writes the manifest before the used_in
files — a mid-way failure leaves a partial lockstep bump.
- Dead code: CACHE_PATH in both copies of dag_workflow.py; MCP_ONLY_TARGETS in
surfaces.py:225 duplicates planned_files and is referenced nowhere.
- __main__.py (2,153 lines) should be split: parser construction, the ~15 _choose_*
wizard prompts, _build_variables, and subcommand mains are four separable modules. The
flag→prompt→preset precedence being re-implemented per concern is the direct cause of M6's
ignored-flags bug.
- Bare-word subcommand dispatch reserves upgrade/add/remove/preset as target
directory names (project-init add scaffolds nothing; workaround ./add undocumented).
Recommendations (highest leverage first)
Because most of these land in templates/, each fix propagates to every future
scaffolded project — the user-facing multiplier is large.
- Unify the write path (fixes C1 and half the engine minors): one protection engine
consulted by scaffold, re-scaffold, upgrade, add/remove — manifest-hash check + preserve
globs +
.newsiblings in a single module. A re-run over a recorded project should refuse or delegate to upgrade. - Combination contract tests: scaffold the full
language × deliverymatrix and (a) parse the justfile withjust --list --justfile, (b) rungit check-ignoreover every emitted file. Those two cheap oracles would have caught M1 and M2 mechanically — the suite's per-layer coverage is excellent, its cross-layer coverage is the gap. - Reframe or harden the DAG guard: parse commands with
shlexand match argv words (skipping-C/-c/--git-diroptions); add the GraphQL merge mutation; treat heredoc-fed interpreters as opaque (block, don't strip). Or say "advisory" everywhere the docs currently say "enforced". Ship the same fix in the template copy. - Remove
--adminfrom theBLOCKEDfast path inmonitor_pr.sh— require an explicit--force-blocked-style flag; never silently defeat conversation-resolution protection the tool itself provisioned. - Supply-chain pinning as a shipped quality gate: SHA-pin actions in this repo's
workflows and
ci.yml.tmpl; checksumshfmtand the devcontainer installers; consider addingzizmor(workflow static analysis) and anosv-scanner/pip-auditjob to the scaffolded CI — it already ships gitleaks, license compliance, SBOM, SLSA, so vuln scanning is the one conspicuous absence. - Wizard hardening: one top-level
except (EOFError, KeyboardInterrupt)with a clean exit; validate at prompt time (re-prompt on invalid enum instead of coercing to"none"); make interactive mode honor — or loudly reject — identity flags; accept MCP ids as well as numbers. - Gate
--helpbefore tool checks in lifecycle scripts and addgh-present skip guards to the two failing integration tests. - Mechanical cleanups: split
__main__.py; dedupe the three version parsers; deleteCACHE_PATH/MCP_ONLY_TARGETS;cd "$ROOT"in the git-facing hooks; fix the.codexgitignore line and the rust+servicebuildrecipe collision; refresh CLAUDE.md's layout table.
What is genuinely good (keep doing this)
- Test isolation quality is rare: loopback-only network, tmp-confined writes, repo-local git identities, function-scoped fixtures — 1,798 tests that actually parallelize.
- The upgrade engine's drift model (hash manifest + merge-base sidecar + 3-way merge +
.newsiblings + addition-consent) is coherent and correctly never overwrites edited files on its own path. - Provenance discipline — issue/ADR references on nearly every non-obvious branch — makes the codebase reviewable in a way few solo-maintained projects are.
- CI's rendered-scaffold shellcheck and byte-identity plugin-sync contract tests are exactly the right kind of gate for a scaffolder.
- Untrusted PR input consistently routed through
env:in workflows.