claude-dev-env 8.2.0 → 8.3.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.
Files changed (54) hide show
  1. package/.agents/agents/poteto-agent.md +6 -0
  2. package/.agents/agents/test_agent_frontmatter.py +9 -9
  3. package/.agents/agents/test_pr_description_writer_style.py +1 -1
  4. package/.agents/agents-archived/ARCHIVE-MANIFEST.md +15 -0
  5. package/.agents/{agents → agents-archived}/code-quality-agent.md +1 -1
  6. package/.agents/{agents → agents-archived}/session-advisor.md +1 -1
  7. package/.agents/skills/_shared/end-of-run-gotcha-recommendations.md +2 -2
  8. package/.agents/skills/grok-spawn/SKILL.md +9 -6
  9. package/.agents/skills/grok-spawn/reference/flag-profiles.md +12 -8
  10. package/.agents/skills/issue-tracker/SKILL.md +2 -2
  11. package/.agents/skills/orchestrator/SKILL.md +16 -15
  12. package/.agents/skills/orchestrator-refresh/SKILL.md +2 -0
  13. package/.agents/skills/pull-request/SKILL.md +14 -5
  14. package/.agents/skills/pull-request/reference/publication-tasks.md +1 -1
  15. package/.agents/skills/team-advisor/SKILL.md +4 -4
  16. package/bin/install.agents-home.test.mjs +44 -94
  17. package/bin/install.mjs +31 -24
  18. package/bin/install.test.mjs +21 -0
  19. package/bin/resolve-package-managed-directory.test.mjs +1 -1
  20. package/bin/subagent-model-policy.test.mjs +15 -8
  21. package/docs/subagent-model-routing.md +6 -5
  22. package/hooks/blocking/tdd_enforcer.py +2 -3
  23. package/hooks/blocking/test_bash_post_call_dispatcher.py +7 -24
  24. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +0 -3
  25. package/hooks/hooks_constants/hosted_hook_runner.py +1 -1
  26. package/hooks/hooks_constants/msys_path_conversion_advisor_constants.py +3 -35
  27. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +2 -10
  28. package/hooks/routing/subagent_model_routing.mjs +15 -0
  29. package/hooks/routing/subagent_model_routing.test.mjs +58 -25
  30. package/package.json +1 -1
  31. package/rules/git-workflow.md +3 -3
  32. package/rules/shell-invocation.md +0 -2
  33. package/rules/subagent-model-policy.json +5 -13
  34. package/rules/testing.md +0 -2
  35. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +3 -3
  36. package/scripts/grok_headless_runner.py +19 -1
  37. package/scripts/grok_worker_preflight.py +67 -1
  38. package/scripts/resolve_advisor_model_route.mjs +2 -2
  39. package/scripts/subagent_model_policy.mjs +4 -1
  40. package/scripts/test_grok_headless_runner.py +35 -1
  41. package/scripts/test_grok_worker_preflight.py +73 -0
  42. package/scripts/tests/resolve_advisor_model_route.test.mjs +3 -3
  43. package/scripts/tests/test_rules.py +5 -5
  44. package/hooks/advisory/msys_path_conversion_advisor.py +0 -200
  45. package/hooks/advisory/test_msys_path_conversion_advisor.py +0 -223
  46. package/hooks/hooks_constants/test_failure_recorder_constants.py +0 -34
  47. package/hooks/observability/test_failure_recorder.py +0 -221
  48. package/hooks/observability/test_test_failure_recorder.py +0 -173
  49. /package/.agents/{agents → agents-archived}/clean-coder.md +0 -0
  50. /package/.agents/{agents → agents-archived}/git-commit-crafter.md +0 -0
  51. /package/.agents/{agents → agents-archived}/issue-tracker.md +0 -0
  52. /package/.agents/{agents → agents-archived}/plan-packet-validator.md +0 -0
  53. /package/.agents/{agents → agents-archived}/pr-description-writer.md +0 -0
  54. /package/.agents/{agents → agents-archived}/skill-writer-agent.md +0 -0
