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
@@ -177,26 +177,37 @@ export function buildFixPrOrchestratorPrompt(options: FixPrPromptOptions): strin
177
177
  sections.push(buildReplyInstructions(config), "");
178
178
 
179
179
  sections.push(
180
- "## Step 6: Push and Check for New Comments",
180
+ "## Step 6: Push, Monitor CI, and Check for New Comments",
181
181
  "",
182
- `1. Stage and commit: \`git add -A && git commit -m \"fix: address PR review comments (iteration ${iteration})\"\``,
182
+ `1. Stage and commit: \`git add -A && git commit -m "fix: address PR review comments (iteration ${iteration})"\``,
183
183
  "2. Push: `git push`",
184
184
  );
185
185
 
186
186
  if (reviewer.type !== "none" && reviewer.triggerMethod) {
187
187
  sections.push(
188
- `3. Trigger re-review: \`bun \"${scriptsDir}/trigger-review.ts\" \"${repo}\" ${prNumber} \"${reviewer.type}\" \"${reviewer.triggerMethod}\"\``,
188
+ `3. Trigger re-review: \`bun "${scriptsDir}/trigger-review.ts" "${repo}" ${prNumber} "${reviewer.type}" "${reviewer.triggerMethod}"\``,
189
+ `4. While the reviewer runs, start the green pipeline: invoke OMP \`/green\` if available; otherwise run \`gh pr checks ${prNumber} --repo ${repo} --watch\`.`,
190
+ " - If CI turns red, stop waiting for review comments and focus on CI.",
191
+ " - Diagnose the failed check from its logs, fix the root cause, push again, then restart green monitoring.",
192
+ "5. Run the wait-and-check runner:",
193
+ );
194
+ } else {
195
+ sections.push(
196
+ `3. Start the green pipeline: invoke OMP \`/green\` if available; otherwise run \`gh pr checks ${prNumber} --repo ${repo} --watch\`.`,
197
+ " - If CI turns red, focus on CI before considering the PR complete.",
198
+ " - Diagnose the failed check from its logs, fix the root cause, push again, then restart green monitoring.",
199
+ "4. Run the wait-and-check runner:",
189
200
  );
190
201
  }
191
202
 
192
203
  sections.push(
193
- `${reviewer.type !== "none" ? "4" : "3"}. Run the wait-and-check runner:`,
194
204
  "```text",
195
- `bun \"${scriptsDir}/wait-and-check.ts\" \"${sessionDir}\" ${delay} ${iteration + 1} \"${repo}\" ${prNumber}`,
205
+ `bun "${scriptsDir}/wait-and-check.ts" "${sessionDir}" ${delay} ${iteration + 1} "${repo}" ${prNumber}`,
196
206
  "```",
197
- `${reviewer.type !== "none" ? "5" : "4"}. Read the last line of output:`,
198
- ` - If \`hasNewComments: true\` and iteration < ${maxIter}: process the new comments (go back to Step 1)`,
199
- ` - If \`hasNewComments: false\` or iteration >= ${maxIter}: report done`,
207
+ `${reviewer.type !== "none" ? "6" : "5"}. Read the last line of output:`,
208
+ ` - If \`hasNewComments: true\` and iteration < ${maxIter}: process the new comments (go back to Step 1), then repeat push, green monitoring, and final validation`,
209
+ ` - If \`hasNewComments: false\` or iteration >= ${maxIter}: continue only after CI is green`,
210
+ `${reviewer.type !== "none" ? "7" : "6"}. Full validation is mandatory at the end: run \`bun ci\` locally after comments are handled and CI is green. Do not report done until both remote CI and local full validation are green.`,
200
211
  "",
201
212
  );
202
213
 
@@ -6,30 +6,24 @@
6
6
  // (every staged file appears in exactly one commit) is a runtime rule that
7
7
  // can't live in the schema — see validateCommitPlanCoverage.
8
8
 
9
- import { type Static, Type } from "@sinclair/typebox";
9
+ import { z } from "zod/v4"
10
10
  import { VALID_COMMIT_TYPES } from "../release/commit-types.js";
11
11
  import type { ValidationError } from "../types.js";
12
12
 
