claude-dev-env 8.56.1 → 8.57.0

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
  });
@@ -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 |
@@ -16,6 +16,7 @@
16
16
  | Readable text | Text is 16px or larger and uses the full text color. Gray stays on lines and borders only. Text holds a 7:1 contrast ratio against its background. |
17
17
  | Touch targets | Buttons and chips are 40px or taller, with a visible focus ring. |
18
18
  | Copy fallback | The copy button uses the clipboard API and falls back to a hidden text area, then says when the copy is blocked. |
19
+ | Review rounds | Parts changed since the viewer's last look carry a thin bar on their left edge, and changed cards a "Changed" label. [review-rounds.md](review-rounds.md) holds the data fields and behavior. |
19
20
 
20
21
  ## House rules for a page built from it
21
22
 
@@ -0,0 +1,26 @@
1
+ # Marking what changed between review rounds
2
+
3
+ A page that a reviewer reads more than once shows what changed since their last look. The template marks each changed card and each changed part with a thin bar on its left edge, gives each changed card a small "Changed" label, and gives each changed board chip a dot in the same color. There is no diff and no list of changes.
4
+
5
+ ## Data
6
+
7
+ | Field | Where | Meaning |
8
+ |---|---|---|
9
+ | `round` | top of the data block | The review round this revision answers. Add one each time the page is republished after feedback. |
10
+ | `changed_round` | an item | The round in which this item last changed. |
11
+ | `changed_parts` | an item, optional | The parts that changed: `title`, `next`, `summary`, `steps`, `links`. With no list, only the card is marked. |
12
+
13
+ When a revision answers feedback, set `round` to the new number and stamp each item it changed with that number and the parts it touched. Leave older stamps in place; they stop showing once the reviewer has seen their round.
14
+
15
+ ## Behavior
16
+
17
+ - The page keeps the last round each viewer saw in `localStorage`. Parts stamped after that round are marked.
18
+ - A first visit, or a browser that blocks storage, marks the parts stamped in the current round.
19
+ - The marks hold through a reload in the same tab, through `sessionStorage`, and clear on the next visit.
20
+ - A changed summary opens by itself.
21
+
22
+ ## Other pages
23
+
24
+ Any element with `data-changed-round="N"` takes the bar. An element that holds a `data-change-slot` child gets the "Changed" label in that child; other elements get a screen reader label.
25
+
26
+ The `--change` and `--change-ink` tokens hold the mark colors in light and dark. Keep the label text at 7:1 or better against `--change`, and the bar at 3:1 or better against `--surface` and `--bg`.
@@ -7,6 +7,7 @@
7
7
  --accent: #2245a2; --accent-soft: #e6ecfb; --accent-ink: #ffffff;
8
8
  --ok: #165737; --ok-soft: #e3f3ea; --bad: #8b2621; --bad-soft: #fbe5e3; --warn: #6f4100; --warn-soft: #fbf0dc;
9
9
  --violet: #553b95; --violet-soft: #efe9fa;
10
+ --change: #8a1f6b; --change-ink: #ffffff;
10
11
  --font-body: "Manrope", system-ui, -apple-system, "Segoe UI", sans-serif;
11
12
  }
12
13
  @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
@@ -14,12 +15,14 @@
14
15
  --accent: #a8bfff; --accent-soft: #1f2a45; --accent-ink: #0d1424;
15
16
  --ok: #6fd3a0; --ok-soft: #173327; --bad: #ffa8a0; --bad-soft: #3a1c1a; --warn: #f0b860; --warn-soft: #362a14;
16
17
  --violet: #c5b1f2; --violet-soft: #251f38;
18
+ --change: #f5a3dc; --change-ink: #1f0a19;
17
19
  color-scheme: dark } }
18
20
  :root[data-theme="dark"] {
19
21
  --bg: #12151b; --surface: #1a1f28; --fg: #e6e9ef; --edge: #6d7891; --line: #2b3240;
20
22
  --accent: #a8bfff; --accent-soft: #1f2a45; --accent-ink: #0d1424;
21
23
  --ok: #6fd3a0; --ok-soft: #173327; --bad: #ffa8a0; --bad-soft: #3a1c1a; --warn: #f0b860; --warn-soft: #362a14;
22
24
  --violet: #c5b1f2; --violet-soft: #251f38;
25
+ --change: #f5a3dc; --change-ink: #1f0a19;
23
26
  color-scheme: dark }
24
27
  * { box-sizing: border-box }
25
28
  body { background: var(--bg); color: var(--fg); font-family: var(--font-body); font-size: 17px; line-height: 1.4; padding-inline: 16px; padding-block: 24px 48px; margin: 0 }
@@ -95,6 +98,15 @@ details.what[open] summary::before { content: "▾" }
95
98
  .steps .sh { font-weight: 800 }
96
99
  .step { display: flex; gap: 10px; align-items: flex-start }
