claude-dev-env 8.6.1 → 8.8.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 (119) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/.agents/skills/team-advisor/SKILL.md +2 -2
  3. package/_shared/advisor/reference/identity.md +1 -1
  4. package/_shared/advisor/reference/spawn-walk-log.md +1 -1
  5. package/_shared/advisor/reference/warm-up.md +3 -1
  6. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +16 -0
  7. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -5
  8. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +85 -6
  9. package/_shared/pr-loop/scripts/terminology_sweep.py +10 -9
  10. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -0
  11. package/bin/install.test.mjs +25 -0
  12. package/docs/high-trust-agent-delivery.md +132 -0
  13. package/docs/references/advisor-tool.md +3 -2
  14. package/docs/second-claude-account.md +50 -0
  15. package/hooks/advisory/refactor_guard.py +2 -2
  16. package/hooks/advisory/test_refactor_guard.py +21 -0
  17. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  18. package/hooks/blocking/code_rules_shared.py +4 -7
  19. package/hooks/blocking/conftest.py +22 -0
  20. package/hooks/blocking/state_description_blocker.py +10 -8
  21. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  22. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +4 -29
  23. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  24. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  25. package/hooks/blocking/test_code_rules_shared.py +18 -0
  26. package/hooks/blocking/test_conftest.py +35 -0
  27. package/hooks/blocking/test_pii_scanner.py +8 -0
  28. package/hooks/blocking/test_state_description_blocker.py +16 -0
  29. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  30. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  31. package/hooks/git-hooks/test_config.py +10 -3
  32. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  33. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  34. package/hooks/git-hooks/verification_notice_state.py +3 -1
  35. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  36. package/hooks/hooks_constants/pii_prevention_constants.py +1 -0
  37. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  38. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  39. package/hooks/hooks_constants/state_description_blocker_constants.py +0 -3
  40. package/hooks/hooks_constants/test_state_description_blocker_constants.py +9 -7
  41. package/hooks/validators/exempt_paths.py +3 -18
  42. package/hooks/validators/test_exempt_paths.py +20 -0
  43. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  44. package/package.json +1 -1
  45. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  46. package/rules/archiving-agent-config.md +10 -0
  47. package/rules/code-standards.md +1 -0
  48. package/rules/correction-lens.md +134 -0
  49. package/rules/flag-non-breaking-findings.md +11 -0
  50. package/rules/long-horizon-autonomy.md +11 -0
  51. package/rules/no-contrast-framing.md +68 -0
  52. package/rules/review-closure-is-a-check.md +44 -0
  53. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  54. package/scripts/agent_merge_check.py +381 -0
  55. package/scripts/check.ps1 +12 -1
  56. package/scripts/ci/install-playtest.mjs +291 -0
  57. package/scripts/ci/install-playtest.test.mjs +84 -0
  58. package/scripts/ci/installer-lifecycle.mjs +11 -30
  59. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  60. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  61. package/scripts/ci/scratch-home-install.mjs +73 -0
  62. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  63. package/scripts/claude_account_choice.py +246 -0
  64. package/scripts/claude_account_profile.py +371 -0
  65. package/scripts/claude_chain_usage.py +49 -5
  66. package/scripts/codex_compat_materializer.py +33 -16
  67. package/scripts/contrast_framing.py +101 -0
  68. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  69. package/scripts/dev_env_scripts_constants/claude_account_constants.py +166 -0
  70. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  71. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  72. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  73. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +21 -0
  74. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  75. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  76. package/scripts/durable_post_lint.py +30 -4
  77. package/scripts/followup_cli.py +6 -10
  78. package/scripts/merge_gate_checks.py +236 -0
  79. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  80. package/scripts/policy_lint/adapters.py +30 -0
  81. package/scripts/policy_lint/config/constants.py +2 -0
  82. package/scripts/policy_lint/registry.py +5 -0
  83. package/scripts/repository_checks/config/constants.py +12 -0
  84. package/scripts/repository_checks/config/policy_document.py +28 -2
  85. package/scripts/repository_checks/followups.py +54 -0
  86. package/scripts/repository_checks/match_exemptions.py +67 -0
  87. package/scripts/repository_checks/models.py +46 -2
  88. package/scripts/repository_checks/reporting.py +23 -7
  89. package/scripts/repository_checks/tracked_secrets.py +2 -2
  90. package/scripts/repository_policy.py +4 -2
  91. package/scripts/review_closure.py +134 -0
  92. package/scripts/review_closure_github.py +345 -0
  93. package/scripts/review_closure_model.py +360 -0
  94. package/scripts/test_agent_merge_check.py +317 -0
  95. package/scripts/test_claude_account_choice.py +217 -0
  96. package/scripts/test_claude_account_profile.py +220 -0
  97. package/scripts/test_claude_chain_usage.py +40 -0
  98. package/scripts/test_durable_post_lint.py +46 -0
  99. package/scripts/test_followup_cli.py +40 -0
  100. package/scripts/test_merge_gate_checks.py +92 -0
  101. package/scripts/tests/test_code_review_constants.py +78 -0
  102. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  103. package/scripts/tests/test_contrast_framing.py +117 -0
  104. package/scripts/tests/test_followups.py +88 -0
  105. package/scripts/tests/test_match_exemptions.py +291 -0
  106. package/scripts/tests/test_models.py +67 -2
  107. package/scripts/tests/test_package_inventory.py +6 -4
  108. package/scripts/tests/test_pester_suites_run.py +53 -0
  109. package/scripts/tests/test_policy_document.py +39 -1
  110. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  111. package/scripts/tests/test_reporting.py +18 -0
  112. package/scripts/tests/test_repository_policy.py +43 -1
  113. package/scripts/tests/test_review_closure.py +126 -0
  114. package/scripts/tests/test_review_closure_github.py +240 -0
  115. package/scripts/tests/test_review_closure_model.py +299 -0
  116. package/scripts/tests/test_tracked_secrets.py +42 -0
  117. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  118. package/scripts/repository_checks/email_exemptions.py +0 -39
  119. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -0,0 +1,291 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Install playtest: run the shipped package the way a session loads it.
