claude-dev-env 2.25.0 → 2.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.agents/agents/clean-coder.md +95 -33
  2. package/.agents/agents/code-quality-agent.md +85 -24
  3. package/.agents/agents/pr-description-writer.md +2 -2
  4. package/.agents/agents/test_agent_frontmatter.py +626 -0
  5. package/.agents/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +47 -1
  6. package/.agents/skills/pr-cleanup/SKILL.md +1 -0
  7. package/.agents/skills/pr-plain-language-cleanup/SKILL.md +93 -0
  8. package/.agents/skills/pr-title-description/SKILL.md +92 -0
  9. package/.agents/skills/source-command-sr-loop/SKILL.md +12 -3
  10. package/bin/ever-shipped-skills.mjs +2 -0
  11. package/bin/install.agents-home.test.mjs +199 -8
  12. package/bin/install.mjs +15 -8
  13. package/bin/install.test.mjs +82 -2
  14. package/commands/sr-loop.md +10 -1
  15. package/docs/codex-compatibility.md +19 -0
  16. package/hooks/blocking/luna_fast_mode_gate.py +160 -0
  17. package/hooks/blocking/test_luna_fast_mode_gate.py +211 -0
  18. package/hooks/hooks.json +10 -5
  19. package/hooks/hooks_constants/AGENTS.md +1 -1
  20. package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +45 -0
  21. package/hooks/hooks_constants/mypy_integration_constants.py +14 -3
  22. package/hooks/hooks_constants/session_start_injector.py +11 -4
  23. package/hooks/hooks_constants/test_session_start_injector.py +4 -2
  24. package/hooks/session/AGENTS.md +2 -2
  25. package/hooks/session/issue_tracker_session_starter.py +3 -2
  26. package/hooks/session/orchestrator_auto_starter.py +3 -2
  27. package/hooks/session/task_list_loop_starter.py +10 -1
  28. package/hooks/session/test_issue_tracker_session_starter.py +3 -1
  29. package/hooks/session/test_orchestrator_auto_starter.py +5 -3
  30. package/hooks/session/test_task_list_loop_starter.py +10 -8
  31. package/hooks/session/test_untracked_repo_detector.py +7 -5
  32. package/hooks/session/test_working_style_prompt.py +5 -3
  33. package/hooks/session/untracked_repo_detector.py +10 -1
  34. package/hooks/session/working_style_prompt.py +10 -1
  35. package/hooks/validators/AGENTS.md +2 -1
  36. package/hooks/validators/conftest.py +21 -4
  37. package/hooks/validators/mypy_integration.py +77 -16
  38. package/hooks/validators/run_all_validators.py +2 -35
  39. package/hooks/validators/system_temporary_roots.py +90 -0
  40. package/hooks/validators/test_directory_exemption_constants.py +1 -1
  41. package/hooks/validators/test_mypy_integration.py +169 -0
  42. package/hooks/validators/test_system_temporary_roots.py +93 -0
  43. package/package.json +1 -1
  44. package/scripts/codex_compat_materializer.py +4 -2
  45. package/scripts/tests/test_codex_compat_materializer.py +2 -2
