@crouton-kit/crouter 0.3.19 → 0.3.21

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 (90) hide show
  1. package/dist/builtin-memory/crouter-development/marketplaces.md +2 -4
  2. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +3 -5
  3. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +3 -6
  4. package/dist/builtin-memory/crouter-development/personas.md +2 -6
  5. package/dist/builtin-memory/crouter-development/plugins.md +2 -4
  6. package/dist/builtin-memory/design.md +2 -4
  7. package/dist/builtin-memory/development.md +3 -3
  8. package/dist/builtin-memory/planning.md +3 -4
  9. package/dist/builtin-memory/spec.md +3 -10
  10. package/dist/builtin-personas/design/PERSONA.md +1 -0
  11. package/dist/builtin-personas/design/orchestrator.md +1 -0
  12. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  13. package/dist/builtin-personas/developer/orchestrator.md +1 -0
  14. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  15. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  16. package/dist/builtin-personas/general/PERSONA.md +1 -0
  17. package/dist/builtin-personas/general/orchestrator.md +4 -0
  18. package/dist/builtin-personas/orchestration-kernel.md +1 -1
  19. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  20. package/dist/builtin-personas/plan/orchestrator.md +1 -0
  21. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  22. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  23. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  24. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  25. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  26. package/dist/builtin-personas/review/PERSONA.md +1 -0
  27. package/dist/builtin-personas/review/orchestrator.md +4 -0
  28. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  29. package/dist/builtin-personas/spec/orchestrator.md +1 -0
  30. package/dist/commands/human/queue.js +13 -2
  31. package/dist/commands/memory/__tests__/lint-schema.test.d.ts +1 -0
  32. package/dist/commands/memory/__tests__/lint-schema.test.js +29 -0
  33. package/dist/commands/memory/find.js +10 -15
  34. package/dist/commands/memory/lint.d.ts +7 -0
  35. package/dist/commands/memory/lint.js +10 -1
  36. package/dist/commands/memory/shared.js +1 -2
  37. package/dist/commands/memory/write.js +6 -9
  38. package/dist/commands/node.js +51 -26
  39. package/dist/commands/push.js +10 -15
  40. package/dist/commands/tmux-spread.js +16 -1
  41. package/dist/commands/view-cycle.js +9 -5
  42. package/dist/commands/view-run.js +3 -2
  43. package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
  44. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  45. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  46. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  47. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  48. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  49. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  50. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  51. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  52. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  53. package/dist/core/__tests__/broker-lifecycle.test.js +14 -604
  54. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  55. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  56. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  57. package/dist/core/__tests__/cascade-close.test.js +3 -2
  58. package/dist/core/__tests__/context-intro.test.js +23 -27
  59. package/dist/core/__tests__/detach-focus.test.js +2 -2
  60. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  61. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  62. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  63. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  64. package/dist/core/__tests__/live-mutation.test.js +4 -147
  65. package/dist/core/__tests__/memory.test.js +4 -4
  66. package/dist/core/__tests__/subscription-delivery.test.js +3 -3
  67. package/dist/core/bootstrap.js +18 -14
  68. package/dist/core/canvas/canvas.js +1 -1
  69. package/dist/core/canvas/types.d.ts +6 -0
  70. package/dist/core/help.d.ts +2 -2
  71. package/dist/core/help.js +1 -1
  72. package/dist/core/render.d.ts +4 -3
  73. package/dist/core/render.js +38 -41
  74. package/dist/core/resolver.js +1 -1
  75. package/dist/core/runtime/bearings.d.ts +2 -2
  76. package/dist/core/runtime/bearings.js +2 -2
  77. package/dist/core/runtime/launch.d.ts +9 -2
  78. package/dist/core/runtime/launch.js +20 -3
  79. package/dist/core/runtime/nodes.d.ts +4 -0
  80. package/dist/core/runtime/nodes.js +1 -0
  81. package/dist/core/runtime/promote.js +3 -0
  82. package/dist/core/runtime/reset.js +3 -2
  83. package/dist/core/runtime/spawn.d.ts +4 -0
  84. package/dist/core/runtime/spawn.js +2 -1
  85. package/dist/core/substrate/render.d.ts +1 -2
  86. package/dist/core/substrate/render.js +9 -12
  87. package/dist/core/substrate/schema.d.ts +18 -13
  88. package/dist/core/substrate/schema.js +12 -11
  89. package/dist/pi-extensions/canvas-context-intro.js +5 -3
  90. package/package.json +1 -1
