@north-light/crouter 0.3.161 → 0.3.163

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 (130) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/modelauth.d.ts +32 -0
  4. package/dist/api/dto/modelauth.js +2 -1
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-memory/00-runtime-base.md +1 -0
  8. package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
  9. package/dist/builtin-memory/init.md +38 -0
  10. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +88 -396
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +215 -419
  14. package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
  15. package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
  16. package/dist/clients/attach/__tests__/edit-diff.test.js +34 -0
  17. package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
  18. package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
  19. package/dist/clients/attach/render/chat-view.js +8 -3
  20. package/dist/clients/attach/render/crtr-output.js +31 -2
  21. package/dist/clients/attach/render/edit-diff.js +26 -2
  22. package/dist/clients/attach/render/tool-calls.d.ts +7 -2
  23. package/dist/clients/attach/render/tool-calls.js +25 -4
  24. package/dist/clients/attach/session/connection.d.ts +1 -1
  25. package/dist/clients/attach/session/editor-frame.d.ts +5 -1
  26. package/dist/clients/attach/session/editor-frame.js +21 -15
  27. package/dist/clients/attach/session/identity.d.ts +1 -1
  28. package/dist/clients/attach/viewer.js +441 -439
  29. package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
  30. package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
  31. package/dist/commands/__tests__/human.test.js +2 -0
  32. package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
  33. package/dist/commands/__tests__/search-contents.test.js +52 -0
  34. package/dist/commands/api-client.js +1 -0
  35. package/dist/commands/human/prompts.js +2 -2
  36. package/dist/commands/human/shared.d.ts +1 -0
  37. package/dist/commands/human/shared.js +7 -4
  38. package/dist/commands/memory/lint.d.ts +6 -0
  39. package/dist/commands/memory/lint.js +177 -35
  40. package/dist/commands/memory/read.js +22 -1
  41. package/dist/commands/memory/write.js +18 -4
  42. package/dist/commands/memory.js +1 -1
  43. package/dist/commands/search/contents.js +2 -2
  44. package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
  45. package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
  46. package/dist/commands/sys/setup-wizard.d.ts +8 -1
  47. package/dist/commands/sys/setup-wizard.js +209 -58
  48. package/dist/commands/sys/sync-deps.js +14 -18
  49. package/dist/commands/sys/sync-project-guidance.js +16 -19
  50. package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
  51. package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
  52. package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
  53. package/dist/core/__tests__/migration.test.js +8 -3
  54. package/dist/core/__tests__/model-pin-durability.test.js +7 -5
  55. package/dist/core/__tests__/model-routes.test.d.ts +1 -0
  56. package/dist/core/__tests__/model-routes.test.js +41 -0
  57. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
  58. package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
  59. package/dist/core/__tests__/on-read-identity.test.js +8 -15
  60. package/dist/core/__tests__/review-model-floor.test.js +2 -2
  61. package/dist/core/__tests__/revive.test.js +2 -2
  62. package/dist/core/__tests__/tmux-surface.test.js +10 -4
  63. package/dist/core/__tests__/worktree.test.js +3 -3
  64. package/dist/core/auth-file.d.ts +3 -2
  65. package/dist/core/auth-file.js +5 -3
  66. package/dist/core/canvas/extensions.d.ts +1 -1
  67. package/dist/core/canvas/extensions.js +18 -11
  68. package/dist/core/canvas/labels.d.ts +4 -5
  69. package/dist/core/canvas/labels.js +9 -9
  70. package/dist/core/canvas/types.d.ts +6 -1
  71. package/dist/core/command-manifests/schema.js +18 -5
  72. package/dist/core/command.js +17 -7
  73. package/dist/core/config.d.ts +3 -1
  74. package/dist/core/config.js +88 -2
  75. package/dist/core/configured-clis/invoker.js +2 -0
  76. package/dist/core/help.d.ts +3 -1
  77. package/dist/core/help.js +5 -2
  78. package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
  79. package/dist/core/keybindings/catalog.d.ts +3 -2
  80. package/dist/core/keybindings/catalog.js +34 -25
  81. package/dist/core/keybindings/index.d.ts +1 -1
  82. package/dist/core/keybindings/resolve.js +8 -0
  83. package/dist/core/keybindings/types.d.ts +6 -1
  84. package/dist/core/memory/doc-link-grammar.d.ts +20 -0
  85. package/dist/core/memory/doc-link-grammar.js +110 -0
  86. package/dist/core/memory-resolver.d.ts +5 -0
  87. package/dist/core/memory-resolver.js +12 -1
  88. package/dist/core/model-routes.d.ts +37 -0
  89. package/dist/core/model-routes.js +140 -0
  90. package/dist/core/runtime/bearings.d.ts +8 -8
  91. package/dist/core/runtime/bearings.js +20 -16
  92. package/dist/core/runtime/broker.d.ts +0 -4
  93. package/dist/core/runtime/broker.js +119 -385
  94. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  95. package/dist/core/runtime/canvas-extensions.js +2 -0
  96. package/dist/core/runtime/launch.d.ts +10 -12
  97. package/dist/core/runtime/launch.js +16 -22
  98. package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
  99. package/dist/core/runtime/managed-provider-cooling.js +0 -19
  100. package/dist/core/runtime/model-swap.js +7 -1
  101. package/dist/core/runtime/promote.js +1 -0
  102. package/dist/core/runtime/tmux.js +136 -100
  103. package/dist/core/scope.js +4 -5
  104. package/dist/core/subscription-state.d.ts +12 -36
  105. package/dist/core/subscription-state.js +19 -203
  106. package/dist/core/substrate/ceiling.d.ts +3 -6
  107. package/dist/core/substrate/ceiling.js +13 -15
  108. package/dist/core/substrate/index.d.ts +2 -3
  109. package/dist/core/substrate/index.js +2 -2
  110. package/dist/core/substrate/on-read.d.ts +4 -13
  111. package/dist/core/substrate/on-read.js +142 -262
  112. package/dist/core/substrate/render.js +4 -4
  113. package/dist/core/substrate/schema.d.ts +3 -2
  114. package/dist/core/substrate/schema.js +5 -5
  115. package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
  116. package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
  117. package/dist/daemon/api/handlers/modelauth.js +51 -2
  118. package/dist/daemon/api/handlers/nodes.js +2 -0
  119. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
  120. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
  121. package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
  122. package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
  123. package/dist/pi-extensions/canvas-tool-guide.js +74 -0
  124. package/dist/types.d.ts +17 -0
  125. package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
  126. package/dist/web-client/index.html +1 -1
  127. package/dist/web-client/sw.js +1 -1
  128. package/package.json +1 -1
  129. package/runtime.lock.json +2 -2
  130. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
