claude-dev-env 8.88.3 → 8.88.5

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/install.mjs CHANGED
@@ -133,6 +133,8 @@ import {
133
133
  addOpenTasksGateToProfiles,
134
134
  otherProfileSettingsPaths,
135
135
  removeOpenTasksGateFromProfiles,
136
+ splitUnreadableProfileSettings,
137
+ unparsedSettingsMayHoldGate,
136
138
  } from './profile-stop-gate.mjs';
137
139
  import { pythonExecutableProbeCommand } from './python-probe.mjs';
138
140
 
@@ -2714,6 +2716,54 @@ function profileSettingsPathsForStopGate(mainSettingsPath) {
2714
2716
  return otherProfileSettingsPaths({ homeDirectory: homedir(), environment: process.env, mainSettingsPath });
2715
2717
  }
2716
2718
 
2719
+ /**
2720
+ * Return the profile settings the gate step can parse, and add an Action needed line for each one it cannot.
2721
+ *
2722
+ * @param {string} mainSettingsPath
2723
+ * @returns {string[]}
2724
+ */
2725
+ function readableProfileSettingsPathsForStopGate(mainSettingsPath) {
2726
+ const { allReadablePaths, allUnreadable } = splitUnreadableProfileSettings(
2727
+ profileSettingsPathsForStopGate(mainSettingsPath),
2728
+ );
2729
+ for (const { settingsPath, reason } of allUnreadable) {
2730
+ const actionLine = `Profile settings: ${settingsPath} was left unchanged because it is not valid JSON (${reason}).`
2731
+ + ' Fix the file and run the install again to add the open-tasks Stop gate there.';
2732
+ console.warn(` ${actionLine}`);
2733
+ allActionNeededLines.push(actionLine);
2734
+ }
2735
+ return allReadablePaths;
2736
+ }
2737
+
2738
+ /**
2739
+ * Return the profile settings the uninstall removes the gate from, or the reason it must stop first.
2740
+ *
2741
+ * A profile file that does not parse and may still hold the gate stops the
2742
+ * uninstall before it changes anything. The manifest then stays, so running
2743
+ * --uninstall again after fixing the file removes the gate. A file that does
2744
+ * not parse and does not name the gate has nothing to remove and is left as
2745
+ * it is.
2746
+ *
2747
+ * @param {string} mainSettingsPath
2748
+ * @returns {{ allReadablePaths: string[], blockingReason: string|null }}
2749
+ */
2750
+ function profileSettingsPathsForGateRemoval(mainSettingsPath) {
2751
+ const { allReadablePaths, allUnreadable } = splitUnreadableProfileSettings(
2752
+ profileSettingsPathsForStopGate(mainSettingsPath),
2753
+ );
2754
+ const allHoldingGate = allUnreadable.filter(
2755
+ ({ settingsPath }) => unparsedSettingsMayHoldGate(settingsPath, CLAUDE_HOME),
2756
+ );
2757
+ if (allHoldingGate.length === 0) return { allReadablePaths, blockingReason: null };
2758
+ const fileLines = allHoldingGate.map(({ settingsPath, reason }) => ` ${settingsPath}: not valid JSON (${reason})`);
2759
+ const blockingReason = [
2760
+ 'ERROR: uninstall stopped before changing anything. These profile settings files still name the open-tasks Stop gate, and they are not valid JSON, so the gate cannot be removed from them:',
2761
+ ...fileLines,
2762
+ 'Fix each file and run --uninstall again.',
2763
+ ].join('\n');
2764
+ return { allReadablePaths, blockingReason };
2765
+ }
2766
+
2717
2767
  function executeInstallPlan(plan) {
2718
2768
  const faultPhase = resolveFaultPhaseFromEnvironment(process.env);
2719
2769
  const snapshot = capturePriorInstallSnapshot({
@@ -3208,7 +3258,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
3208
3258
  for (const eachOutcome of addOpenTasksGateToProfiles({
3209
3259
  mainSettingsPath: join(CLAUDE_HOME, SETTINGS_FILE_NAME),
3210
3260
  claudeHome: CLAUDE_HOME,
3211
- allProfileSettingsPaths: profileSettingsPathsForStopGate(join(CLAUDE_HOME, SETTINGS_FILE_NAME)),
3261
+ allProfileSettingsPaths: readableProfileSettingsPathsForStopGate(join(CLAUDE_HOME, SETTINGS_FILE_NAME)),
3212
3262
  homeDirectory: homedir(),
3213
3263
  })) {
3214
3264
  if (eachOutcome.additions.length > 0) {
@@ -3498,7 +3548,7 @@ function executeUninstallPlan(plan, helpers = {}) {
3498
3548
  }
3499
3549
  for (const eachChangedPath of removeOpenTasksGateFromProfiles({
3500
3550
  claudeHome: CLAUDE_HOME,
3501
- allProfileSettingsPaths: profileSettingsPathsForStopGate(plan.settingsPath),
3551
+ allProfileSettingsPaths: helpers.allProfileSettingsPaths,
3502
3552
  })) {
3503
3553
  console.log(` Profile settings: open-tasks Stop gate removed from ${eachChangedPath}`);
3504
3554
  }
@@ -3554,6 +3604,11 @@ function uninstall() {
3554
3604
  if (plan.isNoOp) {
3555
3605
  return;
3556
3606
  }
3607
+ const gateRemoval = profileSettingsPathsForGateRemoval(plan.settingsPath);
3608
+ if (gateRemoval.blockingReason) {
3609
+ console.error(gateRemoval.blockingReason);
3610
+ process.exit(1);
3611
+ }
3557
3612
 
3558
3613
  const faultPhase = resolveFaultPhaseFromEnvironment(process.env);
3559
3614
  const snapshot = capturePriorInstallSnapshot({
@@ -3576,7 +3631,7 @@ function uninstall() {
3576
3631
  snapshot,
3577
3632
  faultPhase,
3578
3633
  runMutations: ({ throwIfFault }) => {
3579
- executeUninstallPlan(plan, { throwIfFault });
3634
+ executeUninstallPlan(plan, { throwIfFault, allProfileSettingsPaths: gateRemoval.allReadablePaths });
3580
3635
  },
3581
3636
  });
3582
3637
  } catch (mutationError) {
@@ -3726,3 +3726,104 @@ test('install adds the open-tasks Stop gate to every profile with its own settin
3726
3726
  assert.equal(uninstallation.status, 0, uninstallation.stderr);
3727
3727
  assert.deepEqual(JSON.parse(readFileSync(profileSettingsPath, 'utf8')), { outputStyle: 'plain' });
3728
3728
  });
3729
+
3730
+ test('a profile settings.json that is not valid JSON is left alone and named under Action needed, and the rest of the install and uninstall go through', (context) => {
3731
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-profile-gate-malformed-'));
3732
+ context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
3733
+ const brokenSettingsPath = join(homeDirectory, '.claude-profiles', 'broken', 'settings.json');
3734
+ const goodSettingsPath = join(homeDirectory, '.claude-profiles', 'good', 'settings.json');
3735
+ const brokenSettingsText = '{"hooks":{},}\n';
3736
+ mkdirSync(dirname(brokenSettingsPath), { recursive: true });
3737
+ mkdirSync(dirname(goodSettingsPath), { recursive: true });
3738
+ writeFileSync(brokenSettingsPath, brokenSettingsText);
3739
+ writeFileSync(goodSettingsPath, JSON.stringify({ outputStyle: 'plain' }, null, 4));
3740
+ const runInstaller = (argumentsList) => spawnSync(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), 'install.mjs'), ...argumentsList], {
3741
+ cwd: dirname(dirname(fileURLToPath(import.meta.url))),
3742
+ encoding: 'utf8',
3743
+ env: {
3744
+ ...process.env,
3745
+ CLAUDE_CONFIG_DIR: undefined,
3746
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
3747
+ CLAUDE_DEV_ENV_INSTALL_FAULT: undefined,
3748
+ CDE_INSTALL_PSTACK: '0',
3749
+ CDE_INSTALL_DEEPSTACK: '0',
3750
+ CDE_INSTALL_USAGE_WRAPUP: '0',
3751
+ CDE_INSTALL_SUBAGENT_MODELS: '0',
3752
+ CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
3753
+ HOME: homeDirectory,
3754
+ USERPROFILE: homeDirectory,
3755
+ CODEX_HOME: join(homeDirectory, '.codex'),
3756
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
3757
+ TEMP: homeDirectory,
3758
+ TMP: homeDirectory,
3759
+ },
3760
+ });
3761
+ const goodGateCount = () => (JSON.parse(readFileSync(goodSettingsPath, 'utf8')).hooks?.Stop ?? [])
3762
+ .flatMap((eachGroup) => eachGroup.hooks.map((eachHook) => eachHook.command))
3763
+ .filter((eachCommand) => eachCommand.includes('open_tasks_stop_gate.py')).length;
3764
+ const actionNeededSection = (stderrText) => stderrText.slice(stderrText.indexOf('Action needed'));
3765
+
3766
+ const installation = runInstaller([]);
3767
+ assert.equal(installation.status, 0, installation.stderr);
3768
+ assert.equal(readFileSync(brokenSettingsPath, 'utf8'), brokenSettingsText);
3769
+ assert.equal(goodGateCount(), 1);
3770
+ assert.match(actionNeededSection(installation.stderr), /Action needed \(1\):/);
3771
+ assert.ok(actionNeededSection(installation.stderr).includes(brokenSettingsPath));
3772
+ assert.match(actionNeededSection(installation.stderr), /not valid JSON \(.+\)/);
3773
+
3774
+ const uninstallation = runInstaller(['--uninstall']);
3775
+ assert.equal(uninstallation.status, 0, uninstallation.stderr);
3776
+ assert.equal(readFileSync(brokenSettingsPath, 'utf8'), brokenSettingsText);
3777
+ assert.equal(goodGateCount(), 0);
3778
+ });
3779
+
3780
+ test('uninstall stops before changing anything while a profile settings.json that is not valid JSON still names the gate, and a rerun after the fix removes it', (context) => {
3781
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cde-profile-gate-uninstall-malformed-'));
3782
+ context.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
3783
+ const profileSettingsPath = join(homeDirectory, '.claude-profiles', 'prefs-only', 'settings.json');
3784
+ mkdirSync(dirname(profileSettingsPath), { recursive: true });
3785
+ writeFileSync(profileSettingsPath, JSON.stringify({ outputStyle: 'plain' }, null, 4));
3786
+ const runInstaller = (argumentsList) => spawnSync(process.execPath, [join(dirname(fileURLToPath(import.meta.url)), 'install.mjs'), ...argumentsList], {
3787
+ cwd: dirname(dirname(fileURLToPath(import.meta.url))),
3788
+ encoding: 'utf8',
3789
+ env: {
3790
+ ...process.env,
3791
+ CLAUDE_CONFIG_DIR: undefined,
3792
+ LLM_SETTINGS_PROFILES_ROOT: undefined,
3793
+ CLAUDE_DEV_ENV_INSTALL_FAULT: undefined,
3794
+ CDE_INSTALL_PSTACK: '0',
3795
+ CDE_INSTALL_DEEPSTACK: '0',
3796
+ CDE_INSTALL_USAGE_WRAPUP: '0',
3797
+ CDE_INSTALL_SUBAGENT_MODELS: '0',
3798
+ CDE_INSTALL_DEEPEN_FIRST_PROMPT: '0',
3799
+ HOME: homeDirectory,
3800
+ USERPROFILE: homeDirectory,
3801
+ CODEX_HOME: join(homeDirectory, '.codex'),
3802
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
3803
+ TEMP: homeDirectory,
3804
+ TMP: homeDirectory,
3805
+ },
3806
+ });
3807
+ const manifestPath = join(homeDirectory, '.claude', '.claude-dev-env-manifest.json');
3808
+
3809
+ const installation = runInstaller([]);
3810
+ assert.equal(installation.status, 0, installation.stderr);
3811
+ const validText = readFileSync(profileSettingsPath, 'utf8');
3812
+ assert.ok(validText.includes('open_tasks_stop_gate.py'));
3813
+ const brokenText = validText.trimEnd().replace(/\}$/, ',}') + '\n';
3814
+ writeFileSync(profileSettingsPath, brokenText);
3815
+
3816
+ const stoppedUninstall = runInstaller(['--uninstall']);
3817
+ assert.equal(stoppedUninstall.status, 1, stoppedUninstall.stdout);
3818
+ assert.ok(stoppedUninstall.stderr.includes(profileSettingsPath), stoppedUninstall.stderr);
3819
+ assert.match(stoppedUninstall.stderr, /not valid JSON \(.+\)/);
3820
+ assert.match(stoppedUninstall.stderr, /run --uninstall again/);
3821
+ assert.equal(readFileSync(profileSettingsPath, 'utf8'), brokenText);
3822
+ assert.ok(existsSync(manifestPath));
3823
+
3824
+ writeFileSync(profileSettingsPath, validText);
3825
+ const rerunUninstall = runInstaller(['--uninstall']);
3826
+ assert.equal(rerunUninstall.status, 0, rerunUninstall.stderr);
3827
+ assert.deepEqual(JSON.parse(readFileSync(profileSettingsPath, 'utf8')), { outputStyle: 'plain' });
3828
+ assert.equal(existsSync(manifestPath), false);
3829
+ });
@@ -82,6 +82,55 @@ export function otherProfileSettingsPaths(options) {
82
82
  return allOtherPaths;
83
83
  }
84
84
 
85
+ /**
86
+ * Split profile settings paths into the ones whose settings.json parses and the ones that do not.
87
+ *
88
+ * A profile file that is not valid JSON is left as it is. The caller names
89
+ * each one, with its parse error, so the user can fix it and run the install
90
+ * again. An empty file counts as readable.
91
+ *
92
+ * @param {string[]} allProfileSettingsPaths
93
+ * @returns {{ allReadablePaths: string[], allUnreadable: { settingsPath: string, reason: string }[] }}
94
+ */
95
+ export function splitUnreadableProfileSettings(allProfileSettingsPaths) {
96
+ const allReadablePaths = [];
97
+ const allUnreadable = [];
98
+ for (const eachPath of allProfileSettingsPaths) {
99
+ try {
100
+ const settingsText = readFileSync(eachPath, 'utf8');
101
+ if (settingsText.trim()) JSON.parse(settingsText);
102
+ allReadablePaths.push(eachPath);
103
+ } catch (readError) {
104
+ allUnreadable.push({
105
+ settingsPath: eachPath,
106
+ reason: readError instanceof Error ? readError.message : String(readError),
107
+ });
108
+ }
109
+ }
110
+ return { allReadablePaths, allUnreadable };
111
+ }
112
+
113
+ /**
114
+ * True when a settings.json that does not parse may still hold this Claude home's open-tasks gate.
115
+ *
116
+ * The raw text is searched for the gate script path, with JSON-escaped
117
+ * backslashes read as path separators. A file that cannot be read counts as
118
+ * one that may hold the gate.
119
+ *
120
+ * @param {string} settingsPath
121
+ * @param {string} claudeHome
122
+ * @returns {boolean}
123
+ */
124
+ export function unparsedSettingsMayHoldGate(settingsPath, claudeHome) {
125
+ let settingsText;
126
+ try {
127
+ settingsText = readFileSync(settingsPath, 'utf8');
128
+ } catch {
129
+ return true;
130
+ }
131
+ return comparableCommand(settingsText.replace(/\\\\/g, '/')).includes(gateScriptCommandFragment(claudeHome));
132
+ }
133
+
85
134
  function stopHooks(settingsPath) {
86
135
  const settingsText = readFileSync(settingsPath, 'utf8');
87
136
  if (!settingsText.trim()) return [];
@@ -8,6 +8,8 @@ import {
8
8
  addOpenTasksGateToProfiles,
9
9
  otherProfileSettingsPaths,
10
10
  removeOpenTasksGateFromProfiles,
11
+ splitUnreadableProfileSettings,
12
+ unparsedSettingsMayHoldGate,
11
13
  } from './profile-stop-gate.mjs';
12
14
 
13
15
  function makeHome(context) {
@@ -122,3 +124,37 @@ test('a profile that already runs its own copy of the gate script gets no second
122
124
  assert.deepEqual(allOutcomes, []);
123
125
  assert.deepEqual(stopCommands(profilePath), [ownGateCommand]);
124
126
  });
127
+
128
+ test('a settings.json that is not valid JSON is reported with its parse error, and an empty one counts as readable', (context) => {
129
+ const { homeDirectory } = makeHome(context);
130
+ const goodPath = writeProfile(homeDirectory, 'good', { outputStyle: 'plain' });
131
+ const emptyPath = writeProfile(homeDirectory, 'empty');
132
+ writeFileSync(emptyPath, '');
133
+ const brokenPath = writeProfile(homeDirectory, 'broken');
134
+ writeFileSync(brokenPath, '{"hooks":{},}');
135
+
136
+ const { allReadablePaths, allUnreadable } = splitUnreadableProfileSettings([goodPath, emptyPath, brokenPath]);
137
+
138
+ assert.deepEqual(allReadablePaths, [goodPath, emptyPath]);
139
+ assert.equal(allUnreadable.length, 1);
140
+ assert.equal(allUnreadable[0].settingsPath, brokenPath);
141
+ assert.match(allUnreadable[0].reason, /JSON/);
142
+ });
143
+
144
+ test('a settings.json that does not parse may hold the gate when its text names this home\'s gate script, in either slash spelling', (context) => {
145
+ const { homeDirectory, claudeHome, gateCommand } = makeHome(context);
146
+ const forwardPath = writeProfile(homeDirectory, 'forward');
147
+ writeFileSync(forwardPath, `{"hooks":{"Stop":[{"hooks":[{"command":"${gateCommand}"}]}]},}`);
148
+ const escapedPath = writeProfile(homeDirectory, 'escaped');
149
+ writeFileSync(escapedPath, `{"hooks":{"Stop":[{"hooks":[{"command":"${gateCommand.replace(/\//g, '\\\\')}"}]}]},}`);
150
+ const otherHomePath = writeProfile(homeDirectory, 'other-home');
151
+ writeFileSync(otherHomePath, '{"hooks":{"Stop":[{"hooks":[{"command":"python3 /opt/other/.claude/hooks/blocking/open_tasks_stop_gate.py"}]}]},}');
152
+ const noGatePath = writeProfile(homeDirectory, 'no-gate');
153
+ writeFileSync(noGatePath, '{"hooks":{},}');
154
+
155
+ assert.equal(unparsedSettingsMayHoldGate(forwardPath, claudeHome), true);
156
+ assert.equal(unparsedSettingsMayHoldGate(escapedPath, claudeHome), true);
157
+ assert.equal(unparsedSettingsMayHoldGate(otherHomePath, claudeHome), false);
158
+ assert.equal(unparsedSettingsMayHoldGate(noGatePath, claudeHome), false);
159
+ assert.equal(unparsedSettingsMayHoldGate(join(homeDirectory, 'missing', 'settings.json'), claudeHome), true);
160
+ });
@@ -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. 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.
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. On install, a profile `settings.json` that is not valid JSON is left unchanged and listed under Action needed with its path and parse error, and the rest of the install goes on. On uninstall, such a file that still names the gate stops the uninstall before anything changes, so the manifest stays and a rerun after the fix removes the gate; one that does not name the gate is left as it is.
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
 
@@ -0,0 +1,83 @@
1
+ """Behavior tests for the first-task rewrite refusal gate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _SESSION_DIR = Path(__file__).resolve().parent
9
+ _HOOKS_ROOT = _SESSION_DIR.parent
10
+ for each_sys_path_entry in (str(_SESSION_DIR), str(_HOOKS_ROOT)):
11
+ if each_sys_path_entry not in sys.path:
12
+ sys.path.insert(0, each_sys_path_entry)
13
+
14
+ from deepen_role_gate import refusal_reason
15
+ from hooks_constants.deepen_first_prompt_constants import (
16
+ ALL_FOUR_QUESTION_STEMS,
17
+ REFUSAL_DROPPED_HARD_CONSTRAINT,
18
+ REFUSAL_EXAMPLE_NOUN_IN_SCOPE,
19
+ REFUSAL_MISSING_ROLE_LINES,
20
+ REFUSAL_RESTATES_FOUR_QUESTIONS,
21
+ )
22
+
23
+
24
+ def test_accepts_a_rewrite_with_role_lines_and_preserved_constraint() -> None:
25
+ original_task = "Fix the settings page.\nHARD CONSTRAINTS\n- Keep the save button."
26
+ rewritten_prompt = (
27
+ "SCOPE (role): Fix the settings page.\n"
28
+ "EXAMPLES (illustration only): none\n"
29
+ "ZOOM-IN BLOCKER: none\n"
30
+ "Keep the save button."
31
+ )
32
+
33
+ assert refusal_reason(original_task, rewritten_prompt) is None
34
+
35
+
36
+ def test_refuses_a_rewrite_that_repeats_all_four_questions() -> None:
37
+ rewritten_prompt = (
38
+ "SCOPE (role): Fix the settings page.\n"
39
+ "EXAMPLES (illustration only): none\n"
40
+ "ZOOM-IN BLOCKER: none\n"
41
+ + "\n".join(ALL_FOUR_QUESTION_STEMS)
42
+ )
43
+
44
+ assert refusal_reason("Fix the settings page.", rewritten_prompt) == (
45
+ REFUSAL_RESTATES_FOUR_QUESTIONS
46
+ )
47
+
48
+
49
+ def test_refuses_a_rewrite_with_a_blank_role_line() -> None:
50
+ rewritten_prompt = (
51
+ "SCOPE (role): Fix the settings page.\n"
52
+ "EXAMPLES (illustration only): none\n"
53
+ "ZOOM-IN BLOCKER: "
54
+ )
55
+
56
+ assert refusal_reason("Fix the settings page.", rewritten_prompt) == (
57
+ REFUSAL_MISSING_ROLE_LINES
58
+ )
59
+
60
+
61
+ def test_refuses_an_example_noun_in_scope_without_a_blocker() -> None:
62
+ rewritten_prompt = (
63
+ "SCOPE (role): Add contrast to the settings page.\n"
64
+ "EXAMPLES (illustration only): contrast tokens\n"
65
+ "ZOOM-IN BLOCKER: none"
66
+ )
67
+
68
+ assert refusal_reason("Fix the settings page.", rewritten_prompt) == (
69
+ REFUSAL_EXAMPLE_NOUN_IN_SCOPE
70
+ )
71
+
72
+
73
+ def test_refuses_a_rewrite_that_drops_a_hard_constraint() -> None:
74
+ original_task = "Fix the settings page.\nHARD CONSTRAINTS\n- Keep the save button."
75
+ rewritten_prompt = (
76
+ "SCOPE (role): Fix the settings page.\n"
77
+ "EXAMPLES (illustration only): none\n"
78
+ "ZOOM-IN BLOCKER: none"
79
+ )
80
+
81
+ assert refusal_reason(original_task, rewritten_prompt) == (
82
+ REFUSAL_DROPPED_HARD_CONSTRAINT
83
+ )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.88.3",
3
+ "version": "8.88.5",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {