@crouton-kit/crouter 0.3.36 → 0.3.38

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 (177) hide show
  1. package/dist/build-root.js +3 -6
  2. package/dist/{builtin-personas/runtime-base.md → builtin-memory/00-runtime-base.md} +8 -1
  3. package/dist/{builtin-personas/spine/has-manager.md → builtin-memory/01-spine/00-has-manager.md} +8 -0
  4. package/dist/builtin-memory/01-spine/01-no-manager.md +10 -0
  5. package/dist/{builtin-personas/lifecycle/terminal.md → builtin-memory/02-lifecycle/00-terminal.md} +8 -0
  6. package/dist/{builtin-personas/lifecycle/resident.md → builtin-memory/02-lifecycle/01-resident.md} +8 -0
  7. package/dist/{builtin-personas/waiting.md → builtin-memory/03-waiting.md} +7 -0
  8. package/dist/{builtin-personas/orchestration-kernel.md → builtin-memory/04-orchestration-kernel.md} +8 -0
  9. package/dist/{builtin-personas/advisor/PERSONA.md → builtin-memory/05-kinds/advisor/00-base.md} +5 -2
  10. package/dist/{builtin-personas/design/PERSONA.md → builtin-memory/05-kinds/design/00-base.md} +5 -2
  11. package/dist/{builtin-personas/design/orchestrator.md → builtin-memory/05-kinds/design/01-orchestrator.md} +5 -4
  12. package/dist/{builtin-personas/developer/PERSONA.md → builtin-memory/05-kinds/developer/00-base.md} +5 -2
  13. package/dist/{builtin-personas/developer/orchestrator.md → builtin-memory/05-kinds/developer/01-orchestrator.md} +5 -4
  14. package/dist/{builtin-personas/explore/PERSONA.md → builtin-memory/05-kinds/explore/00-base.md} +5 -2
  15. package/dist/{builtin-personas/explore/orchestrator.md → builtin-memory/05-kinds/explore/01-orchestrator.md} +5 -3
  16. package/dist/{builtin-personas/general/PERSONA.md → builtin-memory/05-kinds/general/00-base.md} +5 -2
  17. package/dist/{builtin-personas/general/orchestrator.md → builtin-memory/05-kinds/general/01-orchestrator.md} +5 -3
  18. package/dist/{builtin-personas/plan/PERSONA.md → builtin-memory/05-kinds/plan/00-base.md} +5 -2
  19. package/dist/{builtin-personas/plan/orchestrator.md → builtin-memory/05-kinds/plan/01-orchestrator.md} +5 -4
  20. package/dist/{builtin-personas/plan/reviewers/architecture-fit/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/architecture-fit.md} +5 -2
  21. package/dist/{builtin-personas/plan/reviewers/code-smells/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/code-smells.md} +5 -2
  22. package/dist/{builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md} +5 -2
  23. package/dist/{builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md} +5 -2
  24. package/dist/{builtin-personas/plan/reviewers/security/PERSONA.md → builtin-memory/05-kinds/plan/reviewers/security.md} +5 -2
  25. package/dist/{builtin-personas/product/PERSONA.md → builtin-memory/05-kinds/product/00-base.md} +5 -2
  26. package/dist/{builtin-personas/product/orchestrator.md → builtin-memory/05-kinds/product/01-orchestrator.md} +5 -4
  27. package/dist/{builtin-personas/product/teardown/PERSONA.md → builtin-memory/05-kinds/product/teardown.md} +5 -3
  28. package/dist/{builtin-personas/review/PERSONA.md → builtin-memory/05-kinds/review/00-base.md} +5 -2
  29. package/dist/{builtin-personas/review/orchestrator.md → builtin-memory/05-kinds/review/01-orchestrator.md} +5 -3
  30. package/dist/{builtin-personas/spec/PERSONA.md → builtin-memory/05-kinds/spec/00-base.md} +5 -2
  31. package/dist/{builtin-personas/spec/orchestrator.md → builtin-memory/05-kinds/spec/01-orchestrator.md} +5 -4
  32. package/dist/{builtin-personas/spec/requirements/PERSONA.md → builtin-memory/05-kinds/spec/requirements.md} +5 -2
  33. package/dist/builtin-memory/internal/INDEX.md +1 -1
  34. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  35. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  36. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +4 -4
  37. package/dist/builtin-views/canvas/core.mjs +3 -2
  38. package/dist/builtin-views/prompt-review/core.mjs +6 -134
  39. package/dist/builtin-views/settings/core.mjs +26 -94
  40. package/dist/builtin-views/settings/text.mjs +0 -5
  41. package/dist/builtin-views/settings/tui.mjs +1 -7
  42. package/dist/builtin-views/settings/web.jsx +3 -49
  43. package/dist/clients/attach/__tests__/crtr-output-render.test.js +21 -205
  44. package/dist/clients/attach/attach-cmd.js +429 -432
  45. package/dist/clients/web/web-cmd.js +7 -7
  46. package/dist/commands/__tests__/human.test.js +1 -15
  47. package/dist/commands/attention.js +9 -6
  48. package/dist/commands/canvas-history/search.js +2 -2
  49. package/dist/commands/canvas-history/show.js +3 -3
  50. package/dist/commands/canvas-history.js +1 -1
  51. package/dist/commands/canvas-issue.js +1 -0
  52. package/dist/commands/canvas-snapshot.js +1 -0
  53. package/dist/commands/canvas.js +10 -11
  54. package/dist/commands/chord.js +1 -0
  55. package/dist/commands/human/prompts.js +0 -1
  56. package/dist/commands/human/queue.js +1 -0
  57. package/dist/commands/memory/read.js +3 -3
  58. package/dist/commands/memory/shared.d.ts +13 -8
  59. package/dist/commands/memory/shared.js +22 -8
  60. package/dist/commands/memory/write.js +5 -3
  61. package/dist/commands/node-snapshot.js +1 -0
  62. package/dist/commands/node.js +52 -22
  63. package/dist/commands/profile/add-project.d.ts +1 -0
  64. package/dist/commands/profile/add-project.js +42 -0
  65. package/dist/commands/profile/delete.d.ts +1 -0
  66. package/dist/commands/profile/delete.js +39 -0
  67. package/dist/commands/profile/list.d.ts +1 -0
  68. package/dist/commands/profile/list.js +35 -0
  69. package/dist/commands/profile/new.d.ts +1 -0
  70. package/dist/commands/profile/new.js +50 -0
  71. package/dist/commands/profile/remove-project.d.ts +1 -0
  72. package/dist/commands/profile/remove-project.js +42 -0
  73. package/dist/commands/profile/rename.d.ts +1 -0
  74. package/dist/commands/profile/rename.js +42 -0
  75. package/dist/commands/profile/show.d.ts +1 -0
  76. package/dist/commands/profile/show.js +50 -0
  77. package/dist/commands/profile.d.ts +2 -0
  78. package/dist/commands/profile.js +33 -0
  79. package/dist/commands/push.d.ts +0 -1
  80. package/dist/commands/push.js +2 -228
  81. package/dist/commands/revive.js +2 -2
  82. package/dist/commands/{canvas-tmux-spread.js → surface-tmux-spread.js} +9 -6
  83. package/dist/commands/surface.js +3 -2
  84. package/dist/commands/sys/config.js +6 -21
  85. package/dist/commands/sys/daemon.d.ts +2 -0
  86. package/dist/commands/{daemon.js → sys/daemon.js} +12 -12
  87. package/dist/commands/sys/doctor.js +1 -0
  88. package/dist/commands/sys/prompt-review.js +90 -17
  89. package/dist/commands/sys.js +4 -3
  90. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  91. package/dist/core/__tests__/daemon-wedge.test.d.ts +1 -0
  92. package/dist/core/__tests__/daemon-wedge.test.js +87 -0
  93. package/dist/core/__tests__/hearth-bootstrap.test.js +2 -2
  94. package/dist/core/__tests__/history-inbox.test.d.ts +1 -0
  95. package/dist/core/__tests__/history-inbox.test.js +105 -0
  96. package/dist/core/__tests__/kickoff.test.js +3 -3
  97. package/dist/core/__tests__/revive.test.js +39 -1
  98. package/dist/core/canvas/canvas.js +10 -9
  99. package/dist/core/canvas/db.js +11 -0
  100. package/dist/core/canvas/history.d.ts +1 -1
  101. package/dist/core/canvas/history.js +91 -3
  102. package/dist/core/canvas/paths.d.ts +6 -0
  103. package/dist/core/canvas/paths.js +9 -0
  104. package/dist/core/canvas/status-glyph.js +1 -0
  105. package/dist/core/canvas/types.d.ts +10 -0
  106. package/dist/core/config.d.ts +44 -1
  107. package/dist/core/config.js +155 -12
  108. package/dist/core/fault-classifier.d.ts +2 -2
  109. package/dist/core/feed/inbox.d.ts +7 -12
  110. package/dist/core/feed/inbox.js +45 -29
  111. package/dist/core/hearth/providers/blaxel-bootstrap.js +1 -1
  112. package/dist/core/memory-resolver.d.ts +26 -15
  113. package/dist/core/memory-resolver.js +158 -40
  114. package/dist/core/preview-registry.js +14 -112
  115. package/dist/core/profiles/manifest.d.ts +33 -0
  116. package/dist/core/profiles/manifest.js +332 -0
  117. package/dist/core/profiles/select.d.ts +11 -0
  118. package/dist/core/profiles/select.js +115 -0
  119. package/dist/core/render.js +1 -1
  120. package/dist/core/runtime/bearings.d.ts +9 -0
  121. package/dist/core/runtime/bearings.js +31 -0
  122. package/dist/core/runtime/busy.d.ts +15 -0
  123. package/dist/core/runtime/busy.js +32 -1
  124. package/dist/core/runtime/close.d.ts +10 -0
  125. package/dist/core/runtime/close.js +18 -13
  126. package/dist/core/runtime/fault-recovery.js +4 -0
  127. package/dist/core/runtime/front-door.js +9 -2
  128. package/dist/core/runtime/kickoff.js +5 -7
  129. package/dist/core/runtime/launch.d.ts +25 -7
  130. package/dist/core/runtime/launch.js +42 -55
  131. package/dist/core/runtime/nodes.d.ts +7 -0
  132. package/dist/core/runtime/nodes.js +6 -0
  133. package/dist/core/runtime/persona.js +18 -32
  134. package/dist/core/runtime/placement.js +1 -1
  135. package/dist/core/runtime/revive.js +9 -1
  136. package/dist/core/runtime/spawn.d.ts +13 -0
  137. package/dist/core/runtime/spawn.js +25 -0
  138. package/dist/core/runtime/tmux.d.ts +2 -2
  139. package/dist/core/runtime/tmux.js +2 -2
  140. package/dist/core/scope.d.ts +14 -7
  141. package/dist/core/scope.js +76 -20
  142. package/dist/core/substrate/index.d.ts +3 -3
  143. package/dist/core/substrate/index.js +5 -3
  144. package/dist/core/substrate/on-read.js +3 -2
  145. package/dist/core/substrate/render.d.ts +16 -11
  146. package/dist/core/substrate/render.js +249 -62
  147. package/dist/core/substrate/schema.d.ts +11 -3
  148. package/dist/core/substrate/schema.js +25 -0
  149. package/dist/core/substrate/subject.d.ts +15 -2
  150. package/dist/core/substrate/subject.js +24 -2
  151. package/dist/daemon/crtrd-cli.js +1 -1
  152. package/dist/daemon/crtrd.d.ts +24 -0
  153. package/dist/daemon/crtrd.js +213 -5
  154. package/dist/daemon/manage.js +2 -2
  155. package/dist/pi-extensions/canvas-doc-substrate.js +2 -3
  156. package/dist/pi-extensions/canvas-nav.js +2 -2
  157. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  158. package/dist/pi-extensions/canvas-stophook.js +59 -2
  159. package/dist/types.d.ts +57 -1
  160. package/dist/types.js +83 -1
  161. package/dist/web-client/assets/{index-IAJVtuVe.js → index-CbO8L0mN.js} +4 -4
  162. package/dist/web-client/assets/index-DwO46Cs5.css +2 -0
  163. package/dist/web-client/index.html +2 -2
  164. package/package.json +2 -2
  165. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +0 -56
  166. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +0 -57
  167. package/dist/builtin-memory/crouter-development/personas.md +0 -112
  168. package/dist/builtin-personas/spine/no-manager.md +0 -2
  169. package/dist/commands/daemon.d.ts +0 -2
  170. package/dist/core/personas/index.d.ts +0 -13
  171. package/dist/core/personas/index.js +0 -11
  172. package/dist/core/personas/loader.d.ts +0 -159
  173. package/dist/core/personas/loader.js +0 -327
  174. package/dist/core/personas/resolve.d.ts +0 -59
  175. package/dist/core/personas/resolve.js +0 -376
  176. package/dist/web-client/assets/index-DUThOUzU.css +0 -2
  177. /package/dist/commands/{canvas-tmux-spread.d.ts → surface-tmux-spread.d.ts} +0 -0
