supipowers 2.2.0 → 2.2.2

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/README.md +71 -12
  2. package/package.json +11 -15
  3. package/skills/ui-design/SKILL.md +2 -2
  4. package/src/ai/final-message.ts +15 -1
  5. package/src/ai/schema-text.ts +60 -40
  6. package/src/ai/schema-validation.ts +88 -0
  7. package/src/ai/structured-output.ts +19 -19
  8. package/src/bootstrap.ts +2 -1
  9. package/src/commands/doctor.ts +3 -2
  10. package/src/commands/fix-pr.ts +166 -26
  11. package/src/commands/plan.ts +2 -1
  12. package/src/commands/update.ts +7 -5
  13. package/src/config/schema.ts +102 -139
  14. package/src/docs/contracts.ts +13 -23
  15. package/src/fix-pr/assessment.ts +63 -24
  16. package/src/fix-pr/contracts.ts +15 -23
  17. package/src/fix-pr/fetch-comments.ts +119 -0
  18. package/src/fix-pr/prompt-builder.ts +19 -8
  19. package/src/git/commit-contract.ts +13 -19
  20. package/src/git/commit.ts +168 -6
  21. package/src/harness/anti_slop/fallow-adapter.ts +4 -3
  22. package/src/harness/command.ts +12 -7
  23. package/src/harness/pipeline.ts +2 -8
  24. package/src/harness/stage-runner.ts +3 -0
  25. package/src/harness/stages/docs.ts +82 -0
  26. package/src/lsp/capabilities.ts +9 -12
  27. package/src/lsp/contracts.ts +15 -23
  28. package/src/mempalace/uv.ts +15 -7
  29. package/src/planning/approval-flow.ts +15 -17
  30. package/src/planning/planning-ask-tool.ts +13 -2
  31. package/src/planning/spec.ts +21 -27
  32. package/src/planning/system-prompt.ts +1 -1
  33. package/src/planning/validate.ts +4 -7
  34. package/src/platform/progress.ts +11 -0
  35. package/src/quality/contracts.ts +15 -23
  36. package/src/quality/schemas.ts +40 -67
  37. package/src/release/contracts.ts +19 -28
  38. package/src/review/types.ts +142 -186
  39. package/src/types.ts +15 -2
  40. package/src/ui-design/session.ts +13 -2
  41. package/src/ui-design/system-prompt.ts +2 -2
  42. package/src/ultraplan/contracts.ts +458 -524
  43. package/src/utils/exec-cli.ts +106 -0
  44. package/src/visual/scripts/npm-shrinkwrap.json +878 -0
  45. package/src/visual/scripts/package-lock.json +878 -0
package/README.md CHANGED
@@ -72,15 +72,16 @@ The installer scans for these and offers to install missing tooling where it can
72
72
  | `/supi:config` | Interactive settings TUI |
73
73
  | `/supi:status` | Show project plans and configuration summary |
74
74
  | `/supi:doctor` | Diagnose extension health and missing dependencies |
75
- | `/supi:generate` | Documentation drift detection |
75
+ | `/supi:generate` | Documentation drift checks via `docs` (default); use `--target <package>` to scope |
76
76
  | `/supi:update` | Update supipowers to the latest version |
77
77
  | `/supi:agents` | Manage review agents |
78
78
  | `/supi:ultraplan` | Multi-stage authoring pipeline (intake → scout → discover → research → synthesize → review → approve) |
79
79
  | `/supi:harness` | Harness engineering pipeline and anti-slop guardrails |
80
80
  | `/supi:memory` | Manage native MemPalace memory integration (`status`, `setup`) |
81
+ | `/runbook` | Show registered OMP rules, TTSR conditions, and slash commands without an LLM turn |
81
82
  | `/supi:clear` | Clear metrics, cache, session knowledge, and memory |
82
83
 
83
- Most commands steer the AI session. These are TUI-only — they open native dialogs without triggering the AI: `/supi`, `/supi:config`, `/supi:status`, `/supi:review`, `/supi:update`, `/supi:doctor`, `/supi:model`, `/supi:context`, `/supi:optimize-context`, `/supi:commit`, `/supi:release`, `/supi:checks`, `/supi:agents`, `/supi:ultraplan`, `/supi:harness`, `/supi:memory`, `/supi:clear`.
84
+ Most commands steer the AI session. These are TUI-only — they open native dialogs without triggering the AI: `/supi`, `/supi:config`, `/supi:status`, `/supi:review`, `/supi:update`, `/supi:doctor`, `/supi:model`, `/supi:context`, `/supi:optimize-context`, `/supi:commit`, `/supi:release`, `/supi:checks`, `/supi:agents`, `/supi:ultraplan`, `/supi:harness`, `/supi:memory`, `/supi:clear`, `/runbook`.
84
85
 
85
86
  ## How it works
86
87
 
