@north-light/crouter 0.3.182 → 0.3.184

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 (108) hide show
  1. package/dist/build-root.js +10 -3
  2. package/dist/builtin-memory/internal/memory-loading.md +3 -1
  3. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  4. package/dist/clients/attach/__tests__/titled-editor-paste.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/titled-editor-paste.test.js +114 -0
  6. package/dist/clients/attach/__tests__/titled-editor-preview.test.js +4 -1
  7. package/dist/clients/attach/input/titled-editor.d.ts +18 -0
  8. package/dist/clients/attach/input/titled-editor.js +62 -0
  9. package/dist/clients/attach/render/chat-view.d.ts +3 -0
  10. package/dist/clients/attach/render/chat-view.js +15 -6
  11. package/dist/clients/attach/render/context-message.js +2 -2
  12. package/dist/clients/attach/render/group-activity.d.ts +18 -1
  13. package/dist/clients/attach/render/group-activity.js +54 -6
  14. package/dist/clients/attach/render/group-recap.js +13 -2
  15. package/dist/clients/attach/render/{markdown-headings.d.ts → markdown-source.d.ts} +4 -4
  16. package/dist/clients/attach/render/markdown-source.js +165 -0
  17. package/dist/clients/attach/session/bindings.js +5 -2
  18. package/dist/clients/attach/viewer.js +396 -394
  19. package/dist/clients/inbox/review/keys.js +2 -9
  20. package/dist/clients/inbox/tui/keys.js +2 -9
  21. package/dist/commands/cron.js +1 -1
  22. package/dist/commands/memory/delete.js +1 -1
  23. package/dist/commands/memory/find.js +1 -1
  24. package/dist/commands/memory/lint.js +28 -3
  25. package/dist/commands/memory/list.js +1 -1
  26. package/dist/commands/memory/origin.js +1 -1
  27. package/dist/commands/memory/read.js +4 -4
  28. package/dist/commands/memory/write.js +1 -1
  29. package/dist/commands/sys/settings-shell.d.ts +0 -3
  30. package/dist/commands/sys/settings-shell.js +1 -38
  31. package/dist/commands/sys/settings.js +2 -3
  32. package/dist/commands/sys/setup-core.d.ts +2 -17
  33. package/dist/commands/sys/setup-core.js +9 -33
  34. package/dist/commands/sys/setup-wizard.d.ts +1 -3
  35. package/dist/commands/sys/setup-wizard.js +9 -39
  36. package/dist/commands/sys/setup.js +6 -6
  37. package/dist/commands/sys/sync-project-guidance.js +62 -12
  38. package/dist/core/__tests__/fault-classifier.test.js +20 -0
  39. package/dist/core/__tests__/nested-store-discovery.test.d.ts +1 -0
  40. package/dist/core/__tests__/nested-store-discovery.test.js +89 -0
  41. package/dist/core/__tests__/on-read-nested-store.test.d.ts +1 -0
  42. package/dist/core/__tests__/on-read-nested-store.test.js +117 -0
  43. package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.d.ts +1 -0
  44. package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.js +38 -0
  45. package/dist/core/__tests__/serial/tmux-surface.test.js +8 -5
  46. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +34 -1
  47. package/dist/core/canvas/nav-model.d.ts +1 -8
  48. package/dist/core/canvas/nav-model.js +30 -59
  49. package/dist/core/canvas/nav-render.d.ts +2 -2
  50. package/dist/core/canvas/nav-render.js +8 -6
  51. package/dist/core/command.js +5 -3
  52. package/dist/core/fault-classifier.d.ts +1 -1
  53. package/dist/core/fault-classifier.js +7 -0
  54. package/dist/core/fs-utils.js +6 -1
  55. package/dist/core/keybindings/index.d.ts +1 -1
  56. package/dist/core/keybindings/index.js +1 -1
  57. package/dist/core/keybindings/match.d.ts +11 -0
  58. package/dist/core/keybindings/match.js +22 -0
  59. package/dist/core/memory/doc-link-grammar.js +3 -7
  60. package/dist/core/memory/inline-ref-guidance.js +1 -1
  61. package/dist/core/memory-resolver.d.ts +7 -1
  62. package/dist/core/memory-resolver.js +20 -7
  63. package/dist/core/nested-stores.d.ts +12 -0
  64. package/dist/core/nested-stores.js +146 -0
  65. package/dist/core/preview-registry.js +4 -2
  66. package/dist/core/runtime/bearings.d.ts +1 -1
  67. package/dist/core/runtime/bearings.js +2 -2
  68. package/dist/core/runtime/broker/fault-retry.js +23 -4
  69. package/dist/core/runtime/broker-extension-render.d.ts +1 -1
  70. package/dist/core/runtime/broker-extension-render.js +2 -2
  71. package/dist/core/runtime/broker-protocol.d.ts +1 -1
  72. package/dist/core/runtime/deliver-live.js +11 -1
  73. package/dist/core/runtime/fault.js +1 -1
  74. package/dist/core/runtime/tmux-bindings.js +42 -20
  75. package/dist/core/runtime/tool-group-summary.js +2 -6
  76. package/dist/core/substrate/injected-store.d.ts +6 -0
  77. package/dist/core/substrate/injected-store.js +19 -0
  78. package/dist/core/substrate/on-read-node.d.ts +4 -2
  79. package/dist/core/substrate/on-read-node.js +5 -3
  80. package/dist/core/substrate/on-read.d.ts +4 -2
  81. package/dist/core/substrate/on-read.js +27 -5
  82. package/dist/daemon/__tests__/subtree-filter-freshness.test.d.ts +1 -0
  83. package/dist/daemon/__tests__/subtree-filter-freshness.test.js +63 -0
  84. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -1
  85. package/dist/pi-extensions/canvas-context-intro.js +14 -3
  86. package/dist/pi-extensions/canvas-doc-substrate.js +13 -2
  87. package/dist/shared/generated-context.d.ts +4 -0
  88. package/dist/shared/generated-context.js +20 -1
  89. package/dist/shared/tool-groups.d.ts +3 -3
  90. package/dist/shared/tool-groups.js +1 -4
  91. package/package.json +1 -1
  92. package/runtime.lock.json +2 -2
  93. package/scripts/postinstall.mjs +1 -1
  94. package/dist/clients/attach/render/markdown-headings.js +0 -93
  95. package/dist/commands/search/answer.d.ts +0 -1
  96. package/dist/commands/search/answer.js +0 -50
  97. package/dist/commands/search/contents.d.ts +0 -1
  98. package/dist/commands/search/contents.js +0 -96
  99. package/dist/commands/search/exa.d.ts +0 -53
  100. package/dist/commands/search/exa.js +0 -157
  101. package/dist/commands/search/puremd.d.ts +0 -11
  102. package/dist/commands/search/puremd.js +0 -53
  103. package/dist/commands/search/web.d.ts +0 -1
  104. package/dist/commands/search/web.js +0 -65
  105. package/dist/commands/search.d.ts +0 -2
  106. package/dist/commands/search.js +0 -24
  107. package/dist/commands/sys/panels/keys-panel.d.ts +0 -16
  108. package/dist/commands/sys/panels/keys-panel.js +0 -60
