claude-dev-env 8.56.0 → 8.56.2

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.
@@ -25,7 +25,7 @@ export const PSTACK_PLUGIN_SPEC = Object.freeze({
25
25
  export const USAGE_WRAPUP_PLUGIN_SPEC = Object.freeze({
26
26
  name: 'usage-wrapup',
27
27
  label: 'Usage-wrapup',
28
- marketplaceRepository: 'jl-cmd/claude-dev-env',
28
+ marketplaceRepository: 'jl-cmd-projects/claude-dev-env',
29
29
  marketplaceName: 'claude-dev-env',
30
30
  sparsePaths: Object.freeze(['.claude-plugin', 'packages/usage-wrapup']),
31
31
  pluginIdentifier: 'usage-wrapup@claude-dev-env',
@@ -37,7 +37,7 @@ export const USAGE_WRAPUP_PLUGIN_SPEC = Object.freeze({
37
37
  export const SUBAGENT_MODELS_PLUGIN_SPEC = Object.freeze({
38
38
  name: 'subagent-models',
39
39
  label: 'Subagent-models',
40
- marketplaceRepository: 'jl-cmd/claude-dev-env',
40
+ marketplaceRepository: 'jl-cmd-projects/claude-dev-env',
41
41
  marketplaceName: 'claude-dev-env',
42
42
  sparsePaths: Object.freeze(['.claude-plugin', 'packages/subagent-models']),
43
43
  pluginIdentifier: 'subagent-models@claude-dev-env',
@@ -219,7 +219,7 @@ test('the usage-wrapup plan adds this repository marketplace and installs the pl
219
219
  assert.equal(plan.executable, 'claude');
220
220
  assert.equal(plan.homeVariable, 'CLAUDE_CONFIG_DIR');
221
221
  assert.deepEqual(plan.commands, [
222
- ['plugin', 'marketplace', 'add', 'jl-cmd/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
222
+ ['plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
223
223
  ['plugin', 'install', 'usage-wrapup@claude-dev-env'],
224
224
  ]);
225
225
  });
@@ -237,7 +237,7 @@ test('installing usage-wrapup runs only the Claude commands, inside the managed
237
237
  assert.deepEqual(runner.calls.map(call => [call.executable, ...call.commandArguments]), [
238
238
  ['claude', 'plugin', 'uninstall', 'usage-wrapup@claude-dev-env'],
239
239
  ['claude', 'plugin', 'marketplace', 'remove', 'claude-dev-env'],
240
- ['claude', 'plugin', 'marketplace', 'add', 'jl-cmd/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
240
+ ['claude', 'plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env', '--sparse', '.claude-plugin', 'packages/usage-wrapup'],
241
241
  ['claude', 'plugin', 'install', 'usage-wrapup@claude-dev-env'],
242
242
  ]);
243
243
  for (const call of runner.calls) {
@@ -297,7 +297,7 @@ test('should remove and add the shared marketplace once when installing usage-wr
297
297
  ['claude', 'plugin', 'uninstall', 'subagent-models@claude-dev-env'],
298
298
  ['claude', 'plugin', 'marketplace', 'remove', 'claude-dev-env'],
299
299
  [
300
- 'claude', 'plugin', 'marketplace', 'add', 'jl-cmd/claude-dev-env',
300
+ 'claude', 'plugin', 'marketplace', 'add', 'jl-cmd-projects/claude-dev-env',
301
301
  '--sparse', '.claude-plugin', 'packages/usage-wrapup', 'packages/subagent-models',
302
302
  ],
303
303
  ['claude', 'plugin', 'install', 'usage-wrapup@claude-dev-env'],
@@ -308,7 +308,7 @@ test('should remove and add the shared marketplace once when installing usage-wr
308
308
  test('should refuse one plan for plugins from two marketplaces', () => {
309
309
  assert.throws(
310
310
  () => marketplacePluginsPlan([PSTACK_PLUGIN_SPEC, SUBAGENT_MODELS_PLUGIN_SPEC], 'claude'),
311
- /subagent-models comes from jl-cmd\/claude-dev-env/,
311
+ /subagent-models comes from jl-cmd-projects\/claude-dev-env/,
312
312
  );
313
313
  });
314
314
 
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Installing twice into a profile root under ~/.claude-profiles leaves exactly
3
- * one session-title gate Stop hook, that hook runs the gate under ~/.claude, and
4
- * the second install leaves settings.json byte for byte.
2
+ * Installing into a profile root under ~/.claude-profiles retires the profile
3
+ * session-title gate an earlier install left: its Stop hook and allow rule leave
4
+ * settings.json, its files leave ~/.claude, and a second install leaves
5
+ * settings.json byte for byte.
5
6
  */
6
7
 
7
8
  import { test } from 'node:test';
@@ -12,7 +13,11 @@ import { tmpdir } from 'node:os';
12
13
  import { fileURLToPath } from 'node:url';
13
14
  import { spawnSync } from 'node:child_process';
14
15
 
15
- import { ALL_SESSION_TITLE_GATE_RELATIVE_PATHS, SESSION_TITLE_GATE_FILE_NAME } from './merge_profile_settings.mjs';
16
+ import {
17
+ RETIRED_SESSION_TITLE_GATE_COMMAND,
18
+ RETIRED_SESSION_TITLE_GATE_FILE_NAME,
19
+ RETIRED_SESSION_TITLE_GATE_PERMISSION,
20
+ } from './merge_profile_settings.mjs';
16
21
 
17
22
  const INSTALLER_PATH = fileURLToPath(new URL('./install.mjs', import.meta.url));
18
23
 
@@ -46,31 +51,33 @@ function stopHookCommands(settingsPath) {
46
51
  );
47
52
  }
48
53
 
49
- test('two installs into a profile root keep one gate Stop hook that runs the gate under ~/.claude', () => {
54
+ test('an install retires the profile gate, and a second install leaves settings.json byte for byte', () => {
50
55
  const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-profile-settings-'));
51
56
  try {
52
57
  writeFileSync(join(homeDirectory, '.gitconfig'), '');
53
58
  const profileRoot = join(homeDirectory, '.claude-profiles', 'work');
54
59
  mkdirSync(profileRoot, { recursive: true });
55
60
  const settingsPath = join(profileRoot, 'settings.json');
56
- const forwardSlashedHome = homeDirectory.replace(/\\/g, '/');
57
- const expectedGateCommand = `python3 ${forwardSlashedHome}/.claude/${SESSION_TITLE_GATE_FILE_NAME}`;
61
+ writeFileSync(settingsPath, JSON.stringify({
62
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: RETIRED_SESSION_TITLE_GATE_COMMAND, timeout: 10 }] }] },
63
+ permissions: { allow: [RETIRED_SESSION_TITLE_GATE_PERMISSION] },
64
+ }, null, 4) + '\n');
65
+ const retiredGatePath = join(homeDirectory, '.claude', RETIRED_SESSION_TITLE_GATE_FILE_NAME);
66
+ mkdirSync(join(homeDirectory, '.claude'), { recursive: true });
67
+ writeFileSync(retiredGatePath, 'gate\n');
58
68
 
59
69
  runCoreInstall(homeDirectory, profileRoot);
60
70
  const settingsBytesAfterFirstInstall = readFileSync(settingsPath);
61
71
  runCoreInstall(homeDirectory, profileRoot);
62
72
 
63
73
  assert.deepEqual(readFileSync(settingsPath), settingsBytesAfterFirstInstall, 'second install leaves settings.json byte for byte');
64
-
65
- const allGateCommands = stopHookCommands(settingsPath)
66
- .filter((eachCommand) => eachCommand.includes(SESSION_TITLE_GATE_FILE_NAME));
67
- assert.deepEqual(allGateCommands, [expectedGateCommand]);
68
- for (const eachRelativePath of ALL_SESSION_TITLE_GATE_RELATIVE_PATHS) {
69
- assert.ok(
70
- existsSync(join(homeDirectory, '.claude', eachRelativePath)),
71
- `${eachRelativePath} is installed under ~/.claude`,
72
- );
73
- }
74
+ assert.deepEqual(
75
+ stopHookCommands(settingsPath).filter((eachCommand) => eachCommand.includes(RETIRED_SESSION_TITLE_GATE_FILE_NAME)),
76
+ [],
77
+ );
78
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
79
+ assert.equal((settings.permissions?.allow ?? []).includes(RETIRED_SESSION_TITLE_GATE_PERMISSION), false);
80
+ assert.equal(existsSync(retiredGatePath), false);
74
81
  } finally {
75
82
  rmSync(homeDirectory, { recursive: true, force: true });
76
83
  }
@@ -3327,7 +3327,7 @@ test('a pstack install after a --no-pstack install seeds the Codex model sheet',
3327
3327
  const USAGE_WRAPUP_CLAUDE_COMMANDS = Object.freeze([
3328
3328
  'claude plugin uninstall usage-wrapup@claude-dev-env',
3329
3329
  'claude plugin marketplace remove claude-dev-env',
3330
- 'claude plugin marketplace add jl-cmd/claude-dev-env --sparse .claude-plugin packages/usage-wrapup',
3330
+ 'claude plugin marketplace add jl-cmd-projects/claude-dev-env --sparse .claude-plugin packages/usage-wrapup',
3331
3331
  'claude plugin install usage-wrapup@claude-dev-env',
3332
3332
  ]);
3333
3333
 
@@ -3405,7 +3405,7 @@ const REPOSITORY_PLUGIN_CLAUDE_COMMANDS = Object.freeze([
3405
3405
  'claude plugin uninstall usage-wrapup@claude-dev-env',
3406
3406
  'claude plugin uninstall subagent-models@claude-dev-env',
3407
3407
  'claude plugin marketplace remove claude-dev-env',
3408
- 'claude plugin marketplace add jl-cmd/claude-dev-env --sparse .claude-plugin packages/usage-wrapup packages/subagent-models',
3408
+ 'claude plugin marketplace add jl-cmd-projects/claude-dev-env --sparse .claude-plugin packages/usage-wrapup packages/subagent-models',
3409
3409
  'claude plugin install usage-wrapup@claude-dev-env',
3410
3410
  'claude plugin install subagent-models@claude-dev-env',
3411
3411
  ]);
@@ -7,11 +7,16 @@
7
7
  * Before the first change to a file, the merge copies that file to a
8
8
  * timestamped `.bak` beside it.
9
9
  *
10
- * Run `node merge_profile_settings.mjs --dry-run` to list the additions for
10
+ * Each run also retires the profile-level session-title Stop gate an earlier
11
+ * install copied to `~/.claude`. The plugin's own session-title stop gate does
12
+ * that job, so the merge removes the profile gate's Stop hook and allow rule
13
+ * from each file and deletes the gate files.
14
+ *
15
+ * Run `node merge_profile_settings.mjs --dry-run` to list the changes for
11
16
  * every profile on this machine without writing anything.
12
17
  */
13
18
 
14
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
19
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
15
20
  import { homedir } from 'node:os';
16
21
  import { dirname, join, resolve } from 'node:path';
17
22
  import { fileURLToPath } from 'node:url';
@@ -26,14 +31,13 @@ import {
26
31
  import { resolveProfilesRootDirectory } from './select-install-targets.mjs';
27
32
 
28
33
  export const DRY_RUN_FLAG = '--dry-run';
29
- export const SESSION_TITLE_GATE_FILE_NAME = 'session_title_status_gate.py';
30
- export const SESSION_TITLE_GATE_SOURCE_DIRECTORY = fileURLToPath(new URL('../profile-settings/', import.meta.url));
31
- export const ALL_SESSION_TITLE_GATE_RELATIVE_PATHS = Object.freeze([
32
- SESSION_TITLE_GATE_FILE_NAME,
33
- join('config', '__init__.py'),
34
- join('config', 'session_title_gate_constants.py'),
35
- ]);
36
- export const SESSION_TITLE_GATE_COMMAND = `python3 ~/.claude/${SESSION_TITLE_GATE_FILE_NAME}`;
34
+ export const RETIRED_SESSION_TITLE_GATE_FILE_NAME = 'session_title_status_gate.py';
35
+ export const RETIRED_SESSION_TITLE_GATE_CONFIG_DIRECTORY_NAME = 'config';
36
+ export const RETIRED_SESSION_TITLE_GATE_CONSTANTS_FILE_NAME = 'session_title_gate_constants.py';
37
+ export const RETIRED_SESSION_TITLE_GATE_PACKAGE_INIT_TEXT =
38
+ '"""Constants package installed beside the Stop hook gate."""\n';
39
+ export const RETIRED_SESSION_TITLE_GATE_COMMAND = `python3 ~/.claude/${RETIRED_SESSION_TITLE_GATE_FILE_NAME}`;
40
+ export const RETIRED_SESSION_TITLE_GATE_PERMISSION = `Bash(${RETIRED_SESSION_TITLE_GATE_COMMAND}:*)`;
37
41
  export const AUTO_MODE_DEFAULTS_ENTRY = '$defaults';
38
42
 
39
43
  const DEFAULT_SETTINGS_INDENT = ' ';
@@ -45,22 +49,11 @@ const DEFAULT_HOST_CONFIGURATION_INDENT = ' ';
45
49
 
46
50
  /** @type {readonly DeclaredListEntries[]} */
47
51
  export const DECLARED_PROFILE_SETTINGS = Object.freeze([
48
- {
49
- keyPath: ['hooks', 'Stop'],
50
- items: [
51
- {
52
- hooks: [
53
- { type: 'command', command: SESSION_TITLE_GATE_COMMAND, timeout: 10 },
54
- ],
55
- },
56
- ],
57
- },
58
52
  {
59
53
  keyPath: ['permissions', 'allow'],
60
54
  items: [
61
55
  'mcp__claude-code-remote__set_session_title',
62
56
  'mcp__ccd_session_mgmt__set_session_title',
63
- `Bash(${SESSION_TITLE_GATE_COMMAND}:*)`,
64
57
  'Bash(python ~/.claude/scripts/account_broker.py run:*)',
65
58
  ],
66
59
  },
@@ -125,30 +118,49 @@ export function mergeDeclaredProfileSettings(settings, declaredSettings, homeDir
125
118
  for (const eachItem of items) {
126
119
  if (listHoldsItem(targetList, eachItem, homeDirectory)) continue;
127
120
  const addedItem = itemWithExpandedHookCommands(eachItem, homeDirectory);
128
- const stopGroup = keyPath.join('.') === 'hooks.Stop'
129
- ? targetList.find((eachGroup) => (
130
- eachGroup?.matcher === addedItem.matcher && Array.isArray(eachGroup?.hooks)
131
- ))
132
- : null;
133
- if (stopGroup) {
134
- const managedGateIndex = stopGroup.hooks.findIndex(
135
- (eachHook) => typeof eachHook?.command === 'string'
136
- && eachHook.command.replace(/\\/g, '/').includes('/hooks/blocking/session_title_stop_gate.py'),
137
- );
138
- stopGroup.hooks.splice(
139
- managedGateIndex < 0 ? stopGroup.hooks.length : managedGateIndex,
140
- 0,
141
- ...addedItem.hooks,
142
- );
143
- } else {
144
- targetList.push(addedItem);
145
- }
121
+ targetList.push(addedItem);
146
122
  allAdditions.push(`${keyPath.join('.')}: ${JSON.stringify(addedItem)}`);
147
123
  }
148
124
  }
149
125
  return allAdditions;
150
126
  }
151
127
 
128
+ /**
129
+ * Remove the retired profile gate's Stop hook and allow rule from one settings object.
130
+ *
131
+ * A Stop group left with no hook goes too, and so does an emptied Stop list or
132
+ * hooks object, so the file holds no husk of the retired entry.
133
+ *
134
+ * @param {Record<string, any>} settings
135
+ * @param {string} homeDirectory
136
+ * @returns {string[]} One description per removed entry.
137
+ */
138
+ export function removeRetiredSessionTitleGate(settings, homeDirectory) {
139
+ const allRemovals = [];
140
+ const retiredCommandKey = hookCommandComparisonKey(RETIRED_SESSION_TITLE_GATE_COMMAND, homeDirectory);
141
+ const hooks = settings.hooks;
142
+ if (hooks && typeof hooks === 'object' && Array.isArray(hooks.Stop)) {
143
+ for (const eachGroup of hooks.Stop) {
144
+ if (!Array.isArray(eachGroup?.hooks)) continue;
145
+ eachGroup.hooks = eachGroup.hooks.filter((eachHook) => {
146
+ const isRetired = typeof eachHook?.command === 'string'
147
+ && hookCommandComparisonKey(eachHook.command, homeDirectory) === retiredCommandKey;
148
+ if (isRetired) allRemovals.push(`hooks.Stop: ${eachHook.command}`);
149
+ return !isRetired;
150
+ });
151
+ }
152
+ hooks.Stop = hooks.Stop.filter((eachGroup) => !Array.isArray(eachGroup?.hooks) || eachGroup.hooks.length > 0);
153
+ if (hooks.Stop.length === 0) delete hooks.Stop;
154
+ if (Object.keys(hooks).length === 0) delete settings.hooks;
155
+ }
156
+ const allowList = settings.permissions?.allow;
157
+ if (Array.isArray(allowList) && allowList.includes(RETIRED_SESSION_TITLE_GATE_PERMISSION)) {
158
+ settings.permissions.allow = allowList.filter((eachRule) => eachRule !== RETIRED_SESSION_TITLE_GATE_PERMISSION);
159
+ allRemovals.push(`permissions.allow: ${RETIRED_SESSION_TITLE_GATE_PERMISSION}`);
160
+ }
161
+ return allRemovals;
162
+ }
163
+
152
164
  /**
153
165
  * Merge the declared entries into one settings.json.
154
166
  *
@@ -163,13 +175,14 @@ export function mergeProfileSettingsFile(settingsPath, options) {
163
175
  if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
164
176
  throw new Error(`${settingsPath} holds a value other than a JSON object`);
165
177
  }
178
+ const removals = removeRetiredSessionTitleGate(settings, options.homeDirectory);
166
179
  const additions = mergeDeclaredProfileSettings(
167
180
  settings,
168
181
  options.declaredSettings ?? DECLARED_PROFILE_SETTINGS,
169
182
  options.homeDirectory,
170
183
  );
171
- if (additions.length === 0 || options.dryRun) {
172
- return { settingsPath, additions, backupPath: null };
184
+ if ((additions.length === 0 && removals.length === 0) || options.dryRun) {
185
+ return { settingsPath, additions, removals, backupPath: null };
173
186
  }
174
187
  let backupPath = null;
175
188
  if (settingsText) {
@@ -181,48 +194,57 @@ export function mergeProfileSettingsFile(settingsPath, options) {
181
194
  settingsPath,
182
195
  JSON.stringify(settings, null, hostConfigurationIndent(settingsText, DEFAULT_SETTINGS_INDENT)) + '\n',
183
196
  );
184
- return { settingsPath, additions, backupPath };
197
+ return { settingsPath, additions, removals, backupPath };
185
198
  }
186
199
 
187
200
  /**
188
- * Copy the session-title gate and its constants module to `~/.claude` when a copy is absent or differs.
201
+ * Delete the retired profile gate and its constants package from `~/.claude`.
189
202
  *
190
- * The Stop hook entry runs the gate, so both files have to land before the entry does.
203
+ * The package's `__init__.py` goes only while it still holds the text the
204
+ * installer wrote, and the config directory goes only once nothing else is
205
+ * left in it, so a user's own files there stay.
191
206
  *
192
207
  * @param {{ homeDirectory: string, dryRun: boolean }} options
193
- * @returns {{ gatePath: string, changed: boolean }}
208
+ * @returns {{ removedPaths: string[] }}
194
209
  */
195
- export function installSessionTitleGate(options) {
210
+ export function retireSessionTitleGate(options) {
196
211
  const claudeDirectory = join(options.homeDirectory, DEFAULT_CLAUDE_DIRECTORY_NAME);
197
- const allStaleRelativePaths = ALL_SESSION_TITLE_GATE_RELATIVE_PATHS.filter((eachRelativePath) => {
198
- const targetPath = join(claudeDirectory, eachRelativePath);
199
- const sourceText = readFileSync(join(SESSION_TITLE_GATE_SOURCE_DIRECTORY, eachRelativePath), 'utf8');
200
- return !existsSync(targetPath) || readFileSync(targetPath, 'utf8') !== sourceText;
201
- });
202
- if (!options.dryRun) {
203
- for (const eachRelativePath of allStaleRelativePaths) {
204
- const targetPath = join(claudeDirectory, eachRelativePath);
205
- mkdirSync(dirname(targetPath), { recursive: true });
206
- copyFileSync(join(SESSION_TITLE_GATE_SOURCE_DIRECTORY, eachRelativePath), targetPath);
212
+ const configDirectory = join(claudeDirectory, RETIRED_SESSION_TITLE_GATE_CONFIG_DIRECTORY_NAME);
213
+ const packageInitPath = join(configDirectory, '__init__.py');
214
+ const allRetiredPaths = [
215
+ join(claudeDirectory, RETIRED_SESSION_TITLE_GATE_FILE_NAME),
216
+ join(configDirectory, RETIRED_SESSION_TITLE_GATE_CONSTANTS_FILE_NAME),
217
+ ];
218
+ if (existsSync(packageInitPath) && readFileSync(packageInitPath, 'utf8') === RETIRED_SESSION_TITLE_GATE_PACKAGE_INIT_TEXT) {
219
+ allRetiredPaths.push(packageInitPath);
220
+ }
221
+ const removedPaths = allRetiredPaths.filter((eachPath) => existsSync(eachPath));
222
+ if (options.dryRun) {
223
+ return { removedPaths };
224
+ }
225
+ for (const eachPath of removedPaths) {
226
+ rmSync(eachPath);
227
+ }
228
+ if (existsSync(configDirectory)) {
229
+ const allLeftEntries = readdirSync(configDirectory).filter((eachName) => eachName !== '__pycache__');
230
+ if (allLeftEntries.length === 0) {
231
+ rmSync(configDirectory, { recursive: true, force: true });
207
232
  }
208
233
  }
209
- return {
210
- gatePath: join(claudeDirectory, SESSION_TITLE_GATE_FILE_NAME),
211
- changed: allStaleRelativePaths.length > 0,
212
- };
234
+ return { removedPaths };
213
235
  }
214
236
 
215
237
  /**
216
- * Install the gate, then merge the declared entries into each settings.json.
238
+ * Retire the profile gate, then merge the declared entries into each settings.json.
217
239
  *
218
240
  * @param {string[]} allSettingsPaths
219
241
  * @param {{ dryRun: boolean, homeDirectory: string, now?: Date }} options
220
- * @returns {{ gate: { gatePath: string, changed: boolean },
221
- * files: { settingsPath: string, additions: string[], backupPath: string | null }[] }}
242
+ * @returns {{ gate: { removedPaths: string[] },
243
+ * files: { settingsPath: string, additions: string[], removals: string[], backupPath: string | null }[] }}
222
244
  */
223
245
  export function mergeProfileSettings(allSettingsPaths, options) {
224
246
  const timestamp = (options.now ?? new Date()).toISOString().replace(/[:.]/g, '-');
225
- const gate = installSessionTitleGate(options);
247
+ const gate = retireSessionTitleGate(options);
226
248
  const files = allSettingsPaths.map((eachPath) => mergeProfileSettingsFile(eachPath, {
227
249
  dryRun: options.dryRun,
228
250
  homeDirectory: options.homeDirectory,
@@ -232,25 +254,26 @@ export function mergeProfileSettings(allSettingsPaths, options) {
232
254
  }
233
255
 
234
256
  /**
235
- * @param {{ gate: { gatePath: string, changed: boolean },
236
- * files: { settingsPath: string, additions: string[], backupPath: string | null }[] }} outcome
257
+ * @param {{ gate: { removedPaths: string[] },
258
+ * files: { settingsPath: string, additions: string[], removals: string[], backupPath: string | null }[] }} outcome
237
259
  * @param {boolean} isDryRun
238
260
  * @returns {string[]}
239
261
  */
240
262
  export function describeProfileSettingsOutcome(outcome, isDryRun) {
241
- const verb = isDryRun ? 'would add' : 'added';
242
- const allLines = [
243
- outcome.gate.changed
244
- ? `${outcome.gate.gatePath}: ${isDryRun ? 'would copy' : 'copied'} session-title gate`
245
- : `${outcome.gate.gatePath}: gate current`,
246
- ];
247
- for (const { settingsPath, additions, backupPath } of outcome.files) {
248
- if (additions.length === 0) {
263
+ const addVerb = isDryRun ? 'would add' : 'added';
264
+ const removeVerb = isDryRun ? 'would remove' : 'removed';
265
+ const allLines = outcome.gate.removedPaths.map(
266
+ (eachPath) => `${eachPath}: ${removeVerb} retired session-title gate file`,
267
+ );
268
+ for (const { settingsPath, additions, removals, backupPath } of outcome.files) {
269
+ if (additions.length === 0 && removals.length === 0) {
249
270
  allLines.push(`${settingsPath}: no change`);
250
271
  continue;
251
272
  }
252
- allLines.push(`${settingsPath}: ${verb} ${additions.length}${backupPath ? ` (backup ${backupPath})` : ''}`);
253
- allLines.push(...additions.map((eachAddition) => ` ${eachAddition}`));
273
+ const backupNote = backupPath ? ` (backup ${backupPath})` : '';
274
+ allLines.push(`${settingsPath}: ${addVerb} ${additions.length}, ${removeVerb} ${removals.length}${backupNote}`);
275
+ allLines.push(...additions.map((eachAddition) => ` + ${eachAddition}`));
276
+ allLines.push(...removals.map((eachRemoval) => ` - ${eachRemoval}`));
254
277
  }
255
278
  return allLines;
256
279
  }
@@ -1,21 +1,23 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
6
 
7
7
  import {
8
- ALL_SESSION_TITLE_GATE_RELATIVE_PATHS,
9
8
  AUTO_MODE_DEFAULTS_ENTRY,
10
9
  DECLARED_PROFILE_SETTINGS,
11
- SESSION_TITLE_GATE_FILE_NAME,
12
- SESSION_TITLE_GATE_SOURCE_DIRECTORY,
10
+ RETIRED_SESSION_TITLE_GATE_CONSTANTS_FILE_NAME,
11
+ RETIRED_SESSION_TITLE_GATE_FILE_NAME,
12
+ RETIRED_SESSION_TITLE_GATE_PACKAGE_INIT_TEXT,
13
+ RETIRED_SESSION_TITLE_GATE_PERMISSION,
13
14
  discoverProfileSettingsPaths,
14
15
  mergeProfileSettings,
15
16
  } from './merge_profile_settings.mjs';
16
17
 
17
18
  const FIXED_NOW = new Date('2026-10-04T12:00:00.000Z');
18
19
  const BACKUP_SUFFIX = '.2026-10-04T12-00-00-000Z.bak';
20
+ const PLUGIN_STOP_GATE_COMMAND = 'python3 /plugin/hooks/blocking/session_title_stop_gate.py';
19
21
  const PERMISSION_ALLOW_ITEMS = DECLARED_PROFILE_SETTINGS
20
22
  .find((eachEntry) => eachEntry.keyPath.join('.') === 'permissions.allow').items;
21
23
  const AUTO_MODE_ALLOW_ITEMS = DECLARED_PROFILE_SETTINGS
@@ -38,15 +40,23 @@ function backupFiles(directory) {
38
40
  return readdirSync(directory).filter((eachName) => eachName.endsWith('.bak'));
39
41
  }
40
42
 
41
- function expandedGateCommand(homeDirectory) {
42
- return `python3 ${homeDirectory.replace(/\\/g, '/')}/.claude/${SESSION_TITLE_GATE_FILE_NAME}`;
43
+ function expandedRetiredGateCommand(homeDirectory) {
44
+ return `python3 ${homeDirectory.replace(/\\/g, '/')}/.claude/${RETIRED_SESSION_TITLE_GATE_FILE_NAME}`;
43
45
  }
44
46
 
45
47
  function stopCommands(settings) {
46
- return settings.hooks.Stop.flatMap((eachGroup) => eachGroup.hooks.map((eachHook) => eachHook.command));
48
+ return (settings.hooks?.Stop ?? []).flatMap((eachGroup) => eachGroup.hooks.map((eachHook) => eachHook.command));
47
49
  }
48
50
 
49
- test('a missing settings.json is created with every declared entry and the gate is copied', () => {
51
+ function installRetiredGateFiles(claudeDirectory) {
52
+ mkdirSync(join(claudeDirectory, 'config', '__pycache__'), { recursive: true });
53
+ writeFileSync(join(claudeDirectory, RETIRED_SESSION_TITLE_GATE_FILE_NAME), 'gate\n');
54
+ writeFileSync(join(claudeDirectory, 'config', RETIRED_SESSION_TITLE_GATE_CONSTANTS_FILE_NAME), 'constants\n');
55
+ writeFileSync(join(claudeDirectory, 'config', '__init__.py'), RETIRED_SESSION_TITLE_GATE_PACKAGE_INIT_TEXT);
56
+ writeFileSync(join(claudeDirectory, 'config', '__pycache__', '__init__.cpython-311.pyc'), '');
57
+ }
58
+
59
+ test('a missing settings.json is created with every declared entry and no Stop hook', () => {
50
60
  const homeDirectory = makeHome();
51
61
  const claudeDirectory = join(homeDirectory, '.claude');
52
62
  mkdirSync(claudeDirectory);
@@ -55,33 +65,71 @@ test('a missing settings.json is created with every declared entry and the gate
55
65
  const outcome = mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
56
66
 
57
67
  assert.deepEqual(readSettings(settingsPath), {
58
- hooks: {
59
- Stop: [{ hooks: [{ type: 'command', command: expandedGateCommand(homeDirectory), timeout: 10 }] }],
60
- },
61
68
  permissions: { allow: PERMISSION_ALLOW_ITEMS },
62
69
  autoMode: { allow: AUTO_MODE_ALLOW_ITEMS },
63
70
  });
64
71
  assert.equal(outcome.files[0].backupPath, null);
65
72
  assert.deepEqual(backupFiles(claudeDirectory), []);
66
- for (const eachRelativePath of ALL_SESSION_TITLE_GATE_RELATIVE_PATHS) {
67
- assert.equal(
68
- readFileSync(join(claudeDirectory, eachRelativePath), 'utf8'),
69
- readFileSync(join(SESSION_TITLE_GATE_SOURCE_DIRECTORY, eachRelativePath), 'utf8'),
70
- );
71
- }
73
+ assert.deepEqual(outcome.gate.removedPaths, []);
74
+ });
75
+
76
+ test('the retired profile gate leaves settings.json and ~/.claude, and the plugin gate stays', () => {
77
+ const homeDirectory = makeHome();
78
+ const claudeDirectory = join(homeDirectory, '.claude');
79
+ const settingsPath = join(claudeDirectory, 'settings.json');
80
+ writeSettings(settingsPath, {
81
+ hooks: {
82
+ Stop: [
83
+ {
84
+ hooks: [
85
+ { type: 'command', command: expandedRetiredGateCommand(homeDirectory), timeout: 10 },
86
+ { type: 'command', command: PLUGIN_STOP_GATE_COMMAND },
87
+ ],
88
+ },
89
+ { hooks: [{ type: 'command', command: expandedRetiredGateCommand(homeDirectory), timeout: 10 }] },
90
+ ],
91
+ },
92
+ permissions: { allow: ['Read', RETIRED_SESSION_TITLE_GATE_PERMISSION] },
93
+ });
94
+ installRetiredGateFiles(claudeDirectory);
95
+
96
+ const outcome = mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
97
+
98
+ const mergedSettings = readSettings(settingsPath);
99
+ assert.deepEqual(stopCommands(mergedSettings), [PLUGIN_STOP_GATE_COMMAND]);
100
+ assert.equal(mergedSettings.hooks.Stop.length, 1);
101
+ assert.equal(mergedSettings.permissions.allow.includes(RETIRED_SESSION_TITLE_GATE_PERMISSION), false);
102
+ assert.equal(outcome.files[0].removals.length, 3);
103
+ assert.equal(outcome.gate.removedPaths.length, 3);
104
+ assert.equal(existsSync(join(claudeDirectory, RETIRED_SESSION_TITLE_GATE_FILE_NAME)), false);
105
+ assert.equal(existsSync(join(claudeDirectory, 'config')), false);
72
106
  });
73
107
 
74
- test('a gate whose constants module is missing is reported changed and the module is copied', () => {
108
+ test('a retired gate hook written with backslashes is removed, and an emptied hooks object goes', () => {
75
109
  const homeDirectory = makeHome();
76
110
  const settingsPath = join(homeDirectory, '.claude', 'settings.json');
111
+ const backslashedGateCommand = `python3 ${homeDirectory.replace(/\//g, '\\')}\\.claude\\${RETIRED_SESSION_TITLE_GATE_FILE_NAME}`;
112
+ writeSettings(settingsPath, {
113
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: backslashedGateCommand, timeout: 10 }] }] },
114
+ });
115
+
77
116
  mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
78
- const constantsPath = join(homeDirectory, '.claude', 'config', 'session_title_gate_constants.py');
79
- unlinkSync(constantsPath);
80
117
 
81
- const outcome = mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
118
+ assert.equal(readSettings(settingsPath).hooks, undefined);
119
+ });
120
+
121
+ test('a config directory holding other files keeps them and a changed __init__.py', () => {
122
+ const homeDirectory = makeHome();
123
+ const claudeDirectory = join(homeDirectory, '.claude');
124
+ installRetiredGateFiles(claudeDirectory);
125
+ writeFileSync(join(claudeDirectory, 'config', '__init__.py'), '"""Mine."""\n');
126
+ writeFileSync(join(claudeDirectory, 'config', 'mine.py'), 'VALUE = 1\n');
127
+
128
+ mergeProfileSettings([join(claudeDirectory, 'settings.json')], { dryRun: false, homeDirectory, now: FIXED_NOW });
82
129
 
83
- assert.equal(outcome.gate.changed, true);
84
- assert.equal(existsSync(constantsPath), true);
130
+ assert.equal(existsSync(join(claudeDirectory, 'config', RETIRED_SESSION_TITLE_GATE_CONSTANTS_FILE_NAME)), false);
131
+ assert.equal(readFileSync(join(claudeDirectory, 'config', '__init__.py'), 'utf8'), '"""Mine."""\n');
132
+ assert.equal(existsSync(join(claudeDirectory, 'config', 'mine.py')), true);
85
133
  });
86
134
 
87
135
  test('a file that already holds every entry is left byte for byte, with no backup', () => {
@@ -93,7 +141,7 @@ test('a file that already holds every entry is left byte for byte, with no backu
93
141
  const outcome = mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
94
142
 
95
143
  assert.deepEqual(outcome.files[0].additions, []);
96
- assert.equal(outcome.gate.changed, false);
144
+ assert.deepEqual(outcome.files[0].removals, []);
97
145
  assert.equal(readFileSync(settingsPath, 'utf8'), textAfterFirstRun);
98
146
  assert.deepEqual(backupFiles(join(homeDirectory, '.claude')), []);
99
147
  });
@@ -104,7 +152,7 @@ test('a partial file gains only the missing entries, keeps other keys, and is ba
104
152
  const originalSettings = {
105
153
  model: 'opus',
106
154
  hooks: {
107
- Stop: [{ hooks: [{ type: 'command', command: expandedGateCommand(homeDirectory), timeout: 30 }] }],
155
+ Stop: [{ hooks: [{ type: 'command', command: PLUGIN_STOP_GATE_COMMAND, timeout: 30 }] }],
108
156
  PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'python3 guard.py' }] }],
109
157
  },
110
158
  permissions: { allow: ['Read', 'mcp__claude-code-remote__set_session_title'], deny: ['Bash(rm:*)'] },
@@ -117,8 +165,7 @@ test('a partial file gains only the missing entries, keeps other keys, and is ba
117
165
 
118
166
  const mergedSettings = readSettings(settingsPath);
119
167
  assert.equal(mergedSettings.model, 'opus');
120
- assert.deepEqual(mergedSettings.hooks.Stop, originalSettings.hooks.Stop);
121
- assert.deepEqual(mergedSettings.hooks.PreToolUse, originalSettings.hooks.PreToolUse);
168
+ assert.deepEqual(mergedSettings.hooks, originalSettings.hooks);
122
169
  assert.deepEqual(mergedSettings.permissions.deny, ['Bash(rm:*)']);
123
170
  assert.deepEqual(mergedSettings.permissions.allow, [
124
171
  'Read',
@@ -132,31 +179,6 @@ test('a partial file gains only the missing entries, keeps other keys, and is ba
132
179
  assert.equal(readFileSync(outcome.files[0].backupPath, 'utf8'), originalText);
133
180
  });
134
181
 
135
- test('a gate hook written with backslashes counts as present', () => {
136
- const homeDirectory = makeHome();
137
- const settingsPath = join(homeDirectory, '.claude', 'settings.json');
138
- const backslashedGateCommand = `python3 ${homeDirectory.replace(/\//g, '\\')}\\.claude\\${SESSION_TITLE_GATE_FILE_NAME}`;
139
- writeSettings(settingsPath, {
140
- hooks: { Stop: [{ hooks: [{ type: 'command', command: backslashedGateCommand, timeout: 10 }] }] },
141
- });
142
-
143
- mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
144
-
145
- assert.deepEqual(stopCommands(readSettings(settingsPath)), [backslashedGateCommand]);
146
- });
147
-
148
- test('the gate hook is added beside a different Stop hook', () => {
149
- const homeDirectory = makeHome();
150
- const settingsPath = join(homeDirectory, '.claude', 'settings.json');
151
- writeSettings(settingsPath, {
152
- hooks: { Stop: [{ hooks: [{ type: 'command', command: 'python3 other_stop.py' }] }] },
153
- });
154
-
155
- mergeProfileSettings([settingsPath], { dryRun: false, homeDirectory, now: FIXED_NOW });
156
-
157
- assert.deepEqual(stopCommands(readSettings(settingsPath)), ['python3 other_stop.py', expandedGateCommand(homeDirectory)]);
158
- });
159
-
160
182
  test('discovery finds the main, CLAUDE_CONFIG_DIR, and every profile settings.json once, and each is merged', () => {
161
183
  const homeDirectory = makeHome();
162
184
  const profilesRoot = join(homeDirectory, '.claude-profiles');
@@ -185,24 +207,27 @@ test('discovery finds the main, CLAUDE_CONFIG_DIR, and every profile settings.js
185
207
  ].sort());
186
208
  assert.equal(allSettingsPathsWithSharedConfig.length, 3);
187
209
  for (const eachPath of allSettingsPaths) {
188
- assert.deepEqual(stopCommands(readSettings(eachPath)), [expandedGateCommand(homeDirectory)]);
210
+ assert.deepEqual(readSettings(eachPath).permissions.allow, PERMISSION_ALLOW_ITEMS);
189
211
  assert.deepEqual(readSettings(eachPath).autoMode.allow, AUTO_MODE_ALLOW_ITEMS);
190
212
  }
191
213
  assert.equal(readSettings(join(profilesRoot, 'work', 'settings.json')).model, 'work');
192
214
  assert.equal(backupFiles(join(profilesRoot, 'home')).length, 1);
193
215
  });
194
216
 
195
- test('a dry run reports additions and writes nothing', () => {
217
+ test('a dry run reports additions and removals and writes nothing', () => {
196
218
  const homeDirectory = makeHome();
197
- const settingsPath = join(homeDirectory, '.claude', 'settings.json');
198
- writeSettings(settingsPath, { model: 'opus' });
219
+ const claudeDirectory = join(homeDirectory, '.claude');
220
+ const settingsPath = join(claudeDirectory, 'settings.json');
221
+ writeSettings(settingsPath, { model: 'opus', permissions: { allow: [RETIRED_SESSION_TITLE_GATE_PERMISSION] } });
222
+ installRetiredGateFiles(claudeDirectory);
199
223
  const originalText = readFileSync(settingsPath, 'utf8');
200
224
 
201
225
  const outcome = mergeProfileSettings([settingsPath], { dryRun: true, homeDirectory, now: FIXED_NOW });
202
226
 
203
- assert.equal(outcome.files[0].additions.length, 7);
204
- assert.equal(outcome.gate.changed, true);
227
+ assert.equal(outcome.files[0].additions.length, PERMISSION_ALLOW_ITEMS.length + AUTO_MODE_ALLOW_ITEMS.length);
228
+ assert.equal(outcome.files[0].removals.length, 1);
229
+ assert.equal(outcome.gate.removedPaths.length, 3);
205
230
  assert.equal(readFileSync(settingsPath, 'utf8'), originalText);
206
- assert.deepEqual(backupFiles(join(homeDirectory, '.claude')), []);
207
- assert.equal(existsSync(join(homeDirectory, '.claude', SESSION_TITLE_GATE_FILE_NAME)), false);
231
+ assert.deepEqual(backupFiles(claudeDirectory), []);
232
+ assert.equal(existsSync(join(claudeDirectory, RETIRED_SESSION_TITLE_GATE_FILE_NAME)), true);
208
233
  });
@@ -15,12 +15,12 @@ python scripts/account_broker.py accounts --product claude
15
15
  python scripts/account_broker.py choose --product codex
16
16
  python scripts/account_broker.py choose --product codex --spent account-name:1790000000
17
17
  python scripts/account_broker.py check --product codex
18
- python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p "prompt"
18
+ printf "%s" "prompt" | python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p
19
19
  ```
20
20
 
21
21
  `accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `home`, `reason`, `tier`, and `resets_at`. A wait decision has no account or home and includes the soonest reset in UTC. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. Meter reads younger than 60 seconds are reused.
22
22
 
23
- `check` prints nothing and exits 3 while every account is below its floor. `run` sets `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. A Claude job also starts without the parent session's variables, such as `CLAUDE_CODE_SESSION_ID` and `CLAUDECODE`, so a child started from inside a Claude Code session runs as its own session. `ALL_PARENT_CLAUDE_SESSION_VARIABLES` lists them. It replays the same stdin bytes when a usage limit or start failure leads to another account. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
23
+ `check` prints nothing and exits 3 while every account is below its floor. `run` sets `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. A Claude job also starts without the parent session's variables, such as `CLAUDE_CODE_SESSION_ID` and `CLAUDECODE`, so a child started from inside a Claude Code session runs as its own session. `ALL_PARENT_CLAUDE_SESSION_VARIABLES` lists them. It replays the same stdin bytes when a usage limit or start failure leads to another account. Send the prompt on stdin. When the command resolves to a `.cmd` or `.bat` file, the broker refuses an argument that holds a cmd.exe metacharacter and records `start_failed`. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
24
24
 
25
25
  The broker stores cached meters, spent marks, and Claude session bindings in one JSON file under `~/.claude/account-broker`. Every broker process for the same user reads that file, so a spent mark pauses that account for every job. A write holds an exclusive lock on the sibling `state.json.lock`, re-reads the file, merges, and swaps in a sibling temporary file with `os.replace`. The merge keeps the latest reset for each spent mark and the newest meter read for each account.
26
26
 
@@ -8,6 +8,8 @@ Back to the [rule entry](../../rules/session-title.md).
8
8
 
9
9
  Before each turn ends, set the title to `<emoji> <name>`. Get the session id from `get_session` with no `session_id` when the tool asks for one.
10
10
 
11
+ Set the title before the final reply, and set it in silence. The reply to the user carries no mention of the title, its emoji, or the reminder that asked for it. When a Stop hook asks for the title after the answer went out, make the call and end the turn with no text.
12
+
11
13
  ## Status emoji
12
14
 
13
15
  | Emoji | Use it when |
@@ -271,3 +271,11 @@ def test_stop_block_reason_names_the_seen_tool_when_the_turn_has_no_title() -> N
271
271
  ]
272
272
  block_reason = session_title_stop_gate.stop_block_reason(all_entries, is_remote_session=False)
273
273
  assert block_reason is not None and DESKTOP_TITLE_TOOL in block_reason
274
+
275
+
276
+ def test_stop_block_reason_tells_the_model_to_set_the_title_in_silence() -> None:
277
+ all_entries = [prompt("Fix the runner"), tool_call("t1", REMOTE_TITLE_TOOL), prompt("Next")]
278
+ block_reason = session_title_stop_gate.stop_block_reason(all_entries, is_remote_session=True)
279
+ assert block_reason is not None
280
+ assert "Say nothing to the user about the title" in block_reason
281
+ assert "end with no text" in block_reason
@@ -51,6 +51,10 @@ STOP_BLOCK_REASON = (
51
51
  " two parts joined with ' + ', sentence case, no end punctuation,"
52
52
  " no dates, IDs, branch names or filler words."
53
53
  " Rename it when the scope of the work has changed."
54
+ " The title is a silent step: make the call and end the turn."
55
+ " Say nothing to the user about the title, its emoji or this reminder."
56
+ " When the user already has your answer this turn, end with no text."
57
+ " Next time, set the title before your final reply."
54
58
  )
55
59
  REMOTE_TITLE_TOOL_NAME = "mcp__claude-code-remote__set_session_title"
56
60
  UNKNOWN_TITLE_TOOL_NAME = "its name ends in __set_session_title"
@@ -10,8 +10,7 @@
10
10
  "system-prompts",
11
11
  "scripts",
12
12
  "_shared",
13
- "audit-rubrics",
14
- "profile-settings"
13
+ "audit-rubrics"
15
14
  ],
16
15
  "root_files": [
17
16
  "AGENTS.md",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.56.0",
3
+ "version": "8.56.2",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,6 @@
25
25
  "settings.json",
26
26
  "installable-surfaces.manifest.json",
27
27
  "_shared/",
28
- "profile-settings/",
29
28
  "audit-rubrics/",
30
29
  "codex-rules/",
31
30
  "pstack-codex-models.md",
@@ -50,7 +49,7 @@
50
49
  "license": "MIT",
51
50
  "repository": {
52
51
  "type": "git",
53
- "url": "git+https://github.com/jl-cmd/claude-dev-env.git",
52
+ "url": "git+https://github.com/jl-cmd-projects/claude-dev-env.git",
54
53
  "directory": "packages/claude-dev-env"
55
54
  }
56
55
  }
@@ -1 +0,0 @@
1
- """Constants package installed beside the Stop hook gate."""
@@ -1,15 +0,0 @@
1
- """Constants for the session-title Stop hook gate."""
2
-
3
- ALL_TITLE_TOOL_NAMES = frozenset(
4
- {
5
- "mcp__claude-code-remote__set_session_title",
6
- "mcp__ccd_session_mgmt__set_session_title",
7
- }
8
- )
9
- INSTRUCTION = (
10
- "Call set_session_title (session id from get_session) before you stop. "
11
- 'Title = "<emoji> <brief task name>", one line in a narrow sidebar. '
12
- "\U0001f6a9 = Jon must act (question, approval, a step only he can do). "
13
- "✅ = fully done (merged or nothing left). "
14
- "⏳ = work, CI, or a merge queue still running and nothing waits on Jon."
15
- )
@@ -1,77 +0,0 @@
1
- """Stop hook that blocks until the session title is set with a status emoji."""
2
-
3
- import json
4
- import sys
5
- from typing import TextIO
6
-
7
- from config.session_title_gate_constants import ALL_TITLE_TOOL_NAMES, INSTRUCTION
8
-
9
-
10
- def _content_blocks(entry: dict) -> list:
11
- content = entry.get("message", {}).get("content")
12
- if isinstance(content, str):
13
- return [{"type": "text", "text": content}]
14
- return content if isinstance(content, list) else []
15
-
16
-
17
- def _is_user_text(entry: dict) -> bool:
18
- if entry.get("type") != "user":
19
- return False
20
- return any(
21
- block.get("type") == "text" and block.get("text", "").strip()
22
- for block in _content_blocks(entry)
23
- if isinstance(block, dict)
24
- )
25
-
26
-
27
- def _is_title_call(entry: dict) -> bool:
28
- if entry.get("type") != "assistant":
29
- return False
30
- return any(
31
- block.get("type") == "tool_use" and block.get("name") in ALL_TITLE_TOOL_NAMES
32
- for block in _content_blocks(entry)
33
- if isinstance(block, dict)
34
- )
35
-
36
-
37
- def title_set_since_last_user_text(all_transcript_lines: list[str]) -> bool:
38
- """Report whether a title call follows the newest user text in the transcript.
39
-
40
- Args:
41
- all_transcript_lines: The transcript's JSONL lines, oldest first.
42
-
43
- Returns:
44
- True when a set_session_title call comes after the last user text.
45
- """
46
- for each_line in reversed(all_transcript_lines):
47
- if not each_line.strip():
48
- continue
49
- entry = json.loads(each_line)
50
- if _is_title_call(entry):
51
- return True
52
- if _is_user_text(entry):
53
- return False
54
- return False
55
-
56
-
57
- def main(decision_stream: TextIO) -> None:
58
- """Block the stop with the title instruction when no title follows the last user text.
59
-
60
- Args:
61
- decision_stream: The stream that receives the Stop hook decision.
62
- """
63
- try:
64
- payload = json.load(sys.stdin)
65
- if payload.get("stop_hook_active"):
66
- return
67
- with open(payload["transcript_path"], encoding="utf-8") as transcript_file:
68
- all_transcript_lines = transcript_file.readlines()
69
- if title_set_since_last_user_text(all_transcript_lines):
70
- return
71
- except (OSError, KeyError, ValueError):
72
- return
73
- decision_stream.write(json.dumps({"decision": "block", "reason": INSTRUCTION}))
74
-
75
-
76
- if __name__ == "__main__":
77
- main(sys.stdout)
@@ -1,101 +0,0 @@
1
- """Run the installed session-title gate as the Stop hook runs it."""
2
-
3
- import json
4
- import shutil
5
- import subprocess
6
- import sys
7
- from pathlib import Path
8
-
9
- GATE_SOURCE_DIRECTORY = Path(__file__).resolve().parent
10
- GATE_FILE_NAME = "session_title_status_gate.py"
11
- ALL_GATE_RELATIVE_PATHS = (
12
- Path(GATE_FILE_NAME),
13
- Path("config") / "__init__.py",
14
- Path("config") / "session_title_gate_constants.py",
15
- )
16
- TITLE_TOOL_NAME = "mcp__claude-code-remote__set_session_title"
17
-
18
-
19
- def _install_gate(claude_directory: Path) -> Path:
20
- for each_relative_path in ALL_GATE_RELATIVE_PATHS:
21
- target_path = claude_directory / each_relative_path
22
- target_path.parent.mkdir(parents=True, exist_ok=True)
23
- shutil.copyfile(GATE_SOURCE_DIRECTORY / each_relative_path, target_path)
24
- return claude_directory / GATE_FILE_NAME
25
-
26
-
27
- def _user_text_entry(text: str) -> dict:
28
- return {"type": "user", "message": {"content": text}}
29
-
30
-
31
- def _title_call_entry() -> dict:
32
- return {
33
- "type": "assistant",
34
- "message": {
35
- "content": [{"type": "tool_use", "name": TITLE_TOOL_NAME, "input": {}}]
36
- },
37
- }
38
-
39
-
40
- def _run_gate(
41
- tmp_path: Path, all_entries: list[dict], is_stop_hook_active: bool = False
42
- ) -> str:
43
- gate_path = _install_gate(tmp_path / ".claude")
44
- transcript_path = tmp_path / "transcript.jsonl"
45
- transcript_path.write_text(
46
- "\n".join(json.dumps(each_entry) for each_entry in all_entries) + "\n\n",
47
- encoding="utf-8",
48
- )
49
- payload = {
50
- "transcript_path": str(transcript_path),
51
- "stop_hook_active": is_stop_hook_active,
52
- }
53
- gate_run = subprocess.run(
54
- [sys.executable, str(gate_path)],
55
- input=json.dumps(payload),
56
- capture_output=True,
57
- text=True,
58
- encoding="utf-8",
59
- check=True,
60
- )
61
- return gate_run.stdout
62
-
63
-
64
- def should_allow_stop_when_title_follows_last_user_text(tmp_path: Path) -> None:
65
- assert (
66
- _run_gate(tmp_path, [_user_text_entry("fix the bug"), _title_call_entry()])
67
- == ""
68
- )
69
-
70
-
71
- def should_block_stop_when_user_text_follows_last_title(tmp_path: Path) -> None:
72
- gate_output = _run_gate(
73
- tmp_path, [_title_call_entry(), _user_text_entry("one more thing")]
74
- )
75
-
76
- decision = json.loads(gate_output)
77
- assert decision["decision"] == "block"
78
- assert "set_session_title" in decision["reason"]
79
-
80
-
81
- def should_allow_stop_when_stop_hook_is_already_active(tmp_path: Path) -> None:
82
- assert (
83
- _run_gate(tmp_path, [_user_text_entry("fix the bug")], is_stop_hook_active=True)
84
- == ""
85
- )
86
-
87
-
88
- def should_allow_stop_when_transcript_is_missing(tmp_path: Path) -> None:
89
- gate_path = _install_gate(tmp_path / ".claude")
90
- payload = {"transcript_path": str(tmp_path / "absent.jsonl")}
91
-
92
- gate_run = subprocess.run(
93
- [sys.executable, str(gate_path)],
94
- input=json.dumps(payload),
95
- capture_output=True,
96
- text=True,
97
- encoding="utf-8",
98
- check=True,
99
- )
100
-
101
- assert gate_run.stdout == ""