@reticlehq/init 3.6.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 (64) hide show
  1. package/dist/detect/detect.d.ts +9 -0
  2. package/dist/detect/detect.js +5 -1
  3. package/dist/detect/dev-script.d.ts +17 -0
  4. package/dist/detect/dev-script.js +128 -0
  5. package/dist/detect/dev-server-ports.d.ts +2 -0
  6. package/dist/detect/dev-server-ports.js +20 -0
  7. package/dist/detect/prettier-config.d.ts +21 -0
  8. package/dist/detect/prettier-config.js +38 -0
  9. package/dist/detect/workspace-apps.d.ts +11 -0
  10. package/dist/detect/workspace-apps.js +63 -0
  11. package/dist/detect/workspace-redirect.js +69 -10
  12. package/dist/diagnose/closing-hint.d.ts +5 -0
  13. package/dist/diagnose/closing-hint.js +18 -11
  14. package/dist/diagnose/init-failure.d.ts +2 -0
  15. package/dist/diagnose/init-failure.js +2 -0
  16. package/dist/gather-plan-input.d.ts +11 -0
  17. package/dist/gather-plan-input.js +468 -0
  18. package/dist/host.d.ts +6 -0
  19. package/dist/index.d.ts +11 -4
  20. package/dist/index.js +14 -4
  21. package/dist/no-package-json.js +62 -14
  22. package/dist/node-io.d.ts +1 -1
  23. package/dist/node-io.js +11 -2
  24. package/dist/patch/next-patch.d.ts +45 -9
  25. package/dist/patch/next-patch.js +93 -0
  26. package/dist/patch/nuxt-snippets.js +1 -1
  27. package/dist/patch/prettier-ignore.d.ts +34 -0
  28. package/dist/patch/prettier-ignore.js +49 -0
  29. package/dist/patch/python-snippets.d.ts +22 -0
  30. package/dist/patch/python-snippets.js +113 -0
  31. package/dist/patch/snippets.d.ts +10 -31
  32. package/dist/patch/snippets.js +42 -105
  33. package/dist/patch/static-page.d.ts +1 -0
  34. package/dist/patch/static-page.js +6 -8
  35. package/dist/patch/vite-config.js +11 -0
  36. package/dist/plan/connect-steps.d.ts +2 -0
  37. package/dist/plan/connect-steps.js +6 -0
  38. package/dist/plan/framework-adapter.d.ts +10 -0
  39. package/dist/plan/framework-adapter.js +122 -3
  40. package/dist/plan/notices.js +1 -1
  41. package/dist/plan/plan-angular.js +1 -1
  42. package/dist/plan/plan-framework.d.ts +1 -1
  43. package/dist/plan/plan-framework.js +140 -37
  44. package/dist/plan/plan-types.d.ts +50 -2
  45. package/dist/plan/plan-vite.js +15 -0
  46. package/dist/plan/plan.d.ts +1 -10
  47. package/dist/plan/plan.js +181 -126
  48. package/dist/plan/stop-hook-step.d.ts +30 -0
  49. package/dist/plan/stop-hook-step.js +154 -5
  50. package/dist/project/agent-rules.d.ts +1 -1
  51. package/dist/project/agent-rules.js +22 -17
  52. package/dist/project/refresh-rules.js +2 -1
  53. package/dist/register/codex-toml.d.ts +12 -0
  54. package/dist/register/codex-toml.js +44 -0
  55. package/dist/register/mcp.d.ts +15 -2
  56. package/dist/register/mcp.js +16 -0
  57. package/dist/register/prompt-hooks.d.ts +123 -0
  58. package/dist/register/prompt-hooks.js +449 -0
  59. package/dist/register/slash-command.d.ts +1 -1
  60. package/dist/register/slash-command.js +5 -4
  61. package/dist/run-types.d.ts +10 -0
  62. package/dist/run.d.ts +1 -1
  63. package/dist/run.js +15 -390
  64. package/package.json +2 -2
