claude-dev-env 8.10.0 → 8.11.1

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 (81) 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 +233 -0
  4. package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +47 -0
  5. package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +186 -0
  6. package/bin/codex-skill-load-block.mjs +93 -0
  7. package/bin/codex-skill-load-block.test.mjs +134 -0
  8. package/bin/install.agents-home.test.mjs +76 -1
  9. package/bin/install.mjs +55 -31
  10. package/bin/install.test.mjs +57 -0
  11. package/bin/install.transaction.test.mjs +29 -7
  12. package/bin/seed-codex-pstack-models.mjs +27 -0
  13. package/bin/seed-codex-pstack-models.test.mjs +72 -0
  14. package/docs/CODE_RULES.md +5 -1
  15. package/hooks/blocking/code_rules_enforcer.py +1 -3
  16. package/hooks/blocking/code_rules_magic_values.py +1 -3
  17. package/hooks/blocking/code_rules_paired_test.py +5 -6
  18. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -3
  19. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -1
  20. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +1 -46
  21. package/hooks/blocking/test_pre_tool_use_dispatcher.py +2 -40
  22. package/hooks/blocking/tests/test_repository_exemption.py +7 -0
  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 +15 -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/test_messages.py +1 -7
  30. package/hooks/lifecycle/nested_project_hooks.py +395 -0
  31. package/hooks/lifecycle/test_nested_project_hooks.py +251 -0
  32. package/hooks/test_followup_ledger.py +18 -0
  33. package/package.json +2 -1
  34. package/pstack-codex-models.md +37 -0
  35. package/rules/bdd.md +1 -1
  36. package/rules/build-needs-a-user.md +53 -0
  37. package/rules/code-standards.md +1 -1
  38. package/rules/flag-non-breaking-findings.md +7 -0
  39. package/rules/paired-test-coverage.md +4 -2
  40. package/rules/testing.md +5 -1
  41. package/scripts/cde_lint.py +47 -1
  42. package/scripts/policy_lint/adapter_callers.py +124 -0
  43. package/scripts/policy_lint/adapter_pairing.py +4 -2
  44. package/scripts/policy_lint/adapter_support.py +4 -3
  45. package/scripts/policy_lint/adapters.py +15 -0
  46. package/scripts/policy_lint/check_catalog.py +24 -0
  47. package/scripts/policy_lint/config/approved_test_pairs.py +3 -2
  48. package/scripts/policy_lint/config/check_catalog_constants.py +13 -0
  49. package/scripts/policy_lint/config/constants.py +18 -0
  50. package/scripts/policy_lint/engine.py +9 -3
  51. package/scripts/policy_lint/model.py +6 -2
  52. package/scripts/policy_lint/registry.py +6 -0
  53. package/scripts/policy_lint/selection.py +2 -0
  54. package/scripts/repository_checks/tracked_private_terms.py +15 -1
  55. package/scripts/tests/test_cde_lint.py +50 -0
  56. package/scripts/tests/test_policy_lint_rules_archive.py +16 -0
  57. package/scripts/tests/test_policy_lint_rules_callers.py +140 -0
  58. package/scripts/tests/test_policy_lint_rules_check_catalog.py +25 -1
  59. package/scripts/tests/test_policy_lint_rules_pairing.py +31 -2
  60. package/scripts/tests/test_policy_lint_selection.py +35 -0
  61. package/scripts/tests/test_tracked_private_terms.py +35 -0
  62. package/hooks/blocking/tdd_enforcer.py +0 -195
  63. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +0 -142
  64. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +0 -1
  65. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +0 -49
  66. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +0 -298
  67. package/hooks/blocking/tdd_enforcer_parts/decisions.py +0 -96
  68. package/hooks/blocking/tdd_enforcer_parts/freshness.py +0 -95
  69. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +0 -132
  70. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +0 -119
  71. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +0 -31
  72. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +0 -256
  73. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +0 -46
  74. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +0 -28
  75. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +0 -111
  76. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +0 -36
  77. package/hooks/blocking/test_tdd_enforcer.py +0 -1506
  78. package/hooks/blocking/test_tdd_enforcer_restore.py +0 -108
  79. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +0 -105
  80. package/scripts/codex_compat_watcher.py +0 -502
  81. package/scripts/tests/test_codex_compat_watcher.py +0 -599
