claude-dev-env 8.31.1 → 8.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.agents/skills/pull-request/SKILL.md +4 -3
  2. package/.agents/skills/pull-request/reference/publication-tasks.md +1 -1
  3. package/.agents/skills/pull-request/scripts/pull_request.py +5 -2
  4. package/.agents/skills/pull-request/scripts/test_pull_request.py +32 -0
  5. package/bin/codex-skill-load-block.mjs +87 -34
  6. package/bin/codex-skill-load-block.test.mjs +112 -4
  7. package/bin/install.mjs +23 -9
  8. package/bin/install.question-presentation.test.mjs +2 -2
  9. package/bin/install.test.mjs +105 -0
  10. package/bin/seed-codex-pstack-models.mjs +2 -2
  11. package/bin/seed-codex-pstack-models.test.mjs +52 -1
  12. package/commands/fix-followups.md +3 -3
  13. package/commands/sr-loop.md +4 -3
  14. package/hooks/advisory/test_pr_done_reminder.py +2 -1
  15. package/hooks/blocking/block_main_commit.py +2 -10
  16. package/hooks/blocking/test_block_main_commit.py +13 -0
  17. package/hooks/hooks_constants/block_main_commit_constants.py +9 -0
  18. package/hooks/hooks_constants/pr_done_reminder_constants.py +1 -1
  19. package/hooks/pyproject.toml +0 -1
  20. package/hooks/validators/README.md +0 -1
  21. package/hooks/validators/health_check.py +0 -1
  22. package/hooks/validators/run_all_validators.py +0 -13
  23. package/hooks/validators/test_run_all_validators.py +5 -36
  24. package/package.json +1 -1
  25. package/rules/agent-merges-its-own-green-pull-request.md +1 -1
  26. package/rules/flag-non-breaking-findings.md +1 -1
  27. package/rules/git-workflow.md +2 -7
  28. package/rules/review-closure-is-a-check.md +1 -1
  29. package/scripts/dev_env_scripts_constants/followup_constants.py +1 -1
  30. package/scripts/test_followup_cli.py +2 -1
  31. package/hooks/validators/git_checks.py +0 -110
  32. package/hooks/validators/test_git_checks.py +0 -112
@@ -32,7 +32,7 @@ process-local author value. Keep the parent environment unchanged.
32
32
 
33
33
  Use this skill for one pull request action:
34
34
 
35
- - Create a draft pull request.
35
+ - Create a pull request, ready for review by default or a draft with `--draft`.
36
36
  - Edit a pull request title or body.
37
37
  - Add a pull request comment.
38
38
  - Submit a pull request review.
@@ -91,7 +91,8 @@ Process steps in order.
91
91
 
92
92
  Resolve the repository, action, and selected GitHub author. For create, resolve
93
93
  the source branch, base, and head. For other actions, resolve one existing pull
94
- request target. Create actions publish drafts. Record the target before any write.
94
+ request target. Create publishes a ready pull request, and `--draft` publishes a
95
+ draft. Record the target before any write.
95
96
 
96
97
  ### 2. Author the title and body
97
98
 
@@ -185,7 +186,7 @@ only after remote readback proves the requested state.
185
186
  ## Examples
186
187
 
187
188
  Create example: the writer produces `pr-body.md`. The linter exits `0` for
188
- `pr-create`. `pull_request.py create` publishes one draft pull request. The
189
+ `pr-create`. `pull_request.py create` publishes one ready pull request. The
189
190
  readback matches the title, body, head SHA, and draft state.
190
191
 
191
192
  Rejected comment example: the comment body names a worktree file. The linter
@@ -3,7 +3,7 @@
3
3
  When the session exposes a task tool, register each line as one session task
4
4
  before starting the process. This file is a task seed catalog.
5
5
 
6
- 1. Resolve one repository, action, and selected author. For create, resolve the source branch, base, and head. For other actions, resolve one existing pull request target. Create actions publish drafts.
6
+ 1. Resolve one repository, action, and selected author. For create, resolve the source branch, base, and head. For other actions, resolve one existing pull request target. Create publishes a ready pull request, and `--draft` publishes a draft.
7
7
  2. Record the title and body file for create or full body rewrite, written by you or drafted by `pstack:poteto-agent` with a PR-writing brief. Mark `N/A` for comment or review bodies that do not rewrite the pull request description.
