@north-light/crouter 0.3.162 → 0.3.164

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 (100) hide show
  1. package/dist/builtin-memory/00-runtime-base.md +1 -0
  2. package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
  3. package/dist/builtin-memory/init.md +38 -0
  4. package/dist/builtin-memory/internal/INDEX.md +1 -0
  5. package/dist/builtin-memory/internal/memory-loading.md +45 -0
  6. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +34 -2
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +14 -3
  10. package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +1 -1
  11. package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
  12. package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
  13. package/dist/clients/attach/__tests__/edit-diff.test.js +35 -0
  14. package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
  15. package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
  16. package/dist/clients/attach/render/chat-view.js +54 -38
  17. package/dist/clients/attach/render/crtr-output.d.ts +2 -1
  18. package/dist/clients/attach/render/crtr-output.js +33 -4
  19. package/dist/clients/attach/render/edit-diff.d.ts +21 -6
  20. package/dist/clients/attach/render/edit-diff.js +104 -81
  21. package/dist/clients/attach/render/tool-calls.d.ts +21 -20
  22. package/dist/clients/attach/render/tool-calls.js +89 -43
  23. package/dist/clients/attach/session/connection.d.ts +1 -1
  24. package/dist/clients/attach/session/editor-frame.d.ts +5 -1
  25. package/dist/clients/attach/session/editor-frame.js +21 -15
  26. package/dist/clients/attach/session/identity.d.ts +1 -1
  27. package/dist/clients/attach/viewer.js +628 -624
  28. package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
  29. package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
  30. package/dist/commands/__tests__/human.test.js +2 -0
  31. package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
  32. package/dist/commands/__tests__/search-contents.test.js +52 -0
  33. package/dist/commands/api-client.js +1 -0
  34. package/dist/commands/human/prompts.js +2 -2
  35. package/dist/commands/human/shared.d.ts +1 -0
  36. package/dist/commands/human/shared.js +7 -4
  37. package/dist/commands/memory/lint.d.ts +6 -0
  38. package/dist/commands/memory/lint.js +177 -35
  39. package/dist/commands/memory/read.js +22 -1
  40. package/dist/commands/memory/write.js +18 -4
  41. package/dist/commands/memory.js +1 -1
  42. package/dist/commands/search/contents.js +2 -2
  43. package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
  44. package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
  45. package/dist/commands/sys/setup-wizard.d.ts +8 -1
  46. package/dist/commands/sys/setup-wizard.js +209 -58
  47. package/dist/commands/sys/sync-deps.js +14 -18
  48. package/dist/commands/sys/sync-project-guidance.js +16 -19
  49. package/dist/core/__tests__/migration.test.js +8 -3
  50. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
  51. package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
  52. package/dist/core/__tests__/on-read-identity.test.js +8 -15
  53. package/dist/core/__tests__/revive.test.js +2 -2
  54. package/dist/core/__tests__/tmux-surface.test.js +10 -4
  55. package/dist/core/__tests__/worktree.test.js +3 -3
  56. package/dist/core/canvas/extensions.d.ts +1 -1
  57. package/dist/core/canvas/extensions.js +18 -11
  58. package/dist/core/canvas/labels.d.ts +4 -5
  59. package/dist/core/canvas/labels.js +9 -9
  60. package/dist/core/command-manifests/schema.js +18 -5
  61. package/dist/core/command.js +17 -7
  62. package/dist/core/configured-clis/invoker.js +2 -0
  63. package/dist/core/help.d.ts +3 -1
  64. package/dist/core/help.js +5 -2
  65. package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
  66. package/dist/core/keybindings/catalog.d.ts +3 -2
  67. package/dist/core/keybindings/catalog.js +34 -25
  68. package/dist/core/keybindings/index.d.ts +1 -1
  69. package/dist/core/keybindings/resolve.js +8 -0
  70. package/dist/core/keybindings/types.d.ts +6 -1
  71. package/dist/core/memory/doc-link-grammar.d.ts +20 -0
  72. package/dist/core/memory/doc-link-grammar.js +110 -0
  73. package/dist/core/memory-resolver.d.ts +5 -0
  74. package/dist/core/memory-resolver.js +12 -1
  75. package/dist/core/runtime/bearings.d.ts +8 -8
  76. package/dist/core/runtime/bearings.js +20 -16
  77. package/dist/core/runtime/broker.js +3 -13
  78. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  79. package/dist/core/runtime/canvas-extensions.js +2 -0
  80. package/dist/core/runtime/launch.d.ts +1 -1
  81. package/dist/core/runtime/launch.js +1 -1
  82. package/dist/core/runtime/tmux.js +136 -100
  83. package/dist/core/scope.js +4 -5
  84. package/dist/core/substrate/ceiling.d.ts +3 -6
  85. package/dist/core/substrate/ceiling.js +13 -15
  86. package/dist/core/substrate/index.d.ts +2 -3
  87. package/dist/core/substrate/index.js +2 -2
  88. package/dist/core/substrate/on-read.d.ts +4 -13
  89. package/dist/core/substrate/on-read.js +142 -262
  90. package/dist/core/substrate/render.js +4 -4
  91. package/dist/core/substrate/schema.d.ts +3 -2
  92. package/dist/core/substrate/schema.js +5 -5
  93. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
  94. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
  95. package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
  96. package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
  97. package/dist/pi-extensions/canvas-tool-guide.js +77 -0
  98. package/package.json +2 -2
  99. package/runtime.lock.json +6 -6
  100. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
@@ -1278,6 +1278,50 @@ class ModelsPanel {
1278
1278
  return frame(this.fitBody(body, selectedLine, contentBudget));
1279
1279
  }
1280
1280
  }