package/dist/plan/plan.js CHANGED
@@ -3,53 +3,28 @@
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
- import { claudeAddCommand, claudeProjectMcpJson, mcpManual } from '../register/mcp.js';
6
+ import { claudeAddCommand, claudeProjectMcpJson, codexAddCommand, mcpManual, } from '../register/mcp.js';
7
+ import { codexNamesOurServer } from '../register/codex-toml.js';
10
8
  import { mergeClientConfig, ClientMergeStatus, clientSnippet, clientSpec, CLAUDE_PROJECT_CONFIG, CLAUDE_PROJECT_SPEC, McpClient, } from '../register/mcp-clients.js';
11
9
  import { CLAUDE_COMMAND_PATH, CURSOR_COMMAND_PATH, SLASH_COMMAND_BODY, SLASH_COMMAND_SIGNATURE, } from '../register/slash-command.js';
12
10
  import { mergeMarkedInstruction, reticleMdFile, cursorRuleFile, AgentRuleStatus, CLAUDE_MD_PATH, markedBlock, AGENTS_MD_PATH, RETICLE_MD_PATH, CURSOR_RULE_PATH, } from '../project/agent-rules.js';
13
11
  import { cspStep } from '../diagnose/csp-step.js';
12
+ import { HTML_INDEX_PATH } from '../patch/static-page.js';
14
13
  import { frameworkSteps } from './framework-adapter.js';
15
- import { FRAMEWORK_ADAPTERS, RETICLE_BROWSER_SDK, RETICLE_REACT_KIT } from './framework-adapter.js';
14
+ import { installStep } from './framework-adapter.js';
16
15
  import { MCP_TARGET, StepStatus } from './plan-types.js';
17
16
  export { DEPS_TARGET, MCP_TARGET, StepStatus, } from './plan-types.js';
18
17
  import { join } from 'node:path';
19
- import { reticleConfigContent, usesReactKit } from '../patch/snippets.js';
18
+ import { reticleConfigContent } from '../patch/snippets.js';
20
19
  import { configWithInstallSource } from '../project/install-source-config.js';
21
20
  import { portMoveDetail, retargetConfigPort } from './port-steps.js';
22
21
  import { containerisedStep, uiLibraryStep, webGlCanvasStep, windowsMcpNoteStep, } from './notices.js';
23
22
  import { existingConfigProblem, projectIdOf, RETICLE_CONFIG_FILE, } from '../detect/existing-config.js';
24
- import { CLAUDE_SETTINGS_PATH, stopHookStep } 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';
25
25
  // Re-exported: it moved to the module that reads it, and every existing importer says `plan.js`.
26
26
  export { RETICLE_CONFIG_FILE };
27
- /**
28
- * Pin the SDK to the CLI's own version.
29
- *
30
- * `pnpm add -D @reticlehq/react` once installed an older release in one project while npm and yarn
31
- * took the current one in the next — a stale registry metadata cache, invisible to everyone. A
32
- * version-skewed SDK talking to a newer daemon is the `-32000` failure path: the app connects, the
33
- * protocol disagrees, and nothing on either side names a version. Asking for the CLI's exact version
34
- * makes the cache irrelevant, and a skewed pair impossible to install by accident.
35
- */
36
- function pinnedPackages(packages, version) {
37
- if (version === undefined || 0 === version.length)
38
- return packages;
39
- return packages.map((p) => `${p}@${version}`);
40
- }
41
- /**
42
- * The dev-dependencies `reticle init` installs — kit (or sensor) first, build plugin next.
43
- *
44
- * `uiLibrary` matters because the framework does not always name the renderer. The rule below was
45
- * already written for Nuxt, correctly, and applied only there: a Vue app on plain Vite is
46
- * `Framework.VITE` and a SvelteKit app renders Svelte, and both were being handed the React kit
47
- * right after `init` had detected the real UI library and said so on screen.
48
- */
49
- export function frameworkPackages(framework, uiLibrary = UiLibrary.UNKNOWN) {
50
- const kit = usesReactKit(uiLibrary, framework) ? RETICLE_REACT_KIT : RETICLE_BROWSER_SDK;
51
- return FRAMEWORK_ADAPTERS[framework].packages(kit);
52
- }
27
+ export { frameworkPackages } from './framework-adapter.js';
53
28
  /**
54
29
  * Where the agent-facing files go, which is not always where the app is.
55
30
  *
@@ -65,8 +40,9 @@ function agentFile(input, relPath) {
65
40
  }
66
41
  const CLAUDE_MCP_TITLE = 'MCP server (Claude, global)';
67
42
  function claudeMcpStep(input) {
68
- if (!input.claudeCli)
43
+ if (!input.claudeCli) {
69
44
  return null;
45
+ }
70
46
  if (input.mcpExists) {
71
47
  return {
72
48
  title: CLAUDE_MCP_TITLE,
@@ -92,7 +68,36 @@ function claudeMcpStep(input) {
92
68
  * parse is reported with a paste-able block rather than overwritten, and everything else is written.
93
69
  */