97
100
  .step .num { flex: none; width: 30px; height: 30px; border-radius: 50%; background: var(--warn); color: var(--surface); display: grid; place-items: center; font-weight: 800 }
101
+ .lchip.changed::before { content: ""; width: 10px; height: 10px; border-radius: 50%; background: var(--change); flex: none }
102
+ .changed { box-shadow: inset 4px 0 0 var(--change); padding-left: 12px; border-radius: 4px }
103
+ .card.changed { box-shadow: inset 6px 0 0 var(--change); padding-left: 0; border-radius: 16px }
104
+ .card.changed .band, .card.changed > .body { padding-left: 18px }
105
+ .card.changed .body .changed { padding-left: 12px }
106
+ .next.changed { border-radius: 12px }
107
+ .lchip.changed { box-shadow: none; border-radius: 999px; padding-left: 10px; border-color: var(--change); display: inline-flex; align-items: center; gap: 6px }
108
+ .pill-changed { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border-radius: 999px; background: var(--change); color: var(--change-ink); font-size: 16px; font-weight: 800 }
109
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap }
98
110
  .empty { background: var(--surface); border: 2px dashed var(--edge); border-radius: 14px; padding: 20px; font-weight: 600 }
99
111
  @media (hover: hover) { .lchip:hover, .fchip:hover, .tab:hover, .copy:hover { border-color: var(--accent) } }
100
112
  </style>
@@ -117,6 +129,7 @@ details.what[open] summary::before { content: "▾" }
117
129
  <script type="application/json" id="tracker-data">