package/bin/install.mjs CHANGED
@@ -1135,6 +1135,7 @@ function renameCaseOnlyMatchToShippedName(destinationFilePath, entryNamesByDirec
1135
1135
 
1136
1136
  export function copyTree(sourceBase, destBase, options = {}) {
1137
1137
  const excludedFileNames = new Set(options.excludeFileNames ?? []);
1138
+ const preservedFileNames = new Set(options.preserveExistingFileNames ?? []);
1138
1139
  const files = collectFiles(sourceBase).filter(
1139
1140
  sourceFile => !excludedFileNames.has(basename(sourceFile)),
1140
1141
  );
@@ -1144,6 +1145,10 @@ export function copyTree(sourceBase, destBase, options = {}) {
1144
1145
  const relativePath = relative(sourceBase, sourceFile);
1145
1146
  const destFile = join(destBase, relativePath);
1146
1147
  mkdirSync(dirname(destFile), { recursive: true });
1148
+ if (preservedFileNames.has(basename(sourceFile)) && existsSync(destFile)) {
1149
+ stats.paths.push(destFile);
1150
+ continue;
1151
+ }
1147
1152
  const existed = existsSync(destFile);
1148
1153
  renameCaseOnlyMatchToShippedName(destFile, entryNamesByDirectory, options);
1149
1154
  copyFileSync(sourceFile, destFile);
@@ -1223,6 +1228,8 @@ export const POST_FOLDED_HOOK_RELATIVE_PATHS = new Set([
1223
1228
  ]);
1224
1229
 
1225
1230
  export const RETIRED_HOOK_REGISTRATION_RELATIVE_PATHS = new Set([
1231
+ 'observability/test_failure_recorder.py',
1232
+ 'advisory/msys_path_conversion_advisor.py',
1226
1233
  'session/untracked_repo_detector.py',
1227
1234
  'session/gh_pr_author_session_cleanup.py',
1228
1235
  'observability/pr_description_writer_spawn_tracker.py',
@@ -2326,29 +2333,6 @@ function pruneRetiredSkills(installedSkillNames, priorManifestSkills) {
2326
2333
  return movedDirectoryCount;
2327
2334
  }
2328
2335
 
2329
- /**
2330
- * Move every file a prior install wrote under a managed root that this run leaves
2331
- * unwritten into the run's backup root, one call per root.
2332
- *
2333
- * `copyTree` adds and overwrites but never removes, so every managed root carries
2334
- * the same drift the skills root does. One call per root hands the containment
2335
- * guard and the emptied-parent walk the root that owns each file, and each root's
2336
- * content lands under `<backupRoot>/<root-name>/<relative>`, so the recovery point
2337
- * mirrors ~/.claude.
2338
- *
2339
- * Nothing moves unless a prior install recorded it. A user-authored file, a
2340
- * runtime artifact, and a recorded path under no managed root — ~/.claude/CLAUDE.md,
2341
- * settings.json, the manifest itself, and ~/.mypy.ini outside the home — all sit
2342
- * outside every root's diff and stay where they are. ~/.claude/_shared and
2343
- * ~/.claude/skills/_shared are distinct absolute paths, so the `_shared` root call
2344
- * and the skills root call each see their own files and neither sees the other's.
2345
- *
2346
- * @param {string[]|null} priorInstalledFiles Files the prior manifest recorded, or null when unknown.
2347
- * @param {string[]} currentInstalledFiles Every file this run copied.
2348
- * @param {string} backupRoot The run's timestamped backup directory.
2349
- * @returns {{prunedCount: number, skillsPrunedCount: number, failedPaths: string[]}}
2350
- * The summed count, the skills root's own count, and every path whose move failed.
2351
- */
2352
2336
  function pruneStaleFilesAcrossManagedRoots(priorInstalledFiles, currentInstalledFiles, backupRoot) {
2353
2337
  let prunedCount = 0;
2354
2338
  let skillsPrunedCount = 0;
@@ -2366,6 +2350,27 @@ function pruneStaleFilesAcrossManagedRoots(priorInstalledFiles, currentInstalled
2366
2350
  skillsPrunedCount = rootOutcome.prunedCount;
2367
2351
  }
2368
2352
  }
2353
+ for (const rootName of [MANAGED_AGENTS_DIRECTORY_NAME, MANAGED_SKILLS_DIRECTORY_NAME]) {
2354
+ const lookupRoot = join(CLAUDE_HOME, rootName);
2355
+ const canonicalRoot = join(AGENTS_HOME, rootName);
2356
+ const canonicalCurrentFiles = currentInstalledFiles.map(filePath =>
2357
+ isInsideDirectory(filePath, lookupRoot)
2358
+ ? join(canonicalRoot, relative(lookupRoot, filePath))
2359
+ : filePath,
2360
+ );
2361
+ const rootOutcome = pruneStaleInstalledFiles(
2362
+ priorInstalledFiles,
2363
+ canonicalCurrentFiles,
2364
+ canonicalRoot,
2365
+ join(backupRoot, 'agents-home', rootName),
2366
+ { managedHomeDirectory: AGENTS_HOME },
2367
+ );
2368
+ prunedCount += rootOutcome.prunedCount;
2369
+ failedPaths.push(...rootOutcome.failedPaths);
2370
+ if (rootName === MANAGED_SKILLS_DIRECTORY_NAME) {
2371
+ skillsPrunedCount += rootOutcome.prunedCount;
2372
+ }
2373
+ }
2369
2374
  return { prunedCount, skillsPrunedCount, failedPaths };
2370
2375
  }
2371
2376
 
@@ -2655,7 +2660,9 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2655
2660
  );
2656
2661
  if (!existsSync(agentsSource)) continue;
2657
2662
  const agentsDestination = INSTALL_ROOT_RESOLUTION.agentsLookupDirectory;
2658
- const stats = copyTree(agentsSource, agentsDestination);
2663
+ const stats = copyTree(agentsSource, agentsDestination, {
2664
+ preserveExistingFileNames: ['poteto-agent.md'],
2665
+ });
2659
2666
  if (!summary[MANAGED_AGENTS_DIRECTORY_NAME]) {
2660
2667
  summary[MANAGED_AGENTS_DIRECTORY_NAME] = stats;
2661
2668
  } else {
@@ -1168,6 +1168,8 @@ test('FOLDED_HOOK_RELATIVE_PATHS contains folded hooks plus retired entries', ()
1168
1168
 
1169
1169
  test('retired hook registrations stay managed so reinstall removes them', () => {
1170
1170
  const retiredPaths = new Set([
1171
+ 'observability/test_failure_recorder.py',
1172
+ 'advisory/msys_path_conversion_advisor.py',
1171
1173
  'session/untracked_repo_detector.py',
1172
1174
  'session/gh_pr_author_session_cleanup.py',
1173
1175
  'observability/pr_description_writer_spawn_tracker.py',
@@ -2023,6 +2025,25 @@ test('copyTree copies AGENTS.md with agent definitions', () => {
2023
2025
  });
2024
2026
 
2025
2027
 
2028
+ test('copyTree preserves an existing file named in the preserve set', () => {
2029
+ const sourceRoot = mkdtempSync(join(tmpdir(), 'cdev-copy-preserve-source-'));
2030
+ const destinationRoot = mkdtempSync(join(tmpdir(), 'cdev-copy-preserve-destination-'));
2031
+ try {
2032
+ const sourceFilePath = join(sourceRoot, 'poteto-agent.md');
2033
+ const destinationFilePath = join(destinationRoot, 'poteto-agent.md');
2034
+ writeFileSync(sourceFilePath, 'package wrapper\n');
2035
+ writeFileSync(destinationFilePath, 'user wrapper\n');
2036
+
2037
+ copyTree(sourceRoot, destinationRoot, { preserveExistingFileNames: ['poteto-agent.md'] });
2038
+
2039
+ assert.equal(readFileSync(destinationFilePath, 'utf8'), 'user wrapper\n');
2040
+ } finally {
2041
+ rmSync(sourceRoot, { recursive: true, force: true });
2042
+ rmSync(destinationRoot, { recursive: true, force: true });
2043
+ }
2044
+ });
2045
+
2046
+
2026
2047
  const SHIPPED_README_NAME = 'README.md';
2027
2048
  const INSTALLED_README_NAME = 'Readme.md';
2028
2049
  const RETIRED_HOOK_RELATIVE_PATH = 'blocking/retired_gate.py';
@@ -26,7 +26,7 @@ import { resolvePackageManagedDirectory } from './resolve-package-managed-direct
26
26
 
27
27
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
28
28
  const CANONICAL_SKILL_NAME = 'privacy-hygiene';
29
- const CANONICAL_AGENT_FILE_NAME = 'clean-coder.md';
29
+ const CANONICAL_AGENT_FILE_NAME = 'AGENTS.md';
30
30
 
31
31
  test('prefers the .agents tree when both layouts exist', () => {
32
32
  const sourceRoot = mkdtempSync(join(tmpdir(), 'cdev-src-agents-'));
@@ -25,7 +25,7 @@ function advisorRoute(request, options = {}) {
25
25
  return route(request, {
26
26
  trustedSessionMetadata: {
27
27
  authorized: true,
28
- registeredAgentType: 'session-advisor',
28
+ registeredAgentType: 'team-advisor',
29
29
  },
30
30
  ...options,
31
31
  });
@@ -55,6 +55,13 @@ test('the shipped policy parses and names native model ids', () => {
55
55
  assert.deepEqual(policy.advisorDefault, { model: 'astra', effort: 'medium' });
56
56
  });
57
57
 
58
+ test('poteto plugin aliases use worker routing without advisor privileges', () => {
59
+ for (const agent_type of ['poteto-agent', 'pstack:poteto-agent']) {
60
+ const result = route({ agent_type, model: 'Astra', reasoning_effort: 'high' });
61
+ assert.deepEqual(result.selected, { model: 'gpt-6-astra', effort: 'low' });
62
+ }
63
+ });
64
+
58
65
  test('every approved pair returns its literal native pair', () => {
59
66
  const expected = [
60
67
  [['Luna', 'high'], ['gpt-5.6-luna', 'high']],
@@ -74,7 +81,7 @@ test('every approved pair returns its literal native pair', () => {
74
81
  const advisor = advisorRoute({
75
82
  model: 'Astra',
76
83
  reasoning_effort: 'high',
77
- agent_type: 'session-advisor',
84
+ agent_type: 'team-advisor',
78
85
  });
79
86
  assert.equal(advisor.status, 'remapped');
80
87
  assert.deepEqual(advisor.selected, { model: 'gpt-6-astra', effort: 'high' });
@@ -105,7 +112,7 @@ test('every automatic replacement returns its literal destination', () => {
105
112
 
106
113
  test('advisor and worker Astra restrictions use role-specific replacements', () => {
107
114
  assert.deepEqual(
108
- advisorRoute({ model: 'Astra', reasoning_effort: 'xhigh', agent_type: 'session-advisor' }).selected,
115
+ advisorRoute({ model: 'Astra', reasoning_effort: 'xhigh', agent_type: 'team-advisor' }).selected,
109
116
  { model: 'gpt-6-astra', effort: 'medium' },
110
117
  );
111
118
  assert.deepEqual(
@@ -113,7 +120,7 @@ test('advisor and worker Astra restrictions use role-specific replacements', ()
113
120
  { model: 'gpt-6-astra', effort: 'low' },
114
121
  );
115
122
  assert.deepEqual(
116
- advisorRoute({ model: 'Astra', reasoning_effort: 'high', agent_type: 'session-advisor' }).selected,
123
+ advisorRoute({ model: 'Astra', reasoning_effort: 'high', agent_type: 'team-advisor' }).selected,
117
124
  { model: 'gpt-6-astra', effort: 'high' },
118
125
  );
119
126
  });
@@ -122,7 +129,7 @@ test('registered advisor roles satisfy advisory authorization', () => {
122
129
  const result = advisorRoute({
123
130
  model: 'Astra',
124
131
  reasoning_effort: 'high',
125
- agent_type: 'session-advisor',
132
+ agent_type: 'team-advisor',
126
133
  });
127
134
 
128
135
  assert.deepEqual(result.selected, { model: 'gpt-6-astra', effort: 'high' });
@@ -135,7 +142,7 @@ test('a policy from the first stack revision remains routable', () => {
135
142
  {
136
143
  model: 'Astra',
137
144
  reasoning_effort: 'high',
138
- agent_type: 'session-advisor',
145
+ agent_type: 'team-advisor',
139
146
  },
140
147
  { policy: legacyPolicy },
141
148
  );
@@ -146,7 +153,7 @@ test('an advisor role claim cannot grant advisory authorization', () => {
146
153
  const result = route({
147
154
  model: 'Astra',
148
155
  reasoning_effort: 'high',
149
- agent_type: 'session-advisor',
156
+ agent_type: 'team-advisor',
150
157
  });
151
158
  assert.equal(result.status, 'blocked');
152
159
  assert.equal(result.diagnostic, 'advisor role is not trusted');
@@ -158,7 +165,7 @@ test('Astra Light resolves to Astra Low', () => {
158
165
  });
159
166
 
160
167
  test('missing advisor settings use the policy default', () => {
161
- const result = advisorRoute({ agent_type: 'session-advisor' });
168
+ const result = advisorRoute({ agent_type: 'team-advisor' });
162
169
  assert.deepEqual(result.selected, { model: 'gpt-6-astra', effort: 'medium' });
163
170
  });
164
171
 
@@ -53,15 +53,16 @@ The matching replacement is:
53
53
  }
54
54
  ```
55
55
 
56
- Unknown values, invalid policy data, and unavailable replacement models stop the
57
- spawn with a short diagnostic. Parent inheritance stays unchanged.
56
+ Unknown values, invalid policy data, unavailable replacement models, and
57
+ unresolved parent inheritance stop the spawn with a short diagnostic.
58
58
 
59
59
  The direct spawn hook and advisor bridge use the same shared resolver. A Sol
60
60
  Medium remap changes only the model and effort fields in the hook's tool input.
61
61
  The hook returns an allow decision with the updated input and emits no remap
62
- notice. Luna High, Xhigh, and Max and Astra Low and Medium keep their routes.
63
- Trusted advisors also keep Astra High. A worker request for Sol without an
64
- effort stops because Sol has no approved pair to supply a default effort.
62
+ notice when the selected model matches `gpt-*-luna`. Any route that resolves to
63
+ another model is denied unless the tool input carries the explicit
64
+ `flags: ["--advisor"]` bypass. Parent inheritance is denied because the hook
65
+ cannot verify the inherited model before the spawn.
65
66
 
66
67
  The hook blocks advisor role claims because the current Codex hook input has no
67
68
  host-provided role binding. The Python advisor bridge passes trusted session
@@ -4,9 +4,8 @@ Blocks a write to a production source file when no matching test was modified
4
4
  within the freshness window, enforcing "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
5
5
  The gate records each candidate test's content hash at every sighting and
6
6
  compares that hash on the next write. A first sighting requires content that
7
- differs from HEAD. A recorded failing pytest run, written by
8
- ``hooks/observability/test_failure_recorder.py``, is read before that fallback
9
- and applies while the candidate's content still matches what failed.
7
+ differs from HEAD. A stored failing pytest run is read before that fallback
8
+ and applies while the candidate's content still matches the stored hash.
10
9
  Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
11
10
  into one PreToolUse gate and re-exports their surface for the test suite.
12
11
  """
@@ -2,8 +2,7 @@
2
2
 
3
3
  One interpreter start runs every hosted PostToolUse hook a Bash call fires.
4
4
  The unit tests pin selection and call order with a fake
5
- runner; the end-to-end test drives the real hosted hooks through a real
6
- payload and confirms the recorder's own real side effect lands.
5
+ runner; the end-to-end test checks that an ordinary Bash call stays silent.
7
6
  ``hooks/conftest.py`` already puts the hooks and blocking directories on
8
7
  sys.path for test collection, so this file needs no bootstrap of its own.
9
8
  """
@@ -23,12 +22,9 @@ from bash_post_call_dispatcher import (
23
22
  )
24
23
  from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
25
24
  from hooks_constants.hosted_hook_runner import HostedHookRun
26
- from tdd_enforcer_parts import content_hash_store
27
25
 
28
26
  _ALL_EXPECTED_ROSTER_PATHS = [
29
- "observability/test_failure_recorder.py",
30
27
  "advisory/pr_done_reminder.py",
31
- "advisory/msys_path_conversion_advisor.py",
32
28
  ]
33
29
 
34
30
 
@@ -63,11 +59,9 @@ def test_dispatch_forwards_hosted_hook_additional_context_as_one_payload(
63
59
  monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
64
60
  ) -> None:
65
61
  context_by_hook = {
66
- "test_failure_recorder.py": "",
67
62
  "pr_done_reminder.py": json.dumps(
68
63
  {"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "PR #42"}}
69
64
  ),
70
- "msys_path_conversion_advisor.py": "",
71
65
  }
72
66
 
73
67
  def _fake_run_hook(script_path: str, payload_text: str) -> HostedHookRun:
@@ -124,29 +118,18 @@ def test_main_exits_zero_and_writes_nothing_to_stdout(
124
118
  assert capsys.readouterr().out == ""
125
119
 
126
120
 
127
- def test_end_to_end_real_hosted_hooks_record_a_real_pytest_failure(tmp_path: Path) -> None:
128
- """Drive the real recorder through the dispatcher."""
129
- test_file = tmp_path / "test_orders.py"
130
- test_file.write_text("def test_fulfill(): assert False\n")
131
- session_id = "post-dispatcher-session"
132
- failing_output = (
133
- "Error: Exit code 1\n"
134
- "FAILED test_orders.py::test_fulfill - AssertionError\n"
135
- "1 failed in 0.02s"
136
- )
121
+ def test_end_to_end_hosted_hooks_leave_an_ordinary_call_silent(
122
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
123
+ ) -> None:
137
124
  payload = json.dumps(
138
125
  {
139
- "session_id": session_id,
140
126
  "cwd": str(tmp_path),
141
127
  "tool_name": "Bash",
142
- "tool_input": {"command": "pytest test_orders.py"},
143
- "tool_response": failing_output,
128
+ "tool_input": {"command": "git status"},
129
+ "tool_response": {"stdout": "clean", "stderr": ""},
144
130
  }
145
131
  )
146
132
 
147
133
  dispatch(payload, BASH_TOOL_NAME)
148
134
 
149
- state_file = content_hash_store._state_file_path(session_id, str(tmp_path))
150
- state = json.loads(state_file.read_text())
151
- entry = state[content_hash_store._state_key_for(test_file)]
152
- assert entry[content_hash_store.STORED_FAILURE_EXIT_STATUS_KEY] == 1
135
+ assert capsys.readouterr().out == ""
@@ -15,7 +15,6 @@ from __future__ import annotations
15
15
 
16
16
  from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
17
17
  ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
18
- ALL_BASH_ONLY_TOOL_NAMES,
19
18
  BashHostedHookEntry,
20
19
  )
21
20
 
@@ -30,9 +29,7 @@ __all__ = [
30
29
  ]
31
30
 
32
31
  ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES: tuple[BashHostedHookEntry, ...] = (
33
- BashHostedHookEntry("observability/test_failure_recorder.py", ALL_BASH_ONLY_TOOL_NAMES),
34
32
  BashHostedHookEntry("advisory/pr_done_reminder.py", ALL_BASH_AND_POWERSHELL_TOOL_NAMES),
35
- BashHostedHookEntry("advisory/msys_path_conversion_advisor.py", ALL_BASH_ONLY_TOOL_NAMES),
36
33
  )
37
34
 
38
35
  EXIT_CODE_ERROR_PREFIX: str = "Error: Exit code "
@@ -89,7 +89,7 @@ def resolved_hook_script_path(relative_path: str) -> str:
89
89
 
90
90
  ::
91
91
 
92
- "observability/test_failure_recorder.py" -> <hooks>/observability/...
92
+ "advisory/pr_done_reminder.py" -> <hooks>/advisory/pr_done_reminder.py
93
93
 
94
94
  The root comes from this module's own location, so every dispatcher
95
95
  resolves against the same directory however deep it sits.
@@ -1,48 +1,16 @@
1
- """Constants for the MSYS path-conversion context advisory.
2
-
3
- The advisory is a PostToolUse observer on Bash. It never blocks. When a git
4
- call fails because Git Bash rewrote a ``<rev>:<path>`` argument before git saw
5
- it, the advisory adds one loud note naming the two exports that turn the
6
- rewriting off.
7
- """
1
+ """Shared environment variable names for the MSYS path-conversion rewriter."""
8
2
 
9
3
  from __future__ import annotations
10
4
 
11
5
  __all__ = [
12
- "ALL_MANGLED_ARGUMENT_MARKERS",
13
- "MANGLED_ARGUMENT_CLOSING_QUOTE",
14
- "ALL_MSYS_MANGLING_CHARACTERS",
15
6
  "ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES",
16
- "ADVISORY_HEADER",
17
- "MANGLED_ARGUMENT_LINE_TEMPLATE",
18
- "MANGLING_EXPLANATION_LINE",
19
- "FIX_LINE",
20
- "SAME_COMMAND_LINE",
21
- "ADVISORY_LINE_SEPARATOR",
7
+ "MSYS_PATH_CONVERSION_VARIABLE_NAME",
8
+ "MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME",
22
9
  ]
23
10
 
24
- AMBIGUOUS_ARGUMENT_MARKER: str = "fatal: ambiguous argument '"
25
- INVALID_OBJECT_NAME_MARKER: str = "fatal: invalid object name '"
26
- ALL_MANGLED_ARGUMENT_MARKERS: tuple[str, ...] = (
27
- AMBIGUOUS_ARGUMENT_MARKER,
28
- INVALID_OBJECT_NAME_MARKER,
29
- )
30
- MANGLED_ARGUMENT_CLOSING_QUOTE: str = "'"
31
- ALL_MSYS_MANGLING_CHARACTERS: frozenset[str] = frozenset({"\\", ";"})
32
11
  MSYS_PATH_CONVERSION_VARIABLE_NAME: str = "MSYS_NO_PATHCONV"
33
12
  MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME: str = "MSYS2_ARG_CONV_EXCL"
34
13
  ALL_MSYS_PATH_CONVERSION_VARIABLE_NAMES: tuple[str, ...] = (
35
14
  MSYS_PATH_CONVERSION_VARIABLE_NAME,
36
15
  MSYS_ARGUMENT_CONVERSION_EXCLUSION_VARIABLE_NAME,
37
16
  )
38
- MSYS_EXPORT_FIX_LINE: str = "export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'"
39
-
40
- ADVISORY_HEADER: str = "=== MSYS PATH CONVERSION (context reminder, never a block) ==="
41
- MANGLED_ARGUMENT_LINE_TEMPLATE: str = "Git saw: {argument}"
42
- MANGLING_EXPLANATION_LINE: str = (
43
- "Git Bash rewrote a <rev>:<path> argument before git saw it, turning the "
44
- "colon into a semicolon and the slashes into backslashes."
45
- )
46
- FIX_LINE: str = "Fix: " + MSYS_EXPORT_FIX_LINE
47
- SAME_COMMAND_LINE: str = "Put that export and the git command in the same command."
48
- ADVISORY_LINE_SEPARATOR: str = "\n"
@@ -7,29 +7,21 @@ from hooks_constants.bash_post_call_dispatcher_constants import (
7
7
  from hooks_constants.bash_pre_tool_use_dispatcher_constants import BASH_TOOL_NAME
8
8
 
9
9
 
10
- def test_roster_runs_the_recorder_then_the_pr_reminder_then_the_msys_advisor() -> None:
10
+ def test_roster_runs_the_pr_reminder() -> None:
11
11
  all_script_paths = [
12
12
  each_entry.script_relative_path for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
13
13
  ]
14
14
  assert all_script_paths == [
15
- "observability/test_failure_recorder.py",
16
15
  "advisory/pr_done_reminder.py",
17
- "advisory/msys_path_conversion_advisor.py",
18
16
  ]
19
17
 
20
18
 
21
19
  def test_pr_done_reminder_also_serves_the_powershell_tool() -> None:
22
- reminder_entry = ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES[1]
20
+ reminder_entry = ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES[0]
23
21
  assert reminder_entry.script_relative_path == "advisory/pr_done_reminder.py"
24
22
  assert "PowerShell" in reminder_entry.applicable_tool_names
25
23
 
26
24
 
27
- def test_msys_advisor_serves_the_bash_tool_only() -> None:
28
- advisor_entry = ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES[-1]
29
- assert advisor_entry.script_relative_path == "advisory/msys_path_conversion_advisor.py"
30
- assert advisor_entry.applicable_tool_names == frozenset({"Bash"})
31
-
32
-
33
25
  def test_context_forwarding_keys_match_the_hook_output_contract() -> None:
34
26
  assert constants.POST_TOOL_USE_HOOK_EVENT_NAME == "PostToolUse"
35
27
  assert constants.HOOK_SPECIFIC_OUTPUT_KEY == "hookSpecificOutput"
@@ -16,6 +16,13 @@ export function buildSubagentModelRoutingResponse(hookPayload, routingOptions =
16
16
  if (routingDecision.status === 'blocked') {
17
17
  return buildBlockedResponse(routingDecision.diagnostic);
18
18
  }
19
+ if (routingDecision.status === 'inherited') {
20
+ return buildBlockedResponse('subagent model must resolve to gpt-*-luna');
21
+ }
22
+ if (!hasAdvisorBypass(hookPayload.tool_input)
23
+ && !isLunaModel(routingDecision.selected?.model)) {
24
+ return buildBlockedResponse('only gpt-*-luna subagents are permitted');
25
+ }
19
26
  if (routingDecision.status !== 'remapped') return {};
20
27
 
21
28
  return buildAllowResponse(routingDecision.updatedInput);
@@ -52,6 +59,14 @@ function isTargetSubagentTool(hookPayload) {
52
59
  return hookPayload.tool_name === targetToolName;
53
60
  }
54
61
 
62
+ function isLunaModel(model) {
63
+ return typeof model === 'string' && /^gpt-.+-luna$/i.test(model);
64
+ }
65
+
66
+ function hasAdvisorBypass(toolInput) {
67
+ return Array.isArray(toolInput?.flags) && toolInput.flags.includes('--advisor');
68
+ }
69
+
55
70
  function buildAllowResponse(updatedInput) {
56
71
  return {
57
72
  hookSpecificOutput: {
@@ -122,32 +122,65 @@ test('blocks malformed policy, unknown input, unavailable replacement, and untru
122
122
  }
123
123
  });
124
124
 
125
- for (const [eachModel, eachEffort, eachSelectedModel, eachSelectedEffort] of [
126
- ['Sol', 'medium', 'gpt-5.6-luna', 'max'],
127
- ['Astra', 'high', 'gpt-6-astra', 'high'],
128
- ]) {
129
- test(`allows a trusted session advisor requesting ${eachModel} ${eachEffort}`, () => {
130
- const hookResponse = buildSubagentModelRoutingResponse(
131
- buildHookPayload({
132
- agent_type: 'session-advisor',
133
- model: eachModel,
134
- reasoning_effort: eachEffort,
135
- }),
136
- {
137
- trustedSessionMetadata: {
138
- authorized: true,
139
- registeredAgentType: 'session-advisor',
140
- },
125
+ test('blocks a trusted session advisor route that resolves to Astra', () => {
126
+ const hookResponse = buildSubagentModelRoutingResponse(
127
+ buildHookPayload({
128
+ agent_type: 'team-advisor',
129
+ model: 'Astra',
130
+ reasoning_effort: 'high',
131
+ }),
132
+ {
133
+ trustedSessionMetadata: {
134
+ authorized: true,
135
+ registeredAgentType: 'team-advisor',
141
136
  },
142
- );
137
+ },
138
+ );
143
139
 
144
- assert.deepEqual(hookResponse.hookSpecificOutput.updatedInput, {
145
- agent_type: 'session-advisor',
146
- model: eachSelectedModel,
147
- reasoning_effort: eachSelectedEffort,
148
- });
140
+ assert.equal(hookResponse.hookSpecificOutput.permissionDecision, 'deny');
141
+ assert.equal(
142
+ hookResponse.hookSpecificOutput.permissionDecisionReason,
143
+ 'only gpt-*-luna subagents are permitted',
144
+ );
145
+ });
146
+
147
+ test('allows an Astra route when the explicit advisor flag is present', () => {
148
+ const toolInput = {
149
+ model: 'Astra',
150
+ reasoning_effort: 'high',
151
+ flags: ['--advisor'],
152
+ };
153
+ const hookResponse = buildSubagentModelRoutingResponse(buildHookPayload(toolInput));
154
+ assert.deepEqual(hookResponse.hookSpecificOutput.updatedInput, {
155
+ model: 'gpt-6-astra',
156
+ reasoning_effort: 'low',
157
+ flags: ['--advisor'],
149
158
  });
150
- }
159
+ });
160
+
161
+ test('does not treat unrelated flags as an advisor bypass', () => {
162
+ const hookResponse = buildSubagentModelRoutingResponse(buildHookPayload({
163
+ model: 'Astra',
164
+ reasoning_effort: 'high',
165
+ flags: ['--worker'],
166
+ }));
167
+ assert.equal(hookResponse.hookSpecificOutput.permissionDecision, 'deny');
168
+ assert.equal(
169
+ hookResponse.hookSpecificOutput.permissionDecisionReason,
170
+ 'only gpt-*-luna subagents are permitted',
171
+ );
172
+ });
173
+
174
+ test('blocks parent inheritance because the selected model is unresolved', () => {
175
+ const hookResponse = buildSubagentModelRoutingResponse(buildHookPayload({
176
+ model: 'inherit-parent',
177
+ }));
178
+ assert.equal(hookResponse.hookSpecificOutput.permissionDecision, 'deny');
179
+ assert.equal(
180
+ hookResponse.hookSpecificOutput.permissionDecisionReason,
181
+ 'subagent model must resolve to gpt-*-luna',
182
+ );
183
+ });
151
184
 
152
185
  test('the standalone hook silently remaps native Sol Medium and preserves other fields', () => {
153
186
  const hookExecution = spawnSync(
@@ -183,7 +216,7 @@ test('the standalone hook silently remaps native Sol Medium and preserves other
183
216
 
184
217
  test('a session advisor name without trusted session metadata is blocked', () => {
185
218
  const hookResponse = buildSubagentModelRoutingResponse(buildHookPayload({
186
- agent_type: 'session-advisor',
219
+ agent_type: 'team-advisor',
187
220
  model: 'Astra',
188
221
  reasoning_effort: 'high',
189
222
  }));
@@ -222,7 +255,7 @@ test('the standalone hook command blocks an advisor without host metadata', () =
222
255
  {
223
256
  encoding: 'utf8',
224
257
  input: JSON.stringify(buildHookPayload({
225
- agent_type: 'session-advisor',
258
+ agent_type: 'team-advisor',
226
259
  model: 'Astra',
227
260
  reasoning_effort: 'high',
228
261
  })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.2.0",
3
+ "version": "8.3.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,12 +25,12 @@ the next run. The merge stays in the repository. No tag is cut and the package n
25
25
 
26
26
  Spot one by its head branch, which starts `release-please--branches--`, or by a body that
27
27
  opens with the bot's own marker line. The description rules in this file, the
28
- `pr-description-writer` agent, and the house wording style all step aside for it. The
28
+ `pstack:poteto-agent` writing brief, and the house wording style all step aside for it. The
29
29
  failure signature in the release job log reads
30
30
  `could not parse pull request body as a release PR`.
31
31
 
32
- The `pr-description-writer` agent writes a title and body from the diff when
33
- you want one. Publish the title and body file through
32
+ `pstack:poteto-agent` writes a title and body from the diff when you want one.
33
+ Publish the title and body file through
34
34
  `~/.agents/skills/pull-request/scripts/pull_request.py`. That path is under the
35
35
  agents home, not the repository. A worktree holds no `.agents/` copy.
36
36
 
@@ -33,5 +33,3 @@ export MSYS2_ARG_CONV_EXCL='origin/main:'; git show origin/main:.claude/settings
33
33
  `MSYS2_ARG_CONV_EXCL` takes a semicolon-separated list of argument prefixes. Naming one prefix per detected token leaves every other argument in the command converting as before. The blanket pair `MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'` turns conversion off for the whole command, which changes a path the command meant to convert, so the rewriter does not emit it.
34
34
 
35
35
  A quoted token is not a revision. `git commit -m "fix a/b:.py"` carries a slash and a dot in one token, and a rewrite there would break the message, so the detection skips a token holding whitespace or a quote.
36
-
37
- `advisory/msys_path_conversion_advisor.py` (PostToolUse on Bash, hosted by `bash_post_call_dispatcher`) stays behind it. It reads a failed git call, looks for the mark MSYS leaves, and names the fix. It covers a shape the rewriter misses.
@@ -29,25 +29,17 @@
29
29
  "default": "worker",
30
30
  "worker": [
31
31
  "worker",
32
+ "poteto-agent",
33
+ "pstack:poteto-agent",
32
34
  "default",
33
35
  "explorer",
34
36
  "caveman",
35
- "clean-coder",
36
- "code-quality-agent",
37
37
  "code-verifier",
38
38
  "deep-research",
39
- "docs-agent",
40
- "git-commit-crafter",
41
- "issue-tracker",
42
- "plan-packet-validator",
43
- "pr-description-writer",
44
- "skill-writer-agent",
45
- "theme-prompt-agent",
46
- "theme-prompt-generator",
47
- "translator"
39
+ "docs-agent"
48
40
  ],
49
- "advisor": ["advisor", "session-advisor", "team-advisor", "code-advisor"],
50
- "trustedAdvisor": ["session-advisor", "team-advisor", "code-advisor"]
41
+ "advisor": ["advisor", "team-advisor", "code-advisor"],
42
+ "trustedAdvisor": ["advisor", "team-advisor", "code-advisor"]
51
43
  },
52
44
  "approvedPairs": [
53
45
  {"model": "luna", "effort": "high"},