claude-dev-env 8.38.1 → 8.38.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/install.mjs
CHANGED
|
@@ -1319,7 +1319,9 @@ function managedPackageSourceRoots() {
|
|
|
1319
1319
|
* @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
|
|
1320
1320
|
* @returns {boolean} True when the command references a managed script.
|
|
1321
1321
|
*/
|
|
1322
|
-
export function commandReferencesManagedHook(
|
|
1322
|
+
export function commandReferencesManagedHook(
|
|
1323
|
+
commandString, managedHookRelativePaths, sharedSettingsRealPath = null,
|
|
1324
|
+
) {
|
|
1323
1325
|
if (typeof commandString !== 'string') return false;
|
|
1324
1326
|
const normalizedCommand = commandString.replace(/\\/g, '/');
|
|
1325
1327
|
if (commandIsInlineManagedValidatorRunner(normalizedCommand)) {
|
|
@@ -1329,6 +1331,51 @@ export function commandReferencesManagedHook(commandString, managedHookRelativeP
|
|
|
1329
1331
|
if (commandTailEndsAtManagedHook(normalizedCommand, relativePath)) {
|
|
1330
1332
|
return true;
|
|
1331
1333
|
}
|
|
1334
|
+
if (
|
|
1335
|
+
sharedSettingsRealPath
|
|
1336
|
+
&& commandRunsHookOfRootSharingSettings(normalizedCommand, relativePath, sharedSettingsRealPath)
|
|
1337
|
+
) {
|
|
1338
|
+
return true;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Report whether a command runs `<root>/hooks/<relative>` for a root whose
|
|
1346
|
+
* settings.json resolves to the given settings file.
|
|
1347
|
+
*
|
|
1348
|
+
* Two install roots can share one settings.json through a symlink. Each install
|
|
1349
|
+
* writes its own hooks directory into that one file, so the merge treats the
|
|
1350
|
+
* hooks of every root that shares the file as managed and keeps one entry per
|
|
1351
|
+
* script.
|
|
1352
|
+
*
|
|
1353
|
+
* @param {string} normalizedCommand Forward-slash-normalized hook command.
|
|
1354
|
+
* @param {string} relativePath The managed script path under hooks/.
|
|
1355
|
+
* @param {string} sharedSettingsRealPath The resolved path of the settings file being merged.
|
|
1356
|
+
* @returns {boolean} True when the command runs that script under a sharing root.
|
|
1357
|
+
*/
|
|
1358
|
+
function commandRunsHookOfRootSharingSettings(normalizedCommand, relativePath, sharedSettingsRealPath) {
|
|
1359
|
+
const commandArgumentBoundary = /[\s'";]/;
|
|
1360
|
+
const hookPathTail = `/${MANAGED_HOOKS_DIRECTORY_NAME}/${relativePath}`;
|
|
1361
|
+
const sharedSettingsKey = comparisonKeyForPath(sharedSettingsRealPath);
|
|
1362
|
+
let tailStart = normalizedCommand.indexOf(hookPathTail);
|
|
1363
|
+
while (tailStart !== -1) {
|
|
1364
|
+
const characterAfterPath = normalizedCommand[tailStart + hookPathTail.length];
|
|
1365
|
+
if (characterAfterPath === undefined || commandArgumentBoundary.test(characterAfterPath)) {
|
|
1366
|
+
let rootStart = tailStart;
|
|
1367
|
+
while (rootStart > 0 && !commandArgumentBoundary.test(normalizedCommand[rootStart - 1])) {
|
|
1368
|
+
rootStart--;
|
|
1369
|
+
}
|
|
1370
|
+
const rootPath = normalizedCommand.slice(rootStart, tailStart);
|
|
1371
|
+
const rootSettingsRealPath = rootPath
|
|
1372
|
+
? realPathOrNull(join(rootPath, SETTINGS_FILE_NAME))
|
|
1373
|
+
: null;
|
|
1374
|
+
if (rootSettingsRealPath && comparisonKeyForPath(rootSettingsRealPath) === sharedSettingsKey) {
|
|
1375
|
+
return true;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
tailStart = normalizedCommand.indexOf(hookPathTail, tailStart + 1);
|
|
1332
1379
|
}
|
|
1333
1380
|
return false;
|
|
1334
1381
|
}
|
|
@@ -1399,14 +1446,17 @@ export function commandIsInlineManagedValidatorRunner(normalizedCommand) {
|
|
|
1399
1446
|
* @param {object} settings The parsed settings.json object (mutated in place).
|
|
1400
1447
|
* @param {string} eventType The lifecycle event whose groups are pruned.
|
|
1401
1448
|
* @param {Set<string>} managedHookRelativePaths Managed script paths under hooks/.
|
|
1449
|
+
* @param {string|null} sharedSettingsRealPath Resolved path of the settings file, or null.
|
|
1402
1450
|
* @returns {void}
|
|
1403
1451
|
*/
|
|
1404
|
-
function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths) {
|
|
1452
|
+
function pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths, sharedSettingsRealPath) {
|
|
1405
1453
|
const existingGroups = settings.hooks[eventType];
|
|
1406
1454
|
if (!Array.isArray(existingGroups)) return;
|
|
1407
1455
|
settings.hooks[eventType] = retainedMatcherGroups(
|
|
1408
1456
|
existingGroups,
|
|
1409
|
-
commandString => commandReferencesManagedHook(
|
|
1457
|
+
commandString => commandReferencesManagedHook(
|
|
1458
|
+
commandString, managedHookRelativePaths, sharedSettingsRealPath,
|
|
1459
|
+
),
|
|
1410
1460
|
).keptGroups;
|
|
1411
1461
|
}
|
|
1412
1462
|
|
|
@@ -1457,9 +1507,14 @@ function startEventFromHookGroupList(settings, eventType) {
|
|
|
1457
1507
|
* @param {string} pluginRootDir Directory ${CLAUDE_PLUGIN_ROOT} resolves to
|
|
1458
1508
|
* (the installer's `~/.claude` root; home is its parent directory).
|
|
1459
1509
|
* @param {string} pythonCommand Interpreter command that replaces python3.
|
|
1510
|
+
* @param {string|null} [sharedSettingsRealPath] Resolved path of the settings
|
|
1511
|
+
* file being merged. Hooks of every root whose settings.json resolves to it
|
|
1512
|
+
* count as managed, so roots sharing one file keep one entry per script.
|
|
1460
1513
|
* @returns {number} Count of matcher groups merged.
|
|
1461
1514
|
*/
|
|
1462
|
-
export function mergeHooksIntoSettings(
|
|
1515
|
+
export function mergeHooksIntoSettings(
|
|
1516
|
+
settings, hooksConfig, pluginRootDir, pythonCommand, sharedSettingsRealPath = null,
|
|
1517
|
+
) {
|
|
1463
1518
|
const managedHookRelativePaths = managedHookScriptRelativePaths(hooksConfig);
|
|
1464
1519
|
const pluginRootForward = pluginRootDir.replace(/\\/g, '/');
|
|
1465
1520
|
const encodedPluginHooksPath = Buffer.from(`${pluginRootForward}/hooks`, 'utf8').toString('base64');
|
|
@@ -1467,7 +1522,7 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
|
|
|
1467
1522
|
let groupCount = 0;
|
|
1468
1523
|
for (const [eventType, matcherGroups] of Object.entries(hooksConfig.hooks)) {
|
|
1469
1524
|
startEventFromHookGroupList(settings, eventType);
|
|
1470
|
-
pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths);
|
|
1525
|
+
pruneManagedHooksFromEvent(settings, eventType, managedHookRelativePaths, sharedSettingsRealPath);
|
|
1471
1526
|
for (const sourceGroup of matcherGroups) {
|
|
1472
1527
|
const rewrittenHooks = sourceGroup.hooks.map(hook => {
|
|
1473
1528
|
let command = hook.command;
|
|
@@ -1492,7 +1547,9 @@ export function mergeHooksIntoSettings(settings, hooksConfig, pluginRootDir, pyt
|
|
|
1492
1547
|
if (existingIndex >= 0) {
|
|
1493
1548
|
const existing = settings.hooks[eventType][existingIndex];
|
|
1494
1549
|
const userHooks = (groupHookEntries(existing) || []).filter(
|
|
1495
|
-
hook => !commandReferencesManagedHook(
|
|
1550
|
+
hook => !commandReferencesManagedHook(
|
|
1551
|
+
hook?.command, managedHookRelativePaths, sharedSettingsRealPath,
|
|
1552
|
+
)
|
|
1496
1553
|
);
|
|
1497
1554
|
settings.hooks[eventType][existingIndex] = {
|
|
1498
1555
|
...existing,
|
|
@@ -2079,7 +2136,9 @@ function mergeHooksAtPath(
|
|
|
2079
2136
|
pruneManagedHooksFromSettings(settings, new Set());
|
|
2080
2137
|
}
|
|
2081
2138
|
const hooksConfig = selectHooksConfig(sourceHooksConfig);
|
|
2082
|
-
const groupCount = mergeHooksIntoSettings(
|
|
2139
|
+
const groupCount = mergeHooksIntoSettings(
|
|
2140
|
+
settings, hooksConfig, pluginRootDir, pythonCommand, realPathOrNull(settingsPath),
|
|
2141
|
+
);
|
|
2083
2142
|
writeFileSync(settingsPath, JSON.stringify(settings, null, indentation) + '\n');
|
|
2084
2143
|
return groupCount;
|
|
2085
2144
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two install roots that share one settings.json through a symlink keep one
|
|
3
|
+
* hook entry per managed script, whatever order the roots install in.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { strict as assert } from 'node:assert';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import {
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
symlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { managedHookScriptRelativePaths, mergeHooksIntoSettings } from './install.mjs';
|
|
21
|
+
|
|
22
|
+
const INSTALLER_PATH = fileURLToPath(new URL('./install.mjs', import.meta.url));
|
|
23
|
+
const PACKAGE_HOOKS_JSON_PATH = fileURLToPath(new URL('../hooks/hooks.json', import.meta.url));
|
|
24
|
+
|
|
25
|
+
function installEnvironment(homeDirectory) {
|
|
26
|
+
return {
|
|
27
|
+
...process.env,
|
|
28
|
+
HOME: homeDirectory,
|
|
29
|
+
USERPROFILE: homeDirectory,
|
|
30
|
+
CLAUDE_CONFIG_DIR: '',
|
|
31
|
+
GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
|
|
32
|
+
CDE_INSTALL_PSTACK: '0',
|
|
33
|
+
CDE_INSTALL_USAGE_WRAPUP: '0',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function runCoreInstall(homeDirectory, extraArguments) {
|
|
38
|
+
const installRun = spawnSync(
|
|
39
|
+
process.execPath,
|
|
40
|
+
[INSTALLER_PATH, '--only', 'core', ...extraArguments],
|
|
41
|
+
{ encoding: 'utf8', env: installEnvironment(homeDirectory) },
|
|
42
|
+
);
|
|
43
|
+
assert.equal(installRun.status, 0, installRun.stdout + installRun.stderr);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function allHookCommands(settingsPath) {
|
|
47
|
+
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
48
|
+
const allCommands = [];
|
|
49
|
+
for (const matcherGroups of Object.values(settings.hooks ?? {})) {
|
|
50
|
+
for (const group of matcherGroups) {
|
|
51
|
+
for (const hook of group.hooks ?? []) {
|
|
52
|
+
if (typeof hook.command === 'string') allCommands.push(hook.command.replace(/\\/g, '/'));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return allCommands;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function entryCountByScript(settingsPath) {
|
|
60
|
+
const hooksConfig = JSON.parse(readFileSync(PACKAGE_HOOKS_JSON_PATH, 'utf8'));
|
|
61
|
+
const allCommands = allHookCommands(settingsPath);
|
|
62
|
+
const countByScript = new Map();
|
|
63
|
+
for (const relativePath of managedHookScriptRelativePaths(hooksConfig)) {
|
|
64
|
+
const scriptPattern = new RegExp(`/hooks/${relativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s'";])`, 'g');
|
|
65
|
+
const occurrences = allCommands.reduce(
|
|
66
|
+
(total, command) => total + (command.match(scriptPattern) ?? []).length,
|
|
67
|
+
0,
|
|
68
|
+
);
|
|
69
|
+
countByScript.set(relativePath, occurrences);
|
|
70
|
+
}
|
|
71
|
+
return countByScript;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function expectedCountByScript(hooksConfig) {
|
|
75
|
+
const countByScript = new Map();
|
|
76
|
+
for (const matcherGroups of Object.values(hooksConfig.hooks)) {
|
|
77
|
+
for (const group of matcherGroups) {
|
|
78
|
+
for (const hook of group.hooks) {
|
|
79
|
+
for (const match of hook.command.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+?\.(?:py|mjs))/g)) {
|
|
80
|
+
countByScript.set(match[1], (countByScript.get(match[1]) ?? 0) + 1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return countByScript;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assertOneEntryPerHookRegistration(settingsPath, stageName) {
|
|
89
|
+
const hooksConfig = JSON.parse(readFileSync(PACKAGE_HOOKS_JSON_PATH, 'utf8'));
|
|
90
|
+
const expectedCounts = expectedCountByScript(hooksConfig);
|
|
91
|
+
const actualCounts = entryCountByScript(settingsPath);
|
|
92
|
+
assert.ok(expectedCounts.size > 0, 'the package ships managed hook scripts');
|
|
93
|
+
for (const [relativePath, expectedCount] of expectedCounts) {
|
|
94
|
+
assert.equal(
|
|
95
|
+
actualCounts.get(relativePath),
|
|
96
|
+
expectedCount,
|
|
97
|
+
`${stageName}: ${relativePath} is registered once per hooks.json entry`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function withSharedSettingsHome(runScenario) {
|
|
103
|
+
return t => {
|
|
104
|
+
const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-shared-settings-'));
|
|
105
|
+
try {
|
|
106
|
+
writeFileSync(join(homeDirectory, '.gitconfig'), '');
|
|
107
|
+
const mainRoot = join(homeDirectory, '.claude');
|
|
108
|
+
const profileRoot = join(homeDirectory, 'profiles', 'second');
|
|
109
|
+
mkdirSync(mainRoot, { recursive: true });
|
|
110
|
+
mkdirSync(profileRoot, { recursive: true });
|
|
111
|
+
const mainSettingsPath = join(mainRoot, 'settings.json');
|
|
112
|
+
writeFileSync(mainSettingsPath, '{}\n');
|
|
113
|
+
try {
|
|
114
|
+
symlinkSync(mainSettingsPath, join(profileRoot, 'settings.json'), 'file');
|
|
115
|
+
} catch (linkError) {
|
|
116
|
+
t.skip(`this host cannot create a file symlink (${linkError.code})`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
runScenario({ homeDirectory, profileRoot, mainSettingsPath });
|
|
120
|
+
} finally {
|
|
121
|
+
rmSync(homeDirectory, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
test(
|
|
127
|
+
'main then profile then main keeps one entry per hook in the shared settings file',
|
|
128
|
+
withSharedSettingsHome(({ homeDirectory, profileRoot, mainSettingsPath }) => {
|
|
129
|
+
runCoreInstall(homeDirectory, []);
|
|
130
|
+
assertOneEntryPerHookRegistration(mainSettingsPath, 'after the main install');
|
|
131
|
+
runCoreInstall(homeDirectory, ['--target', profileRoot]);
|
|
132
|
+
assertOneEntryPerHookRegistration(mainSettingsPath, 'after the profile install');
|
|
133
|
+
runCoreInstall(homeDirectory, []);
|
|
134
|
+
assertOneEntryPerHookRegistration(mainSettingsPath, 'after the main rerun');
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
test(
|
|
139
|
+
'profile then main then profile keeps one entry per hook in the shared settings file',
|
|
140
|
+
withSharedSettingsHome(({ homeDirectory, profileRoot, mainSettingsPath }) => {
|
|
141
|
+
runCoreInstall(homeDirectory, ['--target', profileRoot]);
|
|
142
|
+
assertOneEntryPerHookRegistration(mainSettingsPath, 'after the profile install');
|
|
143
|
+
runCoreInstall(homeDirectory, []);
|
|
144
|
+
assertOneEntryPerHookRegistration(mainSettingsPath, 'after the main install');
|
|
145
|
+
runCoreInstall(homeDirectory, ['--target', profileRoot]);
|
|
146
|
+
assertOneEntryPerHookRegistration(mainSettingsPath, 'after the profile rerun');
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
test('the merge prunes a hook of a root sharing the settings file and keeps one with its own file', t => {
|
|
151
|
+
const sandboxRoot = mkdtempSync(join(tmpdir(), 'cdev-shared-settings-unit-'));
|
|
152
|
+
try {
|
|
153
|
+
const installRoot = join(sandboxRoot, 'install-root');
|
|
154
|
+
const sharingRoot = join(sandboxRoot, 'sharing-root');
|
|
155
|
+
const separateRoot = join(sandboxRoot, 'separate-root');
|
|
156
|
+
for (const eachRoot of [installRoot, sharingRoot, separateRoot]) mkdirSync(eachRoot, { recursive: true });
|
|
157
|
+
const settingsPath = join(installRoot, 'settings.json');
|
|
158
|
+
writeFileSync(settingsPath, '{}\n');
|
|
159
|
+
writeFileSync(join(separateRoot, 'settings.json'), '{}\n');
|
|
160
|
+
try {
|
|
161
|
+
symlinkSync(settingsPath, join(sharingRoot, 'settings.json'), 'file');
|
|
162
|
+
} catch (linkError) {
|
|
163
|
+
t.skip(`this host cannot create a file symlink (${linkError.code})`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const hooksConfig = {
|
|
167
|
+
hooks: {
|
|
168
|
+
SessionStart: [{
|
|
169
|
+
matcher: '',
|
|
170
|
+
hooks: [{ type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/start.py' }],
|
|
171
|
+
}],
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
const forwardSlashed = rootPath => rootPath.replace(/\\/g, '/');
|
|
175
|
+
const sharingCommand = `python3 "${forwardSlashed(sharingRoot)}/hooks/session/start.py"`;
|
|
176
|
+
const separateCommand = `python3 "${forwardSlashed(separateRoot)}/hooks/session/start.py"`;
|
|
177
|
+
const settings = {
|
|
178
|
+
hooks: {
|
|
179
|
+
SessionStart: [{
|
|
180
|
+
matcher: '',
|
|
181
|
+
hooks: [
|
|
182
|
+
{ type: 'command', command: sharingCommand },
|
|
183
|
+
{ type: 'command', command: separateCommand },
|
|
184
|
+
],
|
|
185
|
+
}],
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
mergeHooksIntoSettings(settings, hooksConfig, forwardSlashed(installRoot), 'python3', settingsPath);
|
|
190
|
+
|
|
191
|
+
const allCommands = settings.hooks.SessionStart.flatMap(group => group.hooks.map(hook => hook.command));
|
|
192
|
+
assert.ok(!allCommands.includes(sharingCommand), 'the sharing root entry is pruned');
|
|
193
|
+
assert.ok(allCommands.includes(separateCommand), 'the separate root entry stays');
|
|
194
|
+
assert.equal(
|
|
195
|
+
allCommands.filter(command => command.includes(`${forwardSlashed(installRoot)}/hooks/session/start.py`)).length,
|
|
196
|
+
1,
|
|
197
|
+
'the install root entry is written once',
|
|
198
|
+
);
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Hook that
|
|
2
|
+
"""Hook that restores poteto-mode after compaction and loads it for helpers.
|
|
3
3
|
|
|
4
4
|
::
|
|
5
5
|
|
|
6
6
|
Agent or Codex spawn_agent, skill not named -> prompt opens with "invoke poteto-mode"
|
|
7
7
|
Workflow script helper starts -> "invoke poteto-mode now"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
compact SessionStart, skill invoked before -> "invoke the skill again"
|
|
9
|
+
compact SessionStart, no prior invocation
|
|
10
|
+
or transcript unavailable -> nothing
|
|
11
|
+
user turn, skill invoked then compacted -> "invoke poteto-mode now"
|
|
12
|
+
user turn, skill loaded, never invoked,
|
|
13
|
+
or transcript unavailable -> nothing
|
|
14
|
+
|
|
15
|
+
A session that invoked the skill hears nothing more until a compaction drops it.
|
|
13
16
|
The hook prints its output and stops.
|
|
14
17
|
"""
|
|
15
18
|
|
|
@@ -116,66 +119,92 @@ def _is_loaded_after(all_entry_fields: dict[str, object], was_loaded: bool) -> b
|
|
|
116
119
|
return was_loaded or _invokes_poteto_mode(all_entry_fields)
|
|
117
120
|
|
|
118
121
|
|
|
119
|
-
def
|
|
120
|
-
"""
|
|
122
|
+
def poteto_mode_status(all_transcript_lines: Iterable[str]) -> tuple[bool, bool]:
|
|
123
|
+
"""Report whether the skill was invoked and whether it remains loaded.
|
|
121
124
|
|
|
122
125
|
::
|
|
123
126
|
|
|
124
|
-
Skill(poteto-mode) ... Read
|
|
125
|
-
/poteto-mode
|
|
126
|
-
Skill(poteto-mode) ... compact_boundary ...
|
|
127
|
-
|
|
127
|
+
Skill(poteto-mode) ... Read -> (True, True)
|
|
128
|
+
/poteto-mode ... compact_boundary -> (True, False)
|
|
129
|
+
Skill(poteto-mode) ... compact_boundary ... Skill(poteto-mode)
|
|
130
|
+
-> (True, True)
|
|
131
|
+
Read ... Edit -> (False, False)
|
|
128
132
|
|
|
129
133
|
Only lines naming the skill or a compaction are parsed, so a long transcript
|
|
130
134
|
reads fast.
|
|
131
135
|
|
|
132
136
|
Args:
|
|
133
137
|
all_transcript_lines: The session transcript, one JSON entry per line.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Whether the skill was invoked at any point and whether it is loaded now.
|
|
134
141
|
"""
|
|
142
|
+
was_invoked = False
|
|
135
143
|
is_loaded = False
|
|
136
144
|
for each_line in all_transcript_lines:
|
|
137
145
|
all_entry_fields = _marker_entry(each_line)
|
|
138
146
|
if all_entry_fields is not None:
|
|
139
147
|
is_loaded = _is_loaded_after(all_entry_fields, is_loaded)
|
|
140
|
-
|
|
148
|
+
was_invoked = was_invoked or is_loaded
|
|
149
|
+
return was_invoked, is_loaded
|
|
150
|
+
|
|
141
151
|
|
|
152
|
+
def is_poteto_mode_loaded(all_transcript_lines: Iterable[str]) -> bool:
|
|
153
|
+
"""Return whether the transcript invoked the skill after its last compaction.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
all_transcript_lines: The session transcript, one JSON entry per line.
|
|
142
157
|
|
|
143
|
-
|
|
158
|
+
Returns:
|
|
159
|
+
Whether the skill is loaded now.
|
|
160
|
+
"""
|
|
161
|
+
return poteto_mode_status(all_transcript_lines)[1]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _status_in_transcript(transcript_path: object) -> tuple[bool, bool]:
|
|
144
165
|
if not isinstance(transcript_path, str):
|
|
145
|
-
return False
|
|
166
|
+
return False, False
|
|
146
167
|
try:
|
|
147
168
|
with open(
|
|
148
169
|
transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY
|
|
149
170
|
) as transcript:
|
|
150
|
-
return
|
|
171
|
+
return poteto_mode_status(transcript)
|
|
151
172
|
except OSError:
|
|
152
|
-
return False
|
|
173
|
+
return False, False
|
|
153
174
|
|
|
154
175
|
|
|
155
176
|
def reminder_for(all_hook_fields: dict[str, object]) -> str | None:
|
|
156
|
-
"""Return
|
|
177
|
+
"""Return a reminder only for helpers or sessions that need the skill restored.
|
|
157
178
|
|
|
158
179
|
::
|
|
159
180
|
|
|
160
|
-
SessionStart,
|
|
181
|
+
SessionStart, compact, skill invoked before -> COMPACTION_REMINDER
|
|
182
|
+
SessionStart, compact, no prior invocation
|
|
183
|
+
or transcript unavailable -> None
|
|
161
184
|
SubagentStart, workflow-subagent -> NOT_LOADED_REMINDER
|
|
162
|
-
UserPromptSubmit,
|
|
163
|
-
UserPromptSubmit,
|
|
185
|
+
UserPromptSubmit, skill invoked then compacted -> NOT_LOADED_REMINDER
|
|
186
|
+
UserPromptSubmit, skill loaded, never invoked,
|
|
187
|
+
or transcript unavailable -> None
|
|
164
188
|
|
|
165
189
|
Args:
|
|
166
190
|
all_hook_fields: The parsed hook input.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The event's reminder, or None when no reminder is needed.
|
|
167
194
|
"""
|
|
168
195
|
event_name = all_hook_fields.get("hook_event_name")
|
|
169
196
|
if event_name == SESSION_START_EVENT_NAME:
|
|
170
|
-
|
|
197
|
+
if all_hook_fields.get("source") != COMPACTION_SOURCE:
|
|
198
|
+
return None
|
|
199
|
+
was_invoked, _ = _status_in_transcript(all_hook_fields.get("transcript_path"))
|
|
200
|
+
return COMPACTION_REMINDER if was_invoked else None
|
|
171
201
|
if event_name == SUBAGENT_START_EVENT_NAME:
|
|
172
202
|
is_workflow_helper = all_hook_fields.get("agent_type") == WORKFLOW_SUBAGENT_TYPE
|
|
173
203
|
return NOT_LOADED_REMINDER if is_workflow_helper else None
|
|
174
204
|
if event_name != USER_PROMPT_SUBMIT_EVENT_NAME:
|
|
175
205
|
return None
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return NOT_LOADED_REMINDER
|
|
206
|
+
was_invoked, is_loaded = _status_in_transcript(all_hook_fields.get("transcript_path"))
|
|
207
|
+
return NOT_LOADED_REMINDER if was_invoked and not is_loaded else None
|
|
179
208
|
|
|
180
209
|
|
|
181
210
|
def _is_subagent_spawn(all_hook_fields: dict[str, object]) -> bool:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for skill_loaded_reminder. Checks
|
|
1
|
+
"""Tests for skill_loaded_reminder. Checks restoration after compaction and helper prompts."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
@@ -167,14 +167,25 @@ class TestCodexSpawn:
|
|
|
167
167
|
|
|
168
168
|
def test_a_codex_spawn_message_that_already_mentions_the_skill_is_left_alone(self) -> None:
|
|
169
169
|
assert (
|
|
170
|
-
_run_main(
|
|
171
|
-
_agent_call({"message": "$poteto-mode\n\nFix it."}, tool_name="spawn_agent")
|
|
172
|
-
)
|
|
170
|
+
_run_main(_agent_call({"message": "$poteto-mode\n\nFix it."}, tool_name="spawn_agent"))
|
|
173
171
|
== ""
|
|
174
172
|
)
|
|
175
173
|
|
|
176
174
|
|
|
177
175
|
class TestIsPotetoModeLoaded:
|
|
176
|
+
def test_status_without_a_skill_call_reports_no_invocation_or_load(self) -> None:
|
|
177
|
+
assert reminder.poteto_mode_status(iter([READ_CALL_LINE])) == (False, False)
|
|
178
|
+
|
|
179
|
+
def test_status_keeps_invocation_history_after_compaction(self) -> None:
|
|
180
|
+
assert reminder.poteto_mode_status(
|
|
181
|
+
iter([SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, READ_CALL_LINE])
|
|
182
|
+
) == (True, False)
|
|
183
|
+
|
|
184
|
+
def test_status_reports_the_skill_loaded_again_after_compaction(self) -> None:
|
|
185
|
+
assert reminder.poteto_mode_status(
|
|
186
|
+
iter([SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, SKILL_CALL_LINE])
|
|
187
|
+
) == (True, True)
|
|
188
|
+
|
|
178
189
|
def test_a_skill_call_loads_it(self) -> None:
|
|
179
190
|
assert reminder.is_poteto_mode_loaded([READ_CALL_LINE, SKILL_CALL_LINE, READ_CALL_LINE])
|
|
180
191
|
|
|
@@ -210,35 +221,106 @@ class TestIsPotetoModeLoaded:
|
|
|
210
221
|
|
|
211
222
|
|
|
212
223
|
class TestReminderFor:
|
|
213
|
-
def
|
|
224
|
+
def test_a_user_turn_in_a_session_that_never_loaded_the_skill_prints_nothing(
|
|
214
225
|
self, tmp_path: Path
|
|
215
226
|
) -> None:
|
|
216
227
|
transcript_path = _write_transcript(tmp_path, [READ_CALL_LINE])
|
|
228
|
+
assert _run_main(_user_turn(transcript_path)) == ""
|
|
229
|
+
|
|
230
|
+
def test_a_user_turn_after_the_skill_and_compaction_gets_the_reminder(
|
|
231
|
+
self, tmp_path: Path
|
|
232
|
+
) -> None:
|
|
233
|
+
transcript_path = _write_transcript(
|
|
234
|
+
tmp_path, [SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, READ_CALL_LINE]
|
|
235
|
+
)
|
|
217
236
|
emitted = json.loads(_run_main(_user_turn(transcript_path)))
|
|
218
237
|
hook_output = emitted["hookSpecificOutput"]
|
|
219
238
|
assert hook_output["hookEventName"] == "UserPromptSubmit"
|
|
220
239
|
assert hook_output["additionalContext"] == NOT_LOADED_REMINDER
|
|
221
240
|
assert "poteto-mode" in NOT_LOADED_REMINDER
|
|
222
241
|
|
|
242
|
+
def test_a_user_turn_after_the_skill_is_reloaded_prints_nothing(self, tmp_path: Path) -> None:
|
|
243
|
+
transcript_path = _write_transcript(
|
|
244
|
+
tmp_path, [SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE, SKILL_CALL_LINE]
|
|
245
|
+
)
|
|
246
|
+
assert _run_main(_user_turn(transcript_path)) == ""
|
|
247
|
+
|
|
223
248
|
def test_a_user_turn_in_a_session_that_loaded_the_skill_prints_nothing(
|
|
224
249
|
self, tmp_path: Path
|
|
225
250
|
) -> None:
|
|
226
251
|
transcript_path = _write_transcript(tmp_path, [SKILL_CALL_LINE, READ_CALL_LINE])
|
|
227
252
|
assert _run_main(_user_turn(transcript_path)) == ""
|
|
228
253
|
|
|
229
|
-
def
|
|
230
|
-
assert (
|
|
231
|
-
reminder.reminder_for(_user_turn(tmp_path / "not-written-yet.jsonl"))
|
|
232
|
-
== NOT_LOADED_REMINDER
|
|
233
|
-
)
|
|
254
|
+
def test_a_user_turn_with_no_transcript_yet_prints_nothing(self, tmp_path: Path) -> None:
|
|
255
|
+
assert _run_main(_user_turn(tmp_path / "not-written-yet.jsonl")) == ""
|
|
234
256
|
|
|
235
|
-
def
|
|
236
|
-
|
|
257
|
+
def test_session_start_after_compaction_tells_a_prior_user_to_invoke_it_again(
|
|
258
|
+
self, tmp_path: Path
|
|
259
|
+
) -> None:
|
|
260
|
+
transcript_path = _write_transcript(tmp_path, [SKILL_CALL_LINE, READ_CALL_LINE])
|
|
261
|
+
emitted = json.loads(
|
|
262
|
+
_run_main(
|
|
263
|
+
{
|
|
264
|
+
"hook_event_name": "SessionStart",
|
|
265
|
+
"source": "compact",
|
|
266
|
+
"transcript_path": str(transcript_path),
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
)
|
|
237
270
|
hook_output = emitted["hookSpecificOutput"]
|
|
238
271
|
assert hook_output["hookEventName"] == "SessionStart"
|
|
239
272
|
assert hook_output["additionalContext"] == COMPACTION_REMINDER
|
|
240
273
|
assert "poteto-mode" in COMPACTION_REMINDER
|
|
241
274
|
|
|
275
|
+
def test_session_start_after_a_written_boundary_reminds_a_prior_user(
|
|
276
|
+
self, tmp_path: Path
|
|
277
|
+
) -> None:
|
|
278
|
+
transcript_path = _write_transcript(tmp_path, [SKILL_CALL_LINE, COMPACT_BOUNDARY_LINE])
|
|
279
|
+
assert (
|
|
280
|
+
reminder.reminder_for(
|
|
281
|
+
{
|
|
282
|
+
"hook_event_name": "SessionStart",
|
|
283
|
+
"source": "compact",
|
|
284
|
+
"transcript_path": str(transcript_path),
|
|
285
|
+
}
|
|
286
|
+
)
|
|
287
|
+
== COMPACTION_REMINDER
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def test_session_start_after_compaction_without_a_prior_skill_call_prints_nothing(
|
|
291
|
+
self, tmp_path: Path
|
|
292
|
+
) -> None:
|
|
293
|
+
transcript_path = _write_transcript(tmp_path, [READ_CALL_LINE])
|
|
294
|
+
assert (
|
|
295
|
+
_run_main(
|
|
296
|
+
{
|
|
297
|
+
"hook_event_name": "SessionStart",
|
|
298
|
+
"source": "compact",
|
|
299
|
+
"transcript_path": str(transcript_path),
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
== ""
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def test_session_start_after_compaction_without_a_transcript_path_prints_nothing(
|
|
306
|
+
self,
|
|
307
|
+
) -> None:
|
|
308
|
+
assert _run_main({"hook_event_name": "SessionStart", "source": "compact"}) == ""
|
|
309
|
+
|
|
310
|
+
def test_session_start_after_compaction_with_an_unreadable_transcript_prints_nothing(
|
|
311
|
+
self, tmp_path: Path
|
|
312
|
+
) -> None:
|
|
313
|
+
assert (
|
|
314
|
+
_run_main(
|
|
315
|
+
{
|
|
316
|
+
"hook_event_name": "SessionStart",
|
|
317
|
+
"source": "compact",
|
|
318
|
+
"transcript_path": str(tmp_path / "not-written-yet.jsonl"),
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
== ""
|
|
322
|
+
)
|
|
323
|
+
|
|
242
324
|
def test_a_resumed_session_keeps_its_context_and_prints_nothing(self) -> None:
|
|
243
325
|
assert _run_main({"hook_event_name": "SessionStart", "source": "resume"}) == ""
|
|
244
326
|
|