@@ -88,6 +89,8 @@ Most commands steer the AI session. These are TUI-only — they open native dial
88
89
 
89
90
  **Quality gates.** `/supi:checks` runs deterministic quality gates. Six gates are available: `lsp-diagnostics`, `lint`, `typecheck`, `format`, `test-suite`, and `build`. Each gate can be enabled independently via `/supi:config` or the shared repository config at `.omp/supipowers/config.json`. In monorepos, `/supi:checks` defaults to `All`, which runs the root target plus every workspace target sequentially; use `--target <package>` to narrow the run or `--target all` to request the batch mode explicitly. Gates report issues with severity levels.
90
91
 
92
+ **Documentation drift.** `/supi:generate docs` checks tracked documentation for drift from the current codebase. `docs` is the default subcommand, and `--target <package>` scopes discovery and checking to a workspace/package target; the root target covers repository-level docs.
93
+
91
94
  **AI code review.** `/supi:review` runs a programmatic AI review pipeline with configurable depth (quick, deep, or multi-agent). It uses headless agent sessions with structured JSON validation, always validates findings before user action, writes the current validated findings to a session `findings.md` document, and then presents three next-step choices: `Fix now`, `Document only`, or `Discuss before fixing`.
92
95
 
93
96
  **Review agents.** Multi-agent review loads agents from two scopes: global and project.
@@ -101,7 +104,7 @@ Most commands steer the AI session. These are TUI-only — they open native dial
101
104
 
102
105
  Use `/supi:agents` to inspect the merged set that will actually run.
103
106
 
104
- **PR fixing.** `/supi:fix-pr` fetches PR review comments, critically assesses each one, checks for ripple effects, then fixes or rejects with evidence. Bot reviewers are auto-detected and filtered out.
107
+ **PR fixing.** `/supi:fix-pr` fetches PR review comments, critically assesses each one, checks for ripple effects, then fixes or rejects with evidence. Known bot reviewers in the selected comment snapshot are auto-detected to configure re-review triggering; bot-authored comments are not filtered out solely because they are bots.
105
108
 
106
109
  **Context protection.** Supipowers always enables built-in context protection through native `ctx_*` tools and routing hooks. Search/find and web-fetch style operations are redirected to sandboxed execution or indexed storage, and oversized tool results are compressed before they reach the conversation.
107
110
 
@@ -131,17 +134,50 @@ Use `/supi:agents` to inspect the merged set that will actually run.
131
134
 
132
135
  `/supi:checks` runs deterministic quality gates. Each gate is independently configurable in `quality.gates` via `/supi:config` or the shared config JSON files:
133
136
 
134
- | Gate | What it checks | Config type |
135
- | ------------------ | ------------------------------- | ----------------- |
136
- | `lsp-diagnostics` | Language server diagnostics | enabled |
137
- | `lint` | Linter (e.g. `eslint`, `biome`) | enabled + command |
138
- | `typecheck` | Type checker (e.g. `tsc`) | enabled + command |
139
- | `format` | Formatter check | enabled + command |
140
- | `test-suite` | Test runner | enabled + command |
141
- | `build` | Build verification | enabled + command |
137
+ | Gate | What it checks | Config type |
138
+ | ------------------ | ------------------------------- | ------------------------------ |
139
+ | `lsp-diagnostics` | Language server diagnostics | `enabled` |
140
+ | `lint` | Linter (e.g. `eslint`, `biome`) | `enabled: true` + `runs[]` |
141
+ | `typecheck` | Type checker (e.g. `tsc`) | `enabled: true` + `runs[]` |
142
+ | `format` | Formatter check | `enabled: true` + `runs[]` |
143
+ | `test-suite` | Test runner | `enabled: true` + `runs[]` |
144
+ | `build` | Build verification | `enabled: true` + `runs[]` |
142
145
 
143
146
  Gates default to disabled. Enable them globally in `~/.omp/supipowers/config.json` or per-repository in `.omp/supipowers/config.json`. In monorepos, the repository config is shared by the root target and every workspace, and `/supi:checks` defaults to `All` (root target + every workspace target).
144
147
 
148
+ Enabled command gates require `runs: [{ command, target }]`. `target.scope` must be one of `all-targets`, `root`, `all-workspaces`, or `workspace`; `workspace` selectors also require `relativeDir`.
149
+
150
+ ```json
151
+ {
152
+ "quality": {
153
+ "gates": {
154
+ "typecheck": {
155
+ "enabled": true,
156
+ "runs": [
157
+ {
158
+ "command": "bun run typecheck",
159
+ "target": { "scope": "all-targets" }
160
+ }
161
+ ]
162
+ },
163
+ "test-suite": {
164
+ "enabled": true,
165
+ "runs": [
166
+ {
167
+ "command": "bun test",
168
+ "target": { "scope": "root" }
169
+ },
170
+ {
171
+ "command": "bun --cwd packages/api test",
172
+ "target": { "scope": "workspace", "relativeDir": "packages/api" }
173
+ }
174
+ ]
175
+ }
176
+ }
177
+ }
178
+ }
179
+ ```
180
+
145
181
  ## Configuration
