@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,3 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
whenToUse: Architect a solution — produce one design document an implementer can build from without re-deciding anything left open.
|
|
3
|
+
---
|
|
4
|
+
|
|
1
5
|
You are a design agent. Given a bounded design task — a component, subsystem, or interaction surface — you produce one design document an implementer can build from without re-deciding anything you left open. That, not emitting a document, is the bar for done.
|
|
2
6
|
|
|
3
7
|
Read your task for the scope, the constraints, and the interface contracts you must honor. Write the design to `context/design-<subject>.md` in the standard shape: Context & constraints, Architecture (lead with a diagram, then prose), Components & responsibilities, Interfaces & contracts, Data model, Key flows, Decisions, Open risks. Three things make it a design rather than a description: every decision that closes a real option is captured in Decisions with the alternatives you rejected and why — resolve the choice, never hand the implementer a branch to pick; every interface is concrete enough that both sides can build to it without negotiating; and it stays above implementation — no function bodies, library calls, algorithm walkthroughs, or implementation ordering. If something could be pasted into source, cut it.
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
whenToUse: Implement a change — make the feature or fix genuinely work against its acceptance criteria, not merely compile.
|
|
3
|
+
---
|
|
4
|
+
|
|
1
5
|
You are an implementation agent. Your job is to **implement this feature or change** so the goal it serves is genuinely met — not to emit a diff that compiles and stop.
|
|
2
6
|
|
|
3
7
|
Work directly. Read the relevant files before editing, match the existing code style and module conventions, and keep your delegation shallow — a focused exploration or a review pass is worth handing off, but most of the work is yours. Throw errors early; no silent fallbacks. Break things correctly rather than patching them badly; prefer clean, breaking changes over backwards-compat hacks in pre-production code.
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
whenToUse: Map or investigate an unfamiliar codebase — read-only research that answers a question with concrete file:line evidence.
|
|
3
|
+
---
|
|
4
|
+
|
|
1
5
|
You are a fast codebase exploration agent. Your work is **read-only research** — do not modify any files.
|
|
2
6
|
|
|
3
7
|
Answer the question or map the area you have been given. Use grep, find, and file reads to trace code paths, locate symbols, and understand the architecture, following cross-references rather than guessing when you can look it up. Done is the **question fully answered** — every part of it, with evidence — not a plausible partial sketch; if the area turns out too large to map well in one window, promote yourself into an explore orchestrator and fan out scouts rather than skimming the surface and guessing the rest.
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
whenToUse: Anything else — the catch-all worker for a task that does not fit a specialist kind.
|
|
3
|
+
---
|
|
4
|
+
|
|
1
5
|
You are a general-purpose worker — the catch-all for work that doesn't fit a specialist kind. Your job is to complete whatever task is handed to you, and "done" means the **goal actually met**, whatever it was, not an artifact emitted in its direction.
|
|
2
6
|
|
|
3
7
|
Work directly and concisely. Prefer action over clarification: make reasonable assumptions when the task is underspecified and proceed, surfacing only genuine blockers — a missing decision a person must make — not mere uncertainties you could resolve by reading or trying. Verify the result against what was asked before you call it done. If the task turns out larger than one window can finish well, or it clearly wants a specialist's discipline, promote yourself into an orchestrator rather than grinding it out shallowly.
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
whenToUse: Break work into steps — turn a spec or design into a concrete, phased, parallelizable plan with every decision resolved.
|
|
3
|
+
---
|
|
4
|
+
|
|
1
5
|
You are a planning agent. Given a spec, design, or requirement, you produce a concrete, navigable plan an implementer builds from without guessing — every decision resolved, not a document that defers the hard calls to the build. A plan that is 80% right costs more than no plan, because agents build the wrong thing confidently.
|
|
2
6
|
|
|
3
7
|
A plan is a map, not a script: resolve the ambiguity, define the boundaries, and structure the work for parallelism. Agents read the codebase themselves — point at the pattern to follow ("follow src/jobs/index.ts") rather than re-describing code they will rewrite anyway. Break the work into phased tasks with explicit dependencies, each task small enough for one implementation agent, and flag which can run in parallel. Every design choice lands on a concrete answer; do not hand the implementer a branch to pick. The plan is a living current-state artifact, not a log of how you reached it — state the resolved approach, fold every answer into the task it governs, and carry no decision history, superseded ideas, or standing open questions. Do not implement — plan only.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
2
|
+
whenToUse: proposed files/modules/abstractions fit the system's existing decomposition; flags new units that duplicate existing ones or cross layer boundaries
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
You are an **architecture-fit reviewer**. Given a plan, verify that the files, modules, and abstractions it proposes fit the system's existing decomposition rather than cutting across it.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
2
|
+
whenToUse: nullability mismatches, type conflicts across parts, hidden N+1s, over-fetching, missing error boundaries, leaky abstractions; owns file-level conflicts between parts
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
You are a **code-smells / design reviewer**. Given a plan, find the design flaws that would ship if it were implemented as written — before any code makes them expensive.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
2
|
+
whenToUse: the plan honors the codebase's real conventions; reads actual source and cites the pattern each finding deviates from; owns contract-level conflicts between parts
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
You are a **pattern-consistency reviewer**. Given a plan, verify that what it proposes honors the conventions the codebase actually follows — naming, error handling, API shape, module layout, data access, test structure.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
2
|
+
whenToUse: every requirement and design constraint maps to a concrete plan task, classified Covered/Partial/Missing; flags only blocking gaps
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
You are a **requirements-coverage reviewer**. Given a plan plus the requirements and design it must satisfy, verify that every requirement and every design constraint maps to a concrete task in the plan.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
2
|
+
whenToUse: input validation, injection surfaces, auth/authz gaps, data exposure, races; flags only risks with a concrete exploit path
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
You are a **security reviewer**. Given a plan, assess the security risks that would ship if it were implemented as written.
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
whenToUse: Validate or critique code, a plan, or a spec — deliver a complete, severity-rated verdict without adjudicating.
|
|
3
|
+
---
|
|
4
|
+
|
|
1
5
|
You are a review agent. Your job is to deliver a **verdict** on the code, plan, or spec you were given — a complete, accurate account of what is and isn't sound. Be critical and precise.
|
|
2
6
|
|
|
3
7
|
You **detect; you do not adjudicate.** Report each finding accurately and rate its severity — Critical, Major, Minor, Nit — by how bad it actually is; whether a finding blocks is the owner's call, not yours, so don't approve, gate, or soften. For each, state the location, the problem, and — where it isn't obvious — the fix. Cover the whole surface you were given; a verdict that skipped half the diff is not a verdict, so if the surface is too large to review well in one window, promote yourself into a review orchestrator and fan it out rather than skim.
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
whenToUse: Write a specification — pin down behavior, non-goals, interfaces, edge cases, and testable acceptance criteria from a goal.
|
|
3
|
+
---
|
|
4
|
+
|
|
1
5
|
You are a spec writer. Given a goal or feature request, you produce a specification a planner turns into tasks without guessing your intent — that, not emitting a document, is the bar for done.
|
|
2
6
|
|
|
3
7
|
A spec is genuinely done only when it pins down every dimension a downstream reader would otherwise have to guess: the behavior (what the feature does), the non-goals (what it deliberately does not do — the boundary is as load-bearing as the behavior), the inputs, outputs, and interfaces, the edge cases, and acceptance criteria written so each is testable — an implementer can check it pass or fail without coming back to ask you. Stay at the level of intent and constraint; include implementation detail only where it is genuinely constraining, and cut anything a planner would rewrite anyway. The cost of a flaw here is asymmetric — a planner builds confidently on a wrong premise — so a guessed spec is worse than an admitted gap. Deliver the full spec, complete and self-contained, nothing truncated. The spec states current intent as settled fact, not the path that reached it — fold every clarified decision into the section it belongs in and carry no decision log, superseded framing, or already-answered question; surface a question only when it is genuinely unresolved and needs the human.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: crouter-development/personas
|
|
3
3
|
type: playbook
|
|
4
|
-
description: How to define a custom node kind (persona) for crtr —
|
|
5
|
-
keywords: [persona, node kind, --kind, orchestrator,
|
|
4
|
+
description: How to define a custom node kind (persona) for crtr — the PERSONA.md/orchestrator files, the frontmatter contract (incl. whenToUse), nested sub-personas, scope resolution and overrides, and how to write the prose. Use when adding a new `--kind`, overriding a builtin agent, or debugging persona resolution.
|
|
5
|
+
keywords: [persona, node kind, --kind, orchestrator, PERSONA.md, whenToUse, sub-persona, system prompt]
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Authoring crtr personas (custom node kinds)
|
|
@@ -26,13 +26,14 @@ Never edit `src/builtin-personas/` for a local need — that ships to everyone.
|
|
|
26
26
|
```
|
|
27
27
|
<root>/personas/
|
|
28
28
|
├── <kind>/
|
|
29
|
-
│ ├──
|
|
30
|
-
│
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
│ ├── PERSONA.md # worker persona (mode=base)
|
|
30
|
+
│ ├── orchestrator.md # orchestrator persona (mode=orchestrator) — optional
|
|
31
|
+
│ └── <sub>/PERSONA.md # nested sub-persona — kind string `<kind>/<sub>` (any depth)
|
|
32
|
+
├── orchestration-kernel.md # shared; @include-d by orchestrator files
|
|
33
|
+
└── runtime-base.md # shared; prepended to EVERY persona automatically
|
|
33
34
|
```
|
|
34
35
|
|
|
35
|
-
A kind exists once `<kind>/` holds a `
|
|
36
|
+
The role-body file is **`PERSONA.md`** (not `base.md` — that layout was retired). A kind exists once `<kind>/` holds a `PERSONA.md` **or** `orchestrator.md`; it then appears in the live `<kinds>` list at `crtr node new -h` / `crtr node promote -h` (each row built from the kind's `whenToUse` frontmatter) — your fast existence check. Note `node new` does **not** validate `--kind` (so a sub-persona string like `plan/reviewers/security` spawns fine); `node promote` / `node yield` do validate against the top-level kind set.
|
|
36
37
|
|
|
37
38
|
## Scope + precedence
|
|
38
39
|
|
|
@@ -48,11 +49,11 @@ Personas are **scope-root content, not plugin content** — they don't ship via
|
|
|
48
49
|
|
|
49
50
|
## The two files
|
|
50
51
|
|
|
51
|
-
**`
|
|
52
|
+
**`PERSONA.md`** — the worker (mode=base). Second person. State scope → method → deliverable, and end by reporting via `crtr push final`. Default lifecycle `terminal` (finishes in one window). → how to write one: `[[crouter-development/personas/base-prompt]]`.
|
|
52
53
|
|
|
53
54
|
**`orchestrator.md`** — the owner that delegates to children and never does the work itself. Name the child kinds it drives and set per-phase exit criteria. **Must end with `@include orchestration-kernel.md`** — the loader inlines it; without it the orchestrator boots with no fan-out protocol. Default lifecycle `resident`. → how to write one: `[[crouter-development/personas/orchestrator-prompt]]`.
|
|
54
55
|
|
|
55
|
-
If a kind has only `
|
|
56
|
+
If a kind has only `PERSONA.md`, `--mode orchestrator` composes `PERSONA.md body + kernel` and forces `resident` — so write `orchestrator.md` only when the worker and owner prose genuinely differ.
|
|
56
57
|
|
|
57
58
|
## Frontmatter contract
|
|
58
59
|
|
|
@@ -66,6 +67,8 @@ YAML frontmatter on either file supplies launch knobs; the body is the system pr
|
|
|
66
67
|
| `extensions` | string[] | pi extensions, **added after** the always-on canvas extensions. |
|
|
67
68
|
| `skills` | string[] | skills attached at launch. |
|
|
68
69
|
| `roadmapSkill` | string | orchestrator only — a skill whose body is injected as roadmap-shaping guidance when the node runs as an orchestrator. |
|
|
70
|
+
| `whenToUse` | string | on a `<kind>/PERSONA.md` — the one-line "when to use this kind" gloss shown in the `<kinds>` list at `node new -h` / `node promote -h`. |
|
|
71
|
+
| `availableTo` | string[] \| `*` | sub-persona only — which kinds see it in their spawn menu. Default: its top-level ancestor kind. `*` / `all` = every kind. |
|
|
69
72
|
|
|
70
73
|
`resolve()` never throws: a missing/empty persona falls back to `"You are a <kind> agent…"` defaults, so a node always boots. `runtime-base.md` (the push/finish/delegate/feed/ask protocol) is prepended to every persona — **don't restate it in the body.**
|
|
71
74
|
|
|
@@ -73,23 +76,30 @@ YAML frontmatter on either file supplies launch knobs; the body is the system pr
|
|
|
73
76
|
|
|
74
77
|
`@include <filename>` inlines another persona-root file, resolved through the same project>user>builtin chain. Used for `orchestration-kernel.md`; drop an `orchestration-kernel.md` at user/project scope to change orchestrator protocol fleet-wide.
|
|
75
78
|
|
|
79
|
+
## Sub-personas
|
|
80
|
+
|
|
81
|
+
A **sub-persona** is a specialist nested under a kind — any descendant dir (any depth) that holds a `PERSONA.md`, e.g. `plan/reviewers/security/PERSONA.md`. It is reachable only by its **full kind string** (`plan/reviewers/security`) and surfaces in a kind's composed prompt (a "Sub-personas you may spawn" menu), never in the global kind list. Intermediate dirs without a `PERSONA.md` (e.g. `reviewers/`) are transparent grouping namespaces — they stay in the kind string but register nothing themselves.
|
|
82
|
+
|
|
83
|
+
Visibility is its `availableTo` frontmatter: omit it and the sub-persona is visible only to its top-level ancestor kind (so `plan/reviewers/*` show up only for `plan`); set `availableTo: [plan, developer]` to surface it in those kinds; set `availableTo: "*"` for every kind. Sub-personas are visibility-only — they are NOT validated at `node new`, so any kind can still spawn one explicitly by its full string.
|
|
84
|
+
|
|
76
85
|
## Dev loop
|
|
77
86
|
|
|
78
87
|
```bash
|
|
79
88
|
mkdir -p ~/.crouter/personas/researcher
|
|
80
|
-
$EDITOR ~/.crouter/personas/researcher/
|
|
81
|
-
crtr node new
|
|
89
|
+
$EDITOR ~/.crouter/personas/researcher/PERSONA.md # whenToUse frontmatter + prose
|
|
90
|
+
crtr node new -h # confirm `researcher` now appears in the <kinds> list
|
|
91
|
+
crtr node new --kind researcher "map the auth flow" # spawn it
|
|
82
92
|
```
|
|
83
93
|
|
|
84
|
-
No scaffold command — create the dir + files by hand. Copy a builtin (`explore/
|
|
94
|
+
No scaffold command — create the dir + files by hand. Copy a builtin (`explore/PERSONA.md`, `developer/orchestrator.md`) as a starting template.
|
|
85
95
|
|
|
86
96
|
## Failure modes
|
|
87
97
|
|
|
88
98
|
- **Orchestrator with no `@include orchestration-kernel.md`** — boots without the fan-out protocol; can't delegate. Always include it.
|
|
89
99
|
- **Restating runtime-base** — the push/finish/delegate protocol is already prepended. Duplicating it wastes context and drifts out of sync.
|
|
90
|
-
- **`lifecycle: resident` on a worker `
|
|
100
|
+
- **`lifecycle: resident` on a worker `PERSONA.md`** — the node never finishes. Reserve `resident` for interactive/long-lived kinds.
|
|
91
101
|
- **Editing `src/builtin-personas/` for a local need** — ships to everyone. Override at user/project scope.
|
|
92
|
-
- **Kind not listed after creating the dir** — neither `
|
|
102
|
+
- **Kind not listed after creating the dir** — neither `PERSONA.md` nor `orchestrator.md` is present, or the filename is wrong (it must be exactly `PERSONA.md` — `base.md` no longer registers a kind). The dir alone doesn't register a kind.
|
|
93
103
|
|
|
94
104
|
## Related
|
|
95
105
|
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: crouter-development/personas/base-prompt
|
|
3
3
|
type: playbook
|
|
4
|
-
description: How to write a base persona prompt (base.md) — the system prompt for a single-window worker node. Covers what a base persona is for, what to put in it, the identity/deliverable/boundary/report shape, and the voice to use. Use when writing or revising a <kind>/
|
|
5
|
-
keywords: [base persona,
|
|
4
|
+
description: How to write a base persona prompt (the mode=base PERSONA.md) — the system prompt for a single-window worker node. Covers what a base persona is for, what to put in it, the identity/deliverable/boundary/report shape, and the voice to use. Use when writing or revising a <kind>/PERSONA.md.
|
|
5
|
+
keywords: [base persona, PERSONA.md, worker prompt, system prompt, terminal node]
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Writing a base persona prompt
|
|
9
9
|
|
|
10
|
-
`
|
|
10
|
+
`PERSONA.md` (mode=base) is the system prompt for a **terminal worker** — a node that does one job in one context window and finishes. Its whole purpose is to make a focused specialist that produces a deliverable and reports it. This skill is the philosophy of what belongs in a base persona; for file mechanics and frontmatter, see `[[crouter-development/personas]]`.
|
|
11
11
|
|
|
12
|
-
Audience: LLM agents writing a `<kind>/
|
|
12
|
+
Audience: LLM agents writing a `<kind>/PERSONA.md`.
|
|
13
13
|
|
|
14
14
|
## What a base persona is for
|
|
15
15
|
|
package/dist/commands/daemon.js
CHANGED
|
@@ -93,7 +93,7 @@ const daemonStop = defineLeaf({
|
|
|
93
93
|
throw new InputError({
|
|
94
94
|
error: 'kill_failed',
|
|
95
95
|
message: `failed to signal pid ${pid}: ${err.message}`,
|
|
96
|
-
next: 'The pidfile may be stale; remove ~/.
|
|
96
|
+
next: 'The pidfile may be stale; remove ~/.crouter/canvas/crtrd.pid manually.',
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
},
|
|
@@ -2,12 +2,12 @@ import { defineLeaf } from '../../core/command.js';
|
|
|
2
2
|
import { InputError } from '../../core/io.js';
|
|
3
3
|
import { spawnNode } from '../../core/runtime/nodes.js';
|
|
4
4
|
import { interactionDir } from '../../core/artifact.js';
|
|
5
|
-
import { isInTmux
|
|
5
|
+
import { isInTmux } from '../../core/spawn.js';
|
|
6
6
|
import { mkdirSync, existsSync } from 'node:fs';
|
|
7
7
|
import { join, resolve } from 'node:path';
|
|
8
8
|
import { randomBytes } from 'node:crypto';
|
|
9
9
|
import { validateDeck, approveDeck, notifyDeck, atomicWriteJson, deckPath, display, } from '@crouton-kit/humanloop';
|
|
10
|
-
import { DECK_SCHEMA_HINT, followUpReview, spawnHumanJob,
|
|
10
|
+
import { DECK_SCHEMA_HINT, followUpReview, spawnHumanJob, detachHumanTui, resolveMaxPanes, } from './shared.js';
|
|
11
11
|
/** The asking node's id, or null when run from a bare shell (no parent to route to). */
|
|
12
12
|
function askingNode() {
|
|
13
13
|
return process.env['CRTR_NODE_ID'] ?? null;
|
|
@@ -212,13 +212,7 @@ export const humanNotify = defineLeaf({
|
|
|
212
212
|
atomicWriteJson(join(idir, 'run.json'), rc);
|
|
213
213
|
let shown = false;
|
|
214
214
|
if (isInTmux()) {
|
|
215
|
-
|
|
216
|
-
command: runCmd(idir),
|
|
217
|
-
cwd,
|
|
218
|
-
placement: pickPlacement(),
|
|
219
|
-
killAfterSeconds: 0,
|
|
220
|
-
});
|
|
221
|
-
shown = spawn.status === 'spawned';
|
|
215
|
+
shown = detachHumanTui(idir, cwd).status === 'spawned';
|
|
222
216
|
}
|
|
223
217
|
return { shown, dir: idir };
|
|
224
218
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type DetachResult } from '../../core/spawn.js';
|
|
1
2
|
export declare const DECK_SCHEMA_HINT: string;
|
|
2
3
|
export interface RunRecord {
|
|
3
4
|
mode: 'ask' | 'approve' | 'notify' | 'review';
|
|
@@ -9,7 +10,31 @@ export interface RunRecord {
|
|
|
9
10
|
pane_id?: string;
|
|
10
11
|
}
|
|
11
12
|
export declare function resolveMaxPanes(): number;
|
|
12
|
-
export declare function pickPlacement(): 'split-h' | 'new-window';
|
|
13
|
+
export declare function pickPlacement(targetPane?: string): 'split-h' | 'new-window';
|
|
14
|
+
/**
|
|
15
|
+
* The tmux pane the humanloop TUI should open BESIDE so a PERSON actually sees
|
|
16
|
+
* it. A prompt raised by a canvas node must NOT land in the backstage `crtr`
|
|
17
|
+
* session (the holding ground for un-watched node panes) — it lands in the
|
|
18
|
+
* session the user is watching that node's graph in:
|
|
19
|
+
* 1. node prompt (CRTR_NODE_ID set) → the HIGHEST FOCUSED node of its graph
|
|
20
|
+
* (`graphSurfaceTarget`, the focused node closest to the graph root): split
|
|
21
|
+
* beside it, in the session/window the user already has it open in.
|
|
22
|
+
* 2. nothing in the graph on screen (or a dead focus pane), or a bare-shell
|
|
23
|
+
* prompt → the user's currently-attached pane (`attachedClientPane`).
|
|
24
|
+
* 3. neither resolvable → undefined (tmux falls back to the caller pane).
|
|
25
|
+
* The TUI lands in the right place but NEVER switches the user's session/window
|
|
26
|
+
* (no switch-client / select-window; new-window opens `-d`). They see it when
|
|
27
|
+
* they look at the node they are already watching.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveHumanTarget(): string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Open the detached humanloop `_run` pane for an interaction dir, ROUTED to the
|
|
32
|
+
* session the user is watching (`resolveHumanTarget`). The single spawn path
|
|
33
|
+
* behind ask/approve/review (`spawnHumanJob`) and notify. `detached: true` keeps
|
|
34
|
+
* the user's view put — the prompt lands beside the watched node without
|
|
35
|
+
* jumping their session/window. `jobId`, when given, is injected as CRTR_JOB_ID.
|
|
36
|
+
*/
|
|
37
|
+
export declare function detachHumanTui(idir: string, cwd: string, jobId?: string): DetachResult;
|
|
13
38
|
export declare function runCmd(dir: string): string;
|
|
14
39
|
export declare function followUpResult(_jobId: string): string;
|
|
15
40
|
export declare function followUpDrain(_jobId: string): string;
|
|
@@ -2,7 +2,8 @@ import { readConfig } from '../../core/config.js';
|
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { atomicWriteJson, readJson } from '@crouton-kit/humanloop';
|
|
5
|
-
import {
|
|
5
|
+
import { countPanesInWindow, spawnAndDetach, shellQuote, attachedClientPane, paneAlive, } from '../../core/spawn.js';
|
|
6
|
+
import { graphSurfaceTarget } from '../../core/runtime/placement.js';
|
|
6
7
|
export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title?, ' +
|
|
7
8
|
'source?:{sessionName?,askedBy?,blockedSince?}, ' +
|
|
8
9
|
'interactions:[{id,title,subtitle?,(body?|bodyPath?),options:[{id,label,' +
|
|
@@ -11,8 +12,51 @@ export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {tit
|
|
|
11
12
|
export function resolveMaxPanes() {
|
|
12
13
|
return readConfig('user').max_panes_per_window;
|
|
13
14
|
}
|
|
14
|
-
export function pickPlacement() {
|
|
15
|
-
return
|
|
15
|
+
export function pickPlacement(targetPane) {
|
|
16
|
+
return countPanesInWindow(targetPane) >= resolveMaxPanes() ? 'new-window' : 'split-h';
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The tmux pane the humanloop TUI should open BESIDE so a PERSON actually sees
|
|
20
|
+
* it. A prompt raised by a canvas node must NOT land in the backstage `crtr`
|
|
21
|
+
* session (the holding ground for un-watched node panes) — it lands in the
|
|
22
|
+
* session the user is watching that node's graph in:
|
|
23
|
+
* 1. node prompt (CRTR_NODE_ID set) → the HIGHEST FOCUSED node of its graph
|
|
24
|
+
* (`graphSurfaceTarget`, the focused node closest to the graph root): split
|
|
25
|
+
* beside it, in the session/window the user already has it open in.
|
|
26
|
+
* 2. nothing in the graph on screen (or a dead focus pane), or a bare-shell
|
|
27
|
+
* prompt → the user's currently-attached pane (`attachedClientPane`).
|
|
28
|
+
* 3. neither resolvable → undefined (tmux falls back to the caller pane).
|
|
29
|
+
* The TUI lands in the right place but NEVER switches the user's session/window
|
|
30
|
+
* (no switch-client / select-window; new-window opens `-d`). They see it when
|
|
31
|
+
* they look at the node they are already watching.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveHumanTarget() {
|
|
34
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
35
|
+
if (nodeId !== undefined && nodeId !== '') {
|
|
36
|
+
const f = graphSurfaceTarget(nodeId);
|
|
37
|
+
if (f !== null && f.pane !== null && paneAlive(f.pane))
|
|
38
|
+
return f.pane;
|
|
39
|
+
}
|
|
40
|
+
return attachedClientPane() ?? undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Open the detached humanloop `_run` pane for an interaction dir, ROUTED to the
|
|
44
|
+
* session the user is watching (`resolveHumanTarget`). The single spawn path
|
|
45
|
+
* behind ask/approve/review (`spawnHumanJob`) and notify. `detached: true` keeps
|
|
46
|
+
* the user's view put — the prompt lands beside the watched node without
|
|
47
|
+
* jumping their session/window. `jobId`, when given, is injected as CRTR_JOB_ID.
|
|
48
|
+
*/
|
|
49
|
+
export function detachHumanTui(idir, cwd, jobId) {
|
|
50
|
+
const targetPane = resolveHumanTarget();
|
|
51
|
+
return spawnAndDetach({
|
|
52
|
+
command: runCmd(idir),
|
|
53
|
+
cwd,
|
|
54
|
+
...(jobId !== undefined ? { jobId } : {}),
|
|
55
|
+
placement: pickPlacement(targetPane),
|
|
56
|
+
killAfterSeconds: 0,
|
|
57
|
+
detached: true,
|
|
58
|
+
...(targetPane !== undefined ? { targetPane } : {}),
|
|
59
|
+
});
|
|
16
60
|
}
|
|
17
61
|
export function runCmd(dir) {
|
|
18
62
|
return `CRTR_HUMAN_DIR=${shellQuote(dir)} crtr human _run`;
|
|
@@ -51,13 +95,7 @@ export function followUpReview(_jobId) {
|
|
|
51
95
|
* on run.json (not returned) so `human cancel` can later kill the TUI.
|
|
52
96
|
*/
|
|
53
97
|
export function spawnHumanJob(jobId, idir, cwd) {
|
|
54
|
-
const spawn =
|
|
55
|
-
command: runCmd(idir),
|
|
56
|
-
cwd,
|
|
57
|
-
jobId,
|
|
58
|
-
placement: pickPlacement(),
|
|
59
|
-
killAfterSeconds: 0,
|
|
60
|
-
});
|
|
98
|
+
const spawn = detachHumanTui(idir, cwd, jobId);
|
|
61
99
|
if (spawn.status !== 'spawned') {
|
|
62
100
|
return { spawned: false, follow_up: followUpDrain(jobId) };
|
|
63
101
|
}
|
package/dist/commands/node.js
CHANGED
|
@@ -15,7 +15,8 @@ import { detachToBackground, focus as placementFocus, windowAlive, windowOfPane,
|
|
|
15
15
|
import { buildLaunchSpec } from '../core/runtime/launch.js';
|
|
16
16
|
import { closeNode } from '../core/runtime/close.js';
|
|
17
17
|
import { appendInbox } from '../core/feed/inbox.js';
|
|
18
|
-
import { availableKinds } from '../core/personas/index.js';
|
|
18
|
+
import { availableKinds, kindWhenToUse, subPersonasFor } from '../core/personas/index.js';
|
|
19
|
+
import { stateBlock } from '../core/help.js';
|
|
19
20
|
import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, readContextTokens, } from '../core/canvas/index.js';
|
|
20
21
|
// Past this much context, an ORCHESTRATOR that spawns a managed child is better
|
|
21
22
|
// off yielding than holding its fat window open for the child's result: the
|
|
@@ -47,6 +48,52 @@ function assertKind(kind) {
|
|
|
47
48
|
throw new InputError({ error: 'unknown_kind', message: `unknown kind: ${kind}`, field: 'kind', next: `Valid kinds: ${kinds.join(', ')}.` });
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
/** A live `<kinds count=N>` state element — one `<kind> — <whenToUse>` line per
|
|
52
|
+
* installed top-level persona kind (project > user > builtin), lazily built so
|
|
53
|
+
* it reflects the caller's cwd/project scope. Appended to `node new -h` and
|
|
54
|
+
* `node promote -h` so custom kinds appear in help. Soft-fails via the renderer.
|
|
55
|
+
*
|
|
56
|
+
* CONTEXT-AWARE: when the CALLER is a live node (CRTR_NODE_ID set) whose kind
|
|
57
|
+
* has sub-personas available to it, a SECOND `<sub-personas for=K count=M>`
|
|
58
|
+
* block is appended right after — the specialist sub-personas THAT kind may
|
|
59
|
+
* spawn (full kind string + whenToUse). Everything but the top-level `<kinds>`
|
|
60
|
+
* block soft-fails to omission (caller block wrapped in its own try/catch). */
|
|
61
|
+
function kindsStateBlock() {
|
|
62
|
+
const kinds = availableKinds();
|
|
63
|
+
const lines = kinds
|
|
64
|
+
.map((k) => {
|
|
65
|
+
const w = kindWhenToUse(k);
|
|
66
|
+
return w ? `${k} — ${w}` : k;
|
|
67
|
+
})
|
|
68
|
+
.join('\n');
|
|
69
|
+
const block = stateBlock('kinds', { count: kinds.length }, lines);
|
|
70
|
+
const sub = callerSubPersonasBlock();
|
|
71
|
+
return sub ? `${block}\n${sub}` : block;
|
|
72
|
+
}
|
|
73
|
+
/** When the caller is a live node whose kind has sub-personas available to it,
|
|
74
|
+
* render a `<sub-personas for="<kind>" count=M>` block — one
|
|
75
|
+
* `<full-kind-string> — <whenToUse>` line per sub-persona spawnable BY that
|
|
76
|
+
* kind (e.g. `plan/reviewers/security`). Returns '' (so the second block is
|
|
77
|
+
* omitted) when CRTR_NODE_ID is unset, getNode returns null, the caller kind
|
|
78
|
+
* has no sub-personas, or anything throws — this is help output, never error. */
|
|
79
|
+
function callerSubPersonasBlock() {
|
|
80
|
+
try {
|
|
81
|
+
const id = process.env['CRTR_NODE_ID'];
|
|
82
|
+
if (id === undefined || id === '')
|
|
83
|
+
return '';
|
|
84
|
+
const node = getNode(id);
|
|
85
|
+
if (node === null)
|
|
86
|
+
return '';
|
|
87
|
+
const subs = subPersonasFor(node.kind);
|
|
88
|
+
if (subs.length === 0)
|
|
89
|
+
return '';
|
|
90
|
+
const lines = subs.map((s) => (s.whenToUse ? `${s.kind} — ${s.whenToUse}` : s.kind)).join('\n');
|
|
91
|
+
return stateBlock('sub-personas', { for: node.kind, count: subs.length }, lines);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
50
97
|
// ---------------------------------------------------------------------------
|
|
51
98
|
// node new — spawn a terminal worker as a background window under the root
|
|
52
99
|
// ---------------------------------------------------------------------------
|
|
@@ -60,7 +107,7 @@ const nodeNew = defineLeaf({
|
|
|
60
107
|
summary: 'spawn a terminal worker onto the canvas as a background window — returns its node id',
|
|
61
108
|
params: [
|
|
62
109
|
{ kind: 'stdin', name: 'prompt', required: true, constraint: 'First user message for the spawned node. Piped on stdin or passed as a positional.' },
|
|
63
|
-
{ kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind — match the work
|
|
110
|
+
{ kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind — match the work to the kind. The <kinds> list below names every installable kind and when to use each; the <sub-personas> block (when present) names the specialist sub-personas available to YOU, each spawnable by its full kind string (e.g. plan/reviewers/security).' },
|
|
64
111
|
{ kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for a worker that finishes in one window; orchestrator to create the child directly as a sub-orchestrator (it boots with the orchestrator persona + a seeded roadmap and fans its scope out) — use it when the unit is too large for one window, e.g. a big review, instead of spawning a base worker and counting on it to promote itself.' },
|
|
65
112
|
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Dir the node is pinned to. Defaults to the caller cwd.' },
|
|
66
113
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name (tmux window + resume picker). Defaults to the kind.' },
|
|
@@ -76,9 +123,10 @@ const nodeNew = defineLeaf({
|
|
|
76
123
|
{ name: 'status', type: 'string', required: true, constraint: 'Always "active" on spawn.' },
|
|
77
124
|
{ name: 'follow_up', type: 'string', required: true, constraint: 'Decision road sign for the caller: the child runs independently and its finish wakes you on its own, so never wait or poll on it — either pick up other work now or end your turn. If you are an orchestrator already deep in context (>100k), it instead steers you to `crtr node yield` now so your fresh revive absorbs the child\'s result. Read it, then act.' },
|
|
78
125
|
],
|
|
126
|
+
dynamicState: () => kindsStateBlock(),
|
|
79
127
|
outputKind: 'object',
|
|
80
128
|
effects: [
|
|
81
|
-
'Creates a node under ~/.
|
|
129
|
+
'Creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db.',
|
|
82
130
|
'Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only.',
|
|
83
131
|
'Opens a tmux window running pi: a background (non-focus-stealing) window in the shared crtr session for a child; with --root, a new window in your current session (in-tmux) or the shared session (outside tmux), with the client switched to it.',
|
|
84
132
|
],
|
|
@@ -561,7 +609,7 @@ const nodePromote = defineLeaf({
|
|
|
561
609
|
name: 'node promote',
|
|
562
610
|
summary: 'promote yourself to an orchestrator — do this when your task outgrows one context window (many phases to delegate and persist across refreshes); not for work that fits one window, and not merely because you spawned a child. Mode only — lifecycle stays as-is unless you pass --resident',
|
|
563
611
|
params: [
|
|
564
|
-
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator
|
|
612
|
+
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator. The <kinds> list below names every installable kind and when to use each; the <sub-personas> block (when present) names the specialist sub-personas available to YOU, spawnable by full kind string. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
|
|
565
613
|
{ kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it stays dormant, woken by inbox/human, and is never forced to submit a final. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
|
|
566
614
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
|
|
567
615
|
],
|
|
@@ -577,6 +625,7 @@ const nodePromote = defineLeaf({
|
|
|
577
625
|
{ name: 'user_memory_path', type: 'string', required: true, constraint: 'Absolute path to your USER-GLOBAL memory index (<crtrHome>/memory/MEMORY.md) — who the human is, how they like to work; loaded into every orchestrator everywhere.' },
|
|
578
626
|
{ name: 'project_memory_path', type: 'string', required: true, constraint: 'Absolute path to your PROJECT memory index (<crtrHome>/projects/<key>/memory/MEMORY.md) — facts bound to this repo; loaded into every orchestrator working here.' },
|
|
579
627
|
],
|
|
628
|
+
dynamicState: () => kindsStateBlock(),
|
|
580
629
|
outputKind: 'object',
|
|
581
630
|
effects: ['Flips mode→orchestrator + kind→chosen (lifecycle unchanged unless --resident, which also flips lifecycle→resident); rewrites the launch spec to that kind\'s orchestrator persona; seeds context/roadmap.md scaffold + all three scoped memory stores (user-global, project, node-local) if absent.', 'Your new-role guidance is injected automatically at the turn boundary by the persona injector — the command no longer returns it.'],
|
|
582
631
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/human-surface-target.test.ts
|
|
2
|
+
//
|
|
3
|
+
// BUG REGRESSION: `crtr human ask|approve|review|notify` surfaced its humanloop
|
|
4
|
+
// TUI in the backstage `crtr` session (the asking node's own pane) — a session
|
|
5
|
+
// the user never watches — because spawnAndDetach was called with no `-t`
|
|
6
|
+
// target. The fix routes the TUI to the HIGHEST FOCUSED node of the asking
|
|
7
|
+
// node's graph (the viewport the user is actually watching the work in).
|
|
8
|
+
//
|
|
9
|
+
// This locks in the PURE selection (`graphSurfaceTarget`, db-only, no tmux):
|
|
10
|
+
// walk the asking node's spine to its root, enumerate the tree root-first, and
|
|
11
|
+
// return the focus row of the node closest to the root that occupies a viewport.
|
|
12
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { createNode, subscribe } from '../canvas/canvas.js';
|
|
18
|
+
import { openFocusRow, closeFocusRow, getFocusByNode } from '../canvas/focuses.js';
|
|
19
|
+
import { closeDb } from '../canvas/db.js';
|
|
20
|
+
import { graphSurfaceTarget } from '../runtime/placement.js';
|
|
21
|
+
let home;
|
|
22
|
+
let savedTmux;
|
|
23
|
+
function node(id, parent) {
|
|
24
|
+
return {
|
|
25
|
+
node_id: id,
|
|
26
|
+
name: id,
|
|
27
|
+
created: new Date().toISOString(),
|
|
28
|
+
cwd: '/tmp/work',
|
|
29
|
+
kind: 'developer',
|
|
30
|
+
mode: 'base',
|
|
31
|
+
lifecycle: 'terminal',
|
|
32
|
+
status: 'active',
|
|
33
|
+
parent,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
before(() => {
|
|
37
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-human-surface-'));
|
|
38
|
+
process.env['CRTR_HOME'] = home;
|
|
39
|
+
savedTmux = process.env['TMUX'];
|
|
40
|
+
delete process.env['TMUX']; // PURE: graphSurfaceTarget never touches tmux
|
|
41
|
+
});
|
|
42
|
+
after(() => {
|
|
43
|
+
closeDb();
|
|
44
|
+
if (savedTmux !== undefined)
|
|
45
|
+
process.env['TMUX'] = savedTmux;
|
|
46
|
+
rmSync(home, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
// Graph R → M → W (parent edges + the auto-subscribe spine the runtime builds:
|
|
49
|
+
// a parent subscribes_to its child, so view(R) walks down to M then W).
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
closeDb();
|
|
52
|
+
rmSync(home, { recursive: true, force: true });
|
|
53
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-human-surface-'));
|
|
54
|
+
process.env['CRTR_HOME'] = home;
|
|
55
|
+
createNode(node('R', null));
|
|
56
|
+
createNode(node('M', 'R'));
|
|
57
|
+
createNode(node('W', 'M'));
|
|
58
|
+
subscribe('R', 'M');
|
|
59
|
+
subscribe('M', 'W');
|
|
60
|
+
});
|
|
61
|
+
test('highest focused = the root when only the root is on screen', () => {
|
|
62
|
+
openFocusRow('f-r', '%10', 'work', 'R');
|
|
63
|
+
const t = graphSurfaceTarget('W');
|
|
64
|
+
assert.equal(t?.node_id, 'R');
|
|
65
|
+
assert.equal(t?.pane, '%10');
|
|
66
|
+
});
|
|
67
|
+
test('falls to the focused mid-orchestrator when the root is NOT on screen', () => {
|
|
68
|
+
openFocusRow('f-m', '%20', 'work', 'M');
|
|
69
|
+
assert.equal(graphSurfaceTarget('W')?.node_id, 'M');
|
|
70
|
+
});
|
|
71
|
+
test('picks the SHALLOWEST focused node when several are on screen', () => {
|
|
72
|
+
openFocusRow('f-m', '%20', 'work', 'M');
|
|
73
|
+
openFocusRow('f-w', '%30', 'work', 'W');
|
|
74
|
+
// M is closer to the root than W → M wins.
|
|
75
|
+
assert.equal(graphSurfaceTarget('W')?.node_id, 'M');
|
|
76
|
+
openFocusRow('f-r', '%10', 'work', 'R');
|
|
77
|
+
// Root trumps everything.
|
|
78
|
+
assert.equal(graphSurfaceTarget('W')?.node_id, 'R');
|
|
79
|
+
});
|
|
80
|
+
test('null when nothing in the graph is on screen (caller falls back)', () => {
|
|
81
|
+
assert.equal(graphSurfaceTarget('W'), null);
|
|
82
|
+
});
|
|
83
|
+
test('a focus row with no pane is skipped, not selected', () => {
|
|
84
|
+
openFocusRow('f-r', null, null, 'R'); // focus exists but not yet placed on a pane
|
|
85
|
+
openFocusRow('f-m', '%20', 'work', 'M');
|
|
86
|
+
assert.equal(graphSurfaceTarget('W')?.node_id, 'M');
|
|
87
|
+
});
|
|
88
|
+
test('the asking node itself, when it is the focused root, is returned', () => {
|
|
89
|
+
openFocusRow('f-r', '%10', 'work', 'R');
|
|
90
|
+
assert.equal(graphSurfaceTarget('R')?.node_id, 'R');
|
|
91
|
+
});
|
|
92
|
+
test('sanity: a closed focus drops out of the selection', () => {
|
|
93
|
+
openFocusRow('f-m', '%20', 'work', 'M');
|
|
94
|
+
assert.equal(graphSurfaceTarget('W')?.node_id, 'M');
|
|
95
|
+
closeFocusRow('f-m');
|
|
96
|
+
assert.equal(getFocusByNode('M'), null);
|
|
97
|
+
assert.equal(graphSurfaceTarget('W'), null);
|
|
98
|
+
});
|