@@ -0,0 +1,93 @@
1
+ import { lstatSync, mkdirSync, readFileSync, readlinkSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ export const SKILL_LOAD_BLOCK_START = '<!-- claude-dev-env skill load: start -->';
5
+ export const SKILL_LOAD_BLOCK_END = '<!-- claude-dev-env skill load: end -->';
6
+ export const SKILL_LOAD_INSTRUCTION = 'Load the pstack:poteto-mode skill before your first tool call or reply: '
7
+ + 'open its SKILL.md from your skills list and follow it. This binds every Codex agent, '
8
+ + 'including a helper started with spawn_agent, whatever its task message says.';
9
+ export const PACKAGE_GUIDANCE_BLOCK_START = '<!-- claude-dev-env package guidance: start -->';
10
+ export const PACKAGE_GUIDANCE_BLOCK_END = '<!-- claude-dev-env package guidance: end -->';
11
+
12
+ const WINDOWS_LONG_PATH_PREFIX = '\\\\?\\';
13
+
14
+ function managedBlock(blockStart, blockBody, blockEnd) {
15
+ const terminatedBody = blockBody.endsWith('\n') ? blockBody : `${blockBody}\n`;
16
+ return `${blockStart}\n${terminatedBody}${blockEnd}\n`;
17
+ }
18
+
19
+ function withBlockReplaced(guidanceText, blockStart, blockEnd, block) {
20
+ const startIndex = guidanceText.indexOf(blockStart);
21
+ const endIndex = startIndex === -1 ? -1 : guidanceText.indexOf(blockEnd, startIndex);
22
+ if (endIndex === -1) return null;
23
+ let afterBlockIndex = endIndex + blockEnd.length;
24
+ if (guidanceText[afterBlockIndex] === '\n') afterBlockIndex += 1;
25
+ return guidanceText.slice(0, startIndex) + block + guidanceText.slice(afterBlockIndex);
26
+ }
27
+
28
+ /**
29
+ * Return the Codex guidance text with the package's skill-load block in it.
30
+ *
31
+ * Codex starts a spawn_agent helper without running any hook, so the global
32
+ * AGENTS.md is the one channel that reaches every helper. A block already in
33
+ * the text is replaced where it stands; otherwise the block goes first.
34
+ */
35
+ export function withSkillLoadBlock(guidanceText) {
36
+ const block = managedBlock(SKILL_LOAD_BLOCK_START, SKILL_LOAD_INSTRUCTION, SKILL_LOAD_BLOCK_END);
37
+ const replacedText = withBlockReplaced(guidanceText, SKILL_LOAD_BLOCK_START, SKILL_LOAD_BLOCK_END, block);
38
+ if (replacedText !== null) return replacedText;
39
+ return guidanceText ? `${block}\n${guidanceText}` : block;
40
+ }
41
+
42
+ function withPackageGuidanceBlock(guidanceText, packageGuidanceText) {
43
+ const block = managedBlock(PACKAGE_GUIDANCE_BLOCK_START, packageGuidanceText, PACKAGE_GUIDANCE_BLOCK_END);
44
+ const replacedText = withBlockReplaced(
45
+ guidanceText,
46
+ PACKAGE_GUIDANCE_BLOCK_START,
47
+ PACKAGE_GUIDANCE_BLOCK_END,
48
+ block,
49
+ );
50
+ return replacedText ?? `${guidanceText}\n${block}`;
51
+ }
52
+
53
+ function comparablePath(filePath) {
54
+ const withoutPrefix = filePath.startsWith(WINDOWS_LONG_PATH_PREFIX)
55
+ ? filePath.slice(WINDOWS_LONG_PATH_PREFIX.length)
56
+ : filePath;
57
+ const resolvedPath = resolve(withoutPrefix);
58
+ return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
59
+ }
60
+
61
+ function linksToPackageGuidance(agentsPath, allPackageGuidancePaths) {
62
+ const linkTarget = comparablePath(resolve(dirname(agentsPath), readlinkSync(agentsPath)));
63
+ return allPackageGuidancePaths.some(eachPath => comparablePath(eachPath) === linkTarget);
64
+ }
65
+
66
+ /**
67
+ * Keep CODEX_HOME/AGENTS.md carrying the skill-load block.
68
+ *
69
+ * A symbolic link that points at the package guidance (the shared agents-home
70
+ * copy, or the retired copy under the Claude home) is replaced by a regular
71
+ * file holding the skill-load block and a package-guidance block, so Codex
72
+ * keeps both after the package moves its guidance. A file that already holds
73
+ * the package-guidance block gets the current guidance text. A link to any
74
+ * other file is left alone.
75
+ *
76
+ * Returns the file path when the file changed, and null otherwise.
77
+ */
78
+ export function writeCodexAgentsGuidance(codexHome, packageGuidanceText, allPackageGuidancePaths) {
79
+ const agentsPath = join(codexHome, 'AGENTS.md');
80
+ const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
81
+ const isPackageGuidanceLink = agentsEntry?.isSymbolicLink() ?? false;
82
+ if (isPackageGuidanceLink && !linksToPackageGuidance(agentsPath, allPackageGuidancePaths)) return null;
83
+ const currentText = agentsEntry && !isPackageGuidanceLink ? readFileSync(agentsPath, 'utf8') : '';
84
+ let updatedText = withSkillLoadBlock(currentText);
85
+ if (isPackageGuidanceLink || updatedText.includes(PACKAGE_GUIDANCE_BLOCK_START)) {
86
+ updatedText = withPackageGuidanceBlock(updatedText, packageGuidanceText);
87
+ }
88
+ if (!isPackageGuidanceLink && updatedText === currentText) return null;
89
+ mkdirSync(codexHome, { recursive: true });
90
+ if (isPackageGuidanceLink) unlinkSync(agentsPath);
91
+ writeFileSync(agentsPath, updatedText, 'utf8');
92
+ return agentsPath;
93
+ }
@@ -0,0 +1,134 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ PACKAGE_GUIDANCE_BLOCK_END,
8
+ PACKAGE_GUIDANCE_BLOCK_START,
9
+ SKILL_LOAD_BLOCK_END,
10
+ SKILL_LOAD_BLOCK_START,
11
+ SKILL_LOAD_INSTRUCTION,
12
+ withSkillLoadBlock,
13
+ writeCodexAgentsGuidance,
14
+ } from './codex-skill-load-block.mjs';
15
+
16
+ const EXPECTED_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n${SKILL_LOAD_BLOCK_END}\n`;
17
+ const PACKAGE_GUIDANCE = 'Status\n\nChanged / proof / blocked.\n';
18
+
19
+ function guidanceBlock(guidanceText) {
20
+ return `${PACKAGE_GUIDANCE_BLOCK_START}\n${guidanceText}${PACKAGE_GUIDANCE_BLOCK_END}\n`;
21
+ }
22
+
23
+ function makeHomes(context) {
24
+ const root = mkdtempSync(join(tmpdir(), 'cde-skill-load-'));
25
+ context.after(() => rmSync(root, { recursive: true, force: true }));
26
+ const codexHome = join(root, '.codex');
27
+ const retiredGuidancePath = join(root, '.claude', 'AGENTS.md');
28
+ const sharedGuidancePath = join(root, '.agents', 'AGENTS.md');
29
+ return {
30
+ root,
31
+ codexHome,
32
+ agentsPath: join(codexHome, 'AGENTS.md'),
33
+ retiredGuidancePath,
34
+ sharedGuidancePath,
35
+ allPackageGuidancePaths: [retiredGuidancePath, sharedGuidancePath],
36
+ };
37
+ }
38
+
39
+ function writeGuidance(homes) {
40
+ return writeCodexAgentsGuidance(homes.codexHome, PACKAGE_GUIDANCE, homes.allPackageGuidancePaths);
41
+ }
42
+
43
+ test('the instruction names the skill and binds spawned helpers', () => {
44
+ assert.match(SKILL_LOAD_INSTRUCTION, /pstack:poteto-mode/);
45
+ assert.match(SKILL_LOAD_INSTRUCTION, /spawn_agent/);
46
+ });
47
+
48
+ test('a missing Codex guidance file is created holding only the skill-load block', (context) => {
49
+ const homes = makeHomes(context);
50
+
51
+ const writtenPath = writeGuidance(homes);
52
+
53
+ assert.equal(writtenPath, homes.agentsPath);
54
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), EXPECTED_BLOCK);
55
+ });
56
+
57
+ test('existing guidance keeps every line and gains the skill-load block first', (context) => {
58
+ const homes = makeHomes(context);
59
+ mkdirSync(homes.codexHome);
60
+ writeFileSync(homes.agentsPath, '# pstack model configuration\n\nbug-fix: gpt-6-astra\n');
61
+
62
+ writeGuidance(homes);
63
+
64
+ assert.equal(
65
+ readFileSync(homes.agentsPath, 'utf8'),
66
+ `${EXPECTED_BLOCK}\n# pstack model configuration\n\nbug-fix: gpt-6-astra\n`,
67
+ );
68
+ });
69
+
70
+ test('a repeat install leaves the file unchanged and reports no write', (context) => {
71
+ const homes = makeHomes(context);
72
+ mkdirSync(homes.codexHome);
73
+ writeFileSync(homes.agentsPath, 'Custom agents\n');
74
+ writeGuidance(homes);
75
+ const afterFirstInstall = readFileSync(homes.agentsPath, 'utf8');
76
+
77
+ assert.equal(writeGuidance(homes), null);
78
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), afterFirstInstall);
79
+ });
80
+
81
+ test('an outdated block is replaced in place and the text around it is kept', () => {
82
+ const outdated = `Intro\n${SKILL_LOAD_BLOCK_START}\nOld wording\n${SKILL_LOAD_BLOCK_END}\nOutro\n`;
83
+
84
+ assert.equal(withSkillLoadBlock(outdated), `Intro\n${EXPECTED_BLOCK}Outro\n`);
85
+ });
86
+
87
+ test('a start marker with no end marker gets a fresh block and keeps the stray text', () => {
88
+ const broken = `${SKILL_LOAD_BLOCK_START}\nHalf a block\n`;
89
+
90
+ assert.equal(withSkillLoadBlock(broken), `${EXPECTED_BLOCK}\n${broken}`);
91
+ });
92
+
93
+ for (const linkName of ['retiredGuidancePath', 'sharedGuidancePath']) {
94
+ test(`a link to package guidance at ${linkName} becomes a file with both blocks`, (context) => {
95
+ const homes = makeHomes(context);
96
+ mkdirSync(homes.codexHome);
97
+ symlinkSync(homes[linkName], homes.agentsPath);
98
+
99
+ const writtenPath = writeGuidance(homes);
100
+
101
+ assert.equal(writtenPath, homes.agentsPath);
102
+ assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), false);
103
+ assert.equal(
104
+ readFileSync(homes.agentsPath, 'utf8'),
105
+ `${EXPECTED_BLOCK}\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
106
+ );
107
+ });
108
+ }
109
+
110
+ test('a converted file takes the new package guidance on the next install', (context) => {
111
+ const homes = makeHomes(context);
112
+ mkdirSync(homes.codexHome);
113
+ writeFileSync(homes.agentsPath, `${EXPECTED_BLOCK}\n${guidanceBlock('Old guidance\n')}`);
114
+
115
+ writeGuidance(homes);
116
+
117
+ assert.equal(
118
+ readFileSync(homes.agentsPath, 'utf8'),
119
+ `${EXPECTED_BLOCK}\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
120
+ );
121
+ });
122
+
123
+ test('a link to a file the package does not own is left alone', (context) => {
124
+ const homes = makeHomes(context);
125
+ mkdirSync(homes.codexHome);
126
+ const ownGuidancePath = join(homes.root, 'notes', 'codex.md');
127
+ mkdirSync(join(homes.root, 'notes'));
128
+ writeFileSync(ownGuidancePath, 'My own Codex notes\n');
129
+ symlinkSync(ownGuidancePath, homes.agentsPath);
130
+
131
+ assert.equal(writeGuidance(homes), null);
132
+ assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), true);
133
+ assert.equal(readFileSync(ownGuidancePath, 'utf8'), 'My own Codex notes\n');
134
+ });
@@ -18,7 +18,7 @@ import {
18
18
  writeFileSync,
19
19
  } from 'node:fs';