4
+ *
5
+ * The driver installs this package into a scratch home, reads the hook roster
6
+ * the install wrote into `settings.json`, starts one SessionStart hook and one
7
+ * PreToolUse hook from that installed tree against fixture payloads, and reads
8
+ * the output envelope each one returns.
9
+ *
10
+ * Every stage writes the envelope it read to the evidence directory and prints
11
+ * one line carrying the stage name, the artifact path and the artifact's
12
+ * SHA-256 digest::
13
+ *
14
+ * every stage read the envelope it expected -> exit 0
15
+ * one stage read something else -> exit 1
16
+ *
17
+ * Usage::
18
+ *
19
+ * node scripts/ci/install-playtest.mjs
20
+ * node scripts/ci/install-playtest.mjs --home <dir> --evidence <dir>
21
+ * node scripts/ci/install-playtest.mjs --home <dir> --skip-install
22
+ *
23
+ * `--home` names the scratch home to install into and keeps it after the run.
24
+ * `--skip-install` grades the tree already installed in that home, which is how
25
+ * a broken install is read back.
26
+ */
27
+
28
+ import { spawnSync } from 'node:child_process';
29
+ import { createHash } from 'node:crypto';
30
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
31
+ import { tmpdir } from 'node:os';
32
+ import { join } from 'node:path';
33
+ import { pathToFileURL } from 'node:url';
34
+ import {
35
+ createScratchHome,
36
+ removeScratchHome,
37
+ runInstaller,
38
+ scratchHomeEnvironment,
39
+ SANDBOX_GIT_CONFIG_BODY,
40
+ SANDBOX_GIT_CONFIG_NAME,
41
+ } from './scratch-home-install.mjs';
42
+
43
+ export const EVIDENCE_PREFIX = 'cde-playtest-';
44
+ export const SETTINGS_RELATIVE_PATH = join('.claude', 'settings.json');
45
+ export const HOOK_TIMEOUT_MILLISECONDS = 60_000;
46
+ export const PASS_MARK = 'PASS';
47
+ export const FAIL_MARK = 'FAIL';
48
+ export const DIGEST_ALGORITHM = 'sha256';
49
+ export const INSTALL_STAGE_NAME = 'install';
50
+ export const SESSION_START_STAGE_NAME = 'session_start_hook';
51
+ export const BLOCKING_STAGE_NAME = 'blocking_hook';
52
+ export const SESSION_START_EVENT_NAME = 'SessionStart';
53
+ export const PRE_TOOL_USE_EVENT_NAME = 'PreToolUse';
54
+ export const SESSION_START_HOOK_SCRIPT_NAME = 'working_style_prompt.py';
55
+ export const BASH_HOOK_SCRIPT_NAME = 'bash_pre_tool_use_dispatcher.py';
56
+ export const BASH_MATCHER = 'Bash';
57
+ export const ALLOW_DECISION = 'allow';
58
+
59
+ export const SESSION_START_PAYLOAD = {
60
+ hook_event_name: SESSION_START_EVENT_NAME,
61
+ source: 'startup',
62
+ };
63
+
64
+ export const BASH_HOOK_PAYLOAD = {
65
+ tool_name: BASH_MATCHER,
66
+ tool_input: { command: 'git show origin/main:.claude/settings.json' },
67
+ };
68
+
69
+ export const REWRITE_MARKER = 'MSYS2_ARG_CONV_EXCL';
70
+
71
+ /**
72
+ * Parse the driver's arguments.
73
+ *
74
+ * @param {string[]} argv Arguments after the script name.
75
+ * @returns {{homePath: string | null, evidencePath: string | null, shouldSkipInstall: boolean}} The parsed options.
76
+ */
77
+ export function parseArguments(argv) {
78
+ let homePath = null;
79
+ let evidencePath = null;
80
+ let shouldSkipInstall = false;
81
+ for (let index = 0; index < argv.length; index += 1) {
82
+ const token = argv[index];
83
+ if (token === '--home') {
84
+ homePath = argv[index + 1] ?? null;
85
+ index += 1;
86
+ } else if (token === '--evidence') {
87
+ evidencePath = argv[index + 1] ?? null;
88
+ index += 1;
89
+ } else if (token === '--skip-install') {
90
+ shouldSkipInstall = true;
91
+ }
92
+ }
93
+ return { homePath, evidencePath, shouldSkipInstall };
94
+ }
95
+
96
+ /**
97
+ * Read the SHA-256 digest of a file.
98
+ *
99
+ * @param {string} filePath Path of the file to digest.
100
+ * @returns {string} The hexadecimal digest.
101
+ */
102
+ export function fileDigest(filePath) {
103
+ return createHash(DIGEST_ALGORITHM).update(readFileSync(filePath)).digest('hex');
104
+ }
105
+
106
+ /**
107
+ * Read every hook command the installed settings file declares for one event.
108
+ *
109
+ * @param {string} settingsPath Path of the installed settings file.
110
+ * @param {string} eventName Hook event name, such as `SessionStart`.
111
+ * @returns {{matcher: string, command: string}[]} Each declared hook command.
112
+ */
113
+ export function hookCommandsForEvent(settingsPath, eventName) {
114
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
115
+ const allMatcherGroups = settings.hooks?.[eventName] ?? [];
116
+ const allCommands = [];
117
+ for (const eachGroup of allMatcherGroups) {
118
+ for (const eachHook of eachGroup.hooks ?? []) {
119
+ allCommands.push({ matcher: eachGroup.matcher ?? '', command: eachHook.command });
120
+ }
121
+ }
122
+ return allCommands;
123
+ }
124
+
125
+ /**
126
+ * Select the one installed hook command a stage drives.
127
+ *
128
+ * @param {{matcher: string, command: string}[]} allCommands Declared hook commands.
129
+ * @param {string} scriptName Basename of the hook script the stage drives.
130
+ * @returns {string} The command line the install wrote.
131
+ * @throws {Error} The installed roster declares no such hook.
132
+ */
133
+ export function selectHookCommand(allCommands, scriptName) {
134
+ const selected = allCommands.find((each) => each.command.includes(scriptName));
135
+ if (!selected) {
136
+ throw new Error(`The installed hook roster declares no ${scriptName}`);
137
+ }
138
+ return selected.command;
139
+ }
140
+
141
+ /**
142
+ * Run one installed hook command against a payload and return its output.
143
+ *
144
+ * @param {string} command The command line the install wrote.
145
+ * @param {string} scratchHome Scratch home the install wrote into.
146
+ * @param {object} payload The hook payload written to standard input.
147
+ * @returns {{status: number | null, stdout: string, stderr: string}} The hook's result.
148
+ */
149
+ export function runInstalledHook(command, scratchHome, payload) {
150
+ const hookProcess = spawnSync(command, {
151
+ shell: true,
152
+ input: JSON.stringify(payload),
153
+ encoding: 'utf8',
154
+ cwd: scratchHome,
155
+ timeout: HOOK_TIMEOUT_MILLISECONDS,
156
+ env: scratchHomeEnvironment(scratchHome),
157
+ });
158
+ return {
159
+ status: hookProcess.status,
160
+ stdout: hookProcess.stdout ?? '',
161
+ stderr: hookProcess.stderr ?? '',
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Read a hook's standard output as an envelope for one event.
167
+ *
168
+ * @param {string} stdout The hook's standard output.
169
+ * @param {string} eventName The event name the envelope must name.
170
+ * @returns {object} The `hookSpecificOutput` object.
171
+ * @throws {Error} The output is not an envelope for that event.
172
+ */
173
+ export function readEnvelope(stdout, eventName) {
174
+ const trimmed = stdout.trim();
175
+ if (!trimmed) {
176
+ throw new Error(`The hook wrote no ${eventName} envelope`);
177
+ }
178
+ const parsed = JSON.parse(trimmed);
179
+ const hookSpecificOutput = parsed.hookSpecificOutput;
180
+ if (!hookSpecificOutput || hookSpecificOutput.hookEventName !== eventName) {
181
+ throw new Error(`The hook envelope names ${hookSpecificOutput?.hookEventName} rather than ${eventName}`);
182
+ }
183
+ return hookSpecificOutput;
184
+ }
185
+
186
+ function writeEvidence(evidenceDirectory, stageName, body) {
187
+ const evidencePath = join(evidenceDirectory, `${stageName}.json`);
188
+ writeFileSync(evidencePath, `${body}\n`);
189
+ return evidencePath;
190
+ }
191
+
192
+ function reportStage(stageName, evidencePath) {
193
+ process.stdout.write(`[${PASS_MARK}] playtest ${stageName} ${evidencePath} ${DIGEST_ALGORITHM}=${fileDigest(evidencePath)}\n`);
194
+ }
195
+
196
+ function reportFailure(stageName, detail) {
197
+ process.stdout.write(`[${FAIL_MARK}] playtest ${stageName} ${detail}\n`);
198
+ }
199
+
200
+ function runInstallStage(scratchHome, evidenceDirectory, shouldSkipInstall) {
201
+ if (!shouldSkipInstall) {
202
+ const install = runInstaller(scratchHome, []);
203
+ if (install.status !== 0) {
204
+ throw new Error(`The installer exited ${install.status}: ${install.stderr.trim()}`);
205
+ }
206
+ }
207
+ const settingsPath = join(scratchHome, SETTINGS_RELATIVE_PATH);
208
+ if (!existsSync(settingsPath)) {
209
+ throw new Error(`The install wrote no ${settingsPath}`);
210
+ }
211
+ const evidencePath = writeEvidence(
212
+ evidenceDirectory,
213
+ INSTALL_STAGE_NAME,
214
+ JSON.stringify({ settingsPath, digest: fileDigest(settingsPath) }),
215
+ );
216
+ reportStage(INSTALL_STAGE_NAME, evidencePath);
217
+ return settingsPath;
218
+ }
219
+
220
+ function runSessionStartStage(settingsPath, scratchHome, evidenceDirectory) {
221
+ const command = selectHookCommand(
222
+ hookCommandsForEvent(settingsPath, SESSION_START_EVENT_NAME),
223
+ SESSION_START_HOOK_SCRIPT_NAME,
224
+ );
225
+ const hookResult = runInstalledHook(command, scratchHome, SESSION_START_PAYLOAD);
226
+ const envelope = readEnvelope(hookResult.stdout, SESSION_START_EVENT_NAME);
227
+ if (typeof envelope.additionalContext !== 'string' || envelope.additionalContext.length === 0) {
228
+ throw new Error('The SessionStart envelope carries no additionalContext');
229
+ }
230
+ const evidencePath = writeEvidence(evidenceDirectory, SESSION_START_STAGE_NAME, JSON.stringify(envelope));
231
+ reportStage(SESSION_START_STAGE_NAME, evidencePath);
232
+ }
233
+
234
+ function runBlockingStage(settingsPath, scratchHome, evidenceDirectory) {
235
+ const command = selectHookCommand(
236
+ hookCommandsForEvent(settingsPath, PRE_TOOL_USE_EVENT_NAME),
237
+ BASH_HOOK_SCRIPT_NAME,
238
+ );
239
+ const hookResult = runInstalledHook(command, scratchHome, BASH_HOOK_PAYLOAD);
240
+ const envelope = readEnvelope(hookResult.stdout, PRE_TOOL_USE_EVENT_NAME);
241
+ if (envelope.permissionDecision !== ALLOW_DECISION) {
242
+ throw new Error(`The PreToolUse envelope decided ${envelope.permissionDecision}`);
243
+ }
244
+ const rewrittenCommand = envelope.updatedInput?.command ?? '';
245
+ if (!rewrittenCommand.includes(REWRITE_MARKER)) {
246
+ throw new Error(`The PreToolUse envelope left the command unrewritten: ${rewrittenCommand}`);
247
+ }
248
+ const evidencePath = writeEvidence(evidenceDirectory, BLOCKING_STAGE_NAME, JSON.stringify(envelope));
249
+ reportStage(BLOCKING_STAGE_NAME, evidencePath);
250
+ }
251
+
252
+ /**
253
+ * Run every playtest stage and return the driver's exit code.
254
+ *
255
+ * @param {string[]} argv Arguments after the script name.
256
+ * @returns {number} Zero when every stage read the envelope it expected.
257
+ */
258
+ export function runPlaytest(argv) {
259
+ const options = parseArguments(argv);
260
+ const scratchHome = options.homePath ?? createScratchHome();
261
+ if (options.homePath) {
262
+ mkdirSync(scratchHome, { recursive: true });
263
+ const gitConfigPath = join(scratchHome, SANDBOX_GIT_CONFIG_NAME);
264
+ if (!existsSync(gitConfigPath)) {
265
+ writeFileSync(gitConfigPath, SANDBOX_GIT_CONFIG_BODY);
266
+ }
267
+ }
268
+ const evidenceDirectory = options.evidencePath ?? mkdtempSync(join(tmpdir(), EVIDENCE_PREFIX));
269
+ mkdirSync(evidenceDirectory, { recursive: true });
270
+ let stageName = INSTALL_STAGE_NAME;
271
+ try {
272
+ const settingsPath = runInstallStage(scratchHome, evidenceDirectory, options.shouldSkipInstall);
273
+ stageName = SESSION_START_STAGE_NAME;
274
+ runSessionStartStage(settingsPath, scratchHome, evidenceDirectory);
275
+ stageName = BLOCKING_STAGE_NAME;
276
+ runBlockingStage(settingsPath, scratchHome, evidenceDirectory);
277
+ } catch (error) {
278
+ reportFailure(stageName, error instanceof Error ? error.message : String(error));
279
+ return 1;
280
+ } finally {
281
+ if (!options.homePath) {
282
+ removeScratchHome(scratchHome);
283
+ }
284
+ }
285
+ process.stdout.write('install playtest: every stage read its envelope\n');
286
+ return 0;
287
+ }
288
+
289
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
290
+ process.exit(runPlaytest(process.argv.slice(2)));
291
+ }
@@ -0,0 +1,84 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import { parseArguments, readEnvelope, selectHookCommand } from './install-playtest.mjs';
10
+ import { SANDBOX_GIT_CONFIG_BODY, SANDBOX_GIT_CONFIG_NAME } from './scratch-home-install.mjs';
11
+
12
+ const CI_DIRECTORY = dirname(fileURLToPath(import.meta.url));
13
+ const DRIVER_PATH = join(CI_DIRECTORY, 'install-playtest.mjs');
14
+ const DRIVER_TIMEOUT_MILLISECONDS = 300_000;
15
+ const SILENT_HOOK_SOURCE = '#!/usr/bin/env python3\nprint("")\n';
16
+
17
+ function runDriver(driverArguments) {
18
+ return spawnSync(process.execPath, [DRIVER_PATH, ...driverArguments], {
19
+ encoding: 'utf8',
20
+ timeout: DRIVER_TIMEOUT_MILLISECONDS,
21
+ });
22
+ }
23
+
24
+ test('the driver reads an envelope from every stage of a fresh install, and reports red when one stage writes none', () => {
25
+ const scratchHome = mkdtempSync(join(tmpdir(), 'cde-playtest-home-'));
26
+ const evidenceDirectory = mkdtempSync(join(tmpdir(), 'cde-playtest-evidence-'));
27
+ try {
28
+ const greenRun = runDriver(['--home', scratchHome, '--evidence', evidenceDirectory]);
29
+ assert.equal(greenRun.status, 0, `${greenRun.stdout}\n${greenRun.stderr}`);
30
+ assert.match(greenRun.stdout, /\[PASS\] playtest install .* sha256=[0-9a-f]{64}/);
31
+ assert.match(greenRun.stdout, /\[PASS\] playtest session_start_hook .* sha256=[0-9a-f]{64}/);
32
+ assert.match(greenRun.stdout, /\[PASS\] playtest blocking_hook .* sha256=[0-9a-f]{64}/);
33
+ assert.ok(
34
+ readFileSync(join(scratchHome, SANDBOX_GIT_CONFIG_NAME), 'utf8').startsWith(SANDBOX_GIT_CONFIG_BODY),
35
+ );
36
+
37
+ writeFileSync(
38
+ join(scratchHome, '.claude', 'hooks', 'session', 'working_style_prompt.py'),
39
+ SILENT_HOOK_SOURCE,
40
+ );
41
+ const redRun = runDriver(['--home', scratchHome, '--evidence', evidenceDirectory, '--skip-install']);
42
+ assert.equal(redRun.status, 1, `${redRun.stdout}\n${redRun.stderr}`);
43
+ assert.match(redRun.stdout, /\[FAIL\] playtest session_start_hook The hook wrote no SessionStart envelope/);
44
+ assert.doesNotMatch(redRun.stdout, /\[PASS\] playtest session_start_hook/);
45
+ } finally {
46
+ rmSync(scratchHome, { recursive: true, force: true });
47
+ rmSync(evidenceDirectory, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ test('parseArguments reads the home, evidence and skip-install options', () => {
52
+ assert.deepEqual(parseArguments([]), { homePath: null, evidencePath: null, shouldSkipInstall: false });
53
+ assert.deepEqual(
54
+ parseArguments(['--home', '/scratch/home', '--evidence', '/scratch/evidence', '--skip-install']),
55
+ { homePath: '/scratch/home', evidencePath: '/scratch/evidence', shouldSkipInstall: true },
56
+ );
57
+ });
58
+
59
+ test('selectHookCommand names the hook the installed roster declares', () => {
60
+ const allCommands = [
61
+ { matcher: '', command: 'python3 /home/.claude/hooks/session/task_tool_prompt.py' },
62
+ { matcher: '', command: 'python3 /home/.claude/hooks/session/working_style_prompt.py' },
63
+ ];
64
+ assert.equal(
65
+ selectHookCommand(allCommands, 'working_style_prompt.py'),
66
+ 'python3 /home/.claude/hooks/session/working_style_prompt.py',
67
+ );
68
+ assert.throws(
69
+ () => selectHookCommand(allCommands, 'absent_hook.py'),
70
+ /declares no absent_hook\.py/,
71
+ );
72
+ });
73
+
74
+ test('readEnvelope refuses output that names another event or no envelope at all', () => {
75
+ const sessionStartOutput = JSON.stringify({
76
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'guidance' },
77
+ });
78
+ assert.equal(readEnvelope(sessionStartOutput, 'SessionStart').additionalContext, 'guidance');
79
+ assert.throws(() => readEnvelope(' ', 'SessionStart'), /wrote no SessionStart envelope/);
80
+ assert.throws(
81
+ () => readEnvelope(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse' } }), 'SessionStart'),
82
+ /names PreToolUse rather than SessionStart/,
83
+ );
84
+ });
@@ -1,23 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawnSync } from 'node:child_process';
4
3
  import {
5
- mkdtempSync,
6
4
  existsSync,
7
5
  lstatSync,
8
6
  readFileSync,
9
7
  realpathSync,
10
- writeFileSync,
11
8
  readdirSync,
12
- rmSync,
13
9
  } from 'node:fs';
14
- import { join, resolve, dirname } from 'node:path';
15
- import { tmpdir } from 'node:os';
16
- import { fileURLToPath } from 'node:url';
17
-
18
- const DRIVER_DIRECTORY = dirname(fileURLToPath(import.meta.url));
19
- const PACKAGE_ROOT = resolve(DRIVER_DIRECTORY, '..', '..');
20
- const INSTALL_ENTRY = join(PACKAGE_ROOT, 'bin', 'install.mjs');
10
+ import { join } from 'node:path';
11
+ import {
12
+ INSTALL_ENTRY,
13
+ SANDBOX_GIT_CONFIG_NAME,
14
+ createScratchHome,
15
+ removeScratchHome,
16
+ runInstaller,
17
+ } from './scratch-home-install.mjs';
21
18
 
22
19
  const checks = [];
23
20
 
@@ -41,25 +38,9 @@ function collectFilesRecursive(directory) {
41
38
  return collected;
42
39
  }
43
40
 
44
- function runInstaller(sandboxHome, installerArguments) {
45
- const gitConfigGlobal = join(sandboxHome, '.gitconfig-sandbox');
46
- const installerProcess = spawnSync(process.execPath, [INSTALL_ENTRY, ...installerArguments], {
47
- cwd: PACKAGE_ROOT,
48
- encoding: 'utf8',
49
- env: {
50
- ...process.env,
51
- HOME: sandboxHome,
52
- USERPROFILE: sandboxHome,
53
- GIT_CONFIG_GLOBAL: gitConfigGlobal,
54
- },
55
- });
56
- return installerProcess;
57
- }
58
-
59
41
  function main() {
60
- const sandboxHome = mkdtempSync(join(tmpdir(), 'cde-driver-'));
42
+ const sandboxHome = createScratchHome();
61
43
  const claudeHome = join(sandboxHome, '.claude');
62
- writeFileSync(join(sandboxHome, '.gitconfig-sandbox'), '[safe]\n\tdirectory = *\n');
63
44
  console.log(`Sandbox HOME: ${sandboxHome}`);
64
45
  console.log(`Install entry: ${INSTALL_ENTRY}\n`);
65
46
 
@@ -169,7 +150,7 @@ function main() {
169
150
  }
170
151
  record('settings.json carries managed hooks', managedHookCount > 0, `${managedHookCount} hook commands`);
171
152
 
172
- const gitConfigGlobalPath = join(sandboxHome, '.gitconfig-sandbox');
153
+ const gitConfigGlobalPath = join(sandboxHome, SANDBOX_GIT_CONFIG_NAME);
173
154
  const gitHooksPathRedirectedIntoSandbox =
174
155
  existsSync(gitConfigGlobalPath) &&
175
156
  readFileSync(gitConfigGlobalPath, 'utf8').replace(/\\/g, '/').includes('/.claude/hooks/git-hooks');
@@ -195,7 +176,7 @@ function main() {
195
176
  sampleInstalledFile ? sampleInstalledFile : 'no sample file captured',
196
177
  );
197
178
  } finally {
198
- rmSync(sandboxHome, { recursive: true, force: true });
179
+ removeScratchHome(sandboxHome);
199
180
  console.log(`\nSandbox removed: ${sandboxHome}`);
200
181
  }
201
182
 
@@ -24,3 +24,17 @@ test('the Windows adapter invokes the CI-owned lifecycle driver', () => {
24
24
  assert.match(adapterSource, /\$driverPath = Join-Path \$PSScriptRoot 'installer-lifecycle\.mjs'/);
25
25
  assert.doesNotMatch(adapterSource, /skills\\run-claude-dev-env/);
26
26
  });
27
+
28
+ test('the lifecycle driver builds its sandbox through the shared scratch-home helper', () => {
29
+ const driverSource = readFileSync(DRIVER_PATH, 'utf8');
30
+ assert.match(driverSource, /from '\.\/scratch-home-install\.mjs'/);
31
+ assert.match(driverSource, /createScratchHome\(\)/);
32
+ assert.match(driverSource, /removeScratchHome\(sandboxHome\)/);
33
+ assert.doesNotMatch(driverSource, /spawnSync/);
34
+ });
35
+
36
+ test('the lifecycle driver reads the sandbox git-config name from the shared constant', () => {
37
+ const driverSource = readFileSync(DRIVER_PATH, 'utf8');
38
+ assert.match(driverSource, /SANDBOX_GIT_CONFIG_NAME/);
39
+ assert.doesNotMatch(driverSource, /'\.gitconfig-sandbox'/);
40
+ });
@@ -0,0 +1,33 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import test from 'node:test';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const CI_DIRECTORY = dirname(fileURLToPath(import.meta.url));
8
+ const SHIM_PATH = join(CI_DIRECTORY, '..', 'windows', 'pstack', 'pstack-command-shim.ps1');
9
+ const shimSource = readFileSync(SHIM_PATH, 'utf8');
10
+
11
+ test('the shim hands every argument after the command to the entrypoint', () => {
12
+ assert.match(shimSource, /\$commandArguments = @\(\$args \| Select-Object -Skip 1\)/);
13
+ assert.match(shimSource, /& bun \$entrypoint @commandArguments/);
14
+ });
15
+
16
+ test('the shim validates the command without a parameter binder', () => {
17
+ assert.match(shimSource, /-notcontains \$command/);
18
+ assert.doesNotMatch(shimSource, /ValidateSet/);
19
+ assert.doesNotMatch(shimSource, /ValueFromRemainingArguments/);
20
+ });
21
+
22
+ test('the shim bootstraps dependencies before it launches the entrypoint', () => {
23
+ const bootstrapIndex = shimSource.indexOf('ensureDependenciesInstalled');
24
+ const launchIndex = shimSource.indexOf('& bun $entrypoint');
25
+ assert.ok(bootstrapIndex > -1, 'the bootstrap call is absent');
26
+ assert.ok(launchIndex > -1, 'the entrypoint launch is absent');
27
+ assert.ok(bootstrapIndex < launchIndex, 'the bootstrap call runs after the launch');
28
+ });
29
+
30
+ test('the shim exits with the code the entrypoint returned', () => {
31
+ assert.match(shimSource, /\$exitCode = \$LASTEXITCODE/);
32
+ assert.match(shimSource, /exit \$exitCode/);
33
+ });
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Install this package into a scratch home directory.
4
+ *
5
+ * The installer writes into `$HOME/.claude` and rewrites the global git
6
+ * configuration. Both are redirected here so a run leaves the caller's own home
7
+ * untouched.
8
+ */
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { dirname, join, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url));
17
+
18
+ export const PACKAGE_ROOT = resolve(SCRIPT_DIRECTORY, '..', '..');
19
+ export const INSTALL_ENTRY = join(PACKAGE_ROOT, 'bin', 'install.mjs');
20
+ export const SCRATCH_HOME_PREFIX = 'cde-driver-';
21
+ export const SANDBOX_GIT_CONFIG_NAME = '.gitconfig-sandbox';
22
+ export const SANDBOX_GIT_CONFIG_BODY = '[safe]\n\tdirectory = *\n';
23
+
24
+ /**
25
+ * Create a scratch home with an isolated global git configuration.
26
+ *
27
+ * @returns {string} The scratch home directory path.
28
+ */
29
+ export function createScratchHome() {
30
+ const scratchHome = mkdtempSync(join(tmpdir(), SCRATCH_HOME_PREFIX));
31
+ writeFileSync(join(scratchHome, SANDBOX_GIT_CONFIG_NAME), SANDBOX_GIT_CONFIG_BODY);
32
+ return scratchHome;
33
+ }
34
+
35
+ /**
36
+ * Build the environment an installer run and an installed hook both read.
37
+ *
38
+ * @param {string} scratchHome Scratch home directory path.
39
+ * @returns {NodeJS.ProcessEnv} The environment for a child process.
40
+ */
41
+ export function scratchHomeEnvironment(scratchHome) {
42
+ return {
43
+ ...process.env,
44
+ HOME: scratchHome,
45
+ USERPROFILE: scratchHome,
46
+ GIT_CONFIG_GLOBAL: join(scratchHome, SANDBOX_GIT_CONFIG_NAME),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Run `bin/install.mjs` against a scratch home.
52
+ *
53
+ * @param {string} scratchHome Scratch home directory path.
54
+ * @param {string[]} installerArguments Arguments passed to the installer.
55
+ * @returns {import('node:child_process').SpawnSyncReturns<string>} The installer result.
56
+ */
57
+ export function runInstaller(scratchHome, installerArguments) {
58
+ return spawnSync(process.execPath, [INSTALL_ENTRY, ...installerArguments], {
59
+ cwd: PACKAGE_ROOT,
60
+ encoding: 'utf8',
61
+ env: scratchHomeEnvironment(scratchHome),
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Remove a scratch home and everything the install wrote into it.
67
+ *
68
+ * @param {string} scratchHome Scratch home directory path.
69
+ * @returns {void}
70
+ */
71
+ export function removeScratchHome(scratchHome) {
72
+ rmSync(scratchHome, { recursive: true, force: true });
73
+ }
@@ -0,0 +1,49 @@
1
+ import assert from 'node:assert/strict';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import test from 'node:test';
5
+
6
+ import {
7
+ INSTALL_ENTRY,
8
+ PACKAGE_ROOT,
9
+ SANDBOX_GIT_CONFIG_BODY,
10
+ SANDBOX_GIT_CONFIG_NAME,
11
+ createScratchHome,
12
+ removeScratchHome,
13
+ runInstaller,
14
+ scratchHomeEnvironment,
15
+ } from './scratch-home-install.mjs';
16
+
17
+ test('the package root and install entry resolve to files that exist', () => {
18
+ assert.equal(existsSync(INSTALL_ENTRY), true, INSTALL_ENTRY);
19
+ assert.equal(existsSync(join(PACKAGE_ROOT, 'package.json')), true, PACKAGE_ROOT);
20
+ });
21
+
22
+ test('a scratch home starts with an isolated global git configuration and is removable', () => {
23
+ const scratchHome = createScratchHome();
24
+ try {
25
+ const gitConfigPath = join(scratchHome, SANDBOX_GIT_CONFIG_NAME);
26
+ assert.equal(readFileSync(gitConfigPath, 'utf8'), SANDBOX_GIT_CONFIG_BODY);
27
+ assert.equal(existsSync(join(scratchHome, '.claude')), false);
28
+
29
+ const environment = scratchHomeEnvironment(scratchHome);
30
+ assert.equal(environment.HOME, scratchHome);
31
+ assert.equal(environment.USERPROFILE, scratchHome);
32
+ assert.equal(environment.GIT_CONFIG_GLOBAL, gitConfigPath);
33
+ } finally {
34
+ removeScratchHome(scratchHome);
35
+ }
36
+ assert.equal(existsSync(scratchHome), false);
37
+ });
38
+
39
+ test('the installer runs against a scratch home and leaves the caller home alone', () => {
40
+ const scratchHome = createScratchHome();
41
+ try {
42
+ const help = runInstaller(scratchHome, ['--help']);
43
+ assert.equal(help.status, 0, `${help.stdout}\n${help.stderr}`);
44
+ assert.match(help.stdout, /Usage:/);
45
+ assert.equal(existsSync(join(scratchHome, '.claude')), false);
46
+ } finally {
47
+ removeScratchHome(scratchHome);
48
+ }
49
+ });