@devcxl/opencode-cabbage 0.3.2 → 1.1.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 (179) hide show
  1. package/README.md +34 -19
  2. package/README.zh.md +34 -19
  3. package/assets/agents/dev-lifecycle.md +60 -49
  4. package/assets/agents/team/architect.md +8 -1
  5. package/assets/agents/team/developer.md +89 -0
  6. package/assets/agents/team/goal-verify.md +5 -1
  7. package/assets/agents/team/reviewer.md +6 -0
  8. package/assets/commands/code.md +3 -2
  9. package/assets/commands/design.md +3 -2
  10. package/assets/commands/release.md +3 -2
  11. package/assets/commands/requirements.md +3 -2
  12. package/assets/commands/review.md +3 -2
  13. package/assets/commands/setup.md +3 -2
  14. package/assets/commands/tasks.md +2 -1
  15. package/assets/prompts/bootstrap.md +9 -8
  16. package/assets/prompts/stage-contract.md +55 -0
  17. package/assets/skills/flow-code/SKILL.md +38 -140
  18. package/assets/skills/flow-design/SKILL.md +28 -79
  19. package/assets/skills/flow-release/SKILL.md +31 -51
  20. package/assets/skills/flow-requirements/SKILL.md +31 -196
  21. package/assets/skills/flow-review/SKILL.md +26 -123
  22. package/assets/skills/flow-setup/SKILL.md +40 -43
  23. package/assets/skills/flow-tasks/SKILL.md +23 -153
  24. package/assets/skills/flow-tdd/SKILL.md +30 -31
  25. package/dist/kernel/caller.d.ts +48 -0
  26. package/dist/kernel/caller.js +88 -0
  27. package/dist/kernel/caller.js.map +1 -0
  28. package/dist/kernel/context.d.ts +27 -0
  29. package/dist/kernel/context.js +84 -0
  30. package/dist/kernel/context.js.map +1 -0
  31. package/dist/kernel/flow.d.ts +175 -0
  32. package/dist/kernel/flow.js +467 -0
  33. package/dist/kernel/flow.js.map +1 -0
  34. package/dist/kernel/legacy.d.ts +19 -0
  35. package/dist/kernel/legacy.js +34 -0
  36. package/dist/kernel/legacy.js.map +1 -0
  37. package/dist/kernel/mutex.d.ts +8 -0
  38. package/dist/kernel/mutex.js +23 -0
  39. package/dist/kernel/mutex.js.map +1 -0
  40. package/dist/kernel/permission.d.ts +21 -0
  41. package/dist/kernel/permission.js +45 -0
  42. package/dist/kernel/permission.js.map +1 -0
  43. package/dist/kernel/profile.d.ts +35 -0
  44. package/dist/kernel/profile.js +147 -0
  45. package/dist/kernel/profile.js.map +1 -0
  46. package/dist/kernel/records.d.ts +103 -0
  47. package/dist/kernel/records.js +240 -0
  48. package/dist/kernel/records.js.map +1 -0
  49. package/dist/kernel/release.d.ts +106 -0
  50. package/dist/kernel/release.js +241 -0
  51. package/dist/kernel/release.js.map +1 -0
  52. package/dist/kernel/review.d.ts +34 -0
  53. package/dist/kernel/review.js +82 -0
  54. package/dist/kernel/review.js.map +1 -0
  55. package/dist/kernel/session-index.d.ts +45 -0
  56. package/dist/kernel/session-index.js +136 -0
  57. package/dist/kernel/session-index.js.map +1 -0
  58. package/dist/kernel/setup.d.ts +95 -0
  59. package/dist/kernel/setup.js +401 -0
  60. package/dist/kernel/setup.js.map +1 -0
  61. package/dist/kernel/slug.d.ts +23 -0
  62. package/dist/kernel/slug.js +67 -0
  63. package/dist/kernel/slug.js.map +1 -0
  64. package/dist/kernel/task.d.ts +213 -0
  65. package/dist/kernel/task.js +873 -0
  66. package/dist/kernel/task.js.map +1 -0
  67. package/dist/{flowrun → kernel/tdd}/adapter.d.ts +1 -1
  68. package/dist/{flowrun → kernel/tdd}/adapter.js +27 -26
  69. package/dist/kernel/tdd/adapter.js.map +1 -0
  70. package/dist/{flowrun → kernel/tdd}/digest.d.ts +2 -1
  71. package/dist/{flowrun → kernel/tdd}/digest.js +6 -4
  72. package/dist/kernel/tdd/digest.js.map +1 -0
  73. package/dist/{flowrun → kernel/tdd}/evaluator.d.ts +1 -1
  74. package/dist/{flowrun → kernel/tdd}/evaluator.js +1 -1
  75. package/dist/kernel/tdd/evaluator.js.map +1 -0
  76. package/dist/kernel/tdd/evidence.d.ts +42 -0
  77. package/dist/kernel/tdd/evidence.js +48 -0
  78. package/dist/kernel/tdd/evidence.js.map +1 -0
  79. package/dist/{flowrun → kernel/tdd}/state.d.ts +2 -2
  80. package/dist/{flowrun → kernel/tdd}/state.js +1 -1
  81. package/dist/kernel/tdd/state.js.map +1 -0
  82. package/dist/kernel/types.d.ts +180 -0
  83. package/dist/kernel/types.js +2 -0
  84. package/dist/kernel/types.js.map +1 -0
  85. package/dist/kernel/worktree.d.ts +45 -0
  86. package/dist/kernel/worktree.js +156 -0
  87. package/dist/kernel/worktree.js.map +1 -0
  88. package/dist/plugin/agents.d.ts +5 -17
  89. package/dist/plugin/agents.js +0 -23
  90. package/dist/plugin/agents.js.map +1 -1
  91. package/dist/plugin/flow-control.d.ts +91 -0
  92. package/dist/plugin/flow-control.js +287 -0
  93. package/dist/plugin/flow-control.js.map +1 -0
  94. package/dist/plugin/goal.d.ts +17 -46
  95. package/dist/plugin/goal.js +47 -117
  96. package/dist/plugin/goal.js.map +1 -1
  97. package/dist/plugin/prompt-lint.js +78 -6
  98. package/dist/plugin/prompt-lint.js.map +1 -1
  99. package/dist/plugin/release-control.d.ts +39 -0
  100. package/dist/plugin/release-control.js +343 -0
  101. package/dist/plugin/release-control.js.map +1 -0
  102. package/dist/plugin/server.d.ts +19 -0
  103. package/dist/plugin/server.js +198 -320
  104. package/dist/plugin/server.js.map +1 -1
  105. package/dist/plugin/setup-control.d.ts +59 -0
  106. package/dist/plugin/setup-control.js +111 -0
  107. package/dist/plugin/setup-control.js.map +1 -0
  108. package/dist/plugin/shell.d.ts +7 -34
  109. package/dist/plugin/shell.js +12 -137
  110. package/dist/plugin/shell.js.map +1 -1
  111. package/dist/plugin/skills.d.ts +1 -1
  112. package/dist/plugin/skills.js +1 -7
  113. package/dist/plugin/skills.js.map +1 -1
  114. package/dist/plugin/task-control.d.ts +90 -0
  115. package/dist/plugin/task-control.js +208 -0
  116. package/dist/plugin/task-control.js.map +1 -0
  117. package/dist/plugin/tdd-checkpoint.d.ts +128 -0
  118. package/dist/plugin/tdd-checkpoint.js +664 -0
  119. package/dist/plugin/tdd-checkpoint.js.map +1 -0
  120. package/dist/util/gh.d.ts +5 -0
  121. package/dist/util/gh.js +12 -0
  122. package/dist/util/gh.js.map +1 -1
  123. package/dist/util/paths.d.ts +6 -2
  124. package/dist/util/paths.js +15 -7
  125. package/dist/util/paths.js.map +1 -1
  126. package/package.json +4 -4
  127. package/assets/agents/team/backend.md +0 -100
  128. package/assets/agents/team/frontend.md +0 -98
  129. package/assets/commands/handoff.md +0 -4
  130. package/assets/commands/test.md +0 -4
  131. package/assets/context/CONTEXT.md +0 -41
  132. package/assets/skills/flow-handoff/SKILL.md +0 -76
  133. package/assets/skills/flow-test/SKILL.md +0 -75
  134. package/dist/flowrun/adapter.js.map +0 -1
  135. package/dist/flowrun/audit.d.ts +0 -5
  136. package/dist/flowrun/audit.js +0 -67
  137. package/dist/flowrun/audit.js.map +0 -1
  138. package/dist/flowrun/ci.d.ts +0 -64
  139. package/dist/flowrun/ci.js +0 -184
  140. package/dist/flowrun/ci.js.map +0 -1
  141. package/dist/flowrun/coverage.d.ts +0 -70
  142. package/dist/flowrun/coverage.js +0 -207
  143. package/dist/flowrun/coverage.js.map +0 -1
  144. package/dist/flowrun/digest.js.map +0 -1
  145. package/dist/flowrun/evaluator.js.map +0 -1
  146. package/dist/flowrun/gate.d.ts +0 -28
  147. package/dist/flowrun/gate.js +0 -139
  148. package/dist/flowrun/gate.js.map +0 -1
  149. package/dist/flowrun/github.d.ts +0 -24
  150. package/dist/flowrun/github.js +0 -154
  151. package/dist/flowrun/github.js.map +0 -1
  152. package/dist/flowrun/index.d.ts +0 -14
  153. package/dist/flowrun/index.js +0 -15
  154. package/dist/flowrun/index.js.map +0 -1
  155. package/dist/flowrun/merge.d.ts +0 -57
  156. package/dist/flowrun/merge.js +0 -180
  157. package/dist/flowrun/merge.js.map +0 -1
  158. package/dist/flowrun/migration.d.ts +0 -11
  159. package/dist/flowrun/migration.js +0 -276
  160. package/dist/flowrun/migration.js.map +0 -1
  161. package/dist/flowrun/resilience.d.ts +0 -18
  162. package/dist/flowrun/resilience.js +0 -80
  163. package/dist/flowrun/resilience.js.map +0 -1
  164. package/dist/flowrun/state.js.map +0 -1
  165. package/dist/flowrun/transitions.d.ts +0 -66
  166. package/dist/flowrun/transitions.js +0 -168
  167. package/dist/flowrun/transitions.js.map +0 -1
  168. package/dist/flowrun/types.d.ts +0 -356
  169. package/dist/flowrun/types.js +0 -21
  170. package/dist/flowrun/types.js.map +0 -1
  171. package/dist/flowrun/validator.d.ts +0 -19
  172. package/dist/flowrun/validator.js +0 -209
  173. package/dist/flowrun/validator.js.map +0 -1
  174. package/dist/plugin/broker.d.ts +0 -73
  175. package/dist/plugin/broker.js +0 -140
  176. package/dist/plugin/broker.js.map +0 -1
  177. package/dist/plugin/tdd-tool.d.ts +0 -62
  178. package/dist/plugin/tdd-tool.js +0 -336
  179. package/dist/plugin/tdd-tool.js.map +0 -1