13
- export const CommitGroupSchema = Type.Object(
14
- {
15
- type: Type.Union(VALID_COMMIT_TYPES.map((value) => Type.Literal(value))),
16
- scope: Type.Union([Type.String(), Type.Null()]),
17
- summary: Type.String({ minLength: 1 }),
18
- details: Type.Array(Type.String()),
19
- files: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
20
- },
21
- { additionalProperties: false },
22
- );
13
+ export const CommitGroupSchema = z.object({
14
+ type: z.enum(VALID_COMMIT_TYPES),
15
+ scope: z.string().nullable(),
16
+ summary: z.string().min(1),
17
+ details: z.array(z.string()),
18
+ files: z.array(z.string().min(1)).min(1),
19
+ }).strict();
23
20
 
24
- export const CommitPlanSchema = Type.Object(
25
- {
26
- commits: Type.Array(CommitGroupSchema, { minItems: 1 }),
27
- },
28
- { additionalProperties: false },
29
- );
21
+ export const CommitPlanSchema = z.object({
22
+ commits: z.array(CommitGroupSchema).min(1),
23
+ }).strict();
30
24
 
31
- export type CommitGroup = Static<typeof CommitGroupSchema>;
32
- export type CommitPlan = Static<typeof CommitPlanSchema>;
25
+ export type CommitGroup = z.infer<typeof CommitGroupSchema>;
26
+ export type CommitPlan = z.infer<typeof CommitPlanSchema>;
33
27
 
