Global review loop
A curated collection of Claude Code skills — code-review loop, local chat-history search, session end/resume, Gemini image generation, transcript retrospectives — that run machine-wide with just python and node.
npx -y skills add BryceEWatson/claude-global-skills --skill global-review-loopAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Mine ALL of your local Claude history across EVERY project (both corpora — Claude Code CLI and Cowork local-agent-mode) for operator friction that recurs across MULTIPLE projects, then propose GLOBAL ~/.claude leverage (a CLAUDE.md rule, a new/edited global skill, a memory, or a settings hook) to fix it — each proposal reconciled against what already ships globally, self-validated by an adversarial /review-loop --mode claim loop, and recorded in a cross-run ledger so nothing is re-proposed, dismissed-twice, or shipped on its own say-so. DISTINCT FROM: signal-scan (one repo → that repo's .claude/ config), command-retro (Command's own logs → Command's queue), and transcript-analysis (ONE project, Cowork-only, proposes that project's CLAUDE.md candidates). This skill is the GLOBAL, cross-project layer: it only surfaces friction proven to recur across >=N DISTINCT projects (counted by cwd-PATH attribution only) and only ever proposes changes to ~/.claude. Invoke as /global-review-loop. Triggers: "what global rule should I add", "what recurs across my projects", "fleet-wide friction", "improve my global Claude config from how I actually work".
SKILL.md
63.1 KB, ~16.2k tokens by cl100k_base, as published. Nobody here has run it
/global-review-loop
The cross-project, global self-improvement pass. It reads how you actually
work across your WHOLE fleet — every project, both local Claude corpora — finds
the friction that recurs in two or more different projects, and proposes the
smallest global ~/.claude change that would fix it. It PROPOSES only; it
has no write tools and cannot edit ~/.claude itself (see Apply boundary).
It is one member of a deliberately-partitioned family — know which one you want before you run it. The differentiator is scope + the cross-project recurrence gate, not "the others don't propose" (two of them do):
| Skill | Unit of work | Reads | Proposes changes to | Promotion bar |
|---|---|---|---|---|
/signal-scan <repo> | ONE repo | that repo's CLI history | that repo's .claude/ (proposes) | recurs within one repo |
/command-retro | Command itself | Command's own logs | Command's work queue (proposes) | Command-only friction |
transcript-analysis | ONE project | that project's Cowork transcripts | that project's CLAUDE.md (proposes per-project candidates) | recurs across that project's sessions |
/global-review-loop | the whole fleet | ALL projects, BOTH corpora | the global ~/.claude/ (proposes) | recurs across >=N DISTINCT projects (path-attributed) |
/chat-history-search | read-only | anything | nothing (search/inventory) | n/a |
Why global-review-loop is not a duplicate of transcript-analysis (the closest sibling — it also mines transcripts and also proposes CLAUDE.md candidates):
- Scope. transcript-analysis is single-project (filters by
userSelectedFolders/ project keywords, defaults to the CWD) and primarily Cowork; global-review-loop spans ALL projects in the registry across BOTH corpora. - Bar. transcript-analysis surfaces what recurs within one project's
sessions and proposes that project's
CLAUDE.mdentries; global-review-loop promotes only friction that recurs across >=N DISTINCT projects (default 2), attributed by cwd PATH not keyword, and routes the fix to the global~/.claudesurface. - Single-project leftovers belong elsewhere. A cluster that is really
per-project (its per-project candidate is transcript-analysis's job; its
per-repo CLI signal is
/signal-scan's) is OUT OF SCOPE here. global-review-loop drops it from the global set and names where it belongs.
Hard boundary (the no-duplication contract): anything that recurs in only
ONE project is OUT OF SCOPE here. This skill never writes a per-repo signal,
never edits a project's .claude/, and never proposes a project-claude-md
edit. It mines the fleet corpus itself for the cross-project layer and proposes
ONLY the cross-project survivors.
On reuse vs. independent mining (be honest about what's wired). This skill does not read another tool's friction bytes. signal-scan's durable output is a TypeScript Signal store (
data/signals.json) readable only inside the Command checkout via its TSSignalStore; itsfrictions.jsonis an operator-named, ephemeral CLI argument with no stable path. There is no discoverable per-repofrictions.jsonto consume, and this skill must run on a machine with no Node/Command checkout. So global-review-loop independently mines the fleet corpus (Step 3) and is distinct from signal-scan because of the >=N-distinct-project gate and the global~/.claudetarget, not because it reuses signal-scan's output. If a freshsignal-scan frictions.jsonhappens to be handed to it via--frictions-in <path>, it will fold those in as extra input — but it never depends on that file existing.
What it produces
A ranked list of CrossProjectProposals, each one:
- justified by friction observed in >=N DISTINCT projects (default
N = 2, raise with--min-projects), where every counted project is cwd-PATH attributed (keyword-attributed projects never count toward the bar), - routed to exactly one of four global targets (
global-claude-md,global-skill,memory,settings-hook), - carrying exact patch text (ready to paste, not a description),
- reconciled against what already ships in
~/.claude(already-encoded → dropped), - self-validated by
/review-loop --mode claim(only proposals a lens demonstrably examined and could not break are eligible to ship), - recorded in a cross-run ledger so it is never re-proposed blind, and tracked for loop closure (did the applied fix actually stop the friction?).
Applying a survivor is a separate, confirmation-gated, operator-driven step that this skill cannot perform itself.
Helpers (self-contained Python — stdlib + two pip deps; NO Command/chat-arch TS imported)
All under <SKILL_ROOT>/ = ~/.claude/skills/global-review-loop/.
| Helper | Role | When |
|---|---|---|
scripts/corpus_retrieve.py | Cross-project corpus enumeration + false-positive filtering (both corpora); stamps per-turn attribution mode | Step 1 |
scripts/materialize.py | Privacy-guarded writer for the agent-materialized JSON files that have no emitting helper — reads JSON on stdin, writes --out only after _guards.assert_safe_out (fail-closed exit 7) | Steps 3, 6, 8, 9b |
lib/recurrence_promote.py | Cluster fleet frictions, count distinct path-attributed projects, apply the >=N promotion rule | Step 4 |
scripts/reconcile_global_config.py | Read the global ~/.claude surface; drop already-encoded candidates | Step 5 |
lib/claimpack.py | Build the claims-pack + evidence-pack (with explicit join ids); render the fail-closed verdict table | Step 6 / Step 9b |
lib/ledger_store.py | The cross-run state ledger (validate / upsert / dismiss / mark-recurrence / panel) | Steps 2, 7, 9 |
lib/apply_record.py | Record an operator-confirmed apply (loop-closure bookkeeping) | Step 10 (manual) |
All helpers are invoked via Bash (python <SKILL_ROOT>/...). This skill has
no Edit/Write tools — every PRIVATE-CORPUS artifact is written by a helper into
.local-state (never by the agent into ~/.claude). Several intermediate JSON
files, however, have no emitting helper and must be MATERIALIZED by the agent
itself: fleet-frictions.json (Step 3), proposals.json (Step 6), the per-batch
claims-pack-b<b>.json slices (Step 8), findings-captured.json (Step 8), and any
per-survivor proposal file fed to ledger_store.py upsert (Step 9b). These carry
verbatim mined cross-project turns (a friction's/proposal's evidence[].text
IS the verbatim turn), so they must NOT be written with raw Bash redirection
(> file, python -c, a heredoc): raw redirection bypasses the privacy guard, and
an agent that wrote one to the cwd (a tracked/remoted repo) would leak private
corpus into a committable tree. Always materialize them by piping the JSON
through the privacy-guarded writer:
<produce the JSON on stdout> \
| python <SKILL_ROOT>/scripts/materialize.py \
--out <SKILL_ROOT>/.local-state/runs/<id>/<file>.json
materialize.py routes the resolved --out through the same fail-closed
_guards.assert_safe_out (exit 7) that corpus_retrieve.py/claimpack.py use, so
even a mistaken --out cannot land these files in ~/.claude config or a git
working tree — only the skill's own .local-state/. When the producer on the left
of the pipe is itself a helper (e.g. Steps 4–5), run the pipeline under
set -o pipefail so a non-zero upstream exit isn't masked by the writer's success.
Writing them under
.local-state/ is explicitly allowed (skill-owned scratch, not ~/.claude
config); the helper just makes that boundary mechanical instead of a prose
convention. The no-Edit/Write boundary is about never mutating ~/.claude, not
about never producing scratch JSON.
scripts/corpus_retrieve.py is stdlib only (json os re sys glob argparse pathlib datetime).
lib/ledger_store.py and lib/apply_record.py perform real jsonschema
validation of every proposal/record against the ledger schema (a malformed
proposal is rejected, not silently stored) and use filelock for atomic writes,
so they need jsonschema + filelock (pip install --user jsonschema filelock);
on a machine without those deps they exit 3 (missing-dep) printing the install
line. The others are stdlib only.
All helpers mirror the approach of
chat-arch's schema modules (correction.ts,applied-improvement.ts,upgrade-outcome.ts),pattern-retrospective/lib/register_finding.py(atomic tmp+rename, filelock, 3-backup rotation, schema validation, streaming reads, explicit exit codes), and Command'sretroCorpus.ts/transcriptStream.ts/chatlog.ts/scan-cowork-lam.ts/command-sessions.mjs. They import none of that TypeScript — the skill must run with justpythonon a machine that has no Node/Command checkout. Canonical schema source to keep the Python shapes in sync with (copy shapes, do not import):~/Projects/chat-arch/packages/schema/src/{correction,applied-improvement,upgrade-outcome}.ts.
Where artifacts live (local-only; never a tracked/remoted repo)
Run artifacts contain verbatim operator turns mined from the whole fleet — private cross-project chat. They MUST NOT land in a git-tracked, remoted repo.
- Corpus + evidence + claims artifacts (
turns.ndjson,sessions.ndjson,fleet-frictions.json,global-candidates.json,reconciled.json,proposals.json,claims-pack.json,evidence-pack.md,findings-captured.json,validation-verdict.md) → written ONLY under<SKILL_ROOT>/.local-state/runs/<id>/. This directory is the skill's own, is not a git repo, and is never committed. (Verified: Command'sresearch/studies/IS git-tracked with a GitHub remote and no.gitignorecover — so it is explicitly NOT used for these artifacts.) - The Python helpers refuse to write any corpus/evidence artifact to an unsafe
path.
scripts/corpus_retrieve.py,lib/claimpack.py, ANDscripts/materialize.pyroute every output (every--outand every derived output file path) through the sharedlib/_guards.assert_safe_out, which fails closed (exit 7): it refuses any path inside~/.claudeEXCEPT the skill's own.local-state/, refuses any path inside a git working tree, and refuses on any error. So a stray--outcannot leak private turns into a tracked repo or into~/.claudeconfig. The agent-materialized files that have no emitting helper (fleet-frictions.json,proposals.json, theclaims-pack-b<b>.jsonslices,findings-captured.json, the per-survivor proposal files) are covered by this same guarantee because they are written throughmaterialize.py, never rawBashredirection — so this "refuse to write to an unsafe path" guarantee now holds for the agent-written corpus files too, not only the helper outputs. - The ledger lives at
<SKILL_ROOT>/.local-state/proposals-ledger.json(also skill-owned, never~/.claudeconfig, never a project).
Local-only guarantee: nothing this skill writes is intended to be committed or
pushed; all of it stays under <SKILL_ROOT>/.local-state/ on this machine.
Argument parsing
Parse from the slash-command arguments (all optional, all have defaults):
--since <YYYY-MM-DD>— corpus lower bound on session last-activity. Default: 90 days ago.--min-projects <N>— distinct-project promotion floor. Default2.--registry <path>— default~/Projects/Command/registry.json.--project <id>— restrict the corpus pass to one project (debugging; the promotion rule then can't fire — used only to inspect attribution).--max-iter <n>— claim-loop iteration cap. Default3.--threshold <f>— cross-project lexical cluster threshold (cosine). Default0.40.--batch <n>— max proposals handed to one review-loop invocation. Default3.--include-automated— keep Command watcher/runner sessions (default: excluded).--frictions-in <path>— optional extraSurfacedFriction[]to fold in (e.g. a freshsignal-scan frictions.json); never required.--dry-run— run Steps 1–6 and print the would-be proposals; write nothing to the ledger.
Workflow (end-to-end, ordered)
Step 0 — Pick the study id and scratch dir
Choose <id> = global-review-loop-<UTC-yyyymmddThhmmZ>. All run artifacts live under
<SKILL_ROOT>/.local-state/runs/<id>/ (skill-owned, untracked — see "Where
artifacts live"). Never ~/.claude config, never a project, never
research/studies/.
Step 1 — Retrieve the cross-project corpus + filter false positives
This stage produces the clean, project-attributed turn corpus every later stage
consumes. It is the cross-project, both-corpora generalization of the
single-repo retrieval signal-scan runs: it mirrors retroCorpus.ts
(enumerate + cwd-attribute), transcriptStream.ts (stream genuine turns + the
per-turn time guard), chatlog.ts (pathMatchesProject, cleanPromptText,
isAutomatedSummaryPrompt), scan-cowork-lam.ts (Cowork audit/outputs line-shape
filter), and command-sessions.mjs (the MACHINE_PREFIXES automation classifier)
— without importing that TypeScript.
python <SKILL_ROOT>/scripts/corpus_retrieve.py --since <YYYY-MM-DD> \
--registry ~/Projects/Command/registry.json \
--sessions --stats --out <SKILL_ROOT>/.local-state/runs/<id>/sessions.ndjson
python <SKILL_ROOT>/scripts/corpus_retrieve.py --since <YYYY-MM-DD> \
--registry ~/Projects/Command/registry.json \
--turns --stats --out <SKILL_ROOT>/.local-state/runs/<id>/turns.ndjson
--out is required: the script writes its NDJSON to that path (there is no
stdout-as-data path; --stats still prints the summary to stderr). Before writing,
it routes the resolved --out through the shared lib/_guards.assert_safe_out,
which fails closed (exit 7): it refuses any path inside a git working tree, refuses
any path inside ~/.claude EXCEPT the skill's own .local-state/, and refuses on
any error — so a stray --out can never leak private cross-project turns into a
tracked repo or into config. Keep --out under
<SKILL_ROOT>/.local-state/runs/<id>/ as shown. The output file is opened
UTF-8 so verbatim turns with em-dashes / smart quotes / arrows are preserved.
What it does (and why each rule exists):
- Enumerates ALL projects, BOTH corpora, within
--since. Two completely separate trees; the Cowork tree is ~10x larger.- CLI corpus:
~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl(skips<sessionId>/subagents/agent-*.jsonl— no operator prompts). - Cowork corpus: sidecar
claude-code-sessions/<org>/<user>/local_*.json(carriesoriginCwd/cwd,sessionId,lastActivityAt,title) paired with its transcript underlocal-agent-mode-sessions/<org>/<user>/<sessionId>/(audit.jsonl= primary prompt source for older sessions;outputs/.../*.jsonlqueue-operation enqueueevents = cleanest verbatim for newer ones). Walks BOTH thelocal-agent-mode-sessionsandclaude-code-sessionsdir names.
- CLI corpus:
- Attributes by PATH, not keyword. The authoritative key is the in-line
cwd(CLI) /originCwd(Cowork) field — NOT the encoded-cwd directory name, which is mixed-case (C--vsc--) and lossy. Match case-insensitively / trailing-slash-normalized against each registry project'spath+matchHints.pathAliases; most-specific (longest) path wins. Keyword (matchHints.keywords/coworkMarkers) is a fallback used ONLY when no path matches AND exactly one project keyword-matches; 0 or >1 →(unattributed), never a guess. Each emitted turn carries anattributionfield (path/keyword/none) so downstream stages can count only path-attributed projects toward the promotion bar. (A gold corpus must contain sessions ACTUALLY run in a repo, not ones that merely mention it.) --sinceis a cheap session-level LOWER bound on last-activity (mtimefor CLI,lastActivityAtfor Cowork). No session-level upper bound — a straddling session is still opened so its in-window turns count. Any precise upper cut is enforced at the TURN level on each turn's own ISOtimestamp.- Streams line-by-line; never loads a multi-MB transcript whole. Files over a cap (25 MB) are recency-only. Garbage line → skipped; unreadable file → degrades to empty. Never throws.
- Extracts only genuine human/assistant turns, each
{role, text, tsMs}with{projectId, attribution, source, sessionId, filePath}provenance, cleaned via thecleanPromptTextrecipe.
False positives it FILTERS (all look like type:"user"/content but are NOT
genuine operator turns):
| Reject | How detected | Why |
|---|---|---|
| Command automation (watcher/runner) | FIRST genuine prompt matches a MACHINE_PREFIXES template OR the structural narrative discriminator (below) | Command's own backend invocations — the dominant noise class |
tool_result content | user line whose content array is entirely tool_result blocks | Tool output, not typed input |
<task-notification> wrapper | RAW text starts with <task-notification | Subagent completion notice |
<scheduled-task> / cron | sidecar title or raw text starts with <scheduled-task | Cron session |
<local-command-stdout> / <command-message> / <system-reminder> / <untrusted…> | raw text starts with the tag | Harness residue / injected context |
TodoWrite tool_use | block {type:"tool_use",name:"TodoWrite"} | Assistant-written todos |
isMeta:true user line | top-level isMeta===true | Slash-command expansion, not the operator typing |
attachment/system/other META_TYPES | top-level type in the meta set | Meta/system, not a turn |
isSidechain / isApiErrorMessage | top-level boolean true | Background op / API error |
| subagent transcripts | file under subagents/agent-*.jsonl | No operator prompts; whole file skipped |
| Cowork audit/outputs dupes | same prompt in audit.jsonl (2–3 events) AND outputs enqueue | Prefer outputs enqueue; fall back to audit only when no outputs file |
Order matters: the raw-text wrapper checks run BEFORE cleanPromptText —
cleaning strips the wrapper tags and would leave the inner notification text
("auto ping") looking like a genuine prompt.
The Command-automation discriminator (the load-bearing class): a session is
automation-classified (excluded unless --include-automated) when its FIRST
genuine prompt is a Command backend template. BOTH a prefix list and a structural
check, so a one-off rename doesn't leak noise:
- Prefix templates:
Project:,Facts (the only ground truth):,Action to perform:,Allowed actions:,Action just completed:,<untrusted,Here is the whole fleet's state,You are ranking,Write one sentence instructing Claude Code,The operator gave this feedback,Research the following (read-only),Invoke the. - Structural narrative discriminator: cleaned first prompt matches
^Project: .+? \([a-z]+\)AND containsSessions: \d+ recordedORRecent events (newest first):. The header PLUS a second anchor keeps a genuine "Project: …" prompt from being misclassified. This matters fleet-wide: the narrative prompt NAMES the summarized project, so without it those sessions keyword-attribute onto dormant projects and inflate them.
Read the --stats summary. It reports, per corpus: files scanned, sessions
kept, sessions dropped-as-automation, sessions unattributed, sessions
keyword-attributed (NOT counted toward the bar), turns emitted, and the
false-positive categories excluded. State the proxy gap honestly ("N files →
M genuine sessions across K path-attributed projects") — never call a file
count a "session" count.
Step 2 — Load the cross-run ledger (and suppress what's settled)
python <SKILL_ROOT>/lib/ledger_store.py list --full --json
This is the skill's private state at <SKILL_ROOT>/.local-state/proposals-ledger.json
(NOT ~/.claude, NOT any project). It carries previously-surfaced proposals
(deduped by proposalId), the dismissed do-not-resuggest set, and the
applied loop-closure records. With --full --json the output adds, alongside
the existing counts, the full applied[] array (each entry with appliedAt plus
canonicalRule or ruleSummary) and the full dismissed[] array (each with
proposalId + reason) — the plain list --json would only give you counts.
Note which proposalIds are in dismissed[] — you'll suppress any new candidate
that hashes to one of them and print a one-line N suppressed (dismissed) note.
Note the applied[] entries with their appliedAt and canonicalRule — Step 7
reads that applied[] array directly to check loop closure.
Step 3 — Mine per-project friction (reuse the validated lens; do NOT invent a detector)
For each active code project in the corpus, read its genuine turns closely
through the same FRICTION_LENS signal-scan validated at Gate 3, in
priority order:
- Recurring friction — the same complaint/correction across sessions.
- Intent-vs-behaviour gaps — work did something other than wanted; re-steer.
- Unmet / repeated requests — asked for but not delivered, or asked twice.
- Abandoned / thrashed work — stalled, looped, dropped mid-task.
- Corrections that should become a standing rule.
Emit, per project, a ranked SurfacedFriction[] (the established contract). Each
member's scope carries the repoId and the attribution mode that produced it,
so the promotion stage can ignore keyword-attributed scope:
[{ "summary": "...", "proposedChange": "...", "targetHint": "...",
"scope": ["<repoId>"], "scopeAttribution": { "<repoId>": "path" }, "rank": 1,
"evidence": [{ "text": "<verbatim turn>", "sourceRef": "<sessionId>#<turn>",
"projectId": "<repoId>", "attribution": "path", "provenance": "measured" }] }]
Provenance discipline is mandatory: every friction cites a real turn;
provenance is measured for a verbatim quote, derived for an interpretation —
never label an interpretation measured. A pleasant "we did X" is context, not a
finding.
This skill independently mines the fleet corpus (it does not read another
tool's friction bytes — see the reuse note above). If --frictions-in <path> was
supplied (e.g. a fresh signal-scan frictions.json), fold those members in as
additional input first, then mine the remaining corpus inline. For a large corpus
you MAY fan one project's digest out to a subagent that returns the JSON; note any
fan-out (subagent billing/hooks were unverified before 2026-06-15 — see the
Command billing memory).
Concatenate every project's frictions into one fleet-frictions.json. It carries
verbatim turns (evidence[].text), so materialize it through the privacy-guarded
writer — never raw Bash redirection:
<emit the concatenated SurfacedFriction[] JSON on stdout> \
| python <SKILL_ROOT>/scripts/materialize.py \
--out <SKILL_ROOT>/.local-state/runs/<id>/fleet-frictions.json
Step 4 — Cross-project recurrence promotion (the new rule)
global-candidates.json echoes each cluster's evidence (verbatim turns), so pipe
recurrence_promote.py's JSON stdout through the privacy-guarded writer rather than
a raw > redirect (whose destination is unguarded):
python <SKILL_ROOT>/lib/recurrence_promote.py \
--frictions <SKILL_ROOT>/.local-state/runs/<id>/fleet-frictions.json \
--min-projects <N> --threshold <0.40> --json \
| python <SKILL_ROOT>/scripts/materialize.py \
--out <SKILL_ROOT>/.local-state/runs/<id>/global-candidates.json
What it does:
- Cluster all fleet frictions by lexical similarity of
summary + proposedChange— single-link agglomerative over cosine similarity (the shared_guards.cosine_simtoken + char-3gram primitive), default threshold 0.40 (intentionally well below the per-repo fold's 0.5: the same friction is worded differently across repos, so demand less overlap — near-identical frictions sit just above 0.40 cosine and must cluster, which a higher cut would split). Single-link gives transitivity so reworded variants chain into one cluster. - Distinct-project count per cluster = number of distinct
repoIds across its members that were PATH-attributed. A repoId attributed only by keyword (scopeAttribution[repoId] != "path") is excluded from the count — keyword over-attribution would manufacture false global candidates (a chat that merely mentions another project must not supply the 2nd distinct project). This is enforced in code, not just prose. Consequence by construction: a friction that recurs five times in one repo counts as 1 distinct project — intra-project repetition is corroboration, not cross-project breadth (within-project occurrence is kept separately as a secondary rank signal). - Promotion rule:
distinctPathProjectCount >= N (default 2) → GLOBAL CANDIDATE distinctPathProjectCount < N → out of scope (see routing below) - Route the demoted clusters (named, not silently dropped): a cluster that
misses the bar is reported as a 1-project leftover with its home —
per-project
CLAUDE.mdcandidate →transcript-analysis; per-repo CLI signal →/signal-scan <repoId>(handoff requires the Command checkout; see Step 5 note). Without a Command checkout, just report these clusters in the end-of-run summary — there is nothing to hand them to in-process. - Rank survivors by
(distinctPathProjectCount desc, total-occurrences desc).
Honest caveat (carry it to the report): this is lexical clustering only.
Two semantically-identical frictions with no shared content words will NOT
cluster. Never claim semantic dedup; the project COUNT is measured, "this is the
same friction across repos" is derived. The helper's footer states this.
Surface nearMissPairs for human promotion review. The helper emits a
nearMissPairs list — friction pairs whose cosine similarity lands in the band
[threshold-0.10, threshold) (with the 0.40 default, [0.30, 0.40)): close enough
that they might be the same cross-project friction but fell just under the
clustering cut. You MUST surface these pairs in the run output (not just let
the helper print them) so a human can decide whether to manually promote a pair
into one global candidate — never silently merge or split them yourself.
Step 5 — Reconcile each global candidate against the global config surface
reconciled.json carries each candidate's verbatim evidence, so pipe
reconcile_global_config.py's JSON stdout through the privacy-guarded writer
rather than a raw > redirect (whose destination is unguarded):
python <SKILL_ROOT>/scripts/reconcile_global_config.py reconcile \
<SKILL_ROOT>/.local-state/runs/<id>/global-candidates.json --threshold 0.6 \
| python <SKILL_ROOT>/scripts/materialize.py \
--out <SKILL_ROOT>/.local-state/runs/<id>/reconciled.json
reconciled.json echoes each candidate's full body (summary, proposedChange,
evidence, scope) alongside its encoded verdict and matched surface — it is
self-contained, so Step 6 authors CrossProjectProposals straight from it
without re-joining back to global-candidates.json.
Before proposing ANY candidate, check it against what already ships globally — the
single biggest failure mode is recommending a rule/skill/behavior already
encoded in ~/.claude. The reconciler reads three surfaces:
| Surface | Location | Encodes |
|---|---|---|
| Global CLAUDE.md | ~/.claude/CLAUDE.md | Standing directives, rigor rules, gates, workflow |
| Global skills | ~/.claude/skills/<name>/SKILL.md | Capabilities already built (dir name = the unit a skill proposal targets) |
| Memories | ~/.claude/projects/*/memory/*.md (+ MEMORY.md) | Operator-confirmed corrections/facts, per-project — swept across ALL projects |
A candidate is judged already-encoded by three OR'd paths (mirroring Command's
configExists): (1) skill-name exact match; (2) memory-title overlap;
(3) token overlap of the candidate's key tokens present in the surface blob at
fraction >= threshold (default 0.6). Drop every encoded: true candidate;
keep the rest as genuinely-novel.
Honest limit — always disclose: this check is lexical. A candidate
phrased differently from an equivalent rule already in config (e.g. a
mergeStyle: squash setting vs the prose "always squash-merge") may survive as a
false novel. The reconciler sets stopFlag: true on borderline scores
(threshold-0.15 <= frac < threshold); eyeball those. Never claim semantic
dedup you didn't do.
Report what you dropped — list each reconciled-out candidate with the surface
that covers it ("dropped: 'mark claims measured/derived/assumed' — already in
~/.claude/CLAUDE.md Claims & Evidence Rigor"). Transparency about drops proves the
check ran. If the reconciler exits non-zero with WARN: empty global surface, the
reader is broken / HOME is wrong — STOP and investigate before trusting any
NOVEL verdict.
/signal-scan handoff requires the Command checkout. Several places suggest handing a 1-project cluster back to
/signal-scan./signal-scanis a Command-repo skill invoked from within~/Projects/Command; it does not exist on a machine with no Command checkout. On such a machine the correct behavior is to report the 1-project clusters (and their suggested home) in the end-of-run summary, not to promise a handoff target that is absent.
Step 6 — Assemble the proposal set and freeze it into a claims pack
For each surviving candidate, author a CrossProjectProposal (schema below):
pick the single best of the four routing targets, write the exact patch text,
a <=15-word headline, a smoothed confidence (s/(s+c+2) — never "always" from
a thin sample), and attach the cluster's distinct-project evidence (>=2 distinct
path-attributed projectIds, each with a real sourceRef).
Surface-fit / concision gate — apply BEFORE routing a candidate to
global-claude-md. A global CLAUDE.md line is not free: a bloated CLAUDE.md
makes Claude ignore instructions, so every line dilutes the rest. Step 5 only
proved the candidate isn't already encoded — not that a prose line is the right
home. Two tests:
- Would removing this line cause a mistake? If not, it hasn't earned a permanent line — route it to a lighter surface or drop it.
- Must it steer every conversation? If it's domain knowledge that needn't
bloat every chat →
global-skill; a durable fact/correction →memory; something that must be enforced every time →settings-hook. Pickglobal-claude-mdonly when the rule must steer every conversation and no lighter surface fits. This is the entry-side complement to the Step 7 escalation (a prose rule that keeps getting ignored → mechanize): together they guard both against over-loading CLAUDE.md and against a prose rule that never fires.
Materialize the ranked CrossProjectProposal[] into proposals.json through the
privacy-guarded writer (it carries verbatim evidence[].text, so never raw
Bash redirection):
<emit the ranked CrossProjectProposal[] JSON on stdout> \
| python <SKILL_ROOT>/scripts/materialize.py \
--out <SKILL_ROOT>/.local-state/runs/<id>/proposals.json
Then freeze the ranked set into the loop's handoff artifacts:
python <SKILL_ROOT>/lib/claimpack.py build \
--proposals <SKILL_ROOT>/.local-state/runs/<id>/proposals.json \
--evidence-dir <SKILL_ROOT>/.local-state/runs/<id>/turns.ndjson \
--min-projects <N> \
--out <SKILL_ROOT>/.local-state/runs/<id>/claims-pack.json
Note on
--evidence-dir: despite the "-dir" suffix, this flag accepts the turns NDJSON file (turns.ndjson), not a directory. It is a documentation pointer only — the evidence-pack excerpts are pulled from each proposal'sevidence[].text(which IS the verbatim mined turn, captured in Steps 3/6), not re-read from this file.claimpack buildwarns if the path does not exist.
This emits two co-located files and dedups the proposal set within itself at
cosine >= 0.85 — actual cosine similarity over token (and/or char-ngram)
vectors of the sub-claim text, the same cosine >= 0.85 constant the in-loop
drift-guard uses (Step 9) — so two phrasings of one friction don't each burn a
review slot:
claims-pack.json— each proposal rendered as three conjoined falsifiable sub-claims, each with a stable, joinableidof the form<proposalId>:recurrence/:gap/:efficacy:- Recurrence — "friction
<id>appears in >=N distinct projects: A, B, …" - Gap — "the proposed fix is NOT already encoded in any cited project's
.claude/surface (and not in the global surface)." - Efficacy — "this specific fix would actually reduce this specific friction (the cited turns)."
- Recurrence — "friction
evidence-pack.md— per sub-claim, pointers to primary sources the claim lenses must read directly: the exactsession#turnrefs (recurrence), the exact.claude/file paths to grep (gap), the cited friction turns (efficacy). Each section header prints the exact joinable sub-claim id (e.g.### <pid>:recurrence) so reviewers can echo it. Excerpts, not summaries — the lenses read ground truth, not our gloss.
The pack also carries a contentHash. Note (do not over-claim): this hash is
this skill's own integrity/idempotency key over the normalized sub-claim text. It
is not review-loop's internal stall key — review-loop computes its own hash
over the text it receives (Step 9). Stall/termination is decided purely by
review-loop's <promise>review-stalled</promise> output, not by comparing this
hash.
Step 7 — Loop-closure check (is a previously-applied fix still failing?)
For each entry in the applied[] array surfaced by the Step 2
list --full --json call, detect post-apply recurrence with the dedicated
helper — do not interpolate mined corpus text into a python -c shell string
(mined turns are untrusted; that is a shell-injection surface). First materialize
the applied[] array to applied.json via the privacy-guarded writer
(scripts/materialize.py, the same writer Step 8 uses), then run the detector —
it streams turns.ndjson, matches each applied rule against later turns with
_guards.cosine_sim (cutoff 0.40, tsMs > appliedAt), and only ever passes
corpus text to Python as data (never to a shell):
python <SKILL_ROOT>/lib/loop_closure_scan.py \
--turns <SKILL_ROOT>/.local-state/runs/<id>/turns.ndjson \
--applied <SKILL_ROOT>/.local-state/runs/<id>/applied.json --json
For each RECURRED hit it reports ({proposalId, recurredSession, ts}), record it:
python <SKILL_ROOT>/lib/ledger_store.py mark-recurrence \
--proposal-id <id> --recurred-session <sid> --ts <ms>
This flips loopClosure.recurringPostApplication = true, records the recurrence
(the --recurred-session / --ts are persisted to
loopClosure.recurrences[] by the helper, so the audit trail of which later
sessions re-hit the rule is durable across runs), drops confidence in the
un-changed patch, and sorts the proposal to the very top — it means "a rule you
added to ~/.claude is being ignored in practice." Operationally: don't
re-propose the same prose — escalate (a rule that recurred after going into
global-claude-md should be re-proposed as a settings-hook or a global-skill,
citing the recurrence in the new rationale).
THE VALIDATION LOOP (the central requirement)
The skill does NOT ship proposals on its own say-so. It hands the set to
/review-loop --mode claim and lets an independent reviewer team try to break
each one against the ground-truth corpus. Survivors ship; falsified proposals are
dropped; partial-failures are deflated and re-tested.
This reuses --mode claim verbatim — we do NOT copy review-loop's agent files
into this skill (the Option-B fork was explicitly rejected; pattern-retrospective
§14). If review-loop's claim lenses change, this skill inherits the change.
Critical contract — the verdict is built from findings YOU capture, not from review-loop's state file. Verified against real archived claim-mode runs: review-loop's
findings_historyis either empty or a list of per-iteration summaries ({iteration, fixed, speculative, method}) — NOT per-finding records, and the file is moved to.local-state/archive/<session-id>-<ts>.jsonon terminal exit (sometimes with a UTF-8 BOM). You therefore cannot rely on reading per-finding dispositions out of that file. Instead, the orchestrating agent (you) must capture each claim lens's returned finding JSON in-session and write it to a normalized file this skill controls. The verdict step reads that file and is fail-closed: a sub-claim with no finding that demonstrably examined it is reportedunvalidated, neversurvived.
Step 8 — Run the claim-mode loop (in batches) and capture findings
Hand the claims-pack to review-loop in batches of --batch proposals
(default 3) so the per-loop finding budget is not spread so thin that most
sub-claims go unexamined. For each batch b, write the claims-pack.json slice
through the privacy-guarded writer (the slice carries verbatim evidence, so
never raw Bash redirection):
<emit the batch's slice of claims-pack.json's proposals on stdout> \
| python <SKILL_ROOT>/scripts/materialize.py \
--out <SKILL_ROOT>/.local-state/runs/<id>/claims-pack-b<b>.json
then hand that slice to review-loop:
/review-loop --mode claim \
--session-id global-review-loop-<id>-b<b> \
--max-iter 3 \
<claims under review: .local-state/runs/<id>/claims-pack-b<b>.json> \
<evidence: .local-state/runs/<id>/evidence-pack.md>
The four claim lenses fire in parallel, each told to read the cited sources directly:
claim-falsification→ go to the transcript refs and the live.claude/files and try to disprove recurrence / gap / efficacy.claim-method→ is the recurrence count honest (distinct path-attributed projects, not the same project re-counted; attribution by an authoritative field, not inference)?claim-calibration→ does the rank/headline match the evidence; is a 2-project floor being sold as fleet-wide?claim-coverage→ did assembly examine the right corpus; is a high-leverage friction missing to over-aggressive filtering?
Discipline line you MUST add to the invocation (every batch): "Tag every
finding with subClaimId = the exact sub-claim id it addresses (the
<proposalId>:recurrence|gap|efficacy token printed in the evidence-pack section
header). A finding with no subClaimId cannot be attributed and will be dropped.
If you tried to break a sub-claim and could NOT, return a finding with
category: "failed-falsification", load_bearing: true, and that subClaimId —
this is a positive result that marks the sub-claim examined-and-held."
Capture step (the load-bearing fix). As each batch's lenses return, collect their finding JSON objects and append them to a single normalized file the skill owns:
.local-state/runs/<id>/findings-captured.json == a JSON array of:
{ "subClaimId": "<pid>:recurrence|gap|efficacy", // REQUIRED — the join key
"category": "...", "severity": "low|medium|high",
"load_bearing": true|false, "claim": "...", "file": "<source ref>" }
Do not trust the state file to contain these. You transcribe what the lenses
returned in-session. (One file across all batches.) The finding claim/file
fields quote the private corpus, so write this file through the privacy-guarded
writer — accumulate the full array in-session and re-materialize the whole array
(it overwrites), never raw Bash redirection:
<emit the full findings array (all batches so far) on stdout> \
| python <SKILL_ROOT>/scripts/materialize.py \
--out <SKILL_ROOT>/.local-state/runs/<id>/findings-captured.json
subClaimId is assigned by the ORCHESTRATING AGENT, not by the reviewer. The
claim-lens finding schemas do not carry a subClaimId field, so it is not
present in what the lenses return. When YOU (the agent) transcribe each lens
finding into findings-captured.json, YOU assign its subClaimId — you know which
sub-claim the finding addresses from the evidence-pack section header it cited
(each section prints the exact <proposalId>:recurrence|gap|efficacy token; the
finding's file/source ref points back into that section). The reviewer "tag every
finding with subClaimId" discipline line above is a hint that steers the lens
to name the section it attacked — it is not the source of truth for the join.
The authoritative subClaimId is the one the orchestrating agent records here.
Each finding is itself falsified against its primary-source excerpt (review-loop Step 5, claim variant) before it can drop a proposal — a reviewer that misquotes its own source is dropped. This double gate keeps the loop from over-pruning.
Step 9 — Apply the verdict, refine, converge
Attribute each captured finding to a sub-claim via its subClaimId (exact
match on the joinable id — not substring guessing):
- Dropped — a
recurrenceorgapsub-claim is falsified (load-bearing, severity >= medium). A friction that doesn't actually recur across N projects, or a fix that already ships, is not fleet leverage. Remove it. - Refined (deflation-ONLY) — an
efficacy/calibrationfinding is actionable but the gap is real. Narrow the proposal so the surviving claim is true: lower the asserted project count to what evidence supports, tighten the fix to the friction it provably prevents, or downgrade rank. Never strengthen a claim to rescue it — that re-introduces the overstatement the loop exists to catch. - Survived (examined) — a
failed-falsificationfinding cites the sub-claim: a lens actively attacked it and could not break it. This is the strongest positive result. - Unvalidated (untouched) — no finding cites the sub-claim at all. The
loop did not examine it. It is reported
unvalidated, NOTsurvived, and is not eligible to apply. (This is the fail-closed rule: absent evidence never renders as a pass. An un-reviewed proposal and an actively-attacked-and-held proposal MUST be distinguishable.)
Convergence / termination — re-run only over the changed proposals (deflations create new claim text). Terminate on the first of:
- Clean — review-loop returns
<promise>review-clean</promise>: every surviving proposal's three sub-claims were examined and withstood falsification. - Exhausted —
--max-iter(default 3) reached or cost ceiling crossed (<promise>review-exhausted</promise>). Unresolved proposals are reported unvalidated, not shipped as validated. - Stalled — review-loop emits
<promise>review-stalled</promise>(its own internal stall detection — its hash over the claim text it received was unchanged after an attempted-deflation iteration). Stop; report survivors-so-far as partially validated. (We rely on review-loop's promise, not our owncontentHash, for this.)
Deflation-only refinement guarantees forward progress: each refined claim is strictly weaker, so the set monotonically shrinks toward supportable claims — it cannot oscillate.
Dedup at cosine >= 0.85 in two passes (the established fleet constant):
within-set pre-loop (done by claimpack.py build, Step 6) and cross-iteration
in-loop (review-loop's own Step 6 drift-guard, so a deflated proposal isn't
re-flagged for the reason it was already refined).
Step 9b — Render the terminal verdict + persist survivors to the ledger
python <SKILL_ROOT>/lib/claimpack.py verdict \
--claims-pack <SKILL_ROOT>/.local-state/runs/<id>/claims-pack.json \
--findings <SKILL_ROOT>/.local-state/runs/<id>/findings-captured.json \
--terminal-state <review-clean|review-exhausted|review-stalled> \
--iteration <n> \
--out <SKILL_ROOT>/.local-state/runs/<id>/validation-verdict.md
Combining per-batch promises into one --terminal-state: Step 8 may run
several batches, each ending in its own promise. Pass the worst-of to
--terminal-state, ordered review-stalled > review-exhausted > review-clean
— the run is review-clean only if EVERY batch returned clean; any stalled batch
makes the whole run review-stalled. This keeps the headline from over-claiming
validation when one batch failed to converge.
verdict reads the agent-captured findings file (not review-loop's state). It
is fail-closed: any sub-claim with no attributed finding renders unvalidated.
A proposal is survived only if all three of its sub-claims are either
held-examined (a failed-falsification finding cited them) — never if they are
merely untouched. It also prints the attribution match rate (findings that
joined to a sub-claim / total findings) and warns loudly if the rate is low
(a sign reviewers didn't echo subClaimId).
The verdict is a per-proposal disposition table:
| field | meaning |
|---|---|
id, summary | which proposal |
disposition | survived | refined | dropped | unvalidated |
projects_validated | distinct path-attributed projects the recurrence claim survived at (>= N for survivors) |
sub_claims | recurrence / gap / efficacy each held-examined | falsified | deflated | unexamined |
examined | did ANY lens demonstrably examine each sub-claim (yes/no per sub-claim) |
falsifier_evidence | for dropped/deflated: the short reason + the source ref that broke it |
iteration | which iteration settled it |
final_rank | rank among survivors only |
Headline states the terminal promise verbatim + counts, including the
fail-closed bucket: "K of M proposals survived (examined & held), J refined, L
dropped, U unvalidated (never examined); validation reached <terminal-state> at
iteration <n>; finding attribution match rate X%."
Then upsert survivors into the cross-run ledger (cross-run dedup, corroboration,
smoothed confidence, dismissed-skip; rejects any malformed proposal). Each
survivor's proposal file carries verbatim evidence[].text, so materialize it
through the privacy-guarded writer first — never raw Bash redirection — then
upsert it:
<emit the survivor's CrossProjectProposal JSON on stdout> \
| python <SKILL_ROOT>/scripts/materialize.py \
--out <SKILL_ROOT>/.local-state/runs/<id>/<each-survivor>.json
python <SKILL_ROOT>/lib/ledger_store.py upsert \
--proposal-file <SKILL_ROOT>/.local-state/runs/<id>/<each-survivor>.json
(Skip in --dry-run.) Only survived (examined & held) proposals are eligible to
be shown for apply. refined survivors carry their deflated text. dropped and
unvalidated proposals are recorded in validation-verdict.md as the audit trail
but are NOT upserted as apply-eligible. Honesty rule (no-edit-meta in
deliverables): the proposal states its final validated position; the
drop/deflate/unexamined history lives only in the verdict file.
Proposal output format (the product)
A ranked list of CrossProjectProposals. It reuses chat-arch's ProposedUpgrade
verbatim and wraps it with the cross-project evidence and lifecycle this skill adds:
{
"proposalId": "sha1(canonicalRule)", // stable across runs
"canonicalRule": "Run the build before claiming a fix is done", // imperative, project-agnostic
"rank": 1, // 1 = highest leverage / most likely to recur
"scope": { "kind": "global", "distinctProjects": 4 }, // path-attributed projects only
"occurrenceCount": 7,
"firstSeen": 1733000000000, "lastSeen": 1734000000000,
"confidence": 0.71, // smoothed s/(s+c+2); never "always" from one hit
"evidence": [ // >=2 DISTINCT path-attributed projects, hard-required
{ "projectId": "example-project", "attribution": "path", "text": "<verbatim operator turn>", "sourceRef": "<sessionId>#<turn>", "provenance": "measured" },
{ "projectId": "signal-engine", "attribution": "path", "text": "<verbatim operator turn>", "sourceRef": "<sessionId>#<turn>", "provenance": "measured" }
],
"proposedUpgrade": { // chat-arch ProposedUpgrade, verbatim
"target": "settings-hook", // ONE of the four routes below
"targetPath": "settings.json :: hooks.Stop",
"headline": "Block 'done' claims until the build passes", // <=15 words, the punchline
"patch": "<EXACT text to add — ready to paste, not a description>",
"rationale": "Recurred in 4 projects after being added as prose to global CLAUDE.md → escalate to a hook.",
"applied": false, "appliedAt": null
},
"loopClosure": { "recurringPostApplication": false, "alreadyEncoded": false }
}
Non-negotiables for every proposal:
- Ranked — weight by distinct path-attributed project count, then recurrence, then recency. A 2-project proposal must not outrank a 4-project one on recency alone.
- >=2 distinct path-attributed-project instances —
evidence[]spans two or more differentprojectIds, each withattribution: "path";ledger_store.pyrejects (exit 4) any that fails.projectIdis derived from the session's repo/cwd (path-attribution), never LLM-labeled. - Exact patch text —
patchis the literal string to paste. - Exactly one of the four targets (next section).
- Provenance-tagged evidence (
measured/derived/assumed).
The four routing targets
The skill is the global layer — per-project config is out of scope (that's
signal-scan / transcript-analysis). Every proposal routes to exactly one:
target | targetPath example | When |
|---|---|---|
global-claude-md | ~/.claude/CLAUDE.md | A standing behavior/preference rule that must steer every conversation. Carries a dilution cost — choose it only after the Step 6 surface-fit/concision gate (a bloated CLAUDE.md makes Claude ignore instructions); if a lighter surface fits, use that. |
global-skill | ~/.claude/skills/<name>/SKILL.md | Best fixed by creating/editing a reusable skill, not a prose line. |
memory | ~/.claude/projects/.../memory/<slug>.md | A durable fact/correction better as auto-memory than a hard rule. |
settings-hook | settings.json :: hooks.<Event> | Must be enforced by the harness (rule keeps getting ignored as prose → mechanize). Apply via update-config. |
Excluded by design: project-claude-md (duplicates signal-scan /
transcript-analysis), agent, command, prompt-snippet. The validator rejects
any target outside the four. (Internally these map from chat-arch's
UpgradeTarget: skill→global-skill.)
State ledger (cross-run memory)
The skill is stateful across runs so it never re-proposes what you've seen, dismissed, or applied. State lives in one file the skill owns:
<SKILL_ROOT>/.local-state/proposals-ledger.json
This is the skill's private cache — NOT ~/.claude/ and NOT any project.
(The skill proposes changes to ~/.claude; it must never write its own
bookkeeping there.) Same .local-state/ convention review-loop uses. Manage it
only through the helpers (atomic writes, filelock, schema-validated, 3 rotating
backups). Never hand-edit the JSON.
{
"schemaVersion": 1,
"generatedAt": 1734120000000,
"proposals": [ /* CrossProjectProposal; deduped by proposalId */ ],
"dismissed": [ { "proposalId": "…", "reason": "…", "dismissedAt": 1734000000000 } ],
"applied": [ /* AppliedImprovement (chat-arch shape, verbatim) */ ]
}
proposals[]— cross-run dedup.proposalId = sha1(canonicalRule)(the id-derivation chat-arch uses forCorrectionPattern.id), so a re-run assigns the same proposal the same id. Re-mining corroborates an existing row (merge evidence, bumpoccurrenceCount, refreshlastSeen) instead of duplicating.dismissed[]— do-not-resuggest. Decline a proposal →python <SKILL_ROOT>/lib/ledger_store.py dismiss --proposal-id <id> --reason "<why>". Every future run skips it and printsN suppressed (dismissed)(honest, not silent).applied[]— loop closure. When the operator applies a proposal, anAppliedImprovement(chat-arch shape, verbatim) is appended withappliedAt. The next run's Step 7 checks whether the rule recurred after that timestamp.
Apply boundary — PROPOSE only; the boundary is MECHANICAL, not honor-system
This skill has NO write tools. Its allowed-tools are Read, Grep, Glob, Bash, Task — there is no Edit and no Write. Every PRIVATE-CORPUS artifact
and the ledger are written by the Python helpers (invoked via Bash) into the skill's
own .local-state/; the helpers refuse any path inside ~/.claude config or a git
tree. (The agent may also materialize the intermediate JSON files that have no
emitting helper — see Helpers note — but it writes them by piping through
scripts/materialize.py, which routes the --out through the same fail-closed
guard, so those files too can only land in the skill's .local-state/; the agent
still cannot write ~/.claude.) The agent running this skill therefore cannot edit ~/.claude/CLAUDE.md,
a global SKILL.md, a memory file, or settings.json — the apply boundary is
enforced by the absence of write tools, not by the model choosing to obey a prose
rule.
Step 10 — Apply (operator-confirmed, through a separate surface)
Applying a survivor is a separate step the operator drives through a different, write-capable skill — global-review-loop hands off; it does not apply.
- global-review-loop shows the chosen
survivedproposal —headline,target,targetPath, and the exactpatch— as text. - The operator applies it via the surface that owns that target. None of these
are done by global-review-loop:
settings-hook→ theupdate-configskill (it ownssettings.json).memory→ the memory mechanism.global-claude-md/global-skill→ a confirmation-gated edit the operator makes in a separate, write-capable session (e.g. paste the exact patch themselves). The patch is shown verbatim precisely so the apply is an out-of-band, operator-confirmed action — not a same-agent Edit triggered by a possibly-misread "yes".
- Only then record it for loop-closure bookkeeping:
python <SKILL_ROOT>/lib/apply_record.py --proposal-id <id> \ --target-files "~/.claude/CLAUDE.md" --notes "<what was done>" --confirm-operatorapply_record.pyappends anAppliedImprovement(chat-arch shape, verbatim, idempotent on the(patternId,target,targetPath)triple) to the ledger'sapplied[]— it touches only the ledger, never the config. It is a required step — without it the loop never closes and the proposal keeps ranking. It fails closed unless it has positive proof of an attending operator: the explicit--confirm-operatortoken (shown in the command above) is the SOLE positive attendance proof. An interactive TTY on stdin is NOT trusted (isatty()is unreliable on Windows, so it cannot be used as evidence an operator is present). As a secondary block, the helper additionally aborts if any known watcher/unattended env marker is set (incl.COMMAND_PARALLEL_SLOT,COMMAND_ROOT,COMMAND_PORT,COMMAND_FIELDS, plusCLAUDE_WATCHER/COMMAND_WATCHER/CLAUDE_UNATTENDED/CLAUDE_REVIEW_LOOP_ACTIVE/CI) — so even a stray--confirm-operatorin an automated slot is refused. It also warns that, since global-review-loop has no write tools, the actual config edit must already have been made by the operator through the surface above.
The mutating path is guarded primarily by the absence of write tools. Because
global-review-loop has no write tools, it cannot itself perform the global-config
mutation under any context (attended or not) — that is the primary guard. As a
belt-and-suspenders second layer, apply_record.py requires positive attended
proof — the explicit --confirm-operator token (an interactive TTY is NOT
trusted, since isatty() is unreliable on Windows) — and additionally aborts if a
known watcher/unattended env marker is set
(COMMAND_PARALLEL_SLOT, COMMAND_ROOT, COMMAND_PORT, COMMAND_FIELDS,
CLAUDE_WATCHER, COMMAND_WATCHER, CI, …); and the update-config / memory
surfaces are themselves operator-driven and confirmation-gated. There is no path
by which a watcher run mutates global config while only the audit record is
skipped — the mutation requires a write-capable surface a watcher does not drive.
Guards baked into the helpers:
- This skill has no Edit/Write tools — the mechanical apply boundary.
apply_record.pyfails closed unless an operator is attending — the explicit--confirm-operatortoken is the sole positive attendance proof (an interactive TTY is NOT trusted;isatty()is unreliable on Windows), and it also aborts under known watcher/unattended env markers (COMMAND_PARALLEL_SLOT,COMMAND_ROOT,COMMAND_PORT,COMMAND_FIELDS,CLAUDE_WATCHER,COMMAND_WATCHER,CI, …).corpus_retrieve.py,claimpack.py, andscripts/materialize.pyroute every output path through the shared_guards.assert_safe_out(refuses~/.claudeexcept the skill.local-state/, refuses git trees, exit 7);ledger_store.pylikewise refuses any output path inside~/.claudeconfig or a git working tree — bookkeeping, helper outputs, AND the agent-materialized corpus files can never be mistaken for config or be committed.- The skill never sets
proposedUpgrade.applied = trueitself; that flips only viaapply_record.pyacting on the operator's confirmation (chat-arch's "set true ONLY by the user" rule).
Loop guardrails (non-circularity)
The loop is only meaningful if the validator is independent of the proposer:
- Two halves, hard wall. The mining/assembly half (this skill) produces
proposals; it never scores or defends them in the loop. The falsification
half is review-loop's claim-lens subagents reading the primary corpus
(transcripts + live
.claude/files), not this skill's summary. The evidence-pack ships excerpts/pointers precisely to force ground-truth reads. - No self-grading. Do not let the assembling agent also play falsifier in the same pass.
- Fail-closed verdict. Absent evidence is
unvalidated, neversurvived. A proposal no lens examined is never shippable. The verdict is built from agent-captured per-finding JSON (joined bysubClaimId), not from review-loop's state file (which carries only per-iteration summaries). - Deflation-only refinement. Survivors may only be made weaker between iterations. Strengthening to dodge a falsifier is forbidden.
- Verbatim reuse, no fork. Invoke
/review-loop --mode claim; never copy its agent files here. - Failed falsification is a result — but it must be cited. "Tried to break
this and could not" is the strongest outcome — but only when a
failed-falsificationfinding with the sub-claim'ssubClaimIdwas actually returned. An empty finding set is NOT promotion; it isunvalidated. - Reconcile gap-claims against LIVE config. The
gapsub-claim is grepped against each cited repo's actual.claude/surface at loop time — never a stale inventory.
Guardrails (summary)
- Cross-project ONLY. A 1-project cluster — however many times it repeats — is
out of scope; report it and name its home (
transcript-analysisfor a per-project CLAUDE.md candidate,/signal-scanfor a per-repo CLI signal — the latter only when a Command checkout is present). This skill writes no per-repo config and proposes nothing global from a single project. - Global ~/.claude is the only proposal target; it is never written by this skill (no write tools).
- Path-attribution for the count, never keyword. The promotion bar counts only distinct path-attributed projects; keyword over-attribution is excluded in code so it cannot manufacture false global candidates.
- Reconcile before propose. Drop already-encoded; disclose the lexical limit.
- Grounded + provenance-tagged. Every friction cites a real turn; measurement and interpretation are labeled separately (Claims & Evidence Rigor).
- Validate before ship, fail-closed. Only
survived(examined & held) claim-loop proposals are eligible to be applied;unvalidated(never examined),exhausted, andstalledsurvivors are clearly labeled and not apply-eligible. - Private corpus stays local. Verbatim cross-project turns are written only
under the skill's untracked
.local-state/, never a tracked/remoted repo. - Honest sample size. Smoothed confidence; a 2-project proposal is "emerging," not "always."
End-of-run report
Close with (operator persona — terse, plain-language first):
- One line: "Scanned N files → M genuine sessions across K path-attributed
projects (both corpora). P global candidates promoted (>=N distinct
path-attributed projects); Q reconciled out as already-encoded. Validation
reached
<terminal-state>: K survived (examined & held), J refined, L dropped, U unvalidated." - The ranked survivor table (headline, distinct projects named, target + exact patch, confidence).
- The single highest-value item first if any
recurringPostApplicationfired ("you applied X but it's still recurring — escalate to a hook"). - The 1-project leftovers and where they belong (transcript-analysis / signal-scan).
- The lexical-limit caveat (clustering + reconcile are lexical; reworded duplicates can slip through) and the finding attribution match rate.
- Then a few ranked next actions (apply #1 via the right surface? raise --min-projects? hand the 1-project clusters off where a checkout exists?).