@crouton-kit/crouter 0.3.17 → 0.3.18

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 (39) hide show
  1. package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
  2. package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
  3. package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
  4. package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
  5. package/dist/builtin-personas/plan/{base.md → PERSONA.md} +4 -0
  6. package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
  7. package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
  8. package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
  9. package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
  10. package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
  11. package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
  12. package/dist/builtin-personas/spec/{base.md → PERSONA.md} +4 -0
  13. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
  14. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
  15. package/dist/commands/daemon.js +1 -1
  16. package/dist/commands/human/prompts.js +3 -9
  17. package/dist/commands/human/shared.d.ts +26 -1
  18. package/dist/commands/human/shared.js +48 -10
  19. package/dist/commands/node.js +53 -4
  20. package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
  21. package/dist/core/__tests__/human-surface-target.test.js +98 -0
  22. package/dist/core/__tests__/persona-subkind.test.js +18 -15
  23. package/dist/core/canvas/paths.d.ts +4 -1
  24. package/dist/core/canvas/paths.js +10 -4
  25. package/dist/core/canvas/types.js +2 -2
  26. package/dist/core/help.d.ts +6 -0
  27. package/dist/core/help.js +7 -0
  28. package/dist/core/personas/index.d.ts +4 -3
  29. package/dist/core/personas/index.js +3 -2
  30. package/dist/core/personas/loader.d.ts +34 -16
  31. package/dist/core/personas/loader.js +102 -29
  32. package/dist/core/personas/resolve.d.ts +4 -4
  33. package/dist/core/personas/resolve.js +16 -14
  34. package/dist/core/runtime/placement.d.ts +10 -0
  35. package/dist/core/runtime/placement.js +37 -1
  36. package/dist/core/spawn.d.ts +20 -1
  37. package/dist/core/spawn.js +52 -5
  38. package/dist/pi-extensions/canvas-nav.js +77 -30
  39. package/package.json +1 -1
@@ -1,15 +1,18 @@
1
1
  // Run: node --import tsx/esm --test src/core/__tests__/persona-subkind.test.ts
2
2
  //
3
- // Scoped persona sub-kinds: a kind owns specialist reviewer personas at
4
- // `<kind>/reviewers/<name>/base.md`, enumerated by `subKindsFor(kind)` and
3
+ // Scoped persona sub-personas: a kind has specialist reviewer personas at
4
+ // `<kind>/reviewers/<name>/PERSONA.md`, enumerated by `subPersonasFor(kind)` and
5
5
  // rendered into that kind's composed prompt (and nowhere else) by `resolve`.
6
- // Visibility = membership: only `plan` sees the `plan/reviewers/*` menu; the
7
- // sub-kinds never pollute the global `availableKinds()` list; and a sub-kind
8
- // itself boots as a real composed persona with the terminal finish contract.
6
+ // Visibility = membership (the sub-persona's `availableTo`, default = its
7
+ // top-level ancestor kind): only `plan` sees the `plan/reviewers/*` menu; the
8
+ // `reviewers/` grouping dir is transparent so the kind string keeps it; the
9
+ // sub-personas never pollute the global `availableKinds()` list; and a
10
+ // sub-persona itself boots as a real composed persona with the terminal finish
11
+ // contract.
9
12
  import { test } from 'node:test';
10
13
  import assert from 'node:assert/strict';
11
14
  import { resolve } from '../personas/resolve.js';
