supipowers 1.2.6 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +118 -56
  2. package/bin/install.ts +48 -128
  3. package/package.json +11 -3
  4. package/skills/code-review/SKILL.md +137 -40
  5. package/skills/context-mode/SKILL.md +67 -56
  6. package/skills/creating-supi-agents/SKILL.md +204 -0
  7. package/skills/debugging/SKILL.md +86 -40
  8. package/skills/fix-pr/SKILL.md +96 -65
  9. package/skills/planning/SKILL.md +103 -46
  10. package/skills/qa-strategy/SKILL.md +68 -46
  11. package/skills/receiving-code-review/SKILL.md +60 -53
  12. package/skills/release/SKILL.md +111 -39
  13. package/skills/tdd/SKILL.md +118 -67
  14. package/skills/verification/SKILL.md +71 -37
  15. package/src/bootstrap.ts +27 -5
  16. package/src/commands/agents.ts +249 -0
  17. package/src/commands/ai-review.ts +1113 -0
  18. package/src/commands/config.ts +224 -95
  19. package/src/commands/doctor.ts +19 -13
  20. package/src/commands/fix-pr.ts +8 -11
  21. package/src/commands/generate.ts +200 -0
  22. package/src/commands/model-picker.ts +5 -15
  23. package/src/commands/model.ts +4 -5
  24. package/src/commands/optimize-context.ts +202 -0
  25. package/src/commands/plan.ts +148 -92
  26. package/src/commands/qa.ts +14 -23
  27. package/src/commands/release.ts +504 -275
  28. package/src/commands/review.ts +643 -86
  29. package/src/commands/status.ts +44 -17
  30. package/src/commands/supi.ts +69 -41
  31. package/src/commands/update.ts +57 -2
  32. package/src/config/defaults.ts +6 -39
  33. package/src/config/loader.ts +388 -40
  34. package/src/config/model-resolver.ts +26 -22
  35. package/src/config/schema.ts +113 -48
  36. package/src/context/analyzer.ts +61 -2
  37. package/src/context/optimizer.ts +199 -0
  38. package/src/context-mode/compressor.ts +14 -11
  39. package/src/context-mode/detector.ts +16 -54
  40. package/src/context-mode/event-extractor.ts +45 -16
  41. package/src/context-mode/event-store.ts +225 -16
  42. package/src/context-mode/hooks.ts +195 -22
  43. package/src/context-mode/knowledge/chunker.ts +235 -0
  44. package/src/context-mode/knowledge/store.ts +187 -0
  45. package/src/context-mode/routing.ts +12 -23
  46. package/src/context-mode/sandbox/executor.ts +183 -0
  47. package/src/context-mode/sandbox/runners.ts +40 -0
  48. package/src/context-mode/snapshot-builder.ts +243 -7
  49. package/src/context-mode/tools.ts +440 -0
  50. package/src/context-mode/web/fetcher.ts +117 -0
  51. package/src/context-mode/web/html-to-md.ts +293 -0
  52. package/src/debug/logger.ts +107 -0
  53. package/src/deps/registry.ts +0 -20
  54. package/src/docs/drift.ts +454 -0
  55. package/src/fix-pr/fetch-comments.ts +66 -0
  56. package/src/git/commit-msg.ts +2 -1
  57. package/src/git/commit.ts +123 -141
  58. package/src/git/conventions.ts +2 -2
  59. package/src/git/status.ts +4 -1
  60. package/src/lsp/bridge.ts +138 -12
  61. package/src/planning/approval-flow.ts +125 -19
  62. package/src/planning/plan-writer-prompt.ts +4 -11
  63. package/src/planning/planning-ask-tool.ts +81 -0
  64. package/src/planning/prompt-builder.ts +9 -169
  65. package/src/planning/system-prompt.ts +290 -0
  66. package/src/platform/omp.ts +50 -4
  67. package/src/platform/progress.ts +182 -0
  68. package/src/platform/test-utils.ts +4 -1
  69. package/src/platform/tui-colors.ts +30 -0
  70. package/src/platform/types.ts +1 -0
  71. package/src/qa/detect-app-type.ts +102 -0
  72. package/src/qa/discover-routes.ts +353 -0
  73. package/src/quality/ai-session.ts +96 -0
  74. package/src/quality/ai-setup.ts +86 -0
  75. package/src/quality/gates/ai-review.ts +129 -0
  76. package/src/quality/gates/build.ts +8 -0
  77. package/src/quality/gates/command.ts +150 -0
  78. package/src/quality/gates/format.ts +28 -0
  79. package/src/quality/gates/lint.ts +22 -0
  80. package/src/quality/gates/lsp-diagnostics.ts +84 -0
  81. package/src/quality/gates/test-suite.ts +8 -0
  82. package/src/quality/gates/typecheck.ts +22 -0
  83. package/src/quality/registry.ts +25 -0
  84. package/src/quality/review-gates.ts +33 -0
  85. package/src/quality/runner.ts +268 -0
  86. package/src/quality/schemas.ts +48 -0
  87. package/src/quality/setup.ts +227 -0
  88. package/src/release/changelog.ts +7 -3
  89. package/src/release/channels/custom.ts +43 -0
  90. package/src/release/channels/gitea.ts +35 -0
  91. package/src/release/channels/github.ts +35 -0
  92. package/src/release/channels/gitlab.ts +35 -0
  93. package/src/release/channels/registry.ts +52 -0
  94. package/src/release/channels/types.ts +27 -0
  95. package/src/release/detector.ts +10 -63
  96. package/src/release/executor.ts +61 -51
  97. package/src/release/prompt.ts +38 -38
  98. package/src/release/version.ts +129 -10
  99. package/src/review/agent-loader.ts +331 -0
  100. package/src/review/consolidator.ts +180 -0
  101. package/src/review/default-agents/correctness.md +72 -0
  102. package/src/review/default-agents/maintainability.md +64 -0
  103. package/src/review/default-agents/security.md +67 -0
  104. package/src/review/fixer.ts +219 -0
  105. package/src/review/multi-agent-runner.ts +135 -0
  106. package/src/review/output.ts +147 -0
  107. package/src/review/prompts/agent-review-wrapper.md +36 -0
  108. package/src/review/prompts/fix-findings.md +32 -0
  109. package/src/review/prompts/fix-output-schema.md +18 -0
  110. package/src/review/prompts/invalid-output-retry.md +22 -0
  111. package/src/review/prompts/output-instructions.md +14 -0
  112. package/src/review/prompts/review-output-schema.md +38 -0
  113. package/src/review/prompts/single-review.md +53 -0
  114. package/src/review/prompts/validation-review.md +30 -0
  115. package/src/review/runner.ts +128 -0
  116. package/src/review/scope.ts +353 -0
  117. package/src/review/template.ts +15 -0
  118. package/src/review/types.ts +296 -0
  119. package/src/review/validator.ts +160 -0
  120. package/src/storage/plans.ts +5 -3
  121. package/src/storage/reports.ts +50 -7
  122. package/src/storage/review-sessions.ts +117 -0
  123. package/src/text.ts +19 -0
  124. package/src/types.ts +336 -26
  125. package/src/utils/paths.ts +39 -0
  126. package/src/visual/companion.ts +5 -3
  127. package/src/visual/start-server.ts +101 -0
  128. package/src/visual/stop-server.ts +39 -0
  129. package/bin/ctx-mode-wrapper.mjs +0 -66
  130. package/src/config/profiles.ts +0 -64
  131. package/src/context-mode/installer.ts +0 -38
  132. package/src/quality/ai-review-gate.ts +0 -43
  133. package/src/quality/gate-runner.ts +0 -67
  134. package/src/quality/lsp-gate.ts +0 -24
  135. package/src/quality/test-gate.ts +0 -39
  136. package/src/visual/scripts/start-server.sh +0 -98
  137. package/src/visual/scripts/stop-server.sh +0 -21