118
130
  {
119
131
  "updated": "2026-01-15T17:30:00Z",
132
+ "round": 3,
120
133
  "owners": [
121
134
  {
122
135
  "key": "web",
@@ -147,6 +160,8 @@ details.what[open] summary::before { content: "▾" }
147
160
  "needs_you": true,
148
161
  "card": true,
149
162
  "next": "choose between the two dates in the linked plan.",
163
+ "changed_round": 3,
164
+ "changed_parts": ["next"],
150
165
  "summary": [
151
166
  "One release date everyone plans around",
152
167
  "The launch checklist dated backward from it",
@@ -167,6 +182,8 @@ details.what[open] summary::before { content: "▾" }
167
182
  "status": "blocked",
168
183
  "card": true,
169
184
  "next": "wait for the staging database to come back online.",
185
+ "changed_round": 2,
186
+ "changed_parts": ["next"],
170
187
  "summary": [
171
188
  "The login test passes ten runs in a row",
172
189
  "The root cause written in the pull request"
@@ -186,6 +203,8 @@ details.what[open] summary::before { content: "▾" }
186
203
  "status": "doing",
187
204
  "card": true,
188
205
  "next": "finish the index change and rerun the timing check.",
206
+ "changed_round": 3,
207
+ "changed_parts": ["summary"],
189
208
  "summary": [
190
209
  "Search answers in under one second",
191
210
  "A timing check that guards it",
@@ -288,6 +307,8 @@ const STAGE_BY_KEY = Object.fromEntries(STAGES.map(s => [s.key, s]));
288
307
  const TRACK = ["Card", "Builder", "Pull request", "Merged"];
289
308
  const state = { tab: "open", owner: "all", query: "" };
290
309
  try { const saved = JSON.parse(localStorage.getItem("work-tracker-view") || "{}"); if (saved.tab) state.tab = saved.tab; if (saved.owner) state.owner = saved.owner; } catch (e) {}
310
+ const REVIEW_ROUND = Number(TRACKER.round) || 0;
311
+ const CHANGE_BASELINE = changeBaseline();
291
312
  const ownerByKey = Object.fromEntries(TRACKER.owners.map(o => [o.key, o]));
292
313
  const isDone = it => it.status === "done";
293
314
  function stageOf(it) {
@@ -300,6 +321,34 @@ function stageOf(it) {
300
321
  const slug = it => "item-" + it.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
301
322
  const fmt = iso => { if (!iso) return ""; try { return new Date(iso).toLocaleString(undefined, { month: "long", day: "numeric", hour: "numeric", minute: "2-digit" }); } catch (e) { return iso; } };
302
323
  const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
324
+ function changeBaseline() {
325
+ try {
326
+ const held = JSON.parse(sessionStorage.getItem("work-tracker-baseline") || "null");
327
+ if (held && held.round === REVIEW_ROUND) return held.baseline;
328
+ } catch (e) {}
329
+ let baseline = REVIEW_ROUND - 1;
330
+ try {
331
+ const seen = localStorage.getItem("work-tracker-seen-round");
332
+ if (seen !== null && Number.isFinite(Number(seen))) baseline = Math.min(Number(seen), REVIEW_ROUND);
333
+ } catch (e) {}
334
+ try { sessionStorage.setItem("work-tracker-baseline", JSON.stringify({ round: REVIEW_ROUND, baseline })); } catch (e) {}
335
+ try { localStorage.setItem("work-tracker-seen-round", String(REVIEW_ROUND)); } catch (e) {}
336
+ return baseline;
337
+ }
338
+ function stampChange(node, it, part) {
339
+ if (it.changed_round && (!part || [].concat(it.changed_parts || []).includes(part))) node.dataset.changedRound = it.changed_round;
340
+ return node;
341
+ }
342
+ function markChanges() {
343
+ for (const node of document.querySelectorAll("[data-changed-round]")) {
344
+ if (Number(node.dataset.changedRound) <= CHANGE_BASELINE || node.classList.contains("changed")) continue;
345
+ node.classList.add("changed");
346
+ const slot = node.querySelector("[data-change-slot]");
347
+ if (slot) { slot.prepend(el("span", "pill-changed", "Changed")); continue; }
348
+ const label = node.querySelector(":scope > summary") || node;
349
+ label.prepend(el("span", "sr", "Changed. "));
350
+ }
351
+ }
303
352
  function save() { try { localStorage.setItem("work-tracker-view", JSON.stringify({ tab: state.tab, owner: state.owner })); } catch (e) {} }
304
353
  function inView(it) {
305
354
  if ((state.tab === "done") !== isDone(it)) return false;
@@ -329,7 +378,7 @@ function renderBoard(items) {
329
378
  if (mine.length) {
330
379
  const chips = el("div", "lchips");
331
380
  for (const it of mine) {
332
- const b = el("button", "lchip", it.title);
381
+ const b = stampChange(el("button", "lchip", it.title), it);
333
382
  b.setAttribute("aria-label", "Go to " + it.title);
334
383
  b.onclick = () => jumpTo(it);
335
384
  chips.append(b);
@@ -389,25 +438,27 @@ function copyButton(it) {
389
438
  }
390
439
  function itemCard(it) {
391
440
  const key = stageOf(it), s = STAGE_BY_KEY[key], owner = ownerByKey[it.owner] || { name: it.owner };
392
- const card = el("article", "card " + key);
441
+ const card = stampChange(el("article", "card " + key), it);
393
442
  card.id = slug(it);
394
443
  const band = el("div", "band " + key);
444
+ band.dataset.changeSlot = "";
395
445
  band.append(el("span", null, s.icon), el("span", null, s.label));
396
446
  const proj = el("span", "proj");
397
447
  proj.append(el("span", null, (owner.icon || "📁")), el("span", null, owner.name));
398
448
  band.append(proj);
399
449
  card.append(band);
400
450
  const body = el("div", "body");
401
- body.append(el("h3", "title", it.title));
451
+ body.append(stampChange(el("h3", "title", it.title), it, "title"));
402
452
  body.append(trackFor(it));
403
453
  if (it.next) {
404
- const next = el("div", "next");
454
+ const next = stampChange(el("div", "next"), it, "next");
405
455
  next.append(el("span", "ic", isDone(it) ? "✅" : "👉"), el("span", null, it.next.charAt(0).toUpperCase() + it.next.slice(1)));
406
456
  body.append(next);
407
457
  }
408
458
  const points = [].concat(it.summary || []);
409
459
  if (points.length) {
410
- const d = el("details", "what");
460
+ const d = stampChange(el("details", "what"), it, "summary");
461
+ d.open = d.dataset.changedRound > CHANGE_BASELINE;
411
462
  d.append(el("summary", null, "What it delivers"));
412
463
  const ul = el("ul", "points");
413
464
  for (const p of points) ul.append(el("li", null, p));
@@ -415,12 +466,12 @@ function itemCard(it) {
415
466
  body.append(d);
416
467
  }
417
468
  if (it.proposal && !isDone(it) && it.owner_steps && it.owner_steps.length) {
418
- const st = el("div", "steps");
469
+ const st = stampChange(el("div", "steps"), it, "steps");
419
470
  st.append(el("div", "sh", "Your steps to create " + it.proposal.name));
420
471
  it.owner_steps.forEach((text, i) => { const row = el("div", "step"); row.append(el("span", "num", String(i + 1)), el("span", null, text)); st.append(row); });
421
472
  body.append(st);
422
473
  }
423
- const actions = el("div", "actions");
474
+ const actions = stampChange(el("div", "actions"), it, "links");
424
475
  for (const l of it.links || []) {
425
476
  const a = el("a", "link");
426
477
  a.append(el("span", null, "🔗"), el("span", null, l.label));
@@ -458,6 +509,7 @@ function render() {
458
509
  list.append(h);
459
510
  mine.forEach(i => list.append(itemCard(i)));
460
511
  }
512
+ markChanges();
461
513
  }
462
514
  document.querySelectorAll(".tab").forEach(t => t.onclick = () => { state.tab = t.dataset.tab; save(); render(); });
463
515
  document.getElementById("search").oninput = e => { state.query = e.target.value; render(); };
@@ -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.1",
3
+ "version": "8.57.0",
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 == ""