claude-dev-env 2.26.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.
- package/.agents/agents/clean-coder.md +95 -33
- package/.agents/agents/code-quality-agent.md +85 -24
- package/.agents/agents/pr-description-writer.md +2 -2
- package/.agents/agents/test_agent_frontmatter.py +626 -0
- package/.agents/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +47 -1
- package/.agents/skills/pr-plain-language-cleanup/SKILL.md +17 -0
- package/.agents/skills/source-command-sr-loop/SKILL.md +12 -3
- package/bin/install.agents-home.test.mjs +199 -8
- package/bin/install.mjs +15 -8
- package/bin/install.test.mjs +82 -2
- package/commands/sr-loop.md +10 -1
- package/docs/codex-compatibility.md +19 -0
- package/hooks/blocking/luna_fast_mode_gate.py +160 -0
- package/hooks/blocking/test_luna_fast_mode_gate.py +211 -0
- package/hooks/hooks.json +10 -5
- package/hooks/hooks_constants/AGENTS.md +1 -1
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +45 -0
- package/hooks/hooks_constants/mypy_integration_constants.py +14 -3
- package/hooks/session/AGENTS.md +2 -2
- package/hooks/validators/AGENTS.md +2 -1
- package/hooks/validators/conftest.py +21 -4
- package/hooks/validators/mypy_integration.py +77 -16
- package/hooks/validators/run_all_validators.py +2 -35
- package/hooks/validators/system_temporary_roots.py +90 -0
- package/hooks/validators/test_directory_exemption_constants.py +1 -1
- package/hooks/validators/test_mypy_integration.py +169 -0
- package/hooks/validators/test_system_temporary_roots.py +93 -0
- package/package.json +1 -1
|
@@ -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
|
-
"
|
|
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 (
|
|
@@ -6,6 +6,22 @@ description: Review every line of every changed PR file and trim words, names, d
|
|
|
6
6
|
|
|
7
7
|
# PR plain-language cleanup
|
|
8
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
|
+
|
|
9
25
|
Review and trim this pull request:
|
|
10
26
|
|
|
11
27
|
<PR LINK>
|
|
@@ -74,3 +90,4 @@ Checks run and results.
|
|
|
74
90
|
## Remaining items
|
|
75
91
|
|
|
76
92
|
Only items that need a user choice.
|
|
93
|
+
|
|
@@ -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
|
|
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.
|
|
55
|
-
|
|
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
|
|
|
@@ -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
|
-
*
|
|
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;
|
|
1132
|
-
*
|
|
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
|
|
1135
|
-
*
|
|
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
|
|
1178
|
-
*
|
|
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
|
|
1418
|
+
command = command.replace(/^python3?(?=\s)/, () => pythonCommand);
|
|
1412
1419
|
return { ...hook, command };
|
|
1413
1420
|
});
|
|
1414
1421
|
const existingIndex = settings.hooks[eventType].findIndex(
|
package/bin/install.test.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
INSTALL_GROUPS,
|
|
25
25
|
FOLDED_HOOK_RELATIVE_PATHS,
|
|
26
26
|
POST_FOLDED_HOOK_RELATIVE_PATHS,
|
|
27
|
+
RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS,
|
|
27
28
|
pythonCandidatesForPlatform,
|
|
28
29
|
isWindowsStorePythonStub,
|
|
29
30
|
interpreterCommandFromPath,
|
|
@@ -718,6 +719,26 @@ test('mergeHooksIntoSettings matches full standalone arguments at boundaries', (
|
|
|
718
719
|
});
|
|
719
720
|
|
|
720
721
|
|
|
722
|
+
test('mergeHooksIntoSettings substitutes an absolute interpreter path for the python prefix', () => {
|
|
723
|
+
const hooksConfig = {
|
|
724
|
+
hooks: {
|
|
725
|
+
PreToolUse: [
|
|
726
|
+
{
|
|
727
|
+
matcher: 'Agent',
|
|
728
|
+
hooks: [{ type: 'command', command: 'python ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/luna_fast_mode_gate.py' }],
|
|
729
|
+
},
|
|
730
|
+
],
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
const settings = {};
|
|
734
|
+
mergeHooksIntoSettings(settings, hooksConfig, 'C:/Users/x/.claude', 'C:/Python313/python.exe');
|
|
735
|
+
assert.equal(
|
|
736
|
+
settings.hooks.PreToolUse[0].hooks[0].command,
|
|
737
|
+
'C:/Python313/python.exe C:/Users/x/.claude/hooks/blocking/luna_fast_mode_gate.py',
|
|
738
|
+
);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
|
|
721
742
|
test('mergeHooksIntoSettings prunes a prior py -3 managed hook when reinstalling with an absolute interpreter path', () => {
|
|
722
743
|
const hooksConfig = {
|
|
723
744
|
hooks: {
|
|
@@ -1193,12 +1214,27 @@ const OLD_FOLDED_HOOKS_SETTINGS = {
|
|
|
1193
1214
|
};
|
|
1194
1215
|
|
|
1195
1216
|
|
|
1196
|
-
test('FOLDED_HOOK_RELATIVE_PATHS contains
|
|
1197
|
-
assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size,
|
|
1217
|
+
test('FOLDED_HOOK_RELATIVE_PATHS contains folded hooks plus retired entries', () => {
|
|
1218
|
+
assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 16);
|
|
1198
1219
|
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/write_existing_file_blocker.py'));
|
|
1199
1220
|
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/code_rules_enforcer.py'));
|
|
1200
1221
|
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/pytest_testpaths_orphan_blocker.py'));
|
|
1201
1222
|
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('blocking/md_to_html_blocker.py'));
|
|
1223
|
+
assert.ok(FOLDED_HOOK_RELATIVE_PATHS.has('session/untracked_repo_detector.py'));
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
test('retired hook registrations stay managed so reinstall removes them', () => {
|
|
1228
|
+
const retiredPath = 'session/untracked_repo_detector.py';
|
|
1229
|
+
const shippedHooks = JSON.parse(
|
|
1230
|
+
readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
|
|
1231
|
+
);
|
|
1232
|
+
const shippedCommands = Object.values(shippedHooks.hooks).flatMap(matcherGroups => (
|
|
1233
|
+
matcherGroups.flatMap(matcherGroup => matcherGroup.hooks.map(hook => hook.command))
|
|
1234
|
+
));
|
|
1235
|
+
assert.deepEqual(RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS, new Set([retiredPath]));
|
|
1236
|
+
assert.ok(managedHookScriptRelativePaths({ hooks: {} }).has(retiredPath));
|
|
1237
|
+
assert.ok(shippedCommands.every(command => !command.includes(retiredPath)));
|
|
1202
1238
|
});
|
|
1203
1239
|
|
|
1204
1240
|
|
|
@@ -1221,6 +1257,7 @@ test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher host
|
|
|
1221
1257
|
];
|
|
1222
1258
|
const retiredHooks = [
|
|
1223
1259
|
'blocking/md_to_html_blocker.py',
|
|
1260
|
+
'session/untracked_repo_detector.py',
|
|
1224
1261
|
];
|
|
1225
1262
|
for (const hostedPath of dispatcherHostedHooks) {
|
|
1226
1263
|
assert.ok(
|
|
@@ -1242,6 +1279,41 @@ test('FOLDED_HOOK_RELATIVE_PATHS lists every hook the PreToolUse dispatcher host
|
|
|
1242
1279
|
});
|
|
1243
1280
|
|
|
1244
1281
|
|
|
1282
|
+
test('mergeHooksIntoSettings removes the retired SessionStart detector and keeps user hooks', () => {
|
|
1283
|
+
const retiredCommand = 'py -3 C:/Users/x/.claude/hooks/session/untracked_repo_detector.py';
|
|
1284
|
+
const userCommand = 'py -3 C:/Users/x/custom/session_gate.py';
|
|
1285
|
+
const settings = {
|
|
1286
|
+
hooks: {
|
|
1287
|
+
SessionStart: [{
|
|
1288
|
+
matcher: '',
|
|
1289
|
+
hooks: [
|
|
1290
|
+
{ type: 'command', command: retiredCommand },
|
|
1291
|
+
{ type: 'command', command: userCommand },
|
|
1292
|
+
],
|
|
1293
|
+
}],
|
|
1294
|
+
},
|
|
1295
|
+
};
|
|
1296
|
+
const currentHooksConfig = {
|
|
1297
|
+
hooks: {
|
|
1298
|
+
SessionStart: [{
|
|
1299
|
+
matcher: '',
|
|
1300
|
+
hooks: [{
|
|
1301
|
+
type: 'command',
|
|
1302
|
+
command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/plugin_data_dir_cleanup.py',
|
|
1303
|
+
}],
|
|
1304
|
+
}],
|
|
1305
|
+
},
|
|
1306
|
+
};
|
|
1307
|
+
|
|
1308
|
+
mergeHooksIntoSettings(settings, currentHooksConfig, 'C:/Users/x/.claude', 'py -3');
|
|
1309
|
+
|
|
1310
|
+
const commands = settings.hooks.SessionStart[0].hooks.map(hook => hook.command);
|
|
1311
|
+
assert.equal(commands.includes(retiredCommand), false);
|
|
1312
|
+
assert.equal(commands.includes(userCommand), true);
|
|
1313
|
+
assert.equal(commands.some(command => command.includes('plugin_data_dir_cleanup.py')), true);
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
|
|
1245
1317
|
test('managedHookScriptRelativePaths includes the dispatcher and all folded hooks so old entries are prunable', () => {
|
|
1246
1318
|
const relativePaths = managedHookScriptRelativePaths(DISPATCHER_HOOKS_CONFIG);
|
|
1247
1319
|
assert.ok(relativePaths.has('blocking/pre_tool_use_dispatcher.py'), 'dispatcher must be in managed set');
|
|
@@ -1319,6 +1391,14 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
|
|
|
1319
1391
|
.filter(cmd => cmd.includes('post_tool_use_dispatcher.py'));
|
|
1320
1392
|
assert.equal(postDispatcherCommands.length, 1, 'shipped hooks.json must register the PostToolUse dispatcher exactly once');
|
|
1321
1393
|
|
|
1394
|
+
const allSessionStartCommands = (shippedHooksConfig.hooks.SessionStart || [])
|
|
1395
|
+
.flatMap(group => group.hooks.map(hook => hook.command));
|
|
1396
|
+
assert.equal(
|
|
1397
|
+
allSessionStartCommands.some(command => command.includes('untracked_repo_detector.py')),
|
|
1398
|
+
false,
|
|
1399
|
+
'shipped hooks.json must not register the retired untracked-repo detector',
|
|
1400
|
+
);
|
|
1401
|
+
|
|
1322
1402
|
const writePathCommands = allPreToolUseGroups
|
|
1323
1403
|
.filter(group => /Write|Edit|MultiEdit/.test(group.matcher || ''))
|
|
1324
1404
|
.flatMap(group => group.hooks.map(hook => hook.command));
|
package/commands/sr-loop.md
CHANGED
|
@@ -10,6 +10,14 @@ tool and repeats it until a pass returns zero new findings.
|
|
|
10
10
|
Use `rules/asd-ste100-language.md` for user-facing wording. Keep the loop's
|
|
11
11
|
cleanup, verification, commit, push, and finish-report fields.
|
|
12
12
|
|
|
13
|
+
### Nit-only terminal
|
|
14
|
+
|
|
15
|
+
When a pass finds only nits, apply them, run scoped checks, commit, and push.
|
|
16
|
+
Treat that phase as clean. Do not run another simplify or review pass without
|
|
17
|
+
the user's request. If later work creates an uncommitted out-of-scope diff,
|
|
18
|
+
stop and report it. Do not inspect, repair, commit, or adopt that diff without
|
|
19
|
+
new user instruction.
|
|
20
|
+
|
|
13
21
|
## Effort
|
|
14
22
|
|
|
15
23
|
Set every model call to `max` effort. If that model has no `max` effort, use
|
|
@@ -32,7 +40,8 @@ stack's design, intent, and changes.
|
|
|
32
40
|
written with the Write tool, Co-Authored-By line, 10-minute timeout — the
|
|
33
41
|
pre-commit gate runs its own tests), and push to the PR head branch. The PR
|
|
34
42
|
stays draft.
|
|
35
|
-
3. Repeat the invocation
|
|
43
|
+
3. Repeat the invocation only after substantive findings or a user request.
|
|
44
|
+
**Skips are sticky:** carry every adjudicated skip
|
|
36
45
|
forward into the next pass as "already adjudicated — do not re-report: ..."
|
|
37
46
|
context, or the loop never converges.
|
|
38
47
|
4. Phase A converges when a full pass returns zero new findings, or when a
|
|
@@ -50,6 +50,25 @@ Limits:
|
|
|
50
50
|
|
|
51
51
|
- The installer skips Codex `hooks.json` for now. Wire it by hand.
|
|
52
52
|
|
|
53
|
+
## Luna fast-mode guard
|
|
54
|
+
|
|
55
|
+
`hooks/blocking/luna_fast_mode_gate.py` serves Claude and Codex. Codex reads its own `hooks.json`, so add this `PreToolUse` group to that file. Replace `<CODEX_HOOKS_ROOT>` with the directory that holds the shipped hook:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"matcher": "Agent|Task|multi_agent_v1__spawn_agent",
|
|
60
|
+
"hooks": [
|
|
61
|
+
{
|
|
62
|
+
"type": "command",
|
|
63
|
+
"command": "python <CODEX_HOOKS_ROOT>/blocking/luna_fast_mode_gate.py",
|
|
64
|
+
"timeout": 10
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The guard requires exact `fast` for Luna spawns through `Agent` and `Task`. Native Codex `multi_agent_v1__spawn_agent` accepts exact `fast` or `priority`. Other models and tools pass through.
|
|
71
|
+
|
|
53
72
|
## Roots and safety
|
|
54
73
|
|
|
55
74
|
Both roots are caller-supplied. The tool never writes to `.agents` or `CODEX_HOME` automatically; pass those locations explicitly when desired. No personal paths or secrets are embedded in the package.
|