package/src/git/commit.ts CHANGED
@@ -5,13 +5,15 @@
5
5
  // the plan for user approval, then executes file-level staging + commit.
6
6
 
7
7
  import type { Platform } from "../platform/types.js";
8
+ import { createWorkflowProgress } from "../platform/progress.js";
8
9
  import { VALID_COMMIT_TYPES } from "../release/commit-types.js";
9
10
  import { validateCommitMessage } from "./commit-msg.js";
10
11
  import { getWorkingTreeStatus } from "./status.js";
11
12
  import { discoverCommitConventions } from "./conventions.js";
13
+ import { normalizeLineEndings } from "../text.js";
12
14
  import { notifyInfo, notifyError, notifySuccess } from "../notifications/renderer.js";
13
15
  import { modelRegistry } from "../config/model-registry-instance.js";
14
- import { resolveModelForAction, createModelBridge } from "../config/model-resolver.js";
16
+ import { resolveAllCandidates, createModelBridge } from "../config/model-resolver.js";
15
17
  import { loadModelConfig } from "../config/model-config.js";
16
18
 
17
19
  // ── Public types ───────────────────────────────────────────
@@ -95,129 +97,40 @@ const DIFF_TRUNCATED_LINES = 200;
95
97
 
96
98
  // ── Commit progress tracker ────────────────────────────────