8
8
  3. Validate every title, body, artifact, and local path input. Reject inline body input.
9
9
  4. Resolve the active managed root and run `<managed-root>/scripts/durable_post_lint.py` for the matching action. Record exit code `0` before credential lookup or network work.
@@ -43,6 +43,7 @@ def _build_parser() -> argparse.ArgumentParser:
43
43
  create.add_argument("--head", required=True)
44
44
  create.add_argument("--title", required=True)
45
45
  create.add_argument("--body-file", required=True, type=Path)
46
+ create.add_argument("--draft", dest="is_draft", action="store_true")
46
47
  edit = all_actions.add_parser(ACTION_EDIT, allow_abbrev=False)
47
48
  edit.add_argument("--repo", required=True)
48
49
  edit.add_argument("--number", required=True)
@@ -105,11 +106,10 @@ def _linter_arguments(
105
106
 
106
107
 
107
108
  def _create_arguments(arguments: argparse.Namespace) -> list[str]:
108
- return [
109
+ all_arguments = [
109
110
  "gh",
110
111
  "pr",
111
112
  "create",
112
- "--draft",
113
113
  "--repo",
114
114
  arguments.repo,
115
115
  "--base",
@@ -121,6 +121,9 @@ def _create_arguments(arguments: argparse.Namespace) -> list[str]:
121
121
  "--body-file",
122
122
  str(arguments.body_file),
123
123
  ]
124
+ if arguments.is_draft:
125
+ all_arguments.append("--draft")
126
+ return all_arguments
124
127
 
125
128
 
126
129
  def _edit_arguments(arguments: argparse.Namespace) -> list[str]:
@@ -47,7 +47,38 @@ ACTION_CASES = [
47
47
  "gh",
48
48
  "pr",
49
49
  "create",
50
+ "--repo",
51
+ REPOSITORY,
52
+ "--base",
53
+ "main",
54
+ "--head",
55
+ "feature/post",
56
+ "--title",
57
+ PR_TITLE,
58
+ "--body-file",
59
+ BODY_FILENAME,
60
+ ],
61
+ ),
62
+ (
63
+ [
64
+ "create",
65
+ "--repo",
66
+ REPOSITORY,
67
+ "--base",
68
+ "main",
69
+ "--head",
70
+ "feature/post",
71
+ "--title",
72
+ PR_TITLE,
73
+ "--body-file",
74
+ BODY_FILENAME,
50
75
  "--draft",
76
+ ],
77
+ "pr-create",
78
+ [
79
+ "gh",
80
+ "pr",
81
+ "create",
51
82
  "--repo",
52
83
  REPOSITORY,
53
84
  "--base",
@@ -58,6 +89,7 @@ ACTION_CASES = [
58
89
  PR_TITLE,
59
90
  "--body-file",
60
91
  BODY_FILENAME,
92
+ "--draft",
61
93
  ],
62
94
  ),
63
95
  (
@@ -50,7 +50,8 @@ function withPackageGuidanceBlock(guidanceText, packageGuidanceText) {
50
50
  PACKAGE_GUIDANCE_BLOCK_END,
51
51
  block,
52
52
  );
53
- return replacedText ?? `${guidanceText}\n${block}`;
53
+ if (replacedText !== null) return replacedText;
54
+ return guidanceText ? `${guidanceText}\n${block}` : block;
54
55
  }
55
56
 
56
57
  function comparablePath(filePath) {
@@ -66,28 +67,14 @@ function linksToPackageGuidance(agentsPath, allPackageGuidancePaths) {
66
67
  return allPackageGuidancePaths.some(eachPath => comparablePath(eachPath) === linkTarget);
67
68
  }
68
69
 
69
- /**
70
- * Keep CODEX_HOME/AGENTS.md carrying the skill-load block.
71
- *
72
- * A symbolic link that points at the package guidance (the shared agents-home
73
- * copy, or the retired copy under the Claude home) is replaced by a regular
74
- * file holding the skill-load block and a package-guidance block, so Codex
75
- * keeps both after the package moves its guidance. A file that already holds
76
- * the package-guidance block gets the current guidance text. A link to any
77
- * other file is left alone.
78
- *
79
- * Returns the file path when the file changed, and null otherwise.
80
- */
81
- export function writeCodexAgentsGuidance(codexHome, packageGuidanceText, allPackageGuidancePaths) {
70
+ function writeCodexGuidanceFile(codexHome, allPackageGuidancePaths, transformGuidance) {
82
71
  const agentsPath = join(codexHome, 'AGENTS.md');
83
72
  const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
73
+ if (agentsEntry && !agentsEntry.isFile() && !agentsEntry.isSymbolicLink()) return null;
84
74
  const isPackageGuidanceLink = agentsEntry?.isSymbolicLink() ?? false;
85
75
  if (isPackageGuidanceLink && !linksToPackageGuidance(agentsPath, allPackageGuidancePaths)) return null;
86
76
  const currentText = agentsEntry && !isPackageGuidanceLink ? readFileSync(agentsPath, 'utf8') : '';
87
- let updatedText = withSkillLoadBlock(currentText);
88
- if (isPackageGuidanceLink || updatedText.includes(PACKAGE_GUIDANCE_BLOCK_START)) {
89
- updatedText = withPackageGuidanceBlock(updatedText, packageGuidanceText);
90
- }
77
+ const updatedText = transformGuidance(currentText);
91
78
  if (!isPackageGuidanceLink && updatedText === currentText) return null;
92
79
  mkdirSync(codexHome, { recursive: true });
93
80
  if (isPackageGuidanceLink) unlinkSync(agentsPath);
@@ -95,6 +82,39 @@ export function writeCodexAgentsGuidance(codexHome, packageGuidanceText, allPack
95
82
  return agentsPath;
96
83
  }
97
84
 
85
+ /**
86
+ * Keep CODEX_HOME/AGENTS.md carrying the skill-load block and the package guidance.
87
+ *
88
+ * The skill-load block goes first, and the package-guidance block holds the
89
+ * current guidance text. A symbolic link that points at the package guidance
90
+ * (the shared agents-home copy, or the retired copy under the Claude home) is
91
+ * replaced by a regular file holding both blocks, so Codex keeps both after
92
+ * the package moves its guidance. A link to any other file is left alone.
93
+ *
94
+ * Returns the file path when the file changed, and null otherwise.
95
+ */
96
+ export function writeCodexAgentsGuidance(codexHome, packageGuidanceText, allPackageGuidancePaths) {
97
+ return writeCodexGuidanceFile(
98
+ codexHome,
99
+ allPackageGuidancePaths,
100
+ currentText => withPackageGuidanceBlock(withSkillLoadBlock(currentText), packageGuidanceText),
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Keep CODEX_HOME/AGENTS.md carrying the package guidance, with no skill-load block.
106
+ *
107
+ * Links follow the same rule as writeCodexAgentsGuidance. Returns the file
108
+ * path when the file changed, and null otherwise.
109
+ */
110
+ export function writeCodexPackageGuidance(codexHome, packageGuidanceText, allPackageGuidancePaths) {
111
+ return writeCodexGuidanceFile(
112
+ codexHome,
113
+ allPackageGuidancePaths,
114
+ currentText => withPackageGuidanceBlock(currentText, packageGuidanceText),
115
+ );
116
+ }
117
+
98
118
  export function writeCodexQuestionGuidance(codexHome, policyText) {
99
119
  const agentsPath = join(codexHome, 'AGENTS.md');
100
120
  const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
@@ -112,30 +132,63 @@ export function writeCodexQuestionGuidance(codexHome, policyText) {
112
132
  return agentsPath;
113
133
  }
114
134
 
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
- ) === '';
135
+ const ALL_PACKAGE_MANAGED_BLOCK_MARKERS = [
136
+ [SKILL_LOAD_BLOCK_START, SKILL_LOAD_BLOCK_END],
137
+ [PACKAGE_GUIDANCE_BLOCK_START, PACKAGE_GUIDANCE_BLOCK_END],
138
+ [QUESTION_PRESENTATION_BLOCK_START, QUESTION_PRESENTATION_BLOCK_END],
139
+ ];
140
+
141
+ /**
142
+ * Report whether the guidance holds at least one package-managed block and
143
+ * nothing else but blank lines.
144
+ */
145
+ export function hasOnlyPackageManagedBlocks(guidanceText) {
146
+ let remainingText = guidanceText;
147
+ let removedBlockCount = 0;
148
+ for (const [blockStart, blockEnd] of ALL_PACKAGE_MANAGED_BLOCK_MARKERS) {
149
+ const withoutBlock = withBlockReplaced(remainingText, blockStart, blockEnd, '');
150
+ if (withoutBlock === null) continue;
151
+ remainingText = withoutBlock;
152
+ removedBlockCount += 1;
153
+ }
154
+ return removedBlockCount > 0 && remainingText.trim() === '';
155
+ }
156
+
157
+ function removeCodexGuidanceBlock(codexHome, blockStart, blockEnd) {
158
+ const agentsPath = join(codexHome, 'AGENTS.md');
159
+ const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
160
+ if (!agentsEntry?.isFile()) return null;
161
+ const currentText = readFileSync(agentsPath, 'utf8');
162
+ const updatedText = withBlockReplaced(currentText, blockStart, blockEnd, '');
163
+ if (updatedText === null) return null;
164
+ const isLeftEmpty = updatedText.trim() === '';
165
+ if (isLeftEmpty) unlinkSync(agentsPath);
166
+ if (!isLeftEmpty) writeFileSync(agentsPath, updatedText, 'utf8');
167
+ return agentsPath;
121
168
  }
122
169
 
123
170
  /**
124
171
  * Remove the managed question block while keeping surrounding guidance bytes.
125
172
  *
173
+ * A file left holding only blank lines is deleted.
174
+ *
126
175
  * @param {string} codexHome
127
176
  * @returns {string|null} The changed path, or null when no block was removed.
128
177
  */
129
178
  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, '',
179
+ return removeCodexGuidanceBlock(
180
+ codexHome, QUESTION_PRESENTATION_BLOCK_START, QUESTION_PRESENTATION_BLOCK_END,
136
181
  );
137
- if (updatedText === null) return null;
138
- if (updatedText === '') unlinkSync(agentsPath);
139
- if (updatedText !== '') writeFileSync(agentsPath, updatedText, 'utf8');
140
- return agentsPath;
182
+ }
183
+
184
+ /**
185
+ * Remove the managed package-guidance block while keeping surrounding guidance bytes.
186
+ *
187
+ * A file left holding only blank lines is deleted.
188
+ *
189
+ * @param {string} codexHome
190
+ * @returns {string|null} The changed path, or null when no block was removed.
191
+ */
192
+ export function removeCodexPackageGuidance(codexHome) {
193
+ return removeCodexGuidanceBlock(codexHome, PACKAGE_GUIDANCE_BLOCK_START, PACKAGE_GUIDANCE_BLOCK_END);
141
194
  }
@@ -11,9 +11,11 @@ import {
11
11
  SKILL_LOAD_BLOCK_END,
12
12
  SKILL_LOAD_BLOCK_START,
13
13
  SKILL_LOAD_INSTRUCTION,
14
+ removeCodexPackageGuidance,
14
15
  removeCodexQuestionGuidance,
15
16
  withSkillLoadBlock,
16
17
  writeCodexAgentsGuidance,
18
+ writeCodexPackageGuidance,
17
19
  writeCodexQuestionGuidance,
18
20
  } from './codex-skill-load-block.mjs';
19
21
 
@@ -50,16 +52,19 @@ test('the instruction names the skill and binds spawned helpers', () => {
50
52
  assert.match(SKILL_LOAD_INSTRUCTION, /spawn_agent/);
51
53
  });
52
54
 
53
- test('a missing Codex guidance file is created holding only the skill-load block', (context) => {
55
+ test('a missing Codex guidance file is created holding the skill-load block and the package guidance', (context) => {
54
56
  const homes = makeHomes(context);
55
57
 
56
58
  const writtenPath = writeGuidance(homes);
57
59
 
58
60
  assert.equal(writtenPath, homes.agentsPath);
59
- assert.equal(readFileSync(homes.agentsPath, 'utf8'), EXPECTED_BLOCK);
61
+ assert.equal(
62
+ readFileSync(homes.agentsPath, 'utf8'),
63
+ `${EXPECTED_BLOCK}\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
64
+ );
60
65
  });
61
66
 
62
- test('existing guidance keeps every line and gains the skill-load block first', (context) => {
67
+ test('existing guidance keeps every line between the skill-load block and the package guidance', (context) => {
63
68
  const homes = makeHomes(context);
64
69
  mkdirSync(homes.codexHome);
65
70
  writeFileSync(homes.agentsPath, '# pstack model configuration\n\nbug-fix: gpt-6-astra\n');
@@ -68,7 +73,7 @@ test('existing guidance keeps every line and gains the skill-load block first',
68
73
 
69
74
  assert.equal(
70
75
  readFileSync(homes.agentsPath, 'utf8'),
71
- `${EXPECTED_BLOCK}\n# pstack model configuration\n\nbug-fix: gpt-6-astra\n`,
76
+ `${EXPECTED_BLOCK}\n# pstack model configuration\n\nbug-fix: gpt-6-astra\n\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
72
77
  );
73
78
  });
74
79
 
@@ -138,6 +143,78 @@ test('a link to a file the package does not own is left alone', (context) => {
138
143
  assert.equal(readFileSync(ownGuidancePath, 'utf8'), 'My own Codex notes\n');
139
144
  });
140
145
 
146
+ function writePackageGuidance(homes) {
147
+ return writeCodexPackageGuidance(homes.codexHome, PACKAGE_GUIDANCE, homes.allPackageGuidancePaths);
148
+ }
149
+
150
+ test('package guidance creates a missing Codex file holding only the package block', (context) => {
151
+ const homes = makeHomes(context);
152
+
153
+ assert.equal(writePackageGuidance(homes), homes.agentsPath);
154
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), guidanceBlock(PACKAGE_GUIDANCE));
155
+ });
156
+
157
+ test('package guidance replaces its block in place and a repeat write changes nothing', (context) => {
158
+ const homes = makeHomes(context);
159
+ mkdirSync(homes.codexHome);
160
+ writeFileSync(homes.agentsPath, `Before\n${guidanceBlock('Old guidance\n')}After\n`);
161
+
162
+ assert.equal(writePackageGuidance(homes), homes.agentsPath);
163
+ const expectedGuidance = `Before\n${guidanceBlock(PACKAGE_GUIDANCE)}After\n`;
164
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), expectedGuidance);
165
+ assert.equal(writePackageGuidance(homes), null);
166
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), expectedGuidance);
167
+ });
168
+
169
+ test('package guidance appends its block after custom notes', (context) => {
170
+ const homes = makeHomes(context);
171
+ mkdirSync(homes.codexHome);
172
+ writeFileSync(homes.agentsPath, 'Custom agents\n');
173
+
174
+ writePackageGuidance(homes);
175
+
176
+ assert.equal(
177
+ readFileSync(homes.agentsPath, 'utf8'),
178
+ `Custom agents\n\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
179
+ );
180
+ });
181
+
182
+ for (const linkName of ['retiredGuidancePath', 'sharedGuidancePath']) {
183
+ test(`package guidance turns a link at ${linkName} into a file with only the package block`, (context) => {
184
+ const homes = makeHomes(context);
185
+ mkdirSync(homes.codexHome);
186
+ symlinkSync(homes[linkName], homes.agentsPath);
187
+
188
+ assert.equal(writePackageGuidance(homes), homes.agentsPath);
189
+ assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), false);
190
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), guidanceBlock(PACKAGE_GUIDANCE));
191
+ });
192
+ }
193
+
194
+ test('package guidance leaves a link to a file the package does not own unchanged', (context) => {
195
+ const homes = makeHomes(context);
196
+ mkdirSync(homes.codexHome);
197
+ const ownGuidancePath = join(homes.root, 'notes', 'codex.md');
198
+ mkdirSync(join(homes.root, 'notes'));
199
+ writeFileSync(ownGuidancePath, 'My own Codex notes\n');
200
+ symlinkSync(ownGuidancePath, homes.agentsPath);
201
+
202
+ assert.equal(writePackageGuidance(homes), null);
203
+ assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), true);
204
+ assert.equal(readFileSync(ownGuidancePath, 'utf8'), 'My own Codex notes\n');
205
+ });
206
+
207
+ test('package guidance leaves an existing directory and its notes unchanged', (context) => {
208
+ const homes = makeHomes(context);
209
+ mkdirSync(homes.agentsPath, { recursive: true });
210
+ const notesPath = join(homes.agentsPath, 'notes.md');
211
+ writeFileSync(notesPath, 'Directory notes\n');
212
+
213
+ assert.equal(writePackageGuidance(homes), null);
214
+ assert.equal(lstatSync(homes.agentsPath).isDirectory(), true);
215
+ assert.equal(readFileSync(notesPath, 'utf8'), 'Directory notes\n');
216
+ });
217
+
141
218
  test('question guidance creates a missing Codex file and repeat writes preserve bytes', (context) => {
142
219
  const homes = makeHomes(context);
143
220
  const expectedBlock = `${QUESTION_PRESENTATION_BLOCK_START}\n${QUESTION_POLICY}${QUESTION_PRESENTATION_BLOCK_END}\n`;
@@ -245,6 +322,37 @@ test('question removal preserves a guidance directory and its notes', (context)
245
322
  assert.equal(readFileSync(notesPath, 'utf8'), 'Directory notes\n');
246
323
  });
247
324
 
325
+ test('package and question removal delete a file left with only blank lines', (context) => {
326
+ const homes = makeHomes(context);
327
+ writePackageGuidance(homes);
328
+ writeCodexQuestionGuidance(homes.codexHome, QUESTION_POLICY);
329
+
330
+ assert.equal(removeCodexPackageGuidance(homes.codexHome), homes.agentsPath);
331
+ assert.equal(removeCodexQuestionGuidance(homes.codexHome), homes.agentsPath);
332
+ assert.equal(lstatSync(homes.agentsPath, { throwIfNoEntry: false }), undefined);
333
+ });
334
+
335
+ test('package removal keeps the custom text around its block', (context) => {
336
+ const homes = makeHomes(context);
337
+ mkdirSync(homes.codexHome);
338
+ writeFileSync(homes.agentsPath, `Before\n${guidanceBlock(PACKAGE_GUIDANCE)}After\n`);
339
+
340
+ assert.equal(removeCodexPackageGuidance(homes.codexHome), homes.agentsPath);
341
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), 'Before\nAfter\n');
342
+ });
343
+
344
+ test('package removal leaves a linked file and its target unchanged', (context) => {
345
+ const homes = makeHomes(context);
346
+ mkdirSync(homes.codexHome);
347
+ mkdirSync(dirname(homes.sharedGuidancePath), { recursive: true });
348
+ writeFileSync(homes.sharedGuidancePath, guidanceBlock(PACKAGE_GUIDANCE));
349
+ symlinkSync(homes.sharedGuidancePath, homes.agentsPath);
350
+
351
+ assert.equal(removeCodexPackageGuidance(homes.codexHome), null);
352
+ assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), true);
353
+ assert.equal(readFileSync(homes.sharedGuidancePath, 'utf8'), guidanceBlock(PACKAGE_GUIDANCE));
354
+ });
355
+
248
356
  test('question removal leaves a missing guidance file absent', (context) => {
249
357
  const homes = makeHomes(context);
250
358
 
package/bin/install.mjs CHANGED
@@ -53,8 +53,10 @@ import {
53
53
  } from './install-pstack-plugin.mjs';
54
54
  import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
55
55
  import {
56
+ removeCodexPackageGuidance,
56
57
  removeCodexQuestionGuidance,
57
58
  writeCodexAgentsGuidance,
59
+ writeCodexPackageGuidance,
58
60
  writeCodexQuestionGuidance,
59
61
  } from './codex-skill-load-block.mjs';
60
62
  import {
@@ -2606,7 +2608,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2606
2608
  ];
2607
2609
 
2608
2610
  const allInstalledFiles = [];
2609
- const allQuestionGuidancePaths = [];
2611
+ const allCodexGuidancePaths = [];
2610
2612
  const allUserOwnedPreferencePaths = new Set();
2611
2613
  const summary = {};
2612
2614
  for (const directory of CONTENT_DIRECTORIES) {
@@ -2758,6 +2760,8 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2758
2760
  console.log(` Pstack: retired release store removed from ${retiredPstack.removedStorePath}`);
2759
2761
  }
2760
2762
  }
2763
+ const packageGuidanceText = selectedGroups ? '' : readFileSync(join(PACKAGE_ROOT, 'AGENTS.md'), 'utf8');
2764
+ const allPackageGuidancePaths = [join(CLAUDE_HOME, 'AGENTS.md'), join(AGENTS_HOME, 'AGENTS.md')];
2761
2765
  if (!selectedGroups && shouldInstallPstackPlugin()) {
2762
2766
  const pstackPlugin = installPstackPluginForHosts();
2763
2767
  summary.pstackPlugin = pstackPlugin;
@@ -2773,8 +2777,8 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2773
2777
  }
2774
2778
  const skillLoadGuidancePath = writeCodexAgentsGuidance(
2775
2779
  INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
2776
- readFileSync(join(PACKAGE_ROOT, 'AGENTS.md'), 'utf8'),
2777
- [join(CLAUDE_HOME, 'AGENTS.md'), join(AGENTS_HOME, 'AGENTS.md')],
2780
+ packageGuidanceText,
2781
+ allPackageGuidancePaths,
2778
2782
  );
2779
2783
  if (skillLoadGuidancePath) {
2780
2784
  console.log(` \u2713 ${skillLoadGuidancePath} (Codex skill-load line)`);
@@ -2787,13 +2791,22 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2787
2791
  }
2788
2792
  }
