@reticlehq/init 3.7.0 → 3.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 (45) hide show
  1. package/dist/detect/detect.d.ts +9 -0
  2. package/dist/detect/detect.js +5 -1
  3. package/dist/detect/dev-server-ports.d.ts +2 -0
  4. package/dist/detect/dev-server-ports.js +20 -0
  5. package/dist/detect/prettier-config.d.ts +21 -0
  6. package/dist/detect/prettier-config.js +38 -0
  7. package/dist/detect/workspace-redirect.js +23 -8
  8. package/dist/diagnose/closing-hint.d.ts +5 -0
  9. package/dist/diagnose/closing-hint.js +18 -11
  10. package/dist/gather-plan-input.js +70 -18
  11. package/dist/host.d.ts +6 -0
  12. package/dist/index.d.ts +8 -2
  13. package/dist/index.js +8 -2
  14. package/dist/no-package-json.js +62 -14
  15. package/dist/patch/next-patch.d.ts +19 -11
  16. package/dist/patch/next-patch.js +33 -6
  17. package/dist/patch/prettier-ignore.d.ts +34 -0
  18. package/dist/patch/prettier-ignore.js +49 -0
  19. package/dist/patch/python-snippets.d.ts +22 -0
  20. package/dist/patch/python-snippets.js +113 -0
  21. package/dist/patch/snippets.d.ts +9 -30
  22. package/dist/patch/snippets.js +40 -103
  23. package/dist/patch/static-page.js +3 -5
  24. package/dist/patch/vite-config.js +11 -0
  25. package/dist/plan/connect-steps.d.ts +1 -0
  26. package/dist/plan/connect-steps.js +1 -0
  27. package/dist/plan/framework-adapter.d.ts +10 -0
  28. package/dist/plan/framework-adapter.js +120 -1
  29. package/dist/plan/notices.js +1 -1
  30. package/dist/plan/plan-framework.js +101 -32
  31. package/dist/plan/plan-types.d.ts +27 -0
  32. package/dist/plan/plan.d.ts +1 -10
  33. package/dist/plan/plan.js +128 -131
  34. package/dist/plan/stop-hook-step.d.ts +11 -0
  35. package/dist/plan/stop-hook-step.js +82 -6
  36. package/dist/project/agent-rules.d.ts +1 -1
  37. package/dist/project/agent-rules.js +8 -8
  38. package/dist/project/refresh-rules.js +2 -1
  39. package/dist/register/prompt-hooks.d.ts +123 -0
  40. package/dist/register/prompt-hooks.js +449 -0
  41. package/dist/register/slash-command.d.ts +1 -1
  42. package/dist/register/slash-command.js +5 -4
  43. package/dist/run-types.d.ts +4 -0
  44. package/dist/run.js +4 -1
  45. package/package.json +2 -2
@@ -8,6 +8,7 @@
8
8
  import type { Detection, Framework, UiLibrary } from '../detect/detect.js';
9
9
  import type { FoundStore } from '../detect/capabilities.js';
10
10
  import type { McpClient } from '../register/mcp-clients.js';
11
+ import type { PromptHookTarget } from '../register/prompt-hooks.js';
11
12
  /** Exported so the init telemetry can tell an MCP-registration failure from a dependency install. */
12
13
  export declare const MCP_TARGET = "global (claude user scope)";
13
14
  /** The step that runs the package manager — the other thing that commonly fails on a user's machine. */
