claude-dev-env 2.17.0 → 2.19.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 (117) hide show
  1. package/.agents/skills/AGENTS.md +4 -4
  2. package/.agents/skills/_shared/AGENTS.md +1 -0
  3. package/.agents/skills/_shared/pr-loop/AGENTS.md +1 -0
  4. package/.agents/skills/_shared/pr-loop/preflight-proposal.contract.test.mjs +89 -0
  5. package/.agents/skills/_shared/pr-loop/preflight-proposal.md +5 -0
  6. package/.agents/skills/orchestrator/SKILL.md +6 -4
  7. package/.agents/skills/orchestrator-refresh/SKILL.md +9 -5
  8. package/.agents/skills/pr-cleanup/SKILL.md +17 -85
  9. package/.agents/skills/pr-cleanup/reference/process-inventory.md +15 -0
  10. package/.agents/skills/pr-cleanup/reference/task-seeds.md +20 -0
  11. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/SKILL.md +18 -12
  12. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/offense-examples.md +1 -1
  13. package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/task-seeds.md +3 -2
  14. package/.agents/skills/pr-refinement/SKILL.md +31 -0
  15. package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/SKILL.md +24 -7
  16. package/.agents/skills/{small-cl → pr-small-cl}/SKILL.md +1 -1
  17. package/.agents/skills/prototype/scripts/test_probe_sandbox_safety.py +33 -5
  18. package/.agents/skills/skill-builder/SKILL.md +1 -0
  19. package/.agents/skills/team-advisor/SKILL.md +5 -5
  20. package/.agents/skills-archived/bugteam/SKILL.md +1 -1
  21. package/.agents/skills-archived/bugteam/test_skill_additions.py +1 -1
  22. package/.agents/skills-archived/comments/SKILL.md +1 -1
  23. package/.agents/skills-archived/descriptions/SKILL.md +1 -1
  24. package/.agents/skills-archived/reviews/SKILL.md +1 -1
  25. package/AGENTS.md +1 -1
  26. package/_shared/advisor/AGENTS.md +5 -5
  27. package/_shared/advisor/advisor-protocol.md +54 -31
  28. package/_shared/advisor/reference/advisor-block.md +5 -1
  29. package/_shared/advisor/reference/consult-format.md +1 -1
  30. package/_shared/advisor/reference/identity.md +28 -0
  31. package/_shared/advisor/reference/lifecycle.md +8 -1
  32. package/_shared/advisor/reference/sol-rung.md +12 -7
  33. package/_shared/advisor/reference/spawn-walk-log.md +6 -5
  34. package/_shared/advisor/reference/third-party-bind.md +5 -6
  35. package/_shared/advisor/reference/warm-up.md +9 -2
  36. package/_shared/advisor/scripts/codex_sol_advisor.py +66 -11
  37. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +9 -0
  38. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -4
  39. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +11 -10
  40. package/_shared/advisor/scripts/model_tier_run_validator.py +50 -23
  41. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +108 -3
  42. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +171 -82
  43. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +43 -0
  44. package/_shared/advisor/scripts/tier_model_ids.py +75 -7
  45. package/_shared/pr-loop/AGENTS.md +2 -1
  46. package/_shared/pr-loop/preflight-proposal.md +56 -0
  47. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +47 -4
  48. package/_shared/pr-loop/worker-spawn.md +4 -3
  49. package/bin/ever-shipped-skills.mjs +14 -0
  50. package/bin/install-constants.mjs +5 -0
  51. package/bin/install.agents-home.test.mjs +23 -0
  52. package/bin/install.codex-rules.test.mjs +61 -6
  53. package/bin/install.mjs +57 -9
  54. package/bin/install.prune.test.mjs +65 -3
  55. package/bin/install.test.mjs +202 -2
  56. package/docs/CODE_RULES.md +4 -0
  57. package/docs/references/team-advisor-skill.md +2 -2
  58. package/hooks/advisory/migration_safety_advisor.py +14 -14
  59. package/hooks/advisory/test_migration_safety_advisor.py +120 -0
  60. package/hooks/atomic_file_writer.py +81 -0
  61. package/hooks/blocking/AGENTS.md +2 -1
  62. package/hooks/blocking/code_rules_banned_identifiers.py +55 -4
  63. package/hooks/blocking/code_rules_enforcer_test_support.py +92 -0
  64. package/hooks/blocking/destructive_command_blocker.py +8 -7
  65. package/hooks/blocking/pre_tool_use_dispatcher.py +11 -7
  66. package/hooks/blocking/precommit_code_rules_gate.py +14 -139
  67. package/hooks/blocking/sensitive_file_protector.py +8 -6
  68. package/hooks/blocking/session_edit_stage_gate.py +31 -1
  69. package/hooks/blocking/test_claude_md_orphan_file_blocker.py +171 -1
  70. package/hooks/blocking/test_code_rules_enforcer_agent_home_tooling.py +37 -12
  71. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +54 -0
  72. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +15 -53
  73. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +13 -62
  74. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +308 -0
  75. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +14 -71
  76. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +14 -16
  77. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +31 -46
  78. package/hooks/blocking/test_code_rules_enforcer_stage.py +192 -0
  79. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +13 -59
  80. package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +42 -25
  81. package/hooks/blocking/test_destructive_command_blocker_no_verify.py +58 -38
  82. package/hooks/blocking/test_docstring_rule_gate_count_blocker.py +113 -1
  83. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +70 -3
  84. package/hooks/blocking/test_hook_subprocess_support.py +296 -0
  85. package/hooks/blocking/test_package_inventory_stale_blocker.py +1 -1
  86. package/hooks/blocking/test_pii_scanner.py +81 -0
  87. package/hooks/blocking/test_pre_tool_use_dispatcher.py +32 -3
  88. package/hooks/blocking/test_precommit_code_rules_gate.py +11 -16
  89. package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +168 -0
  90. package/hooks/blocking/test_pytest_testpaths_orphan_blocker.py +25 -18
  91. package/hooks/blocking/test_sensitive_file_protector.py +145 -5
  92. package/hooks/blocking/test_session_edit_stage_gate_staging.py +64 -0
  93. package/hooks/blocking/test_session_edit_stage_gate_support.py +204 -0
  94. package/hooks/blocking/test_session_edit_stage_gate_tracker.py +116 -0
  95. package/hooks/blocking/test_shared_stdin_adoption.py +31 -20
  96. package/hooks/blocking/test_test_preflight_check.py +80 -0
  97. package/hooks/git-hooks/pre_commit.py +6 -2
  98. package/hooks/git-hooks/test_pre_push.py +57 -0
  99. package/hooks/hooks_constants/AGENTS.md +3 -1
  100. package/hooks/hooks_constants/atomic_file_writer_constants.py +4 -0
  101. package/hooks/hooks_constants/banned_identifiers_constants.py +1 -0
  102. package/hooks/hooks_constants/destructive_command_environment_constants.py +18 -0
  103. package/hooks/hooks_constants/destructive_command_segment_constants.py +1 -11
  104. package/hooks/hooks_constants/hardcoded_user_path_constants.py +9 -3
  105. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +1 -1
  106. package/hooks/hooks_constants/sensitive_file_protector_constants.py +2 -4
  107. package/hooks/json_file_reader.py +22 -0
  108. package/hooks/pending_sidecars.py +33 -0
  109. package/hooks/test_atomic_file_writer.py +73 -0
  110. package/hooks/test_json_file_reader.py +24 -0
  111. package/hooks/test_pending_sidecars.py +25 -0
  112. package/package.json +1 -1
  113. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/fetch-commands.md +0 -0
  114. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/report-template.md +0 -0
  115. /package/.agents/skills/{name-by-capability-audit → pr-name-by-capability}/reference/rule-checklist.md +0 -0
  116. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/examples.md +0 -0
  117. /package/.agents/skills/{shared-extraction-audit → pr-shared-extraction}/reference/offense-taxonomy.md +0 -0
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
- import { execFileSync } from 'node:child_process';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
4
  import {
5
5
  mkdtempSync,
6
6
  rmSync,
@@ -27,6 +27,7 @@ import {
27
27
  pythonCandidatesForPlatform,
28
28
  isWindowsStorePythonStub,
29
29
  interpreterCommandFromPath,
30
+ commandArgumentFromPath,
30
31
  invokedAsEntryPoint,
31
32
  managedHookScriptRelativePaths,
32
33
  managedHookScriptRelativePathsFromSourceRoots,
@@ -504,7 +505,9 @@ test('mergeHooksIntoSettings inserts dollar characters in plugin root and interp
504
505
  const rewrittenCommand = settings.hooks.SessionStart[0].hooks[0].command;
505
506
  assert.equal(
506
507
  rewrittenCommand,
507
- 'C:/Users/$&evil$1/Python/python.exe C:/Users/$&evil$1/.claude/hooks/session/session_env_cleanup.py',
508
+ process.platform === 'win32'
509
+ ? 'C:/Users/$&evil$1/Python/python.exe "C:/Users/$&evil$1/.claude/hooks/session/session_env_cleanup.py"'
510
+ : "C:/Users/$&evil$1/Python/python.exe 'C:/Users/$&evil$1/.claude/hooks/session/session_env_cleanup.py'",
508
511
  );
509
512
  assert.equal(rewrittenCommand.includes('${CLAUDE_PLUGIN_ROOT}'), false);
510
513
  });
@@ -530,6 +533,191 @@ test('mergeHooksIntoSettings substitutes a quoted absolute interpreter path for
530
533
  });
