@crouton-kit/crouter 0.3.15 → 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.
- package/dist/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/base.md +1 -1
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/spec/base.md +1 -1
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/chord.js +1 -1
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/node.js +14 -2
- package/dist/commands/skill/author.js +2 -2
- package/dist/commands/tmux-spread.js +2 -3
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/close.test.js +2 -2
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/focuses.test.js +5 -68
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/home-session.test.js +1 -1
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/placement-focus.test.js +106 -46
- package/dist/core/__tests__/placement-teardown.test.js +4 -9
- package/dist/core/__tests__/relaunch.test.js +22 -16
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/__tests__/tmux-surface.test.js +8 -9
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/db.js +2 -3
- package/dist/core/canvas/focuses.d.ts +2 -2
- package/dist/core/canvas/focuses.js +4 -3
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/demote.js +2 -7
- package/dist/core/runtime/launch.d.ts +3 -1
- package/dist/core/runtime/launch.js +4 -1
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +10 -1
- package/dist/core/runtime/placement.d.ts +39 -10
- package/dist/core/runtime/placement.js +100 -44
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +36 -31
- package/dist/core/runtime/revive.d.ts +1 -1
- package/dist/core/runtime/revive.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/tmux-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +13 -6
- package/dist/core/runtime/tmux.js +21 -12
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +40 -28
- package/dist/pi-extensions/canvas-resume.d.ts +21 -0
- package/dist/pi-extensions/canvas-resume.js +82 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +21 -9
- package/dist/prompts/skill.js +6 -1
- package/package.json +2 -2
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- package/dist/core/runtime/presence.d.ts +0 -30
- package/dist/core/runtime/presence.js +0 -178
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
|
@@ -4,7 +4,7 @@ roadmapSkill: development
|
|
|
4
4
|
|
|
5
5
|
You are a **developer orchestrator** — a senior engineer who owns a feature-sized goal and delivers it by driving specialist children, never by writing the code yourself. Your children are `explore` (to map), `spec` (to specify), `plan` (to decompose), `developer` (to implement), and `review` (to validate). Keep them pointed at the right work with the right context, integrate what they return, and advance the goal phase by phase until it is genuinely done.
|
|
6
6
|
|
|
7
|
-
Run the build as a delegation pipeline — spec → plan → implement → review → fix → validate — parallel wherever tasks are file-independent. Each phase clears a non-negotiable exit criterion before anything builds on it: implementation is done when it is **provably correct against the spec's acceptance criteria**, not when it compiles; review is done when an agent *other than the implementer* has read the diff and every Major and Critical finding is resolved; validation is done when the thing works end-to-end in the real runtime, exercised by something other than the code that produced it. Not every change earns the full pipeline — a one-line wrapper goes straight to implementation — but whatever phase you do run, it clears its bar.
|
|
7
|
+
Before you shape the roadmap, read `crtr skill read development` for the roadmap shapes, development styles, and exit-criteria patterns for software goals. Run the build as a delegation pipeline — spec → plan → implement → review → fix → validate — parallel wherever tasks are file-independent. Each phase clears a non-negotiable exit criterion before anything builds on it: implementation is done when it is **provably correct against the spec's acceptance criteria**, not when it compiles; review is done when an agent *other than the implementer* has read the diff and every Major and Critical finding is resolved; validation is done when the thing works end-to-end in the real runtime, exercised by something other than the code that produced it. Not every change earns the full pipeline — a one-line wrapper goes straight to implementation — but whatever phase you do run, it clears its bar.
|
|
8
8
|
|
|
9
9
|
Stay flexible, not waterfall. When a review exposes a flaw in the spec, re-delegate the **spec** phase — don't patch the implementation forward on a bad foundation. When an implementer reports unexpected complexity or a dependency the plan missed, fix the **plan** and re-delegate the affected tasks rather than asking the implementer to improvise. The bad phase is the one you re-run; patching downstream of a wrong upstream phase buries the flaw instead of removing it.
|
|
10
10
|
|
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
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.
|
|
@@ -8,6 +8,6 @@ Decompose by **domain seam, not raw size** — what forces a split is a boundary
|
|
|
8
8
|
|
|
9
9
|
When you split, **synthesis is the load-bearing step — not the splitting.** As the only agent holding the whole picture, edit the part-plans into one coherent voice: resolve file-ownership conflicts, align naming and shared types across slices, and stress-test the seams no single sub-planner could see. Keep the master a small navigable index — a dependency task table over linked part-plans — because that is what forces the decomposition to be real instead of a flat dump.
|
|
10
10
|
|
|
11
|
-
No consequential plan leaves your hands unreviewed. Fan out your plan-reviewer sub-kinds — the **requirements-coverage**, **pattern-consistency**, **code-smells**, **security**, and **architecture-fit** lenses in your spawnable menu — in parallel, then fold their findings back before you advance: a light plan folds one pass inside a single wake, a load-bearing one loops review → yield → revise → re-review across cycles until it is sound. Calibrate the roster to the stakes — a one-file wrapper change does not summon five lenses.
|
|
11
|
+
No consequential plan leaves your hands unreviewed. Fan out your plan-reviewer sub-kinds — the **requirements-coverage**, **pattern-consistency**, **code-smells**, **security**, and **architecture-fit** lenses in your spawnable menu — in parallel, then fold their findings back before you advance: a light plan folds one pass inside a single wake, a load-bearing one loops review → yield → revise → re-review across cycles until it is sound. Calibrate the roster to the stakes — a one-file wrapper change does not summon five lenses. Each reviewer reports findings, not verdicts; you decide what blocks, and a clean review is a valid and expected result.
|
|
12
12
|
|
|
13
13
|
@include orchestration-kernel.md
|
|
@@ -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,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
|
|
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,
|
package/dist/commands/canvas.js
CHANGED
|
@@ -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
|
}
|
package/dist/commands/chord.js
CHANGED
|
@@ -21,7 +21,7 @@ import { execFile } from 'node:child_process';
|
|
|
21
21
|
import { defineLeaf } from '../core/command.js';
|
|
22
22
|
import { InputError } from '../core/io.js';
|
|
23
23
|
import { readConfig } from '../core/config.js';
|
|
24
|
-
import { sendKeysEnter } from '../core/runtime/tmux.js';
|
|
24
|
+
import { sendKeysEnter } from '../core/runtime/tmux-chrome.js';
|
|
25
25
|
import { nodeInPane } from './node.js';
|
|
26
26
|
import { getNode, subscribersOf, subscriptionsOf, view, fullName, } from '../core/canvas/index.js';
|
|
27
27
|
const pexec = promisify(execFile);
|
|
@@ -6,7 +6,7 @@ import { countPanesInCurrentWindow, spawnAndDetach, shellQuote } from '../../cor
|
|
|
6
6
|
export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title?, ' +
|
|
7
7
|
'source?:{sessionName?,askedBy?,blockedSince?}, ' +
|
|
8
8
|
'interactions:[{id,title,subtitle?,(body?|bodyPath?),options:[{id,label,' +
|
|
9
|
-
'description
|
|
9
|
+
'description?}],multiSelect?,allowFreetext?,freetextLabel?,' +
|
|
10
10
|
"kind?:'notify'|'validation'|'decision'|'context'|'error'}]}.";
|
|
11
11
|
export function resolveMaxPanes() {
|
|
12
12
|
return readConfig('user').max_panes_per_window;
|
package/dist/commands/node.js
CHANGED
|
@@ -11,10 +11,9 @@ import { promote, requestYield } from '../core/runtime/promote.js';
|
|
|
11
11
|
import { writeYieldMessage } from '../core/runtime/kickoff.js';
|
|
12
12
|
import { reviveNode } from '../core/runtime/revive.js';
|
|
13
13
|
import { demoteNode } from '../core/runtime/demote.js';
|
|
14
|
-
import { detachToBackground, focus as placementFocus } from '../core/runtime/placement.js';
|
|
14
|
+
import { detachToBackground, focus as placementFocus, windowAlive, windowOfPane, currentTmux } from '../core/runtime/placement.js';
|
|
15
15
|
import { buildLaunchSpec } from '../core/runtime/launch.js';
|
|
16
16
|
import { closeNode } from '../core/runtime/close.js';
|
|
17
|
-
import { windowAlive, windowOfPane, currentTmux } from '../core/runtime/tmux.js';
|
|
18
17
|
import { appendInbox } from '../core/feed/inbox.js';
|
|
19
18
|
import { availableKinds } from '../core/personas/index.js';
|
|
20
19
|
import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, readContextTokens, } from '../core/canvas/index.js';
|
|
@@ -192,6 +191,7 @@ const nodeFocus = defineLeaf({
|
|
|
192
191
|
params: [
|
|
193
192
|
{ kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
|
|
194
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.' },
|
|
195
195
|
],
|
|
196
196
|
output: [
|
|
197
197
|
{ name: 'focused', type: 'boolean', required: true, constraint: 'True when the node was brought into view.' },
|
|
@@ -207,11 +207,23 @@ const nodeFocus = defineLeaf({
|
|
|
207
207
|
const node = getNode(id);
|
|
208
208
|
if (node === null)
|
|
209
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
|
+
}
|
|
210
221
|
// Placement owns the whole act (§2.3): resolve the caller's focus (or open a
|
|
211
222
|
// new viewport with --new-pane), revive the target into the backstage if it
|
|
212
223
|
// is dormant, then hot-swap it onto the focus. The reviver is injected so
|
|
213
224
|
// placement need not import revive.ts.
|
|
214
225
|
const res = placementFocus(id, {
|
|
226
|
+
pane: input['pane'],
|
|
215
227
|
newPane: input['newPane'] === true,
|
|
216
228
|
callerNode: process.env['CRTR_NODE_ID'],
|
|
217
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: '
|
|
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.
|
|
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',
|
|
@@ -10,13 +10,12 @@
|
|
|
10
10
|
// meta.{tmux_session,window} goes stale — `windowAlive` would then report it
|
|
11
11
|
// dormant and the daemon would spuriously revive it. After each join we
|
|
12
12
|
// re-derive the child's location from its (stable) pane id and updateNode it,
|
|
13
|
-
// mirroring the swap fix-up in
|
|
13
|
+
// mirroring the swap fix-up in placement.ts.
|
|
14
14
|
import { defineLeaf } from '../core/command.js';
|
|
15
15
|
import { InputError } from '../core/io.js';
|
|
16
16
|
import { readConfig } from '../core/config.js';
|
|
17
17
|
import { reviveNode } from '../core/runtime/revive.js';
|
|
18
|
-
import { isNodePaneAlive, spreadNode } from '../core/runtime/placement.js';
|
|
19
|
-
import { inTmux } from '../core/runtime/tmux.js';
|
|
18
|
+
import { isNodePaneAlive, spreadNode, inTmux } from '../core/runtime/placement.js';
|
|
20
19
|
import { nodeInPane } from './node.js';
|
|
21
20
|
import { getNode, subscriptionsOf } from '../core/canvas/index.js';
|
|
22
21
|
export const tmuxSpreadLeaf = defineLeaf({
|
|
@@ -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
|
+
});
|
|
@@ -140,8 +140,8 @@ test('Step 7: closing a FOCUSED node closes its focus row + nulls its pane (tear
|
|
|
140
140
|
openFocusRow('fN', '%x', 'Sa', 'N');
|
|
141
141
|
closeNode('N');
|
|
142
142
|
// tearDownNode closes the focus row the node occupied and nulls its LOCATION.
|
|
143
|
-
// Non-vacuous: pre-Step-7 close used closeWindow
|
|
144
|
-
//
|
|
143
|
+
// Non-vacuous: pre-Step-7 close used closeWindow and never touched the focuses
|
|
144
|
+
// table, so getFocusByNode('N') would still return fN.
|
|
145
145
|
assert.equal(getFocusByNode('N'), null, 'focus row closed by tearDownNode');
|
|
146
146
|
assert.equal(getNode('N').pane ?? null, null, 'pane nulled (pane-keyed teardown)');
|
|
147
147
|
assert.equal(getNode('N').status, 'canceled', 'node canceled as before');
|
|
@@ -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.
|