@@ -115,6 +116,11 @@ export interface PlanInput {
115
116
  * handing it an io would let any later step reach the disk from inside `buildPlan`.
116
117
  */
117
118
  cspSources?: Readonly<Record<string, string | undefined>> | undefined;
119
+ /**
120
+ * The project's `.prettierignore` content, pre-read so the prettier-ignore step stays pure.
121
+ * Null when the file does not exist — `init` creates it only when the project uses Prettier.
122
+ */
123
+ prettierIgnoreSource?: string | null | undefined;
118
124
  /** Whether the `claude` CLI is installed (so we can register the MCP server globally). */
119
125
  claudeCli: boolean;
120
126
  /** Whether an `reticle` MCP server is already registered with Claude (any scope) — idempotency. */
@@ -217,6 +223,11 @@ export interface PlanInput {
217
223
  * when there is no top-level layout and no single group renders `/` (#1538).
218
224
  */
219
225
  nextLayoutCandidates?: readonly string[] | undefined;
226
+ /**
227
+ * The other route-group root layouts when one was chosen to mount into, that do not mount
228
+ * `<ReticleDev />` themselves: the routes under them render without it.
229
+ */
230
+ nextUnmountedLayouts?: readonly string[] | undefined;
220
231
  /** Where the dev-only connect component goes — never inside `pages/`, which routes on presence. */
221
232
  nextReticleDevPath?: string | undefined;
222
233
  /** What the mount file should import — a sibling for App Router, `../components/…` for Pages. */
@@ -325,6 +336,14 @@ export interface PlanInput {
325
336
  * see reticleConfigStep for the port that survived every re-run because of it.
326
337
  */
327
338
  reticleConfigSource?: string | null | undefined;
339
+ /**
340
+ * `--new-id`: the app's source files that quote the id being replaced. A hand-written
341
+ * `connect({ projectId: '…' })` still naming it sends this checkout's evidence to the original.
342
+ */
343
+ copiedIdSources?: readonly {
344
+ path: string;
345
+ source: string;
346
+ }[] | undefined;
328
347
  /** Current project-root CLAUDE.md content (for the idempotent agent-rule merge), or null/undefined. */
329
348
  claudeMdContent?: string | null | undefined;
330
349
  /** Current project-root AGENTS.md content (the cross-agent rule), or null/undefined. */
@@ -347,6 +366,12 @@ export interface PlanInput {
347
366
  hooks?: boolean | undefined;
348
367
  /** Current .claude/settings.json content, or null when absent (the hook is MERGED into it). */
349
368
  claudeSettingsContent?: string | null | undefined;
369
+ /** Install the prompt-capture hook into `.claude/settings.json`: Claude Code is used here. */
370
+ claudePromptHook?: boolean | undefined;
371
+ /** Every OTHER agent found here with a prompt hook, and its config as it is now. */
372
+ promptHooks?: readonly PromptHookTarget[] | undefined;
373
+ /** See `InitHost.hookBin`. */
374
+ hookBin?: string | undefined;
350
375
  /**
351
376
  * Absolute directory the AGENT runs in, when that is not the app's directory.
352
377
  *
@@ -369,6 +394,8 @@ export interface PlanInput {
369
394
  install: boolean;
370
395
  /** Stable project identity derived at init (package.json name + root). Baked into snippets/.reticle.json. */
371
396
  projectId?: string;
397
+ /** `--new-id`: the config on disk takes `projectId` above in place of the id it was copied with. */
398
+ newId?: boolean;
372
399
  /** The CLI's own version, pinned onto the SDK install so a stale registry cache cannot skew it. */
373
400
  sdkVersion?: string;
374
401
  };
@@ -3,18 +3,9 @@
3
3
  * contents, produce an ordered list of steps — each marked apply / manual / already / skip. The
4
4
  * runner performs the `write` side-effects; this module decides *what* should happen.
5
5
  */
6
- import { Framework, UiLibrary } from '../detect/detect.js';
7
6
  import { type Plan, type PlanInput } from './plan-types.js';
8
7
  export { DEPS_TARGET, MCP_TARGET, StepStatus, type Step, type Plan, type PlanInput, } from './plan-types.js';
9
8
  import { RETICLE_CONFIG_FILE } from '../detect/existing-config.js';
10
9
  export { RETICLE_CONFIG_FILE };
11
- /**
12
- * The dev-dependencies `reticle init` installs — kit (or sensor) first, build plugin next.
13
- *
14
- * `uiLibrary` matters because the framework does not always name the renderer. The rule below was
15
- * already written for Nuxt, correctly, and applied only there: a Vue app on plain Vite is
16
- * `Framework.VITE` and a SvelteKit app renders Svelte, and both were being handed the React kit
17
- * right after `init` had detected the real UI library and said so on screen.
18
- */
19
- export declare function frameworkPackages(framework: Framework, uiLibrary?: UiLibrary): readonly string[];
10
+ export { frameworkPackages } from './framework-adapter.js';
20
11
  export declare function buildPlan(input: PlanInput): Plan;
package/dist/plan/plan.js CHANGED
@@ -3,9 +3,6 @@
3
3
  * contents, produce an ordered list of steps — each marked apply / manual / already / skip. The
4
4
  * runner performs the `write` side-effects; this module decides *what* should happen.
5
5
  */
6
- import { Framework, UiLibrary, installCommand, installCommandParts } from '../detect/detect.js';
7
- import { installFailureHint } from '../diagnose/install-hint.js';
8
- import { installRetries } from '../diagnose/install-retries.js';
9
6
  import { claudeAddCommand, claudeProjectMcpJson, codexAddCommand, mcpManual, } from '../register/mcp.js';
10
7
  import { codexNamesOurServer } from '../register/codex-toml.js';
11
8
  import { mergeClientConfig, ClientMergeStatus, clientSnippet, clientSpec, CLAUDE_PROJECT_CONFIG, CLAUDE_PROJECT_SPEC, McpClient, } from '../register/mcp-clients.js';
@@ -14,44 +11,20 @@ import { mergeMarkedInstruction, reticleMdFile, cursorRuleFile, AgentRuleStatus,
14
11
  import { cspStep } from '../diagnose/csp-step.js';
15
12
  import { HTML_INDEX_PATH } from '../patch/static-page.js';
16
13
  import { frameworkSteps } from './framework-adapter.js';
17
- import { FRAMEWORK_ADAPTERS, RETICLE_BROWSER_SDK, RETICLE_REACT_KIT } from './framework-adapter.js';
14
+ import { installStep } from './framework-adapter.js';
18
15
  import { MCP_TARGET, StepStatus } from './plan-types.js';
19
16
  export { DEPS_TARGET, MCP_TARGET, StepStatus, } from './plan-types.js';
20
17
  import { join } from 'node:path';
21
- import { reticleConfigContent, usesReactKit } from '../patch/snippets.js';
18
+ import { reticleConfigContent } from '../patch/snippets.js';
22
19
  import { configWithInstallSource } from '../project/install-source-config.js';
23
20
  import { portMoveDetail, retargetConfigPort } from './port-steps.js';
24
21
  import { containerisedStep, uiLibraryStep, webGlCanvasStep, windowsMcpNoteStep, } from './notices.js';
25
22
  import { existingConfigProblem, projectIdOf, RETICLE_CONFIG_FILE, } from '../detect/existing-config.js';
26
- import { CLAUDE_SETTINGS_PATH, claudeSettingsSteps } from './stop-hook-step.js';
23
+ import { CLAUDE_SETTINGS_PATH, claudeSettingsSteps, promptHookSteps } from './stop-hook-step.js';
24
+ import { PRETTIERIGNORE_PATH, RETICLE_IGNORE_ENTRY, ignoresReticle, withReticleIgnored, } from '../patch/prettier-ignore.js';
27
25
  // Re-exported: it moved to the module that reads it, and every existing importer says `plan.js`.
28
26
  export { RETICLE_CONFIG_FILE };
29
- /**
30
- * Pin the SDK to the CLI's own version.
31
- *
32
- * `pnpm add -D @reticlehq/react` once installed an older release in one project while npm and yarn
33
- * took the current one in the next — a stale registry metadata cache, invisible to everyone. A
34
- * version-skewed SDK talking to a newer daemon is the `-32000` failure path: the app connects, the
35
- * protocol disagrees, and nothing on either side names a version. Asking for the CLI's exact version
36
- * makes the cache irrelevant, and a skewed pair impossible to install by accident.
37
- */
38
- function pinnedPackages(packages, version) {
39
- if (version === undefined || 0 === version.length)
40
- return packages;
41
- return packages.map((p) => `${p}@${version}`);
42
- }
43
- /**
44
- * The dev-dependencies `reticle init` installs — kit (or sensor) first, build plugin next.
45
- *
46
- * `uiLibrary` matters because the framework does not always name the renderer. The rule below was
47
- * already written for Nuxt, correctly, and applied only there: a Vue app on plain Vite is
48
- * `Framework.VITE` and a SvelteKit app renders Svelte, and both were being handed the React kit
49
- * right after `init` had detected the real UI library and said so on screen.
50
- */
51
- export function frameworkPackages(framework, uiLibrary = UiLibrary.UNKNOWN) {
52
- const kit = usesReactKit(uiLibrary, framework) ? RETICLE_REACT_KIT : RETICLE_BROWSER_SDK;
53
- return FRAMEWORK_ADAPTERS[framework].packages(kit);
54
- }
27
+ export { frameworkPackages } from './framework-adapter.js';
55
28
  /**
56
29
  * Where the agent-facing files go, which is not always where the app is.
57
30
  *
@@ -67,8 +40,9 @@ function agentFile(input, relPath) {
67
40
  }
68
41
  const CLAUDE_MCP_TITLE = 'MCP server (Claude, global)';
69
42
  function claudeMcpStep(input) {
70
- if (!input.claudeCli)
43
+ if (!input.claudeCli) {
71
44
  return null;
45
+ }
72
46
  if (input.mcpExists) {
73
47
  return {
74
48
  title: CLAUDE_MCP_TITLE,
@@ -110,10 +84,12 @@ function otherClientSteps(input) {
110
84
  * leaves it alone (ALREADY) or hands over the block (MANUAL), exactly as before.
111
85
  */
112
86
  function codexCliStep(spec, configPath, existing, input) {
113
- if (McpClient.CODEX !== spec.id || true !== input.codexCli)
87
+ if (McpClient.CODEX !== spec.id || true !== input.codexCli) {
114
88
  return null;
115
- if (codexNamesOurServer(existing, spec.serversKey))
89
+ }
90
+ if (codexNamesOurServer(existing, spec.serversKey)) {
116
91
  return null;
92
+ }
117
93
  const cmd = codexAddCommand();
118
94
  return {
119
95
  title: `MCP server (${spec.label})`,
@@ -160,8 +136,9 @@ function clientStep(spec, configPath, existing) {
160
136
  * they get the notice instead.
161
137
  */
162
138
  function claudeProjectStep(input) {
163
- if (input.claudeCli || true !== input.insideClaudeCode)
139
+ if (input.claudeCli || true !== input.insideClaudeCode) {
164
140
  return null;
141
+ }
165
142
  const path = agentFile(input, CLAUDE_PROJECT_CONFIG);
166
143
  return clientStep(CLAUDE_PROJECT_SPEC, path, input.claudeProjectConfig ?? null);
167
144
  }
@@ -227,8 +204,9 @@ function mcpSteps(input) {
227
204
  });
228
205
  }
229
206
  const windowsNote = windowsMcpNoteStep(input);
230
- if (windowsNote !== null)
207
+ if (windowsNote !== null) {
231
208
  steps.push(windowsNote);
209
+ }
232
210
  return steps;
233
211
  }
234
212
  const SLASH_COMMAND_TITLE = 'The /reticle command';
@@ -240,8 +218,9 @@ const SLASH_COMMAND_TITLE = 'The /reticle command';
240
218
  * our signature is left exactly as it is.
241
219
  */
242
220
  function commandIsSettled(content) {
243
- if (null === content || content === undefined)
221
+ if (null === content || content === undefined) {
244
222
  return false;
223
+ }
245
224
  return content === SLASH_COMMAND_BODY || !content.includes(SLASH_COMMAND_SIGNATURE);
246
225
  }
247
226
  /**
@@ -253,8 +232,9 @@ function commandIsSettled(content) {
253
232
  * whole file is Reticle's, so a stale one is rewritten.
254
233
  */
255
234
  function commandStepFor(path, present, content) {
256
- if (!present)
235
+ if (!present) {
257
236
  return null;
237
+ }
258
238
  return commandIsSettled(content)
259
239
  ? {
260
240
  title: SLASH_COMMAND_TITLE,
@@ -297,18 +277,34 @@ const AGENT_RULE_DETAIL = 'teach the agent to verify features with Reticle after
297
277
  */
298
278
  function ruleWriteDetail(existing, file) {
299
279
  const hasContent = existing !== null && existing !== undefined && existing.trim().length > 0;
300
- if (!hasContent)
280
+ if (!hasContent) {
301
281
  return `${AGENT_RULE_DETAIL} — creates ${file}`;
282
+ }
302
283
  const added = markedBlock().split('\n').length - 1;
303
284
  return (`${AGENT_RULE_DETAIL} — APPENDS ${String(added)} lines to your existing ${file}, ` +
304
285
  'inside `reticle:begin`/`reticle:end` markers; delete the marked block to undo');
305
286
  }
306
287
  const RETICLE_MD_TITLE = 'Full agent rules';
307
288
  const RETICLE_MD_DETAIL = 'the reference the always-loaded rule points at (when NOT to verify, recovery, feedback)';
289
+ /**
290
+ * A line that is only an import of AGENTS.md (`@AGENTS.md`, `@./AGENTS.md`). Claude Code reads the
291
+ * imported file as part of CLAUDE.md, so the rule `agentsMdStep` writes there already reaches it, and
292
+ * a second copy in CLAUDE.md would load the rule twice on every turn (#1680).
293
+ */
294
+ const IMPORTS_AGENTS_MD = /^[ \t]*@(?:\.\/)?AGENTS\.md[ \t]*\r?$/m;
308
295
  function claudeRuleStep(input) {
309
- if (!input.claudeCli)
296
+ if (!input.claudeCli) {
310
297
  return null;
298
+ }
311
299
  const path = agentFile(input, CLAUDE_MD_PATH);
300
+ if (IMPORTS_AGENTS_MD.test(input.claudeMdContent ?? '')) {
301
+ return {
302
+ title: AGENT_RULE_TITLE,
303
+ target: path,
304
+ status: StepStatus.ALREADY,
305
+ detail: `CLAUDE.md imports ${AGENTS_MD_PATH}, so the Reticle rule written there reaches Claude Code through the import; no second copy`,
306
+ };
307
+ }
312
308
  const r = mergeMarkedInstruction(input.claudeMdContent);
313
309
  if (r.status === AgentRuleStatus.ALREADY) {
314
310
  return {
@@ -334,10 +330,12 @@ function claudeRuleStep(input) {
334
330
  */
335
331
  function cursorRuleStep(input) {
336
332
  const cursorGlobal = (input.detectedClients ?? []).some((c) => c.id === McpClient.CURSOR);
337
- if (!cursorGlobal && true !== input.cursorProjectPresent)
333
+ if (!cursorGlobal && true !== input.cursorProjectPresent) {
338
334
  return null;
339
- if (input.cursorProjectPresent !== true && input.claudeCli)
335
+ }
336
+ if (input.cursorProjectPresent !== true && input.claudeCli) {
340
337
  return null;
338
+ }
341
339
  // The whole file is Reticle's — init created it — so a stale one is REWRITTEN rather than merged.
342
340
  // Comparing content is what makes the rule updatable; comparing existence made it permanent.
343
341
  const path = agentFile(input, CURSOR_RULE_PATH);
@@ -458,95 +456,44 @@ function reticleMdStep(input) {
458
456
  * noise.
459
457
  */
460
458
  function agentRuleSteps(input) {
461
- if (!input.options.mcp)
459
+ if (!input.options.mcp) {
462
460
  return [];
461
+ }
463
462
  return [...stepsForAgents(input, (a) => a.ruleStep), agentsMdStep(input), reticleMdStep(input)];
464
463
  }
465
464
  /**
466
- * What to say when the install command fails.
467
- *
468
- * pnpm's `minimumReleaseAge` refuses any release younger than the configured window — a deliberate
469
- * supply-chain policy, not a bug — with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`. Unpinned it silently
470
- * resolves to an OLDER version instead, which is how an app ends up running a stale SDK against a
471
- * newer daemon: the connection succeeds, the protocol disagrees, and the failure surfaces as -32000
472
- * with nothing naming a version. Pinning turns that into this loud failure, which is the better
473
- * trade — but only if the message says what to do about it.
474
- */
475
- /**
476
- * Is this package already declared at the version we would pin?
465
+ * Keep `.reticle/` out of the project's `prettier --check`.
477
466
  *
478
- * The caret is the point. `npm i -D pkg@3.2.0` WRITES `^3.2.0`, so on every later run the pinned
479
- * `3.2.0` failed a string comparison against the range npm had just created, and init re-installed
480
- * for ever. Only the two forms a package manager writes by itself are read - an exact version, and
481
- * `^`/`~` over that same version.
467
+ * When the project uses Prettier, `init` adds `.reticle/` to the project's `.prettierignore`.
468
+ * The nested `.reticle/.gitignore` does not help — Prettier only reads the ignore file of the
469
+ * directory it runs from — and matching Prettier's exact JSON output is a losing game, so
470
+ * excluding the directory is the root-cause fix (#1560).
482
471
  *
483
- * Everything else installs. A false "already" leaves somebody with no SDK and an install that
484
- * claims to have run; a redundant install only costs time, so the unreadable cases go the safe way.
472
+ * SKIP when the project does not use Prettier. ALREADY when `.reticle/` is already excluded.
473
+ * Otherwise APPLY: create the file, or append the entry to the existing one.
485
474
  */
486
- function alreadyDeclared(declared, pinned) {
487
- if (declared === undefined)
488
- return false;
489
- if (pinned === undefined || 0 === pinned.length)
490
- return true;
491
- return declared === pinned || `^${pinned}` === declared || `~${pinned}` === declared;
492
- }
493
- function installStep(input) {
494
- const pm = input.detection.packageManager;
495
- // `Detection.packageManagerCommand` (see `preflight.ts`, #1149). Falling back to `pm` covers every
496
- // caller that never went through preflight (tests, and any future one).
497
- const pmCommand = input.detection.packageManagerCommand ?? pm;
498
- const packages = pinnedPackages(frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion);
499
- const command = installCommand(pm, packages, pmCommand);
500
- /*
501
- * A re-run over an already-wired project does NO dependency work.
502
- *
503
- * Reported from the field: `init` on an instrumented npm-workspaces project ran a pnpm dependency
504
- * migration, which moved the existing `node_modules` aside and broke the dev server. Whatever
505
- * manager gets chosen, the redundant install is the step that touches the tree — so the fix that
506
- * matters is not running one that nothing needs. `init` is documented as idempotent; this is the
507
- * step that was not.
508
- */
509
- const declared = input.detection.dependencies ?? {};
510
- const version = input.options.sdkVersion;
511
- const names = frameworkPackages(input.detection.framework, input.detection.uiLibrary);
512
- if (names.length > 0 && names.every((n) => alreadyDeclared(declared[n], version))) {
513
- return {
514
- title: 'Install dependencies',
515
- target: 'package.json',
516
- status: StepStatus.ALREADY,
517
- detail: `${names.join(', ')} already declared${version === undefined || 0 === version.length ? '' : ` at ${version}`}`,
518
- };
519
- }
520
- // Never install into a linked tree: the package manager replaces the link (a workspace's tree).
521
- if (true === input.nodeModulesLinked) {
522
- return {
523
- title: 'Install dependencies',
524
- target: 'package.json',
525
- status: StepStatus.SKIP,
526
- detail: `skipped: node_modules is a link to another tree, which an install here would replace; install them where it points: ${command}`,
527
- };
475
+ function prettierIgnoreStep(input) {
476
+ if (true !== input.detection.usesPrettier) {
477
+ return null;
528
478
  }
529
- // The person said `--no-install`: a choice, not a step left undone, so not a ⚠.
530
- if (!input.options.install) {
479
+ const existing = input.prettierIgnoreSource ?? null;
480
+ if (null !== existing && ignoresReticle(existing)) {
531
481
  return {
532
- title: 'Install dependencies',
533
- target: 'package.json',
534
- status: StepStatus.SKIP,
535
- detail: `skipped (--no-install); install them yourself: ${command}`,
482
+ title: 'Prettier ignore',
483
+ target: PRETTIERIGNORE_PATH,
484
+ status: StepStatus.ALREADY,
485
+ detail: '.reticle/ already excluded from Prettier',
536
486
  };
537
487
  }
538
- const parts = installCommandParts(pm, packages, [], pmCommand);
488
+ const content = withReticleIgnored(existing);
539
489
  return {
540
- title: 'Install dependencies',
541
- target: 'package.json',
490
+ title: 'Prettier ignore',
491
+ target: PRETTIERIGNORE_PATH,
542
492
  status: StepStatus.APPLY,
543
- detail: command,
544
- exec: {
545
- command: parts.command,
546
- args: parts.args,
547
- fallback: `${command}\n\n${installFailureHint(pm, pmCommand)}`,
548
- },
549
- retries: installRetries(pm, packages, frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion, pmCommand),
493
+ detail: null === existing
494
+ ? 'create .prettierignore excluding .reticle/ so `prettier --check` passes'
495
+ : 'add .reticle/ to .prettierignore so `prettier --check` passes',
496
+ write: { path: PRETTIERIGNORE_PATH, content, expect: [RETICLE_IGNORE_ENTRY] },
550
497
  };
551
498
  }
552
499
  const RETICLE_CONFIG_TITLE = 'Reticle config';
@@ -565,12 +512,15 @@ const AGENT_ROOT_CONFIG_TITLE = 'Reticle config (agent root)';
565
512
  * and the two files are byte-identical, so neither can disagree with the other about the port or the
566
513
  * project identity.
567
514
  */
568
- function agentRootConfigStep(input, content) {
515
+ function agentRootConfigStep(input, content, renamedFrom) {
569
516
  const root = input.agentFileRoot;
570
- if (root === undefined || 0 === root.length)
517
+ if (root === undefined || 0 === root.length) {
571
518
  return [];
519
+ }
572
520
  const path = agentFile(input, RETICLE_CONFIG_FILE);
573
- const existingProject = projectIdOf(input.agentRootConfigSource);
521
+ const declared = projectIdOf(input.agentRootConfigSource);
522
+ // The root's copy of the id `--new-id` just replaced is this checkout's, not another app's.
523
+ const existingProject = declared === renamedFrom ? undefined : declared;
574
524
  const wantedProject = projectIdOf(content);
575
525
  /**
576
526
  * A monorepo has more than one app and the root can only point at one of them.
@@ -634,7 +584,7 @@ function agentRootConfigStep(input, content) {
634
584
  },
635
585
  ];
636
586
  }
637
- function reticleConfigStep(input, content, onDisk, movedFrom) {
587
+ function reticleConfigStep(input, content, onDisk, movedFrom, renamedFrom) {
638
588
  if (true === input.reticleConfigExists) {
639
589
  const problem = existingConfigProblem(onDisk);
640
590
  if (problem !== undefined) {
@@ -650,15 +600,17 @@ function reticleConfigStep(input, content, onDisk, movedFrom) {
650
600
  // The one thing a re-run can still learn: which channel the user actually arrived through.
651
601
  // See configWithInstallSource — it only ever ADDS a field that is absent.
652
602
  const backfilled = configWithInstallSource(onDisk, input.installSource);
653
- if (backfilled !== undefined || movedFrom !== undefined) {
603
+ if (backfilled !== undefined || movedFrom !== undefined || renamedFrom !== undefined) {
654
604
  const port = input.options.port;
655
605
  return {
656
606
  title: RETICLE_CONFIG_TITLE,
657
607
  target: RETICLE_CONFIG_FILE,
658
608
  status: StepStatus.APPLY,
659
- detail: movedFrom !== undefined && port !== undefined
660
- ? portMoveDetail(movedFrom, port)
661
- : 'record which install route this project came through',
609
+ detail: renamedFrom !== undefined
610
+ ? `this checkout's own project id: ${String(input.options.projectId)} (was ${renamedFrom}, the id it was copied with)`
611
+ : movedFrom !== undefined && port !== undefined
612
+ ? portMoveDetail(movedFrom, port)
613
+ : 'record which install route this project came through',
662
614
  write: { path: RETICLE_CONFIG_FILE, content: backfilled ?? onDisk ?? content },
663
615
  };
664
616
  }
@@ -686,11 +638,27 @@ function reticleConfigStep(input, content, onDisk, movedFrom) {
686
638
  * requested port, and a config still naming the old one is half of a split brain (see
687
639
  * port-steps.ts). Only the port moves; the identity stays the one already on disk.
688
640
  */
641
+ /**
642
+ * `--new-id`: the config with `projectId` replaced by the one derived for THIS checkout, or null when
643
+ * there is nothing to replace. A copied repository keeps the original's `.reticle.json`, and init
644
+ * otherwise keeps a config on disk as it is, so nothing else gives the copy an id of its own.
645
+ */
646
+ function withOwnProjectId(source, options) {
647
+ const id = options.projectId;
648
+ const from = projectIdOf(source);
649
+ if (true !== options.newId || id === undefined || from === undefined || from === id) {
650
+ return null;
651
+ }
652
+ const fields = JSON.parse(source);
653
+ return { code: `${JSON.stringify({ ...fields, projectId: id }, null, 2)}\n`, from };
654
+ }
689
655
  function reticleConfigSteps(input) {
690
656
  const existing = input.reticleConfigSource;
691
- const onDisk = true === input.reticleConfigExists && null !== existing && existing !== undefined
657
+ const copied = true === input.reticleConfigExists && null !== existing && existing !== undefined
692
658
  ? existing
693
659
  : undefined;
660
+ const renamed = copied === undefined ? null : withOwnProjectId(copied, input.options);
661
+ const onDisk = renamed?.code ?? copied;
694
662
  const moved = onDisk === undefined ? null : retargetConfigPort(onDisk, input.options.port);
695
663
  const content = moved?.code ??
696
664
  onDisk ??
@@ -699,12 +667,36 @@ function reticleConfigSteps(input) {
699
667
  // config written before this field existed, and the two mean different things.
700
668
  input.installSource);
701
669
  return [
702
- reticleConfigStep(input, content, moved?.code ?? existing, moved?.from),
703
- ...agentRootConfigStep(input, content),
670
+ reticleConfigStep(input, content, moved?.code ?? onDisk, moved?.from, renamed?.from),
671
+ ...agentRootConfigStep(input, content, renamed?.from),
672
+ ...(null === renamed ? [] : copiedIdSteps(input, renamed.from)),
704
673
  ];
705
674
  }
675
+ /**
676
+ * `--new-id`: the old id where the app quotes it by hand, as in `connect({ projectId: '…' })`. Left
677
+ * there, the copy's evidence goes on into the original checkout. Only the whole quoted literal moves:
678
+ * the id is a hash-suffixed name nothing else spells.
679
+ */
680
+ function copiedIdSteps(input, from) {
681
+ const id = input.options.projectId;
682
+ if (id === undefined) {
683
+ return [];
684
+ }
685
+ return (input.copiedIdSources ?? []).map(({ path, source }) => ({
686
+ title: RETICLE_CONFIG_TITLE,
687
+ target: path,
688
+ status: StepStatus.APPLY,
689
+ detail: `the project id the app names by hand: ${id} (was ${from})`,
690
+ write: {
691
+ path,
692
+ content: ['"', "'", '`'].reduce((code, q) => code.replaceAll(`${q}${from}${q}`, `${q}${id}${q}`), source),
693
+ expect: [id],
694
+ },
695
+ }));
696
+ }
706
697
  export function buildPlan(input) {
707
698
  const cspSteps = cspStep(input);
699
+ const prettierIgnore = prettierIgnoreStep(input);
708
700
  const steps = [
709
701
  ...cspSteps,
710
702
  ...mcpSteps(input),
@@ -714,11 +706,15 @@ export function buildPlan(input) {
714
706
  stop: true === input.hooks,
715
707
  // Beside the CLAUDE.md rule: the same agent, the same consent to write its instruction files.
716
708
  compact: input.options.mcp && input.claudeCli,
709
+ prompt: true === input.claudePromptHook,
710
+ bin: input.hookBin,
717
711
  }),
712
+ ...promptHookSteps(input.promptHooks ?? [], true === input.claudePromptHook, input.hookBin),
718
713
  ...uiLibraryStep(input),
719
714
  ...webGlCanvasStep(input),
720
715
  installStep(input),
721
716
  ...reticleConfigSteps(input),
717
+ ...(prettierIgnore !== null ? [prettierIgnore] : []),
722
718
  ];
723
719
  // Both steps can patch index.html: the connect write must keep the CSP edit that runs first.
724
720
  const patchedIndex = cspSteps.find((step) => step.write?.path === HTML_INDEX_PATH)?.write
@@ -727,7 +723,8 @@ export function buildPlan(input) {
727
723
  // LAST, and a notice rather than an action: it is a statement about this machine's shape, and it
728
724
  // only matters once every file above has been written. See containerised-dev-server.ts.
729
725
  const container = containerisedStep(input);
730
- if (container !== null)
726
+ if (container !== null) {
731
727
  steps.push(container);
728
+ }
732
729
  return { framework: input.detection.framework, uiLibrary: input.detection.uiLibrary, steps };
733
730
  }
@@ -1,4 +1,5 @@
1
1
  import { type Step } from './plan-types.js';
2
+ import { type PromptHookTarget } from '../register/prompt-hooks.js';
2
3
  export declare const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
3
4
  export declare const STOP_HOOK_COMMAND = "npx @reticlehq/server report --hook";
4
5
  export declare function stopHookStep(path: string, existing: string | null | undefined): Step;
@@ -20,4 +21,14 @@ export declare function compactHookStep(path: string, existing: string | null |
20
21
  export declare function claudeSettingsSteps(path: string, existing: string | null | undefined, options: {
21
22
  stop: boolean;
22
23
  compact: boolean;
24
+ prompt?: boolean;
25
+ bin?: string | undefined;
23
26
  }): Step[];
27
+ /**
28
+ * The consent line. Names all that the agent hooks keep, what leaves with a linked run and under
29
+ * which setting, and the one switch that stops all of it.
30
+ */
31
+ export declare const PROMPT_CAPTURE_NOTICE: string;
32
+ export declare function promptHookStep(target: PromptHookTarget): Step;
33
+ /** The steps for every detected agent, then the one consent line when any hook is in place. */
34
+ export declare function promptHookSteps(targets: readonly PromptHookTarget[], claude: boolean, bin?: string): Step[];
@@ -1,12 +1,14 @@
1
1
  /**
2
- * `init --hooks`: the print-only Claude Code Stop hook, merged into `.claude/settings.json`.
2
+ * `init --hooks`: the Claude Code Stop hook, merged into `.claude/settings.json`.
3
3
  *
4
4
  * Opt-in, because it writes into a file the user and other tools own. When the agent stops without a
5
- * single claim having held, the hook prints one line saying so; it never blocks the agent. The line
6
- * itself is `reticle report --hook`, which reads the session journal on disk.
5
+ * single claim having held, the hook prints one line saying so and does not block. The line
6
+ * itself is `reticle report --hook`, which reads the session journal on disk. It blocks the stop in one
7
+ * case: a Harness drive the agent started still runs, so the agent stays to read its result.
7
8
  */
8
9
  import { RETICLE_NPM_PACKAGE } from '../version.js';
9
10
  import { StepStatus } from './plan-types.js';
11
+ import { PROMPT_HOOK_AGENTS, PromptHookAgent, PromptHookStatus, installPromptHook, promptHookCommand, } from '../register/prompt-hooks.js';
10
12
  export const CLAUDE_SETTINGS_PATH = '.claude/settings.json';
11
13
  export const STOP_HOOK_COMMAND = `npx ${RETICLE_NPM_PACKAGE} report --hook`;
12
14
  const TITLE = 'Claude Code Stop hook';
@@ -48,7 +50,7 @@ export function stopHookStep(path, existing) {
48
50
  title: TITLE,
49
51
  target: path,
50
52
  status: StepStatus.APPLY,
51
- detail: 'print one line when the agent stops without a claim that held; never blocks it',
53
+ detail: 'print one line when the agent stops without a claim that held; blocks only while its Harness drive runs',
52
54
  write: { path, content: `${JSON.stringify(merged, null, 2)}\n`, expect: [STOP_HOOK_COMMAND] },
53
55
  };
54
56
  }
@@ -116,7 +118,81 @@ export function claudeSettingsSteps(path, existing, options) {
116
118
  steps.push(stop);
117
119
  current = stop.write?.content ?? current;
118
120
  }
119
- if (options.compact)
120
- steps.push(compactHookStep(path, current));
121
+ if (options.compact) {
122
+ const compact = compactHookStep(path, current);
123
+ steps.push(compact);
124
+ current = compact.write?.content ?? current;
125
+ }
126
+ const claude = PROMPT_HOOK_AGENTS.find((agent) => PromptHookAgent.CLAUDE_CODE === agent.id);
127
+ if (true === options.prompt && claude !== undefined) {
128
+ steps.push(promptHookStep({
129
+ spec: claude,
130
+ path,
131
+ existing: current ?? null,
132
+ ...(options.bin === undefined ? {} : { bin: options.bin }),
133
+ }));
134
+ }
121
135
  return steps;
122
136
  }
137
+ /**
138
+ * The prompt-capture hook, for each agent found. On by default, because the request is what a
139
+ * verdict is checked against and an agent forgets to relay it. `--no-prompt-capture` skips it;
140
+ * `"promptCapture": false` in `.reticle.json` keeps every agent hook from writing; `reticle hook uninstall`
141
+ * removes exactly what this added.
142
+ */
143
+ const PROMPT_TITLE = 'Prompt capture';
144
+ const NOTICE_TARGET = '.reticle/request.json';
145
+ /**
146
+ * The consent line. Names all that the agent hooks keep, what leaves with a linked run and under
147
+ * which setting, and the one switch that stops all of it.
148
+ */
149
+ export const PROMPT_CAPTURE_NOTICE = 'Reticle keeps these in .reticle/ in this project, with secrets removed: each prompt you send, the tool calls your agent makes (commands, file edits, web calls), its model, and its closing message for each turn. ' +
150
+ 'Tool calls stay on this machine. ' +
151
+ 'When the project is linked (reticle connect, or RETICLE_API_KEY in the environment), each synced run sends the model to your own workspace. ' +
152
+ 'Your prompts and the closing messages also go there with the runs, and to the Harness, unless "shareRequests": false is in .reticle.json. ' +
153
+ 'Stop all of it: "promptCapture": false in .reticle.json, or RETICLE_PROMPT_CAPTURE=0 on this machine.';
154
+ export function promptHookStep(target) {
155
+ const { spec, path, existing, bin } = target;
156
+ const title = `${PROMPT_TITLE}: ${spec.label}`;
157
+ const merged = installPromptHook(spec, existing, bin);
158
+ const then = spec.activation === undefined ? '' : `. ${spec.activation}`;
159
+ if (PromptHookStatus.ALREADY === merged.status) {
160
+ return {
161
+ title,
162
+ target: path,
163
+ status: StepStatus.ALREADY,
164
+ detail: `hook already installed${then}`,
165
+ };
166
+ }
167
+ if (PromptHookStatus.MANUAL === merged.status) {
168
+ return {
169
+ title,
170
+ target: path,
171
+ status: StepStatus.MANUAL,
172
+ detail: `${path} is not plain JSON, so it was left alone. Add a prompt-submit hook that runs \`${promptHookCommand(spec.id)}\` (${spec.docs})`,
173
+ };
174
+ }
175
+ return {
176
+ title,
177
+ target: path,
178
+ status: StepStatus.APPLY,
179
+ detail: `save each prompt, tool call, model and closing message to .reticle/; never blocks the agent${then}`,
180
+ write: { path, content: merged.content, expect: [promptHookCommand(spec.id)] },
181
+ };
182
+ }
183
+ /** The steps for every detected agent, then the one consent line when any hook is in place. */
184
+ export function promptHookSteps(targets, claude, bin) {
185
+ const steps = targets.map((target) => promptHookStep(bin === undefined ? target : { ...target, bin }));
186
+ if (0 === steps.length && !claude) {
187
+ return steps;
188
+ }
189
+ return [
190
+ ...steps,
191
+ {
192
+ title: PROMPT_TITLE,
193
+ target: NOTICE_TARGET,
194
+ status: StepStatus.NOTICE,
195
+ detail: PROMPT_CAPTURE_NOTICE,
196
+ },
197
+ ];
198
+ }