@@ -49,11 +49,14 @@
49
49
  import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, statSync, } from 'node:fs';
50
50
  import { dirname, join } from 'node:path';
51
51
  import { homedir } from 'node:os';
52
+ import { spawnSync } from 'node:child_process';
52
53
  import { crtrHome, nodeDir, jobDir } from '../core/canvas/paths.js';
53
- import { listNodes, getRow, getNode, dueClockTriggers, consumeTrigger, advanceTrigger, listFocuses, closeFocusRow, pruneToLimit, } from '../core/canvas/index.js';
54
+ import { listNodes, getRow, getNode, dueClockTriggers, consumeTrigger, advanceTrigger, listFocuses, closeFocusRow, pruneToLimit, subscribersOf, } from '../core/canvas/index.js';
55
+ import { fullName } from '../core/canvas/labels.js';
54
56
  import { transition } from '../core/runtime/lifecycle.js';
55
- import { isBusy } from '../core/runtime/busy.js';
56
- import { FAULT_QUIET_MS } from '../core/runtime/fault.js';
57
+ import { isBusy, busySince } from '../core/runtime/busy.js';
58
+ import { FAULT_QUIET_MS, recordFault } from '../core/runtime/fault.js';
59
+ import { fanDoctrineWake } from '../core/runtime/close.js';
57
60
  import { logger } from '../core/log.js';
