claude-dev-env 8.7.0 → 8.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/privacy-hygiene/SKILL.md +1 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
- package/.agents/skills/team-advisor/SKILL.md +2 -2
- package/_shared/advisor/reference/identity.md +1 -1
- package/_shared/advisor/reference/spawn-walk-log.md +1 -1
- package/_shared/advisor/reference/warm-up.md +3 -1
- package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -0
- package/_shared/advisor/scripts/model_tier_run_validator.py +20 -3
- package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +50 -6
- package/docs/references/advisor-tool.md +3 -2
- package/docs/second-claude-account.md +57 -0
- package/hooks/blocking/test_pii_scanner.py +8 -0
- package/hooks/hooks.json +25 -0
- package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +58 -0
- package/hooks/session/skill_loaded_reminder.py +199 -0
- package/hooks/session/test_skill_loaded_reminder.py +206 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +26 -1
- package/rules/long-horizon-autonomy.md +11 -0
- package/rules/prefer-existing-tools.md +35 -0
- package/rules/review-closure-is-a-check.md +1 -1
- package/scripts/claude_account_choice.py +291 -0
- package/scripts/claude_account_profile.py +371 -0
- package/scripts/claude_chain_usage.py +49 -5
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +181 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +46 -0
- package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +1 -0
- package/scripts/gh_artifact_upload.py +56 -3
- package/scripts/private_term_scan.py +135 -0
- package/scripts/private_terms.py +87 -0
- package/scripts/repository_checks/config/constants.py +3 -0
- package/scripts/repository_checks/runner.py +7 -0
- package/scripts/repository_checks/tracked_private_terms.py +57 -0
- package/scripts/test_claude_account_choice.py +245 -0
- package/scripts/test_claude_account_profile.py +220 -0
- package/scripts/test_claude_chain_usage.py +40 -0
- package/scripts/test_durable_post_lint.py +90 -0
- package/scripts/tests/test_gh_artifact_upload.py +75 -0
- package/scripts/tests/test_match_exemptions.py +2 -2
- package/scripts/tests/test_private_term_scan.py +176 -0
- package/scripts/tests/test_private_terms.py +59 -0
- package/scripts/tests/test_repository_policy.py +2 -0
- package/scripts/tests/test_runner.py +20 -0
- package/scripts/tests/test_tracked_private_terms.py +71 -0
- package/scripts/tests/test_tracked_secrets.py +1 -1
|
@@ -32,6 +32,7 @@ Surfaces:
|
|
|
32
32
|
1. **Write / Edit / MultiEdit** — payload text about to land on disk (via PreToolUse dispatcher)
|
|
33
33
|
2. **Durable posts** — `gh pr/issue create|comment|edit|review` bodies and GitHub MCP body/comment fields (Bash and PowerShell)
|
|
34
34
|
3. **git commit** — staged blob text (non-exempt paths) on Bash and PowerShell, including `git.exe` and flag forms (`--no-verify`, `-c`, `-C`). Commit message bodies (`-m` / `-F`) are out of scope for the automated gate
|
|
35
|
+
4. **CI** — the `committed-tree` job in `.github/workflows/ci-tests.yml` runs [gitleaks](https://github.com/gitleaks/gitleaks) over every commit the pull request adds, test files included. The repository-root `.gitleaks.toml` adds three rules to the gitleaks defaults: a user folder path that names a person, an RFC 1918 address, and an email other than a noreply or example address. To clear a fixture that must stay, add its gitleaks fingerprint to `.gitleaksignore`
|
|
35
36
|
|
|
36
37
|
## Sweep procedure
|
|
37
38
|
|
|
@@ -100,6 +100,7 @@ def _linter_arguments(
|
|
|
100
100
|
all_arguments.extend(["--body-file", str(arguments.body_file)])
|
|
101
101
|
if getattr(arguments, "head", None) is not None:
|
|
102
102
|
all_arguments.extend(["--head-branch", arguments.head])
|
|
103
|
+
all_arguments.extend(["--repository", arguments.repo])
|
|
103
104
|
return all_arguments
|
|
104
105
|
|
|
105
106
|
|
|
@@ -198,6 +198,8 @@ def test_actions_run_exact_linter_then_gh_arguments(
|
|
|
198
198
|
"--body-file",
|
|
199
199
|
BODY_FILENAME,
|
|
200
200
|
*head_arguments,
|
|
201
|
+
"--repository",
|
|
202
|
+
all_cli_arguments[all_cli_arguments.index("--repo") + 1],
|
|
201
203
|
]
|
|
202
204
|
assert exit_code == 0
|
|
203
205
|
assert _calls(command_runner) == [expected_linter, expected_gh]
|
|
@@ -25,7 +25,7 @@ One warm advisor at the strongest tier this session can reach. This session is t
|
|
|
25
25
|
|
|
26
26
|
1. Name the session identity first (protocol **Host profiles**), then walk the model floor.
|
|
27
27
|
2. Claude: when `advisor` is in this session's tool list, the built-in advisor tool is the advisor. Spawn nothing and call `advisor()` at each consult point. Otherwise spawn or resume `pstack:poteto-agent` at `ADVISOR_EFFORT` (default low) with the advisor response contract in the prompt. When the bound model is out of usage, use the configured fallback at the same effort. Codex: use the native advisor path. Third-party: use the headless advisor path. When the host's walk fails, fail closed.
|
|
28
|
-
3. Name the warm instance `team-advisor-agent` and keep its advisor response contract in every first prompt. When a native Codex subagent spawn is the advisor path, pass `flags: ["--advisor"]` with Astra.
|
|
28
|
+
3. Name the warm instance `team-advisor-agent` and keep its advisor response contract in every first prompt. When a native Codex subagent spawn is the advisor path and the spawn tool has a `flags` field, pass `flags: ["--advisor"]` with Astra and record `advisor_flag: passed`. A spawn that skips an offered flag is a failed bind. When the spawn tool has no `flags` field, as in `codex exec`, spawn `gpt-6-astra` plainly, record `advisor_flag: unavailable`, and treat it as bound.
|
|
29
29
|
4. Skip the multi-consumer "who you are" opener. This session is the sole consumer.
|
|
30
30
|
5. When the bind or reply path fails, fail closed and report to the user. On a third-party host, only the bound advisor issues ENDORSE / CORRECTION / PLAN / STOP.
|
|
31
31
|
|
|
@@ -49,7 +49,7 @@ Aim for two consults on a normal task: one after orientation and one after write
|
|
|
49
49
|
- Bind at or above the protocol floor for this host.
|
|
50
50
|
- The advisor only answers (messaging); the session runs tools and posts.
|
|
51
51
|
- Keep an optional reference gap in the reference record: path, status, repair action, and repair result. A reference gap leaves bind status separate.
|
|
52
|
-
- On Codex, select native Astra when its bind and reply validation pass. Record `selected_tier: Astra` and `reply_path: native`.
|
|
52
|
+
- On Codex, select native Astra when its bind and reply validation pass. Record `selected_tier: Astra` and `reply_path: native`, plus `advisor_flag: passed` or `advisor_flag: unavailable`.
|
|
53
53
|
- Mark `fallback_kind: broken` only when the selected bind or reply path fails. Record the selected tier, fallback reason, and reply path in the same evidence object.
|
|
54
54
|
- Record first, recovery, and completion consults with changed evidence, validation, unresolved risks, and report-back status.
|
|
55
55
|
- Write the evidence object to the session-controlled `model-tier-run.json` record described in `advisor-tool.md`.
|
|
@@ -19,7 +19,7 @@ When both `codex` and `claude` tokens appear, Codex wins. Empty text is ThirdPar
|
|
|
19
19
|
|
|
20
20
|
**Claude.** Spawn `subagent_type: session-advisor` at Fable through the Agent tool. When Fable is out of usage, bind Astra through the Codex helper if `ADVISOR_ASTRA` is on. Fail closed when neither binds.
|
|
21
21
|
|
|
22
|
-
**Codex.** Spawn a native in-session Astra subagent at `resolve_codex_model_id("Astra")` (`gpt-6-astra`). Walk `candidate_tiers = ["Astra"]`. Record `{tier: "Astra", result: "spawned"}` on success. The `ADVISOR_ASTRA` flag is not required. Fail closed when Astra does not bind. Do not walk Fable on a Codex host.
|
|
22
|
+
**Codex.** Spawn a native in-session Astra subagent at `resolve_codex_model_id("Astra")` (`gpt-6-astra`). Walk `candidate_tiers = ["Astra"]`. When the spawn tool has a `flags` field, pass `flags: ["--advisor"]` and record `fallback.advisor_flag: "passed"`. When it has none, as in `codex exec`, spawn `gpt-6-astra` plainly and record `fallback.advisor_flag: "unavailable"`. Both count as a bind. Record `{tier: "Astra", result: "spawned"}` on success. The `ADVISOR_ASTRA` flag is not required. Fail closed when Astra does not bind, or when the spawn skips a flag the host offers. Do not walk Fable on a Codex host.
|
|
23
23
|
|
|
24
24
|
**ThirdParty.** Bind Fable through the CLI Claude-chain. When Fable is out of usage, bind Astra through the Codex helper if `ADVISOR_ASTRA` is on. Fail closed when neither binds.
|
|
25
25
|
|
|
@@ -29,5 +29,5 @@ Exit code `1` means a ladder invariant failed.
|
|
|
29
29
|
Exit code `2` means the path or JSON was unusable.
|
|
30
30
|
The same checks are available in-process via `validate_model_tier_run(run)`.
|
|
31
31
|
|
|
32
|
-
The validator checks ladder shape and validates `evidence` when it is present. On Claude and ThirdParty, Astra is attempted after Fable when `astra_enabled` is enabled, and `selected_tier: "Astra"` requires `result: "codex"`. On Codex, the walk is Astra only, and `result: "spawned"` or `result: "codex"` counts as success. A Codex success record uses `reply_path: "native"`.
|
|
32
|
+
The validator checks ladder shape and validates `evidence` when it is present. On Claude and ThirdParty, Astra is attempted after Fable when `astra_enabled` is enabled, and `selected_tier: "Astra"` requires `result: "codex"`. On Codex, the walk is Astra only, and `result: "spawned"` or `result: "codex"` counts as success. A Codex success record uses `reply_path: "native"` and `advisor_flag: "passed"` or `advisor_flag: "unavailable"`. A missing `advisor_flag` or `advisor_flag: "omitted"` fails validation.
|
|
33
33
|
Host policy sits on top of it. See the Model floor section of the protocol.
|
|
@@ -31,7 +31,9 @@ The agent finishes its first turn standing by. `SendMessage` alone resumes it; b
|
|
|
31
31
|
## Codex host
|
|
32
32
|
|
|
33
33
|
Spawn a native in-session Astra subagent at `resolve_codex_model_id("Astra")` (`gpt-6-astra`) with the charter as its prompt.
|
|
34
|
-
|
|
34
|
+
When the spawn tool has a `flags` field, pass `flags: ["--advisor"]` and record `fallback.advisor_flag: "passed"`.
|
|
35
|
+
When the spawn tool has no `flags` field, as in `codex exec`, spawn `gpt-6-astra` plainly, record `fallback.advisor_flag: "unavailable"`, and treat the advisor as bound.
|
|
36
|
+
Record `{tier: "Astra", result: "spawned"}` on success. Fail closed when that spawn does not bind, or when it skips a flag the host offers.
|
|
35
37
|
The `ADVISOR_ASTRA` flag is not required. Do not walk Fable. Consults stay in-session with that Astra subagent.
|
|
36
38
|
Identity routing: [`identity.md`](identity.md).
|
|
37
39
|
|
|
@@ -106,3 +106,18 @@ ALL_ADVISOR_REPLY_PATHS: frozenset[str] = frozenset(
|
|
|
106
106
|
{"native", "sendmessage", "cli", "codex", "none"}
|
|
107
107
|
)
|
|
108
108
|
EVIDENCE_MUST_BE_OBJECT_MESSAGE: str = "evidence must be an object"
|
|
109
|
+
ADVISOR_FLAG_PASSED: str = "passed"
|
|
110
|
+
ADVISOR_FLAG_UNAVAILABLE: str = "unavailable"
|
|
111
|
+
ADVISOR_FLAG_OMITTED: str = "omitted"
|
|
112
|
+
ALL_ADVISOR_FLAG_STATES: frozenset[str] = frozenset(
|
|
113
|
+
{ADVISOR_FLAG_PASSED, ADVISOR_FLAG_UNAVAILABLE, ADVISOR_FLAG_OMITTED}
|
|
114
|
+
)
|
|
115
|
+
UNKNOWN_ADVISOR_FLAG_MESSAGE: str = (
|
|
116
|
+
"evidence.fallback.advisor_flag must be passed, unavailable, or omitted"
|
|
117
|
+
)
|
|
118
|
+
MISSING_ADVISOR_FLAG_MESSAGE: str = (
|
|
119
|
+
"Codex Astra success requires evidence.fallback.advisor_flag"
|
|
120
|
+
)
|
|
121
|
+
OMITTED_ADVISOR_FLAG_MESSAGE: str = (
|
|
122
|
+
"Codex Astra spawn skipped the --advisor flag the host offers"
|
|
123
|
+
)
|
|
@@ -67,6 +67,8 @@ from advisor_scripts_constants.astra_advisor_constants import (
|
|
|
67
67
|
ASTRA_FALLBACK_KIND_DECLINED,
|
|
68
68
|
)
|
|
69
69
|
from advisor_scripts_constants.model_tier_run_validator_constants import (
|
|
70
|
+
ADVISOR_FLAG_OMITTED,
|
|
71
|
+
ALL_ADVISOR_FLAG_STATES,
|
|
70
72
|
ALL_ADVISOR_REPLY_PATHS,
|
|
71
73
|
ATTEMPT_ORDER_MISMATCH_MESSAGE,
|
|
72
74
|
ATTEMPT_TIER_OUT_OF_SLICE_MESSAGE,
|
|
@@ -82,10 +84,13 @@ from advisor_scripts_constants.model_tier_run_validator_constants import (
|
|
|
82
84
|
HOST_PROFILE_JSON_KEY,
|
|
83
85
|
HOST_PROFILE_MUST_BE_STRING_MESSAGE,
|
|
84
86
|
INCOMPLETE_FALLBACK_WALK_MESSAGE,
|
|
87
|
+
MISSING_ADVISOR_FLAG_MESSAGE,
|
|
85
88
|
MISSING_FALLBACK_REASON_MESSAGE,
|
|
89
|
+
OMITTED_ADVISOR_FLAG_MESSAGE,
|
|
86
90
|
SELECTED_TIER_MISMATCH_MESSAGE,
|
|
87
91
|
SELECTED_TIER_NOT_NULL_MESSAGE,
|
|
88
92
|
THIRD_PARTY_MODEL_TIER,
|
|
93
|
+
UNKNOWN_ADVISOR_FLAG_MESSAGE,
|
|
89
94
|
UNKNOWN_HOST_PROFILE_ERROR,
|
|
90
95
|
UNKNOWN_OWN_TIER_MESSAGE,
|
|
91
96
|
)
|
|
@@ -212,14 +217,26 @@ def _validate_fallback_reply_path(
|
|
|
212
217
|
raise ModelTierRunError(
|
|
213
218
|
"evidence.fallback.reply_path is not a known advisor path"
|
|
214
219
|
)
|
|
215
|
-
|
|
220
|
+
is_codex_astra_success = (
|
|
216
221
|
canonical_host_profile(run.host_profile) == HOST_PROFILE_CODEX
|
|
217
222
|
and maybe_run_selected_tier == ADVISOR_MODEL_TIER
|
|
218
|
-
|
|
219
|
-
|
|
223
|
+
)
|
|
224
|
+
if is_codex_astra_success and reply_path != "native":
|
|
220
225
|
raise ModelTierRunError(
|
|
221
226
|
"Codex Astra success requires the native reply path"
|
|
222
227
|
)
|
|
228
|
+
raw_advisor_flag = fallback_by_name.get("advisor_flag")
|
|
229
|
+
if raw_advisor_flag is None:
|
|
230
|
+
if is_codex_astra_success:
|
|
231
|
+
raise ModelTierRunError(MISSING_ADVISOR_FLAG_MESSAGE)
|
|
232
|
+
return
|
|
233
|
+
if (
|
|
234
|
+
not isinstance(raw_advisor_flag, str)
|
|
235
|
+
or raw_advisor_flag not in ALL_ADVISOR_FLAG_STATES
|
|
236
|
+
):
|
|
237
|
+
raise ModelTierRunError(UNKNOWN_ADVISOR_FLAG_MESSAGE)
|
|
238
|
+
if is_codex_astra_success and raw_advisor_flag == ADVISOR_FLAG_OMITTED:
|
|
239
|
+
raise ModelTierRunError(OMITTED_ADVISOR_FLAG_MESSAGE)
|
|
223
240
|
|
|
224
241
|
|
|
225
242
|
def _validate_fallback_evidence(
|
|
@@ -44,7 +44,16 @@ def _native_codex_evidence(
|
|
|
44
44
|
*,
|
|
45
45
|
reply_path: str = "native",
|
|
46
46
|
reference_status: str = "missing",
|
|
47
|
+
advisor_flag: str | None = "passed",
|
|
47
48
|
) -> dict[str, object]:
|
|
49
|
+
fallback: dict[str, object] = {
|
|
50
|
+
"selected_tier": "Astra",
|
|
51
|
+
"fallback_kind": None,
|
|
52
|
+
"fallback_reason": None,
|
|
53
|
+
"reply_path": reply_path,
|
|
54
|
+
}
|
|
55
|
+
if advisor_flag is not None:
|
|
56
|
+
fallback["advisor_flag"] = advisor_flag
|
|
48
57
|
return {
|
|
49
58
|
"schema_version": 1,
|
|
50
59
|
"reference": {
|
|
@@ -53,12 +62,7 @@ def _native_codex_evidence(
|
|
|
53
62
|
"repair_action": "use the projected docs root",
|
|
54
63
|
"repair_result": "read",
|
|
55
64
|
},
|
|
56
|
-
"fallback":
|
|
57
|
-
"selected_tier": "Astra",
|
|
58
|
-
"fallback_kind": None,
|
|
59
|
-
"fallback_reason": None,
|
|
60
|
-
"reply_path": reply_path,
|
|
61
|
-
},
|
|
65
|
+
"fallback": fallback,
|
|
62
66
|
"consult": {
|
|
63
67
|
"changed_evidence": ["native bind returned a reply"],
|
|
64
68
|
"validation": ["signal and session id read back"],
|
|
@@ -96,6 +100,46 @@ def test_codex_native_success_rejects_non_native_reply_path() -> None:
|
|
|
96
100
|
validate_model_tier_run(run)
|
|
97
101
|
|
|
98
102
|
|
|
103
|
+
def _codex_astra_success_run(evidence: dict[str, object]) -> ModelTierRun:
|
|
104
|
+
return ModelTierRun(
|
|
105
|
+
own_tier="Opus",
|
|
106
|
+
candidate_tiers=[ADVISOR_MODEL_TIER],
|
|
107
|
+
attempts=[{"tier": ADVISOR_MODEL_TIER, "result": "spawned"}],
|
|
108
|
+
selected_tier=ADVISOR_MODEL_TIER,
|
|
109
|
+
host_profile="Codex",
|
|
110
|
+
evidence=evidence,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_codex_plain_astra_spawn_binds_when_host_lacks_advisor_flag() -> None:
|
|
115
|
+
run = _codex_astra_success_run(
|
|
116
|
+
_native_codex_evidence(advisor_flag="unavailable")
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
assert validate_model_tier_run(run) is None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_codex_astra_spawn_that_skips_offered_advisor_flag_fails_closed() -> None:
|
|
123
|
+
run = _codex_astra_success_run(_native_codex_evidence(advisor_flag="omitted"))
|
|
124
|
+
|
|
125
|
+
with pytest.raises(ModelTierRunError, match="--advisor"):
|
|
126
|
+
validate_model_tier_run(run)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_codex_astra_success_without_advisor_flag_record_fails_closed() -> None:
|
|
130
|
+
run = _codex_astra_success_run(_native_codex_evidence(advisor_flag=None))
|
|
131
|
+
|
|
132
|
+
with pytest.raises(ModelTierRunError, match="advisor_flag"):
|
|
133
|
+
validate_model_tier_run(run)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_unknown_advisor_flag_state_is_rejected() -> None:
|
|
137
|
+
run = _codex_astra_success_run(_native_codex_evidence(advisor_flag="skipped"))
|
|
138
|
+
|
|
139
|
+
with pytest.raises(ModelTierRunError, match="advisor_flag"):
|
|
140
|
+
validate_model_tier_run(run)
|
|
141
|
+
|
|
142
|
+
|
|
99
143
|
def test_broken_bind_keeps_fallback_evidence_separate_from_reference_gap() -> None:
|
|
100
144
|
evidence = _native_codex_evidence()
|
|
101
145
|
evidence["fallback"] = {
|
|
@@ -40,7 +40,7 @@ Work a disagreement in this order: keep the observed evidence in the record, nam
|
|
|
40
40
|
|
|
41
41
|
## Recovery record
|
|
42
42
|
|
|
43
|
-
Keep one versioned `evidence` object in the session-controlled `model-tier-run.json` record. A missing optional reference belongs in `reference.status`; it keeps the bind result independent. `fallback_kind: "broken"` identifies a bind or reply failure. On a Codex host, a successful native route records `selected_tier: "Astra"
|
|
43
|
+
Keep one versioned `evidence` object in the session-controlled `model-tier-run.json` record. A missing optional reference belongs in `reference.status`; it keeps the bind result independent. `fallback_kind: "broken"` identifies a bind or reply failure. On a Codex host, a successful native route records `selected_tier: "Astra"`, `reply_path: "native"`, and `advisor_flag`. The value is `"passed"` when the spawn tool offers the `--advisor` flag and the spawn passed it, or `"unavailable"` when the spawn tool has no flag field. `"omitted"` fails the bind.
|
|
44
44
|
|
|
45
45
|
```json
|
|
46
46
|
{
|
|
@@ -56,7 +56,8 @@ Keep one versioned `evidence` object in the session-controlled `model-tier-run.j
|
|
|
56
56
|
"selected_tier": "Astra",
|
|
57
57
|
"fallback_kind": null,
|
|
58
58
|
"fallback_reason": null,
|
|
59
|
-
"reply_path": "native"
|
|
59
|
+
"reply_path": "native",
|
|
60
|
+
"advisor_flag": "passed"
|
|
60
61
|
},
|
|
61
62
|
"consult": {
|
|
62
63
|
"changed_evidence": ["native bind returned a reply"],
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Second Claude account
|
|
2
|
+
|
|
3
|
+
A second Claude subscription runs agents on the same machine as the main one. It
|
|
4
|
+
shares every skill, rule, plugin, setting and doc in the main Claude home, and it
|
|
5
|
+
keeps its own sign-in and history.
|
|
6
|
+
|
|
7
|
+
## Pieces
|
|
8
|
+
|
|
9
|
+
| File | What it does |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `scripts/claude_account_profile.py` | Links each shared entry of `~/.claude` into the profile `~/.claude-profiles/ev`, moves stale copies into `.replaced/<time>/`, and writes the `claude-ev.cmd` launcher into `~/.local/bin` |
|
|
12
|
+
| `scripts/claude_account_choice.py` | Reads both accounts' 5-hour and weekly meters and prints which account a job runs on |
|
|
13
|
+
| `scripts/dev_env_scripts_constants/claude_account_constants.py` | The profile name, the entries that stay per account, and the picker thresholds |
|
|
14
|
+
|
|
15
|
+
The launcher sets `CLAUDE_CONFIG_DIR` to the profile and passes every argument to
|
|
16
|
+
`claude`, so `claude-ev -p "..."` runs like `claude -p "..."` on the second
|
|
17
|
+
account.
|
|
18
|
+
|
|
19
|
+
These entries stay per account and are never linked: `.credentials.json` and
|
|
20
|
+
`.claude.json` with their backups, `projects`, `sessions`, `todos`, `history.jsonl`,
|
|
21
|
+
and the other state folders named in the constants file.
|
|
22
|
+
|
|
23
|
+
## Sign in once
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
python packages/claude-dev-env/scripts/claude_account_profile.py
|
|
27
|
+
claude-ev auth login
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The sign-in lives in `~/.claude-profiles/ev/.credentials.json` on that machine.
|
|
31
|
+
Claude refreshes it on each run.
|
|
32
|
+
|
|
33
|
+
## Which account a job uses
|
|
34
|
+
|
|
35
|
+
The main account belongs to the person who works on it, so a job borrows it only
|
|
36
|
+
to spend leftover usage that expires soon.
|
|
37
|
+
|
|
38
|
+
| Condition | Account |
|
|
39
|
+
|---|---|
|
|
40
|
+
| Main week resets within 24 hours, main under 90% of its week, main under 50% of its 5-hour window | main |
|
|
41
|
+
| Otherwise, second under 95% of its week and under 90% of its 5-hour window | second |
|
|
42
|
+
| Second meter unreadable | second, whose own run refreshes the sign-in |
|
|
43
|
+
| Otherwise | wait, with the next reset time |
|
|
44
|
+
|
|
45
|
+
An unreadable main meter never picks main.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
python packages/claude-dev-env/scripts/claude_account_choice.py
|
|
49
|
+
{"account": "second", "config_dir": "C:\\Users\\me\\.claude-profiles\\ev", "reason": "...",
|
|
50
|
+
"meters": {"main": {"session_used_percent": 12.0, "session_resets_at": "...",
|
|
51
|
+
"weekly_used_percent": 34.0, "weekly_resets_at": "..."},
|
|
52
|
+
"second": null}}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The `meters` object carries each account's used percent and reset time for the
|
|
56
|
+
5-hour window and the week. An account whose meter could not be read shows `null`.
|
|
57
|
+
A usage report reads both accounts from this one call.
|
|
@@ -24,6 +24,7 @@ SYNTHETIC_AWS_KEY = "AKIA" + ("B" * 16)
|
|
|
24
24
|
SYNTHETIC_PEM_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
|
|
25
25
|
SYNTHETIC_REAL_EMAIL = "person.fixture@company-example.io"
|
|
26
26
|
SYNTHETIC_SAFE_EMAIL = "user@example.com"
|
|
27
|
+
SYNTHETIC_GITHUB_NOREPLY_EMAIL = "24366590+octocat@users.noreply.github.com"
|
|
27
28
|
SYNTHETIC_HOME_PATH = r"C:\Users\fixture_real_user\notes.txt"
|
|
28
29
|
SYNTHETIC_PLACEHOLDER_HOME = r"C:\Users\<you>\notes.txt"
|
|
29
30
|
SYNTHETIC_PRIVATE_IP = "192.168.42.17"
|
|
@@ -42,6 +43,13 @@ def test_flags_real_email_and_allows_example_domain() -> None:
|
|
|
42
43
|
assert all_safe_hits == []
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
def test_allows_github_noreply_email_and_still_flags_a_company_lookalike() -> None:
|
|
47
|
+
all_noreply_hits = scan_text_for_pii(f"commit author {SYNTHETIC_GITHUB_NOREPLY_EMAIL}")
|
|
48
|
+
all_lookalike_hits = scan_text_for_pii("contact person@users.noreply.github.com.evil.io")
|
|
49
|
+
assert all_noreply_hits == []
|
|
50
|
+
assert any(each.category == "email" for each in all_lookalike_hits)
|
|
51
|
+
|
|
52
|
+
|
|
45
53
|
def test_flags_home_path_and_allows_placeholder_user() -> None:
|
|
46
54
|
all_home_hits = scan_text_for_pii(f"path is {SYNTHETIC_HOME_PATH}")
|
|
47
55
|
all_placeholder_hits = scan_text_for_pii(f"path is {SYNTHETIC_PLACEHOLDER_HOME}")
|
package/hooks/hooks.json
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
"description": "Code standards enforcement, safety guards, and development workflow hooks",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Agent|Task",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
|
|
11
|
+
"timeout": 10
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
5
15
|
{
|
|
6
16
|
"matcher": "multi_agent_v1__spawn_agent",
|
|
7
17
|
"hooks": [
|
|
@@ -51,6 +61,11 @@
|
|
|
51
61
|
"type": "command",
|
|
52
62
|
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/style_reminder_prompt.py",
|
|
53
63
|
"timeout": 10
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"type": "command",
|
|
67
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
|
|
68
|
+
"timeout": 10
|
|
54
69
|
}
|
|
55
70
|
]
|
|
56
71
|
}
|
|
@@ -95,6 +110,16 @@
|
|
|
95
110
|
"timeout": 10
|
|
96
111
|
}
|
|
97
112
|
]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"matcher": "compact",
|
|
116
|
+
"hooks": [
|
|
117
|
+
{
|
|
118
|
+
"type": "command",
|
|
119
|
+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/session/skill_loaded_reminder.py",
|
|
120
|
+
"timeout": 10
|
|
121
|
+
}
|
|
122
|
+
]
|
|
98
123
|
}
|
|
99
124
|
],
|
|
100
125
|
"SessionEnd": [
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Texts and names for the skill_loaded_reminder hook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ALL_SELF_LOADING_SUBAGENT_TYPES",
|
|
7
|
+
"ALL_SUBAGENT_TOOL_NAMES",
|
|
8
|
+
"ASSISTANT_ENTRY_TYPE",
|
|
9
|
+
"COMPACT_BOUNDARY_SUBTYPE",
|
|
10
|
+
"COMPACTION_REMINDER",
|
|
11
|
+
"COMPACTION_SOURCE",
|
|
12
|
+
"NOT_LOADED_REMINDER",
|
|
13
|
+
"POTETO_MODE_SKILL_NAME",
|
|
14
|
+
"PRE_TOOL_USE_EVENT_NAME",
|
|
15
|
+
"PROMPT_SEPARATOR",
|
|
16
|
+
"SESSION_START_EVENT_NAME",
|
|
17
|
+
"SKILL_TOOL_NAME",
|
|
18
|
+
"SLASH_COMMAND_MARKER",
|
|
19
|
+
"SUBAGENT_PROMPT_PREFIX",
|
|
20
|
+
"TOOL_USE_BLOCK_TYPE",
|
|
21
|
+
"USER_ENTRY_TYPE",
|
|
22
|
+
"USER_PROMPT_SUBMIT_EVENT_NAME",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
PRE_TOOL_USE_EVENT_NAME = "PreToolUse"
|
|
26
|
+
SESSION_START_EVENT_NAME = "SessionStart"
|
|
27
|
+
USER_PROMPT_SUBMIT_EVENT_NAME = "UserPromptSubmit"
|
|
28
|
+
COMPACTION_SOURCE = "compact"
|
|
29
|
+
|
|
30
|
+
ALL_SUBAGENT_TOOL_NAMES = frozenset({"Agent", "Task"})
|
|
31
|
+
ALL_SELF_LOADING_SUBAGENT_TYPES = frozenset({"pstack:poteto-agent", "poteto-agent"})
|
|
32
|
+
|
|
33
|
+
POTETO_MODE_SKILL_NAME = "pstack:poteto-mode"
|
|
34
|
+
SKILL_TOOL_NAME = "Skill"
|
|
35
|
+
SLASH_COMMAND_MARKER = "<command-name>/pstack:poteto-mode</command-name>"
|
|
36
|
+
PROMPT_SEPARATOR = "\n\n"
|
|
37
|
+
|
|
38
|
+
ASSISTANT_ENTRY_TYPE = "assistant"
|
|
39
|
+
USER_ENTRY_TYPE = "user"
|
|
40
|
+
TOOL_USE_BLOCK_TYPE = "tool_use"
|
|
41
|
+
COMPACT_BOUNDARY_SUBTYPE = "compact_boundary"
|
|
42
|
+
|
|
43
|
+
SUBAGENT_PROMPT_PREFIX = (
|
|
44
|
+
"Before any other work, invoke the pstack:poteto-mode skill with the Skill tool. "
|
|
45
|
+
"Every prompt you write for a subagent opens with the same instruction."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
NOT_LOADED_REMINDER = (
|
|
49
|
+
"The pstack:poteto-mode skill is not loaded in this context. "
|
|
50
|
+
"Your next tool call is the Skill tool with skill pstack:poteto-mode, before any other "
|
|
51
|
+
"tool call."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
COMPACTION_REMINDER = (
|
|
55
|
+
"The context was just compacted and the pstack:poteto-mode skill rules were dropped. "
|
|
56
|
+
"Your next tool call is the Skill tool with skill pstack:poteto-mode, before any other "
|
|
57
|
+
"tool call. Then re-read the request that started this session and continue."
|
|
58
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse, SessionStart and UserPromptSubmit hook. Keeps poteto-mode loaded in every context.
|
|
3
|
+
|
|
4
|
+
::
|
|
5
|
+
|
|
6
|
+
Agent tool call, prompt lacks the skill -> prompt opens with "invoke pstack:poteto-mode"
|
|
7
|
+
context compacted mid-run -> "invoke pstack:poteto-mode again"
|
|
8
|
+
user turn, skill not loaded since compacting -> "invoke pstack:poteto-mode now"
|
|
9
|
+
user turn, skill already loaded -> nothing
|
|
10
|
+
|
|
11
|
+
A session that loaded the skill hears nothing more until a compaction drops it.
|
|
12
|
+
The hook prints its output and stops.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
23
|
+
if _hooks_dir not in sys.path:
|
|
24
|
+
sys.path.insert(0, _hooks_dir)
|
|
25
|
+
|
|
26
|
+
from hooks_constants.skill_loaded_reminder_constants import (
|
|
27
|
+
ALL_SELF_LOADING_SUBAGENT_TYPES,
|
|
28
|
+
ALL_SUBAGENT_TOOL_NAMES,
|
|
29
|
+
ASSISTANT_ENTRY_TYPE,
|
|
30
|
+
COMPACT_BOUNDARY_SUBTYPE,
|
|
31
|
+
COMPACTION_REMINDER,
|
|
32
|
+
COMPACTION_SOURCE,
|
|
33
|
+
NOT_LOADED_REMINDER,
|
|
34
|
+
POTETO_MODE_SKILL_NAME,
|
|
35
|
+
PRE_TOOL_USE_EVENT_NAME,
|
|
36
|
+
PROMPT_SEPARATOR,
|
|
37
|
+
SESSION_START_EVENT_NAME,
|
|
38
|
+
SKILL_TOOL_NAME,
|
|
39
|
+
SLASH_COMMAND_MARKER,
|
|
40
|
+
SUBAGENT_PROMPT_PREFIX,
|
|
41
|
+
TOOL_USE_BLOCK_TYPE,
|
|
42
|
+
USER_ENTRY_TYPE,
|
|
43
|
+
USER_PROMPT_SUBMIT_EVENT_NAME,
|
|
44
|
+
)
|
|
45
|
+
from hooks_constants.pre_tool_use_allow_output import write_pre_tool_use_allow_to_stdout
|
|
46
|
+
from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
|
|
47
|
+
from hooks_constants.setup_project_paths_constants import DECODE_ERRORS_POLICY, UTF8_ENCODING
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def subagent_input_with_poteto_mode(
|
|
51
|
+
all_tool_input_fields: dict[str, object],
|
|
52
|
+
) -> dict[str, object] | None:
|
|
53
|
+
"""Return the Agent tool input with the poteto-mode instruction first, or None to leave it.
|
|
54
|
+
|
|
55
|
+
::
|
|
56
|
+
|
|
57
|
+
{"prompt": "Reply leaf."} -> {"prompt": "Before any ...\\n\\nReply leaf."}
|
|
58
|
+
{"prompt": "Invoke pstack:poteto-mode, then ..."} -> None
|
|
59
|
+
{"subagent_type": "pstack:poteto-agent", ...} -> None, that agent loads the skill itself
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
all_tool_input_fields: The Agent tool input the session is about to send.
|
|
63
|
+
"""
|
|
64
|
+
prompt = all_tool_input_fields.get("prompt")
|
|
65
|
+
if not isinstance(prompt, str) or POTETO_MODE_SKILL_NAME in prompt:
|
|
66
|
+
return None
|
|
67
|
+
if all_tool_input_fields.get("subagent_type") in ALL_SELF_LOADING_SUBAGENT_TYPES:
|
|
68
|
+
return None
|
|
69
|
+
return {**all_tool_input_fields, "prompt": SUBAGENT_PROMPT_PREFIX + PROMPT_SEPARATOR + prompt}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _invokes_poteto_mode(all_entry_fields: dict[str, object]) -> bool:
|
|
73
|
+
message = all_entry_fields.get("message")
|
|
74
|
+
if not isinstance(message, dict):
|
|
75
|
+
return False
|
|
76
|
+
content_blocks = message.get("content")
|
|
77
|
+
if all_entry_fields.get("type") == USER_ENTRY_TYPE and isinstance(content_blocks, str):
|
|
78
|
+
return SLASH_COMMAND_MARKER in content_blocks
|
|
79
|
+
if all_entry_fields.get("type") != ASSISTANT_ENTRY_TYPE or not isinstance(content_blocks, list):
|
|
80
|
+
return False
|
|
81
|
+
return any(
|
|
82
|
+
isinstance(each_block, dict)
|
|
83
|
+
and each_block.get("type") == TOOL_USE_BLOCK_TYPE
|
|
84
|
+
and each_block.get("name") == SKILL_TOOL_NAME
|
|
85
|
+
and isinstance(each_block.get("input"), dict)
|
|
86
|
+
and each_block["input"].get("skill") == POTETO_MODE_SKILL_NAME
|
|
87
|
+
for each_block in content_blocks
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _marker_entry(transcript_line: str) -> dict[str, object] | None:
|
|
92
|
+
if (
|
|
93
|
+
COMPACT_BOUNDARY_SUBTYPE not in transcript_line
|
|
94
|
+
and POTETO_MODE_SKILL_NAME not in transcript_line
|
|
95
|
+
):
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
parsed_entry = json.loads(transcript_line)
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
return None
|
|
101
|
+
return parsed_entry if isinstance(parsed_entry, dict) else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_loaded_after(all_entry_fields: dict[str, object], was_loaded: bool) -> bool:
|
|
105
|
+
if all_entry_fields.get("subtype") == COMPACT_BOUNDARY_SUBTYPE:
|
|
106
|
+
return False
|
|
107
|
+
return was_loaded or _invokes_poteto_mode(all_entry_fields)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_poteto_mode_loaded(all_transcript_lines: Iterable[str]) -> bool:
|
|
111
|
+
"""Return True when the transcript invoked the skill after its last compaction.
|
|
112
|
+
|
|
113
|
+
::
|
|
114
|
+
|
|
115
|
+
Skill(pstack:poteto-mode) ... Read ... Edit -> True
|
|
116
|
+
/pstack:poteto-mode typed as a command ... Read -> True
|
|
117
|
+
Skill(pstack:poteto-mode) ... compact_boundary ... Read -> False
|
|
118
|
+
Read ... Edit -> False
|
|
119
|
+
|
|
120
|
+
Only lines naming the skill or a compaction are parsed, so a long transcript
|
|
121
|
+
reads fast.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
all_transcript_lines: The session transcript, one JSON entry per line.
|
|
125
|
+
"""
|
|
126
|
+
is_loaded = False
|
|
127
|
+
for each_line in all_transcript_lines:
|
|
128
|
+
all_entry_fields = _marker_entry(each_line)
|
|
129
|
+
if all_entry_fields is not None:
|
|
130
|
+
is_loaded = _is_loaded_after(all_entry_fields, is_loaded)
|
|
131
|
+
return is_loaded
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_loaded_in_transcript(transcript_path: object) -> bool:
|
|
135
|
+
if not isinstance(transcript_path, str):
|
|
136
|
+
return False
|
|
137
|
+
try:
|
|
138
|
+
with open(
|
|
139
|
+
transcript_path, encoding=UTF8_ENCODING, errors=DECODE_ERRORS_POLICY
|
|
140
|
+
) as transcript:
|
|
141
|
+
return is_poteto_mode_loaded(transcript)
|
|
142
|
+
except OSError:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def reminder_for(all_hook_fields: dict[str, object]) -> str | None:
|
|
147
|
+
"""Return the reminder a session event carries, or None when the skill is in view.
|
|
148
|
+
|
|
149
|
+
::
|
|
150
|
+
|
|
151
|
+
SessionStart, source compact -> COMPACTION_REMINDER
|
|
152
|
+
UserPromptSubmit, transcript lacks the skill -> NOT_LOADED_REMINDER
|
|
153
|
+
UserPromptSubmit, transcript loaded the skill -> None
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
all_hook_fields: The parsed hook input.
|
|
157
|
+
"""
|
|
158
|
+
event_name = all_hook_fields.get("hook_event_name")
|
|
159
|
+
if event_name == SESSION_START_EVENT_NAME:
|
|
160
|
+
return COMPACTION_REMINDER if all_hook_fields.get("source") == COMPACTION_SOURCE else None
|
|
161
|
+
if event_name != USER_PROMPT_SUBMIT_EVENT_NAME:
|
|
162
|
+
return None
|
|
163
|
+
if _is_loaded_in_transcript(all_hook_fields.get("transcript_path")):
|
|
164
|
+
return None
|
|
165
|
+
return NOT_LOADED_REMINDER
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_subagent_spawn(all_hook_fields: dict[str, object]) -> bool:
|
|
169
|
+
return (
|
|
170
|
+
all_hook_fields.get("hook_event_name") == PRE_TOOL_USE_EVENT_NAME
|
|
171
|
+
and all_hook_fields.get("tool_name") in ALL_SUBAGENT_TOOL_NAMES
|
|
172
|
+
and isinstance(all_hook_fields.get("tool_input"), dict)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main() -> None:
|
|
177
|
+
"""Rewrite a subagent prompt, or print the reminder its event needs, or stay quiet."""
|
|
178
|
+
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
179
|
+
if hook_payload is None:
|
|
180
|
+
return
|
|
181
|
+
if _is_subagent_spawn(hook_payload):
|
|
182
|
+
rewritten_input = subagent_input_with_poteto_mode(hook_payload["tool_input"])
|
|
183
|
+
if rewritten_input is not None:
|
|
184
|
+
write_pre_tool_use_allow_to_stdout(rewritten_input)
|
|
185
|
+
return
|
|
186
|
+
reminder = reminder_for(hook_payload)
|
|
187
|
+
if reminder is None:
|
|
188
|
+
return
|
|
189
|
+
reminder_output = {
|
|
190
|
+
"hookSpecificOutput": {
|
|
191
|
+
"hookEventName": hook_payload["hook_event_name"],
|
|
192
|
+
"additionalContext": reminder,
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
sys.stdout.write(json.dumps(reminder_output) + "\n")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|