2789
2793
  if (!selectedGroups) {
2794
+ const packageGuidancePath = writeCodexPackageGuidance(
2795
+ INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
2796
+ packageGuidanceText,
2797
+ allPackageGuidancePaths,
2798
+ );
2799
+ if (packageGuidancePath) {
2800
+ allCodexGuidancePaths.push(packageGuidancePath);
2801
+ console.log(` \u2713 ${packageGuidancePath} (Codex package guidance)`);
2802
+ }
2790
2803
  const questionGuidancePath = writeCodexQuestionGuidance(
2791
2804
  INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
2792
2805
  readFileSync(join(PACKAGE_ROOT, 'rules', 'question-presentation.md'), 'utf8'),
2793
2806
  );
2794
- if (questionGuidancePath) allQuestionGuidancePaths.push(questionGuidancePath);
2807
+ if (questionGuidancePath) allCodexGuidancePaths.push(questionGuidancePath);
2795
2808
  }
2796
- syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2809
+ syncWrittenPaths([...allInstalledFiles, ...allCodexGuidancePaths, ...publishedPointerPaths]);
2797
2810
  throwIfFault(FAULT_PHASES.AFTER_FILE_STAGING);
2798
2811
  throwIfFault(FAULT_PHASES.BEFORE_DURABLE_PROMOTION);
