claude-dev-env 8.82.0 → 8.83.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 (40) hide show
  1. package/.agents/skills/deepen/SKILL.md +42 -0
  2. package/bin/codex-skill-load-block.mjs +32 -0
  3. package/bin/codex-skill-load-block.test.mjs +27 -0
  4. package/bin/codex-trimmed-sol.test.mjs +1 -0
  5. package/bin/ever-shipped-skills.mjs +1 -0
  6. package/bin/install-pstack-plugin.mjs +12 -0
  7. package/bin/install-pstack-plugin.test.mjs +23 -0
  8. package/bin/install.agents-home.test.mjs +1 -0
  9. package/bin/install.codex-rules.test.mjs +2 -0
  10. package/bin/install.cursor-rules.test.mjs +38 -1
  11. package/bin/install.mjs +34 -2
  12. package/bin/install.prompt-context.test.mjs +3 -1
  13. package/bin/install.prune.test.mjs +1 -0
  14. package/bin/install.question-presentation.test.mjs +4 -0
  15. package/bin/install.settings-defaults.test.mjs +1 -0
  16. package/bin/install.shared-settings.test.mjs +1 -0
  17. package/bin/install.test.mjs +34 -1
  18. package/bin/install.transaction.test.mjs +8 -0
  19. package/bin/install.uninstall-transaction.test.mjs +2 -0
  20. package/bin/install.update-keeps-hooks.test.mjs +1 -0
  21. package/commands/deepen.md +19 -0
  22. package/hooks/features/lifecycle-cleanup.md +1 -1
  23. package/hooks/features/session-context.md +3 -0
  24. package/hooks/hooks.json +10 -0
  25. package/hooks/hooks_constants/deepen_first_prompt_constants.py +119 -0
  26. package/hooks/hooks_constants/mod_handoff_constants.py +2 -0
  27. package/hooks/hooks_constants/nested_project_hooks_constants.py +2 -0
  28. package/hooks/lifecycle/nested_project_hooks.py +17 -8
  29. package/hooks/lifecycle/test_nested_project_hooks.py +50 -0
  30. package/hooks/session/deepen_first_prompt.py +390 -0
  31. package/hooks/session/test_deepen_first_prompt.py +274 -0
  32. package/package.json +1 -1
  33. package/scripts/account_broker_support.py +5 -4
  34. package/scripts/ci/install-playtest.mjs +329 -2
  35. package/scripts/ci/install-playtest.test.mjs +6 -0
  36. package/scripts/claude_chain_usage.py +28 -2
  37. package/scripts/dev_env_scripts_constants/account_broker_constants.py +27 -1
  38. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +6 -0
  39. package/scripts/test_account_broker_support.py +35 -3
  40. package/scripts/test_claude_chain_usage.py +26 -3
@@ -26,7 +26,7 @@ else:
26
26
  import codex_account_choice
27
27
  import codex_account_meters
28
28
  from claude_account_profile import default_profile_home, validate_profile_name