@@ -0,0 +1,175 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/live-mutation-verbs.test.ts
2
+ //
3
+ // AXIS: LIVE MUTATION, part 2 — the demote/recycle verb split and the A4
4
+ // promote-then-yield boundary. Split out of live-mutation.test.ts (see its
5
+ // header for the coverage rationale and the oracle reference) so node:test's
6
+ // file-level parallelism applies; each test builds its OWN isolated harness and
7
+ // is moved here UNCHANGED.
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { createHarness, hasTmux } from './helpers/harness.js';
12
+ const SKIP = !hasTmux() ? 'tmux unavailable' : false;
13
+ function sessionExists(session) {
14
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
15
+ }
16
+ // --- pure test-local helpers (shared verbatim with live-mutation.test.ts) ---
17
+ /** The injected entries delivered as a turn-boundary `steer`. */
18
+ function steers(inj) {
19
+ return inj.filter((e) => e.deliverAs === 'steer');
20
+ }
21
+ /** A steer carrying the base→orchestrator orchestration guidance. */
22
+ function orchestrationSteers(inj) {
23
+ return steers(inj).filter((e) => /ORCHESTRATOR/i.test(e.content));
24
+ }
25
+ /** Normalize the two persona axes off a NodeMeta for deepEqual. */
26
+ function persona(m) {
27
+ return { mode: m.mode, lifecycle: m.lifecycle };
28
+ }
29
+ /** The first %pane_id of a tmux window. The spawn path records window+session
30
+ * but NOT pane (spawn.ts: pane is null until a reconcile/focus), so a node's
31
+ * live pane must be resolved from its window here. */
32
+ function firstPaneOf(window) {
33
+ const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
34
+ if (r.status !== 0)
35
+ return null;
36
+ return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
37
+ }
38
+ // ===========================================================================
39
+ // (b) THE demote / recycle SPLIT — two DISTINCT verbs after the rename:
40
+ // • `node demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
41
+ // its pane, its MODE, and its parentage, keeps running, is NOT finalized; it
42
+ // now merely owes a final up the spine (vision F5). It is NOT an
43
+ // orchestrator→base mode flip — MODE is untouched (so persona.ts
44
+ // `baseModeGuidance` stays unreachable via live mutation, as before).
45
+ // • `node recycle` is FINISH+RECYCLE — push final → done, then recycle the
46
+ // pane into a FRESH general/base/resident root (a DIFFERENT node). The
47
+ // recycled node keeps mode=orchestrator (it is merely `done`).
48
+ // This test drives BOTH real verbs on one live node and pins each behavior.
49
+ // ===========================================================================
50
+ test('node demote flips lifecycle→terminal IN PLACE; node recycle is FINISH+RECYCLE', { skip: SKIP, timeout: 120_000 }, async () => {
51
+ const h = await createHarness({ sessionPrefix: 'crtr-live-demote' });
52
+ try {
53
+ const A = h.spawnRoot('resident root');
54
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
55
+ // Make B resident + orchestrator so the demote's flip→terminal is visible
56
+ // and we can prove MODE/parentage survive it.
57
+ assert.equal(h.cli(B, ['node', 'lifecycle', 'resident', '--node', B]).code, 0, 'B → resident');
58
+ assert.equal(h.cli(B, ['node', 'promote', '--kind', 'developer']).code, 0, 'promote B');
59
+ {
60
+ const b = h.node(B);
61
+ assert.equal(b.lifecycle, 'resident', 'B resident before demote');
62
+ assert.equal(b.mode, 'orchestrator', 'B orchestrator before demote');
63
+ }
64
+ const bParent = h.node(B).parent;
65
+ // --- node demote: flip-to-terminal IN PLACE. Keeps B alive, MODE/parentage
66
+ // untouched, NOT finalized.
67
+ const dem = h.cli(B, ['node', 'demote', '--node', B]);
68
+ assert.equal(dem.code, 0, `node demote exit 0\n${dem.stderr}`);
69
+ assert.match(dem.stdout, /^Demoted /, `demote rendered\n${dem.stdout}`);
70
+ {
71
+ const b = h.node(B);
72
+ assert.equal(b.lifecycle, 'terminal', 'demote flips lifecycle→terminal IN PLACE');
73
+ assert.equal(b.mode, 'orchestrator', 'demote leaves MODE untouched (not an orchestrator→base flip)');
74
+ assert.equal(b.parent, bParent, 'demote leaves parentage unchanged');
75
+ assert.equal(b.status, 'active', 'demote does NOT finish B — it keeps running in place');
76
+ assert.notEqual(b.intent ?? null, 'done', 'demote does NOT finalize B');
77
+ }
78
+ // --- node recycle: FINISH + RECYCLE the SAME pane into a fresh root.
79
+ // Resolve B's live %pane_id from its window (the row's `pane` is null
80
+ // until a reconcile; the spawn path records only window+session).
81
+ const pane = firstPaneOf(h.node(B).window);
82
+ assert.ok(typeof pane === 'string' && pane !== '', 'B has a live pane to recycle');
83
+ // RECYCLE via the real verb (TMUX_PANE is scrubbed from child env → pass --pane).
84
+ const res = h.cli(B, ['node', 'recycle', '--node', B, '--pane', pane]);
85
+ assert.equal(res.code, 0, `recycle exit 0\n${res.stderr}`);
86
+ // The leaf renders plain markdown: a lead sentence + `- finalized:` / `- new root:` bullets.
87
+ assert.match(res.stdout, /^Recycled the pane /, `recycle recycled the pane\n${res.stdout}`);
88
+ const newRoot = /- new root: (\S+)/.exec(res.stdout)?.[1];
89
+ const finalized = /- finalized: true/.test(res.stdout);
90
+ // The recycled node is FINISHED, not mode-flipped.
91
+ {
92
+ const b = h.node(B);
93
+ assert.equal(b.status, 'done', 'recycled node → done (finished), NOT re-roled');
94
+ assert.equal(b.intent, 'done', 'intent=done (finalize), per the push-final path');
95
+ assert.equal(b.mode, 'orchestrator', 'recycled node KEEPS mode=orchestrator — recycle is NOT a mode flip');
96
+ assert.ok(finalized, 'recycle pushed a final for the node');
97
+ }
98
+ // The fresh root is a DIFFERENT, BASE×RESIDENT node.
99
+ assert.ok(typeof newRoot === 'string' && newRoot !== B, 'a fresh root (≠ B) was minted');
100
+ {
101
+ const fresh = h.node(newRoot);
102
+ assert.deepEqual(persona(fresh), { mode: 'base', lifecycle: 'resident' }, 'recycled root is born base×resident (general)');
103
+ assert.deepEqual(fresh.persona_ack, { mode: 'base', lifecycle: 'resident' }, 'fresh root born acked base×resident');
104
+ }
105
+ }
106
+ finally {
107
+ const session = h.session;
108
+ await h.dispose();
109
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
110
+ }
111
+ });
112
+ // ===========================================================================
113
+ // (b) A4 BOUNDARY — promote-then-yield emits a steer that is discarded. The
114
+ // oracle/flagship boundary: the base→orchestrator guidance lands as a STEER
115
+ // only if a turn_end fires while the drift is pending. A `node yield` on a
116
+ // base node auto-promotes (mode→orchestrator, ack NOT committed) and its
117
+ // agent_end goes STRAIGHT to reviveInPlace (b') with NO preceding turn_end —
118
+ // so the only steer-delivery site (turn_end) is BYPASSED. Two deterministic
119
+ // facts pin the boundary, both confirmed by direct observation:
120
+ // (1) NO orchestration STEER is ever delivered (the LOSS).
121
+ // (2) The ack is silently advanced base→orchestrator at the refresh DRAIN
122
+ // (reviveInPlace→drainBearings→commitPersonaAck), NOT via a steer — so
123
+ // neither this turn nor (per A4) the fresh revive re-offers it as a
124
+ // steer; the guidance survives only in the kickoff PROMPT it built.
125
+ // ⚑ FLAGGED (not fixed): the in-place refresh of this LARGE pending-drift
126
+ // kickoff prompt did NOT complete a fresh fake-pi boot in the harness (it
127
+ // stayed at 1 boot, intent=refresh, ack=orchestrator, pane alive) — a
128
+ // base→orchestrator yield's giant <persona-transition> kickoff pushed
129
+ // through respawn-pane did not bring up the fresh vehicle. Whether a real
130
+ // edge (oversized argv through respawn-pane) or a harness artifact, it is
131
+ // out of scope to fix; this test asserts only the deterministic boundary.
132
+ // ===========================================================================
133
+ test('A4: a base→orchestrator yield with no preceding turn_end loses the orchestration STEER (ack advances silently at the refresh drain)', { skip: SKIP, timeout: 120_000 }, async () => {
134
+ const h = await createHarness({ sessionPrefix: 'crtr-live-a4' });
135
+ try {
136
+ const A = h.spawnRoot('resident root');
137
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
138
+ assert.deepEqual(persona(h.node(B)), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
139
+ const injBefore = h.injected(B).length;
140
+ // `crtr node yield` (base → auto-promote → intent=refresh). INTERMEDIATE
141
+ // state, BEFORE any agent_end: mode flipped, ack NOT yet committed (the
142
+ // turn_end injector has not run), intent=refresh, drift PENDING.
143
+ const y = h.cli(B, ['node', 'yield', 'refresh against the roadmap']);
144
+ assert.equal(y.code, 0, `node yield exit 0\n${y.stderr}`);
145
+ {
146
+ const b = h.node(B);
147
+ assert.equal(b.mode, 'orchestrator', 'yield auto-promoted base→orchestrator');
148
+ assert.equal(b.intent, 'refresh', 'intent=refresh set by the yield');
149
+ assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'ack STILL base — promote/yield never commits it; only an injector does');
150
+ }
151
+ // Fire the stop: agent_end sees intent=refresh → (b') reviveInPlace, whose
152
+ // drainBearings commits the ack synchronously BEFORE the respawn. NO
153
+ // turn_end fires this turn, so the turn_end steer site is bypassed.
154
+ await h.stop(B);
155
+ // (2) The ack is silently advanced to orchestrator at the refresh DRAIN —
156
+ // not by any steer. (waitFor: the agent_end handler runs after h.stop
157
+ // observes the recorded event.)
158
+ await h.waitFor(() => {
159
+ const a = h.node(B)?.persona_ack;
160
+ return a?.mode === 'orchestrator' && a?.lifecycle === 'terminal';
161
+ }, { timeoutMs: 20_000, label: 'persona_ack advanced at the refresh drain (not via a steer)' });
162
+ assert.deepEqual(h.node(B).persona_ack, { mode: 'orchestrator', lifecycle: 'terminal' }, 'ack committed base→orchestrator by drainBearings during reviveInPlace');
163
+ // (1) ⚑ LOSS site: across the whole yield→refresh, NO orchestration
164
+ // guidance was ever delivered as a turn-boundary STEER (the only steer
165
+ // site, turn_end, never ran). The ack moved without the agent ever being
166
+ // steered with the new-role guidance — it survives only in the kickoff
167
+ // prompt drainBearings built for the (here, non-booting) fresh vehicle.
168
+ assert.equal(orchestrationSteers(h.injected(B).slice(injBefore)).length, 0, '⚑ A4: no orchestration STEER delivered — the turn_end injector was bypassed by the yield');
169
+ }
170
+ finally {
171
+ const session = h.session;
172
+ await h.dispose();
173
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
174
+ }
175
+ });
@@ -24,6 +24,10 @@
24
24
  // (idle-release vs dormant; persona-ack recompose; the A4 boundary) changes