94
70
  function otherClientSteps(input) {
95
- return (input.detectedClients ?? []).map((detected) => clientStep(clientSpec(detected.id), detected.configPath, detected.existing));
71
+ return (input.detectedClients ?? []).map((detected) => {
72
+ const spec = clientSpec(detected.id);
73
+ return (codexCliStep(spec, detected.configPath, detected.existing, input) ??
74
+ clientStep(spec, detected.configPath, detected.existing));
75
+ });
76
+ }
77
+ /**
78
+ * Codex, registered through its own CLI instead of a printed block (#1238).
79
+ *
80
+ * `mergeClientConfig` answers MANUAL for TOML, since we never edit it blind. `codex mcp add` does
81
+ * the editing, so with the binary on PATH nothing is left for a human. It REPLACES an entry of the
82
+ * same name and exits 0, so this answers only when the config names no `reticle` server at all, in
83
+ * any shape. An entry somebody wrote — a local build, a `url` — falls through to `clientStep`, which
84
+ * leaves it alone (ALREADY) or hands over the block (MANUAL), exactly as before.
85
+ */
86
+ function codexCliStep(spec, configPath, existing, input) {
87
+ if (McpClient.CODEX !== spec.id || true !== input.codexCli) {
88
+ return null;
89
+ }
90
+ if (codexNamesOurServer(existing, spec.serversKey)) {
91
+ return null;
92
+ }
93
+ const cmd = codexAddCommand();
94
+ return {
95
+ title: `MCP server (${spec.label})`,
96
+ target: configPath,
97
+ status: StepStatus.APPLY,
98
+ detail: `register reticle with ${spec.label} (${cmd.display})`,
99
+ exec: { command: cmd.command, args: cmd.args, fallback: cmd.display },
100
+ };
96
101
  }
97
102
  /** One client's step: already-correct is left alone, an unparseable file gets a paste-able block. */
98
103
  function clientStep(spec, configPath, existing) {
@@ -131,8 +136,9 @@ function clientStep(spec, configPath, existing) {
131
136
  * they get the notice instead.
132
137
  */
133
138
  function claudeProjectStep(input) {
134
- if (input.claudeCli || true !== input.insideClaudeCode)
139
+ if (input.claudeCli || true !== input.insideClaudeCode) {
135
140
  return null;
141
+ }
136
142
  const path = agentFile(input, CLAUDE_PROJECT_CONFIG);
137
143
  return clientStep(CLAUDE_PROJECT_SPEC, path, input.claudeProjectConfig ?? null);
138
144
  }
@@ -198,8 +204,9 @@ function mcpSteps(input) {
198
204
  });
199
205
  }
200
206
  const windowsNote = windowsMcpNoteStep(input);
201
- if (windowsNote !== null)
207
+ if (windowsNote !== null) {
202
208
  steps.push(windowsNote);
209
+ }
203
210
  return steps;
204
211
  }
205
212
  const SLASH_COMMAND_TITLE = 'The /reticle command';