20
20
  import { tmpdir } from 'node:os';
21
- import { dirname, join } from 'node:path';
21
+ import { dirname, join, resolve } from 'node:path';
22
22
  import { fileURLToPath } from 'node:url';
23
23
  import { CONTENT_DIRECTORIES } from './install.mjs';
24
24
  import {
@@ -209,6 +209,81 @@ test('a full install writes skills and agents under .agents and points .claude a
209
209
  }
210
210
  });
211
211
 
212
+ /**
213
+ * @param {string} hubPath
214
+ * @returns {string}
215
+ */
216
+ function resolveHubImportTarget(hubPath) {
217
+ const hubText = readFileSync(hubPath, 'utf8').trim();
218
+ assert.match(hubText, /^@\S+$/, 'the Claude hub holds one import line');
219
+ return resolve(dirname(hubPath), hubText.slice(1));
220
+ }
221
+
222
+ for (const eachLayout of [
223
+ { label: 'default home', extraArguments: [], claudeHomeName: '.claude', agentsHomeName: '.agents' },
224
+ {
225
+ label: 'profile root',
226
+ extraArguments: ['--target', '.claude-profile-a'],
227
+ claudeHomeName: '.claude-profile-a',
228
+ agentsHomeName: '.claude-profile-a.agents',
229
+ },
230
+ ]) {
231
+ test(`a ${eachLayout.label} install loads the package guidance from the agents home through the Claude hub`, () => {
232
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-agents-hub-'));
233
+ const claudeHome = join(homeDirectory, eachLayout.claudeHomeName);
234
+ const sharedGuidancePath = join(homeDirectory, eachLayout.agentsHomeName, 'AGENTS.md');
235
+ try {
236
+ runInstaller(homeDirectory, eachLayout.extraArguments.map(
237
+ (eachArgument) => (eachArgument.startsWith('.') ? join(homeDirectory, eachArgument) : eachArgument),
238
+ ));
239
+
240
+ assert.equal(
241
+ readFileSync(sharedGuidancePath, 'utf8'),
242
+ readFileSync(join(PACKAGE_DIRECTORY, 'AGENTS.md'), 'utf8'),
243
+ 'the package guidance lives once in the agents home',
244
+ );
245
+ assert.equal(
246
+ resolveHubImportTarget(join(claudeHome, 'CLAUDE.md')),
247
+ sharedGuidancePath,
248
+ 'the Claude hub import resolves to the shared guidance file',
249
+ );
250
+ assert.equal(
251
+ existsSync(join(claudeHome, 'AGENTS.md')),
252
+ false,
253
+ 'no second guidance copy lands in the Claude home',
254
+ );
255
+ } finally {
256
+ rmSync(homeDirectory, { recursive: true, force: true });
257
+ }
258
+ });
259
+ }
260
+
261
+ test('an upgrade moves the guidance copy an earlier install left in the Claude home into the run backup', () => {
262
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-agents-hub-upgrade-'));
263
+ const claudeHome = join(homeDirectory, '.claude');
264
+ const retiredGuidancePath = join(claudeHome, 'AGENTS.md');
265
+ const retiredGuidanceText = 'guidance an earlier install copied here\n';
266
+ try {
267
+ runInstaller(homeDirectory, []);
268
+ writeFileSync(retiredGuidancePath, retiredGuidanceText);
269
+ const manifestPath = join(claudeHome, '.claude-dev-env-manifest.json');
270
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
271
+ manifest.files.push(retiredGuidancePath);
272
+ writeFileSync(manifestPath, JSON.stringify(manifest));
273
+
274
+ runInstaller(homeDirectory, []);
275
+
276
+ assert.equal(existsSync(retiredGuidancePath), false, 'the old Claude-home copy is gone');
277
+ const backupRoot = join(claudeHome, '.claude-dev-env-pruned');
278
+ const backupName = readdirSync(backupRoot, { recursive: true })
279
+ .find(name => name.endsWith('AGENTS.md'));
280
+ assert.ok(backupName, 'the old copy sits in the run backup');
281
+ assert.equal(readFileSync(join(backupRoot, backupName), 'utf8'), retiredGuidanceText);
282
+ } finally {
283
+ rmSync(homeDirectory, { recursive: true, force: true });
284
+ }
285
+ });
286
+
212
287
  test('installs omit retired agents in each active agents home', () => {
213
288
  const runRoot = mkdtempSync(join(tmpdir(), 'cdev-active-roots-'));
214
289
  const homeDirectory = join(runRoot, 'home');
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';
@@ -44,6 +44,8 @@ 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 { writeCodexAgentsGuidance } 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, '-');
@@ -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,25 @@ 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 = writeCodexAgentsGuidance(
2792
+ INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
2793
+ readFileSync(join(PACKAGE_ROOT, 'AGENTS.md'), 'utf8'),
2794
+ [join(CLAUDE_HOME, 'AGENTS.md'), join(AGENTS_HOME, 'AGENTS.md')],
2795
+ );
2796
+ if (skillLoadGuidancePath) {
2797
+ console.log(` \u2713 ${skillLoadGuidancePath} (Codex skill-load line)`);
2798
+ }
2799
+ }
2772
2800
  for (const eachHost of pstackPlugin.hosts) {
2773
2801
  console.log(eachHost.warning
2774
2802
  ? ` Pstack (${eachHost.host}): ${eachHost.status} \u2014 ${eachHost.warning}`
@@ -2877,31 +2905,27 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2877
2905
  );
2878
2906
  }
