/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
…(truncated)