25
25
  // accordingly. None of the above drives `crtr node lifecycle` on a live node,
26
26
  // nor asserts persona_ack mutation across a live promote, nor the A4 loss site.
27
+ //
28
+ // Part 2 of this axis — the demote/recycle verb split and the A4 promote-then-
29
+ // yield boundary — lives in live-mutation-verbs.test.ts (split for node:test
30
+ // file-level parallelism; each test holds its own isolated harness).
27
31
  import { test } from 'node:test';
28
32
  import assert from 'node:assert/strict';
29
33
  import { spawnSync } from 'node:child_process';
@@ -45,15 +49,6 @@ function orchestrationSteers(inj) {
45
49
  function persona(m) {
46
50
  return { mode: m.mode, lifecycle: m.lifecycle };
47
51
  }
48
- /** The first %pane_id of a tmux window. The spawn path records window+session
49
- * but NOT pane (spawn.ts: pane is null until a reconcile/focus), so a node's
50
- * live pane must be resolved from its window here. */
51
- function firstPaneOf(window) {
52
- const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
53
- if (r.status !== 0)
54
- return null;
55
- return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
56
- }
57
52
  // ===========================================================================
58
53
  // (a) LIFECYCLE FLIP — `crtr node lifecycle` on a LIVE node, both directions,
59
54
  // observing the idle-release behavior change. A round-trip on ONE live
@@ -218,141 +213,3 @@ test('live mode flip: promote base→orchestrator recomposes persona_ack at turn
218
213
  assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
219
214
  }
220
215
  });
