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.
- package/bin/install-pstack-plugin.mjs +2 -2
- package/bin/install-pstack-plugin.test.mjs +4 -4
- package/bin/install.profile-settings.test.mjs +24 -17
- package/bin/install.test.mjs +2 -2
- package/bin/merge_profile_settings.mjs +99 -76
- package/bin/merge_profile_settings.test.mjs +85 -60
- package/docs/rule-guides/session-title.md +2 -0
- package/docs/templates/artifact-page/README.md +1 -0
- package/docs/templates/artifact-page/review-rounds.md +26 -0
- package/docs/templates/artifact-page/template.html +59 -7
- package/hooks/blocking/test_session_title_stop_gate.py +8 -0
- package/hooks/hooks_constants/session_title_constants.py +4 -0
- package/installable-surfaces.manifest.json +1 -2
- package/package.json +2 -3
- package/profile-settings/config/__init__.py +0 -1
- package/profile-settings/config/session_title_gate_constants.py +0 -15
- package/profile-settings/session_title_status_gate.py +0 -77
- package/profile-settings/test_session_title_status_gate.py +0 -101
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
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('
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
}
|
package/bin/install.test.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
30
|
-
export const
|
|
31
|
-
export const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
201
|
+
* Delete the retired profile gate and its constants package from `~/.claude`.
|
|
189
202
|
*
|
|
190
|
-
* The
|
|
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 {{
|
|
208
|
+
* @returns {{ removedPaths: string[] }}
|
|
194
209
|
*/
|
|
195
|
-
export function
|
|
210
|
+
export function retireSessionTitleGate(options) {
|
|
196
211
|
const claudeDirectory = join(options.homeDirectory, DEFAULT_CLAUDE_DIRECTORY_NAME);
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
*
|
|
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: {
|
|
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 =
|
|
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: {
|
|
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
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
253
|
-
allLines.push(
|
|
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,
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
42
|
-
return `python3 ${homeDirectory.replace(/\\/g, '/')}/.claude/${
|
|
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
|
|
48
|
+
return (settings.hooks?.Stop ?? []).flatMap((eachGroup) => eachGroup.hooks.map((eachHook) => eachHook.command));
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
84
|
-
assert.equal(
|
|
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.
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
198
|
-
|
|
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,
|
|
204
|
-
assert.equal(outcome.
|
|
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(
|
|
207
|
-
assert.equal(existsSync(join(
|
|
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"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-dev-env",
|
|
3
|
-
"version": "8.
|
|
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 == ""
|