@@ -1,20 +1,37 @@
1
- import { tool } from "@opencode-ai/plugin/tool";
2
1
  import path from "node:path";
3
- import { readFile, writeFile, mkdir } from "node:fs/promises";
4
- import { existsSync } from "node:fs";
5
2
  import { initPrompts } from "./prompts.js";
6
3
  import { initBootstrap, getBootstrapContent } from "./bootstrap.js";
7
4
  import { loadCommands } from "./commands.js";
8
5
  import { setupSkillsDir } from "./skills.js";
9
6
  import { loadAgents } from "./agents.js";
10
- import { createIsolatedShellEnv, detectAmbientCredentials } from "./shell.js";
11
- import { createGoalClient, createGoalTool, readGoal, writeGoal, bindFlowRunRef, readFlowRunRef, checkFlowRunBlockers, MAX_CONTINUATIONS, continuationPrompt, formatGoal } from "./goal.js";
12
- import { FlowBroker } from "./broker.js";
13
- import { flowRunStart, flowStageStart, flowStageComplete, flowTaskStart, flowRunFinalize, } from "../flowrun/transitions.js";
14
- import { readFlowRun } from "../flowrun/github.js";
7
+ import { createAgentShellEnv } from "./shell.js";
8
+ import { createGoalClient, createGoalTool, readGoal, writeGoal, MAX_CONTINUATIONS, continuationPrompt, formatGoal } from "./goal.js";
9
+ import { readIndex, updateFlowSession } from "../kernel/session-index.js";
10
+ import { createSetupControlTool } from "./setup-control.js";
11
+ import { createFlowControlTool } from "./flow-control.js";
12
+ import { createTaskControlTool } from "./task-control.js";
13
+ import { createTddCheckpointTool } from "./tdd-checkpoint.js";
14
+ import { createReleaseControlTool } from "./release-control.js";
15
+ import { getContextBlock, formatContextBlock, CONTEXT_MARKER } from "../kernel/context.js";
16
+ import { readProjectProfile } from "../kernel/profile.js";
15
17
  const abortedSessions = new Set();
