@north-light/crouter 0.3.230 → 0.3.232

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 (173) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +4 -0
  3. package/dist/api/dto/canvas.d.ts +10 -0
  4. package/dist/api/dto/common.d.ts +1 -1
  5. package/dist/api/dto/health.d.ts +2 -1
  6. package/dist/api/dto/lifecycle.d.ts +3 -4
  7. package/dist/api/dto/messages.d.ts +5 -4
  8. package/dist/api/dto/nodes.d.ts +2 -0
  9. package/dist/api/dto/profiles.d.ts +5 -0
  10. package/dist/api/routes.d.ts +1 -0
  11. package/dist/api/routes.js +1 -0
  12. package/dist/builtin-memory/00-runtime-base/00-authoring.md +8 -0
  13. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  14. package/dist/builtin-memory/01-spine/00-has-manager.md +1 -1
  15. package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +5 -0
  16. package/dist/builtin-memory/02-turn-lifecycle/02-resident.md +5 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +2 -2
  18. package/dist/builtin-memory/insights/capture.md +3 -2
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +6 -1
  20. package/dist/clients/attach/render/diagram.js +13 -5
  21. package/dist/clients/attach/render/page-block.d.ts +0 -1
  22. package/dist/clients/attach/render/page-block.js +4 -57
  23. package/dist/clients/attach/viewer.js +570 -563
  24. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +9 -0
  25. package/dist/clients/inbox/__tests__/integration/mount-panel.test.js +62 -1
  26. package/dist/clients/inbox/controller.d.ts +10 -0
  27. package/dist/clients/inbox/controller.js +56 -12
  28. package/dist/clients/inbox/tui/input.js +38 -10
  29. package/dist/clients/inbox/tui/page-body.d.ts +10 -0
  30. package/dist/clients/inbox/tui/page-body.js +66 -0
  31. package/dist/clients/inbox/tui/panel.js +6 -4
  32. package/dist/clients/inbox/tui/render.js +89 -17
  33. package/dist/clients/inbox/tui/types.d.ts +4 -4
  34. package/dist/commands/__tests__/human.test.js +18 -3
  35. package/dist/commands/__tests__/node-message.test.js +3 -3
  36. package/dist/commands/api-client.js +1 -7
  37. package/dist/commands/canvas-config.js +6 -14
  38. package/dist/commands/canvas-use.js +4 -6
  39. package/dist/commands/cron.js +16 -22
  40. package/dist/commands/human/prompts.d.ts +1 -1
  41. package/dist/commands/human/prompts.js +118 -112
  42. package/dist/commands/human/request.js +13 -14
  43. package/dist/commands/human/review.js +3 -4
  44. package/dist/commands/human/shared.d.ts +6 -0
  45. package/dist/commands/human/shared.js +43 -5
  46. package/dist/commands/human.js +1 -1
  47. package/dist/commands/memory/delete.js +4 -6
  48. package/dist/commands/memory/edit.js +0 -4
  49. package/dist/commands/memory/move.js +3 -5
  50. package/dist/commands/memory/shared.d.ts +1 -1
  51. package/dist/commands/memory/shared.js +9 -5
  52. package/dist/commands/memory/write.js +60 -29
  53. package/dist/commands/memory.js +1 -1
  54. package/dist/commands/node/bash.js +6 -9
  55. package/dist/commands/node/create.js +89 -22
  56. package/dist/commands/node/inspect.js +3 -3
  57. package/dist/commands/node/lifecycle.js +30 -27
  58. package/dist/commands/node/message.js +24 -45
  59. package/dist/commands/node/subscription.js +6 -15
  60. package/dist/commands/node/wait.js +2 -3
  61. package/dist/commands/node-lifecycle-revive.js +1 -12
  62. package/dist/commands/pkg/browse/actions.js +2 -3
  63. package/dist/commands/pkg/market-manage.js +2 -5
  64. package/dist/commands/pkg/plugin-manage.js +7 -8
  65. package/dist/commands/profile/default.js +5 -5
  66. package/dist/commands/profile/delete.js +1 -1
  67. package/dist/commands/profile/env.js +9 -15
  68. package/dist/commands/profile/kind.js +3 -7
  69. package/dist/commands/profile/meta.js +3 -5
  70. package/dist/commands/profile/new.js +0 -6
  71. package/dist/commands/profile/pause.js +4 -8
  72. package/dist/commands/profile/project.js +5 -9
  73. package/dist/commands/profile/rename.js +3 -7
  74. package/dist/commands/profile/show.js +3 -3
  75. package/dist/commands/profile.js +4 -3
  76. package/dist/commands/surface-tmux-spread.js +1 -3
  77. package/dist/commands/sys/config.js +3 -4
  78. package/dist/commands/sys/support/prepare.js +8 -4
  79. package/dist/commands/sys/support/submit.js +2 -3
  80. package/dist/commands/sys/sync-deps.js +1 -9
  81. package/dist/commands/sys/sync-project-guidance.js +1 -7
  82. package/dist/commands/sys/sync-skills.js +1 -11
  83. package/dist/core/__tests__/cron-node-sink-parked-root.test.d.ts +1 -0
  84. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +147 -0
  85. package/dist/core/__tests__/history-inbox.test.js +11 -1
  86. package/dist/core/__tests__/human-deliver.test.js +2 -1
  87. package/dist/core/__tests__/integration/command-plugins.test.js +0 -1
  88. package/dist/core/__tests__/integration/deferred-no-wake.test.js +0 -1
  89. package/dist/core/__tests__/lifecycle.test.js +30 -2
  90. package/dist/core/__tests__/revive-parked-fresh.test.d.ts +1 -0
  91. package/dist/core/__tests__/revive-parked-fresh.test.js +109 -0
  92. package/dist/core/__tests__/seam/dormancy-release.test.js +32 -5
  93. package/dist/core/canvas/attention.d.ts +2 -0
  94. package/dist/core/canvas/attention.js +25 -18
  95. package/dist/core/canvas/extensions.d.ts +1 -1
  96. package/dist/core/canvas/extensions.js +7 -1
  97. package/dist/core/canvas/history.js +20 -2
  98. package/dist/core/canvas/types.d.ts +1 -1
  99. package/dist/core/command.js +33 -8
  100. package/dist/core/help.d.ts +28 -2
  101. package/dist/core/help.js +46 -10
  102. package/dist/core/human/__tests__/page-html-markdown.test.d.ts +1 -0
  103. package/dist/core/human/__tests__/page-html-markdown.test.js +48 -0
  104. package/dist/core/human/component-docs.js +4 -4
  105. package/dist/core/human/page-html-markdown.d.ts +8 -0
  106. package/dist/core/human/page-html-markdown.js +260 -0
  107. package/dist/core/memory/lint.d.ts +15 -0
  108. package/dist/core/memory/lint.js +150 -90
  109. package/dist/core/profiles/__tests__/fuzzy-match.test.d.ts +1 -0
  110. package/dist/core/profiles/__tests__/fuzzy-match.test.js +51 -0
  111. package/dist/core/profiles/fuzzy-match.d.ts +19 -0
  112. package/dist/core/profiles/fuzzy-match.js +92 -0
  113. package/dist/core/profiles/manifest.d.ts +14 -7
  114. package/dist/core/profiles/manifest.js +62 -12
  115. package/dist/core/profiles/select.d.ts +3 -1
  116. package/dist/core/profiles/select.js +5 -3
  117. package/dist/core/profiles/state-block.js +4 -3
  118. package/dist/core/runtime/boot-root.d.ts +3 -2
  119. package/dist/core/runtime/canvas-extensions.d.ts +7 -1
  120. package/dist/core/runtime/canvas-extensions.js +8 -1
  121. package/dist/core/runtime/lifecycle.d.ts +11 -2
  122. package/dist/core/runtime/lifecycle.js +15 -2
  123. package/dist/core/runtime/model-selection.d.ts +4 -0
  124. package/dist/core/runtime/model-selection.js +5 -0
  125. package/dist/core/runtime/nodes.js +5 -0
  126. package/dist/core/runtime/reopen.d.ts +6 -0
  127. package/dist/core/runtime/reopen.js +12 -1
  128. package/dist/core/runtime/revive.d.ts +6 -0
  129. package/dist/core/runtime/revive.js +22 -2
  130. package/dist/core/runtime/spawn.d.ts +5 -2
  131. package/dist/core/runtime/spawn.js +18 -32
  132. package/dist/core/runtime/structured-output.d.ts +6 -0
  133. package/dist/core/runtime/structured-output.js +6 -0
  134. package/dist/core/substrate/__tests__/surface-match-command.test.d.ts +1 -0
  135. package/dist/core/substrate/__tests__/surface-match-command.test.js +89 -0
  136. package/dist/core/substrate/on-read.js +2 -1
  137. package/dist/core/substrate/surface-match.js +164 -12
  138. package/dist/core/termrender/version.d.ts +1 -1
  139. package/dist/core/termrender/version.js +1 -1
  140. package/dist/core/user-settings.js +1 -1
  141. package/dist/daemon/api/__tests__/broker-settle-park.test.d.ts +1 -0
  142. package/dist/daemon/api/__tests__/broker-settle-park.test.js +102 -0
  143. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.d.ts +1 -0
  144. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +188 -0
  145. package/dist/daemon/api/__tests__/node-create-description.test.d.ts +1 -0
  146. package/dist/daemon/api/__tests__/node-create-description.test.js +83 -0
  147. package/dist/daemon/api/__tests__/profile-metadata-route.test.d.ts +1 -0
  148. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +92 -0
  149. package/dist/daemon/api/__tests__/reopen-delivery.test.d.ts +1 -0
  150. package/dist/daemon/api/__tests__/reopen-delivery.test.js +173 -0
  151. package/dist/daemon/api/handlers/broker-ops.js +21 -0
  152. package/dist/daemon/api/handlers/canvas.js +10 -0
  153. package/dist/daemon/api/handlers/messages.js +25 -16
  154. package/dist/daemon/api/handlers/nodes.js +5 -0
  155. package/dist/daemon/api/handlers/profiles.js +22 -1
  156. package/dist/daemon/cron-run.js +19 -1
  157. package/dist/daemon/manage.d.ts +16 -1
  158. package/dist/daemon/manage.js +20 -1
  159. package/dist/daemon/park-pending.d.ts +13 -0
  160. package/dist/daemon/park-pending.js +42 -0
  161. package/dist/daemon/reconcilers/broker-supervision.d.ts +13 -0
  162. package/dist/daemon/reconcilers/broker-supervision.js +139 -21
  163. package/dist/daemon/reconcilers/live-obligation.d.ts +10 -4
  164. package/dist/daemon/reconcilers/live-obligation.js +7 -3
  165. package/dist/daemon/reconcilers/storage-maintenance.d.ts +0 -4
  166. package/dist/daemon/reconcilers/storage-maintenance.js +1 -33
  167. package/dist/pi-extensions/canvas-prompt-scrub.d.ts +13 -0
  168. package/dist/pi-extensions/canvas-prompt-scrub.js +53 -0
  169. package/dist/shared/generated-context.d.ts +7 -0
  170. package/dist/shared/generated-context.js +11 -0
  171. package/package.json +4 -4
  172. package/runtime.lock.json +2 -2
  173. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +0 -47
