@crouton-kit/crouter 0.3.12 → 0.3.14

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 (45) hide show
  1. package/dist/builtin-personas/runtime-base.md +2 -2
  2. package/dist/commands/__tests__/human.test.js +73 -2
  3. package/dist/commands/human/queue.d.ts +1 -0
  4. package/dist/commands/human/queue.js +89 -2
  5. package/dist/commands/human/shared.d.ts +5 -0
  6. package/dist/commands/human/shared.js +15 -0
  7. package/dist/commands/human.js +4 -2
  8. package/dist/commands/node.js +239 -15
  9. package/dist/core/__tests__/passive-subscription.test.d.ts +1 -0
  10. package/dist/core/__tests__/passive-subscription.test.js +141 -0
  11. package/dist/core/__tests__/subcommand-tier.test.d.ts +1 -0
  12. package/dist/core/__tests__/subcommand-tier.test.js +97 -0
  13. package/dist/core/canvas/paths.d.ts +4 -0
  14. package/dist/core/canvas/paths.js +6 -0
  15. package/dist/core/command.js +40 -7
  16. package/dist/core/feed/feed.js +11 -9
  17. package/dist/core/feed/passive.d.ts +17 -0
  18. package/dist/core/feed/passive.js +79 -0
  19. package/dist/core/help.d.ts +45 -12
  20. package/dist/core/help.js +42 -4
  21. package/dist/core/runtime/demote.d.ts +14 -0
  22. package/dist/core/runtime/demote.js +103 -0
  23. package/dist/core/runtime/kickoff.d.ts +9 -0
  24. package/dist/core/runtime/kickoff.js +19 -1
  25. package/dist/core/runtime/launch.d.ts +12 -1
  26. package/dist/core/runtime/launch.js +18 -2
  27. package/dist/core/runtime/presence.d.ts +1 -1
  28. package/dist/core/runtime/presence.js +6 -4
  29. package/dist/core/runtime/promote.d.ts +4 -0
  30. package/dist/core/runtime/promote.js +21 -6
  31. package/dist/core/runtime/revive.js +6 -8
  32. package/dist/core/runtime/roadmap.d.ts +5 -4
  33. package/dist/core/runtime/roadmap.js +9 -16
  34. package/dist/core/runtime/spawn.d.ts +0 -2
  35. package/dist/core/runtime/spawn.js +26 -16
  36. package/dist/core/runtime/tmux.d.ts +18 -0
  37. package/dist/core/runtime/tmux.js +77 -0
  38. package/dist/pi-extensions/canvas-commands.d.ts +34 -0
  39. package/dist/pi-extensions/canvas-commands.js +100 -0
  40. package/dist/pi-extensions/canvas-goal-capture.d.ts +18 -0
  41. package/dist/pi-extensions/canvas-goal-capture.js +53 -0
  42. package/dist/pi-extensions/canvas-passive-context.d.ts +32 -0
  43. package/dist/pi-extensions/canvas-passive-context.js +114 -0
  44. package/dist/pi-extensions/canvas-stophook.js +42 -19
  45. package/package.json +1 -1
@@ -34,6 +34,6 @@ If the work is bigger or different than your task implies, say so in a push to y
34
34
  ## When your task is too big for one context window
35
35
  If you discover the job is far larger than one node can hold — many phases, work that won't fit before you run low on context — **promote yourself** instead of grinding:
36
36
 
37
- crtr node promote --goal "<the high-level goal you now own>"
37
+ crtr node promote --kind <kind>
38
38
 
39
- This makes you a resident orchestrator: you get a roadmap (`context/roadmap.md`), you delegate each phase to children, and when your context fills you `crtr node yield` to refresh against that roadmap. Don't promote for work that fits one window — finish it.
39
+ This makes you a resident orchestrator: you author a roadmap (`context/roadmap.md`), delegate each phase to children, and when your context fills you `crtr node yield` to refresh against that roadmap. `--kind` specializes the orchestrator you revive into (developer, review, spec, design, plan, explore, general); omit it to keep your current kind. Don't promote for work that fits one window — finish it.
@@ -3,13 +3,15 @@
3
3
  //
