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.
- package/.agents/skills/codex-cleanse/SKILL.md +36 -0
- package/.agents/skills/codex-cleanse/config/constants.mjs +11 -0
- package/.agents/skills/codex-cleanse/scripts/cleanse.mjs +458 -0
- package/.agents/skills/codex-cleanse/scripts/cleanse.test.mjs +708 -0
- package/bin/codex-skill-load-block.mjs +50 -2
- package/bin/codex-skill-load-block.test.mjs +120 -1
- package/bin/ever-shipped-skills.mjs +1 -0
- package/bin/install.mjs +28 -27
- package/bin/install.question-presentation.test.mjs +264 -0
- package/bin/install.test.mjs +13 -7
- package/bin/seed-codex-pstack-models.mjs +13 -2
- package/bin/seed-codex-pstack-models.test.mjs +45 -1
- package/package.json +1 -1
- package/rules/question-presentation.md +12 -0
- package/rules/verify-before-asking.md +3 -3
|
@@ -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
|
|
21
|
-
const endIndex =
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|