@@ -0,0 +1,92 @@
1
+ // Fuzzy matching for a user-TYPED profile operand. Pure — no fs, no manifest
2
+ // reads — so the resolution policy is testable on its own and `manifest.ts`
3
+ // stays the sole owner of path safety and manifest IO.
4
+ //
5
+ // This exists because nobody remembers a profile's exact registered name, let
6
+ // alone its `<slug>-<8hex>` id. `crtr --profile crout` should run the `crouter`
7
+ // profile instead of failing with a list the user then has to read and retype.
8
+ /** Case-, space-, and punctuation-insensitive comparison key. "My Profile",
9
+ * "my-profile", and "myprofile" all collapse to the same key, so the common
10
+ * near-miss (wrong separator, wrong case) resolves in the first tier. */
11
+ function normalize(value) {
12
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '');
13
+ }
14
+ /** The generated id's `-<8 hex>` suffix carries no meaning to a human typing a
15
+ * profile, so match against the slug alone as well as the whole id. */
16
+ function idSlug(profileId) {
17
+ return profileId.replace(/-[0-9a-f]{8}$/, '');
18
+ }
19
+ /** Every string a human might plausibly have been aiming at for one profile. */
20
+ function haystacks(candidate) {
21
+ const keys = [normalize(candidate.name), normalize(idSlug(candidate.profileId)), normalize(candidate.profileId)];
22
+ return keys.filter((key, index) => key !== '' && keys.indexOf(key) === index);
23
+ }
24
+ function levenshtein(a, b) {
25
+ if (a === b)
26
+ return 0;
27
+ if (a.length === 0)
28
+ return b.length;
29
+ if (b.length === 0)
30
+ return a.length;
31
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
32
+ for (let i = 1; i <= a.length; i += 1) {
33
+ const current = [i];
34
+ for (let j = 1; j <= b.length; j += 1) {
35
+ const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
36
+ current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, substitution);
37
+ }
38
+ previous = current;
39
+ }
40
+ return previous[b.length];
41
+ }
42
+ /** Tiers, strongest first. They are ordered by how confidently a human meant
43
+ * this profile, NOT by string similarity — a prefix the user stopped typing is
44
+ * a stronger signal than a one-character edit that happens to score well. */
45
+ const TIER_NORMALIZED_EQUAL = 0;
46
+ const TIER_PREFIX = 1;
47
+ const TIER_CONTAINS = 2;
48
+ const TIER_TYPO = 3;
49
+ function scoreOne(needle, candidate) {
50
+ let best = null;
51
+ for (const hay of haystacks(candidate)) {
52
+ const distance = levenshtein(needle, hay);
53
+ let tier = null;
54
+ if (needle === hay)
55
+ tier = TIER_NORMALIZED_EQUAL;
56
+ else if (hay.startsWith(needle))
57
+ tier = TIER_PREFIX;
58
+ else if (hay.includes(needle) || needle.includes(hay))
59
+ tier = TIER_CONTAINS;
60
+ // A typo budget that scales with length: one edit for a short name, more
61
+ // for a long one. Without the floor, a 3-character name would tolerate
62
+ // nothing; without the scaling, two unrelated long names would collide.
63
+ else if (distance <= Math.max(1, Math.floor(hay.length / 3)))
64
+ tier = TIER_TYPO;
65
+ if (tier === null)
66
+ continue;
67
+ if (best === null || tier < best.tier || (tier === best.tier && distance < best.distance)) {
68
+ best = { candidate, tier, distance };
69
+ }
70
+ }
71
+ return best;
72
+ }
73
+ /** Resolve a typed operand against the profiles on this host. Returns a single
74
+ * match only when it is unambiguously the best one: the strongest tier any
75
+ * candidate reached, and within it the single lowest edit distance. A genuine
76
+ * tie is reported rather than guessed, because silently running under the
77
+ * wrong profile is worse than one more prompt. */
78
+ export function fuzzyMatchProfile(operand, candidates) {
79
+ const needle = normalize(operand);
80
+ if (needle === '')
81
+ return { match: null, tied: [] };
82
+ const scored = candidates.map((c) => scoreOne(needle, c)).filter((s) => s !== null);
83
+ if (scored.length === 0)
84
+ return { match: null, tied: [] };
85
+ const bestTier = Math.min(...scored.map((s) => s.tier));
86
+ const inTier = scored.filter((s) => s.tier === bestTier);
87
+ const bestDistance = Math.min(...inTier.map((s) => s.distance));
88
+ const winners = inTier.filter((s) => s.distance === bestDistance);
89
+ if (winners.length === 1)
90
+ return { match: winners[0].candidate, tied: [] };
91
+ return { match: null, tied: winners.map((s) => s.candidate) };
92
+ }
@@ -30,17 +30,24 @@ export interface ProfileEntry {
30
30
  * generated id shape, or whose `profile.json` is missing/corrupt, is skipped
31
31
  * rather than crashing the whole listing. */
32
32
  export declare function listProfiles(): ProfileEntry[];
33
- /** Resolve a CLI operand to its profile — exact id first (checked against the
34
- * actually-enumerated dirs, never a blind join), then a unique manifest
35
- * `name` match. Ambiguous names fail listing every matching id; no match
36
- * fails naming `profile list`/`profile new` as recovery. This is the ONLY
37
- * function every command leaf and runtime consumer should
38
- * call to turn a `<profile>` operand or `CRTR_PROFILE_ID` into a concrete,
39
- * safe profile id. */
40
33
  /** Read one exact profile id without falling back to manifest-name matching.
41
34
  * Used at persistence gates that already hold a resolved durable identity. */
42
35
  export declare function loadExactProfileManifest(profileId: string): ProfileEntry;
43
36
  export declare function loadProfileManifest(profileIdOrName: string): ProfileEntry;
37
+ /** Resolve a profile the USER TYPED — a `--profile` flag or a `<profile>`
38
+ * operand. Exact id or name wins outright; only when nothing matches exactly
39
+ * does this fall back to fuzzy matching on name and id slug, so a typed
40
+ * `crout`, `Crouter`, or `cruoter` reaches the `crouter` profile instead of
41
+ * failing with a list the user has to read and retype. A fuzzy hit is
42
+ * announced on STDERR (never stdout, which callers pipe) so the user always
43
+ * knows which profile actually ran.
44
+ *
45
+ * NEVER call this with a STORED `profile_id` (a node's `meta.profile_id`,
46
+ * `CRTR_PROFILE_ID`, a pinned default). A durable id that no longer resolves
47
+ * means the profile was deleted — the correct answer there is the not-found
48
+ * those callers already handle, not the nearest surviving profile. Use
49
+ * `loadProfileManifest` (or `loadExactProfileManifest`) for those. */
50
+ export declare function resolveProfileOperand(operand: string): ProfileEntry;
44
51
  /** Hold the per-profile manifest lock for the duration of `fn`. ALL manifest
45
52
  * mutations (create/rename/add-project/remove-project/delete/last-used)
46
53
  * below run inside this. */
@@ -14,6 +14,7 @@ import { userScopeRoot, resetScopeCache } from '../scope.js';
14
14
  import { ensureDir, nowIso } from '../fs-utils.js';
15
15
  import { usage, notFound, ambiguous, general } from '../errors.js';
16
16
  import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
17
+ import { fuzzyMatchProfile } from './fuzzy-match.js';
17
18
  // ---------------------------------------------------------------------------
18
19
  // Path safety — the only code in the tree that builds a path under the
19
20
  // profiles root. Every other module reaches profile paths through this file.
@@ -197,13 +198,15 @@ export function listProfiles() {
197
198
  }
198
199
  return out;
199
200
  }