58
61
  export { FAULT_QUIET_MS };
59
62
  import { isPidAlive } from '../core/canvas/pid.js';
@@ -209,6 +212,207 @@ function handleYieldStall(row, pid, now) {
209
212
  }
210
213
  }
211
214
  // ---------------------------------------------------------------------------
215
+ // Wedge detection (issue #110): a broker whose pid is alive but whose engine is
216
+ // frozen MID-TURN (a runaway subprocess, a hung stream) is invisible to every
217
+ // other guarantee — the pid-alive branch above returns early, yield-stall only
218
+ // fires on intent='refresh', and provider-fault recovery needs a completed
219
+ // ERROR turn. A node in this state never pushes, so its parent waits forever
220
+ // with no wake and no visible fault marker (`node inspect list --hanging`
221
+ // returns nothing — there's no fault recorded, just silence).
222
+ //
223
+ // Detection, not recovery: this NOTIFIES (fans a doctrine wake + records a
224
+ // `daemon→node`/'wedged' fault so the node shows ⌘ “wedged · needs you” on the
225
+ // canvas graph views, same surface `node inspect list --hanging` already reads)
226
+ // — it never kills or restarts the engine. Killing the runaway SUBPROCESS (not
227
+ // the node) is the sanctioned recovery; see `crtr memory read
228
+ // wedged-child-on-runaway-bash`.
229
+ //
230
+ // Two corroborating signals, both generous, so a routine long-running bash/
231
+ // build/test tool call is never mistaken for a wedge:
232
+ // 1. quietForMs — ms since the busy marker's mtime last moved. canvas-
233
+ // stophook re-touches that marker (`touchBusy`) on every engine-level sign
234
+ // of life within a turn: a tool starting/streaming/ending, a streamed
235
+ // assistant token, or the turn ending. A tool that is actively streaming
236
+ // output, or a turn producing tokens, keeps this at ~0 no matter how long
237
+ // the OVERALL turn runs. Only a turn with NO observed engine event at all
238
+ // for WEDGE_QUIET_MS is even a candidate.
239
+ // 2. cpuPercent — the broker's WHOLE PROCESS TREE (the broker pid + every
240
+ // descendant), summed, sampled ONLY once a node is already a time-based
241
+ // candidate (never every tick — the `ps` shell-out is cheap but pointless
242
+ // on a healthy node). Sampling the tree, not just the broker pid, matters
243
+ // because the broker often sits idle awaiting a CHILD subprocess (bash,
244
+ // a build, a test run) that is the one actually burning CPU — sampling
245
+ // the broker alone would read that as 0% and false-fire on a perfectly
246
+ // healthy long-running tool call. A pi process (or any descendant)
247
+ // genuinely computing shows non-trivial tree CPU and is exonerated even
248
+ // past the quiet grace; a pi merely awaiting a subprocess or a stalled
249
+ // I/O call — wedged OR legitimately blocked on something equally silent —
250
+ // reads ~0% across the whole tree, matching the filed issue's own
251
+ // evidence (pid alive, 0% CPU, session file not advancing). A failed/
252
+ // unreadable CPU sample is treated as UNKNOWN, never as “low” — the
253
+ // verdict stays 'pending' rather than risk a false fire on an unreadable
254
+ // signal.
255
+ //
256
+ // ACCEPTED residual: a legitimate subprocess blocked on silent I/O (e.g. a
257
+ // 20-minute download emitting no output, a long `sleep`) reads ~0% tree
258
+ // CPU and is indistinguishable from a real wedge at the OS level — no CPU
259
+ // signal can tell a hung child from an I/O-blocked one. This is accepted,
260
+ // not deferred, because detection only NOTIFIES and never kills: the cost
261
+ // of that rare false positive is one doctrine wake to a parent, and 20+
262
+ // minutes of total silence with near-zero tree CPU is itself worth
263
+ // surfacing either way.
264
+ // ---------------------------------------------------------------------------
265
+ // Generous: routine long tool calls settle well under this; only a turn with
266
+ // zero observed engine progress for 20+ minutes is even a wedge CANDIDATE.
267
+ export const WEDGE_QUIET_MS = 20 * 60_000;
268
+ // CPU% ceiling for the corroborating sample, taken once quietForMs already
269
+ // clears the grace above. Small but nonzero — tolerates measurement noise
270
+ // without accepting a process that's visibly still doing work.
271
+ export const WEDGE_CPU_MAX_PERCENT = 2;
272
+ /** Pure wedge decision — mirrors livenessVerdict/yieldStallVerdict: a total
273
+ * function of the observed signals, no clock or process access of its own.
274
+ * `busy` false — the turn already ended (however it routed) — is never a
275
+ * wedge, regardless of how stale the marker looks. `quietForMs` null means
276
+ * the heartbeat marker couldn't be read (treated as “not enough evidence”, not
277
+ * “wedged”). `cpuPercent` null (sample failed/unavailable) or above the
278
+ * ceiling — EITHER keeps the verdict at 'pending' (keep watching next tick)
279
+ * rather than firing on an unconfirmed or contradicted signal. */
280
+ export function wedgeVerdict(input) {
281
+ if (!input.busy)
282
+ return 'leave';
283
+ if (input.quietForMs === null || input.quietForMs < WEDGE_QUIET_MS)
284
+ return 'leave';
285
+ if (input.cpuPercent === null || input.cpuPercent > WEDGE_CPU_MAX_PERCENT)
286
+ return 'pending';
287
+ return 'wedged';
288
+ }
289
+ /** Notified-episode dedup (one notice per wedge, per §Detection-not-repeat-fire):
290
+ * keyed on node id → the busy-marker mtime we already notified for. The marker
291
+ * only advances on genuine engine progress (a heartbeat) or a fresh turn
292
+ * starting, so a stale wedge that later recovers (or a brand-new turn) always
293
+ * carries a DIFFERENT mtime and is free to notify again. In-memory only, like
294
+ * every other daemon dedup latch here — a restart re-arms it (worst case: one
295
+ * repeat notice), which is the same tradeoff `notifiedTriggerFailures` and
296
+ * `unhealthySince` already accept. */
297
+ const wedgeNotifiedAt = new Map();
298
+ /** Pure: sum CPU% across a process's entire tree (itself + every descendant).
299
+ * Parses `ps -ax -o pid=,ppid=,pcpu=` output, builds a ppid→children map, and
300
+ * DFS's from `rootPid` collecting root + all descendants; malformed/blank
301
+ * lines are skipped. Returns `null` if `rootPid` never appears in the output
302
+ * (can't measure at all — treated as UNKNOWN by wedgeVerdict, never as
303
+ * “low”) or the output has no parsable rows. Exported for direct unit testing
304
+ * — no process access of its own, mirroring wedgeVerdict's purity. */
305
+ export function sumTreeCpu(psOutput, rootPid) {
306
+ const byPid = new Map(); // pid → pcpu
307
+ const childrenOf = new Map(); // ppid → child pids
308
+ for (const line of psOutput.split('\n')) {
309
+ const trimmed = line.trim();
310
+ if (trimmed === '')
311
+ continue;
312
+ const parts = trimmed.split(/\s+/);
313
+ if (parts.length < 3)
314
+ continue;
315
+ const pid = Number(parts[0]);
316
+ const ppid = Number(parts[1]);
317
+ const pcpu = Number(parts[2]);
318
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid) || !Number.isFinite(pcpu))
319
+ continue;
320
+ byPid.set(pid, pcpu);
321
+ const siblings = childrenOf.get(ppid);
322
+ if (siblings === undefined)
323
+ childrenOf.set(ppid, [pid]);
324
+ else
325
+ siblings.push(pid);
326
+ }
327
+ if (!byPid.has(rootPid))
328
+ return null;
329
+ let total = 0;
330
+ const stack = [rootPid];
331
+ const seen = new Set();
332
+ while (stack.length > 0) {
333
+ const pid = stack.pop();
334
+ if (seen.has(pid))
335
+ continue; // guard against a malformed/cyclic ps snapshot
336
+ seen.add(pid);
337
+ total += byPid.get(pid) ?? 0;
338
+ const kids = childrenOf.get(pid);
339
+ if (kids !== undefined)
340
+ stack.push(...kids);
341
+ }
342
+ return total;
343
+ }
344
+ /** Sample the broker's WHOLE PROCESS TREE — the broker pid plus every
345
+ * descendant, summed — via one `ps` shell-out, the OS-level signal that
346
+ * corroborates the heartbeat silence (see the section banner above). Null on
347
+ * ANY failure (the shell-out itself failing, `ps` missing, or `sumTreeCpu`
348
+ * finding the root pid absent/output unparseable) — wedgeVerdict treats null
349
+ * exactly like “too high”, so a broken probe can only ever suppress a false
350
+ * fire, never cause one. */
351
+ function readTreeCpuPercent(pid) {
352
+ try {
353
+ const r = spawnSync('ps', ['-ax', '-o', 'pid=,ppid=,pcpu='], { encoding: 'utf8', timeout: 2000 });
354
+ if (r.status !== 0 || typeof r.stdout !== 'string')
355
+ return null;
356
+ return sumTreeCpu(r.stdout, pid);
357
+ }
358
+ catch {
359
+ return null;
360
+ }
361
+ }
362
+ /** Enact wedge detection for a LIVE, busy node (called from the pid-alive
363
+ * branch of handleNodeLiveness, alongside handleYieldStall — the two never
364
+ * overlap in practice: yield-stall requires the turn OVER, wedge requires it
365
+ * mid-flight). Reads the heartbeat + samples CPU only when already past the
366
+ * time grace, fans one doctrine wake + records one `daemon→node` fault per
367
+ * episode, and never touches the engine itself. */
368
+ function handleWedgeDetection(id, pid, now) {
369
+ if (!isBusy(id)) {
370
+ wedgeNotifiedAt.delete(id); // turn ended (however it routed) — next turn starts a fresh episode
371
+ return;
372
+ }
373
+ const since = busySince(id);
374
+ const quietForMs = since === null ? null : now - since;
375
+ const cpuPercent = quietForMs !== null && quietForMs >= WEDGE_QUIET_MS ? readTreeCpuPercent(pid) : null;
376
+ const verdict = wedgeVerdict({ busy: true, quietForMs, cpuPercent });
377
+ if (verdict !== 'wedged')
378
+ return;
379
+ if (since !== null && wedgeNotifiedAt.get(id) === since)
380
+ return; // already notified THIS episode
381
+ if (since !== null)
382
+ wedgeNotifiedAt.set(id, since);
383
+ const meta = getNode(id);
384
+ const name = meta !== null ? fullName(meta) : id;
385
+ const minutes = Math.round((quietForMs ?? WEDGE_QUIET_MS) / 60_000);
386
+ const label = `Child wedged — ${name} (${id}) is alive and mid-turn but has shown NO engine progress for ` +
387
+ `${minutes}+ minutes and its process is near-idle. It will NOT recover on its own — read ` +
388
+ `\`crtr memory read wedged-child-on-runaway-bash\` and, if a runaway subprocess is to blame, kill ` +
389
+ `the SUBPROCESS (not this node) so its turn can resume with no lost context.`;
390
+ process.stderr.write(`[crtrd] wedged ${id} (busy ${minutes}+ min with no engine progress, cpu~0%)\n`);
391
+ try {
392
+ recordFault(id, {
393
+ link: 'daemon→node',
394
+ op: 'engine wedge',
395
+ kind: 'wedged',
396
+ retry: { disposition: 'manual' },
397
+ message: label,
398
+ });
399
+ }
400
+ catch {
401
+ /* best-effort — the doctrine wake below is the load-bearing notice */
402
+ }
403
+ try {
404
+ fanDoctrineWake(id, subscribersOf(id), label, {
405
+ reason: 'child-wedged',
406
+ child: id,
407
+ quiet_ms: quietForMs,
408
+ cpu_percent: cpuPercent,
409
+ });
410
+ }
411
+ catch {
412
+ /* best-effort, mirrors every other fan-out in this file */
413
+ }
414
+ }
415
+ // ---------------------------------------------------------------------------
212
416
  // Auth-reload fan (one /login → every live broker follows)