@@ -1,5 +1,5 @@
1
1
  // The `crtr sys setup` first-run installer: log in a provider, then install the
2
- // companion packages, system dependencies and API key a fresh machine needs.
2
+ // companion packages and system dependencies a fresh machine needs.
3
3
  //
4
4
  // Ongoing configuration — models, keybindings, display preferences, panes,
5
5
  // broker limits, updates — lives in settings-shell.ts. Both hosts share the
@@ -7,14 +7,13 @@
7
7
  // confirmed install, that one is a persistent editor that saves as you go.
8
8
  import { matchesKey, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
9
9
  import { formatGesture, matchesPiTuiInput, } from "../../core/keybindings/index.js";
10
- import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, packagePlanAction, reconcileInboxFrontDoor, renderSetupStaticInstructions, resolveExaKeyPath, } from "./setup-core.js";
10
+ import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, packagePlanAction, reconcileInboxFrontDoor, renderSetupStaticInstructions, } from "./setup-core.js";
11
11
  import { runSetupSettingsProcess } from "./setup-settings-runner.js";
12
12
  import { DependenciesPanel } from "./panels/dependencies-panel.js";
13
- import { KeysPanel } from "./panels/keys-panel.js";
14
13
  import { PAGE_CHROME_ROWS, pageFrame, theme, } from "./panels/panel.js";
15
14
  import { PluginsPanel } from "./panels/plugins-panel.js";
16
15
  import { ProviderPanel } from "./panels/provider-panel.js";
17
- const STEPS = ["Providers", "Plugins", "Dependencies", "Exa key"];
16
+ const STEPS = ["Providers", "Plugins", "Dependencies"];
18
17
  /** The host owns step navigation, focus, submission and composition. Each step
19
18
  * owns its own mutable UI state. */
