@justjoshn/pi-pstack 0.15.36-pi.1 → 0.15.36-pi.8

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 (114) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +15 -12
  3. package/agents/ci-watcher.md +27 -0
  4. package/agents/thermo-nuclear-code-quality-review.md +23 -0
  5. package/assets/logo.png +0 -0
  6. package/automations/benny/FOR_AGENTS.md +1 -1
  7. package/automations/benny/README.md +2 -2
  8. package/automations/benny/skills/setup-benny/SKILL.md +5 -5
  9. package/bin/pstack-routines-supervisor +9 -0
  10. package/docs/guide/07-overnight.md +1 -1
  11. package/docs/guide/09-make-it-yours.md +1 -1
  12. package/docs/guide/10-recipes-and-pitfalls.md +1 -1
  13. package/docs/native-gaps.md +11 -5
  14. package/docs/parity-reverse-engineering.md +94 -0
  15. package/package.json +41 -6
  16. package/packaging/install-supervisor-unit.sh +164 -0
  17. package/packaging/launchd/com.pstack.routines-supervisor.plist.example +24 -0
  18. package/packaging/systemd/pstack-routines-supervisor.service.example +15 -0
  19. package/skills/architect/SKILL.md +1 -1
  20. package/skills/architect/references/runner-prompt.md +1 -1
  21. package/skills/arena/SKILL.md +3 -3
  22. package/skills/check-compiler-errors/SKILL.md +23 -0
  23. package/skills/create-skill/SKILL.md +515 -29
  24. package/skills/fix-ci/SKILL.md +29 -0
  25. package/skills/fix-merge-conflicts/SKILL.md +32 -0
  26. package/skills/get-pr-comments/SKILL.md +23 -0
  27. package/skills/how/SKILL.md +1 -1
  28. package/skills/ketchup-video/SKILL.md +2 -2
  29. package/skills/loop-on-ci/SKILL.md +50 -0
  30. package/skills/make-bot-ui/SKILL.md +2 -2
  31. package/skills/make-pr-easy-to-review/SKILL.md +59 -0
  32. package/skills/new-branch-and-pr/SKILL.md +29 -0
  33. package/skills/no-inline-imports/SKILL.md +12 -0
  34. package/skills/poteto-help/SKILL.md +4 -4
  35. package/skills/poteto-mode/playbooks/multi-phase-plan.md +1 -1
  36. package/skills/poteto-mode/playbooks/orchestrate.md +1 -1
  37. package/skills/pr-review-canvas/SKILL.md +176 -0
  38. package/skills/pr-review-canvas/renderer.js +170 -0
  39. package/skills/pr-review-canvas/styles.css +96 -0
  40. package/skills/pr-review-canvas/template.html +18 -0
  41. package/skills/reflect/SKILL.md +1 -1
  42. package/skills/review-and-ship/SKILL.md +41 -0
  43. package/skills/run-smoke-tests/SKILL.md +42 -0
  44. package/skills/statechart/scripts/test/cli.test.ts +1 -1
  45. package/skills/swarm/SKILL.md +2 -2
  46. package/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
  47. package/skills/typescript-exhaustive-switch/SKILL.md +10 -0
  48. package/skills/verify-this/SKILL.md +74 -0
  49. package/skills/weekly-review/SKILL.md +32 -0
  50. package/skills/what-did-i-get-done/SKILL.md +31 -0
  51. package/skills/why/SKILL.md +1 -1
  52. package/skills/workflow-from-chats/SKILL.md +50 -0
  53. package/src/mode.ts +3 -3
  54. package/src/paths.ts +13 -0
  55. package/src/pi-command.ts +12 -0
  56. package/src/rule.ts +4 -4
  57. package/src/sections.ts +2 -1
  58. package/src/subscriptions/durable-store.ts +482 -0
  59. package/src/subscriptions/durable-types.ts +41 -0
  60. package/src/subscriptions/ensure-supervisor.ts +67 -0
  61. package/src/subscriptions/index.ts +231 -271
  62. package/src/subscriptions/supervisor-main.ts +88 -0
  63. package/src/subscriptions/supervisor.ts +271 -0
  64. package/src/subscriptions/webhook.ts +22 -9
  65. package/src/task/card.ts +29 -0
  66. package/src/task/records.ts +3 -8
  67. package/src/task/runner.ts +15 -15
  68. package/src/task/tool.ts +18 -21
  69. package/src/todo.ts +1 -2
  70. package/scripts/live-e2e.mjs +0 -257
  71. package/test/aliases.test.ts +0 -57
  72. package/test/ask.test.ts +0 -85
  73. package/test/autocomplete.test.ts +0 -39
  74. package/test/e2e/aliases.e2e.test.ts +0 -81
  75. package/test/e2e/ask.e2e.test.ts +0 -135
  76. package/test/e2e/harness.ts +0 -179
  77. package/test/e2e/host.e2e.test.ts +0 -193
  78. package/test/e2e/lifecycle.e2e.test.ts +0 -99
  79. package/test/e2e/mode.e2e.test.ts +0 -160
  80. package/test/e2e/package.e2e.test.ts +0 -67
  81. package/test/e2e/task.e2e.test.ts +0 -734
  82. package/test/e2e/todo.e2e.test.ts +0 -59
  83. package/test/fixtures/cursor-askquestion-calls.json +0 -806
  84. package/test/fixtures/fake-pi.mjs +0 -96
  85. package/test/fixtures/image-model.ts +0 -16
  86. package/test/fixtures/list-models.ts +0 -15
  87. package/test/fixtures/readonly-shell-exit.ts +0 -17
  88. package/test/fixtures/scripted-model.ts +0 -113
  89. package/test/loop.test.ts +0 -209
  90. package/test/mode.test.ts +0 -143
  91. package/test/plan.test.ts +0 -126
  92. package/test/readonly-shell-security.test.ts +0 -84
  93. package/test/readonly-shell.test.ts +0 -44
  94. package/test/records.test.ts +0 -143
  95. package/test/registry.test.ts +0 -18
  96. package/test/resources.test.ts +0 -162
  97. package/test/rule.test.ts +0 -92
  98. package/test/scripts.test.ts +0 -182
  99. package/test/sections.test.ts +0 -56
  100. package/test/subscriptions-cron.test.ts +0 -60
  101. package/test/subscriptions.test.ts +0 -598
  102. package/test/task-agents.test.ts +0 -49
  103. package/test/task-card.test.ts +0 -34
  104. package/test/task-fork.test.ts +0 -233
  105. package/test/task-format.test.ts +0 -65
  106. package/test/task-model.test.ts +0 -141
  107. package/test/task-runner.test.ts +0 -259
  108. package/test/task-schema.test.ts +0 -50
  109. package/test/task-status.test.ts +0 -155
  110. package/test/task-worktree.test.ts +0 -106
  111. package/test/todo.test.ts +0 -74
  112. package/test/visualize.test.ts +0 -152
  113. package/tsconfig.json +0 -5
  114. package/vitest.config.ts +0 -16
package/src/task/tool.ts CHANGED
@@ -1,19 +1,20 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, rmSync } from 'node:fs';
3
- import { basename, join } from 'node:path';
3
+ import { join } from 'node:path';
4
4
 
5
- import { getSupportedThinkingLevels, StringEnum } from '@earendil-works/pi-ai';
5
+ import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
6
6
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
7
7
  import { type Static, Type } from 'typebox';
8
8
  import { childIdentity, childVariables } from '../child-env.ts';
9
9
  import { rootStoreDir, subagentDir, workspaceStateDir, worktreesDir } from '../paths.ts';
10
+ import { piCommand } from '../pi-command.ts';
10
11
  import { type AgentDefinition, findAgent } from './agents.ts';
11
- import { renderTaskCard } from './card.ts';
12
+ import { renderTaskCard, renderTaskNotification } from './card.ts';
12
13
  import { backgroundText, bounded, foregroundText, steerText, worktreeLine, worktreePreface } from './format.ts';
13
14
  import { type AvailableModel, type ModelSpec, resolveSubagentModel } from './models.ts';
14
15
  import { type AgentRecord, acquireRunLock, forkTranscript, type RunLock, readonlyLaunchRestores, readRecord, transcriptFor, writeRecord } from './records.ts';
15
- import { type BackgroundNotice, createSubagentRegistry } from './registry.ts';
16
- import { type ChildHandle, type ChildOutcome, childArgs, resolveAttachments, startChild, validateAgentId, withAttachments } from './runner.ts';
16
+ import { type BackgroundNotice, createSubagentRegistry, NOTIFICATION_TYPE } from './registry.ts';
17
+ import { type ChildHandle, type ChildInput, type ChildOutcome, childArgs, resolveAttachments, startChild, validateAgentId, withAttachments } from './runner.ts';
17
18
  import { registerStopAgent } from './stop.ts';
18
19
  import { addWorktree, planWorktree, type Worktree } from './worktree.ts';
19
20
 
@@ -50,7 +51,8 @@ export const TaskParams = Type.Object({
50
51
  ),
51
52
  file_attachments: Type.Optional(Type.Array(Type.String(), { description: 'Paths of files or images to attach to the agent first message.' })),
52
53
  environment: Type.Optional(
53
- StringEnum(['local', 'cloud'] as const, {
54
+ Type.Enum(['local', 'cloud'] as const, {
55
+ type: 'string',
54
56
  description:
55
57
  'local (default) runs the agent in this working directory. cloud runs it on this machine in its own git worktree on a new branch pstack/<agent id>, created from cloud_base_branch or the current HEAD commit, so uncommitted changes do not reach it. The result names the worktree and branch; merge the branch to take its commits. Ignored with resume; not allowed with fork.',
56
58
  }),
@@ -101,13 +103,6 @@ export interface Launch {
101
103
 
102
104
  const runsReadonly = (plan: Launch) => plan.readonly || plan.planMode;
103
105
 
104
- /** The running pi script, so children match the parent's Pi version; falls back to `pi` on PATH. */
105
- function piCommand(): string[] {
106
- const script = process.argv[1];
107
- if (script !== undefined && !script.startsWith('/$bunfs/') && existsSync(script)) return [process.execPath, script];
108
- return /^(node|bun)(\.exe)?$/.test(basename(process.execPath).toLowerCase()) ? ['pi'] : [process.execPath];
109
- }
110
-
111
106
  export function availableModels(ctx: ExtensionContext): AvailableModel[] {
112
107
  return ctx.modelRegistry.getAvailable().map((model) => ({ provider: model.provider, id: model.id, reasoning: model.reasoning, levels: getSupportedThinkingLevels(model) }));
113
108
  }
@@ -219,13 +214,12 @@ export function planFork(params: TaskInput, ctx: ExtensionContext, agents: reado
219
214
  };
220
215
  }
221
216
 
222
- function launchChild(plan: Launch, prompt: string, signal: AbortSignal | undefined) {
217
+ function launchChild(plan: Launch, input: ChildInput, signal: AbortSignal | undefined) {
223
218
  const readonly = runsReadonly(plan);
224
219
  const env = { ...process.env, ...childVariables(plan.agent.type, plan.depth, readonly, plan.rootStore, workspaceStateDir(plan.workspace)) };
225
220
  const transcript = plan.kind === 'fresh' || readonly ? undefined : transcriptFor(subagentDir(plan.workspace), plan.agentId);
226
221
  const restoreTools = transcript === undefined ? [] : readonlyLaunchRestores(transcript);
227
222
  const args = childArgs({ sessionDir: subagentDir(plan.workspace), agentId: plan.agentId, readonly, restoreTools, trusted: plan.trusted, ...(plan.model === undefined ? {} : { model: plan.model }) });
228
- const input = withAttachments(prompt, plan.attachments);
229
223
  return startChild({ command: piCommand(), args, prompt: input.text, images: input.images, cwd: plan.worktree?.cwd ?? plan.workspace, env, ...(signal === undefined ? {} : { signal }) });
230
224
  }
231
225
 
@@ -256,10 +250,11 @@ function recordResume(plan: Launch): void {
256
250
  * Starts the child, then points the run lock at it and records the agent. A child that never started (no pid) has no
257
251
  * transcript and changes no record, so a new agent's ID never becomes resumable; a fork's copied transcript is removed with it.
258
252
  */
259
- function start(plan: Launch, prompt: string, signal: AbortSignal | undefined, lock: RunLock): ChildHandle {
253
+ function start(plan: Launch, rawInput: ChildInput, signal: AbortSignal | undefined, lock: RunLock): ChildHandle {
260
254
  let handle: ChildHandle | undefined;
261
255
  try {
262
- handle = launchChild(plan, plan.kind === 'fresh' && plan.worktree !== undefined ? worktreePreface(plan.worktree, prompt) : prompt, signal);
256
+ const input = plan.kind === 'fresh' && plan.worktree !== undefined ? { ...rawInput, text: worktreePreface(plan.worktree, rawInput.text) } : rawInput;
257
+ handle = launchChild(plan, input, signal);
263
258
  if (handle.pid !== undefined) {
264
259
  lock.holdFor(handle.pid);
265
260
  if (plan.kind === 'resume') recordResume(plan);
@@ -387,7 +382,7 @@ async function steerRunning(params: TaskInput, running: { handle: ChildHandle; r
387
382
  const agentId = params.resume ?? '';
388
383
  if ((params.readonly === true || planMode) && !running.readonly)
389
384
  throw new Error(`Agent ${agentId} is running with write access, so a readonly call cannot message it${planMode ? ' (plan mode makes every Task call readonly)' : ''}. Wait for its completion notification, or stop it with stop_agent.`);
390
- const input = withAttachments(params.prompt, resolveAttachments(params.file_attachments ?? [], ctx.cwd));
385
+ const input = await withAttachments(params.prompt, resolveAttachments(params.file_attachments ?? [], ctx.cwd));
391
386
  await running.handle.steer(input.text, input.images).catch((error: Error) => {
392
387
  throw new Error(`Agent ${agentId} finished before the message reached it (${error.message}). Resume it once its result arrives.`);
393
388
  });
@@ -427,6 +422,7 @@ export interface TaskHandle {
427
422
 
428
423
  export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHandle {
429
424
  const registry = createSubagentRegistry(pi);
425
+ pi.registerMessageRenderer?.(NOTIFICATION_TYPE, (message, renderOptions) => renderTaskNotification(message, renderOptions));
430
426
  let sessionCtx: ExtensionContext | undefined;
431
427
  pi.on('agent_before_settle', (event, ctx) => registry.beforeSettle(ctx, event.outcome));
432
428
  pi.on('session_shutdown', (event, ctx) => {
@@ -458,11 +454,12 @@ export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHa
458
454
  taskPreflight(params, options.depth);
459
455
  const running = params.resume === undefined ? undefined : registry.get(validateAgentId(params.resume));
460
456
  if (running !== undefined) return steerRunning(params, running, ctx, options.planMode?.() === true);
457
+ const attachments = resolveAttachments(params.file_attachments ?? [], ctx.cwd);
458
+ const input = attachments.length === 0 ? { text: params.prompt.trim(), images: [] } : await withAttachments(params.prompt, attachments);
461
459
  const { plan, lock } = prepare(params, ctx, options, registry.isRunning);
462
- // Awaiting yields to a parallel Task call, so only a fresh isolated agent awaits before it is tracked.
463
460
  if (plan.kind === 'fresh' && plan.worktree !== undefined) await createWorktree(plan, lock, signal);
464
461
  const background = params.run_in_background ?? plan.agent.background;
465
- const handle = start(plan, params.prompt, background ? undefined : signal, lock);
462
+ const handle = start(plan, input, background ? undefined : signal, lock);
466
463
  if (background) {
467
464
  registry.track(plan.agentId, handle, lock.release, runsReadonly(plan), backgroundNotice(plan, params.prompt, ctx, toolCallId));
468
465
  return { content: [{ type: 'text', text: backgroundText(plan.agentId, transcriptOf(plan), plan.notes) }], details: details(plan, true, 'running', handle.pid) };
@@ -482,7 +479,7 @@ export function registerTask(pi: ExtensionAPI, options: TaskToolOptions): TaskHa
482
479
  taskPreflight(params, options.depth);
483
480
  const { plan, lock } = prepare(params, ctx, options, registry.isRunning);
484
481
  await createWorktree(plan, lock, undefined);
485
- const handle = start(plan, prompt, undefined, lock);
482
+ const handle = start(plan, { text: prompt.trim(), images: [] }, undefined, lock);
486
483
  registry.track(plan.agentId, handle, lock.release, runsReadonly(plan), backgroundNotice(plan, prompt, ctx, `pstack-${plan.agentId}`));
487
484
  return plan.agentId;
488
485
  }
package/src/todo.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { StringEnum } from '@earendil-works/pi-ai';
2
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
3
2
  import { Type } from 'typebox';
4
3
 
@@ -30,7 +29,7 @@ const TodoParams = Type.Object({
30
29
  Type.Object({
31
30
  id: Type.String({ description: 'Unique identifier for the TODO item' }),
32
31
  content: Type.Optional(Type.String({ description: 'The description/content of the TODO item. Required for a new item; may be omitted when merging an update.' })),
33
- status: Type.Optional(StringEnum(STATUSES, { description: 'The current status of the TODO item. Required for a new item; may be omitted when merging an update.' })),
32
+ status: Type.Optional(Type.Enum(STATUSES, { type: 'string', description: 'The current status of the TODO item. Required for a new item; may be omitted when merging an update.' })),
34
33
  }),
35
34
  { minItems: 1, description: 'Array of TODO items to update or create' },
36
35
  ),
@@ -1,257 +0,0 @@
1
- #!/usr/bin/env node
2
- // Runs pstack scenarios against a real model through the Pi CLI and records what the model did.
3
- // Usage: live-e2e.mjs [--model provider/id] [--scenario poteto|delegate|nocomments|setup|all] [--out <dir>] [--tag <name>]
4
- // [--rule <file>] [--providers claude-subscription]
5
- // --rule copies a pstack-models.md into the throwaway agent directory first, for example to bound subagent cost.
6
- // --providers loads extra provider packages so their models are available without driving the run.
7
- // It needs credentials in the user's Pi auth.json. Each scenario uses a throwaway workspace, HOME, and agent
8
- // directory, and auth.json is symlinked rather than copied. Pi writes refreshed OAuth tokens through that symlink,
9
- // so an OAuth refresh during a live run updates the user's real auth.json, and its lock file sits beside the
10
- // symlink rather than the real file; avoid running OAuth-provider scenarios while another Pi session refreshes.
11
- // The model under test has bash and write tools, so it could read or overwrite the real auth.json through that
12
- // symlink. Run it only with models and prompts you trust, and review the recorded tool calls in the evidence file.
13
- import { execFileSync, spawn } from 'node:child_process';
14
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
15
- import { homedir, tmpdir } from 'node:os';
16
- import { dirname, join, resolve } from 'node:path';
17
- import { fileURLToPath } from 'node:url';
18
-
19
- import { resolveSubagentModel } from '../src/task/models.ts';
20
-
21
- const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
22
- const repoRoot = resolve(packageRoot, '../..');
23
- const cli = join(packageRoot, 'node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js');
24
- const userAgentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi/agent');
25
- const ROLES = [
26
- 'feature, refactoring',
27
- 'bug-fix',
28
- 'perf-issue',
29
- 'hillclimb',
30
- 'judgment and prose',
31
- 'hardest tasks',
32
- 'how explorer',
33
- 'how explainer',
34
- 'why investigators',
35
- 'why synthesizer',
36
- 'reflect tooling',
37
- 'reflect judgment, divergent, synthesizer',
38
- 'arena runners',
39
- 'arena cross-judge pool',
40
- 'swarm workers',
41
- 'architect runners',
42
- 'interrogate reviewers',
43
- ];
44
-
45
- const PROVIDER_PACKAGES = { 'claude-subscription': 'extensions/pi-anthropic-oauth' };
46
-
47
- function sandbox(model, ruleFile, providers) {
48
- const root = realpathSync(mkdtempSync(join(tmpdir(), 'pstack-live-')));
49
- const agentDir = join(root, 'agent');
50
- const work = join(root, 'work');
51
- mkdirSync(agentDir);
52
- mkdirSync(work);
53
- mkdirSync(join(root, 'home'));
54
- symlinkSync(join(userAgentDir, 'auth.json'), join(agentDir, 'auth.json'));
55
- // Only the driver's provider loads, so default role models such as claude-opus-5-5-xhigh cannot bill a subscription unasked.
56
- const wanted = new Set([model.split('/')[0], ...providers]);
57
- const packages = [
58
- packageRoot,
59
- ...Object.entries(PROVIDER_PACKAGES)
60
- .filter(([provider]) => wanted.has(provider))
61
- .map(([, path]) => join(repoRoot, path)),
62
- ];
63
- writeFileSync(join(agentDir, 'settings.json'), JSON.stringify({ packages, quietStartup: true, defaultProvider: model.split('/')[0], defaultModel: model.split('/')[1] }));
64
- if (ruleFile !== undefined) {
65
- mkdirSync(join(agentDir, 'pstack'));
66
- writeFileSync(join(agentDir, 'pstack/pstack-models.md'), readFileSync(ruleFile));
67
- }
68
- const git = (...args) => execFileSync('git', ['-C', work, ...args], { stdio: 'ignore' });
69
- git('init', '-q', '-b', 'main');
70
- return { root, agentDir, work, git, env: { ...process.env, HOME: join(root, 'home'), PI_CODING_AGENT_DIR: agentDir } };
71
- }
72
-
73
- function commitAll(box, message) {
74
- box.git('add', '-A');
75
- box.git('-c', 'user.email=live@example.com', '-c', 'user.name=live', 'commit', '-qm', message);
76
- }
77
-
78
- function runPi(box, model, prompt, timeoutMs) {
79
- return new Promise((resolveRun) => {
80
- const started = Date.now();
81
- const child = spawn(process.execPath, [cli, '--mode', 'json', '--no-session', '--model', model, prompt], { cwd: box.work, env: box.env, stdio: ['ignore', 'pipe', 'pipe'] });
82
- const events = [];
83
- let buffer = '';
84
- let stderr = '';
85
- const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
86
- child.stdout.on('data', (chunk) => {
87
- buffer += chunk.toString('utf8');
88
- for (let index = buffer.indexOf('\n'); index !== -1; index = buffer.indexOf('\n')) {
89
- const line = buffer.slice(0, index).trim();
90
- buffer = buffer.slice(index + 1);
91
- if (line !== '') events.push(JSON.parse(line));
92
- }
93
- });
94
- child.stderr.on('data', (chunk) => {
95
- stderr += chunk.toString('utf8');
96
- });
97
- child.on('close', (code) => {
98
- clearTimeout(timer);
99
- resolveRun({ events, code, stderr: stderr.slice(-4000), seconds: Math.round((Date.now() - started) / 1000) });
100
- });
101
- });
102
- }
103
-
104
- function toolCalls(events) {
105
- return events
106
- .filter((event) => event.type === 'message_end' && event.message?.role === 'assistant')
107
- .flatMap((event) => event.message.content.filter((part) => part.type === 'toolCall').map((part) => ({ name: part.name, arguments: part.arguments })));
108
- }
109
-
110
- function finalText(events) {
111
- const last = events.filter((event) => event.type === 'message_end' && event.message?.role === 'assistant').at(-1);
112
- return (last?.message.content ?? [])
113
- .filter((part) => part.type === 'text')
114
- .map((part) => part.text)
115
- .join('')
116
- .slice(0, 3000);
117
- }
118
-
119
- const playbookSteps = (name) =>
120
- readFileSync(join(packageRoot, `skills/poteto-mode/playbooks/${name}.md`), 'utf8')
121
- .split('\n')
122
- .filter((line) => /^\d+\. /.test(line))
123
- .map((line) => line.replace(/^\d+\. /, ''));
124
-
125
- const SCENARIOS = {
126
- poteto: {
127
- requirement: 'LIVE-001',
128
- prompt: '/poteto-mode How does add in calc.js treat two numeric strings such as "2" and "3"? This is a read-only question; do not change any files.',
129
- prepare: (box) => {
130
- writeFileSync(join(box.work, 'calc.js'), 'export function add(a, b) {\n return a + b;\n}\n');
131
- commitAll(box, 'calc');
132
- },
133
- check: (calls, box) => {
134
- const steps = playbookSteps('investigation');
135
- const playbook = 'poteto-mode/playbooks/investigation.md';
136
- const readPlaybook = calls.some((call) => (call.name === 'read' && String(call.arguments.path ?? '').endsWith(playbook)) || (call.name === 'bash' && String(call.arguments.command ?? '').includes(playbook)));
137
- const todos = calls.find((call) => call.name === 'TodoWrite')?.arguments.todos ?? [];
138
- // A todo list is plain text, so Markdown emphasis marks (** and backticks) are dropped before comparing.
139
- const plain = (text) => text.replace(/\*\*|`/g, '');
140
- const verbatim = steps.map((step, index) => typeof todos[index]?.content === 'string' && plain(todos[index].content) === plain(step));
141
- const clean = execFileSync('git', ['-C', box.work, 'status', '--porcelain'], { encoding: 'utf8' }).trim() === '';
142
- return { readPlaybook, firstTodosAreStepsVerbatim: verbatim.every(Boolean), stepMatches: verbatim, workspaceUnchanged: clean, todos: todos.map((todo) => todo.content) };
143
- },
144
- },
145
- delegate: {
146
- requirement: 'LIVE-004',
147
- prompt: '/poteto-mode Delegate to one foreground poteto-agent: find what calc.js exports. Read-only.',
148
- prepare: (box) => {
149
- writeFileSync(join(box.work, 'calc.js'), 'export const add = (a, b) => a + b;\n');
150
- commitAll(box, 'calc');
151
- },
152
- check: (calls) => {
153
- // pstack 0.15.21: the parent puts poteto-mode's absolute SKILL.md path in every poteto-agent prompt.
154
- const skillPath = join(packageRoot, 'skills/poteto-mode/SKILL.md');
155
- const delegates = calls.filter((call) => call.name === 'Task' && call.arguments.subagent_type === 'poteto-agent');
156
- return {
157
- delegated: delegates.length > 0,
158
- everyPromptHasSkillPath: delegates.length > 0 && delegates.every((call) => String(call.arguments.prompt ?? '').includes(skillPath)),
159
- prompts: delegates.map((call) => String(call.arguments.prompt ?? '').slice(0, 400)),
160
- };
161
- },
162
- },
163
- nocomments: {
164
- requirement: 'LIVE-002',
165
- prompt: '/no-comments',
166
- prepare: (box) => {
167
- writeFileSync(join(box.work, 'util.js'), 'export function add(a, b) {\n return a + b;\n}\n');
168
- commitAll(box, 'util');
169
- writeFileSync(join(box.work, 'util.js'), '// This function adds two numbers together\nexport function add(a, b) {\n // return the sum of a and b\n return a + b;\n}\n');
170
- },
171
- check: (calls) => {
172
- const tasks = calls.filter((call) => call.name === 'Task');
173
- return { spawnedCommentSicko: tasks.some((call) => call.arguments.subagent_type === 'comment-sicko'), taskTypes: tasks.map((call) => call.arguments.subagent_type ?? '(default)') };
174
- },
175
- },
176
- setup: {
177
- requirement: 'LIVE-003',
178
- prompt:
179
- "/setup-pstack Use the large budget and keep the skill's mapping. For a role whose default model is not available, use the closest available model of the same family, else inherit-parent. Do not ask me anything; write the rule now.",
180
- prepare: () => undefined,
181
- check: (_calls, box, model) => {
182
- const rule = join(box.agentDir, 'pstack/pstack-models.md');
183
- const text = existsSync(rule) ? readFileSync(rule, 'utf8') : '';
184
- const missing = ROLES.filter((role) => !text.split('\n').some((line) => line.startsWith(`${role}:`)));
185
- const values = text
186
- .split('\n')
187
- .filter((line) => ROLES.some((role) => line.startsWith(`${role}:`)))
188
- .flatMap((line) => line.slice(line.indexOf(':') + 1).split(','))
189
- .map((value) => value.trim());
190
- const available = listModels(box);
191
- const parent = { provider: model.split('/')[0], id: model.split('/')[1] };
192
- const unresolved = values.filter((value) => {
193
- try {
194
- resolveSubagentModel(value, available, parent);
195
- return false;
196
- } catch {
197
- return true;
198
- }
199
- });
200
- return {
201
- ruleWritten: text !== '',
202
- noFrontmatter: !text.startsWith('---'),
203
- missingRoles: missing,
204
- distinctValues: [...new Set(values)],
205
- unresolvedValues: unresolved,
206
- rule: text,
207
- };
208
- },
209
- },
210
- };
211
-
212
- /** The models the throwaway agent directory exposes, with the thinking levels Pi supports for each, as Task sees them. */
213
- function listModels(box) {
214
- const fixture = join(packageRoot, 'test/fixtures/list-models.ts');
215
- const output = execFileSync(process.execPath, [cli, '-p', '--offline', '--no-session', '-e', fixture, '/list-models'], { cwd: box.work, env: box.env, encoding: 'utf8' });
216
- return output
217
- .split('\n')
218
- .filter((line) => line.startsWith('{'))
219
- .map((line) => JSON.parse(line));
220
- }
221
-
222
- async function runScenario(name, model, outDir, tag, ruleFile, providers) {
223
- const scenario = SCENARIOS[name];
224
- const box = sandbox(model, ruleFile, providers);
225
- try {
226
- scenario.prepare(box);
227
- const run = await runPi(box, model, scenario.prompt, 15 * 60_000);
228
- const calls = toolCalls(run.events);
229
- const result = {
230
- scenario: name,
231
- requirement: scenario.requirement,
232
- model,
233
- prompt: scenario.prompt,
234
- exitCode: run.code,
235
- seconds: run.seconds,
236
- checks: scenario.check(calls, box, model),
237
- toolCalls: calls.map((call) => ({ name: call.name, arguments: JSON.stringify(call.arguments).slice(0, 600) })),
238
- finalText: finalText(run.events),
239
- stderrTail: run.stderr,
240
- };
241
- writeFileSync(join(outDir, `live-${name}${tag === undefined ? '' : `-${tag}`}.json`), `${JSON.stringify(result, null, 2)}\n`);
242
- process.stdout.write(`${name}: ${JSON.stringify(result.checks)}\n`);
243
- } finally {
244
- rmSync(box.root, { recursive: true, force: true });
245
- }
246
- }
247
-
248
- const argv = process.argv.slice(2);
249
- const option = (name, fallback) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : fallback);
250
- const model = option('--model', 'deepseek/deepseek-flash');
251
- const outDir = resolve(option('--out', join(repoRoot, 'parity/pi-pstack-two/evidence')));
252
- const chosen = option('--scenario', 'all');
253
- const tag = option('--tag', undefined);
254
- const ruleFile = argv.includes('--rule') ? resolve(option('--rule', '')) : undefined;
255
- const providers = option('--providers', '').split(',').filter(Boolean);
256
- mkdirSync(outDir, { recursive: true });
257
- for (const name of chosen === 'all' ? Object.keys(SCENARIOS) : [chosen]) await runScenario(name, model, outDir, tag, ruleFile, providers);
@@ -1,57 +0,0 @@
1
- import { join } from 'node:path';
2
-
3
- import { describe, expect, test } from 'vitest';
4
- import { aliasInputResult, aliasTable, aliasTransform, packageSkillNames } from '../src/aliases.ts';
5
- import { SKILLS_DIR } from '../src/paths.ts';
6
-
7
- const names = new Set(['how', 'poteto-mode', 'principle-prove-it-works']);
8
-
9
- describe('aliasTransform', () => {
10
- test.for([
11
- { input: '/how', expected: '/skill:how' },
12
- { input: '/how does the cache work?', expected: '/skill:how does the cache work?' },
13
- { input: '/poteto-mode fix the "drift" bug\nrepro first, then $1 and $@', expected: '/skill:poteto-mode fix the "drift" bug\nrepro first, then $1 and $@' },
14
- { input: '/how\nwhat owns retries?', expected: '/skill:how \nwhat owns retries?' },
15
- { input: '/principle-prove-it-works', expected: '/skill:principle-prove-it-works' },
16
- ])('rewrites $input to a native skill command (CMD-001)', ({ input, expected }) => {
17
- expect(aliasTransform(input, names)).toBe(expected);
18
- });
19
-
20
- test.for([
21
- { input: '/skill:how what', why: 'already native' },
22
- { input: '/model', why: 'a Pi built-in' },
23
- { input: '/howdy partner', why: 'a longer word that only starts with a skill name' },
24
- { input: '/unknown thing', why: 'not a shipped skill' },
25
- { input: 'please /how this', why: 'not at the start' },
26
- { input: ' /how leading space', why: 'Pi commands start at column zero' },
27
- { input: '', why: 'empty input' },
28
- ])('leaves $why input alone (CMD-002)', ({ input }) => {
29
- expect(aliasInputResult({ text: input }, names)).toEqual({ action: 'continue' });
30
- });
31
-
32
- test('the input handler transforms an alias and keeps attached images (CMD-001)', () => {
33
- const images = [{ type: 'image' as const, data: 'AAAA', mimeType: 'image/png' }];
34
- expect(aliasInputResult({ text: '/how this screen?', images }, names)).toEqual({ action: 'transform', text: '/skill:how this screen?', images });
35
- });
36
- });
37
-
38
- describe('aliasTable (CMD-002, CMD-003; review: architecture 6)', () => {
39
- const skill = (name: string, path: string) => ({ name: `skill:${name}`, description: `${name} skill`, source: 'skill' as const, sourceInfo: { path, source: 'test', scope: 'user' as const, origin: 'package' as const } });
40
- const commands = [
41
- skill('how', join(SKILLS_DIR, 'how/SKILL.md')),
42
- skill('why', join(SKILLS_DIR, 'why/SKILL.md')),
43
- skill('mine', '/home/me/.pi/agent/skills/mine/SKILL.md'),
44
- { name: 'why', description: 'user template', source: 'prompt' as const, sourceInfo: { path: '/home/me/.pi/agent/prompts/why.md', source: 'test', scope: 'user' as const, origin: 'top-level' as const } },
45
- ];
46
-
47
- test('aliases every loaded skill that no template or command already names, as Cursor attaches any skill by /name (review r2: parity 1)', () => {
48
- expect([...aliasTable(commands)]).toEqual([
49
- ['how', 'how skill'],
50
- ['mine', 'mine skill'],
51
- ]);
52
- });
53
-
54
- test('lists the loaded skills that live in this package, by canonical path (A4, recheck 1)', () => {
55
- expect(packageSkillNames(commands)).toEqual(['how', 'why']);
56
- });
57
- });
package/test/ask.test.ts DELETED
@@ -1,85 +0,0 @@
1
- import { readFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
-
5
- import type { Static } from 'typebox';
6
- import { Value } from 'typebox/value';
7
- import { describe, expect, test } from 'vitest';
8
- import { AskParams, DONE_LABEL, validateQuestions } from '../src/ask.ts';
9
-
10
- const question = (options: { id: string; label: string }[], allow_multiple?: boolean) => ({ id: 'q', prompt: 'Pick', options, ...(allow_multiple === undefined ? {} : { allow_multiple }) });
11
- const outcome = (questions: ReturnType<typeof question>[]) => {
12
- try {
13
- validateQuestions(questions);
14
- return 'valid';
15
- } catch (error) {
16
- return (error as Error).message;
17
- }
18
- };
19
-
20
- describe('validateQuestions (HOST-005; reviews: parity, security 6, architecture 4)', () => {
21
- test.for([
22
- {
23
- name: 'duplicate labels',
24
- questions: [
25
- question([
26
- { id: 'a', label: 'Same' },
27
- { id: 'b', label: 'Same' },
28
- ]),
29
- ],
30
- expected: 'Question "q" has two options labelled "Same".',
31
- },
32
- {
33
- name: 'duplicate option ids',
34
- questions: [
35
- question([
36
- { id: 'a', label: 'A' },
37
- { id: 'a', label: 'B' },
38
- ]),
39
- ],
40
- expected: 'Question "q" has two options with id "a".',
41
- },
42
- { name: 'no options', questions: [question([])], expected: 'Question "q" has no options.' },
43
- { name: 'the reserved other id', questions: [question([{ id: 'other', label: 'Something else' }])], expected: 'Option label "Other: (type to answer)" and id "other" are reserved for the typed answer.' },
44
- { name: 'the reserved done label', questions: [question([{ id: 'd', label: DONE_LABEL }], true)], expected: `Option label "${DONE_LABEL}" is reserved for ending a multi-select question.` },
45
- {
46
- name: 'a well-formed question',
47
- questions: [
48
- question(
49
- [
50
- { id: 'a', label: 'Done' },
51
- { id: 'b', label: 'B' },
52
- ],
53
- true,
54
- ),
55
- ],
56
- expected: 'valid',
57
- },
58
- ])('$name', ({ questions, expected }) => {
59
- expect(outcome(questions)).toBe(expected);
60
- });
61
- });
62
-
63
- describe('Cursor AskQuestion calls (HOST-005)', () => {
64
- // 24 AskQuestion inputs the Cursor IDE produced, read from its agent transcripts.
65
- const fixture = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'fixtures/cursor-askquestion-calls.json'), 'utf8')) as { calls: { input: Static<typeof AskParams> }[] };
66
- const questionSchema = AskParams.properties.questions.items;
67
- const optionSchema = questionSchema.properties.options.items;
68
-
69
- test('every field Cursor sends is declared by the port schema', () => {
70
- const undeclared = fixture.calls.flatMap(({ input }) => [
71
- ...Object.keys(input).filter((key) => !(key in AskParams.properties)),
72
- ...input.questions.flatMap((question) => [
73
- ...Object.keys(question).filter((key) => !(key in questionSchema.properties)),
74
- ...question.options.flatMap((option) => Object.keys(option).filter((key) => !(key in optionSchema.properties))),
75
- ]),
76
- ]);
77
- expect(fixture.calls.length).toBe(24);
78
- expect(undeclared).toEqual([]);
79
- });
80
-
81
- test('every Cursor call is valid input for the port', () => {
82
- expect(fixture.calls.filter(({ input }) => !Value.Check(AskParams, input))).toEqual([]);
83
- expect(fixture.calls.map(({ input }) => outcome(input.questions as ReturnType<typeof question>[]))).toEqual(fixture.calls.map(() => 'valid'));
84
- });
85
- });
@@ -1,39 +0,0 @@
1
- import type { AutocompleteItem, AutocompleteProvider } from '@earendil-works/pi-tui';
2
- import { describe, expect, test } from 'vitest';
3
- import { aliasAutocomplete } from '../src/autocomplete.ts';
4
-
5
- const builtin: AutocompleteItem[] = [
6
- { value: 'skill:poteto-mode', label: 'skill:poteto-mode' },
7
- { value: 'model', label: 'model' },
8
- ];
9
- const current: AutocompleteProvider = {
10
- getSuggestions: async (lines, _line, col) => ((lines[0] ?? '').slice(0, col).startsWith('/') ? { items: builtin, prefix: (lines[0] ?? '').slice(0, col) } : null),
11
- applyCompletion: (lines, cursorLine, _col, item, prefix) => ({ lines: [`/${item.value} `, ...lines.slice(1)], cursorLine, cursorCol: prefix.length }),
12
- };
13
- const aliases = new Map([
14
- ['poteto-mode', "poteto's agent style"],
15
- ['poteto-help', 'Guides users through pstack'],
16
- ['how', 'Explains how code works'],
17
- ]);
18
- const signal = new AbortController().signal;
19
-
20
- describe('alias autocomplete (CMD-003)', () => {
21
- test('lists matching pstack skills under their Cursor names ahead of the built-in suggestions', async () => {
22
- const provider = aliasAutocomplete(current, aliases);
23
- expect(await provider.getSuggestions(['/pot'], 0, 4, { signal })).toEqual({
24
- prefix: '/pot',
25
- items: [{ value: 'poteto-mode', label: 'poteto-mode', description: "poteto's agent style" }, { value: 'poteto-help', label: 'poteto-help', description: 'Guides users through pstack' }, ...builtin],
26
- });
27
- });
28
-
29
- test('leaves completions alone outside a leading command token', async () => {
30
- const provider = aliasAutocomplete(current, aliases);
31
- expect(await provider.getSuggestions(['/how why'], 0, 8, { signal })).toEqual({ items: builtin, prefix: '/how why' });
32
- expect(await provider.getSuggestions(['say /pot'], 0, 8, { signal })).toEqual(null);
33
- });
34
-
35
- test('applies an alias like any command completion', () => {
36
- const provider = aliasAutocomplete(current, aliases);
37
- expect(provider.applyCompletion(['/pot'], 0, 4, { value: 'poteto-mode', label: 'poteto-mode' }, '/pot')).toEqual({ lines: ['/poteto-mode '], cursorLine: 0, cursorCol: 4 });
38
- });
39
- });