97
99
 
98
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
99
- const STATUS_KEY = "supi-commit";
100
- const WIDGET_KEY = "supi-commit";
100
+ const COMMIT_STEPS = [
101
+ { key: "check-working-tree", label: "Check working tree" },
102
+ { key: "stage-changes", label: "Stage changes" },
103
+ { key: "read-diff", label: "Read diff" },
104
+ { key: "scan-conventions", label: "Scan conventions" },
105
+ { key: "ai-analysis", label: "AI analysis" },
106
+ { key: "review-plan", label: "Review plan" },
107
+ { key: "execute-commits", label: "Execute commits" },
108
+ ] as const;
101
109
 
102
- /** A named step in the commit workflow. */
103
- interface Step {
104
- label: string;
105
- status: "pending" | "active" | "done" | "skipped";
106
- detail?: string;
107
- }
108
-
109
- /**
110
- * Rich progress tracker for the commit flow.
111
- *
112
- * Uses `setWidget` for a persistent multi-line panel showing all steps,
113
- * and `setStatus` for the current operation detail in the footer.
114
- */
115
110
  function createProgress(ctx: any) {
116
- const steps: Step[] = [
117
- { label: "Check working tree", status: "pending" },
118
- { label: "Stage changes", status: "pending" },
119
- { label: "Read diff", status: "pending" },
120
- { label: "Scan conventions", status: "pending" },
121
- { label: "AI analysis", status: "pending" },
122
- { label: "Review plan", status: "pending" },
123
- { label: "Execute commits", status: "pending" },
124
- ];
125
-
126
- let frame = 0;
127
- let statusDetail = "";
128
- let timer: ReturnType<typeof setInterval> | null = null;
129
-
130
- function icon(step: Step): string {
131
- switch (step.status) {
132
- case "done": return "✓";
133
- case "active": return SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
134
- case "skipped": return "–";
135
- default: return "○";
136
- }
137
- }
138
-
139
- function renderWidget(): string[] {
140
- const lines: string[] = ["┌─ supi:commit ─────────────────────┐"];
141
- for (const step of steps) {
142
- const mark = icon(step);
143
- const detail = step.detail ? ` (${step.detail})` : "";
144
- lines.push(`│ ${mark} ${step.label}${detail}`);
145
- }
146
- lines.push("└───────────────────────────────────┘");
147
- return lines;
148
- }
149
-
150
- function refresh() {
151
- frame++;
152
- ctx.ui.setWidget?.(WIDGET_KEY, renderWidget());
153
- if (statusDetail) {
154
- ctx.ui.setStatus?.(STATUS_KEY, `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${statusDetail}`);
155
- }
156
- }
157
-
158
- function startTimer() {
159
- if (!timer) {
160
- timer = setInterval(refresh, 80);
161
- }
162
- }
163
-
164
- function stopTimer() {
165
- if (timer) {
166
- clearInterval(timer);
167
- timer = null;
168
- }
169
- }
111
+ const progress = createWorkflowProgress(ctx.ui, {
112
+ title: "supi:commit",
113
+ statusKey: "supi-commit",
114
+ widgetKey: "supi-commit",
115
+ clearStatusKeys: ["supi-model"],
116
+ steps: [...COMMIT_STEPS],
117
+ });
170
118
 
171
119
  return {
172
- /**
173
- * Mark a step as active (in progress) with an optional status-bar detail.
174
- * `stepIndex` is 0-based into the steps array.
175
- */
176
120
  activate(stepIndex: number, detail?: string) {
177
- const step = steps[stepIndex];
178
- if (step) {
179
- step.status = "active";
180
- step.detail = detail;
181
- }
182
- statusDetail = detail ?? step?.label ?? "";
183
- startTimer();
184
- refresh();
121
+ progress.activate(COMMIT_STEPS[stepIndex]!.key, detail);
185
122
  },
186
-
187
- /** Update the status-bar detail text without changing the step. */
188
123
  detail(text: string) {
189
- statusDetail = text;
190
- // Also update the current active step's detail
191
- const active = steps.find((s) => s.status === "active");
192
- if (active) active.detail = text;
124
+ progress.detail(text);
193
125
  },
194
-
195
- /** Mark a step as completed. */
196
126
  complete(stepIndex: number, detail?: string) {
197
- const step = steps[stepIndex];
198
- if (step) {
199
- step.status = "done";
200
- if (detail !== undefined) step.detail = detail;
201
- }
202
- refresh();
127
+ progress.complete(COMMIT_STEPS[stepIndex]!.key, detail);
203
128
  },
204
-
205
- /** Mark a step as skipped. */
206
129
  skip(stepIndex: number, detail?: string) {
207
- const step = steps[stepIndex];
208
- if (step) {
209
- step.status = "skipped";
210
- if (detail !== undefined) step.detail = detail;
211
- }
212
- refresh();
130
+ progress.skip(COMMIT_STEPS[stepIndex]!.key, detail);
213
131
  },
214
-
215
- /** Tear down: stop animation, clear status bar and widget. */
216
132
  dispose() {
217
- stopTimer();
218
- ctx.ui.setStatus?.(STATUS_KEY, undefined);
219
- ctx.ui.setStatus?.("supi-model", undefined);
220
- ctx.ui.setWidget?.(WIDGET_KEY, undefined);
133
+ progress.dispose();
221
134
  },
222
135
  };
223
136
  }
