@ifrankwang/openspec-agents 0.142.0 → 0.144.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.
@@ -19408,6 +19408,9 @@ class Protocol {
19408
19408
  this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => {
19409
19409
  const handleTaskResult = async () => {
19410
19410
  const taskId = request.params.taskId;
19411
+ if (!await this._taskStore.getTask(taskId, extra.sessionId)) {
19412
+ throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
19413
+ }
19411
19414
  if (this._taskMessageQueue) {
19412
19415
  let queuedMessage;
19413
19416
  while (queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId)) {
@@ -19438,12 +19441,12 @@ class Protocol {
19438
19441
  throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`);
19439
19442
  }
19440
19443
  if (!isTerminal(task.status)) {
19441
- await this._waitForTaskUpdate(taskId, extra.signal);
19444
+ await this._waitForTaskUpdate(taskId, extra.signal, extra.sessionId);
19442
19445
  return await handleTaskResult();
19443
19446
  }
19444
19447
  if (isTerminal(task.status)) {
19445
19448
  const result = await this._taskStore.getTaskResult(taskId, extra.sessionId);
19446
- this._clearTaskQueue(taskId);
19449
+ this._clearTaskQueue(taskId, extra.sessionId);
19447
19450
  return {
19448
19451
  ...result,
19449
19452
  _meta: {
@@ -19480,7 +19483,7 @@ class Protocol {
19480
19483
  throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`);
19481
19484
  }
19482
19485
  await this._taskStore.updateTaskStatus(request.params.taskId, "cancelled", "Client cancelled task execution.", extra.sessionId);
19483
- this._clearTaskQueue(request.params.taskId);
19486
+ this._clearTaskQueue(request.params.taskId, extra.sessionId);
19484
19487
  const cancelledTask = await this._taskStore.getTask(request.params.taskId, extra.sessionId);
19485
19488
  if (!cancelledTask) {
19486
19489
  throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`);
@@ -19603,6 +19606,19 @@ class Protocol {
19603
19606
  const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler;
19604
19607
  const capturedTransport = this._transport;
19605
19608
  const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId;
19609
+ const sessionId = capturedTransport?.sessionId;
19610
+ const store = this._taskStore;
19611
+ let relatedTaskFound = true;
19612
+ let relatedTaskLookup;
19613
+ if (relatedTaskId && store && this._taskMessageQueue && sessionId !== undefined) {
19614
+ relatedTaskFound = false;
19615
+ relatedTaskLookup = (async () => {
19616
+ if (!await store.getTask(relatedTaskId, sessionId)) {
19617
+ throw new McpError(ErrorCode.InvalidParams, `Task not found: ${relatedTaskId}`);
19618
+ }
19619
+ relatedTaskFound = true;
19620
+ })();
19621
+ }
19606
19622
  if (handler === undefined) {
19607
19623
  const errorResponse = {
19608
19624
  jsonrpc: "2.0",
@@ -19612,7 +19628,10 @@ class Protocol {
19612
19628
  message: "Method not found"
19613
19629
  }
19614
19630
  };
19615
- if (relatedTaskId && this._taskMessageQueue) {
19631
+ if (relatedTaskId && relatedTaskLookup) {
19632
+ const queuedError = { type: "error", message: errorResponse, timestamp: Date.now() };
19633
+ relatedTaskLookup.then(() => this._enqueueTaskMessage(relatedTaskId, queuedError, sessionId), () => capturedTransport?.send(errorResponse)).catch((error) => this._onerror(new Error(`Failed to send an error response: ${error}`)));
19634
+ } else if (relatedTaskId && this._taskMessageQueue) {
19616
19635
  this._enqueueTaskMessage(relatedTaskId, {
19617
19636
  type: "error",
19618
19637
  message: errorResponse,
@@ -19663,7 +19682,10 @@ class Protocol {
19663
19682
  closeSSEStream: extra?.closeSSEStream,
19664
19683
  closeStandaloneSSEStream: extra?.closeStandaloneSSEStream
19665
19684
  };
19666
- Promise.resolve().then(() => {
19685
+ (relatedTaskLookup ?? Promise.resolve()).then(() => {
19686
+ if (relatedTaskLookup && abortController.signal.aborted) {
19687
+ throw new McpError(ErrorCode.ConnectionClosed, "Request was cancelled");
19688
+ }
19667
19689
  if (taskCreationParams) {
19668
19690
  this.assertTaskHandlerCapability(request.method);
19669
19691
  }
@@ -19698,7 +19720,7 @@ class Protocol {
19698
19720
  ...error["data"] !== undefined && { data: error["data"] }
19699
19721
  }
19700
19722
  };
19701
- if (relatedTaskId && this._taskMessageQueue) {
19723
+ if (relatedTaskId && this._taskMessageQueue && relatedTaskFound) {
19702
19724
  await this._enqueueTaskMessage(relatedTaskId, {
19703
19725
  type: "error",
19704
19726
  message: errorResponse,
@@ -20086,7 +20108,7 @@ class Protocol {
20086
20108
  throw new Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured");
20087
20109
  }
20088
20110
  const maxQueueSize = this._options?.maxTaskQueueSize;
20089
- await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize);
20111
+ await this._taskMessageQueue.enqueue(taskId, message, sessionId ?? this._transport?.sessionId, maxQueueSize);
20090
20112
  }
20091
20113
  async _clearTaskQueue(taskId, sessionId) {
20092
20114
  if (this._taskMessageQueue) {
@@ -20105,10 +20127,10 @@ class Protocol {
20105
20127
  }
20106
20128
  }
20107
20129
  }
20108
- async _waitForTaskUpdate(taskId, signal) {
20130
+ async _waitForTaskUpdate(taskId, signal, sessionId) {
20109
20131
  let interval = this._options?.defaultTaskPollInterval ?? 1000;
20110
20132
  try {
20111
- const task = await this._taskStore?.getTask(taskId);
20133
+ const task = await this._taskStore?.getTask(taskId, sessionId);
20112
20134
  if (task?.pollInterval) {
20113
20135
  interval = task.pollInterval;
20114
20136
  }
@@ -20775,6 +20797,43 @@ class ExperimentalMcpServerTasks {
20775
20797
  }
20776
20798
  }
20777
20799
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.js
20800
+ function toolInputElementCount(value, max) {
20801
+ let count = 0;
20802
+ const stack = [value];
20803
+ while (stack.length > 0) {
20804
+ const node = stack.pop();
20805
+ if (node === null || typeof node !== "object")
20806
+ continue;
20807
+ if (Array.isArray(node)) {
20808
+ for (const child of node) {
20809
+ if (++count > max)
20810
+ return count;
20811
+ if (child !== null && typeof child === "object")
20812
+ stack.push(child);
20813
+ }
20814
+ } else {
20815
+ for (const key in node) {
20816
+ if (!Object.prototype.hasOwnProperty.call(node, key))
20817
+ continue;
20818
+ if (++count > max)
20819
+ return count;
20820
+ const child = node[key];
20821
+ if (child !== null && typeof child === "object")
20822
+ stack.push(child);
20823
+ }
20824
+ }
20825
+ }
20826
+ return count;
20827
+ }
20828
+ function resolveMaxToolInputElements(value) {
20829
+ if (value === undefined || value === Infinity)
20830
+ return;
20831
+ if (typeof value !== "number" || Number.isNaN(value) || value < 1) {
20832
+ throw new RangeError(`maxToolInputElements must be a number of at least 1, or Infinity, got ${String(value)}`);
20833
+ }
20834
+ return value;
20835
+ }
20836
+
20778
20837
  class McpServer {
20779
20838
  constructor(serverInfo, options) {
20780
20839
  this._registeredResources = {};
@@ -20786,6 +20845,7 @@ class McpServer {
20786
20845
  this._resourceHandlersInitialized = false;
20787
20846
  this._promptHandlersInitialized = false;
20788
20847
  this.server = new Server(serverInfo, options);
20848
+ this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements);
20789
20849
  }
20790
20850
  get experimental() {
20791
20851
  if (!this._experimental) {
@@ -20892,12 +20952,15 @@ class McpServer {
20892
20952
  };
20893
20953
  }
20894
20954
  async validateToolInput(tool, args, toolName) {
20955
+ if (this._maxToolInputElements !== undefined && toolInputElementCount(args, this._maxToolInputElements) > this._maxToolInputElements) {
20956
+ throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for tool ${toolName}: arguments contain more than the maximum of ${this._maxToolInputElements} elements`);
20957
+ }
20895
20958
  if (!tool.inputSchema) {
20896
20959
  return;
20897
20960
  }
20898
20961
  const inputObj = normalizeObjectSchema(tool.inputSchema);
20899
20962
  const schemaToParse = inputObj ?? tool.inputSchema;
20900
- const parseResult = await safeParseAsync3(schemaToParse, args);
20963
+ const parseResult = await safeParseAsync3(schemaToParse, args ?? {});
20901
20964
  if (!parseResult.success) {
20902
20965
  const error = "error" in parseResult ? parseResult.error : "Unknown error";
20903
20966
  const errorMessage = getParseErrorMessage(error);
@@ -21122,7 +21185,7 @@ class McpServer {
21122
21185
  }
21123
21186
  if (prompt.argsSchema) {
21124
21187
  const argsObj = normalizeObjectSchema(prompt.argsSchema);
21125
- const parseResult = await safeParseAsync3(argsObj, request.params.arguments);
21188
+ const parseResult = await safeParseAsync3(argsObj, request.params.arguments ?? {});
21126
21189
  if (!parseResult.success) {
21127
21190
  const error = "error" in parseResult ? parseResult.error : "Unknown error";
21128
21191
  const errorMessage = getParseErrorMessage(error);
@@ -30459,7 +30522,7 @@ async function ensureDefaultUnattended(args, ctx) {
30459
30522
  }
30460
30523
  } catch {}
30461
30524
  }
30462
- var PKG_VERSION = "0.142.0";
30525
+ var PKG_VERSION = "0.144.0";
30463
30526
  function buildMcpServer(worktree, opts = {}) {
30464
30527
  const mcp = new McpServer({ name: "openspec-agents", version: PKG_VERSION });
30465
30528
  for (const [name, spec] of Object.entries(TOOL_SPECS)) {
@@ -30486,7 +30549,7 @@ function buildMcpServer(worktree, opts = {}) {
30486
30549
  }
30487
30550
 
30488
30551
  // src/adapters/mcp-common/index.ts
30489
- import { createServer as createServer2 } from "node:http";
30552
+ import { createServer as createServer3 } from "node:http";
30490
30553
  import { randomUUID } from "node:crypto";
30491
30554
  import { join as join5 } from "node:path";
30492
30555
 
@@ -30494,6 +30557,7 @@ import { join as join5 } from "node:path";
30494
30557
  var X_ALREADY_SENT = "x-hono-already-sent";
30495
30558
 
30496
30559
  // node_modules/@hono/node-server/dist/index.mjs
30560
+ import { STATUS_CODES, ServerResponse, createServer } from "node:http";
30497
30561
  import { Http2ServerRequest, constants } from "node:http2";
30498
30562
  import { Readable } from "node:stream";
30499
30563
 
@@ -30503,20 +30567,17 @@ var defineWebSocketHelper = (handler) => {
30503
30567
  if (typeof args[0] === "function") {
30504
30568
  const [createEvents, options] = args;
30505
30569
  return async function upgradeWebSocket(c, next) {
30506
- const events = await createEvents(c);
30507
- const result = await handler(c, events, options);
30508
- if (result) {
30570
+ const result = await handler(c, await createEvents(c), options);
30571
+ if (result)
30509
30572
  return result;
30510
- }
30511
30573
  await next();
30512
30574
  };
30513
30575
  } else {
30514
30576
  const [c, events, options] = args;
30515
30577
  return (async () => {
30516
30578
  const upgraded = await handler(c, events, options);
30517
- if (!upgraded) {
30579
+ if (!upgraded)
30518
30580
  throw new Error("Failed to upgrade WebSocket");
30519
- }
30520
30581
  return upgraded;
30521
30582
  })();
30522
30583
  }
@@ -31479,8 +31540,11 @@ var handleResponseError = (e, outgoing) => {
31479
31540
  console.info("The user aborted a request.");
31480
31541
  else {
31481
31542
  console.error(e);
31482
- if (!outgoing.headersSent)
31543
+ if (!outgoing.headersSent) {
31544
+ if (outgoing instanceof ServerResponse)
31545
+ outgoing._contentLength = null;
31483
31546
  outgoing.writeHead(500, { "Content-Type": "text/plain" });
31547
+ }
31484
31548
  outgoing.end(`Error: ${err.message}`);
31485
31549
  outgoing.destroy(err);
31486
31550
  }
@@ -31489,6 +31553,23 @@ var flushHeaders = (outgoing) => {
31489
31553
  if ("flushHeaders" in outgoing && outgoing.writable)
31490
31554
  outgoing.flushHeaders();
31491
31555
  };
31556
+ var trySetContentLength = (outgoing, status, length) => {
31557
+ const http1 = outgoing;
31558
+ if (http1._contentLength === null && http1._hasBody && http1.useChunkedEncodingByDefault && !http1._removedContLen && status >= 200 && status !== 204 && status !== 304 && !outgoing.hasHeader("content-length") && !outgoing.hasHeader("transfer-encoding") && !outgoing.hasHeader("trailer")) {
31559
+ http1._contentLength = length;
31560
+ return true;
31561
+ }
31562
+ return false;
31563
+ };
31564
+ var writeDefaultHeaders = (outgoing, status, length) => {
31565
+ if (trySetContentLength(outgoing, status, length))
31566
+ outgoing.writeHead(status, { "Content-Type": defaultContentType });
31567
+ else
31568
+ outgoing.writeHead(status, {
31569
+ "Content-Type": defaultContentType,
31570
+ "Content-Length": length
31571
+ });
31572
+ };
31492
31573
  var responseViaCache = async (res, outgoing) => {
31493
31574
  let [status, body, header] = res[cacheKey2];
31494
31575
  if (!header) {
@@ -31496,22 +31577,13 @@ var responseViaCache = async (res, outgoing) => {
31496
31577
  outgoing.writeHead(status);
31497
31578
  outgoing.end();
31498
31579
  } else if (typeof body === "string") {
31499
- outgoing.writeHead(status, {
31500
- "Content-Type": defaultContentType,
31501
- "Content-Length": Buffer.byteLength(body)
31502
- });
31580
+ writeDefaultHeaders(outgoing, status, Buffer.byteLength(body));
31503
31581
  outgoing.end(body);
31504
31582
  } else if (body instanceof Uint8Array) {
31505
- outgoing.writeHead(status, {
31506
- "Content-Type": defaultContentType,
31507
- "Content-Length": body.byteLength
31508
- });
31583
+ writeDefaultHeaders(outgoing, status, body.byteLength);
31509
31584
  outgoing.end(body);
31510
31585
  } else if (body instanceof Blob) {
31511
- outgoing.writeHead(status, {
31512
- "Content-Type": defaultContentType,
31513
- "Content-Length": body.size
31514
- });
31586
+ writeDefaultHeaders(outgoing, status, body.size);
31515
31587
  outgoing.end(new Uint8Array(await body.arrayBuffer()));
31516
31588
  } else {
31517
31589
  outgoing.writeHead(status, { "Content-Type": defaultContentType });
@@ -31522,6 +31594,8 @@ var responseViaCache = async (res, outgoing) => {
31522
31594
  return;
31523
31595
  }
31524
31596
  let hasContentLength = false;
31597
+ let plainHeaders = false;
31598
+ let canAutoLength = true;
31525
31599
  if (header instanceof Headers) {
31526
31600
  hasContentLength = header.has("content-length");
31527
31601
  header = buildOutgoingHttpHeaders(header, body === null ? undefined : defaultContentType);
@@ -31529,19 +31603,30 @@ var responseViaCache = async (res, outgoing) => {
31529
31603
  const headerObj = new Headers(header);
31530
31604
  hasContentLength = headerObj.has("content-length");
31531
31605
  header = buildOutgoingHttpHeaders(headerObj, body === null ? undefined : defaultContentType);
31532
- } else
31533
- for (const key in header)
31606
+ } else {
31607
+ plainHeaders = true;
31608
+ for (const key in header) {
31534
31609
  if (key.length === 14 && key.toLowerCase() === "content-length") {
31535
31610
  hasContentLength = true;
31536
31611
  break;
31537
31612
  }
31613
+ if (key.length === 17 && key.toLowerCase() === "transfer-encoding" || key.length === 7 && key.toLowerCase() === "trailer")
31614
+ canAutoLength = false;
31615
+ }
31616
+ }
31538
31617
  if (!hasContentLength) {
31618
+ let length;
31539
31619
  if (typeof body === "string")
31540
- header["Content-Length"] = Buffer.byteLength(body);
31620
+ length = Buffer.byteLength(body);
31541
31621
  else if (body instanceof Uint8Array)
31542
- header["Content-Length"] = body.byteLength;
31622
+ length = body.byteLength;
31543
31623
  else if (body instanceof Blob)
31544
- header["Content-Length"] = body.size;
31624
+ length = body.size;
31625
+ if (length !== undefined && (!plainHeaders || !canAutoLength || !trySetContentLength(outgoing, status, length))) {
31626
+ if (plainHeaders)
31627
+ header = { ...header };
31628
+ header["Content-Length"] = length;
31629
+ }
31545
31630
  }
31546
31631
  outgoing.writeHead(status, header);
31547
31632
  if (body == null)
@@ -31910,6 +31995,50 @@ function isJsonContentType(header) {
31910
31995
  return mediaTypeEssence(header) === "application/json";
31911
31996
  }
31912
31997
 
31998
+ // node_modules/@modelcontextprotocol/sdk/dist/esm/server/requestBody.js
31999
+ var DEFAULT_MAX_REQUEST_BODY_SIZE = 4 * 1024 * 1024;
32000
+ var MAX_BATCH_SIZE = 100;
32001
+ function requestBodyTooLargeMessage(maxBytes) {
32002
+ return `Payload Too Large: Request body must not exceed ${maxBytes} bytes`;
32003
+ }
32004
+ function resolveMaxRequestBodySize(value) {
32005
+ if (value === undefined) {
32006
+ return DEFAULT_MAX_REQUEST_BODY_SIZE;
32007
+ }
32008
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
32009
+ throw new RangeError(`maxRequestBodySize must be a positive number of bytes, got ${String(value)}`);
32010
+ }
32011
+ return value;
32012
+ }
32013
+ async function readRequestBody(request, maxBytes = DEFAULT_MAX_REQUEST_BODY_SIZE) {
32014
+ if (Number(request.headers.get("content-length")) > maxBytes) {
32015
+ return { tooLarge: true };
32016
+ }
32017
+ if (request.body === null) {
32018
+ return { tooLarge: false, text: "" };
32019
+ }
32020
+ const reader = request.body.getReader();
32021
+ const decoder = new TextDecoder;
32022
+ let received = 0;
32023
+ let text = "";
32024
+ try {
32025
+ for (;; ) {
32026
+ const { done, value } = await reader.read();
32027
+ if (done) {
32028
+ break;
32029
+ }
32030
+ received += value.byteLength;
32031
+ if (received > maxBytes) {
32032
+ return { tooLarge: true };
32033
+ }
32034
+ text += decoder.decode(value, { stream: true });
32035
+ }
32036
+ } finally {
32037
+ reader.releaseLock();
32038
+ }
32039
+ return { tooLarge: false, text: text + decoder.decode() };
32040
+ }
32041
+
31913
32042
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/sseKeepAlive.js
31914
32043
  var DEFAULT_SSE_KEEP_ALIVE_MS = 15000;
31915
32044
  var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
@@ -31945,6 +32074,7 @@ class WebStandardStreamableHTTPServerTransport {
31945
32074
  this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
31946
32075
  this._retryInterval = options.retryInterval;
31947
32076
  this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS;
32077
+ this._maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize);
31948
32078
  }
31949
32079
  startKeepAlive(controller, encoder) {
31950
32080
  if (this._closed)
@@ -32261,12 +32391,22 @@ data:
32261
32391
  rawMessage = options.parsedBody;
32262
32392
  } else {
32263
32393
  try {
32264
- rawMessage = await req.json();
32394
+ const body = await readRequestBody(req, this._maxRequestBodySize);
32395
+ if (body.tooLarge) {
32396
+ const message = requestBodyTooLargeMessage(this._maxRequestBodySize);
32397
+ this.onerror?.(new Error(message));
32398
+ return this.createJsonErrorResponse(413, -32000, message);
32399
+ }
32400
+ rawMessage = JSON.parse(body.text);
32265
32401
  } catch {
32266
32402
  this.onerror?.(new Error("Parse error: Invalid JSON"));
32267
32403
  return this.createJsonErrorResponse(400, -32700, "Parse error: Invalid JSON");
32268
32404
  }
32269
32405
  }
32406
+ if (Array.isArray(rawMessage) && rawMessage.length > MAX_BATCH_SIZE) {
32407
+ this.onerror?.(new Error(`Invalid Request: Batch must not exceed ${MAX_BATCH_SIZE} messages`));
32408
+ return this.createJsonErrorResponse(400, -32600, `Invalid Request: Batch must not exceed ${MAX_BATCH_SIZE} messages`);
32409
+ }
32270
32410
  let messages;
32271
32411
  try {
32272
32412
  if (Array.isArray(rawMessage)) {
@@ -32641,7 +32781,7 @@ class StreamableHTTPServerTransport {
32641
32781
  }
32642
32782
 
32643
32783
  // src/adapters/opencode/dashboard.ts
32644
- import { createServer } from "node:http";
32784
+ import { createServer as createServer2 } from "node:http";
32645
32785
 
32646
32786
  // src/core/dashboard.ts
32647
32787
  import { readdirSync as readdirSync4, existsSync as existsSync5 } from "node:fs";
@@ -32746,7 +32886,7 @@ async function startDashboard(worktree) {
32746
32886
  const pageHtml = getDashboardPage();
32747
32887
  for (let port = BASE_PORT;port < BASE_PORT + MAX_ATTEMPTS; port++) {
32748
32888
  try {
32749
- const server = createServer(async (req, res) => {
32889
+ const server = createServer2(async (req, res) => {
32750
32890
  try {
32751
32891
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
32752
32892
  if (url.pathname === "/api/state") {
@@ -32994,7 +33134,7 @@ async function startMcpServer(opts) {
32994
33134
  const port = opts.port ?? DEFAULT_PORT;
32995
33135
  const hostname = opts.hostname ?? "127.0.0.1";
32996
33136
  const transports = new Map;
32997
- const server = createServer2(async (req, res) => {
33137
+ const server = createServer3(async (req, res) => {
32998
33138
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? hostname}`);
32999
33139
  if (url.pathname === "/mcp") {
33000
33140
  const sessionId = Array.isArray(req.headers["mcp-session-id"]) ? req.headers["mcp-session-id"][0] : req.headers["mcp-session-id"];
package/README.md CHANGED
@@ -55,11 +55,25 @@ dsh plugin --profile web add @ifrankwang/openspec-agents
55
55
 
56
56
  ### OpenCode
57
57
 
58
+ 要求 OpenCode 2.x(1.x 的插件接口不兼容,本插件只支持 2.x)。
59
+
58
60
  ```bash
59
61
  npm install -D @ifrankwang/openspec-agents
60
62
  ```
61
63
 
62
- 然后在 OpenCode 配置中加载 `@ifrankwang/openspec-agents` 插件,重启 OpenCode。
64
+ 然后在 OpenCode 配置的 `plugins` 中登记 `@ifrankwang/openspec-agents`,重启 OpenCode:
65
+
66
+ ```jsonc
67
+ // opencode.json
68
+ {
69
+ "$schema": "https://opencode.ai/config.json",
70
+ "plugins": ["@ifrankwang/openspec-agents"]
71
+ }
72
+ ```
73
+
74
+ 首次加载时,插件会在项目 `.opencode/agents/` 下生成三个子代理定义文件
75
+ (`openspec-main` / `openspec-developer` / `openspec-reviewer`)。这些文件带
76
+ `generated-by` 标记:插件升级后随版本刷新,你手工改过的同名文件不会被覆盖。
63
77
 
64
78
  ## 让 AI 帮你接入(一句话)
65
79
 
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: openspec-standard
3
- description: OpenSpec 提案标准初始化与升级——把标准版提案规则(openspec/config.yaml + spec-driven-standard schema 全套)安装到当前项目,统一后续提 change 的质量门槛(关联场景清点、契约变更详列、人话实施逻辑、结构化方案摘要、动笔前访谈与用户确认)。当用户要求初始化、升级或标准化 openspec 提案标准/提案规范时使用。
3
+ description: OpenSpec 提案标准初始化与升级——把标准版提案规则(openspec/config.yaml + spec-driven-standard schema 全套)安装到当前项目,统一后续提 change 的质量门槛(关联场景清点、契约变更详列、人话变更清单与实施逻辑、结构化方案摘要、动笔前访谈与用户确认)。当用户要求初始化、升级或标准化 openspec 提案标准/提案规范时使用。
4
4
  capabilities: ["openspec-standard"]
5
5
  ---
6
6
 
7
7
  ## 职责
8
8
 
9
- 把本 skill 目录 `templates/` 下的标准文件安装到当前项目的 `openspec/` 目录,使该项目此后用 openspec 提 change 时自动遵循统一标准:关联场景清点(共享处理逻辑的变更须与其全部消费入口同一 change 统一适配,同一数据多条落库路径的校验一致性为访谈必问分支)、契约变更详列、实施逻辑人话分步(proposal 单列实施逻辑小节,禁代码级名词)、结构化方案摘要(访谈穷尽后才给出、以普通消息确认)、动笔前访谈门与用户确认门、任务组实质变更主体等。幂等:重跑即把标准升级到插件当前版本,项目自定义内容按下方合并策略保留。
9
+ 把本 skill 目录 `templates/` 下的标准文件安装到当前项目的 `openspec/` 目录,使该项目此后用 openspec 提 change 时自动遵循统一标准:关联场景清点(共享处理逻辑的变更须与其全部消费入口同一 change 统一适配,同一数据多条落库路径的校验一致性为访谈必问分支)、契约变更详列、实施逻辑人话分步(proposal 单列实施逻辑小节,禁代码级名词)、结构化方案摘要(访谈穷尽后才给出,一句话做什么、变更内容人话逐条列明并禁代码级名词,以普通消息确认)、动笔前访谈门与用户确认门、任务组实质变更主体等。幂等:重跑即把标准升级到插件当前版本,项目自定义内容按下方合并策略保留。
10
10
 
11
11
  ## 前置检查
12
12
 
@@ -15,7 +15,7 @@ rules:
15
15
  - "关联场景清点(强制):proposal 动笔前必须完成「关联场景清点」并以代码检索为证据记入 clarify.md 的「关联场景清点(Related-Surface Survey)」小节:枚举本变更触及能力的全部消费方与关联面——功能入口(API 端点、导入/导出入口、定时任务、消息消费、前端调用等)、共享处理逻辑(被多个入口复用的处理链路)、数据模型与数据流下游(表/字段/事件/导出产物的读写双方),每条附代码位置证据(文件/类/方法)。凡共享处理逻辑发生变更,其全部消费入口必须在同一 change 内统一适配,禁止按入口分别提 change 导致共享逻辑两边割裂维护;确需拆分时必须在 proposal.md 单列「拆分边界」小节论证(每个 change 覆盖哪些入口、共享逻辑变更归属哪个 change、入口间契约),并经用户确认。proposal.md 必须单列「关联场景(Related Surface)」小节逐条列出清点结论与划界结论;泛写「无关联影响」而不附检索证据视为 proposal 未完成。"
16
16
  - "契约变更详列(强制):凡变更涉及 DDL(建表/加列/索引/约束)或接口契约(新增/修改端点、请求响应结构、错误码),proposal.md 必须单列「契约变更点」小节逐条列出——DDL 逐表逐列给全表名、列名、类型、可空性、默认值与用途;API 逐端点给全方法+路径、请求/响应结构变化、新增/修改标记与兼容性影响。泛写「有契约变更」不逐条展开视为 proposal 未完成。"
17
17
  - "动笔前访谈门(强制):proposal/specs/design/tasks 任一文档动笔前,必须已按 spec-driven-standard schema clarify 指令的决策树访谈方法论完成对用户的访谈——把方案建模为决策树逐轮穷尽疑点:每轮将前提已落定、当前可回答的问题一次性编号抛出并逐题附推荐答案,用户答复后重算下一轮前沿,直到决策树所有分支走完、无未决疑点、无静默假设、关联场景清点每个条目均经用户划界确认(纳入/排除+理由);访谈问答全程记入 clarify.md(Open Questions / User Answers / 关联场景清点)。事实类问题必须自行查代码与 spec 求证,不抛给用户;疑点未穷尽时禁止写任何文档,不得以「需求简单」等理由跳过。访谈未穷尽(含关联场景清点尚有未经用户划界确认的条目)时同样禁止呈现方案或方案摘要请求确认——轮次中每题附的推荐答案不视为方案,不得把方案确认合并进任何一轮提问;用户中途索要方案时说明访谈未完并继续提问,不得提前收口。"
18
- - "实施逻辑与方案摘要结构(强制):proposal.md 必须单列「实施逻辑(Implementation Outline)」小节,按步骤用业务语言(人话)讲清具体怎么做——数据从哪来、经过什么校验与处理、落到哪、关联场景小节划入的每条链路怎么适配;禁止出现类名/方法名/内部函数名等代码级名词(用户明确要求技术深度时除外;契约变更点小节的表名/列名/端点路径等契约事实不受此限、保持精确)。方案摘要必须按 spec-driven-standard schema clarify 指令 Plan confirmation gate 的要素清单结构化逐项展开(含人话分步实施逻辑与关联链路划界结论),两三句话的摘要视为未完成。"
18
+ - "实施逻辑与方案摘要结构(强制):proposal.md 必须单列「实施逻辑(Implementation Outline)」小节,按步骤用业务语言(人话)讲清具体怎么做——数据从哪来、经过什么校验与处理、落到哪、关联场景小节划入的每条链路怎么适配;禁止出现类名/方法名/内部函数名等代码级名词(用户明确要求技术深度时除外;契约变更点小节的表名/列名/端点路径等契约事实不受此限、保持精确)。方案摘要必须按 spec-driven-standard schema clarify 指令 Plan confirmation gate 的要素清单结构化逐项展开——先用一句话说明本次变更做什么,变更内容再用自然语言(人话)逐条列出,每条形如「导出功能新增「状态」字段」并视需要展开必要背景(如该字段的来源、用途),禁止掺杂类名/方法名等代码级名词(限制口径同实施逻辑小节);摘要含人话分步实施逻辑与关联链路划界结论,两三句话的摘要视为未完成。"
19
19
  - "动笔前用户确认门(强制):proposal/specs/design/tasks 任一文档动笔前,clarify.md 的 Architecture Direction 必须已记录用户对方案摘要的明确确认;确认必须以普通消息直接列出方案摘要要素点(要素清单见 spec-driven-standard schema clarify 指令的 Plan confirmation gate)并停下等待用户直接文字回复,禁止通过提问工具发起确认,提问工具的选项点击不视为确认;未记录时禁止写任何文档——主代理亲自写时先停下向用户补确认,生成子代理则立即终止并向编排方报告,不得以「需求简单」等理由跳过或代用户确认。"
20
20
  specs:
21
21
  - "数据行为需求(如\"系统 SHALL 持久化 X 含审计字段\")放 spec;物理 DDL 放 design.md。"
@@ -8,9 +8,10 @@
8
8
  change 统一适配或排除附理由;同一数据存在多条落库路径时各路径校验与
9
9
  处理逻辑的一致性为必问分支;共享处理逻辑变更须与其全部消费入口同一
10
10
  change 统一适配);访谈穷尽后、写任何文档之前,主代理必须先向用户汇报
11
- 结构化方案摘要(按要素清单逐项编号展开:一句话做什么 + 是否动契约 +
12
- 选定方案与备选取舍 + 人话分步实施逻辑 + 关联链路划界结论 + 影响的能力
13
- 面)并取得用户明确确认(HARD-GATE:摘要须以普通消息呈现,禁止用提问
11
+ 结构化方案摘要(按要素清单逐项编号展开:一句话做什么 + 变更内容人话
12
+ 逐条列明(形如「导出功能新增「状态」字段」,视需要说明字段来源等
13
+ 背景)+ 是否动契约 + 选定方案与备选取舍 + 人话分步实施逻辑 +
14
+ 关联链路划界结论 + 影响的能力面)并取得用户明确确认(HARD-GATE:摘要须以普通消息呈现,禁止用提问
14
15
  工具发起确认,访谈中的问答不视为确认;访谈未穷尽禁止呈现方案求确认,
15
16
  轮次中每题附的推荐答案不视为方案),确认记录落在 clarify.md,后续文档
16
17
  不得偏离。proposal.md 单列「实施逻辑」小节,以人话分步讲清怎么做(禁
@@ -165,7 +165,14 @@ artifacts:
165
165
  number each element below and give it real substance — a
166
166
  two-or-three-sentence blob is an invalid summary, expand it before
167
167
  presenting. The elements:
168
- 1. what the change does, in one sentence;
168
+ 1. what the change does, in one sentence, THEN the change
169
+ contents itemized one item per line in plain business
170
+ language: each item is a natural-language sentence of the
171
+ form 「导出功能新增「状态」字段」, expanded as far as it helps
172
+ the user judge the change (e.g. where the new field comes
173
+ from, what it gives the user) rather than a bare label. No
174
+ code-level identifiers — same language rule as element 4
175
+ below;
169
176
  2. whether it touches contracts (API parameters or DDL);
170
177
  3. the chosen approach and its key trade-offs versus the
171
178
  alternatives;
@@ -204,8 +211,9 @@ artifacts:
204
211
  alternatives considered, and the confirmed plan summary with
205
212
  the user's explicit confirmation — the summary structured per the
206
213
  six elements of the plan confirmation gate (including the
207
- plain-language implementation outline and the related-surface
208
- verdicts), presented as a plain message only after the interview
214
+ itemized plain-language change list, the plain-language
215
+ implementation outline and the related-surface verdicts),
216
+ presented as a plain message only after the interview
209
217
  was exhausted. This is the HARD-GATE output.
210
218
  - **Initial Capabilities**: preliminary guess of which specs will be touched
211
219
  (kebab-case). Finalized in proposal.md.
@@ -37,7 +37,8 @@
37
37
  considered with tradeoffs, and the plan summary the user explicitly confirmed
38
38
  (interview answers alone do not count as confirmation; the summary must be
39
39
  structured per the six elements of the plan confirmation gate — including
40
- the plain-language implementation outline and the related-surface verdicts —
40
+ the itemized plain-language change list, the plain-language
41
+ implementation outline and the related-surface verdicts —
41
42
  presented as a plain message listing the key points, never a question-tool
42
43
  prompt, and only AFTER the interview was exhausted: presenting the plan or
43
44
  asking for its confirmation mid-interview is forbidden, and the recommended
@@ -25,6 +25,47 @@
25
25
  includeDefaultRoots: false
26
26
  customSkillDirs:
27
27
  - !!js "process.getBuiltinModule('node:url').fileURLToPath(new URL('./node_modules/@ifrankwang/openspec-agents/assets/skills', baseUrl))"
28
+ - id: openspec-subagent-developer
29
+ name: '@deepseek-ai/dsh-tool-subagent'
30
+ config:
31
+ provider: spawn
32
+ toolName: openspec_developer
33
+ persona: |-
34
+ ## DSH 工具接入(必读)
35
+
36
+ - 本流程的 opx_* 工具在 DSH 中以 `mcp__opx__*` 形式出现,子代理通常只需要:
37
+ - `mcp__opx__status`
38
+ - `mcp__opx__agent_submit`
39
+ - 如果当前工具列表中没有这些工具,请立即调用 `dev_tool_search` 搜索 `opx`,或一次性解锁:
40
+ ```json
41
+ {"toolNames": ["mcp__opx__status", "mcp__opx__agent_submit"]}
42
+ ```
43
+ 如果计划使用 `todo_write` 跟踪任务,也请在同一轮一并解锁。
44
+ - 严禁使用 `find` / `which` / `grep` 等 shell 命令搜索 `opx`、`openspec`、MCP 配置或安装路径;工具未出现是因为尚未解锁,不是未安装。
45
+ - 解锁后第一件事是调用 `mcp__opx__status` 获取上下文,不要先探查仓库或读取状态文件。
46
+
47
+
48
+ OpenSpec 编排流程专用 — 开发者(物理 agent,承载开发者逻辑身份)。遵循 TDD 开发新功能,使用 5-Why 分析修复 Bug。仅在 openspec-agents 工作流内由编排者分派使用,不用于通用开发任务。
49
+
50
+ ## 角色(开发者身份)
51
+
52
+ 你是后端开发工程师,专注于实现 OpenSpec 任务组中的代码开发任务,同时是质量门与接口测试的执行责任人:确定性检查与接口测试由你执行并经自检申报留痕,自检申报是 reviewer 分级复验的事实输入。
53
+
54
+ 提交前按已加载 skill(含质量维度规范类)完成自检,审查兜底与终局裁定仍由对应 reviewer 承担(自检不替代 reviewer 裁定)。
55
+
56
+ 自证/验证启动的服务与基础设施一律隔离启动、用后即停,自证完成即清理,不留残留。隔离对象不限于数据库(mock 服务、消息队列/缓存等中间件、扫描分析平台均按隔离标识隔离)。
57
+
58
+ 任务执行与提交约束(Task/Issue 清单处理、环境问题处置、blocker 提交、文档读取范围、工具调用边界)以 opx_status 操作指引与约束区块为准,此处不重复描述。
59
+
60
+ 修复的 issue 进入待复核,终态由提出者(对应 reviewer)裁定;被驳回后按修复未过次数门槛处理。
61
+
62
+ 收尾阶段承担基准分支同步、合并冲突解决、回归复验与隔离环境清理职责;收尾验证中自报 issue 由自己复核收敛(区别于「修复的 issue 进入待复核,终态由提出者裁定」的常规路径)。
63
+
64
+ ## 压制处理(开发者身份)
65
+
66
+ 确定性质量工具检查出的问题若以压制手段解决(压制注解、注释抑制、规则排除配置、平台状态标注、构建跳过开关等),属豁免申请范畴:必须在压制处代码注释说明清楚理由(commit message 可补充),交由报源审查者判定合理性;不得以压制手段冒充修复。豁免被驳回后,须移除压制并真实修复。
67
+ enableRunInBackground: false
68
+ backgroundMode: one-shot
28
69
  - id: openspec-subagent-reviewer
29
70
  name: '@deepseek-ai/dsh-tool-subagent'
30
71
  config:
@@ -241,45 +282,4 @@
241
282
  确定性质量工具检查出的问题若以压制手段解决(压制注解、注释抑制、规则排除配置、平台状态标注、构建跳过开关等),一律视为豁免申请,须判定其合理性:可接受情形(无法消除的框架/第三方限制、有证据的误报,且风险可控、理由充分)→ 认可豁免;不可接受情形(真实缺陷被压制规避、仅图省事、无理由或理由不成立)→ 不认可,须要求移除压制并真实修复。判定时核验压制处的理由(代码注释、commit message);不认可时须给出可见的判定理由(写入问题描述/建议或驳回说明)。此判定适用于承担工具检查的审查身份(工具审查者、simple 合并审查者)。
242
283
  enableRunInBackground: false
243
284
  backgroundMode: one-shot
244
- - id: openspec-subagent-developer
245
- name: '@deepseek-ai/dsh-tool-subagent'
246
- config:
247
- provider: spawn
248
- toolName: openspec_developer
249
- persona: |-
250
- ## DSH 工具接入(必读)
251
-
252
- - 本流程的 opx_* 工具在 DSH 中以 `mcp__opx__*` 形式出现,子代理通常只需要:
253
- - `mcp__opx__status`
254
- - `mcp__opx__agent_submit`
255
- - 如果当前工具列表中没有这些工具,请立即调用 `dev_tool_search` 搜索 `opx`,或一次性解锁:
256
- ```json
257
- {"toolNames": ["mcp__opx__status", "mcp__opx__agent_submit"]}
258
- ```
259
- 如果计划使用 `todo_write` 跟踪任务,也请在同一轮一并解锁。
260
- - 严禁使用 `find` / `which` / `grep` 等 shell 命令搜索 `opx`、`openspec`、MCP 配置或安装路径;工具未出现是因为尚未解锁,不是未安装。
261
- - 解锁后第一件事是调用 `mcp__opx__status` 获取上下文,不要先探查仓库或读取状态文件。
262
-
263
-
264
- OpenSpec 编排流程专用 — 开发者(物理 agent,承载开发者逻辑身份)。遵循 TDD 开发新功能,使用 5-Why 分析修复 Bug。仅在 openspec-agents 工作流内由编排者分派使用,不用于通用开发任务。
265
-
266
- ## 角色(开发者身份)
267
-
268
- 你是后端开发工程师,专注于实现 OpenSpec 任务组中的代码开发任务,同时是质量门与接口测试的执行责任人:确定性检查与接口测试由你执行并经自检申报留痕,自检申报是 reviewer 分级复验的事实输入。
269
-
270
- 提交前按已加载 skill(含质量维度规范类)完成自检,审查兜底与终局裁定仍由对应 reviewer 承担(自检不替代 reviewer 裁定)。
271
-
272
- 自证/验证启动的服务与基础设施一律隔离启动、用后即停,自证完成即清理,不留残留。隔离对象不限于数据库(mock 服务、消息队列/缓存等中间件、扫描分析平台均按隔离标识隔离)。
273
-
274
- 任务执行与提交约束(Task/Issue 清单处理、环境问题处置、blocker 提交、文档读取范围、工具调用边界)以 opx_status 操作指引与约束区块为准,此处不重复描述。
275
-
276
- 修复的 issue 进入待复核,终态由提出者(对应 reviewer)裁定;被驳回后按修复未过次数门槛处理。
277
-
278
- 收尾阶段承担基准分支同步、合并冲突解决、回归复验与隔离环境清理职责;收尾验证中自报 issue 由自己复核收敛(区别于「修复的 issue 进入待复核,终态由提出者裁定」的常规路径)。
279
-
280
- ## 压制处理(开发者身份)
281
-
282
- 确定性质量工具检查出的问题若以压制手段解决(压制注解、注释抑制、规则排除配置、平台状态标注、构建跳过开关等),属豁免申请范畴:必须在压制处代码注释说明清楚理由(commit message 可补充),交由报源审查者判定合理性;不得以压制手段冒充修复。豁免被驳回后,须移除压制并真实修复。
283
- enableRunInBackground: false
284
- backgroundMode: one-shot
285
285
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifrankwang/openspec-agents",
3
- "version": "0.142.0",
3
+ "version": "0.144.0",
4
4
  "description": "OpenSpec change orchestration plugin for AI coding agents (Claude Code, Codex, ZCode, DeepSeek Harness, OpenCode)",
5
5
  "author": "ifrankwang",
6
6
  "license": "MIT",
@@ -75,14 +75,14 @@
75
75
  "typecheck": "tsc --noEmit"
76
76
  },
77
77
  "devDependencies": {
78
+ "@opencode/plugin": "^2.0.26",
78
79
  "@types/bun": "^1.3.0",
79
80
  "@types/js-yaml": "^4.0.9",
80
81
  "typescript": "^5.9.0"
81
82
  },
82
83
  "dependencies": {
83
84
  "@modelcontextprotocol/sdk": "^1.30.0",
84
- "js-yaml": "^4.1.0",
85
- "@opencode-ai/plugin": "^1.17.0"
85
+ "js-yaml": "^4.1.0"
86
86
  },
87
87
  "dsh": {
88
88
  "bundle": {
@@ -1,61 +1,71 @@
1
- import { readFileSync, existsSync, readdirSync } from "node:fs"
1
+ /**
2
+ * 子代理定义投放:assets/agents/*.md 转成 OpenCode V2 原生 agent 文件,写入目标项目
3
+ * `.opencode/agents/<名>.md`——V2 发现项目级 agent 的位置。
4
+ *
5
+ * V2 插件接口的 agent 编辑器只提供 update/remove,没有新增能力,而 V2 也不从插件包目录发现
6
+ * agent,因此新增子代理只能走文件投放,与 codex 适配器把 agent 写进目标仓库 `.codex/agents/`
7
+ * 的做法同构。
8
+ *
9
+ * frontmatter 按 V2 字段落地:mode/steps/hidden 沿用同名,permission 对象展开为 permissions
10
+ * 有序数组(见 permissions.ts)。文件正文首行带生成标记:已存在且带标记的文件随插件版本刷新,
11
+ * 用户手工改写过的同名文件保留不动。
12
+ */
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "node:fs"
2
14
  import { join } from "node:path"
3
- import type { AgentConfig } from "@opencode-ai/sdk"
15
+ import * as yaml from "js-yaml"
4
16
  import { parseAgentMd, resolve } from "../agent-md.ts"
5
-
6
- function mapPermission(
7
- fm: Record<string, unknown>
8
- ): AgentConfig["permission"] | undefined {
9
- const raw = fm.permission as Record<string, unknown> | undefined
10
- if (!raw) return undefined
11
- const permission: AgentConfig["permission"] = {}
12
- for (const [key, val] of Object.entries(raw)) {
13
- if (typeof val === "string" && ["ask", "allow", "deny"].includes(val)) {
14
- ;(permission as any)[key] = val
15
- } else if (typeof val === "object" && val !== null) {
16
- ;(permission as any)[key] = val
17
- }
18
- }
19
- return permission
20
- }
17
+ import { toV2Permissions } from "./permissions.ts"
21
18
 
22
19
  const AGENTS_ROOT = resolve("assets", "agents")
23
20
 
24
- export function injectAgents(config: Record<string, unknown>): void {
25
- if (existsSync(AGENTS_ROOT)) {
26
- const files = readdirSync(AGENTS_ROOT).filter((f) => f.endsWith(".md"))
27
- for (const file of files) {
28
- const md = readFileSync(join(AGENTS_ROOT, file), "utf-8")
29
- const { frontmatter, body } = parseAgentMd(md)
30
- const name = pathToName(file) ?? (frontmatter.name as string)
31
- if (!name) continue
21
+ /** 生成标记:正文首行。用于识别「本文件由插件生成、允许随版本刷新」。 */
22
+ const GENERATED_MARKER = "<!-- generated-by: openspec-agents -->"
32
23
 
33
- const agentConfig: Record<string, unknown> = {
34
- description: frontmatter.description ?? "",
35
- mode: frontmatter.mode ?? "subagent",
36
- prompt: body,
37
- }
38
- if (frontmatter.hidden !== undefined) {
39
- agentConfig.hidden = frontmatter.hidden
40
- }
41
- const maxSteps = (frontmatter.maxSteps ?? frontmatter.steps) as number | undefined
42
- if (maxSteps !== undefined) {
43
- agentConfig.maxSteps = maxSteps
44
- }
45
- const perm = mapPermission(frontmatter)
46
- if (perm) agentConfig.permission = perm
24
+ /** 项目内 agent 投放目录(V2 发现位置)。 */
25
+ export function agentsTargetDir(projectRoot: string): string {
26
+ return join(projectRoot, ".opencode", "agents")
27
+ }
47
28
 
48
- const existingAgentsMap = (config.agent as Record<string, unknown>) ?? {}
49
- const existingAgent = existingAgentsMap[name] as Record<string, unknown> | undefined
50
- config.agent = {
51
- ...existingAgentsMap,
52
- [name]: { ...agentConfig, ...existingAgent },
53
- }
54
- }
29
+ /**
30
+ * 把随包分发的子代理写入目标项目,返回本次实际写入的 agent 名列表。
31
+ * assets/agents 缺失(分发形态裁剪)时返回空列表,不阻断插件启动。
32
+ */
33
+ export function writeAgents(projectRoot: string): string[] {
34
+ const written: string[] = []
35
+ if (!existsSync(AGENTS_ROOT)) return written
36
+ const targetDir = agentsTargetDir(projectRoot)
37
+ for (const file of readdirSync(AGENTS_ROOT)) {
38
+ if (!file.endsWith(".md")) continue
39
+ const { frontmatter, body } = parseAgentMd(readFileSync(join(AGENTS_ROOT, file), "utf-8"))
40
+ const name = (frontmatter.name as string) ?? file.replace(/\.md$/, "")
41
+ if (!name) continue
42
+ const target = join(targetDir, `${name}.md`)
43
+ if (!isOverwritable(target)) continue
44
+ mkdirSync(targetDir, { recursive: true })
45
+ writeFileSync(target, renderAgentFile(frontmatter, body), "utf-8")
46
+ written.push(name)
55
47
  }
48
+ return written
56
49
  }
57
50
 
58
- function pathToName(filename: string): string | null {
59
- const base = filename.replace(/\.md$/i, "")
60
- return base || null
51
+ /** 未投放过、或上次由本插件生成的文件可覆盖;用户手工维护的文件一律保留。 */
52
+ function isOverwritable(target: string): boolean {
53
+ if (!existsSync(target)) return true
54
+ try {
55
+ return readFileSync(target, "utf-8").includes(GENERATED_MARKER)
56
+ } catch {
57
+ return false
58
+ }
61
59
  }
60
+
61
+ function renderAgentFile(fm: Record<string, unknown>, body: string): string {
62
+ const frontmatter: Record<string, unknown> = {}
63
+ if (fm.description !== undefined) frontmatter.description = fm.description
64
+ if (fm.mode !== undefined) frontmatter.mode = fm.mode
65
+ if (fm.hidden !== undefined) frontmatter.hidden = fm.hidden
66
+ const steps = (fm.steps ?? fm.maxSteps) as number | undefined
67
+ if (steps !== undefined) frontmatter.steps = steps
68
+ const permissions = toV2Permissions(fm.permission)
69
+ if (permissions.length > 0) frontmatter.permissions = permissions
70
+ return `---\n${yaml.dump(frontmatter)}---\n\n${GENERATED_MARKER}\n\n${body.trim()}\n`
71
+ }
@@ -1,41 +1,63 @@
1
- import { type Plugin } from "@opencode-ai/plugin"
2
- import { injectSkills } from "./skills.ts"
3
- import { injectAgents } from "./agents.ts"
1
+ import type { Plugin } from "@opencode/plugin"
4
2
  import { resolve } from "../agent-md.ts"
3
+ import { collectSkills, type SkillRegistration } from "./skills.ts"
4
+ import { writeAgents } from "./agents.ts"
5
5
 
6
6
  /**
7
- * OpenCode 插件壳(MCP 形态):配置注入(agent/skill)+ MCP server 配置注入(config.mcp)。
8
- * 直载工具注册已移除(BREAKING,change agent-merge-and-mode-config 组 5.1):
9
- * - opx_* 工具统一经 MCP server 暴露(stdio bundle,--worktree 指向当前项目根),
10
- * 与 claude-code / zcode / codex 插件的 .mcp.json 形态一致;
11
- * - 身份不再由会话运行时推导,统一走 mcp-common 的 `_agent` 参数解析(缺省视为编排视角)。
12
- * dashboard/collector/poller 副作用已迁移至 MCP server 进程(src/adapters/mcp-common/)。
7
+ * OpenCode V2 插件壳:MCP server 注册 + skill 注册 + 子代理文件投放。
8
+ *
9
+ * V2 插件以默认导出 { id, setup } 生效,setup 内按域注册,不再持有全局配置对象:
10
+ * - MCP server 走 mcp 域 transform 声明(local + stdio command 数组),opx_* 工具由此暴露,
11
+ * 身份统一由 mcp-common 的 `_agent` 参数解析(缺省视为编排视角);
12
+ * - 随包 skill 走 skill 域 transform 逐项注册,path 指向包内 SKILL.md 以保留附属文件基准;
13
+ * - 子代理以文件形式投放至项目 `.opencode/agents/`(V2 插件接口无新增 agent 的能力)。
14
+ *
15
+ * dashboard/collector/poller 副作用由 MCP server 进程承担(src/adapters/mcp-common/),
16
+ * 插件壳不启动任何后台任务。
17
+ *
18
+ * 插件 ID 稳定为 openspec-agents:V2 以该 ID 标识插件、隔离其持久化存储,并用于配置中的
19
+ * 启用/禁用前缀匹配。
13
20
  */
14
- export const OpenspecOrchestratePlugin: Plugin = async (input) => {
21
+ export const OPENCODE_PLUGIN_ID = "openspec-agents"
22
+
23
+ /**
24
+ * 构造 opx MCP server 配置。入口为包内 .mcp-server/cli.mjs(prepack 构建的自包含 bundle),
25
+ * --worktree 指向当前加载本插件的项目根(ctx.location.directory),dashboard/collector 副作用
26
+ * 与状态读写均以此为根。
27
+ */
28
+ function mcpServerConfig(projectRoot: string) {
15
29
  return {
16
- config: async (config) => {
17
- injectAgents(config)
18
- injectSkills(config)
19
- // MCP server 声明:opencode config 的 mcp 段(官方形态
20
- // { mcp: { <server>: { type: "local", command: [...] } } },见 opencode docs/mcp-servers)。
21
- // 入口为包内 .mcp-server/cli.mjs(prepack 构建的 MCP server 自包含 bundle),
22
- // --worktree 指向当前项目根(input.worktree 即 opencode 打开的 git worktree 路径)。
23
- config.mcp = {
24
- ...(config.mcp ?? {}),
25
- opx: {
26
- type: "local",
27
- command: [
28
- "node",
29
- resolve(".mcp-server", "cli.mjs"),
30
- "--transport",
31
- "stdio",
32
- "--worktree",
33
- input.worktree,
34
- "--unattended",
35
- "--strip-opx-prefix",
36
- ],
37
- },
38
- }
39
- },
30
+ type: "local" as const,
31
+ command: [
32
+ "node",
33
+ resolve(".mcp-server", "cli.mjs"),
34
+ "--transport",
35
+ "stdio",
36
+ "--worktree",
37
+ projectRoot,
38
+ "--unattended",
39
+ "--strip-opx-prefix",
40
+ ],
40
41
  }
41
42
  }
43
+
44
+ const plugin: Plugin.Plugin = {
45
+ id: OPENCODE_PLUGIN_ID,
46
+ async setup(ctx) {
47
+ const projectRoot = ctx.location.directory
48
+
49
+ await ctx.mcp.transform((editor) => {
50
+ editor.set("opx", mcpServerConfig(projectRoot))
51
+ })
52
+
53
+ await ctx.skill.transform((editor) => {
54
+ for (const skill of collectSkills()) {
55
+ editor.add(skill)
56
+ }
57
+ })
58
+
59
+ writeAgents(projectRoot)
60
+ },
61
+ }
62
+
63
+ export default plugin
@@ -1,19 +1,57 @@
1
- import { existsSync } from "node:fs"
1
+ /**
2
+ * skill 投放:收集随包分发的 skill,交由 V2 skill 域的 transform 逐个注册。
3
+ *
4
+ * V1 形态把 skill 根目录追加进 config.skills.paths,由 opencode 自行发现;V2 插件不持有全局
5
+ * 配置对象,skill 改由 skill 域显式注册。注册项的 path 指向包内 SKILL.md,使 skill 加载时
6
+ * 仍以该目录为附属文件(reference/、scripts/ 等)的相对引用基准。
7
+ *
8
+ * ID 取目录名(V2 以路径派生 ID 为准),name/description 取 SKILL.md frontmatter,
9
+ * content 取去 frontmatter 的正文——与 V2 加载时「加入去掉 frontmatter 的正文」一致。
10
+ */
11
+ import { readFileSync, existsSync, readdirSync } from "node:fs"
12
+ import { join } from "node:path"
13
+ import type { Skill } from "@opencode/plugin"
14
+ import { parseAgentMd } from "../agent-md.ts"
2
15
  import { DISTRIBUTED_SKILL_ROOTS } from "../../skills/scan.ts"
3
16
 
4
- export function injectSkills(config: Record<string, unknown>): void {
5
- const skillsCfg = (config.skills ?? {}) as Record<string, unknown>
6
- const paths = (skillsCfg.paths ?? []) as string[]
17
+ /**
18
+ * 注册一项 skill 所需的字段(对齐 V2 Skill.Info 中由插件负责提供的部分)。
19
+ * id/name/path 在 V2 schema 中为品牌化字符串(编译期约束,运行时即普通 string),
20
+ * 构造处统一以断言完成品牌化,避免引入 @opencode/plugin 的运行时依赖
21
+ * (Plugin.define 为恒等函数,插件壳不需要该包的运行时实例)。
22
+ */
23
+ export interface SkillRegistration {
24
+ id: Skill.Info["id"]
25
+ name: Skill.Info["name"]
26
+ description?: string
27
+ path: Skill.Info["path"]
28
+ content: string
29
+ }
7
30
 
31
+ /** 收集随包分发的全部 skill;随包 skill 缺失(分发形态裁剪)时返回空数组。 */
32
+ export function collectSkills(): SkillRegistration[] {
33
+ const out: SkillRegistration[] = []
34
+ const seen = new Set<string>()
8
35
  for (const root of DISTRIBUTED_SKILL_ROOTS) {
9
36
  if (!existsSync(root)) continue
10
- if (!paths.includes(root)) {
11
- paths.push(root)
37
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
38
+ if (!entry.isDirectory()) continue
39
+ const skillFile = join(root, entry.name, "SKILL.md")
40
+ if (!existsSync(skillFile)) continue
41
+ // 多根场景下同名 skill 只注册一次(V2 按 ID 选择,后注册者覆盖前者)。
42
+ if (seen.has(entry.name)) continue
43
+ seen.add(entry.name)
44
+ const { frontmatter, body } = parseAgentMd(readFileSync(skillFile, "utf-8"))
45
+ // id 由目录名派生(V2 以路径派生 ID 为准),name 为展示名,缺失时回落 id。
46
+ const name = (frontmatter.name as string) ?? entry.name
47
+ out.push({
48
+ id: entry.name as Skill.Info["id"],
49
+ name: name as Skill.Info["name"],
50
+ description: frontmatter.description as string | undefined,
51
+ path: skillFile as Skill.Info["path"],
52
+ content: body,
53
+ })
12
54
  }
13
55
  }
14
-
15
- if (paths.length > 0) {
16
- skillsCfg.paths = paths
17
- config.skills = skillsCfg
18
- }
19
- }
56
+ return out
57
+ }
package/src/index.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // 旧入口保留向后兼容
2
- export { OpenspecOrchestratePlugin } from "./adapters/opencode/index.ts"
2
+ export { default as OpenspecOrchestratePlugin } from "./adapters/opencode/index.ts"