@@ -18,7 +18,7 @@
18
18
  //
19
19
  // These run offline (no network/auth): registration + system-prompt assembly +
20
20
  // dialog routing are all local. No tmux, no broker process — direct unit drives.
21
- import { test } from 'node:test';
21
+ import { after, before, test } from 'node:test';
22
22
  import assert from 'node:assert/strict';
23
23
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
24
24
  import { spawnSync } from 'node:child_process';
@@ -27,7 +27,6 @@ import { join, dirname } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
29
29
  import { buildBrokerSession, makeBrokerUiContext, isLeadingEngineCommand, chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, isHiddenJsonlBranchEntry, stripHiddenEntriesFromJsonlExport, engineLeadingCommandTokens, } from '../runtime/broker.js';
30
- import { readManagedProviderRotationPolicy } from '../runtime/managed-provider-cooling.js';
31
30
  import { CANVAS_EXTENSIONS } from '../runtime/launch.js';
32
31
  import { buildRefInventory } from '../memory/inline-ref-inventory.js';
33
32
  import { buildGuidance } from '../memory/inline-ref-guidance.js';
@@ -45,6 +44,32 @@ const realEngine = {
45
44
  const HERE = dirname(fileURLToPath(import.meta.url));
46
45
  const C3_EXT = join(HERE, 'fixtures', 'c3-custom-provider-ext.ts');
47
46
  const C5_EXT = join(HERE, 'fixtures', 'c5-command-boundary-ext.ts');
47
+ const priorModelIntent = process.env['CRTR_MODEL_INTENT'];
48
+ const priorNodeCwd = process.env['CRTR_NODE_CWD'];
49
+ const priorProfileId = process.env['CRTR_PROFILE_ID'];
50
+ before(() => {
51
+ // buildBrokerSession normally receives its environment through buildBrokerEnv,
52
+ // which strips the launcher's CRTR_* values before applying the target recipe.
53
+ // These direct SDK tests bypass that boundary, so clear the ambient node's
54
+ // routing scope and intent to exercise only each fixture's explicit config.
55
+ delete process.env['CRTR_MODEL_INTENT'];
56
+ delete process.env['CRTR_NODE_CWD'];
57
+ delete process.env['CRTR_PROFILE_ID'];
58
+ });
59
+ after(() => {
60
+ if (priorModelIntent === undefined)
61
+ delete process.env['CRTR_MODEL_INTENT'];
62
+ else
63
+ process.env['CRTR_MODEL_INTENT'] = priorModelIntent;
64
+ if (priorNodeCwd === undefined)
65
+ delete process.env['CRTR_NODE_CWD'];
66
+ else
67
+ process.env['CRTR_NODE_CWD'] = priorNodeCwd;
68
+ if (priorProfileId === undefined)
69
+ delete process.env['CRTR_PROFILE_ID'];
70
+ else
71
+ process.env['CRTR_PROFILE_ID'] = priorProfileId;
72
+ });
48
73
  function brokerExts() {
49
74
  return [...CANVAS_EXTENSIONS, C3_EXT];
50
75
  }
@@ -138,7 +163,7 @@ function runIsolatedManagedLaunch(pool) {
138
163
  const script = `
139
164
  const { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION } = await import(${JSON.stringify(new URL('../runtime/broker-sdk.ts', import.meta.url).href)});
140
165
  const { buildBrokerSession } = await import(${JSON.stringify(new URL('../runtime/broker.ts', import.meta.url).href)});
141
- const { writeSubscriptionPool, readRotationConfig } = await import(${JSON.stringify(new URL('../subscription-state.ts', import.meta.url).href)});
166
+ const { writeSubscriptionPool } = await import(${JSON.stringify(new URL('../subscription-state.ts', import.meta.url).href)});
142
167
  await writeSubscriptionPool('anthropic', ${JSON.stringify(pool)});
143
168
  const realEngine = { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION };
144
169
  const engine = {
@@ -160,7 +185,7 @@ function runIsolatedManagedLaunch(pool) {
160
185
  },
161
186
  };
162
187
  const { session } = await buildBrokerSession(engine, { cwd: ${JSON.stringify(cwd)}, extensionPaths: [${JSON.stringify(C3_EXT)}], model: 'anthropic/cooling-model' });
163
- console.log(JSON.stringify({ provider: session.model?.provider, id: session.model?.id, preferred: readRotationConfig().preferredModel }));
188
+ console.log(JSON.stringify({ provider: session.model?.provider, id: session.model?.id }));
164
189
  session.dispose();
165
190
  `;
166
191
  try {
@@ -177,8 +202,23 @@ function runIsolatedManagedLaunch(pool) {
177
202
  rmSync(cwd, { recursive: true, force: true });
178
203
  }
179
204
  }
180
- test('C3f — broker launch falls back to a non-managed provider when no managed provider is authenticated', async () => {
205
+ test('C3f — a logical request can reach a non-managed raw-registry fallback when no managed provider is authenticated', async () => {
181
206
  const cwd = mkdtempSync(join(tmpdir(), 'crtr-c3c-'));
207
+ const priorIntent = process.env['CRTR_MODEL_INTENT'];
208
+ const priorNodeCwd = process.env['CRTR_NODE_CWD'];
209
+ process.env['CRTR_MODEL_INTENT'] = 'custom/strong';
210
+ process.env['CRTR_NODE_CWD'] = cwd;
211
+ mkdirSync(join(cwd, '.crouter'), { recursive: true });
212
+ writeFileSync(join(cwd, '.crouter', 'config.json'), `${JSON.stringify({
213
+ modelRoutes: {
214
+ c3: {
215
+ family: 'custom',
216
+ credentialSource: 'user',
217
+ providerId: 'c3prov',
218
+ models: { strong: 'c3prov/c3model' },
219
+ },
220
+ },
221
+ })}\n`);
182
222
  try {
183
223
  const deterministicEngine = {
184
224
  ...realEngine,
@@ -209,6 +249,14 @@ test('C3f — broker launch falls back to a non-managed provider when no managed
209
249
  }
210
250
  }
211
251
  finally {
252
+ if (priorIntent === undefined)
253
+ delete process.env['CRTR_MODEL_INTENT'];
254
+ else
255
+ process.env['CRTR_MODEL_INTENT'] = priorIntent;
256
+ if (priorNodeCwd === undefined)
257
+ delete process.env['CRTR_NODE_CWD'];
258
+ else
259
+ process.env['CRTR_NODE_CWD'] = priorNodeCwd;
212
260
  rmSync(cwd, { recursive: true, force: true });
213
261
  }
214
262
  });
@@ -218,7 +266,6 @@ test('C3g — an explicit managed subscription pool authenticates a registered r
218
266
  ]);
219
267
  assert.equal(selected.provider, 'anthropic');
220
268
  assert.equal(selected.id, 'cooling-model');
221
- assert.equal(selected.preferred, undefined);
222
269
  });
223
270
  test('C3h — a cooling managed launch target boots model-less rather than falling back to a non-managed provider', () => {
224
271
  // The managed anthropic target is registered + pool-authenticated but COOLING
@@ -233,7 +280,6 @@ test('C3h — a cooling managed launch target boots model-less rather than falli
233
280
  ]);
234
281
  assert.equal(selected.provider, 'unknown');
235
282
  assert.equal(selected.id, 'unknown');
236
- assert.equal(selected.preferred, undefined);
237
283
  });
238
284
  // (C4 — project guidance now comes from the document substrate: crouter keeps
239
285
  // `noContextFiles: true` in buildBrokerSession, and buildContextIntro's
@@ -271,24 +317,6 @@ test('C2 — zero-writable UI context resolves dialogs to deny/cancel immediatel
271
317
  assert.equal(await ctx.input('name'), undefined, 'C2: input() cancels (undefined)');
272
318
  assert.equal(await ctx.editor('edit'), undefined, 'C2: editor() cancels (undefined)');
273
319
  });
