@crouton-kit/crouter 0.3.16 → 0.3.17

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 (72) hide show
  1. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  2. package/dist/builtin-personas/plan/base.md +1 -1
  3. package/dist/builtin-personas/spec/base.md +1 -1
  4. package/dist/commands/canvas-browse.d.ts +2 -0
  5. package/dist/commands/canvas-browse.js +45 -0
  6. package/dist/commands/canvas-prune.js +11 -2
  7. package/dist/commands/canvas.js +3 -2
  8. package/dist/commands/node.js +13 -0
  9. package/dist/commands/skill/author.js +2 -2
  10. package/dist/core/__tests__/cascade-close.test.js +199 -0
  11. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  12. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  13. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  14. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  15. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  16. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  17. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  18. package/dist/core/__tests__/grace-clock.test.js +115 -0
  19. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  20. package/dist/core/__tests__/helpers/harness.js +406 -0
  21. package/dist/core/__tests__/lifecycle.test.js +6 -13
  22. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  23. package/dist/core/__tests__/live-mutation.test.js +341 -0
  24. package/dist/core/__tests__/placement-focus.test.js +53 -15
  25. package/dist/core/__tests__/relaunch.test.js +12 -12
  26. package/dist/core/__tests__/reset.test.js +11 -6
  27. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  28. package/dist/core/__tests__/spike-harness.test.js +241 -0
  29. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  30. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  31. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  32. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  33. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  34. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  35. package/dist/core/canvas/browse/app.d.ts +4 -0
  36. package/dist/core/canvas/browse/app.js +349 -0
  37. package/dist/core/canvas/browse/model.d.ts +97 -0
  38. package/dist/core/canvas/browse/model.js +258 -0
  39. package/dist/core/canvas/browse/render.d.ts +41 -0
  40. package/dist/core/canvas/browse/render.js +387 -0
  41. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  42. package/dist/core/canvas/browse/terminal.js +100 -0
  43. package/dist/core/canvas/canvas.d.ts +9 -2
  44. package/dist/core/canvas/canvas.js +41 -3
  45. package/dist/core/canvas/render.d.ts +10 -0
  46. package/dist/core/canvas/render.js +25 -1
  47. package/dist/core/feed/inbox.d.ts +0 -3
  48. package/dist/core/feed/inbox.js +1 -5
  49. package/dist/core/runtime/busy.d.ts +8 -0
  50. package/dist/core/runtime/busy.js +46 -0
  51. package/dist/core/runtime/lifecycle.d.ts +1 -1
  52. package/dist/core/runtime/lifecycle.js +12 -4
  53. package/dist/core/runtime/naming.d.ts +3 -3
  54. package/dist/core/runtime/naming.js +6 -6
  55. package/dist/core/runtime/placement.d.ts +22 -5
  56. package/dist/core/runtime/placement.js +44 -13
  57. package/dist/core/runtime/reset.d.ts +11 -8
  58. package/dist/core/runtime/reset.js +23 -18
  59. package/dist/daemon/crtrd.js +43 -21
  60. package/dist/pi-extensions/canvas-nav.js +29 -25
  61. package/dist/pi-extensions/canvas-resume.d.ts +0 -1
  62. package/dist/pi-extensions/canvas-resume.js +35 -126
  63. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  64. package/dist/pi-extensions/canvas-stophook.js +16 -0
  65. package/dist/prompts/skill.js +6 -1
  66. package/package.json +1 -1
  67. package/dist/commands/__tests__/skill.test.js +0 -290
  68. package/dist/core/__tests__/pkg.test.js +0 -218
  69. package/dist/core/__tests__/sys.test.js +0 -208
  70. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  71. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  72. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -15,30 +15,30 @@ Every time you wake — whether revived fresh after a yield, or woken because a
15
15
  3. **Understand before you delegate.** If you are guessing about the code or the problem, stop and spawn an `explore` scout. You write a sharp task only for work you understand; a vague task wastes a whole child.
16
16
  4. **Find all the parallel work.** Don't default to one child at a time. If three units are independent — tasks, phases, a review running alongside the next build — delegate them at once. A wake with idle capacity is a wasted wake.
17
17
  5. **Don't skip what you noticed.** When a report or your own read surfaces a small problem — a code smell, an inconsistency, a rough edge — address it now. Small things compound; deprioritizing them is how quality erodes.
18
- 6. **Act, then record.** Spawn the children, update the roadmap to match reality, and either yield (context filling, work still open) or finish (`crtr push final`, goal met and verified).
18
+ 6. **Act, then settle the turn.** Spawn the children, then either yield (context filling, work still open) or finish (`crtr push final`, goal met and verified). Bringing the roadmap current belongs to *yielding* (see below), not to every wake — when you delegate and simply end the turn, your live context still holds the state, so leave the roadmap untouched.
19
19
 
20
20
  Be proactive — look ahead. If the current phase is wrapping up, prepare the next one. If a review found issues, spawn the fix agents in the same wake. Every wake should leave the maximum number of agents doing useful work.
21
21
 
22
22
  ## The roadmap is your memory
23
23
 