1281
+ /** Collapse explicitly-paired catalog definitions into one setup action row.
1282
+ * Pair membership and field roles come only from catalog metadata; neither
1283
+ * rendering nor installation guesses from binding ID names. */
1284
+ function buildSetupKeybindingRows() {
1285
+ const byId = new Map(BINDING_CATALOG.map((definition) => [definition.id, definition]));
1286
+ const consumed = new Set();
1287
+ const rows = [];
1288
+ for (const definition of BINDING_CATALOG) {
1289
+ if (consumed.has(definition.id))
1290
+ continue;
1291
+ if (definition.pair === undefined) {
1292
+ rows.push({
1293
+ group: definition.group,
1294
+ name: definition.name,
1295
+ description: definition.description,
1296
+ fields: [{ definition }],
1297
+ });
1298
+ consumed.add(definition.id);
1299
+ continue;
1300
+ }
1301
+ const paired = byId.get(definition.pair.pairedId);
1302
+ if (paired?.pair?.pairedId !== definition.id || paired.pair.field === definition.pair.field) {
1303
+ throw new Error(`invalid keybinding pair metadata for ${definition.id}`);
1304
+ }
1305
+ const members = [definition, paired];
1306
+ const menu = members.find((member) => member.pair?.field === 'menu');
1307
+ const global = members.find((member) => member.pair?.field === 'global');
1308
+ if (menu === undefined || global === undefined)
1309
+ throw new Error(`incomplete keybinding pair for ${definition.id}`);
1310
+ rows.push({
1311
+ group: menu.group,
1312
+ name: menu.name,
1313
+ description: menu.description,
1314
+ fields: [
1315
+ { label: 'Menu', definition: menu },
1316
+ { label: 'Global', definition: global },
1317
+ ],
1318
+ });
1319
+ consumed.add(menu.id);
1320
+ consumed.add(global.id);
1321
+ }
1322
+ return rows;
1323
+ }
1324
+ const SETUP_KEYBINDING_ROWS = buildSetupKeybindingRows();
1281
1325
  export class SetupWizard {
1282
1326
  manifest;
1283
1327
  installedPackageIds;
@@ -1299,6 +1343,7 @@ export class SetupWizard {
1299
1343
  expandedPluginId = null;
1300
1344
  confirming = false;
1301
1345
  keyIndex = 0;
1346
+ keyFieldIndex = 0;
1302
1347
  keyScroll = 0;
1303
1348
  keyFilter = '';
1304
1349
  filteringKeys = false;
@@ -1539,25 +1584,38 @@ export class SetupWizard {
1539
1584
  const resolution = this.keyDraft.resolution();
1540
1585
  const needle = this.keyFilter.trim().toLowerCase();
1541
1586
  if (needle === '')
1542
- return BINDING_CATALOG;
1543
- return BINDING_CATALOG.filter((definition) => {
1544
- const rendered = formatBinding(resolution, definition.id);
1545
- return [definition.name, definition.id, definition.group, definition.description, rendered]
1546
- .some((value) => value.toLowerCase().includes(needle));
1547
- });
1548
- }
1549
- selectedKeybinding() {
1587
+ return SETUP_KEYBINDING_ROWS;
1588
+ return SETUP_KEYBINDING_ROWS.filter((row) => [
1589
+ row.name,
1590
+ row.group,
1591
+ row.description,
1592
+ ...row.fields.flatMap((field) => [
1593
+ field.label ?? '',
1594
+ field.definition.id,
1595
+ field.definition.name,
1596
+ field.definition.description,
1597
+ formatBinding(resolution, field.definition.id),
1598
+ ]),
1599
+ ].some((value) => value.toLowerCase().includes(needle)));
1600
+ }
1601
+ selectedKeyRow() {
1550
1602
  const rows = this.filteredKeybindings();
1551
1603
  if (rows.length === 0)
1552
1604
  return undefined;
1553
1605
  this.keyIndex = Math.max(0, Math.min(this.keyIndex, rows.length - 1));
1554
- return rows[this.keyIndex];
1606
+ const row = rows[this.keyIndex];
1607
+ this.keyFieldIndex = Math.max(0, Math.min(this.keyFieldIndex, row.fields.length - 1));
1608
+ return row;
1609
+ }
1610
+ selectedKeybinding() {
1611
+ return this.selectedKeyRow()?.fields[this.keyFieldIndex]?.definition;
1555
1612
  }
1556
1613
  moveKey(delta) {
1557
1614
  const rows = this.filteredKeybindings();
1558
1615
  if (rows.length === 0)
1559
1616
  return;
1560
1617
  this.keyIndex = Math.max(0, Math.min(rows.length - 1, this.keyIndex + delta));
1618
+ this.keyFieldIndex = Math.min(this.keyFieldIndex, rows[this.keyIndex].fields.length - 1);
1561
1619
  const page = this.keyPageSize();
1562
1620
  if (this.keyIndex < this.keyScroll)
1563
1621
  this.keyScroll = this.keyIndex;
@@ -1566,6 +1624,14 @@ export class SetupWizard {
1566
1624
  this.keyEditError = undefined;
1567
1625
  this.invalidate();
1568
1626
  }
1627
+ moveKeyField(delta) {
1628
+ const row = this.selectedKeyRow();
1629
+ if (row === undefined)
1630
+ return;
1631
+ this.keyFieldIndex = Math.max(0, Math.min(row.fields.length - 1, this.keyFieldIndex + delta));
1632
+ this.keyEditError = undefined;
1633
+ this.invalidate();
1634
+ }
1569
1635
  // Max output lines the row region may emit before it starts clipping the
1570
1636
  // confirm prompt / notice / footer. Budgets the fixed chrome around the list:
1571
1637
  // header (3) + blank + tab header/count (2) + blank + footer (1) plus a small
@@ -1821,6 +1887,14 @@ export class SetupWizard {
1821
1887
  this.moveKey(-1);
1822
1888
  return;
1823
1889
  }
1890
+ else if (this.matches('crtr.setup.cell-previous', data)) {
1891
+ this.moveKeyField(-1);
1892
+ return;
1893
+ }
1894
+ else if (this.matches('crtr.setup.cell-next', data)) {
1895
+ this.moveKeyField(1);
1896
+ return;
1897
+ }
1824
1898
  else if (this.matches('crtr.setup.keybindings.page-up', data)) {
1825
1899
  this.moveKey(-this.keyPageSize());
1826
1900
  return;
@@ -2027,7 +2101,7 @@ export class SetupWizard {
2027
2101
  ? `type gestures separated by commas · ${this.label('crtr.setup.keybindings.commit-edit')} commit · ${this.label('crtr.setup.keybindings.cancel-edit')} cancel · ${this.label('crtr.setup.keybindings.page-up')}/${this.label('crtr.setup.keybindings.page-down')} scroll`
2028
2102
  : this.filteringKeys
2029
2103
  ? `filter: ${this.keyFilter || 'type to search'} · ${this.label('crtr.setup.keybindings.edit')} keep · ${this.label('crtr.setup.keybindings.filter-delete')} delete · ${this.label('crtr.setup.keybindings.filter-clear')} clear`
2030
- : `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} move · ${this.label('crtr.setup.keybindings.edit')} edit · ${this.label('crtr.setup.keybindings.unbind')} disable · ${this.label('crtr.setup.keybindings.reset-row')} reset · ${this.label('crtr.setup.keybindings.reset-all')} reset all · ${this.label('crtr.setup.keybindings.filter')} filter · ${this.label('crtr.setup.keybindings.save')} save · ${this.label('crtr.setup.keybindings.discard')} ${this.keyFilter !== '' ? 'clear filter' : 'discard'}`;
2104
+ : `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} move · ${this.label('crtr.setup.cell-previous')}/${this.label('crtr.setup.cell-next')} field · ${this.label('crtr.setup.keybindings.edit')} edit · ${this.label('crtr.setup.keybindings.unbind')} disable · ${this.label('crtr.setup.keybindings.reset-row')} reset · ${this.label('crtr.setup.keybindings.reset-all')} reset all · ${this.label('crtr.setup.keybindings.filter')} filter · ${this.label('crtr.setup.keybindings.save')} save · ${this.label('crtr.setup.keybindings.discard')} ${this.keyFilter !== '' ? 'clear filter' : 'discard'}`;
2031
2105
  return [truncateToWidth(theme.fg('dim', line), width)];
2032
2106
  }
2033
2107
  renderProviders(width) {
@@ -2113,30 +2187,50 @@ export class SetupWizard {
2113
2187
  out.push(truncateToWidth(theme.fg('dim', `${this.label('crtr.setup.confirm.accept')} confirm · ${this.label('crtr.setup.confirm.back')} go back`), width));
2114
2188
  return out;
2115
2189
  }
2116
- // Does the row at `index` contribute a diagnostic line in the current state?
2117
- // Mirrors the emit loop's `message` computation so the line-cost accounting
2118
- // and the render stay in lockstep.
2119
- keyRowHasMessage(rows, index) {
2120
- const definition = rows[index];
2190
+ keyRowMessage(rows, index) {
2191
+ const row = rows[index];
2121
2192
  const selected = index === this.keyIndex;
2122
- const editing = this.editingKey === definition.id;
2123
- if (!selected && !editing)
2124
- return false;
2125
- if (editing && this.keyEditError)
2126
- return true;
2127
- const diagnostic = this.keyDiagnostics.find((entry) => entry.bindingId === definition.id || entry.relatedBindingIds?.includes(definition.id));
2128
- return diagnostic !== undefined;
2129
- }
2130
- // Output lines the visible rows [start..end] cost, counting the per-row group
2131
- // heading (the first visible row always re-emits its heading) and any
2132
- // diagnostic line. Used to clamp the scroll so the anchor row always renders.
2133
- keyLineCost(rows, start, end) {
2193
+ const editing = row.fields.find((field) => field.definition.id === this.editingKey);
2194
+ if (!selected && editing === undefined)
2195
+ return undefined;
2196
+ if (editing !== undefined && this.keyEditError)
2197
+ return { label: editing.label, message: this.keyEditError };
2198
+ const diagnostic = this.keyDiagnostics.find((entry) => row.fields.some((field) => entry.bindingId === field.definition.id || entry.relatedBindingIds?.includes(field.definition.id)));
2199
+ if (diagnostic === undefined)
2200
+ return undefined;
2201
+ const responsible = row.fields.find((field) => diagnostic.bindingId === field.definition.id || diagnostic.relatedBindingIds?.includes(field.definition.id));
2202
+ return { label: responsible?.label, message: diagnostic.message };
2203
+ }
2204
+ /** Number of lines needed to keep the active paired field legible. A narrow
2205
+ * row stacks the action and field instead of sacrificing the action name,
2206
+ * selected-field identity, or value to fixed columns. */
2207
+ keyRowBodyLineCount(row, index, resolution, width) {
2208
+ const editingFieldIndex = row.fields.findIndex((field) => field.definition.id === this.editingKey);
2209
+ const active = editingFieldIndex >= 0 || index === this.keyIndex;
2210
+ if (!active || row.fields.length < 2)
2211
+ return 1;
2212
+ const fieldIndex = editingFieldIndex >= 0 ? editingFieldIndex : Math.min(this.keyFieldIndex, row.fields.length - 1);
2213
+ const field = row.fields[fieldIndex];
2214
+ const value = editingFieldIndex >= 0 ? `> ${this.keyInput.getValue()}` : formatBinding(resolution, field.definition.id);
2215
+ const bracketedLabel = `[${field.label}]`;
2216
+ const inlineWidth = 3 + visibleWidth(row.name) + 1 + visibleWidth(bracketedLabel) + 1 + visibleWidth(value);
2217
+ if (inlineWidth <= width)
2218
+ return 1;
2219
+ const compactFieldWidth = visibleWidth(field.label ?? '') + 1 + visibleWidth(value);
2220
+ return compactFieldWidth <= width ? 2 : 3;
2221
+ }
2222
+ // Output lines the visible rows [start..end] cost, counting group headings,
2223
+ // responsive row bodies, and diagnostics. Used to clamp the scroll so the
2224
+ // anchor row always renders.
2225
+ keyLineCost(rows, start, end, resolution, width) {
2134
2226
  let lines = 0;
2135
2227
  let previousGroup;
2136
2228
  for (let index = start; index <= end; index += 1) {
2137
- const definition = rows[index];
2138
- lines += (definition.group !== previousGroup ? 1 : 0) + 1 + (this.keyRowHasMessage(rows, index) ? 1 : 0);
2139
- previousGroup = definition.group;
2229
+ const row = rows[index];
2230
+ lines += (row.group !== previousGroup ? 1 : 0)
2231
+ + this.keyRowBodyLineCount(row, index, resolution, width)
2232
+ + (this.keyRowMessage(rows, index) ? 1 : 0);
2233
+ previousGroup = row.group;
2140
2234
  }
2141
2235
  return lines;
2142
2236
  }
@@ -2157,12 +2251,14 @@ export class SetupWizard {
2157
2251
  // Injected group-heading and diagnostic lines can make a pageSize-row window
2158
2252
  // exceed the raw viewport, so instead of a fixed reserve, clamp the scroll
2159
2253
  // dynamically until the anchor is guaranteed to render within `viewport`.
2160
- const editIndex = this.editingKey !== null ? rows.findIndex((row) => row.id === this.editingKey) : -1;
2254
+ const editIndex = this.editingKey !== null
2255
+ ? rows.findIndex((row) => row.fields.some((field) => field.definition.id === this.editingKey))
2256
+ : -1;
2161
2257
  const anchor = editIndex >= 0 ? editIndex : this.keyIndex;
2162
2258
  if (anchor >= 0) {
2163
2259
  if (anchor < this.keyScroll)
2164
2260
  this.keyScroll = anchor;
2165
- while (this.keyScroll < anchor && this.keyLineCost(rows, this.keyScroll, anchor) > viewport) {
2261
+ while (this.keyScroll < anchor && this.keyLineCost(rows, this.keyScroll, anchor, resolution, width) > viewport) {
2166
2262
  this.keyScroll += 1;
2167
2263
  }
2168
2264
  }
@@ -2179,40 +2275,95 @@ export class SetupWizard {
2179
2275
  out.push(truncateToWidth(theme.fg('warning', 'No matching keybindings.'), width));
2180
2276
  return out;
2181
2277
  }
2182
- const nameWidth = Math.max(16, Math.min(28, Math.floor(width * 0.28)));
2183
- const keyWidth = Math.max(18, Math.min(34, Math.floor(width * 0.34)));
2184
- const showDescription = width >= 72;
2278
+ const nameWidth = Math.max(10, Math.min(26, Math.floor(width * 0.28)));
2185
2279
  let emitted = 0;
2186
2280
  let previousGroup;
2187
2281
  for (let index = this.keyScroll; index < rows.length; index += 1) {
2188
- const definition = rows[index];
2282
+ const row = rows[index];
2189
2283
  const selected = index === this.keyIndex;
2190
- const editing = this.editingKey === definition.id;
2191
- const diagnostics = (selected || editing)
2192
- ? this.keyDiagnostics.filter((diagnostic) => diagnostic.bindingId === definition.id || diagnostic.relatedBindingIds?.includes(definition.id))
2193
- : [];
2194
- const message = editing ? (this.keyEditError ?? diagnostics[0]?.message) : (selected ? diagnostics[0]?.message : undefined);
2195
- // Budget output lines (heading + row + optional diagnostic) so the list
2196
- // never clips the confirm prompt / notice / footer below it.
2197
- const cost = (definition.group !== previousGroup ? 1 : 0) + 1 + (message ? 1 : 0);
2284
+ const editingFieldIndex = row.fields.findIndex((field) => field.definition.id === this.editingKey);
2285
+ const activeFieldIndex = editingFieldIndex >= 0
2286
+ ? editingFieldIndex
2287
+ : selected ? Math.min(this.keyFieldIndex, row.fields.length - 1) : 0;
2288
+ const message = this.keyRowMessage(rows, index);
2289
+ const bodyLines = this.keyRowBodyLineCount(row, index, resolution, width);
2290
+ // Budget output lines (heading + responsive row + optional diagnostic) so
2291
+ // the list never clips the confirm prompt / notice / footer below it.
2292
+ const cost = (row.group !== previousGroup ? 1 : 0) + bodyLines + (message ? 1 : 0);
2198
2293
  if (emitted > 0 && emitted + cost > viewport)
2199
2294
  break;
2200
- if (definition.group !== previousGroup) {
2201
- out.push(truncateToWidth(theme.fg('accent', definition.group), width));
2202
- previousGroup = definition.group;
2295
+ if (row.group !== previousGroup) {
2296
+ out.push(truncateToWidth(theme.fg('accent', row.group), width));
2297
+ previousGroup = row.group;
2203
2298
  }
2204
2299
  const marker = selected ? theme.fg('accent', '▶') : ' ';
2205
- const sourceMark = resolution.get(definition.id).source === 'user' ? theme.fg('warning', '•') : ' ';
2206
- const name = padAnsi(selected ? theme.fg('accent', definition.name) : definition.name, nameWidth);
2207
- // Render the edit field whenever THIS row is the one being edited — even if
2208
- // paging has moved the selection elsewhere — so the active editor stays
2209
- // visible while it captures input.
2210
- const keyText = editing ? (this.keyInput.render(keyWidth)[0] ?? '') : formatBinding(resolution, definition.id);
2211
- const key = padAnsi(keyText, keyWidth);
2212
- const description = showDescription ? ` ${theme.fg('dim', definition.description)}` : '';
2213
- out.push(truncateToWidth(`${marker}${sourceMark} ${name} ${key}${description}`, width));
2214
- if (message)
2215
- out.push(truncateToWidth(theme.fg('warning', ` ${message}`), width));
2300
+ const sourceMark = row.fields.some((field) => resolution.get(field.definition.id).source === 'user')
2301
+ ? theme.fg('warning', '•')
2302
+ : ' ';
2303
+ if (bodyLines > 1) {
2304
+ const field = row.fields[activeFieldIndex];
2305
+ const editing = field.definition.id === this.editingKey;
2306
+ const plainValue = editing ? `> ${this.keyInput.getValue()}` : formatBinding(resolution, field.definition.id);
2307
+ const bracketedLabel = `[${field.label}]`;
2308
+ const compactLabel = field.label ?? '';
2309
+ out.push(truncateToWidth(`${marker}${sourceMark} ${selected ? theme.fg('accent', row.name) : row.name}`, width));
2310
+ if (bodyLines === 2) {
2311
+ const useBracketed = visibleWidth(bracketedLabel) + 1 + visibleWidth(plainValue) <= width;
2312
+ const plainLabel = useBracketed ? bracketedLabel : compactLabel;
2313
+ const label = theme.fg('accent', theme.bold(plainLabel));
2314
+ const indent = ' '.repeat(Math.max(0, Math.min(3, width - visibleWidth(plainLabel) - 1 - visibleWidth(plainValue))));
2315
+ const valueWidth = Math.max(1, width - visibleWidth(indent) - visibleWidth(label) - 1);
2316
+ const value = editing ? (this.keyInput.render(valueWidth)[0] ?? '') : plainValue;
2317
+ out.push(truncateToWidth(`${indent}${label} ${value}`, width));
2318
+ }
2319
+ else {
2320
+ const label = theme.fg('accent', theme.bold(bracketedLabel));
2321
+ const labelIndent = ' '.repeat(Math.max(0, Math.min(3, width - visibleWidth(label))));
2322
+ const value = editing ? (this.keyInput.render(width)[0] ?? '') : plainValue;
2323
+ out.push(truncateToWidth(`${labelIndent}${label}`, width));
2324
+ out.push(truncateToWidth(value, width));
2325
+ }
2326
+ }
2327
+ else {
2328
+ const name = padAnsi(selected ? theme.fg('accent', row.name) : row.name, nameWidth);
2329
+ const available = Math.max(1, width - 4 - nameWidth);
2330
+ const showAllFields = row.fields.length === 1 || available >= 28;
2331
+ const visibleFields = showAllFields ? row.fields : [row.fields[activeFieldIndex]];
2332
+ const fieldAreaWidth = row.fields.length > 1
2333
+ ? Math.min(61, available)
2334
+ : Math.min(34, available);
2335
+ const gapWidth = Math.max(0, visibleFields.length - 1);
2336
+ const fieldWidth = Math.max(1, Math.floor((fieldAreaWidth - gapWidth) / visibleFields.length));
2337
+ const renderedFields = visibleFields.map((field) => {
2338
+ const fieldIndex = row.fields.indexOf(field);
2339
+ const editing = field.definition.id === this.editingKey;
2340
+ const active = editing || (selected && fieldIndex === activeFieldIndex);
2341
+ const label = field.label === undefined
2342
+ ? ''
2343
+ : active
2344
+ ? theme.fg('accent', theme.bold(`[${field.label}]`))
2345
+ : theme.fg('dim', `${field.label}:`);
2346
+ const labelWidth = visibleWidth(label);
2347
+ const valueWidth = Math.max(1, fieldWidth - labelWidth - (label === '' ? 0 : 1));
2348
+ // Render the edit field whenever THIS underlying definition is being
2349
+ // edited, even if paging moved row selection elsewhere.
2350
+ const value = editing
2351
+ ? (this.keyInput.render(valueWidth)[0] ?? '')
2352
+ : formatBinding(resolution, field.definition.id);
2353
+ const content = label === '' ? value : `${label} ${value}`;
2354
+ return padAnsi(truncateToWidth(content, fieldWidth), fieldWidth);
2355
+ });
2356
+ const fieldsText = renderedFields.join(' ');
2357
+ const descriptionWidth = available - visibleWidth(fieldsText) - 1;
2358
+ const description = descriptionWidth >= 16
2359
+ ? ` ${theme.fg('dim', truncateToWidth(row.description, descriptionWidth))}`
2360
+ : '';
2361
+ out.push(truncateToWidth(`${marker}${sourceMark} ${name} ${fieldsText}${description}`, width));
2362
+ }
2363
+ if (message) {
2364
+ const fieldPrefix = message.label === undefined ? '' : `${message.label}: `;
2365
+ out.push(truncateToWidth(theme.fg('warning', ` ${fieldPrefix}${message.message}`), width));
2366
+ }
2216
2367
  emitted += cost;
2217
2368
  }
2218
2369
  if (this.keyConfirm !== null) {
@@ -16,16 +16,13 @@
16
16
  // via the bare-dir → INDEX contract, AND the bundle's relative sidecar
17
17
  // links (./references/x.md, ./docs/y.md) keep resolving on disk because
18
18
  // the bundle's file layout is preserved. Sidecar .md files get minimal
19
- // substrate frontmatter injected (lint-clean, name/name visible); other files
20
- // copy verbatim.
21
- // • Vendored bundle and sidecar docs sit at name/name, so agents can
22
- // discover their resolvable identities without loading their bodies. The
23
- // ONE generated top deps/INDEX.md sits at preview/preview and carries the
24
- // routing catalog. The catalog is one read away, each vendored doc another;
25
- // the positional on-read hook surfaces the catalog's preview line plus each
26
- // vendored doc's bare name, once per session — which also routes agents
27
- // whose cwd (and boot tree) is elsewhere the moment they read a file in
28
- // this project.
19
+ // substrate frontmatter injected (lint-clean, system name/file none); other
20
+ // files copy verbatim.
21
+ // • Vendored bundle and sidecar docs sit at system name/file none, so the boot
22
+ // catalog exposes their resolvable identities without loading their bodies.
23
+ // The ONE generated top deps/INDEX.md sits at system preview/file none and
24
+ // carries the routing catalog. The catalog is one read away, each vendored
25
+ // doc another.
29
26
  import { copyFileSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
30
27
  import { CRTR_DIR_NAME } from '../../types.js';
31
28
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
@@ -181,9 +178,8 @@ function condenseDescription(desc, max = 220) {
181
178
  return `${cut.slice(0, lastSpace > 80 ? lastSpace : max).trimEnd()}…`;
182
179
  }
183
180
  /** Frontmatter fields from a source file that must never pass through into a
184
- * vendored doc: identity, routing, and trigger fields are all set (or
185
- * deliberately absent) by this lane — a source applies-to/read-when would
186
- * pierce the INDEX ceiling (on-read D6) and defeat the collapsed design. */
181
+ * vendored doc: this lane owns identity, routing, visibility, and gates, so a
182
+ * dependency bundle cannot alter how the generated project catalog surfaces. */
187
183
  const CONTROLLED_FIELDS = [
188
184
  'name',
189
185
  'description',
@@ -374,7 +370,7 @@ export function runDepsSync(opts) {
374
370
  ? condenseDescription(entry.description)
375
371
  : `Vendored agent doc shipped by ${entry.pkg}.`,
376
372
  'system-prompt-visibility': 'name',
377
- 'file-read-visibility': 'name',
373
+ 'file-read-visibility': 'none',
378
374
  'source-package': `${entry.pkg}@${entry.version}`,
379
375
  ...passthroughFm(entry.fm),
380
376
  };
@@ -398,7 +394,7 @@ export function runDepsSync(opts) {
398
394
  'when-and-why-to-read': `When ${parentName} links to this file, this knowledge should be read because it carries the linked supporting reference.`,
399
395
  'short-form': firstHeading(body) ?? `Supporting reference vendored from ${entry.pkg}.`,
400
396
  'system-prompt-visibility': 'name',
401
- 'file-read-visibility': 'name',
397
+ 'file-read-visibility': 'none',
402
398
  'source-package': `${entry.pkg}@${entry.version}`,
403
399
  ...passthroughFm(sourceFm),
404
400
  };
@@ -409,7 +405,7 @@ export function runDepsSync(opts) {
409
405
  copyFileSync(entry.sourceAbs, target);
410
406
  }
411
407
  }
412
- // The generated catalog INDEX: preview/preview routing + full per-package map.
408
+ // The generated catalog INDEX: boot preview + full per-package map.
413
409
  const libNames = [...new Set(skills.map(([, skill]) => skill.pkg))];
414
410
  const shownLibs = libNames.slice(0, 6).join(', ') + (libNames.length > 6 ? ', …' : '');
415
411
  const byPackage = new Map();
@@ -435,7 +431,7 @@ export function runDepsSync(opts) {
435
431
  ? 'No dependency-shipped agent docs are currently vendored.'
436
432
  : "Generated catalog of the agent docs shipped inside this project's npm dependencies.",
437
433
  'system-prompt-visibility': 'preview',
438
- 'file-read-visibility': 'preview',
434
+ 'file-read-visibility': 'none',
439
435
  'generated-by': DEPS_GENERATED_MARKER,
440
436
  };
441
437
  const indexBody = emptyCatalog
@@ -490,7 +486,7 @@ export const sysSyncDepsLeaf = defineLeaf({
490
486
  ],
491
487
  outputKind: 'object',
492
488
  effects: [
493
- "Wipes and regenerates <project>/.crouter/memory/deps/ from installed dependency skills, except bundles selected by exact {package,version,skill} entries in package.json crtr.sync.deps.exclude. Each included SKILL.md becomes a name/name deps/<bundle>/INDEX.md (relative sidecar links keep resolving), Markdown sidecar docs are vendored alongside at name/name, other sidecar assets are copied verbatim, and a generated preview/preview deps/INDEX.md catalog plus a self-ignoring .gitignore are written. Excluded bundles and their sidecars do not enter the catalog.",
489
+ "Wipes and regenerates <project>/.crouter/memory/deps/ from installed dependency skills, except bundles selected by exact {package,version,skill} entries in package.json crtr.sync.deps.exclude. Each included SKILL.md becomes a system-name/file-none deps/<bundle>/INDEX.md (relative sidecar links keep resolving), Markdown sidecar docs are vendored alongside at the same rungs, other sidecar assets are copied verbatim, and a system-preview/file-none deps/INDEX.md catalog plus a self-ignoring .gitignore are written. Excluded bundles and their sidecars do not enter the catalog.",
494
490
  'With --dry-run: read-only; writes nothing.',
495
491
  ],
496
492
  },
@@ -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
- // `.crouter/memory/`, so the doc travels with the repo and gets positional
4
- // on-read for free. See `crtr memory read taste/document-substrate` for the
5
- // content-rung ruling this migration relies on.
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.
6
6
  import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
7
7
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
8
8
  import { defineLeaf } from '../../core/command.js';
@@ -95,7 +95,7 @@ function synthesizeShortForm(body) {
95
95
  return 'Migrated project guidance.';
96
96
  }
97
97
  /** `.claude/rules` frontmatter `paths:` normalizes to a glob list — a single
98
- * string, an array, or absent (positional, unconditional rule). */
98
+ * string, an array, or absent for a workspace-wide rule. */
99
99
  function normalizeRulePaths(v) {
100
100
  if (typeof v === 'string')
101
101
  return v.trim() === '' ? [] : [v.trim()];
@@ -201,12 +201,10 @@ function projectDocLabel(candidate) {
201
201
  .replace(/^\.$/, '');
202
202
  return relDir === '' ? rootLabel : normalizedName(`${rootLabel}/${relDir}`);
203
203
  }
204
- /** AGENTS.md/CLAUDE.md → `<sourceDir>/.crouter/memory/AGENTS.md` at
205
- * content/content (the CTO's ruling, 2026-07-06: an AGENTS.md-style doc is
206
- * always-relevant AND wholly-important, so full-body at `content` is
207
- * sanctioned — see `taste/document-substrate`). The on-disk filename stays
208
- * `AGENTS.md`, but the substrate identity is the explicit project label so
209
- * sibling roots do not collapse on a shared `AGENTS` leaf. */
204
+ /** AGENTS.md/CLAUDE.md → `<sourceDir>/.crouter/memory/INDEX.md`, the
205
+ * workspace front door. Its explicit project identity keeps sibling roots
206
+ * distinct; system none/file content plus `applies-to: "."` puts the wholly
207
+ * important guide in first-message context when cwd/profile mounts the store. */
210
208
  function prepareAgentsCandidate(candidate) {
211
209
  const body = parseFrontmatterGeneric(readText(candidate.path)).body;
212
210
  const docLabel = projectDocLabel(candidate);
@@ -215,21 +213,21 @@ function prepareAgentsCandidate(candidate) {
215
213
  name: docLabel,
216
214
  'when-and-why-to-read': `When working in ${docLabel}, this knowledge should be read because it is the project's operating guide.`,
217
215
  'short-form': synthesizeShortForm(body),
218
- 'system-prompt-visibility': 'content',
216
+ 'system-prompt-visibility': 'none',
219
217
  'file-read-visibility': 'content',
218
+ 'applies-to': ['.'],
220
219
  };
221
220
  return {
222
221
  candidate,
223
222
  name: docLabel,
224
- target: memoryFilePath(join(candidate.sourceDir, CRTR_DIR_NAME, 'memory'), 'AGENTS'),
223
+ target: memoryFilePath(join(candidate.sourceDir, CRTR_DIR_NAME, 'memory'), 'INDEX'),
225
224
  frontmatter,
226
225
  body,
227
226
  };
228
227
  }
229
228
  /** `.claude/rules/<name>.md` → `<sourceDir>/.crouter/memory/<name>.md`. A
230
- * `paths:` rule becomes an `applies-to` glob trigger at none/content; a
231
- * pathless rule is positional (surfaces like AGENTS.md on any read under its
232
- * own project scope) at the same none/content rungs. */
229
+ * `paths:` rule keeps its `applies-to` file globs at none/content; a pathless
230
+ * rule receives `applies-to: "."` and surfaces when the workspace opens. */
233
231
  function prepareRuleCandidate(candidate) {
234
232
  const raw = readText(candidate.path);
235
233
  const parsed = parseFrontmatterGeneric(raw);
@@ -247,8 +245,7 @@ function prepareRuleCandidate(candidate) {
247
245
  'file-read-visibility': 'content',
248
246
  };
249
247
  const globs = normalizeRulePaths(fm['paths']);
250
- if (globs.length > 0)
251
- frontmatter['applies-to'] = globs;
248
+ frontmatter['applies-to'] = globs.length > 0 ? globs : ['.'];
252
249
  return {
253
250
  candidate,
254
251
  name,
@@ -299,7 +296,7 @@ function renderSummary(results) {
299
296
  export const sysSyncProjectGuidanceLeaf = defineLeaf({
300
297
  name: 'project-guidance',
301
298
  description: "migrate CLAUDE.md/AGENTS.md/.claude/rules into that directory's own crouter memory docs",
302
- whenToUse: "migrating per-project CLAUDE.md/AGENTS.md/.claude/rules into the crouter memory-doc model: one-way copy into that same directory's OWN .crouter/memory/, so the doc travels with the repo and gets positional on-read for free. Never exports memory docs back to these files; crouter stops reading the originals unconditionally.",
299
+ whenToUse: "migrating per-project CLAUDE.md/AGENTS.md/.claude/rules into the crouter memory-doc model: one-way copy into that same directory's OWN .crouter/memory/. The project guide becomes its root INDEX front door; path-scoped rules keep their globs and pathless rules open with the workspace. Never exports memory docs back to these source files.",
303
300
  help: {
304
301
  name: 'sys sync project-guidance',
305
302
  summary: "one-way migration: CLAUDE.md/AGENTS.md/.claude/rules → that dir's own crouter memory docs",
@@ -316,7 +313,7 @@ export const sysSyncProjectGuidanceLeaf = defineLeaf({
316
313
  ],
317
314
  outputKind: 'object',
318
315
  effects: [
319
- "Copies each discovered CLAUDE.md/AGENTS.md into <dir>/.crouter/memory/AGENTS.md at system-prompt-visibility/file-read-visibility content/content, and each .claude/rules/*.md into <dir>/.crouter/memory/<name>.md at none/content (applies-to set from the rule's paths: globs when present) — copy, not move; original files are left untouched.",
316
+ "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.",
320
317
  'Skips existing memory docs unless --overwrite is present.',
321
318
  'With --dry-run: read-only; writes nothing.',
322
319
  ],
@@ -7,12 +7,17 @@ import { fileURLToPath } from 'node:url';
7
7
  import { DatabaseSync } from 'node:sqlite';
8
8
  import { openDb, closeDb, migrate, MIGRATIONS } from '../canvas/db.js';
9
9
  import { canvasDbPath, ensureHome } from '../canvas/paths.js';
10
- import { CANVAS_EXTENSION_NAMES, customExtensionPaths } from '../canvas/extensions.js';
10
+ import { customExtensionPaths } from '../canvas/extensions.js';
11
11
  import { updateNode } from '../canvas/canvas.js';
12
12
  import { spawn, spawnSync } from 'node:child_process';
13
13
  const HERE = dirname(fileURLToPath(import.meta.url));
14
14
  const RUNTIME_COLUMNS = ['intent', 'pi_pid', 'window', 'tmux_session'];
15
15
  const RACE_FIXTURE = join(HERE, 'fixtures', 'metadata-migration-race.ts');
16
+ const PRE_TOOL_GUIDE_CANVAS_EXTENSION_NAMES = [
17
+ 'canvas-inbox-watcher', 'canvas-stophook', 'canvas-recap',
18
+ 'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
19
+ 'canvas-doc-substrate', 'canvas-structured-output', 'canvas-bash-valve',
20
+ ];
16
21
  let home;
17
22
  async function waitForPath(path) {
18
23
  for (;;) {
@@ -76,7 +81,7 @@ test('a v14 canvas normalizes historical canvas paths while retaining custom lau
76
81
  node_id: 'saved', name: 'saved', created: '2026-01-01T00:00:00.000Z', cwd: '/tmp',
77
82
  kind: 'developer', mode: 'base', lifecycle: 'terminal', status: 'idle',
78
83
  launch: { extensions: [
79
- ...CANVAS_EXTENSION_NAMES.map((name) => `/old-generation/pi-extensions/${name}.js`),
84
+ ...PRE_TOOL_GUIDE_CANVAS_EXTENSION_NAMES.map((name) => `/old-generation/pi-extensions/${name}.js`),
80
85
  '/plugins/canvas-inbox-watcher.js',
81
86
  ] },
82
87
  }));
@@ -102,7 +107,7 @@ test('a v14 migration is protected against concurrent writeback by normal update
102
107
  lifecycle: 'terminal',
103
108
  status: 'idle',
104
109
  launch: {
105
- extensions: [...CANVAS_EXTENSION_NAMES.map((name) => `/old-generation/pi-extensions/${name}.js`), '/plugins/canvas-inbox-watcher.js'],
110
+ extensions: [...PRE_TOOL_GUIDE_CANVAS_EXTENSION_NAMES.map((name) => `/old-generation/pi-extensions/${name}.js`), '/plugins/canvas-inbox-watcher.js'],
106
111
  },
107
112
  }));
108
113
  ensureHome();
@@ -1,12 +1,8 @@
1
- // The on-read POSITIONAL walk must never treat the crouter home (`~/.crouter`,
2
- // the user scope root) as a project ancestor. Canvas runtime artifacts live at
3
- // `~/.crouter/canvas/nodes/<id>/{reports,context}/...`, so a naive ancestor
4
- // walk from such a read climbs THROUGH `~/.crouter` and would inject whatever
5
- // lives at `~/.crouter/.crouter/memory/` — e.g. a differently-scoped node whose
6
- // cwd resolved its private project store there. That leaks one node's private
7
- // memory into every unrelated node that reads a canvas file. This test pins the
8
- // fence: reading a canvas artifact must not surface a store nested in the
9
- // crouter home.
1
+ // Explicit file-context discovery never treats the crouter home
2
+ // (`~/.crouter`, the user scope root) as a project ancestor. Canvas runtime
3
+ // artifacts live below that root, while a nested `~/.crouter/.crouter/memory/`
4
+ // store can belong to a differently scoped node. The user-root fence keeps its
5
+ // explicitly routed docs out of unrelated canvas-file reads.
10
6
  //
11
7
  // Run: node --import tsx/esm --test src/core/__tests__/on-read-crouter-home-fence.test.ts
12
8
  import { test, before, beforeEach, after } from 'node:test';
@@ -54,7 +50,8 @@ test('a store nested in the crouter home does not leak into a canvas-artifact re
54
50
  mkdirSync(leakedStore, { recursive: true });
55
51
  writeFileSync(join(leakedStore, 'private-thing.md'), '---\nkind: knowledge\n' +
56
52
  "when-and-why-to-read: When X, read this because Y.\n" +
57
- 'file-read-visibility: content\n---\n' +
53
+ 'file-read-visibility: content\n' +
54
+ 'applies-to: "**/*.md"\n---\n' +
58
55
  'PRIVATE ASSISTANT MEMORY MUST NOT LEAK\n');
59
56
  // A canvas runtime artifact under the crouter home — the shape of every node
60
57
  // report/context file the harness auto-reads.