opencode-herdr-orchestration 0.3.1 → 0.3.3

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.
@@ -24,6 +24,7 @@ import {
24
24
  writePluginConfig,
25
25
  writeSteerCommand,
26
26
  } from "../src/installer.js";
27
+ import { collectDoctorReport } from "../src/doctor.js";
27
28
 
28
29
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
29
30
  const hooksPath = join(homedir(), ".config", "opencode-herdr-orchestration", "hooks");
@@ -190,6 +191,11 @@ function captureHookStatus() {
190
191
  });
191
192
  }
192
193
 
194
+ function runDoctor() {
195
+ const report = collectDoctorReport();
196
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
197
+ }
198
+
193
199
  try {
194
200
  if (command === "install") await installOrUpdate(false);
195
201
  else if (command === "update") await installOrUpdate(true);
@@ -198,8 +204,9 @@ try {
198
204
  else if (command === "install-hooks") installHooks();
199
205
  else if (command === "uninstall-hooks") uninstallHooks();
200
206
  else if (command === "status") fullStatus();
207
+ else if (command === "doctor") runDoctor();
201
208
  else {
202
- process.stderr.write("Usage: opencode-herdr-orchestration <install|update|configure-agents|status|uninstall|install-hooks|uninstall-hooks> [--with-hooks] [--force]\n");
209
+ process.stderr.write("Usage: opencode-herdr-orchestration <install|update|configure-agents|status|doctor|uninstall|install-hooks|uninstall-hooks> [--with-hooks] [--force]\n");
203
210
  process.exitCode = 2;
204
211
  }
205
212
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-herdr-orchestration",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Capability-separated Herdr orchestration agents for OpenCode",
5
5
  "author": "CodingJinxx",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "test": "node --test",
31
- "check": "node --check src/plugin.js && node --check src/index.js && node --check src/agents.js && node --check src/prompts.js && node --check src/response.js && node --check src/state.js && node --check src/diagnostics.js && node --check src/steer.js && node --check src/installer.js && node --check bin/orchestration.js"
31
+ "check": "node --check src/plugin.js && node --check src/index.js && node --check src/agents.js && node --check src/prompts.js && node --check src/response.js && node --check src/state.js && node --check src/diagnostics.js && node --check src/steer.js && node --check src/installer.js && node --check src/doctor.js && node --check bin/orchestration.js"
32
32
  },
33
33
  "engines": {
34
34
  "node": ">=20"
package/src/agents.js CHANGED
@@ -288,9 +288,9 @@ const GOVERNOR_SEND_KEYS_CTRL_C_ALLOWS = {
288
288
  // --clear`, close takes `<pane_id>`.
289
289
  // Agent rename evidenced as `herdr agent rename <TARGET> <NAME>|--clear`
290
290
  // (live `agent list` shows `name` such as `issue1418m1sheep` and `agent get`
291
- // shows `name` plus `pane_id`); pane rename plus tab rename helps evidenced
292
- // but only pane plus agent rename are enabled, tab rename stays denied to
293
- // keep the single-tab 4-pane cap.
291
+ // shows `name` plus `pane_id`); pane rename plus agent rename plus tab create
292
+ // helps evidenced and enabled for per-tab overflow, tab rename stays denied
293
+ // to keep the per-tab 4-pane cap with new-tab overflow.
294
294
  // Count queries evidenced as `herdr tab list --workspace <ID>` returning
295
295
  // `{"id":"cli:tab:list","result":{"tabs":[{"pane_count":1,"tab_id":"w1K:t1",...}]},"type":"tab_list"}`,
296
296
  // `herdr pane list --workspace <ID>` returning
@@ -316,19 +316,21 @@ const GOVERNOR_SEND_KEYS_CTRL_C_ALLOWS = {
316
316
  // IDs are opaque stable handles (`w1K`, `w1K:t1`, `w1K:p1` from
317
317
  // `HERDR_WORKSPACE_ID` plus `HERDR_TAB_ID` plus `HERDR_PANE_ID`); closed IDs
318
318
  // are not reused; prefer `--current` and never rely on the UI-focused pane.
319
- // Fallback if any of tab list plus pane get plus rename plus close plus
320
- // split are missing: reuse the current pane via `--pane` plus `--current`,
321
- // report STOP naming the missing capability, never invent `herdr pane
322
- // create*` plus `herdr tab split*` plus `herdr agent events*` plus
323
- // `herdr pane move*` plus `herdr pane resize*` plus `herdr workspace
319
+ // Fallback if any of tab list plus tab create plus pane get plus rename plus
320
+ // close plus split are missing: reuse the current pane via `--pane` plus
321
+ // `--current`, report STOP naming the missing capability, never invent
322
+ // `herdr pane create*` plus `herdr tab split*` plus `herdr agent events*`
323
+ // plus `herdr pane move*` plus `herdr pane resize*` plus `herdr workspace
324
324
  // create*` behavior.
325
325
  // Protected Dev Developer Terminal exclusion cannot be matcher-enforced:
326
326
  // pane IDs are opaque and labels are absent from scan plus split plus close
327
327
  // command strings, while a `*Dev*` glob would overmatch legitimate
328
- // `--cwd C:\Dev\...` values, so no such glob is added; the prompt plus
329
- // README Pane layout policy exclusion stays primary; see README residual.
328
+ // `--cwd C:\Dev\...` values, so no such glob is added; the prompt plus docs
329
+ // architecture tab section exclusion stays primary with per-tab cap plus
330
+ // new-tab overflow plus never touch Dev in any tab; see docs residual.
330
331
  const SHEPHERD_PANE_ALLOWS = {
331
332
  "herdr tab list*": "allow",
333
+ "herdr tab create*": "allow",
332
334
  "herdr pane get*": "allow",
333
335
  "herdr pane rename*": "allow",
334
336
  "herdr agent rename*": "allow",
@@ -341,6 +343,7 @@ const GOVERNOR_PANE_ALLOWS = {
341
343
  };
342
344
  const SHEEPDOG_PANE_ALLOWS = {
343
345
  "herdr tab list*": "allow",
346
+ "herdr tab create*": "allow",
344
347
  "herdr pane get*": "allow",
345
348
  "herdr pane rename*": "allow",
346
349
  "herdr agent rename*": "allow",
package/src/doctor.js ADDED
@@ -0,0 +1,383 @@
1
+ // Read-only launcher diagnostics (doctor M1).
2
+ // Inspects the Windows opencode launcher resolution without changing state.
3
+ // Every collector below only reads: PATH inspection, Get-Command ordering,
4
+ // per-candidate --version probes, spawn probe observation, integration
5
+ // presence checks, and flapping signals. No global environment mutation,
6
+ // no shell profile change, no file creation, and no Herdr or OpenCode
7
+ // invocation beyond read-only status and version queries.
8
+ // Operator-executed remedies are reported as text for the human operator;
9
+ // this module never applies them.
10
+ import spawn from "cross-spawn";
11
+ import { spawnSync } from "node:child_process";
12
+ import { existsSync } from "node:fs";
13
+ import { platform } from "node:os";
14
+ import { delimiter, join } from "node:path";
15
+
16
+ export const DOCTOR_ANCHORS = Object.freeze([
17
+ "#installation",
18
+ "#manual-installation",
19
+ "#upgrade",
20
+ "#recovery",
21
+ "#troubleshooting",
22
+ "#missing-herdr-opencode-integration",
23
+ "#windows-exe-versus-shim-launcher-resolution",
24
+ ]);
25
+
26
+ export const README_ANCHORS = DOCTOR_ANCHORS;
27
+
28
+ export const HEALTHY_OPENCODE_VERSION = "1.18.29";
29
+ export const HEALTHY_HERDR_VERSION = "0.8.2";
30
+ export const HEALTHY_INTEGRATION_STATUS = "opencode: current (v10)";
31
+ export const MISSING_INTEGRATION_STATUS = "opencode: not installed";
32
+ export const WIN32_SPAWN_ERROR = "%1 is not a valid Win32 application";
33
+ export const STABLE_CHANNEL = "stable";
34
+
35
+ function normalizeSource(source) {
36
+ return typeof source === "string" ? source.trim() : "";
37
+ }
38
+
39
+ export function classifyLauncher(source) {
40
+ const value = normalizeSource(source);
41
+ if (!value) return { source: value, kind: "unknown" };
42
+ const lower = value.toLowerCase().replaceAll("/", "\\");
43
+ const base = value.split(/[\\/]/).pop().toLowerCase();
44
+ if (base === "opencode.cmd") return { source: value, kind: "shim-cmd" };
45
+ if (base === "opencode.ps1") return { source: value, kind: "shim-ps1" };
46
+ if (base === "opencode" || base === "opencode.sh") return { source: value, kind: "extensionless-shim" };
47
+ if (base === "opencode.exe") {
48
+ if (lower.includes("\\node_modules\\opencode-ai\\bin\\opencode.exe")) {
49
+ return { source: value, kind: "direct-exe" };
50
+ }
51
+ return { source: value, kind: "shim-exe" };
52
+ }
53
+ return { source: value, kind: "unknown" };
54
+ }
55
+
56
+ export function orderCandidates(sources) {
57
+ const list = Array.isArray(sources) ? sources : [];
58
+ const ordered = [];
59
+ const seen = new Set();
60
+ for (const raw of list) {
61
+ const value = normalizeSource(raw);
62
+ if (!value || seen.has(value.toLowerCase())) continue;
63
+ seen.add(value.toLowerCase());
64
+ const classified = classifyLauncher(value);
65
+ ordered.push({ source: classified.source, kind: classified.kind, order: ordered.length });
66
+ }
67
+ return ordered;
68
+ }
69
+
70
+ export function parseVersionText(text) {
71
+ if (typeof text !== "string") return null;
72
+ const match = text.match(/(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/);
73
+ return match ? match[1] : null;
74
+ }
75
+
76
+ export function checkVersionAgreement(versionEntries) {
77
+ const entries = Array.isArray(versionEntries) ? versionEntries : [];
78
+ const normalized = entries.map((entry) => ({
79
+ source: normalizeSource(entry?.source),
80
+ version: typeof entry?.version === "string" && entry.version.length > 0 ? entry.version : null,
81
+ error: typeof entry?.error === "string" && entry.error.length > 0 ? entry.error : null,
82
+ }));
83
+ const versions = normalized.map((entry) => entry.version);
84
+ const distinct = [...new Set(versions.filter((version) => version !== null))];
85
+ const allHaveVersion = normalized.length > 0 && normalized.every((entry) => entry.version !== null && entry.error === null);
86
+ const agree = allHaveVersion && distinct.length === 1;
87
+ return {
88
+ agree,
89
+ agreement: agree,
90
+ versions: normalized,
91
+ distinct,
92
+ detail: agree
93
+ ? `All ${normalized.length} launcher(s) agree on ${distinct[0]}.`
94
+ : `Launchers disagree or are missing versions: ${normalized.map((entry) => `${entry.source || "(empty)"}=${entry.version || entry.error || "unknown"}`).join(", ") || "no candidates"}.`,
95
+ };
96
+ }
97
+
98
+ export function evaluateSpawnProbe({ source, stdout, stderr, status }) {
99
+ const origin = normalizeSource(source);
100
+ const outText = typeof stdout === "string" ? stdout : "";
101
+ const errText = typeof stderr === "string" ? stderr : "";
102
+ const combined = `${outText}\n${errText}`;
103
+ if (combined.includes(WIN32_SPAWN_ERROR)) {
104
+ return { source: origin, ok: false, version: parseVersionText(outText), error: WIN32_SPAWN_ERROR };
105
+ }
106
+ if (status !== 0) {
107
+ const detail = errText.replace(/\s+/g, " ").trim().slice(0, 500) || `exit status ${String(status)}`;
108
+ return { source: origin, ok: false, version: parseVersionText(outText), error: detail };
109
+ }
110
+ const version = parseVersionText(outText);
111
+ if (!version) {
112
+ return { source: origin, ok: false, version: null, error: "no version in probe output" };
113
+ }
114
+ return { source: origin, ok: true, version, error: null };
115
+ }
116
+
117
+ export function checkIntegrationPresence({ integrationStatusText, herdrHelpText, integrationInstallHelpText } = {}) {
118
+ const statusText = typeof integrationStatusText === "string" ? integrationStatusText : "";
119
+ const helpText = typeof herdrHelpText === "string" ? herdrHelpText : "";
120
+ const installHelpText = typeof integrationInstallHelpText === "string" ? integrationInstallHelpText : "";
121
+ const present = statusText.includes(HEALTHY_INTEGRATION_STATUS);
122
+ const missing = statusText.includes(MISSING_INTEGRATION_STATUS);
123
+ const hasIntegrationSubcommand = helpText.includes("herdr integration") || helpText.includes("integration <subcommand>");
124
+ const installListsOpencode = installHelpText.includes("opencode");
125
+ return {
126
+ present,
127
+ presence: present,
128
+ statusText: statusText ? statusText.slice(0, 2000) : null,
129
+ missing,
130
+ hasIntegrationSubcommand,
131
+ installListsOpencode,
132
+ detail: present
133
+ ? `Integration present: ${HEALTHY_INTEGRATION_STATUS}.`
134
+ : `Integration not present: expected ${HEALTHY_INTEGRATION_STATUS}${missing ? ` (saw ${MISSING_INTEGRATION_STATUS})` : ""}.`,
135
+ };
136
+ }
137
+
138
+ export function detectFlapping({ candidates, versions, npmViewVersion } = {}) {
139
+ const ordered = Array.isArray(candidates) ? candidates : [];
140
+ const versionEntries = Array.isArray(versions) ? versions : [];
141
+ const npmVersion = typeof npmViewVersion === "string" && npmViewVersion.length > 0 ? npmViewVersion : null;
142
+ const hasShim = ordered.some((candidate) => {
143
+ const kind = candidate?.kind || classifyLauncher(candidate?.source || "").kind;
144
+ return kind === "shim-cmd" || kind === "shim-ps1" || kind === "shim-exe" || kind === "extensionless-shim";
145
+ });
146
+ const reappearingShim = ordered.length > 1 && hasShim;
147
+ const winnerVersion = versionEntries[0]?.version || null;
148
+ const bumpedVersion = Boolean(winnerVersion && npmVersion && winnerVersion !== npmVersion);
149
+ const distinct = [...new Set(versionEntries.map((entry) => entry?.version).filter(Boolean))];
150
+ const versionSplit = distinct.length > 1;
151
+ const signals = [];
152
+ if (reappearingShim) signals.push("reappearing shim among ordered candidates after a global update");
153
+ if (bumpedVersion) signals.push(`bumped launcher version ${winnerVersion} versus npm view ${npmVersion}`);
154
+ if (versionSplit) signals.push(`version split across candidates: ${distinct.join(", ")}`);
155
+ if (ordered.length > 1 && ordered[0]?.kind !== "direct-exe" && hasShim) {
156
+ signals.push("winner is a shim while a direct exe exists elsewhere in order");
157
+ }
158
+ const flapping = signals.length > 0;
159
+ return {
160
+ flapping,
161
+ reappearingShim,
162
+ bumpedVersion,
163
+ versionSplit,
164
+ npmViewVersion: npmVersion,
165
+ signals,
166
+ detail: flapping ? `Flapping signals: ${signals.join("; ")}.` : "No flapping signals.",
167
+ };
168
+ }
169
+
170
+ function describeCandidate(candidate, index) {
171
+ return `${index}: ${candidate.source} [${candidate.kind}]`;
172
+ }
173
+
174
+ export function formatHumanSummary(report) {
175
+ const winner = report?.winner ? `${report.winner.source} [${report.winner.kind}]` : "(no winner)";
176
+ const candidates = Array.isArray(report?.candidates) ? report.candidates : [];
177
+ const versions = Array.isArray(report?.versions) ? report.versions : [];
178
+ const agreement = report?.agreement || {};
179
+ const spawnProbe = report?.spawnProbe || {};
180
+ const integration = report?.integration || {};
181
+ const flapping = report?.flapping || {};
182
+ const lines = [];
183
+ lines.push("Launcher diagnostics (read-only, no changes applied).");
184
+ lines.push(`Winner per (Get-Command opencode).Source: ${winner}.`);
185
+ lines.push(`Ordered candidates per (Get-Command opencode -All).Source (${candidates.length}):`);
186
+ for (let index = 0; index < candidates.length; index += 1) {
187
+ const version = versions[index]?.version || versions[index]?.error || "unknown";
188
+ lines.push(`- ${describeCandidate(candidates[index], index)} reports ${version}.`);
189
+ }
190
+ lines.push(
191
+ agreement.agree
192
+ ? `Agreement: all candidates agree on ${agreement.distinct?.[0] || HEALTHY_OPENCODE_VERSION}.`
193
+ : `Agreement: ${agreement.detail || "versions disagree"}. Compare opencode --version against each full-path exe and shim form; healthy hosts report the same ${HEALTHY_OPENCODE_VERSION} from each.`,
194
+ );
195
+ lines.push(
196
+ spawnProbe.ok
197
+ ? `Spawn probe: Start-Process with -FilePath from the resolved launcher plus -ArgumentList --version -NoNewWindow -Wait reports ${spawnProbe.version} without ${WIN32_SPAWN_ERROR}.`
198
+ : `Spawn probe: Start-Process with -FilePath from ${spawnProbe.source || "winner"} plus -ArgumentList --version -NoNewWindow -Wait did not succeed (${spawnProbe.error || "unknown"}); live the extensionless shim fails with ${WIN32_SPAWN_ERROR} while the direct exe reports ${HEALTHY_OPENCODE_VERSION}.`,
199
+ );
200
+ lines.push(
201
+ integration.present
202
+ ? `Integration presence: herdr integration status shows ${HEALTHY_INTEGRATION_STATUS}. herdr --help lists herdr integration <subcommand> and herdr integration install --help lists opencode as a valid target.`
203
+ : `Integration presence: herdr integration status does not show ${HEALTHY_INTEGRATION_STATUS}${integration.missing ? ` (saw ${MISSING_INTEGRATION_STATUS})` : ""}. Check opencode agent list plus opencode debug agent shepherd plus node bin/orchestration.js status.`,
204
+ );
205
+ lines.push(
206
+ flapping.flapping
207
+ ? `Flapping signals: ${flapping.detail || flapping.signals?.join("; ")}. A global opencode upgrade recreates the npm shims, so a previously durable order can flap back to shim-first with a bumped opencode --version versus npm view opencode-ai version. Re-inspect (Get-Command opencode -All).Source for a reappearing shim.`
208
+ : "Flapping signals: none. No reappearing shim and no bumped version versus npm view opencode-ai version.",
209
+ );
210
+ lines.push(`PATH inspected read-only via $env:PATH plus (Get-Command herdr).Source plus (Get-Command herdr -All).Source; Herdr resolves to a single bin exe with no shim confusion per herdr --version ${HEALTHY_HERDR_VERSION}. Confirm the server split with herdr status showing status: running for server separate from client. Autoupdate context is npm view opencode-herdr-orchestration version plus herdr channel show reporting ${STABLE_CHANNEL} plus herdr update --help plus opencode upgrade --help.`);
211
+ lines.push("Operator-executed remedies (this package never overwrites the global environment and never sets global environment values automatically):");
212
+ lines.push("- For current-session relief use the winning full exe path directly.");
213
+ lines.push("- For a durable fix apply a persistent user-chosen PATH reorder that places the direct exe directory before the npm shim directory, then restart Herdr plus the terminal plus OpenCode intentionally when ready.");
214
+ lines.push("- Session-local changes alone never fix Herdr spawns because panes inherit the Herdr server environment; the persistent reorder is an operator-chosen Windows user environment change, not a package change.");
215
+ lines.push("See [Installation](#installation), [Manual Installation](#manual-installation), [Upgrade](#upgrade), and [Recovery](#recovery) for procedures instead of duplicating them here, with [Troubleshooting](#troubleshooting) plus [Missing Herdr OpenCode integration](#missing-herdr-opencode-integration) plus [Windows exe versus shim launcher resolution](#windows-exe-versus-shim-launcher-resolution) for symptom plus check plus verify lineage.");
216
+ return lines.join("\n");
217
+ }
218
+
219
+ export function buildDoctorReport({ candidates, versions, spawnProbe, integration, flapping, pathEntries } = {}) {
220
+ const ordered = orderCandidates(candidates || []);
221
+ const versionEntries = Array.isArray(versions) && versions.length > 0
222
+ ? versions.map((entry, index) => ({
223
+ source: normalizeSource(entry?.source || ordered[index]?.source || ""),
224
+ version: typeof entry?.version === "string" && entry.version ? entry.version : null,
225
+ error: typeof entry?.error === "string" && entry.error ? entry.error : null,
226
+ }))
227
+ : ordered.map((candidate) => ({ source: candidate.source, version: null, error: "unknown" }));
228
+ const agreement = checkVersionAgreement(versionEntries);
229
+ const winner = ordered[0] || null;
230
+ const probe = spawnProbe && typeof spawnProbe === "object" && spawnProbe.source
231
+ ? spawnProbe
232
+ : { source: winner?.source || null, ok: false, version: versionEntries[0]?.version || null, error: versionEntries[0]?.error || "unknown" };
233
+ const integrationResult = integration && typeof integration === "object" ? integration : checkIntegrationPresence({});
234
+ const flappingResult = flapping && typeof flapping === "object" && Array.isArray(flapping.signals)
235
+ ? flapping
236
+ : { flapping: false, reappearingShim: false, bumpedVersion: false, versionSplit: false, npmViewVersion: null, signals: [], detail: "No flapping signals." };
237
+ const pathList = Array.isArray(pathEntries) ? pathEntries : [];
238
+ const base = {
239
+ tool: "doctor",
240
+ readOnly: true,
241
+ winner,
242
+ candidates: ordered,
243
+ orderedCandidates: ordered,
244
+ versions: versionEntries,
245
+ candidateVersions: versionEntries,
246
+ agreement,
247
+ spawnProbe: probe,
248
+ spawnProbes: versionEntries.map((entry) => ({
249
+ source: entry.source,
250
+ ok: entry.version !== null && entry.error === null,
251
+ version: entry.version,
252
+ error: entry.error,
253
+ })),
254
+ integration: integrationResult,
255
+ integrationPresence: integrationResult,
256
+ flapping: flappingResult,
257
+ flappingSignals: flappingResult.signals || [],
258
+ path: { entries: pathList, inspectedReadOnly: true },
259
+ remedies: [
260
+ "For current-session relief use the winning full exe path directly.",
261
+ "For a durable fix apply a persistent user-chosen PATH reorder that places the direct exe directory before the npm shim directory, then restart Herdr plus the terminal plus OpenCode intentionally when ready.",
262
+ "Session-local changes alone never fix Herdr spawns because panes inherit the Herdr server environment.",
263
+ ],
264
+ anchors: [...DOCTOR_ANCHORS],
265
+ readmeAnchors: [...DOCTOR_ANCHORS],
266
+ };
267
+ const summary = formatHumanSummary(base);
268
+ return {
269
+ ...base,
270
+ operatorRemedies: [...base.remedies],
271
+ summary,
272
+ humanSummary: summary,
273
+ };
274
+ }
275
+
276
+ function runCrossSpawnVersion(candidate) {
277
+ try {
278
+ const result = spawn.sync(candidate, ["--version"], { encoding: "utf8", windowsHide: true, timeout: 15000 });
279
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
280
+ const stderr = typeof result.stderr === "string" ? result.stderr : (result.error ? String(result.error.message || result.error) : "");
281
+ const version = parseVersionText(stdout);
282
+ if (result.error) {
283
+ return { source: candidate, version, error: String(result.error.message || result.error).slice(0, 500) };
284
+ }
285
+ if (result.status !== 0) {
286
+ return { source: candidate, version, error: (stderr || `exit status ${String(result.status)}`).replace(/\s+/g, " ").trim().slice(0, 500) };
287
+ }
288
+ if (!version) return { source: candidate, version: null, error: "no version in output" };
289
+ return { source: candidate, version, error: null };
290
+ } catch (error) {
291
+ return { source: candidate, version: null, error: String(error?.message || error).slice(0, 500) };
292
+ }
293
+ }
294
+
295
+ function runHelpCapture(command, args) {
296
+ try {
297
+ const result = spawn.sync(command, args, { encoding: "utf8", windowsHide: true, timeout: 15000 });
298
+ if (result.error) return "";
299
+ return typeof result.stdout === "string" ? result.stdout : "";
300
+ } catch {
301
+ return "";
302
+ }
303
+ }
304
+
305
+ function collectLiveCandidates(envPath) {
306
+ const currentPlatform = platform();
307
+ if (currentPlatform === "win32") {
308
+ try {
309
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "(Get-Command opencode -All).Source"], {
310
+ encoding: "utf8",
311
+ windowsHide: true,
312
+ timeout: 15000,
313
+ });
314
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
315
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
316
+ if (lines.length > 0) return lines;
317
+ } catch {}
318
+ }
319
+ const raw = typeof envPath === "string" ? envPath : "";
320
+ const entries = raw.split(delimiter).map((entry) => entry.trim()).filter(Boolean);
321
+ const found = [];
322
+ const names = currentPlatform === "win32" ? ["opencode.cmd", "opencode.ps1", "opencode.exe", "opencode"] : ["opencode"];
323
+ for (const dir of entries) {
324
+ for (const name of names) {
325
+ const candidate = join(dir.replace(/[\\/]+$/, ""), name);
326
+ try {
327
+ if (existsSync(candidate)) found.push(candidate);
328
+ } catch {}
329
+ }
330
+ if (found.length >= 8) break;
331
+ }
332
+ return found;
333
+ }
334
+
335
+ function collectLivePathEntries(envPath) {
336
+ const raw = typeof envPath === "string" ? envPath : "";
337
+ return raw.split(delimiter).map((entry) => entry.trim()).filter(Boolean);
338
+ }
339
+
340
+ export function collectDoctorReport(options = {}) {
341
+ const envPath = options.envPath !== undefined ? options.envPath : (process.env.PATH || process.env.Path || "");
342
+ const liveCandidates = options.candidates !== undefined ? options.candidates : collectLiveCandidates(envPath);
343
+ const ordered = orderCandidates(liveCandidates);
344
+ const liveVersions = options.versions !== undefined
345
+ ? options.versions
346
+ : ordered.map((candidate) => runCrossSpawnVersion(candidate.source));
347
+ const agreement = checkVersionAgreement(liveVersions);
348
+ const winnerProbeInput = options.spawnProbe !== undefined ? options.spawnProbe : (() => {
349
+ const winnerSource = ordered[0]?.source;
350
+ if (!winnerSource) return { source: null, ok: false, version: null, error: "no candidates" };
351
+ const match = liveVersions[0];
352
+ if (match && match.error && String(match.error).includes(WIN32_SPAWN_ERROR)) {
353
+ return evaluateSpawnProbe({ source: winnerSource, stdout: "", stderr: match.error, status: 1 });
354
+ }
355
+ if (match && match.version) {
356
+ return { source: winnerSource, ok: match.error === null, version: match.version, error: match.error };
357
+ }
358
+ return { source: winnerSource, ok: false, version: null, error: (match && match.error) || "unknown" };
359
+ })();
360
+ const integration = options.integration !== undefined ? options.integration : checkIntegrationPresence({
361
+ integrationStatusText: runHelpCapture("herdr", ["integration", "status"]),
362
+ herdrHelpText: runHelpCapture("herdr", ["--help"]),
363
+ integrationInstallHelpText: runHelpCapture("herdr", ["integration", "install", "--help"]),
364
+ });
365
+ let npmViewVersion = null;
366
+ if (options.npmViewVersion !== undefined) {
367
+ npmViewVersion = options.npmViewVersion;
368
+ } else {
369
+ const npmText = runHelpCapture("npm", ["view", "opencode-ai", "version"]);
370
+ npmViewVersion = parseVersionText(npmText);
371
+ }
372
+ const flapping = options.flapping !== undefined
373
+ ? options.flapping
374
+ : detectFlapping({ candidates: ordered, versions: liveVersions, npmViewVersion });
375
+ return buildDoctorReport({
376
+ candidates: ordered.map((candidate) => candidate.source),
377
+ versions: liveVersions,
378
+ spawnProbe: winnerProbeInput,
379
+ integration,
380
+ flapping,
381
+ pathEntries: collectLivePathEntries(envPath),
382
+ });
383
+ }
package/src/prompts.js CHANGED
@@ -1,6 +1,6 @@
1
- // 14-18-M2 prompt policy plus startup layout plus placement behavior.
2
- // Shared M1 normative pane policy sentences are cited with the same wording
3
- // as README Pane layout policy (14-18-M1) so prompts and runtime cannot drift.
1
+ // Prompt policy plus startup layout plus placement behavior.
2
+ // Shared normative pane policy sentences are cited with the same wording
3
+ // as docs architecture tab section so prompts and runtime cannot drift.
4
4
  // Every spawning role carries the same shared sentences; role-specific
5
5
  // ownership plus startup destinations differ per role. Leaves stay unchanged.
6
6
  // Spawn plus response plus state matrices stay intact with no new spawn targets.
@@ -9,8 +9,9 @@ export const PANE_CAP = 4;
9
9
  export const DEV_PANE_LABELS = Object.freeze(["Dev", "Developer Terminal"]);
10
10
  export const PANE_POLICY_SHARED_SENTENCES = Object.freeze([
11
11
  "at most four panes per tab including the caller pane",
12
- "Never split when the filtered count is already four; use indexed overflow instead.",
13
- "Overflow by index within role grouping instead of creating a fifth pane.",
12
+ "Reuse first within the four-pane cap per tab",
13
+ "Never split when the filtered count is already four; overflow to a new tab instead.",
14
+ "Overflow to a new tab with indexed role labels when the cap binds.",
14
15
  "Grouped by role with indexed labels (Sheepdog, sheep-1, sheep-2, shearer-low-1, grazer-1)",
15
16
  "tabs keep their existing labels",
16
17
  "Reuse the matching pane when found; split only when no reusable pane exists and the cap permits.",
@@ -19,26 +20,27 @@ export const PANE_POLICY_SHARED_SENTENCES = Object.freeze([
19
20
  "excluded from every scan plus split plus placement plus rename plus close plus reuse",
20
21
  "Never count it toward the four-pane cap, never list it as a reuse candidate, never split from it or into it, never place a worker there, never rename it, never close it, and never reuse it for overflow.",
21
22
  "Filter it during scan by terminal_title plus terminal_title_stripped plus label before counting plus reusing",
22
- "when only the Dev pane would satisfy reuse, treat reuse as absent and either split elsewhere within cap or report STOP with preserved state.",
23
- "Never create a workspace or tab to evade the cap",
23
+ "when only the Dev pane would satisfy reuse, treat reuse as absent and either split elsewhere within cap or overflow to a new tab or report STOP with preserved state.",
24
+ "Never create a workspace to evade the cap",
25
+ "Never touch the Dev pane in any tab.",
24
26
  "Start in the calling pane and never rely on another client focused pane.",
25
27
  "If any primitive below is missing, reuse the current pane via --pane plus --current and report STOP naming the missing capability with preserved state.",
26
28
  "Pre-create default stays minimal",
27
29
  "On finish, reuse the pane for the next same-role assignment after confirming idle plus done.",
28
- "Six-step placement is single-tab plus reuse-first plus evidence-only",
29
- "Dynamic placement follows the same single normative pane policy with no separate rulebook.",
30
+ "Six-step placement is reuse-first plus evidence-only with new-tab overflow",
31
+ "Dynamic placement follows the same single normative pane policy with new-tab overflow and no separate rulebook.",
30
32
  ]);
31
33
  export const PANE_POLICY_SHARED_PARAGRAPH = String.raw`
32
- Pane layout policy (single normative policy, same wording as README): Four-pane cap: at most four panes per tab including the caller pane. Never split when the filtered count is already four; use indexed overflow instead. Overflow by index within role grouping instead of creating a fifth pane. Grouped by role with indexed labels (Sheepdog, sheep-1, sheep-2, shearer-low-1, grazer-1); tabs keep their existing labels. Reuse the matching pane when found; split only when no reusable pane exists and the cap permits. Reuse is preferred over clutter: reuse a capacity-available managed pane for the same role before splitting. Never derive IDs from sidebar order or examples; parse them from JSON responses. The pane labeled Dev (Developer Terminal) is excluded from every scan plus split plus placement plus rename plus close plus reuse. Never count it toward the four-pane cap, never list it as a reuse candidate, never split from it or into it, never place a worker there, never rename it, never close it, and never reuse it for overflow. Filter it during scan by terminal_title plus terminal_title_stripped plus label before counting plus reusing; when only the Dev pane would satisfy reuse, treat reuse as absent and either split elsewhere within cap or report STOP with preserved state. Never create a workspace or tab to evade the cap. Start in the calling pane and never rely on another client focused pane. If any primitive below is missing, reuse the current pane via --pane plus --current and report STOP naming the missing capability with preserved state. Pre-create default stays minimal: reuse the current pane via --pane plus --current when primitives are missing. On finish, reuse the pane for the next same-role assignment after confirming idle plus done. Six-step placement is single-tab plus reuse-first plus evidence-only. Dynamic placement follows the same single normative pane policy with no separate rulebook.
34
+ Pane layout policy (single normative policy, same wording as docs architecture tab section): Four-pane cap: at most four panes per tab including the caller pane. Reuse first within the four-pane cap per tab. Never split when the filtered count is already four; overflow to a new tab instead. Overflow to a new tab with indexed role labels when the cap binds. Grouped by role with indexed labels (Sheepdog, sheep-1, sheep-2, shearer-low-1, grazer-1); tabs keep their existing labels. Reuse the matching pane when found; split only when no reusable pane exists and the cap permits. Reuse is preferred over clutter: reuse a capacity-available managed pane for the same role before splitting. Never derive IDs from sidebar order or examples; parse them from JSON responses. The pane labeled Dev (Developer Terminal) is excluded from every scan plus split plus placement plus rename plus close plus reuse. Never count it toward the four-pane cap, never list it as a reuse candidate, never split from it or into it, never place a worker there, never rename it, never close it, and never reuse it for overflow. Filter it during scan by terminal_title plus terminal_title_stripped plus label before counting plus reusing; when only the Dev pane would satisfy reuse, treat reuse as absent and either split elsewhere within cap or overflow to a new tab or report STOP with preserved state. Never create a workspace to evade the cap. Never touch the Dev pane in any tab. Start in the calling pane and never rely on another client focused pane. If any primitive below is missing, reuse the current pane via --pane plus --current and report STOP naming the missing capability with preserved state. Pre-create default stays minimal: reuse the current pane via --pane plus --current when primitives are missing. On finish, reuse the pane for the next same-role assignment after confirming idle plus done. Six-step placement is reuse-first plus evidence-only with new-tab overflow. Dynamic placement follows the same single normative pane policy with new-tab overflow and no separate rulebook.
33
35
  `.trim();
34
36
  export const SHEPHERD_PANE_OWNERSHIP_PARAGRAPH = String.raw`
35
- Shepherd pane ownership plus startup: shepherd manages its single Sheepdog pane scan plus placement plus rename only and never closes; starts its grazer in a sibling pane of the current tab; leaves gain no pane plus tab plus rename commands and stay unchanged.
37
+ Shepherd pane ownership plus startup: shepherd manages its single Sheepdog pane scan plus placement plus rename plus tab create only and never closes; starts its grazer in a sibling pane of the current tab with overflow to a new tab with indexed role labels when the cap binds; leaves gain no pane plus tab plus rename commands and stay unchanged.
36
38
  `.trim();
37
39
  export const GOVERNOR_PANE_OWNERSHIP_PARAGRAPH = String.raw`
38
40
  Governor pane ownership plus startup: shepherd-governor manages its single Sheepdog pane scan plus placement plus rename only and never closes flock panes; starts its sheepdog in a dedicated sibling Sheepdog pane of the current tab; leaves gain no pane plus tab plus rename commands and stay unchanged.
39
41
  `.trim();
40
42
  export const SHEEPDOG_PANE_OWNERSHIP_PARAGRAPH = String.raw`
41
- Sheepdog pane ownership plus startup plus dynamic placement: sheepdog manages flock panes scan plus placement plus rename plus close up to the cap; only the creator may rename plus close its panes and only after commits are integrated or otherwise preserved and the worktree is clean; starts in its own Sheepdog pane and starts flock workers in sibling flock panes of the same tab grouped by role with indexed labels; startup destinations for grazer plus sheep plus shearer-low plus shearer-medium worker categories stay within the four-pane cap with Dev excluded and pre-create default minimal; six-step placement is single-tab plus reuse-first plus evidence-only with cap check plus reuse check plus split plus rename plus start; on cap go to indexed overflow reuse and never split; reuse capacity-available managed panes first and reuse the pane on finish after confirming idle plus done; never create a new tab or workspace to evade the cap.
43
+ Sheepdog pane ownership plus startup plus dynamic placement: sheepdog manages flock panes scan plus placement plus rename plus close plus tab create up to the per-tab cap; only the creator may rename plus close its panes and only after commits are integrated or otherwise preserved and the worktree is clean; starts in its own Sheepdog pane and starts flock workers in sibling flock panes of the current tab with overflow to a new tab with indexed role labels when the cap binds grouped by role with indexed labels; startup destinations for grazer plus sheep plus shearer-low plus shearer-medium worker categories stay within the four-pane cap per tab with Dev excluded in any tab and pre-create default minimal; six-step placement is reuse-first plus evidence-only with new-tab overflow with cap check plus reuse check plus split plus rename plus tab create plus start; on cap go to a new tab with indexed role labels and never split a fifth pane; reuse capacity-available managed panes first within the cap and reuse the pane on finish after confirming idle plus done; never create a workspace to evade the cap and never touch the Dev pane in any tab.
42
44
  `.trim();
43
45
  export const SHEPHERD_PROMPT = String.raw`
44
46
  You are shepherd, the planning authority of the flock. You research the user's goal through grazer workers and present an implementation-ready plan. You never implement, integrate, or deliver; execution and final delivery belong to shepherd-governor after the user approves your plan by selecting it.
@@ -258,13 +260,14 @@ Keep summaries secondary to findings. Never implement fixes.
258
260
  Raw Developer steering is not yours: shepherd phases own raw check, read, consume, and lifecycle tools in code and you are denied them.
259
261
  `.trim();
260
262
 
261
- // 14-18-M2 placement helpers (pure, evidence-only, same policy, no separate rulebook).
263
+ // Placement helpers (pure, evidence-only, same policy, no separate rulebook).
262
264
  // These helpers operate on abstract pane records without Herdr calls, so they
263
265
  // need no broader spawn authority and no missing CLI primitives. They mirror
264
- // the shared normative wording above: four-pane cap, Dev exclusion, reuse
265
- // before create, indexed overflow, startup destinations within cap, and
266
- // pre-create default minimal. Caller context stays via current pane ID and
267
- // never relies on another client focused pane.
266
+ // the shared normative wording above: four-pane cap per tab, Dev exclusion
267
+ // in any tab, reuse first within cap, new-tab overflow with indexed role
268
+ // labels, startup destinations per tab, and pre-create default minimal.
269
+ // Caller context stays via current pane ID and never relies on another
270
+ // client focused pane.
268
271
  export function isDevPane(pane) {
269
272
  if (!pane || typeof pane !== "object") return false;
270
273
  for (const key of ["label", "terminal_title", "terminal_title_stripped"]) {
@@ -318,21 +321,13 @@ export function decidePanePlacement({ panes, tabId, reuseCandidateId, currentPan
318
321
  return {
319
322
  action: "overflow-reuse",
320
323
  paneId: reuseCandidateId,
321
- reason: "Never split when the filtered count is already four; use indexed overflow instead.",
322
- };
323
- }
324
- const firstManaged = managedInTab[0];
325
- if (firstManaged && !candidateIsDev) {
326
- return {
327
- action: "overflow-reuse",
328
- paneId: firstManaged.pane_id,
329
- reason: "Overflow by index within role grouping instead of creating a fifth pane.",
324
+ reason: "Reuse first within the four-pane cap per tab",
330
325
  };
331
326
  }
332
327
  return {
333
- action: "overflow-reuse",
334
- paneId: preCreateDefaultPane(currentPaneId) ?? null,
335
- reason: "when only the Dev pane would satisfy reuse, treat reuse as absent and either split elsewhere within cap or report STOP with preserved state.",
328
+ action: "new-tab",
329
+ paneId: null,
330
+ reason: "Overflow to a new tab with indexed role labels when the cap binds.",
336
331
  };
337
332
  }
338
333
 
@@ -348,7 +343,7 @@ export function decidePanePlacement({ panes, tabId, reuseCandidateId, currentPan
348
343
  return {
349
344
  action: "split",
350
345
  paneId: null,
351
- reason: "when only the Dev pane would satisfy reuse, treat reuse as absent and either split elsewhere within cap or report STOP with preserved state.",
346
+ reason: "when only the Dev pane would satisfy reuse, treat reuse as absent and either split elsewhere within cap or overflow to a new tab or report STOP with preserved state.",
352
347
  };
353
348
  }
354
349