531
534
 
532
535
 
536
+ test('mergeHooksIntoSettings quotes standalone plugin-root hook paths and preserves existing quotes', () => {
537
+ const hooksConfig = {
538
+ hooks: {
539
+ PreToolUse: [{
540
+ matcher: 'Write|Edit|MultiEdit',
541
+ hooks: [
542
+ { type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py' },
543
+ { type: 'command', command: 'python3 "${CLAUDE_PLUGIN_ROOT}/hooks/blocking/pre_tool_use_dispatcher.py"' },
544
+ { type: 'command', command: 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\')"' },
545
+ { type: 'command', command: 'python3 -c "import sys; sys.path.insert(0, r\"${CLAUDE_PLUGIN_ROOT}/hooks\")"' },
546
+ { type: 'command', command: "python3 -c \"print('${CLAUDE_PLUGIN_ROOT}/hooks/foo.py')\"" },
547
+ ],
548
+ }],
549
+ },
550
+ };
551
+ const settings = {};
552
+
553
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Program Files/Claude/.claude', 'python3');
554
+
555
+ assert.deepEqual(
556
+ settings.hooks.PreToolUse[0].hooks.map(eachHook => eachHook.command),
557
+ [
558
+ process.platform === 'win32'
559
+ ? 'python3 "C:/Program Files/Claude/.claude/hooks/blocking/pre_tool_use_dispatcher.py"'
560
+ : "python3 'C:/Program Files/Claude/.claude/hooks/blocking/pre_tool_use_dispatcher.py'",
561
+ process.platform === 'win32'
562
+ ? 'python3 "C:/Program Files/Claude/.claude/hooks/blocking/pre_tool_use_dispatcher.py"'
563
+ : "python3 'C:/Program Files/Claude/.claude/hooks/blocking/pre_tool_use_dispatcher.py'",
564
+ "python3 -c \"import sys; sys.path.insert(0, (__import__('base64').b64decode('QzovUHJvZ3JhbSBGaWxlcy9DbGF1ZGUvLmNsYXVkZS9ob29rcw==').decode(), '__claude_dev_env_managed_hooks__')[0])\"",
565
+ "python3 -c \"import sys; sys.path.insert(0, (__import__('base64').b64decode('QzovUHJvZ3JhbSBGaWxlcy9DbGF1ZGUvLmNsYXVkZS9ob29rcw==').decode(), '__claude_dev_env_managed_hooks__')[0])\"",
566
+ "python3 -c \"print('C:/Program Files/Claude/.claude/hooks/foo.py')\"",
567
+ ],
568
+ );
569
+ });
570
+
571
+
572
+ test('mergeHooksIntoSettings embeds a decodable inline plugin-root path', () => {
573
+ const hooksConfig = {
574
+ hooks: {
575
+ SessionStart: [{
576
+ matcher: '',
577
+ hooks: [{
578
+ type: 'command',
579
+ command: 'python3 -c "import sys; sys.path.insert(0, r\'${CLAUDE_PLUGIN_ROOT}/hooks\'); print(sys.path[0])"',
580
+ }],
581
+ }],
582
+ },
583
+ };
584
+ const settings = {};
585
+ const pluginRootDirectory = 'C:/Program Files/Claude/.claude';
586
+
587
+ mergeHooksIntoSettings(settings, hooksConfig, pluginRootDirectory, 'python3');
588
+
589
+ const rewrittenCommand = settings.hooks.SessionStart[0].hooks[0].command;
590
+ const pythonCommand = process.platform === 'win32' ? 'python' : 'python3';
591
+ const inlineRun = spawnSync(rewrittenCommand.replace(/^python3\b/, pythonCommand), {
592
+ encoding: 'utf8',
593
+ shell: process.platform === 'win32' ? true : '/bin/sh',
594
+ timeout: 30000,
595
+ });
596
+ assert.equal(inlineRun.status, 0, inlineRun.stderr);
597
+ assert.equal(inlineRun.stdout.trim(), `${pluginRootDirectory}/hooks`);
598
+ assert.equal(rewrittenCommand.includes("r'${CLAUDE_PLUGIN_ROOT}/hooks'"), false);
599
+ });
600
+
601
+
602
+ test('commandArgumentFromPath returns raw shell-safe paths', () => {
603
+ assert.equal(
604
+ commandArgumentFromPath('/opt/claude/hooks/pre_tool_use_dispatcher.py', 'linux'),
605
+ '/opt/claude/hooks/pre_tool_use_dispatcher.py',
606
+ );
607
+ assert.equal(
608
+ commandArgumentFromPath('C:\\Python313\\python.exe', 'win32'),
609
+ 'C:/Python313/python.exe',
610
+ );
611
+ });
612
+
613
+
614
+ test('commandArgumentFromPath quotes POSIX shell metacharacters', () => {
615
+ assert.equal(
616
+ commandArgumentFromPath("/opt/Claude's $hooks `pre_tool_use_dispatcher`.py", 'linux'),
617
+ "'/opt/Claude'\\''s $hooks `pre_tool_use_dispatcher`.py'",
618
+ );
619
+ });
620
+
621
+
622
+ test('commandArgumentFromPath quotes Windows percent and shell metacharacters', () => {
623
+ assert.equal(
624
+ commandArgumentFromPath('C:\\Program Files\\%PATH% & (x) ! ^.py', 'win32'),
625
+ '"C:/Program Files/"^%"PATH"^%" & (x) ! ^.py"',
626
+ );
627
+ assert.equal(
628
+ commandArgumentFromPath('C:\\safe\\%PATH%.py', 'win32'),
629
+ '"C:/safe/"^%"PATH"^%".py"',
630
+ );
631
+ });
632
+
633
+
634
+ test('commandArgumentFromPath launches a script from a metacharacter path', () => {
635
+ const temporaryDirectory = mkdtempSync(join(tmpdir(), 'cdev command & (path)!-'));
636
+ const isWindows = process.platform === 'win32';
637
+ const scriptPath = join(
638
+ temporaryDirectory,
639
+ isWindows ? 'launcher script %PATH% & (x) !.py' : "launcher script & (x) '$'.py",
640
+ );
641
+ try {
642
+ writeFileSync(scriptPath, 'print("launched")\n');
643
+ const pythonCommand = isWindows ? 'python' : 'python3';
644
+ const shellCommand = `${pythonCommand} ${commandArgumentFromPath(scriptPath, process.platform)}`;
645
+ const launcherRun = spawnSync(shellCommand, {
646
+ encoding: 'utf8',
647
+ shell: isWindows ? true : '/bin/sh',
648
+ timeout: 30000,
649
+ });
650
+ assert.equal(launcherRun.error, undefined);
651
+ assert.equal(launcherRun.signal, null);
652
+ assert.equal(launcherRun.status, 0, launcherRun.stderr);
653
+ assert.equal(launcherRun.stdout.trim(), 'launched');
654
+ } finally {
655
+ rmSync(temporaryDirectory, { recursive: true, force: true });
656
+ }
657
+ });
658
+
659
+
660
+ test('commandArgumentFromPath launches a no-space percent path', () => {
661
+ const temporaryDirectory = mkdtempSync(join(tmpdir(), 'cdev-command-launch-'));
662
+ const isWindows = process.platform === 'win32';
663
+ const scriptName = isWindows ? 'launcher%PATH%.py' : 'launcher.py';
664
+ const scriptPath = join(temporaryDirectory, scriptName);
665
+ try {
666
+ writeFileSync(scriptPath, 'print("launched")\n');
667
+ const pythonCommand = isWindows ? 'python' : 'python3';
668
+ const shellCommand = `${pythonCommand} ${commandArgumentFromPath(scriptPath, process.platform)}`;
669
+ const launcherRun = spawnSync(shellCommand, {
670
+ encoding: 'utf8',
671
+ shell: isWindows ? true : '/bin/sh',
672
+ timeout: 30000,
673
+ });
674
+ assert.equal(launcherRun.error, undefined);
675
+ assert.equal(launcherRun.signal, null);
676
+ assert.equal(launcherRun.status, 0, launcherRun.stderr);
677
+ assert.equal(launcherRun.stdout.trim(), 'launched');
678
+ } finally {
679
+ rmSync(temporaryDirectory, { recursive: true, force: true });
680
+ }
681
+ });
682
+
683
+
684
+ test('mergeHooksIntoSettings matches full standalone arguments at boundaries', () => {
685
+ const hooksConfig = {
686
+ hooks: {
687
+ PreToolUse: [{
688
+ matcher: 'Write',
689
+ hooks: [
690
+ { type: 'command', command: 'python3 prefix${CLAUDE_PLUGIN_ROOT}/hooks/a.py' },
691
+ { type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/a.py.bak' },
692
+ { type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/a.py' },
693
+ { type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/a.py; next' },
694
+ { type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/a.py& next' },
695
+ { type: 'command', command: 'python3 ${CLAUDE_PLUGIN_ROOT}/hooks/a.py| next' },
696
+ ],
697
+ }],
698
+ },
699
+ };
700
+ const settings = {};
701
+
702
+ mergeHooksIntoSettings(settings, hooksConfig, 'C:/Program Files/Claude/.claude', 'python3');
703
+
704
+ const expectedQuotedHookPath = process.platform === 'win32'
705
+ ? '"C:/Program Files/Claude/.claude/hooks/a.py"'
706
+ : "'C:/Program Files/Claude/.claude/hooks/a.py'";
707
+ assert.deepEqual(
708
+ settings.hooks.PreToolUse[0].hooks.map(eachHook => eachHook.command),
709
+ [
710
+ 'python3 prefixC:/Program Files/Claude/.claude/hooks/a.py',
711
+ 'python3 C:/Program Files/Claude/.claude/hooks/a.py.bak',
712
+ `python3 ${expectedQuotedHookPath}`,
713
+ `python3 ${expectedQuotedHookPath}; next`,
714
+ `python3 ${expectedQuotedHookPath}& next`,
715
+ `python3 ${expectedQuotedHookPath}| next`,
716
+ ],
717
+ );
718
+ });
719
+
720
+
533
721
  test('mergeHooksIntoSettings prunes a prior py -3 managed hook when reinstalling with an absolute interpreter path', () => {
534
722
  const hooksConfig = {
535
723
  hooks: {
@@ -687,6 +875,12 @@ test('commandReferencesManagedHook matches the rewritten inline validators-runne
687
875
  const rewrittenInlineCommand =
688
876
  "py -3 -c \"import sys; sys.path.insert(0, r'C:/Users/example/.claude/hooks'); from validators.run_all_validators import main; sys.exit(main())\"";
689
877
  assert.ok(commandReferencesManagedHook(rewrittenInlineCommand, managedPaths));
878
+ const base64InlineCommand =
879
+ "py -3 -c \"import sys; sys.path.insert(0, (__import__('base64').b64decode('QzovVXNlcnMvZXhhbXBsZS8uY2xhdWRlL2hvb2tz').decode(), '__claude_dev_env_managed_hooks__')[0]); from validators.run_all_validators import main; sys.exit(main())\"";
880
+ assert.ok(commandReferencesManagedHook(base64InlineCommand, managedPaths));
881
+ const unmarkedBase64InlineCommand =
882
+ "py -3 -c \"import sys; sys.path.insert(0, __import__('base64').b64decode('QzovVXNlcnMvZXhhbXBsZS8uY2xhdWRlL2hvb2tz').decode()); from validators.run_all_validators import main; sys.exit(main())\"";
883
+ assert.equal(commandReferencesManagedHook(unmarkedBase64InlineCommand, managedPaths), false);
690
884
  });
691
885
 
692
886
 
@@ -695,6 +889,12 @@ test('commandReferencesManagedHook leaves an unmanaged inline -c command that im
695
889
  const userInlineCommand =
696
890
  "python -c \"import sys; sys.path.insert(0, r'/home/me/tools'); from my_tools.runner import main; sys.exit(main())\"";
697
891
  assert.equal(commandReferencesManagedHook(userInlineCommand, managedPaths), false);
892
+ const similarlyNamedUserInlineCommand =
893
+ "python -c \"import sys; sys.path.insert(0, r'/home/me/tools'); from mypkg.run_all_validators import main; sys.exit(main())\"";
894
+ assert.equal(commandReferencesManagedHook(similarlyNamedUserInlineCommand, managedPaths), false);
895
+ const userPathWithManagedImport =
896
+ "python -c \"import sys; sys.path.insert(0, r'/home/me/tools'); from validators.run_all_validators import main; sys.exit(main())\"";
897
+ assert.equal(commandReferencesManagedHook(userPathWithManagedImport, managedPaths), false);
698
898
  });
699
899
 
700
900
 
@@ -44,6 +44,10 @@ Before writing ANY constant: search `config/` for the exact value → semantic m
44
44
 
45
45
  Full words only (`context`, not `ctx`). Exceptions: `i`/`j`/`k` in loops, `e` for exception. Naming patterns: loop vars `each_*`; booleans `is_/has_/should_/can_/was_/did_`; collections `all_*`; maps `X_by_Y`; preposition params (`from_path=`, `to=`, `into=`). Banned names: `result`, `data`, `output`, `response`, `value`, `item`, `temp`. Banned prefixes: `handle`, `process`, `manage`, `do`. Name a component for what it IS — `Overlay`, `Validator`, `InvoicePreview`.
46
46
 
47
+ ### Public compatibility definitions
48
+
49
+ A public function may use the exact inline marker `# pragma: no-banned-noun` on its `def` line. The banned-noun check permits the function name and parameters on that definition. Body local bindings, other definitions, and similar names remain checked.
50
+
47
51
  ---
48
52
 
49
53
  ## 6. COMPLETE TYPE HINTS
@@ -8,7 +8,7 @@
8
8
  |---|---|
9
9
  | `skills/team-advisor/SKILL.md` | Sole-consumer bind wiring and constraints |
10
10
  | `advisor-tool.md` | Consult timing, hard rule, how to treat advice |
11
- | `~/.claude/_shared/advisor/advisor-protocol.md` | Host bind, floor, lifecycle |
11
+ | `~/.claude/_shared/advisor/advisor-protocol.md` | Session identity, host bind, floor, lifecycle |
12
12
  | `agents/session-advisor.md` | ENDORSE / CORRECTION / PLAN / STOP |
13
13
 
14
14
  ## When to use
@@ -17,4 +17,4 @@ Follow the call rules in `advisor-tool.md` (orientation first, then consult befo
17
17
 
18
18
  ## Selected path
19
19
 
20
- `/team-advisor` is the repository's advisor implementation. It provides explicit first-consult packets, delta consults, a standing warm reviewer, and a read-only Sol CLI option.
20
+ `/team-advisor` is the repository's advisor implementation. It provides explicit first-consult packets, delta consults, a standing warm reviewer, an in-session Sol spawn on Codex, and a read-only Sol CLI option.
@@ -26,21 +26,21 @@ def main() -> None:
26
26
 
27
27
  if found_unsafe:
28
28
  operations = ", ".join(found_unsafe)
29
- print(
30
- json.dumps(
31
- {
32
- "hookSpecificOutput": {
33
- "hookEventName": "PreToolUse",
34
- "permissionDecision": "ask",
35
- "permissionDecisionReason": (
36
- f"MIGRATION SAFETY: Contains {operations}. "
37
- "Post-launch, model changes MUST be backwards-compatible. "
38
- "Verify this won't break running instances during deployment."
39
- ),
40
- }
41
- }
42
- )
29
+ advisory_message = (
30
+ f"MIGRATION SAFETY: Contains {operations}. "
31
+ "Post-launch, model changes MUST be backwards-compatible. "
32
+ "Verify this won't break running instances during deployment."
43
33
  )
34
+ advisory_payload = {
35
+ "systemMessage": advisory_message,
36
+ "hookSpecificOutput": {
37
+ "hookEventName": "PreToolUse",
38
+ "permissionDecision": "allow",
39
+ "permissionDecisionReason": advisory_message,
40
+ "additionalContext": advisory_message,
41
+ },
42
+ }
43
+ print(json.dumps(advisory_payload))
44
44
 
45
45
  sys.exit(0)
46
46
 
@@ -0,0 +1,120 @@
1
+ """Behavior tests for migration_safety_advisor through its production entry paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+
13
+ _ADVISORY_DIRECTORY = Path(__file__).resolve().parent
14
+ _HOOKS_DIRECTORY = _ADVISORY_DIRECTORY.parent
15
+ _ADVISOR_SCRIPT = _ADVISORY_DIRECTORY / "migration_safety_advisor.py"
16
+ _DISPATCHER_SCRIPT = _HOOKS_DIRECTORY / "blocking" / "pre_tool_use_dispatcher.py"
17
+
18
+
19
+ def _run_hook(script_path: Path, payload: dict[str, object]) -> dict[str, object] | None:
20
+ completed_process = subprocess.run(
21
+ [sys.executable, str(script_path)],
22
+ check=False,
23
+ input=json.dumps(payload),
24
+ capture_output=True,
25
+ text=True,
26
+ encoding="utf-8",
27
+ )
28
+ assert completed_process.returncode == 0
29
+ if not completed_process.stdout.strip():
30
+ return None
31
+ parsed_payload = json.loads(completed_process.stdout)
32
+ assert isinstance(parsed_payload, dict)
33
+ return parsed_payload
34
+
35
+
36
+ def _build_edit_payload(file_path: Path, new_string: str) -> dict[str, object]:
37
+ return {
38
+ "tool_name": "Edit",
39
+ "tool_input": {
40
+ "file_path": str(file_path),
41
+ "old_string": "operations = []",
42
+ "new_string": new_string,
43
+ },
44
+ }
45
+
46
+
47
+ def _build_control_payload(tool_name: str, file_path: Path, content: str) -> dict[str, object]:
48
+ if tool_name == "Write":
49
+ tool_input: dict[str, object] = {
50
+ "file_path": str(file_path),
51
+ "content": content,
52
+ }
53
+ else:
54
+ tool_input = {
55
+ "file_path": str(file_path),
56
+ "edits": [{"old_string": "operations = []", "new_string": content}],
57
+ }
58
+ return {"tool_name": tool_name, "tool_input": tool_input}
59
+
60
+
61
+ def _prepare_fresh_test_pair(migration_path: Path) -> None:
62
+ migration_path.parent.mkdir(parents=True)
63
+ migration_path.write_text("operations = []\n", encoding="utf-8")
64
+ test_path = migration_path.with_name(f"test_{migration_path.stem}.py")
65
+ test_path.write_text("def test_migration_contract():\n assert True\n", encoding="utf-8")
66
+
67
+
68
+ def _permission_fields(parsed_payload: dict[str, object]) -> dict[str, object]:
69
+ hook_specific = parsed_payload.get("hookSpecificOutput", {})
70
+ assert isinstance(hook_specific, dict)
71
+ return hook_specific
72
+
73
+
74
+ def _text_field(field_by_key: dict[str, object], field_name: str) -> str:
75
+ field_value = field_by_key.get(field_name)
76
+ assert isinstance(field_value, str)
77
+ return field_value
78
+
79
+
80
+ def test_standalone_advisor_warns_for_unsafe_migration_edit(tmp_path: Path) -> None:
81
+ migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
82
+ payload = _build_edit_payload(migration_path, 'operations = [migrations.RemoveField("name")]')
83
+
84
+ advisory_payload = _run_hook(_ADVISOR_SCRIPT, payload)
85
+ assert advisory_payload is not None
86
+ permission_fields = _permission_fields(advisory_payload)
87
+
88
+ assert permission_fields["permissionDecision"] == "allow"
89
+ assert "RemoveField" in _text_field(permission_fields, "additionalContext")
90
+ assert "MIGRATION SAFETY" in _text_field(advisory_payload, "systemMessage")
91
+
92
+
93
+ def test_dispatcher_surfaces_migration_warning_for_edit(tmp_path: Path) -> None:
94
+ migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
95
+ _prepare_fresh_test_pair(migration_path)
96
+ payload = _build_edit_payload(migration_path, 'operations = [migrations.RemoveField("name")]')
97
+
98
+ dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
99
+ assert dispatched_payload is not None
100
+ permission_fields = _permission_fields(dispatched_payload)
101
+
102
+ assert permission_fields["permissionDecision"] == "allow"
103
+ assert "RemoveField" in _text_field(permission_fields, "additionalContext")
104
+ assert "MIGRATION SAFETY" in _text_field(dispatched_payload, "systemMessage")
105
+
106
+
107
+ @pytest.mark.parametrize("tool_name", ("Write", "MultiEdit"))
108
+ def test_dispatcher_keeps_migration_advisor_edit_only(tmp_path: Path, tool_name: str) -> None:
109
+ migration_path = tmp_path / "billing" / "migrations" / "0002_remove_name.py"
110
+ _prepare_fresh_test_pair(migration_path)
111
+ payload = _build_control_payload(
112
+ tool_name,
113
+ migration_path,
114
+ 'operations = [migrations.RemoveField("name")]',
115
+ )
116
+
117
+ dispatched_payload = _run_hook(_DISPATCHER_SCRIPT, payload)
118
+ serialized_payload = json.dumps(dispatched_payload or {})
119
+
120
+ assert "MIGRATION SAFETY" not in serialized_payload
@@ -0,0 +1,81 @@
1
+ """Atomic text replacement for hook-state files."""
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from hooks_constants.atomic_file_writer_constants import TEXT_WRITE_MODE
8
+
9
+
10
+ def _remove_orphaned_temporary_files(
11
+ parent_directory: Path, temporary_prefix: str, temporary_suffix: str
12
+ ) -> None:
13
+ for each_temporary_path in parent_directory.glob(f"{temporary_prefix}*{temporary_suffix}"):
14
+ try:
15
+ each_temporary_path.unlink(missing_ok=True)
16
+ except OSError:
17
+ continue
18
+
19
+
20
+ def _write_temporary_text(
21
+ parent_directory: Path,
22
+ serialized_text: str,
23
+ encoding: str,
24
+ temporary_prefix: str,
25
+ temporary_suffix: str,
26
+ ) -> Path:
27
+ temporary_path: Path | None = None
28
+ try:
29
+ with tempfile.NamedTemporaryFile(
30
+ mode=TEXT_WRITE_MODE,
31
+ encoding=encoding,
32
+ dir=parent_directory,
33
+ prefix=temporary_prefix,
34
+ suffix=temporary_suffix,
35
+ delete=False,
36
+ ) as writable_handle:
37
+ temporary_path = Path(writable_handle.name)
38
+ writable_handle.write(serialized_text)
39
+ return temporary_path
40
+ except (OSError, UnicodeError):
41
+ if temporary_path is not None:
42
+ temporary_path.unlink(missing_ok=True)
43
+ raise
44
+
45
+
46
+ def write_text_atomically(
47
+ target_path: Path,
48
+ serialized_text: str,
49
+ encoding: str,
50
+ temporary_prefix: str,
51
+ temporary_suffix: str,
52
+ should_reap_orphans: bool,
53
+ ) -> None:
54
+ """Replace one text file through a unique sibling temporary file.
55
+
56
+ Args:
57
+ target_path: The destination file path.
58
+ serialized_text: The complete replacement text.
59
+ encoding: The text encoding.
60
+ temporary_prefix: Prefix for sibling temporary files.
61
+ temporary_suffix: Suffix for sibling temporary files.
62
+ should_reap_orphans: Whether to remove same-pattern temporary files first.
63
+ """
64
+ parent_directory = target_path.parent
65
+ parent_directory.mkdir(parents=True, exist_ok=True)
66
+ if should_reap_orphans:
67
+ _remove_orphaned_temporary_files(parent_directory, temporary_prefix, temporary_suffix)
68
+ temporary_path = _write_temporary_text(
69
+ parent_directory,
70
+ serialized_text,
71
+ encoding,
72
+ temporary_prefix,
73
+ temporary_suffix,
74
+ )
75
+ try:
76
+ os.replace(temporary_path, target_path)
77
+ finally:
78
+ try:
79
+ temporary_path.unlink(missing_ok=True)
80
+ except OSError:
81
+ pass
@@ -95,7 +95,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
95
95
  | `pytest_testpaths_orphan_blocker.py` | PreToolUse (Write/Edit/MultiEdit) | New `test_*.py` files created under a directory absent from a package's explicit pytest `testpaths` allowlist |
96
96
  | `question_to_user_enforcer.py` | Stop | User-directed questions not routed through `AskUserQuestion` |
97
97
  | `send_user_file_open_locally_blocker.py` | PreToolUse (SendUserFile) | A desk-side file attach (`SendUserFile` with `status` not `proactive`); points to opening the file with its native Windows app |
98
- | `sensitive_file_protector.py` | PreToolUse (Write/Edit) | Writes to sensitive credential or config files |
98
+ | `sensitive_file_protector.py` | PreToolUse (Write/Edit/MultiEdit) | Writes to sensitive credential or config files |
99
99
  | `session_edit_stage_gate.py` | PreToolUse (Bash) | A `git commit` that would drop files edited this session because they are tracked but left unstaged |
100
100
  | `session_handoff_blocker.py` | Stop | Responses suggesting a new session mid-task |
101
101
  | `shell_substitution_blocker.py` | PreToolUse (Bash) | A command carrying `$(...)`, a live backtick, or `<(...)`/`>(...)` process substitution, which the allowlist matcher cannot descend into |
@@ -114,6 +114,7 @@ The check modules it calls are the `code_rules_<concern>.py` files below.
114
114
  | File | Role |
115
115
  |---|---|
116
116
  | `_gh_body_arg_utils.py` | Parsing helpers for `gh_body_arg_blocker.py` |
117
+ | `test_hook_subprocess_support.py` | Shared subprocess runner for blocking-hook behavior tests |
117
118
 
118
119
  ## Conventions
119
120
 
@@ -1,7 +1,9 @@
1
1
  """Banned identifier, banned noun-word, and banned function-prefix naming checks."""
2
2
 
3
3
  import ast
4
+ import io
4
5
  import sys
6
+ import tokenize
5
7
  from pathlib import Path
6
8
 
7
9
  _blocking_directory = str(Path(__file__).resolve().parent)
@@ -30,6 +32,7 @@ from hooks_constants.banned_identifiers_constants import ( # noqa: E402
30
32
  ALL_BANNED_NOUN_WORDS,
31
33
  BANNED_IDENTIFIER_MESSAGE_SUFFIX,
32
34
  BANNED_IDENTIFIER_SKIP_ADVISORY,
35
+ BANNED_NOUN_COMPATIBILITY_COMMENT,
33
36
  BANNED_NOUN_WORD_MESSAGE_SUFFIX,
34
37
  CAMEL_CASE_WORD_PATTERN,
35
38
  MAX_BANNED_IDENTIFIER_ISSUES,
@@ -235,8 +238,31 @@ def _is_dunder_name(identifier: str) -> bool:
235
238
  return identifier.startswith("__") and identifier.endswith("__")
236
239
 
237
240
 
241
+ def _collect_annotated_public_definition_lines(
242
+ content: str, parsed_tree: ast.AST
243
+ ) -> frozenset[int]:
244
+ all_annotation_lines: set[int] = set()
245
+ try:
246
+ for each_token in tokenize.generate_tokens(io.StringIO(content).readline):
247
+ if each_token.type != tokenize.COMMENT:
248
+ continue
249
+ if each_token.string.strip() == BANNED_NOUN_COMPATIBILITY_COMMENT:
250
+ all_annotation_lines.add(each_token.start[0])
251
+ except (IndentationError, SyntaxError, tokenize.TokenError):
252
+ return frozenset()
253
+
254
+ return frozenset(
255
+ each_node.lineno
256
+ for each_node in ast.walk(parsed_tree)
257
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef))
258
+ and not each_node.name.startswith("_")
259
+ and each_node.lineno in all_annotation_lines
260
+ )
261
+
262
+
238
263
  def _collect_banned_noun_word_bindings(
239
264
  parsed_tree: ast.AST,
265
+ all_annotated_public_definition_lines: frozenset[int],
240
266
  ) -> list[tuple[str, int, int, str]]:
241
267
  """Yield ``(identifier, lineno, col_offset, banned_word)`` for each binding.
242
268
 
@@ -248,7 +274,14 @@ def _collect_banned_noun_word_bindings(
248
274
  flagged_bindings: list[tuple[str, int, int, str]] = []
249
275
  seen_keys: set[tuple[str, int, int]] = set()
250
276
 
251
- def record(name: str, lineno: int, col_offset: int) -> None:
277
+ def record(
278
+ name: str,
279
+ lineno: int,
280
+ col_offset: int,
281
+ is_annotated_public_definition: bool = False,
282
+ ) -> None:
283
+ if is_annotated_public_definition:
284
+ return
252
285
  if name in ALL_BANNED_IDENTIFIERS:
253
286
  return
254
287
  if _is_dunder_name(name):
@@ -282,11 +315,24 @@ def _collect_banned_noun_word_bindings(
282
315
  for each_name_node in _collect_target_names(each_node.optional_vars):
283
316
  record(each_name_node.id, each_name_node.lineno, each_name_node.col_offset)
284
317
  elif isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
285
- record(each_node.name, each_node.lineno, each_node.col_offset)
318
+ is_annotated_public_definition = (
319
+ each_node.lineno in all_annotated_public_definition_lines
320
+ )
321
+ record(
322
+ each_node.name,
323
+ each_node.lineno,
324
+ each_node.col_offset,
325
+ is_annotated_public_definition,
326
+ )
286
327
  for each_arg in _collect_annotated_arguments(each_node):
287
328
  if each_arg.arg in ALL_SELF_AND_CLS_PARAMETER_NAMES:
288
329
  continue
289
- record(each_arg.arg, each_arg.lineno, each_arg.col_offset)
330
+ record(
331
+ each_arg.arg,
332
+ each_arg.lineno,
333
+ each_arg.col_offset,
334
+ is_annotated_public_definition,
335
+ )
290
336
  elif isinstance(each_node, ast.ClassDef):
291
337
  record(each_node.name, each_node.lineno, each_node.col_offset)
292
338
  elif isinstance(each_node, (ast.Import, ast.ImportFrom)):
@@ -368,9 +414,14 @@ def check_banned_noun_word_boundary(
368
414
  except SyntaxError:
369
415
  return []
370
416
 
417
+ all_annotated_public_definition_lines = _collect_annotated_public_definition_lines(
418
+ content, parsed_tree
419
+ )
371
420
  single_line_span = 1
372
421
  all_violations_in_walk_order: list[tuple[range, str]] = []
373
- for each_name, each_lineno, _, each_word in _collect_banned_noun_word_bindings(parsed_tree):
422
+ for each_name, each_lineno, _, each_word in _collect_banned_noun_word_bindings(
423
+ parsed_tree, all_annotated_public_definition_lines
424
+ ):
374
425
  span_range = range(each_lineno, each_lineno + single_line_span)
375
426
  span_fragment = BANNED_NOUN_SPAN_FRAGMENT_TEMPLATE.format(
376
427
  definition_line=each_lineno, line_span=single_line_span