claude-dev-env 8.106.0 → 8.106.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install-constants.mjs +0 -5
- package/bin/install.cursor-rules.test.mjs +11 -6
- package/bin/install.mjs +19 -2
- package/bin/install.prune.test.mjs +45 -0
- package/bin/install.test.mjs +1 -0
- package/docs/codex-compatibility.md +2 -2
- package/hooks/features/README.md +1 -1
- package/hooks/features/observability.md +1 -5
- package/hooks/features/session-context.md +4 -4
- package/hooks/hooks.json +0 -15
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +0 -23
- package/hooks/session/skill_loaded_reminder.py +2 -52
- package/hooks/session/test_skill_loaded_reminder.py +22 -85
- package/package.json +1 -1
- package/hooks/workflow/investigation_tracker_reset.py +0 -48
- package/hooks/workflow/test_investigation_tracker_reset.py +0 -90
|
@@ -174,11 +174,6 @@ export const WINDOWS_PYTHON_LAUNCHER_COMMAND = 'py -3';
|
|
|
174
174
|
export const PYTHON_PROBE_TIMEOUT_MILLISECONDS = 10_000;
|
|
175
175
|
export const ADDITIONAL_SETTINGS_BLOB_PREFIX = 'settings-';
|
|
176
176
|
export const CODEX_NATIVE_ROUTING_MATCHER = 'multi_agent_v1__spawn_agent';
|
|
177
|
-
export const CODEX_SUBAGENT_PROMPT_MATCHER = 'Agent|Task';
|
|
178
|
-
export const ALL_CODEX_NATIVE_PRE_TOOL_USE_MATCHERS = new Set([
|
|
179
|
-
CODEX_NATIVE_ROUTING_MATCHER,
|
|
180
|
-
CODEX_SUBAGENT_PROMPT_MATCHER,
|
|
181
|
-
]);
|
|
182
177
|
export const ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES = new Set([
|
|
183
178
|
'open_tasks_stop_gate.py',
|
|
184
179
|
'owned_pr_merge_stop_gate.py',
|
|
@@ -130,11 +130,10 @@ test('seeds one editable policy and one native Codex routing hook', () => {
|
|
|
130
130
|
assert.match(firstRoutingGroups[0].hooks[0].command, /subagent_model_routing\.mjs/);
|
|
131
131
|
assert.match(firstRoutingGroups[0].hooks[1].command, /spawn_readiness_hook\.py/);
|
|
132
132
|
assert.match(firstRoutingGroups[0].hooks[2].command, /spawn_oversight_hook\.py/);
|
|
133
|
-
|
|
134
|
-
group => group.matcher
|
|
133
|
+
assert.deepEqual(
|
|
134
|
+
firstCodexHooks.hooks.PreToolUse.map(group => group.matcher),
|
|
135
|
+
['multi_agent_v1__spawn_agent'],
|
|
135
136
|
);
|
|
136
|
-
assert.equal(firstSpawnPromptGroups.length, 1);
|
|
137
|
-
assert.match(firstSpawnPromptGroups[0].hooks[0].command, /skill_loaded_reminder\.py/);
|
|
138
137
|
assert.deepEqual(Object.keys(firstCodexHooks.hooks), ['PreToolUse', 'UserPromptSubmit', 'PostToolUse', 'Stop', 'SessionStart']);
|
|
139
138
|
const allCodexPostToolUseCommands = firstCodexHooks.hooks.PostToolUse.flatMap(
|
|
140
139
|
group => group.hooks.map(hook => hook.command),
|
|
@@ -276,6 +275,7 @@ test('Codex reinstall removes omitted package hooks and preserves user configura
|
|
|
276
275
|
const allLegacyRegistrations = [
|
|
277
276
|
['PreToolUse', 'Edit', '.claude', 'blocking/pre_tool_use_dispatcher.py'],
|
|
278
277
|
['PreToolUse', 'Bash', '.agents', 'blocking/bash_pre_tool_use_dispatcher.py'],
|
|
278
|
+
['PreToolUse', 'Agent|Task', 'custom-codex', 'session/skill_loaded_reminder.py'],
|
|
279
279
|
['SessionStart', '*', 'custom-codex', 'session/issue_tracker_session_starter.py'],
|
|
280
280
|
['Stop', '*', '.claude', 'session/skill_loaded_reminder.py'],
|
|
281
281
|
['SessionEnd', '*', 'custom-codex', 'lifecycle/session_end_cleanup.py'],
|
|
@@ -311,8 +311,13 @@ test('Codex reinstall removes omitted package hooks and preserves user configura
|
|
|
311
311
|
|
|
312
312
|
const migratedHooks = JSON.parse(readFileSync(codexHooksPath, 'utf8'));
|
|
313
313
|
const allSpawnGroups = migratedHooks.hooks.PreToolUse.filter(eachGroup =>
|
|
314
|
-
|
|
315
|
-
assert.equal(allSpawnGroups.length,
|
|
314
|
+
eachGroup.matcher === 'multi_agent_v1__spawn_agent');
|
|
315
|
+
assert.equal(allSpawnGroups.length, 1);
|
|
316
|
+
assert.equal(
|
|
317
|
+
migratedHooks.hooks.PreToolUse.flatMap(eachGroup => eachGroup.hooks)
|
|
318
|
+
.some(eachHook => String(eachHook.command).includes('skill_loaded_reminder.py')),
|
|
319
|
+
false,
|
|
320
|
+
);
|
|
316
321
|
migratedHooks.hooks.PreToolUse = migratedHooks.hooks.PreToolUse.filter(eachGroup => !allSpawnGroups.includes(eachGroup));
|
|
317
322
|
const allPackagePromptCommands = (migratedHooks.hooks.UserPromptSubmit ?? [])
|
|
318
323
|
.flatMap(eachGroup => eachGroup.hooks)
|
package/bin/install.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
SUBAGENT_MODEL_POLICY_FILE_NAME,
|
|
51
51
|
WINDOWS_PYTHON_LAUNCHER_COMMAND,
|
|
52
52
|
PYTHON_PROBE_TIMEOUT_MILLISECONDS,
|
|
53
|
-
|
|
53
|
+
CODEX_NATIVE_ROUTING_MATCHER,
|
|
54
54
|
ALL_CODEX_NATIVE_SESSION_START_HOOK_SCRIPT_NAMES,
|
|
55
55
|
ALL_CODEX_NATIVE_STOP_HOOK_SCRIPT_NAMES,
|
|
56
56
|
ALL_CODEX_NATIVE_POST_TOOL_USE_HOOK_SCRIPT_NAMES,
|
|
@@ -1064,6 +1064,7 @@ export function pruneStaleInstalledFiles(
|
|
|
1064
1064
|
currentInstalledFiles.map(currentFile => comparisonKeyForPath(currentFile, options)),
|
|
1065
1065
|
);
|
|
1066
1066
|
const resolvedRoot = resolve(destinationRoot);
|
|
1067
|
+
const resolvedRootTarget = realPathOrNull(resolvedRoot);
|
|
1067
1068
|
let prunedCount = 0;
|
|
1068
1069
|
const failedPaths = [];
|
|
1069
1070
|
for (const priorFile of priorInstalledFiles) {
|
|
@@ -1072,6 +1073,17 @@ export function pruneStaleInstalledFiles(
|
|
|
1072
1073
|
if (currentFileKeys.has(comparisonKeyForPath(stalePath, options))) continue;
|
|
1073
1074
|
if (isRetainedUnmanagedPath(stalePath, managedHomeDirectory)) continue;
|
|
1074
1075
|
if (!isMovableStaleFile(stalePath)) continue;
|
|
1076
|
+
const staleTarget = realPathOrNull(stalePath);
|
|
1077
|
+
if (!staleTarget || !resolvedRootTarget) {
|
|
1078
|
+
failedPaths.push(stalePath);
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
if (!isInsideDirectory(staleTarget, resolvedRootTarget)) {
|
|
1082
|
+
console.warn(
|
|
1083
|
+
` Warning: leaving ${relative(managedHomeDirectory, stalePath)} in place — a link on its path leads outside ${resolvedRoot}`,
|
|
1084
|
+
);
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1075
1087
|
const backupRelativePath = relative(resolvedRoot, stalePath);
|
|
1076
1088
|
const didMove = moveIntoRunBackup(
|
|
1077
1089
|
stalePath, backupRoot, backupRelativePath, STALE_FILE_REASON_LABEL, managedHomeDirectory,
|
|
@@ -1303,6 +1315,7 @@ export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
|
|
|
1303
1315
|
'blocking/session_title_stop_gate.py',
|
|
1304
1316
|
'session/issue_tracker_session_starter.py',
|
|
1305
1317
|
'session/deepen_first_prompt.py',
|
|
1318
|
+
'workflow/investigation_tracker_reset.py',
|
|
1306
1319
|
]);
|
|
1307
1320
|
|
|
1308
1321
|
export const MOVED_HOOK_RELATIVE_PATHS = new Set([
|
|
@@ -2271,7 +2284,7 @@ function selectCodexNativeHooksConfig(hooksConfig) {
|
|
|
2271
2284
|
...hooksConfig,
|
|
2272
2285
|
hooks: {
|
|
2273
2286
|
PreToolUse: Array.isArray(preToolUseGroups)
|
|
2274
|
-
? preToolUseGroups.filter(group =>
|
|
2287
|
+
? preToolUseGroups.filter(group => group?.matcher === CODEX_NATIVE_ROUTING_MATCHER)
|
|
2275
2288
|
: [],
|
|
2276
2289
|
UserPromptSubmit: Array.isArray(userPromptSubmitGroups) ? userPromptSubmitGroups : [],
|
|
2277
2290
|
PostToolUse: selectCodexNativeGroupsByScriptName(
|
|
@@ -3013,6 +3026,10 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
3013
3026
|
if (allowedSkills && !allowedSkills.has(skillDir.name)) continue;
|
|
3014
3027
|
const skillSourceDirectory = join(skillsSource, skillDir.name);
|
|
3015
3028
|
const skillDestinationDirectory = join(CLAUDE_HOME, MANAGED_SKILLS_DIRECTORY_NAME, skillDir.name);
|
|
3029
|
+
if (lstatSync(skillDestinationDirectory, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
|
3030
|
+
console.log(` = ${join(MANAGED_SKILLS_DIRECTORY_NAME, skillDir.name)} (linked skill left in place)`);
|
|
3031
|
+
continue;
|
|
3032
|
+
}
|
|
3016
3033
|
const stats = copyTree(skillSourceDirectory, skillDestinationDirectory);
|
|
3017
3034
|
skillsCreated += stats.created;
|
|
3018
3035
|
skillsUpdated += stats.updated;
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
readFileSync,
|
|
10
10
|
readdirSync,
|
|
11
11
|
rmSync,
|
|
12
|
+
symlinkSync,
|
|
13
|
+
lstatSync,
|
|
12
14
|
} from 'node:fs';
|
|
13
15
|
import { tmpdir } from 'node:os';
|
|
14
16
|
import { join, dirname, basename } from 'node:path';
|
|
@@ -828,6 +830,49 @@ test('a fresh scoped core install includes efficient-review', () => {
|
|
|
828
830
|
}
|
|
829
831
|
});
|
|
830
832
|
|
|
833
|
+
test('a full reinstall leaves a skill folder linked into another repository untouched', () => {
|
|
834
|
+
const sandbox = createSandbox();
|
|
835
|
+
const linkedRepositoryDirectory = mkdtempSync(join(tmpdir(), 'cdev-linked-skill-repo-'));
|
|
836
|
+
try {
|
|
837
|
+
runInstaller(sandbox.homeDirectory, []);
|
|
838
|
+
const linkedSkillName = 'efficient-review';
|
|
839
|
+
const repositorySkillDirectory = join(linkedRepositoryDirectory, 'skills', linkedSkillName);
|
|
840
|
+
const repositorySkillText = 'name: efficient-review\nthe repository copy\n';
|
|
841
|
+
writeFileWithParents(join(repositorySkillDirectory, 'SKILL.md'), repositorySkillText);
|
|
842
|
+
const skillLinkPath = join(sandbox.skillsDirectory, linkedSkillName);
|
|
843
|
+
rmSync(skillLinkPath, { recursive: true, force: true });
|
|
844
|
+
symlinkSync(repositorySkillDirectory, skillLinkPath, 'junction');
|
|
845
|
+
|
|
846
|
+
const installerOutput = runInstallerReadingBothStreams(sandbox.homeDirectory, []);
|
|
847
|
+
|
|
848
|
+
assert.equal(lstatSync(skillLinkPath).isSymbolicLink(), true, 'the skill link stays a link');
|
|
849
|
+
assert.equal(
|
|
850
|
+
readFileSync(join(repositorySkillDirectory, 'SKILL.md'), 'utf8'),
|
|
851
|
+
repositorySkillText,
|
|
852
|
+
'the repository SKILL.md keeps its own text',
|
|
853
|
+
);
|
|
854
|
+
assert.deepEqual(
|
|
855
|
+
readdirSync(repositorySkillDirectory),
|
|
856
|
+
['SKILL.md'],
|
|
857
|
+
'the install writes no package file into the linked repository',
|
|
858
|
+
);
|
|
859
|
+
assert.equal(
|
|
860
|
+
prunedSkillBackupContains(sandbox.claudeDirectory, join(linkedSkillName, 'SKILL.md')),
|
|
861
|
+
false,
|
|
862
|
+
'the stale prune moves no file out of the linked repository',
|
|
863
|
+
);
|
|
864
|
+
assert.match(installerOutput, /skills[\\/]efficient-review \(linked skill left in place\)/);
|
|
865
|
+
assert.equal(
|
|
866
|
+
readManifest(sandbox.manifestPath).files.some(recordedPath => recordedPath.includes(linkedSkillName)),
|
|
867
|
+
false,
|
|
868
|
+
'the manifest records no file under the linked skill',
|
|
869
|
+
);
|
|
870
|
+
} finally {
|
|
871
|
+
rmSync(sandbox.homeDirectory, { recursive: true, force: true });
|
|
872
|
+
rmSync(linkedRepositoryDirectory, { recursive: true, force: true });
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
|
|
831
876
|
test('a fresh scoped core install ships active skills and omits retired skills', () => {
|
|
832
877
|
const sandbox = createSandbox();
|
|
833
878
|
try {
|
package/bin/install.test.mjs
CHANGED
|
@@ -1245,6 +1245,7 @@ test('retired hook registrations stay managed so reinstall removes them', () =>
|
|
|
1245
1245
|
'blocking/session_title_stop_gate.py',
|
|
1246
1246
|
'session/issue_tracker_session_starter.py',
|
|
1247
1247
|
'session/deepen_first_prompt.py',
|
|
1248
|
+
'workflow/investigation_tracker_reset.py',
|
|
1248
1249
|
]);
|
|
1249
1250
|
const shippedHooks = JSON.parse(
|
|
1250
1251
|
readFileSync(new URL('../hooks/hooks.json', import.meta.url), 'utf8')
|
|
@@ -26,8 +26,8 @@ Run `codex-compat bridge --surface <name> --payload '<json-object>'`. The bridge
|
|
|
26
26
|
|
|
27
27
|
The package installer seeds `subagent-model-policy.json` in the agents-home
|
|
28
28
|
`rules/` directory when the file is absent. It registers one `PreToolUse` group
|
|
29
|
-
for `multi_agent_v1__spawn_agent`
|
|
30
|
-
Before adding
|
|
29
|
+
for `multi_agent_v1__spawn_agent` in `$CODEX_HOME/hooks.json`.
|
|
30
|
+
Before adding this group, the installer removes obsolete package-owned hook
|
|
31
31
|
registrations from every event. Custom hook entries and Codex's enabled or
|
|
32
32
|
disabled hook settings stay unchanged. Claude-only hook groups do not enter the
|
|
33
33
|
Codex file.
|
package/hooks/features/README.md
CHANGED
|
@@ -28,6 +28,6 @@ Each family page starts with a title and an agent-facing behavior summary. Its f
|
|
|
28
28
|
- [Conduct gates](./conduct-gates.md) covers chat replies, edit markers, step notes, checked claims, and the pull request lifecycle skill.
|
|
29
29
|
- [Session context](./session-context.md) covers skill reminders, startup guidance, and the auto mode denial quick fix.
|
|
30
30
|
- [Spawn routing](./spawn-routing.md) covers spawn readiness, pacing, and model selection.
|
|
31
|
-
- [Observability](./observability.md) covers instruction loads
|
|
31
|
+
- [Observability](./observability.md) covers instruction loads and edited files.
|
|
32
32
|
- [Read gates](./read-gates.md) covers the image size cap on Read.
|
|
33
33
|
- [Lifecycle cleanup](./lifecycle-cleanup.md) covers nested checkouts, worktree setup, and session cleanup.
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
# Observability
|
|
2
2
|
|
|
3
|
-
This family records instruction loads and file edits for later checks.
|
|
3
|
+
This family records instruction loads and file edits for later checks.
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
7
7
|
- `observability/instructions_loaded_logger.py` writes selected instruction-load fields to a JSONL log.
|
|
8
8
|
- `observability/session_file_edit_tracker.py` records edited file paths in a per-session tracker for the stage gate.
|
|
9
|
-
- `workflow/investigation_tracker_reset.py` clears the investigation tracker after delegation.
|
|
10
9
|
|
|
11
10
|
## When it fires
|
|
12
11
|
|
|
13
12
|
- `observability/instructions_loaded_logger.py` runs on `InstructionsLoaded`, matcher `session_start|nested_traversal|path_glob_match|include|compact`, timeout `10` seconds in `hooks.json`.
|
|
14
13
|
- `observability/session_file_edit_tracker.py` runs on `PostToolUse`, matcher `Write|Edit|MultiEdit|apply_patch`, timeout `30` seconds in `hooks.json`.
|
|
15
|
-
- `workflow/investigation_tracker_reset.py` runs on `PostToolUse`, matcher `Agent|Task|TeamCreate`, timeout `30` seconds in `hooks.json`.
|
|
16
14
|
|
|
17
15
|
## Proving it
|
|
18
16
|
|
|
@@ -22,10 +20,8 @@ Preconditions:
|
|
|
22
20
|
|
|
23
21
|
- **Instruction log.** Input is an `InstructionsLoaded` payload with a file path and load reason. Run `python -m pytest packages/claude-dev-env/hooks/observability/test_instructions_loaded_logger.py -q`. The adjacent test observes one JSONL record with the selected fields.
|
|
24
22
|
- **Edit tracker.** Input is a Write payload with a file path and session ID. Run `python -m pytest packages/claude-dev-env/hooks/observability/test_session_file_edit_tracker.py -q`. The adjacent test observes the resolved path in that session's tracker.
|
|
25
|
-
- **Investigation reset.** Input is a completed Agent, Task, or TeamCreate call. Run `python -m pytest packages/claude-dev-env/hooks/workflow/test_investigation_tracker_reset.py -q`. The adjacent test observes removal of the tracker file.
|
|
26
23
|
|
|
27
24
|
## Gotchas
|
|
28
25
|
|
|
29
26
|
- The instruction logger keeps only selected payload fields. It exits zero if logging fails.
|
|
30
27
|
- The edit tracker never blocks a tool call. Its per-session file is read later by the stage gate.
|
|
31
|
-
- The investigation reset applies to delegation tools. Its companion investigation gate lives outside this Claude hook registration map.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Session context
|
|
2
2
|
|
|
3
|
-
This family injects reminders and task guidance when an agent starts, resumes, submits a prompt,
|
|
3
|
+
This family injects reminders and task guidance when an agent starts, resumes, submits a prompt, starts a workflow helper, starts an artifact page, or hits an auto mode denial. The startup scripts leave the session quiet when their opt-in conditions fail.
|
|
4
4
|
|
|
5
5
|
## Checks
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ This family injects reminders and task guidance when an agent starts, resumes, s
|
|
|
17
17
|
|
|
18
18
|
## When it fires
|
|
19
19
|
|
|
20
|
-
- `session/skill_loaded_reminder.py` runs on `
|
|
20
|
+
- `session/skill_loaded_reminder.py` runs on `UserPromptSubmit` matcher empty at `10` seconds, `SessionStart` matcher `compact` at `10` seconds, and `SubagentStart` matcher `workflow-subagent` at `10` seconds in `hooks.json`.
|
|
21
21
|
- `session/compaction_recall_prompt.py` runs on `SessionStart` matcher `compact` at `10` seconds in `hooks.json`. The installer also writes it to the Codex `hooks.json`, the only `SessionStart` hook Codex gets.
|
|
22
22
|
- `session/task_tool_prompt.py`, `session/working_style_prompt.py`, `session/advisor_rules_prompt.py`, and `session/orchestrator_auto_starter.py` run on `SessionStart`, matcher empty, timeout `10` seconds each in `hooks.json`.
|
|
23
23
|
- `session/plugin_session_baseline_writer.py` runs on `SessionStart`, matcher empty, at `10` seconds, so it sees every source. `session/plugin_change_warning.py` runs on `UserPromptSubmit`, matcher empty, at `10` seconds in `hooks.json`.
|
|
@@ -30,7 +30,7 @@ Preconditions:
|
|
|
30
30
|
|
|
31
31
|
- Run each command from the repository root with Python and pytest available. Enable opt-in flags only inside the tests.
|
|
32
32
|
|
|
33
|
-
- **Skill reminder.** Input is a
|
|
33
|
+
- **Skill reminder.** Input is a compaction, a user turn, or a workflow helper start. Run `python -m pytest packages/claude-dev-env/hooks/session/test_skill_loaded_reminder.py -q`. The adjacent test observes the reminder, silence on a spawn payload, and no `PreToolUse` registration in `hooks.json`.
|
|
34
34
|
- **Compaction recall.** Input is a Claude Code transcript or a Codex rollout with typed, meta, sidechain, peer, and harness entries across a compaction. Run `python -m pytest packages/claude-dev-env/hooks/session/test_compaction_user_messages.py packages/claude-dev-env/hooks/session/test_compaction_trim.py packages/claude-dev-env/hooks/session/test_compaction_recall_prompt.py packages/claude-dev-env/hooks/session/test_compaction_trim_eval.py -q`. The tests observe only typed messages, a stable result across a second compaction, the trim rules, the recall budget, and the trimmed lines in the hook's `additionalContext`. `python packages/claude-dev-env/hooks/session/compaction_trim_eval.py` trims each fixture transcript with planted facts, prints the share of facts that survive, the reversals marked, and the compression ratio, and exits 1 on any loss.
|
|
35
35
|
- **Task tracking.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_task_tool_prompt.py -q`. The adjacent test observes a `SessionStart` context directive that names the task tool.
|
|
36
36
|
- **Working style.** Input is a SessionStart event. Run `python -m pytest packages/claude-dev-env/hooks/session/test_working_style_prompt.py -q`. The adjacent test observes the fixed context text.
|
|
@@ -44,7 +44,7 @@ Preconditions:
|
|
|
44
44
|
|
|
45
45
|
- The recall keeps the first message and the newest that fit in 6,000 characters, under the 2,500 token preview limit Codex applies to hook context. A message over 1,500 characters is cut, and the line says how much was left out.
|
|
46
46
|
- The overruled mark comes from cue phrases such as `instead` or `change of plan` and shared words. A reversal with no cue phrase gets no mark, and the later line still overrides by its place. A cue in a message that agrees with an earlier one, such as `dont use haiku` after `use Python trim instead of Haiku`, marks that earlier line too, so the context tells the agent to follow the later message where the two differ.
|
|
47
|
-
- `session/skill_loaded_reminder.py` has
|
|
47
|
+
- `session/skill_loaded_reminder.py` has three registration points. An `Agent` or `Task` helper gets no poteto-mode line from it; a brief that needs the skill names it, or spawns `pstack:poteto-agent`. Its tests cover the compaction restore, the workflow helper reminder, silence on a user turn while the skill stays loaded, silence on a spawn payload, and no `PreToolUse` registration in `hooks.json`.
|
|
48
48
|
- The advisor prompt depends on settings and an environment disable flag. The two starter scripts each use separate opt-in checks.
|
|
49
49
|
- The issue tracker starter requires the checkout to appear in the project path registry.
|
|
50
50
|
- `advisory/auto_mode_denial_quick_fix.py` lives under `advisory/` but belongs here because its only output is agent context and a one-line user message.
|
package/hooks/hooks.json
CHANGED
|
@@ -5,11 +5,6 @@
|
|
|
5
5
|
{
|
|
6
6
|
"matcher": "Agent|Task",
|
|
7
7
|
"hooks": [
|
|
8
|
-
{
|
|
9
|
-
"type": "command",
|
|
10
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
|
|
11
|
-
"timeout": 10
|
|
12
|
-
},
|
|
13
8
|
{
|
|
14
9
|
"type": "command",
|
|
15
10
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/haiku_spawn_slim_gate.py",
|
|
@@ -526,16 +521,6 @@
|
|
|
526
521
|
}
|
|
527
522
|
]
|
|
528
523
|
},
|
|
529
|
-
{
|
|
530
|
-
"matcher": "Agent|Task|TeamCreate",
|
|
531
|
-
"hooks": [
|
|
532
|
-
{
|
|
533
|
-
"type": "command",
|
|
534
|
-
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/workflow/investigation_tracker_reset.py",
|
|
535
|
-
"timeout": 30
|
|
536
|
-
}
|
|
537
|
-
]
|
|
538
|
-
},
|
|
539
524
|
{
|
|
540
525
|
"matcher": "Bash|PowerShell",
|
|
541
526
|
"hooks": [
|
|
@@ -3,52 +3,29 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
__all__ = [
|
|
6
|
-
"ALL_SELF_LOADING_SUBAGENT_TYPES",
|
|
7
6
|
"COMPACTION_REMINDER",
|
|
8
7
|
"COMPACTION_SOURCE",
|
|
9
8
|
"NOT_LOADED_REMINDER",
|
|
10
9
|
"ALL_POTETO_MODE_SKILL_NAMES",
|
|
11
|
-
"PRE_TOOL_USE_EVENT_NAME",
|
|
12
|
-
"PROMPT_SEPARATOR",
|
|
13
10
|
"SESSION_START_EVENT_NAME",
|
|
14
11
|
"ALL_SLASH_COMMAND_MARKERS",
|
|
15
12
|
"SUBAGENT_START_EVENT_NAME",
|
|
16
|
-
"CLAUDE_SUBAGENT_PROMPT_PREFIX",
|
|
17
|
-
"CODEX_SUBAGENT_PROMPT_PREFIX",
|
|
18
|
-
"ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME",
|
|
19
13
|
"USER_PROMPT_SUBMIT_EVENT_NAME",
|
|
20
14
|
"WORKFLOW_SUBAGENT_TYPE",
|
|
21
15
|
]
|
|
22
16
|
|
|
23
|
-
PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
|
|
24
17
|
SESSION_START_EVENT_NAME = "SessionStart"
|
|
25
18
|
USER_PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
|
|
26
19
|
COMPACTION_SOURCE = "compact"
|
|
27
20
|
SUBAGENT_START_EVENT_NAME = "SubagentStart"
|
|
28
21
|
WORKFLOW_SUBAGENT_TYPE = "workflow-subagent"
|
|
29
22
|
|
|
30
|
-
ALL_SELF_LOADING_SUBAGENT_TYPES = frozenset({"pstack:poteto-agent", "poteto-agent"})
|
|
31
|
-
|
|
32
23
|
ALL_POTETO_MODE_SKILL_NAMES = ("poteto-mode", "pstack:poteto-mode")
|
|
33
24
|
SKILL_TOOL_NAME = "Skill"
|
|
34
25
|
ALL_SLASH_COMMAND_MARKERS = (
|
|
35
26
|
"<command-name>/poteto-mode</command-name>",
|
|
36
27
|
"<command-name>/pstack:poteto-mode</command-name>",
|
|
37
28
|
)
|
|
38
|
-
PROMPT_SEPARATOR = "\n\n"
|
|
39
|
-
|
|
40
|
-
CLAUDE_SUBAGENT_PROMPT_PREFIX = (
|
|
41
|
-
"Before any other work, invoke the poteto-mode skill with the Skill tool. "
|
|
42
|
-
"Every prompt you write for a subagent opens with the same instruction."
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
CODEX_SUBAGENT_PROMPT_PREFIX = "$poteto-mode"
|
|
46
|
-
|
|
47
|
-
ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME = {
|
|
48
|
-
"Agent": ("prompt", CLAUDE_SUBAGENT_PROMPT_PREFIX),
|
|
49
|
-
"Task": ("prompt", CLAUDE_SUBAGENT_PROMPT_PREFIX),
|
|
50
|
-
"spawn_agent": ("message", CODEX_SUBAGENT_PROMPT_PREFIX),
|
|
51
|
-
}
|
|
52
29
|
|
|
53
30
|
NOT_LOADED_REMINDER = (
|
|
54
31
|
"The poteto-mode skill is not loaded in this context. "
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Hook that restores poteto-mode after compaction and loads it for helpers.
|
|
2
|
+
"""Hook that restores poteto-mode after compaction and loads it for workflow helpers.
|
|
3
3
|
|
|
4
4
|
::
|
|
5
5
|
|
|
6
|
-
Agent or Codex spawn_agent, skill not named -> prompt opens with "invoke poteto-mode"
|
|
7
6
|
Workflow script helper starts -> "invoke poteto-mode now"
|
|
8
7
|
compact SessionStart, skill invoked before -> "invoke the skill again"
|
|
9
8
|
compact SessionStart, no prior invocation
|
|
@@ -28,55 +27,21 @@ if _hooks_dir not in sys.path:
|
|
|
28
27
|
sys.path.insert(0, _hooks_dir)
|
|
29
28
|
|
|
30
29
|
from hooks_constants.skill_loaded_reminder_constants import (
|
|
31
|
-
ALL_SELF_LOADING_SUBAGENT_TYPES,
|
|
32
30
|
COMPACTION_REMINDER,
|
|
33
31
|
COMPACTION_SOURCE,
|
|
34
32
|
NOT_LOADED_REMINDER,
|
|
35
33
|
ALL_POTETO_MODE_SKILL_NAMES,
|
|
36
|
-
PRE_TOOL_USE_EVENT_NAME,
|
|
37
|
-
PROMPT_SEPARATOR,
|
|
38
34
|
SESSION_START_EVENT_NAME,
|
|
39
35
|
ALL_SLASH_COMMAND_MARKERS,
|
|
40
36
|
SUBAGENT_START_EVENT_NAME,
|
|
41
|
-
ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME,
|
|
42
37
|
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
43
38
|
WORKFLOW_SUBAGENT_TYPE,
|
|
44
39
|
)
|
|
45
|
-
from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
|
|
46
40
|
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
47
41
|
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
48
42
|
from transcript_skill_scan import skill_invocation_status
|
|
49
43
|
|
|
50
44
|
|
|
51
|
-
def subagent_input_with_poteto_mode(
|
|
52
|
-
tool_name: str,
|
|
53
|
-
all_tool_input_fields: dict[str, object],
|
|
54
|
-
) -> dict[str, object] | None:
|
|
55
|
-
"""Return the spawn input with the poteto-mode invocation first, or None to leave it.
|
|
56
|
-
|
|
57
|
-
::
|
|
58
|
-
|
|
59
|
-
Agent {"prompt": "Reply leaf."} -> {"prompt": "Before any ...\\n\\nReply leaf."}
|
|
60
|
-
spawn_agent {"message": "Fix it."} -> {"message": "$poteto-mode\\n\\nFix it."}
|
|
61
|
-
Agent {"prompt": "Invoke poteto-mode, then ..."} -> None
|
|
62
|
-
Agent {"subagent_type": "poteto-agent", ...} -> None, it loads the skill
|
|
63
|
-
|
|
64
|
-
Claude Code spawns through Agent or Task and Codex through spawn_agent, so
|
|
65
|
-
each tool name carries its own prompt field and invocation text.
|
|
66
|
-
|
|
67
|
-
Args:
|
|
68
|
-
tool_name: The spawn tool the session called.
|
|
69
|
-
all_tool_input_fields: The spawn input the session is about to send.
|
|
70
|
-
"""
|
|
71
|
-
field_name, invocation_prefix = ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME[tool_name]
|
|
72
|
-
prompt = all_tool_input_fields.get(field_name)
|
|
73
|
-
if not isinstance(prompt, str) or ALL_POTETO_MODE_SKILL_NAMES[0] in prompt:
|
|
74
|
-
return None
|
|
75
|
-
if all_tool_input_fields.get("subagent_type") in ALL_SELF_LOADING_SUBAGENT_TYPES:
|
|
76
|
-
return None
|
|
77
|
-
return {**all_tool_input_fields, field_name: invocation_prefix + PROMPT_SEPARATOR + prompt}
|
|
78
|
-
|
|
79
|
-
|
|
80
45
|
def poteto_mode_status(all_transcript_lines: Iterable[str]) -> tuple[bool, bool]:
|
|
81
46
|
"""Report whether the skill was invoked and whether it remains loaded.
|
|
82
47
|
|
|
@@ -160,26 +125,11 @@ def reminder_for(all_hook_fields: dict[str, object]) -> str | None:
|
|
|
160
125
|
return NOT_LOADED_REMINDER if was_invoked and not is_loaded else None
|
|
161
126
|
|
|
162
127
|
|
|
163
|
-
def _is_subagent_spawn(all_hook_fields: dict[str, object]) -> bool:
|
|
164
|
-
return (
|
|
165
|
-
all_hook_fields.get("hook_event_name") == PRE_TOOL_USE_EVENT_NAME
|
|
166
|
-
and all_hook_fields.get("tool_name") in ALL_SPAWN_PROMPT_FIELDS_AND_PREFIXES_BY_TOOL_NAME
|
|
167
|
-
and isinstance(all_hook_fields.get("tool_input"), dict)
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
|
|
171
128
|
def main() -> None:
|
|
172
|
-
"""
|
|
129
|
+
"""Print the reminder its event needs, or stay quiet."""
|
|
173
130
|
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
174
131
|
if hook_payload is None:
|
|
175
132
|
return
|
|
176
|
-
if _is_subagent_spawn(hook_payload):
|
|
177
|
-
rewritten_input = subagent_input_with_poteto_mode(
|
|
178
|
-
hook_payload["tool_name"], hook_payload["tool_input"]
|
|
179
|
-
)
|
|
180
|
-
if rewritten_input is not None:
|
|
181
|
-
write_pre_tool_use_allow_to_stdout(rewritten_input)
|
|
182
|
-
return
|
|
183
133
|
reminder = reminder_for(hook_payload)
|
|
184
134
|
if reminder is None:
|
|
185
135
|
return
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Tests for skill_loaded_reminder. Checks restoration after compaction and
|
|
1
|
+
"""Tests for skill_loaded_reminder. Checks restoration after compaction and spawn pass-through."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
@@ -76,10 +76,6 @@ def _run_main(hook_payload: object) -> str:
|
|
|
76
76
|
return captured_stdout.getvalue()
|
|
77
77
|
|
|
78
78
|
|
|
79
|
-
def _agent_call(tool_input: dict[str, object], tool_name: str = "Agent") -> dict[str, object]:
|
|
80
|
-
return {"hook_event_name": "PreToolUse", "tool_name": tool_name, "tool_input": tool_input}
|
|
81
|
-
|
|
82
|
-
|
|
83
79
|
def _user_turn(transcript_path: Path) -> dict[str, object]:
|
|
84
80
|
return {"hook_event_name": "UserPromptSubmit", "transcript_path": str(transcript_path)}
|
|
85
81
|
|
|
@@ -90,85 +86,28 @@ def _write_transcript(tmp_path: Path, all_lines: list[str]) -> Path:
|
|
|
90
86
|
return transcript_path
|
|
91
87
|
|
|
92
88
|
|
|
93
|
-
class
|
|
94
|
-
def
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"prompt": "List every caller of main.",
|
|
102
|
-
}
|
|
103
|
-
)
|
|
104
|
-
)
|
|
105
|
-
)
|
|
106
|
-
hook_output = emitted["hookSpecificOutput"]
|
|
107
|
-
rewritten_input = hook_output["updatedInput"]
|
|
108
|
-
assert hook_output["permissionDecision"] == "allow"
|
|
109
|
-
assert rewritten_input["prompt"].startswith(
|
|
110
|
-
"Before any other work, invoke the poteto-mode skill with the Skill tool."
|
|
111
|
-
)
|
|
112
|
-
assert rewritten_input["prompt"].endswith("\n\nList every caller of main.")
|
|
113
|
-
assert rewritten_input["subagent_type"] == "Explore"
|
|
114
|
-
assert rewritten_input["description"] == "Find callers"
|
|
115
|
-
|
|
116
|
-
def test_the_legacy_task_tool_name_is_rewritten_too(self) -> None:
|
|
117
|
-
emitted = json.loads(_run_main(_agent_call({"prompt": "Go."}, tool_name="Task")))
|
|
118
|
-
assert "poteto-mode" in emitted["hookSpecificOutput"]["updatedInput"]["prompt"]
|
|
119
|
-
|
|
120
|
-
def test_a_prompt_that_already_names_the_skill_is_left_alone(self) -> None:
|
|
121
|
-
assert _run_main(_agent_call({"prompt": "Invoke poteto-mode first. Then go."})) == ""
|
|
89
|
+
class TestRegistration:
|
|
90
|
+
def test_a_spawn_payload_from_a_stale_registration_prints_nothing(self) -> None:
|
|
91
|
+
stale_spawn_payload = {
|
|
92
|
+
"hook_event_name": "PreToolUse",
|
|
93
|
+
"tool_name": "Agent",
|
|
94
|
+
"tool_input": {"prompt": "Reply leaf."},
|
|
95
|
+
}
|
|
96
|
+
assert _run_main(stale_spawn_payload) == ""
|
|
122
97
|
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
|
|
98
|
+
def test_no_pre_tool_use_group_runs_the_reminder(self) -> None:
|
|
99
|
+
shipped_hooks = json.loads(
|
|
100
|
+
(Path(__file__).resolve().parents[1] / "hooks.json").read_text(encoding="utf-8")
|
|
126
101
|
)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
)
|
|
137
|
-
assert rewritten_input is not None
|
|
138
|
-
assert rewritten_input["model"] == "sonnet"
|
|
139
|
-
assert rewritten_input["run_in_background"] is True
|
|
140
|
-
|
|
141
|
-
def test_an_input_with_no_prompt_is_left_alone(self) -> None:
|
|
142
|
-
assert reminder.subagent_input_with_poteto_mode("Agent", {"description": "Go"}) is None
|
|
143
|
-
|
|
144
|
-
def test_a_codex_spawn_input_with_only_items_is_left_alone(self) -> None:
|
|
145
|
-
assert (
|
|
146
|
-
reminder.subagent_input_with_poteto_mode(
|
|
147
|
-
"spawn_agent", {"items": [{"type": "text", "text": "Go."}]}
|
|
148
|
-
)
|
|
149
|
-
is None
|
|
150
|
-
)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
class TestCodexSpawn:
|
|
154
|
-
def test_a_codex_spawn_message_opens_with_the_skill_mention(self) -> None:
|
|
155
|
-
emitted = json.loads(
|
|
156
|
-
_run_main(
|
|
157
|
-
_agent_call(
|
|
158
|
-
{"message": "Fix the failing test.", "model": "gpt-6-luna"},
|
|
159
|
-
tool_name="spawn_agent",
|
|
160
|
-
)
|
|
161
|
-
)
|
|
162
|
-
)
|
|
163
|
-
rewritten_input = emitted["hookSpecificOutput"]["updatedInput"]
|
|
164
|
-
assert rewritten_input["message"] == "$poteto-mode\n\nFix the failing test."
|
|
165
|
-
assert rewritten_input["model"] == "gpt-6-luna"
|
|
166
|
-
assert "prompt" not in rewritten_input
|
|
167
|
-
|
|
168
|
-
def test_a_codex_spawn_message_that_already_mentions_the_skill_is_left_alone(self) -> None:
|
|
169
|
-
assert (
|
|
170
|
-
_run_main(_agent_call({"message": "$poteto-mode\n\nFix it."}, tool_name="spawn_agent"))
|
|
171
|
-
== ""
|
|
102
|
+
all_pre_tool_use_commands = [
|
|
103
|
+
each_hook.get("command", "")
|
|
104
|
+
for each_group in shipped_hooks["hooks"]["PreToolUse"]
|
|
105
|
+
for each_hook in each_group["hooks"]
|
|
106
|
+
]
|
|
107
|
+
assert all_pre_tool_use_commands
|
|
108
|
+
assert not any(
|
|
109
|
+
"skill_loaded_reminder.py" in each_command
|
|
110
|
+
for each_command in all_pre_tool_use_commands
|
|
172
111
|
)
|
|
173
112
|
|
|
174
113
|
|
|
@@ -353,9 +292,7 @@ class TestReminderFor:
|
|
|
353
292
|
assert hook_output["hookEventName"] == "SubagentStart"
|
|
354
293
|
assert hook_output["additionalContext"] == NOT_LOADED_REMINDER
|
|
355
294
|
|
|
356
|
-
def
|
|
357
|
-
self,
|
|
358
|
-
) -> None:
|
|
295
|
+
def test_an_agent_tool_helper_starting_prints_nothing(self) -> None:
|
|
359
296
|
assert (
|
|
360
297
|
_run_main({"hook_event_name": "SubagentStart", "agent_type": "general-purpose"}) == ""
|
|
361
298
|
)
|
package/package.json
CHANGED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""PostToolUse hook — resets the serial investigation tracker when delegation occurs.
|
|
3
|
-
|
|
4
|
-
Clears the investigation timestamp tracker when the lead session delegates work
|
|
5
|
-
via Agent, Task, or TeamCreate tools. This allows the lead to resume limited
|
|
6
|
-
diagnostic calls after properly delegating.
|
|
7
|
-
|
|
8
|
-
Companion: blocking/serial-investigation-blocker.py (PreToolUse on Read|Bash|Grep)
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
import sys
|
|
14
|
-
|
|
15
|
-
TRACKER_STATE_PATH = os.path.join(
|
|
16
|
-
os.path.expanduser("~"), ".claude", "runtime", "investigation-tracker.json"
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
DELEGATION_TOOLS = frozenset({"Agent", "Task", "TeamCreate"})
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def clear_investigation_tracker() -> None:
|
|
23
|
-
try:
|
|
24
|
-
os.remove(TRACKER_STATE_PATH)
|
|
25
|
-
except FileNotFoundError:
|
|
26
|
-
pass
|
|
27
|
-
except OSError:
|
|
28
|
-
pass
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def main() -> None:
|
|
32
|
-
try:
|
|
33
|
-
hook_input = json.load(sys.stdin)
|
|
34
|
-
except json.JSONDecodeError:
|
|
35
|
-
sys.exit(0)
|
|
36
|
-
if not isinstance(hook_input, dict):
|
|
37
|
-
sys.exit(0)
|
|
38
|
-
|
|
39
|
-
tool_name = hook_input.get("tool_name", "")
|
|
40
|
-
if tool_name not in DELEGATION_TOOLS:
|
|
41
|
-
sys.exit(0)
|
|
42
|
-
|
|
43
|
-
clear_investigation_tracker()
|
|
44
|
-
sys.exit(0)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if __name__ == "__main__":
|
|
48
|
-
main()
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
"""Behavior tests for investigation tracker reset."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import importlib.util
|
|
6
|
-
import io
|
|
7
|
-
import json
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from unittest import mock
|
|
10
|
-
|
|
11
|
-
import pytest
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
HOOK_DIRECTORY = Path(__file__).parent
|
|
15
|
-
HOOK_SPEC = importlib.util.spec_from_file_location(
|
|
16
|
-
"investigation_tracker_reset",
|
|
17
|
-
HOOK_DIRECTORY / "investigation_tracker_reset.py",
|
|
18
|
-
)
|
|
19
|
-
assert HOOK_SPEC is not None
|
|
20
|
-
assert HOOK_SPEC.loader is not None
|
|
21
|
-
HOOK_MODULE = importlib.util.module_from_spec(HOOK_SPEC)
|
|
22
|
-
HOOK_SPEC.loader.exec_module(HOOK_MODULE)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _run_main(input_text: str) -> int | None:
|
|
26
|
-
with mock.patch("sys.stdin", io.StringIO(input_text)):
|
|
27
|
-
try:
|
|
28
|
-
HOOK_MODULE.main()
|
|
29
|
-
except SystemExit as system_exit:
|
|
30
|
-
if system_exit.code not in (None, 0):
|
|
31
|
-
raise
|
|
32
|
-
return system_exit.code if isinstance(system_exit.code, int) else None
|
|
33
|
-
return None
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@pytest.mark.parametrize("delegation_tool", ["Agent", "Task", "TeamCreate"])
|
|
37
|
-
def test_main_removes_tracker_after_delegation(
|
|
38
|
-
tmp_path: Path,
|
|
39
|
-
delegation_tool: str,
|
|
40
|
-
) -> None:
|
|
41
|
-
tracker_path = tmp_path / "investigation-tracker.json"
|
|
42
|
-
tracker_path.write_text("{}", encoding="utf-8")
|
|
43
|
-
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
44
|
-
|
|
45
|
-
exit_status = _run_main(json.dumps({"tool_name": delegation_tool}))
|
|
46
|
-
|
|
47
|
-
assert exit_status in (None, 0)
|
|
48
|
-
assert not tracker_path.exists()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
@pytest.mark.parametrize("unrelated_tool", ["Read", "Bash", "Grep"])
|
|
52
|
-
def test_main_keeps_tracker_for_unrelated_tool(
|
|
53
|
-
tmp_path: Path,
|
|
54
|
-
unrelated_tool: str,
|
|
55
|
-
) -> None:
|
|
56
|
-
tracker_path = tmp_path / "investigation-tracker.json"
|
|
57
|
-
tracker_path.write_text("{}", encoding="utf-8")
|
|
58
|
-
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
59
|
-
|
|
60
|
-
exit_status = _run_main(json.dumps({"tool_name": unrelated_tool}))
|
|
61
|
-
|
|
62
|
-
assert exit_status in (None, 0)
|
|
63
|
-
assert tracker_path.exists()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@pytest.mark.parametrize("invalid_input", ["not json", json.dumps([])])
|
|
67
|
-
def test_main_ignores_invalid_input(
|
|
68
|
-
tmp_path: Path,
|
|
69
|
-
invalid_input: str,
|
|
70
|
-
) -> None:
|
|
71
|
-
tracker_path = tmp_path / "investigation-tracker.json"
|
|
72
|
-
tracker_path.write_text("{}", encoding="utf-8")
|
|
73
|
-
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
74
|
-
|
|
75
|
-
exit_status = _run_main(invalid_input)
|
|
76
|
-
|
|
77
|
-
assert exit_status in (None, 0)
|
|
78
|
-
assert tracker_path.exists()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def test_main_keeps_running_when_tracker_removal_fails(tmp_path: Path) -> None:
|
|
82
|
-
tracker_path = tmp_path / "investigation-tracker.json"
|
|
83
|
-
tracker_path.write_text("{}", encoding="utf-8")
|
|
84
|
-
HOOK_MODULE.__dict__["TRACKER_STATE_PATH"] = str(tracker_path)
|
|
85
|
-
|
|
86
|
-
with mock.patch.object(HOOK_MODULE.os, "remove", side_effect=OSError("locked")):
|
|
87
|
-
exit_status = _run_main(json.dumps({"tool_name": "Agent"}))
|
|
88
|
-
|
|
89
|
-
assert exit_status in (None, 0)
|
|
90
|
-
assert tracker_path.exists()
|