claude-dev-env 8.9.0 → 8.11.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 (82) hide show
  1. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_regression.py +85 -3
  2. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_regression.py +34 -0
  3. package/_shared/pr-loop/scripts/fix_pr_test_proof.py +132 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +43 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +126 -0
  6. package/bin/codex-skill-load-block.mjs +45 -0
  7. package/bin/codex-skill-load-block.test.mjs +72 -0
  8. package/bin/install-constants.mjs +5 -0
  9. package/bin/install.agents-home.test.mjs +76 -1
  10. package/bin/install.cursor-rules.test.mjs +6 -0
  11. package/bin/install.mjs +55 -33
  12. package/bin/install.test.mjs +37 -0
  13. package/bin/install.transaction.test.mjs +29 -7
  14. package/bin/seed-codex-pstack-models.mjs +27 -0
  15. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  16. package/docs/CODE_RULES.md +5 -1
  17. package/hooks/blocking/code_rules_enforcer.py +1 -3
  18. package/hooks/blocking/code_rules_magic_values.py +1 -3
  19. package/hooks/blocking/code_rules_paired_test.py +5 -6
  20. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  21. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  22. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  23. package/hooks/followup_ledger.py +8 -1
  24. package/hooks/git-hooks/test_installed_policy_lint.py +4 -2
  25. package/hooks/hooks.json +27 -0
  26. package/hooks/hooks_constants/followup_ledger_constants.py +4 -0
  27. package/hooks/hooks_constants/messages.py +0 -1
  28. package/hooks/hooks_constants/nested_project_hooks_constants.py +37 -0
  29. package/hooks/hooks_constants/skill_loaded_reminder_constants.py +16 -4
  30. package/hooks/hooks_constants/test_messages.py +1 -7
  31. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  32. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  33. package/hooks/session/skill_loaded_reminder.py +28 -13
  34. package/hooks/session/test_skill_loaded_reminder.py +49 -2
  35. package/hooks/test_followup_ledger.py +18 -0
  36. package/package.json +2 -1
  37. package/pstack-codex-models.md +37 -0
  38. package/rules/bdd.md +1 -1
  39. package/rules/build-needs-a-user.md +53 -0
  40. package/rules/code-standards.md +1 -1
  41. package/rules/flag-non-breaking-findings.md +7 -0
  42. package/rules/paired-test-coverage.md +4 -2
  43. package/rules/testing.md +5 -1
  44. package/scripts/cde_lint.py +47 -1
  45. package/scripts/policy_lint/adapter_callers.py +124 -0
  46. package/scripts/policy_lint/adapter_pairing.py +4 -2
  47. package/scripts/policy_lint/adapter_support.py +4 -3
  48. package/scripts/policy_lint/adapters.py +15 -0
  49. package/scripts/policy_lint/check_catalog.py +24 -0
  50. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  51. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  52. package/scripts/policy_lint/config/constants.py +18 -0
  53. package/scripts/policy_lint/engine.py +9 -3
  54. package/scripts/policy_lint/model.py +6 -2
  55. package/scripts/policy_lint/registry.py +6 -0
  56. package/scripts/policy_lint/selection.py +2 -0
  57. package/scripts/tests/test_cde_lint.py +50 -0
  58. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  59. package/scripts/tests/test_policy_lint_rules_callers.py +126 -0
  60. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  61. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  62. package/scripts/tests/test_policy_lint_selection.py +35 -0
  63. package/hooks/blocking/tdd_enforcer.py +0 -195
  64. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  65. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  66. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  67. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  68. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  69. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  70. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  71. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  73. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  74. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  75. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  76. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  77. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  78. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  79. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  80. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  81. package/scripts/codex_compat_watcher.py +0 -502
  82. package/scripts/tests/test_codex_compat_watcher.py +0 -599
package/bin/install.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, copyFileSync, unlinkSync, rmSync, rmdirSync, renameSync, realpathSync, lstatSync, constants as filesystemConstants } from 'node:fs';
4
- import { join, dirname, resolve, relative, basename, isAbsolute, extname } from 'node:path';
4
+ import { join, dirname, resolve, relative, basename, isAbsolute, extname, sep } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { execSync, execFileSync, spawnSync } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
@@ -38,12 +38,14 @@ import {
38
38
  SUBAGENT_MODEL_POLICY_FILE_NAME,
39
39
  WINDOWS_PYTHON_LAUNCHER_COMMAND,
40
40
  PYTHON_PROBE_TIMEOUT_MILLISECONDS,
41
- CODEX_NATIVE_ROUTING_MATCHER,
41
+ ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS,
42
42
  } from './install-constants.mjs';
43
43
  import {
44
44
  installPstackPlugin,
45
45
  shouldInstallPstackPlugin,
46
46
  } from './install-pstack-plugin.mjs';