221
- // ===========================================================================
222
- // (b) THE demote / recycle SPLIT — two DISTINCT verbs after the rename:
223
- // • `node demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
224
- // its pane, its MODE, and its parentage, keeps running, is NOT finalized; it
225
- // now merely owes a final up the spine (vision F5). It is NOT an
226
- // orchestrator→base mode flip — MODE is untouched (so persona.ts
227
- // `baseModeGuidance` stays unreachable via live mutation, as before).
228
- // • `node recycle` is FINISH+RECYCLE — push final → done, then recycle the
229
- // pane into a FRESH general/base/resident root (a DIFFERENT node). The
230
- // recycled node keeps mode=orchestrator (it is merely `done`).
231
- // This test drives BOTH real verbs on one live node and pins each behavior.
232
- // ===========================================================================
233
- test('node demote flips lifecycle→terminal IN PLACE; node recycle is FINISH+RECYCLE', { skip: SKIP, timeout: 120_000 }, async () => {
234
- const h = await createHarness({ sessionPrefix: 'crtr-live-demote' });
235
- try {
236
- const A = h.spawnRoot('resident root');
237
- const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
238
- // Make B resident + orchestrator so the demote's flip→terminal is visible
239
- // and we can prove MODE/parentage survive it.
240
- assert.equal(h.cli(B, ['node', 'lifecycle', 'resident', '--node', B]).code, 0, 'B → resident');
241
- assert.equal(h.cli(B, ['node', 'promote', '--kind', 'developer']).code, 0, 'promote B');
242
- {
243
- const b = h.node(B);
244
- assert.equal(b.lifecycle, 'resident', 'B resident before demote');
245
- assert.equal(b.mode, 'orchestrator', 'B orchestrator before demote');
246
- }
247
- const bParent = h.node(B).parent;
248
- // --- node demote: flip-to-terminal IN PLACE. Keeps B alive, MODE/parentage
249
- // untouched, NOT finalized.
250
- const dem = h.cli(B, ['node', 'demote', '--node', B]);
251
- assert.equal(dem.code, 0, `node demote exit 0\n${dem.stderr}`);
252
- assert.match(dem.stdout, /<demoted /, `demote rendered\n${dem.stdout}`);
253
- {
254
- const b = h.node(B);
255
- assert.equal(b.lifecycle, 'terminal', 'demote flips lifecycle→terminal IN PLACE');
256
- assert.equal(b.mode, 'orchestrator', 'demote leaves MODE untouched (not an orchestrator→base flip)');
257
- assert.equal(b.parent, bParent, 'demote leaves parentage unchanged');
258
- assert.equal(b.status, 'active', 'demote does NOT finish B — it keeps running in place');
259
- assert.notEqual(b.intent ?? null, 'done', 'demote does NOT finalize B');
260
- }
261
- // --- node recycle: FINISH + RECYCLE the SAME pane into a fresh root.
262
- // Resolve B's live %pane_id from its window (the row's `pane` is null
263
- // until a reconcile; the spawn path records only window+session).
264
- const pane = firstPaneOf(h.node(B).window);
265
- assert.ok(typeof pane === 'string' && pane !== '', 'B has a live pane to recycle');
266
- // RECYCLE via the real verb (TMUX_PANE is scrubbed from child env → pass --pane).
267
- const res = h.cli(B, ['node', 'recycle', '--node', B, '--pane', pane]);
268
- assert.equal(res.code, 0, `recycle exit 0\n${res.stderr}`);
269
- // The leaf renders `<recycled ... finalized=".." new_root=".."/>` (not JSON).
270
- assert.match(res.stdout, /<recycled /, `recycle recycled the pane\n${res.stdout}`);
271
- const newRoot = /new_root="([^"]+)"/.exec(res.stdout)?.[1];
272
- const finalized = /finalized="true"/.test(res.stdout);
273
- // The recycled node is FINISHED, not mode-flipped.
274
- {
275
- const b = h.node(B);
276
- assert.equal(b.status, 'done', 'recycled node → done (finished), NOT re-roled');
277
- assert.equal(b.intent, 'done', 'intent=done (finalize), per the push-final path');
278
- assert.equal(b.mode, 'orchestrator', 'recycled node KEEPS mode=orchestrator — recycle is NOT a mode flip');
279
- assert.ok(finalized, 'recycle pushed a final for the node');
280
- }
281
- // The fresh root is a DIFFERENT, BASE×RESIDENT node.
282
- assert.ok(typeof newRoot === 'string' && newRoot !== B, 'a fresh root (≠ B) was minted');
283
- {
284
- const fresh = h.node(newRoot);
285
- assert.deepEqual(persona(fresh), { mode: 'base', lifecycle: 'resident' }, 'recycled root is born base×resident (general)');
286
- assert.deepEqual(fresh.persona_ack, { mode: 'base', lifecycle: 'resident' }, 'fresh root born acked base×resident');
287
- }
288
- }
289
- finally {
290
- const session = h.session;
291
- await h.dispose();
292
- assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
293
- }
294
- });
295
- // ===========================================================================
296
- // (b) A4 BOUNDARY — promote-then-yield emits a steer that is discarded. The
297
- // oracle/flagship boundary: the base→orchestrator guidance lands as a STEER
298
- // only if a turn_end fires while the drift is pending. A `node yield` on a
299
- // base node auto-promotes (mode→orchestrator, ack NOT committed) and its
300
- // agent_end goes STRAIGHT to reviveInPlace (b') with NO preceding turn_end —
301
- // so the only steer-delivery site (turn_end) is BYPASSED. Two deterministic
302
- // facts pin the boundary, both confirmed by direct observation:
303
- // (1) NO orchestration STEER is ever delivered (the LOSS).
304
- // (2) The ack is silently advanced base→orchestrator at the refresh DRAIN
305
- // (reviveInPlace→drainBearings→commitPersonaAck), NOT via a steer — so
306
- // neither this turn nor (per A4) the fresh revive re-offers it as a
307
- // steer; the guidance survives only in the kickoff PROMPT it built.
308
- // ⚑ FLAGGED (not fixed): the in-place refresh of this LARGE pending-drift
309
- // kickoff prompt did NOT complete a fresh fake-pi boot in the harness (it
310
- // stayed at 1 boot, intent=refresh, ack=orchestrator, pane alive) — a
311
- // base→orchestrator yield's giant <persona-transition> kickoff pushed
312
- // through respawn-pane did not bring up the fresh vehicle. Whether a real
313
- // edge (oversized argv through respawn-pane) or a harness artifact, it is
314
- // out of scope to fix; this test asserts only the deterministic boundary.
315
- // ===========================================================================
316
- test('A4: a base→orchestrator yield with no preceding turn_end loses the orchestration STEER (ack advances silently at the refresh drain)', { skip: SKIP, timeout: 120_000 }, async () => {
317
- const h = await createHarness({ sessionPrefix: 'crtr-live-a4' });
318
- try {
319
- const A = h.spawnRoot('resident root');
320
- const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
321
- assert.deepEqual(persona(h.node(B)), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
322
- const injBefore = h.injected(B).length;
323
- // `crtr node yield` (base → auto-promote → intent=refresh). INTERMEDIATE
324
- // state, BEFORE any agent_end: mode flipped, ack NOT yet committed (the
325
- // turn_end injector has not run), intent=refresh, drift PENDING.
326
- const y = h.cli(B, ['node', 'yield', 'refresh against the roadmap']);
327
- assert.equal(y.code, 0, `node yield exit 0\n${y.stderr}`);
328
- {
329
- const b = h.node(B);
330
- assert.equal(b.mode, 'orchestrator', 'yield auto-promoted base→orchestrator');
331
- assert.equal(b.intent, 'refresh', 'intent=refresh set by the yield');
332
- assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'ack STILL base — promote/yield never commits it; only an injector does');
333
- }
334
- // Fire the stop: agent_end sees intent=refresh → (b') reviveInPlace, whose
335
- // drainBearings commits the ack synchronously BEFORE the respawn. NO
336
- // turn_end fires this turn, so the turn_end steer site is bypassed.
337
- await h.stop(B);
338
- // (2) The ack is silently advanced to orchestrator at the refresh DRAIN —
339
- // not by any steer. (waitFor: the agent_end handler runs after h.stop
340
- // observes the recorded event.)
341
- await h.waitFor(() => {
342
- const a = h.node(B)?.persona_ack;
343
- return a?.mode === 'orchestrator' && a?.lifecycle === 'terminal';
344
- }, { timeoutMs: 20_000, label: 'persona_ack advanced at the refresh drain (not via a steer)' });
345
- assert.deepEqual(h.node(B).persona_ack, { mode: 'orchestrator', lifecycle: 'terminal' }, 'ack committed base→orchestrator by drainBearings during reviveInPlace');
346
- // (1) ⚑ LOSS site: across the whole yield→refresh, NO orchestration
347
- // guidance was ever delivered as a turn-boundary STEER (the only steer
348
- // site, turn_end, never ran). The ack moved without the agent ever being
349
- // steered with the new-role guidance — it survives only in the kickoff
350
- // prompt drainBearings built for the (here, non-booting) fresh vehicle.
351
- assert.equal(orchestrationSteers(h.injected(B).slice(injBefore)).length, 0, '⚑ A4: no orchestration STEER delivered — the turn_end injector was bypassed by the yield');
352
- }
353
- finally {
354
- const session = h.session;
355
- await h.dispose();
356
- assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
357
- }
358
- });
@@ -29,7 +29,7 @@ after(() => {
29
29
  delete process.env['CRTR_HOME'];
30
30
  });