4
4
  // These tests exercise the leaf param schemas via parseArgv (framework) and
5
5
  // spot-check the leaf definitions directly — no subprocess spawning, no tmux.
6
- import { test, describe } from 'node:test';
6
+ import { test, describe, before, after, beforeEach } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
- import { writeFileSync, mkdirSync } from 'node:fs';
8
+ import { writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { randomBytes } from 'node:crypto';
12
12
  import { parseArgv } from '../../core/command.js';
13
+ import { humanCancel } from '../human/queue.js';
14
+ import { createNode, getNode, closeDb } from '../../core/canvas/index.js';
13
15
  // ---------------------------------------------------------------------------
14
16
  // Helper: write a temp JSON file and return its path.
15
17
  // ---------------------------------------------------------------------------
@@ -170,6 +172,75 @@ describe('human ask: params', () => {
170
172
  });
171
173
  });
172
174
  // ---------------------------------------------------------------------------
175
+ // human cancel — positional job_id + optional --reason
176
+ // ---------------------------------------------------------------------------
177
+ describe('human cancel: params', () => {
178
+ const params = [
179
+ { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Interaction node id.' },
180
+ { kind: 'flag', name: 'reason', type: 'string', required: false, constraint: 'Why it was retracted.' },
181
+ ];
182
+ test('parses positional job_id', async () => {
183
+ const result = await parseArgv(params, ['abc-1234']);
184
+ assert.equal(result['job_id'], 'abc-1234');
185
+ });
186
+ test('parses job_id + --reason', async () => {
187
+ const result = await parseArgv(params, ['abc-1234', '--reason', 'answered myself']);
188
+ assert.equal(result['job_id'], 'abc-1234');
189
+ assert.equal(result['reason'], 'answered myself');
190
+ });
191
+ test('missing job_id throws missing_parameter', async () => {
192
+ await assert.rejects(() => parseArgv(params, []), (err) => { assert.match(err.message, /required parameter is missing/); return true; });
193
+ });
194
+ });
195
+ // ---------------------------------------------------------------------------
196
+ // human cancel — run behavior (canvas-backed)
197
+ // ---------------------------------------------------------------------------
198
+ describe('human cancel: behavior', () => {
199
+ let home;
200
+ function humanNode(id, over = {}) {
201
+ return {
202
+ node_id: id,
203
+ name: 'human-ask',
204
+ created: new Date().toISOString(),
205
+ cwd: join(tmpdir(), `crtr-cancel-cwd-${randomBytes(3).toString('hex')}`),
206
+ kind: 'human',
207
+ mode: 'base',
208
+ lifecycle: 'terminal',
209
+ status: 'active',
210
+ ...over,
211
+ };
212
+ }
213
+ before(() => {
214
+ home = mkdtempSync(join(tmpdir(), 'crtr-cancel-home-'));
215
+ process.env['CRTR_HOME'] = home;
216
+ });
217
+ beforeEach(() => {
218
+ closeDb();
219
+ rmSync(home, { recursive: true, force: true });
220
+ });
221
+ after(() => {
222
+ closeDb();
223
+ rmSync(home, { recursive: true, force: true });
224
+ delete process.env['CRTR_HOME'];
225
+ });
226
+ test('unknown node throws not_found', async () => {
227
+ await assert.rejects(() => humanCancel.run({ job_id: 'no-such-node' }), (err) => { assert.match(err.message, /no interaction node/); return true; });
228
+ });
229
+ test('already-done node returns canceled:false / already_resolved', async () => {
230
+ createNode(humanNode('done-1', { status: 'done' }));
231
+ const r = await humanCancel.run({ job_id: 'done-1' });
232
+ assert.equal(r['canceled'], false);
233
+ assert.equal(r['reason'], 'already_resolved');
234
+ });
235
+ test('live node with no pane → retires the node (status done)', async () => {
236
+ createNode(humanNode('live-1', { status: 'active' }));
237
+ const r = await humanCancel.run({ job_id: 'live-1' });
238
+ assert.equal(r['canceled'], true);
239
+ assert.equal(r['job_id'], 'live-1');
240
+ assert.equal(getNode('live-1')?.status, 'done');
241
+ });
242
+ });
243
+ // ---------------------------------------------------------------------------
173
244
  // human list — --limit int + --cursor string
174
245
  // ---------------------------------------------------------------------------
175
246
  describe('human list: params', () => {
@@ -1,3 +1,4 @@
1
1
  export declare const humanInbox: import("../../core/command.js").LeafDef;
2
2
  export declare const humanList: import("../../core/command.js").LeafDef;
3
+ export declare const humanCancel: import("../../core/command.js").LeafDef;
3
4
  export declare const humanRun: import("../../core/command.js").LeafDef;
@@ -1,9 +1,14 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
+ import { InputError } from '../../core/io.js';
2
3
  import { pushFinal } from '../../core/feed/feed.js';
3
- import { interactionsRoot } from '../../core/artifact.js';
4
+ import { interactionsRoot, interactionDir } from '../../core/artifact.js';
4
5
  import { paginate } from '../../core/pagination.js';
6
+ import { getNode, setStatus, updateNode, subscribersOf } from '../../core/canvas/index.js';
7
+ import { appendInbox } from '../../core/feed/inbox.js';
8
+ import { existsSync } from 'node:fs';
5
9
  import { join } from 'node:path';
6
- import { inbox, scanInbox, parseDeck, deckPath, ask, launchReview, readJson, } from '@crouton-kit/humanloop';
10
+ import { inbox, scanInbox, parseDeck, deckPath, responsePath, isResolved, atomicWriteJson, ask, launchReview, readJson, } from '@crouton-kit/humanloop';
11
+ import { killPane } from './shared.js';
7
12
  // ---------------------------------------------------------------------------
8
13
  // inbox (human-invoked, blocking)
9
14
  // ---------------------------------------------------------------------------
@@ -71,6 +76,88 @@ export const humanList = defineLeaf({
71
76
  },
72
77
  });
73
78
  // ---------------------------------------------------------------------------
79
+ // cancel — retract a pending ask/approve/review
80
+ // ---------------------------------------------------------------------------
81
+ export const humanCancel = defineLeaf({
82
+ name: 'cancel',
83
+ help: {
84
+ name: 'human cancel',
85
+ summary: 'retract a pending ask/approve/review you posed — kills its TUI pane, drops it from the human queue, and retires the node. Reach for this the moment a question goes stale (you answered it yourself, the situation changed) so a human is not left resolving a prompt whose answer no longer matters',
86
+ guide: 'Pass the job_id returned by `human ask`/`approve`/`review`. Best-effort and idempotent: if the human already answered, or it was already canceled, it reports canceled:false with reason "already_resolved" and changes nothing. Subscribers (you and the asking node) get an inbox note that no answer is coming, so nobody waits on it. A blocking `human review` caller unblocks with status "closed" when its pane is killed.',
87
+ params: [
88
+ { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Node id of the interaction to cancel — the job_id returned by ask/approve/review.' },
89
+ { kind: 'flag', name: 'reason', type: 'string', required: false, constraint: 'Optional short note delivered to subscribers explaining why it was retracted.' },
90
+ ],
91
+ output: [
92
+ { name: 'canceled', type: 'boolean', required: true, constraint: 'True when the interaction was retracted; false when there was nothing live to cancel (already answered/canceled).' },
93
+ { name: 'job_id', type: 'string', required: true, constraint: 'The interaction node id.' },
94
+ { name: 'reason', type: 'string', required: false, constraint: 'Why nothing was canceled (e.g. "already_resolved"), present when canceled is false.' },
95
+ ],
96
+ outputKind: 'object',
97
+ effects: [
98
+ "Kills the detached TUI pane (if any) so the prompt leaves the human's screen.",
99
+ 'Writes a canceled response.json so the interaction drops out of `human list`/`inbox`.',
100
+ 'Marks the node done and notifies its subscribers that no answer is coming.',
101
+ ],
102
+ },
103
+ run: async (input) => {
104
+ const jobId = input['job_id'];
105
+ const reason = input['reason'];
106
+ const node = getNode(jobId);
107
+ if (node === null) {
108
+ throw new InputError({
109
+ error: 'not_found',
110
+ message: `no interaction node: ${jobId}`,
111
+ field: 'job_id',
112
+ next: 'Pass the job_id from human ask/approve/review, or list pending with `crtr human list`.',
113
+ });
114
+ }
115
+ // Resolve the interaction dir from the node's RECORDED cwd: interaction dirs
116
+ // are keyed by the asking process's cwd, which may differ from the caller's.
117
+ const idir = interactionDir(jobId, node.cwd);
118
+ // Nothing live to cancel: the human already answered, or it was retired.
119
+ if (node.status === 'done' || node.status === 'dead' || isResolved(idir)) {
120
+ return { canceled: false, job_id: jobId, reason: 'already_resolved' };
121
+ }
122
+ // (1) Kill the detached TUI pane so the prompt leaves the human's screen and
123
+ // any blocking `human review` caller unblocks (pane death → 'closed').
124
+ const rc = readJson(join(idir, 'run.json'));
125
+ if (rc?.pane_id !== undefined)
126
+ killPane(rc.pane_id);
127
+ // (2) Drop it from the human queue: a response.json marks the dir resolved,
128
+ // so scanInbox (human list/inbox) skips it.
129
+ if (existsSync(idir)) {
130
+ atomicWriteJson(responsePath(idir), {
131
+ canceled: true,
132
+ canceledAt: new Date().toISOString(),
133
+ ...(reason !== undefined && reason !== '' ? { reason } : {}),
134
+ });
135
+ }
136
+ // (3) Retire the node and tell its subscribers no answer is coming. We do
137
+ // NOT push a -final.md report: a blocking `human review` caller must see
138
+ // the pane-death 'closed', not a phantom 'done' result.
139
+ setStatus(jobId, 'done');
140
+ updateNode(jobId, { intent: 'done' });
141
+ const caller = process.env['CRTR_NODE_ID'] ?? 'human';
142
+ const note = reason !== undefined && reason !== '' ? ` — ${reason}` : '';
143
+ for (const sub of subscribersOf(jobId)) {
144
+ if (sub.node_id === caller)
145
+ continue; // don't ping whoever issued the cancel
146
+ appendInbox(sub.node_id, {
147
+ from: caller,
148
+ tier: 'normal',
149
+ kind: 'message',
150
+ label: `human interaction ${jobId} canceled — no answer is coming${note}`,
151
+ data: { body: `The human interaction ${jobId} was canceled${note}. No response will arrive.` },
152
+ });
153
+ }
154
+ return { canceled: true, job_id: jobId };
155
+ },
156
+ render: (r) => r['canceled'] === true
157
+ ? `<canceled job_id="${r['job_id']}"/>`
158
+ : `<cancel-noop job_id="${r['job_id']}">${r['reason'] ?? 'nothing to cancel'}</cancel-noop>`,
159
+ });
160
+ // ---------------------------------------------------------------------------
74
161
  // _run (hidden worker; not listed in branch help)
75
162
  // ---------------------------------------------------------------------------
76
163
  export const humanRun = defineLeaf({
@@ -5,6 +5,8 @@ export interface RunRecord {
5
5
  approve_iid?: string;
6
6
  file?: string;
7
7
  output?: string;
8
+ /** tmux pane id of the detached TUI, recorded so `human cancel` can kill it. */
9
+ pane_id?: string;
8
10
  }
9
11
  export declare function resolveMaxPanes(): number;
10
12
  export declare function pickPlacement(): 'split-h' | 'new-window';
@@ -28,6 +30,9 @@ export declare function spawnHumanJob(jobId: string, idir: string, cwd: string):
28
30
  follow_up: string;
29
31
  paneId?: string;
30
32
  };
33
+ /** Best-effort kill of a detached TUI pane. No-op (and never throws) when the
34
+ * pane is already gone or tmux is unavailable — `human cancel` is best-effort. */
35
+ export declare function killPane(paneId: string): boolean;
31
36
  export interface HumanResult {
32
37
  status: string;
33
38
  result?: unknown;
@@ -2,6 +2,7 @@ import { readConfig } from '../../core/config.js';
2
2
  import { spawnSync } from 'node:child_process';
3
3
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
+ import { atomicWriteJson, readJson } from '@crouton-kit/humanloop';
5
6
  import { countPanesInCurrentWindow, spawnAndDetach, shellQuote } from '../../core/spawn.js';
6
7
  import { reportsDir } from '../../core/canvas/paths.js';
7
8
  export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title?, ' +
@@ -48,12 +49,26 @@ export function spawnHumanJob(jobId, idir, cwd) {
48
49
  if (spawn.status !== 'spawned') {
49
50
  return { spawned: false, follow_up: followUpDrain(jobId) };
50
51
  }
52
+ // Record the pane id on run.json so `human cancel` can later kill the TUI.
53
+ // run.json was already written by the caller; merge the pane id in place.
54
+ if (spawn.paneId !== undefined) {
55
+ const rcPath = join(idir, 'run.json');
56
+ const rc = readJson(rcPath);
57
+ if (rc !== null)
58
+ atomicWriteJson(rcPath, { ...rc, pane_id: spawn.paneId });
59
+ }
51
60
  return {
52
61
  spawned: true,
53
62
  follow_up: followUpResult(jobId),
54
63
  ...(spawn.paneId !== undefined ? { paneId: spawn.paneId } : {}),
55
64
  };
56
65
  }
66
+ /** Best-effort kill of a detached TUI pane. No-op (and never throws) when the
67
+ * pane is already gone or tmux is unavailable — `human cancel` is best-effort. */
68
+ export function killPane(paneId) {
69
+ const r = spawnSync('tmux', ['kill-pane', '-t', paneId], { encoding: 'utf8' });
70
+ return r.status === 0;
71
+ }
57
72
  /** True when a tmux pane is still alive. */
58
73
  function paneAlive(paneId) {
59
74
  const r = spawnSync('tmux', ['display-message', '-p', '-t', paneId, '#{pane_id}'], {
@@ -13,13 +13,13 @@
13
13
  // run.json, never stdin.
14
14
  import { defineBranch } from '../core/command.js';
15
15
  import { humanAsk, humanApprove, humanReview, humanNotify, humanShow } from './human/prompts.js';
16
- import { humanInbox, humanList, humanRun } from './human/queue.js';
16
+ import { humanInbox, humanList, humanCancel, humanRun } from './human/queue.js';
17
17
  export function registerHuman() {
18
18
  return defineBranch({
19
19
  name: 'human',
20
20
  rootEntry: {
21
21
  concept: 'human-in-the-loop decisions, document review, and live display: ask puts a structured choice to a person, approve gates a handoff on a Yes/No sign-off, review collects anchored comments on a plan or spec, notify informs without blocking, show puts a file live on screen',
22
- desc: 'ask, approve, review, notify, show, inbox, list',
22
+ desc: 'ask, approve, review, notify, show, cancel, inbox, list',
23
23
  useWhen: 'you have a question for the user or want their feedback — always reach for human instead of guessing or assuming when a person can decide',
24
24
  },
25
25
  help: {
@@ -32,6 +32,7 @@ export function registerHuman() {
32
32
  { name: 'review', desc: 'anchored-comment review of a .md', useWhen: 'a human should comment on a plan or spec' },
33
33
  { name: 'notify', desc: 'fire-and-forget acknowledgement', useWhen: 'informing a person without blocking' },
34
34
  { name: 'show', desc: 'put a file live on screen', useWhen: 'displaying a doc while a human comments' },
35
+ { name: 'cancel', desc: 'retract a pending ask/approve/review', useWhen: 'a question went stale before the human answered' },
35
36
  { name: 'inbox', desc: 'interactively drain pending interactions', useWhen: 'a human clears the queue at their terminal' },
36
37
  { name: 'list', desc: 'enumerate pending interactions', useWhen: 'discovering what is blocked on a human' },
37
38
  ],
@@ -44,6 +45,7 @@ export function registerHuman() {
44
45
  humanShow,
45
46
  humanInbox,
46
47
  humanList,
48
+ humanCancel,
47
49
  humanRun,
48
50
  ],
49
51
  });