213
417
  // ---------------------------------------------------------------------------
214
418
  /** Path of the shared pi credentials file every broker reads. VENDORED from pi
@@ -306,7 +510,10 @@ export function livenessVerdict(piPidAlive, deadFor) {
306
510
  * the authority on refresh, and the dead-pid refresh branch below then
307
511
  * revives it fresh. Fault recovery now runs in a separate pass: live,
308
512
  * daemon-owned provider faults are retried by inbox nudge, while dead-pid
309
- * crashes still fall through the ordinary resume path below.
513
+ * crashes still fall through the ordinary resume path below. Wedge
514
+ * detection (issue #110) runs here too: handleWedgeDetection no-ops unless
515
+ * the node is BUSY (mid-turn) and its heartbeat has gone quiet past a
516
+ * generous grace, corroborated by near-zero CPU.
310
517
  * • pid null + intent==='idle-release' → dormant by choice; its pid may have
311
518
  * just been cleared by boot reconciliation, but a null pid here is still
312
519
  * that chosen dormancy, not a stranded relaunch — leave it, the second pass
@@ -337,6 +544,7 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
337
544
  if (pid != null && isPidAlive(pid)) {
338
545
  unhealthySince.delete(id);
339
546
  handleYieldStall(row, pid, now);
547
+ handleWedgeDetection(id, pid, now);
340
548
  return;
341
549
  }
342
550
  if (pid == null) {
@@ -642,7 +850,7 @@ export function readPidfile() {
642
850
  }
643
851
  // isPidAlive now lives in canvas/pid.ts (the one shared signal-0 probe at the
644
852
  // lowest layer, so canvas/, runtime/, AND daemon/ all import it down). Re-
645
- // exported here to preserve the public surface consumed by commands/daemon.ts
853
+ // exported here to preserve the public surface consumed by commands/sys/daemon.ts
646
854
  // and the grace-clock test.
647
855
  export { isPidAlive };
648
856
  /** True when a crtrd process is already running (pidfile exists + pid alive). */