@@ -268,7 +181,7 @@ export async function analyzeAndCommit(
268
181
  exec("git", ["diff", "--cached", "--name-only"], { cwd }),
269
182
  ]);
270
183
 
271
- const fileList = filesResult.stdout.trim().split("\n").filter(Boolean);
184
+ const fileList = normalizeLineEndings(filesResult.stdout).trim().split("\n").filter(Boolean);
272
185
  if (fileList.length === 0) {
273
186
  progress.complete(2, "empty");
274
187
  progress.dispose();
@@ -293,32 +206,41 @@ export async function analyzeAndCommit(
293
206
  });
294
207
 
295
208
  let plan: CommitPlan | null = null;
209
+ let agentReason: string | undefined;
296
210
 
297
211
  if (platform.capabilities.agentSessions) {
298
212
  progress.activate(4, `${fileList.length} file(s)`);
299
- // Resolve the commit sub-agent model from config (falls back to session default)
300
213
  const modelCfg = loadModelConfig(platform.paths, cwd);
301
214
  const bridge = createModelBridge(platform);
302
- const commitModel = resolveModelForAction("commit", modelRegistry, modelCfg, bridge);
303
-
304
- // Show model override in status bar if not using the main session model
305
- if (commitModel.source !== "main" && commitModel.model) {
306
- const sourceLabel =
307
- commitModel.source === "action" ? "configured for commit" :
308
- commitModel.source === "default" ? "supipowers default" :
309
- "harness role";
310
- let detail = sourceLabel;
311
- if (commitModel.thinkingLevel) {
312
- detail += ` \u00b7 ${commitModel.thinkingLevel} thinking`;
215
+ const candidates = resolveAllCandidates("commit", modelRegistry, modelCfg, bridge);
216
+
217
+ for (const candidate of candidates) {
218
+ // Show model override in status bar if not using the main session model
219
+ if (candidate.source !== "main" && candidate.model) {
220
+ const sourceLabel =
221
+ candidate.source === "action" ? "configured for commit" :
222
+ candidate.source === "default" ? "supipowers default" :
223
+ "harness role";
224
+ let detail = sourceLabel;
225
+ if (candidate.thinkingLevel) {
226
+ detail += ` \u00b7 ${candidate.thinkingLevel} thinking`;
227
+ }
228
+ ctx.ui?.setStatus?.("supi-model", `Model: ${candidate.model} (${detail})`);
229
+ }
230
+
231
+ const agentResult = await tryAgentPlan(platform, cwd, prompt, candidate.model);
232
+ if (agentResult.plan) {
233
+ plan = validatePlanFiles(agentResult.plan, fileList);
234
+ progress.complete(4, `${plan.commits.length} commit(s)`);
235
+ break;
313
236
  }
314
- ctx.ui?.setStatus?.("supi-model", `Model: ${commitModel.model} (${detail})`);
237
+
238
+ // Store last failure reason; try next candidate
239
+ agentReason = agentResult.reason;
315
240
  }
316
- plan = await tryAgentPlan(platform, cwd, prompt, commitModel.model);
317
- if (plan) {
318
- plan = validatePlanFiles(plan, fileList);
319
- progress.complete(4, `${plan.commits.length} commit(s)`);
320
- } else {
321
- progress.skip(4, "unavailable");
241
+
242
+ if (!plan) {
243
+ progress.skip(4, agentReason ?? "unavailable");
322
244
  }
323
245
  } else {
324
246
  progress.skip(4, "no agent sessions");
@@ -329,7 +251,10 @@ export async function analyzeAndCommit(
329
251
  progress.skip(5, "manual");
330
252
  progress.skip(6, "manual");
331
253
  progress.dispose();
332
- return manualFallback(platform, ctx, cwd, fileList);
254
+ const reason = !platform.capabilities.agentSessions
255
+ ? "no agent sessions"
256
+ : agentReason;
257
+ return manualFallback(platform, ctx, cwd, fileList, reason);
333
258
  }
334
259
 
335
260
  // 6. Present plan for approval
@@ -363,12 +288,18 @@ export async function analyzeAndCommit(
363
288
 
364
289
  // ── Agent interaction ──────────────────────────────────────
365
290
 
291
+ interface AgentPlanResult {
292
+ plan: CommitPlan | null;
293
+ /** Human-readable reason when plan is null */
294
+ reason?: string;
295
+ }
296
+
366
297
  async function tryAgentPlan(
367
298
  platform: Platform,
368
299
  cwd: string,
369
300
  prompt: string,
370
301
  model?: string,
371
- ): Promise<CommitPlan | null> {
302
+ ): Promise<AgentPlanResult> {
372
303
  let session: Awaited<ReturnType<Platform["createAgentSession"]>> | null = null;
373
304
  try {
374
305
  session = await platform.createAgentSession({ cwd, hasUI: false, ...(model ? { model } : {}) });
@@ -388,7 +319,7 @@ async function tryAgentPlan(
388
319
  .reverse()
389
320
  .find((m: any) => m.role === "assistant");
390
321
 
391
- if (!lastAssistant) return null;
322
+ if (!lastAssistant) return { plan: null, reason: "no assistant response" };
392
323
 
393
324
  const text =
394
325
  typeof lastAssistant.content === "string"
@@ -400,9 +331,15 @@ async function tryAgentPlan(
400
331
  .join("\n")
401
332
  : "";
402
333
 
403
- return parseCommitPlan(text);
404
- } catch {
405
- return null;
334
+ if (!text) return { plan: null, reason: "empty agent response" };
335
+
336
+ const plan = parseCommitPlan(text);
337
+ if (!plan) return { plan: null, reason: diagnoseParseFailure(text) };
338
+
339
+ return { plan };
340
+ } catch (err) {
341
+ const message = err instanceof Error ? err.message : String(err);
342
+ return { plan: null, reason: message };
406
343
  } finally {
407
344
  if (session) {
408
345
  try {
@@ -421,13 +358,16 @@ async function manualFallback(
421
358
  ctx: any,
422
359
  cwd: string,
423
360
  fileList: string[],
361
+ reason?: string,
424
362
  ): Promise<CommitResult | null> {
425
363
  const exec = platform.exec.bind(platform);
426
364
 
427
365
  notifyInfo(
428
366
  ctx,
429
367
  "AI commit unavailable",
430
- "Enter a commit message manually",
368
+ reason
369
+ ? `${reason} \u2014 enter a commit message manually`
370
+ : "Enter a commit message manually",
431
371
  );
432
372
 
433
373
  const message = await ctx.ui.input("Commit message (empty to abort)", {
@@ -581,6 +521,7 @@ interface PromptInput {
581
521
  /** Exported for testing */
582
522
  export function buildAnalysisPrompt(input: PromptInput): string {
583
523
  const { diff, stat, fileList, conventions, userContext } = input;
524
+ const normalizedDiff = normalizeLineEndings(diff);
584
525
 
585
526
  const parts: string[] = [
586
527
  "You are a commit message generator. Analyze the following code changes and produce a commit plan.",
@@ -602,12 +543,12 @@ export function buildAnalysisPrompt(input: PromptInput): string {
602
543
  }
603
544
 
604
545
  // Diff content — truncate for large diffs
605
- const diffBytes = Buffer.byteLength(diff, "utf8");
546
+ const diffBytes = Buffer.byteLength(normalizedDiff, "utf8");
606
547
 
607
548
  if (diffBytes <= DIFF_FULL_LIMIT) {
608
- parts.push("**Full diff:**", "```", diff, "```", "");
549
+ parts.push("**Full diff:**", "```", normalizedDiff, "```", "");
609
550
  } else if (diffBytes <= DIFF_STAT_ONLY_LIMIT) {
610
- const truncated = diff.split("\n").slice(0, DIFF_TRUNCATED_LINES).join("\n");
551
+ const truncated = normalizedDiff.split("\n").slice(0, DIFF_TRUNCATED_LINES).join("\n");
611
552
  parts.push(
612
553
  "**Diff (truncated — too large for full inclusion):**",
613
554
  "```",
@@ -650,6 +591,47 @@ export function buildAnalysisPrompt(input: PromptInput): string {
650
591
 
651
592
  // ── Plan parsing ───────────────────────────────────────────
652
593
 
594
+ /**
595
+ * Produce a human-readable reason why parseCommitPlan returned null.
596
+ * Used for diagnostics — never shown raw to the user.
597
+ */
598
+ function diagnoseParseFailure(text: string): string {
599
+ const fenceRe = /```json\s*\n([\s\S]*?)```/;
600
+ const match = fenceRe.exec(text);
601
+ if (!match) return "no JSON code block in response";
602
+
603
+ let parsed: any;
604
+ try {
605
+ parsed = JSON.parse(match[1]);
606
+ } catch {
607
+ return "JSON parse error in response";
608
+ }
609
+
610
+ if (!parsed.commits || !Array.isArray(parsed.commits)) return "missing commits array";
611
+ if (parsed.commits.length === 0) return "empty commits array";
612
+
613
+ for (const c of parsed.commits) {
614
+ if (!c.type) return "commit missing type";
615
+ if (!c.summary) return "commit missing summary";
616
+ if (!Array.isArray(c.files) || c.files.length === 0) return "commit missing files";
617
+ if (!(VALID_COMMIT_TYPES as readonly string[]).includes(c.type)) {
618
+ return `invalid commit type: ${c.type}`;
619
+ }
620
+ }
621
+
622
+ // Check for duplicate files across groups
623
+ const seen = new Set<string>();
624
+ for (const group of parsed.commits) {
625
+ for (const file of group.files) {
626
+ if (seen.has(file)) return `duplicate file across commits: ${file}`;
627
+ seen.add(file);
628
+ }
629
+ }
630
+
631
+ return "unknown parse failure";
632
+ }
633
+
634
+
653
635
  /** Exported for testing */
654
636
  export function parseCommitPlan(text: string): CommitPlan | null {
655
637
  // Look for ```json ... ``` fenced block
@@ -1,7 +1,7 @@
1
1
  // src/git/conventions.ts — Discover commit message conventions from repo docs and config
2
2
 
3
3
  import { readFileSync, existsSync } from "node:fs";
4
- import { join } from "node:path";
4
+ import { isAbsolute, join } from "node:path";
5
5
 
6
6
  type ExecFn = (
7
7
  cmd: string,
@@ -110,7 +110,7 @@ export async function discoverCommitConventions(
110
110
  );
111
111
  if (result.code === 0 && result.stdout.trim()) {
112
112
  const templatePath = result.stdout.trim();
113
- const absPath = templatePath.startsWith("/")
113
+ const absPath = isAbsolute(templatePath)
114
114
  ? templatePath
115
115
  : join(cwd, templatePath);
116
116
  const content = readFileSafe(absPath);
package/src/git/status.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { normalizeLineEndings } from "../text.js";
2
+
3
+
1
4
  type ExecFn = (cmd: string, args: string[], opts?: { cwd?: string }) => Promise<{ stdout: string; code: number }>;
2
5
 
3
6
  export interface WorkingTreeStatus {
@@ -16,7 +19,7 @@ export async function getWorkingTreeStatus(exec: ExecFn, cwd: string): Promise<W
16
19
  if (result.code !== 0) {
17
20
  return { dirty: false, files: [] };
18
21
  }
19
- const files = result.stdout
22
+ const files = normalizeLineEndings(result.stdout)
20
23
  .split("\n")
21
24
  .filter(Boolean)
22
25
  .map((line) => line.slice(3)); // strip 2-char status + space
package/src/lsp/bridge.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  // src/lsp/bridge.ts
2
+ import type { GateExecutionContext, GateIssue } from "../types.js";
3
+ import { runStructuredAgentSession } from "../quality/ai-session.js";
4
+ import { stripMarkdownCodeFence } from "../text.js";
2
5
 
3
6
  export interface DiagnosticsResult {
4
7
  file: string;
@@ -12,25 +15,148 @@ export interface Diagnostic {
12
15
  column: number;
13
16
  }
14
17
 
18
+ function isDiagnostic(value: unknown): value is Diagnostic {
19
+ return (
20
+ typeof value === "object" &&
21
+ value !== null &&
22
+ ["error", "warning", "info", "hint"].includes((value as Diagnostic).severity) &&
23
+ typeof (value as Diagnostic).message === "string" &&
24
+ typeof (value as Diagnostic).line === "number" &&
25
+ typeof (value as Diagnostic).column === "number"
26
+ );
27
+ }
28
+
29
+ function isDiagnosticsResult(value: unknown): value is DiagnosticsResult {
30
+ return (
31
+ typeof value === "object" &&
32
+ value !== null &&
33
+ typeof (value as DiagnosticsResult).file === "string" &&
34
+ Array.isArray((value as DiagnosticsResult).diagnostics) &&
35
+ (value as DiagnosticsResult).diagnostics.every((diagnostic) => isDiagnostic(diagnostic))
36
+ );
37
+ }
38
+
39
+ function normalizeDiagnosticSeverity(severity: Diagnostic["severity"]): GateIssue["severity"] {
40
+ return severity === "hint" ? "info" : severity;
41
+ }
42
+
43
+ function formatDiagnosticDetail(diagnostic: Diagnostic): string | undefined {
44
+ return diagnostic.column > 0 ? `column ${diagnostic.column}` : undefined;
45
+ }
46
+
47
+ function extractJsonArray(raw: string): unknown | null {
48
+ const trimmed = raw.trim();
49
+
50
+ // 1. Try direct parse (no fence)
51
+ try {
52
+ const parsed = JSON.parse(trimmed);
53
+ if (Array.isArray(parsed)) return parsed;
54
+ } catch {}
55
+
56
+ // 2. Strip markdown code fence and retry
57
+ const stripped = stripMarkdownCodeFence(trimmed);
58
+ if (stripped !== trimmed) {
59
+ try {
60
+ const parsed = JSON.parse(stripped);
61
+ if (Array.isArray(parsed)) return parsed;
62
+ } catch {}
63
+ }
64
+
65
+ // 3. Extract first JSON array from prose via bracket matching
66
+ const start = trimmed.indexOf("[");
67
+ if (start === -1) return null;
68
+
69
+ let depth = 0;
70
+ for (let i = start; i < trimmed.length; i++) {
71
+ if (trimmed[i] === "[") depth++;
72
+ else if (trimmed[i] === "]") depth--;
73
+ if (depth === 0) {
74
+ try {
75
+ const parsed = JSON.parse(trimmed.slice(start, i + 1));
76
+ if (Array.isArray(parsed)) return parsed;
77
+ } catch {}
78
+ break;
79
+ }
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ function parseLspDiagnosticsResults(raw: string): DiagnosticsResult[] | null {
86
+ const parsed = extractJsonArray(raw);
87
+ if (!Array.isArray(parsed)) return null;
88
+ return parsed.every((entry) => isDiagnosticsResult(entry)) ? parsed : null;
89
+ }
90
+
15
91
  /**
16
- * Request LSP diagnostics for a file by sending a message that
17
- * triggers the LLM to use the lsp tool. For direct use,
18
- * we provide a prompt snippet the orchestrator can include
19
- * in sub-agent assignments.
92
+ * Request LSP diagnostics via a headless agent session that can call the lsp tool.
20
93
  */
21
- export function buildLspDiagnosticsPrompt(files: string[]): string {
22
- const fileList = files.map((f) => `- ${f}`).join("\n");
94
+ export function buildLspDiagnosticsPrompt(
95
+ scopeFiles: string[],
96
+ fileScope: GateExecutionContext["fileScope"],
97
+ ): string {
98
+ const scopeInstruction =
99
+ fileScope === "all-files"
100
+ ? [
101
+ 'Run repository-wide diagnostics with the lsp tool using action "diagnostics" and file "*".',
102
+ "Return diagnostics for files that report issues.",
103
+ ]
104
+ : [
105
+ "Run LSP diagnostics on these files:",
106
+ ...scopeFiles.map((file) => `- ${file}`),
107
+ "",
108
+ 'Use the lsp tool with action "diagnostics" for each listed file.',
109
+ ];
110
+
23
111
  return [
24
- "Run LSP diagnostics on these files and report any errors or warnings:",
25
- fileList,
112
+ "You are collecting structured LSP diagnostics for a review quality gate.",
113
+ ...scopeInstruction,
26
114
  "",
27
- 'Use the lsp tool with action "diagnostics" for each file.',
28
- "Report the results in this format:",
29
- "FILE: <path>",
30
- " LINE:COL SEVERITY: message",
115
+ "Return JSON only as an array with this exact shape:",
116
+ '[{"file":"path/to/file.ts","diagnostics":[{"severity":"error|warning|info|hint","message":"text","line":1,"column":1}]}]',
117
+ "",
118
+ "Rules:",
119
+ "- Do not wrap the JSON in markdown fences.",
120
+ "- Include line and column numbers exactly as reported by the tool.",
121
+ "- Use an empty array when there are no diagnostics.",
31
122
  ].join("\n");
32
123
  }
33
124
 
125
+ export async function collectLspDiagnostics(options: {
126
+ cwd: string;
127
+ scopeFiles: string[];
128
+ fileScope: GateExecutionContext["fileScope"];
129
+ createAgentSession: GateExecutionContext["createAgentSession"];
130
+ reviewModel?: GateExecutionContext["reviewModel"];
131
+ }): Promise<GateIssue[]> {
132
+ const sessionResult = await runStructuredAgentSession(options.createAgentSession, {
133
+ cwd: options.cwd,
134
+ prompt: buildLspDiagnosticsPrompt(options.scopeFiles, options.fileScope),
135
+ model: options.reviewModel?.model,
136
+ thinkingLevel: options.reviewModel?.thinkingLevel ?? null,
137
+ timeoutMs: 120_000,
138
+ });
139
+
140
+ if (sessionResult.status !== "ok") {
141
+ throw new Error(sessionResult.error);
142
+ }
143
+
144
+ const parsed = parseLspDiagnosticsResults(sessionResult.finalText);
145
+ if (!parsed) {
146
+ throw new Error("LSP diagnostics integration returned invalid JSON.");
147
+ }
148
+
149
+ return parsed.flatMap((result) =>
150
+ result.diagnostics.map<GateIssue>((diagnostic) => ({
151
+ severity: normalizeDiagnosticSeverity(diagnostic.severity),
152
+ message: diagnostic.message,
153
+ file: result.file,
154
+ line: diagnostic.line,
155
+ detail: formatDiagnosticDetail(diagnostic),
156
+ })),
157
+ );
158
+ }
159
+
34
160
  /**
35
161
  * Build a prompt snippet for sub-agents to check references before renaming.
36
162
  */