claude-dev-env 8.28.0 → 8.28.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.
@@ -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
+ });
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,
@@ -2504,15 +2508,6 @@ function install(selectedGroups, options = {}) {
2504
2508
  executeInstallPlan(plan);
2505
2509
  }
2506
2510
 
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
2511
  function executeInstallPlan(plan) {
2517
2512
  const faultPhase = resolveFaultPhaseFromEnvironment(process.env);
2518
2513
  const snapshot = capturePriorInstallSnapshot({
@@ -2520,7 +2515,10 @@ function executeInstallPlan(plan) {
2520
2515
  manifestFilePath: MANIFEST_FILE,
2521
2516
  settingsPath: plan.settingsPath,
2522
2517
  additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
2523
- priorManifestFiles: plan.priorManifest.files,
2518
+ priorManifestFiles: [
2519
+ ...(plan.priorManifest.files || []),
2520
+ join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
2521
+ ],
2524
2522
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
2525
2523
  });
2526
2524
 
@@ -2547,16 +2545,6 @@ function executeInstallPlan(plan) {
2547
2545
  }
2548
2546
  }
2549
2547
 
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
2548
  function executeInstallPlanMutations(plan, transactionHelpers) {
2561
2549
  const { throwIfFault, syncWrittenPaths } = transactionHelpers;
2562
2550
  const priorSettingsHookCommands = [
@@ -2617,6 +2605,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2617
2605
  ];
2618
2606
 
2619
2607
  const allInstalledFiles = [];
2608
+ const allQuestionGuidancePaths = [];
2620
2609
  const allUserOwnedPreferencePaths = new Set();
2621
2610
  const summary = {};
2622
2611
  for (const directory of CONTENT_DIRECTORIES) {
@@ -2796,7 +2785,14 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2796
2785
  : ` Pstack (${eachHost.host}): ${eachHost.status}`);
2797
2786
  }
2798
2787
  }
2799
- syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2788
+ if (!selectedGroups) {
2789
+ const questionGuidancePath = writeCodexQuestionGuidance(
2790
+ INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
2791
+ readFileSync(join(PACKAGE_ROOT, 'rules', 'question-presentation.md'), 'utf8'),
2792
+ );
2793
+ if (questionGuidancePath) allQuestionGuidancePaths.push(questionGuidancePath);
2794
+ }
2795
+ syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2800
2796
  throwIfFault(FAULT_PHASES.AFTER_FILE_STAGING);
2801
2797
  throwIfFault(FAULT_PHASES.BEFORE_DURABLE_PROMOTION);
2802
2798
  const shouldInstallAnyHooks = shouldInstallAllHooks || (allowedHookFiles && allowedHookFiles.size > 0);
@@ -2837,7 +2833,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2837
2833
  summary.hookGroups = totalHookGroups;
2838
2834
  summary.codexHookGroups = totalCodexHookGroups;
2839
2835
  console.log(` Hook groups: ${totalHookGroups} merged into settings.json, ${totalCodexHookGroups} merged into hooks.json`);
2840
- syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2836
+ syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2841
2837
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
2842
2838
 
2843
2839
  console.warn(
@@ -2859,7 +2855,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2859
2855
  console.warn(` Git hooks: ${gitHookInstallationResult.hooksPathConfigurationResult.reason}`);
2860
2856
  }
2861
2857
  console.log(` Git hook shims: ${gitHookInstallationResult.createdShimPaths.length} files (${KNOWN_GIT_HOOK_NAMES.join(', ')})`);
2862
- syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2858
+ syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2863
2859
  throwIfFault(FAULT_PHASES.AFTER_GIT_CONFIG);
2864
2860
  throwIfFault(FAULT_PHASES.AFTER_LINK_PUBLICATION);
2865
2861
 
@@ -2879,7 +2875,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2879
2875
  console.warn(` ${mypyIniInstallResult.expectedLine}`);
2880
2876
  }