@@ -211,8 +218,9 @@ const SLASH_COMMAND_TITLE = 'The /reticle command';
211
218
  * our signature is left exactly as it is.
212
219
  */
213
220
  function commandIsSettled(content) {
214
- if (null === content || content === undefined)
221
+ if (null === content || content === undefined) {
215
222
  return false;
223
+ }
216
224
  return content === SLASH_COMMAND_BODY || !content.includes(SLASH_COMMAND_SIGNATURE);
217
225
  }
218
226
  /**
@@ -224,8 +232,9 @@ function commandIsSettled(content) {
224
232
  * whole file is Reticle's, so a stale one is rewritten.
225
233
  */
226
234
  function commandStepFor(path, present, content) {
227
- if (!present)
235
+ if (!present) {
228
236
  return null;
237
+ }
229
238
  return commandIsSettled(content)
230
239
  ? {
231
240
  title: SLASH_COMMAND_TITLE,
@@ -268,18 +277,34 @@ const AGENT_RULE_DETAIL = 'teach the agent to verify features with Reticle after
268
277
  */
269
278
  function ruleWriteDetail(existing, file) {
270
279
  const hasContent = existing !== null && existing !== undefined && existing.trim().length > 0;
271
- if (!hasContent)
280
+ if (!hasContent) {
272
281
  return `${AGENT_RULE_DETAIL} — creates ${file}`;
282
+ }
273
283
  const added = markedBlock().split('\n').length - 1;
274
284
  return (`${AGENT_RULE_DETAIL} — APPENDS ${String(added)} lines to your existing ${file}, ` +
275
285
  'inside `reticle:begin`/`reticle:end` markers; delete the marked block to undo');
276
286
  }
277
287
  const RETICLE_MD_TITLE = 'Full agent rules';
278
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;
279
295
  function claudeRuleStep(input) {
280
- if (!input.claudeCli)
296
+ if (!input.claudeCli) {
281
297
  return null;
298
+ }
282
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
+ }
283
308
  const r = mergeMarkedInstruction(input.claudeMdContent);
284
309
  if (r.status === AgentRuleStatus.ALREADY) {
285
310
  return {
@@ -305,10 +330,12 @@ function claudeRuleStep(input) {
305
330
  */
306
331
  function cursorRuleStep(input) {
307
332
  const cursorGlobal = (input.detectedClients ?? []).some((c) => c.id === McpClient.CURSOR);
308
- if (!cursorGlobal && true !== input.cursorProjectPresent)
333
+ if (!cursorGlobal && true !== input.cursorProjectPresent) {
309
334
  return null;
310
- if (input.cursorProjectPresent !== true && input.claudeCli)
335
+ }
336
+ if (input.cursorProjectPresent !== true && input.claudeCli) {
311
337
  return null;
338
+ }
312
339
  // The whole file is Reticle's — init created it — so a stale one is REWRITTEN rather than merged.
313
340
  // Comparing content is what makes the rule updatable; comparing existence made it permanent.
314
341
  const path = agentFile(input, CURSOR_RULE_PATH);
@@ -429,85 +456,44 @@ function reticleMdStep(input) {
429
456
  * noise.
430
457
  */
431
458
  function agentRuleSteps(input) {
432
- if (!input.options.mcp)
459
+ if (!input.options.mcp) {
433
460
  return [];
461
+ }
434
462
  return [...stepsForAgents(input, (a) => a.ruleStep), agentsMdStep(input), reticleMdStep(input)];
435
463
  }
436
464
  /**
437
- * What to say when the install command fails.
465
+ * Keep `.reticle/` out of the project's `prettier --check`.
438
466
  *
439
- * pnpm's `minimumReleaseAge` refuses any release younger than the configured window — a deliberate
440
- * supply-chain policy, not a bug — with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`. Unpinned it silently
441
- * resolves to an OLDER version instead, which is how an app ends up running a stale SDK against a
442
- * newer daemon: the connection succeeds, the protocol disagrees, and the failure surfaces as -32000
443
- * with nothing naming a version. Pinning turns that into this loud failure, which is the better
444
- * trade — but only if the message says what to do about it.
445
- */
446
- /**
447
- * Is this package already declared at the version we would pin?
448
- *
449
- * The caret is the point. `npm i -D pkg@3.2.0` WRITES `^3.2.0`, so on every later run the pinned
450
- * `3.2.0` failed a string comparison against the range npm had just created, and init re-installed
451
- * for ever. Only the two forms a package manager writes by itself are read - an exact version, and
452
- * `^`/`~` 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).
453
471
  *
454
- * Everything else installs. A false "already" leaves somebody with no SDK and an install that
455
- * 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.
456
474
  */
457
- function alreadyDeclared(declared, pinned) {
458
- if (declared === undefined)
459
- return false;
460
- if (pinned === undefined || 0 === pinned.length)
461
- return true;
462
- return declared === pinned || `^${pinned}` === declared || `~${pinned}` === declared;
463
- }
464
- function installStep(input) {
465
- const pm = input.detection.packageManager;
466
- // `Detection.packageManagerCommand` (see `preflight.ts`, #1149). Falling back to `pm` covers every
467
- // caller that never went through preflight (tests, and any future one).
468
- const pmCommand = input.detection.packageManagerCommand ?? pm;
469
- const packages = pinnedPackages(frameworkPackages(input.detection.framework, input.detection.uiLibrary), input.options.sdkVersion);
470
- const command = installCommand(pm, packages, pmCommand);
471
- /*
472
- * A re-run over an already-wired project does NO dependency work.
473
- *
474
- * Reported from the field: `init` on an instrumented npm-workspaces project ran a pnpm dependency
475
- * migration, which moved the existing `node_modules` aside and broke the dev server. Whatever
476
- * manager gets chosen, the redundant install is the step that touches the tree — so the fix that
477
- * matters is not running one that nothing needs. `init` is documented as idempotent; this is the
478
- * step that was not.
479
- */
480
- const declared = input.detection.dependencies ?? {};
481
- const version = input.options.sdkVersion;
482
- const names = frameworkPackages(input.detection.framework, input.detection.uiLibrary);
483
- if (names.length > 0 && names.every((n) => alreadyDeclared(declared[n], version))) {
484
- return {
485
- title: 'Install dependencies',
486
- target: 'package.json',
487
- status: StepStatus.ALREADY,
488
- detail: `${names.join(', ')} already declared${version === undefined || 0 === version.length ? '' : ` at ${version}`}`,
489
- };
475
+ function prettierIgnoreStep(input) {
476
+ if (true !== input.detection.usesPrettier) {
477
+ return null;
490
478
  }
491
- if (!input.options.install) {
479
+ const existing = input.prettierIgnoreSource ?? null;
480
+ if (null !== existing && ignoresReticle(existing)) {
492
481
  return {
493
- title: 'Install dependencies',
494
- target: 'package.json',
495
- status: StepStatus.MANUAL,
496
- detail: command,
482
+ title: 'Prettier ignore',
483
+ target: PRETTIERIGNORE_PATH,
484
+ status: StepStatus.ALREADY,
485
+ detail: '.reticle/ already excluded from Prettier',
497
486
  };
498
487
  }
499
- const parts = installCommandParts(pm, packages, [], pmCommand);
488
+ const content = withReticleIgnored(existing);
500
489
  return {
501
- title: 'Install dependencies',
502
- target: 'package.json',
490
+ title: 'Prettier ignore',
491
+ target: PRETTIERIGNORE_PATH,
503
492
  status: StepStatus.APPLY,
504
- detail: command,
505
- exec: {
506
- command: parts.command,
507
- args: parts.args,
508
- fallback: `${command}\n\n${installFailureHint(pm, pmCommand)}`,
509
- },
510
- 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] },
511
497
  };
512
498
  }
513
499
  const RETICLE_CONFIG_TITLE = 'Reticle config';
@@ -526,12 +512,15 @@ const AGENT_ROOT_CONFIG_TITLE = 'Reticle config (agent root)';
526
512
  * and the two files are byte-identical, so neither can disagree with the other about the port or the
527
513
  * project identity.
528
514
  */
529
- function agentRootConfigStep(input, content) {
515
+ function agentRootConfigStep(input, content, renamedFrom) {
530
516
  const root = input.agentFileRoot;
531
- if (root === undefined || 0 === root.length)
517
+ if (root === undefined || 0 === root.length) {
532
518
  return [];
519
+ }
533
520
  const path = agentFile(input, RETICLE_CONFIG_FILE);
534
- 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;
535
524
  const wantedProject = projectIdOf(content);
536
525
  /**
537
526
  * A monorepo has more than one app and the root can only point at one of them.
@@ -561,6 +550,19 @@ function agentRootConfigStep(input, content) {
561
550
  },
562
551
  ];
563
552
  }
553
+ // Several apps: a root config names one and is wrong for the rest. Not written; an agent at the
554
+ // root is served whichever app's daemon is live, and one started in an app reads that app's.
555
+ if (true === input.agentRootSeveralApps && existingProject === undefined) {
556
+ return [
557
+ {
558
+ title: AGENT_ROOT_CONFIG_TITLE,
559
+ target: path,
560
+ status: StepStatus.NOTICE,
561
+ detail: 'not written: this workspace has several apps and the root can name only one. Run the ' +
562
+ "agent from an app's directory to pin it to that app.",
563
+ },
564
+ ];
565
+ }
564
566
  if (input.agentRootConfigSource === content) {
565
567
  return [
566
568
  {
@@ -582,7 +584,7 @@ function agentRootConfigStep(input, content) {
582
584
  },
583
585
  ];
584
586
  }
585
- function reticleConfigStep(input, content, onDisk, movedFrom) {
587
+ function reticleConfigStep(input, content, onDisk, movedFrom, renamedFrom) {
586
588
  if (true === input.reticleConfigExists) {
587
589
  const problem = existingConfigProblem(onDisk);
588
590
  if (problem !== undefined) {
@@ -598,15 +600,17 @@ function reticleConfigStep(input, content, onDisk, movedFrom) {
598
600
  // The one thing a re-run can still learn: which channel the user actually arrived through.
599
601
  // See configWithInstallSource — it only ever ADDS a field that is absent.
600
602
  const backfilled = configWithInstallSource(onDisk, input.installSource);
601
- if (backfilled !== undefined || movedFrom !== undefined) {
603
+ if (backfilled !== undefined || movedFrom !== undefined || renamedFrom !== undefined) {
602
604
  const port = input.options.port;
603
605
  return {
604
606
  title: RETICLE_CONFIG_TITLE,
605
607
  target: RETICLE_CONFIG_FILE,
606
608
  status: StepStatus.APPLY,
607
- detail: movedFrom !== undefined && port !== undefined
608
- ? portMoveDetail(movedFrom, port)
609
- : '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',
610
614
  write: { path: RETICLE_CONFIG_FILE, content: backfilled ?? onDisk ?? content },
611
615
  };
612
616
  }
@@ -634,11 +638,27 @@ function reticleConfigStep(input, content, onDisk, movedFrom) {
634
638
  * requested port, and a config still naming the old one is half of a split brain (see
635
639
  * port-steps.ts). Only the port moves; the identity stays the one already on disk.
636
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
+ }
637
655
  function reticleConfigSteps(input) {
638
656
  const existing = input.reticleConfigSource;
639
- const onDisk = true === input.reticleConfigExists && null !== existing && existing !== undefined
657
+ const copied = true === input.reticleConfigExists && null !== existing && existing !== undefined
640
658
  ? existing
641
659
  : undefined;
660
+ const renamed = copied === undefined ? null : withOwnProjectId(copied, input.options);
661
+ const onDisk = renamed?.code ?? copied;
642
662
  const moved = onDisk === undefined ? null : retargetConfigPort(onDisk, input.options.port);
643
663
  const content = moved?.code ??
644
664
  onDisk ??
@@ -647,29 +667,64 @@ function reticleConfigSteps(input) {
647
667
  // config written before this field existed, and the two mean different things.
648
668
  input.installSource);
649
669
  return [
650
- reticleConfigStep(input, content, moved?.code ?? existing, moved?.from),
651
- ...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)),
652
673
  ];
653
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
+ }
654
697
  export function buildPlan(input) {
698
+ const cspSteps = cspStep(input);
699
+ const prettierIgnore = prettierIgnoreStep(input);
655
700
  const steps = [
656
- ...cspStep(input),
701
+ ...cspSteps,
657
702
  ...mcpSteps(input),
658
703
  ...agentRuleSteps(input),
659
704
  ...slashCommandSteps(input),
660
- ...(true === input.hooks
661
- ? [stopHookStep(agentFile(input, CLAUDE_SETTINGS_PATH), input.claudeSettingsContent)]
662
- : []),
705
+ ...claudeSettingsSteps(agentFile(input, CLAUDE_SETTINGS_PATH), input.claudeSettingsContent, {
706
+ stop: true === input.hooks,
707
+ // Beside the CLAUDE.md rule: the same agent, the same consent to write its instruction files.
708
+ compact: input.options.mcp && input.claudeCli,
709
+ prompt: true === input.claudePromptHook,
710
+ bin: input.hookBin,
711
+ }),
712
+ ...promptHookSteps(input.promptHooks ?? [], true === input.claudePromptHook, input.hookBin),
663
713
  ...uiLibraryStep(input),
664
714
  ...webGlCanvasStep(input),
665
715
  installStep(input),
666
716
  ...reticleConfigSteps(input),
717
+ ...(prettierIgnore !== null ? [prettierIgnore] : []),
667
718
  ];
668
- steps.push(...frameworkSteps(input));
719
+ // Both steps can patch index.html: the connect write must keep the CSP edit that runs first.
720
+ const patchedIndex = cspSteps.find((step) => step.write?.path === HTML_INDEX_PATH)?.write
721
+ ?.content;
722
+ steps.push(...frameworkSteps(patchedIndex === undefined ? input : { ...input, htmlIndexSource: patchedIndex }));
669
723
  // LAST, and a notice rather than an action: it is a statement about this machine's shape, and it
670
724
  // only matters once every file above has been written. See containerised-dev-server.ts.
671
725
  const container = containerisedStep(input);
672
- if (container !== null)
726
+ if (container !== null) {
673
727
  steps.push(container);
728
+ }
674
729
  return { framework: input.detection.framework, uiLibrary: input.detection.uiLibrary, steps };
675
730
  }
@@ -1,4 +1,34 @@
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;
6
+ /**
7
+ * The Claude Code `SessionStart` hook with matcher `compact`: right after a compaction it prints
8
+ * `reticle context --compact` — what this session proved, what failed, what is still owed — back
9
+ * into the agent's fresh context. Reticle holds the memory; this is how it is handed back without
10
+ * the agent having to remember to ask.
11
+ *
12
+ * Written by default wherever the CLAUDE.md rule is (it only reads the project's own `.reticle/`, and
13
+ * prints nothing where there is none). Removable like any hook: delete the entry.
14
+ */
15
+ export declare const COMPACT_HOOK_COMMAND = "npx @reticlehq/server context --compact";
16
+ export declare function compactHookStep(path: string, existing: string | null | undefined): Step;
17
+ /**
18
+ * Every hook this run writes into `.claude/settings.json`, each merged on top of the one before, so
19
+ * two steps writing the same file cannot undo each other.
20
+ */
21
+ export declare function claudeSettingsSteps(path: string, existing: string | null | undefined, options: {
22
+ stop: boolean;
23
+ compact: boolean;
24
+ prompt?: boolean;
25
+ bin?: string | undefined;
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[];