@crouton-kit/crouter 0.3.13 → 0.3.15

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 (233) hide show
  1. package/dist/build-root.d.ts +8 -0
  2. package/dist/build-root.js +30 -0
  3. package/dist/builtin-personas/design/base.md +3 -7
  4. package/dist/builtin-personas/design/orchestrator.md +4 -3
  5. package/dist/builtin-personas/developer/base.md +3 -7
  6. package/dist/builtin-personas/developer/orchestrator.md +5 -4
  7. package/dist/builtin-personas/explore/base.md +3 -7
  8. package/dist/builtin-personas/explore/orchestrator.md +1 -5
  9. package/dist/builtin-personas/general/base.md +2 -4
  10. package/dist/builtin-personas/general/orchestrator.md +2 -4
  11. package/dist/builtin-personas/lifecycle/resident.md +2 -0
  12. package/dist/builtin-personas/lifecycle/terminal.md +6 -0
  13. package/dist/builtin-personas/orchestration-kernel.md +42 -3
  14. package/dist/builtin-personas/plan/base.md +3 -5
  15. package/dist/builtin-personas/plan/orchestrator.md +5 -4
  16. package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
  17. package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
  18. package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
  19. package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
  20. package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
  21. package/dist/builtin-personas/review/base.md +3 -5
  22. package/dist/builtin-personas/review/orchestrator.md +2 -6
  23. package/dist/builtin-personas/runtime-base.md +3 -19
  24. package/dist/builtin-personas/spec/base.md +3 -5
  25. package/dist/builtin-personas/spec/orchestrator.md +4 -3
  26. package/dist/builtin-personas/spine/has-manager.md +10 -0
  27. package/dist/builtin-personas/spine/no-manager.md +2 -0
  28. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
  29. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
  30. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
  31. package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
  32. package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
  33. package/dist/cli.js +6 -29
  34. package/dist/commands/__tests__/human.test.js +73 -2
  35. package/dist/commands/attention.js +76 -7
  36. package/dist/commands/canvas-prune.d.ts +2 -0
  37. package/dist/commands/canvas-prune.js +66 -0
  38. package/dist/commands/canvas.js +5 -8
  39. package/dist/commands/chord.d.ts +2 -0
  40. package/dist/commands/chord.js +143 -0
  41. package/dist/commands/daemon.js +8 -5
  42. package/dist/commands/dashboard.js +2 -0
  43. package/dist/commands/human/prompts.js +28 -27
  44. package/dist/commands/human/queue.d.ts +1 -0
  45. package/dist/commands/human/queue.js +105 -2
  46. package/dist/commands/human/shared.d.ts +28 -18
  47. package/dist/commands/human/shared.js +53 -60
  48. package/dist/commands/human.js +6 -14
  49. package/dist/commands/node.d.ts +11 -0
  50. package/dist/commands/node.js +381 -87
  51. package/dist/commands/pkg/market-inspect.js +6 -4
  52. package/dist/commands/pkg/market-manage.js +10 -6
  53. package/dist/commands/pkg/market.js +2 -4
  54. package/dist/commands/pkg/plugin-inspect.js +6 -4
  55. package/dist/commands/pkg/plugin-manage.js +12 -7
  56. package/dist/commands/pkg/plugin.js +2 -4
  57. package/dist/commands/pkg.js +0 -4
  58. package/dist/commands/push.js +178 -15
  59. package/dist/commands/revive.js +5 -3
  60. package/dist/commands/skill/author.js +6 -4
  61. package/dist/commands/skill/find.js +8 -5
  62. package/dist/commands/skill/read.js +2 -0
  63. package/dist/commands/skill/state.js +6 -4
  64. package/dist/commands/skill.js +0 -6
  65. package/dist/commands/sys/config.js +21 -7
  66. package/dist/commands/sys/doctor.js +2 -0
  67. package/dist/commands/sys/update.js +4 -0
  68. package/dist/commands/sys.js +0 -6
  69. package/dist/commands/tmux-spread.d.ts +2 -0
  70. package/dist/commands/tmux-spread.js +130 -0
  71. package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
  72. package/dist/core/__tests__/child-followup.test.d.ts +1 -0
  73. package/dist/core/__tests__/child-followup.test.js +83 -0
  74. package/dist/core/__tests__/close.test.d.ts +1 -0
  75. package/dist/core/__tests__/close.test.js +148 -0
  76. package/dist/core/__tests__/context-intro.test.d.ts +1 -0
  77. package/dist/core/__tests__/context-intro.test.js +196 -0
  78. package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
  79. package/dist/core/__tests__/daemon-boot.test.js +93 -0
  80. package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
  81. package/dist/core/__tests__/daemon-liveness.test.js +223 -0
  82. package/dist/core/__tests__/focuses.test.d.ts +1 -0
  83. package/dist/core/__tests__/focuses.test.js +259 -0
  84. package/dist/core/__tests__/fork.test.d.ts +1 -0
  85. package/dist/core/__tests__/fork.test.js +91 -0
  86. package/dist/core/__tests__/home-session.test.d.ts +1 -0
  87. package/dist/core/__tests__/home-session.test.js +153 -0
  88. package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
  89. package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
  90. package/dist/core/__tests__/keystone.test.d.ts +1 -0
  91. package/dist/core/__tests__/keystone.test.js +185 -0
  92. package/dist/core/__tests__/kickoff.test.d.ts +1 -0
  93. package/dist/core/__tests__/kickoff.test.js +89 -0
  94. package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
  95. package/dist/core/__tests__/lifecycle.test.js +178 -0
  96. package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
  97. package/dist/core/__tests__/listing-completeness.test.js +31 -0
  98. package/dist/core/__tests__/memory.test.d.ts +1 -0
  99. package/dist/core/__tests__/memory.test.js +152 -0
  100. package/dist/core/__tests__/migration.test.d.ts +1 -0
  101. package/dist/core/__tests__/migration.test.js +238 -0
  102. package/dist/core/__tests__/pane-column.test.d.ts +1 -0
  103. package/dist/core/__tests__/pane-column.test.js +153 -0
  104. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  105. package/dist/core/__tests__/passive-subscription.test.js +164 -0
  106. package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
  107. package/dist/core/__tests__/persona-compose.test.js +53 -0
  108. package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
  109. package/dist/core/__tests__/persona-subkind.test.js +62 -0
  110. package/dist/core/__tests__/persona.test.d.ts +1 -0
  111. package/dist/core/__tests__/persona.test.js +107 -0
  112. package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
  113. package/dist/core/__tests__/placement-focus.test.js +244 -0
  114. package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
  115. package/dist/core/__tests__/placement-reconcile.test.js +212 -0
  116. package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
  117. package/dist/core/__tests__/placement-revive.test.js +238 -0
  118. package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
  119. package/dist/core/__tests__/placement-teardown.test.js +183 -0
  120. package/dist/core/__tests__/prune.test.d.ts +1 -0
  121. package/dist/core/__tests__/prune.test.js +116 -0
  122. package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
  123. package/dist/core/__tests__/push-final-guard.test.js +71 -0
  124. package/dist/core/__tests__/relaunch.test.d.ts +1 -0
  125. package/dist/core/__tests__/relaunch.test.js +328 -0
  126. package/dist/core/__tests__/reset.test.js +26 -7
  127. package/dist/core/__tests__/revive.test.d.ts +1 -0
  128. package/dist/core/__tests__/revive.test.js +217 -0
  129. package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
  130. package/dist/core/__tests__/spawn-root.test.js +73 -0
  131. package/dist/core/__tests__/steer-note.test.d.ts +1 -0
  132. package/dist/core/__tests__/steer-note.test.js +39 -0
  133. package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
  134. package/dist/core/__tests__/stop-guard.test.js +82 -0
  135. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  136. package/dist/core/__tests__/subcommand-tier.test.js +99 -0
  137. package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
  138. package/dist/core/__tests__/tmux-surface.test.js +106 -0
  139. package/dist/core/__tests__/unknown-path.test.js +8 -2
  140. package/dist/core/canvas/attention.d.ts +10 -0
  141. package/dist/core/canvas/attention.js +40 -0
  142. package/dist/core/canvas/canvas.d.ts +66 -7
  143. package/dist/core/canvas/canvas.js +209 -21
  144. package/dist/core/canvas/db.d.ts +8 -0
  145. package/dist/core/canvas/db.js +206 -4
  146. package/dist/core/canvas/focuses.d.ts +22 -0
  147. package/dist/core/canvas/focuses.js +80 -0
  148. package/dist/core/canvas/index.d.ts +3 -0
  149. package/dist/core/canvas/index.js +3 -0
  150. package/dist/core/canvas/labels.d.ts +27 -0
  151. package/dist/core/canvas/labels.js +36 -0
  152. package/dist/core/canvas/paths.d.ts +4 -0
  153. package/dist/core/canvas/paths.js +6 -0
  154. package/dist/core/canvas/render.js +25 -10
  155. package/dist/core/canvas/telemetry.d.ts +14 -0
  156. package/dist/core/canvas/telemetry.js +35 -0
  157. package/dist/core/canvas/types.d.ts +115 -12
  158. package/dist/core/command.d.ts +25 -1
  159. package/dist/core/command.js +48 -7
  160. package/dist/core/config.js +36 -2
  161. package/dist/core/feed/feed.js +14 -12
  162. package/dist/core/feed/inbox.d.ts +3 -1
  163. package/dist/core/feed/inbox.js +45 -5
  164. package/dist/core/feed/passive.d.ts +17 -0
  165. package/dist/core/feed/passive.js +92 -0
  166. package/dist/core/help.d.ts +59 -13
  167. package/dist/core/help.js +73 -28
  168. package/dist/core/personas/index.d.ts +1 -1
  169. package/dist/core/personas/index.js +1 -1
  170. package/dist/core/personas/loader.d.ts +40 -1
  171. package/dist/core/personas/loader.js +63 -1
  172. package/dist/core/personas/resolve.d.ts +13 -6
  173. package/dist/core/personas/resolve.js +46 -34
  174. package/dist/core/runtime/bearings.d.ts +20 -0
  175. package/dist/core/runtime/bearings.js +92 -0
  176. package/dist/core/runtime/close.d.ts +14 -0
  177. package/dist/core/runtime/close.js +151 -0
  178. package/dist/core/runtime/demote.d.ts +14 -0
  179. package/dist/core/runtime/demote.js +120 -0
  180. package/dist/core/runtime/front-door.js +1 -1
  181. package/dist/core/runtime/kickoff.d.ts +32 -6
  182. package/dist/core/runtime/kickoff.js +111 -37
  183. package/dist/core/runtime/launch.d.ts +29 -6
  184. package/dist/core/runtime/launch.js +85 -13
  185. package/dist/core/runtime/lifecycle.d.ts +13 -0
  186. package/dist/core/runtime/lifecycle.js +86 -0
  187. package/dist/core/runtime/memory.d.ts +43 -0
  188. package/dist/core/runtime/memory.js +165 -0
  189. package/dist/core/runtime/naming.d.ts +22 -0
  190. package/dist/core/runtime/naming.js +166 -0
  191. package/dist/core/runtime/nodes.d.ts +32 -1
  192. package/dist/core/runtime/nodes.js +60 -10
  193. package/dist/core/runtime/persona.d.ts +25 -0
  194. package/dist/core/runtime/persona.js +139 -0
  195. package/dist/core/runtime/placement.d.ts +287 -0
  196. package/dist/core/runtime/placement.js +663 -0
  197. package/dist/core/runtime/presence.d.ts +7 -32
  198. package/dist/core/runtime/presence.js +90 -110
  199. package/dist/core/runtime/promote.d.ts +18 -7
  200. package/dist/core/runtime/promote.js +70 -65
  201. package/dist/core/runtime/reset.d.ts +47 -4
  202. package/dist/core/runtime/reset.js +223 -52
  203. package/dist/core/runtime/revive.d.ts +26 -2
  204. package/dist/core/runtime/revive.js +166 -39
  205. package/dist/core/runtime/roadmap.d.ts +5 -4
  206. package/dist/core/runtime/roadmap.js +9 -16
  207. package/dist/core/runtime/spawn.d.ts +20 -5
  208. package/dist/core/runtime/spawn.js +169 -44
  209. package/dist/core/runtime/stop-guard.d.ts +1 -1
  210. package/dist/core/runtime/stop-guard.js +18 -8
  211. package/dist/core/runtime/tmux.d.ts +106 -21
  212. package/dist/core/runtime/tmux.js +249 -45
  213. package/dist/core/spawn.js +15 -0
  214. package/dist/daemon/crtrd.d.ts +12 -1
  215. package/dist/daemon/crtrd.js +152 -34
  216. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
  217. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
  218. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  219. package/dist/pi-extensions/canvas-commands.js +103 -0
  220. package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
  221. package/dist/pi-extensions/canvas-context-intro.js +164 -0
  222. package/dist/pi-extensions/canvas-goal-capture.d.ts +21 -0
  223. package/dist/pi-extensions/canvas-goal-capture.js +67 -0
  224. package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
  225. package/dist/pi-extensions/canvas-nav.d.ts +12 -4
  226. package/dist/pi-extensions/canvas-nav.js +586 -262
  227. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  228. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  229. package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
  230. package/dist/pi-extensions/canvas-stophook.js +344 -228
  231. package/dist/types.d.ts +28 -0
  232. package/dist/types.js +16 -0
  233. package/package.json +1 -1
