claude-dev-env 8.88.1 → 8.88.3

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.
@@ -0,0 +1,193 @@
1
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH } from './install-constants.mjs';
5
+ import {
6
+ discoverProfileSettingsPaths,
7
+ hostConfigurationIndent,
8
+ mergeProfileSettingsFile,
9
+ } from './merge_profile_settings.mjs';
10
+ import { normalizePathForComparison } from './resolve-install-root.mjs';
11
+
12
+ const STOP_EVENT_NAME = 'Stop';
13
+ const MANAGED_HOOKS_DIRECTORY_NAME = 'hooks';
14
+ const SETTINGS_INDENT = ' ';
15
+
16
+ function comparablePath(filesystemPath) {
17
+ try {
18
+ return normalizePathForComparison(realpathSync(filesystemPath));
19
+ } catch {
20
+ return normalizePathForComparison(filesystemPath);
21
+ }
22
+ }
23
+
24
+ function comparableCommand(command) {
25
+ const forwardSlashed = command.replace(/\\/g, '/');
26
+ return process.platform === 'win32' ? forwardSlashed.toLowerCase() : forwardSlashed;
27
+ }
28
+
29
+ /**
30
+ * Return the gate script's path under a Claude home, in the spelling hook commands compare by.
31
+ *
32
+ * @param {string} claudeHome
33
+ * @returns {string}
34
+ */
35
+ export function gateScriptCommandFragment(claudeHome) {
36
+ return comparableCommand(join(claudeHome, MANAGED_HOOKS_DIRECTORY_NAME, ...CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH));
37
+ }
38
+
39
+ function isGateHook(eachHook, claudeHome) {
40
+ return typeof eachHook?.command === 'string'
41
+ && comparableCommand(eachHook.command).includes(gateScriptCommandFragment(claudeHome));
42
+ }
43
+
44
+ /**
45
+ * Find the open-tasks Stop gate hook the install wrote into the main settings.
46
+ *
47
+ * @param {string} mainSettingsPath
48
+ * @param {string} claudeHome
49
+ * @returns {Record<string, unknown>|null} A copy of the hook entry, or null when the main settings hold none.
50
+ */
51
+ export function mainOpenTasksGateHook(mainSettingsPath, claudeHome) {
52
+ if (!existsSync(mainSettingsPath)) return null;
53
+ const settings = JSON.parse(readFileSync(mainSettingsPath, 'utf8'));
54
+ for (const eachGroup of settings?.hooks?.[STOP_EVENT_NAME] ?? []) {
55
+ for (const eachHook of eachGroup?.hooks ?? []) {
56
+ if (isGateHook(eachHook, claudeHome)) return structuredClone(eachHook);
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * List the settings.json of every other Claude profile on this machine.
64
+ *
65
+ * A profile whose settings.json links to the main settings already reads the
66
+ * main hooks, so it is left out, and so is the main file itself.
67
+ *
68
+ * @param {{ homeDirectory: string, environment?: Record<string, string|undefined>, mainSettingsPath: string }} options
69
+ * @returns {string[]}
70
+ */
71
+ export function otherProfileSettingsPaths(options) {
72
+ const mainKey = comparablePath(options.mainSettingsPath);
73
+ const seenKeys = new Set([mainKey]);
74
+ const allOtherPaths = [];
75
+ for (const eachPath of discoverProfileSettingsPaths(options)) {
76
+ if (!existsSync(eachPath)) continue;
77
+ const pathKey = comparablePath(eachPath);
78
+ if (seenKeys.has(pathKey)) continue;
79
+ seenKeys.add(pathKey);
80
+ allOtherPaths.push(eachPath);
81
+ }
82
+ return allOtherPaths;
83
+ }
84
+
85
+ function stopHooks(settingsPath) {
86
+ const settingsText = readFileSync(settingsPath, 'utf8');
87
+ if (!settingsText.trim()) return [];
88
+ const settings = JSON.parse(settingsText);
89
+ const stopGroups = settings?.hooks?.[STOP_EVENT_NAME];
90
+ if (!Array.isArray(stopGroups)) return [];
91
+ return stopGroups.flatMap((eachGroup) => (Array.isArray(eachGroup?.hooks) ? eachGroup.hooks : []));
92
+ }
93
+
94
+ function profileGateCommands(settingsPath, claudeHome) {
95
+ return stopHooks(settingsPath)
96
+ .filter((eachHook) => isGateHook(eachHook, claudeHome))
97
+ .map((eachHook) => eachHook.command);
98
+ }
99
+
100
+ /**
101
+ * True when a profile's settings.json already runs a gate script from another Claude home.
102
+ *
103
+ * An install with CLAUDE_CONFIG_DIR set to the profile writes a gate entry
104
+ * for the profile's own copy of the script, and a second entry for the main
105
+ * home's copy would run the gate twice on every stop.
106
+ *
107
+ * @param {string} settingsPath
108
+ * @param {string} claudeHome
109
+ * @returns {boolean}
110
+ */
111
+ export function profileRunsItsOwnGate(settingsPath, claudeHome) {
112
+ const scriptPathSuffix = `/${comparableCommand(join(MANAGED_HOOKS_DIRECTORY_NAME, ...CURSOR_OPEN_TASKS_HOOK_RELATIVE_PATH))}`;
113
+ return stopHooks(settingsPath).some((eachHook) => typeof eachHook?.command === 'string'
114
+ && comparableCommand(eachHook.command).includes(scriptPathSuffix)
115
+ && !isGateHook(eachHook, claudeHome));
116
+ }
117
+
118
+ /**
119
+ * Add the main settings' open-tasks Stop gate to every other profile's settings.json.
120
+ *
121
+ * A profile keeps its own settings and hooks. The merge adds the one gate
122
+ * entry, in the spelling the main settings use, only where it is missing;
123
+ * an entry left with an older interpreter spelling is replaced. The file as
124
+ * it was before either change is copied to a timestamped `.bak`. A profile
125
+ * that already runs its own copy of the gate script is left as it is.
126
+ *
127
+ * @param {{ mainSettingsPath: string, claudeHome: string, allProfileSettingsPaths: string[],
128
+ * homeDirectory: string, now?: Date }} options
129
+ * @returns {{ settingsPath: string, additions: string[], backupPath: string|null }[]}
130
+ */
131
+ export function addOpenTasksGateToProfiles(options) {
132
+ const gateHook = mainOpenTasksGateHook(options.mainSettingsPath, options.claudeHome);
133
+ if (!gateHook) return [];
134
+ const timestamp = (options.now ?? new Date()).toISOString().replace(/[:.]/g, '-');
135
+ const declaredSettings = [{ keyPath: ['hooks', STOP_EVENT_NAME], items: [{ hooks: [gateHook] }] }];
136
+ const allTargetPaths = options.allProfileSettingsPaths.filter(
137
+ (eachPath) => !profileRunsItsOwnGate(eachPath, options.claudeHome),
138
+ );
139
+ const allStalePaths = allTargetPaths.filter(
140
+ (eachPath) => profileGateCommands(eachPath, options.claudeHome)
141
+ .some((eachCommand) => eachCommand !== gateHook.command),
142
+ );
143
+ const originalTextByStalePath = new Map(allStalePaths.map((eachPath) => [eachPath, readFileSync(eachPath, 'utf8')]));
144
+ removeOpenTasksGateFromProfiles({ claudeHome: options.claudeHome, allProfileSettingsPaths: allStalePaths });
145
+ return allTargetPaths.map((eachPath) => {
146
+ const outcome = mergeProfileSettingsFile(eachPath, {
147
+ dryRun: false,
148
+ homeDirectory: options.homeDirectory,
149
+ timestamp,
150
+ declaredSettings,
151
+ });
152
+ if (outcome.backupPath && originalTextByStalePath.has(eachPath)) {
153
+ writeFileSync(outcome.backupPath, originalTextByStalePath.get(eachPath));
154
+ }
155
+ return outcome;
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Remove the gate entry that points at the main Claude home from each listed profile settings path.
161
+ *
162
+ * Only a hook whose command runs the main home's gate script goes. A Stop
163
+ * group left empty goes too, and so does an emptied Stop list or hooks object.
164
+ *
165
+ * @param {{ claudeHome: string, allProfileSettingsPaths: string[] }} options
166
+ * @returns {string[]} The settings paths that changed.
167
+ */
168
+ export function removeOpenTasksGateFromProfiles(options) {
169
+ const allChangedPaths = [];
170
+ for (const eachPath of options.allProfileSettingsPaths) {
171
+ const settingsText = readFileSync(eachPath, 'utf8');
172
+ if (!settingsText.trim()) continue;
173
+ const settings = JSON.parse(settingsText);
174
+ const stopGroups = settings?.hooks?.[STOP_EVENT_NAME];
175
+ if (!Array.isArray(stopGroups)) continue;
176
+ let removedCount = 0;
177
+ for (const eachGroup of stopGroups) {
178
+ if (!Array.isArray(eachGroup?.hooks)) continue;
179
+ const keptHooks = eachGroup.hooks.filter((eachHook) => !isGateHook(eachHook, options.claudeHome));
180
+ removedCount += eachGroup.hooks.length - keptHooks.length;
181
+ eachGroup.hooks = keptHooks;
182
+ }
183
+ if (removedCount === 0) continue;
184
+ settings.hooks[STOP_EVENT_NAME] = stopGroups.filter(
185
+ (eachGroup) => !Array.isArray(eachGroup?.hooks) || eachGroup.hooks.length > 0,
186
+ );
187
+ if (settings.hooks[STOP_EVENT_NAME].length === 0) delete settings.hooks[STOP_EVENT_NAME];
188
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
189
+ writeFileSync(eachPath, JSON.stringify(settings, null, hostConfigurationIndent(settingsText, SETTINGS_INDENT)) + '\n');
190
+ allChangedPaths.push(eachPath);
191
+ }
192
+ return allChangedPaths;
193
+ }
@@ -0,0 +1,124 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import {
8
+ addOpenTasksGateToProfiles,
9
+ otherProfileSettingsPaths,
10
+ removeOpenTasksGateFromProfiles,
11
+ } from './profile-stop-gate.mjs';
12
+
13
+ function makeHome(context) {
14
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-profile-gate-'));
15
+ context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
16
+ const claudeHome = join(homeDirectory, '.claude');
17
+ mkdirSync(claudeHome, { recursive: true });
18
+ const gateCommand = `python3 ${join(claudeHome, 'hooks', 'blocking', 'open_tasks_stop_gate.py').replace(/\\/g, '/')}`;
19
+ const mainSettingsPath = join(claudeHome, 'settings.json');
20
+ writeFileSync(mainSettingsPath, JSON.stringify({
21
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: gateCommand, timeout: 10 }] }] },
22
+ }, null, 4));
23
+ return { homeDirectory, claudeHome, mainSettingsPath, gateCommand };
24
+ }
25
+
26
+ function writeProfile(homeDirectory, name, settings) {
27
+ const profileDirectory = join(homeDirectory, '.claude-profiles', name);
28
+ mkdirSync(profileDirectory, { recursive: true });
29
+ const settingsPath = join(profileDirectory, 'settings.json');
30
+ if (settings !== undefined) writeFileSync(settingsPath, JSON.stringify(settings, null, 4));
31
+ return settingsPath;
32
+ }
33
+
34
+ function stopCommands(settingsPath) {
35
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
36
+ return (settings.hooks?.Stop ?? []).flatMap((eachGroup) => eachGroup.hooks.map((eachHook) => eachHook.command));
37
+ }
38
+
39
+ test('every profile with its own settings.json gets the main gate entry', (context) => {
40
+ const { homeDirectory, claudeHome, mainSettingsPath, gateCommand } = makeHome(context);
41
+ const ownHooksPath = writeProfile(homeDirectory, 'own-hooks', {
42
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'python3 /opt/own/stop.py' }] }] },
43
+ });
44
+ const prefsOnlyPath = writeProfile(homeDirectory, 'prefs-only', { outputStyle: 'plain' });
45
+ writeProfile(homeDirectory, 'no-settings');
46
+
47
+ const allProfilePaths = otherProfileSettingsPaths({ homeDirectory, environment: {}, mainSettingsPath });
48
+ addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: allProfilePaths, homeDirectory });
49
+
50
+ assert.deepEqual(allProfilePaths.sort(), [ownHooksPath, prefsOnlyPath].sort());
51
+ assert.deepEqual(stopCommands(ownHooksPath), ['python3 /opt/own/stop.py', gateCommand]);
52
+ assert.deepEqual(stopCommands(prefsOnlyPath), [gateCommand]);
53
+ assert.equal(JSON.parse(readFileSync(prefsOnlyPath, 'utf8')).outputStyle, 'plain');
54
+ });
55
+
56
+ test('a profile whose settings.json links to the main settings is left out', (context) => {
57
+ const { homeDirectory, mainSettingsPath } = makeHome(context);
58
+ const linkedPath = writeProfile(homeDirectory, 'linked');
59
+ try {
60
+ symlinkSync(mainSettingsPath, linkedPath, 'file');
61
+ } catch (error) {
62
+ if (error.code === 'EPERM') return context.skip('this account cannot create symlinks');
63
+ throw error;
64
+ }
65
+
66
+ assert.deepEqual(otherProfileSettingsPaths({ homeDirectory, environment: {}, mainSettingsPath }), []);
67
+ });
68
+
69
+ test('a second run adds nothing, and an older interpreter spelling is replaced', (context) => {
70
+ const { homeDirectory, claudeHome, mainSettingsPath, gateCommand } = makeHome(context);
71
+ const staleCommand = gateCommand.replace(/^python3/, '/usr/bin/python3.9');
72
+ const profilePath = writeProfile(homeDirectory, 'stale', {
73
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: staleCommand }] }] },
74
+ });
75
+ const options = { mainSettingsPath, claudeHome, allProfileSettingsPaths: [profilePath], homeDirectory };
76
+
77
+ addOpenTasksGateToProfiles(options);
78
+ const secondOutcome = addOpenTasksGateToProfiles(options);
79
+
80
+ assert.deepEqual(stopCommands(profilePath), [gateCommand]);
81
+ assert.deepEqual(secondOutcome[0].additions, []);
82
+ });
83
+
84
+ test('replacing an older spelling backs up the file as it was before the replacement', (context) => {
85
+ const { homeDirectory, claudeHome, mainSettingsPath, gateCommand } = makeHome(context);
86
+ const staleCommand = gateCommand.replace(/^python3/, '/usr/bin/python3.9');
87
+ const profilePath = writeProfile(homeDirectory, 'stale', {
88
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: staleCommand }] }] },
89
+ });
90
+ const originalText = readFileSync(profilePath, 'utf8');
91
+
92
+ const [outcome] = addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: [profilePath], homeDirectory });
93
+
94
+ assert.equal(readFileSync(outcome.backupPath, 'utf8'), originalText);
95
+ });
96
+
97
+ test('removal takes out only the main gate entry and leaves the profile as it was', (context) => {
98
+ const { homeDirectory, claudeHome, mainSettingsPath } = makeHome(context);
99
+ const ownHooksPath = writeProfile(homeDirectory, 'own-hooks', {
100
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'python3 /opt/own/stop.py' }] }] },
101
+ });
102
+ const prefsOnlyPath = writeProfile(homeDirectory, 'prefs-only', { outputStyle: 'plain' });
103
+ const allProfilePaths = [ownHooksPath, prefsOnlyPath];
104
+ addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: allProfilePaths, homeDirectory });
105
+
106
+ const allChangedPaths = removeOpenTasksGateFromProfiles({ claudeHome, allProfileSettingsPaths: allProfilePaths });
107
+
108
+ assert.deepEqual(allChangedPaths, allProfilePaths);
109
+ assert.deepEqual(stopCommands(ownHooksPath), ['python3 /opt/own/stop.py']);
110
+ assert.deepEqual(JSON.parse(readFileSync(prefsOnlyPath, 'utf8')), { outputStyle: 'plain' });
111
+ });
112
+
113
+ test('a profile that already runs its own copy of the gate script gets no second entry', (context) => {
114
+ const { homeDirectory, claudeHome, mainSettingsPath } = makeHome(context);
115
+ const ownGateCommand = `python3 ${join(homeDirectory, '.claude-profiles', 'isolated', 'hooks', 'blocking', 'open_tasks_stop_gate.py').replace(/\\/g, '/')}`;
116
+ const profilePath = writeProfile(homeDirectory, 'isolated', {
117
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: ownGateCommand }] }] },
118
+ });
119
+
120
+ const allOutcomes = addOpenTasksGateToProfiles({ mainSettingsPath, claudeHome, allProfileSettingsPaths: [profilePath], homeDirectory });
121
+
122
+ assert.deepEqual(allOutcomes, []);
123
+ assert.deepEqual(stopCommands(profilePath), [ownGateCommand]);
124
+ });
@@ -25,7 +25,7 @@ When the host keeps an asks ledger with `ledger.py` and `asks_check.py`, record
25
25
 