29
- from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
29
+ from claude_chain_usage import ClaudeSignedOutError, WeeklyUtilizationProbeError, probe_account_meters
30
30
  from dev_env_scripts_constants.account_broker_constants import (
31
31
  ALL_BATCH_FILE_EXTENSIONS,
32
32
  ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS,
@@ -41,6 +41,7 @@ from dev_env_scripts_constants.account_broker_constants import (
41
41
  CMD_SHELL_METACHARACTERS,
42
42
  Decision,
43
43
  JobOutcome,
44
+ ALL_LOGIN_REFRESH_ENVIRONMENT,
44
45
  LOGIN_REFRESH_TIMEOUT_SECONDS,
45
46
  METER_READ_ATTEMPTS,
46
47
  Meters,
@@ -253,7 +254,7 @@ def refresh_claude_login(account_home: Path) -> bool:
253
254
  claude_path = resolve_claude_path()
254
255
  if claude_path is None:
255
256
  return False
256
- refresh_environment = {**os.environ, CLAUDE_CONFIG_DIR_ENV_VAR: str(account_home)}
257
+ refresh_environment = {**os.environ, **ALL_LOGIN_REFRESH_ENVIRONMENT, CLAUDE_CONFIG_DIR_ENV_VAR: str(account_home)}
257
258
  try:
258
259
  subprocess.run(
259
260
  [str(claude_path), *ALL_LOGIN_REFRESH_ARGUMENTS],
@@ -325,7 +326,7 @@ def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], ac
325
326
  Returns:
326
327
  The meters from the first read that succeeds, or None with the last
327
328
  failure text once every attempt failed. A missing Codex executable
328
- fails at once, without a retry.
329
+ or a signed-out Claude account fails at once, without a retry.
329
330
  """
330
331
  unread_reason = None
331
332
  for each_attempt in range(METER_READ_ATTEMPTS):
@@ -333,7 +334,7 @@ def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], ac
333
334
  time.sleep(meter_read_delay_seconds(each_attempt))
334
335
  try:
335
336
  return read_meters(account), None
336
- except codex_account_meters.CodexExecutableNotFoundError as error:
337
+ except (codex_account_meters.CodexExecutableNotFoundError, ClaudeSignedOutError) as error:
337
338
  return None, str(error)
338
339
  except (
339
340
  codex_account_meters.CodexMeterUnreadError,
@@ -54,6 +54,56 @@ export const PRE_TOOL_USE_EVENT_NAME = 'PreToolUse';
54
54
  export const SESSION_START_HOOK_SCRIPT_NAME = 'working_style_prompt.py';
55
55
  export const BASH_HOOK_SCRIPT_NAME = 'bash_pre_tool_use_dispatcher.py';
56
56
  export const BASH_MATCHER = 'Bash';
57
+ export const USER_PROMPT_SUBMIT_EVENT_NAME = 'UserPromptSubmit';
58
+ export const DEEPEN_HOOK_SCRIPT_NAME = 'deepen_first_prompt.py';
59
+ export const NESTED_HOOK_SCRIPT_NAME = 'nested_project_hooks.py';
60
+ export const DEEPEN_MARKER = 'CDE_DEEPEN:v1';
61
+ export const DEEPEN_AGENT_MARKER = 'CDE_DEEPEN_AGENT';
62
+ export const DEEPEN_STAGE_NAME = 'deepen_first_prompt';
63
+ export const DEEPEN_SKIP_STAGE_NAME = 'deepen_skips';
64
+ export const DEEPEN_REWRITE_STAGE_NAME = 'deepen_mod_text';
65
+ export const DEEPEN_NESTED_STAGE_NAME = 'deepen_nested_home';
66
+ export const DEEPEN_CLOUD_GAP_STAGE_NAME = 'deepen_cloud_gaps';
67
+ export const DEEPEN_STUB_REWRITE = [
68
+ 'Omitted: ship contrast tokens with the theme.',
69
+ 'Source: src/settings/theme.ts',
70
+ 'Methods: existing token file and a visual check.',
71
+ 'Proof: screenshot of the settings page in dark mode.',
72
+ ].join('\n');
73
+ export const CLAUDE_SUBAGENT_PROMPT_PREFIX = (
74
+ 'Before any other work, invoke the poteto-mode skill with the Skill tool. '
75
+ + 'Every prompt you write for a subagent opens with the same instruction.'
76
+ );
77
+ export const CLOUD_GAP_EVIDENCE = {
78
+ cursorCloud: {
79
+ doc: 'https://cursor.com/docs/hooks',
80
+ quotes: [
81
+ 'User-level hooks (`~/.cursor/hooks.json`) are not available in cloud agents. Cloud agent VMs don\'t have access to your local home directory configuration.',
82
+ 'sessionStart: Deferred while cloud agents can still start in a read-only environment. Hooks don\'t load there, so a cloud sessionStart would fire too late (after the first write) rather than at true session start.',
83
+ 'beforeSubmitPrompt output is continue and user_message only.',
84
+ ],
85
+ path: 'the deepen skill under ~/.cursor/skills',
86
+ },
87
+ codexCloud: {
88
+ doc: 'https://learn.chatgpt.com/docs/hooks',
89
+ quotes: [
90
+ 'Command/shell, prompt, and agent handlers; hooks from local configuration, plugins, or local directories; environment-scoped hooks; and SessionEnd MCP hooks are not supported with cloud orchestration, even when tools execute locally.',
91
+ ],
92
+ },
93
+ claudeUserPromptSubmit: {
94
+ doc: 'https://code.claude.com/docs/en/hooks',
95
+ quotes: [
96
+ 'UserPromptSubmit: can\'t replace the prompt; it only injects additionalContext alongside it',
97
+ ],
98
+ },
99
+ claudePromptSubmitMod: {
100
+ doc: 'https://code.claude.com/docs/en/plugins/mods/events',
101
+ quotes: [
102
+ 'Rewrite the prompt. The transcript and your prompt history show the new text. next({ ...e, text: newText })',
103
+ 'A cloud session: Yes, for a plugin that reaches the cloud session. This package still keeps the Python hook as the cloud fallback because mods that replace settings hooks stay off when CLAUDE_CODE_REMOTE is true.',
104
+ ],
105
+ },
106
+ };
57
107
 
58
108
  export const SESSION_START_PAYLOAD = {
59
109
  hook_event_name: SESSION_START_EVENT_NAME,
@@ -146,14 +196,14 @@ export function selectHookCommand(allCommands, scriptName) {
146
196
  * @param {object} payload The hook payload written to standard input.
147
197
  * @returns {{status: number | null, stdout: string, stderr: string}} The hook's result.
148
198
  */
149
- export function runInstalledHook(command, scratchHome, payload) {
199
+ export function runInstalledHook(command, scratchHome, payload, extraEnvironment = {}) {
150
200
  const hookProcess = spawnSync(command, {
151
201
  shell: true,
152
202
  input: JSON.stringify(payload),
153
203
  encoding: 'utf8',
154
204
  cwd: scratchHome,
155
205
  timeout: HOOK_TIMEOUT_MILLISECONDS,
156
- env: scratchHomeEnvironment(scratchHome),
206
+ env: { ...scratchHomeEnvironment(scratchHome), ...extraEnvironment },
157
207
  });
158
208
  return {
159
209
  status: hookProcess.status,
@@ -227,10 +277,275 @@ function runSessionStartStage(settingsPath, scratchHome, evidenceDirectory) {
227
277
  if (typeof envelope.additionalContext !== 'string' || envelope.additionalContext.length === 0) {
228
278
  throw new Error('The SessionStart envelope carries no additionalContext');
229
279
  }
280
+ if (envelope.additionalContext.includes(DEEPEN_MARKER)) {
281
+ throw new Error('The SessionStart envelope repeated first-task deepen text');
282
+ }
230
283
  const evidencePath = writeEvidence(evidenceDirectory, SESSION_START_STAGE_NAME, JSON.stringify(envelope));
231
284
  reportStage(SESSION_START_STAGE_NAME, evidencePath);
232
285
  }
233
286
 
287
+ function userPromptPayload(prompt, transcriptPath) {
288
+ const payload = {
289
+ hook_event_name: USER_PROMPT_SUBMIT_EVENT_NAME,
290
+ prompt,
291
+ session_id: 'playtest-session',
292
+ };
293
+ if (transcriptPath) payload.transcript_path = transcriptPath;
294
+ return payload;
295
+ }
296
+
297
+ function deepenHookEnvironment(extraEnvironment = {}) {
298
+ return {
299
+ CDE_DEEPEN_REWRITE: DEEPEN_STUB_REWRITE,
300
+ ...extraEnvironment,
301
+ };
302
+ }
303
+
304
+ function parseJsonDecision(stdout) {
305
+ const trimmed = stdout.trim();
306
+ if (!trimmed) return { decision: 'silent' };
307
+ return JSON.parse(trimmed);
308
+ }
309
+
310
+ function runDeepenFirstTaskStage(settingsPath, scratchHome, evidenceDirectory) {
311
+ const command = selectHookCommand(
312
+ hookCommandsForEvent(settingsPath, USER_PROMPT_SUBMIT_EVENT_NAME),
313
+ DEEPEN_HOOK_SCRIPT_NAME,
314
+ );
315
+ const transcriptPath = join(evidenceDirectory, 'first-task.jsonl');
316
+ const extraEnvironment = deepenHookEnvironment();
317
+ const envelopeResult = runInstalledHook(
318
+ command,
319
+ scratchHome,
320
+ userPromptPayload('Add dark mode to the settings page', transcriptPath),
321
+ extraEnvironment,
322
+ );
323
+ const envelope = readEnvelope(envelopeResult.stdout, USER_PROMPT_SUBMIT_EVENT_NAME);
324
+ if (!String(envelope.additionalContext || '').includes(DEEPEN_MARKER)) {
325
+ throw new Error('The UserPromptSubmit envelope carries no deepen marker');
326
+ }
327
+ if (!String(envelope.additionalContext).includes('src/settings/theme.ts')) {
328
+ throw new Error('The UserPromptSubmit envelope omitted the stub source');
329
+ }
330
+ const evidencePath = writeEvidence(
331
+ evidenceDirectory,
332
+ DEEPEN_STAGE_NAME,
333
+ JSON.stringify(envelope),
334
+ );
335
+ reportStage(DEEPEN_STAGE_NAME, evidencePath);
336
+ return { command, transcriptPath };
337
+ }
338
+
339
+ function runDeepenRewriteStage(command, scratchHome, evidenceDirectory) {
340
+ const transcriptPath = join(evidenceDirectory, 'mod-text.jsonl');
341
+ const hookResult = runInstalledHook(
342
+ command,
343
+ scratchHome,
344
+ userPromptPayload('Add dark mode to the settings page', transcriptPath),
345
+ deepenHookEnvironment({ CDE_DEEPEN_OUTPUT: 'rewrite' }),
346
+ );
347
+ const printed = hookResult.stdout;
348
+ if (!printed.startsWith(DEEPEN_MARKER)) {
349
+ throw new Error(`The rewrite output did not start with ${DEEPEN_MARKER}`);
350
+ }
351
+ if (printed.includes('hookSpecificOutput')) {
352
+ throw new Error('The Claude mod rewrite printed a command-hook envelope');
353
+ }
354
+ const evidencePath = writeEvidence(
355
+ evidenceDirectory,
356
+ DEEPEN_REWRITE_STAGE_NAME,
357
+ JSON.stringify({ stdout: printed }),
358
+ );
359
+ reportStage(DEEPEN_REWRITE_STAGE_NAME, evidencePath);
360
+ }
361
+
362
+ function runDeepenSkipStage(command, scratchHome, evidenceDirectory, firstTranscriptPath) {
363
+ const jsonEnvironment = deepenHookEnvironment({ CDE_DEEPEN_OUTPUT: 'json' });
364
+ const cases = [
365
+ {
366
+ name: 'thanks',
367
+ payload: userPromptPayload('thanks', join(evidenceDirectory, 'thanks.jsonl')),
368
+ extraEnvironment: jsonEnvironment,
369
+ expected: { decision: 'skip', reason: 'trivial' },
370
+ },
371
+ {
372
+ name: 'slash',
373
+ payload: userPromptPayload('/sr-loop', join(evidenceDirectory, 'slash.jsonl')),
374
+ extraEnvironment: jsonEnvironment,
375
+ expected: { decision: 'skip', reason: 'slash-command' },
376
+ },
377
+ {
378
+ name: 'spawn',
379
+ payload: userPromptPayload(
380
+ `${CLAUDE_SUBAGENT_PROMPT_PREFIX}\n\nReply leaf.`,
381
+ join(evidenceDirectory, 'spawn.jsonl'),
382
+ ),
383
+ extraEnvironment: jsonEnvironment,
384
+ expected: { decision: 'skip', reason: 'spawn-rewrite' },
385
+ },
386
+ {
387
+ name: 'skip-flag',
388
+ payload: userPromptPayload('Add dark mode to the settings page', join(evidenceDirectory, 'skip-flag.jsonl')),
389
+ extraEnvironment: deepenHookEnvironment({ CDE_DEEPEN_OUTPUT: 'json', CDE_SKIP_DEEPEN: '1' }),
390
+ expected: { decision: 'skip', reason: 'skip-flag' },
391
+ },
392
+ {
393
+ name: 'later-turn',
394
+ payload: userPromptPayload('and also the billing page', firstTranscriptPath),
395
+ extraEnvironment: jsonEnvironment,
396
+ expected: { decision: 'skip', reason: 'later-turn' },
397
+ },
398
+ {
399
+ name: 'marker',
400
+ payload: userPromptPayload(`${DEEPEN_MARKER}\n\nDo the job.`, join(evidenceDirectory, 'marker.jsonl')),
401
+ extraEnvironment: jsonEnvironment,
402
+ expected: { decision: 'skip', reason: 'marker-present' },
403
+ },
404
+ {
405
+ name: 'loop',
406
+ payload: userPromptPayload('/loop until tests pass', join(evidenceDirectory, 'loop.jsonl')),
407
+ extraEnvironment: jsonEnvironment,
408
+ expected: { decision: 'skip', reason: 'loop-or-helper' },
409
+ },
410
+ {
411
+ name: 'deepen-agent',
412
+ payload: userPromptPayload(
413
+ `${DEEPEN_AGENT_MARKER}\nAnswer the four deepening questions.`,
414
+ join(evidenceDirectory, 'agent.jsonl'),
415
+ ),
416
+ extraEnvironment: jsonEnvironment,
417
+ expected: { decision: 'skip', reason: 'deepen-agent' },
418
+ },
419
+ ];
420
+ const results = [];
421
+ for (const eachCase of cases) {
422
+ const hookResult = runInstalledHook(
423
+ command,
424
+ scratchHome,
425
+ eachCase.payload,
426
+ eachCase.extraEnvironment,
427
+ );
428
+ const decision = parseJsonDecision(hookResult.stdout);
429
+ if (decision.decision !== eachCase.expected.decision || decision.reason !== eachCase.expected.reason) {
430
+ throw new Error(
431
+ `Skip ${eachCase.name} returned ${JSON.stringify(decision)} expected ${JSON.stringify(eachCase.expected)}`,
432
+ );
433
+ }
434
+ results.push({ name: eachCase.name, decision });
435
+ }
436
+ const silentThanks = runInstalledHook(
437
+ command,
438
+ scratchHome,
439
+ userPromptPayload('thanks', join(evidenceDirectory, 'thanks-silent.jsonl')),
440
+ deepenHookEnvironment(),
441
+ );
442
+ if (silentThanks.stdout.trim() !== '') {
443
+ throw new Error('A skipped prompt printed command-hook output');
444
+ }
445
+ const evidencePath = writeEvidence(
446
+ evidenceDirectory,
447
+ DEEPEN_SKIP_STAGE_NAME,
448
+ JSON.stringify({ results, silentThanks: silentThanks.stdout }),
449
+ );
450
+ reportStage(DEEPEN_SKIP_STAGE_NAME, evidencePath);
451
+ }
452
+
453
+ function runDeepenNestedStage(settingsPath, scratchHome, evidenceDirectory) {
454
+ const nestedCommand = selectHookCommand(
455
+ hookCommandsForEvent(settingsPath, USER_PROMPT_SUBMIT_EVENT_NAME),
456
+ NESTED_HOOK_SCRIPT_NAME,
457
+ );
458
+ const deepenCommand = selectHookCommand(
459
+ hookCommandsForEvent(settingsPath, USER_PROMPT_SUBMIT_EVENT_NAME),
460
+ DEEPEN_HOOK_SCRIPT_NAME,
461
+ );
462
+ const sessionDirectory = join(evidenceDirectory, 'nested-session');
463
+ const checkoutDirectory = join(sessionDirectory, 'alpha');
464
+ mkdirSync(join(checkoutDirectory, '.git'), { recursive: true });
465
+ mkdirSync(join(checkoutDirectory, '.claude'), { recursive: true });
466
+ writeFileSync(
467
+ join(checkoutDirectory, '.claude', 'settings.json'),
468
+ `${JSON.stringify({
469
+ hooks: {
470
+ UserPromptSubmit: [
471
+ {
472
+ matcher: '',
473
+ hooks: [{ type: 'command', command: deepenCommand, timeout: 10 }],
474
+ },
475
+ ],
476
+ },
477
+ }, null, 2)}\n`,
478
+ );
479
+ const hookResult = spawnSync(nestedCommand, {
480
+ shell: true,
481
+ input: JSON.stringify(userPromptPayload(
482
+ 'Add dark mode to the settings page',
483
+ join(evidenceDirectory, 'nested.jsonl'),
484
+ )),
485
+ encoding: 'utf8',
486
+ cwd: sessionDirectory,
487
+ timeout: HOOK_TIMEOUT_MILLISECONDS,
488
+ env: {
489
+ ...scratchHomeEnvironment(scratchHome),
490
+ ...deepenHookEnvironment(),
491
+ CLAUDE_PROJECT_DIR: sessionDirectory,
492
+ },
493
+ });
494
+ const envelope = readEnvelope(hookResult.stdout, USER_PROMPT_SUBMIT_EVENT_NAME);
495
+ if (!String(envelope.additionalContext || '').includes(DEEPEN_MARKER)) {
496
+ throw new Error('The nested home-folder session carried no deepen marker');
497
+ }
498
+ const evidencePath = writeEvidence(
499
+ evidenceDirectory,
500
+ DEEPEN_NESTED_STAGE_NAME,
501
+ JSON.stringify(envelope),
502
+ );
503
+ reportStage(DEEPEN_NESTED_STAGE_NAME, evidencePath);
504
+ }
505
+
506
+ function runDeepenInstallSurfaceStage(scratchHome, evidenceDirectory) {
507
+ const settingsPath = join(scratchHome, SETTINGS_RELATIVE_PATH);
508
+ const userPromptCommands = hookCommandsForEvent(settingsPath, USER_PROMPT_SUBMIT_EVENT_NAME);
509
+ if (!userPromptCommands.some((each) => each.command.includes(DEEPEN_HOOK_SCRIPT_NAME))) {
510
+ throw new Error('The installed Claude settings declare no deepen_first_prompt.py');
511
+ }
512
+ const codexHooksPath = join(scratchHome, '.codex', 'hooks.json');
513
+ const codexHooks = JSON.parse(readFileSync(codexHooksPath, 'utf8'));
514
+ const codexUserPrompt = JSON.stringify(codexHooks.hooks?.UserPromptSubmit ?? []);
515
+ if (!codexUserPrompt.includes(DEEPEN_HOOK_SCRIPT_NAME)) {
516
+ throw new Error('The installed Codex hooks declare no UserPromptSubmit deepen_first_prompt.py');
517
+ }
518
+ const cursorSkillPath = join(scratchHome, '.cursor', 'skills', 'deepen', 'SKILL.md');
519
+ if (!existsSync(cursorSkillPath)) {
520
+ throw new Error(`The install wrote no ${cursorSkillPath}`);
521
+ }
522
+ const codexAgentsPath = join(scratchHome, '.codex', 'AGENTS.md');
523
+ const codexAgentsText = existsSync(codexAgentsPath) ? readFileSync(codexAgentsPath, 'utf8') : '';
524
+ if (!codexAgentsText.includes('<!-- claude-dev-env deepen: start -->')) {
525
+ throw new Error(`The install wrote no deepen block into ${codexAgentsPath}`);
526
+ }
527
+ const evidencePath = writeEvidence(
528
+ evidenceDirectory,
529
+ 'deepen_install_surfaces',
530
+ JSON.stringify({
531
+ claudeUserPromptSubmit: userPromptCommands.map((each) => each.command),
532
+ codexUserPromptSubmit: codexHooks.hooks.UserPromptSubmit,
533
+ cursorSkillPath,
534
+ codexAgentsPath,
535
+ }),
536
+ );
537
+ reportStage('deepen_install_surfaces', evidencePath);
538
+ }
539
+
540
+ function runDeepenCloudGapStage(evidenceDirectory) {
541
+ const evidencePath = writeEvidence(
542
+ evidenceDirectory,
543
+ DEEPEN_CLOUD_GAP_STAGE_NAME,
544
+ JSON.stringify(CLOUD_GAP_EVIDENCE),
545
+ );
546
+ reportStage(DEEPEN_CLOUD_GAP_STAGE_NAME, evidencePath);
547
+ }
548
+
234
549
  function runBlockingStage(settingsPath, scratchHome, evidenceDirectory) {
235
550
  const command = selectHookCommand(
236
551
  hookCommandsForEvent(settingsPath, PRE_TOOL_USE_EVENT_NAME),
@@ -274,6 +589,18 @@ export function runPlaytest(argv) {
274
589
  runSessionStartStage(settingsPath, scratchHome, evidenceDirectory);
275
590
  stageName = BLOCKING_STAGE_NAME;
276
591
  runBlockingStage(settingsPath, scratchHome, evidenceDirectory);
592
+ stageName = 'deepen_install_surfaces';
593
+ runDeepenInstallSurfaceStage(scratchHome, evidenceDirectory);
594
+ stageName = DEEPEN_STAGE_NAME;
595
+ const deepen = runDeepenFirstTaskStage(settingsPath, scratchHome, evidenceDirectory);
596
+ stageName = DEEPEN_REWRITE_STAGE_NAME;
597
+ runDeepenRewriteStage(deepen.command, scratchHome, evidenceDirectory);
598
+ stageName = DEEPEN_SKIP_STAGE_NAME;
599
+ runDeepenSkipStage(deepen.command, scratchHome, evidenceDirectory, deepen.transcriptPath);
600
+ stageName = DEEPEN_NESTED_STAGE_NAME;
601
+ runDeepenNestedStage(settingsPath, scratchHome, evidenceDirectory);
602
+ stageName = DEEPEN_CLOUD_GAP_STAGE_NAME;
603
+ runDeepenCloudGapStage(evidenceDirectory);
277
604
  } catch (error) {
278
605
  reportFailure(stageName, error instanceof Error ? error.message : String(error));
279
606
  return 1;
@@ -30,6 +30,12 @@ test('the driver reads an envelope from every stage of a fresh install, and repo
30
30
  assert.match(greenRun.stdout, /\[PASS\] playtest install .* sha256=[0-9a-f]{64}/);
31
31
  assert.match(greenRun.stdout, /\[PASS\] playtest session_start_hook .* sha256=[0-9a-f]{64}/);
32
32
  assert.match(greenRun.stdout, /\[PASS\] playtest blocking_hook .* sha256=[0-9a-f]{64}/);
33
+ assert.match(greenRun.stdout, /\[PASS\] playtest deepen_install_surfaces .* sha256=[0-9a-f]{64}/);
34
+ assert.match(greenRun.stdout, /\[PASS\] playtest deepen_first_prompt .* sha256=[0-9a-f]{64}/);
35
+ assert.match(greenRun.stdout, /\[PASS\] playtest deepen_mod_text .* sha256=[0-9a-f]{64}/);
36
+ assert.match(greenRun.stdout, /\[PASS\] playtest deepen_skips .* sha256=[0-9a-f]{64}/);
37
+ assert.match(greenRun.stdout, /\[PASS\] playtest deepen_nested_home .* sha256=[0-9a-f]{64}/);
38
+ assert.match(greenRun.stdout, /\[PASS\] playtest deepen_cloud_gaps .* sha256=[0-9a-f]{64}/);
33
39
  const blockingEnvelope = JSON.parse(readFileSync(join(evidenceDirectory, 'blocking_hook.json'), 'utf8'));
34
40
  assert.equal(Object.hasOwn(blockingEnvelope, 'permissionDecision'), false);
35
41
  assert.ok(
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import http.client
6
6
  import importlib.util
7
+ import json
7
8
  import sys
8
9
  import urllib.error
9
10
  from dataclasses import dataclass
@@ -13,6 +14,8 @@ from types import ModuleType
13
14
  from typing import Callable
14
15
 
15
16
  from dev_env_scripts_constants.claude_chain_usage_constants import (
17
+ CREDENTIALS_OAUTH_SECTION_KEY,
18
+ CREDENTIALS_REFRESH_TOKEN_KEY,
16
19
  NO_ACCESS_TOKEN_ERROR_TEMPLATE,
17
20
  RESOLVE_USAGE_WINDOW_FILENAME,
18
21
  RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE,
@@ -29,6 +32,10 @@ class WeeklyUtilizationProbeError(Exception):
29
32
  """Raised when an account's usage meters cannot be measured."""
30
33
 
31
34
 
35
+ class ClaudeSignedOutError(WeeklyUtilizationProbeError):
36
+ """Raised when an account has no usable token and no way to get one, so a retry cannot help."""
37
+
38
+
32
39
  def _usage_pause_scripts_directory(module_file: Path) -> Path:
33
40
  """Return the usage-pause scripts directory beside this scripts tree.
34
41
 
@@ -111,6 +118,24 @@ class AccountUsageMeters:
111
118
  weekly_resets_at: datetime | None
112
119
 
113
120
 
121
+ def _holds_refresh_token(credentials_path: Path) -> bool:
122
+ """Report whether a credential file holds a refresh token the CLI can renew a login from.
123
+
124
+ Args:
125
+ credentials_path: The account's CLI credential file.
126
+
127
+ Returns:
128
+ True when the file's OAuth section carries a non-empty refresh token.
129
+ """
130
+ try:
131
+ credentials_payload = json.loads(credentials_path.read_text(encoding="utf-8"))
132
+ except (OSError, ValueError):
133
+ return False
134
+ oauth_section = credentials_payload.get(CREDENTIALS_OAUTH_SECTION_KEY) if isinstance(credentials_payload, dict) else None
135
+ refresh_token = oauth_section.get(CREDENTIALS_REFRESH_TOKEN_KEY) if isinstance(oauth_section, dict) else None
136
+ return isinstance(refresh_token, str) and bool(refresh_token)
137
+
138
+
114
139
  def _read_access_token(
115
140
  usage_window_resolver: ModuleType,
116
141
  credentials_path: Path,
@@ -123,6 +148,7 @@ def _read_access_token(
123
148
 
124
149
  credential file holds a token -> that token
125
150
  token expired, refresh_login saves a new one -> the new token
151
+ no token and no refresh token (signed out) -> no refresh_login call
126
152
  no token, the session's own credential path -> the session ingress token
127
153
  no token, another account's credential path -> None
128
154
 
@@ -142,7 +168,7 @@ def _read_access_token(
142
168
  file_token = usage_window_resolver.read_oauth_access_token(credentials_path, now)
143
169
  if file_token is not None:
144
170
  return file_token
145
- if refresh_login is not None and credentials_path.is_file():
171
+ if refresh_login is not None and _holds_refresh_token(credentials_path):
146
172
  refresh_login(credentials_path.parent)
147
173
  refreshed_token = usage_window_resolver.read_oauth_access_token(credentials_path, datetime.now().astimezone())
148
174
  if refreshed_token is not None:
@@ -162,7 +188,7 @@ def _fetch_account_usage_payload(
162
188
  try:
163
189
  access_token = _read_access_token(usage_window_resolver, credentials_path, now, refresh_login)
164
190
  if access_token is None:
165
- raise WeeklyUtilizationProbeError(
191
+ raise ClaudeSignedOutError(
166
192
  NO_ACCESS_TOKEN_ERROR_TEMPLATE.format(credentials_path=credentials_path)
167
193
  )
168
194
  return usage_window_resolver._fetch_usage_payload(access_token)
@@ -68,7 +68,33 @@ ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS: tuple[tuple[str, ...], ...] = (
68
68
  (".local", "bin", "claude.exe"),
69
69
  (".local", "bin", "claude"),
70
70
  )
71
- ALL_LOGIN_REFRESH_ARGUMENTS: tuple[str, ...] = ("-p", "ok", "--model", "haiku")
71
+ ALL_LOGIN_REFRESH_ARGUMENTS: tuple[str, ...] = (
72
+ "-p",
73
+ "ok",
74
+ "--model",
75
+ "haiku",
76
+ "--setting-sources",
77
+ "local",
78
+ "--settings",
79
+ '{"enabledPlugins": {}, "autoMemoryEnabled": false}',
80
+ "--strict-mcp-config",
81
+ "--mcp-config",
82
+ '{"mcpServers": {}}',
83
+ "--no-session-persistence",
84
+ "--disable-slash-commands",
85
+ "--tools",
86
+ "",
87
+ "--max-turns",
88
+ "1",
89
+ )
90
+ ALL_LOGIN_REFRESH_ENVIRONMENT: dict[str, str] = {
91
+ "CLAUDE_CODE_DISABLE_CLAUDE_MDS": "1",
92
+ "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
93
+ "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
94
+ "DISABLE_TELEMETRY": "1",
95
+ "DISABLE_ERROR_REPORTING": "1",
96
+ "DISABLE_AUTOUPDATER": "1",
97
+ }
72
98
  LOGIN_REFRESH_TIMEOUT_SECONDS = 120.0
73
99
  ALL_PARENT_CLAUDE_SESSION_VARIABLES: frozenset[str] = frozenset({
74
100
  "CLAUDE_CODE_SESSION_ID",
@@ -42,6 +42,12 @@ JSON_ERROR_KEY: str = "error"
42
42
  CLI_CONFIG_PATH_FLAG: str = "--config-path"
43
43
  """CLI flag that overrides the chain configuration file path."""
44
44
 
45
+ CREDENTIALS_OAUTH_SECTION_KEY: str = "claudeAiOauth"
46
+ """Credential file key holding the CLI's OAuth login."""
47
+
48
+ CREDENTIALS_REFRESH_TOKEN_KEY: str = "refreshToken"
49
+ """OAuth section key holding the token the CLI renews a login from; empty when signed out."""
50
+
45
51
  NO_ACCESS_TOKEN_ERROR_TEMPLATE: str = (
46
52
  "no usable bearer token from the OAuth credential file at {credentials_path}"
47
53
  )
@@ -348,6 +348,22 @@ def test_should_cap_the_meter_read_delay(monkeypatch: pytest.MonkeyPatch, tmp_pa
348
348
  assert all_delays == [1.0, 2.0, 4.0, 8.0, 8.0]
349
349
 
350
350
 
351
+ def test_should_read_a_signed_out_claude_account_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
352
+ all_delays = _recorded_sleeps(monkeypatch)
353
+ all_reads: list[Account] = []
354
+
355
+ def signed_out_reader(account: Account) -> Meters:
356
+ all_reads.append(account)
357
+ raise support.ClaudeSignedOutError("no usable bearer token")
358
+
359
+ meters, unread_reason = support.read_meters_with_backoff(signed_out_reader, Account(Product.CLAUDE, "main", tmp_path))
360
+
361
+ assert meters is None
362
+ assert unread_reason == "no usable bearer token"
363
+ assert len(all_reads) == 1
364
+ assert all_delays == []
365
+
366
+
351
367
  def test_should_cache_account_reading_under_injected_state_path(
352
368
  monkeypatch: pytest.MonkeyPatch, tmp_path: Path
353
369
  ) -> None:
@@ -604,19 +620,35 @@ def test_should_match_order_names_to_an_account_named_by_its_windows_launcher_pa
604
620
 
605
621
 
606
622
  def test_should_run_the_cli_against_the_account_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
607
- all_calls: list[tuple[list[str], str]] = []
623
+ all_calls: list[tuple[list[str], dict[str, str]]] = []
608
624
 
609
625
  def fake_run(command: list[str], **keyword_arguments: object) -> subprocess.CompletedProcess[bytes]:
610
626
  environment = keyword_arguments["env"]
611
627
  assert isinstance(environment, dict)
612
- all_calls.append((command, environment["CLAUDE_CONFIG_DIR"]))
628
+ all_calls.append((command, environment))
613
629
  return subprocess.CompletedProcess(command, 0)
614
630
 
615
631
  monkeypatch.setattr(support, "resolve_claude_path", lambda: Path("claude"))
616
632
  monkeypatch.setattr(support.subprocess, "run", fake_run)
617
633
 
618
634
  assert support.refresh_claude_login(tmp_path) is True
619
- assert all_calls == [(["claude", "-p", "ok", "--model", "haiku"], str(tmp_path))]
635
+ [(command, environment)] = all_calls
636
+ assert command[:5] == ["claude", "-p", "ok", "--model", "haiku"]
637
+ assert command[command.index("--setting-sources") + 1] == "local"
638
+ assert json.loads(command[command.index("--settings") + 1]) == {"enabledPlugins": {}, "autoMemoryEnabled": False}
639
+ assert "--strict-mcp-config" in command
640
+ assert json.loads(command[command.index("--mcp-config") + 1]) == {"mcpServers": {}}
641
+ assert "--no-session-persistence" in command
642
+ assert "--disable-slash-commands" in command
643
+ assert command[command.index("--tools") + 1] == ""
644
+ assert command[command.index("--max-turns") + 1] == "1"
645
+ assert environment["CLAUDE_CONFIG_DIR"] == str(tmp_path)
646
+ assert environment["CLAUDE_CODE_DISABLE_CLAUDE_MDS"] == "1"
647
+ assert environment["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] == "1"
648
+ assert environment["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1"
649
+ assert environment["DISABLE_TELEMETRY"] == "1"
650
+ assert environment["DISABLE_ERROR_REPORTING"] == "1"
651
+ assert environment["DISABLE_AUTOUPDATER"] == "1"
620
652
 
621
653
 
622
654
  def test_should_skip_the_refresh_without_a_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import subprocess
6
7
  import sys
7
8
  import urllib.error
@@ -212,11 +213,18 @@ def _expiring_resolver(all_tokens: list[str | None], all_fetched_tokens: list[st
212
213
  return Resolver()
213
214
 
214
215
 
216
+ def _write_credentials(credentials_path: Path, refresh_token: str) -> None:
217
+ credentials_path.write_text(
218
+ json.dumps({"claudeAiOauth": {"accessToken": "", "refreshToken": refresh_token, "expiresAt": 0}}),
219
+ encoding="utf-8",
220
+ )
221
+
222
+
215
223
  def test_should_refresh_an_expired_login_and_read_the_meter(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
216
224
  account_home = tmp_path / "ev"
217
225
  account_home.mkdir()
218
226
  credentials_path = account_home / ".credentials.json"
219
- credentials_path.write_text("{}", encoding="utf-8")
227
+ _write_credentials(credentials_path, "refresh-value")
220
228
  all_refreshed_homes: list[Path] = []
221
229
  all_fetched_tokens: list[str] = []
222
230
  monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None, "fresh-token"], all_fetched_tokens))
@@ -230,8 +238,23 @@ def test_should_refresh_an_expired_login_and_read_the_meter(monkeypatch: pytest.
230
238
 
231
239
  def test_should_report_no_token_when_the_refresh_saves_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
232
240
  credentials_path = tmp_path / ".credentials.json"
233
- credentials_path.write_text("{}", encoding="utf-8")
241
+ _write_credentials(credentials_path, "refresh-value")
234
242
  monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None, None], []))
235
243
 
236
- with pytest.raises(usage.WeeklyUtilizationProbeError, match="bearer token"):
244
+ with pytest.raises(usage.ClaudeSignedOutError, match="bearer token"):
237
245
  usage.probe_account_meters(credentials_path, lambda home: True)
246
+
247
+
248
+ @pytest.mark.parametrize("credentials_text", ["{}", '{"claudeAiOauth": {"accessToken": "", "refreshToken": "", "expiresAt": 0}}', "not json"])
249
+ def test_should_skip_the_refresh_for_a_signed_out_account(
250
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, credentials_text: str
251
+ ) -> None:
252
+ credentials_path = tmp_path / ".credentials.json"
253
+ credentials_path.write_text(credentials_text, encoding="utf-8")
254
+ all_refreshed_homes: list[Path] = []
255
+ monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None], []))
256
+
257
+ with pytest.raises(usage.ClaudeSignedOutError, match="bearer token"):
258
+ usage.probe_account_meters(credentials_path, all_refreshed_homes.append)
259
+
260
+ assert all_refreshed_homes == []