2799
2812
  const shouldInstallAnyHooks = shouldInstallAllHooks || (allowedHookFiles && allowedHookFiles.size > 0);
@@ -2834,7 +2847,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2834
2847
  summary.hookGroups = totalHookGroups;
2835
2848
  summary.codexHookGroups = totalCodexHookGroups;
2836
2849
  console.log(` Hook groups: ${totalHookGroups} merged into settings.json, ${totalCodexHookGroups} merged into hooks.json`);
2837
- syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2850
+ syncWrittenPaths([...allInstalledFiles, ...allCodexGuidancePaths, ...publishedPointerPaths]);
2838
2851
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
2839
2852
 
2840
2853
  console.warn(
@@ -2856,7 +2869,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2856
2869
  console.warn(` Git hooks: ${gitHookInstallationResult.hooksPathConfigurationResult.reason}`);
2857
2870
  }
2858
2871
  console.log(` Git hook shims: ${gitHookInstallationResult.createdShimPaths.length} files (${KNOWN_GIT_HOOK_NAMES.join(', ')})`);
2859
- syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2872
+ syncWrittenPaths([...allInstalledFiles, ...allCodexGuidancePaths, ...publishedPointerPaths]);
2860
2873
  throwIfFault(FAULT_PHASES.AFTER_GIT_CONFIG);
