@crouton-kit/crouter 0.3.16 → 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/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/{base.md → PERSONA.md} +5 -1
- 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} +5 -1
- 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/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- 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 +66 -4
- package/dist/commands/skill/author.js +2 -2
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- 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__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/persona-subkind.test.js +18 -15
- package/dist/core/__tests__/placement-focus.test.js +53 -15
- package/dist/core/__tests__/relaunch.test.js +12 -12
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/paths.d.ts +4 -1
- package/dist/core/canvas/paths.js +10 -4
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.js +2 -2
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- 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/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/placement.d.ts +32 -5
- package/dist/core/runtime/placement.js +81 -14
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +23 -18
- package/dist/core/spawn.d.ts +20 -1
- package/dist/core/spawn.js +52 -5
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +106 -55
- package/dist/pi-extensions/canvas-resume.d.ts +0 -1
- package/dist/pi-extensions/canvas-resume.js +35 -126
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +16 -0
- package/dist/prompts/skill.js +6 -1
- package/package.json +1 -1
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
|
@@ -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(),
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Mark a node mid-turn (pi entered a turn). Best-effort. */
|
|
2
|
+
export declare function markBusy(nodeId: string): void;
|
|
3
|
+
/** Clear the mid-turn marker (the turn ended, however it routed). Best-effort. */
|
|
4
|
+
export declare function clearBusy(nodeId: string): void;
|
|
5
|
+
/** Is the node currently inside a turn? AND this with `pidAlive` at the call
|
|
6
|
+
* site — a stale marker from a crashed pi is harmless because the dead pid
|
|
7
|
+
* fails the AND. */
|
|
8
|
+
export declare function isBusy(nodeId: string): boolean;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// busy.ts — the "is pi actually mid-turn" signal (a marker file, no db column).
|
|
2
|
+
//
|
|
3
|
+
// The disposition of a focus's OUTGOING node on a hot-swap (placement.ts
|
|
4
|
+
// `outgoingDisposition`) must distinguish a terminal worker that is GENUINELY
|
|
5
|
+
// mid-turn (keep it running off-screen, Invariant F2) from one merely PARKED at
|
|
6
|
+
// its prompt with a live pi (a viewer revived for inspection — despawn it back to
|
|
7
|
+
// dormant on focus-away). A live pid is NOT that signal: a parked node has a live
|
|
8
|
+
// pid too. This marker is.
|
|
9
|
+
//
|
|
10
|
+
// `<jobDir>/busy` exists for exactly the span pi is inside a turn: the stophook
|
|
11
|
+
// touches it on `agent_start` and unlinks it at the top of `agent_end` (and
|
|
12
|
+
// defensively on `session_shutdown`). It is always AND-ed with `pidAlive` at the
|
|
13
|
+
// read site, so a stale marker (process crashed mid-turn without firing
|
|
14
|
+
// agent_end) is harmless — the dead pid fails the AND and the node is reaped.
|
|
15
|
+
// No db migration, atomic touch/unlink, best-effort (never throws).
|
|
16
|
+
import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { jobDir } from '../canvas/index.js';
|
|
19
|
+
function busyPath(nodeId) {
|
|
20
|
+
return join(jobDir(nodeId), 'busy');
|
|
21
|
+
}
|
|
22
|
+
/** Mark a node mid-turn (pi entered a turn). Best-effort. */
|
|
23
|
+
export function markBusy(nodeId) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(jobDir(nodeId), { recursive: true });
|
|
26
|
+
writeFileSync(busyPath(nodeId), '');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* best-effort */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Clear the mid-turn marker (the turn ended, however it routed). Best-effort. */
|
|
33
|
+
export function clearBusy(nodeId) {
|
|
34
|
+
try {
|
|
35
|
+
rmSync(busyPath(nodeId), { force: true });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* best-effort */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Is the node currently inside a turn? AND this with `pidAlive` at the call
|
|
42
|
+
* site — a stale marker from a crashed pi is harmless because the dead pid
|
|
43
|
+
* fails the AND. */
|
|
44
|
+
export function isBusy(nodeId) {
|
|
45
|
+
return existsSync(busyPath(nodeId));
|
|
46
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { NodeMeta } from '../canvas/types.js';
|
|
|
2
2
|
/** The lifecycle events — the only vocabulary for moving a node's status/intent.
|
|
3
3
|
* Each maps (in the table below) to a target status and/or intent plus the set
|
|
4
4
|
* of from-statuses it is legal from. */
|
|
5
|
-
export type LifecycleEvent = 'finalize' | '
|
|
5
|
+
export type LifecycleEvent = 'finalize' | 'cancel' | 'crash' | 'yield' | 'release' | 'revive' | 'boot';
|
|
6
6
|
/** Enact a lifecycle event on a node: validate the from-status against the
|
|
7
7
|
* table, then write status+intent in ONE atomic statement (so they can never
|
|
8
8
|
* disagree). Returns the hydrated node view after the write.
|
|
@@ -17,9 +17,17 @@
|
|
|
17
17
|
// "flip status to a non-supervised value + clear intent BEFORE killing the
|
|
18
18
|
// window" — the daemon only ever revives active|idle nodes, so a teardown must
|
|
19
19
|
// leave the node done/canceled first to close the revive race. That invariant is
|
|
20
|
-
// now the DEFINITION of the `
|
|
20
|
+
// now the DEFINITION of the `cancel` event: callers flip via transition()
|
|
21
21
|
// and only THEN kill the window.
|
|
22
22
|
//
|
|
23
|
+
// Unification (A5, human-confirmed 2026-06-06): an externally-reaped node — torn
|
|
24
|
+
// down because the user moved on (close cascade) OR because a root reset/relaunch
|
|
25
|
+
// superseded it — ends `canceled`, NOT `done`. `done` is reserved for a node that
|
|
26
|
+
// finished its OWN work (finalize). The old `reap` event (→ done) was identical to
|
|
27
|
+
// `cancel` in every field and side effect once unified on status, so it was
|
|
28
|
+
// COLLAPSED into `cancel`; reset.ts's reapDescendants + relaunchRoot park-old now
|
|
29
|
+
// route through `cancel`.
|
|
30
|
+
//
|
|
23
31
|
// Layering note: lifecycle.ts is runtime, but it is the canvas write surface's
|
|
24
32
|
// `transition` verb (the only writer of status+intent), so it owns its atomic
|
|
25
33
|
// row UPDATE directly via openDb — the one sanctioned exception to "only
|
|
@@ -35,9 +43,9 @@ const LIVE = ['active', 'idle'];
|
|
|
35
43
|
const TRANSITIONS = {
|
|
36
44
|
// feed.push(final) · queue.cancelJob · markCleanExitDone (clean quit).
|
|
37
45
|
finalize: { status: 'done', intent: 'done', from: LIVE },
|
|
38
|
-
// reapDescendants · relaunchRoot park-old. Forced teardown
|
|
39
|
-
|
|
40
|
-
//
|
|
46
|
+
// closeNode cascade · reapDescendants · relaunchRoot park-old. Forced teardown
|
|
47
|
+
// of a node that did NOT finish its own work → canceled, intent cleared. (A5:
|
|
48
|
+
// done is reserved for finalize; every external reap unifies on canceled.)
|
|
41
49
|
cancel: { status: 'canceled', intent: null, from: ANY },
|
|
42
50
|
// daemon superviseTick: window gone with no yield/release intent. Intent KEPT
|
|
43
51
|
// (the dead log line still reports it).
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { NodeMeta } from '../canvas/index.js';
|
|
2
|
-
/** Coerce arbitrary text into a 3-
|
|
2
|
+
/** Coerce arbitrary text into a 3-8 word kebab-case name, or '' if nothing
|
|
3
3
|
* usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
|
|
4
|
-
* single hyphen, and clamps to the first
|
|
4
|
+
* single hyphen, and clamps to the first 8 words. */
|
|
5
5
|
export declare function sanitizeSessionName(raw: string): string;
|
|
6
6
|
/** Local fallback: derive a name straight from the prompt (no pi call). Drops
|
|
7
7
|
* stop-words, takes the first few content words. */
|
|
8
8
|
export declare function slugFromPrompt(prompt: string): string;
|
|
9
|
-
/** Synchronously ask pi for a
|
|
9
|
+
/** Synchronously ask pi for a 3-8 word kebab name for `prompt`. Blocks up to
|
|
10
10
|
* NAME_TIMEOUT_MS; on any failure (non-zero exit, timeout, empty/garbled
|
|
11
11
|
* output) falls back to a local slug. Returns '' only for an empty prompt. */
|
|
12
12
|
export declare function generateSessionName(prompt: string): string;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// handle for the editor label.
|
|
3
3
|
//
|
|
4
4
|
// A node's editor label is `<kind> (<mode>) <name> <cycle>` (see editorLabel in
|
|
5
|
-
// launch.ts). The `<name>` is a 3-
|
|
5
|
+
// launch.ts). The `<name>` is a 3-8 word kebab-case "description" derived from
|
|
6
6
|
// the first prompt by asking pi headlessly (`pi -p`), persisted on the node's
|
|
7
7
|
// meta so it survives revives and shows in every cycle.
|
|
8
8
|
//
|
|
@@ -26,7 +26,7 @@ const PROMPT_CAP = 2000;
|
|
|
26
26
|
const NAME_TIMEOUT_MS = 20_000;
|
|
27
27
|
const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. This name is a label used to identify the ' +
|
|
28
28
|
'session at a glance among many other concurrent programming sessions, so it must ' +
|
|
29
|
-
'describe what the task is about. Reply with ONLY a concise 3-
|
|
29
|
+
'describe what the task is about. Reply with ONLY a concise 3-8 word name in ' +
|
|
30
30
|
'kebab-case: lowercase words joined by single hyphens (e.g. `refactor-auth-token-flow`, ' +
|
|
31
31
|
'`add-csv-export-endpoint`). No punctuation, quotes, prose, or trailing text. ' +
|
|
32
32
|
'Output JUST the name, nothing else.';
|
|
@@ -43,9 +43,9 @@ const STOPWORDS = new Set([
|
|
|
43
43
|
'is', 'are', 'be', 'this', 'that', 'it', 'as', 'at', 'by', 'from', 'into',
|
|
44
44
|
'please', 'can', 'you', 'i', 'we', 'my', 'our', 'me', 'so', 'then',
|
|
45
45
|
]);
|
|
46
|
-
/** Coerce arbitrary text into a 3-
|
|
46
|
+
/** Coerce arbitrary text into a 3-8 word kebab-case name, or '' if nothing
|
|
47
47
|
* usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
|
|
48
|
-
* single hyphen, and clamps to the first
|
|
48
|
+
* single hyphen, and clamps to the first 8 words. */
|
|
49
49
|
export function sanitizeSessionName(raw) {
|
|
50
50
|
const firstLine = (raw ?? '').split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
|
|
51
51
|
const words = firstLine
|
|
@@ -53,7 +53,7 @@ export function sanitizeSessionName(raw) {
|
|
|
53
53
|
.replace(/[^a-z0-9]+/g, '-')
|
|
54
54
|
.split('-')
|
|
55
55
|
.filter((w) => w !== '');
|
|
56
|
-
return words.slice(0,
|
|
56
|
+
return words.slice(0, 8).join('-');
|
|
57
57
|
}
|
|
58
58
|
/** Local fallback: derive a name straight from the prompt (no pi call). Drops
|
|
59
59
|
* stop-words, takes the first few content words. */
|
|
@@ -97,7 +97,7 @@ function nameArgs(prompt) {
|
|
|
97
97
|
argv.push(nameUserPrompt(prompt));
|
|
98
98
|
return argv;
|
|
99
99
|
}
|
|
100
|
-
/** Synchronously ask pi for a
|
|
100
|
+
/** Synchronously ask pi for a 3-8 word kebab name for `prompt`. Blocks up to
|
|
101
101
|
* NAME_TIMEOUT_MS; on any failure (non-zero exit, timeout, empty/garbled
|
|
102
102
|
* output) falls back to a local slug. Returns '' only for an empty prompt. */
|
|
103
103
|
export function generateSessionName(prompt) {
|
|
@@ -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 {
|
|
@@ -171,16 +181,32 @@ export interface FocusResult {
|
|
|
171
181
|
revived: boolean;
|
|
172
182
|
}
|
|
173
183
|
/** PURE disposition of a focus's outgoing occupant after a retarget swap (§2.5/
|
|
174
|
-
* §1.3)
|
|
175
|
-
*
|
|
176
|
-
*
|
|
184
|
+
* §1.3). Four signals decide one of three fates; unit-testable in isolation:
|
|
185
|
+
* - `kill` — a holder pane (no row) or a done/dead/canceled node: reap the
|
|
186
|
+
* (now-backstage) pane (Invariant P: not-focused + not-live ⇒
|
|
187
|
+
* no pane).
|
|
188
|
+
* - `backstage`— a human-driven RESIDENT node (editor/root/orchestrator — NEVER
|
|
189
|
+
* despawned on focus-away), or a terminal worker that is
|
|
190
|
+
* genuinely MID-TURN (Invariant F2 — keeps running off-screen).
|
|
191
|
+
* - `release` — a PARKED terminal viewer (live but not mid-turn): a node
|
|
192
|
+
* revived only for inspection. Despawn it back to dormant
|
|
193
|
+
* (transition `release` → idle/idle-release) and reap its pane;
|
|
194
|
+
* the daemon revives it on its inbox, or the user re-focuses.
|
|
195
|
+
* This is the bug fix: such a node was misclassified as
|
|
196
|
+
* generating and left stuck active forever.
|
|
197
|
+
* Order matters: `resident` is checked BEFORE `generating` so a resident node is
|
|
198
|
+
* always kept warm regardless of whether it happens to be mid-turn. */
|
|
177
199
|
export type OutgoingAction = {
|
|
178
200
|
kind: 'backstage';
|
|
179
201
|
} | {
|
|
180
202
|
kind: 'kill';
|
|
203
|
+
} | {
|
|
204
|
+
kind: 'release';
|
|
181
205
|
};
|
|
182
206
|
export declare function outgoingDisposition(o: {
|
|
183
207
|
exists: boolean;
|
|
208
|
+
live: boolean;
|
|
209
|
+
resident: boolean;
|
|
184
210
|
generating: boolean;
|
|
185
211
|
}): OutgoingAction;
|
|
186
212
|
/** Open a NEW viewport (§2.3, F4) — the ONLY path a new pane appears in a user
|
|
@@ -210,8 +236,9 @@ export declare function registerRootFocus(nodeId: string, pane: string, session:
|
|
|
210
236
|
* - `swapPaneInPlace(pin, focusPane)`: incoming → the viewport slot; the
|
|
211
237
|
* outgoing occupant → incoming's old (backstage) slot, %ids preserved
|
|
212
238
|
* (cross-session swap confirmed by the spike).
|
|
213
|
-
* - outgoing still
|
|
214
|
-
*
|
|
239
|
+
* - outgoing resident OR still mid-turn → backstage (kept warm / F2); a parked
|
|
240
|
+
* terminal viewer → RELEASE (status → idle, pane reaped); a holder or
|
|
241
|
+
* done/dormant occupant → reap its now-backstage pane (Invariant P).
|
|
215
242
|
* Arms remain-on-exit on the viewport (F3); the focus row is the record. */
|
|
216
243
|
export declare function retargetFocus(focusId: string, incoming: string, revive: Reviver): FocusResult;
|
|
217
244
|
/** The front door for `node focus` / `node cycle` (§2.3): resolve which focus the
|