34
28
  /**
35
29
  * Verify every staged file appears in exactly one commit and that no commit
package/src/git/commit.ts CHANGED
@@ -103,6 +103,8 @@ const COMMIT_STEPS = [
103
103
  { key: "ai-analysis", label: "AI analysis" },
104
104
  { key: "review-plan", label: "Review plan" },
105
105
  { key: "execute-commits", label: "Execute commits" },
106
+ { key: "push-commits", label: "Push commits" },
107
+ { key: "open-pr", label: "Open pull request" },
106
108
  ] as const;
107
109
 
108
110
  function createProgress(ctx: any) {
@@ -127,6 +129,9 @@ function createProgress(ctx: any) {
127
129
  skip(stepIndex: number, detail?: string) {
128
130
  progress.skip(COMMIT_STEPS[stepIndex]!.key, detail);
129
131
  },
132
+ fail(stepIndex: number, detail?: string) {
133
+ progress.fail(COMMIT_STEPS[stepIndex]!.key, detail);
134
+ },
130
135
  dispose() {
131
136
  progress.dispose();
132
137
  },
@@ -340,14 +345,11 @@ export async function analyzeAndCommit(
340
345
  }
341
346
 
342
347
  if (!plan) {
343
- // Skip remaining tracked steps for the manual path
344
348
  progress.skip(5, "manual");
345
- progress.skip(6, "manual");
346
- progress.dispose();
347
349
  const reason = !platform.capabilities.agentSessions
348
350
  ? "no agent sessions"
349
351
  : agentReason;
350
- return manualFallback(platform, ctx, cwd, fileList, platform.paths, agentAttempts, reason);
352
+ return manualFallback(platform, ctx, cwd, fileList, platform.paths, agentAttempts, progress, reason);
351
353
  }
352
354
 
353
355
  // 6. Present plan for approval
@@ -444,6 +446,7 @@ async function manualFallback(
444
446
  fileList: string[],
445
447
  paths: PlatformPaths,
446
448
  attempts: number,
449
+ progress: ReturnType<typeof createProgress>,
447
450
  reason?: string,
448
451
  ): Promise<CommitResult | null> {
449
452
  const exec = platform.exec.bind(platform);
@@ -467,27 +470,186 @@ async function manualFallback(
467
470
  ? `${reason} \u2014 enter a commit message manually`
468
471
  : "Enter a commit message manually",
469
472
  );
473
+ progress.activate(6, "manual");
470
474
 
471
475
  const message = await ctx.ui.input("Commit message (empty to abort)", {
472
476
  helpText: `${fileList.length} file(s) staged`,
473
477
  });
474
-
475
478
  if (!message?.trim()) {
476
479
  notifyInfo(ctx, "Commit cancelled", "No message provided");
480
+ progress.skip(6, "aborted");
477
481
  return null;
478
482
  }
479
483
 
480
484
  const commitResult = await commitStaged(exec, cwd, message);
481
485
  if (!commitResult.success) {
482
486
  notifyError(ctx, "Commit failed", commitResult.error);
487
+ progress.fail(6, "failed");
483
488
  return null;
484
489
  }
485
490
 
486
491
  notifySuccess(ctx, "Committed", message.split("\n")[0]);
492
+ progress.complete(6, "1 done");
493
+ await offerPostCommitActions(platform, ctx, cwd, progress);
487
494
  return { committed: 1, messages: [message] };
488
495
  }
489
496
 
490
497
 
498
+ const PUSH_NO_OPTION = "No — keep commits local";
499
+ const PR_NO_OPTION = "No — leave branch without a PR";
500
+ const PR_YES_OPTION = "Yes — open a Pull Request";
501
+
502
+ function pushYesOption(branch: string): string {
503
+ return `Yes — push to origin/${branch}`;
504
+ }
505
+
506
+ function isDefaultBranchName(branch: string): boolean {
507
+ return branch === "main" || branch === "master";
508
+ }
509
+
510
+ function formatCommandFailure(result: { stdout: string; stderr: string; code: number }): string {
511
+ return result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
512
+ }
513
+
514
+ async function readCurrentBranch(
515
+ exec: ExecFn,
516
+ ctx: any,
517
+ cwd: string,
518
+ ): Promise<string | null> {
519
+ const result = await exec("git", ["branch", "--show-current"], { cwd });
520
+ if (result.code !== 0) {
521
+ notifyError(ctx, "Could not determine current branch", formatCommandFailure(result));
522
+ return null;
523
+ }
524
+
525
+ return result.stdout.trim() || null;
526
+ }
527
+
528
+ async function pushCurrentBranch(
529
+ exec: ExecFn,
530
+ ctx: any,
531
+ cwd: string,
532
+ branch: string,
533
+ ): Promise<boolean> {
534
+ const result = await exec("git", ["push", "-u", "origin", branch], { cwd });
535
+ if (result.code !== 0) {
536
+ notifyError(ctx, "Push failed", formatCommandFailure(result));
537
+ return false;
538
+ }
539
+
540
+ notifySuccess(ctx, "Pushed", `origin/${branch}`);
541
+ return true;
542
+ }
543
+
544
+ async function createPullRequest(
545
+ exec: ExecFn,
546
+ ctx: any,
547
+ cwd: string,
548
+ branch: string,
549
+ ): Promise<boolean> {
550
+ const result = await exec("gh", ["pr", "create", "--fill", "--head", branch], { cwd });
551
+ if (result.code !== 0) {
552
+ notifyError(ctx, "Pull request failed", formatCommandFailure(result));
553
+ return false;
554
+ }
555
+
556
+ const detail = result.stdout.trim() || result.stderr.trim() || `Branch: ${branch}`;
557
+ notifySuccess(ctx, "Pull request opened", detail);
558
+ return true;
559
+ }
560
+
561
+ async function offerPostCommitActions(
562
+ platform: Platform,
563
+ ctx: any,
564
+ cwd: string,
565
+ progress: ReturnType<typeof createProgress>,
566
+ ): Promise<void> {
567
+ try {
568
+ const exec = platform.exec.bind(platform);
569
+ progress.activate(7, "detect branch");
570
+ const branch = await readCurrentBranch(exec, ctx, cwd);
571
+ if (!branch) {
572
+ progress.skip(7, "no branch");
573
+ progress.skip(8, "no branch");
574
+ return;
575
+ }
576
+
577
+ const yesPush = pushYesOption(branch);
578
+ progress.activate(7, "prompt");
579
+ const pushSelection = await ctx.ui.select("Push commits?", [
580
+ PUSH_NO_OPTION,
581
+ yesPush,
582
+ ], {
583
+ helpText: `Current branch: ${branch}`,
584
+ });
585
+
586
+ if (!pushSelection) {
587
+ progress.skip(7, "cancelled");
588
+ progress.skip(8, "cancelled");
589
+ return;
590
+ }
591
+
592
+ let pushed = false;
593
+ if (pushSelection === yesPush) {
594
+ progress.activate(7, `origin/${branch}`);
595
+ pushed = await pushCurrentBranch(exec, ctx, cwd, branch);
596
+ if (!pushed) {
597
+ progress.fail(7, "failed");
598
+ progress.skip(8, "push failed");
599
+ return;
600
+ }
601
+ progress.complete(7, `origin/${branch}`);
602
+ } else {
603
+ progress.skip(7, "kept local");
604
+ }
605
+
606
+ if (isDefaultBranchName(branch)) {
607
+ progress.skip(8, "default branch");
608
+ return;
609
+ }
610
+
611
+ progress.activate(8, "prompt");
612
+ const prSelection = await ctx.ui.select("Open a Pull Request?", [
613
+ PR_NO_OPTION,
614
+ PR_YES_OPTION,
615
+ ], {
616
+ helpText: pushed
617
+ ? `Branch: ${branch}`
618
+ : `Opening a PR will first push origin/${branch}.`,
619
+ });
620
+ if (!prSelection) {
621
+ progress.skip(8, "cancelled");
622
+ return;
623
+ }
624
+ if (prSelection !== PR_YES_OPTION) {
625
+ progress.skip(8, "not requested");
626
+ return;
627
+ }
628
+
629
+ if (!pushed) {
630
+ progress.activate(7, `origin/${branch}`);
631
+ pushed = await pushCurrentBranch(exec, ctx, cwd, branch);
632
+ if (!pushed) {
633
+ progress.fail(7, "failed");
634
+ progress.skip(8, "push failed");
635
+ return;
636
+ }
637
+ progress.complete(7, `origin/${branch}`);
638
+ }
639
+
640
+ progress.activate(8, "gh pr create");
641
+ if (!await createPullRequest(exec, ctx, cwd, branch)) {
642
+ progress.fail(8, "failed");
643
+ return;
644
+ }
645
+ progress.complete(8, "created");
646
+ } catch (err) {
647
+ const message = err instanceof Error ? err.message : String(err);
648
+ progress.fail(8, "error");
649
+ notifyError(ctx, "Post-commit action failed", message);
650
+ }
651
+ }
652
+
491
653
 
492
654
  // ── Commit execution ───────────────────────────────────────
493
655
 
@@ -548,12 +710,12 @@ async function executeCommitPlan(
548
710
  await exec("git", ["read-tree", savedTree], { cwd });
549
711
 
550
712
  progress.complete(6, `${committedMessages.length} done`);
551
- progress.dispose();
552
713
  notifySuccess(
553
714
  ctx,
554
715
  `${committedMessages.length} commit(s) created`,
555
716
  committedMessages.map((m) => m.split("\n")[0]).join(" | "),
556
717
  );
718
+ await offerPostCommitActions(platform, ctx, cwd, progress);
557
719
 
558
720
  return { committed: committedMessages.length, messages: committedMessages };
559
721
  }
@@ -25,6 +25,7 @@ import {
25
25
  type SlopBackendResult,
26
26
  type SlopFinding,
27
27
  } from "./backend.js";
28
+ import { execCli } from "../../utils/exec-cli.js";
28
29
 
29
30
  const DEFAULT_TIMEOUT_MS = 60_000;
30
31
 
@@ -149,7 +150,7 @@ async function resolveInvocation(
149
150
  }
150
151
 
151
152
  try {
152
- const probe = await platform.exec("npx", ["--no-install", "fallow", "--version"], { timeout: 5000 });
153
+ const probe = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npx", ["--no-install", "fallow", "--version"], { timeout: 5000 });
153
154
  if (probe.code === 0) {
154
155
  availabilityCache = { ok: true, via: "npx" };
155
156
  return { ok: true, cmd: "npx", baseArgs: ["--no-install", "fallow"], via: "npx" };
@@ -187,7 +188,7 @@ async function runFallow(
187
188
  const startedAt = Date.now();
188
189
  let result;
189
190
  try {
190
- result = await platform.exec(invocation.cmd, args, {
191
+ result = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), invocation.cmd, args, {
191
192
  cwd: opts.cwd,
192
193
  timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
193
194
  });
@@ -269,7 +270,7 @@ export class FallowAdapter implements SlopBackend {
269
270
  if (opts.subtree) args.push("--path", opts.subtree);
270
271
 
271
272
  try {
272
- const result = await platform.exec(invocation.cmd, args, {
273
+ const result = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), invocation.cmd, args, {
273
274
  cwd: opts.cwd,
274
275
  timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
275
276
  });
@@ -45,7 +45,6 @@ import {
45
45
  import { computeScore } from "./anti_slop/score.js";
46
46
  import {
47
47
  type BuildRunnerInput,
48
- type HarnessPipelineProgressEvent,
49
48
  type PipelineRunOutcome,
50
49
  HARNESS_STAGE_ORDER,
51
50
  runHarnessPipelineUntilGate,
@@ -58,7 +57,7 @@ import { DEFAULT_HARNESS_CONFIG } from "./hooks/register.js";
58
57
  import { handlePrComment } from "./pr-comment/handler.js";
59
58
  import { runGitVerificationQa } from "./git-verify-qa.js";
60
59
  import { getHarnessSessionDir } from "./project-paths.js";
61
- import type { HarnessDesignSpec, HarnessGateMode, HarnessSession, HarnessStage } from "../types.js";
60
+ import type { HarnessDesignSpec, HarnessGateMode, HarnessPipelineProgressEvent, HarnessSession, HarnessStage } from "../types.js";
62
61
 
63
62
  modelRegistry.register({
64
63
  id: "harness",
@@ -133,9 +132,11 @@ function createHarnessProgress(ctx: HarnessCommandContext) {
133
132
  let done = 0;
134
133
  let cur: HarnessStage | null = null;
135
134
  const completed: string[] = [];
135
+ let liveDetail: string | null = null;
136
136
 
137
137
  function refresh() {
138
- const label = cur ? HARNESS_STAGE_LABELS[cur] : "Complete";
138
+ const baseLabel = cur ? HARNESS_STAGE_LABELS[cur] : "Complete";
139
+ const label = cur && liveDetail ? `${baseLabel} — ${liveDetail}` : baseLabel;
139
140
  const spinner = cur ? "\u25cc" : "\u2713";
140
141
  (ctx.ui as any).setStatus?.("supi-harness", ` ${spinner} harness: ${label} (${done}/${SO.length})`);
141
142
  }
@@ -147,22 +148,26 @@ function createHarnessProgress(ctx: HarnessCommandContext) {
147
148
  case "stage-started":
148
149
  cur = event.stage;
149
150
  break;
151
+ case "stage-progress":
152
+ cur = event.stage;
153
+ liveDetail = event.detail;
154
+ break;
150
155
  case "stage-completed": {
151
- done += 1; cur = null;
156
+ done += 1; cur = null; liveDetail = null;
152
157
  const mark = "\u2713";
153
158
  completed.push(`${mark} ${HARNESS_STAGE_LABELS[event.stage]}: ${event.detail || "done"}`);
154
159
  break;
155
160
  }
156
161
  case "stage-skipped":
157
- done += 1; cur = null;
162
+ done += 1; cur = null; liveDetail = null;
158
163
  completed.push(`\u2013 ${HARNESS_STAGE_LABELS[event.stage]}: skipped`);
159
164
  break;
160
165
  case "awaiting-user":
161
- done += 1; cur = null;
166
+ done += 1; cur = null; liveDetail = null;
162
167
  completed.push(`\u25cb ${HARNESS_STAGE_LABELS[event.stage]}: ${event.detail || "awaiting review"}`);
163
168
  break;
164
169
  case "stage-failed": case "stage-blocked":
165
- cur = null;
170
+ cur = null; liveDetail = null;
166
171
  completed.push(`\u2717 ${HARNESS_STAGE_LABELS[event.stage]}: ${event.detail || "failed"}`);
167
172
  break;
168
173
  }
@@ -14,6 +14,7 @@ import * as path from "node:path";
14
14
  import type { Platform, PlatformPaths } from "../platform/types.js";
15
15
  import type {
16
16
  HarnessGateMode,
17
+ HarnessPipelineProgressEvent,
17
18
  HarnessStage,
18
19
  ModelConfig,
19
20
  } from "../types.js";
@@ -38,14 +39,6 @@ import { buildBackendAdapter } from "./anti_slop/backend-factory.js";
38
39
  import { DEFAULT_HARNESS_CONFIG } from "./hooks/register.js";
39
40
  import { getProjectStatePath } from "../workspace/state-paths.js";
40
41
 
41
- /** Progress event emitted by the pipeline driver for UI feedback. */
42
- export type HarnessPipelineProgressEvent =
43
- | { type: "stage-started"; stage: HarnessStage }
44
- | { type: "stage-skipped"; stage: HarnessStage }
45
- | { type: "stage-completed"; stage: HarnessStage; detail?: string }
46
- | { type: "stage-blocked"; stage: HarnessStage; detail: string }
47
- | { type: "stage-failed"; stage: HarnessStage; detail: string }
48
- | { type: "awaiting-user"; stage: HarnessStage; detail?: string };
49
42
 