274
- test('broker UI exposes the live provider pin policy to package extensions', () => {
275
- let pinned = false;
276
- const ctx = makeBrokerUiContext({
277
- writable: () => [],
278
- forward: () => { },
279
- pending: new Map(),
280
- broadcast: () => { },
281
- providerRotationPolicy: () => pinned
282
- ? { allowCrossProviderFallback: false, reason: 'provider-pinned' }
283
- : { allowCrossProviderFallback: true },
284
- });
285
- assert.deepEqual(readManagedProviderRotationPolicy(ctx), { allowCrossProviderFallback: true });
286
- pinned = true;
287
- assert.deepEqual(readManagedProviderRotationPolicy(ctx), {
288
- allowCrossProviderFallback: false,
289
- reason: 'provider-pinned',
290
- }, 'the same running UI context observes a live set_model pin change');
291
- });
292
320
  // ===========================================================================
293
321
  // M2 (review mq5wkqep / T4) — REPLACES the Wave-0 M-1 cancel-on-detach. A dialog
294
322
  // forwarded to a writable viewer that then DETACHES must NOT be cancelled: it
@@ -5,14 +5,13 @@
5
5
  // exercises runBroker's live retry handler rather than its helpers in isolation.
6
6
  import { test, before, after, afterEach } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { createHeadlessHarness } from '../helpers/harness.js';
11
11
  import { createAttachKit } from '../helpers/broker-clients.js';
12
12
  import { defaultModelLaddersConfig } from '../../../types.js';
13
13
  import { readFault } from '../../runtime/fault.js';
14
14
  import { normalizeModel } from '../../runtime/launch.js';
15
- import { setModelLive } from '../../runtime/model-swap.js';
16
15
  const MODEL_LADDERS = defaultModelLaddersConfig();
