@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.
- package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/plan/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/spec/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
- package/dist/commands/daemon.js +1 -1
- package/dist/commands/human/prompts.js +3 -9
- package/dist/commands/human/shared.d.ts +26 -1
- package/dist/commands/human/shared.js +48 -10
- package/dist/commands/node.js +53 -4
- package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
- package/dist/core/__tests__/human-surface-target.test.js +98 -0
- package/dist/core/__tests__/persona-subkind.test.js +18 -15
- package/dist/core/canvas/paths.d.ts +4 -1
- package/dist/core/canvas/paths.js +10 -4
- package/dist/core/canvas/types.js +2 -2
- package/dist/core/help.d.ts +6 -0
- package/dist/core/help.js +7 -0
- package/dist/core/personas/index.d.ts +4 -3
- package/dist/core/personas/index.js +3 -2
- package/dist/core/personas/loader.d.ts +34 -16
- package/dist/core/personas/loader.js +102 -29
- package/dist/core/personas/resolve.d.ts +4 -4
- package/dist/core/personas/resolve.js +16 -14
- package/dist/core/runtime/placement.d.ts +10 -0
- package/dist/core/runtime/placement.js +37 -1
- package/dist/core/spawn.d.ts +20 -1
- package/dist/core/spawn.js +52 -5
- package/dist/pi-extensions/canvas-nav.js +77 -30
- 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-
|
|
4
|
-
// `<kind>/reviewers/<name>/
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 {
|
|
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 = '
|
|
21
|
-
test('
|
|
22
|
-
const subs =
|
|
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.
|
|
28
|
+
assert.ok(s.whenToUse.length > 0, `${s.kind} carries a non-empty whenToUse`);
|
|
26
29
|
}
|
|
27
30
|
});
|
|
28
|
-
test('
|
|
29
|
-
assert.deepEqual(
|
|
30
|
-
assert.deepEqual(
|
|
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-
|
|
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 (`~/.
|
|
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 `~/.
|
|
1
|
+
// The `~/.crouter/canvas/` layout. One global, cwd-agnostic home for the whole canvas.
|
|
2
2
|
//
|
|
3
|
-
// ~/.
|
|
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
|
-
|
|
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 !== ''
|
|
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 (`~/.
|
|
4
|
-
// each node's flesh lives on disk under `~/.
|
|
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).
|
package/dist/core/help.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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>/
|
|
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 (
|
|
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
|
-
|
|
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 "
|
|
69
|
-
|
|
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
|
|
73
|
-
* personas
|
|
74
|
-
*
|
|
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-
|
|
77
|
-
* immediate children of each
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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>/
|
|
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/
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
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
|
|
202
|
-
const
|
|
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
|
-
|
|
205
|
-
if (!existsSync(dir))
|
|
270
|
+
if (!existsSync(root))
|
|
206
271
|
continue;
|
|
207
|
-
for (const
|
|
208
|
-
if (!
|
|
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
|
|
214
|
-
const
|
|
215
|
-
|
|
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
|
|
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>/
|
|
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>/
|
|
14
|
-
* If even the
|
|
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
|
-
*
|
|
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>/
|
|
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>/
|
|
14
|
-
* If even the
|
|
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
|
-
*
|
|
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,
|
|
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-
|
|
44
|
-
* Returns '' when
|
|
45
|
-
* spawn string + its `
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
52
|
+
const lines = subs.map((s) => `- \`${s.kind}\` — ${s.whenToUse}`);
|
|
51
53
|
return [
|
|
52
|
-
'##
|
|
54
|
+
'## Sub-personas you may spawn',
|
|
53
55
|
'',
|
|
54
|
-
`These specialist
|
|
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 =
|
|
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`
|
package/dist/core/spawn.d.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
export declare function isInTmux(): boolean;
|
|
2
2
|
export declare function shellQuote(s: string): string;
|
|
3
|
-
/** Count panes in
|
|
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';
|