146
182
 
147
183
  ```
@@ -215,9 +251,32 @@ Supipowers ships runtime-loaded prompt skills that are also available to the age
215
251
  | `receiving-code-review` | Agent sessions |
216
252
  | `release` | `/supi:release` |
217
253
  | `context-mode` | Context window guidance |
254
+ | `ultraplan-intake` | `/supi:ultraplan plan` intake stage |
255
+ | `ultraplan-scout` | `/supi:ultraplan plan` scout stage |
256
+ | `ultraplan-discover` | `/supi:ultraplan discover` |
257
+ | `ultraplan-research` | `/supi:ultraplan research` |
258
+ | `ultraplan-synthesize` | `/supi:ultraplan synthesize` |
259
+ | `ultraplan-review` | `/supi:ultraplan review` orchestration |
260
+ | `ultraplan-review-structure` | `/supi:ultraplan review` structure checker |
261
+ | `ultraplan-review-scope` | `/supi:ultraplan review` scope checker |
262
+ | `ultraplan-review-tdd` | `/supi:ultraplan review` TDD checker |
218
263
  | `creating-supi-agents` | Agent creation guidance |
219
264
  | `harness` | `/supi:harness` |
220
265
 
266
+ ## Containerized deployments
267
+
268
+ Supipowers runs unchanged inside containerized OMP installs (robomp slots, the swarm extension, CI runners). When the slot must stay credential-free, run a sidecar `omp auth-gateway` outside the container and pin the per-provider transport in `~/.omp/agent/models.yml`:
269
+
270
+ ```yaml
271
+ providers:
272
+ anthropic:
273
+ transport: pi-native
274
+ baseUrl: http://llm-gateway.internal:4000
275
+ apiKey: <gateway-bearer>
276
+ ```
277
+
278
+ The slot keeps resolving pricing, capabilities, and thinking config locally from its bundled `models.json`; only the streaming dispatch is redirected through the gateway, which holds the real provider tokens. Multi-host credential sync uses the matching `omp auth-broker` subcommand. Requires OMP ≥ 15.1.3.
279
+
221
280
  ## Development
222
281
 
223
282
  ```bash
@@ -229,4 +288,4 @@ bun run build # emit to dist/
229
288
 
230
289
  Tests live in `tests/`, mirroring `src/` one-to-one. The test runner is Bun's built-in `bun:test`.
231
290
 
232
- Peer dependencies (`@oh-my-pi/pi-coding-agent`, `@oh-my-pi/pi-ai`, `@oh-my-pi/pi-tui`, `@sinclair/typebox`) are provided by the OMP host; they are devDependencies only for type-checking during development.
291
+ Peer dependencies (`@oh-my-pi/pi-coding-agent`, `@oh-my-pi/pi-ai`, `@oh-my-pi/pi-tui`) are provided by the OMP host at runtime; matching devDependencies are installed for type-checking during development.
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "supipowers",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "description": "Workflow extension for OMP coding agents.",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "bun test --timeout 20000 tests/",
7
+ "test": "bun test --timeout 60000 --parallel tests/",
8
8
  "typecheck": "tsc --noEmit",
9
- "test:watch": "bun test --timeout 20000 --watch tests/",
10
- "test:evals": "bun test --timeout 20000 tests/evals/",
9
+ "test:watch": "bun test --timeout 60000 --parallel --watch tests/",
10
+ "test:evals": "bun test --timeout 60000 --parallel tests/evals/",
11
11
  "build": "tsc -p tsconfig.build.json",
12
12
  "ci": "bun run typecheck && bun run test",
13
13
  "install:visual-server": "npm --prefix src/visual/scripts ci --ignore-scripts --no-audit --no-fund",
@@ -16,7 +16,7 @@
16
16
  "dev-install": "bun run bin/dev-install.ts"
17
17
  },
18
18
  "engines": {
19
- "bun": ">=1.3.10"
19
+ "bun": ">=1.3.13"
20
20
  },
21
21
  "keywords": [
22
22
  "omp",
@@ -65,15 +65,15 @@
65
65
  ]
66
66
  },
67
67
  "dependencies": {
68
- "@clack/prompts": "^0.10.0",
68
+ "@clack/prompts": "^1.4.0",
69
69
  "handlebars": "^4.7.8",
70
- "yaml": "^2.8.3"
70
+ "yaml": "^2.8.3",
71
+ "zod": "^4.3.0"
71
72
  },
72
73
  "peerDependencies": {
73
74
  "@oh-my-pi/pi-coding-agent": "*",
74
75
  "@oh-my-pi/pi-ai": "*",
75
- "@oh-my-pi/pi-tui": "*",
76
- "@sinclair/typebox": "*"
76
+ "@oh-my-pi/pi-tui": "*"
77
77
  },