17
16
  function resolvedModel(spec) {
18
17
  const normalized = normalizeModel(spec, MODEL_LADDERS);
@@ -21,9 +20,8 @@ function resolvedModel(spec) {
21
20
  assert.notEqual(slash, -1, `${spec} must normalize to a qualified provider/model spec`);
22
21
  const provider = modelSpec.slice(0, slash);
23
22
  const id = modelSpec.slice(slash + 1);
24
- return { spec: normalized, model: { provider, id }, pin: { providerId: provider, modelId: id } };
23
+ return { spec: normalized, model: { provider, id } };
25
24
  }
26
- const ANTHROPIC_STRONG = resolvedModel('anthropic/strong');
27
25
  const OPENAI_STRONG = resolvedModel('openai/strong');
28
26
  let h;
29
27
  const kit = createAttachKit(() => h);
@@ -39,20 +37,6 @@ function seedPool(provider, cooling) {
39
37
  rateLimitedUntil: cooling ? Date.now() + 60_000 : 0, lastAttemptAt: 0, lastRateLimitedAt: 0,
40
38
  }]));
41
39
  }
42
- function rotationPath() {
43
- return join(h.agentHome, '.pi', 'agent', 'provider-rotation.json');
44
- }
45
- function clearRotation() {
46
- rmSync(rotationPath(), { force: true });
47
- }
48
- function preferred() {
49
- try {
50
- return JSON.parse(readFileSync(rotationPath(), 'utf8')).preferredModel;
51
- }
52
- catch {
53
- return undefined;
54
- }
55
- }
56
40
  function model(id) {
57
41
  return JSON.parse(readFileSync(join(h.home, 'nodes', id, 'fake-pi.model.json'), 'utf8'));
58
42
  }
@@ -76,28 +60,6 @@ function agentStartCount(id) {
76
60
  return 0;
77
61
  }
78
62
  }
79
- async function autoRetry(id, expected) {
80
- h.fakeCmd(id, { cmd: 'auto_retry', errorMessage: 'rate limit exceeded' });
81
- await h.waitFor(() => {
82
- try {
83
- return assert.deepEqual(model(id), expected) === undefined ? true : false;
84
- }
85
- catch {
86
- return false;
87
- }
88
- }, { label: `auto-retry switch for ${id}` });
89
- }
90
- // The auto-retry receipt file's timestamp, so re-firing auto_retry a second/
91
- // third time in one test doesn't false-positive on a STALE receipt that
92
- // already existed from an earlier fire in the same test.
93
- function autoRetryAt(id) {
94
- try {
95
- return JSON.parse(readFileSync(join(h.home, 'nodes', id, 'fake-pi.auto-retry.json'), 'utf8')).at;
96
- }
97
- catch {
98
- return 0;
99
- }
100
- }
101
63
  before(async () => {
102
64
  h = await createHeadlessHarness({ sessionPrefix: 'crtr-provider-retry' });
103
65
  });