200
- /** Resolve a CLI operand to its profile — exact id first (checked against the
201
- * actually-enumerated dirs, never a blind join), then a unique manifest
202
- * `name` match. Ambiguous names fail listing every matching id; no match
203
- * fails naming `profile list`/`profile new` as recovery. This is the ONLY
204
- * function every command leaf and runtime consumer should
205
- * call to turn a `<profile>` operand or `CRTR_PROFILE_ID` into a concrete,
206
- * safe profile id. */
201
+ // Three resolvers, in descending tolerance. `resolveProfileOperand` is what a
202
+ // command leaf calls for a value the USER TYPED: exact id, exact name, then a
203
+ // fuzzy fallback. `loadProfileManifest` is the strict middle — exact id or
204
+ // exact name, no guessing — and is what every runtime consumer holding a
205
+ // stored `profile_id` or `CRTR_PROFILE_ID` calls. `loadExactProfileManifest`
206
+ // skips name matching entirely, for persistence gates already holding a
207
+ // resolved durable identity. All three check the id shape against the
208
+ // actually-enumerated dirs rather than joining a raw operand onto the root, so
209
+ // no caller can traverse out of the profiles root.
207
210
  /** Read one exact profile id without falling back to manifest-name matching.
208
211
  * Used at persistence gates that already hold a resolved durable identity. */