50
43
  const STAGE_ORDER: readonly HarnessStage[] = [
51
44
  "discover",
@@ -301,6 +294,7 @@ export async function runHarnessPipelineUntilGate(
301
294
  sessionId: input.sessionId,
302
295
  modelConfig: input.modelConfig,
303
296
  gateMode: input.gates,
297
+ onProgress: (event) => input.onProgress?.(event),
304
298
  };
305
299
 
306
300
  const result = await runner.run(ctx);
@@ -21,6 +21,7 @@
21
21
  import type { Platform, PlatformPaths } from "../platform/types.js";
22
22
  import type {
23
23
  HarnessGateMode,
24
+ HarnessPipelineProgressEvent,
24
25
  HarnessStage,
25
26
  HarnessStageStatus,
26
27
  ModelConfig,
@@ -46,6 +47,8 @@ export interface HarnessStageRunnerContext {
46
47
  now?: () => string;
47
48
  /** Optional override for the agent session model. Tests use this to bypass resolution. */
48
49
  modelOverride?: { model: string; thinkingLevel: string | null };
50
+ /** Live progress sink for long-running stage internals such as subagent turns. */
51
+ onProgress?: (event: HarnessPipelineProgressEvent) => void;
49
52
  }
50
53
 
51
54
  export type HarnessStageRunStatus =
@@ -90,6 +90,7 @@ export interface DocsStageInput {
90
90
  }
91
91
 
92
92
  interface AgentSessionLike {
93
+ subscribe?: (handler: (event: unknown) => void) => () => void;
93
94
  prompt(text: string, opts?: { expandPromptTemplates?: boolean }): Promise<void>;
94
95
  dispose(): Promise<void>;
95
96
  }
@@ -536,6 +537,60 @@ async function orchestrateLayerSubagent(input: OrchestrateLayerInput): Promise<O
536
537
  }
537
538
  }