2861
2874
  throwIfFault(FAULT_PHASES.AFTER_LINK_PUBLICATION);
2862
2875
 
@@ -2876,7 +2889,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2876
2889
  console.warn(` ${mypyIniInstallResult.expectedLine}`);
2877
2890
  }
2878
2891
  } else {
2879
- syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, ...publishedPointerPaths]);
2892
+ syncWrittenPaths([...allInstalledFiles, ...allCodexGuidancePaths, ...publishedPointerPaths]);
2880
2893
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
2881
2894
  throwIfFault(FAULT_PHASES.AFTER_GIT_CONFIG);
2882
2895
  throwIfFault(FAULT_PHASES.AFTER_LINK_PUBLICATION);
@@ -2964,7 +2977,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2964
2977
  ? manifestFilesWithFailedPrunes(allManagedInstalledFiles, failedPrunePaths)
2965
2978
  : unionOnComparisonKey(priorManifestFiles || [], allManagedInstalledFiles);
2966
2979
  writeManifest(manifestFiles, manifestSkillNames, summary.managedPermissions.managedPermissions);
2967
- syncWrittenPaths([...allInstalledFiles, ...allQuestionGuidancePaths, MANIFEST_FILE, plan.settingsPath, ...publishedPointerPaths]);
2980
+ syncWrittenPaths([...allInstalledFiles, ...allCodexGuidancePaths, MANIFEST_FILE, plan.settingsPath, ...publishedPointerPaths]);
2968
2981
  throwIfFault(FAULT_PHASES.AFTER_MANIFEST_WRITE);