26
26
  The installer registers `open_tasks_stop_gate.py` as a Stop hook for each host:
27
27
 
28
- - Claude Code: `hooks.json`. A block returns the open items as the reason, and the session keeps working.
28
+ - Claude Code: `hooks.json`. A block returns the open items as the reason, and the session keeps working. An install into `~/.claude` also adds the same Stop entry to every profile under `~/.claude-profiles` that has its own `settings.json` and does not already run its own copy of the gate. A profile whose `settings.json` links to `~/.claude/settings.json` already has it. Uninstall removes only that entry.
29
29
  - Codex: `$CODEX_HOME/hooks.json`. Codex turns the reason into a continuation prompt.
30
30
  - Cursor: a native `stop` entry in `~/.cursor/hooks.json`. The reason comes back as `followup_message`, the next user message. A stop with status `aborted` or `error` passes, so a turn the user cancels stays silent.
31
31
 
@@ -40,6 +40,7 @@ What has been seen to work, in an interactive `cursor-agent` session (version 20
40
40
  - The `sessionStart` entry runs, and the session can quote the Asks line. Without it, the session cannot.
41
41
  - The `stop` entry runs at the end of each turn. With an open todo left, the gate sent one follow-up, and the agent closed the turn with a `Blocked:` line. Without the gate, the turn ended at "Done." with the todo still open.
42
42
  - A turn cancelled with Esc sends `stop` with status `aborted`, then `error`. The gate stays silent for both.
43
+ - On Windows, each hook command runs in PowerShell: Cursor writes it into a temporary `.ps1` script and starts `pwsh -NonInteractive -File` on it. A command that starts with a quote gets the `&` call operator first, so a quoted Python path with a space runs. A probe from `C:/.../py space/venv/Scripts/python.exe` ran with both a bare and a quoted script path. The Cursor editor (3.24.12) has the same `&` rule in its hook runner.
43
44
 
44
45
  Limits:
45
46
 
@@ -133,14 +133,26 @@ Once installed, that is `~/.claude/scripts/account_broker.py`.
133
133
 
134
134
  The installer writes one top-level line into `~/.codex/config.toml`, under a
135
135
  `# claude-dev-env trimmed Sol prompt` marker. The line points
136
- `model_instructions_file` at `~/.claude/system-prompts/codex-sol.md`. The file
136
+ `model_instructions_file` at `~/.codex/codex-sol.md`, a copy of the package's
137
+ `system-prompts/codex-sol.md`. The copy sits beside `config.toml`, so pruning
138
+ the managed files under `~/.claude`, which an older package version does on
139
+ `--update`, cannot leave Codex pointing at a missing file. The file
137
140
  is Codex's default `gpt-6.1-sol` prompt with the lines that conflict with the
138
141
  package rules rewritten. The line names no model, so every Codex run uses the
139
142
  file, whichever model it selects. `sync` links `config.toml` into every account
140
143
  home, so every account gets the line.
141
144
 
145
+ When `CODEX_HOME/config.toml` is the same file as `~/.codex/config.toml`, the
146
+ copy goes in `~/.codex`. `sync` makes that file a symbolic link, or a hard link
147
+ when the account cannot create symbolic links. Installing from an account home
148
+ then names the shared copy in `~/.codex`, and deleting or uninstalling that
149
+ account home leaves the other homes working. A `config.toml` that links
150
+ anywhere else, such as a dotfiles folder, keeps the copy in `CODEX_HOME`.
151
+
142
152
  A `model_instructions_file` the user wrote at the top level keeps the config as
143
- it is. Uninstall removes only the package line.
153
+ it is. Uninstall removes only the package line and the copy. Uninstalling from
154
+ an account home that shares `~/.codex/config.toml` leaves both alone, because
155
+ `~/.codex` still uses them.
144
156
 
145
157
  To see which prompt Codex uses, read the setting:
146
158
 
@@ -14,15 +14,19 @@ The filled four-answer stub printed `decision` `deepen`. The rewritten prompt ca
14
14
 
15
15
  | Reason | When |
16
16
  |---|---|
17
- | `restates-four-questions` | The rewrite contains every canonical question stem. The paste path is this case. |
17
+ | `restates-four-questions` | The rewrite contains every canonical question stem. The old paste path was this case. |
18
18
  | `missing-role-lines` | `SCOPE (role):`, `EXAMPLES (illustration only):`, or `ZOOM-IN BLOCKER:` is missing or blank. |
19
19
  | `example-noun-in-scope` | The scope line contains an example noun and `ZOOM-IN BLOCKER:` is `none`. |
20
20
  | `dropped-hard-constraint` | A bullet under `HARD CONSTRAINTS` in the task is absent as its own line in the rewrite. |
21
21
 
22
22
  Json mode prints `{"decision": "refuse", "reason": "..."}`. Rewrite mode and the command-hook path print nothing, so the prompt-submit mod leaves the submitted text in place.
23
23
 
24
+ ## The default rewrite
25
+
26
+ With no rewrite stub and no `CDE_DEEPEN_COMMAND`, `hooks/session/deepen_default_rewrite.py` builds the rewrite. It takes the scope from the task's own sentences, moves clauses the task marks as examples (`for example`, `for instance`, `e.g.`, `such as`) to the examples line, clips a scope over 240 characters with a pointer to the full task, copies the task with its hard constraints, and ends with one order to do the job. When the scope still names an example, the blocker line asks the worker to confirm before narrowing to it. The gate checks this rewrite like any other, so a first task deepens on every host with nothing configured.
27
+
24
28
  ## Proof shape
25
29
 
26
- Run the hook on stdin with `CDE_DEEPEN_OUTPUT=json`. The paste path refuses. A role rewrite that leaves the example noun out deepens. Removing `refusal_reason` from `decide_first_prompt` lets the paste path deepen again, and `test_should_refuse_the_paste_path_as_the_known_bad` fails. The wrong twin puts the example noun in the scope line with `ZOOM-IN BLOCKER: none` and is refused.
30
+ Run the hook on stdin with `CDE_DEEPEN_OUTPUT=json`. A stub that pastes the four question stems refuses, and the default rewrite deepens. A role rewrite that leaves the example noun out deepens. Removing `refusal_reason` from `decide_first_prompt` lets the paste path deepen again, and `test_should_refuse_the_paste_path_as_the_known_bad` fails. The wrong twin puts the example noun in the scope line with `ZOOM-IN BLOCKER: none` and is refused.
27
31
 
28
32
  `hooks/session/grade_deepstack_replay.py` scores a fixture whose task names one failure as an example. It calls `refusal_reason` and does not rewrite.
@@ -33,7 +33,7 @@ Preconditions:
33
33
  - **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
34
34
  - **Advisor guidance.** Input is SessionStart with `advisorModel` set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_advisor_rules_prompt.py -q`. The adjacent test observes guidance only when the advisor setting is enabled.
35
35
  - **Orchestrator start.** Input is SessionStart with its opt-in flag set. Run `python -m pytest packages/claude-dev-env/hooks/session/test_orchestrator_auto_starter.py -q`. The adjacent test observes an orchestrator directive.
36
- - **First-task deepen.** Input is a first UserPromptSubmit task. Run `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_first_prompt.py -q`. The adjacent test observes a skip table, a refusal of the paste path, a rewrite that carries the SCOPE, EXAMPLES, and ZOOM-IN BLOCKER lines, and one injection when two copies see the same Cursor prompt. `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_turn_marker.py -q` observes that only the first marker claim wins.
36
+ - **First-task deepen.** Input is a first UserPromptSubmit task. Run `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_first_prompt.py -q`. The adjacent test observes a skip table, a refusal of the paste path, a rewrite that carries the SCOPE, EXAMPLES, and ZOOM-IN BLOCKER lines, and one injection when two copies see the same Cursor prompt. `python -m pytest packages/claude-dev-env/hooks/session/test_deepen_turn_marker.py -q` observes that only the first marker claim wins. With nothing configured, `deepen_default_rewrite.py` builds that role-shaped rewrite, and `test_deepen_default_rewrite.py` checks the gate accepts it.
37
37
  - **Artifact template pointer.** Input is an `Artifact` quickstart call. Run `python -m pytest packages/claude-dev-env/hooks/session/test_artifact_template_pointer.py -q`. The adjacent test observes the status flowchart path first, the html-plan path ahead of the template path in `additionalContext`, and silence on a publish call or another skill.
38
38
  - **Denial quick fix.** Input is a `PermissionDenied` event with a bracketed rule label and a classifier verdict. Run `python -m pytest packages/claude-dev-env/hooks/advisory/test_auto_mode_denial_quick_fix.py -q`. The adjacent test observes the named rule, the allow entry, and the PowerShell block; a denial with no verdict gets a note and no block.
39
39
 
@@ -0,0 +1,39 @@
1
+ """Named values for the role-shaped default rewrite of a first task."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "ALL_EXAMPLE_SENTENCE_MARKERS",
7
+ "DEFAULT_BLOCKER_TEMPLATE",
8
+ "DEFAULT_CLOSING_LINE",
9
+ "DEFAULT_SCOPE_MAX_CHARACTERS",
10
+ "DEFAULT_TASK_HEADING",
11
+ "ALL_EXAMPLE_END_MARKERS",
12
+ "EXAMPLE_LEAD_PUNCTUATION",
13
+ "EXAMPLE_LIST_SEPARATOR",
14
+ "EXAMPLE_MARKER_ALTERNATION_SEPARATOR",
15
+ "EXAMPLE_MARKER_PATTERN_TEMPLATE",
16
+ "SCOPE_CLIPPED_SUFFIX",
17
+ "SENTENCE_BREAK_PATTERN",
18
+ "WORD_SEPARATOR",
19
+ ]
20
+
21
+ ALL_EXAMPLE_SENTENCE_MARKERS: tuple[str, ...] = ("for example", "for instance", "e.g.", "such as")
22
+ EXAMPLE_LEAD_PUNCTUATION: str = " ,:;-"
23
+ EXAMPLE_MARKER_ALTERNATION_SEPARATOR: str = "|"
24
+ EXAMPLE_MARKER_PATTERN_TEMPLATE: str = r"(?<!\w)(?:{alternatives})(?!\w)"
25
+ EXAMPLE_LIST_SEPARATOR: str = "; "
26
+ WORD_SEPARATOR: str = " "
27
+ ALL_EXAMPLE_END_MARKERS: tuple[str, ...] = (", and ", ", but ", ", then ", ", so ", ";", ")")
28
+ SENTENCE_BREAK_PATTERN: str = r"(?<=[.!?])\s+(?=[A-Z0-9\"'`(\[])"
29
+ DEFAULT_SCOPE_MAX_CHARACTERS: int = 240
30
+ SCOPE_CLIPPED_SUFFIX: str = "(the rest is in the task below)"
31
+ DEFAULT_TASK_HEADING: str = "Task as given:"
32
+ DEFAULT_BLOCKER_TEMPLATE: str = (
33
+ "the scope names something the task also gives as an example ({examples}); "
34
+ "confirm the job is only that case before narrowing to it"
35
+ )
36
+ DEFAULT_CLOSING_LINE: str = (
37
+ "Work the whole scope as a leading practitioner in this field would, "
38
+ "keep every hard constraint, and then do exactly that job."
39
+ )
@@ -0,0 +1,128 @@
1
+ """Role-shaped default rewrite for a first task when no rewrite command is set.
2
+
3
+ ``refusal_reason`` in ``deepen_role_gate.py`` refuses a rewrite that pastes the
4
+ four question stems back at the worker, and one without the SCOPE, EXAMPLES,
5
+ and ZOOM-IN BLOCKER lines. The default rewrite is built to carry those lines,
6
+ so a first task deepens on every host with nothing configured: the scope is
7
+ the task's own sentences with the marked examples taken out, the examples are
8
+ the parts the task marks as examples, and the blocker asks for confirmation
9
+ when the scope names one of those examples.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from hooks_constants.deepen_default_rewrite_constants import (
17
+ ALL_EXAMPLE_END_MARKERS,
18
+ ALL_EXAMPLE_SENTENCE_MARKERS,
19
+ DEFAULT_BLOCKER_TEMPLATE,
20
+ DEFAULT_CLOSING_LINE,
21
+ DEFAULT_SCOPE_MAX_CHARACTERS,
22
+ DEFAULT_TASK_HEADING,
23
+ EXAMPLE_LEAD_PUNCTUATION,
24
+ EXAMPLE_LIST_SEPARATOR,
25
+ EXAMPLE_MARKER_ALTERNATION_SEPARATOR,
26
+ EXAMPLE_MARKER_PATTERN_TEMPLATE,
27
+ SCOPE_CLIPPED_SUFFIX,
28
+ SENTENCE_BREAK_PATTERN,
29
+ WORD_SEPARATOR,
30
+ )
31
+ from hooks_constants.deepen_first_prompt_constants import (
32
+ DEEPEN_MARKER,
33
+ HARD_CONSTRAINTS_LABEL,
34
+ REFUSAL_EXAMPLE_NOUN_IN_SCOPE,
35
+ ROLE_BLOCKER_LABEL,
36
+ ROLE_BLOCKER_NONE,
37
+ ROLE_EXAMPLES_LABEL,
38
+ ROLE_SCOPE_LABEL,
39
+ )
40
+ from deepen_role_gate import refusal_reason
41
+
42
+
43
+ def _body_before_constraints(task_text: str) -> str:
44
+ all_body_lines: list[str] = []
45
+ for each_line in task_text.splitlines():
46
+ if each_line.strip().upper().startswith(HARD_CONSTRAINTS_LABEL):
47
+ break
48
+ all_body_lines.append(each_line)
49
+ return WORD_SEPARATOR.join(WORD_SEPARATOR.join(all_body_lines).split())
50
+
51
+
52
+ def _sentences(body_text: str) -> list[str]:
53
+ return [each.strip() for each in re.split(SENTENCE_BREAK_PATTERN, body_text) if each.strip()]
54
+
55
+
56
+ def _example_marker_pattern() -> re.Pattern[str]:
57
+ alternatives = EXAMPLE_MARKER_ALTERNATION_SEPARATOR.join(re.escape(each_marker) for each_marker in ALL_EXAMPLE_SENTENCE_MARKERS)
58
+ return re.compile(EXAMPLE_MARKER_PATTERN_TEMPLATE.format(alternatives=alternatives), re.IGNORECASE)
59
+
60
+
61
+ def _split_example(sentence: str) -> tuple[str, list[str]]:
62
+ """Return the sentence without its marked examples, and the example texts.
63
+
64
+ A marker counts only as a whole phrase, so "for examples of" and "such
65
+ assumptions" stay in the scope. The earliest marker splits first, and the
66
+ text after the example is split again for later markers.
67
+ """
68
+ marker_match = _example_marker_pattern().search(sentence)
69
+ if marker_match is None:
70
+ return sentence, []
71
+ head = sentence[: marker_match.start()].rstrip(EXAMPLE_LEAD_PUNCTUATION + "(")
72
+ tail = sentence[marker_match.end() :].lstrip(EXAMPLE_LEAD_PUNCTUATION)
73
+ end_index = min(
74
+ (tail.find(each_end) for each_end in ALL_EXAMPLE_END_MARKERS if tail.find(each_end) >= 0),
75
+ default=len(tail),
76
+ )
77
+ example = tail[:end_index].rstrip(".!? ")
78
+ remainder_scope, all_remainder_examples = _split_example(
79
+ tail[end_index:].lstrip(EXAMPLE_LEAD_PUNCTUATION + ")")
80
+ )
81
+ all_examples = ([example] if example else []) + all_remainder_examples
82
+ scope_part = f"{head} {remainder_scope}".strip() if head else ""
83
+ return scope_part, all_examples
84
+
85
+
86
+ def _clipped(text: str) -> str:
87
+ if len(text) <= DEFAULT_SCOPE_MAX_CHARACTERS:
88
+ return text
89
+ cut = text[:DEFAULT_SCOPE_MAX_CHARACTERS]
90
+ kept = cut[: cut.rfind(" ")] if " " in cut else cut
91
+ return f"{kept} {SCOPE_CLIPPED_SUFFIX}"
92
+
93
+
94
+ def _scope_and_examples(task_text: str) -> tuple[str, str]:
95
+ all_splits = [_split_example(each) for each in _sentences(_body_before_constraints(task_text))]
96
+ all_examples = [each_example for _, all_sentence_examples in all_splits for each_example in all_sentence_examples]
97
+ all_scope_parts = [each_part for each_part, _ in all_splits if each_part]
98
+ scope_text = WORD_SEPARATOR.join(all_scope_parts) or task_text.strip()
99
+ examples_text = EXAMPLE_LIST_SEPARATOR.join(all_examples) if all_examples else ROLE_BLOCKER_NONE
100
+ return _clipped(scope_text), examples_text
101
+
102
+
103
+ def _rewrite_with_blocker(scope: str, examples: str, blocker: str, task_text: str) -> str:
104
+ return (
105
+ f"{DEEPEN_MARKER}\n\n"
106
+ f"{ROLE_SCOPE_LABEL} {scope}\n"
107
+ f"{ROLE_EXAMPLES_LABEL} {examples}\n"
108
+ f"{ROLE_BLOCKER_LABEL} {blocker}\n\n"
109
+ f"{DEFAULT_TASK_HEADING}\n{task_text}\n\n"
110
+ f"{DEFAULT_CLOSING_LINE}"
111
+ )
112
+
113
+
114
+ def role_default_rewrite(task_text: str) -> str:
115
+ """Return the default rewrite: role lines from the task, the task itself, and one closing order.
116
+
117
+ Args:
118
+ task_text: The first task with any leading /deepen removed.
119
+
120
+ Returns:
121
+ A rewrite that opens with the deepen marker and carries the three role lines.
122
+ """
123
+ scope, examples = _scope_and_examples(task_text)
124
+ rewrite = _rewrite_with_blocker(scope, examples, ROLE_BLOCKER_NONE, task_text)
125
+ if refusal_reason(task_text, rewrite) != REFUSAL_EXAMPLE_NOUN_IN_SCOPE:
126
+ return rewrite
127
+ blocker = DEFAULT_BLOCKER_TEMPLATE.format(examples=examples)
128
+ return _rewrite_with_blocker(scope, examples, blocker, task_text)
@@ -31,8 +31,6 @@ from hooks_constants.cloud_session import is_cloud_session
31
31
  from hooks_constants.deepen_first_prompt_constants import (
32
32
  ADDITIONAL_CONTEXT_KEY,
33
33
  ALL_FIRST_PROMPT_EVENT_NAMES,
34
- ALL_FOUR_QUESTION_STEMS,
35
- QUESTION_LINE_SEPARATOR,
36
34
  ALL_HELPER_REPORT_MARKERS,
37
35
  ALL_LOOP_PROMPT_PREFIXES,
38
36
  ALL_NONINTERACTIVE_ENV_TRUE_VALUES,
@@ -68,6 +66,7 @@ from hooks_constants.deepen_first_prompt_constants import (
68
66
  from hooks_constants.mod_handoff import is_mod_plugin_enabled
69
67
  from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
70
68
  from hooks_constants.subprocess_window import hidden_window_creation_flags
69
+ from deepen_default_rewrite import role_default_rewrite
71
70
  from deepen_role_gate import refusal_reason
72
71
  from deepen_turn_marker import claim_turn_marker, turn_marker_exists
73
72
 
@@ -256,19 +255,8 @@ def _command_rewrite(task_text: str) -> str | None:
256
255
 
257
256
 
258
257
  def _default_rewrite(task_text: str) -> str:
259
- """Return the paste path: the four question stems and an order to do the job."""
260
- numbered_stems = QUESTION_LINE_SEPARATOR.join(
261
- f"{each_index}. {each_stem}"
262
- for each_index, each_stem in enumerate(ALL_FOUR_QUESTION_STEMS, start=1)
263
- )
264
- return (
265
- f"{DEEPEN_MARKER}\n\n"
266
- "Do this task as a leading practitioner in its field would.\n\n"
267
- f"{task_text}\n\n"
268
- "Cover four things the original prompt left out:\n"
269
- f"{numbered_stems}\n"
270
- "Then do exactly that job."
271
- )
258
+ """Return the role-shaped default rewrite the gate accepts."""
259
+ return role_default_rewrite(task_text)
272
260
 
273
261
 
274
262
  def expand_first_task(prompt: str) -> str: