@nicknisi/pi-workflows 0.2.1 → 0.3.0

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.
@@ -0,0 +1,85 @@
1
+ // sanity-check.js — read-only review of a contribution: necessary? duplicated? proportionate?
2
+ //
3
+ // Ported from osolmaz/pi-workflows' sanity-check. Four area reviewers run in
4
+ // parallel, then a verifier REFUTES the findings — every concern must survive
5
+ // a skeptic with exact file:symbol evidence or it's dropped. The verdict is
6
+ // strict: keep | simplify | refactor | drop | needs-evidence.
7
+ //
8
+ // THE REASONING SHAPE (the part worth stealing):
9
+ // 1. Evidence first: one agent collects the diff (base..HEAD + working tree
10
+ // + untracked) so reviewers judge facts, not the PR description.
11
+ // 2. Four reviewers in PARALLEL, one area each: necessity (is this needed
12
+ // at all?), duplication (does the codebase already do this?), contracts
13
+ // (are new abstractions justified?), scope+tests (proportionate?).
14
+ // 3. A verifier that tries to REFUTE every concern. Confirmation bias is
15
+ // the default failure mode of review; a skeptic pass is the fix.
16
+ // 4. Read-only: reviewers get read tools only; nothing edits, posts, or fixes.
17
+ //
18
+ // HOW TO ADAPT: pass { baseRef } via args — /wf run sanity-check {"baseRef":"origin/main"}
19
+
20
+ export const meta = {
21
+ name: 'sanity-check',
22
+ description: 'Parallel necessity/duplication/contracts/scope review, then a verifier that refutes every finding',
23
+ };
24
+
25
+ const BASE = (args && args.baseRef) || 'origin/main';
26
+
27
+ phase('evidence');
28
+ const evidence = await agent(
29
+ `Collect the contribution under review in this repo: \`git diff ${BASE}...HEAD\`, the working-tree ` +
30
+ `diff (\`git diff\`), untracked files (\`git status --porcelain\`), and the matching PR title/body ` +
31
+ `(\`gh pr view\`) if one exists. Return the full raw evidence. Do not editorialize.`,
32
+ { label: 'evidence', tools: ['read', 'bash', 'grep', 'find'] },
33
+ );
34
+
35
+ phase('reviewers');
36
+ const AREAS = [
37
+ [
38
+ 'necessity',
39
+ 'Is this contribution NECESSARY? What breaks or stays broken without it? Could the need be met without new code?',
40
+ ],
41
+ [
42
+ 'duplication',
43
+ 'Does the codebase already have this? Search for existing helpers, patterns, and near-misses that cover the same ground.',
44
+ ],
45
+ [
46
+ 'contracts',
47
+ 'Are new abstractions (interfaces, config, public API surface) justified by more than one caller? Flag speculative generality.',
48
+ ],
49
+ [
50
+ 'scope-tests',
51
+ 'Is the scope proportionate to the problem? Are tests present, focused, and actually exercising the change?',
52
+ ],
53
+ ];
54
+ const findings = await parallel(
55
+ AREAS.map(([area, brief]) => async () => {
56
+ return agent(
57
+ `You are the ${area} reviewer. ${brief}\n\nReport pass | concern | unclear per point, with EXACT ` +
58
+ `file:symbol evidence for every claim. No evidence = do not claim it.\n\nEVIDENCE:\n${evidence}`,
59
+ { label: `review-${area}` },
60
+ );
61
+ }),
62
+ );
63
+
64
+ phase('verify');
65
+ const verdict = await agent(
66
+ `You are a hostile verifier. Below are four reviewers' findings about a contribution. Try to REFUTE ` +
67
+ `every concern: check the cited file:symbol yourself and drop anything unsupported or wrong. Then ` +
68
+ `issue one verdict: keep | simplify | refactor | drop | needs-evidence.\n\n` +
69
+ `FINDINGS:\n${findings.map((f, i) => `--- ${AREAS[i][0]} ---\n${f}`).join('\n\n')}`,
70
+ {
71
+ label: 'verifier',
72
+ schema: {
73
+ type: 'object',
74
+ properties: {
75
+ verdict: { type: 'string', enum: ['keep', 'simplify', 'refactor', 'drop', 'needs-evidence'] },
76
+ survivingConcerns: { type: 'array', items: { type: 'string' } },
77
+ refutedClaims: { type: 'array', items: { type: 'string' } },
78
+ rationale: { type: 'string' },
79
+ },
80
+ required: ['verdict', 'survivingConcerns', 'rationale'],
81
+ },
82
+ },
83
+ );
84
+
85
+ return verdict;
package/index.ts CHANGED
@@ -40,10 +40,13 @@ import {
40
40
  } from '@nicknisi/pi-shared';
41
41
  import { Type } from 'typebox';
42
42
  import {
43
+ createRunGate,
43
44
  runScript,
45
+ type EngineAskFn,
44
46
  type EngineSpawnFn,
45
47
  type EngineSpawnOptions,
46
48
  type EngineSpawnResult,
49
+ type RunGate,
47
50
  type RunScriptResult,
48
51
  } from './engine.js';
49
52
 
@@ -63,6 +66,96 @@ const MAX_LOG_CHARS = 2000;
63
66
  // process must never share (or cross-abort) each other's runs.
64
67
  type Cancellables = Map<string, AbortController>;
65
68
 
69
+ // ── Active workflow runs (this session), for pause/resume + footer status. ──
70
+ // A run has no id of its own (runIds belong to child spawns), so pause/resume
71
+ // are session-scoped: they apply to every in-flight run. In practice one.
72
+ interface ActiveRun {
73
+ label: string;
74
+ gate: RunGate;
75
+ /** Refresh the footer status line to reflect pause/phase state. */
76
+ refresh: () => void;
77
+ }
78
+ type ActiveRuns = Set<ActiveRun>;
79
+
80
+ function pauseAll(activeRuns: ActiveRuns): number {
81
+ for (const run of activeRuns) {
82
+ run.gate.pause();
83
+ run.refresh();
84
+ }
85
+ return activeRuns.size;
86
+ }
87
+
88
+ function resumeAll(activeRuns: ActiveRuns): number {
89
+ for (const run of activeRuns) {
90
+ run.gate.resume();
91
+ run.refresh();
92
+ }
93
+ return activeRuns.size;
94
+ }
95
+
96
+ interface UiLike {
97
+ setStatus: (id: string, text: string | undefined) => void;
98
+ confirm: (title: string, message: string) => Promise<boolean>;
99
+ select: (title: string, options: string[]) => Promise<string | undefined>;
100
+ }
101
+
102
+ /**
103
+ * Per-run scaffolding: gate (pause/resume/abort), footer status that tracks
104
+ * phase() markers, and the checkpoint/ask host functions backed by pi's UI.
105
+ * checkpoint is a confirm dialog — aborting it rejects so a dismissed gate
106
+ * stops the run instead of silently continuing.
107
+ */
108
+ function makeRunControls(
109
+ label: string,
110
+ ui: UiLike,
111
+ activeRuns: ActiveRuns,
112
+ ): {
113
+ gate: RunGate;
114
+ onLog: (line: string) => void;
115
+ checkpoint: (checkpointLabel?: string) => Promise<void>;
116
+ ask: EngineAskFn;
117
+ dispose: () => void;
118
+ } {
119
+ const gate = createRunGate();
120
+ let lastPhase = '';
121
+ const run: ActiveRun = {
122
+ label,
123
+ gate,
124
+ refresh: () => {
125
+ const state = gate.paused ? 'paused' : 'running';
126
+ ui.setStatus('workflows', `wf ${label} [${state}]${lastPhase ? ` ${lastPhase}` : ''}`);
127
+ },
128
+ };
129
+ const checkpoint = async (checkpointLabel?: string): Promise<void> => {
130
+ const ok = await ui.confirm(
131
+ 'Workflow checkpoint',
132
+ checkpointLabel ? `${checkpointLabel} — continue?` : 'Continue?',
133
+ );
134
+ if (!ok) throw new Error(`checkpoint${checkpointLabel ? ` '${checkpointLabel}'` : ''} rejected`);
135
+ };
136
+ const ask: EngineAskFn = async (question, options) => {
137
+ if (options && options.length > 0) return ui.select(question, options);
138
+ return ui.confirm('Workflow', question);
139
+ };
140
+ activeRuns.add(run);
141
+ run.refresh();
142
+ return {
143
+ gate,
144
+ checkpoint,
145
+ ask,
146
+ onLog: (line) => {
147
+ if (line.startsWith('── ')) {
148
+ lastPhase = line.slice(3);
149
+ run.refresh();
150
+ }
151
+ },
152
+ dispose: () => {
153
+ activeRuns.delete(run);
154
+ ui.setStatus('workflows', undefined);
155
+ },
156
+ };
157
+ }
158
+
66
159
  function spawnCancellable(
67
160
  cancellables: Cancellables,
68
161
  runtime: SubagentRuntime,
@@ -236,6 +329,7 @@ function formatRunResult(result: RunScriptResult, label: string): string {
236
329
 
237
330
  export default function workflows(pi: ExtensionAPI): void {
238
331
  const cancellables: Cancellables = new Map();
332
+ const activeRuns: ActiveRuns = new Set();
239
333
  const runtime = createSubagentRuntime({ namespace: NAMESPACE, artifactsDir: ARTIFACTS_ROOT });
240
334
  sweepRunArtifactsOnce(ARTIFACTS_ROOT);
241
335
 
@@ -246,14 +340,21 @@ export default function workflows(pi: ExtensionAPI): void {
246
340
  'Run a JavaScript workflow script that orchestrates subagents over the first-party runtime,',
247
341
  "or manage runs. Actions: 'run' (compile a script in a vm and execute it with injected",
248
342
  'globals), "list" (saved workflow files), "status" (a run record by runId), "stop" (cancel a',
249
- "run by runId). For 'run', pass EITHER `script` (inline JS) OR `name` (a saved workflow file",
343
+ 'run by runId), "pause"/"resume" (hold active runs before their next agent step, then',
344
+ "continue them). For 'run', pass EITHER `script` (inline JS) OR `name` (a saved workflow file",
250
345
  'stem from ~/.pi/agent/workflows/*.js or .pi/workflows/*.js). Optional `args` (any JSON value)',
251
346
  "is passed in as the script's `args` global.",
252
347
  '',
253
348
  'Script contract — injected globals: agent(prompt, opts), parallel(thunks),',
254
349
  'pipeline(items, ...stages), phase(name), log(...args), args, budget ({total, spent,',
255
- "remaining}), cwd. The script's FIRST statement SHOULD be `export const meta = { name,",
256
- 'description }` (rewritten so the vm compiles; meta.name/description surface in the result).',
350
+ "remaining}), cwd, checkpoint(label?), ask(question, options?). The script's FIRST statement",
351
+ 'SHOULD be `export const meta = { name, description }` (rewritten so the vm compiles;',
352
+ 'meta.name/description surface in the result).',
353
+ '',
354
+ 'checkpoint(label?) gates the run on a human confirm (dismiss/reject throws and stops the',
355
+ 'run); ask(question, options?) asks the human mid-run — select when options are given,',
356
+ 'yes/no confirm otherwise, undefined when dismissed. Use them before destructive or',
357
+ 'expensive steps.',
257
358
  'The script returns a value via a trailing expression or a top-level `return` (the body is',
258
359
  'wrapped in an async function).',
259
360
  '',
@@ -279,9 +380,17 @@ export default function workflows(pi: ExtensionAPI): void {
279
380
  'Keep the returned value small — summaries, counts, key findings — never raw file contents.',
280
381
  ],
281
382
  parameters: Type.Object({
282
- action: Type.Union([Type.Literal('run'), Type.Literal('list'), Type.Literal('status'), Type.Literal('stop')], {
283
- description: 'Action: run | list | status | stop',
284
- }),
383
+ action: Type.Union(
384
+ [
385
+ Type.Literal('run'),
386
+ Type.Literal('list'),
387
+ Type.Literal('status'),
388
+ Type.Literal('stop'),
389
+ Type.Literal('pause'),
390
+ Type.Literal('resume'),
391
+ ],
392
+ { description: 'Action: run | list | status | stop | pause | resume' },
393
+ ),
285
394
  script: Type.Optional(Type.String({ description: 'Inline JS workflow script (action: run).' })),
286
395
  name: Type.Optional(Type.String({ description: 'Saved workflow file stem (action: run).' })),
287
396
  args: Type.Optional(
@@ -335,6 +444,15 @@ export default function workflows(pi: ExtensionAPI): void {
335
444
  };
336
445
  }
337
446
 
447
+ if (action === 'pause' || action === 'resume') {
448
+ const n = action === 'pause' ? pauseAll(activeRuns) : resumeAll(activeRuns);
449
+ const text =
450
+ n === 0
451
+ ? 'No active workflow run in this session.'
452
+ : `${action === 'pause' ? 'Paused' : 'Resumed'} ${n} active run${n === 1 ? '' : 's'}.`;
453
+ return { content: [{ type: 'text' as const, text }], details: { affected: n } };
454
+ }
455
+
338
456
  if (action === 'stop') {
339
457
  const runId = params.runId;
340
458
  if (!runId) {
@@ -421,6 +539,10 @@ export default function workflows(pi: ExtensionAPI): void {
421
539
  ctx.sessionManager.getSessionFile(),
422
540
  controller.signal,
423
541
  );
542
+ const controls = makeRunControls(label, ctx.ui, activeRuns);
543
+ // A parked run (gate.wait / checkpoint) must not leak when the tool is
544
+ // aborted or times out — aborting the gate rejects its waiters.
545
+ controller.signal.addEventListener('abort', () => controls.gate.abort(), { once: true });
424
546
  const timeoutPromise = new Promise<never>((_, reject) => {
425
547
  controller.signal.addEventListener(
426
548
  'abort',
@@ -442,7 +564,10 @@ export default function workflows(pi: ExtensionAPI): void {
442
564
  args: params.args,
443
565
  spawn: spawnFn,
444
566
  cwd: ctx.cwd,
445
- onLog: () => {},
567
+ onLog: controls.onLog,
568
+ gate: controls.gate,
569
+ checkpoint: controls.checkpoint,
570
+ ask: controls.ask,
446
571
  }),
447
572
  timeoutPromise,
448
573
  ]);
@@ -466,6 +591,7 @@ export default function workflows(pi: ExtensionAPI): void {
466
591
  } finally {
467
592
  clearTimeout(timer);
468
593
  signal?.removeEventListener('abort', onToolAbort);
594
+ controls.dispose();
469
595
  }
470
596
  },
471
597
  });
@@ -473,16 +599,16 @@ export default function workflows(pi: ExtensionAPI): void {
473
599
  // ── /wf — thin human-facing wrapper ───────────────────────────────────
474
600
  pi.registerCommand('wf', {
475
601
  description:
476
- 'Workflows: /wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId>. Saved workflows live in ~/.pi/agent/workflows/*.js (global) and .pi/workflows/*.js (project, trusted only).',
602
+ 'Workflows: /wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId> | /wf pause | /wf resume. Saved workflows live in ~/.pi/agent/workflows/*.js (global) and .pi/workflows/*.js (project, trusted only).',
477
603
  getArgumentCompletions: (argumentPrefix) => {
478
604
  if (argumentPrefix.includes(' ')) return null;
479
605
  const prefix = argumentPrefix.trim();
480
- const subs = ['list', 'run', 'status', 'stop'].filter((s) => s.startsWith(prefix));
606
+ const subs = ['list', 'run', 'status', 'stop', 'pause', 'resume'].filter((s) => s.startsWith(prefix));
481
607
  if (subs.length === 0) return null;
482
608
  return subs.map((s) => ({ value: s + ' ', label: s }));
483
609
  },
484
610
  handler: async (args, ctx) => {
485
- await cmdWf(args, ctx, runtime, cancellables);
611
+ await cmdWf(args, ctx, runtime, cancellables, activeRuns);
486
612
  },
487
613
  });
488
614
  }
@@ -494,6 +620,7 @@ async function cmdWf(
494
620
  ctx: ExtensionCommandContext,
495
621
  runtime: SubagentRuntime,
496
622
  cancellables: Cancellables,
623
+ activeRuns: ActiveRuns,
497
624
  ): Promise<void> {
498
625
  const parts = args.trim().split(/\s+/).filter(Boolean);
499
626
  const sub = parts[0];
@@ -537,7 +664,11 @@ async function cmdWf(
537
664
  parsedArgs = argsJson;
538
665
  }
539
666
  }
540
- ctx.ui.setStatus('workflows', `running ${name}…`);
667
+ const controls = makeRunControls(name, ctx.ui, activeRuns);
668
+ if (ctx.signal) {
669
+ if (ctx.signal.aborted) controls.gate.abort();
670
+ else ctx.signal.addEventListener('abort', () => controls.gate.abort(), { once: true });
671
+ }
541
672
  try {
542
673
  const spawnFn = makeSpawnFn(
543
674
  cancellables,
@@ -546,16 +677,36 @@ async function cmdWf(
546
677
  ctx.sessionManager.getSessionFile(),
547
678
  ctx.signal ?? undefined,
548
679
  );
549
- const result = await runScript({ script: src, args: parsedArgs, spawn: spawnFn, cwd: ctx.cwd });
680
+ const result = await runScript({
681
+ script: src,
682
+ args: parsedArgs,
683
+ spawn: spawnFn,
684
+ cwd: ctx.cwd,
685
+ onLog: controls.onLog,
686
+ gate: controls.gate,
687
+ checkpoint: controls.checkpoint,
688
+ ask: controls.ask,
689
+ });
550
690
  ctx.ui.notify(formatRunResult(result, name), 'info');
551
691
  } catch (err) {
552
692
  ctx.ui.notify(`${name} failed: ${err instanceof Error ? err.message : String(err)}`, 'error');
553
693
  } finally {
554
- ctx.ui.setStatus('workflows', undefined);
694
+ controls.dispose();
555
695
  }
556
696
  return;
557
697
  }
558
698
 
699
+ if (sub === 'pause' || sub === 'resume') {
700
+ const n = sub === 'pause' ? pauseAll(activeRuns) : resumeAll(activeRuns);
701
+ ctx.ui.notify(
702
+ n === 0
703
+ ? 'No active workflow run in this session.'
704
+ : `${sub === 'pause' ? 'Paused' : 'Resumed'} ${n} run${n === 1 ? '' : 's'}.`,
705
+ n === 0 ? 'warning' : 'info',
706
+ );
707
+ return;
708
+ }
709
+
559
710
  if (sub === 'status') {
560
711
  const runId = parts[1];
561
712
  if (!runId) {
@@ -593,7 +744,10 @@ async function cmdWf(
593
744
  return;
594
745
  }
595
746
 
596
- ctx.ui.notify('Usage: /wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId>', 'warning');
747
+ ctx.ui.notify(
748
+ 'Usage: /wf list | /wf run <name> [argsJson] | /wf status <runId> | /wf stop <runId> | /wf pause | /wf resume',
749
+ 'warning',
750
+ );
597
751
  }
598
752
 
599
753
  // ── Run lookup / cancellation resolution ──────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nicknisi/pi-workflows",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Model-facing front door to the first-party workflow engine — run JS workflow scripts over the subagent runtime, replacing the third-party @quintinshaw/pi-dynamic-workflows extension",
5
5
  "keywords": [
6
6
  "pi",
@@ -17,7 +17,9 @@
17
17
  "files": [
18
18
  "dist",
19
19
  "index.ts",
20
- "engine.ts"
20
+ "engine.ts",
21
+ "examples",
22
+ "skills"
21
23
  ],
22
24
  "type": "module",
23
25
  "exports": {
@@ -28,7 +30,7 @@
28
30
  },
29
31
  "dependencies": {
30
32
  "typebox": "^1.1.0",
31
- "@nicknisi/pi-shared": "0.5.0"
33
+ "@nicknisi/pi-shared": "0.5.1"
32
34
  },
33
35
  "peerDependencies": {
34
36
  "@earendil-works/pi-coding-agent": "*"
@@ -36,6 +38,9 @@
36
38
  "pi": {
37
39
  "extensions": [
38
40
  "./index.ts"
41
+ ],
42
+ "skills": [
43
+ "./skills"
39
44
  ]
40
45
  }
41
46
  }
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: autoplan
3
+ description: Use when the user says "autoplan this", "autoplan", or wants an open-ended design/bugfix problem mined into a multiple-choice decision. Runs the saved autoplan workflow — parallel candidates, holy-grail check, advisor recommendation, human decision gate, plan write.
4
+ ---
5
+
6
+ # Autoplan
7
+
8
+ Mine the agent for ideas mechanically instead of debating design in the open: the `autoplan`
9
+ workflow fans out candidate solutions, checks the holy grail, ranks them with a recommendation,
10
+ and stops at a human decision gate. The user decides AFTER the mining, never during.
11
+
12
+ ## Run it
13
+
14
+ Call the `workflow` tool: action `run`, name `autoplan`, with `args` derived from the current
15
+ conversation. Do not ask the user to restate what "this" means — derive it:
16
+
17
+ - `problem`: the decision or planning problem and its observable end state.
18
+ - `scope`: repos, systems, and interfaces that may change, plus important exclusions.
19
+ - `constraints`: array of user/repo/safety/authority limits; `[]` when none apply.
20
+
21
+ If `workflow` `list` shows no `autoplan`, install it first: copy `examples/autoplan.js` from the
22
+ `@nicknisi/pi-workflows` package into `~/.pi/agent/workflows/autoplan.js`, then run.
23
+
24
+ ## After the run
25
+
26
+ - `decided: false` — the user rejected every option or dismissed the gate. Present the options and
27
+ the advisor's recommendation; offer to re-mine with new constraints or a proposed new option.
28
+ Never write a plan for an undecided run.
29
+ - `decided: true` — present `choice` and the `plan`. Elaborate sections on request.