claude-dev-env 8.82.1 → 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 (34) 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/ci/install-playtest.mjs +329 -2
  34. package/scripts/ci/install-playtest.test.mjs +6 -0
@@ -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(