16
18
  const errorRetryCount = new Map();
17
19
  const COMPACTION_THRESHOLD = 20;
20
+ /**
21
+ * 决定首条 user 消息注入内容:
22
+ * - Primary(goal 激活)→ bootstrap + Project Context 块
23
+ * - 子 agent → 仅 Project Context 块
24
+ * - 其他 → 不注入
25
+ */
26
+ export function buildFirstUserInjection(input) {
27
+ const parts = [];
28
+ if (input.hasGoal)
29
+ parts.push(input.bootstrap);
30
+ if (input.contextBlock && (input.hasGoal || input.isSubAgent)) {
31
+ parts.push(input.contextBlock.block);
32
+ }
33
+ return parts.length > 0 ? parts.join("\n\n") : null;
34
+ }
18
35
  export function configureGoalTools(config) {
19
36
  config.tools = { ...config.tools, goal: false };
20
37
  for (const [agentName, agent] of Object.entries(config.agent ?? {})) {
@@ -25,57 +42,68 @@ export function configureGoalTools(config) {
25
42
  agentConfig.tools = { ...agentConfig.tools, goal: canUseGoal };
26
43
  }
27
44
  }
28
- function sessionStatePath(projectDir) {
29
- return path.join(projectDir, ".opencode", "opencode-cabbage", "session-state.json");
30
- }
31
- async function saveSessionState(projectDir, sessionID, goal) {
32
- const dir = path.dirname(sessionStatePath(projectDir));
33
- if (!existsSync(dir))
34
- await mkdir(dir, { recursive: true });
35
- await writeFile(sessionStatePath(projectDir), JSON.stringify({
36
- sessionID,
37
- status: goal.status,
38
- updatedAt: Date.now(),
39
- }), "utf8");
40
- }
41
- async function loadLastSession(projectDir) {
42
- try {
43
- const data = JSON.parse(await readFile(sessionStatePath(projectDir), "utf8"));
44
- return data.status === "active" ? data.sessionID : null;
45
- }
46
- catch {
47
- return null;
48
- }
49
- }
50
- async function clearSessionState(projectDir) {
51
- try {
52
- await writeFile(sessionStatePath(projectDir), JSON.stringify({ status: "completed", updatedAt: Date.now() }), "utf8");
45
+ /**
46
+ * config 层第二道门(spec §2.2):按 caller 矩阵把 5 个生命周期工具
47
+ * 对每个 agent 置 true/false。agent 名 → 角色映射与 caller.ts ROLE_BY_AGENT 一致。
48
+ */
49
+ export function configureLifecycleTools(config) {
50
+ const lifecycleTools = ["setup_control", "flow_control", "task_control", "tdd_checkpoint", "release_control"];
51
+ const agentRole = (name) => {
52
+ switch (name) {
53
+ case "dev-lifecycle": return "primary";
54
+ case "developer": return "developer";
55
+ case "architect": return "architect";
56
+ case "reviewer": return "reviewer";
57
+ case "goal-verify": return "goal-verify";
58
+ default: return "reviewer";
59
+ }
60
+ };
61
+ const canUseTool = (role, tool) => {
62
+ if (role === "goal-verify")
63
+ return tool === "flow_control";
64
+ switch (tool) {
65
+ case "tdd_checkpoint": return role === "primary" || role === "developer";
66
+ default: return role === "primary";
67
+ }
68
+ };
69
+ for (const [agentName, agent] of Object.entries(config.agent ?? {})) {
70
+ if (!agent || typeof agent !== "object" || Array.isArray(agent))
71
+ continue;
72
+ const agentConfig = agent;
73
+ const role = agentRole(agentName);
74
+ const toolFlags = {};
75
+ for (const toolName of lifecycleTools) {
76
+ toolFlags[toolName] = canUseTool(role, toolName);
77
+ }
78
+ agentConfig.tools = { ...agentConfig.tools, ...toolFlags };
53
79
  }
54
- catch { }
55
80
  }
56
81
  async function queueContinuation(client, sessionID, projectDir) {
57
82
  const { goal } = await readGoal(client, sessionID);
58
83
  if (!goal || goal.status !== "active")
59
84
  return;
60
- if (projectDir)
61
- saveSessionState(projectDir, sessionID, goal);
85
+ if (projectDir) {
86
+ await updateFlowSession(projectDir, goal.parentIssueNumber, { status: goal.status, continuationCount: goal.continuationCount });
87
+ }
62
88
  if (abortedSessions.has(sessionID)) {
63
89
  abortedSessions.delete(sessionID);
64
90
  goal.status = "paused";
65
91
  await writeGoal(client, sessionID, goal);
66
92
  if (projectDir)
67
- clearSessionState(projectDir);
93
+ await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" });
68
94
  return;
69
95
  }
70
96
  if (goal.continuationCount >= MAX_CONTINUATIONS) {
71
97
  goal.status = "paused";
72
98
  await writeGoal(client, sessionID, goal);
73
99
  if (projectDir)
74
- clearSessionState(projectDir);
100
+ await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "paused" });
75
101
  return;
76
102
  }
77
103
  goal.continuationCount++;
78
104
  await writeGoal(client, sessionID, goal);
105
+ if (projectDir)
106
+ await updateFlowSession(projectDir, goal.parentIssueNumber, { continuationCount: goal.continuationCount });
79
107
  try {
80
108
  if (goal.continuationCount > 0 && goal.continuationCount % COMPACTION_THRESHOLD === 0) {
81
109
  try {
@@ -86,9 +114,13 @@ async function queueContinuation(client, sessionID, projectDir) {
86
114
  }
87
115
  catch { }
88
116
  }
117
+ const contextBlock = projectDir ? await getContextBlock(projectDir) : null;
118
+ const contextText = contextBlock ? `${formatContextBlock(contextBlock)}\n\n` : "";
119
+ // 显式传回用户最后使用的 agent/模型,防止 idle 续接回落/切换默认 agent(如 build)
89
120
  await client.session.promptAsync({
90
121
  sessionID,
91
- parts: [{ type: "text", text: continuationPrompt(goal.objective, goal.completionCriterion), synthetic: true }],
122
+ parts: [{ type: "text", text: contextText + continuationPrompt(goal.parentIssueNumber), synthetic: true }],
123
+ ...(goal.sessionProfile ? { agent: goal.sessionProfile.agent, model: goal.sessionProfile.model } : {}),
92
124
  });
93
125
  }
94
126
  catch (err) {
@@ -96,26 +128,29 @@ async function queueContinuation(client, sessionID, projectDir) {
96
128
  }
97
129
  }
98
130
  async function autoResume(client, projectDir) {
99
- const lastSessionID = await loadLastSession(projectDir);
100
- if (!lastSessionID)
101
- return;
102
- const { goal } = await readGoal(client, lastSessionID);
103
- if (!goal || goal.status !== "active") {
104
- await clearSessionState(projectDir);
105
- return;
106
- }
107
- try {
108
- await client.session.promptAsync({
109
- sessionID: lastSessionID,
110
- parts: [{
111
- type: "text",
112
- text: `[auto-resume] Plugin restarted. Resuming previous goal:\n\n${formatGoal(goal)}\n\nContinue working.`,
113
- synthetic: true,
114
- }],
115
- });
116
- }
117
- catch (err) {
118
- console.error("[cabbage] auto-resume failed:", err);
131
+ const index = await readIndex(projectDir);
132
+ for (const [parentIssueNumber, entry] of Object.entries(index.flows)) {
133
+ if (entry.status !== "active")
134
+ continue;
135
+ const { goal } = await readGoal(client, entry.sessionID);
136
+ if (!goal || goal.status !== "active") {
137
+ await updateFlowSession(projectDir, Number(parentIssueNumber), { status: "paused" });
138
+ continue;
139
+ }
140
+ try {
141
+ await client.session.promptAsync({
142
+ sessionID: entry.sessionID,
143
+ parts: [{
144
+ type: "text",
145
+ text: `[auto-resume] Plugin restarted. Resuming previous goal:\n\n${formatGoal(goal)}\n\nContinue working.`,
146
+ synthetic: true,
147
+ }],
148
+ ...(goal.sessionProfile ? { agent: goal.sessionProfile.agent, model: goal.sessionProfile.model } : {}),
149
+ });
150
+ }
151
+ catch (err) {
152
+ console.error("[cabbage] auto-resume failed:", err);
153
+ }
119
154
  }
120
155
  }
121
156
  function getRetryKey(sessionID, phase) {
@@ -141,257 +176,40 @@ function startPeriodicCleanup() {
141
176
  errorRetryCount.clear();
142
177
  }, ABORTED_SESSION_CLEANUP_INTERVAL);
143
178
  }
144
- // ─── Flow Control Tool ───
145
- function createFlowControlTool(broker, goalClient) {
146
- return tool({
147
- description: `Control the FlowRun lifecycle: start a FlowRun, transition stages, start tasks, and finalize completed runs.
148
-
149
- Operations:
150
- - run-start: Transition FlowRun from planned → running. Binds Goal to FlowRun.
151
- - stage-start: Start a stage (requirements/design/tasks/code). Requires prerequisites met.
152
- - stage-complete: Complete a stage (requirements/design/tasks). Requires all checks pass.
153
- - task-start: Start a task (pending/ready → running). Freezes TDD policy and sets execution binding.
154
- - run-finalize: Mark code→test→review→merge stages as pass, set FlowRun to completed. Requires all Tasks merged. Idempotent.`,
155
- args: {
156
- op: tool.schema.enum(["run-start", "stage-start", "stage-complete", "task-start", "run-finalize"]).describe("Flow control operation"),
157
- parent_issue_number: tool.schema.number().describe("Parent GitHub Issue number containing the FlowRun"),
158
- stage: tool.schema.enum(["requirements", "design", "tasks", "code", "test", "review", "merge"]).optional().describe("Stage name (for stage-start/stage-complete)"),
159
- task_id: tool.schema.string().optional().describe("Task ID (for task-start)"),
160
- execution_binding: tool.schema.object({
161
- branch: tool.schema.string(),
162
- base_sha: tool.schema.string(),
163
- start_head_sha: tool.schema.string(),
164
- worktree_id: tool.schema.string(),
165
- session_id: tool.schema.string(),
166
- }).optional().describe("Execution binding for task-start"),
167
- tdd_policy_json: tool.schema.string().optional().describe("JSON string of frozen TDD policy (for task-start)"),
168
- },
169
- async execute(args, ctx) {
170
- const sessionID = ctx.sessionID;
171
- const op = args.op;
172
- const parentIssueNumber = args.parent_issue_number;
173
- try {
174
- switch (op) {
175
- case "run-start":
176
- return handleRunStart(broker, goalClient, parentIssueNumber, sessionID);
177
- case "stage-start":
178
- return handleStageStart(broker, parentIssueNumber, args.stage);
179
- case "stage-complete":
180
- return handleStageComplete(broker, parentIssueNumber, args.stage);
181
- case "task-start":
182
- return handleTaskStart(broker, parentIssueNumber, args.task_id, args.execution_binding, args.tdd_policy_json);
183
- case "run-finalize":
184
- return handleRunFinalize(broker, parentIssueNumber);
185
- default:
186
- return errorResponse("UNKNOWN_OP", `Unknown operation: "${op}"`);
187
- }
188
- }
189
- catch (err) {
190
- return errorResponse("INTERNAL_ERROR", String(err));
191
- }
192
- },
193
- });
194
- }
195
- function okResponse(overrides = {}) {
196
- const resp = { ok: true, ...overrides };
197
- return JSON.stringify(resp, null, 2);
198
- }
199
- function errorResponse(code, message) {
200
- const resp = { ok: false, error: { code, message } };
201
- return JSON.stringify(resp, null, 2);
202
- }
203
- async function handleRunStart(broker, goalClient, parentIssueNumber, sessionID) {
204
- // 通过 broker 执行状态迁移
205
- const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => {
206
- const res = flowRunStart(flowRun);
207
- if (!res.ok) {
208
- return { flowRun, result: res.error, shouldPersist: false };
209
- }
210
- return { flowRun: res.value, result: res.value.status, shouldPersist: true };
211
- });
212
- if (!writeResult.ok) {
213
- return errorResponse(writeResult.code, writeResult.message);
214
- }
215
- if (!writeResult.persisted) {
216
- // 状态迁移被拒绝(如已是 running)
217
- return errorResponse("INVALID_TRANSITION", "FlowRun is not in planned state");
218
- }
219
- // 绑定 Goal → FlowRun
220
- const actualFlowRunId = writeResult.flowRun.flowRunId;
221
- const bindResult = await bindFlowRunRef(goalClient, sessionID, {
222
- repo: writeResult.flowRun.repo,
223
- parentIssueNumber,
224
- flowRunId: actualFlowRunId,
225
- });
226
- if (!bindResult.ok) {
227
- return errorResponse(bindResult.code, bindResult.message);
228
- }
229
- return okResponse({
230
- flowRunStatus: writeResult.flowRun.status,
231
- });
232
- }
233
- async function handleStageStart(broker, parentIssueNumber, stageName) {
234
- if (!stageName) {
235
- return errorResponse("POLICY_INVALID", "stage is required for stage-start");
236
- }
237
- const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => {
238
- const res = flowStageStart(flowRun, stageName);
239
- if (!res.ok) {
240
- return { flowRun, result: res.error, shouldPersist: false };
241
- }
242
- return {
243
- flowRun: res.value,
244
- result: { stage: stageName, status: res.value.stages[stageName].status },
245
- shouldPersist: true,
246
- };
247
- });
248
- if (!writeResult.ok) {
249
- return errorResponse(writeResult.code, writeResult.message);
250
- }
251
- if (!writeResult.persisted && writeResult.result && typeof writeResult.result === "object" && "code" in writeResult.result) {
252
- const err = writeResult.result;
253
- return errorResponse(err.code, err.message);
254
- }
255
- return okResponse({
256
- flowRunStatus: writeResult.flowRun.status,
257
- stage: writeResult.result,
258
- });
259
- }
260
- async function handleStageComplete(broker, parentIssueNumber, stageName) {
261
- if (!stageName) {
262
- return errorResponse("POLICY_INVALID", "stage is required for stage-complete");
263
- }
264
- const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => {
265
- const res = flowStageComplete(flowRun, stageName);
266
- if (!res.ok) {
267
- return { flowRun, result: res.error, shouldPersist: false };
268
- }
269
- return {
270
- flowRun: res.value,
271
- result: { stage: stageName, status: res.value.stages[stageName].status },
272
- shouldPersist: true,
273
- };
274
- });
275
- if (!writeResult.ok) {
276
- return errorResponse(writeResult.code, writeResult.message);
277
- }
278
- if (!writeResult.persisted && writeResult.result && typeof writeResult.result === "object" && "code" in writeResult.result) {
279
- const err = writeResult.result;
280
- return errorResponse(err.code, err.message);
281
- }
282
- return okResponse({
283
- flowRunStatus: writeResult.flowRun.status,
284
- stage: writeResult.result,
285
- });
286
- }
287
- async function handleTaskStart(broker, parentIssueNumber, taskId, executionBinding, tddPolicyJson) {
288
- if (!taskId) {
289
- return errorResponse("POLICY_INVALID", "task_id is required for task-start");
290
- }
291
- if (!executionBinding) {
292
- return errorResponse("POLICY_INVALID", "execution_binding is required for task-start");
293
- }
294
- // 解析 frozen policy
295
- let tddPolicy = undefined;
296
- if (tddPolicyJson) {
297
- try {
298
- tddPolicy = JSON.parse(tddPolicyJson);
299
- }
300
- catch {
301
- return errorResponse("POLICY_INVALID", "tdd_policy_json is not valid JSON");
302
- }
303
- }
304
- const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => {
305
- const res = flowTaskStart(flowRun, taskId, executionBinding, tddPolicy);
306
- if (!res.ok) {
307
- return { flowRun, result: res.error, shouldPersist: false };
308
- }
309
- return {
310
- flowRun: res.value.flowRun,
311
- result: {
312
- taskId,
313
- status: res.value.task.status,
314
- executionBinding: res.value.task.executionBinding,
315
- },
316
- shouldPersist: true,
317
- };
318
- });
319
- if (!writeResult.ok) {
320
- return errorResponse(writeResult.code, writeResult.message);
321
- }
322
- if (!writeResult.persisted && writeResult.result && typeof writeResult.result === "object" && "code" in writeResult.result) {
323
- const err = writeResult.result;
324
- return errorResponse(err.code, err.message);
325
- }
326
- return okResponse({
327
- flowRunStatus: writeResult.flowRun.status,
328
- task: writeResult.result,
329
- });
330
- }
331
- async function handleRunFinalize(broker, parentIssueNumber) {
332
- const writeResult = await broker.writeFlowRunWithLock(parentIssueNumber, (flowRun) => {
333
- const res = flowRunFinalize(flowRun);
334
- if (!res.ok) {
335
- return { flowRun, result: res.error, shouldPersist: false };
336
- }
337
- return {
338
- flowRun: res.value,
339
- result: { status: res.value.status, completedAt: res.value.completedAt },
340
- shouldPersist: true,
341
- };
342
- });
343
- if (!writeResult.ok) {
344
- return errorResponse(writeResult.code, writeResult.message);
345
- }
346
- if (!writeResult.persisted && writeResult.result && typeof writeResult.result === "object" && "code" in writeResult.result) {
347
- const err = writeResult.result;
348
- return errorResponse(err.code, err.message);
349
- }
350
- return okResponse({
351
- flowRunStatus: writeResult.flowRun.status,
352
- });
353
- }
354
179
  export function createOpencodeCabbage(packageRoot) {
355
180
  return async (ctx, _options) => {
356
181
  const sourceSkillsDir = path.join(packageRoot, "assets", "skills");
357
- const contextDir = path.join(packageRoot, "assets", "context");
358
182
  const promptsDir = path.join(packageRoot, "assets", "prompts");
359
183
  const commandsDir = path.join(packageRoot, "assets", "commands");
360
- const skillsDir = await setupSkillsDir(sourceSkillsDir, contextDir, promptsDir);
184
+ const skillsDir = await setupSkillsDir(sourceSkillsDir, promptsDir);
361
185
  const projectDir = ctx.worktree || ctx.directory;
362
186
  const v1Client = ctx.client._client;
363
187
  const goalClient = createGoalClient(ctx.serverUrl, v1Client);
364
- const goalTool = createGoalTool(goalClient, async (parentSessionID) => {
365
- // 验证绑定的 FlowRun 是否已终态
366
- const ref = await readFlowRunRef(goalClient, parentSessionID);
367
- if (!ref)
368
- return null; // 无 FlowRun 绑定,允许完成
369
- // 读取 FlowRun 状态
370
- const flowResult = await readFlowRun(ref.parentIssueNumber);
371
- if (!flowResult.ok) {
372
- return `Failed to read FlowRun #${ref.parentIssueNumber}: ${flowResult.code}`;
373
- }
374
- return checkFlowRunBlockers(flowResult.data.status);
375
- });
376
- const broker = new FlowBroker();
377
- const flowControlTool = createFlowControlTool(broker, goalClient);
188
+ const goalTool = createGoalTool(goalClient);
189
+ const sessionClient = goalClient;
190
+ const flowControlTool = createFlowControlTool({ projectDir, sessionClient });
191
+ const taskControlTool = createTaskControlTool({ projectDir, sessionClient });
192
+ const setupControlTool = createSetupControlTool({ projectDir, sessionClient });
193
+ const tddCheckpointTool = createTddCheckpointTool({ projectDir, sessionClient });
194
+ const releaseControlTool = createReleaseControlTool({ projectDir, sessionClient });
378
195
  const agentsDir = path.join(packageRoot, "assets", "agents");
379
196
  await initPrompts(packageRoot, projectDir);
380
197
  await initBootstrap();
381
198
  startPeriodicCleanup();
382
- autoResume(goalClient, projectDir);
199
+ void autoResume(goalClient, projectDir).catch(err => {
200
+ console.error("[cabbage] auto-resume failed:", err);
201
+ });
383
202
  return {
384
203
  tool: {
385
204
  goal: goalTool,
386
205
  flow_control: flowControlTool,
206
+ task_control: taskControlTool,
207
+ setup_control: setupControlTool,
208
+ tdd_checkpoint: tddCheckpointTool,
209
+ release_control: releaseControlTool,
387
210
  },
388
211
  config: async (rawConfig) => {
389
212
  const config = rawConfig;
390
- // ── Ambient credential 检测 ──
391
- const ambientReport = detectAmbientCredentials();
392
- if (ambientReport.hasWriteCredentials) {
393
- console.warn("[cabbage] ⚠️ 检测到可用 GitHub 写凭证,Runtime enforcement 已降级为 advisory:", ambientReport.sources.map(s => s.location).join(", "));
394
- }
395
213
  config.skills = config.skills || {};
396
214
  config.skills.paths = config.skills.paths || [];
397
215
  if (!config.skills.paths.includes(skillsDir)) {
@@ -410,6 +228,31 @@ export function createOpencodeCabbage(packageRoot) {
410
228
  };
411
229
  }
412
230
  config.agent = config.agent || {};
231
+ // 渲染 permission 占位符:`<profile-test-command>` → Profile.testCommand 首 token(§9.1)
232
+ const profile = await readProjectProfile(projectDir);
233
+ const testCommandToken = profile.testCommand?.split(" ")[0] ?? "";
234
+ const renderPermission = (perm) => {
235
+ if (!perm)
236
+ return perm;
237
+ const renderRules = (rules) => {
238
+ if (!rules)
239
+ return rules;
240
+ const out = {};
241
+ for (const [pattern, action] of Object.entries(rules)) {
242
+ if (pattern.includes("<profile-test-command>") && testCommandToken === "") {
243
+ // Profile 未配置测试命令 → 删除占位规则,避免渲染成 "*" 覆盖兜底 deny
244
+ continue;
245
+ }
246
+ out[pattern.replaceAll("<profile-test-command>", testCommandToken)] = action;
247
+ }
248
+ return out;
249
+ };
250
+ const out = { ...perm };
251
+ if (perm.bash && typeof perm.bash === "object" && !Array.isArray(perm.bash)) {
252
+ out.bash = renderRules(perm.bash);
253
+ }
254
+ return out;
255
+ };
413
256
  for (const agent of loadAgents(agentsDir)) {
414
257
  if (config.agent[agent.key])
415
258
  continue;
@@ -418,14 +261,14 @@ export function createOpencodeCabbage(packageRoot) {
418
261
  mode: agent.mode,
419
262
  color: agent.color,
420
263
  prompt: agent.prompt,
421
- tools: agent.tools ?? { read: true, bash: true, edit: true },
422
- permission: agent.permission,
264
+ permission: renderPermission(agent.permission),
423
265
  shell: {
424
- env: createIsolatedShellEnv(agent),
266
+ env: createAgentShellEnv(),
425
267
  },
426
268
  };
427
269
  }
428
270
  configureGoalTools(config);
271
+ configureLifecycleTools(config);
429
272
  },
430
273
  "experimental.chat.messages.transform": async (_input, output) => {
431
274
  if (!output.messages.length)
@@ -433,13 +276,20 @@ export function createOpencodeCabbage(packageRoot) {
433
276
  const firstUser = output.messages.find(m => m.info.role === "user");
434
277
  if (!firstUser || !firstUser.parts.length)
435
278
  return;
436
- if (firstUser.parts.some(p => p.type === "text" && p.text.includes("EXTREMELY_IMPORTANT")))
279
+ if (firstUser.parts.some(p => p.type === "text" && (p.text.includes("EXTREMELY_IMPORTANT") || p.text.includes(CONTEXT_MARKER))))
437
280
  return;
438
- // Only inject bootstrap for active flow: goal is active or user sent a flow command
439
- const hasGoal = await readGoal(goalClient, output.messages[0].info.sessionID).then(r => r.goal?.status === "active").catch(() => false);
440
- if (!hasGoal)
441
- return;
442
- firstUser.parts.unshift({ type: "text", text: getBootstrapContent() });
281
+ const sessionID = output.messages[0].info.sessionID;
282
+ const { goal, session } = await readGoal(goalClient, sessionID);
283
+ const contextBlock = await getContextBlock(projectDir);
284
+ const injection = buildFirstUserInjection({
285
+ hasGoal: goal?.status === "active",
286
+ isSubAgent: !!session?.parentID,
287
+ contextBlock,
288
+ bootstrap: getBootstrapContent(),
289
+ });
290
+ if (injection) {
291
+ firstUser.parts.unshift({ type: "text", text: injection });
292
+ }
443
293
  },
444
294
  async event({ event }) {
445
295
  const evt = event;
@@ -453,10 +303,18 @@ export function createOpencodeCabbage(packageRoot) {
453
303
  if (evt.type === "session.status" && evt.properties?.status?.type === "idle") {
454
304
  const sessionID = evt.properties.sessionID;
455
305
  if (sessionID) {
456
- const result = await goalClient.session.get({ sessionID });
457
- if (result?.data?.parentID)
306
+ try {
307
+ const result = await goalClient.session.get({ sessionID });
308
+ if (result?.data?.parentID)
309
+ return;
310
+ }
311
+ catch (err) {
312
+ console.error("[cabbage] session lookup failed:", err);
458
313
  return;
459
- void queueContinuation(goalClient, sessionID, projectDir);
314
+ }
315
+ void queueContinuation(goalClient, sessionID, projectDir).catch(err => {
316
+ console.error("[cabbage] continuation failed:", err);
317
+ });
460
318
  }
461
319
  }
462
320
  if (evt.type === "session.status" && evt.properties?.status?.type === "error") {
@@ -466,24 +324,36 @@ export function createOpencodeCabbage(packageRoot) {
466
324
  const action = shouldEscalate(sessionID, phase);
467
325
  const { goal } = await readGoal(goalClient, sessionID);
468
326
  if (action === "retry" && goal) {
469
- await goalClient.session.promptAsync({
470
- sessionID,
471
- parts: [{
472
- type: "text",
473
- text: `[auto-retry] Previous attempt failed. Try a different approach.\n\nGoal: ${goal.objective}`,
474
- synthetic: true,
475
- }],
476
- });
327
+ try {
328
+ await goalClient.session.promptAsync({
329
+ sessionID,
330
+ parts: [{
331
+ type: "text",
332
+ text: `[auto-retry] Previous attempt failed. Try a different approach.\n\n${formatGoal(goal)}`,
333
+ synthetic: true,
334
+ }],
335
+ ...(goal.sessionProfile ? { agent: goal.sessionProfile.agent, model: goal.sessionProfile.model } : {}),
336
+ });
337
+ }
338
+ catch (err) {
339
+ console.error("[cabbage] auto-retry prompt failed:", err);
340
+ }
477
341
  }
478
342
  else if (action === "skip" && goal) {
479
- await goalClient.session.promptAsync({
480
- sessionID,
481
- parts: [{
482
- type: "text",
483
- text: `[skip] Skipping failed step. Continue with remaining work.\n\nGoal: ${goal.objective}`,
484
- synthetic: true,
485
- }],
486
- });
343
+ try {
344
+ await goalClient.session.promptAsync({
345
+ sessionID,
346
+ parts: [{
347
+ type: "text",
348
+ text: `[skip] Skipping failed step. Continue with remaining work.\n\n${formatGoal(goal)}`,
349
+ synthetic: true,
350
+ }],
351
+ ...(goal.sessionProfile ? { agent: goal.sessionProfile.agent, model: goal.sessionProfile.model } : {}),
352
+ });
353
+ }
354
+ catch (err) {
355
+ console.error("[cabbage] auto-skip prompt failed:", err);
356
+ }
487
357
  }
488
358
  }
489
359
  }
@@ -494,6 +364,14 @@ export function createOpencodeCabbage(packageRoot) {
494
364
  const { goal, session } = await readGoal(goalClient, sessionID);
495
365
  if (goal) {
496
366
  goal.continuationCount = 0;
367
+ // 刷新用户最后使用的 agent/模型快照(用户在 TUI 切换 agent/模型后发消息)
368
+ const info = evt.properties.info;
369
+ if (info?.agent) {
370
+ goal.sessionProfile = {
371
+ agent: info.agent,
372
+ model: info.model?.providerID ? { providerID: info.model.providerID, modelID: info.model.modelID } : goal.sessionProfile?.model,
373
+ };
374
+ }
497
375
  await writeGoal(goalClient, sessionID, goal, session ?? undefined);
498
376
  }
499
377
  }
@@ -503,7 +381,7 @@ export function createOpencodeCabbage(packageRoot) {
503
381
  if (sessionID) {
504
382
  const { goal } = await readGoal(goalClient, sessionID);
505
383
  if (goal?.status === "complete") {
506
- await clearSessionState(projectDir);
384
+ await updateFlowSession(projectDir, goal.parentIssueNumber, { status: "completed" });
507
385
  }
508
386
  }
509
387
  }