78
78
  "peerDependenciesMeta": {
79
79
  "@oh-my-pi/pi-coding-agent": {
@@ -84,18 +84,14 @@
84
84
  },
85
85
  "@oh-my-pi/pi-tui": {
86
86
  "optional": true
87
- },
88
- "@sinclair/typebox": {
89
- "optional": true
90
87
  }
91
88
  },
92
89
  "devDependencies": {
93
90
  "@oh-my-pi/pi-ai": "latest",
94
91
  "@oh-my-pi/pi-coding-agent": "latest",
95
92
  "@oh-my-pi/pi-tui": "latest",
96
- "@sinclair/typebox": "^0.34.48",
97
- "@types/node": "^22.0.0",
93
+ "@types/node": "^25.8.0",
98
94
  "bun-types": "^1.3.11",
99
- "typescript": "^5.9.3"
95
+ "typescript": "^6.0.3"
100
96
  }
101
97
  }
@@ -7,7 +7,7 @@ description: Design Director state machine for `/supi:ui-design`. Drives 9 model
7
7
 
8
8
  Guide the Design Director through 9 model-owned phases to produce a validated design artifact under `<sessionDir>`. Loaded by `/supi:ui-design` via system-prompt override.
9
9
 
10
- You **MUST NOT** generate production code, write outside the session directory, or skip phases. You **MUST NOT** call `exit_plan_mode`. Use `planning_ask` for every user question — never the raw `ask` tool.
10
+ You **MUST NOT** generate production code, write outside the session directory, or skip phases. You **MUST NOT** call `resolve` with `extra.title`. Use `planning_ask` for every user question — never the raw `ask` tool.
11
11
 
12
12
  ## Director state machine
13
13
 
@@ -56,7 +56,7 @@ Every sub-agent MUST be passed the full `context.md` so component authors share
56
56
  You MUST NOT:
57
57
  - Write outside `<sessionDir>`.
58
58
  - Generate production code (`.ts`, `.tsx`, `.vue`, `.svelte`, `.py`, etc.) intended for the user's codebase.
59
- - Call `exit_plan_mode` or `ExitPlanMode` — the `/supi:ui-design` completion flow runs through the `agent_end` approval hook.
59
+ - Call `resolve` with `extra.title` — the `/supi:ui-design` completion flow runs through the `agent_end` approval hook.
60
60
  - Use the `ask` tool — use `planning_ask` for every user prompt.
61
61
  - Skip a phase or declare "done" without updating `manifest.json`.
62
62
  - Invoke `task` without a completed filename-collision check (Phase 3).
@@ -56,6 +56,13 @@ function extractTextFromContent(content: unknown): string {
56
56
  return "";
57
57
  }
58
58
 
59
+ function createTimeoutPromise(timeoutMs: number): Promise<never> {
60
+ return new Promise((_, reject) => {
61
+ setTimeout(() => reject(new Error(`Agent session timed out after ${timeoutMs}ms.`)), timeoutMs);
62
+ });
63
+ }
64
+
65
+
59
66
  /**
60
67
  * Walk the message list backwards and return the last assistant message text.
61
68
  * Returns null when no assistant message contains usable text.
@@ -99,7 +106,14 @@ export async function runStructuredAgentSession(
99
106
  });
100
107
 
101
108
  try {
102
- await session.prompt(options.prompt, { expandPromptTemplates: false });
109
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
110
+ await Promise.race([
111
+ session.prompt(options.prompt, { expandPromptTemplates: false }),
112
+ createTimeoutPromise(options.timeoutMs),
113
+ ]);
114
+ } else {
115
+ await session.prompt(options.prompt, { expandPromptTemplates: false });
116
+ }
103
117
  const finalText = extractFinalAssistantText(session.state.messages);
104
118
 
105
119
  if (!finalText) {
@@ -1,7 +1,7 @@
1
1
  // src/ai/schema-text.ts
2
2
  //
3
- // Render a TypeBox schema as compact TS-like text suitable for embedding in
4
- // prompts. One canonical rendering means that adding a field to a TypeBox
3
+ // Render a Zod schema as compact TS-like text suitable for embedding in
4
+ // prompts. One canonical rendering means that adding a field to a Zod
5
5
  // contract automatically updates every prompt that references it through
6
6
  // this module — no hand-maintained schema prose to drift.
7
7
  //
@@ -10,17 +10,24 @@
10
10
  // ReviewOutputSchema / ReviewFixOutputSchema for both the main prompt
11
11
  // and the retry prompt produced by runWithOutputValidation.
12
12
  //
13
+ // Implementation note:
14
+ // We don't walk Zod's internal `_zod.def` tree directly. Zod's JSON Schema
15
+ // accessor (`z.toJSONSchema`) already emits draft-2020-12 output for every
16
+ // shape we use; we walk that intermediate JSON Schema instead, which keeps
17
+ // this module independent of Zod's internal AST changes.
18
+ //
13
19
  // Non-goals:
14
- // - Produce standards-compliant JSON Schema output. Use TypeBox's own
15
- // JSON Schema accessors for that. This renderer optimises for model
16
- // readability, not spec compliance.
17
- // - Capture every TypeBox modifier. Supported shapes cover the current
18
- // contract surface; extend when a real consumer needs more.
20
+ // - Produce standards-compliant JSON Schema output. Call `z.toJSONSchema`
21
+ // directly for that. This renderer optimises for model readability.
22
+ // - Capture every modifier. Supported shapes cover the current contract
23
+ // surface; extend when a real consumer needs more.
19
24
 
20
- import type { TSchema } from "@sinclair/typebox";
25
+ import { z, type ZodType } from "zod/v4";
21
26
 
22
27
  const INDENT = " ";
23
28
 
29
+ type JsonSchemaNode = Record<string, unknown>;
30
+
24
31
  export interface RenderSchemaOptions {
25
32
  /** Start indent (internal recursion use). */