2881
2877
  } else {
2882
- syncWrittenPaths([...allInstalledFiles, ...publishedPointerPaths]);
2878
+ syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2883
2879
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
2884
2880
  throwIfFault(FAULT_PHASES.AFTER_GIT_CONFIG);
2885
2881
  throwIfFault(FAULT_PHASES.AFTER_LINK_PUBLICATION);
@@ -2967,7 +2963,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2967
2963
  ? manifestFilesWithFailedPrunes(allManagedInstalledFiles, failedPrunePaths)
2968
2964
  : unionOnComparisonKey(priorManifestFiles || [], allManagedInstalledFiles);
2969
2965
  writeManifest(manifestFiles, manifestSkillNames, summary.managedPermissions.managedPermissions);
2970
- syncWrittenPaths([...allInstalledFiles, MANIFEST_FILE, plan.settingsPath, ...publishedPointerPaths]);
2966
+ syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, MANIFEST_FILE, plan.settingsPath, ...publishedPointerPaths]);
2971
2967
  throwIfFault(FAULT_PHASES.AFTER_MANIFEST_WRITE);
2972
2968
  console.log(`\nInstalled ${PACKAGE_NAME}:`);
2973
2969
  for (const directory of CONTENT_DIRECTORIES) {
@@ -3146,6 +3142,7 @@ function executeUninstallPlan(plan, helpers = {}) {
3146
3142
  ` ${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
3143
  );
3148
3144
  }
3145
+ removeCodexQuestionGuidance(INSTALL_ROOT_RESOLUTION.codexHomeDirectory);
3149
3146
  throwIfFault(FAULT_PHASES.AFTER_FILE_STAGING);
3150
3147
 
3151
3148
  if (existsSync(plan.settingsPath)) {
@@ -3258,7 +3255,10 @@ function uninstall() {
3258
3255
  manifestFilePath: MANIFEST_FILE,
3259
3256
  settingsPath: plan.settingsPath,
3260
3257
  additionalSettingsPaths: [CODEX_HOOKS_CONFIGURATION_PATH],
3261
- priorManifestFiles: plan.removableFiles,
3258
+ priorManifestFiles: [
3259
+ ...plan.removableFiles,
3260
+ join(INSTALL_ROOT_RESOLUTION.codexHomeDirectory, 'AGENTS.md'),
3261
+ ],
3262
3262
  journalParentDirectory: join(CLAUDE_HOME, TRANSACTION_JOURNAL_DIRECTORY_NAME),
3263
3263
  });
3264
3264
 
@@ -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
+ }
@@ -56,6 +56,7 @@ import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
56
56
  import {
57
57
  SKILL_LOAD_BLOCK_START,
58
58
  SKILL_LOAD_INSTRUCTION,
59
+ QUESTION_PRESENTATION_BLOCK_START,
59
60
  withSkillLoadBlock,
60
61
  } from './codex-skill-load-block.mjs';
61
62
 
@@ -3094,10 +3095,9 @@ test('a repeat install preserves Codex pstack configuration', t => {
3094
3095
 
3095
3096
  runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3096
3097
 
3097
- assert.equal(
3098
- readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'),
3099
- withSkillLoadBlock('Custom Codex guidance\n'),
3100
- );
3098
+ const codexGuidance = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
3099
+ assert.ok(codexGuidance.startsWith(withSkillLoadBlock('Custom Codex guidance\n')));
3100
+ assert.ok(codexGuidance.includes(QUESTION_PRESENTATION_BLOCK_START));
3101
3101
  assert.equal(readFileSync(join(codexHome, 'pstack-models.md'), 'utf8'), 'session hook: off\n');
3102
3102
  });
3103
3103
 
@@ -3140,10 +3140,13 @@ test('an absent host command is reported and leaves the other host installed', t
3140
3140
  [],
3141
3141
  );
3142
3142
  assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'pstack-models.md')), false);
3143
- assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md')), false);
3143
+ assert.match(
3144
+ readFileSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md'), 'utf8'),
3145
+ /<!-- claude-dev-env question presentation: start -->/,
3146
+ );
3144
3147
  });
3145
3148
 
3146
- test('--no-pstack leaves both hosts untouched', t => {
3149
+ test('--no-pstack skips the plugin and delivers Codex question guidance', t => {
3147
3150
  const sandbox = pstackPluginSandbox(t);
3148
3151
 
3149
3152
  const installerOutput = runPstackInstaller(
@@ -3153,7 +3156,10 @@ test('--no-pstack leaves both hosts untouched', t => {
3153
3156
  assert.deepEqual(sandbox.recordedCommands(), []);
3154
3157
  assert.doesNotMatch(installerOutput, /Pstack \(/);
3155
3158
  assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'pstack-models.md')), false);
3156
- assert.equal(existsSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md')), false);
3159
+ assert.match(
3160
+ readFileSync(join(sandbox.homeDirectory, '.codex', 'AGENTS.md'), 'utf8'),
3161
+ /<!-- claude-dev-env question presentation: start -->/,
3162
+ );
3157
3163
  });
3158
3164
 
3159
3165
  function continuityCommandCount(configurationPath) {
@@ -1,6 +1,7 @@
1
1
  import { copyFileSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, constants as filesystemConstants } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { hasOnlyQuestionPresentationBlock } from './codex-skill-load-block.mjs';
4
5
 
5
6
  const CODEX_PRESET_PATH = fileURLToPath(new URL('../pstack-codex-models.md', import.meta.url));
6
7
 
@@ -11,14 +12,24 @@ function pathExists(path) {
11
12
  export function seedCodexPstackModels(codexHome, writeAgentGuidance = writeFileSync) {
12
13
  const sheetPath = join(codexHome, 'pstack-models.md');
13
14
  const agentsPath = join(codexHome, 'AGENTS.md');
14
- if (pathExists(sheetPath) || pathExists(agentsPath)) return null;
15
+ if (pathExists(sheetPath)) return null;
16
+ const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
17
+ if (agentsEntry && !agentsEntry.isFile()) return null;
18
+ const existingGuidance = agentsEntry ? readFileSync(agentsPath, 'utf8') : '';
19
+ if (agentsEntry && !hasOnlyQuestionPresentationBlock(existingGuidance)) return null;
15
20
 
16
21
  const preset = readFileSync(CODEX_PRESET_PATH, 'utf8');
17
22
  const agentGuidance = preset.replace(/^session hook: on\n?$/m, '');
23
+ const separator = agentGuidance.endsWith('\n') ? '\n' : '\n\n';
24
+ const combinedGuidance = existingGuidance
25
+ ? `${agentGuidance}${separator}${existingGuidance}`
26
+ : agentGuidance;
18
27
  mkdirSync(codexHome, { recursive: true });
19
28
  copyFileSync(CODEX_PRESET_PATH, sheetPath, filesystemConstants.COPYFILE_EXCL);
20
29
  try {
21
- writeAgentGuidance(agentsPath, agentGuidance, { encoding: 'utf8', flag: 'wx' });
30
+ writeAgentGuidance(agentsPath, combinedGuidance, {
31
+ encoding: 'utf8', flag: agentsEntry ? 'w' : 'wx',
32
+ });
22
33
  } catch (writeError) {
23
34
  unlinkSync(sheetPath);
24
35
  throw writeError;
@@ -1,9 +1,12 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
- import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
7
+ import { QUESTION_PRESENTATION_BLOCK_END, QUESTION_PRESENTATION_BLOCK_START } from './codex-skill-load-block.mjs';
8
+
9
+ const QUESTION_GUIDANCE = `${QUESTION_PRESENTATION_BLOCK_START}\nAsk one question.\n${QUESTION_PRESENTATION_BLOCK_END}\n`;
7
10
 
8
11
  test('fresh Codex home gets the selected roles and hook setting', (context) => {
9
12
  const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
@@ -70,3 +73,44 @@ for (const existingFile of ['AGENTS.md', 'pstack-models.md']) {
70
73
  assert.equal(existsSync(join(codexHome, existingFile === 'AGENTS.md' ? 'pstack-models.md' : 'AGENTS.md')), false);
71
74
  });
72
75
  }
76
+
77
+ test('a question-only Codex file receives model defaults and keeps its block', (context) => {
78
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
79
+ context.after(() => rmSync(root, { recursive: true, force: true }));
80
+ const codexHome = join(root, '.codex');
81
+ mkdirSync(codexHome);
82
+ writeFileSync(join(codexHome, 'AGENTS.md'), QUESTION_GUIDANCE);
83
+
84
+ const paths = seedCodexPstackModels(codexHome);
85
+
86
+ assert.deepEqual(paths, [join(codexHome, 'pstack-models.md'), join(codexHome, 'AGENTS.md')]);
87
+ assert.match(readFileSync(join(codexHome, 'pstack-models.md'), 'utf8'), /^session hook: on$/m);
88
+ const agents = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
89
+ assert.match(agents, /^feature, refactoring: gpt-6-sol$/m);
90
+ assert.ok(agents.endsWith(QUESTION_GUIDANCE));
91
+ });
92
+
93
+ test('a question block with personal notes does not trigger model seeding', (context) => {
94
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
95
+ context.after(() => rmSync(root, { recursive: true, force: true }));
96
+ const codexHome = join(root, '.codex');
97
+ mkdirSync(codexHome);
98
+ writeFileSync(join(codexHome, 'AGENTS.md'), `${QUESTION_GUIDANCE}My notes\n`);
99
+
100
+ assert.equal(seedCodexPstackModels(codexHome), null);
101
+ assert.equal(existsSync(join(codexHome, 'pstack-models.md')), false);
102
+ });
103
+
104
+ test('a linked question-only Codex file does not trigger model seeding', (context) => {
105
+ const root = mkdtempSync(join(tmpdir(), 'cde-pstack-models-'));
106
+ context.after(() => rmSync(root, { recursive: true, force: true }));
107
+ const codexHome = join(root, '.codex');
108
+ const linkedGuidancePath = join(root, 'linked-guidance.md');
109
+ mkdirSync(codexHome);
110
+ writeFileSync(linkedGuidancePath, QUESTION_GUIDANCE);
111
+ symlinkSync(linkedGuidancePath, join(codexHome, 'AGENTS.md'));
112
+
113
+ assert.equal(seedCodexPstackModels(codexHome), null);
114
+ assert.equal(lstatSync(join(codexHome, 'AGENTS.md')).isSymbolicLink(), true);
115
+ assert.equal(readFileSync(linkedGuidancePath, 'utf8'), QUESTION_GUIDANCE);
116
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.28.0",
3
+ "version": "8.28.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,12 @@
1
+ # Present questions clearly
2
+
3
+ Before asking for input, give a short formatted brief in chat. Put the context and the effect of each choice there. Add a visual or artifact only when it helps the decision.
4
+
5
+ For Codex, when a visual comparison helps, discover callable tools for Page creation, visualization upload, and Page opening. Create the comparison, upload its visualization, and open its Page in the current task before asking. If a tool is unavailable, creation, upload, or opening fails, or opening queues, put the comparison in chat before asking. Inline Visualize references appear only in final replies. Use the user's submitted answer for the decision. An async dispatch acknowledgement or saved visual supplies context only. Wait for the answer before work that depends on it.
6
+
7
+ Ask one short, self-contained question with two or three clear choices. Use short labels, and keep each supported choice description to one short sentence. Use the same labels in the chat brief, comparison, and picker.
8
+
9
+ Discover the current session's native question tool and its schema. Claude may expose `AskUserQuestion`.
10
+ Codex may expose `request_user_input_async` or `request_user_input`. Follow the tool's mode restrictions.
11
+ Put the question in its declared title or question field and choices in its declared string or object fields.
12
+ When no native tool is usable, ask the short question and choices in chat.
@@ -8,7 +8,7 @@ If a question can be answered by inspecting files, running a command, querying a
8
8
 
9
9
  ## Decision Checklist
10
10
 
11
- Before writing any AskUserQuestion or asking a clarifying question in chat, evaluate:
11
+ Before using the current session's native question tool or asking a clarifying question in chat, evaluate:
12
12
 
13
13
  | Check | Action |
14
14
  |---|---|
@@ -21,14 +21,14 @@ Before writing any AskUserQuestion or asking a clarifying question in chat, eval
21
21
  | Is the answer retrievable from any available MCP tool? | Use the tool. |
22
22
  | Did the user already state a criterion, standard, or line that decides this? | Apply it, state the call and the reason, and keep going. |
23
23
 
24
- Only after confirming the answer cannot be obtained through any available tool, ask the user.
24
+ Only after confirming the answer cannot be obtained through any available tool, ask the user. Present the question using [Present questions clearly](question-presentation.md).
25
25
 
26
26
  ## Prior-session facts expire
27
27
 
28
28
  A path, port, branch name, or config value you recall from an earlier session counts as unanswered until a tool re-checks it this session. Memory records what was true when it was written; the file may have moved, the port may be down, the branch may have merged. Treat every recalled fact as a claim to re-ground, not an answer to reuse.
29
29
 
30
30
  - When a tool can settle it, re-check in silence and act on the fresh result — no question to the user.
31
- - When no tool can settle it and the user has a stake in the answer, ask through `AskUserQuestion`.
31
+ - When no tool can settle it and the user has a stake in the answer, use the current session's native question tool when available.
32
32
 
33
33
  ## Questions That Belong to the User
34
34