@crouton-kit/crouter 0.3.17 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
  2. package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
  3. package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
  4. package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
  5. package/dist/builtin-personas/plan/{base.md → PERSONA.md} +4 -0
  6. package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
  7. package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
  8. package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
  9. package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
  10. package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
  11. package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
  12. package/dist/builtin-personas/spec/{base.md → PERSONA.md} +4 -0
  13. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
  14. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
  15. package/dist/commands/daemon.js +1 -1
  16. package/dist/commands/human/prompts.js +3 -9
  17. package/dist/commands/human/shared.d.ts +26 -1
  18. package/dist/commands/human/shared.js +48 -10
  19. package/dist/commands/node.js +53 -4
  20. package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
  21. package/dist/core/__tests__/human-surface-target.test.js +98 -0
  22. package/dist/core/__tests__/persona-subkind.test.js +18 -15
  23. package/dist/core/canvas/paths.d.ts +4 -1
  24. package/dist/core/canvas/paths.js +10 -4
  25. package/dist/core/canvas/types.js +2 -2
  26. package/dist/core/help.d.ts +6 -0
  27. package/dist/core/help.js +7 -0
  28. package/dist/core/personas/index.d.ts +4 -3
  29. package/dist/core/personas/index.js +3 -2
  30. package/dist/core/personas/loader.d.ts +34 -16
  31. package/dist/core/personas/loader.js +102 -29
  32. package/dist/core/personas/resolve.d.ts +4 -4
  33. package/dist/core/personas/resolve.js +16 -14
  34. package/dist/core/runtime/placement.d.ts +10 -0
  35. package/dist/core/runtime/placement.js +37 -1
  36. package/dist/core/spawn.d.ts +20 -1
  37. package/dist/core/spawn.js +52 -5
  38. package/dist/pi-extensions/canvas-nav.js +77 -30
  39. package/package.json +1 -1
@@ -1,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
- summary: proposed files/modules/abstractions fit the system's existing decomposition; flags new units that duplicate existing ones or cross layer boundaries
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
- summary: nullability mismatches, type conflicts across parts, hidden N+1s, over-fetching, missing error boundaries, leaky abstractions; owns file-level conflicts between parts
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
- summary: 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
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
- summary: every requirement and design constraint maps to a concrete plan task, classified Covered/Partial/Missing; flags only blocking gaps
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
- summary: input validation, injection surfaces, auth/authz gaps, data exposure, races; flags only risks with a concrete exploit path
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 — base/orchestrator files, the frontmatter contract, 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, base, system prompt]
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
- │ ├── base.md # worker persona (mode=base)
30
- └── orchestrator.md # orchestrator persona (mode=orchestrator) — optional
31
- ├── orchestration-kernel.md # shared; @include-d by orchestrator files
32
- └── runtime-base.md # shared; prepended to EVERY persona automatically
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 `base.md` **or** `orchestrator.md`. `crtr node new --kind <x>` validates against the discovered set and errors with the valid list — your fast existence check.
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
- **`base.md`** — the worker. 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
+ **`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 `base.md`, `--mode orchestrator` composes `base.md body + kernel` and forces `resident` — so write `orchestrator.md` only when the worker and owner prose genuinely differ.
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/base.md # frontmatter + prose
81
- crtr node new --kind researcher "map the auth flow" # spawn; a bad --kind prints the valid kinds
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/base.md`, `developer/orchestrator.md`) as a starting template.
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 `base.md`** — the node never finishes. Reserve `resident` for interactive/long-lived kinds.
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 `base.md` nor `orchestrator.md` is present, or the filename is wrong. The dir alone doesn't register a kind.
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>/base.md.
5
- keywords: [base persona, base.md, worker prompt, system prompt, terminal node]
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
- `base.md` 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]]`.
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>/base.md`.
12
+ Audience: LLM agents writing a `<kind>/PERSONA.md`.
13
13
 
14
14
  ## What a base persona is for
15
15
 
@@ -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 ~/.crtr/crtrd.pid manually.',
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, spawnAndDetach } from '../../core/spawn.js';
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, pickPlacement, runCmd, resolveMaxPanes, } from './shared.js';
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
- const spawn = spawnAndDetach({
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 { countPanesInCurrentWindow, spawnAndDetach, shellQuote } from '../../core/spawn.js';
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 countPanesInCurrentWindow() >= resolveMaxPanes() ? 'new-window' : 'split-h';
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 = spawnAndDetach({
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
  }
@@ -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: explore (map/investigate a codebase), spec (write a spec), design (architect a solution), plan (break work into steps), developer (implement a change), review (validate/critique), general (anything else).' },
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 ~/.crtr/nodes/<id>/ and indexes it in canvas.db.',
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: developer (own feature delivery), review, spec, design, plan, explore, general. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
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
+ });