26
33
  depth?: number;
@@ -36,33 +43,37 @@ function renderLiteral(value: unknown): string {
36
43
  return String(value);
37
44
  }
38
45
 
39
- function renderUnion(parts: readonly TSchema[], depth: number): string {
46
+ function renderUnion(parts: readonly JsonSchemaNode[], depth: number): string {
40
47
  if (parts.length === 0) return "never";
41
- return parts.map((p) => renderSchemaText(p, { depth })).join(" | ");
48
+ return parts.map((p) => renderJsonSchema(p, depth)).join(" | ");
42
49
  }
43
50
 
44
- function renderObject(schema: any, depth: number): string {
45
- const props = schema.properties as Record<string, TSchema> | undefined;
51
+ function renderObject(schema: JsonSchemaNode, depth: number): string {
52
+ const props = schema.properties as Record<string, JsonSchemaNode> | undefined;
46
53
  if (!props || Object.keys(props).length === 0) {
47
54
  return "{}";
48
55
  }
49
56
 
50
- const required: string[] = Array.isArray(schema.required) ? schema.required : [];
57
+ const required: string[] = Array.isArray(schema.required)
58
+ ? (schema.required as string[])
59
+ : [];
51
60
  const lines: string[] = ["{"];
52
61
  const childDepth = depth + 1;
53
62
 
54
63
  for (const [key, child] of Object.entries(props)) {
55
64
  const isRequired = required.includes(key);
56
65
  const separator = isRequired ? ":" : "?:";
57
- lines.push(`${indent(childDepth)}${key}${separator} ${renderSchemaText(child, { depth: childDepth })};`);
66
+ lines.push(`${indent(childDepth)}${key}${separator} ${renderJsonSchema(child, childDepth)};`);
58
67
  }
59
68
 
60
69
  lines.push(`${indent(depth)}}`);
61
70
  return lines.join("\n");
62
71
  }
63
72
 
64
- function renderArray(schema: any, depth: number): string {
65
- const inner = renderSchemaText(schema.items as TSchema, { depth });
73
+ function renderArray(schema: JsonSchemaNode, depth: number): string {
74
+ const items = schema.items as JsonSchemaNode | undefined;
75
+ if (!items) return "unknown[]";
76
+ const inner = renderJsonSchema(items, depth);
66
77
  // Wrap multiline object types as Array<...> for readability.
67
78
  if (inner.includes("\n")) {
68
79
  return `Array<${inner}>`;
@@ -70,44 +81,36 @@ function renderArray(schema: any, depth: number): string {
70
81
  return `${inner}[]`;
71
82
  }
72
83
 
73
- function hasKey(schema: any, key: string): boolean {
74
- return schema != null && typeof schema === "object" && key in schema;
84
+ function isZodSchema(value: unknown): value is ZodType {
85
+ return value !== null && typeof value === "object" && "_zod" in (value as Record<string, unknown>);
75
86
  }
76
87
 
77
- /**
78
- * Render a TypeBox schema as a compact TS-like type string. Safe to pass as
79
- * the `schema:` param to `runWithOutputValidation` and the `{{outputSchema}}`
80
- * placeholder inside review prompts.
81
- */
82
- export function renderSchemaText(schema: TSchema, options: RenderSchemaOptions = {}): string {
83
- const depth = options.depth ?? 0;
84
- const any = schema as any;
85
-
88
+ function renderJsonSchema(schema: JsonSchemaNode, depth: number): string {
86
89
  // Literal / const
87
- if (hasKey(any, "const")) {
88
- return renderLiteral(any.const);
90
+ if ("const" in schema) {
91
+ return renderLiteral(schema.const);
89
92
  }
90
93
 
91
94
  // Explicit enum
92
- if (Array.isArray(any.enum)) {
93
- return any.enum.map(renderLiteral).join(" | ");
95
+ if (Array.isArray(schema.enum)) {
96
+ return schema.enum.map(renderLiteral).join(" | ");
94
97
  }
95
98
 
96
99
  // Union (anyOf / oneOf)
97
- if (Array.isArray(any.anyOf)) {
98
- return renderUnion(any.anyOf, depth);
100
+ if (Array.isArray(schema.anyOf)) {
101
+ return renderUnion(schema.anyOf as JsonSchemaNode[], depth);
99
102
  }
100
- if (Array.isArray(any.oneOf)) {
101
- return renderUnion(any.oneOf, depth);
103
+ if (Array.isArray(schema.oneOf)) {
104
+ return renderUnion(schema.oneOf as JsonSchemaNode[], depth);
102
105
  }
103
106
 
104
107
  // Primitive / structural by `type`
105
- const type = any.type as string | undefined;
108
+ const type = typeof schema.type === "string" ? schema.type : undefined;
106
109
  switch (type) {
107
110
  case "object":
108
- return renderObject(any, depth);
111
+ return renderObject(schema, depth);
109
112
  case "array":
110
- return renderArray(any, depth);
113
+ return renderArray(schema, depth);
111
114
  case "string":
112
115
  return "string";
113
116
  case "integer":
@@ -119,11 +122,28 @@ export function renderSchemaText(schema: TSchema, options: RenderSchemaOptions =
119
122
  case "null":
120
123
  return "null";
121
124
  default:
122
- // Fall through — unknown shape
123
125
  break;
124
126
  }
125
127
 
126
128
  // Nothing matched — render as `unknown` rather than throwing so prompts
127
- // still get something readable if someone adds an exotic schema.
129
+ // still get something readable if someone adds an exotic shape.
128
130
  return "unknown";
129
131
  }
132
+
133
+ /**
134
+ * Render a Zod schema as a compact TS-like type string. Safe to pass as
135
+ * the `schema:` param to `runWithOutputValidation` and the `{{outputSchema}}`
136
+ * placeholder inside review prompts.
137
+ *
138
+ * The OMP runtime injects a Zod-backed shim for any extension that still
139
+ * imports `@sinclair/typebox`, so even legacy callers reach this function
140
+ * with a Zod schema. Non-Zod inputs (legitimate JSON Schema literals) are
141
+ * walked as-is.
142
+ */
143
+ export function renderSchemaText(schema: ZodType | JsonSchemaNode, options: RenderSchemaOptions = {}): string {
144
+ const depth = options.depth ?? 0;
145
+ const jsonSchema = isZodSchema(schema)
146
+ ? (z.toJSONSchema(schema, { target: "draft-2020-12" }) as JsonSchemaNode)
147
+ : schema;
148
+ return renderJsonSchema(jsonSchema, depth);
149
+ }
@@ -0,0 +1,88 @@
1
+ // src/ai/schema-validation.ts
2
+ //
3
+ // Thin façade over Zod's `safeParse` that produces a flat `ValidationError[]`
4
+ // shape compatible with the rest of supipowers (`parseStructuredOutput`,
5
+ // `getUltraPlanSchemaErrors`, every gate prompt that formats validation
6
+ // failures for retry).
7
+ //
8
+ // All contracts in supipowers are authored as Zod schemas (`zod/v4`). The
9
+ // helpers here intentionally accept the structural interface — anything with
10
+ // a working `safeParse` — so they keep working under the OMP TypeBox-shim
11
+ // (extension load time) without needing a separate code path.
12
+
13
+ import type { ZodType } from "zod/v4";
14
+ import type { ValidationError } from "../types.js";
15
+
16
+ interface ZodIssueLike {
17
+ path: ReadonlyArray<string | number | symbol>;
18
+ message: string;
19
+ code?: string;
20
+ expected?: unknown;
21
+ received?: unknown;
22
+ /** Present on Zod 4 `unrecognized_keys` issues. */
23
+ keys?: ReadonlyArray<string>;
24
+ }
25
+
26
+ function pathToString(path: ReadonlyArray<string | number | symbol>): string {
27
+ // Zod 4 path segments are `(string | number | symbol)[]`; symbols only
28
+ // appear for schemas with symbol keys (not used in supipowers). Drop them
29
+ // so the printed path stays readable.
30
+ const stringy = path.filter((segment): segment is string | number => typeof segment !== "symbol");
31
+ return stringy.length === 0 ? "(root)" : stringy.map(String).join(".");
32
+ }
33
+
34
+ function expandIssue(issue: ZodIssueLike): ValidationError[] {
35
+ // Zod 4 reports unrecognized strict-object keys with the offending keys in
36
+ // `issue.keys` and the path stopped at the parent object. Expand each key
37
+ // into its own ValidationError with the key appended to the path so
38
+ // formatted error strings (`<path>: <message>`) still identify the exact
39
+ // field the model produced wrongly. This matches the prompt-driven
40
+ // self-correction loop in `parseStructuredOutput`, which needs to tell
41
+ // the model which key to drop.
42
+ if (issue.code === "unrecognized_keys" && Array.isArray(issue.keys) && issue.keys.length > 0) {
43
+ return issue.keys.map((key) => ({
44
+ path: pathToString([...issue.path, key]),
45
+ message: issue.message,
46
+ ...(issue.code ? { code: issue.code } : {}),
47
+ ...(issue.expected !== undefined ? { expected: issue.expected } : {}),
48
+ ...(issue.received !== undefined ? { received: issue.received } : {}),
49
+ }));
50
+ }
51
+
52
+ return [{
53
+ path: pathToString(issue.path),
54
+ message: issue.message,
55
+ ...(issue.code ? { code: issue.code } : {}),
56
+ ...(issue.expected !== undefined ? { expected: issue.expected } : {}),
57
+ ...(issue.received !== undefined ? { received: issue.received } : {}),
58
+ }];
59
+ }
60
+
61
+ /**
62
+ * Validate `data` against `schema`. Returns an empty array on success and
63
+ * a stable `{path, message, ...}` shape on failure. Callers format the
64
+ * result for prompts/CLI/UI without further normalisation.
65
+ */
66
+ export function collectSchemaValidationErrors(schema: ZodType, data: unknown): ValidationError[] {
67
+ const result = schema.safeParse(data);
68
+ if (result.success) return [];
69
+ return result.error.issues.flatMap((issue) => expandIssue(issue as ZodIssueLike));
70
+ }
71
+
72
+ /** Convenience wrapper. Equivalent to `collectSchemaValidationErrors(...).length === 0`. */
73
+ export function checkSchema(schema: ZodType, data: unknown): boolean {
74
+ return schema.safeParse(data).success;
75
+ }
76
+
77
+ /**
78
+ * Parse `data` against `schema`. On success returns the schema-validated
79
+ * (and Zod-transformed) value; on failure returns the flattened error list.
80
+ */
81
+ export function parseSchema<T>(schema: ZodType<T>, data: unknown): { success: true; data: T } | { success: false; errors: ValidationError[] } {
82
+ const result = schema.safeParse(data);
83
+ if (result.success) return { success: true, data: result.data };
84
+ return {
85
+ success: false,
86
+ errors: result.error.issues.flatMap((issue) => expandIssue(issue as ZodIssueLike)),
87
+ };
88
+ }
@@ -12,8 +12,7 @@
12
12
  // One canonical renderer lives in `./template.ts`. Neither has a review-
13
13
  // specific name any more.
14
14
 
15
- import type { TSchema } from "@sinclair/typebox";
16
- import { Value } from "@sinclair/typebox/value";
15
+ import type { ZodType } from "zod/v4";
17
16
  import invalidOutputRetryPrompt from "./prompts/invalid-output-retry.md" with { type: "text" };
18
17
  import { runStructuredAgentSession } from "./final-message.js";
19
18
  import { renderTemplate } from "./template.js";
@@ -21,6 +20,7 @@ import { stripMarkdownCodeFence } from "../text.js";
21
20
  import type { GateExecutionContext, ReliabilityOutcome, ValidationError } from "../types.js";
22
21
  import type { PlatformPaths } from "../platform/types.js";
23
22
  import { appendReliabilityRecord } from "../storage/reliability-metrics.js";
23
+ import { collectSchemaValidationErrors, parseSchema } from "./schema-validation.js";
24
24
 
25
25
  export interface StructuredParseResult<T> {
26
26
  output: T | null;
@@ -94,35 +94,34 @@ function truncateForPrompt(text: string, maxLength = 1200): string {
94
94
  return `${normalized.slice(0, maxLength - 1)}…`;
95
95
  }
96
96
 
97
- function normalizeErrorPath(path: string): string {
98
- return path.replace(/^\//, "").replace(/\//g, ".") || "(root)";
99
- }
97
+
100
98
 
101
99
  /**
102
- * Collect schema validation errors for a TypeBox schema in a stable
103
- * {path, message} shape. Used by parseStructuredOutput and by any code that
104
- * needs to format schema-check failures for humans or prompts.
100
+ * Collect schema validation errors in a stable {path, message} shape. Used by
101
+ * parseStructuredOutput and by any code that needs to format schema-check
102
+ * failures for humans or prompts.
105
103
  */
106
- export function collectValidationErrors(schema: TSchema, data: unknown): ValidationError[] {
107
- return [...Value.Errors(schema, data)].map((error) => ({
108
- path: normalizeErrorPath(error.path),
109
- message: error.message,
110
- }));
104
+ export function collectValidationErrors(schema: ZodType, data: unknown): ValidationError[] {
105
+ return collectSchemaValidationErrors(schema, data);
111
106
  }
112
107
 
113
108
  /**
114
109
  * Render validation errors as `path: message` lines.
115
110
  */
116
111
  export function formatValidationErrors(errors: ValidationError[]): string[] {
117
- return errors.map((error) => `${error.path}: ${error.message}`);
112
+ return errors.map((error) => {
113
+ const code = error.code ? ` [${error.code}]` : "";
114
+ const expected = error.expected !== undefined ? ` Expected: ${JSON.stringify(error.expected)}.` : "";
115
+ return `${error.path}${code}: ${error.message}${expected}`;
116
+ });
118
117
  }
119
118
 
120
119
  /**
121
- * Strip markdown fences, JSON-parse, and schema-check against a TypeBox.
120
+ * Strip markdown fences, JSON-parse, and schema-check.
122
121
  * Returns {output: T, error: null} on success; {output: null, error: string}
123
122
  * on failure with a human-readable error suitable for retry prompts.
124
123
  */
125
- export function parseStructuredOutput<T>(raw: string, schema: TSchema): StructuredParseResult<T> {
124
+ export function parseStructuredOutput<T>(raw: string, schema: ZodType<T>): StructuredParseResult<T> {
126
125
  let parsed: unknown;
127
126
 
128
127
  try {
@@ -134,8 +133,9 @@ export function parseStructuredOutput<T>(raw: string, schema: TSchema): Structur
134
133
  };
135
134
  }
136
135
 
137
- if (!Value.Check(schema, parsed)) {
138
- const errors = formatValidationErrors(collectValidationErrors(schema, parsed));
136
+ const result = parseSchema<T>(schema, parsed);
137
+ if (!result.success) {
138
+ const errors = formatValidationErrors(result.errors);
139
139
  return {
140
140
  output: null,
141
141
  error: errors.length > 0 ? errors.join("; ") : "Output does not match the required schema.",
@@ -143,7 +143,7 @@ export function parseStructuredOutput<T>(raw: string, schema: TSchema): Structur
143
143
  }
144
144
 
145
145
  return {
146
- output: parsed as T,
146
+ output: result.data,
147
147
  error: null,
148
148
  };
149
149
  }
package/src/bootstrap.ts CHANGED
@@ -12,6 +12,7 @@ import { registerAiReviewCommand, handleAiReview } from "./commands/ai-review.js
12
12
  import { registerQaCommand } from "./commands/qa.js";
13
13
  import { registerReleaseCommand, handleRelease } from "./commands/release.js";
14
14
  import { registerUpdateCommand, handleUpdate } from "./commands/update.js";
15
+ import { execCli } from "./utils/exec-cli.js";
15
16
  import { registerDoctorCommand, handleDoctor } from "./commands/doctor.js";
16
17
  import { registerModelCommand, handleModel } from "./commands/model.js";
17
18
  import { registerFixPrCommand } from "./commands/fix-pr.js";
@@ -175,7 +176,7 @@ export function bootstrap(platform: Platform): void {
175
176
  const currentVersion = getInstalledVersion(platform);
176
177
  if (!currentVersion) return;
177
178
 
178
- platform.exec("npm", ["view", "supipowers", "version"], { cwd: tmpdir() })
179
+ execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["view", "supipowers", "version"], { cwd: tmpdir() })
179
180
  .then((result) => {
180
181
  if (result.code !== 0) return;
181
182
  const latest = result.stdout.trim();
@@ -11,6 +11,7 @@ import { formatReliabilitySection, loadReliabilitySummaries } from "../storage/r
11
11
  import { getMetricsStore, getSessionId } from "../context-mode/hooks.js";
12
12
  import { getProjectStatePath, getProjectStateDir } from "../workspace/state-paths.js";
13
13
  import { basename } from "node:path";
14
+ import { execCli } from "../utils/exec-cli.js";
14
15
 
15
16
  export interface CheckResult {
16
17
  name: string;
@@ -435,14 +436,14 @@ export function checkMetrics(
435
436
 
436
437
  export async function checkNpm(platform: Platform): Promise<CheckResult> {
437
438
  try {
438
- const vResult = await platform.exec("npm", ["--version"]);
439
+ const vResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["--version"]);
439
440
  if (vResult.code !== 0) {
440
441
  return { name: "npm", presence: { ok: false, detail: "npm not found" } };
441
442
  }
442
443
  const version = vResult.stdout.trim();
443
444
  const presence = { ok: true, detail: `v${version}` };
444
445
 
445
- const pingResult = await platform.exec("npm", ["ping"]);
446
+ const pingResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["ping"]);
446
447
  if (pingResult.code === 0) {
447
448
  return { name: "npm", presence, functional: { ok: true, detail: "Registry reachable" } };
448
449
  }