31
31
  // ---------------------------------------------------------------------------
32
- // Kernel + guidance: substrate flow, not MEMORY.md
32
+ // Kernel + guidance: the document substrate flow
33
33
  // ---------------------------------------------------------------------------
34
34
  test('the orchestration kernel names the three stores and the substrate commands', () => {
35
35
  const kernel = loadKernel();
@@ -41,7 +41,7 @@ test('the orchestration kernel names the three stores and the substrate commands
41
41
  assert.ok(kernel.includes('crtr memory list'), 'kernel mentions crtr memory list');
42
42
  assert.ok(kernel.includes('crtr memory find'), 'kernel mentions crtr memory find');
43
43
  assert.ok(kernel.includes('crtr memory read'), 'kernel mentions crtr memory read');
44
- // The old MEMORY.md pointer-line flow must NOT be present.
44
+ // It teaches the substrate flow only — no MEMORY.md pointer-line index.
45
45
  assert.ok(!kernel.includes('MEMORY.md'), 'kernel does not mention MEMORY.md');
46
46
  assert.ok(!kernel.includes('pointer line'), 'kernel does not teach the pointer-line flow');
47
47
  });
@@ -55,6 +55,6 @@ test('promotion guidance references the three stores; no <memory> block', () =>
55
55
  for (const store of ['user-global', 'project', 'node-local']) {
56
56
  assert.ok(guidance.includes(store), `guidance names the ${store} store`);
57
57
  }
58
- // The old <memory> block reference must NOT be present in guidance.
59
- assert.ok(!guidance.includes('<memory>'), 'guidance does not point at the removed <memory> block');
58
+ // The guidance points at the substrate flow, not a <memory> block.
59
+ assert.ok(!guidance.includes('<memory>'), 'guidance does not point at a <memory> block');
60
60
  });