209
212
  export function loadExactProfileManifest(profileId) {
@@ -216,8 +219,11 @@ export function loadExactProfileManifest(profileId) {
216
219
  next: 'Run `crtr profile list` to see available profiles.',
217
220
  });
218
221
  }
219
- export function loadProfileManifest(profileIdOrName) {
220
- const all = listProfiles();
222
+ /** Exact resolution only: the generated id, else a unique manifest `name`.
223
+ * Returns `null` when nothing matches exactly; still THROWS when a name is
224
+ * shared by several profiles, because that is a real collision the caller
225
+ * cannot resolve by guessing (and fuzzy matching would only guess harder). */
226
+ function findExactProfile(all, profileIdOrName) {
221
227
  if (ID_SHAPE.test(profileIdOrName)) {
222
228
  const byId = all.find((p) => p.profileId === profileIdOrName);
223
229
  if (byId !== undefined)
@@ -234,10 +240,13 @@ export function loadProfileManifest(profileIdOrName) {
234
240
  next: `Re-run with one exact profile id: ${ids.join(', ')}.`,
235
241
  });
236
242
  }
237
- // Name the actual value set in the failure: a caller that guessed an id or a
238
- // stale name can correct itself from this error alone, without a lookup hop.
243
+ return null;
244
+ }
245
+ /** Name the actual value set in the failure: a caller that guessed an id or a
246
+ * stale name can correct itself from this error alone, without a lookup hop. */
247
+ function profileNotFound(all, profileIdOrName) {
239
248
  const available = all.map((p) => `${p.profileId} (${p.manifest.name})`);
240
- throw notFound(`profile not found: ${profileIdOrName}`, {
249
+ return notFound(`profile not found: ${profileIdOrName}`, {
241
250
  received: profileIdOrName,
242
251
  available,
243
252
  next: available.length > 0
@@ -245,6 +254,47 @@ export function loadProfileManifest(profileIdOrName) {
245
254
  : 'No profiles exist on this host yet — create one with `crtr profile new --name <name>`.',
246
255
  });
247
256
  }
257
+ export function loadProfileManifest(profileIdOrName) {
258
+ const all = listProfiles();
259
+ const exact = findExactProfile(all, profileIdOrName);
260
+ if (exact !== null)
261
+ return exact;
262
+ throw profileNotFound(all, profileIdOrName);
263
+ }
264
+ /** Resolve a profile the USER TYPED — a `--profile` flag or a `<profile>`
265
+ * operand. Exact id or name wins outright; only when nothing matches exactly
266
+ * does this fall back to fuzzy matching on name and id slug, so a typed
267
+ * `crout`, `Crouter`, or `cruoter` reaches the `crouter` profile instead of
268
+ * failing with a list the user has to read and retype. A fuzzy hit is
269
+ * announced on STDERR (never stdout, which callers pipe) so the user always
270
+ * knows which profile actually ran.
271
+ *
272
+ * NEVER call this with a STORED `profile_id` (a node's `meta.profile_id`,
273
+ * `CRTR_PROFILE_ID`, a pinned default). A durable id that no longer resolves
274
+ * means the profile was deleted — the correct answer there is the not-found
275
+ * those callers already handle, not the nearest surviving profile. Use
276
+ * `loadProfileManifest` (or `loadExactProfileManifest`) for those. */
277
+ export function resolveProfileOperand(operand) {
278
+ const all = listProfiles();
279
+ const exact = findExactProfile(all, operand);
280
+ if (exact !== null)
281
+ return exact;
282
+ const { match, tied } = fuzzyMatchProfile(operand, all.map((p) => ({ profileId: p.profileId, name: p.manifest.name })));
283
+ if (match !== null) {
284
+ const picked = all.find((p) => p.profileId === match.profileId);
285
+ console.error(`crtr: no profile named "${operand}" — using the closest match: ${picked.manifest.name} (${picked.profileId})`);
286
+ return picked;
287
+ }
288
+ if (tied.length > 1) {
289
+ const ids = tied.map((c) => `${c.profileId} (${c.name})`);
290
+ throw ambiguous(`"${operand}" is equally close to ${tied.length} profiles: ${ids.join(', ')}`, {
291
+ received: operand,
292
+ candidates: tied.map((c) => c.profileId),
293
+ next: `Re-run with one exact profile id or name: ${ids.join(', ')}.`,
294
+ });
295
+ }
296
+ throw profileNotFound(all, operand);
297
+ }
248
298
  function locksDir() {
249
299
  return join(profilesRoot(), '.locks');
250
300
  }
@@ -16,7 +16,9 @@ export declare function selectProfileForCwdReadOnly(cwd: string): string;
16
16
  export declare function tildify(p: string): string;
17
17
  /** Select the profile a node about to boot at `cwd` should run under.
18
18
  *
19
- * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
19
+ * 1. `explicitProfile` present → resolve it as a user-typed operand (exact id,
20
+ * exact name, then the closest fuzzy match — nobody recalls the generated
21
+ * `<slug>-<8hex>` id, and a near-miss name should still start the session). If
20
22
  * its manifest does not already cover `cwd` and the session is interactive,
21
23
  * offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
22
24
  * return the id.
@@ -13,7 +13,7 @@ import { homedir } from 'node:os';
13
13
  import { basename, resolve as resolvePath, sep } from 'node:path';
14
14
  import { createInterface } from 'node:readline/promises';
15
15
  import { emitKeypressEvents } from 'node:readline';
16
- import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, ensureRootProfile, ROOT_PROFILE_ID, } from './manifest.js';
16
+ import { listProfiles, loadProfileManifest, resolveProfileOperand, updateProfileLastUsed, createProfile, addProfileProject, ensureRootProfile, ROOT_PROFILE_ID, } from './manifest.js';
17
17
  import { getDefaultProfileId, setDefaultProfileId, clearDefaultProfile, } from './default-binding.js';
18
18
  import { stdoutColor } from '../output.js';
19
19
  import { inTmux } from '../runtime/placement-tmux.js';
@@ -652,7 +652,9 @@ async function promptPickProfileOrCreate(candidates, cwd, exact, pinnedId, bindi
652
652
  }
653
653
  /** Select the profile a node about to boot at `cwd` should run under.
654
654
  *
655
- * 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
655
+ * 1. `explicitProfile` present → resolve it as a user-typed operand (exact id,
656
+ * exact name, then the closest fuzzy match — nobody recalls the generated
657
+ * `<slug>-<8hex>` id, and a near-miss name should still start the session). If
656
658
  * its manifest does not already cover `cwd` and the session is interactive,
657
659
  * offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
658
660
  * return the id.
@@ -683,7 +685,7 @@ export async function selectProfileForCwd(cwd, explicitProfile, forcePicker = fa
683
685
  const resolvedCwd = resolveCwd(cwd);
684
686
  const bindings = resolveUserKeybindings();
685
687
  if (explicitProfile !== undefined && explicitProfile !== null && explicitProfile !== '') {
686
- const entry = loadProfileManifest(explicitProfile);
688
+ const entry = resolveProfileOperand(explicitProfile);
687
689
  // Selecting a profile from a directory it does not yet cover: offer to
688
690
  // widen its purview (interactive only, default yes) before pi boots.
689
691
  if (!profileCoversCwd(entry, resolvedCwd) && isInteractive()) {
@@ -1,6 +1,7 @@
1
- // The `<profiles>` help state element — every leaf carrying a `--profile` flag
2
- // enumerates the accepted value set on that same leaf (command-surface rule),
3
- // so an agent never has to guess an id or take a `crtr profile list` hop.
1
+ // The `<profiles>` help state element. A leaf carrying a selectable `--profile`
2
+ // renders this beside that flag's full contract — in ordinary leaf help when
3
+ // profile selection is common, or in focused `--profile -h` when omission is
4
+ // normal — so an agent never has to guess an id or take a separate list hop.
4
5
  import { stateBlock } from '../help.js';
5
6
  import { listProfiles } from './manifest.js';
6
7
  /** A live `<profiles count=N>` state element — one `<profile_id> — <name>` line
@@ -4,8 +4,9 @@ export interface BootRootOpts {
4
4
  name?: string;
5
5
  /** Optional starter prompt (bare `crtr` requires none). */
6
6
  prompt?: string;
7
- /** Explicit `--profile <id-or-name>` on the front-door invocation, validated
8
- * through `loadProfileManifest`. Omit to run the MRU/create-or-root-profile
7
+ /** Explicit `--profile <id-or-name>` on the front-door invocation, resolved
8
+ * through `resolveProfileOperand` — so a near-miss name still starts the
9
+ * session, under the closest profile. Omit to run the MRU/create-or-root-profile
9
10
  * startup selector for `cwd` (see `selectProfileForCwd`) — a root has no
10
11
  * spawner to inherit from, so this is the front door's only source of profile
11
12
  * identity. */
@@ -10,10 +10,16 @@ export declare const CANVAS_DOC_SUBSTRATE_PATH: string;
10
10
  export declare const CANVAS_STRUCTURED_OUTPUT_PATH: string;
11
11
  export declare const CANVAS_BASH_VALVE_PATH: string;
12
12
  export declare const CANVAS_PREVIEW_RESULT_PATH: string;
13
+ export declare const CANVAS_PROMPT_SCRUB_PATH: string;
13
14
  /** The canvas extensions every node loads, in order. The inbox watcher commits
14
15
  * its cursor at agent_settled before the stophook checks it for idle release.
15
16
  * The review boundary must run before the stophook commits companion session
16
- * coordinates, and before the context intro appends fork bearings. */
17
+ * coordinates, and before the context intro appends fork bearings.
18
+ *
19
+ * Every entry here loads ahead of any installed pi package, which is why the
20
+ * prompt scrub belongs in this list rather than in crouter's pi package: the
21
+ * Claude subscription adapter is a package, and it must not see pi's docs
22
+ * block still in the prompt (see canvas-prompt-scrub.ts). */
17
23
  export declare const CANVAS_EXTENSIONS: string[];
18
24
  /** Preserve the invariant: mandatory extensions are always present exactly once.
19
25
  *
@@ -22,10 +22,16 @@ export const CANVAS_DOC_SUBSTRATE_PATH = resolveExtension('canvas-doc-substrate'
22
22
  export const CANVAS_STRUCTURED_OUTPUT_PATH = resolveExtension('canvas-structured-output');
23
23
  export const CANVAS_BASH_VALVE_PATH = resolveExtension('canvas-bash-valve');
24
24
  export const CANVAS_PREVIEW_RESULT_PATH = resolveExtension('canvas-preview-result');
25
+ export const CANVAS_PROMPT_SCRUB_PATH = resolveExtension('canvas-prompt-scrub');
25
26
  /** The canvas extensions every node loads, in order. The inbox watcher commits
26
27
  * its cursor at agent_settled before the stophook checks it for idle release.
27
28
  * The review boundary must run before the stophook commits companion session
28
- * coordinates, and before the context intro appends fork bearings. */
29
+ * coordinates, and before the context intro appends fork bearings.
30
+ *
31
+ * Every entry here loads ahead of any installed pi package, which is why the
32
+ * prompt scrub belongs in this list rather than in crouter's pi package: the
33
+ * Claude subscription adapter is a package, and it must not see pi's docs
34
+ * block still in the prompt (see canvas-prompt-scrub.ts). */
29
35
  export const CANVAS_EXTENSIONS = [
30
36
  CANVAS_INBOX_WATCHER_PATH,
31
37
  CANVAS_REVIEW_BOUNDARY_PATH,
@@ -39,6 +45,7 @@ export const CANVAS_EXTENSIONS = [
39
45
  CANVAS_STRUCTURED_OUTPUT_PATH,
40
46
  CANVAS_BASH_VALVE_PATH,
41
47
  CANVAS_PREVIEW_RESULT_PATH,
48
+ CANVAS_PROMPT_SCRUB_PATH,
42
49
  ];
43
50
  /** Preserve the invariant: mandatory extensions are always present exactly once.
44
51
  *
@@ -1,8 +1,8 @@
1
- import type { NodeMeta } from '../canvas/types.js';
1
+ import type { NodeMeta, NodeStatus, ExitIntent } from '../canvas/types.js';
2
2
  /** The lifecycle events — the only vocabulary for moving a node's status/intent.
3
3
  * Each maps (in the table below) to a target status and/or intent plus the set
4
4
  * of from-statuses it is legal from. */
5
- export type LifecycleEvent = 'finish' | 'cancel' | 'crash' | 'yield' | 'release' | 'refresh-launch' | 'revive';
5
+ export type LifecycleEvent = 'finish' | 'park' | 'cancel' | 'crash' | 'yield' | 'release' | 'refresh-launch' | 'revive';
6
6
  /** Enact a lifecycle event on a node: validate the from-status against the
7
7
  * table, then write status+intent in ONE atomic statement (so they can never
8
8
  * disagree). Returns the hydrated node view after the write.
@@ -10,3 +10,12 @@ export type LifecycleEvent = 'finish' | 'cancel' | 'crash' | 'yield' | 'release'
10
10
  * Throws on an unknown node or an illegal move. The conditional update makes
11
11
  * legality authoritative at SQLite write time rather than at a stale read. */
12
12
  export declare function transition(nodeId: string, event: LifecycleEvent): NodeMeta;
13
+ /** Whether this row is a PARKED node — terminalized by the unattended clock
14
+ * rather than by finishing its own work. The one read of the park marker:
15
+ * `done` qualified by `intent='parked'`, both written in the same atomic
16
+ * statement by the `park` event above. Revival clears it (`revive` writes
17
+ * `intent: null`), so a reopened node is an ordinary node again. */
18
+ export declare function isParked(node: {
19
+ status: NodeStatus;
20
+ intent?: ExitIntent;
21
+ }): boolean;
@@ -39,6 +39,11 @@ const LIVE = ['active', 'idle'];
39
39
  const TRANSITIONS = {
40
40
  // markCleanExitDone (clean quit) · queue cancellation · relaunchRoot park-old.
41
41
  finish: { status: 'done', intent: 'done', from: LIVE },
42
+ // The unattended park: a node whose conversation went idle with nothing left
43
+ // to wake it. `intent='parked'` IS the durable park marker — it qualifies the
44
+ // `done` it is written with, and `revive` clears it. Read it through
45
+ // `isParked` below, never by comparing the string at a call site.
46
+ park: { status: 'done', intent: 'parked', from: LIVE },
42
47
  // closeNode cascade · reapDescendants. Forced teardown of a node that did NOT
43
48
  // finish its own work → canceled, intent cleared. (A5: done is reserved for
44
49
  // finish; every external reap unifies on canceled.)
@@ -98,7 +103,7 @@ export function transition(nodeId, event) {
98
103
  // Wait-cron cleanup — writes a DIFFERENT table (crons) AFTER the atomic
99
104
  // status/intent write above, so the single-(status,intent)-writer rule holds.
100
105
  // Event-gated; exactly ONE delegated crons helper, never inline cron SQL here:
101
- // finish · cancel → cancelCronsOnWake: delete the cancel-on-wake crons
106
+ // finish · park · cancel → cancelCronsOnWake: delete the cancel-on-wake crons
102
107
  // ANCHORED to this node — the deadlines it armed to bound a wait. Ending
103
108
  // the wait deliberately (finish) or having it ended for you (close cascade
104
109
  // · reapDescendants) settles that race, so the deadline must not fire
@@ -109,7 +114,15 @@ export function transition(nodeId, event) {
109
114
  // SURVIVES a node ending.
110
115
  // crash → NOTHING. A fault is not a deliberate end-of-waiting; a pending
111
116
  // deadline MUST survive instance death so the wait still settles.
112
- if (event === 'finish' || event === 'cancel')
117
+ if (event === 'finish' || event === 'park' || event === 'cancel')
113
118
  cancelCronsOnWake(nodeId);
114
119
  return getNode(nodeId);
115
120
  }
121
+ /** Whether this row is a PARKED node — terminalized by the unattended clock
122
+ * rather than by finishing its own work. The one read of the park marker:
123
+ * `done` qualified by `intent='parked'`, both written in the same atomic
124
+ * statement by the `park` event above. Revival clears it (`revive` writes
125
+ * `intent: null`), so a reopened node is an ordinary node again. */
126
+ export function isParked(node) {
127
+ return node.status === 'done' && node.intent === 'parked';
128
+ }
@@ -15,6 +15,10 @@ export declare function defaultProvider(ladders?: ScopeConfig['modelLadders']):
15
15
  export declare const STRENGTH_ALIASES: Record<string, ModelStrength>;
16
16
  export declare const MODEL_PROVIDER_KEYS: ModelProvider[];
17
17
  export declare const MODEL_STRENGTHS: ModelStrength[];
18
+ /** Canonical user-facing grammar for a durable model override. Help and
19
+ * recovery paths compose from this one phrase so accepted forms cannot drift. */
20
+ export declare const MODEL_SPEC_FORMS: string;
21
+ export declare const MODEL_SPEC_NEXT: string;
18
22
  /** The ladder providers a picker/cycle should OFFER: a provider column is kept
19
23
  * only when at least one of its ladder cells names a runtime provider the
20
24
  * caller reports as signed in (`isConfigured` — e.g. pool occupancy or
@@ -42,6 +42,11 @@ export const STRENGTH_ALIASES = {
42
42
  // picker, and the Alt+M cycle. Both participate in automatic turn-time fallback.
43
43
  export const MODEL_PROVIDER_KEYS = ['anthropic', 'openai'];
44
44
  export const MODEL_STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
45
+ /** Canonical user-facing grammar for a durable model override. Help and
46
+ * recovery paths compose from this one phrase so accepted forms cannot drift. */
47
+ export const MODEL_SPEC_FORMS = `an exact registered provider/id; provider/tier where provider is ${MODEL_PROVIDER_KEYS.join('|')} and tier is ${MODEL_STRENGTHS.join('|')}; ` +
48
+ `a bare tier (${MODEL_STRENGTHS.join('|')}); or a family alias (opus|sonnet|haiku)`;
49
+ export const MODEL_SPEC_NEXT = `Pass ${MODEL_SPEC_FORMS}.`;
45
50
  /** The ladder providers a picker/cycle should OFFER: a provider column is kept
46
51
  * only when at least one of its ladder cells names a runtime provider the
47
52
  * caller reports as signed in (`isConfigured` — e.g. pool occupancy or
@@ -191,6 +191,11 @@ export function nodeEnv(meta) {
191
191
  // multi-project pointer set. '' (never omitted) only for historical
192
192
  // null-profile rows, matching every other CRTR_* scalar here.
193
193
  CRTR_PROFILE_ID: meta.profile_id ?? '',
194
+ // The Claude subscription adapter re-injects pi's docs block as a hidden
195
+ // message whenever a turn's prompt mentions pi. canvas-prompt-scrub already
196
+ // removes that block from the system prompt, so re-injection would only put
197
+ // the adapter's own fallback copy back into the conversation. Never inject.
198
+ PI_CLAUDE_OAUTH_REINJECT_SCOPE: 'never',
194
199
  };
195
200
  if (meta.parent)
196
201
  env['CRTR_PARENT_NODE_ID'] = meta.parent;
@@ -20,6 +20,12 @@ export declare function assertFinalizedForReopen(nodeId: string): string;
20
20
  * canonical result. Callers MUST NOT proceed to revive/deliver when this
21
21
  * throws. */
22
22
  export declare function commitReopen(nodeId: string, expectedFinalReport: string): void;
23
+ /** Commit the wider message-delivery reopen: make the node resident, then
24
+ * retract a final latch when one exists. Unlike the revive/fresh gate, an
25
+ * unlatched node is valid here — it still becomes resident. Writing lifecycle
26
+ * first leaves a failed latch CAS with the canonical-final pointer intact.
27
+ * Both writes finish before either delivery channel begins. */
28
+ export declare function commitReopenResident(nodeId: string): void;
23
29
  /** Convenience wrapper for a doorway with NO intervening fallible step between
24
30
  * the gate check and the reopen clear (`node lifecycle revive` has nothing
25
31
  * fallible between the gate and `reviveNode`). Validates and, when `reopen`,
@@ -38,7 +38,7 @@
38
38
  // mutating the live pointer — the explicit operation must not retract a
39
39
  // canonical final unless it can actually enact the requested re-task.
40
40
  import { join } from 'node:path';
41
- import { openDb, reportsDir } from '../canvas/index.js';
41
+ import { openDb, reportsDir, updateNode } from '../canvas/index.js';
42
42
  import { InputError } from '../io.js';
43
43
  function currentFinalReport(nodeId) {
44
44
  const row = openDb().prepare('SELECT final_report FROM nodes WHERE node_id = ?').get(nodeId);
@@ -101,6 +101,17 @@ export function commitReopen(nodeId, expectedFinalReport) {
101
101
  });
102
102
  }
103
103
  }
104
+ /** Commit the wider message-delivery reopen: make the node resident, then
105
+ * retract a final latch when one exists. Unlike the revive/fresh gate, an
106
+ * unlatched node is valid here — it still becomes resident. Writing lifecycle
107
+ * first leaves a failed latch CAS with the canonical-final pointer intact.
108
+ * Both writes finish before either delivery channel begins. */
109
+ export function commitReopenResident(nodeId) {
110
+ const finalReport = currentFinalReport(nodeId);
111
+ updateNode(nodeId, { lifecycle: 'resident' });
112
+ if (finalReport !== null)
113
+ commitReopen(nodeId, finalReport);
114
+ }
104
115
  /** Convenience wrapper for a doorway with NO intervening fallible step between
105
116
  * the gate check and the reopen clear (`node lifecycle revive` has nothing
106
117
  * fallible between the gate and `reviveNode`). Validates and, when `reopen`,
@@ -25,6 +25,12 @@ export declare function resumeArgs(meta: NodeMeta, resume: boolean): {
25
25
  resumeSessionPath?: string;
26
26
  newCycle?: boolean;
27
27
  };
28
+ /** Whether a fresh context window would have anything on disk to stand on: a
29
+ * goal, a roadmap, or both. The negative is what makes a fresh cycle worse
30
+ * than a resume — the new window would wake amnesiac, with no statement of
31
+ * what it is for. Read by the parked-resident veto below and by the message
32
+ * handler's explicit fresh-revive gate. */
33
+ export declare function hasFreshGroundState(nodeId: string): boolean;
28
34
  export interface ReviveResult {
29
35
  /** Always null — the broker engine is never placed in a tmux window. Kept on
30
36
  * the result for caller back-compat. */
@@ -24,7 +24,7 @@ import { existsSync } from 'node:fs';
24
24
  import { isAbsolute } from 'node:path';
25
25
  import { findNodeBySessionFile, getNode, updateNode, clearPid, recordPid, fullName, subscribersOf, } from '../canvas/index.js';
26
26
  import { cancelCronsOnWake } from '../canvas/crons.js';
27
- import { transition } from './lifecycle.js';
27
+ import { isParked, transition } from './lifecycle.js';
28
28
  import { fanDoctrineWake } from './close.js';
29
29
  import { InputError } from '../io.js';
30
30
  import { buildPiArgv } from './launch.js';
@@ -79,6 +79,18 @@ export function resumeArgs(meta, resume) {
79
79
  function isUnstartedBirth(nodeId) {
80
80
  return readRoadmap(nodeId) === null && readYieldMessage(nodeId) === null;
81
81
  }
82
+ /** Whether a fresh context window would have anything on disk to stand on: a
83
+ * goal, a roadmap, or both. The negative is what makes a fresh cycle worse
84
+ * than a resume — the new window would wake amnesiac, with no statement of
85
+ * what it is for. Read by the parked-resident veto below and by the message
86
+ * handler's explicit fresh-revive gate. */
87
+ export function hasFreshGroundState(nodeId) {
88
+ const goal = readGoal(nodeId);
89
+ if (goal !== null && goal.trim() !== '')
90
+ return true;
91
+ const roadmap = readRoadmap(nodeId);
92
+ return roadmap !== null && roadmap.trim() !== '';
93
+ }
82
94
  // ---------------------------------------------------------------------------
83
95
  // reviveNode
84
96
  // ---------------------------------------------------------------------------
@@ -181,8 +193,16 @@ export function reviveNode(nodeId, opts) {
181
193
  // crash-retry) outranks a caller's strict-resume request. resumeArgs then
182
194
  // selects only an existing absolute session path: strict
183
195
  // resume continues its leaf, while fresh/cycle mode roots a new one.
196
+ // A parked node re-enters on a FRESH cycle grounded in goal + roadmap rather
197
+ // than resuming the transcript its parking turn concluded — the second veto
198
+ // on strict resume, and a property of the node, so every revival doorway gets
199
+ // it without learning a predicate. Its one exception is a park with nothing
200
+ // on disk to ground a fresh window (reachable only on the degraded path,
201
+ // which writes no summary): resuming an old transcript beats waking amnesiac.
202
+ // The transition below clears the marker, and this snapshot precedes it.
184
203
  const refreshPending = meta.intent === 'refresh' || meta.cycle_pending === true;
185
- const effectiveResume = refreshPending ? false : opts.resume;
204
+ const parkedReentry = isParked(meta) && hasFreshGroundState(nodeId);
205
+ const effectiveResume = refreshPending || parkedReentry ? false : opts.resume;
186
206
  const resume = forkBirthPending ? {} : resumeArgs(meta, effectiveResume);
187
207
  // hasSessionPath: there is a real `.jsonl` to hand pi via `--session`, whether
188
208
  // as a true resume OR a cycling refresh-yield (resumeArgs sets `newCycle` in
@@ -6,6 +6,8 @@ export interface SpawnChildOpts {
6
6
  mode?: Mode;
7
7
  cwd: string;
8
8
  name?: string;
9
+ /** Caller-supplied description preserved against automatic naming. */
10
+ description?: string;
9
11
  prompt: string;
10
12
  /** Override the parent (defaults to the calling node from env). */
11
13
  parent?: string;
@@ -52,7 +54,8 @@ export interface SpawnChildOpts {
52
54
  * ladder config edits keep propagating). Omit to use the persona default. */
53
55
  model?: string;
54
56
  /** Select the profile this node runs under — an exact profile id or a unique
55
- * manifest name, validated through `loadProfileManifest`. Omit to INHERIT the
57
+ * manifest name (or the closest match to one), resolved through
58
+ * `resolveProfileOperand`. Omit to INHERIT the
56
59
  * spawner's current `profile_id` (managed child or --root alike — --root only
57
60
  * means top-level, it does not reset to a different profile; a shell root
58
61
  * falls back to the stable root profile in the startup selector). Passed
@@ -81,7 +84,7 @@ export interface SpawnChildOpts {
81
84
  * a uuid resolves to zero or multiple sessions. */
82
85
  export declare function resolveForkSource(value: string): string;
83
86
  /** Resolve the profile a spawned child runs under: an explicit `--profile`
84
- * operand (id or name) validated through `loadProfileManifest`, else INHERIT
87
+ * operand (id, name, or closest match) resolved through `resolveProfileOperand`, else INHERIT
85
88
  * the spawner's current `profile_id` (null when the spawner has none). `--root`
86
89
  * never resets this to null on its own; only an explicit override does. When
87
90
  * there is NO spawner at all — `crtr node new --root` run directly from a