20
19
  export class SetupWizard {
@@ -23,7 +22,6 @@ export class SetupWizard {
23
22
  providers;
24
23
  plugins;
25
24
  dependencies;
26
- keys;
27
25
  step = 0;
28
26
  confirming = false;
29
27
  cachedWidth;
@@ -42,13 +40,9 @@ export class SetupWizard {
42
40
  this.providers.onChange = invalidate;
43
41
  this.plugins = new PluginsPanel(manifest, installedPackageIds, invalidate);
44
42
  this.dependencies = new DependenciesPanel(systemPlans, invalidate);
45
- this.keys = new KeysPanel();
46
- this.keys.onSubmit = () => this.finalize();
47
- this.keys.onCancel = () => this.cancel();
48
43
  }
49
44
  set focused(value) {
50
45
  this._focused = value;
51
- this.syncFocus();
52
46
  }
53
47
  get focused() {
54
48
  return this._focused;
@@ -58,9 +52,6 @@ export class SetupWizard {
58
52
  this.cachedHeight = undefined;
59
53
  this.cachedLines = undefined;
60
54
  }
61
- syncFocus() {
62
- this.keys.focused = this._focused && this.step === 3 && !this.confirming;
63
- }
64
55
  matches(id, data) {
65
56
  return matchesPiTuiInput(this.bindings, id, data, (input, gesture) => matchesKey(input, gesture));
66
57
  }
@@ -85,7 +76,6 @@ export class SetupWizard {
85
76
  }
86
77
  setStep(next) {
87
78
  this.step = next;
88
- this.syncFocus();
89
79
  this.invalidate();
90
80
  }
91
81
  moveStep(delta) {
@@ -93,7 +83,7 @@ export class SetupWizard {
93
83
  }
94
84
  /** Enter on a non-final step walks forward; on the last one it finishes. */
95
85
  advance() {
96
- if (this.step === 3)
86
+ if (this.step === 2)
97
87
  this.finalize();
98
88
  else
99
89
  this.moveStep(1);
@@ -101,7 +91,6 @@ export class SetupWizard {
101
91
  cancel() {
102
92
  if (this.confirming) {
103
93
  this.confirming = false;
104
- this.syncFocus();
105
94
  this.invalidate();
106
95
  return;
107
96
  }
@@ -109,7 +98,6 @@ export class SetupWizard {
109
98
  }
110
99
  dispose() {
111
100
  this.providers.dispose();
112
- this.keys.focused = false;
113
101
  }
114
102
  hasPendingInstalls() {
115
103
  return (this.plugins.selected.some((entry) => !this.installedPackageIds.has(entry.id)) || this.dependencies.selected.length > 0);
@@ -119,8 +107,6 @@ export class SetupWizard {
119
107
  packages: [...this.plugins.selected],
120
108
  installedPackageIds: new Set(this.installedPackageIds),
121
109
  systemDeps: [...this.dependencies.selected],
122
- exaKey: this.keys.value,
123
- exaKeyPath: resolveExaKeyPath(),
124
110
  };
125
111
  }
126
112
  finalize() {
@@ -129,7 +115,6 @@ export class SetupWizard {
129
115
  return;
130
116
  }
131
117
  this.confirming = true;
132
- this.syncFocus();
133
118
  this.invalidate();
134
119
  }
135
120
  currentPanel() {
@@ -137,20 +122,12 @@ export class SetupWizard {
137
122
  ? this.providers
138
123
  : this.step === 1
139
124
  ? this.plugins
140
- : this.step === 2
141
- ? this.dependencies
142
- : this.keys;
125
+ : this.dependencies;
143
126
  }
144
127
  handleInput(data) {
145
128
  const panel = this.currentPanel();
146
129
  const match = (id, input) => this.matches(id, input);
147
- const nestedInput = (this.step === 0 && this.providers.isNestedFlowActive()) ||
148
- (this.step === 3 &&
149
- ((data.length === 1 && data.charCodeAt(0) >= 32) ||
150
- matchesKey(data, "left") ||
151
- matchesKey(data, "right") ||
152
- matchesKey(data, "up") ||
153
- matchesKey(data, "down")));
130
+ const nestedInput = this.step === 0 && this.providers.isNestedFlowActive();
154
131
  if (nestedInput) {
155
132
  if (panel.handleInput(data, match))
156
133
  this.invalidate();
@@ -161,7 +138,6 @@ export class SetupWizard {
161
138
  this.onSubmit?.(this.buildSubmission());
162
139
  else if (this.matches("crtr.setup.confirm.back", data)) {
163
140
  this.confirming = false;
164
- this.syncFocus();
165
141
  this.invalidate();
166
142
  }
167
143
  return;
@@ -185,7 +161,7 @@ export class SetupWizard {
185
161
  if (this.matches("crtr.setup.advance", data))
186
162
  this.advance();
187
163
  }
188
- /** `Providers › Plugins › Dependencies › Exa key`, active step lit, done
164
+ /** `Providers › Plugins › Dependencies`, active step lit, done
189
165
  * steps checked — a first run should always show how much is left. */
190
166
  renderTrail(width) {
191
167
  const parts = STEPS.map((label, index) => {
@@ -214,10 +190,8 @@ export class SetupWizard {
214
190
  : `${move} select \u00b7 a add \u00b7 p prefer \u00b7 r rename \u00b7 l re-login \u00b7 x remove \u00b7 ${this.label("crtr.setup.advance")} next`;
215
191
  else if (this.step === 1)
216
192
  left = `${move} move \u00b7 ${this.label("crtr.setup.toggle")} toggle \u00b7 ${this.label("crtr.setup.details")} details \u00b7 ${this.label("crtr.setup.advance")} next`;
217
- else if (this.step === 2)
218
- left = `${move} move \u00b7 ${this.label("crtr.setup.toggle")} toggle \u00b7 ${this.label("crtr.setup.advance")} next`;
219
193
  else
220
- left = `paste EXA_API_KEY \u00b7 ${this.label("crtr.setup.advance")} finish`;
194
+ left = `${move} move \u00b7 ${this.label("crtr.setup.toggle")} toggle \u00b7 ${this.label("crtr.setup.advance")} finish`;
221
195
  const gap = width - visibleWidth(left) - visibleWidth(steps);
222
196
  return gap >= 2
223
197
  ? `${theme.fg("dim", left)}${" ".repeat(gap)}${theme.fg("dim", steps)}`
@@ -229,9 +203,7 @@ export class SetupWizard {
229
203
  truncateToWidth(theme.fg("warning", theme.bold("Confirm installs")), width),
230
204
  "",
231
205
  ];
232
- if (submission.packages.length === 0 &&
233
- submission.systemDeps.length === 0 &&
234
- submission.exaKey === "")
206
+ if (submission.packages.length === 0 && submission.systemDeps.length === 0)
235
207
  return [
236
208
  ...out,
237
209
  truncateToWidth(theme.fg("success", `Nothing to install; ${this.label("crtr.setup.confirm.accept")} finish.`), width),
@@ -246,8 +218,6 @@ export class SetupWizard {
246
218
  for (const plan of submission.systemDeps)
247
219
  out.push(truncateToWidth(` \u2022 ${plan.tool} ${theme.fg("dim", "\u2192")} ${theme.fg("muted", plan.command ?? plan.hint)}`, width));
248
220
  }
249
- if (submission.exaKey)
250
- out.push(truncateToWidth(`${theme.fg("accent", "Exa key")} ${theme.fg("dim", "\u2192")} ${theme.fg("warning", `write ${submission.exaKeyPath}`)}`, width));
251
221
  return out;
252
222
  }
253
223
  render(width) {
@@ -11,19 +11,19 @@ import { defineLeaf } from '../../core/command.js';
11
11
  export const BROKER_LIMIT_OVERRIDE_PRECEDENCE = 'Valid CRTR_WARN_LIVE_BROKERS and CRTR_MAX_LIVE_BROKERS environment overrides take precedence over their corresponding configured limits.';
12
12
  export const sysSetupLeaf = defineLeaf({
13
13
  name: 'setup',
14
- description: 'bootstrap a fresh machine — provider login, companion packages, system dependencies, Exa key',
14
+ description: 'bootstrap a fresh machine — provider login, companion packages, system dependencies',
15
15
  whenToUse: 'you are setting up crouter on a machine for the first time and need credentials and installs in place. Use sys settings to change configuration on an installed machine, and sys config set for scriptable edits.',
16
16
  help: {
17
17
  name: 'sys setup',
18
- summary: 'run the first-run installer: provider login, companion packages, system dependencies, Exa key',
18
+ summary: 'run the first-run installer: provider login, companion packages, system dependencies',
19
19
  inputNote: 'No input parameters.',
20
20
  output: [],
21
21
  outputKind: 'object',
22
22
  effects: [
23
- 'Opens a four-step pi-tui installer. Step one logs in to a model provider (pi\'s real OAuth/API-key flow, same as in-app /login) so a fresh install has credentials before running any node.',
24
- 'The remaining steps collect companion package installs, system dependency installs, and an Exa API key, then confirm them all at once.',
25
- 'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key when provided, and prints a summary.',
26
- 'The companion-package list includes the official crtr marketplace and the plugins installed from it (currently `capture`), all checked by default and registered or installed into the user scope when left checked. Nothing else registers a marketplace, so an install that never runs setup fetches no catalog. An unreachable marketplace is reported as skipped rather than failing setup.',
23
+ 'Opens a three-step pi-tui installer. Step one logs in to a model provider (pi\'s real OAuth/API-key flow, same as in-app /login) so a fresh install has credentials before running any node.',
24
+ 'The remaining steps collect companion package installs and system dependency installs, then confirm them all at once.',
25
+ 'Runs the bundled pi CLI to install the selected companion packages and prints a summary.',
26
+ 'The companion-package list includes the official crtr marketplace and the plugins installed from it (currently `capture` and `search`), all checked by default and registered or installed into the user scope when left checked. Nothing else registers a marketplace, so an install that never runs setup fetches no catalog. An unreachable marketplace is reported as skipped rather than failing setup. Vendor credentials belong to the plugin that needs them, never to setup.',
27
27
  'On every invocation (best-effort, idempotent), refreshes crouter-owned tmux bindings and registers known nodes\' interaction roots so resolved tickets can call back into the asking node. The inbox popup toggle is a crouter-owned keybinding, editable on the keybindings tab.',
28
28
  ],
29
29
  },
@@ -1,8 +1,8 @@
1
1
  // `crtr sys sync project-guidance` — one-way migration of a project's own
2
2
  // CLAUDE.md/AGENTS.md/.claude/rules into that SAME directory's OWN
3
3
  // `.crouter/memory/`. Each project guide becomes the root INDEX front door;
4
- // rules retain their explicit path routes, or use the workspace-open `.` route
5
- // when they are pathless.
4
+ // rules retain their explicit path routes, or use the `.` route (workspace
5
+ // mount + any file read beneath the store's owning dir) when they are pathless.
6
6
  import { existsSync, readdirSync, statSync } from 'node:fs';
7
7
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
8
8
  import { defineLeaf } from '../../core/command.js';
@@ -97,11 +97,37 @@ function synthesizeShortForm(body) {
97
97
  }
98
98
  /** `.claude/rules` frontmatter `paths:` normalizes to a glob list — a single
99
99
  * string, an array, or absent for a workspace-wide rule. */
100
+ /** Split a rule's comma-separated `paths` string into globs, honoring brace
101
+ * groups — `"lib/**\/*.{ts,tsx}, proxy.ts"` is two globs, and the comma inside
102
+ * `{ts,tsx}` never splits. Authored rules use both the comma-joined string and
103
+ * the YAML-list shape. */
104
+ function splitTopLevelCommas(s) {
105
+ const out = [];
106
+ let depth = 0;
107
+ let cur = '';
108
+ for (const ch of s) {
109
+ if (ch === '{')
110
+ depth += 1;
111
+ else if (ch === '}')
112
+ depth = Math.max(0, depth - 1);
113
+ if (ch === ',' && depth === 0) {
114
+ out.push(cur);
115
+ cur = '';
116
+ continue;
117
+ }
118
+ cur += ch;
119
+ }
120
+ out.push(cur);
121
+ return out.map((g) => g.trim()).filter((g) => g !== '');
122
+ }
100
123
  function normalizeRulePaths(v) {
101
124
  if (typeof v === 'string')
102
- return v.trim() === '' ? [] : [v.trim()];
103
- if (Array.isArray(v))
104
- return v.filter((g) => typeof g === 'string' && g.trim() !== '');
125
+ return splitTopLevelCommas(v);
126
+ if (Array.isArray(v)) {
127
+ return v
128
+ .filter((g) => typeof g === 'string')
129
+ .flatMap(splitTopLevelCommas);
130
+ }
105
131
  return [];
106
132
  }
107
133
  /** The main repo root's directory name, found via `git rev-parse
@@ -195,7 +221,8 @@ function projectDocLabel(candidate) {
195
221
  /** AGENTS.md/CLAUDE.md → `<sourceDir>/.crouter/memory/INDEX.md`, the
196
222
  * workspace front door. Its explicit project identity keeps sibling roots
197
223
  * distinct; system none/file content plus `applies-to: "."` puts the wholly
198
- * important guide in first-message context when cwd/profile mounts the store. */
224
+ * important guide in first-message context when cwd/profile mounts the store,
225
+ * and surfaces it on any file read beneath the store's owning dir. */
199
226
  function prepareAgentsCandidate(candidate) {
200
227
  const body = parseFrontmatterGeneric(readText(candidate.path)).body;
201
228
  const docLabel = projectDocLabel(candidate);
@@ -218,16 +245,23 @@ function prepareAgentsCandidate(candidate) {
218
245
  }
219
246
  /** `.claude/rules/<name>.md` → `<sourceDir>/.crouter/memory/<name>.md`. A
220
247
  * `paths:` rule keeps its `applies-to` file globs at none/content; a pathless
221
- * rule receives `applies-to: "."` and surfaces when the workspace opens. */
248
+ * rule receives `applies-to: "."` and surfaces when the workspace opens or a
249
+ * file beneath the store's owning dir is read. Like the INDEX docs, each rule
250
+ * carries an explicit namespaced substrate name (`<root>/<subdir>/<leaf>`):
251
+ * without it the resolver falls back to the store-relative leaf, and two
252
+ * nested stores sharing a rule filename (e.g. apps/core and apps/gateway both
253
+ * carrying `unit-tests.md`) collapse onto one bare name, shadowing one doc. */
222
254
  function prepareRuleCandidate(candidate) {
223
255
  const raw = readText(candidate.path);
224
256
  const parsed = parseFrontmatterGeneric(raw);
225
257
  const fm = parsed.data ?? {};
226
- const name = normalizedName(basename(candidate.path));
258
+ const leaf = normalizedName(basename(candidate.path));
227
259
  const description = scalarString(fm['description']) ?? '';
228
260
  const dirLabel = projectDocLabel(candidate);
261
+ const name = normalizedName(`${dirLabel}/${leaf}`);
229
262
  const frontmatter = {
230
263
  kind: 'knowledge',
264
+ name,
231
265
  'when-and-why-to-read': description !== ''
232
266
  ? routeFromDescription(description, 'knowledge')
233
267
  : `When working under ${dirLabel}, this knowledge should be read because it is migrated project rule guidance.`,
@@ -240,7 +274,7 @@ function prepareRuleCandidate(candidate) {
240
274
  return {
241
275
  candidate,
242
276
  name,
243
- target: memoryFilePath(join(candidate.sourceDir, CRTR_DIR_NAME, 'memory'), name),
277
+ target: memoryFilePath(join(candidate.sourceDir, CRTR_DIR_NAME, 'memory'), leaf),
244
278
  frontmatter,
245
279
  body: parsed.body,
246
280
  };
@@ -304,7 +338,7 @@ export const sysSyncProjectGuidanceLeaf = defineLeaf({
304
338
  ],
305
339
  outputKind: 'object',
306
340
  effects: [
307
- "Copies each discovered CLAUDE.md/AGENTS.md into <dir>/.crouter/memory/INDEX.md with an explicit project name at system-prompt-visibility none, file-read-visibility content, and applies-to `.`. Copies each .claude/rules/*.md into <dir>/.crouter/memory/<name>.md at none/content, using the source paths as applies-to globs or `.` when no paths are declared. Originals are left untouched.",
341
+ "Copies each discovered CLAUDE.md/AGENTS.md into <dir>/.crouter/memory/INDEX.md with an explicit project name at system-prompt-visibility none, file-read-visibility content, and applies-to `.`. Copies each .claude/rules/*.md into <dir>/.crouter/memory/<name>.md at none/content, using the source paths as applies-to globs or `.` (workspace mount + reads beneath the owning dir) when no paths are declared. Originals are left untouched.",
308
342
  'Skips existing memory docs unless --overwrite is present.',
309
343
  'With --dry-run: read-only; writes nothing.',
310
344
  ],
@@ -320,8 +354,24 @@ export const sysSyncProjectGuidanceLeaf = defineLeaf({
320
354
  if (docSeen.has(c.path))
321
355
  continue;
322
356
  docSeen.add(c.path);
323
- const prepared = c.kind === 'agents' ? prepareAgentsCandidate(c) : prepareRuleCandidate(c);
324
- results.push(convertProjectDoc(prepared, { dryRun, overwrite }));
357
+ // Isolate one malformed source file (e.g. invalid YAML in a rule's
358
+ // frontmatter) as its own skipped row — it must never abort the whole
359
+ // migration.
360
+ try {
361
+ const prepared = c.kind === 'agents' ? prepareAgentsCandidate(c) : prepareRuleCandidate(c);
362
+ results.push(convertProjectDoc(prepared, { dryRun, overwrite }));
363
+ }
364
+ catch (e) {
365
+ const msg = (e instanceof Error ? e.message : String(e)).split('\n')[0];
366
+ results.push({
367
+ source: c.path,
368
+ target: '',
369
+ name: '',
370
+ scope: 'project',
371
+ status: 'skipped',
372
+ reason: `unparseable source frontmatter: ${msg} — fix the source file and re-run`,
373
+ });
374
+ }
325
375
  }
326
376
  }
327
377
  const imported = results.filter((r) => r.status === 'imported').length;
@@ -43,6 +43,26 @@ test('flattened provider 5xx and API-error text classify as overloaded/auto (#15
43
43
  assert.equal(c.disposition, 'auto', `"${msg}" must be auto-retried`);
44
44
  }
45
45
  });
46
+ // A gateway that refuses the request for SIZE cannot be retried: the daemon
47
+ // re-posts the same conversation and fails identically. One oversized image in
48
+ // a tool result wedged a live applet conversation this way — the rejection
49
+ // arrived as 5xx prose, classified 'overloaded', and looped on injected
50
+ // recovery messages until the chat surfaced CROUTER_PROVIDER_UNAVAILABLE.
51
+ test('a request-size rejection is fatal, even wrapped in 5xx prose', () => {
52
+ for (const msg of [
53
+ '413 Payload Too Large',
54
+ 'PayloadTooLargeError: request entity too large',
55
+ 'Request body exceeds the 5 MB limit',
56
+ '500 Internal Server Error: request entity too large',
57
+ ]) {
58
+ const c = classify('pi→provider', msg);
59
+ assert.equal(c.kind, 'protocol', `"${msg}" must classify as protocol, not overloaded`);
60
+ assert.equal(c.disposition, 'fatal', `"${msg}" must never arm an automatic retry`);
61
+ }
62
+ const http = classify('pi→provider', { status: 413, statusText: 'Payload Too Large' });
63
+ assert.equal(http.kind, 'protocol');
64
+ assert.equal(http.disposition, 'fatal');
65
+ });
46
66
  test('interrupted WebSocket text, including close 1000 and flattened error/interrupt (#302, #258), classifies as connection/auto', () => {
47
67
  for (const msg of ['WebSocket closed 1000', 'WebSocket stream closed before response.completed', 'WebSocket error', 'WebSocket connection interrupted']) {
48
68
  const c = classify('pi→provider', msg);
@@ -0,0 +1,89 @@
1
+ // Bounded descendant-store discovery (nested-stores.ts) and its resolver
2
+ // opt-in: git owners enumerate via ls-files (tracked + untracked, .gitignore
3
+ // respected, the input root excluded), non-git owners walk with a depth cap,
4
+ // and `resolveMemoryDocForTarget` sees a nested doc only when the caller opts
5
+ // into descendants.
6
+ //
7
+ // Run: node --import tsx/esm --test src/core/__tests__/nested-store-discovery.test.ts
8
+ import { test, before, beforeEach, after } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import { CrtrError } from '../errors.js';
14
+ import { realpathOrSelf } from '../fs-utils.js';
15
+ import { gitSync } from '../git.js';
16
+ import { closeDb } from '../canvas/db.js';
17
+ import { resetScopeCache } from '../scope.js';
18
+ import { descendantStoreRoots } from '../nested-stores.js';
19
+ import { resolveMemoryDocForTarget } from '../memory-resolver.js';
20
+ let fakeHome;
21
+ let prevHomeEnv;
22
+ const cleanups = [];
23
+ before(() => {
24
+ prevHomeEnv = process.env['HOME'];
25
+ });
26
+ beforeEach(() => {
27
+ closeDb();
28
+ resetScopeCache();
29
+ fakeHome = mkdtempSync(join(tmpdir(), 'crtr-descend-home-'));
30
+ process.env['HOME'] = fakeHome;
31
+ cleanups.push(fakeHome);
32
+ });
33
+ after(() => {
34
+ closeDb();
35
+ resetScopeCache();
36
+ for (const dir of cleanups)
37
+ rmSync(dir, { recursive: true, force: true });
38
+ if (prevHomeEnv === undefined)
39
+ delete process.env['HOME'];
40
+ else
41
+ process.env['HOME'] = prevHomeEnv;
42
+ });
43
+ function makeStore(dir) {
44
+ const memoryDir = join(dir, '.crouter', 'memory');
45
+ mkdirSync(memoryDir, { recursive: true });
46
+ writeFileSync(join(memoryDir, 'INDEX.md'), '# store\n');
47
+ }
48
+ test('git owner: tracked and untracked stores found, gitignored and input-root stores excluded', () => {
49
+ // Distinct tmp root per case: descendantStoreRoots caches by root list.
50
+ const owner = realpathOrSelf(mkdtempSync(join(tmpdir(), 'crtr-descend-git-')));
51
+ cleanups.push(owner);
52
+ assert.equal(gitSync(['-C', owner, 'init'], undefined).status, 0, 'git init succeeds');
53
+ makeStore(owner); // the input root itself — must be excluded
54
+ makeStore(join(owner, 'pkg-tracked'));
55
+ makeStore(join(owner, 'pkg-untracked'));
56
+ makeStore(join(owner, 'ignored'));
57
+ writeFileSync(join(owner, '.gitignore'), 'ignored/\n');
58
+ assert.equal(gitSync(['-C', owner, 'add', 'pkg-tracked'], undefined).status, 0, 'git add succeeds (ls-files needs no commit or identity)');
59
+ const found = descendantStoreRoots([join(owner, '.crouter')]).map(realpathOrSelf);
60
+ assert.deepEqual([...found].sort(), [join(owner, 'pkg-tracked', '.crouter'), join(owner, 'pkg-untracked', '.crouter')].sort(), 'tracked + untracked stores, nothing gitignored, never the input root');
61
+ });
62
+ test('non-git owner: the walk finds a store at the depth cap and stops below it', () => {
63
+ const owner = realpathOrSelf(mkdtempSync(join(tmpdir(), 'crtr-descend-walk-')));
64
+ cleanups.push(owner);
65
+ const atCap = join(owner, 'd1', 'd2', 'd3', 'd4', 'd5', 'd6'); // depth 6
66
+ makeStore(atCap);
67
+ const belowCap = join(atCap, 'd7'); // depth 7
68
+ makeStore(belowCap);
69
+ const found = descendantStoreRoots([join(owner, '.crouter')]).map(realpathOrSelf);
70
+ assert.deepEqual(found, [join(atCap, '.crouter')], 'depth 6 found; depth 7 is past the cap');
71
+ });
72
+ test('resolveMemoryDocForTarget resolves a nested doc only with includeDescendants', () => {
73
+ const proj = join(fakeHome, 'proj');
74
+ mkdirSync(join(proj, '.crouter', 'memory'), { recursive: true }); // project scope engages
75
+ const nestedMemory = join(proj, 'sub', '.crouter', 'memory');
76
+ mkdirSync(nestedMemory, { recursive: true });
77
+ writeFileSync(join(nestedMemory, 'guide.md'), '---\nkind: knowledge\n' +
78
+ 'name: nested-guide\n' +
79
+ 'when-and-why-to-read: When working in sub, this knowledge should be read because it carries the subsystem constraints.\n' +
80
+ 'system-prompt-visibility: none\n' +
81
+ 'file-read-visibility: none\n---\n' +
82
+ 'NESTED GUIDE BODY\n');
83
+ const target = { cwd: proj, profileId: null, nodeId: null };
84
+ assert.throws(() => resolveMemoryDocForTarget('nested-guide', target, {}), (e) => e instanceof CrtrError && e.code === 'not_found', 'without the opt-in the flat ancestor stack does not see the nested store');
85
+ const doc = resolveMemoryDocForTarget('nested-guide', target, { includeDescendants: true });
86
+ assert.equal(doc.name, 'nested-guide', 'resolves by explicit frontmatter name');
87
+ assert.equal(doc.scope, 'project');
88
+ assert.ok(doc.body.includes('NESTED GUIDE BODY'));
89
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,117 @@
1
+ // A `.crouter/memory` store nested BELOW a mounted project root is delivered
2
+ // by the read path, not by boot: its `.`-routed docs surface on the first read
3
+ // beneath the store's owning dir, deduplicated against the workspace-open
4
+ // render through the shared transcript dedup set. The `.` route stays fenced
5
+ // to project-store docs — a hand-authored `.` in a user-scope doc must never
6
+ // match a read.
7
+ //
8
+ // Run: node --import tsx/esm --test src/core/__tests__/on-read-nested-store.test.ts
9
+ import { test, before, beforeEach, after } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { closeDb } from '../canvas/db.js';
15
+ import { resetScopeCache } from '../scope.js';
16
+ import { spawnNode } from '../runtime/nodes.js';
17
+ import { renderOnReadDocs, renderWorkspaceOpenDocs } from '../substrate/on-read-node.js';
18
+ import { clearSessionCache } from '../substrate/session-cache.js';
19
+ const ROOT_BODY = 'ROOT WORKSPACE FRONT DOOR BODY';
20
+ const NESTED_BODY = 'NESTED PKG FRONT DOOR BODY';
21
+ let canvasHome;
22
+ let fakeHome;
23
+ let prevHomeEnv;
24
+ before(() => {
25
+ prevHomeEnv = process.env['HOME'];
26
+ });
27
+ beforeEach(() => {
28
+ closeDb();
29
+ resetScopeCache();
30
+ clearSessionCache();
31
+ fakeHome = mkdtempSync(join(tmpdir(), 'crtr-nested-home-'));
32
+ process.env['HOME'] = fakeHome;
33
+ canvasHome = mkdtempSync(join(tmpdir(), 'crtr-nested-canvas-'));
34
+ process.env['CRTR_HOME'] = canvasHome;
35
+ });
36
+ after(() => {
37
+ closeDb();
38
+ resetScopeCache();
39
+ rmSync(canvasHome, { recursive: true, force: true });
40
+ rmSync(fakeHome, { recursive: true, force: true });
41
+ delete process.env['CRTR_HOME'];
42
+ if (prevHomeEnv === undefined)
43
+ delete process.env['HOME'];
44
+ else
45
+ process.env['HOME'] = prevHomeEnv;
46
+ });
47
+ /** A work tree with a root front door and a nested store front door, both
48
+ * routed `.`. Returns the work dir. */
49
+ function makeWorkTree() {
50
+ const work = join(fakeHome, 'work');
51
+ const rootStore = join(work, '.crouter', 'memory');
52
+ mkdirSync(rootStore, { recursive: true });
53
+ writeFileSync(join(rootStore, 'INDEX.md'), '---\nkind: knowledge\n' +
54
+ 'when-and-why-to-read: When working in this workspace, this knowledge should be read because it is the operating guide.\n' +
55
+ 'file-read-visibility: content\n' +
56
+ 'applies-to: "."\n---\n' +
57
+ `${ROOT_BODY}\n`);
58
+ const nestedStore = join(work, 'pkg', '.crouter', 'memory');
59
+ mkdirSync(nestedStore, { recursive: true });
60
+ writeFileSync(join(nestedStore, 'INDEX.md'), '---\nkind: knowledge\n' +
61
+ 'when-and-why-to-read: When working in pkg, this knowledge should be read because it carries the package constraints.\n' +
62
+ 'file-read-visibility: content\n' +
63
+ 'applies-to: "."\n---\n' +
64
+ `${NESTED_BODY}\n`);
65
+ return work;
66
+ }
67
+ test('a nested store front door surfaces on a read beneath its owning dir, not outside it', () => {
68
+ const work = makeWorkTree();
69
+ const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
70
+ const inside = join(work, 'pkg', 'lib', 'file.ts');
71
+ mkdirSync(join(work, 'pkg', 'lib'), { recursive: true });
72
+ writeFileSync(inside, 'export const x = 1;\n');
73
+ const rendered = renderOnReadDocs(node, inside, new Set());
74
+ assert.ok(rendered.includes(NESTED_BODY), `read beneath pkg surfaces the nested front door; got: ${rendered}`);
75
+ const outside = join(work, 'docs', 'notes.ts');
76
+ mkdirSync(join(work, 'docs'), { recursive: true });
77
+ writeFileSync(outside, 'export const y = 2;\n');
78
+ const renderedOutside = renderOnReadDocs(node, outside, new Set());
79
+ assert.ok(!renderedOutside.includes(NESTED_BODY), `read outside pkg must not surface the nested front door; got: ${renderedOutside}`);
80
+ });
81
+ test('workspace-open and on-read share one dedup set: root front door once, nested store on first read beneath it', () => {
82
+ const work = makeWorkTree();
83
+ const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
84
+ const seen = new Set();
85
+ const opened = renderWorkspaceOpenDocs(node, seen);
86
+ assert.ok(opened.includes(ROOT_BODY), 'workspace open delivers the mounted root front door');
87
+ assert.ok(!opened.includes(NESTED_BODY), 'boot stays flat: the nested store is not in the mounted stack');
88
+ const readFile = join(work, 'pkg', 'lib', 'file.ts');
89
+ mkdirSync(join(work, 'pkg', 'lib'), { recursive: true });
90
+ writeFileSync(readFile, 'export const x = 1;\n');
91
+ const onRead = renderOnReadDocs(node, readFile, seen);
92
+ assert.ok(onRead.includes(NESTED_BODY), 'the nested front door delivers on the first read beneath it');
93
+ assert.ok(!onRead.includes(ROOT_BODY), 'the root front door already delivered at workspace open — never twice');
94
+ });
95
+ test('a hand-authored `.` route in a user-scope doc never matches a read', () => {
96
+ const userStore = join(fakeHome, '.crouter', 'memory');
97
+ mkdirSync(userStore, { recursive: true });
98
+ writeFileSync(join(userStore, 'control.md'), '---\nkind: knowledge\n' +
99
+ 'when-and-why-to-read: When reading markdown, this knowledge should be read because it is the corpus-loaded control fixture.\n' +
100
+ 'file-read-visibility: content\n' +
101
+ 'applies-to: "**/*.md"\n---\n' +
102
+ 'USER CONTROL BODY\n');
103
+ writeFileSync(join(userStore, 'dot-routed.md'), '---\nkind: knowledge\n' +
104
+ 'when-and-why-to-read: When anything, this knowledge should be read because it must never fire.\n' +
105
+ 'file-read-visibility: content\n' +
106
+ 'applies-to: "."\n---\n' +
107
+ 'USER DOT BODY MUST NOT FIRE\n');
108
+ const work = join(fakeHome, 'work');
109
+ mkdirSync(work, { recursive: true });
110
+ const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
111
+ const readFile = join(fakeHome, 'notes', 'thing.md');
112
+ mkdirSync(join(fakeHome, 'notes'), { recursive: true });
113
+ writeFileSync(readFile, '# a note\n');
114
+ const rendered = renderOnReadDocs(node, readFile, new Set());
115
+ assert.ok(rendered.includes('USER CONTROL BODY'), 'the glob-routed user doc proves the user store is in the corpus');
116
+ assert.ok(!rendered.includes('USER DOT BODY MUST NOT FIRE'), `the \`.\` route is project-store only — a user doc's owning root is ~; got: ${rendered}`);
117
+ });
@@ -0,0 +1,38 @@
1
+ // Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/plugin-link-symlinked-scope-root.test.ts
2
+ //
3
+ // Regression for the silent half of a local/marketplace plugin install: the
4
+ // scope root is reached through a symlinked ancestor (the macOS /tmp →
5
+ // /private/tmp case), so a relative link target computed from the lexical
6
+ // directory counts `..` hops up the wrong chain. symlinkSync accepts a
7
+ // dangling target, so `pkg plugin install` reported success and registered
8
+ // enabled=true while the next invocation resolved zero plugins.
9
+ import { test, describe } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { linkOrCopy } from '../fs-utils.js';
15
+ import { listInstalledPluginsInRoot } from '../resolver.js';
16
+ describe('plugin link through a symlinked scope-root ancestor', () => {
17
+ test('the installed plugin resolves on the next invocation', () => {
18
+ const base = mkdtempSync(join(tmpdir(), 'crtr-plugin-link-'));
19
+ try {
20
+ // Source plugin package, outside the scope root.
21
+ const source = join(base, 'source', 'search');
22
+ mkdirSync(join(source, '.crouter-plugin'), { recursive: true });
23
+ writeFileSync(join(source, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: 'search', version: '0.1.0' }));
24
+ // Scope root addressed through a symlinked ancestor: `home` is a link,
25
+ // so the plugins directory's physical parent chain differs from its
26
+ // lexical one.
27
+ mkdirSync(join(base, 'physical', 'home'), { recursive: true });
28
+ symlinkSync(join('physical', 'home'), join(base, 'home'), 'dir');
29
+ const scopeRootPath = join(base, 'home', '.crouter');
30
+ mkdirSync(join(scopeRootPath, 'plugins'), { recursive: true });
31
+ linkOrCopy(source, join(scopeRootPath, 'plugins', 'search'));
32
+ assert.deepEqual(listInstalledPluginsInRoot('user', scopeRootPath).map((p) => p.name), ['search']);
33
+ }
34
+ finally {
35
+ rmSync(base, { recursive: true, force: true });
36
+ }
37
+ });
38
+ });
@@ -196,11 +196,14 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
196
196
  assert.equal(remapped.ok, true, JSON.stringify(remapped.diagnostics));
197
197
  assert.equal(tmuxBinding(socket, 'root', 'M-c'), null);
198
198
  assert.equal(tmuxBinding(socket, 'root', 'M-]'), null);
199
- assert.equal(tmuxBinding(socket, 'root', 'M-F'), null);
200
- assert.equal(tmuxBinding(socket, 'root', 'M-S-f'), null);
201
- const foldTools = tmuxBinding(socket, 'root', 'M-S-F') ?? '';
202
- assert.match(foldTools, /fold-tools/);
203
- assert.doesNotMatch(foldTools, /#\{pane_id\}/);
199
+ // One chord, every name tmux can derive for it: the terminal's byte encoding
200
+ // changes with the focused pane's extended-key mode, and tmux does not
201
+ // normalize across them, so all three must carry the action.
202
+ for (const key of ['M-S-F', 'M-F', 'M-S-f']) {
203
+ const foldTools = tmuxBinding(socket, 'root', key) ?? '';
204
+ assert.match(foldTools, /fold-tools/, `${key} should run fold-tools`);
205
+ assert.doesNotMatch(foldTools, /#\{pane_id\}/);
206
+ }
204
207
  const menu = tmuxBinding(socket, 'root', 'M-S-Z') ?? '';
205
208
  assert.match(menu, /canvas graph/);
206
209
  assert.match(menu, /Z/);