24
- `context/roadmap.md` is the one artifact that survives your refresh. If it is stale, the fresh you wakes up lost. Keep it current as a reflex, every wake, before you yield. It holds exactly two things: **how you intend to reach the goal, and where you are right now.** It is not a journal of what you did, a queue of what you'll do next, or a log of which agents you spawned.
24
+ `context/roadmap.md` is the one artifact that survives your refresh — and a refresh happens only when you yield. Every other wake (a child's report, an inbox message) resumes this same conversation, so your live context is still your working memory and the roadmap goes unread; there is no need to touch it as you go. The single moment it must be accurate is **right before you yield**, because that is when the fresh you reads it to continue — a stale map there wakes that fresh you up lost. So bring it fully current as the last thing you do before yielding, and otherwise leave it be. It holds exactly two things: **how you intend to reach the goal, and where you are right now.** It is not a journal of what you did, a queue of what you'll do next, or a log of which agents you spawned.
25
25
 
26
26
  **The roadmap has exactly these sections. Nothing else belongs in it.** A **frozen core** you set once and rarely touch:
27
27
  - `## Goal` — one paragraph: what "done" looks like, who and what is affected.
28
28
  - `## Exit criteria` — concrete, evaluable conditions for finishing.
29
29
 
30
- And an **evolving body** you keep current every wake:
30
+ And an **evolving body** you bring current right before you yield:
31
31
  - `## Scope assumptions / non-goals` — what's settled and what's out, so children inherit the framing.
32
32
  - `## Strategy / phases` — your high-level shape of how you reach the goal: the ordered phases from here to done, the current one carrying a one-line status of what's happening right now. This is the heart of the roadmap. A phase too big for one child becomes a child you promote.
33
33
  - `## Active context` — the `context/` files currently relevant to the work, referenced by path.
34
34
 
35
35
  **Present state and strategic shape only — never tactical plans.** Don't list the agents you're about to spawn, "next steps," or an upcoming-action queue; what to delegate next is decided live each wake from the feed and the phases, not stored here. Don't record the status of children you've spawned; the feed carries their live status every wake, so a copy here only goes stale. Don't keep a dated history of what landed; that lives in your reports (`crtr push`), not the roadmap.
36
36
 
37
- Curate it like a living document, not a journal. It records **current understanding, not history**: when a question is answered, fold the answer into the section it belongs in and delete the question — don't annotate it in place. Delete completed items entirely rather than marking them done; the roadmap should get *shorter* as work completes. Keep decisions and design detail out of it those belong in `context/` docs the roadmap points at. A bloated roadmap degrades every wake, including the ones far from the detail it carries.
37
+ Curate it like a living document, not a journal. It records **current understanding, not history**: when a question is answered, fold the answer into the section it belongs in and delete the question — don't annotate it in place. Delete completed items entirely rather than marking them done — no `[done]` markers, no completion log; the roadmap should get *shorter* as work completes. Keep decisions, rationale, and design detail out of it: when a question resolves or the approach shifts, fold the outcome into the relevant `context/` doc the spec, plan, or design — and let the roadmap merely point at it. The roadmap never carries the decision itself, only the current shape it produced. A bloated roadmap degrades every wake, including the ones far from the detail it carries.
38
38
 
39
39
  You shape the roadmap once at the start and revise it rarely afterward — so when you write or reshape it, read your kind's methodology skill first (`crtr skill read <your-kind>` — `development`, `planning`, `spec`, `design`, …). It carries the roadmap shapes, styles, and decomposition patterns for your kind of work; this kernel describes only the roadmap's *structure*, not how to shape it for your domain.
40
40
 
41
- Larger artifacts — specs, plans, exploration findings, test recipes — live as files in `context/`. Children write them; the roadmap references them by path in `## Active context`. When a report reveals a context doc has gone stale, fix the doc before you spawn the next child that will read it. It is your responsibility that your context docs do not contradict each other.
41
+ Larger artifacts — specs, plans, exploration findings, test recipes — live as files in `context/`. Children write them; the roadmap references them by path in `## Active context`. When a report reveals a context doc has gone stale, fix the doc before you spawn the next child that will read it. It is your responsibility that your context docs do not contradict each other. Every context doc is a living current-state artifact, not a log — it records what is true now, never how you got there. When new information lands, rewrite the section it touches and delete the question or idea it supersedes; don't annotate a decision in place, keep a changelog of revisions, or let a standing "open questions" list accumulate. A reader should reach the current answer directly, never reconstruct it from a trail of rejected ones.
42
42
 
43
43
  ## Your long-term memory
44
44
 
@@ -93,7 +93,7 @@ Match each unit to the most specific kind that fits — `explore` to map, `spec`
93
93
 
94
94
  ## Steering what comes back
95
95
 
96
- Read every report critically. Did the child meet the task? Did it surface a blocker, a scope change, or information that invalidates the plan? Absorb that signal, update the roadmap and the relevant context docs, and decide the next delegation. Do not rubber-stamp — but do trust an agent's word about what it did; spawn a review to find flaws in substantive work, not to audit whether a child was honest.
96
+ Read every report critically. Did the child meet the task? Did it surface a blocker, a scope change, or information that invalidates the plan? Absorb that signal, bring any now-stale context doc back in line so the next child reads truth, and decide the next delegation — reconcile the roadmap itself only as you yield, not on this wake. Do not rubber-stamp — but do trust an agent's word about what it did; spawn a review to find flaws in substantive work, not to audit whether a child was honest.
97
97
 
98
98
  Run the work through critique → refine → validate. Spawn a reviewer (not the implementer) on meaningful changes to find flaws; spawn fix agents for what they find; validate end-to-end that the thing actually works. Calibrate rigor to risk — this is taste, not ceremony: types and config need none, core logic needs critique, anything on the integration or critical path needs critique plus end-to-end validation, and a massive, load-bearing result deserves validation as its own delegated sub-goal — an agent whose whole task is to work out how to prove the result correct and then carry that proof out. Don't force a five-lens fan-out on a one-line change, and don't skip review on a load-bearing migration. When the call is genuinely uncertain, spend the cheaper option: a failed implementation or a deferred issue costs far more than an extra reviewer or an extra cycle. When in doubt, more rigor.
99
99
 
@@ -1,5 +1,5 @@
1
1
  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
2
 
3
- 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. Do not implement — plan only.
3
+ 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.
4
4
 
5
5
  If you are planning one slice of a larger effort, stay in your lane: where your slice touches another, surface it as an integration point or constraint for whoever synthesizes — do not solve the other slice. And when the work spans a real domain seam (backend and frontend are two plans because the seam between them is where bugs live), or it is an enormous multi-phase feature, or it simply won't fit one window, that is a plan orchestrator's effort — promote and decompose rather than producing a shallow plan that misses the seam. When in doubt, split.
@@ -1,5 +1,5 @@
1
1
  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
2
 
3
- 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.
3
+ 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.
4
4
 
5
5
  Do not invent intent to fill a hole. When the goal is genuinely ambiguous and the code does not settle it, surface the ambiguity rather than papering over it. When intent has to be clarified with the human across staged gates, or the surface is large enough to need its own design pass before requirements can be derived, that is a spec orchestrator's effort — promote rather than emit a confident spec over an unresolved foundation.
@@ -0,0 +1,2 @@
1
+ import type { LeafDef } from '../core/command.js';
2
+ export declare const browseLeaf: LeafDef;
@@ -0,0 +1,45 @@
1
+ // `crtr canvas browse` — the interactive full-screen canvas navigator.
2
+ //
3
+ // A raw-mode TUI over the WHOLE canvas: tabs (All/Live/Dormant/Flagged), an
4
+ // auto-collapsed tree, and `/` fuzzy search. Enter resumes the chosen node via
5
+ // `crtr node focus` (the ONLY sanctioned open — reviveNode, never `pi --session`).
6
+ // Owns the screen, so it returns void and writes nothing to stdout itself.
7
+ // Outside a TTY it prints the static forest and exits 0 (see runBrowse).
8
+ import { defineLeaf } from '../core/command.js';
9
+ import { runBrowse } from '../core/canvas/browse/app.js';
10
+ export const browseLeaf = defineLeaf({
11
+ name: 'browse',
12
+ description: 'open the interactive canvas navigator (tabs/tree/search)',
13
+ whenToUse: 'you want to VIEW and NAVIGATE the whole canvas interactively — a full-screen TUI with tabs (All/Live/Dormant/Flagged), an expandable tree (children auto-collapsed; → to expand), and `/` fuzzy search that auto-expands ancestors of matches; Enter resumes the chosen node. Use this to find your way around a large canvas. Use `canvas dashboard` instead for a one-shot ASCII tree you can pipe, and `node inspect list` for a flat machine-readable roster',
14
+ help: {
15
+ name: 'canvas browse',
16
+ summary: 'interactive full-screen canvas navigator — tabs, an auto-collapsed tree, and `/` fuzzy search; Enter resumes the chosen node via `crtr node focus`. Outside a TTY it prints the static forest and exits',
17
+ params: [
18
+ {
19
+ kind: 'flag',
20
+ name: 'return-pane',
21
+ type: 'string',
22
+ required: false,
23
+ constraint: 'tmux pane id to focus the chosen node INTO (set by the /resume-node popup so the node lands back in your pi pane). Default: this pane.',
24
+ },
25
+ {
26
+ kind: 'flag',
27
+ name: 'cwd',
28
+ type: 'string',
29
+ required: false,
30
+ constraint: 'directory to scope the navigator to by default — only nodes spawned from this dir show until you toggle to All dirs (c). The /resume-node popup passes the launching node\'s cwd. Default: the invocation cwd.',
31
+ },
32
+ ],
33
+ output: [],
34
+ outputKind: 'object',
35
+ effects: [
36
+ 'Takes over the terminal in raw mode (alt-screen) until you quit (q/Esc) or pick a node.',
37
+ 'On Enter: resumes the selected node via `crtr node focus` (reviveNode — the only sanctioned open).',
38
+ 'Read-only on the canvas db; mutates nothing but the chosen node\'s placement on resume.',
39
+ ],
40
+ },
41
+ run: async (input) => {
42
+ const cwd = input['cwd'] ?? process.cwd();
43
+ await runBrowse({ returnPane: input['returnPane'], cwd });
44
+ },
45
+ });
@@ -26,6 +26,14 @@ export const canvasPruneLeaf = defineLeaf({
26
26
  default: DEFAULT_TTL_DAYS,
27
27
  constraint: `Retention window in days: only dead/done/canceled nodes created more than this many days ago are pruned. Default: ${DEFAULT_TTL_DAYS}.`,
28
28
  },
29
+ {
30
+ kind: 'flag',
31
+ name: 'include-stale',
32
+ type: 'bool',
33
+ required: false,
34
+ default: false,
35
+ constraint: 'ALSO prune stale active/idle nodes past the TTL whose process is gone (pi_pid null or dead) — reaps abandoned roots the daemon never reconciled. A genuinely-running node (live pi_pid) and the caller are protected.',
36
+ },
29
37
  {
30
38
  kind: 'flag',
31
39
  name: 'dry-run',
@@ -44,14 +52,15 @@ export const canvasPruneLeaf = defineLeaf({
44
52
  outputKind: 'object',
45
53
  effects: [
46
54
  'Deletes matching `nodes` rows; their edges cascade-delete via the FK; each node\u2019s `nodes/<id>/` dir is removed.',
47
- 'No-op on live nodes (active/idle) and on terminal nodes newer than the TTL.',
55
+ 'No-op on live nodes (active/idle) and on terminal nodes newer than the TTL. With --include-stale: also deletes active/idle nodes past the TTL whose process is gone (pi_pid null/dead); genuinely-running nodes and the caller are kept.',
48
56
  'Under --dry-run: read-only, deletes nothing.',
49
57
  ],
50
58
  },
51
59
  run: async (input) => {
52
60
  const ttlDays = input['ttl'] ?? DEFAULT_TTL_DAYS;
53
61
  const dryRun = input['dryRun'] ?? false;
54
- const result = pruneNodes({ ttlDays, dryRun });
62
+ const includeStale = input['includeStale'] ?? false;
63
+ const result = pruneNodes({ ttlDays, dryRun, includeStale });
55
64
  return {
56
65
  pruned: dryRun ? 0 : result.pruned.length,
57
66
  dryRun,
@@ -8,6 +8,7 @@
8
8
  // own, so each piece declares its own help one level down.
9
9
  import { defineBranch } from '../core/command.js';
10
10
  import { dashboardLeaf } from './dashboard.js';
11
+ import { browseLeaf } from './canvas-browse.js';
11
12
  import { reviveLeaf } from './revive.js';
12
13
  import { attentionBranch } from './attention.js';
13
14
  import { daemonBranch } from './daemon.js';
@@ -25,8 +26,8 @@ export function registerCanvas() {
25
26
  help: {
26
27
  name: 'canvas',
27
28
  summary: 'observe and supervise the whole agent graph',
28
- model: 'Canvas-wide operations, distinct from per-node work (`node`) and a node\'s own spine I/O (`push`/`feed`). `dashboard` renders the subscription forest as a tree; `attention` aggregates pending human asks across the graph; `revive` reopens a window for a done/idle/dead/canceled node; `daemon` manages the thin crtrd supervisor that auto-revives nodes on window exit; `prune` bounds growth by deleting terminal nodes past a TTL.',
29
+ model: 'Canvas-wide operations, distinct from per-node work (`node`) and a node\'s own spine I/O (`push`/`feed`). `dashboard` renders the subscription forest as a tree; `browse` opens an interactive full-screen navigator (tabs/tree/search) over the whole canvas and resumes the chosen node; `attention` aggregates pending human asks across the graph; `revive` reopens a window for a done/idle/dead/canceled node; `daemon` manages the thin crtrd supervisor that auto-revives nodes on window exit; `prune` bounds growth by deleting terminal nodes past a TTL.',
29
30
  },
30
- children: [dashboardLeaf, attentionBranch, reviveLeaf, tmuxSpreadLeaf, daemonBranch, chordLeaf, canvasPruneLeaf],
31
+ children: [dashboardLeaf, browseLeaf, attentionBranch, reviveLeaf, tmuxSpreadLeaf, daemonBranch, chordLeaf, canvasPruneLeaf],
31
32
  });
32
33
  }
@@ -191,6 +191,7 @@ const nodeFocus = defineLeaf({
191
191
  params: [
192
192
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
193
193
  { kind: 'flag', name: 'new-pane', type: 'bool', required: false, constraint: 'Open the node in a NEW viewport SIDE-BY-SIDE with your current pane (a second focus) instead of swapping it into your pane. Two agents on screen at once (F4).' },
194
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to focus INTO (default: caller TMUX_PANE). Used by the canvas browser popup to focus back into the originating pane.' },
194
195
  ],
195
196
  output: [
196
197
  { name: 'focused', type: 'boolean', required: true, constraint: 'True when the node was brought into view.' },
@@ -206,11 +207,23 @@ const nodeFocus = defineLeaf({
206
207
  const node = getNode(id);
207
208
  if (node === null)
208
209
  throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
210
+ // A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
211
+ // screen), NOT a pi conversation — it has no session. Reviving one boots a
212
+ // confused blank "you have been revived" pi, so refuse rather than focus it.
213
+ // (The nav/resume UIs already hide human nodes; this guards a hand-typed id.)
214
+ if (node.kind === 'human') {
215
+ throw new InputError({
216
+ error: 'not_focusable',
217
+ message: `node ${id} is a human-ask (kind:human), not a conversation — it has no pi session to focus.`,
218
+ next: `The pending question is already on the human's screen; see it with \`crtr human list\` / \`crtr human inbox\`, or retract it with \`crtr human cancel ${id}\`.`,
219
+ });
220
+ }
209
221
  // Placement owns the whole act (§2.3): resolve the caller's focus (or open a
210
222
  // new viewport with --new-pane), revive the target into the backstage if it
211
223
  // is dormant, then hot-swap it onto the focus. The reviver is injected so
212
224
  // placement need not import revive.ts.
213
225
  const res = placementFocus(id, {
226
+ pane: input['pane'],
214
227
  newPane: input['newPane'] === true,
215
228
  callerNode: process.env['CRTR_NODE_ID'],
216
229
  revive: (nid) => { reviveNode(nid, { resume: true }); },
@@ -13,7 +13,7 @@ import { VALID_TYPES, resolveWriteScope } from './shared.js';
13
13
  export const authorGuide = defineLeaf({
14
14
  name: 'guide',
15
15
  description: 'load authoring workflow + skeleton for a type',
16
- whenToUse: 'writing a new skill and you want the authoring workflow plus the template skeleton call it once with no --type for the template picker, then again with --type for the full workflow and skeleton of that type.',
16
+ whenToUse: 'REQUIRED reading before you author a new skill OR edit an existing one — it carries the SKILL.md format, the description-drives-discovery rule (when-to-use lives in the frontmatter description, never the body), the voice constraints, and the per-type workflow. Call it once with no --type for the template picker, then again with --type for that type\'s full workflow and skeleton. Editing an existing skill counts: read this first, because the format and voice rules govern every change, not just new files.',
17
17
  help: {
18
18
  name: 'skill author guide',
19
19
  summary: 'load the skill authoring workflow — two stages: omit type to pick one, pass type for its full skeleton',
@@ -140,7 +140,7 @@ export const authorScaffold = defineLeaf({
140
140
  export const authorBranch = defineBranch({
141
141
  name: 'author',
142
142
  description: 'create and scaffold skills',
143
- whenToUse: 'you have a reusable workflow, methodology, or hard-won convention worth capturing so future agents adopt it instead of re-deriving it — author carries you from picking a template through scaffolding the file. Reach for it when a task just taught you a repeatable procedure, when the same guidance keeps getting re-explained across sessions, or when the house conventions for a tool deserve to be written down once. Start with `crtr skill author guide` for the template picker and authoring workflow; use `crtr skill author scaffold` to stub the SKILL.md file.',
143
+ whenToUse: 'you have a reusable workflow, methodology, or hard-won convention worth capturing so future agents adopt it instead of re-deriving it — author carries you from picking a template through scaffolding the file. Reach for it when a task just taught you a repeatable procedure, when the same guidance keeps getting re-explained across sessions, or when the house conventions for a tool deserve to be written down once. Always start with `crtr skill author guide` required reading before you author OR edit any skill (the SKILL.md format, the description-vs-body rule, and the voice constraints all live there) — then use `crtr skill author scaffold` to stub the SKILL.md file.',
144
144
  help: {
145
145
  name: 'skill author',
146
146
  summary: 'create and scaffold new skills',
@@ -0,0 +1,199 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/cascade-close.test.ts
2
+ //
3
+ // CASCADE CLOSE + exclusive-subtree ownership — a FAITHFUL integration test of
4
+ // `crtr node close` on a MIDDLE node, driven through the REAL CLI against a REAL
5
+ // isolated tmux session with REAL fake-pi vehicles in REAL panes, asserting off
6
+ // the canvas data layer and checked against the state-model ORACLE
7
+ // (state-model.md §4 `node close`, §5 cascade-close ownership, invariant 3,
8
+ // ambiguity A5).
9
+ //
10
+ // WHY this exists alongside the unit `close.test.ts`: that test exercises
11
+ // `closeNode()` in-process against the data layer with FABRICATED pane strings
12
+ // (`pane:'%x'`) — it proves the closing-set fixpoint and the status flip, but it
13
+ // never kills a real tmux pane, never boots a real vehicle, and never closes a
14
+ // MIDDLE node with a live sideways-sibling subtree. This test fills exactly that
15
+ // gap: it builds a real tree, closes the middle, and proves the reap is EXACTLY
16
+ // the closed node's own subtree — nothing above (the ancestor) or sideways (a
17
+ // sibling subtree) is touched, and real panes actually die.
18
+ //
19
+ // The tree (covers BOTH required shapes in one topology):
20
+ //
21
+ // A (resident root, in-process)
22
+ // ├── B ◄── CLOSE TARGET (the middle node)
23
+ // │ ├── C
24
+ // │ │ └── E depth : A→B→C→E (deep chain)
25
+ // │ └── D breadth: B has two children {C, D}
26
+ // └── S sideways sibling subtree — MUST survive
27
+ // └── G
28
+ //
29
+ // close(B) ⇒ exclusive subtree {B,C,D,E} reaped; A,S,G untouched.
30
+ //
31
+ // CRITICAL DELIVERABLE (ambiguity A5): the EXACT terminal status a cascade-
32
+ // reaped descendant receives under `node close`. CURRENT behavior, asserted
33
+ // end-to-end below: status === 'canceled', intent === null (cleared) — for the
34
+ // cascade ROOT and EVERY descendant alike (`transition(id,'cancel')`,
35
+ // close.ts → lifecycle.ts cancel row: status='canceled', intent=null, from '*').
36
+ // This differs from the ancestor-RESET path (`reapDescendants` → `reap` → 'done')
37
+ // — same act, different terminal status: A5's open question. We assert the close
38
+ // side and report the confirmed fact; we do NOT change it.
39
+ //
40
+ // FLAG vs the task brief: the brief expected "subscription+parent edges removed"
41
+ // on close. CURRENT behavior + the ORACLE (§4: "Nothing is deleted: pi sessions +
42
+ // edges persist → revivable") + the CLI help ("their pi sessions and canvas edges
43
+ // persist for a later revive") all say edges PERSIST. This test asserts the
44
+ // CURRENT (persist) behavior and the report flags the contradiction; production
45
+ // is NOT changed.
46
+ import { test } from 'node:test';
47
+ import assert from 'node:assert/strict';
48
+ import { spawnSync } from 'node:child_process';
49
+ import { createHarness, hasTmux } from './helpers/harness.js';
50
+ function sessionExists(session) {
51
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
52
+ }
53
+ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), ancestor + sideways untouched, edges persist', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 180_000 }, async () => {
54
+ const h = await createHarness({ sessionPrefix: 'crtr-cascade' });
55
+ try {
56
+ // ===================================================================
57
+ // BUILD — the real tree. A is the in-process resident root (no pane);
58
+ // B,C,D,E,S,G are real managed children, each a real fake-pi in a real
59
+ // backstage pane (spawnChild awaits each boot). Every `node new` seeds the
60
+ // spine edge parent→child (active) + the spawned_by/parent provenance.
61
+ // ===================================================================
62
+ const A = h.spawnRoot('cascade-close root');
63
+ const B = await h.spawnChild(A, 'middle node — the close target', { kind: 'developer' });
64
+ const C = await h.spawnChild(B, 'B child one');
65
+ const E = await h.spawnChild(C, 'deep leaf under C'); // depth: A→B→C→E
66
+ const D = await h.spawnChild(B, 'B child two'); // breadth: B has {C, D}
67
+ const S = await h.spawnChild(A, 'sideways sibling of B'); // sideways subtree
68
+ const G = await h.spawnChild(S, 'child of the sideways sibling');
69
+ const subtree = [B, C, D, E]; // B's exclusive subtree (the expected reap set)
70
+ const sideways = [S, G]; // a sibling subtree — must be wholly untouched
71
+ // Pre-close sanity: every node live + present, spine wired as drawn.
72
+ {
73
+ for (const id of [B, C, D, E, S, G]) {
74
+ assert.equal(h.status(id), 'active', `${id} active before close`);
75
+ assert.equal(h.paneAlive(id), true, `${id} has a live pane before close`);
76
+ }
77
+ assert.equal(h.status(A), 'active', 'A (root) active before close');
78
+ // Spine: A→{B,S}, B→{C,D}, C→{E}, S→{G} (all active spawn-seed edges).
79
+ const subIds = (n) => h.subscriptions(n).map((s) => s.node_id).sort();
80
+ assert.deepEqual(subIds(A), [B, S].sort(), 'A subscribes_to B and S');
81
+ assert.deepEqual(subIds(B), [C, D].sort(), 'B subscribes_to C and D');
82
+ assert.deepEqual(subIds(C), [E], 'C subscribes_to E');
83
+ assert.deepEqual(subIds(S), [G], 'S subscribes_to G');
84
+ // provenance edges (spawned_by / parent)
85
+ for (const [child, parent] of [[B, A], [C, B], [E, C], [D, B], [S, A], [G, S]]) {
86
+ assert.equal(h.node(child).parent, parent, `${child}.parent = ${parent}`);
87
+ }
88
+ }
89
+ // Make A's inbox NON-EMPTY before the close so "ancestor inbox intact" is a
90
+ // non-vacuous assertion: B pushes a routine update up its spine → A (B's
91
+ // sole active subscriber). A is the in-process root with no live watcher,
92
+ // so the pointer simply accumulates and must survive the close untouched.
93
+ {
94
+ const res = h.cli(B, ['push', 'update', 'progress from B before close']);
95
+ assert.equal(res.code, 0, `B push update exit 0\n${res.stderr}`);
96
+ }
97
+ const aInboxBefore = h.inbox(A);
98
+ assert.ok(aInboxBefore.some((e) => e.from === B && e.kind === 'update'), "A's inbox holds B's pre-close update (non-vacuous 'intact' baseline)");
99
+ // ===================================================================
100
+ // CLOSE the MIDDLE node B, through the REAL CLI (`crtr node close --node B`,
101
+ // run AS the root A). closeNode is synchronous in the subprocess: by the
102
+ // time it returns, rows are flipped and panes are killed.
103
+ // ===================================================================
104
+ const closeRes = h.cli(A, ['node', 'close', '--node', B]);
105
+ assert.equal(closeRes.code, 0, `node close exit 0\n${closeRes.stderr}`);
106
+ // The CLI's own rendered report: cascade root B, exactly 4 closed, 0 spared.
107
+ assert.match(closeRes.stdout, new RegExp(`<closed id="${B}" count="4" spared="0"\\s*/>`), `close report names B as root, count=4, spared=0\n${closeRes.stdout}`);
108
+ // ===================================================================
109
+ // ASSERT 1 — the A5 DELIVERABLE: the EXACT terminal status of every
110
+ // cascade-reaped node. CURRENT behavior: status='canceled', intent=null,
111
+ // for the ROOT (B) and EVERY descendant {C,D,E} identically.
112
+ // ===================================================================
113
+ for (const id of subtree) {
114
+ const n = h.node(id);
115
+ assert.equal(n.status, 'canceled', `${id} terminal status === 'canceled' (cancel event)`);
116
+ assert.strictEqual(n.intent ?? null, null, `${id} intent cleared to null on cancel`);
117
+ }
118
+ // Pin the exact field tuple once more, explicitly, for the human decision:
119
+ // a cascade-reaped descendant under `node close` reads (canceled, null) —
120
+ // NOT (done, done) and NOT (done, null). This is the confirmed A5 fact.
121
+ {
122
+ const e = h.node(E); // the deepest descendant
123
+ assert.deepEqual({ status: e.status, intent: e.intent ?? null }, { status: 'canceled', intent: null }, "deepest reaped descendant E: (status,intent) === ('canceled', null)");
124
+ }
125
+ // ===================================================================
126
+ // ASSERT 2 — real panes destroyed for the ENTIRE subtree (invariant 12-ish:
127
+ // a torn-down node owns no pane). tearDownNode killed each real tmux pane.
128
+ // ===================================================================
129
+ for (const id of subtree) {
130
+ await h.waitForPaneGone(id);
131
+ assert.equal(h.paneAlive(id), false, `${id} real pane destroyed by close`);
132
+ }
133
+ // ===================================================================
134
+ // ASSERT 3 — EXCLUSIVE-SUBTREE OWNERSHIP: nothing ABOVE (A) and nothing
135
+ // SIDEWAYS (S, G) was reaped. close(B) reaped EXACTLY B's own subtree.
136
+ // ===================================================================
137
+ // Ancestor A — fully untouched: still the live resident root, inbox intact.
138
+ {
139
+ const a = h.node(A);
140
+ assert.equal(a.status, 'active', 'A (ancestor) still active — untouched by the cascade');
141
+ assert.equal(a.lifecycle, 'resident', 'A still resident');
142
+ assert.equal(a.intent ?? null, null, 'A intent untouched');
143
+ assert.deepEqual(h.inbox(A), aInboxBefore, "A's inbox is byte-for-byte intact across the close (cascade touches only closed nodes' inboxes)");
144
+ }
145
+ // Sideways subtree S→G — wholly alive: status active AND real panes alive.
146
+ for (const id of sideways) {
147
+ assert.equal(h.status(id), 'active', `${id} (sideways) still active — not reaped`);
148
+ assert.equal(h.paneAlive(id), true, `${id} (sideways) real pane still alive`);
149
+ }
150
+ // The strong "EXACTLY its own subtree" closure: every node in the graph is
151
+ // either in B's subtree (canceled) or untouched (active) — no over-reach.
152
+ {
153
+ const reaped = new Set(subtree);
154
+ for (const id of [A, B, C, D, E, S, G]) {
155
+ const want = reaped.has(id) ? 'canceled' : 'active';
156
+ assert.equal(h.status(id), want, `${id} status === '${want}' (no over-/under-reach)`);
157
+ }
158
+ }
159
+ // ===================================================================
160
+ // ASSERT 4 — EDGES PERSIST (CURRENT behavior + ORACLE §4). close is a pause,
161
+ // not a delete: a closed node keeps its spine + provenance edges so it can
162
+ // be revived. (FLAGGED in the report: the task brief expected "edges
163
+ // removed" — that contradicts current behavior; we assert persistence.)
164
+ // ===================================================================
165
+ {
166
+ const subIds = (n) => h.subscriptions(n).map((s) => s.node_id).sort();
167
+ // Ancestor's spine edge to the closed middle node survives.
168
+ assert.ok(h.subscriptions(A).some((s) => s.node_id === B && s.active), 'A→B subscription edge PERSISTS after close (revivable)');
169
+ // Intra-subtree spine edges survive too.
170
+ assert.deepEqual(subIds(B), [C, D].sort(), 'B→{C,D} edges persist');
171
+ assert.deepEqual(subIds(C), [E], 'C→E edge persists');
172
+ // Provenance (spawned_by / parent) survives.
173
+ for (const [child, parent] of [[B, A], [C, B], [E, C], [D, B]]) {
174
+ assert.equal(h.node(child).parent, parent, `${child}.parent = ${parent} persists`);
175
+ }
176
+ // Sideways edges obviously untouched.
177
+ assert.deepEqual(subIds(S), [G], 'S→G edge untouched');
178
+ }
179
+ // ===================================================================
180
+ // ASSERT 5 — each closed node got its cancellation notice (the resume
181
+ // breadcrumb), appended AFTER its watcher died (close.ts step 3). The root
182
+ // B reads "CLOSED by the user"; descendants read "CANCELED — an ancestor…".
183
+ // ===================================================================
184
+ {
185
+ const bNotice = h.inbox(B).at(-1);
186
+ assert.equal(bNotice.from, null, "B's cancel notice is a system message");
187
+ assert.match(bNotice.label, /CLOSED by the user/, 'B (cascade root) reads the CLOSED notice');
188
+ assert.equal(bNotice.data?.['cascade_root'], B, "B notice records the cascade root");
189
+ const eNotice = h.inbox(E).at(-1);
190
+ assert.match(eNotice.label, /CANCELED/, 'E (descendant) reads the CANCELED notice');
191
+ assert.equal(eNotice.data?.['cascade_root'], B, "E notice records B as the cascade root");
192
+ }
193
+ }
194
+ finally {
195
+ const session = h.session;
196
+ await h.dispose();
197
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
198
+ }
199
+ });
@@ -7,6 +7,7 @@ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
7
7
  import { closeDb } from '../canvas/db.js';
8
8
  import { readInboxSince } from '../feed/inbox.js';
9
9
  import { superviseTick } from '../../daemon/crtrd.js';
10
+ import { markBusy, clearBusy } from '../runtime/busy.js';
10
11
  let home;
11
12
  function node(id, over = {}) {
12
13
  return {
@@ -77,9 +78,15 @@ test('a crash after boot is marked dead without a boot-failure push', async () =
77
78
  intent: null,
78
79
  }));
79
80
  spawnEdge('P2', 'C2');
81
+ // MID-GENERATION when the window vanished (busy marker present, agent_end
82
+ // never cleared it) → a genuine mid-run crash. Without the marker a booted,
83
+ // unsubscribed pane-gone node would FINALIZE to 'done' instead (it would read
84
+ // as a finished node dismissed) — see the gone-pane routing in crtrd.ts.
85
+ markBusy('C2');
80
86
  await superviseTick();
81
87
  assert.equal(getNode('C2').status, 'dead', 'crashed child is dead');
82
88
  assert.equal(readInboxSince('P2').length, 0, 'a real crash does not push a boot-failure pointer');
89
+ clearBusy('C2');
83
90
  });
84
91
  // A still-booting node whose window is alive must be left untouched — boot is
85
92
  // slow, and an alive window means pi may still be coming up.
@@ -4,9 +4,10 @@ import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { spawnSync } from 'node:child_process';
7
- import { createNode, getNode } from '../canvas/canvas.js';
7
+ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
8
8
  import { closeDb } from '../canvas/db.js';
9
9
  import { appendInbox } from '../feed/inbox.js';
10
+ import { markBusy, clearBusy } from '../runtime/busy.js';
10
11
  import { superviseTick, isPidAlive, livenessVerdict, } from '../../daemon/crtrd.js';
11
12
  let home;
12
13
  function node(id, over = {}) {
@@ -149,8 +150,9 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
149
150
  spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
150
151
  // The window is alive, but the node is anchored on that dead pane. Under the
151
152
  // old window-keyed liveness this node would read healthy (window alive + live
152
- // pid). Pane-keyed: the pane is gone → the crash branch fires. pi_session_id
153
- // is set so it's a clean crash, not a boot-failure push.
153
+ // pid). Pane-keyed: the pane is gone → the gone-branch fires. The node is
154
+ // MID-GENERATION (busy marker present agent_start touched it, agent_end
155
+ // never cleared it), so the gone-branch routes to crash → 'dead'.
154
156
  createNode(node('G', {
155
157
  pane: dead,
156
158
  tmux_session: session,
@@ -159,8 +161,61 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
159
161
  pi_session_id: 'booted',
160
162
  intent: null,
161
163
  }));
164
+ markBusy('G'); // pane killed inside a turn → genuine mid-run crash
162
165
  await superviseTick();
163
- assert.equal(getNode('G').status, 'dead', 'a gone pane fires the gone-branch even with a live window + pid');
166
+ assert.equal(getNode('G').status, 'dead', 'a gone pane mid-generation fires the gone-branch crash (dead)');
167
+ clearBusy('G');
168
+ });
169
+ });
170
+ // ---------------------------------------------------------------------------
171
+ // Gone-pane routing: a pane-gone node in the crash branch is no longer an
172
+ // unconditional 'dead'. It routes on what the node was DOING at pane-kill time:
173
+ // • mid-generation (busy marker PRESENT) → crash ('dead')
174
+ // • finished its turn, awaiting nothing live → finalize ('done')
175
+ // • finished its turn, still awaiting a LIVE child → crash ('dead'), for now
176
+ // (The mid-generation → dead leg is covered by node 'G' above.)
177
+ // ---------------------------------------------------------------------------
178
+ test('pane gone + booted + busy ABSENT + no live subscription → finalize (done): the pane was closed to dismiss a finished node', { skip: !hasTmux() }, async () => {
179
+ await withLivePane('fin1', async (session, window) => {
180
+ const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
181
+ const dead = (sp.stdout ?? '').trim();
182
+ spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
183
+ // Booted (pi_session_id set), no busy marker (agent_end cleared it → the turn
184
+ // finished), and no subscription to any live node. Closing the pane was a
185
+ // dismissal of a node that already did its own work → finalize to 'done'.
186
+ createNode(node('FIN', {
187
+ pane: dead,
188
+ tmux_session: session,
189
+ window,
190
+ pi_pid: process.pid,
191
+ pi_session_id: 'booted',
192
+ intent: null,
193
+ }));
194
+ // no markBusy: the turn ended cleanly.
195
+ await superviseTick();
196
+ assert.equal(getNode('FIN').status, 'done', 'a finished, unsubscribed node whose pane was closed finalizes to done');
197
+ });
198
+ });
199
+ test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → crash (dead), for now', { skip: !hasTmux() }, async () => {
200
+ await withLivePane('fin2', async (session, window) => {
201
+ const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
202
+ const dead = (sp.stdout ?? '').trim();
203
+ spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
204
+ // A live child the parent is subscribed to: hasActiveLiveSubscription === true.
205
+ createNode(node('CHILD', { status: 'active', pi_session_id: 'booted' }));
206
+ createNode(node('PARENT', {
207
+ pane: dead,
208
+ tmux_session: session,
209
+ window,
210
+ pi_pid: process.pid,
211
+ pi_session_id: 'booted',
212
+ intent: null,
213
+ }));
214
+ subscribe('PARENT', 'CHILD', true); // active subscription to a LIVE node
215
+ // Parent finished its turn (no busy marker) but is still awaiting a live
216
+ // child → NOT a finalize. Routes to crash ('dead') for now.
217
+ await superviseTick();
218
+ assert.equal(getNode('PARENT').status, 'dead', 'a finished node still awaiting a live child crashes (dead), not finalizes');
164
219
  });
165
220
  });
166
221
  // ---------------------------------------------------------------------------