2969
2982
  console.log(`\nInstalled ${PACKAGE_NAME}:`);
2970
2983
  for (const directory of CONTENT_DIRECTORIES) {
@@ -3143,6 +3156,7 @@ function executeUninstallPlan(plan, helpers = {}) {
3143
3156
  ` ${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}`,
3144
3157
  );
3145
3158
  }
3159
+ removeCodexPackageGuidance(INSTALL_ROOT_RESOLUTION.codexHomeDirectory);
3146
3160
  removeCodexQuestionGuidance(INSTALL_ROOT_RESOLUTION.codexHomeDirectory);
3147
3161
  throwIfFault(FAULT_PHASES.AFTER_FILE_STAGING);
3148
3162
 
@@ -81,7 +81,7 @@ test('uninstall removes question-only Codex guidance', (context) => {
81
81
  assert.equal(existsSync(codexGuidancePath), false);
82
82
  });
83
83
 
84
- test('uninstall removes the question block and preserves surrounding custom guidance', (context) => {
84
+ test('uninstall removes the managed blocks and preserves surrounding custom guidance', (context) => {
85
85
  const homeDirectory = makeScratchHome(context);
86
86
  const codexGuidancePath = join(homeDirectory, '.codex', 'AGENTS.md');
87
87
  mkdirSync(dirname(codexGuidancePath), { recursive: true });
@@ -94,7 +94,7 @@ test('uninstall removes the question block and preserves surrounding custom guid
94
94
  const removal = installInScratchHome(homeDirectory, ['--uninstall']);
95
95
 
96
96
  assert.equal(removal.status, 0, removal.stdout + removal.stderr);
97
- assert.equal(readFileSync(codexGuidancePath, 'utf8'), `${customGuidance}\nTrailing notes\n`);
97
+ assert.equal(readFileSync(codexGuidancePath, 'utf8'), `${customGuidance}\n\nTrailing notes\n`);
98
98
  });
99
99
 
100
100
  for (const eachPriorGuidance of [null, '# Personal guidance\n']) {