fix: pre-3.0.0 hardening of the single-scope install and per-repo config layer - #407
Merged
Merged
Conversation
When gh and ls-remote both fail to name the default branch, the resolver now reads `git symbolic-ref --quiet refs/remotes/origin/HEAD` (local, no network) and folds that branch's tracking copy as the existing offline path does, so a branch that sets evidence standard can no longer resolve below main's required just because origin is unreachable (D-OFFLINE-ORIGIN-HEAD). With no origin/HEAD recorded the worktree still governs with WARN=remote-unavailable; an unanswered read raises. SAFE_REF_RE and the origin/HEAD parser move to the shared lib so the settings resolver reads branch names the same way. Replaces the openSync/readSync spy test of the retired policy.json with the behavioural FIFO test that already proves it is never opened. Refs #406
D-PERSONAL-UNTRACKED: a .devflow/config.json that git tracks (checked locally with `git ls-files --error-unmatch`, only when the file exists) is not personal, so it is ignored entirely and the resolver warns on stderr to untrack it. A branch that commits `reviewPublication: "full"` with `git add -f` now resolves the ceiling default, auto. The hooks' readRepoLayers fold ignores it the same way. D-LENS-UNION: the compliance lens is machine ∪ default branch ∪ worktree. The default branch's project.json is read from the local tracking copy (origin/HEAD, then cat-file of refs/remotes/origin/<D>, bounded, strict parser), so a PR that deletes compliance:["hipaa"] is still reviewed under hipaa; a branch can only add frameworks. Any unreadable default-branch state is a malformed declaration (generic), never a lower lens. An unreadable config.json now fails only the keys it owns: the readable project.json's (and default branch's) frameworks stay in COMPLIANCE. The settings tests prove the git-only, never-gh property from the scripted shim's call log instead of counting spawnSync/stdout/require sites in the source, and add a behavioural writes-nothing check. Refs #406
The v6 upgrade appended `!.devflow/project.json` (and any other missing completion line) at the END of .gitignore. gitignore is last-match-wins, so that line overrode a user's later `.devflow/project.json` re-ignore, and it contradicted the documented placement just before the block's .claudeignore line. D-GITIGNORE-IN-BLOCK: both twins (ensure-root-gitignore and computeDevflowGitignore) now insert the missing lines as one run, in block order, right after the block sentinel and the block lines that precede the first missing one in a fresh block (bounded at three). Every other byte is kept; a v5 block upgrades to the current block byte for byte. v6 is unreleased, so the marker stays v6. The block comment no longer calls policy.json the evidence policy: it is retired and only its presence matters. The init log line says so too. Refs #406 (items 10, 11)
When `git rev-parse` failed from a subdirectory of a checkout (dubious ownership, GIT_CEILING_DIRECTORIES), df_resolve_roots fell back to the raw cwd while df_is_project_root's ancestor marker walk still found the checkout's .git. ensure-devflow-init and session-start-context then wrote .devflow/ and a .gitignore block into the subdirectory. D-HOOKS-TOPLEVEL-ONLY: df_is_project_root now requires the .git entry AT the root (a directory, or a linked worktree's/submodule's file), so the gate and the resolver agree. Still zero forks; the HOME refusal and the non-git refusal are unchanged, and a directory holding its own .git marker still counts when git cannot read it. tests/shell-hooks-tracker.test.ts seeds project roots as subdirectories of a marker-only tmpDir; those fixtures need a .git of their own (patch handed to the orchestrator, file outside this package). Refs #406 (item 9)
/code-review's language-focus presence gate probed
~/.claude/skills/devflow:{focus}/SKILL.md and /dynamic-profile mined
~/.claude/projects, history.jsonl and rules directly, so on a machine that
installs into CLAUDE_CONFIG_DIR every language focus was silently dropped
and the profile read the wrong tree. Both now resolve the directory once
with the installer's rule (CLAUDE_CONFIG_DIR when absolute, else
$HOME/.claude — D-CLAUDE-DIR-PROMPTS); the worktree-support pointers are
directory-neutral.
tests/guards/claude-dir.test.ts bans a path under the home Claude
directory, or the bare directory without the rule, across compiled
commands, agents, references, skills and rules, with red probes and a
behavioural run of the sanctioned line.
Task: feat/406-pre-3-0-0-hardening-local-scope-retireme-wp4
Refs #406
About thirty relative .devflow/docs/ read and write sites in plan,
research, bug-analysis, implement (handoff and evidence files) and the
dynamic plan/tickets/build commands resolved against the session's cwd,
so a run started in a subdirectory scattered a second .devflow/docs/ tree
there that no later run found.
A new one-define partial, _docs_root.mds (D-DOCS-ROOT, extending
D-PROMPT-ROOT), resolves {worktree} with git -C "{start}" rev-parse
--show-toplevel, falling back to the start directory outside git, and every
docs path is written {worktree}/.devflow/docs/... File names are unchanged.
Paths that reach a PR or issue comment (EVIDENCE_FILE, REVIEW_SUMMARY_PATH,
WAVE_REPORT_PATH, PLAN_ARTIFACT_PATH) stay repo-relative and now always
travel with WORKTREE_PATH; authored workflow scripts take the root as
args.root.
tests/guards/docs-root.test.ts holds every compiled command to rooted
paths, relative agent fields beside WORKTREE_PATH, or a named live
exemption; partials-root.test.ts runs the resolution command on real git.
Task: feat/406-pre-3-0-0-hardening-local-scope-retireme-wp4
Refs #406
… remedy
TRACKER_WARN=mismatch means a personal tracker override tried to widen the
resolved provider. The remedy said `devflow tracker --set {id}`, which only
moves the machine default and changes nothing when the repository's
project.json selects the provider. It now says to correct or drop the
personal config.json tracker key. The rewording is length-neutral, so the
compiled git.md stays at 43,832 chars against its 43,912 ceiling.
The seam test pins the new remedy and reports the machine-default command
as a missing remedy.
Task: feat/406-pre-3-0-0-hardening-local-scope-retireme-wp4
Refs #406
Fixture-only: npm run test:golden:update -- git-agent after the remedy rewording in the previous commit. One line changes; the fixture stays 44,149 bytes. Task: feat/406-pre-3-0-0-hardening-local-scope-retireme-wp4 Refs #406
no-config-read now reads the orchestrator charter and the text hooks hand a model — *_SECTION/*_NOTE/*_HEADER/CONTEXT literals, json_prompt_output arguments and $(cat <<EOF) prompt bodies — extracted from the hook source, whose own shell stays out of scope, with a marker per emitter and a red probe. It first fails by name on any compiled command, agent or reference older than a source it is compiled from, so a clean scan can no longer be a scan of stale dist/ text. The staleness collector has a hermetic mtime probe. Task: feat/406-pre-3-0-0-hardening-local-scope-retireme-wp4 Refs #406
The skill told agents to source .devflow/scripts/docs-helpers.sh, a repo-local install path that no longer exists. The helpers are now defined inline, and ensure_docs_dir roots .devflow/docs at the checkout toplevel (D-DOCS-ROOT). Task: feat/406-pre-3-0-0-hardening-local-scope-retireme-wp4 Refs #406
The forbidden-I/O negative control's harmless neighbour named the retired DEVFLOW_DIR. It now names DEVFLOW_BODY; the control's intent is unchanged. Task: feat/406-pre-3-0-0-hardening-local-scope-retireme-wp4 Refs #406
Section 3 of session-start-context now also forks the settings resolver
when a bounded builtin read of .devflow/config.json shows a "tracker"
key and the machine sentinel names a provider (a personal override can
only narrow, so a github machine never reads it). A jira machine whose
repository has config.json {"tracker":"github"} no longer gets a jira
Tracker directive there; a config.json git tracks stays ignored, as the
resolver ignores it.
New Section 4 (D-LEGACY-LOCAL-NOTICE): when <root>/.claude/settings.json
registers a devflow hook, matched by the exact ownership shape (a
command ending in /scripts/hooks/run-hook <marker> for a marker devflow
registered, or a v1 .sh hook), a fresh session gets one line pointing
at `devflow uninstall --scope local`. Project work only, never for the
machine-wide .claude, and a settings.json with no devflow hook costs a
builtin read and no subprocess.
Also gives the tracker suite's fixture roots their own .git, as the
toplevel-only project gate (5e8b656) requires.
Refs #406
The shim-recorded fork check runs three hook sessions through freshly written wrapper executables; like the tracker suite's fork tests it now allows NODE_EXEC_STALL_MS for the first exec, so it does not time out under a loaded run. Refs #406
init resolved its install paths four times, and guarded one of them with a 'Resolving paths' spinner and a "Path configuration error" catch for throws that could no longer happen. The paths now resolve once, at the top of the action, through resolveInstallationPaths(), whose only failure — a process with no home directory — is a Result the command reports and exits on. readHomeDirectory() never throws, even where os.homedir() does. The path and manifest comments describe the end state in the present tense, without the retired mechanisms they replaced. Refs #406 (items 24, 25)
In a dotfiles repository rooted at HOME, init treated HOME as a project: it wrote ~/.devflow/config.json into the machine root, a ~/.claudeignore, and devflow's block into ~/.gitignore. D-INIT-NOT-HOME extends the hooks' D-HOOKS-GIT-ONLY rule to the CLI: a git root whose realpath is HOME's is treated as no repository, so init writes no per-repository file, runs no per-project migration or queue drain there, drops HOME from the discovered .claudeignore targets, and prints a one-line notice. isSameLocation moves from uninstall.ts to src/core/same-location.ts, shared by both commands. Refs #406 (item 6)
init's managed write read .devflow/config.json with a bare JSON.parse: a syntax error read as an empty file, and the rewrite then deleted the user's hand-written keys (the tracker override among them); a duplicate key read as its last value, where the resolvers read the file as malformed. D-CONFIG-STRICT-PARSE: the file is now judged by the shared strict parser the resolvers use (lib/project-config.cjs, loaded through the CJS seam as loadProjectConfigLib). D-CONFIG-NO-REPAIR: a malformed or unreadable file is left byte-for-byte as it is; writeManagedConfig returns a Result naming why, and init prints it as a warning. Refs #406 (item 8)
- init --no-compliance said "artifacts removed"; it removes only the rule, and the skill and framework references stay installed. - memory/learning/knowledge --enable said "in every project"; a repository can narrow the machine switch, so the text says so. - compliance --enable told a team to "commit this as project.json", which overwrites a committed file; it now says to add the keys to it, never replacing it. - the compliance --status migration hint told a team to delete policy.json at once; it now says to keep it until every teammate runs devflow 3.0 or later. Refs #406 (items 13, 32)
`devflow debug --enable` with `"env": []` set the key on the array, which JSON.stringify drops, and reported success having written nothing. D-DEBUG-ENV-OBJECT: a present env that is not an object is rejected — exit 1, file untouched — and the pure edits return a Result instead of throwing on malformed JSON. Settings writes were a mix: init, debug and uninstall wrote in place with fs.writeFile, the rest renamed a temp file over the target, which also replaced a dotfiles-managed symlink with a plain file. D-SETTINGS-ATOMIC: every Claude settings.json write now goes through writeSettingsFileAtomic, which renames a sibling temp file into place and, for a symlinked settings.json, writes the file the link resolves to so the link survives. A guard holds src/ to the one helper. Refs #406 (item 15)
detectUpgrade computed isDowngrade and init ignored it, so an older `npx devflow-kit@x init` silently swept every newer skill, agent and command as an orphan. D-INIT-DOWNGRADE-WARN: when the manifest names a newer version than the running CLI, init warns before installing, naming both versions, what will be removed and how to keep it. It does not block. Refs #406 (item 16)
Hooks log under ~/.devflow/logs/<cwd-slug>/, one folder per working directory, and nothing removed any: one machine held 31k of them, most left by test runs in temp directories. D-LOG-DIR-CAP: init keeps the MAX_HOOK_LOG_DIRS (200) most recently written folders and removes the rest, oldest first. Recency is the newest mtime among a folder and its logs, since an appended log never moves the folder's own mtime. Each pass reads at most 100,000 folders and removes at most MAX_LOG_DIRS_PRUNED_PER_RUN (10,000), so a backlog of any size is worked off over successive inits at a bounded cost per run. Files at the logs root (proxy.log) and symlinks are never touched. Refs #406 (item 17)
`uninstall --scope local` refuses when there is no repo-local install to act on — outside a git repository as well as in one rooted at HOME (uninstall.ts). Only the HOME case was tested. The new arm runs the built CLI from a non-git temp directory holding .claude/.devflow decoys and proves exit 1, the refusal message, and both HOME and the cwd byte-identical. Refs #406 (item 20)
resolveDevflowDirCleanup took a `scope` and returned artifacts-only for anything but 'user', but its one caller is the user-scope branch and always passed 'user'. The parameter, the unreachable guard and the two tests pinning it are gone. Refs #406 (item 23)
compliance --status still said an unreadable personal config.json left the repository at "generic controls only". Since D-LENS-UNION a broken file affects only the keys it owns: config.json owns no compliance, so the lines are now those of a readable file, and an unreadable project.json reads as a malformed (generic) declaration. The status also shows the default branch's declared ids and the effective lens (machine + default branch + this checkout). The resolver ignores a git-tracked .devflow/config.json and says so on stderr (D-PERSONAL-UNTRACKED), but prompts discard its stderr. tracker, memory, learning and knowledge --status now print the warning with the `git rm --cached` fix. The switch-table fixture header describes the union lens. Refs #406 (resolver follow-ups)
Claude Code runs a Stop event's hooks in parallel, so the memory worker can read the queue after capture-prompt appended the user row and before capture-turn appends the assistant row. The orphan-only auto-clean then deleted the queue and lost that turn's prompt (audit REPORT §4 #13). The worker now logs and exits without the LLM run, leaving the queue in place (D-QUEUE-NO-ORPHAN-DELETE); the next run takes the whole turn, and queue-append already caps the file. capture.ts's comment claimed array order sequenced the Stop hooks; it now states the real contract. Refs #406
The init-only prune (D-LOG-DIR-CAP) never runs for a user who does not re-init, so ~/.devflow/logs kept growing one folder per working directory. devflow_log_dir now prunes whenever it creates a new folder: one `ls -t`, oldest first, at most 50 removed per call, never a root file or a symlink, and a folder whose log is newer than the oldest kept folder is spared. The common path, an existing folder, costs nothing. The shell cap is pinned equal to MAX_HOOK_LOG_DIRS by a test. Refs #406
_docs_root.mds brought ALL_MDS_PARTIALS to 16; the ratchet follows it up so the roster cannot shrink back to 15 unnoticed. Refs #406
The regression net ADR-031 names (audit appendix 09-tests.md, items 3, 4 and 9) now exists. write-set-fence: after one sandboxed init, 51 rows run every toggle action (and the clear/status actions beside them) through the built CLI, walk the whole sandbox before and after, and fail naming any path outside the row's allowlist; a mustWrite row must change something. Coverage is held to the CLI itself: every command in --help, and every --enable/--disable/--set a command defines, has a row or a recorded reason (security --disable reaches the managed-settings path; proxy starts a relay; safe-delete depends on the trash tool). init-location-invariance: the same init from the repo root, a subdirectory, a linked worktree, a non-git dir, a repo path with a space, a symlinked HOME and a repo rooted at HOME installs an identical machine side, and writes per-repo files only at the checkout toplevel (none in subdirs, outside git, or at HOME). numeric-floors.json registers the row, command and location counts as floors and the allowlist size (11) as a ceiling. Refs #406
Claude Code runs a Stop event's hooks in parallel, so settings.json array position sequences nothing at run time. The memory.ts, init.ts, memory-worker and capture-turn comments claimed an append-before-spawn order enforced by array position; they now state the real contract: the worker tolerates a not-yet-appended turn (D-QUEUE-NO-ORPHAN-DELETE), and the array position only keeps init and the memory toggle byte-alike. Refs #406
devflow_debug_set_cwd created the per-cwd log folder itself when hook debugging was on, so a later devflow_log_dir saw an existing folder and skipped the D-LOG-DIR-CAP prune. The debug trace now takes its folder from devflow_log_dir (sourcing log-paths beside it when the hook has not), so every hook-created log folder goes through the same bounded prune. Refs #406
The D-SPAWN-BUDGET note claimed a 12-spawn allowance across the unit suite that nothing enforces and many later files exceed. It now states this file's own spend (9 CLI spawns, 32 hook spawns) and that the file's structure, not a counter, holds it. Refs #406
- feature-knowledge-system: 47 reference files (11 tracker ops, 9 PR-host ops, 4 named docs), 16 partials with _docs_root.mds; the retired D-INSTALL-SET text now states install-all. - compliance-feature: the lens is machine ∪ default branch ∪ worktree (D-LENS-UNION); unreadable files never lower it; the offline origin/HEAD fallback; the language gate resolves CLAUDE_CONFIG_DIR. - installer-shadowing: the v6 block with top-ups inserted inside it (D-GITIGNORE-IN-BLOCK); init-not-home, no config repair, atomic settings writes, the downgrade warning. - learning-capture-system: parallel Stop hooks and the kept user-only queue; the hook log cap; top-level-only scaffolding; the session-start personal tracker narrowing and legacy-install notice. - test-harness: the write-set fence, the init location matrix, and the new guards. - tracker-feature: the personal narrowing in Section 3, the corrected mismatch remedy, and the tracked-config.json rule. Refs #406
session-start-context: extract the bounded-read + tracker-key case logic shared by TRACKER_PROJECT_FILE and TRACKER_PERSONAL_FILE into one _sc_tracker_file_asks() shell function (no new forks). Same D-series commentary, same behavior. resolve-settings.cjs: rewrap one overlong JSDoc line in foldPublication to match the file's ~80-char comment width. No logic change.
git ls-files reads the index, and reading the index runs a configured core.fsmonitor hook (verified on git 2.50.1). The settings resolver runs from session hooks and documented the check as a pure read, so pass -c core.fsmonitor=false on that one call. Adds a real-git test with a marker-writing hook plus a known-bad probe.
The resolver.test.ts full-suite failure (exit 4 at the first e2e row) was the first exec of the freshly written bash fakes: macOS scans a new executable on its first run, and under a loaded syspolicyd that exceeds the resolvers' 5 s per-git-call bound, so rev-parse read as unanswered and the row failed closed. warmFakeBin pays that cost in beforeAll.
Rewrites transition phrasing ("can no longer", "exactly as before",
"now keeps") in the D-OFFLINE-ORIGIN-HEAD and D-LOG-DIR-CAP comments
and a test header, and unescapes the literal \` sequences a heredoc
copy left in session-start-context's Section 4 comments.
…clone The D-OFFLINE-ORIGIN-HEAD rows were scripted only. A real clone records refs/remotes/origin/HEAD; with origin gone the lowered branch resolves required from main's tracking copy, and deleting the symref (the control) leaves the residual worktree-governed standard.
An origin/HEAD that exists but names no safe branch (another remote, a hostile or unparseable name) fell back to the worktree's own file, the residual case. It is a failure, and every failure resolves required: it now reads as an invalid base, as the settings resolver already fails the same answer closed to generic. A genuinely unrecorded origin/HEAD keeps the residual behaviour (worktree governs, WARN=remote-unavailable). Refs #406
feature-config read config.json through a symlink while the resolvers refuse one. It now reads through the resolvers' own bounded no-follow read (readBoundedRegularFile), so a symlink, dangling or not, reads as unreadable: readers configure nothing from it, and the managed write leaves it in place with a warning (D-CONFIG-NO-REPAIR) rather than writing through it or renaming a file over it (D-CONFIG-NO-FOLLOW). Refs #406
The only writing security row re-enabled a deny list init had already installed, so it was marked mustWrite: false and proved nothing. A new row seeds the state `init --security none` leaves (no deny list in the user settings, `none` in the manifest) and requires the enable to write inside [settings, manifest]. It is seeded rather than installed because `init --security none` also strips the platform managed-settings file, an absolute system path the sandbox cannot redirect. `security --disable` stays exempt, with the reason stated precisely: it removes Devflow entries from that same managed file. Row floor 51 -> 52. Refs #406
MAX_LOG_DIRS_PRUNED_PER_RUN was 10,000, so the 31k backlog the cap was written for took about four inits to clear. The removal bound now equals the scan bound (100,000): one init removes every scanned folder beyond the cap, about 2.2 s per 10,000 removals, and only a backlog larger than the scan bound waits for the next init. Refs #406
- README: compliance --enable/--set print the keys to add to .devflow/project.json, never a file to replace it; the lens reads the default branch's copy from the local tracking ref. - CHANGELOG: an unsafe origin/HEAD resolves required; a symlinked config.json reads as unreadable and init leaves it; the lens reads the default branch from the last fetch; the log prune clears in one run. - file-organization: 170 deny entries = 145 Bash + 25 Read patterns. Refs #406
MDS_PARTIALS is 15 (ALL_MDS_PARTIALS 16 with _common.mds) and ALL_DISCOVERED_HOSTS is 20 (13 command + 1 generator + 6 reference modules), as the build prints. Three KBs still said 12/13 partials and 16 hosts with two reference modules. Refs #406
resolveDevflowDirCleanup no longer takes a scope, so its test titles no longer say "for user scope". Refs #406
init --security none reaches the managed-settings removal, which rewrites or unlinks the real platform file before any TTY or sudo step. The init-guards and hook-ownership e2e suites passed `none` with no guard; neither needs that mode, so both now install with --security user. The one test that does need it keeps its skip, now through the shared systemManagedSettingsAtRisk() helper (D-TESTS-NO-SYSTEM-MANAGED), and a guard fails any test file that passes --security none or security --disable without it. Refs #406
The dynamic-workflow-engine description and its count-rule pointer named 12 partials and 16 hosts; the tree holds 15 and 20. Refs #406
The prune-call comment still said a large backlog is worked off over successive inits. One pass clears every folder it scans beyond the cap; only a backlog beyond the 100,000-folder scan bound waits. Refs #406
An unreadable personal config.json resolves to the fail-closed settings, not to no configuration; only the memory and learning hooks read it as narrowing nothing. Refs #406
The CLI prints the keys to add to .devflow/project.json, not the file itself. Refs #406
The from-no-deny-list row shared its test name with the re-enable row, so a failure could not say which one broke. Rows take an optional label that rowName appends. Refs #406
The notice must reach piped stdout exactly once, ahead of the first-install migrations and the install spinner. It is printed before any spinner starts, so nothing drawn later can overwrite it. QA saw it missing in 1 of 5 runs. That run had no cd into the sandbox HOME, so init ran in an ordinary checkout, where the notice is rightly absent. Task: feat/406-pre-3-0-0-hardening-local-scope-retireme Refs #406
Registers the literal, scoped to the skill's own tree, so the sourced repo-local helper script cannot come back. CHANGELOG.md keeps naming it. Task: feat/406-pre-3-0-0-hardening-local-scope-retireme Refs #406
Labels the second compliance --disable row 'after --enable'. Adds a check, with a red probe, that no two rows register the same test name. Task: feat/406-pre-3-0-0-hardening-local-scope-retireme Refs #406
The doc comment now matches the printed text: the keys to add to the repository's .devflow/project.json, merged into it, never replacing it. Task: feat/406-pre-3-0-0-hardening-local-scope-retireme Refs #406
`compliance --status` runs the evidence-policy resolver, whose one
reachability probe is `gh api repos/{owner}/{repo}`. The sandbox PATH
reached the machine's gh, and gh 2.10x seeds
$HOME/.local/state/gh/device-id on its first run, so on CI the
write-set fence saw four writes outside the row's allowlist. Older gh
releases (2.88 locally) write nothing, which is why it passed here.
createSandbox now ships a fake gh beside the fake claude: exit 4 with
gh's unauthenticated message and nothing on stdout, writing nothing, so
the resolver takes its offline path whatever gh the machine carries.
Every sandbox consumer (the fence, init location invariance, the
install snapshot and hook matrix, the golden generator) inherits it.
A harness test pins that claude and gh resolve to the sandbox fakes
and that the resolver's probe writes nothing; the fence asserts the
same resolution beside its HOME check.
Refs #406
This was referenced Sep 30, 2026
Owner
Author
Test Plan Evidence — 495d84cVerified 10/36: VERIFIED-CI 1, ATTESTED-LOCAL 9, UNVERIFIED 0, STALE 7, FAILED 19, INDETERMINATE 0
|
Owner
Author
Test Plan Evidence — 495d84cVerified 36/36: VERIFIED-CI 26, ATTESTED-LOCAL 10, UNVERIFIED 0, STALE 0, FAILED 0, INDETERMINATE 0
|
dean0x
deleted the
feat/406-pre-3-0-0-hardening-local-scope-retireme
branch
September 30, 2026 19:44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Hardens the single-scope install and the per-repository settings layer (epic #387) before the 3.0.0 release: the confirmed bugs and audit leftovers in issue #406, the regression net ADR-031 required, and every doc, CHANGELOG, knowledge-base and ledger update that goes with them. Nothing is deferred.
Release: not included — 3.0.0 is dispatched separately after merge.
User decisions (2026-09-30)
D-LENS-UNION).COMPLIANCE= machine ∪ the default branch'sproject.json(read from the local tracking copyrefs/remotes/origin/<default>, never the network) ∪ the worktree's. A branch can add a framework and never remove one; an unreadable file never lowers the lens. The evidence floor is unchanged..devflow/config.jsonis ignored (D-PERSONAL-UNTRACKED). Checked locally withgit ls-files --error-unmatch; a tracked copy reads as absent and the resolver warns togit rm --cachedit. Closes the publication-ceiling bypass of a committed{"reviewPublication":"full"}. If git does not answer, the personal keys fail closed.D-OFFLINE-ORIGIN-HEAD). Whenghandls-remoteboth fail,git symbolic-ref --quiet refs/remotes/origin/HEADnames the default branch and its tracking copy is folded in. With no origin/HEAD, the worktree governs withWARN=remote-unavailableas before; an unanswered read raises torequired.Changes
Settings and evidence resolvers
resolve-settings.cjs: D-A, D-B. An unreadable personalconfig.jsonfails only its own keys and keeps every readable layer's frameworks.resolve-evidence-policy.cjs: D-C.SAFE_REF_REand the origin/HEAD parser moved to the sharedlib/project-config.cjs.Hooks
D-HOOKS-TOPLEVEL-ONLY: the.gitentry must be at the root, so a failedgit rev-parsein a subdirectory no longer scaffolds.devflow/there.D-GITIGNORE-IN-BLOCK: v6 top-up lines are inserted inside the devflow block, never appended at end of file. The shell and TS twins stay byte-identical, and the block comment callspolicy.jsonretired (v6 is unreleased, so the marker is not bumped).D-LEGACY-LOCAL-NOTICE).D-QUEUE-NO-ORPHAN-DELETE: Stop hooks run in parallel, so a user-only memory queue is kept for the next run instead of deleted. The comments that claimed array order sequenced them now state the parallel contract.D-LOG-DIR-CAP(hook side):devflow_log_dirprunes to 200 folders, at most 50 per new folder. The debug trace creates its folder through the same path.Prompts
D-CLAUDE-DIR-PROMPTS:/code-review's language gate and/dynamic-profileresolveCLAUDE_CONFIG_DIRthe way the installer does.D-DOCS-ROOT: every.devflow/docswrite is rooted at the checkout toplevel, through_partials/_docs_root.mds.config.jsonkey;git.mdstays under its ceiling and the frozen status-lines golden is untouched.docs-frameworkno longer sources a nonexistentdocs-helpers.sh.CLI
D-INIT-NOT-HOME: init writes no repository files when the git root is HOME.D-CONFIG-STRICT-PARSE/D-CONFIG-NO-REPAIR: a malformed or duplicate-keyconfig.jsonis never rewritten.D-SETTINGS-ATOMIC: everysettings.jsonwrite is atomic and preserves a symlink.D-DEBUG-ENV-OBJECT: a non-objectenvis rejected.D-INIT-DOWNGRADE-WARN: init warns before an older CLI downgrades the install.D-LOG-DIR-CAP(init side): at most 200 log folders kept, at most 10k pruned per run.--no-complianceremoves only the rule; the--enableswitches say a repository can opt out.compliance --enablesuggests adding keys toproject.json, never replacing it, and the migration hint says to keeppolicy.jsonuntil every teammate runs 3.0.compliance --statusshowsDefault branch:andEffective here:lines.tracker|memory|learning|knowledge --statuswarn on a trackedconfig.json.Tests
claude-dir,docs-root,settings-atomic-write.no-config-readnow also covers the ambient charter, hook directives and dist staleness.uninstall --scope localoutside a repository.skill-referencesFormat 3 now holds compiled commands to directory-neutral install paths; thelanguage-gate-focusesfloor replaces the removedinstall-path-refsfloor.Docs
[Unreleased](3.0.0): corrected migration notes (keeppolicy.jsonuntil everyone runs 3.0; removing a stale~/.claudeinstall;CLAUDE_CODE_DIR), a "how a team adopts project.json" guide, and entries for every change above.cli-reference,commands,file-organization(170 deny entries; the tracker files uninstall keeps),hooks,working-memory.refresh-anchor; the ledger is local and gitignored, so it is not in this diff.Breaking Changes
Part of 3.0.0, already breaking. The new break here: a
.devflow/config.jsoncommitted to git is now ignored. Teams that shared a tracker or feature switch that way move the keys into.devflow/project.jsonand rungit rm --cached .devflow/config.json.Testing
npm run build: passes.eager-memory-refresh, which spawns a realclaude): 211 of 217 files passed. The 6 that failed (compliance-e2e,decisions/learning-curation,decisions/ledger-ops,queue-append,redact-secrets,shell-hooks) are on the known load-flaky list, and each passes in isolation.install-snapshot,hook-log-paths,init-machine-switch-e2e,write-set-fence,init-location-invariance,tests/guards,tests/docs,skill-references,shell-hooks,capture-hooks,memory,cli-seam,feature-status-narrow): 937 passed, 4 skipped. The one failure, inshell-hooks, passes in isolation (255/255).DEVFLOW_GITIGNORE_BLOCK.eager-memory-refreshand the integration suite, both of which spawn a realclaude.Related Issues
Closes #406
Test Plan