@@ -7,6 +7,8 @@ import { readText } from '../../core/fs-utils.js';
7
7
  import { appendNeighbors } from './shared.js';
8
8
  export const readLeaf = defineLeaf({
9
9
  name: 'read',
10
+ description: 'load SKILL.md body + metadata for a named skill',
11
+ whenToUse: 'a task in front of you matches a skill that is already loaded — read it BEFORE you start improvising, not after. Reach for it the moment you are about to do something a skill covers: adopting a documented workflow that fits the task at hand, following a methodology before writing a spec or a plan, picking up the house conventions for a tool or framework, replaying a runbook for an operation you have run before. Takes the crtr skill name as a positional (a crtr identifier, never a file path — do not cat or find SKILL.md off disk) and returns the SKILL.md body plus its resolution metadata; add --no-body to just confirm a skill exists or locate it. Reach for `crtr skill find` first when you do not yet know which skill applies.',
10
12
  help: {
11
13
  name: 'skill read',
12
14
  summary: 'load SKILL.md body and resolution metadata for a named skill',
@@ -19,6 +19,8 @@ async function toggleSkill(input, enabled) {
19
19
  }
20
20
  export const stateEnable = defineLeaf({
21
21
  name: 'enable',
22
+ description: 'enable a skill',
23
+ whenToUse: 're-enable a skill that was previously disabled, making it visible to list and agent discovery again in the target scope.',
22
24
  help: {
23
25
  name: 'skill state enable',
24
26
  summary: 'enable a skill in the given scope',
@@ -38,6 +40,8 @@ export const stateEnable = defineLeaf({
38
40
  });
39
41
  export const stateDisable = defineLeaf({
40
42
  name: 'disable',
43
+ description: 'disable a skill',
44
+ whenToUse: 'hide a skill from list and agent discovery without deleting it — writes a disable flag to config.json in the target scope; reverse it later with `crtr skill state enable`.',
41
45
  help: {
42
46
  name: 'skill state disable',
43
47
  summary: 'disable a skill in the given scope, hiding it from list and agent discovery',
@@ -57,13 +61,11 @@ export const stateDisable = defineLeaf({
57
61
  });
58
62
  export const stateBranch = defineBranch({
59
63
  name: 'state',
64
+ description: 'enable or disable skills',
65
+ whenToUse: 'turn a skill on or off in a scope — disable to hide it from discovery without removing it, enable to bring it back.',
60
66
  help: {
61
67
  name: 'skill state',
62
68
  summary: 'enable or disable skills',
63
- children: [
64
- { name: 'enable', desc: 'enable a skill', useWhen: 'making a previously disabled skill available again' },
65
- { name: 'disable', desc: 'disable a skill', useWhen: 'hiding a skill from list and agent discovery without removing it' },
66
- ],
67
69
  },
68
70
  children: [stateEnable, stateDisable],
69
71
  });
@@ -21,12 +21,6 @@ export function registerSkill() {
21
21
  summary: 'discover, read, author, and manage skill state',
22
22
  model: '`find` when you do not yet know which skill applies — it locates candidates by topic, keyword, or body text. `read` (leaf) loads SKILL.md by name; takes the name as a positional, returns body + metadata, accepts --no-body to skip the body. `author` when you are writing a new skill — it carries the template workflow and the scaffolder. `state` when a skill should be hidden from discovery without being removed. Append `-h` at any branch or leaf for its full schema.',
23
23
  dynamicState: buildSkillCatalog,
24
- children: [
25
- { name: 'find', desc: 'list, search, or grep skills', useWhen: 'discovering what skills are available' },
26
- { name: 'read', desc: 'load SKILL.md body + metadata for a named skill', useWhen: 'loading a skill to act on it' },
27
- { name: 'author', desc: 'create and scaffold skills', useWhen: 'writing a new skill' },
28
- { name: 'state', desc: 'enable or disable skills', useWhen: 'toggling skill visibility' },
29
- ],
30
24
  },
31
25
  children: [findBranch, readLeaf, authorBranch, stateBranch],
32
26
  });
@@ -11,6 +11,7 @@ const TOP_LEVEL_KEYS = new Set([
11
11
  'marketplaces',
12
12
  'plugins',
13
13
  'max_panes_per_window',
14
+ 'canvasNav',
14
15
  ]);
15
16
  function getNestedValue(obj, key) {
16
17
  const parts = key.split('.');
@@ -59,6 +60,16 @@ function setNestedValue(cfg, key, value) {
59
60
  cfg.max_panes_per_window = Math.floor(value);
60
61
  return;
61
62
  }
63
+ if (topKey === 'canvasNav') {
64
+ // Only the scalar prefixKey is settable here; the prefixBinds/graphBinds
65
+ // record tables are edited directly in config.json (run `crtr sys config
66
+ // path` to locate it).
67
+ if (key === 'canvasNav.prefixKey') {
68
+ cfg.canvasNav.prefixKey = String(value);
69
+ return;
70
+ }
71
+ throw usage(`canvasNav.${parts.slice(1).join('.') || '*'} is a record table — edit config.json directly (run \`crtr sys config path\`). Only canvasNav.prefixKey is settable via this command.`);
72
+ }
62
73
  if (parts.length === 1) {
63
74
  cfg[topKey] = value;
64
75
  return;
@@ -75,11 +86,13 @@ function setNestedValue(cfg, key, value) {
75
86
  // ---------------------------------------------------------------------------
76
87
  const configGet = defineLeaf({
77
88
  name: 'get',
89
+ description: 'read a config value by key',
90
+ whenToUse: 'you want to read a single config value by its dotted key — the current auto_update policy, max_panes_per_window, the canvasNav prefix key — optionally from a specific scope. Use sys config set instead to change a value, sys config path to locate the file for hand-editing.',
78
91
  help: {
79
92
  name: 'sys config get',
80
93
  summary: 'read a config value by dotted key',
81
94
  params: [
82
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window.' },
95
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window, canvasNav (read whole; edit canvasNav.prefixBinds/graphBinds in config.json directly).' },
83
96
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project', 'all'], required: false, constraint: 'Scope to read from. Default: user.' },
84
97
  ],
85
98
  output: [
@@ -103,11 +116,13 @@ const configGet = defineLeaf({
103
116
  });
104
117
  const configSet = defineLeaf({
105
118
  name: 'set',
119
+ description: 'write a config value by key',
120
+ whenToUse: 'you want to change a crtr setting — flip auto_update.crtr or auto_update.content to notify, apply, or off, raise max_panes_per_window, rebind canvasNav.prefixKey — written to the user or project scope. Use sys config get instead to read a value; the canvasNav record tables are not settable here, so edit config.json directly (sys config path) for those.',
106
121
  help: {
107
122
  name: 'sys config set',
108
123
  summary: 'write a config value by dotted key',
109
124
  params: [
110
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window.' },
125
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window, canvasNav.prefixKey. The canvasNav.prefixBinds/graphBinds record tables are not settable here — edit config.json directly (`crtr sys config path`).' },
111
126
  { kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'value VALUE — string, required. Stored as-is if quoted; coerced to number or boolean when unambiguous.' },
112
127
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to write to. Default: user.' },
113
128
  ],
@@ -135,6 +150,8 @@ const configSet = defineLeaf({
135
150
  });
136
151
  const configPath = defineLeaf({
137
152
  name: 'path',
153
+ description: 'print path(s) to config.json',
154
+ whenToUse: 'you need the absolute path to config.json — typically to hand-edit settings sys config set cannot reach, like the canvasNav.prefixBinds and graphBinds record tables.',
138
155
  help: {
139
156
  name: 'sys config path',
140
157
  summary: 'print absolute path(s) to config.json',
@@ -173,14 +190,11 @@ const configPath = defineLeaf({
173
190
  });
174
191
  export const configBranch = defineBranch({
175
192
  name: 'config',
193
+ description: 'read and write configuration',
194
+ whenToUse: 'inspecting or changing crtr settings — read a value with sys config get, change one with sys config set (auto_update policy, max_panes_per_window, canvasNav.prefixKey), or locate config.json with sys config path to hand-edit the record tables set cannot reach.',
176
195
  help: {
177
196
  name: 'sys config',
178
197
  summary: 'read and write crtr configuration',
179
- children: [
180
- { name: 'get', desc: 'read a config value by key', useWhen: 'inspecting current configuration' },
181
- { name: 'set', desc: 'write a config value by key', useWhen: 'changing a configuration setting' },
182
- { name: 'path', desc: 'print path(s) to config.json', useWhen: 'locating the config file for manual inspection' },
183
- ],
184
198
  },
185
199
  children: [configGet, configSet, configPath],
186
200
  });
@@ -325,6 +325,8 @@ function runChecksForScope(scope, opts) {
325
325
  }
326
326
  export const sysDoctorLeaf = defineLeaf({
327
327
  name: 'doctor',
328
+ description: 'diagnose installation health',
329
+ whenToUse: 'something in your crtr install looks off and you want it diagnosed — a plugin or marketplace manifest is missing, a config entry points at a directory that no longer exists, or skill frontmatter has drifted from its filename. Reports each problem with a structured remediation; pass --fix to apply the repairs.',
328
330
  help: {
329
331
  name: 'sys doctor',
330
332
  summary: 'diagnose missing manifests, broken config entries, and skill frontmatter drift',
@@ -6,6 +6,8 @@ import { nowIso } from '../../core/fs-utils.js';
6
6
  import { readPackageVersion } from './shared.js';
7
7
  export const sysUpdateLeaf = defineLeaf({
8
8
  name: 'update',
9
+ description: 'update binary and content',
10
+ whenToUse: 'upgrading crtr or its installed plugins and marketplaces — pull the latest binary, refresh installed content, or both; pass --check first to see what is out of date without applying anything.',
9
11
  help: {
10
12
  name: 'sys update',
11
13
  summary: 'update the crtr binary and/or installed plugins and marketplaces',
@@ -98,6 +100,8 @@ export const sysUpdateLeaf = defineLeaf({
98
100
  });
99
101
  export const sysVersionLeaf = defineLeaf({
100
102
  name: 'version',
103
+ description: 'print installed version',
104
+ whenToUse: 'you need the installed crtr version — to report it in a bug or confirm an upgrade landed.',
101
105
  help: {
102
106
  name: 'sys version',
103
107
  summary: 'print the installed crtr version',
@@ -15,12 +15,6 @@ export function registerSys() {
15
15
  help: {
16
16
  name: 'sys',
17
17
  summary: 'crtr system configuration, diagnostics, and self-management',
18
- children: [
19
- { name: 'config', desc: 'read and write configuration', useWhen: 'inspecting or changing crtr settings' },
20
- { name: 'doctor', desc: 'diagnose installation health', useWhen: 'troubleshooting missing manifests or broken config' },
21
- { name: 'update', desc: 'update binary and content', useWhen: 'upgrading crtr or its installed plugins/marketplaces' },
22
- { name: 'version', desc: 'print installed version', useWhen: 'checking which version of crtr is installed' },
23
- ],
24
18
  },
25
19
  children: [configBranch, sysDoctorLeaf, sysUpdateLeaf, sysVersionLeaf],
26
20
  });
@@ -0,0 +1,2 @@
1
+ import type { LeafDef } from '../core/command.js';
2
+ export declare const tmuxSpreadLeaf: LeafDef;
@@ -0,0 +1,130 @@
1
+ // `crtr canvas tmux-spread <id>` — expand a node + its live children into one
2
+ // tiled tmux window: the target wide in the LEFT (main) pane, its live children
3
+ // stacked as panes on the RIGHT, then grab focus.
4
+ //
5
+ // Reuses the presence/tmux machinery: revive a dormant target/child so it has a
6
+ // live pane, `join-pane` each child's existing pane into the target's window
7
+ // (preserving its running pi), `select-layout main-vertical`, then focus.
8
+ //
9
+ // CRITICAL fix-up: a joined child physically changes windows, so its
10
+ // meta.{tmux_session,window} goes stale — `windowAlive` would then report it
11
+ // dormant and the daemon would spuriously revive it. After each join we
12
+ // re-derive the child's location from its (stable) pane id and updateNode it,
13
+ // mirroring the swap fix-up in presence.ts.
14
+ import { defineLeaf } from '../core/command.js';
15
+ import { InputError } from '../core/io.js';
16
+ import { readConfig } from '../core/config.js';
17
+ import { reviveNode } from '../core/runtime/revive.js';
18
+ import { isNodePaneAlive, spreadNode } from '../core/runtime/placement.js';
19
+ import { inTmux } from '../core/runtime/tmux.js';
20
+ import { nodeInPane } from './node.js';
21
+ import { getNode, subscriptionsOf } from '../core/canvas/index.js';
22
+ export const tmuxSpreadLeaf = defineLeaf({
23
+ name: 'tmux-spread',
24
+ description: 'tile a node + its live children into one window and focus it',
25
+ whenToUse: 'tiling a node and its live children into one window — the node wide on the left, its workers stacked on the right — to watch an orchestrator and its team together (alt+c → e / GRAPH e)',
26
+ help: {
27
+ name: 'canvas tmux-spread',
28
+ summary: 'tile a node and its live children into one window — target wide on the left, children stacked on the right — and focus it. Revives dormant nodes first; caps children by max_panes_per_window.',
29
+ params: [
30
+ {
31
+ kind: 'positional',
32
+ name: 'node',
33
+ required: false,
34
+ constraint: 'Node id to spread. Defaults to the node occupying --pane (or your current pane).',
35
+ },
36
+ {
37
+ kind: 'flag',
38
+ name: 'pane',
39
+ type: 'string',
40
+ required: false,
41
+ constraint: 'tmux pane id to resolve the node from when no positional is given. The alt+c menu passes this for you.',
42
+ },
43
+ ],
44
+ output: [
45
+ { name: 'window', type: 'string', required: false, constraint: 'The window all panes were tiled into.' },
46
+ { name: 'session', type: 'string', required: false, constraint: 'The tmux session that window lives in.' },
47
+ { name: 'children_joined', type: 'string[]', required: true, constraint: 'Child node ids whose panes were joined into the window.' },
48
+ { name: 'overflow', type: 'string[]', required: true, constraint: 'Live children left out because max_panes_per_window was reached.' },
49
+ { name: 'focused', type: 'boolean', required: true, constraint: 'True when the window was brought forefront.' },
50
+ ],
51
+ outputKind: 'object',
52
+ effects: [
53
+ 'Revives the target (and joined children) if dormant — opens tmux windows running pi.',
54
+ 'Moves each joined child\'s pane into the target window (join-pane) and re-points its canvas record to the new window.',
55
+ 'Applies a main-vertical layout and focuses the window.',
56
+ ],
57
+ },
58
+ run: async (input) => {
59
+ if (!inTmux()) {
60
+ throw new InputError({
61
+ error: 'not_in_tmux',
62
+ message: 'tmux-spread needs a tmux server (no $TMUX)',
63
+ next: 'Run from inside the shared crtr tmux session.',
64
+ });
65
+ }
66
+ const pane = input['pane'];
67
+ let id = input['node'];
68
+ if (id === undefined || id === '')
69
+ id = nodeInPane(pane);
70
+ if (id === undefined || id === '') {
71
+ throw new InputError({
72
+ error: 'no_node',
73
+ message: 'no node to spread (pass a node id, or run from inside its pane)',
74
+ next: 'Pass `crtr canvas tmux-spread <id>` or --pane <pane-id>.',
75
+ });
76
+ }
77
+ if (getNode(id) === null) {
78
+ throw new InputError({
79
+ error: 'not_found',
80
+ message: `no node: ${id}`,
81
+ next: 'List nodes with `crtr node inspect list`.',
82
+ });
83
+ }
84
+ // 1. Revive the target if it has no live pane (placement is pane-keyed).
85
+ if (!isNodePaneAlive(id)) {
86
+ try {
87
+ reviveNode(id, { resume: true });
88
+ }
89
+ catch { /* fall through */ }
90
+ }
91
+ if (!isNodePaneAlive(id)) {
92
+ throw new InputError({
93
+ error: 'no_window',
94
+ message: `could not open a live window for ${id}`,
95
+ next: 'Try `crtr canvas revive <id>` then retry.',
96
+ });
97
+ }
98
+ // 2. Live children, capped: the target owns one pane, so up to max-1 join.
99
+ const max = readConfig('user').max_panes_per_window;
100
+ const budget = Math.max(0, max - 1);
101
+ const liveChildren = subscriptionsOf(id)
102
+ .map((r) => r.node_id)
103
+ .filter((cid) => {
104
+ const s = getNode(cid)?.status;
105
+ return s === 'active' || s === 'idle';
106
+ });
107
+ const selected = liveChildren.slice(0, budget);
108
+ const overflow = liveChildren.slice(budget);
109
+ // 3. Revive any dormant selected child so it has a live pane, then hand the
110
+ // join + pane-fix-up (reconcile FOLLOWS each joined pane) + layout + focus
111
+ // to placement.
112
+ for (const cid of selected) {
113
+ if (!isNodePaneAlive(cid)) {
114
+ try {
115
+ reviveNode(cid, { resume: true });
116
+ }
117
+ catch { /* skip on failure */ }
118
+ }
119
+ }
120
+ const spread = spreadNode(id, selected);
121
+ return {
122
+ window: spread.window,
123
+ session: spread.session,
124
+ children_joined: spread.joined,
125
+ overflow,
126
+ focused: spread.focused,
127
+ };
128
+ },
129
+ render: (r) => `<spread node window="${r['window'] ?? ''}" joined="${r['children_joined'].length}" overflow="${r['overflow'].length}" focused="${r['focused']}"/>`,
130
+ });
@@ -11,6 +11,8 @@ import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
13
13
  import { appendInbox } from '../feed/inbox.js';
14
+ import { createNode, setIntent } from '../canvas/canvas.js';
15
+ import { closeDb } from '../canvas/db.js';
14
16
  // Mirror the watcher's internal cadence (TICK_MS=800, DEBOUNCE_MS=1000): allow a
15
17
  // resolve+seed tick, a read tick, and the debounce window before asserting.
16
18
  const TICK_MS = 800;
@@ -86,6 +88,29 @@ describe('canvas inbox watcher — finished-node delivery', () => {
86
88
  assert.equal(pi.injected.length, 1);
87
89
  assert.equal(pi.injected[0].deliverAs, 'followUp', 'a normal update is not urgent → followUp');
88
90
  });
91
+ test('refresh-yield in flight: inbox entries are HELD, then delivered once intent clears (no loss)', async () => {
92
+ freshNode('node-refresh');
93
+ closeDb(); // rebind the canvas db to this test's fresh home
94
+ const meta = {
95
+ node_id: 'node-refresh', name: 'r', created: new Date().toISOString(),
96
+ cwd: '/tmp', kind: 'general', mode: 'base', lifecycle: 'resident',
97
+ status: 'active', intent: 'refresh',
98
+ };
99
+ createNode(meta);
100
+ const pi = makeFakePi();
101
+ disposers.push(registerCanvasInboxWatcher(pi));
102
+ // Streaming (mid-turn) when a child finishes — normally this would steer.
103
+ pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
104
+ await wait(TICK_MS + 100);
105
+ appendInbox('node-refresh', { from: 'child-x', tier: 'urgent', kind: 'final', label: 'done' });
106
+ await wait(SETTLE_MS);
107
+ assert.equal(pi.injected.length, 0, 'entries are held while a refresh-yield is in flight (no steer-hijack)');
108
+ // The fresh pi clears intent on boot; the held entry must then be delivered
109
+ // — the cursor was never advanced, so nothing is lost.
110
+ setIntent('node-refresh', null);
111
+ await wait(SETTLE_MS);
112
+ assert.equal(pi.injected.length, 1, 'the held entry is delivered once the refresh clears');
113
+ });
89
114
  test('idle: a finished node triggers a fresh turn (no deliverAs)', async () => {
90
115
  freshNode('node-idle');
91
116
  const pi = makeFakePi();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import { test, before, after, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createNode } from '../canvas/canvas.js';
7
+ import { closeDb } from '../canvas/db.js';
8
+ import { jobDir } from '../canvas/paths.js';
9
+ import { readContextTokens } from '../canvas/telemetry.js';
10
+ import { childFollowUp } from '../../commands/node.js';
11
+ let home;
12
+ function node(id, over = {}) {
13
+ return {
14
+ node_id: id,
15
+ name: id,
16
+ created: new Date().toISOString(),
17
+ cwd: '/tmp/work',
18
+ kind: 'general',
19
+ mode: 'base',
20
+ lifecycle: 'terminal',
21
+ status: 'active',
22
+ ...over,
23
+ };
24
+ }
25
+ /** Write a telemetry.json with a live context gauge for a node. */
26
+ function writeTelemetry(id, over) {
27
+ const dir = jobDir(id);
28
+ mkdirSync(dir, { recursive: true });
29
+ writeFileSync(join(dir, 'telemetry.json'), JSON.stringify({ tokens_in: 0, tokens_out: 0, model: 'm', updated_at: new Date().toISOString(), ...over }));
30
+ }
31
+ before(() => {
32
+ home = mkdtempSync(join(tmpdir(), 'crtr-followup-'));
33
+ process.env['CRTR_HOME'] = home;
34
+ });
35
+ beforeEach(() => {
36
+ closeDb();
37
+ rmSync(home, { recursive: true, force: true });
38
+ });
39
+ after(() => {
40
+ closeDb();
41
+ rmSync(home, { recursive: true, force: true });
42
+ delete process.env['CRTR_HOME'];
43
+ });
44
+ const STD = 'Two moves only';
45
+ const YIELD = 'crtr node yield';
46
+ test('readContextTokens: prefers context_tokens, falls back to tokens_in, else null', () => {
47
+ createNode(node('n1'));
48
+ writeTelemetry('n1', { context_tokens: 120_000, tokens_in: 5_000 });
49
+ assert.equal(readContextTokens('n1'), 120_000);
50
+ createNode(node('n2'));
51
+ writeTelemetry('n2', { tokens_in: 42_000 });
52
+ assert.equal(readContextTokens('n2'), 42_000);
53
+ createNode(node('n3'));
54
+ assert.equal(readContextTokens('n3'), null); // no telemetry at all
55
+ });
56
+ test('childFollowUp: orchestrator past 100k → yield nudge with rounded k', () => {
57
+ createNode(node('orch', { mode: 'orchestrator' }));
58
+ writeTelemetry('orch', { context_tokens: 134_500 });
59
+ const msg = childFollowUp('orch');
60
+ assert.match(msg, /crtr node yield/);
61
+ assert.match(msg, /~135k/); // rounded
62
+ assert.doesNotMatch(msg, /Two moves only/);
63
+ });
64
+ test('childFollowUp: orchestrator below 100k → standard road sign', () => {
65
+ createNode(node('orch', { mode: 'orchestrator' }));
66
+ writeTelemetry('orch', { context_tokens: 80_000 });
67
+ assert.match(childFollowUp('orch'), new RegExp(STD));
68
+ assert.doesNotMatch(childFollowUp('orch'), new RegExp(YIELD));
69
+ });
70
+ test('childFollowUp: base spawner past 100k → standard (only orchestrators yield)', () => {
71
+ createNode(node('base', { mode: 'base' }));
72
+ writeTelemetry('base', { context_tokens: 150_000 });
73
+ assert.match(childFollowUp('base'), new RegExp(STD));
74
+ });
75
+ test('childFollowUp: orchestrator with no telemetry → standard (unknown size)', () => {
76
+ createNode(node('orch', { mode: 'orchestrator' }));
77
+ assert.match(childFollowUp('orch'), new RegExp(STD));
78
+ });
79
+ test('childFollowUp: missing / undefined spawner → standard', () => {
80
+ assert.match(childFollowUp(undefined), new RegExp(STD));
81
+ assert.match(childFollowUp(''), new RegExp(STD));
82
+ assert.match(childFollowUp('ghost'), new RegExp(STD)); // no such node
83
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,148 @@
1
+ import { test, before, after, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
7
+ import { openFocusRow, getFocusByNode } from '../canvas/focuses.js';
8
+ import { closeDb } from '../canvas/db.js';
9
+ import { closeNode } from '../runtime/close.js';
10
+ import { readInboxSince } from '../feed/inbox.js';
11
+ let home;
12
+ function node(id, over = {}) {
13
+ return {
14
+ node_id: id,
15
+ name: id,
16
+ created: new Date().toISOString(),
17
+ cwd: '/tmp/work',
18
+ kind: 'general',
19
+ mode: 'base',
20
+ lifecycle: 'terminal',
21
+ status: 'active',
22
+ ...over,
23
+ };
24
+ }
25
+ /** Wire `parent subscribes_to child` — the spawn-time edge (parent wakes on the
26
+ * child's pushes). The child is the publisher/down, the parent the manager/up. */
27
+ function spawnEdge(parent, child) {
28
+ subscribe(parent, child, true);
29
+ }
30
+ before(() => {
31
+ home = mkdtempSync(join(tmpdir(), 'crtr-close-'));
32
+ process.env['CRTR_HOME'] = home;
33
+ });
34
+ beforeEach(() => {
35
+ closeDb();
36
+ rmSync(home, { recursive: true, force: true });
37
+ });
38
+ after(() => {
39
+ closeDb();
40
+ rmSync(home, { recursive: true, force: true });
41
+ delete process.env['CRTR_HOME'];
42
+ });
43
+ test('closes the root and its exclusive descendants; spares a shared node', () => {
44
+ // N ─▶ A ─▶ C (A, C exclusive to the N subtree)
45
+ // N ─▶ B ◀─ M (B also subscribed to by external manager M)
46
+ for (const id of ['N', 'A', 'B', 'C', 'M'])
47
+ createNode(node(id, { pane: `%${id.toLowerCase()}` }));
48
+ spawnEdge('N', 'A');
49
+ spawnEdge('N', 'B');
50
+ spawnEdge('A', 'C');
51
+ spawnEdge('M', 'B'); // external manager keeps B alive
52
+ const res = closeNode('N');
53
+ assert.deepEqual([...res.closed].sort(), ['A', 'C', 'N']);
54
+ assert.deepEqual(res.spared, ['B']);
55
+ // Closed nodes → canceled, intent cleared, presence (incl. the pane) dropped.
56
+ for (const id of ['N', 'A', 'C']) {
57
+ const m = getNode(id);
58
+ assert.equal(m.status, 'canceled', `${id} canceled`);
59
+ assert.equal(m.intent, null, `${id} intent cleared`);
60
+ assert.equal(m.window ?? null, null, `${id} window cleared`);
61
+ assert.equal(m.tmux_session ?? null, null, `${id} session cleared`);
62
+ // Step 7: tearDownNode is pane-keyed and nulls the durable pane handle too.
63
+ // Non-vacuous: each node was given a pane (`%n`/`%a`/`%c`) above, so an impl
64
+ // that didn't route close through tearDownNode would leave it set.
65
+ assert.equal(m.pane ?? null, null, `${id} pane cleared`);
66
+ }
67
+ // Spared node and the unrelated manager are untouched.
68
+ assert.equal(getNode('B').status, 'active');
69
+ assert.equal(getNode('M').status, 'active');
70
+ });
71
+ test('kill order is leaves-first, root-last ("cascades up")', () => {
72
+ for (const id of ['N', 'A', 'C'])
73
+ createNode(node(id));
74
+ spawnEdge('N', 'A');
75
+ spawnEdge('A', 'C');
76
+ const res = closeNode('N');
77
+ assert.deepEqual(res.closed, ['C', 'A', 'N']);
78
+ });
79
+ test('appends a resume notice naming the dead children to each closed node', () => {
80
+ for (const id of ['N', 'A', 'C'])
81
+ createNode(node(id));
82
+ spawnEdge('N', 'A');
83
+ spawnEdge('A', 'C');
84
+ closeNode('N');
85
+ const nNotice = readInboxSince('N').at(-1);
86
+ assert.equal(nNotice.from, null);
87
+ assert.equal(nNotice.kind, 'message');
88
+ assert.match(nNotice.label, /CLOSED by the user/);
89
+ assert.match(nNotice.label, /A \(A\)/); // dead child named
90
+ assert.deepEqual(nNotice.data?.['canceled_children'], ['A']);
91
+ assert.equal(nNotice.data?.['cascade_root'], 'N');
92
+ const aNotice = readInboxSince('A').at(-1);
93
+ assert.match(aNotice.label, /CANCELED/);
94
+ assert.match(aNotice.label, /C \(C\)/);
95
+ // A leaf has no dead children — gets the short "session preserved" notice.
96
+ const cNotice = readInboxSince('C').at(-1);
97
+ assert.match(cNotice.label, /session is preserved/);
98
+ assert.deepEqual(cNotice.data?.['canceled_children'], []);
99
+ });
100
+ test('diamond: a node reachable via two closing parents is closed', () => {
101
+ // N ─▶ A ─▶ D
102
+ // N ─▶ B ─▶ D (D managed by A and B, both inside the closing set)
103
+ for (const id of ['N', 'A', 'B', 'D'])
104
+ createNode(node(id));
105
+ spawnEdge('N', 'A');
106
+ spawnEdge('N', 'B');
107
+ spawnEdge('A', 'D');
108
+ spawnEdge('B', 'D');
109
+ const res = closeNode('N');
110
+ assert.deepEqual([...res.closed].sort(), ['A', 'B', 'D', 'N']);
111
+ assert.deepEqual(res.spared, []);
112
+ assert.equal(getNode('D').status, 'canceled');
113
+ });
114
+ test('diamond with one external parent spares the shared node and its subtree', () => {
115
+ // N ─▶ A ─▶ D ─▶ E ; D also managed by external M ⇒ D, E spared
116
+ for (const id of ['N', 'A', 'D', 'E', 'M'])
117
+ createNode(node(id));
118
+ spawnEdge('N', 'A');
119
+ spawnEdge('A', 'D');
120
+ spawnEdge('D', 'E');
121
+ spawnEdge('M', 'D');
122
+ const res = closeNode('N');
123
+ assert.deepEqual([...res.closed].sort(), ['A', 'N']);
124
+ assert.deepEqual(res.spared, ['D']);
125
+ assert.equal(getNode('D').status, 'active');
126
+ assert.equal(getNode('E').status, 'active'); // not descended into
127
+ });
128
+ test('closing a leaf node closes only itself', () => {
129
+ createNode(node('solo'));
130
+ const res = closeNode('solo');
131
+ assert.deepEqual(res.closed, ['solo']);
132
+ assert.deepEqual(res.spared, []);
133
+ assert.equal(getNode('solo').status, 'canceled');
134
+ });
135
+ test('throws on an unknown node', () => {
136
+ assert.throws(() => closeNode('ghost'), /unknown node/);
137
+ });
138
+ test('Step 7: closing a FOCUSED node closes its focus row + nulls its pane (tearDownNode)', () => {
139
+ createNode(node('N', { pane: '%x' }));
140
+ openFocusRow('fN', '%x', 'Sa', 'N');
141
+ closeNode('N');
142
+ // tearDownNode closes the focus row the node occupied and nulls its LOCATION.
143
+ // Non-vacuous: pre-Step-7 close used closeWindow/setFocus('') and never touched
144
+ // the focuses table, so getFocusByNode('N') would still return fN.
145
+ assert.equal(getFocusByNode('N'), null, 'focus row closed by tearDownNode');
146
+ assert.equal(getNode('N').pane ?? null, null, 'pane nulled (pane-keyed teardown)');
147
+ assert.equal(getNode('N').status, 'canceled', 'node canceled as before');
148
+ });
@@ -0,0 +1 @@
1
+ export {};