@@ -79,10 +79,10 @@ test('multi-level subscription delivery: active subscriber WOKEN vs passive subs
79
79
  {
80
80
  const passiveRes = h.cli(P, ['node', 'subscribe', T, '--passive']);
81
81
  assert.equal(passiveRes.code, 0, `P→T passive subscribe exit 0\n${passiveRes.stderr}`);
82
- assert.match(passiveRes.stdout, /mode="passive"/, 'P→T wired PASSIVE');
82
+ assert.match(passiveRes.stdout, /\(passive\)/, 'P→T wired PASSIVE');
83
83
  const activeRes = h.cli(P, ['node', 'subscribe', K]);
84
84
  assert.equal(activeRes.code, 0, `P→K active subscribe exit 0\n${activeRes.stderr}`);
85
- assert.match(activeRes.stdout, /mode="active"/, 'P→K wired ACTIVE');
85
+ assert.match(activeRes.stdout, /\(active\)/, 'P→K wired ACTIVE');
86
86
  }
87
87
  // T now has BOTH an active (B) and a passive (P) subscriber — the crux.
88
88
  assert.deepEqual(edgeSet(h.subscribers(T)), new Set([`${B}:active`, `${P}:passive`]), 'T has B(active) + P(passive) as subscribers — the same-target split');
@@ -103,7 +103,7 @@ test('multi-level subscription delivery: active subscriber WOKEN vs passive subs
103
103
  {
104
104
  const res = h.cli(X, ['node', 'subscribe', K, '--passive']);
105
105
  assert.equal(res.code, 0, `X→K passive subscribe exit 0\n${res.stderr}`);
106
- assert.match(res.stdout, /mode="passive"/, 'X→K wired PASSIVE-only (its ONLY subscription)');
106
+ assert.match(res.stdout, /\(passive\)/, 'X→K wired PASSIVE-only (its ONLY subscription)');
107
107
  }
108
108
  assert.deepEqual(edgeSet(h.subscriptions(X)), new Set([`${K}:passive`]), 'X holds exactly one tie: K(passive) — no active live subscription');
109
109
  const injBeforeStall = h.injected(X).length;
@@ -30,36 +30,40 @@ const BOOT_SKILL_MARKER_PREFIX = '<!-- crtr-boot-skill v';
30
30
  function bootSkillBody() {
31
31
  return `---
32
32
  name: crtr-skills
33
- description: 'Capture, list, search, and load skills via the crtr CLI. Skills are durable agent memory — markdown future LLM sessions load on demand. Use when the user wants to remember/save knowledge, build a context primer, or recall a previously saved skill. Triggers: "save", "remember", "build context for", "what skills do we have", "skill for X".'
33
+ description: 'Author, list, search, and load crtr memory documents (skills, references, preferences) via the crtr CLI. These are durable agent memory — markdown future LLM sessions load on demand. Use when the user wants to remember/save knowledge, build a context primer, or recall a previously saved doc. Triggers: "save", "remember", "build context for", "what skills do we have", "skill for X".'
34
34
  argument-hint: [topic or verb]
35
35
  ---
36
36
 
37
37
  ${BOOT_SKILL_MARKER}
38
38
 
39
- # /crtr-skills — skill router
39
+ # /crtr-skills — crtr memory router
40
40
 
41
- Skills = durable agent memory. Written for **future LLM sessions**, not the
42
- user. \`crtr skill\` is the index discoverable via list/search/grep.
41
+ crtr memory documents = durable agent memory (kind: skill, reference, or
42
+ preference). Written for **future LLM sessions**, not the user. \`crtr memory\`
43
+ is the surface — author, discover, and load them.
43
44
 
44
45
  ## Route by intent
45
46
 
46
47
  - **Capture** ("save", "remember", "build context for", "make a skill"):
47
- \`crtr skill create $ARGUMENTS\` pick template (primer/playbook/freeform)
48
- \`crtr skill template <type> $ARGUMENTS\` for the full workflow. Follow it
49
- directly.
50
- - **Find** ("what do we have on X"): \`crtr skill search "$ARGUMENTS"\` →
51
- \`crtr skill show <name>\` on the best hit.
52
- - **Load by name**: \`crtr skill show <name>\`.
53
- - **List all**: \`crtr skill list\`.
54
- - **Anything else**: \`crtr skill\` (no args) prints the full workflow guide.
48
+ \`crtr memory write <name> --kind <skill|reference|preference>\` with the
49
+ body piped on stdin e.g.
50
+ \`crtr memory write $ARGUMENTS --kind skill <<'EOF' … EOF\`. Run
51
+ \`crtr memory write -h\` for the full frontmatter schema
52
+ (--when-and-why-to-read, --short-form, --scope, visibility rungs), then
53
+ \`crtr memory lint\` to validate what you wrote.
54
+ - **Find** ("what do we have on X"): \`crtr memory find "$ARGUMENTS"\` →
55
+ \`crtr memory read <name>\` on the best hit (add --body/--grep to search
56
+ bodies).
57
+ - **Load by name**: \`crtr memory read <name>\`.
58
+ - **List all**: \`crtr memory list\`.
55
59
 
56
60
  If \`$ARGUMENTS\` is empty, ask the user what they want before running.
57
61
 
58
62
  ## Rules
59
63
 
60
64
  - CLI stdout is the prompt — act on it, don't paraphrase to the user.
61
- - Don't load \`create\` and \`template\` outputs in the same turn (progressive
62
- disclosure). \`create\` decides type; \`template\` returns the workflow.
65
+ - Append \`-h\` at any leaf (\`crtr memory write -h\`) for its full schema
66
+ before authoring.
63
67
  - If \`crtr\` is not on PATH, tell the user and stop.
64
68
  `;
65
69
  }
@@ -22,7 +22,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, } from '.
22
22
  const IDENTITY_KEYS = [
23
23
  'node_id', 'name', 'description', 'cycles', 'created', 'cwd', 'host_kind', 'kind', 'mode',
24
24
  'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'passive_default',
25
- 'home_session', 'pi_session_id', 'pi_session_file', 'launch',
25
+ 'home_session', 'pi_session_id', 'pi_session_file', 'model_override', 'launch',
26
26
  ];
27
27
  /** Project any node object down to its durable-identity subset. */
28
28
  function toIdentity(m) {
@@ -104,6 +104,12 @@ export interface NodeIdentity {
104
104
  * PATH is opened directly — immune to any cwd discrepancy. Null for older
105
105
  * nodes booted before this field existed → revive falls back to the bare id. */
106
106
  pi_session_file?: string | null;
107
+ /** A caller-pinned model TIER (ultra/strong/medium/light) that overrides the
108
+ * persona's declared default. Durable so it survives a polymorph: every
109
+ * spec-rebuild path (promote/demote/lifecycle/reset) re-passes it to
110
+ * buildLaunchSpec, while the persona default is recomputed fresh for the
111
+ * (possibly new) kind. Null/undefined ⇒ use the persona default. */
112
+ model_override?: string | null;
107
113
  /** Full pi launch recipe; rewritten on every polymorph. */
108
114
  launch?: LaunchSpec;
109
115
  }
@@ -94,7 +94,7 @@ export interface RootEntry {
94
94
  useWhen: string;
95
95
  /** Optional bounded block this subtree contributes to its <name> block at
96
96
  * root. Returns a complete self-named state element (build it with
97
- * stateBlock), e.g. `<skills count="42">…</skills>`. Aggregate, never an
97
+ * stateBlock), e.g. `<kinds count="7">…</kinds>`. Aggregate, never an
98
98
  * unbounded enumeration on a cold path. Soft-fails to omission on
99
99
  * null/throw. */
100
100
  dynamicState?: () => string | null;
@@ -141,7 +141,7 @@ export interface BranchHelp {
141
141
  * child's purpose lives in its own listing row). */
142
142
  model?: string;
143
143
  /** Bounded runtime aggregate as a complete self-named state element (build
144
- * it with stateBlock), e.g. `<skills count="42">…</skills>`. Renderer
144
+ * it with stateBlock), e.g. `<kinds count="7">…</kinds>`. Renderer
145
145
  * soft-fails to omission if this returns null or throws. */
146
146
  dynamicState?: () => string | null;
147
147
  /** Parent-level listing assembled by defineBranch from the actual child defs.
package/dist/core/help.js CHANGED
@@ -105,7 +105,7 @@ export function renderRoot(h) {
105
105
  for (const l of rootSubcommandLines(c))
106
106
  lines.push(l);
107
107
  // dynamicState returns a complete self-named element (e.g.
108
- // <skills count="42">…</skills>) — emit it as-is, nested in the command.
108
+ // <kinds count="7">…</kinds>) — emit it as-is, nested in the command.
109
109
  const state = evalDynamic(c.dynamicState);
110
110
  if (state !== null)
111
111
  lines.push(state);
@@ -1,9 +1,10 @@
1
1
  import type { LeafHelp } from './help.js';
2
2
  import type { ErrorPayload } from './io.js';
3
- /** Schema-driven fallback: turn a result object into agent-ready XML+markdown.
3
+ /** Schema-driven fallback: turn a result object into agent-ready plain markdown.
4
4
  * Field order follows the leaf's declared output schema; any extra keys append
5
- * after. Scalars become attributes or a bullet list, prose and collections
6
- * become their own fenced blocks. */
5
+ * after. Scalars (and nested objects) list as `- name: value` bullets, prose
6
+ * fields render as paragraphs, and arrays become a count lead-in plus a table
7
+ * or bullets. No root tag — the result is read as a continuation of the prompt. */
7
8
  export declare function renderResult(result: Record<string, unknown>, help: LeafHelp): string;
8
9
  /** Render a structured failure as an instruction-shaped block: what broke, what
9
10
  * was received, and the recovery road sign — the same recovery info the JSON