claude-dev-env 8.28.0 → 8.29.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.
@@ -8,6 +8,8 @@ export const SKILL_LOAD_INSTRUCTION = 'Load the pstack:poteto-mode skill before
8
8
  + 'including a helper started with spawn_agent, whatever its task message says.';
9
9
  export const PACKAGE_GUIDANCE_BLOCK_START = '<!-- claude-dev-env package guidance: start -->';
10
10
  export const PACKAGE_GUIDANCE_BLOCK_END = '<!-- claude-dev-env package guidance: end -->';
11
+ export const QUESTION_PRESENTATION_BLOCK_START = '<!-- claude-dev-env question presentation: start -->';
12
+ export const QUESTION_PRESENTATION_BLOCK_END = '<!-- claude-dev-env question presentation: end -->';
11
13
 
12
14
  const WINDOWS_LONG_PATH_PREFIX = '\\\\?\\';
13
15
 
@@ -17,9 +19,10 @@ function managedBlock(blockStart, blockBody, blockEnd) {
17
19
  }
18
20
 
19
21
  function withBlockReplaced(guidanceText, blockStart, blockEnd, block) {
20
- const startIndex = guidanceText.indexOf(blockStart);
21
- const endIndex = startIndex === -1 ? -1 : guidanceText.indexOf(blockEnd, startIndex);
22
+ const firstStartIndex = guidanceText.indexOf(blockStart);
23
+ const endIndex = firstStartIndex === -1 ? -1 : guidanceText.indexOf(blockEnd, firstStartIndex);
22
24
  if (endIndex === -1) return null;
25
+ const startIndex = guidanceText.lastIndexOf(blockStart, endIndex);
23
26
  let afterBlockIndex = endIndex + blockEnd.length;
24
27
  if (guidanceText[afterBlockIndex] === '\n') afterBlockIndex += 1;
25
28
  return guidanceText.slice(0, startIndex) + block + guidanceText.slice(afterBlockIndex);
@@ -91,3 +94,48 @@ export function writeCodexAgentsGuidance(codexHome, packageGuidanceText, allPack
91
94
  writeFileSync(agentsPath, updatedText, 'utf8');
92
95
  return agentsPath;
93
96
  }
97
+
98
+ export function writeCodexQuestionGuidance(codexHome, policyText) {
99
+ const agentsPath = join(codexHome, 'AGENTS.md');
100
+ const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
101
+ if (agentsEntry && !agentsEntry.isFile()) return null;
102
+ const currentText = agentsEntry ? readFileSync(agentsPath, 'utf8') : '';
103
+ const block = managedBlock(QUESTION_PRESENTATION_BLOCK_START, policyText, QUESTION_PRESENTATION_BLOCK_END);
104
+ const replacedText = withBlockReplaced(
105
+ currentText, QUESTION_PRESENTATION_BLOCK_START, QUESTION_PRESENTATION_BLOCK_END, block,
106
+ );
107
+ const separator = currentText && !currentText.endsWith('\n') ? '\n\n' : currentText ? '\n' : '';
108
+ const updatedText = replacedText ?? `${currentText}${separator}${block}`;
109
+ if (updatedText === currentText) return null;
110
+ mkdirSync(codexHome, { recursive: true });
111
+ writeFileSync(agentsPath, updatedText, 'utf8');
112
+ return agentsPath;
113
+ }
114
+
115
+ export function hasOnlyQuestionPresentationBlock(guidanceText) {
116
+ if (!guidanceText.startsWith(`${QUESTION_PRESENTATION_BLOCK_START}\n`)) return false;
117
+ if (!guidanceText.endsWith(`${QUESTION_PRESENTATION_BLOCK_END}\n`)) return false;
118
+ return withBlockReplaced(
119
+ guidanceText, QUESTION_PRESENTATION_BLOCK_START, QUESTION_PRESENTATION_BLOCK_END, '',
120
+ ) === '';
121
+ }
122
+
123
+ /**
124
+ * Remove the managed question block while keeping surrounding guidance bytes.
125
+ *
126
+ * @param {string} codexHome
127
+ * @returns {string|null} The changed path, or null when no block was removed.
128
+ */
129
+ export function removeCodexQuestionGuidance(codexHome) {
130
+ const agentsPath = join(codexHome, 'AGENTS.md');
131
+ const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
132
+ if (!agentsEntry?.isFile()) return null;
133
+ const currentText = readFileSync(agentsPath, 'utf8');
134
+ const updatedText = withBlockReplaced(
135
+ currentText, QUESTION_PRESENTATION_BLOCK_START, QUESTION_PRESENTATION_BLOCK_END, '',
136
+ );
137
+ if (updatedText === null) return null;
138
+ if (updatedText === '') unlinkSync(agentsPath);
139
+ if (updatedText !== '') writeFileSync(agentsPath, updatedText, 'utf8');
140
+ return agentsPath;
141
+ }
@@ -2,19 +2,24 @@ import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
3
  import { lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
5
+ import { dirname, join } from 'node:path';
6
6
  import {
7
7
  PACKAGE_GUIDANCE_BLOCK_END,
8
8
  PACKAGE_GUIDANCE_BLOCK_START,
9
+ QUESTION_PRESENTATION_BLOCK_END,
10
+ QUESTION_PRESENTATION_BLOCK_START,
9
11
  SKILL_LOAD_BLOCK_END,
10
12
  SKILL_LOAD_BLOCK_START,
11
13
  SKILL_LOAD_INSTRUCTION,
14
+ removeCodexQuestionGuidance,
12
15
  withSkillLoadBlock,
13
16
  writeCodexAgentsGuidance,
17
+ writeCodexQuestionGuidance,
14
18
  } from './codex-skill-load-block.mjs';
15
19
 
16
20
  const EXPECTED_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n${SKILL_LOAD_BLOCK_END}\n`;
17
21
  const PACKAGE_GUIDANCE = 'Status\n\nChanged / proof / blocked.\n';
22
+ const QUESTION_POLICY = '# Present questions clearly\n\nAsk one question.\n';
18
23
 
19
24
  function guidanceBlock(guidanceText) {
20
25
  return `${PACKAGE_GUIDANCE_BLOCK_START}\n${guidanceText}${PACKAGE_GUIDANCE_BLOCK_END}\n`;
@@ -132,3 +137,117 @@ test('a link to a file the package does not own is left alone', (context) => {
132
137
  assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), true);
133
138
  assert.equal(readFileSync(ownGuidancePath, 'utf8'), 'My own Codex notes\n');
134
139
  });
140
+
141
+ test('question guidance creates a missing Codex file and repeat writes preserve bytes', (context) => {
142
+ const homes = makeHomes(context);
143
+ const expectedBlock = `${QUESTION_PRESENTATION_BLOCK_START}\n${QUESTION_POLICY}${QUESTION_PRESENTATION_BLOCK_END}\n`;
144
+
145
+ assert.equal(writeCodexQuestionGuidance(homes.codexHome, QUESTION_POLICY), homes.agentsPath);
146
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), expectedBlock);
147
+ assert.equal(writeCodexQuestionGuidance(homes.codexHome, QUESTION_POLICY), null);
148
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), expectedBlock);
149
+ });
150
+
151
+ test('question guidance preserves custom text and replaces only its block', (context) => {
152
+ const homes = makeHomes(context);
153
+ mkdirSync(homes.codexHome);
154
+ const oldBlock = `${QUESTION_PRESENTATION_BLOCK_START}\nOld policy\n${QUESTION_PRESENTATION_BLOCK_END}\n`;
155
+ writeFileSync(homes.agentsPath, `Before\n${oldBlock}After\n`);
156
+
157
+ assert.equal(writeCodexQuestionGuidance(homes.codexHome, QUESTION_POLICY), homes.agentsPath);
158
+ assert.equal(
159
+ readFileSync(homes.agentsPath, 'utf8'),
160
+ `Before\n${QUESTION_PRESENTATION_BLOCK_START}\n${QUESTION_POLICY}${QUESTION_PRESENTATION_BLOCK_END}\nAfter\n`,
161
+ );
162
+ });
163
+
164
+ test('question guidance appends after custom notes without changing them', (context) => {
165
+ const homes = makeHomes(context);
166
+ mkdirSync(homes.codexHome);
167
+ writeFileSync(homes.agentsPath, 'My notes without a final newline');
168
+
169
+ writeCodexQuestionGuidance(homes.codexHome, QUESTION_POLICY);
170
+
171
+ assert.ok(readFileSync(homes.agentsPath, 'utf8').startsWith('My notes without a final newline\n\n'));
172
+ });
173
+
174
+ test('question guidance leaves every symlink and its target unchanged', (context) => {
175
+ const homes = makeHomes(context);
176
+ mkdirSync(homes.codexHome);
177
+ mkdirSync(dirname(homes.sharedGuidancePath), { recursive: true });
178
+ writeFileSync(homes.sharedGuidancePath, 'Shared notes\n');
179
+ symlinkSync(homes.sharedGuidancePath, homes.agentsPath);
180
+
181
+ assert.equal(writeCodexQuestionGuidance(homes.codexHome, QUESTION_POLICY), null);
182
+ assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), true);
183
+ assert.equal(readFileSync(homes.sharedGuidancePath, 'utf8'), 'Shared notes\n');
184
+ });
185
+
186
+ test('question guidance leaves an existing directory and its notes unchanged', (context) => {
187
+ const homes = makeHomes(context);
188
+ mkdirSync(homes.agentsPath, { recursive: true });
189
+ const notesPath = join(homes.agentsPath, 'notes.md');
190
+ writeFileSync(notesPath, 'Directory notes\n');
191
+
192
+ assert.equal(writeCodexQuestionGuidance(homes.codexHome, QUESTION_POLICY), null);
193
+ assert.equal(lstatSync(homes.agentsPath).isDirectory(), true);
194
+ assert.equal(readFileSync(notesPath, 'utf8'), 'Directory notes\n');
195
+ });
196
+
197
+ test('a stray question start marker does not consume notes on repeat writes', (context) => {
198
+ const homes = makeHomes(context);
199
+ mkdirSync(homes.codexHome);
200
+ const customNotes = `${QUESTION_PRESENTATION_BLOCK_START}\nIncomplete block\nMy notes\n`;
201
+ writeFileSync(homes.agentsPath, customNotes);
202
+
203
+ writeCodexQuestionGuidance(homes.codexHome, QUESTION_POLICY);
204
+ const firstGuidance = readFileSync(homes.agentsPath, 'utf8');
205
+ assert.ok(firstGuidance.startsWith(customNotes));
206
+ assert.equal(writeCodexQuestionGuidance(homes.codexHome, QUESTION_POLICY), null);
207
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), firstGuidance);
208
+ });
209
+
210
+ for (const eachGuidance of [
211
+ 'Custom guidance\n',
212
+ `${QUESTION_PRESENTATION_BLOCK_START}\nUnfinished policy\nCustom notes\n`,
213
+ ]) {
214
+ test(`question removal preserves guidance without a complete block ${JSON.stringify(eachGuidance)}`, (context) => {
215
+ const homes = makeHomes(context);
216
+ mkdirSync(homes.codexHome, { recursive: true });
217
+ writeFileSync(homes.agentsPath, eachGuidance);
218
+
219
+ assert.equal(removeCodexQuestionGuidance(homes.codexHome), null);
220
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), eachGuidance);
221
+ });
222
+ }
223
+
224
+ test('question removal preserves a linked file even when its target holds a managed block', (context) => {
225
+ const homes = makeHomes(context);
226
+ mkdirSync(homes.codexHome, { recursive: true });
227
+ const linkedGuidance = `${QUESTION_PRESENTATION_BLOCK_START}\n${QUESTION_POLICY}${QUESTION_PRESENTATION_BLOCK_END}\n`;
228
+ mkdirSync(dirname(homes.sharedGuidancePath), { recursive: true });
229
+ writeFileSync(homes.sharedGuidancePath, linkedGuidance);
230
+ symlinkSync(homes.sharedGuidancePath, homes.agentsPath);
231
+
232
+ assert.equal(removeCodexQuestionGuidance(homes.codexHome), null);
233
+ assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), true);
234
+ assert.equal(readFileSync(homes.sharedGuidancePath, 'utf8'), linkedGuidance);
235
+ });
236
+
237
+ test('question removal preserves a guidance directory and its notes', (context) => {
238
+ const homes = makeHomes(context);
239
+ mkdirSync(homes.agentsPath, { recursive: true });
240
+ const notesPath = join(homes.agentsPath, 'notes.md');
241
+ writeFileSync(notesPath, 'Directory notes\n');
242
+
243
+ assert.equal(removeCodexQuestionGuidance(homes.codexHome), null);
244
+ assert.equal(lstatSync(homes.agentsPath).isDirectory(), true);
245
+ assert.equal(readFileSync(notesPath, 'utf8'), 'Directory notes\n');
246
+ });
247
+
248
+ test('question removal leaves a missing guidance file absent', (context) => {
249
+ const homes = makeHomes(context);
250
+
251
+ assert.equal(removeCodexQuestionGuidance(homes.codexHome), null);
252
+ assert.equal(lstatSync(homes.agentsPath, { throwIfNoEntry: false }), undefined);
253
+ });
@@ -17,6 +17,7 @@ export const EVER_SHIPPED_SKILL_NAMES = new Set([
17
17
  'comments',
18
18
  'condensing-instructions',
19
19
  'codex-review',
20
+ 'codex-cleanse',
20
21
  'context7-mcp',
21
22
  'copilot-finding-triage',
22
23
  'copilot-review',
package/bin/install.mjs CHANGED
@@ -52,7 +52,11 @@ import {
52
52
  shouldInstallPstackPlugin,
53
53
  } from './install-pstack-plugin.mjs';
54
54
  import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
55
- import { writeCodexAgentsGuidance } from './codex-skill-load-block.mjs';
55
+ import {
56
+ removeCodexQuestionGuidance,
57
+ writeCodexAgentsGuidance,
58
+ writeCodexQuestionGuidance,
59
+ } from './codex-skill-load-block.mjs';
56
60
  import {
57
61
  continuityHostConfigurationPaths,
58
62
  removeContinuityHooks,
@@ -193,6 +197,7 @@ export const CORE_INCLUDE_DIRECTORIES = [
193
197
  export const CORE_SKILLS = [
194
198
  'orchestrator', 'orchestrator-refresh', 'team-advisor',
195
199
  'grok-spawn',
200
+ 'codex-cleanse',
196
201
  'everything-search',
197
202
  'test-runner',
198
203
  'privacy-hygiene',
@@ -2504,15 +2509,6 @@ function install(selectedGroups, options = {}) {
2504
2509
  executeInstallPlan(plan);
2505
2510
  }
2506
2511
 
2507
- /**
2508
- * Apply one preflighted installation plan inside a snapshot/restore transaction.
2509
- *
2510
- * Captures the prior managed installation before any purge or write, then
2511
- * restores settings, manifest, managed files, and core.hooksPath on failure.
2512
- *
2513
- * @param {ReturnType<typeof buildInstallPlan>} plan
2514
- * @returns {void}
2515
- */
2516
2512
  function executeInstallPlan(plan) {
2517
2513
  const faultPhase = resolveFaultPhaseFromEnvironment(process.env);
2518
2514
  const snapshot = capturePriorInstallSnapshot({
@@ -2520,7 +2516,10 @@ function executeInstallPlan(plan) {
2520
2516
  manifestFilePath: MANIFEST_FILE,
2521
2517
  settingsPath: plan.settingsPath,
2522
2518
  additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
2523
- priorManifestFiles: plan.priorManifest.files,
2519
+ priorManifestFiles: [
2520
+ ...(plan.priorManifest.files || []),
2521
+ join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
2522
+ ],
2524
2523
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
2525
2524
  });
2526
2525
 
@@ -2547,16 +2546,6 @@ function executeInstallPlan(plan) {
2547
2546
  }
2548
2547
  }
2549
2548
 
2550
- /**
2551
- * Mutation body for one preflighted plan. Invoked inside the transaction wrapper.
2552
- *
2553
- * @param {ReturnType<typeof buildInstallPlan>} plan
2554
- * @param {{
2555
- * throwIfFault: (phase: string) => void,
2556
- * syncWrittenPaths: (allPaths: string[]) => void,
2557
- * }} transactionHelpers
2558
- * @returns {void}
2559
- */
2560
2549
  function executeInstallPlanMutations(plan, transactionHelpers) {
2561
2550
  const { throwIfFault, syncWrittenPaths } = transactionHelpers;
2562
2551
  const priorSettingsHookCommands = [
@@ -2617,6 +2606,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2617
2606
  ];
2618
2607
 
2619
2608
  const allInstalledFiles = [];
2609
+ const allQuestionGuidancePaths = [];
2620
2610
  const allUserOwnedPreferencePaths = new Set();
2621
2611
  const summary = {};
2622
2612
  for (const directory of CONTENT_DIRECTORIES) {
@@ -2796,7 +2786,14 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2796
2786
  : ` Pstack (${eachHost.host}): ${eachHost.status}`);
2797
2787
  }
2798
2788
  }
2799
- syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2789
+ if (!selectedGroups) {
2790
+ const questionGuidancePath = writeCodexQuestionGuidance(
2791
+ INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
2792
+ readFileSync(join(PACKAGE_ROOT, 'rules', 'question-presentation.md'), 'utf8'),
2793
+ );
2794
+ if (questionGuidancePath) allQuestionGuidancePaths.push(questionGuidancePath);
2795
+ }
2796
+ syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2800
2797
  throwIfFault(FAULT_PHASES.AFTER_FILE_STAGING);
2801
2798
  throwIfFault(FAULT_PHASES.BEFORE_DURABLE_PROMOTION);
2802
2799
  const shouldInstallAnyHooks = shouldInstallAllHooks || (allowedHookFiles && allowedHookFiles.size > 0);
@@ -2837,7 +2834,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2837
2834
  summary.hookGroups = totalHookGroups;
2838
2835
  summary.codexHookGroups = totalCodexHookGroups;
2839
2836
  console.log(` Hook groups: ${totalHookGroups} merged into settings.json, ${totalCodexHookGroups} merged into hooks.json`);
2840
- syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2837
+ syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2841
2838
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
2842
2839
 
2843
2840
  console.warn(
@@ -2859,7 +2856,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2859
2856
  console.warn(` Git hooks: ${gitHookInstallationResult.hooksPathConfigurationResult.reason}`);
2860
2857
  }
2861
2858
  console.log(` Git hook shims: ${gitHookInstallationResult.createdShimPaths.length} files (${KNOWN_GIT_HOOK_NAMES.join(', ')})`);
2862
- syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2859
+ syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2863
2860
  throwIfFault(FAULT_PHASES.AFTER_GIT_CONFIG);
2864
2861
  throwIfFault(FAULT_PHASES.AFTER_LINK_PUBLICATION);
2865
2862
 
@@ -2879,7 +2876,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2879
2876
  console.warn(` ${mypyIniInstallResult.expectedLine}`);
2880
2877
  }
2881
2878
  } else {
2882
- syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2879
+ syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2883
2880
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
2884
2881
  throwIfFault(FAULT_PHASES.AFTER_GIT_CONFIG);
2885
2882
  throwIfFault(FAULT_PHASES.AFTER_LINK_PUBLICATION);
@@ -2967,7 +2964,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2967
2964
  ? manifestFilesWithFailedPrunes(allManagedInstalledFiles, failedPrunePaths)
2968
2965
  : unionOnComparisonKey(priorManifestFiles || [], allManagedInstalledFiles);
2969
2966
  writeManifest(manifestFiles, manifestSkillNames, summary.managedPermissions.managedPermissions);
2970
- syncWrittenPaths([...allInstalledFiles, MANIFEST_FILE, plan.settingsPath, ...publishedPointerPaths]);
2967
+ syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, MANIFEST_FILE, plan.settingsPath, ...publishedPointerPaths]);
2971
2968
  throwIfFault(FAULT_PHASES.AFTER_MANIFEST_WRITE);
2972
2969
  console.log(`\nInstalled ${PACKAGE_NAME}:`);
2973
2970
  for (const directory of CONTENT_DIRECTORIES) {
@@ -3146,6 +3143,7 @@ function executeUninstallPlan(plan, helpers = {}) {
3146
3143
  ` ${plan.skippedFiles.length} manifest record(s) skipped — each names a path outside ${CLAUDE_HOME}, outside ${MYPY_INI_INSTALL_PATH}, outside ${INSTALL_ROOT_RESOLUTION.codexRulesInstallDirectory}, outside ${INSTALL_ROOT_RESOLUTION.cursorInstallDirectory}, and outside ${AGENTS_HOME}`,
3147
3144
  );
3148
3145
  }
3146
+ removeCodexQuestionGuidance(INSTALL_ROOT_RESOLUTION.codexHomeDirectory);
3149
3147
  throwIfFault(FAULT_PHASES.AFTER_FILE_STAGING);
3150
3148
 
3151
3149
  if (existsSync(plan.settingsPath)) {
@@ -3258,7 +3256,10 @@ function uninstall() {
3258
3256
  manifestFilePath: MANIFEST_FILE,
3259
3257
  settingsPath: plan.settingsPath,
3260
3258
  additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
3261
- priorManifestFiles: plan.removableFiles,
3259
+ priorManifestFiles: [
3260
+ ...plan.removableFiles,
3261
+ join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
3262
+ ],
3262
3263
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
3263
3264
  });
3264
3265
 
@@ -0,0 +1,264 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const packageDirectory = fileURLToPath(new URL('..', import.meta.url));
10
+ const installerPath = join(packageDirectory, 'bin', 'install.mjs');
11
+ const policyPath = join(packageDirectory, 'rules', 'question-presentation.md');
12
+
13
+ function makeScratchHome(context) {
14
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-question-'));
15
+ context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
16
+ return homeDirectory;
17
+ }
18
+
19
+ function installInScratchHome(homeDirectory, argumentsList = [], extraEnvironment = {}) {
20
+ return spawnSync(process.execPath, [installerPath, ...argumentsList], {
21
+ cwd: packageDirectory,
22
+ encoding: 'utf8',
23
+ env: {
24
+ ...process.env,
25
+ CDE_INSTALL_PSTACK: '0',
26
+ HOME: homeDirectory,
27
+ USERPROFILE: homeDirectory,
28
+ CODEX_HOME: join(homeDirectory, '.codex'),
29
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
30
+ ...extraEnvironment,
31
+ },
32
+ });
33
+ }
34
+
35
+ test('full install with pstack disabled delivers one question policy to Claude and Codex', (context) => {
36
+ const homeDirectory = makeScratchHome(context);
37
+ const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
38
+ const codexHooksPath = join(homeDirectory, '.codex', 'config.toml');
39
+ const hooksConfiguration = '[features]\nhooks = false\n';
40
+ mkdirSync(dirname(codexHooksPath), { recursive: true });
41
+ writeFileSync(codexHooksPath, hooksConfiguration);
42
+
43
+ const installation = installInScratchHome(homeDirectory, ['--no-pstack']);
44
+
45
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
46
+ const policyText = readFileSync(policyPath, 'utf8');
47
+ assert.equal(readFileSync(join(homeDirectory, '.claude', 'rules', 'question-presentation.md'), 'utf8'), policyText);
48
+ assert.match(readFileSync(codexGuidancePath, 'utf8'), /<!-- claude-dev-env question presentation: start -->/);
49
+ assert.ok(readFileSync(codexGuidancePath, 'utf8').includes(policyText));
50
+ assert.equal(readFileSync(codexHooksPath, 'utf8'), hooksConfiguration);
51
+ const manifestPath = join(homeDirectory, '.claude', '.claude-dev-env-manifest.json');
52
+ assert.equal(JSON.parse(readFileSync(manifestPath, 'utf8')).files.includes(codexGuidancePath), false);
53
+ });
54
+
55
+ test('full install preserves custom Codex notes and keeps the question block stable on repeat', (context) => {
56
+ const homeDirectory = makeScratchHome(context);
57
+ const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
58
+ mkdirSync(dirname(codexGuidancePath), { recursive: true });
59
+ writeFileSync(codexGuidancePath, '# My notes\n\nKeep this line.\n');
60
+
61
+ const firstInstallation = installInScratchHome(homeDirectory);
62
+ assert.equal(firstInstallation.status, 0, firstInstallation.stdout + firstInstallation.stderr);
63
+ const firstGuidance = readFileSync(codexGuidancePath, 'utf8');
64
+ assert.ok(firstGuidance.startsWith('# My notes\n\nKeep this line.\n'));
65
+ assert.ok(firstGuidance.includes(readFileSync(policyPath, 'utf8')));
66
+
67
+ const secondInstallation = installInScratchHome(homeDirectory);
68
+ assert.equal(secondInstallation.status, 0, secondInstallation.stdout + secondInstallation.stderr);
69
+ assert.equal(readFileSync(codexGuidancePath, 'utf8'), firstGuidance);
70
+ });
71
+
72
+ test('uninstall removes question-only Codex guidance', (context) => {
73
+ const homeDirectory = makeScratchHome(context);
74
+ const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
75
+ const installation = installInScratchHome(homeDirectory);
76
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
77
+
78
+ const removal = installInScratchHome(homeDirectory, ['--uninstall']);
79
+
80
+ assert.equal(removal.status, 0, removal.stdout + removal.stderr);
81
+ assert.equal(existsSync(codexGuidancePath), false);
82
+ });
83
+
84
+ test('uninstall removes the question block and preserves surrounding custom guidance', (context) => {
85
+ const homeDirectory = makeScratchHome(context);
86
+ const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
87
+ mkdirSync(dirname(codexGuidancePath), { recursive: true });
88
+ const customGuidance = '# My notes\n\nKeep this line.\n';
89
+ writeFileSync(codexGuidancePath, customGuidance);
90
+ const installation = installInScratchHome(homeDirectory);
91
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
92
+ writeFileSync(codexGuidancePath, `${readFileSync(codexGuidancePath, 'utf8')}Trailing notes\n`);
93
+
94
+ const removal = installInScratchHome(homeDirectory, ['--uninstall']);
95
+
96
+ assert.equal(removal.status, 0, removal.stdout + removal.stderr);
97
+ assert.equal(readFileSync(codexGuidancePath, 'utf8'), `${customGuidance}\nTrailing notes\n`);
98
+ });
99
+
100
+ for (const eachPriorGuidance of [null, '# Personal guidance\n']) {
101
+ test(`an uninstall staging fault restores ${eachPriorGuidance ? 'custom' : 'question-only'} Codex guidance`, (context) => {
102
+ const homeDirectory = makeScratchHome(context);
103
+ const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
104
+ if (eachPriorGuidance !== null) {
105
+ mkdirSync(dirname(codexGuidancePath), { recursive: true });
106
+ writeFileSync(codexGuidancePath, eachPriorGuidance);
107
+ }
108
+ const installation = installInScratchHome(homeDirectory);
109
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
110
+ const installedGuidance = readFileSync(codexGuidancePath, 'utf8');
111
+
112
+ const failedRemoval = installInScratchHome(homeDirectory, ['--uninstall'], {
113
+ CLAUDE_DEV_ENV_INSTALL_FAULT: 'after_file_staging',
114
+ });
115
+
116
+ assert.notEqual(failedRemoval.status, 0, failedRemoval.stdout + failedRemoval.stderr);
117
+ assert.match(failedRemoval.stderr, /after_file_staging/);
118
+ assert.equal(readFileSync(codexGuidancePath, 'utf8'), installedGuidance);
119
+ });
120
+ }
121
+
122
+ test('full install leaves linked Codex guidance and its target unchanged', (context) => {
123
+ const homeDirectory = makeScratchHome(context);
124
+ const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
125
+ const userGuidancePath = join(homeDirectory, 'my-guidance.md');
126
+ mkdirSync(dirname(codexGuidancePath), { recursive: true });
127
+ writeFileSync(userGuidancePath, 'My linked notes\n');
128
+ symlinkSync(userGuidancePath, codexGuidancePath);
129
+
130
+ const installation = installInScratchHome(homeDirectory);
131
+
132
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
133
+ assert.equal(lstatSync(codexGuidancePath).isSymbolicLink(), true);
134
+ assert.equal(readFileSync(userGuidancePath, 'utf8'), 'My linked notes\n');
135
+ });
136
+
137
+ test('full install with pstack disabled preserves an AGENTS.md directory and delivers Claude policy', (context) => {
138
+ const homeDirectory = makeScratchHome(context);
139
+ const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
140
+ mkdirSync(codexGuidancePath, { recursive: true });
141
+ const notesPath = join(codexGuidancePath, 'notes.md');
142
+ writeFileSync(notesPath, 'Directory notes\n');
143
+
144
+ const installation = installInScratchHome(homeDirectory, ['--no-pstack']);
145
+
146
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
147
+ assert.equal(lstatSync(codexGuidancePath).isDirectory(), true);
148
+ assert.equal(readFileSync(notesPath, 'utf8'), 'Directory notes\n');
149
+ assert.equal(
150
+ readFileSync(join(homeDirectory, '.claude', 'rules', 'question-presentation.md'), 'utf8'),
151
+ readFileSync(policyPath, 'utf8'),
152
+ );
153
+ });
154
+
155
+ test('a selected Claude profile receives the source rule and its Codex guidance', (context) => {
156
+ const homeDirectory = makeScratchHome(context);
157
+ const profileDirectory = join(homeDirectory, 'profile');
158
+
159
+ const installation = installInScratchHome(homeDirectory, ['--target', profileDirectory]);
160
+
161
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
162
+ assert.equal(
163
+ readFileSync(join(profileDirectory, 'rules', 'question-presentation.md'), 'utf8'),
164
+ readFileSync(policyPath, 'utf8'),
165
+ );
166
+ assert.ok(readFileSync(join(profileDirectory, '.codex', 'AGENTS.md'), 'utf8').includes(readFileSync(policyPath, 'utf8')));
167
+ });
168
+
169
+ test('a successful pstack install retains its skill block and gains the question block', (context) => {
170
+ const homeDirectory = makeScratchHome(context);
171
+ const commandPath = join(homeDirectory, process.platform === 'win32' ? 'codex.cmd' : 'codex');
172
+ writeFileSync(
173
+ commandPath,
174
+ process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
175
+ { mode: 0o755 },
176
+ );
177
+
178
+ const installation = installInScratchHome(homeDirectory, [], {
179
+ CDE_INSTALL_PSTACK: '1',
180
+ CDE_CODEX_EXECUTABLE: commandPath,
181
+ CDE_CLAUDE_EXECUTABLE: commandPath,
182
+ });
183
+
184
+ assert.equal(installation.status, 0, installation.stdout + installation.stderr);
185
+ const guidanceText = readFileSync(join(homeDirectory, '.codex', 'AGENTS.md'), 'utf8');
186
+ assert.match(guidanceText, /<!-- claude-dev-env skill load: start -->/);
187
+ assert.match(guidanceText, /<!-- claude-dev-env question presentation: start -->/);
188
+ assert.ok(guidanceText.includes(readFileSync(policyPath, 'utf8')));
189
+ });
190
+
191
+ test('enabling pstack after a pstack-off install preserves question guidance', (context) => {
192
+ const homeDirectory = makeScratchHome(context);
193
+ const commandPath = join(homeDirectory, process.platform === 'win32' ? 'codex.cmd' : 'codex');
194
+ writeFileSync(
195
+ commandPath,
196
+ process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
197
+ { mode: 0o755 },
198
+ );
199
+ const firstInstallation = installInScratchHome(homeDirectory);
200
+ assert.equal(firstInstallation.status, 0, firstInstallation.stdout + firstInstallation.stderr);
201
+ const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
202
+ const firstGuidance = readFileSync(codexGuidancePath, 'utf8');
203
+
204
+ const secondInstallation = installInScratchHome(homeDirectory, [], {
205
+ CDE_INSTALL_PSTACK: '1',
206
+ CDE_CODEX_EXECUTABLE: commandPath,
207
+ CDE_CLAUDE_EXECUTABLE: commandPath,
208
+ });
209
+
210
+ assert.equal(secondInstallation.status, 0, secondInstallation.stdout + secondInstallation.stderr);
211
+ const updatedGuidance = readFileSync(codexGuidancePath, 'utf8');
212
+ assert.ok(updatedGuidance.includes(firstGuidance));
213
+ assert.match(updatedGuidance, /<!-- claude-dev-env skill load: start -->/);
214
+ assert.match(updatedGuidance, /^feature, refactoring: gpt-6-sol$/m);
215
+ assert.match(readFileSync(join(homeDirectory, '.codex', 'pstack-models.md'), 'utf8'), /^session hook: on$/m);
216
+ });
217
+
218
+ test('a failed pstack transition restores question-only guidance and removes its new model sheet', (context) => {
219
+ const homeDirectory = makeScratchHome(context);
220
+ const commandPath = join(homeDirectory, process.platform === 'win32' ? 'codex.cmd' : 'codex');
221
+ writeFileSync(
222
+ commandPath,
223
+ process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
224
+ { mode: 0o755 },
225
+ );
226
+ const firstInstallation = installInScratchHome(homeDirectory);
227
+ assert.equal(firstInstallation.status, 0, firstInstallation.stdout + firstInstallation.stderr);
228
+ const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
229
+ const priorGuidance = readFileSync(codexGuidancePath, 'utf8');
230
+
231
+ const failedInstallation = installInScratchHome(homeDirectory, [], {
232
+ CDE_INSTALL_PSTACK: '1',
233
+ CDE_CODEX_EXECUTABLE: commandPath,
234
+ CDE_CLAUDE_EXECUTABLE: commandPath,
235
+ CLAUDE_DEV_ENV_INSTALL_FAULT: 'after_file_staging',
236
+ });
237
+
238
+ assert.notEqual(failedInstallation.status, 0, failedInstallation.stdout + failedInstallation.stderr);
239
+ assert.equal(readFileSync(codexGuidancePath, 'utf8'), priorGuidance);
240
+ assert.equal(existsSync(join(homeDirectory, '.codex', 'pstack-models.md')), false);
241
+ });
242
+
243
+ for (const priorGuidance of [null, '# Personal guidance\n']) {
244
+ test(`a staging fault restores ${priorGuidance ? 'existing' : 'missing'} Codex guidance`, (context) => {
245
+ const homeDirectory = makeScratchHome(context);
246
+ const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
247
+ if (priorGuidance !== null) {
248
+ mkdirSync(dirname(codexGuidancePath), { recursive: true });
249
+ writeFileSync(codexGuidancePath, priorGuidance);
250
+ }
251
+
252
+ const installation = installInScratchHome(homeDirectory, [], {
253
+ CLAUDE_DEV_ENV_INSTALL_FAULT: 'after_file_staging',
254
+ });
255
+
256
+ assert.notEqual(installation.status, 0, installation.stdout + installation.stderr);
257
+ assert.match(installation.stderr, /after_file_staging/);
258
+ if (priorGuidance === null) {
259
+ assert.equal(existsSync(codexGuidancePath), false);
260
+ } else {
261
+ assert.equal(readFileSync(codexGuidancePath, 'utf8'), priorGuidance);
262
+ }
263
+ });
264
+ }