47
+ import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
48
+ import { writeCodexSkillLoadBlock } from './codex-skill-load-block.mjs';
47
49
  import {
48
50
  continuityHostConfigurationPaths,
49
51
  removeContinuityHooks,
@@ -1173,11 +1175,9 @@ export function copyTree(sourceBase, destBase, options = {}) {
1173
1175
  * @param {string} backupName The managed file name used in the backup path.
1174
1176
  * @returns {string|null} The backup path when existing content differs.
1175
1177
  */
1176
- function backupHubBeforeOverwrite(destPath, incomingPath, backupName) {
1178
+ function backupHubBeforeOverwrite(destPath, incomingText, backupName) {
1177
1179
  if (!existsSync(destPath)) return null;
1178
- const existingBytes = readFileSync(destPath);
1179
- const incomingBytes = readFileSync(incomingPath);
1180
- if (existingBytes.equals(incomingBytes)) return null;
1180
+ if (readFileSync(destPath, 'utf8') === incomingText) return null;
1181
1181
  const backupsDir = join(CLAUDE_HOME, 'backups');
1182
1182
  mkdirSync(backupsDir, { recursive: true });
1183
1183
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
@@ -2102,7 +2102,7 @@ function selectCodexNativeHooksConfig(hooksConfig) {
2102
2102
  ...hooksConfig,
2103
2103
  hooks: {
2104
2104
  PreToolUse: Array.isArray(preToolUseGroups)
2105
- ? preToolUseGroups.filter(group => group?.matcher === CODEX_NATIVE_ROUTING_MATCHER)
2105
+ ? preToolUseGroups.filter(group => ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS.has(group?.matcher))
2106
2106
  : [],
2107
2107
  },
2108
2108
  };
@@ -2371,6 +2371,15 @@ function pruneStaleFilesAcrossManagedRoots(priorInstalledFiles, currentInstalled
2371
2371
  skillsPrunedCount += rootOutcome.prunedCount;
2372
2372
  }
2373
2373
  }
2374
+ const retiredClaudeGuidancePath = join(CLAUDE_HOME, 'AGENTS.md');
2375
+ const retiredGuidanceOutcome = pruneStaleInstalledFiles(
2376
+ priorInstalledFiles?.filter(priorFile => resolve(priorFile) === retiredClaudeGuidancePath) ?? null,
2377
+ currentInstalledFiles,
2378
+ CLAUDE_HOME,
2379
+ backupRoot,
2380
+ );
2381
+ prunedCount += retiredGuidanceOutcome.prunedCount;
2382
+ failedPaths.push(...retiredGuidanceOutcome.failedPaths);
2374
2383
  return { prunedCount, skillsPrunedCount, failedPaths };
2375
2384
  }
2376
2385
 
@@ -2769,6 +2778,23 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2769
2778
  if (!selectedGroups && shouldInstallPstackPlugin()) {
2770
2779
  const pstackPlugin = installPstackPluginForHosts();
2771
2780
  summary.pstackPlugin = pstackPlugin;
2781
+ if (pstackPlugin.hosts.some(host => host.host === 'codex' && host.status === 'installed')) {
2782
+ summary.codexPstackModels = seedCodexPstackModels(
2783
+ INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
2784
+ );
2785
+ if (summary.codexPstackModels) {
2786
+ allInstalledFiles.push(...summary.codexPstackModels);
2787
+ for (const eachPath of summary.codexPstackModels) {
2788
+ allUserOwnedPreferencePaths.add(eachPath);
2789
+ }
2790
+ }
2791
+ const skillLoadGuidancePath = writeCodexSkillLoadBlock(
2792
+ INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
2793
+ );
2794
+ if (skillLoadGuidancePath) {
2795
+ console.log(` \u2713 ${skillLoadGuidancePath} (Codex skill-load line)`);
2796
+ }
2797
+ }
2772
2798
  for (const eachHost of pstackPlugin.hosts) {
2773
2799
  console.log(eachHost.warning
2774
2800
  ? ` Pstack (${eachHost.host}): ${eachHost.status} \u2014 ${eachHost.warning}`
@@ -2877,31 +2903,27 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2877
2903
  );
2878
2904
  }
2879
2905
 
2880
- const claudeHubSource = join(PACKAGE_ROOT, '.claude', 'CLAUDE.md');
2881
- if (existsSync(claudeHubSource)) {
2882
- const claudeHubDest = join(CLAUDE_HOME, 'CLAUDE.md');
2883
- const backupPath = backupHubBeforeOverwrite(claudeHubDest, claudeHubSource, 'CLAUDE.md');
2884
- if (backupPath) {
2885
- console.log(
2886
- ` \u21bb ${relative(CLAUDE_HOME, backupPath)} (previous CLAUDE.md hub preserved)`
2887
- );
2888
- }
2889
- copyFileSync(claudeHubSource, claudeHubDest);
2890
- allInstalledFiles.push(claudeHubDest);
2891
- console.log(` \u2713 ${relative(CLAUDE_HOME, claudeHubDest)} (hub)`);
2892
- }
2893
2906
  const agentsHubSource = join(PACKAGE_ROOT, 'AGENTS.md');
2894
2907
  if (existsSync(agentsHubSource)) {
2895
- const agentsHubDest = join(CLAUDE_HOME, 'AGENTS.md');
2896
- const backupPath = backupHubBeforeOverwrite(agentsHubDest, agentsHubSource, 'AGENTS.md');
2897
- if (backupPath) {
2898
- console.log(
2899
- ` \u21bb ${relative(CLAUDE_HOME, backupPath)} (previous AGENTS.md guidance preserved)`
2900
- );
2908
+ const agentsHubDest = join(AGENTS_HOME, 'AGENTS.md');
2909
+ const claudeHubDest = join(CLAUDE_HOME, 'CLAUDE.md');
2910
+ const claudeHubText = `@${relative(CLAUDE_HOME, agentsHubDest).split(sep).join('/')}\n`;
2911
+ const allHubWrites = [
2912
+ { destPath: agentsHubDest, text: readFileSync(agentsHubSource, 'utf8'), label: 'AGENTS.md', role: 'shared guidance' },
2913
+ { destPath: claudeHubDest, text: claudeHubText, label: 'CLAUDE.md', role: 'hub' },
2914
+ ];
2915
+ mkdirSync(AGENTS_HOME, { recursive: true });
2916
+ for (const eachHubWrite of allHubWrites) {
2917
+ const backupPath = backupHubBeforeOverwrite(eachHubWrite.destPath, eachHubWrite.text, eachHubWrite.label);
2918
+ if (backupPath) {
2919
+ console.log(
2920
+ ` \u21bb ${relative(CLAUDE_HOME, backupPath)} (previous ${eachHubWrite.label} preserved)`
2921
+ );
2922
+ }
2923
+ writeFileSync(eachHubWrite.destPath, eachHubWrite.text);
2924
+ allInstalledFiles.push(eachHubWrite.destPath);
2925
+ console.log(` \u2713 ${eachHubWrite.destPath} (${eachHubWrite.role})`);
2901
2926
  }
2902
- copyFileSync(agentsHubSource, agentsHubDest);
2903
- allInstalledFiles.push(agentsHubDest);
2904
- console.log(` \u2713 ${relative(CLAUDE_HOME, agentsHubDest)} (canonical guidance)`);
2905
2927
  }
2906
2928
  const isFullInstall = !selectedGroups;
2907
2929
  const didPruneRun = isFullInstall && UNRESOLVED_DEPENDENCY_NAMES.length === 0;
@@ -3322,10 +3344,10 @@ Profile selection (--profile/--profiles) is mutually exclusive with --target.
3322
3344
  Target selection finishes before any mutation. Each target writes its own manifest
3323
3345
  with package, version, targetIdentity, managedRoot, files, and skills.
3324
3346
 
3325
- If ~/.claude/CLAUDE.md already exists and differs from the package copy, the installer
3326
- writes the previous contents to ~/.claude/backups/CLAUDE.md.<timestamp>.bak first.
3327
- If ~/.claude/AGENTS.md already exists and differs from the package copy, the installer
3328
- writes the previous contents to ~/.claude/backups/AGENTS.md.<timestamp>.bak first.
3347
+ The package guidance lands once at ~/.agents/AGENTS.md, and ~/.claude/CLAUDE.md
3348
+ holds one import line that loads it. If either file already exists with other
3349
+ contents, the installer writes the previous contents to
3350
+ ~/.claude/backups/<name>.<timestamp>.bak first.
3329
3351
  `);
3330
3352
  }
3331
3353
 
@@ -52,6 +52,11 @@ import {
52
52
  retainNewestRunBackupOnly,
53
53
  } from './install.mjs';
54
54
  import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
55
+ import {
56
+ SKILL_LOAD_BLOCK_START,
57
+ SKILL_LOAD_INSTRUCTION,
58
+ withSkillLoadBlock,
59
+ } from './codex-skill-load-block.mjs';
55
60
 
56
61
 
57
62
  test('installer reports the authoritative Git hook names', () => {
@@ -3064,6 +3069,34 @@ test('a full install adds the pstack marketplace and plugin on both hosts', t =>
3064
3069
  ]);
3065
3070
  assert.match(installerOutput, /Pstack \(claude\): installed/);
3066
3071
  assert.match(installerOutput, /Pstack \(codex\): installed/);
3072
+ const codexHome = join(sandbox.homeDirectory, '.codex');
3073
+ const sheet = readFileSync(join(codexHome, 'pstack-models.md'), 'utf8');
3074
+ const agents = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
3075
+ assert.match(sheet, /^session hook: on$/m);
3076
+ assert.match(agents, /^feature, refactoring: gpt-6-sol$/m);
3077
+ assert.doesNotMatch(agents, /^session hook:/m);
3078
+ assert.ok(agents.startsWith(`${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n`));
3079
+ assert.equal(existsSync(join(sandbox.homeDirectory, '.claude', 'pstack-models.md')), false);
3080
+ const manifestPath = join(sandbox.homeDirectory, '.claude', '.claude-dev-env-manifest.json');
3081
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
3082
+ assert.equal(manifest.files.includes(join(codexHome, 'pstack-models.md')), false);
3083
+ assert.equal(manifest.files.includes(join(codexHome, 'AGENTS.md')), false);
3084
+ });
3085
+
3086
+ test('a repeat install preserves Codex pstack configuration', t => {
3087
+ const sandbox = pstackPluginSandbox(t);
3088
+ const codexHome = join(sandbox.homeDirectory, '.codex');
3089
+ runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3090
+ writeFileSync(join(codexHome, 'AGENTS.md'), 'Custom Codex guidance\n');
3091
+ writeFileSync(join(codexHome, 'pstack-models.md'), 'session hook: off\n');
3092
+
3093
+ runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3094
+
3095
+ assert.equal(
3096
+ readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'),
3097
+ withSkillLoadBlock('Custom Codex guidance\n'),
3098
+ );
3099
+ assert.equal(readFileSync(join(codexHome, 'pstack-models.md'), 'utf8'), 'session hook: off\n');
3067
3100
  });
3068
3101
 
3069
3102
  test('an absent host command is reported and leaves the other host installed', t => {
@@ -3085,6 +3118,8 @@ test('an absent host command is reported and leaves the other host installed', t
3085
3118
  sandbox.recordedCommands().filter(eachCommand => eachCommand.startsWith('codex')),
3086
3119
  [],
3087
3120
  );
3121
+ assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'pstack-models.md')), false);
3122
+ assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md')), false);
3088
3123
  });
3089
3124
 
3090
3125
  test('--no-pstack leaves both hosts untouched', t => {
@@ -3096,6 +3131,8 @@ test('--no-pstack leaves both hosts untouched', t => {
3096
3131
 
3097
3132
  assert.deepEqual(sandbox.recordedCommands(), []);
3098
3133
  assert.doesNotMatch(installerOutput, /Pstack \(/);
3134
+ assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'pstack-models.md')), false);
3135
+ assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md')), false);
3099
3136
  });
3100
3137
 
3101
3138
  function continuityCommandCount(configurationPath) {
@@ -270,13 +270,6 @@ test('runWithInstallTransaction commits and discards journal on success', () =>
270
270
  rmSync(box.root, { recursive: true, force: true });
271
271
  });
272
272
 
273
- /**
274
- * Spawn the real installer against a sandbox home with optional fault phase.
275
- *
276
- * @param {string} homeDirectory
277
- * @param {string[]} extraArguments
278
- * @param {{ faultPhase?: string|null }} [options]
279
- */
280
273
  function runInstaller(homeDirectory, extraArguments, options = {}) {
281
274
  const childEnvironment = {
282
275
  ...process.env,
@@ -285,6 +278,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
285
278
  USERPROFILE: homeDirectory,
286
279
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
287
280
  CODEX_HOME: join(homeDirectory, '.codex'),
281
+ ...options.environment,
288
282
  };
289
283
  if (options.faultPhase) {
290
284
  childEnvironment[INSTALL_FAULT_ENV] = options.faultPhase;
@@ -298,6 +292,34 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
298
292
  });
299
293
  }
300
294
 
295
+ test('a later install fault removes newly seeded Codex pstack files', () => {
296
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-txn-codex-pstack-'));
297
+ try {
298
+ const codexCommandPath = join(
299
+ homeDirectory,
300
+ process.platform === 'win32' ? 'codex.cmd' : 'codex',
301
+ );
302
+ writeFileSync(
303
+ codexCommandPath,
304
+ process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
305
+ { mode: 0o755 },
306
+ );
307
+ const failedRun = runInstaller(homeDirectory, [], {
308
+ faultPhase: FAULT_PHASES.AFTER_MANIFEST_WRITE,
309
+ environment: {
310
+ CDE_INSTALL_PSTACK: '1',
311
+ CDE_CODEX_EXECUTABLE: codexCommandPath,
312
+ },
313
+ });
314
+ assert.notEqual(failedRun.status, 0, failedRun.stdout + failedRun.stderr);
315
+ assert.match(`${failedRun.stdout}${failedRun.stderr}`, /after_manifest_write/);
316
+ assert.equal(existsSync(join(homeDirectory, '.codex', 'pstack-models.md')), false);
317
+ assert.equal(existsSync(join(homeDirectory, '.codex', 'AGENTS.md')), false);
318
+ } finally {
319
+ rmSync(homeDirectory, { recursive: true, force: true });
320
+ }
321
+ });
322
+
301
323
  function seedPriorInstall(homeDirectory) {
302
324
  const claudeDirectory = join(homeDirectory, '.claude');
303
325
  const rulesFile = join(claudeDirectory, 'rules', 'prior.md');
@@ -0,0 +1,27 @@
1
+ import { copyFileSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, constants as filesystemConstants } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const CODEX_PRESET_PATH = fileURLToPath(new URL('../pstack-codex-models.md', import.meta.url));
6
+
7
+ function pathExists(path) {
8
+ return lstatSync(path, { throwIfNoEntry: false }) !== undefined;
9
+ }
10
+
11
+ export function seedCodexPstackModels(codexHome, writeAgentGuidance = writeFileSync) {
12
+ const sheetPath = join(codexHome, 'pstack-models.md');
13
+ const agentsPath = join(codexHome, 'AGENTS.md');
14
+ if (pathExists(sheetPath) || pathExists(agentsPath)) return null;
15
+
16
+ const preset = readFileSync(CODEX_PRESET_PATH, 'utf8');
17
+ const agentGuidance = preset.replace(/^session hook: on\n?$/m, '');
18
+ mkdirSync(codexHome, { recursive: true });
19
+ copyFileSync(CODEX_PRESET_PATH, sheetPath, filesystemConstants.COPYFILE_EXCL);
20
+ try {
21
+ writeAgentGuidance(agentsPath, agentGuidance, { encoding: 'utf8', flag: 'wx' });
22
+ } catch (writeError) {
23
+ unlinkSync(sheetPath);
24
+ throw writeError;
25
+ }
26
+ return [sheetPath, agentsPath];
27
+ }
@@ -0,0 +1,72 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
7
+
8
+ test('fresh Codex home gets the selected roles and hook setting', (context) => {
9
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
10
+ context.after(() => rmSync(root, { recursive: true, force: true }));
11
+ const codexHome = join(root, '.codex');
12
+ const claudeHome = join(root, '.claude');
13
+ mkdirSync(claudeHome);
14
+ writeFileSync(join(claudeHome, 'CLAUDE.md'), 'Claude guidance\n');
15
+
16
+ const paths = seedCodexPstackModels(codexHome);
17
+ const sheet = readFileSync(join(codexHome, 'pstack-models.md'), 'utf8');
18
+ const agents = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
19
+ assert.deepEqual(paths, [join(codexHome, 'pstack-models.md'), join(codexHome, 'AGENTS.md')]);
20
+ assert.match(sheet, /^feature, refactoring: gpt-6-sol$/m);
21
+ assert.match(sheet, /^bug-fix: gpt-6-astra$/m);
22
+ assert.match(sheet, /^how explorer: gpt-6-luna$/m);
23
+ assert.match(sheet, /^arena runners: gpt-6-astra, gpt-6-sol, gpt-6-luna$/m);
24
+ assert.match(sheet, /^session hook: on$/m);
25
+ assert.match(agents, /^feature, refactoring: gpt-6-sol$/m);
26
+ assert.doesNotMatch(agents, /^session hook:/m);
27
+ assert.equal(readFileSync(join(claudeHome, 'CLAUDE.md'), 'utf8'), 'Claude guidance\n');
28
+ assert.equal(existsSync(join(claudeHome, 'pstack-models.md')), false);
29
+ });
30
+
31
+ test('existing Codex configuration stays untouched on repeat installs', (context) => {
32
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
33
+ context.after(() => rmSync(root, { recursive: true, force: true }));
34
+ const codexHome = join(root, '.codex');
35
+ seedCodexPstackModels(codexHome);
36
+ writeFileSync(join(codexHome, 'AGENTS.md'), 'Custom agents\n');
37
+
38
+ assert.equal(seedCodexPstackModels(codexHome), null);
39
+ assert.equal(readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'), 'Custom agents\n');
40
+ });
41
+
42
+ test('a failed AGENTS write removes only the sheet created by that attempt', (context) => {
43
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
44
+ context.after(() => rmSync(root, { recursive: true, force: true }));
45
+ const codexHome = join(root, '.codex');
46
+ const claudeHome = join(root, '.claude');
47
+ mkdirSync(claudeHome);
48
+ writeFileSync(join(claudeHome, 'CLAUDE.md'), 'Existing Claude guidance\n');
49
+
50
+ const competingWrite = (agentsPath) => {
51
+ writeFileSync(agentsPath, 'Existing Codex guidance\n');
52
+ throw new Error('AGENTS.md already exists');
53
+ };
54
+ assert.throws(() => seedCodexPstackModels(codexHome, competingWrite), /already exists/);
55
+ assert.equal(existsSync(join(codexHome, 'pstack-models.md')), false);
56
+ assert.equal(readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'), 'Existing Codex guidance\n');
57
+ assert.equal(readFileSync(join(claudeHome, 'CLAUDE.md'), 'utf8'), 'Existing Claude guidance\n');
58
+ });
59
+
60
+ for (const existingFile of ['AGENTS.md', 'pstack-models.md']) {
61
+ test(`partial Codex setup with ${existingFile} is preserved`, (context) => {
62
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
63
+ context.after(() => rmSync(root, { recursive: true, force: true }));
64
+ const codexHome = join(root, '.codex');
65
+ mkdirSync(codexHome);
66
+ writeFileSync(join(codexHome, existingFile), 'Custom configuration\n');
67
+
68
+ assert.equal(seedCodexPstackModels(codexHome), null);
69
+ assert.equal(readFileSync(join(codexHome, existingFile), 'utf8'), 'Custom configuration\n');
70
+ assert.equal(existsSync(join(codexHome, existingFile === 'AGENTS.md' ? 'pstack-models.md' : 'AGENTS.md')), false);
71
+ });
72
+ }
@@ -75,7 +75,11 @@ Parameters follow YAGNI: add an optional parameter when a caller varies the valu
75
75
 
76
76
  ## 8. TDD PROCESS
77
77
 
78
- 1. **RED** — failing test first. 2. **GREEN** — minimum code to pass. 3. **REFACTOR** — only if valuable.
78
+ 1. **RED** — a failing test. 2. **GREEN** — minimum code to pass. 3. **REFACTOR** — only if valuable.
79
+
80
+ This loop is the default for a bug fix and for new behavior, and the TDD skill (`pstack:tdd`) carries the procedure. No hook or lint checks the order. A prototype may run ahead of its tests and adds them before the pull request goes ready. A bug fix ships with a test that reproduces the bug.
81
+
82
+ **Proof of check.** Each pull request body states how the change was checked: a test, a run, a screenshot, or a measurement, named so a reviewer can repeat it. A reviewer flags a pull request whose body names no proof.
79
83
 
80
84
  ## 9. SELF-CONTAINED COMPONENTS
81
85
 
@@ -11,9 +11,7 @@ Advisory only (non-blocking):
11
11
 
12
12
  Companion tests live alongside this file as
13
13
  ``test_code_rules_enforcer_<suffix>.py``; the ``<suffix>`` split keeps each
14
- concern focused. The separate ``tdd_enforcer.py`` hook accepts any
15
- ``test_code_rules_enforcer_*.py`` sibling as a test candidate for the
16
- ``code_rules_*`` module family, so the suffix files satisfy its gate.
14
+ concern focused.
17
15
  """
18
16
  import json
19
17
  import sys
@@ -36,9 +36,7 @@ def _mask_string_literals_preserving_length(source_line: str) -> str:
36
36
 
37
37
  Matching tests live in
38
38
  ``test_code_rules_enforcer_magic_string_masking.py``, one of the
39
- ``test_code_rules_enforcer_<suffix>.py`` family files the
40
- ``tdd_enforcer.py`` hook accepts as test candidates for the
41
- ``code_rules_*`` module family.
39
+ ``test_code_rules_enforcer_<suffix>.py`` family files.
42
40
  """
43
41
 
44
42
  string_literal_pattern = re.compile(
@@ -1,10 +1,9 @@
1
1
  """Public-function paired-test coverage check for ``code_rules_enforcer``.
2
2
 
3
- The TDD gate ``tdd_enforcer.py`` requires a fresh test file to exist before a
4
- production module is written, but it judges coverage at file granularity: a
5
- module whose dedicated test file exercises some public functions while leaving
6
- one untested still satisfies it. This check closes that gap from both write
7
- orders, so a forgotten function gets a behavioral test before the
3
+ The staged policy lint's test-pairing rule judges coverage at file
4
+ granularity: a module whose dedicated test file exercises some public functions
5
+ while leaving one untested still satisfies it. This check closes that gap from
6
+ both write orders, so a forgotten function gets a behavioral test before the
8
7
  partially-covered module lands.
9
8
 
10
9
  ``check_public_function_missing_paired_test`` fires on the production-module
@@ -25,7 +24,7 @@ Both checks stay conservative to keep false positives near zero:
25
24
 
26
25
  - The production-side check runs only on a production module whose stem-matched
27
26
  test file already exists; a module with no dedicated test file is out of scope
28
- and left to the file-level TDD gate. The test-side check runs only on a
27
+ and left to the staged lint's test-pairing rule. The test-side check runs only on a
29
28
  stem-matched test file whose paired production module exists on disk.
30
29
  - The production-side check fires only when the suite already exercises the
31
30
  module — covering at least one of its public functions, or referencing one of
@@ -1 +1 @@
1
- """Decomposed concern modules the ``tdd_enforcer`` entry hook wires together."""
1
+ """Content analysis the policy lint's adapter pairing rule loads to exempt constants-only and import-only edits."""
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  import os
7
6
  import subprocess
8
7
  import sys
@@ -21,13 +20,11 @@ if _HOOKS_DIRECTORY not in sys.path:
21
20
  from code_rules_enforcer import main as enforcer_main # noqa: E402
22
21
  from code_rules_shared import is_ephemeral_script_path # noqa: E402
23
22
  from code_rules_enforcer_test_support import ( # noqa: E402
24
- build_write_payload,
25
23
  run_enforcer_cli,
26
24
  run_write_entrypoint,
27
25
  )
28
26
 
29
27
  _ENFORCER_SCRIPT = Path(__file__).resolve().parent / "code_rules_enforcer.py"
30
- _TDD_SCRIPT = Path(__file__).resolve().parent / "tdd_enforcer.py"
31
28
 
32
29
  _VIOLATING_PRODUCTION_SOURCE = "def process_data(payload: str) -> None:\n print(payload)\n"
33
30
 
@@ -56,44 +53,6 @@ def _run_main_with_write_payload(
56
53
  return run_write_entrypoint(enforcer_main, file_path, content)
57
54
 
58
55
 
59
- def _run_tdd_with_write_payload(
60
- file_path: str,
61
- content: str,
62
- ) -> subprocess.CompletedProcess[str]:
63
- """Drive the TDD enforcer through subprocess with a Write payload.
64
-
65
- Args:
66
- file_path: The destination path the Write targets.
67
- content: The production-looking content to write.
68
-
69
- Returns:
70
- The completed process carrying stdout, stderr, and the exit code.
71
- """
72
- write_payload = build_write_payload(file_path, content)
73
- return subprocess.run(
74
- [sys.executable, str(_TDD_SCRIPT)],
75
- input=write_payload,
76
- capture_output=True,
77
- text=True,
78
- )
79
-
80
-
81
- def _decision_from(completed: subprocess.CompletedProcess[str]) -> str | None:
82
- """Extract the permissionDecision from a hook's JSON stdout.
83
-
84
- Args:
85
- completed: The completed subprocess carrying the hook's stdout.
86
-
87
- Returns:
88
- The permissionDecision string, or None when stdout is empty.
89
- """
90
- if not completed.stdout:
91
- return None
92
- parsed = json.loads(completed.stdout)
93
- hook_output = parsed.get("hookSpecificOutput", {})
94
- return hook_output.get("permissionDecision")
95
-
96
-
97
56
  def test_should_return_true_for_claude_job_dir_tmp_path(
98
57
  tmp_path: Path,
99
58
  monkeypatch: pytest.MonkeyPatch,
@@ -320,7 +279,7 @@ def test_should_exempt_same_path_set_on_both_gates(
320
279
  tmp_path: Path,
321
280
  monkeypatch: pytest.MonkeyPatch,
322
281
  ) -> None:
323
- """B21: every ephemeral path exits 0 on both enforcer main and TDD enforcer main."""
282
+ """B21: every ephemeral path exits 0 on enforcer main."""
324
283
  monkeypatch.setenv("CLAUDE_JOB_DIR", str(tmp_path))
325
284
  all_ephemeral_paths = [
326
285
  str(tmp_path / "tmp" / "scratch.py"),
@@ -339,7 +298,3 @@ def test_should_exempt_same_path_set_on_both_gates(
339
298
  assert "deny" not in captured_stdout.lower(), (
340
299
  f"enforcer must not deny ephemeral path {each_ephemeral_path!r}"
341
300
  )
342
- completed = _run_tdd_with_write_payload(each_ephemeral_path, _VIOLATING_PRODUCTION_SOURCE)
343
- assert _decision_from(completed) != "deny", (
344
- f"TDD enforcer must not deny ephemeral path {each_ephemeral_path!r}"
345
- )
@@ -496,8 +496,8 @@ def test_aggregate_exit_code_zero_with_no_output_allows() -> None:
496
496
  def test_aggregate_explicit_allow_payload_signals_allow_decision() -> None:
497
497
  """An explicit permissionDecision allow from a hosted hook signals an allow decision.
498
498
 
499
- tdd_enforcer writes an explicit allow payload on its allow path, which
500
- auto-approves the write standalone. The aggregator must surface that as an
499
+ A hosted hook can write an explicit allow payload, which auto-approves the
500
+ write standalone. The aggregator must surface that as an
501
501
  explicit allow decision so the dispatcher re-emits it rather than silently
502
502
  falling back to the default permission flow.
503
503
  """
@@ -680,43 +680,6 @@ def _orphan_claude_md_payload(tmp_path: Path) -> str:
680
680
  return _write_payload(claude_md_path, orphan_table)
681
681
 
682
682
 
683
- def _parse_hook_allow(completed_process: subprocess.CompletedProcess[str]) -> bool:
684
- """Parse one hook's subprocess result for an explicit permissionDecision allow.
685
-
686
- Args:
687
- completed_process: The completed subprocess from running a hook.
688
-
689
- Returns:
690
- True when the hook emitted an explicit allow decision.
691
- """
692
- stdout_text = completed_process.stdout.strip()
693
- if not stdout_text:
694
- return False
695
- try:
696
- parsed_output = json.loads(stdout_text)
697
- except json.JSONDecodeError:
698
- return False
699
- hook_specific = parsed_output.get("hookSpecificOutput", {})
700
- if not isinstance(hook_specific, dict):
701
- return False
702
- return hook_specific.get("permissionDecision") == "allow"
703
-
704
-
705
- def test_retired_tdd_hook_cannot_auto_approve_the_write(tmp_path: Path) -> None:
706
- """Pairing belongs to staged lint; its edit-time auto-approval is absent."""
707
- config_target_path = str(tmp_path / "config" / "timing.py")
708
- constants_only_content = (
709
- '"""Timing constants."""\n\nMAXIMUM_RETRIES = 3\nRETRY_DELAY_SECONDS = 5\n'
710
- )
711
- payload_text = _write_payload(config_target_path, constants_only_content)
712
- standalone_result = _run_hook_subprocess("blocking/tdd_enforcer.py", payload_text)
713
- assert _parse_hook_allow(standalone_result)
714
- dispatcher_result = _run_dispatcher(payload_text)
715
- dispatcher_is_deny, reason = _parse_hook_decision(dispatcher_result)
716
- assert not dispatcher_is_deny, reason
717
- assert not _parse_hook_allow(dispatcher_result)
718
-
719
-
720
683
  def test_runpy_hosted_hook_sees_its_own_argv_not_the_dispatchers(tmp_path: Path) -> None:
721
684
  """A runpy-hosted hook resolves its own script path as sys.argv, not the dispatcher's.
722
685
 
@@ -851,7 +814,6 @@ ALL_RETAINED_PATHS = {
851
814
  ALL_RETIRED_PATHS = {
852
815
  "validation/hook_format_validator.py",
853
816
  "blocking/code_rules_enforcer.py",
854
- "blocking/tdd_enforcer.py",
855
817
  "blocking/windows_rmtree_blocker.py",
856
818
  "blocking/state_description_blocker.py",
857
819
  "blocking/subprocess_budget_completeness.py",
@@ -31,6 +31,8 @@ from hooks_constants.followup_ledger_constants import (
31
31
  GIT_REFERENCE_PREFIX,
32
32
  LEDGER_APPEND_MODE,
33
33
  LEDGER_ENCODING,
34
+ LEDGER_IGNORE_FILE_NAME,
35
+ LEDGER_IGNORE_TEXT,
34
36
  MESSAGE_KEY,
35
37
  ORIGIN_COMMIT_KEY,
36
38
  RULE_ID_KEY,
@@ -88,7 +90,9 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
88
90
  """Append one finding to the repository's ledger, once.
89
91
 
90
92
  A finding already present in the ledger is left alone, so a gate that runs
91
- on every commit records a standing smell a single time. Every filesystem
93
+ on every commit records a standing smell a single time. The ledger
94
+ directory carries a ``.gitignore`` matching every file in it, so the
95
+ ledger stays out of ``git status`` in any repository. Every filesystem
92
96
  error is swallowed, so a ledger failure leaves the caller's gate decision
93
97
  unchanged.
94
98
 
@@ -116,6 +120,9 @@ def record_followup_finding(repository_root: Path, finding: FollowupFinding) ->
116
120
  )
117
121
  try:
118
122
  ledger_path.parent.mkdir(parents=True, exist_ok=True)
123
+ ignore_path = ledger_path.parent / LEDGER_IGNORE_FILE_NAME
124
+ if not ignore_path.exists():
125
+ ignore_path.write_text(LEDGER_IGNORE_TEXT, encoding=LEDGER_ENCODING)
119
126
  with ledger_path.open(LEDGER_APPEND_MODE, encoding=LEDGER_ENCODING) as ledger_file:
120
127
  ledger_file.write(record_text + "\n")
121
128
  except OSError:
@@ -14,7 +14,6 @@ from test_native_hook_support import run_git
14
14
 
15
15
  ALL_RETIRED_HOOK_PATHS = (
16
16
  "blocking/code_rules_enforcer.py",
17
- "blocking/tdd_enforcer.py",
18
17
  "blocking/windows_rmtree_blocker.py",
19
18
  "blocking/state_description_blocker.py",
20
19
  "blocking/subprocess_budget_completeness.py",
@@ -26,6 +25,7 @@ ALL_RETIRED_HOOK_PATHS = (
26
25
  "blocking/plain_language_blocker.py",
27
26
  "lifecycle/config_change_guard.py",
28
27
  )
28
+ ALL_ARCHIVED_HOOK_PATHS = ("blocking/tdd_enforcer.py",)
29
29
  PACKAGE_ROOT = Path(__file__).resolve().parents[2]
30
30
  ALL_RULE_FIXTURES = (
31
31
  ("code-rules", "src/worker.py", "from typing import Any\n\ndef worker() -> Any:\n return None\n"),
@@ -80,7 +80,7 @@ def _legacy_settings_bytes(managed_root: Path, foreign_command: str) -> bytes:
80
80
  "type": "command",
81
81
  "command": f"python {managed_root.as_posix()}/hooks/{each_path}",
82
82
  "timeout": 10,
83
- } for each_path in ALL_RETIRED_HOOK_PATHS),
83
+ } for each_path in (*ALL_RETIRED_HOOK_PATHS, *ALL_ARCHIVED_HOOK_PATHS)),
84
84
  ],
85
85
  }]},
86
86
  }
@@ -135,6 +135,8 @@ def _assert_retired_registrations(managed_root: Path, settings_bytes: bytes, for
135
135
  source = PACKAGE_ROOT / "hooks" / each_relative_path
136
136
  installed = managed_root / "hooks" / each_relative_path
137
137
  assert hashlib.sha256(installed.read_bytes()).digest() == hashlib.sha256(source.read_bytes()).digest()
138
+ for each_relative_path in ALL_ARCHIVED_HOOK_PATHS:
139
+ assert not any(each_relative_path in each_command for each_command in all_commands)
138
140
 
139
141
 
140
142
  @pytest.fixture(scope="module")