@@ -1,6 +1,6 @@
1
1
  // Daemon management helpers — importable without the full command tree.
2
2
  //
3
- // spawnDaemon() is the low-level spawn call shared by `crtr canvas daemon start` and
3
+ // spawnDaemon() is the low-level spawn call shared by `crtr sys daemon start` and
4
4
  // ensureDaemon(). ensureDaemon() is the silent "start if not running" front-
5
5
  // door helper called by the canvas runtime before spawning child nodes.
6
6
  import { spawn } from 'node:child_process';
@@ -68,7 +68,7 @@ export async function spawnDaemon() {
68
68
  // Launch from the crouter-branded host binary so the daemon shows "crouter"
69
69
  // in macOS Full Disk Access, not "node" (see branded-host). NOTE: when crtrd
70
70
  // is launchd-owned, the plist's ProgramArguments must point at the branded
71
- // binary too — this path only covers a manual `crtr canvas daemon start`.
71
+ // binary too — this path only covers a manual `crtr sys daemon start`.
72
72
  const entry = resolveCrtrdEntry();
73
73
  const child = spawn(hostExecPath(), [entry], {
74
74
  detached: true,
@@ -2,9 +2,8 @@
2
2
  // a single self-contained canvas pi extension.
3
3
  //
4
4
  // Loaded into a canvas node's pi process via the node's launch.extensions list
5
- // (registered in CANVAS_EXTENSIONS at the flip — see the note at the bottom of
6
- // this file; AUTHOR-ONLY for now, NOT yet registered). INERT when CRTR_NODE_ID
7
- // is absent (a plain pi session or legacy job agent loads it as a no-op).
5
+ // (registered in CANVAS_EXTENSIONS, src/core/runtime/launch.ts). INERT when
6
+ // CRTR_NODE_ID is absent (a plain pi session or legacy job agent loads it as a no-op).
8
7
  //
9
8
  // It owns the substrate's two new pi hooks:
10
9
  //
@@ -479,8 +479,8 @@ export function registerCanvasNav(pi) {
479
479
  }
480
480
  if (isPlain(data, 'e')) {
481
481
  // Expand → tmux: spread the cursor node's local subtree into a tiled tmux
482
- // window (same action as the alt+c → e menu chord), for parity.
483
- shellCrtr(['canvas', 'tmux-spread', cursorId ?? nodeId], render);
482
+ // window (`crtr surface tmux-spread`).
483
+ shellCrtr(['surface', 'tmux-spread', cursorId ?? nodeId], render);
484
484
  render();
485
485
  return { consume: true };
486
486
  }
@@ -1,4 +1,4 @@
1
- type PiEvents = 'agent_start' | 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start' | 'tool_execution_start' | 'tool_execution_end';
1
+ type PiEvents = 'agent_start' | 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start' | 'tool_execution_start' | 'tool_execution_end' | 'tool_execution_update' | 'message_update';
2
2
  interface PiLike {
3
3
  on: (event: PiEvents, handler: (event: any, ctx: any) => void | Promise<void>) => void;
4
4
  sendUserMessage: (content: string, options?: {
@@ -30,7 +30,7 @@ import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
30
30
  import { join } from 'node:path';
31
31
  import { getNode, jobDir, updateNode, recordPid, setPresence } from '../core/canvas/index.js';
32
32
  import { transition } from '../core/runtime/lifecycle.js';
33
- import { markBusy, clearBusy } from '../core/runtime/busy.js';
33
+ import { markBusy, clearBusy, touchBusy } from '../core/runtime/busy.js';
34
34
  import { recordFault, clearFault } from '../core/runtime/fault.js';
35
35
  import { classify } from '../core/fault-classifier.js';
36
36
  import { evaluateStop } from '../core/runtime/stop-guard.js';
@@ -241,6 +241,25 @@ export function registerCanvasStophook(pi) {
241
241
  // checkpoint roadmap + yield; a resident base (root chat) to promote-or-wrap-
242
242
  // up (it has no roadmap); a terminal worker to promote / push final.
243
243
  const firedBands = new Set();
244
+ // Wedge-heartbeat throttle: message_update/tool_execution_update can fire many
245
+ // times per second while streaming, and touchBusy is a disk write — rate-limit
246
+ // it to at most once per HEARTBEAT_THROTTLE_MS. The daemon's wedge grace is
247
+ // measured in minutes, so this throttle costs it nothing. Also clears any
248
+ // stale `daemon→node` wedge fault at the same throttled cadence: a sign of
249
+ // life should both refresh the heartbeat marker AND clear a stale wedge
250
+ // notice as one coherent act, same as every coarser progress site below
251
+ // (tool_execution_start/end, turn_end, agent_end) already does. clearFault is
252
+ // a cheap no-op when no fault exists, so this stays safe to call every time.
253
+ const HEARTBEAT_THROTTLE_MS = 3_000;
254
+ let lastHeartbeatAt = 0;
255
+ const heartbeat = () => {
256
+ const now = Date.now();
257
+ if (now - lastHeartbeatAt < HEARTBEAT_THROTTLE_MS)
258
+ return;
259
+ lastHeartbeatAt = now;
260
+ touchBusy(nodeId);
261
+ clearFault(nodeId, { link: 'daemon→node' });
262
+ };
244
263
  // ---------------------------------------------------------------------------
245
264
  // session_start — capture pi's session id, and detect `/new`.
246
265
  //
@@ -336,6 +355,36 @@ export function registerCanvasStophook(pi) {
336
355
  try {
337
356
  lastActivity = summarizeActivity(String(event?.toolName ?? ''), event?.args);
338
357
  flushTelemetry(jobDirPath, totalIn, totalOut, model, lastContext, lastActivity);
358
+ touchBusy(nodeId); // wedge heartbeat: a fresh tool starting is a sign of life
359
+ clearFault(nodeId, { link: 'daemon→node' }); // a tool actually starting clears any prior wedge notice
360
+ }
361
+ catch { /* best-effort */ }
362
+ });
363
+ // ---------------------------------------------------------------------------
364
+ // tool_execution_update — wedge heartbeat + fault clear only. Fired with
365
+ // partial/streaming tool output (when a tool reports progress); throttled via
366
+ // `heartbeat()` since it can arrive many times per second. Most tools (e.g.
367
+ // bash) don't stream progress today, so this is currently a light-touch no-op
368
+ // for them — it exists so any tool that DOES stream partial output (now or in
369
+ // the future) counts as live progress instead of tripping the wedge detector,
370
+ // AND clears any wedge notice that already fired before this progress resumed.
371
+ // ---------------------------------------------------------------------------
372
+ pi.on('tool_execution_update', () => {
373
+ try {
374
+ heartbeat();
375
+ }
376
+ catch { /* best-effort */ }
377
+ });
378
+ // ---------------------------------------------------------------------------
379
+ // message_update — wedge heartbeat + fault clear only. Fired token-by-token
380
+ // while the assistant is actively streaming a reply — genuine progress,
381
+ // throttled the same way, and equally clears a stale wedge notice if streaming
382
+ // resumes before a coarser boundary. A node stuck streaming forever without
383
+ // ever firing this is exactly the daemon's busy+quiet wedge signature.
384
+ // ---------------------------------------------------------------------------
385
+ pi.on('message_update', () => {
386
+ try {
387
+ heartbeat();
339
388
  }
340
389
  catch { /* best-effort */ }
341
390
  });
@@ -353,6 +402,8 @@ export function registerCanvasStophook(pi) {
353
402
  // refreshes on its first tool — the desired outcome.)
354
403
  pi.on('tool_execution_end', (_event, ctx) => {
355
404
  try {
405
+ touchBusy(nodeId); // wedge heartbeat: the tool returning is a sign of life
406
+ clearFault(nodeId, { link: 'daemon→node' }); // a tool that was "stuck" just returned — the wedge notice was resolved
356
407
  if (getNode(nodeId)?.intent === 'refresh') {
357
408
  clearBusy(nodeId); // agent_end won't run to clear it; harmless if it lingers
358
409
  try {
@@ -422,6 +473,8 @@ export function registerCanvasStophook(pi) {
422
473
  lastContext = contextTokens;
423
474
  // Fire-and-forget: flushTelemetry uses synchronous fs writes and never throws.
424
475
  flushTelemetry(jobDirPath, totalIn, totalOut, model, contextTokens, lastActivity);
476
+ touchBusy(nodeId); // wedge heartbeat: a turn ending is a sign of life
477
+ clearFault(nodeId, { link: 'daemon→node' }); // reaching turn_end proves this turn wasn't wedged
425
478
  // Context-size steering: fire the current band once, with lifecycle-specific
426
479
  // guidance (lifecycle is read live — a terminal worker may have promoted to
427
480
  // resident since launch).
@@ -473,8 +526,12 @@ export function registerCanvasStophook(pi) {
473
526
  // removed, so the handler no longer needs to be async — the node reaches its
474
527
  // subscribers ONLY through its own explicit `crtr push` calls.
475
528
  // The turn has ended regardless of how it routes below — clear the mid-turn
476
- // marker FIRST so a focus-away from this now-parked node despawns it.
529
+ // marker FIRST so a focus-away from this now-parked node despawns it. Also
530
+ // clear any wedge notice: reaching agent_end at all proves the turn ended
531
+ // and the engine was never permanently stuck (covers an abort/error path
532
+ // that skips the tool_execution_end/turn_end clear sites above).
477
533
  clearBusy(nodeId);
534
+ clearFault(nodeId, { link: 'daemon→node' });
478
535
  try {
479
536
  const messages = Array.isArray(event?.messages) ? event.messages : [];
480
537
  // Accumulate tokens from the final batch (edge case: a turn that fired
package/dist/types.d.ts CHANGED
@@ -100,6 +100,29 @@ export interface ModelLaddersConfig {
100
100
  anthropic: Record<ModelStrength, string>;
101
101
  openai: Record<ModelStrength, string>;
102
102
  }
103
+ /** Launch metadata for one kind (a top-level kind like `developer`, or a full
104
+ * sub-kind string like `plan/reviewers/security`) — the settings-file
105
+ * replacement for per-kind persona frontmatter. `model`/`tools`/`extensions`
106
+ * are launch knobs consumed by `buildLaunchSpec` (Phase 3); `whenToUse` is the
107
+ * one-line gloss shown in `node new -h` / `node promote -h`. `availableTo` is
108
+ * VISIBILITY-ONLY (never launch validation): the list of top-level kind names
109
+ * whose spawn menus surface this sub-kind, `'*'` meaning every kind; omitted
110
+ * defaults to the sub-kind's own top-level ancestor (e.g.
111
+ * `plan/reviewers/security` defaults to `['plan']`) — this reproduces the
112
+ * original sub-persona `availableTo` semantics. A direct launch by full kind
113
+ * string is always valid regardless of `availableTo`. */
114
+ export interface KindConfig {
115
+ model?: string;
116
+ /** Model override for ORCHESTRATOR mode only, e.g. a kind whose orchestrator
117
+ * body warrants a stronger model than its base worker. Selection: base mode
118
+ * always uses `model`; orchestrator mode uses `orchestratorModel ?? model`.
119
+ * Omit when the kind runs the same model in both modes. */
120
+ orchestratorModel?: string;
121
+ tools?: string[];
122
+ extensions?: string[];
123
+ whenToUse: string;
124
+ availableTo?: string[];
125
+ }
103
126
  export interface ScopeConfig {
104
127
  schema_version: number;
105
128
  marketplaces: Record<string, ConfigMarketplaceEntry>;
@@ -108,7 +131,12 @@ export interface ScopeConfig {
108
131
  max_panes_per_window: number;
109
132
  canvasNav: CanvasNavConfig;
110
133
  modelLadders: ModelLaddersConfig;
111
- personaStrengths: Record<string, ModelStrength>;
134
+ /** The kind registry (spec §1.5): kind existence + launch knobs, keyed by
135
+ * full kind string (top-level e.g. `developer`, or sub-kind e.g.
136
+ * `plan/reviewers/security`). Builtins ship a default registry via
137
+ * `defaultScopeConfig()`; user/project `config.json` adds or shadows
138
+ * entries at the same scope precedence as the rest of `ScopeConfig`. */
139
+ kinds: Record<string, KindConfig>;
112
140
  }
113
141
  export interface ScopeState {
114
142
  marketplaces: Record<string, {
@@ -158,6 +186,23 @@ export interface InstalledMarketplace {
158
186
  url: string;
159
187
  ref: string;
160
188
  }
189
+ /** The on-disk manifest at `~/.crouter/profiles/<slug>-<id>/profile.json`
190
+ * (spec §2.1). A profile is an agent identity defined at the user root: it
191
+ * names N project directories and owns its own `memory/` store. `projects`
192
+ * are absolute, real-path-resolved directories in manifest order — the
193
+ * pointer set `findProjectScopeRoots` walks (Phase 5), after the node's own
194
+ * cwd. `name` is the mutable display name (and the gateable `profile` subject
195
+ * field, Phase 6); `<slug>-<id>` in the directory name is the stable id and
196
+ * never changes on rename. See `src/core/profiles/manifest.ts` for every
197
+ * read/write of this file — it is the sole owner of path resolution and
198
+ * mutation. */
199
+ export interface ProfileManifest {
200
+ schema_version: number;
201
+ name: string;
202
+ projects: string[];
203
+ created_at: string;
204
+ last_used_at: string | null;
205
+ }
161
206
  export declare const PLUGIN_MANIFEST_DIR = ".crouter-plugin";
162
207
  export declare const PLUGIN_MANIFEST_FILE = "plugin.json";
163
208
  export declare const MARKETPLACE_MANIFEST_DIR = ".crouter-marketplace";
@@ -166,8 +211,19 @@ export declare const CRTR_DIR_NAME = ".crouter";
166
211
  export declare const CONFIG_FILE = "config.json";
167
212
  export declare const STATE_FILE = "state.json";
168
213
  export declare const AGENTS_DIR = "agents";
214
+ export declare const PROFILE_DIR = "profiles";
169
215
  export declare const DEFAULT_MAX_PANES_PER_WINDOW = 3;
170
216
  export declare function defaultScopeConfig(): ScopeConfig;
217
+ /** The builtin kind registry (spec §1.5): the built-in defaults for every
218
+ * top-level kind and its sub-persona kinds. `whenToUse`/`model` are the
219
+ * one-per-kind defaults (not one per mode) that seed a node's spawn menu
220
+ * and default model choice. Roadmap-shaping guidance is orchestrator-only —
221
+ * an orchestrator-gated memory doc keyed to the kind, not baked into the
222
+ * registry entry. Sub-persona `availableTo` is
223
+ * omitted where it only reproduces the default (its own top-level
224
+ * ancestor) — `product/teardown` is included explicitly below for parity
225
+ * even though its value equals the default. */
226
+ export declare function defaultKindsConfig(): Record<string, KindConfig>;
171
227
  /** Built-in canvas-nav binds so an absent config still gives the intended UX.
172
228
  * prefixBinds restate the current menu defaults; graphBinds stay empty.
173
229
  */
package/dist/types.js CHANGED
@@ -17,6 +17,9 @@ export const STATE_FILE = 'state.json';
17
17
  // Subagent definitions live as flat `<name>.md` files under `<root>/agents/`,
18
18
  // for both scope roots and plugins.
19
19
  export const AGENTS_DIR = 'agents';
20
+ // Profile stores live as `<slug>-<id>/` dirs under `<user-scope-root>/profiles/`
21
+ // (never a project/builtin scope — profiles are a user-root-only concept).
22
+ export const PROFILE_DIR = 'profiles';
20
23
  export const DEFAULT_MAX_PANES_PER_WINDOW = 3;
21
24
  export function defaultScopeConfig() {
22
25
  return {
@@ -27,7 +30,86 @@ export function defaultScopeConfig() {
27
30
  max_panes_per_window: DEFAULT_MAX_PANES_PER_WINDOW,
28
31
  canvasNav: defaultCanvasNavConfig(),
29
32
  modelLadders: defaultModelLaddersConfig(),
30
- personaStrengths: {},
33
+ kinds: defaultKindsConfig(),
34
+ };
35
+ }
36
+ /** The builtin kind registry (spec §1.5): the built-in defaults for every
37
+ * top-level kind and its sub-persona kinds. `whenToUse`/`model` are the
38
+ * one-per-kind defaults (not one per mode) that seed a node's spawn menu
39
+ * and default model choice. Roadmap-shaping guidance is orchestrator-only —
40
+ * an orchestrator-gated memory doc keyed to the kind, not baked into the
41
+ * registry entry. Sub-persona `availableTo` is
42
+ * omitted where it only reproduces the default (its own top-level
43
+ * ancestor) — `product/teardown` is included explicitly below for parity
44
+ * even though its value equals the default. */
45
+ export function defaultKindsConfig() {
46
+ return {
47
+ general: {
48
+ whenToUse: 'Anything else — the catch-all worker for a task that does not fit a specialist kind.',
49
+ model: 'anthropic/strong',
50
+ },
51
+ explore: {
52
+ whenToUse: 'Map unfamiliar code or architecture — read-only orientation and code-path research with concrete file:line evidence. Anything diagnostic — investigating a failure, why something is broken or misbehaving, or live/runtime behavior — is advisor, not explore.',
53
+ model: 'anthropic/light',
54
+ },
55
+ developer: {
56
+ whenToUse: 'Implement a change — make the feature or fix genuinely work against its acceptance criteria, not merely compile.',
57
+ model: 'anthropic/medium',
58
+ orchestratorModel: 'anthropic/strong',
59
+ },
60
+ design: {
61
+ whenToUse: 'Architect a solution — produce one design document an implementer can build from without re-deciding anything left open.',
62
+ model: 'anthropic/strong',
63
+ },
64
+ plan: {
65
+ whenToUse: 'Break work into steps — turn a spec or design into a concrete, phased, parallelizable plan with every decision resolved.',
66
+ model: 'openai/strong',
67
+ },
68
+ spec: {
69
+ whenToUse: 'Collaborate with the user to discover what to build, then write the spec — behavior, non-goals, interfaces, edge cases, testable acceptance criteria.',
70
+ model: 'anthropic/strong',
71
+ },
72
+ product: {
73
+ whenToUse: 'Discover the real user need behind a request and define the product experience that meets it — use cases, how it should feel and look, grounded in how comparable products solve it. Hands off to spec.',
74
+ model: 'anthropic/strong',
75
+ },
76
+ review: {
77
+ whenToUse: 'Validate or critique code, a plan, or a spec — deliver a complete, severity-rated verdict without adjudicating.',
78
+ model: 'openai/strong',
79
+ },
80
+ advisor: {
81
+ whenToUse: 'Debug failures, investigate why something is broken or misbehaving, diagnose live/runtime issues, or give engineering advice and second opinions — reason from evidence and recommend the next move. Use advisor (not explore) whenever the task is to find out what is going wrong.',
82
+ model: 'anthropic/ultra',
83
+ },
84
+ 'plan/reviewers/requirements-coverage': {
85
+ whenToUse: 'every requirement and design constraint maps to a concrete plan task, classified Covered/Partial/Missing; flags only blocking gaps',
86
+ model: 'openai/strong',
87
+ },
88
+ 'plan/reviewers/pattern-consistency': {
89
+ whenToUse: "the plan honors the codebase's real conventions; reads actual source and cites the pattern each finding deviates from; owns contract-level conflicts between parts",
90
+ model: 'openai/strong',
91
+ },
92
+ 'plan/reviewers/code-smells': {
93
+ whenToUse: 'nullability mismatches, type conflicts across parts, hidden N+1s, over-fetching, missing error boundaries, leaky abstractions; owns file-level conflicts between parts',
94
+ model: 'openai/strong',
95
+ },
96
+ 'plan/reviewers/security': {
97
+ whenToUse: 'input validation, injection surfaces, auth/authz gaps, data exposure, races; flags only risks with a concrete exploit path',
98
+ model: 'openai/strong',
99
+ },
100
+ 'plan/reviewers/architecture-fit': {
101
+ whenToUse: "proposed files/modules/abstractions fit the system's existing decomposition; flags new units that duplicate existing ones or cross layer boundaries",
102
+ model: 'openai/strong',
103
+ },
104
+ 'spec/requirements': {
105
+ whenToUse: 'Derive testable EARS requirements from a finished, approved design — in isolation, from the rendered design text alone.',
106
+ model: 'anthropic/strong',
107
+ },
108
+ 'product/teardown': {
109
+ whenToUse: 'Tear down ONE comparable product against a specific job-to-be-done — how it solves it, where it succeeds and fails its users, and what to borrow, avoid, or differentiate from.',
110
+ model: 'anthropic/strong',
111
+ availableTo: ['product'],
112
+ },
31
113
  };
32
114
  }
33
115
  /** Built-in canvas-nav binds so an absent config still gives the intended UX.