538
539
 
540
+ function isRecord(value: unknown): value is Record<string, unknown> {
541
+ return typeof value === "object" && value !== null && !Array.isArray(value);
542
+ }
543
+
544
+ function stringField(record: Record<string, unknown>, key: string): string | null {
545
+ const value = record[key];
546
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
547
+ }
548
+
549
+ function nestedStringField(record: Record<string, unknown>, objectKey: string, fieldKey: string): string | null {
550
+ const nested = record[objectKey];
551
+ if (!isRecord(nested)) return null;
552
+ return stringField(nested, fieldKey);
553
+ }
554
+
555
+ function compactDetail(value: string, maxChars = 180): string {
556
+ const compact = value.replace(/\s+/g, " ").trim();
557
+ if (compact.length <= maxChars) return compact;
558
+ return `${compact.slice(0, maxChars - 1).trimEnd()}…`;
559
+ }
560
+
561
+ function summarizeAgentSessionEvent(event: unknown): string | null {
562
+ if (!isRecord(event)) return null;
563
+ const type = stringField(event, "type") ?? stringField(event, "kind") ?? stringField(event, "event");
564
+ const lowerType = type?.toLowerCase() ?? "";
565
+ const toolName =
566
+ stringField(event, "toolName") ??
567
+ stringField(event, "tool") ??
568
+ stringField(event, "name") ??
569
+ nestedStringField(event, "toolCall", "name") ??
570
+ nestedStringField(event, "tool_call", "name");
571
+ if (toolName && (lowerType.includes("tool") || lowerType.includes("call"))) {
572
+ return `tool ${compactDetail(toolName, 80)}`;
573
+ }
574
+
575
+ const text =
576
+ stringField(event, "text") ??
577
+ stringField(event, "delta") ??
578
+ stringField(event, "thought") ??
579
+ stringField(event, "content") ??
580
+ nestedStringField(event, "message", "text") ??
581
+ nestedStringField(event, "message", "content");
582
+ if (text) {
583
+ const prefix = lowerType.includes("thought") || lowerType.includes("reason")
584
+ ? "thought"
585
+ : lowerType.includes("tool")
586
+ ? "tool"
587
+ : "agent";
588
+ return `${prefix} ${compactDetail(text)}`;
589
+ }
590
+
591
+ return type ? compactDetail(type, 80) : null;
592
+ }
593
+
539
594
  async function dispatchSubagent(input: {
540
595
  platform: Platform;
541
596
  ctx: HarnessStageRunnerContext;
@@ -548,7 +603,13 @@ async function dispatchSubagent(input: {
548
603
  const agentDisplayName = buildHarnessAgentDisplayName("docs", input.entry.layer.layer);
549
604
 
550
605
  let session: AgentSessionLike | null = null;
606
+ let unsubscribe: (() => void) | null = null;
551
607
  try {
608
+ input.ctx.onProgress?.({
609
+ type: "stage-progress",
610
+ stage: "docs",
611
+ detail: `${agentDisplayName}: starting attempt ${input.attempt}`,
612
+ });
552
613
  if (input.factory) {
553
614
  session = await input.factory(input.platform, {
554
615
  cwd: input.ctx.cwd,
@@ -562,7 +623,21 @@ async function dispatchSubagent(input: {
562
623
  agentDisplayName,
563
624
  });
564
625
  }
626
+ unsubscribe = session.subscribe?.((event) => {
627
+ const summary = summarizeAgentSessionEvent(event);
628
+ if (!summary) return;
629
+ input.ctx.onProgress?.({
630
+ type: "stage-progress",
631
+ stage: "docs",
632
+ detail: `${agentDisplayName}: ${summary}`,
633
+ });
634
+ }) ?? null;
565
635
  await session.prompt(input.assignment, { expandPromptTemplates: false });
636
+ input.ctx.onProgress?.({
637
+ type: "stage-progress",
638
+ stage: "docs",
639
+ detail: `${agentDisplayName}: attempt ${input.attempt} complete`,
640
+ });
566
641
  } catch (error) {
567
642
  return {
568
643
  ok: false,
@@ -571,6 +646,13 @@ async function dispatchSubagent(input: {
571
646
  ],
572
647
  };
573
648
  } finally {
649
+ if (unsubscribe) {
650
+ try {
651
+ unsubscribe();
652
+ } catch {
653
+ /* best-effort */
654
+ }
655
+ }
574
656
  if (session) {
575
657
  try {
576
658
  await session.dispose();
@@ -11,7 +11,7 @@
11
11
  // treated as fail-closed: NO_LSP_SUPPORT (gate skips rather than pretending
12
12
  // it ran).
13
13
 
14
- import { Type, type Static } from "@sinclair/typebox";
14
+ import { z } from "zod/v4";
15
15
  import {
16
16
  parseStructuredOutput,
17
17
  runWithOutputValidation,
@@ -20,18 +20,15 @@ import {
20
20
  import { renderSchemaText } from "../ai/schema-text.js";
21
21
  import type { GateExecutionContext } from "../types.js";
22
22
 
23
- export const LspCapabilitiesSchema = Type.Object(
24
- {
25
- diagnostics: Type.Boolean(),
26
- references: Type.Boolean(),
27
- definition: Type.Boolean(),
28
- hover: Type.Boolean(),
29
- rename: Type.Boolean(),
30
- },
31
- { additionalProperties: false },
32
- );
23
+ export const LspCapabilitiesSchema = z.object({
24
+ diagnostics: z.boolean(),
25
+ references: z.boolean(),
26
+ definition: z.boolean(),
27
+ hover: z.boolean(),
28
+ rename: z.boolean(),
29
+ }).strict();
33
30
 
34
- export type LspCapabilities = Static<typeof LspCapabilitiesSchema>;
31
+ export type LspCapabilities = z.infer<typeof LspCapabilitiesSchema>;
35
32
 
36
33
  const SCHEMA_TEXT = renderSchemaText(LspCapabilitiesSchema);
37
34