@@ -108,61 +70,12 @@ after(async () => {
108
70
  afterEach(() => {
109
71
  kit.closeAll();
110
72
  });
111
- test('broker auto-retry preserves the launch requested pin, clears it on restoration, and never persists the temporary fallback', { timeout: 30_000 }, async () => {
112
- const parent = h.spawnRoot('provider retry parent');
113
- seedPool('anthropic', true);
114
- seedPool('openai-codex', false);
115
- clearRotation();
116
- const child = await h.spawnHeadlessChild(parent, 'launch fallback then retry', { model: ANTHROPIC_STRONG.spec });
117
- assert.deepEqual(model(child), OPENAI_STRONG.model, 'a cooling requested managed launch starts its viable equivalent');
118
- assert.deepEqual(preferred(), ANTHROPIC_STRONG.pin, 'launch retains the requested managed model as its restoration pin');
119
- const before = h.node(child);
120
- seedPool('anthropic', false);
121
- await autoRetry(child, ANTHROPIC_STRONG.model);
122
- await h.waitFor(() => preferred() === undefined, { label: 'launch preferred pin cleared after restoration' });
123
- assert.deepEqual(preferred(), undefined, 'reaching the launch-requested provider fulfils and clears its temporary pin');
124
- const after = h.node(child);
125
- assert.equal(after.model_override, before.model_override, 'turn-time fallback never overwrites the durable model override');
126
- assert.equal(after.launch?.model, before.launch?.model, 'turn-time fallback never overwrites the durable launch recipe');
127
- });
128
- test('broker auto-retry records its current managed model only when no restoration pin exists', { timeout: 30_000 }, async () => {
129
- const parent = h.spawnRoot('normal retry parent');
130
- seedPool('anthropic', false);
131
- seedPool('openai-codex', false);
132
- clearRotation();
133
- // Establish the exact qualified OpenAI ladder cell, then explicitly release
134
- // its structural pin so this test reaches the retry resolver by construction.
135
- const child = await h.spawnHeadlessChild(parent, 'normal retry', { model: OPENAI_STRONG.spec });
136
- await setModelLive(child, OPENAI_STRONG.spec, false);
137
- clearRotation();
138
- const before = h.node(child);
139
- assert.deepEqual(model(child), OPENAI_STRONG.model);
140
- await autoRetry(child, ANTHROPIC_STRONG.model);
141
- await h.waitFor(() => {
142
- try {
143
- assert.deepEqual(preferred(), OPENAI_STRONG.pin);
144
- return true;
145
- }
146
- catch {
147
- return false;
148
- }
149
- }, { label: 'normal retry preferred model recorded' });
150
- assert.deepEqual(preferred(), OPENAI_STRONG.pin, 'a normal automatic switch records its failed managed model for later restoration');
151
- const after = h.node(child);
152
- assert.equal(after.model_override, before.model_override);
153
- assert.equal(after.launch?.model, before.launch?.model);
154
- });
155
73
  test('a managed-cooling terminal authors a rate-limit auto fault whose deadline drives exactly one broker re-drive', { timeout: 30_000 }, async () => {
156
74
  const parent = h.spawnRoot('cooling terminal parent');
157
75
  // The terminal fault path is pool-independent — the stophook classifies purely
158
76
  // off the errorMessage text + the cooling diagnostic, so pool state is inert.
159
- // A qualified openai-codex id now pins structurally, but that has no effect
160
- // here either way: this test never triggers retry/not-found policy (only the
161
- // stophook fault path + launch-time pinnedToFirst, which is a no-op because
162
- // the pool isn't cooling at spawn).
163
77
  seedPool('anthropic', false);
164
78
  seedPool('openai-codex', false);
165
- clearRotation();
166
79
  const child = await h.spawnHeadlessChild(parent, 'cooling terminal', { model: OPENAI_STRONG.spec });
167
80
  await h.waitFor(() => agentStartCount(child) >= 1, { label: 'initial cooling fixture turn started' });
168
81
  const baselineStarts = agentStartCount(child);
@@ -332,47 +245,18 @@ test('raw overflow settling without compaction terminates instead of hanging', {
332
245
  await h.tick();
333
246
  assert.equal(h.status(child), 'dead', 'settled raw overflow cannot leave the child hanging');
334
247
  });
335
- test("a live set_model decision updates the running broker's pin policy immediately, in both directions, without a revive", { timeout: 30_000 }, async () => {
336
- const parent = h.spawnRoot('live pin policy parent');
337
- seedPool('anthropic', false);
338
- seedPool('openai-codex', false);
339
- clearRotation();
340
- // Establish the desired qualified Anthropic ladder cell while explicitly
341
- // keeping its live retry policy unpinned, independent of ambient defaults.
342
- const child = await h.spawnHeadlessChild(parent, 'live pin policy', { model: ANTHROPIC_STRONG.spec });
343
- await setModelLive(child, ANTHROPIC_STRONG.spec, false);
344
- clearRotation();
345
- assert.deepEqual(model(child), ANTHROPIC_STRONG.model, 'explicit setup selects the current Anthropic strong model');
346
- // false → true, LIVE: an explicit raw qualified re-pick pins the SAME
347
- // resolved model — no model value changes, only the live pin decision. The
348
- // running broker's retry-fallback policy (broker.ts's cfg.modelProviderPinned
349
- // read) enforces it on the VERY NEXT retry, with no revive in between: the
350
- // auto_retry below is suppressed rather than allowed to rotate the model.
351
- await setModelLive(child, ANTHROPIC_STRONG.spec, true);
352
- assert.deepEqual(model(child), ANTHROPIC_STRONG.model, 'the explicit re-pick keeps the same resolved model');
353
- const priorAt1 = autoRetryAt(child);
354
- h.fakeCmd(child, { cmd: 'auto_retry', errorMessage: 'rate limit exceeded' });
355
- await h.waitFor(() => autoRetryAt(child) > priorAt1, { label: 'pinned auto-retry processed' });
356
- // Settle past the fallback's own async continuation window.
357
- await new Promise((resolve) => setTimeout(resolve, 500));
358
- assert.deepEqual(model(child), ANTHROPIC_STRONG.model, 'a live pin suppresses turn-time provider rotation immediately, without revive');
359
- assert.deepEqual(preferred(), undefined, 'a suppressed retry records no restoration pin');
360
- // true → false, LIVE: an explicit re-pick of the same qualified model with
361
- // pinned=false releases the pin. The next retry must now be free to rotate.
362
- await setModelLive(child, ANTHROPIC_STRONG.spec, false);
363
- await autoRetry(child, OPENAI_STRONG.model);
364
- assert.deepEqual(preferred(), ANTHROPIC_STRONG.pin, 'the released pin lets the next retry rotate and record a restoration pin');
365
- });
366
- test('a picker-originated set_model frame with no pinned field derives the pin from the real registry provider namespace; an internal no-raw-token path preserves it', { timeout: 30_000 }, async () => {
248
+ test('an explicit picker choice pins and clears prior logical routing; an internal no-raw-token path preserves that decision', { timeout: 30_000 }, async () => {
367
249
  const parent = h.spawnRoot('picker frame parent');
368
250
  seedPool('anthropic', false);
369
251
  seedPool('openai-codex', false);
370
- const child = await h.spawnHeadlessChild(parent, 'picker frame child', { model: 'strong' }); // unpinned boot
252
+ const child = await h.spawnHeadlessChild(parent, 'picker frame child', { model: 'openai/strong' }); // logical route boot
371
253
  const ctrl = await kit.attach(child, 'controller', 'picker-test');
372
254
  ctrl.send({ type: 'set_model', model: OPENAI_STRONG.spec }); // exactly what buildModelPicker sends — no `pinned`
373
255
  await ctrl.waitFrame((f) => f.type === 'ack' && f.for === 'set_model', 'picker set_model acked');
374
256
  let after = h.node(child);
375
257
  assert.equal(after.launch?.modelProviderPinned, true, 'the omitted-pinned frame derives pinned from the real openai-codex registry namespace');
258
+ assert.equal(after.launch?.modelIntent, undefined, 'the explicit concrete choice clears the logical launch intent');
259
+ assert.equal(after.launch?.env.CRTR_MODEL_INTENT, undefined, 'a revive cannot restore the stale logical route intent');
376
260
  // `cycle_ladder` — an internal no-raw-token path (no `model`/`pinned` on the
377
261
  // frame at all) — reuses the SAME resolve+setModel machinery `set_model` does
378
262
  // (real `nextLadderModel`/`resolveModelQuery`, the fake engine's real
@@ -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();
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // `modelProviderPinned` is durable node state with ONE derivation rule: it is
4
4
  // decided exactly once, at an explicit user model change, from that change's
5
- // RAW pre-normalize token — a qualified `<provider>/<model-id>` or
6
- // `<provider>/<tier>` token pins; a bare tier (`ultra`) or bare family alias
5
+ // RAW pre-normalize token — a concrete `<provider>/<model-id>` token pins;
6
+ // logical `<family>/<tier>` refs, bare tiers (`ultra`), and family aliases
7
7
  // (`opus`/`sonnet`/`haiku`) unpins. Every other rebuild or persist (a
8
8
  // polymorph/lifecycle change with no new `--model`, a dormant model write whose
9
9
  // spec happens to normalize to a concrete cell, cycle_model/cycle_ladder,
@@ -77,11 +77,11 @@ test('fixture: a bare tier is unpinned; its normalized concrete cell reads as pi
77
77
  assert.equal(isProviderPinnedModelToken('ultra'), false, 'a bare tier is unpinned');
78
78
  assert.equal(isProviderPinnedModelToken(ULTRA_CELL), true, 'a qualified concrete cell reads as pinned');
79
79
  });
80
- test('a qualified provider/id or provider/tier token pins structurally, across real registry provider namespaces', () => {
80
+ test('only concrete provider/id tokens pin; logical family/tier refs remain preferences', () => {
81
81
  assert.equal(isProviderPinnedModelToken('anthropic/claude-fable-5:high'), true, 'a concrete anthropic id pins');
82
82
  assert.equal(isProviderPinnedModelToken('openai-codex/gpt-5.6-sol:max'), true, 'the real openai-codex registry namespace pins, not just the family alias `openai`');
83
83
  assert.equal(isProviderPinnedModelToken('amazon-bedrock/some-model-id'), true, 'any qualified provider/id pins — no undocumented exception for a provider outside the three built-in families');
84
- assert.equal(isProviderPinnedModelToken('openai/ultra'), true, 'a qualified provider/tier pins too');
84
+ assert.equal(isProviderPinnedModelToken('openai/ultra'), false, 'a qualified family/tier ref is logical and unpinned');
85
85
  assert.equal(isProviderPinnedModelToken('ultra'), false, 'a bare tier has no `/` and stays unpinned');
86
86
  assert.equal(isProviderPinnedModelToken('opus'), false, 'a bare family alias has no `/` and stays unpinned');
87
87
  });
@@ -114,11 +114,13 @@ test('a deliberately tier-run node is not wrongly re-pinned when a rebuild or pe
114
114
  // the concrete spec ULTRA_CELL normalizes to.
115
115
  createNode(node('tier-node', {
116
116
  model_override: ULTRA_CELL,
117
- launch: { extensions: [], env: {}, model: ULTRA_CELL, modelProviderPinned: true },
117
+ launch: { extensions: [], env: { CRTR_MODEL_INTENT: 'openai/ultra' }, model: ULTRA_CELL, modelIntent: { family: 'openai', strength: 'ultra' }, modelProviderPinned: true },
118
118
  }));
119
119
  persistDormantModel('tier-node', ULTRA_CELL, false);
120
120
  const after = getNode('tier-node');
121
121
  assert.equal(after?.launch?.model, ULTRA_CELL, 'the concrete spec is persisted as the launch model');
122
122
  assert.equal(after?.model_override, ULTRA_CELL, 'the concrete spec is persisted as the override');
123
+ assert.equal(after?.launch?.modelIntent, undefined, 'an explicit concrete choice clears the prior logical route intent');
124
+ assert.equal(after?.launch?.env.CRTR_MODEL_INTENT, undefined, 'the revived broker does not inherit the prior logical route intent');
123
125
  assert.equal(after?.launch?.modelProviderPinned, false, 'an explicit bare-tier decision clears a prior pin — the raw-token decision wins over the concrete spec shape');
124
126
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import { after, before, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { expandModelCandidates } from '../model-routes.js';
7
+ const previousHome = process.env.HOME;
8
+ let home = '';
9
+ before(() => {
10
+ home = mkdtempSync(join(tmpdir(), 'crtr-model-routes-'));
11
+ process.env.HOME = home;
12
+ mkdirSync(join(home, '.crouter'), { recursive: true });
13
+ writeFileSync(join(home, '.crouter', 'config.json'), JSON.stringify({
14
+ modelRoutes: {
15
+ 'anthropic-user': { family: 'anthropic', credentialSource: 'user', providerId: 'anthropic', models: { strong: 'anthropic/a-strong', medium: 'anthropic/a-medium', light: 'anthropic/a-light' } },
16
+ 'openai-user': { family: 'openai', credentialSource: 'user', providerId: 'openai-codex', models: { strong: 'openai-codex/o-strong', medium: 'openai-codex/o-medium', light: 'openai-codex/o-light' } },
17
+ 'anthropic-funded': { family: 'anthropic', credentialSource: 'northlight', providerId: 'funded-anthropic', models: { strong: 'funded-anthropic/a-strong', medium: 'funded-anthropic/a-medium', light: 'funded-anthropic/a-light' } },
18
+ 'openai-funded': { family: 'openai', credentialSource: 'northlight', providerId: 'funded-openai', models: { strong: 'funded-openai/o-strong', medium: 'funded-openai/o-medium', light: 'funded-openai/o-light' } },
19
+ },
20
+ modelRouting: { credentialSourceOrder: ['user', 'northlight'], familyOrder: ['anthropic', 'openai'], strengthFallback: ['medium', 'light'] },
21
+ }));
22
+ });
23
+ after(() => {
24
+ if (previousHome === undefined)
25
+ delete process.env.HOME;
26
+ else
27
+ process.env.HOME = previousHome;
28
+ rmSync(home, { recursive: true, force: true });
29
+ });
30
+ test('declared routes expand credential source, strength, then family preference', () => {
31
+ const candidates = expandModelCandidates({ kind: 'logical', family: 'openai', strength: 'strong', spec: 'openai-codex/o-strong' }, home, null);
32
+ assert.deepEqual(candidates.map((candidate) => `${candidate.credentialSource}/${candidate.family}/${candidate.strength}`), [
33
+ 'user/openai/strong', 'user/anthropic/strong', 'user/openai/medium', 'user/anthropic/medium', 'user/openai/light', 'user/anthropic/light',
34
+ 'northlight/openai/strong', 'northlight/anthropic/strong', 'northlight/openai/medium', 'northlight/anthropic/medium', 'northlight/openai/light', 'northlight/anthropic/light',
35
+ ]);
36
+ assert.deepEqual(candidates.map((candidate) => candidate.rank), [...Array(12).keys()]);
37
+ });
38
+ test('concrete routes retain provider/model first and only move to same-model credential sources', () => {
39
+ const candidates = expandModelCandidates({ kind: 'concrete', spec: 'anthropic/a-strong' }, home, null);
40
+ assert.deepEqual(candidates.map((candidate) => `${candidate.providerId}/${candidate.modelId}`), ['anthropic/a-strong', 'funded-anthropic/a-strong']);
41
+ });
@@ -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.
@@ -1,17 +1,6 @@
1
- // Bug-regression: on-read doc injection "fires a second time per session".
2
- //
3
- // The on-read substrate hook dedups so a doc surfaces at most once per
4
- // conversation. That dedup set USED to live only in the pi process heap (cleared
5
- // on session_start). But a node's logical session — the .jsonl transcript —
6
- // spans MULTIPLE pi processes: a dormancy → revive(resume) cycle exits the old
7
- // process and launches a fresh `pi --session` that REUSES the same transcript.
8
- // The fresh process started with an empty set, so any doc already injected
9
- // before dormancy got injected AGAIN on the next read.
10
- //
11
- // The fix persists the set to nodes/<id>/injected-docs.json: a resume rehydrates
12
- // it (dedup holds), and only the FRESH-transcript launch paths clear it. These
13
- // tests lock in both halves: the store round-trip, and the dedup surviving a
14
- // simulated revive(resume) while re-surfacing after a fresh launch.
1
+ // On-read context surfaces at most once per conversation. The dedup set lives
2
+ // at nodes/<id>/injected-docs.json so a process resumed onto the same transcript
3
+ // rehydrates it, while a fresh transcript clears it.
15
4
  //
16
5
  // Run: node --import tsx/esm --test src/core/__tests__/on-read-dedup-resume.test.ts
17
6
  import { test, before, beforeEach, after } from 'node:test';
@@ -64,7 +53,7 @@ test('injected-store round-trips and clears', () => {
64
53
  assert.ok(!existsSync(injectedDocsPath(node)), 'clear removes the file');
65
54
  assert.deepEqual([...loadInjectedDocs(node)], [], 'load after clear is empty');
66
55
  });
67
- test('user-global memory docs do not fire positionally for every file under home', () => {
56
+ test('project and user memory docs without applies-to do not fire on file reads', () => {
68
57
  const fakeHome = mkdtempSync(join(tmpdir(), 'crtr-onread-user-home-'));
69
58
  process.env['HOME'] = fakeHome;
70
59
  resetScopeCache();
@@ -72,33 +61,32 @@ test('user-global memory docs do not fire positionally for every file under home
72
61
  const userMemDir = join(fakeHome, '.crouter', 'memory');
73
62
  mkdirSync(userMemDir, { recursive: true });
74
63
  writeFileSync(join(userMemDir, 'noisy-global.md'), '---\nkind: preference\n' +
75
- 'when-and-why-to-read: When reading any file, this preference should be read because this fixture must not fire positionally\n' +
64
+ 'when-and-why-to-read: When reading any file, this preference should be read because this fixture must not fire without an explicit route\n' +
76
65
  'file-read-visibility: content\n---\n' +
77
66
  'NOISY USER BODY\n');
78
67
  const projectMemDir = join(fakeHome, 'work', '.crouter', 'memory');
79
68
  mkdirSync(projectMemDir, { recursive: true });
80
69
  writeFileSync(join(projectMemDir, 'local.md'), '---\nkind: knowledge\n' +
81
- 'when-and-why-to-read: When reading project files, this reference should be read because local project memories remain positional\n' +
70
+ 'when-and-why-to-read: When reading project files, this reference should be read because this fixture proves explicit file routing\n' +
82
71
  'file-read-visibility: content\n---\n' +
83
72
  'LOCAL PROJECT BODY\n');
84
73
  const readFile = join(fakeHome, 'work', 'ideas.md');
85
74
  writeFileSync(readFile, 'turn off xml closer\n');
86
75
  const rendered = renderOnReadDocs(node, readFile, new Set());
87
- assert.ok(!rendered.includes('NOISY USER BODY'), 'user-global doc is not positional');
88
- assert.ok(rendered.includes('LOCAL PROJECT BODY'), 'project doc still fires positionally');
89
- assert.ok(rendered.includes('<memory kind="knowledge" name="local"'), 'memory docs render as memory tags');
90
- assert.ok(!rendered.includes('triggered-by='), 'read target is not repeated on child items');
91
- assert.ok(!rendered.includes('<auto-loaded-context file='), 'read target is not repeated on the wrapper');
76
+ assert.ok(!rendered.includes('NOISY USER BODY'), 'user-global doc has no explicit file route');
77
+ assert.ok(!rendered.includes('LOCAL PROJECT BODY'), 'project doc also has no implicit file route');
78
+ assert.equal(rendered, '', 'no file context renders without an explicit trigger');
92
79
  rmSync(fakeHome, { recursive: true, force: true });
93
80
  });
94
81
  test('on-read doc surfaces once, stays deduped across a revive(resume), re-surfaces after a fresh launch', () => {
95
82
  const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
96
- // A positional substrate doc in a `.crouter/memory/` ancestor of the read file.
83
+ // A file-routed substrate doc in a `.crouter/memory/` ancestor.
97
84
  const memDir = join(work, '.crouter', 'memory');
98
85
  mkdirSync(memDir, { recursive: true });
99
86
  writeFileSync(join(memDir, 'onread-fixture.md'), '---\nkind: knowledge\n' +
100
87
  'when-and-why-to-read: When reading work files, this reference should be read because it is the on-read regression fixture\n' +
101
- 'file-read-visibility: content\n---\n' +
88
+ 'file-read-visibility: content\n' +
89
+ 'applies-to: "src/**"\n---\n' +
102
90
  `${FIXTURE_BODY}\n`);
103
91
  const readFile = join(work, 'src', 'file.ts');
104
92
  mkdirSync(join(work, 'src'), { recursive: true });
@@ -113,7 +101,7 @@ test('on-read doc surfaces once, stays deduped across a revive(resume), re-surfa
113
101
  // --- dormancy → revive(resume): NEW process rehydrates the set from disk ---
114
102
  const seenAfterRevive = loadInjectedDocs(node);
115
103
  assert.deepEqual(seenAfterRevive, seen, 'revive(resume) rehydrates the persisted dedup set');
116
- assert.ok(!renderOnReadDocs(node, readFile, seenAfterRevive).includes(FIXTURE_BODY), 'resumed process does NOT re-inject the doc (the bug)');
104
+ assert.ok(!renderOnReadDocs(node, readFile, seenAfterRevive).includes(FIXTURE_BODY), 'resumed process does not re-inject the doc');
117
105
  // --- fresh launch (revive resume=false, e.g. a refresh-yield) clears the set ---
118
106
  clearInjectedDocs(node);
119
107
  const seenFresh = loadInjectedDocs(node);
@@ -1,13 +1,7 @@
1
- // The on-read POSITIONAL loader resolves a doc's identity with the SAME
1
+ // The explicit file-context loader resolves a doc's identity with the same
2
2
  // explicit-name-then-path-fallback rule the boot resolver uses (schema.ts's
3
3
  // resolveDocName), never deriving `name` from the physical path alone.
4
4
  //
5
- // A migrated AGENTS.md doc (`crtr sys sync project-guidance`'s CLAUDE.md/AGENTS.md conversion)
6
- // is written at `file-read-visibility: content` with an explicit frontmatter
7
- // `name` (the project label, e.g. `crouter`) so sibling project roots do not
8
- // collapse onto the shared `AGENTS` leaf. `loadPositionalDoc` must inject the
9
- // doc under that explicit name, not the path-derived `AGENTS`.
10
- //
11
5
  // Run: node --import tsx/esm --test src/core/__tests__/on-read-identity.test.ts
12
6
  import { test, before, beforeEach, after } from 'node:test';
13
7
  import assert from 'node:assert/strict';
@@ -47,22 +41,21 @@ after(() => {
47
41
  else
48
42
  process.env['HOME'] = prevHomeEnv;
49
43
  });
50
- test('a migrated AGENTS doc injects on-read under its explicit frontmatter name, not the path-derived one', () => {
44
+ test('an explicitly routed doc injects on-read under its frontmatter name, not the path-derived one', () => {
51
45
  const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
52
- // Shape of a `crtr sys sync project-guidance` CLAUDE.md/AGENTS.md migration: physical filename
53
- // stays AGENTS.md, but frontmatter carries the project's explicit identity.
54
46
  const memDir = join(work, '.crouter', 'memory');
55
47
  mkdirSync(memDir, { recursive: true });
56
- writeFileSync(join(memDir, 'AGENTS.md'), '---\nkind: knowledge\n' +
48
+ writeFileSync(join(memDir, 'physical-guide.md'), '---\nkind: knowledge\n' +
57
49
  'name: acme-project\n' +
58
- 'when-and-why-to-read: When working in acme-project, this knowledge should be read because it is the project\'s operating guide.\n' +
59
- 'file-read-visibility: content\n---\n' +
50
+ 'when-and-why-to-read: When reading acme source, this knowledge should be read because it carries the project-specific implementation constraints.\n' +
51
+ 'file-read-visibility: content\n' +
52
+ 'applies-to: "src/**"\n---\n' +
60
53
  'ACME PROJECT OPERATING GUIDE\n');
61
54
  const readFile = join(work, 'src', 'file.ts');
62
55
  mkdirSync(join(work, 'src'), { recursive: true });
63
56
  writeFileSync(readFile, 'export const x = 1;\n');
64
57
  const rendered = renderOnReadDocs(node, readFile, new Set());
65
- assert.ok(rendered.includes('ACME PROJECT OPERATING GUIDE'), 'the migrated doc fires positionally');
58
+ assert.ok(rendered.includes('ACME PROJECT OPERATING GUIDE'), 'the matching explicit route fires');
66
59
  assert.ok(rendered.includes('<memory kind="knowledge" name="acme-project"'), `expected explicit frontmatter name "acme-project" in the injected envelope, got: ${rendered}`);
67
- assert.ok(!rendered.includes('name="AGENTS"'), 'must not fall back to the path-derived name when frontmatter sets one');
60
+ assert.ok(!rendered.includes('name="physical-guide"'), 'must not fall back to the path-derived name when frontmatter sets one');
68
61
  });