12
- import { subKindsFor, availableKinds } from '../personas/loader.js';
15
+ import { subPersonasFor, availableKinds } from '../personas/loader.js';
13
16
  const PLAN_REVIEWER_KINDS = [
14
17
  'plan/reviewers/architecture-fit',
15
18
  'plan/reviewers/code-smells',
@@ -17,19 +20,19 @@ const PLAN_REVIEWER_KINDS = [
17
20
  'plan/reviewers/requirements-coverage',
18
21
  'plan/reviewers/security',
19
22
  ];
20
- const MENU_HEADER = 'Reviewer sub-kinds you may spawn';
21
- test('subKindsFor("plan") returns the five reviewers sorted, each with a non-empty summary', () => {
22
- const subs = subKindsFor('plan');
23
- assert.deepEqual(subs.map((s) => s.kind), PLAN_REVIEWER_KINDS, 'the five plan reviewer kind strings in sorted order');
23
+ const MENU_HEADER = 'Sub-personas you may spawn';
24
+ test('subPersonasFor("plan") returns the five reviewers sorted, each with a non-empty whenToUse', () => {
25
+ const subs = subPersonasFor('plan');
26
+ assert.deepEqual(subs.map((s) => s.kind), PLAN_REVIEWER_KINDS, 'the five plan reviewer kind strings in sorted order — the transparent reviewers/ dir keeps the full kind path');
24
27
  for (const s of subs) {
25
- assert.ok(s.summary.length > 0, `${s.kind} carries a non-empty summary`);
28
+ assert.ok(s.whenToUse.length > 0, `${s.kind} carries a non-empty whenToUse`);
26
29
  }
27
30
  });
28
- test('sub-kinds do not recurse and absent rosters yield []', () => {
29
- assert.deepEqual(subKindsFor('explore'), [], 'explore owns no reviewers/');
30
- assert.deepEqual(subKindsFor('plan/reviewers/security'), [], 'a sub-kind owns no nested reviewers/ no recursion');
31
+ test('availability is membership: a kind with no available sub-personas yields []', () => {
32
+ assert.deepEqual(subPersonasFor('explore'), [], 'no sub-persona is availableTo explore');
33
+ assert.deepEqual(subPersonasFor('plan/reviewers/security'), [], 'the five reviewers default availableTo:[plan]none are available to a reviewer kind');
31
34
  });
32
- test('availableKinds() contains no plan/reviewers/* — sub-kinds never pollute the global list', () => {
35
+ test('availableKinds() contains no plan/reviewers/* — sub-personas never pollute the global list', () => {
33
36
  const kinds = availableKinds();
34
37
  for (const k of PLAN_REVIEWER_KINDS) {
35
38
  assert.ok(!kinds.includes(k), `${k} must not appear in availableKinds()`);
@@ -1,4 +1,7 @@
1
- /** Root of the global canvas home (`~/.crtr` unless `CRTR_HOME` is set). */
1
+ /** Root of the global canvas home (`~/.crouter/canvas` unless `CRTR_HOME` is set).
2
+ * Nested under the `.crouter` scope root so the whole runtime lives in one
3
+ * visible top-level dir; `canvas/` keeps node-graph runtime state separate from
4
+ * durable user content (skills/plugins/marketplaces/config) at the scope root. */
2
5
  export declare function crtrHome(): string;
3
6
  export declare function canvasDbPath(): string;
4
7
  export declare function nodesRoot(): string;
@@ -1,6 +1,6 @@
1
- // The `~/.crtr/` layout. One global, cwd-agnostic home for the whole canvas.
1
+ // The `~/.crouter/canvas/` layout. One global, cwd-agnostic home for the whole canvas.
2
2
  //
3
- // ~/.crtr/
3
+ // ~/.crouter/canvas/
4
4
  // canvas.db sqlite (WAL) — topology only (nodes + edges)
5
5
  // nodes/<node_id>/
6
6
  // meta.json source of truth for the node's row
@@ -15,10 +15,16 @@
15
15
  import { homedir } from 'node:os';
16
16
  import { join } from 'node:path';
17
17
  import { mkdirSync } from 'node:fs';
18
- /** Root of the global canvas home (`~/.crtr` unless `CRTR_HOME` is set). */
18
+ import { CRTR_DIR_NAME } from '../../types.js';
19
+ /** Root of the global canvas home (`~/.crouter/canvas` unless `CRTR_HOME` is set).
20
+ * Nested under the `.crouter` scope root so the whole runtime lives in one
21
+ * visible top-level dir; `canvas/` keeps node-graph runtime state separate from
22
+ * durable user content (skills/plugins/marketplaces/config) at the scope root. */
19
23
  export function crtrHome() {
20
24
  const override = process.env['CRTR_HOME'];
21
- return override !== undefined && override !== '' ? override : join(homedir(), '.crtr');
25
+ return override !== undefined && override !== ''
26
+ ? override
27
+ : join(homedir(), CRTR_DIR_NAME, 'canvas');
22
28
  }
23
29
  export function canvasDbPath() {
24
30
  return join(crtrHome(), 'canvas.db');
@@ -1,7 +1,7 @@
1
1
  // The canvas vocabulary — the node + edge model the whole runtime hangs on.
2
2
  //
3
- // One global canvas (`~/.crtr/canvas.db`) holds the topology (nodes + edges);
4
- // each node's flesh lives on disk under `~/.crtr/nodes/<id>/`. A node's
3
+ // One global canvas (`~/.crouter/canvas/canvas.db`) holds the topology (nodes +
4
+ // edges); each node's flesh lives on disk under `~/.crouter/canvas/nodes/<id>/`. A node's
5
5
  // `meta.json` is the source of truth for its own row; the db is a queryable
6
6
  // index over those metas, plus the authoritative store for the mutable
7
7
  // `subscribes_to` edges (which no single meta owns).
@@ -162,6 +162,12 @@ export interface LeafHelp {
162
162
  /** Every persistent change the command makes to the world. For read-only
163
163
  * leaves use exactly: ["None. Read-only."] */
164
164
  effects: string[];
165
+ /** Bounded runtime aggregate as a complete self-named state element (build it
166
+ * with stateBlock), e.g. `<kinds count="7">…</kinds>`. Lazily evaluated at
167
+ * render time so it reflects the caller's cwd/project scope; appended after
168
+ * the schema. Renderer soft-fails to omission if it returns null or throws.
169
+ * Mirrors BranchHelp.dynamicState. */
170
+ dynamicState?: () => string | null;
165
171
  }
166
172
  /** Build a self-named runtime-state element: `<tag attr="v">body</tag>`. The
167
173
  * subtree that owns the state authors it through this, so the tag name and any
package/dist/core/help.js CHANGED
@@ -238,5 +238,12 @@ export function renderLeafArgv(h) {
238
238
  for (const e of h.effects) {
239
239
  lines.push(` ${e}`);
240
240
  }
241
+ // Optional bounded runtime-state block (e.g. the live <kinds> list), appended
242
+ // after the schema. Soft-fails to omission on null/throw, mirroring renderBranch.
243
+ const state = evalDynamic(h.dynamicState);
244
+ if (state !== null) {
245
+ lines.push('');
246
+ lines.push(state);
247
+ }
241
248
  return lines.join('\n');
242
249
  }
@@ -2,11 +2,12 @@
2
2
  * Persona composer public surface.
3
3
  *
4
4
  * Re-exports:
5
- * - loadPersona / loadKernel / availableKinds (loader raw file access)
5
+ * - loadPersona / loadKernel / availableKinds / kindWhenToUse / subPersonasFor
6
+ * (loader — raw file access)
6
7
  * - resolve (high-level composer)
7
8
  * - ResolvedPersona (return type of resolve)
8
9
  */
9
- export { loadPersona, loadKernel, availableKinds, loadLifecycleFragment, loadSpineFragment } from './loader.js';
10
- export type { LoadedPersona } from './loader.js';
10
+ export { loadPersona, loadKernel, availableKinds, kindWhenToUse, subPersonasFor, loadLifecycleFragment, loadSpineFragment } from './loader.js';
11
+ export type { LoadedPersona, SubPersona } from './loader.js';
11
12
  export { resolve } from './resolve.js';
12
13
  export type { ResolvedPersona } from './resolve.js';
@@ -2,9 +2,10 @@
2
2
  * Persona composer public surface.
3
3
  *
4
4
  * Re-exports:
5
- * - loadPersona / loadKernel / availableKinds (loader raw file access)
5
+ * - loadPersona / loadKernel / availableKinds / kindWhenToUse / subPersonasFor
6
+ * (loader — raw file access)
6
7
  * - resolve (high-level composer)
7
8
  * - ResolvedPersona (return type of resolve)
8
9
  */
9
- export { loadPersona, loadKernel, availableKinds, loadLifecycleFragment, loadSpineFragment } from './loader.js';
10
+ export { loadPersona, loadKernel, availableKinds, kindWhenToUse, subPersonasFor, loadLifecycleFragment, loadSpineFragment } from './loader.js';
10
11
  export { resolve } from './resolve.js';
@@ -5,9 +5,10 @@
5
5
  * Resolution order (highest → lowest precedence): project > user > builtin.
6
6
  *
7
7
  * Layout on disk:
8
- * <root>/personas/<kind>/base.md
8
+ * <root>/personas/<kind>/PERSONA.md
9
9
  * <root>/personas/<kind>/orchestrator.md
10
10
  * <root>/personas/orchestration-kernel.md
11
+ * <root>/personas/<kind>/<...>/PERSONA.md (nested sub-personas)
11
12
  *
12
13
  * The builtin root is src/builtin-personas (or dist/builtin-personas in the
13
14
  * compiled build), resolved relative to this module — mirrors the pattern used
@@ -55,29 +56,46 @@ export declare function loadLifecycleFragment(lifecycle: 'terminal' | 'resident'
55
56
  */
56
57
  export declare function loadSpineFragment(hasManager: boolean): string;
57
58
  /**
58
- * Enumerate the kinds with at least one persona file (base.md or
59
+ * Enumerate the kinds with at least one persona file (PERSONA.md or
59
60
  * orchestrator.md) across all scope roots (project/user/builtin). Used to
60
- * validate a requested `--kind` and to list the valid choices.
61
+ * validate a requested `--kind` and to list the valid choices. Only the
62
+ * IMMEDIATE children of each root count — nested sub-personas never pollute
63
+ * the global kind list (see subPersonasFor).
61
64
  */
62
65
  export declare function availableKinds(): string[];
63
- export interface SubKind {
66
+ /**
67
+ * The one-line "when to use this node type" gloss for `kind`, read from its
68
+ * `<kind>/PERSONA.md` `whenToUse` frontmatter (resolved project > user >
69
+ * builtin). Returns '' when the kind has no PERSONA.md or no `whenToUse`.
70
+ * Drives the dynamic kind list in `node new -h` / `node promote -h`.
71
+ */
72
+ export declare function kindWhenToUse(kind: string): string;
73
+ export interface SubPersona {
64
74
  /** Full kind string to spawn, e.g. 'plan/reviewers/security'. */
65
75
  kind: string;
66
76
  /** Leaf name, e.g. 'security'. */
67
77
  name: string;
68
- /** One-line "what it reviews", from the sub-kind base.md `summary` frontmatter (or ''). */
69
- summary: string;
78
+ /** One-line "when to use", from the sub-persona PERSONA.md `whenToUse` frontmatter (or ''). */
79
+ whenToUse: string;
70
80
  }
71
81
  /**
72
- * Enumerate the reviewer sub-kinds owned by `parentKind` — the specialist
73
- * personas at `<root>/<parentKind>/reviewers/<name>/base.md`, scanned across all
74
- * scope roots (project > user > builtin; highest precedence wins per name).
82
+ * Enumerate the sub-personas AVAILABLE TO `kind` — the nested specialist
83
+ * personas (e.g. `plan/reviewers/security`) a `kind` node may spawn, surfaced
84
+ * in its composed prompt (resolve.ts) and nowhere else.
85
+ *
86
+ * A sub-persona is any descendant dir (ANY depth) under a top-level kind dir
87
+ * that holds a PERSONA.md, EXCLUDING the top-level PERSONA.md itself. Its
88
+ * availability is its `availableTo` frontmatter: an explicit list of kind
89
+ * strings, or the wildcard `"*"`/`"all"` (visible to every kind); absent, it
90
+ * defaults to its own top-level ancestor kind. So the five `plan/reviewers/*`
91
+ * (no `availableTo`) are visible only to `plan`, while a sub-persona under
92
+ * `developer/` can declare `availableTo: [plan]` to surface in plan's menu —
93
+ * which is why ALL top-level kinds' descendants are scanned, not just `<kind>/`.
75
94
  *
76
- * Sub-kinds are intentionally NOT global kinds: `availableKinds()` scans only the
77
- * immediate children of each persona root, so `<parentKind>/reviewers/*` never
78
- * leaks into the global list. A sub-kind is reachable only by its full kind
79
- * string and is surfaced only in its parent kind's composed prompt (resolve.ts).
80
- * Kind-parametric: any kind owns a roster simply by adding
81
- * `<kind>/reviewers/<name>/base.md` — no code change.
95
+ * Sub-personas are intentionally NOT global kinds: `availableKinds()` scans only
96
+ * the immediate children of each root, so a nested sub-persona never leaks into
97
+ * the global list; it is reachable only by its full kind string. Precedence is
98
+ * project > user > builtin keyed on the FULL kind string the highest root that
99
+ * defines a given kind string wins (and owns its `availableTo`).
82
100
  */
83
- export declare function subKindsFor(parentKind: string): SubKind[];
101
+ export declare function subPersonasFor(kind: string): SubPersona[];
@@ -5,9 +5,10 @@
5
5
  * Resolution order (highest → lowest precedence): project > user > builtin.
6
6
  *
7
7
  * Layout on disk:
8
- * <root>/personas/<kind>/base.md
8
+ * <root>/personas/<kind>/PERSONA.md
9
9
  * <root>/personas/<kind>/orchestrator.md
10
10
  * <root>/personas/orchestration-kernel.md
11
+ * <root>/personas/<kind>/<...>/PERSONA.md (nested sub-personas)
11
12
  *
12
13
  * The builtin root is src/builtin-personas (or dist/builtin-personas in the
13
14
  * compiled build), resolved relative to this module — mirrors the pattern used
@@ -59,7 +60,7 @@ function personaSearchRoots() {
59
60
  // ---------------------------------------------------------------------------
60
61
  /**
61
62
  * Find the first existing file across the scope roots.
62
- * `relativePath` is relative to each root (e.g. 'general/base.md').
63
+ * `relativePath` is relative to each root (e.g. 'general/PERSONA.md').
63
64
  */
64
65
  function resolveFile(relativePath) {
65
66
  for (const root of personaSearchRoots()) {
@@ -92,6 +93,11 @@ function inlineIncludes(body) {
92
93
  return kernelBody.trim();
93
94
  });
94
95
  }
96
+ // ---------------------------------------------------------------------------
97
+ // Public API
98
+ // ---------------------------------------------------------------------------
99
+ /** The role-body filename for every kind/sub-persona (replaces legacy base.md). */
100
+ const PERSONA_FILE = 'PERSONA.md';
95
101
  /**
96
102
  * Load and parse a persona file for the given `kind` and `mode`.
97
103
  *
@@ -99,7 +105,8 @@ function inlineIncludes(body) {
99
105
  * On success, `@include` directives in the body are resolved and inlined.
100
106
  */
101
107
  export function loadPersona(kind, mode) {
102
- const relativePath = `${kind}/${mode}.md`;
108
+ // base PERSONA.md (the role body); orchestrator → its sibling orchestrator.md.
109
+ const relativePath = mode === 'orchestrator' ? `${kind}/orchestrator.md` : `${kind}/PERSONA.md`;
103
110
  const filePath = resolveFile(relativePath);
104
111
  if (!filePath)
105
112
  return null;
@@ -165,9 +172,11 @@ export function loadSpineFragment(hasManager) {
165
172
  return body.trim();
166
173
  }
167
174
  /**
168
- * Enumerate the kinds with at least one persona file (base.md or
175
+ * Enumerate the kinds with at least one persona file (PERSONA.md or
169
176
  * orchestrator.md) across all scope roots (project/user/builtin). Used to
170
- * validate a requested `--kind` and to list the valid choices.
177
+ * validate a requested `--kind` and to list the valid choices. Only the
178
+ * IMMEDIATE children of each root count — nested sub-personas never pollute
179
+ * the global kind list (see subPersonasFor).
171
180
  */
172
181
  export function availableKinds() {
173
182
  const kinds = new Set();
@@ -178,42 +187,106 @@ export function availableKinds() {
178
187
  if (!entry.isDirectory())
179
188
  continue;
180
189
  const dir = join(root, entry.name);
181
- if (existsSync(join(dir, 'base.md')) || existsSync(join(dir, 'orchestrator.md'))) {
190
+ if (existsSync(join(dir, PERSONA_FILE)) || existsSync(join(dir, 'orchestrator.md'))) {
182
191
  kinds.add(entry.name);
183
192
  }
184
193
  }
185
194
  }
186
195
  return [...kinds].sort();
187
196
  }
188
- const REVIEWERS_SUBDIR = 'reviewers';
189
197
  /**
190
- * Enumerate the reviewer sub-kinds owned by `parentKind` the specialist
191
- * personas at `<root>/<parentKind>/reviewers/<name>/base.md`, scanned across all
192
- * scope roots (project > user > builtin; highest precedence wins per name).
198
+ * The one-line "when to use this node type" gloss for `kind`, read from its
199
+ * `<kind>/PERSONA.md` `whenToUse` frontmatter (resolved project > user >
200
+ * builtin). Returns '' when the kind has no PERSONA.md or no `whenToUse`.
201
+ * Drives the dynamic kind list in `node new -h` / `node promote -h`.
202
+ */
203
+ export function kindWhenToUse(kind) {
204
+ const filePath = resolveFile(`${kind}/${PERSONA_FILE}`);
205
+ if (!filePath)
206
+ return '';
207
+ const { data } = parseFrontmatterGeneric(readFileSync(filePath, 'utf8'));
208
+ return data && typeof data['whenToUse'] === 'string' ? data['whenToUse'] : '';
209
+ }
210
+ /** Recursively yield every dir under `dir` (inclusive) that holds a PERSONA.md,
211
+ * with `relKind` = the dir's path relative to the scope root (slash-joined).
212
+ * Dirs WITHOUT a PERSONA.md (e.g. a `reviewers/` grouping namespace) are
213
+ * transparent — they yield nothing themselves but are still descended into,
214
+ * so `plan/reviewers/security` keeps that exact kind string. */
215
+ function* walkPersonaDirs(dir, relParts) {
216
+ const file = join(dir, PERSONA_FILE);
217
+ if (existsSync(file))
218
+ yield { relKind: relParts.join('/'), file };
219
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
220
+ if (entry.isDirectory())
221
+ yield* walkPersonaDirs(join(dir, entry.name), [...relParts, entry.name]);
222
+ }
223
+ }
224
+ /** Parse a sub-persona's `availableTo` frontmatter into its availability set.
225
+ * Returns the wildcard sentinel `'*'` for `"*"`/`"all"` (scalar or in an
226
+ * array), an explicit list of kind strings when present, else the default
227
+ * `[topKind]` (the top-level ancestor kind). */
228
+ function parseAvailableTo(data, topKind) {
229
+ const isWild = (s) => {
230
+ const t = s.trim().toLowerCase();
231
+ return t === '*' || t === 'all';
232
+ };
233
+ const v = data ? data['availableTo'] : undefined;
234
+ if (v === undefined)
235
+ return [topKind];
236
+ if (typeof v === 'string')
237
+ return isWild(v) ? '*' : [v];
238
+ if (Array.isArray(v)) {
239
+ const arr = v.filter((x) => typeof x === 'string');
240
+ if (arr.some(isWild))
241
+ return '*';
242
+ return arr.length > 0 ? arr : [topKind];
243
+ }
244
+ return [topKind];
245
+ }
246
+ /**
247
+ * Enumerate the sub-personas AVAILABLE TO `kind` — the nested specialist
248
+ * personas (e.g. `plan/reviewers/security`) a `kind` node may spawn, surfaced
249
+ * in its composed prompt (resolve.ts) and nowhere else.
193
250
  *
194
- * Sub-kinds are intentionally NOT global kinds: `availableKinds()` scans only the
195
- * immediate children of each persona root, so `<parentKind>/reviewers/*` never
196
- * leaks into the global list. A sub-kind is reachable only by its full kind
197
- * string and is surfaced only in its parent kind's composed prompt (resolve.ts).
198
- * Kind-parametric: any kind owns a roster simply by adding
199
- * `<kind>/reviewers/<name>/base.md` no code change.
251
+ * A sub-persona is any descendant dir (ANY depth) under a top-level kind dir
252
+ * that holds a PERSONA.md, EXCLUDING the top-level PERSONA.md itself. Its
253
+ * availability is its `availableTo` frontmatter: an explicit list of kind
254
+ * strings, or the wildcard `"*"`/`"all"` (visible to every kind); absent, it
255
+ * defaults to its own top-level ancestor kind. So the five `plan/reviewers/*`
256
+ * (no `availableTo`) are visible only to `plan`, while a sub-persona under
257
+ * `developer/` can declare `availableTo: [plan]` to surface in plan's menu —
258
+ * which is why ALL top-level kinds' descendants are scanned, not just `<kind>/`.
259
+ *
260
+ * Sub-personas are intentionally NOT global kinds: `availableKinds()` scans only
261
+ * the immediate children of each root, so a nested sub-persona never leaks into
262
+ * the global list; it is reachable only by its full kind string. Precedence is
263
+ * project > user > builtin keyed on the FULL kind string — the highest root that
264
+ * defines a given kind string wins (and owns its `availableTo`).
200
265
  */
201
- export function subKindsFor(parentKind) {
202
- const byName = new Map();
266
+ export function subPersonasFor(kind) {
267
+ const seen = new Set(); // full kind strings already resolved (higher root won)
268
+ const out = [];
203
269
  for (const root of personaSearchRoots()) {
204
- const dir = join(root, parentKind, REVIEWERS_SUBDIR);
205
- if (!existsSync(dir))
270
+ if (!existsSync(root))
206
271
  continue;
207
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
208
- if (!entry.isDirectory() || byName.has(entry.name))
209
- continue; // higher root already won
210
- const baseFile = join(dir, entry.name, 'base.md');
211
- if (!existsSync(baseFile))
272
+ for (const top of readdirSync(root, { withFileTypes: true })) {
273
+ if (!top.isDirectory())
212
274
  continue;
213
- const { data } = parseFrontmatterGeneric(readFileSync(baseFile, 'utf8'));
214
- const summary = data && typeof data['summary'] === 'string' ? data['summary'] : '';
215
- byName.set(entry.name, { kind: `${parentKind}/${REVIEWERS_SUBDIR}/${entry.name}`, name: entry.name, summary });
275
+ const topKind = top.name;
276
+ for (const { relKind, file } of walkPersonaDirs(join(root, topKind), [topKind])) {
277
+ if (relKind === topKind)
278
+ continue; // the top-level PERSONA.md is the kind itself, not a sub-persona
279
+ if (seen.has(relKind))
280
+ continue; // a higher root already resolved this kind string
281
+ seen.add(relKind);
282
+ const { data } = parseFrontmatterGeneric(readFileSync(file, 'utf8'));
283
+ const availableTo = parseAvailableTo(data, topKind);
284
+ if (availableTo !== '*' && !availableTo.includes(kind))
285
+ continue;
286
+ const whenToUse = data && typeof data['whenToUse'] === 'string' ? data['whenToUse'] : '';
287
+ out.push({ kind: relKind, name: relKind.split('/').pop(), whenToUse });
288
+ }
216
289
  }
217
290
  }
218
- return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
291
+ return out.sort((a, b) => a.kind.localeCompare(b.kind));
219
292
  }
@@ -4,17 +4,17 @@
4
4
  *
5
5
  * Composition rules:
6
6
  * mode==='base'
7
- * → load <kind>/base.md; if missing fall back to general defaults.
7
+ * → load <kind>/PERSONA.md; if missing fall back to general defaults.
8
8
  *
9
9
  * mode==='orchestrator'
10
10
  * → prefer <kind>/orchestrator.md (which must embed the kernel via
11
11
  * @include orchestration-kernel.md — inlined by the loader).
12
12
  * If no orchestrator.md exists for this kind, compose:
13
- * <kind>/base.md body + '\n\n' + kernel body
14
- * If even the base is missing, fall back to general defaults + kernel.
13
+ * <kind>/PERSONA.md body + '\n\n' + kernel body
14
+ * If even the PERSONA.md is missing, fall back to general defaults + kernel.
15
15
  *
16
16
  * Frontmatter from whichever file is the primary source (orchestrator.md >
17
- * base.md) supplies model/skills/extensions/tools. Lifecycle and spine position
17
+ * PERSONA.md) supplies model/skills/extensions/tools. Lifecycle and spine position
18
18
  * are INPUTS (the caller decides them — root/child, terminal/resident), not
19
19
  * derived here; they select the lifecycle/spine protocol fragments spliced
20
20
  * ahead of the persona body.
@@ -4,22 +4,22 @@
4
4
  *
5
5
  * Composition rules:
6
6
  * mode==='base'
7
- * → load <kind>/base.md; if missing fall back to general defaults.
7
+ * → load <kind>/PERSONA.md; if missing fall back to general defaults.
8
8
  *
9
9
  * mode==='orchestrator'
10
10
  * → prefer <kind>/orchestrator.md (which must embed the kernel via
11
11
  * @include orchestration-kernel.md — inlined by the loader).
12
12
  * If no orchestrator.md exists for this kind, compose:
13
- * <kind>/base.md body + '\n\n' + kernel body
14
- * If even the base is missing, fall back to general defaults + kernel.
13
+ * <kind>/PERSONA.md body + '\n\n' + kernel body
14
+ * If even the PERSONA.md is missing, fall back to general defaults + kernel.
15
15
  *
16
16
  * Frontmatter from whichever file is the primary source (orchestrator.md >
17
- * base.md) supplies model/skills/extensions/tools. Lifecycle and spine position
17
+ * PERSONA.md) supplies model/skills/extensions/tools. Lifecycle and spine position
18
18
  * are INPUTS (the caller decides them — root/child, terminal/resident), not
19
19
  * derived here; they select the lifecycle/spine protocol fragments spliced
20
20
  * ahead of the persona body.
21
21
  */
22
- import { loadPersona, loadKernel, loadRuntimeBase, loadSpineFragment, loadLifecycleFragment, subKindsFor } from './loader.js';
22
+ import { loadPersona, loadKernel, loadRuntimeBase, loadSpineFragment, loadLifecycleFragment, subPersonasFor } from './loader.js';
23
23
  // ---------------------------------------------------------------------------
24
24
  // Helpers
25
25
  // ---------------------------------------------------------------------------
@@ -40,24 +40,26 @@ function fallbackBasePrompt(kind) {
40
40
  * fragment (report-up vs. silent, keyed on whether the node has a manager),
41
41
  * then the lifecycle fragment (finish-with-`push final` vs. dormant/wake). The
42
42
  * kind×mode persona body follows after a rule. Empty fragments drop out. */
43
- /** Render the "sub-kinds you may spawn" menu for a kind that owns a roster.
44
- * Returns '' when the kind owns none. Data-driven: one line per sub-kind, its
45
- * spawn string + its `summary`. Adding a roster file makes it appear here. */
46
- function renderSubKindMenu(kind) {
47
- const subs = subKindsFor(kind);
43
+ /** Render the "sub-personas you may spawn" menu for a kind that has any
44
+ * available to it. Returns '' when none are. Data-driven: one line per
45
+ * sub-persona, its spawn string + its `whenToUse`. A sub-persona surfaces here
46
+ * for a kind when its `availableTo` includes that kind (default: its own
47
+ * top-level ancestor) or is the wildcard. */
48
+ function renderSubPersonaMenu(kind) {
49
+ const subs = subPersonasFor(kind);
48
50
  if (subs.length === 0)
49
51
  return '';
50
- const lines = subs.map((s) => `- \`${s.kind}\` — ${s.summary}`);
52
+ const lines = subs.map((s) => `- \`${s.kind}\` — ${s.whenToUse}`);
51
53
  return [
52
- '## Reviewer sub-kinds you may spawn',
54
+ '## Sub-personas you may spawn',
53
55
  '',
54
- `These specialist reviewers exist only in the ${kind} kind's world — no other kind sees them. Spawn one with \`crtr node new --kind <sub-kind> "<scope>"\`, giving it only its scope, never your suspicions: a reviewer handed a hint anchors on it instead of finding problems independently.`,
56
+ `These specialist sub-personas are available to the ${kind} kind. Spawn one with \`crtr node new --kind <sub> "<scope>"\`, giving it only its scope, never your suspicions: a reviewer handed a hint anchors on it instead of finding problems independently.`,
55
57
  '',
56
58
  ...lines,
57
59
  ].join('\n');
58
60
  }
59
61
  function composeProtocol(personaPrompt, kind, lifecycle, hasManager) {
60
- const menu = renderSubKindMenu(kind);
62
+ const menu = renderSubPersonaMenu(kind);
61
63
  const body = menu ? `${personaPrompt}\n\n${menu}` : personaPrompt;
62
64
  const protocol = [
63
65
  loadRuntimeBase(),
@@ -15,6 +15,16 @@ export declare function focusByPane(pane: string): FocusRow | null;
15
15
  export declare function focusedNodes(): Set<string>;
16
16
  /** Every focus row (every live viewport). */
17
17
  export declare function listFocuses(): FocusRow[];
18
+ /** The on-screen viewport a human-in-the-loop prompt raised by `nodeId` should
19
+ * surface into: the HIGHEST FOCUSED node of nodeId's graph — the focused node
20
+ * closest to the graph root, i.e. the session/window the user is actually
21
+ * watching this work in. Walks nodeId's spine to its root, enumerates the whole
22
+ * tree root-first (`view` is BFS ⇒ shallowest first), and returns the focus row
23
+ * of the first node that occupies a viewport. null when nothing in the graph is
24
+ * on screen — the caller then surfaces in the user's attached pane rather than
25
+ * the backstage node session. PURE (db reads only): no tmux probe, so the pane
26
+ * may be stale; the caller liveness-checks before targeting it. */
27
+ export declare function graphSurfaceTarget(nodeId: string): FocusRow | null;
18
28
  /** The cached LOCATION as stored on a node row: the authoritative `pane` handle
19
29
  * plus its derived window/session cache. */
20
30
  export interface CachedLocation {
@@ -25,7 +25,7 @@
25
25
  // The robustness contract: a manual `move-pane`/`join-pane`/`break-pane` must
26
26
  // NEVER read as a node death. Liveness is pane-existence, not window-existence,
27
27
  // and reconcile makes crtr follow a move instead of fighting it.
28
- import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, } from '../canvas/index.js';
28
+ import { getRow, getRowByPane, getNode, setPresence, openDb, openFocusRow, setFocusOccupant, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, setFocusPane, listFocuses as listFocusRows, view, } from '../canvas/index.js';
29
29
  import { paneExists, paneLocation, paneOfWindow, windowAlive, windowOfPane, ensureSession, openNodeWindow, respawnPaneSync, respawnPaneDetached, breakPaneToSession, splitWindow, swapPaneInPlace, setRemainOnExit, closePane, currentTmux, joinPane, selectLayout, setWindowOption, switchClient, selectWindow, } from './tmux.js';
30
30
  import { homeSessionOf, nodeSession, newNodeId } from './nodes.js';
31
31
  import { isBusy } from './busy.js';
@@ -71,6 +71,42 @@ export function focusedNodes() {
71
71
  export function listFocuses() {
72
72
  return listFocusRows();
73
73
  }
74
+ // ---------------------------------------------------------------------------
75
+ // Graph → focus routing (for surfacing human-in-the-loop prompts)
76
+ // ---------------------------------------------------------------------------
77
+ /** The root of a node's spine: walk the `parent` column up to `parent == null`.
78
+ * Cycle-guarded (parents must not cycle, but never loop forever). */
79
+ function rootOfSpine(nodeId) {
80
+ let cur = nodeId;
81
+ const seen = new Set();
82
+ for (;;) {
83
+ if (seen.has(cur))
84
+ return cur;
85
+ seen.add(cur);
86
+ const row = getRow(cur);
87
+ if (row === null || row.parent == null)
88
+ return cur;
89
+ cur = row.parent;
90
+ }
91
+ }
92
+ /** The on-screen viewport a human-in-the-loop prompt raised by `nodeId` should
93
+ * surface into: the HIGHEST FOCUSED node of nodeId's graph — the focused node
94
+ * closest to the graph root, i.e. the session/window the user is actually
95
+ * watching this work in. Walks nodeId's spine to its root, enumerates the whole
96
+ * tree root-first (`view` is BFS ⇒ shallowest first), and returns the focus row
97
+ * of the first node that occupies a viewport. null when nothing in the graph is
98
+ * on screen — the caller then surfaces in the user's attached pane rather than
99
+ * the backstage node session. PURE (db reads only): no tmux probe, so the pane
100
+ * may be stale; the caller liveness-checks before targeting it. */
101
+ export function graphSurfaceTarget(nodeId) {
102
+ const root = rootOfSpine(nodeId);
103
+ for (const id of [root, ...view(root)]) {
104
+ const f = getFocusByNode(id);
105
+ if (f !== null && f.pane !== null)
106
+ return f;
107
+ }
108
+ return null;
109
+ }
74
110
  /** PURE reconciliation decision (§2.4) — unit-testable without a live tmux.
75
111
  * Given the cached row LOCATION and what tmux currently reports, decide the
76
112
  * presence patch. Mirrors the pure-core/impure-shell split (cf. `livenessVerdict`
@@ -1,7 +1,21 @@
1
1
  export declare function isInTmux(): boolean;
2
2
  export declare function shellQuote(s: string): string;
3
- /** Count panes in the current tmux window (0 outside tmux / on error). */
3
+ /** Count panes in a tmux window (0 outside tmux / on error). With `targetPane`,
4
+ * counts the window THAT pane lives in (the placement decision must reflect the
5
+ * window the new pane will actually open into, not the caller's backstage one);
6
+ * without it, the caller's current window. */
7
+ export declare function countPanesInWindow(targetPane?: string): number;
8
+ /** Back-compat alias: panes in the caller's current window. */
4
9
  export declare function countPanesInCurrentWindow(): number;
10
+ /** Does this tmux pane id still exist? `display-message` EXITS 0 with EMPTY
11
+ * output on an unresolvable pane, so test for non-empty stdout, not just `.ok`.
12
+ * False outside tmux / on error. */
13
+ export declare function paneAlive(pane: string): boolean;
14
+ /** The active pane of the user's attached tmux client — where they are looking
15
+ * right now. `list-clients` first attached client, then its current pane. Used
16
+ * to surface a human prompt in the user's view when nothing in the asking
17
+ * node's graph is focused. null outside tmux / no client / on error. */
18
+ export declare function attachedClientPane(): string | null;
5
19
  /**
6
20
  * Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
7
21
  * so the caller can return normally before the pane dies. No-op outside tmux,
@@ -23,6 +37,11 @@ export interface DetachOptions {
23
37
  * uses the attached client's currently-focused pane — which drifts if the
24
38
  * user switches windows between kickoff and spawn. */
25
39
  targetPane?: string;
40
+ /** Pass tmux `-d` to new-window so CREATING the window never switches the
41
+ * attached client to it (split-window already leaves the client's view put).
42
+ * The prompt lands in the target session/window without jumping the user out
43
+ * of what they are looking at. No effect on split-h/split-v. */
44
+ detached?: boolean;
26
45
  }
27
46
  export interface DetachResult {
28
47
  status: 'spawned' | 'spawn-failed' | 'not-in-tmux';