@@ -386,6 +386,28 @@ def test_main_headless_flavor_emits_outcome_path_on_stdout(
386
386
  assert "add_comment_to_pending_review" not in captured.out
387
387
 
388
388
 
389
+ def test_code_quality_agent_requires_scoped_intake_and_evidence_gaps() -> None:
390
+ agent_text = _CODE_QUALITY_AGENT_PATH.read_text(encoding="utf-8")
391
+
392
+ required_phrases = (
393
+ "read each existing `AGENTS.md` and `CLAUDE.md`",
394
+ "scoped `AGENTS.md` files as the canonical",
395
+ "`docs/CODE_RULES.md` is a compact projection",
396
+ "`hooks/blocking/code_rules_enforcer.py` is hand-maintained write-time coverage",
397
+ "full diff",
398
+ "resolved base or merge-base",
399
+ "complete changed-file list",
400
+ "PR description",
401
+ "evidence gap",
402
+ "Never infer omitted content",
403
+ )
404
+ for each_phrase in required_phrases:
405
+ assert each_phrase in agent_text
406
+
407
+ assert "## Review intake and policy sources" in agent_text
408
+ assert "Open questions" in agent_text
409
+
410
+
389
411
  _PACKAGE_ROOT = _PACKAGE_ROOT_FOR_RUBRICS
390
412
  _CODE_QUALITY_AGENT_PATH = _PACKAGE_ROOT / ".agents" / "agents" / "code-quality-agent.md"
391
413
  _AUDIT_CONTRACT_PATH = _PACKAGE_ROOT / "_shared" / "pr-loop" / "audit-contract.md"
@@ -395,7 +417,11 @@ _CATEGORY_Q_LABEL = (
395
417
  "(terminology, PR-description claims, message-vs-guard)"
396
418
  )
397
419
  _CATEGORY_Q_RUBRIC_REFERENCE = (
398
- "../audit-rubrics/category_rubrics/category-q-cross-surface-claims.md"
420
+ "`<managed-root>/audit-rubrics/category_rubrics/"
421
+ "category-q-cross-surface-claims.md`"
422
+ " (source fallback: "
423
+ "`packages/claude-dev-env/audit-rubrics/category_rubrics/"
424
+ "category-q-cross-surface-claims.md`)"
399
425
  )
400
426
  _A_THROUGH_Q_PATTERN = re.compile(r"A[\u2013-]Q")
401
427
  _SEVENTEEN_CATEGORIES_PATTERN = re.compile(r"seventeen categor", re.IGNORECASE)
@@ -417,6 +443,26 @@ def test_code_quality_agent_default_scope_is_a_through_q() -> None:
417
443
  assert "| Q |" in agent_text
418
444
 
419
445
 
446
+ def test_code_quality_agent_contract_is_caller_neutral() -> None:
447
+ agent_text = _CODE_QUALITY_AGENT_PATH.read_text(encoding="utf-8")
448
+ stale_terms = (
449
+ "PR #394",
450
+ "PR #397",
451
+ "May 2026",
452
+ "r3210166636",
453
+ "/bugteam",
454
+ "/pr-converge",
455
+ "/autoconverge",
456
+ "opus",
457
+ "sonnet",
458
+ "model:",
459
+ )
460
+ for stale_term in stale_terms:
461
+ assert stale_term not in agent_text
462
+ assert "The caller provides the diff, audit scope, ID prefix, and output format." in agent_text
463
+ assert "Do not assume a model, caller name, or persistence path." in agent_text
464
+
465
+
420
466
  def test_audit_contract_category_schema_includes_q() -> None:
421
467
  contract_text = _AUDIT_CONTRACT_PATH.read_text(encoding="utf-8")
422
468
  assert '"category": "A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q"' in (
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  name: pr-cleanup
3
+ disable-model-invocation: true
3
4
  description: >-
4
5
  Refine pull requests through parallel placement and capability-name audits,
5
6
  focused delivery sizing, and a final simplify and code-review loop. Triggers:
@@ -0,0 +1,93 @@
1
+ ---
2
+ name: pr-plain-language-cleanup
3
+ disable-model-invocation: true
4
+ description: Review every line of every changed PR file and trim words, names, docs, comments, and code while keeping real behavior.
5
+ ---
6
+
7
+ # PR plain-language cleanup
8
+
9
+ ## Balance compression and readability
10
+
11
+ Shorten changed prose and make it easier to scan. Both matter.
12
+
13
+ - **Word count:** record before and after counts and the delta. Count changed
14
+ prose only. Exclude code, frontmatter, and unchanged lines.
15
+ - **Readability:** check sentence length, order, headings, jargon, and dense
16
+ blocks. Edit only when the result is easier to read.
17
+ - **Diff churn:** count changed lines. Remove whitespace-only movement and
18
+ reflow-only churn.
19
+ - **Meaning:** preserve behavior, APIs, data, test intent, conditions, warnings,
20
+ error detail, context, and public names.
21
+
22
+ Reflow is allowed only when it materially improves scanability after a real trim.
23
+ Report the four results separately.
24
+
25
+ Review and trim this pull request:
26
+
27
+ <PR LINK>
28
+
29
+ If no link is given, find the clear PR in the current context. If none is clear, ask for one.
30
+
31
+ Read the full PR, full diff, every changed file, changed tests and docs, and all repo rules that apply.
32
+
33
+ Find the changed files. Send one capable agent to each file. If there are more files than agents, use small batches.
34
+
35
+ Each agent owns one file. Read every line. Review code, tests, names, comments, docstrings, strings, notes, Markdown, and examples.
36
+
37
+ Trim filler, repeats, vague words, needless code, needless names, dead notes, jargon, and steps that add no value. Use few words and small words.
38
+
39
+ Keep real behavior, checks, data, error detail, context, tests, and public names unless a safe full rename is clear. Do not change behavior for style. Do not shorten a name when it loses meaning.
40
+
41
+ Follow the repo rules:
42
+
43
+ - Name modules for their capability.
44
+ - Name functions for their action.
45
+ - Name variables for the value they hold.
46
+ - Name classes for what they are.
47
+ - Use capability names for shared code.
48
+ - Keep workflow words on workflow code.
49
+ - Keep comments and docs true to the code.
50
+ - Use real data and real behavior in tests.
51
+
52
+ ## Hard limits
53
+
54
+ This is a non-material, text-only trim.
55
+
56
+ DO NOT:
57
+
58
+ - run `e-simplify`, `e-code-review`, or `source-command-sr-loop`;
59
+ - run `pr-cleanup`, `pr-refinement`, `pr-small-cl`, placement audits, extraction audits, or capability-name audits;
60
+ - run review loops, advisor gates, commit gates, promotion gates, or any other extra cleanup;
61
+ - hunt bugs, change behavior, change APIs, change data, add features, or split the PR;
62
+ - turn a text trim into a code fix.
63
+
64
+ Run only the smallest checks needed for the files changed. Keep the repo rules. If a material issue appears, report it and stop.
65
+
66
+ If a change needs another file, report it to the main agent. The main agent owns cross-file changes.
67
+
68
+ After the file agents finish, apply safe edits, fix cross-file names and links, search for old names and wording, and read every changed file and the full diff again.
69
+
70
+ Run only the smallest relevant check for the files changed. Fix failures. Check the final diff against the repo rules.
71
+
72
+ Do not invent behavior, tests, or benefits. Stop and ask only when a change could alter behavior, break a public API, or exceed the PR scope.
73
+
74
+ Commit and push the changes to the PR branch.
75
+
76
+ Return:
77
+
78
+ ## What changed
79
+
80
+ Short list of the main trims.
81
+
82
+ ## Files checked
83
+
84
+ Every file reviewed.
85
+
86
+ ## Verification
87
+
88
+ Checks run and results.
89
+
90
+ ## Remaining items
91
+
92
+ Only items that need a user choice.
93
+
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: pr-title-description
3
+ description: Review one or more pull requests and write plain titles and descriptions from the full diff.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ Accept one or more pull request links as arguments. Apply the prompt below to each link.
8
+
9
+ With no argument, use the current task, browser, worktree, or GitHub context to find the pull request or pull requests. If the context does not show a clear pull request, ask for a pull request link.
10
+
11
+ Review this pull request: <PR link>
12
+
13
+ Read the PR and its full diff. Do not use the branch name, commit message, labels, current title, or a shallow summary.
14
+
15
+ Find the main thing this adds for a user, caller, or operator.
16
+
17
+ Write like the reader is smart but knows nothing about this code. Pretend they asked you to explain it like they are five and feel stupid. If a word needs another sentence to explain it, replace the word — or turn it into a tiny story.
18
+
19
+ Your job is not a dry API blurb. Your job is an illustrative before/after a person can picture.
20
+
21
+ Gold voice for titles and “What this adds”:
22
+
23
+ The theme package has a phone icon somewhere. We need to find it.
24
+
25
+ Before: yell “anyone know how to find icons?” and wait for a hand.
26
+
27
+ After: call the finder by its name tag: “icon finder, go.”
28
+
29
+ That’s all “operation id” is — the finder’s name tag.
30
+
31
+ Rules for that voice:
32
+
33
+ Start from a concrete scene (what’s in someone’s hand, what they’re hunting for).
34
+ Prefer Before / After when the change is “how you ask” or “how you look it up.”
35
+ Use picture words: hand it, open, find, check, save, skip, stop, call by name, name tag.
36
+ Prefer spoken results: “found it,” “missing,” “duplicated,” “couldn’t.”
37
+ When the diff uses an abstract word (operation id, registry, envelope, adapter, digest, schema), do one of two things:
38
+ Drop it and say what the person does with their hands, or
39
+ Keep it once, then gloss it in the same breath with a name-tag / mailbox / checklist metaphor — like the “finder’s name tag” line above.
40
+ Never leave jargon unexplained. Never write as if the reader already knows the jargon.
41
+ Add a tiny concrete example in parentheses when a name is abstract (like “the phone icon”).
42
+ Few words. Small words. Clear on first read.
43
+ Return:
44
+
45
+ Recommended title
46
+
47
+ Few words. Small words. Clear on first read.
48
+ Say what you hand it, what it does, and what you get back — or the Before/After of how you ask.
49
+ Prefer picture words and spoken results.
50
+ Good title: “Call the icon finder by its name tag instead of asking around”
51
+ Good title: “Given a theme STP and an asset name, report whether that asset was found, missing, or duplicated”
52
+ Bad title: anything that only says operation id / registry / typed envelope / adapter / digest / schema with no picture.
53
+ Use “Add” only if it helps. Do not force it.
54
+ No vague words like “improve,” “enhance,” or “update.”
55
+ Return one title only.
56
+ Why this title fits
57
+
58
+ 1–2 short sentences in the same voice.
59
+ If needed, one Before / After beat, then what you get back.
60
+ If this section is needed to decode the title, rewrite the title.
61
+ Evidence
62
+
63
+ Key files or functions that support the title.
64
+ If you cannot read the PR or full diff, say so and do not propose a title.
65
+
66
+ Then write a short, paste-ready PR description.
67
+
68
+ Use this structure:
69
+
70
+ What this adds
71
+ 1–2 short paragraphs in that illustrative voice. Lead with a scene when you can (“The theme package has a phone icon somewhere…”). If the change is a new way to ask, use Before / After. Say what you give it, what it does with its hands, and what you get back. If an abstract machine-word must appear, gloss it immediately (“That’s all X is — …”). Describe the job people can see. Do not describe inner machinery. Mention a check only if it is central to the feature. If there are many changes, keep the main one. Mention others only if they explain the main one.
72
+
73
+ Why
74
+ One short paragraph. Same plain level. Say who needs this and what goes wrong without it (the “yell and wait for a hand” problem). If this is needed to decode the feature, rewrite “What this adds.”
75
+
76
+ Verification
77
+ List only proof from the PR, CI, or your own checks.
78
+
79
+ Reported in the PR
80
+
81
+
82
+ Checked by you / CI here
83
+
84
+
85
+ Rules:
86
+
87
+ Few words. Small words. Clear on first read. ELI5 I-am-stupid mark.
88
+ Do not invent behavior, tests, or benefits.
89
+ Strip branch names, hashes, draft notes, agent notes, and merge notes.
90
+ No vague words like “improve,” “enhance,” or “update.”
91
+ Illustrative first. Abstract labels only if glossed in the same breath.
92
+ Use the recommended title and paste-ready description to run `gh pr edit <PR link> --title ... --body-file ...`, then run `gh pr view <PR link> --json title,body` to confirm the saved title and body. Report the saved result.
@@ -13,6 +13,14 @@ Run the converging cleanup loop on the target the user names (a PR URL,
13
13
  a branch, or blank for the current branch's diff). Each phase invokes an
14
14
  existing skill and repeats it until a pass returns zero new findings.
15
15
 
16
+ ### Nit-only terminal
17
+
18
+ When a pass finds only nits, apply them, run scoped checks, commit, and push.
19
+ Treat that phase as clean. Do not run another simplify or review pass without
20
+ the user's request. If later work creates an uncommitted out-of-scope diff,
21
+ stop and report it. Do not inspect, repair, commit, or adopt that diff without
22
+ new user instruction.
23
+
16
24
  ### Effort
17
25
 
18
26
  Set every model call to `max` effort. If that model has no `max` effort, use
@@ -34,7 +42,8 @@ stack's design, intent, and changes.
34
42
  code, not the full repo suite), commit once (commit body via
35
43
  `git commit -F <file>`, 10-minute timeout — the pre-commit gate runs its
36
44
  own tests), and push to the PR head branch. The PR stays draft.
37
- 3. Repeat the invocation. Skips are sticky: carry every adjudicated skip
45
+ 3. Repeat the invocation only after substantive findings or a user request.
46
+ Skips are sticky: carry every adjudicated skip
38
47
  forward into the next pass as "already adjudicated — do not re-report: ..."
39
48
  context, or the loop never converges.
40
49
  4. Phase A converges when a full pass returns zero new findings. When the
@@ -51,8 +60,8 @@ stack's design, intent, and changes.
51
60
  when the headless run fails: invoke the `e-code-review` skill with
52
61
  `low --fix` — same review-and-fix contract, no invocation restriction.
53
62
  2. After any pass that changed files: test, commit, push as in Phase A.
54
- 3. Repeat until a pass reports zero findings. Phase B usually converges in
55
- one pass when Phase A ran first.
63
+ 3. Repeat until a pass reports zero findings. A nit-only pass ends this phase
64
+ after its fixes are pushed.
56
65
 
57
66
  ### Finish
58
67
 
@@ -69,6 +69,8 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
69
69
  'midjourney-submit',
70
70
  'monitor-open-prs',
71
71
  'pr-name-by-capability',
72
+ 'pr-title-description',
73
+ 'pr-plain-language-cleanup',
72
74
  'pr-refinement',
73
75
  'orchestrator',
74
76
  'orchestrator-refresh',
@@ -25,6 +25,7 @@ import {
25
25
  MANAGED_SKILLS_DIRECTORY_NAME,
26
26
  } from './install-constants.mjs';
27
27
  import { isDirectoryPointerTo } from './publish-directory-pointer.mjs';
28
+ import { resolvePackageManagedDirectory } from './resolve-package-managed-directory.mjs';
28
29
 
29
30
  const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
30
31
  const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
@@ -32,6 +33,41 @@ const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
32
33
  const SHIPPED_SKILL_NAME = 'privacy-hygiene';
33
34
  const ELI5_SKILL_NAME = 'eli5';
34
35
  const SHIPPED_AGENT_FILE_NAME = 'clean-coder.md';
36
+ const SHIPPED_AGENT_FILE_NAMES = [
37
+ SHIPPED_AGENT_FILE_NAME,
38
+ 'code-quality-agent.md',
39
+ 'pr-description-writer.md',
40
+ ];
41
+ const CLEAN_CODER_POLICY_REFERENCES = [
42
+ [
43
+ '<managed-root>/docs/CODE_RULES.md',
44
+ 'packages/claude-dev-env/docs/CODE_RULES.md',
45
+ ],
46
+ [
47
+ '<managed-root>/hooks/blocking/code_rules_enforcer.py',
48
+ 'packages/claude-dev-env/hooks/blocking/code_rules_enforcer.py',
49
+ ],
50
+ [
51
+ '<managed-root>/rules/code-standards.md',
52
+ 'packages/claude-dev-env/rules/code-standards.md',
53
+ ],
54
+ [
55
+ '<managed-root>/rules/file-global-constants.md',
56
+ 'packages/claude-dev-env/rules/file-global-constants.md',
57
+ ],
58
+ [
59
+ '<managed-root>/rules/windows-filesystem-safe.md',
60
+ 'packages/claude-dev-env/rules/windows-filesystem-safe.md',
61
+ ],
62
+ [
63
+ '<managed-root>/rules/gh-cli-conventions.md',
64
+ 'packages/claude-dev-env/rules/gh-cli-conventions.md',
65
+ ],
66
+ [
67
+ '<managed-root>/rules/plain-illustrative-docstrings.md',
68
+ 'packages/claude-dev-env/rules/plain-illustrative-docstrings.md',
69
+ ],
70
+ ];
35
71
  const PERSONAL_SKILL_NAME = 'my-notes';
36
72
  const SHARED_DIRECTORY_NAME = '_shared';
37
73
  const PR_LOOP_DIRECTORY_NAME = 'pr-loop';
@@ -41,19 +77,25 @@ const PREFLIGHT_PROPOSAL_POINTER = '@~/.claude/_shared/pr-loop/preflight-proposa
41
77
  /**
42
78
  * @param {string} homeDirectory
43
79
  * @param {string[]} extraArguments
80
+ * @param {Record<string, string | undefined>} [environmentOverrides]
44
81
  * @returns {string}
45
82
  */
46
- function runInstaller(homeDirectory, extraArguments) {
83
+ function runInstaller(homeDirectory, extraArguments, environmentOverrides = {}) {
84
+ const installerEnvironment = {
85
+ ...process.env,
86
+ HOME: homeDirectory,
87
+ USERPROFILE: homeDirectory,
88
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
89
+ CODEX_HOME: join(homeDirectory, '.codex'),
90
+ ...environmentOverrides,
91
+ };
92
+ for (const [eachName, eachValue] of Object.entries(environmentOverrides)) {
93
+ if (eachValue === undefined) delete installerEnvironment[eachName];
94
+ }
47
95
  return execFileSync('node', [INSTALLER_PATH, ...extraArguments], {
48
96
  cwd: PACKAGE_DIRECTORY,
49
97
  encoding: 'utf8',
50
- env: {
51
- ...process.env,
52
- HOME: homeDirectory,
53
- USERPROFILE: homeDirectory,
54
- GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
55
- CODEX_HOME: join(homeDirectory, '.codex'),
56
- },
98
+ env: installerEnvironment,
57
99
  });
58
100
  }
59
101
 
@@ -75,6 +117,33 @@ function assertProposalContractInstallation(installationPaths) {
75
117
  assert.equal(readFileSync(installedContractPath, 'utf8'), readFileSync(sourceContractPath, 'utf8'));
76
118
  }
77
119
 
120
+ /**
121
+ * @param {string} agentFilePath
122
+ * @param {string} layoutName
123
+ * @param {string} managedRoot
124
+ * @returns {string[]}
125
+ */
126
+ function cleanCoderPolicyReferenceProblems(agentFilePath, layoutName, managedRoot) {
127
+ const agentBody = readFileSync(agentFilePath, 'utf8');
128
+ const missingReferences = [];
129
+ for (const [installedReference, sourceReference] of CLEAN_CODER_POLICY_REFERENCES) {
130
+ const eachReference = layoutName === 'source'
131
+ ? sourceReference
132
+ : installedReference;
133
+ const eachTargetPath = sourceReference.replace('packages/claude-dev-env/', '');
134
+ if (!agentBody.includes(eachReference)) {
135
+ missingReferences.push(eachReference + ' is absent');
136
+ }
137
+ const resolvedPath = layoutName === 'source'
138
+ ? join(PACKAGE_DIRECTORY, eachTargetPath)
139
+ : join(managedRoot, eachTargetPath);
140
+ if (!existsSync(resolvedPath)) {
141
+ missingReferences.push(layoutName + ': ' + resolvedPath);
142
+ }
143
+ }
144
+ return missingReferences.map((reference) => layoutName + ': ' + reference);
145
+ }
146
+
78
147
  test('CONTENT_DIRECTORIES omits agents because that tree installs to the agents home', () => {
79
148
  assert.equal(CONTENT_DIRECTORIES.includes(MANAGED_AGENTS_DIRECTORY_NAME), false);
80
149
  });
@@ -133,6 +202,18 @@ test('a full install writes skills and agents under .agents and points .claude a
133
202
  readFileSync(lookupAgentFile, 'utf8'),
134
203
  readFileSync(canonicalAgentFile, 'utf8'),
135
204
  );
205
+ const sourceAgentFile = join(
206
+ resolvePackageManagedDirectory(
207
+ PACKAGE_DIRECTORY,
208
+ MANAGED_AGENTS_DIRECTORY_NAME,
209
+ ),
210
+ SHIPPED_AGENT_FILE_NAME,
211
+ );
212
+ const brokenPolicyReferences = [
213
+ ...cleanCoderPolicyReferenceProblems(sourceAgentFile, 'source', claudeHome),
214
+ ...cleanCoderPolicyReferenceProblems(canonicalAgentFile, 'installed', claudeHome),
215
+ ];
216
+ assert.deepEqual(brokenPolicyReferences, [], 'Clean Coder has broken policy references');
136
217
  assert.equal(realpathSync(lookupAgentFile), realpathSync(canonicalAgentFile));
137
218
  assert.equal(
138
219
  lstatSync(skillsInstallDirectory).isSymbolicLink(),
@@ -149,6 +230,116 @@ test('a full install writes skills and agents under .agents and points .claude a
149
230
  }
150
231
  });
151
232
 
233
+ test('real installs place the Clean Coder in each active agents home', () => {
234
+ const runRoot = mkdtempSync(join(tmpdir(), 'cdev-active-roots-'));
235
+ const homeDirectory = join(runRoot, 'home');
236
+ const configRoot = join(runRoot, 'config-profile');
237
+ const explicitRoot = join(runRoot, 'explicit-target');
238
+ const inheritedProfilesRoot = join(runRoot, 'inherited-profiles');
239
+ const inheritedProfileMarker = join(inheritedProfilesRoot, 'untouched.txt');
240
+ mkdirSync(homeDirectory, { recursive: true });
241
+ mkdirSync(inheritedProfilesRoot, { recursive: true });
242
+ writeFileSync(inheritedProfileMarker, 'leave this profile root alone\n');
243
+ try {
244
+ const installCases = [
245
+ {
246
+ name: 'default',
247
+ arguments: ['--only', 'core'],
248
+ environment: { CLAUDE_CONFIG_DIR: undefined },
249
+ managedRoot: join(homeDirectory, '.claude'),
250
+ agentsHome: join(homeDirectory, '.agents'),
251
+ },
252
+ {
253
+ name: 'CLAUDE_CONFIG_DIR',
254
+ arguments: ['--only', 'core'],
255
+ environment: { CLAUDE_CONFIG_DIR: configRoot },
256
+ managedRoot: configRoot,
257
+ agentsHome: `${configRoot}.agents`,
258
+ },
259
+ {
260
+ name: 'named profile',
261
+ arguments: ['--profile', 'editor', '--only', 'core'],
262
+ environment: {
263
+ CLAUDE_CONFIG_DIR: undefined,
264
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
265
+ },
266
+ managedRoot: join(homeDirectory, '.claude-profiles', 'editor'),
267
+ agentsHome: join(homeDirectory, '.claude-profiles', 'editor.agents'),
268
+ },
269
+ {
270
+ name: 'explicit target',
271
+ arguments: ['--target', explicitRoot, '--only', 'core'],
272
+ environment: { CLAUDE_CONFIG_DIR: configRoot },
273
+ managedRoot: explicitRoot,
274
+ agentsHome: `${explicitRoot}.agents`,
275
+ },
276
+ ];
277
+
278
+ for (const eachInstallCase of installCases) {
279
+ runInstaller(
280
+ homeDirectory,
281
+ eachInstallCase.arguments,
282
+ {
283
+ LLM_SETTINGS_PROFILES_ROOT: inheritedProfilesRoot,
284
+ ...eachInstallCase.environment,
285
+ },
286
+ );
287
+ for (const eachAgentFileName of SHIPPED_AGENT_FILE_NAMES) {
288
+ const installedAgentPath = join(
289
+ eachInstallCase.agentsHome,
290
+ MANAGED_AGENTS_DIRECTORY_NAME,
291
+ eachAgentFileName,
292
+ );
293
+ assert.equal(
294
+ existsSync(installedAgentPath),
295
+ true,
296
+ `${eachInstallCase.name}: agent is under the active agents home`,
297
+ );
298
+ const installedAgentText = readFileSync(installedAgentPath, 'utf8');
299
+ assert.match(installedAgentText, /active managed root/i);
300
+ assert.match(installedAgentText, /active agents home/i);
301
+ assert.match(installedAgentText, /<managed-root>\//);
302
+ assert.match(installedAgentText, /<agents-home>\//);
303
+ }
304
+ const installedCleanCoderPath = join(
305
+ eachInstallCase.agentsHome,
306
+ MANAGED_AGENTS_DIRECTORY_NAME,
307
+ SHIPPED_AGENT_FILE_NAME,
308
+ );
309
+ const policyReferenceProblems = cleanCoderPolicyReferenceProblems(
310
+ installedCleanCoderPath,
311
+ 'installed',
312
+ eachInstallCase.managedRoot,
313
+ );
314
+ assert.deepEqual(
315
+ policyReferenceProblems,
316
+ [],
317
+ `${eachInstallCase.name}: Clean Coder has broken policy references`,
318
+ );
319
+ assert.equal(
320
+ isDirectoryPointerTo(
321
+ join(eachInstallCase.managedRoot, MANAGED_AGENTS_DIRECTORY_NAME),
322
+ join(eachInstallCase.agentsHome, MANAGED_AGENTS_DIRECTORY_NAME),
323
+ ),
324
+ true,
325
+ `${eachInstallCase.name}: lookup path points to the active agents home`,
326
+ );
327
+ }
328
+ assert.equal(
329
+ readFileSync(inheritedProfileMarker, 'utf8'),
330
+ 'leave this profile root alone\n',
331
+ 'an inherited profile root stays untouched',
332
+ );
333
+ assert.equal(
334
+ existsSync(join(inheritedProfilesRoot, 'editor')),
335
+ false,
336
+ 'the named profile does not use the inherited profile root',
337
+ );
338
+ } finally {
339
+ rmSync(runRoot, { recursive: true, force: true });
340
+ }
341
+ });
342
+
152
343
  test('a real skills directory from an older install lands in .agents and stays readable through the pointer', () => {
153
344
  const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-agents-relocate-'));
154
345
  const claudeHome = join(homeDirectory, '.claude');
package/bin/install.mjs CHANGED
@@ -1126,13 +1126,13 @@ function backupHubBeforeOverwrite(destPath, incomingPath, backupName) {
1126
1126
  }
1127
1127
 
1128
1128
  /**
1129
- * PreToolUse hook script paths the installer manages even though hooks.json
1129
+ * Hook script paths the installer manages even though hooks.json
1130
1130
  * carries no standalone entry for them. Most were folded into the PreToolUse
1131
- * dispatcher in Stage 1; md_to_html_blocker is a retired hook with no script on
1132
- * disk. Each path stays in this set so a reinstall from an older settings shape
1131
+ * dispatcher in Stage 1; retired hooks have no current registration.
1132
+ * Each path stays in this set so a reinstall from an older settings shape
1133
1133
  * prunes its standalone entry — a folded hook would otherwise double-run
1134
- * alongside the dispatcher, and the retired hook's entry would point at a
1135
- * missing script.
1134
+ * alongside the dispatcher, and a retired hook's entry would continue to run
1135
+ * from the user's settings.json.
1136
1136
  */
1137
1137
  export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
1138
1138
  'blocking/write_existing_file_blocker.py',
@@ -1150,6 +1150,7 @@ export const FOLDED_HOOK_RELATIVE_PATHS = new Set([
1150
1150
  'blocking/pytest_testpaths_orphan_blocker.py',
1151
1151
  'blocking/open_questions_in_plans_blocker.py',
1152
1152
  'blocking/md_to_html_blocker.py',
1153
+ 'session/untracked_repo_detector.py',
1153
1154
  ]);
1154
1155
 
1155
1156
  /**
@@ -1168,14 +1169,19 @@ export const POST_FOLDED_HOOK_RELATIVE_PATHS = new Set([
1168
1169
  'workflow/md_to_html_companion.py',
1169
1170
  ]);
1170
1171
 
1172
+ /** Hook scripts that still ship but no longer run as registered hooks. */
1173
+ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
1174
+ 'session/untracked_repo_detector.py',
1175
+ ]);
1176
+
1171
1177
  /**
1172
1178
  * Builds the set of hook script paths this installer manages, each relative to
1173
1179
  * the hooks directory (e.g. 'blocking/code_rules_enforcer.py'), parsed from the
1174
1180
  * `${CLAUDE_PLUGIN_ROOT}/hooks/<path>` references in hooks.json. Inline
1175
1181
  * `python3 -c` commands reference the hooks directory without a script tail and
1176
1182
  * contribute nothing. Also includes every path from FOLDED_HOOK_RELATIVE_PATHS
1177
- * and POST_FOLDED_HOOK_RELATIVE_PATHS so a reinstall from an older settings shape
1178
- * prunes both the PreToolUse and the PostToolUse folded entries.
1183
+ * and the retained path sets so a reinstall from an older settings shape prunes
1184
+ * folded and retired entries.
1179
1185
  *
1180
1186
  * @param {{hooks: object}} hooksConfig Parsed hooks.json.
1181
1187
  * @returns {Set<string>} Forward-slash relative script paths under hooks/.
@@ -1184,6 +1190,7 @@ export function managedHookScriptRelativePaths(hooksConfig) {
1184
1190
  const relativePaths = new Set([
1185
1191
  ...FOLDED_HOOK_RELATIVE_PATHS,
1186
1192
  ...POST_FOLDED_HOOK_RELATIVE_PATHS,
1193
+ ...RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS,
1187
1194
  ]);
1188
1195
  const scriptReferencePattern = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.py)/g;
1189
1196
  for (const matcherGroups of Object.values(hooksConfig.hooks)) {
@@ -1408,7 +1415,7 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
1408
1415
  /\$\{CLAUDE_PLUGIN_ROOT\}/g,
1409
1416
  () => pluginRootForward,
1410
1417
  );
1411
- command = command.replace(/^python3\b/, () => pythonCommand);
1418
+ command = command.replace(/^python3?(?=\s)/, () => pythonCommand);
1412
1419
  return { ...hook, command };
1413
1420
  });
1414
1421
  const existingIndex = settings.hooks[eventType].findIndex(