claude-dev-env 8.27.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.
- package/bin/codex-skill-load-block.mjs +50 -2
- package/bin/codex-skill-load-block.test.mjs +120 -1
- package/bin/export-pstack-skills.mjs +71 -0
- package/bin/export-pstack-skills.test.mjs +64 -0
- package/bin/install.mjs +27 -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
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { cpSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
function inventory(directory, prefix = '') {
|
|
7
|
+
return readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))
|
|
8
|
+
.flatMap(entry => {
|
|
9
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
10
|
+
const entryPath = join(directory, entry.name);
|
|
11
|
+
if (entry.isDirectory()) return inventory(entryPath, relativePath);
|
|
12
|
+
if (!entry.isFile()) throw new Error(`Unsupported source entry: ${relativePath}`);
|
|
13
|
+
return [[relativePath, createHash('sha256').update(readFileSync(entryPath)).digest('hex')]];
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function exportPstackSkills(pluginRoot, destination) {
|
|
18
|
+
const source = resolve(pluginRoot);
|
|
19
|
+
const target = resolve(destination);
|
|
20
|
+
const manifest = JSON.parse(readFileSync(join(source, '.codex-plugin', 'plugin.json'), 'utf8'));
|
|
21
|
+
if (manifest.name !== 'pstack' || manifest.skills !== './skills/' || !manifest.version || !manifest.repository) {
|
|
22
|
+
throw new Error('Expected a versioned pstack Codex plugin with a skills directory and repository.');
|
|
23
|
+
}
|
|
24
|
+
if (lstatSync(target, { throwIfNoEntry: false })) throw new Error(`Destination already exists: ${target}`);
|
|
25
|
+
const skillsRoot = join(source, 'skills');
|
|
26
|
+
const poteto = readFileSync(join(skillsRoot, 'poteto-mode', 'SKILL.md'), 'utf8');
|
|
27
|
+
if (!/^name: poteto-mode\r?$/m.test(poteto)) throw new Error('Missing upstream poteto-mode metadata.');
|
|
28
|
+
const files = Object.fromEntries(inventory(skillsRoot));
|
|
29
|
+
const license = readFileSync(join(skillsRoot, 'poteto-mode', 'references', 'licenses', 'LICENSE'));
|
|
30
|
+
const receipt = {
|
|
31
|
+
repository: manifest.repository,
|
|
32
|
+
version: manifest.version,
|
|
33
|
+
sourceManifestSha256: createHash('sha256').update(readFileSync(join(source, '.codex-plugin', 'plugin.json'))).digest('hex'),
|
|
34
|
+
licenseSha256: createHash('sha256').update(license).digest('hex'),
|
|
35
|
+
skillCount: Object.keys(files).filter(path => /^[^/]+\/SKILL\.md$/.test(path)).length,
|
|
36
|
+
expectedRepositorySkillName: 'poteto-mode',
|
|
37
|
+
cloudDiscovery: 'unverified',
|
|
38
|
+
cloudInvocation: 'unverified',
|
|
39
|
+
files,
|
|
40
|
+
};
|
|
41
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
42
|
+
const staging = mkdtempSync(join(dirname(target), '.pstack-export-'));
|
|
43
|
+
try {
|
|
44
|
+
cpSync(skillsRoot, staging, { recursive: true });
|
|
45
|
+
writeFileSync(join(staging, 'PSTACK-LICENSE'), license);
|
|
46
|
+
writeFileSync(join(staging, 'pstack-export.json'), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
47
|
+
if (lstatSync(target, { throwIfNoEntry: false })) throw new Error(`Destination appeared: ${target}`);
|
|
48
|
+
renameSync(staging, target);
|
|
49
|
+
} finally {
|
|
50
|
+
rmSync(staging, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
return receipt;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
56
|
+
const [pluginRoot, destination, ...extra] = process.argv.slice(2);
|
|
57
|
+
if (!pluginRoot || !destination || extra.length) {
|
|
58
|
+
console.error('Usage: node export-pstack-skills.mjs <plugin-root> <new-repository/.agents/skills>');
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
} else {
|
|
61
|
+
try {
|
|
62
|
+
const receipt = exportPstackSkills(pluginRoot, destination);
|
|
63
|
+
console.log(JSON.stringify({ destination: resolve(destination), version: receipt.version, skillCount: receipt.skillCount,
|
|
64
|
+
expectedRepositorySkillName: receipt.expectedRepositorySkillName, cloudDiscovery: receipt.cloudDiscovery,
|
|
65
|
+
cloudInvocation: receipt.cloudInvocation }, null, 2));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(error.message);
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { exportPstackSkills } from './export-pstack-skills.mjs';
|
|
8
|
+
|
|
9
|
+
function fixture(context) {
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), 'pstack-export-test-'));
|
|
11
|
+
context.after(() => rmSync(root, { recursive: true, force: true }));
|
|
12
|
+
const plugin = join(root, 'plugin');
|
|
13
|
+
const target = join(root, 'repo', '.agents', 'skills');
|
|
14
|
+
mkdirSync(join(plugin, '.codex-plugin'), { recursive: true });
|
|
15
|
+
writeFileSync(join(plugin, '.codex-plugin', 'plugin.json'), JSON.stringify({
|
|
16
|
+
name: 'pstack', version: '0.9.53', repository: 'https://github.com/michael-denyer/pstack-claude', skills: './skills/',
|
|
17
|
+
}));
|
|
18
|
+
mkdirSync(join(plugin, 'skills', 'poteto-mode', 'references'), { recursive: true });
|
|
19
|
+
mkdirSync(join(plugin, 'skills', 'unslop'), { recursive: true });
|
|
20
|
+
writeFileSync(join(plugin, 'skills', 'poteto-mode', 'SKILL.md'), '---\nname: poteto-mode\n---\nUpstream body\n');
|
|
21
|
+
writeFileSync(join(plugin, 'skills', 'poteto-mode', 'references', 'codex-tools.md'), 'Tool mapping\n');
|
|
22
|
+
writeFileSync(join(plugin, 'skills', 'unslop', 'SKILL.md'), 'Sibling dependency\n');
|
|
23
|
+
mkdirSync(join(plugin, 'skills', 'poteto-mode', 'references', 'licenses'));
|
|
24
|
+
writeFileSync(join(plugin, 'skills', 'poteto-mode', 'references', 'licenses', 'LICENSE'), 'Upstream license\n');
|
|
25
|
+
return { root, plugin, target };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('exports upstream bodies and dependencies unchanged with verifiable provenance', context => {
|
|
29
|
+
const { plugin, target } = fixture(context);
|
|
30
|
+
const receipt = exportPstackSkills(plugin, target);
|
|
31
|
+
assert.equal(receipt.skillCount, 2);
|
|
32
|
+
assert.equal(receipt.cloudInvocation, 'unverified');
|
|
33
|
+
assert.equal(receipt.expectedRepositorySkillName, 'poteto-mode');
|
|
34
|
+
for (const [path, hash] of Object.entries(receipt.files)) {
|
|
35
|
+
const exported = readFileSync(join(target, path));
|
|
36
|
+
assert.deepEqual(exported, readFileSync(join(plugin, 'skills', path)));
|
|
37
|
+
assert.equal(createHash('sha256').update(exported).digest('hex'), hash);
|
|
38
|
+
}
|
|
39
|
+
assert.deepEqual(JSON.parse(readFileSync(join(target, 'pstack-export.json'))), receipt);
|
|
40
|
+
assert.equal(readFileSync(join(target, 'PSTACK-LICENSE'), 'utf8'), 'Upstream license\n');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('existing custom skills survive a rejected repeat export', context => {
|
|
44
|
+
const { plugin, target } = fixture(context);
|
|
45
|
+
exportPstackSkills(plugin, target);
|
|
46
|
+
const customPath = join(target, 'poteto-mode', 'SKILL.md');
|
|
47
|
+
writeFileSync(customPath, 'Custom instructions\n');
|
|
48
|
+
assert.throws(() => exportPstackSkills(plugin, target), /already exists/);
|
|
49
|
+
assert.equal(readFileSync(customPath, 'utf8'), 'Custom instructions\n');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a source symlink is rejected before creating the destination', context => {
|
|
53
|
+
const { plugin, target } = fixture(context);
|
|
54
|
+
symlinkSync(join(plugin, 'skills', 'poteto-mode', 'SKILL.md'), join(plugin, 'skills', 'linked-skill'));
|
|
55
|
+
assert.throws(() => exportPstackSkills(plugin, target), /Unsupported source entry/);
|
|
56
|
+
assert.equal(existsSync(target), false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a plugin without the required poteto entry cannot produce a partial export', context => {
|
|
60
|
+
const { plugin, target } = fixture(context);
|
|
61
|
+
rmSync(join(plugin, 'skills', 'poteto-mode', 'SKILL.md'));
|
|
62
|
+
assert.throws(() => exportPstackSkills(plugin, target), /ENOENT/);
|
|
63
|
+
assert.equal(existsSync(target), false);
|
|
64
|
+
});
|
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,
|
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/bin/install.test.mjs
CHANGED
|
@@ -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
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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)
|
|
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,
|
|
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
|
@@ -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
|
|
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,
|
|
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
|
|