2879
2907
 
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
2908
  const agentsHubSource = join(PACKAGE_ROOT, 'AGENTS.md');
2894
2909
  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
- );
2910
+ const agentsHubDest = join(AGENTS_HOME, 'AGENTS.md');
2911
+ const claudeHubDest = join(CLAUDE_HOME, 'CLAUDE.md');
2912
+ const claudeHubText = `@${relative(CLAUDE_HOME, agentsHubDest).split(sep).join('/')}\n`;
2913
+ const allHubWrites = [
2914
+ { destPath: agentsHubDest, text: readFileSync(agentsHubSource, 'utf8'), label: 'AGENTS.md', role: 'shared guidance' },
2915
+ { destPath: claudeHubDest, text: claudeHubText, label: 'CLAUDE.md', role: 'hub' },
2916
+ ];
2917
+ mkdirSync(AGENTS_HOME, { recursive: true });
2918
+ for (const eachHubWrite of allHubWrites) {
2919
+ const backupPath = backupHubBeforeOverwrite(eachHubWrite.destPath, eachHubWrite.text, eachHubWrite.label);
2920
+ if (backupPath) {
2921
+ console.log(
2922
+ ` \u21bb ${relative(CLAUDE_HOME, backupPath)} (previous ${eachHubWrite.label} preserved)`
2923
+ );
2924
+ }
2925
+ writeFileSync(eachHubWrite.destPath, eachHubWrite.text);
2926
+ allInstalledFiles.push(eachHubWrite.destPath);
2927
+ console.log(` \u2713 ${eachHubWrite.destPath} (${eachHubWrite.role})`);
2901
2928
  }
