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.
@@ -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 (
@@ -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. 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
 
@@ -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(
@@ -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 all 14 hooks removed from hooks.json plus the retired md_to_html_blocker', () => {
1197
- assert.equal(FOLDED_HOOK_RELATIVE_PATHS.size, 15);
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));
@@ -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. **Skips are sticky:** carry every adjudicated skip
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.