2902
- copyFileSync(agentsHubSource, agentsHubDest);
2903
- allInstalledFiles.push(agentsHubDest);
2904
- console.log(` \u2713 ${relative(CLAUDE_HOME, agentsHubDest)} (canonical guidance)`);
2905
2929
  }
2906
2930
  const isFullInstall = !selectedGroups;
2907
2931
  const didPruneRun = isFullInstall && UNRESOLVED_DEPENDENCY_NAMES.length === 0;
@@ -3322,10 +3346,10 @@ Profile selection (--profile/--profiles) is mutually exclusive with --target.
3322
3346
  Target selection finishes before any mutation. Each target writes its own manifest
3323
3347
  with package, version, targetIdentity, managedRoot, files, and skills.
3324
3348
 
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.
3349
+ The package guidance lands once at ~/.agents/AGENTS.md, and ~/.claude/CLAUDE.md
3350
+ holds one import line that loads it. If either file already exists with other
3351
+ contents, the installer writes the previous contents to
3352
+ ~/.claude/backups/<name>.<timestamp>.bak first.
3329
3353
  `);
3330
3354
  }
3331
3355
 
@@ -11,6 +11,7 @@ import {
11
11
  readdirSync,
12
12
  existsSync,
13
13
  copyFileSync,
14
+ lstatSync,
14
15
  } from 'node:fs';
15
16
  import { tmpdir } from 'node:os';
16
17
  import { join, dirname } from 'node:path';
@@ -52,6 +53,11 @@ import {
52
53
  retainNewestRunBackupOnly,
53
54
  } from './install.mjs';
54
55
  import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
56
+ import {
57
+ SKILL_LOAD_BLOCK_START,
58
+ SKILL_LOAD_INSTRUCTION,
59
+ withSkillLoadBlock,
60
+ } from './codex-skill-load-block.mjs';
55
61
 
56
62
 
57
63
  test('installer reports the authoritative Git hook names', () => {
@@ -3064,6 +3070,53 @@ test('a full install adds the pstack marketplace and plugin on both hosts', t =>
3064
3070
  ]);
3065
3071
  assert.match(installerOutput, /Pstack \(claude\): installed/);
3066
3072
  assert.match(installerOutput, /Pstack \(codex\): installed/);
3073
+ const codexHome = join(sandbox.homeDirectory, '.codex');
3074
+ const sheet = readFileSync(join(codexHome, 'pstack-models.md'), 'utf8');
3075
+ const agents = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
3076
+ assert.match(sheet, /^session hook: on$/m);
3077
+ assert.match(agents, /^feature, refactoring: gpt-6-sol$/m);
3078
+ assert.doesNotMatch(agents, /^session hook:/m);
3079
+ assert.ok(agents.startsWith(`${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n`));
3080
+ assert.equal(existsSync(join(sandbox.homeDirectory, '.claude', 'pstack-models.md')), false);
3081
+ const manifestPath = join(sandbox.homeDirectory, '.claude', '.claude-dev-env-manifest.json');
3082
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
3083
+ assert.equal(manifest.files.includes(join(codexHome, 'pstack-models.md')), false);
3084
+ assert.equal(manifest.files.includes(join(codexHome, 'AGENTS.md')), false);
3085
+ });
3086
+
3087
+ test('a repeat install preserves Codex pstack configuration', t => {
3088
+ const sandbox = pstackPluginSandbox(t);
3089
+ const codexHome = join(sandbox.homeDirectory, '.codex');
3090
+ runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3091
+ writeFileSync(join(codexHome, 'AGENTS.md'), 'Custom Codex guidance\n');
3092
+ writeFileSync(join(codexHome, 'pstack-models.md'), 'session hook: off\n');
3093
+
3094
+ runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3095
+
3096
+ assert.equal(
3097
+ readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'),
3098
+ withSkillLoadBlock('Custom Codex guidance\n'),
3099
+ );
3100
+ assert.equal(readFileSync(join(codexHome, 'pstack-models.md'), 'utf8'), 'session hook: off\n');
3101
+ });
3102
+
3103
+ test('a Codex guidance link to the retired Claude copy becomes a file Codex can read', t => {
3104
+ const sandbox = pstackPluginSandbox(t);
3105
+ const codexHome = join(sandbox.homeDirectory, '.codex');
3106
+ const codexAgentsPath = join(codexHome, 'AGENTS.md');
3107
+ mkdirSync(codexHome, { recursive: true });
3108
+ symlinkSync(join(sandbox.homeDirectory, '.claude', 'AGENTS.md'), codexAgentsPath);
3109
+
3110
+ runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3111
+
3112
+ const packageGuidance = readFileSync(
3113
+ join(dirname(fileURLToPath(import.meta.url)), '..', 'AGENTS.md'),
3114
+ 'utf8',
3115
+ );
3116
+ assert.equal(lstatSync(codexAgentsPath).isSymbolicLink(), false);
3117
+ const codexGuidance = readFileSync(codexAgentsPath, 'utf8');
3118
+ assert.ok(codexGuidance.startsWith(`${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n`));
3119
+ assert.ok(codexGuidance.includes(packageGuidance));
3067
3120
  });
3068
3121
 
3069
3122
  test('an absent host command is reported and leaves the other host installed', t => {
@@ -3085,6 +3138,8 @@ test('an absent host command is reported and leaves the other host installed', t
3085
3138
  sandbox.recordedCommands().filter(eachCommand => eachCommand.startsWith('codex')),
3086
3139
  [],
3087
3140
  );
3141
+ assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'pstack-models.md')), false);
3142
+ assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md')), false);
3088
3143
  });
3089
3144
 
3090
3145
  test('--no-pstack leaves both hosts untouched', t => {
@@ -3096,6 +3151,8 @@ test('--no-pstack leaves both hosts untouched', t => {
3096
3151
 
3097
3152
  assert.deepEqual(sandbox.recordedCommands(), []);
3098
3153
  assert.doesNotMatch(installerOutput, /Pstack \(/);
3154
+ assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'pstack-models.md')), false);
3155
+ assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md')), false);
3099
3156
  });
3100
3157
 
3101
3158
  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
+ }