@hadooppei/hwcode 1.0.2 → 1.0.5

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.
@@ -4,4 +4,7 @@ Treat this repository as the active project boundary. Follow `AGENTS.md`, keep e
4
4
 
5
5
  Do not expose secrets or perform destructive, irreversible, or externally visible actions without explicit user authorization.
6
6
 
7
- When using the hwcode-cloud workflow, invoke cloud provider CLI operations (e.g., hcloud, aws) via hwcode_cloud_exec instead of bash. Use hwcode_terraform_* tools for Terraform tasks. Standard project tasks (such as ssh build or test scripts) should continue using normal tools.
7
+ - 在执行华为云云端资源操作时,优先使用 `hwcode_cloud_exec` 工具以保证凭据隔离;若工具不可用,允许使用脚本或 API。
8
+ - **并发与批处理准则**:在信息查询、环境探测及执行互无依赖的安全操作时,应在单次响应中并发调用多个工具(Parallel Tool Calling),避免多轮单步往返以提升执行效率。
9
+ - 禁止在未获得用户明确授权的情况下执行破坏性、删除或高危操作。
10
+ - 遵循工作区边界,保持修改最小化并按阶段执行。
@@ -2,8 +2,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { WORKFLOW_STATE_TYPE, activeWorkflow, updateWorkflowState, workflowLabel, type WorkflowState } from "../lib/workflows/state.ts";
3
3
 
4
4
  export default function hwcodeExtension(pi: ExtensionAPI) {
5
- pi.registerCommand("hwcode", {
6
- description: "Show, complete, or cancel the active HWCode workflow",
5
+ pi.registerCommand("hwcode-status", {
6
+ description: "Show status, complete, or cancel the active HWCode workflow",
7
7
  handler: async (_args, ctx) => {
8
8
  const state = activeWorkflow(ctx.sessionManager.getEntries());
9
9
  if (!state) {
@@ -163,8 +163,27 @@ export async function activateCloudWorkflow(runtime: CloudExtensionRuntime, args
163
163
 
164
164
  const vendorLabel = getCloudProvider(vendor).label;
165
165
  const cliCheck = await checkProviderCli(vendor, root);
166
- if (cliCheck.spawnErrorCode === "ENOENT") { notify(ctx, missingCloudCliMessage(vendor), "error"); return; }
167
- if (cliCheck.spawnErrorCode === "EACCES") { notify(ctx, inaccessibleCloudCliMessage(vendor), "error"); return; }
166
+
167
+ let allowApiFallback = false;
168
+
169
+ if (cliCheck.spawnErrorCode === "ENOENT") {
170
+ const choice = await ctx.ui.select(
171
+ `未检测到 ${vendorLabel} 命令行工具「${getCloudProvider(vendor).cli}」`,
172
+ [
173
+ "退出并安装官方 CLI(推荐,享受凭据隔离保护)",
174
+ "接受风险并继续(允许 Agent 使用 API/脚本操作,存在 AK/SK 泄露风险)",
175
+ "取消"
176
+ ]
177
+ );
178
+ if (!choice || !choice.startsWith("接受风险")) {
179
+ notify(ctx, missingCloudCliMessage(vendor), "error");
180
+ return;
181
+ }
182
+ allowApiFallback = true;
183
+ notify(ctx, "您已选择接受风险:将允许 Agent 使用 API 或脚本进行云资源操作。", "warning");
184
+ } else if (cliCheck.spawnErrorCode === "EACCES") {
185
+ notify(ctx, inaccessibleCloudCliMessage(vendor), "error"); return;
186
+ }
168
187
 
169
188
  const vaultPath = defaultCloudVaultPath();
170
189
  if (!(await ctx.ui.confirm("安全保存云账户信息?", [
@@ -192,7 +211,7 @@ export async function activateCloudWorkflow(runtime: CloudExtensionRuntime, args
192
211
  if (!credentials) return;
193
212
  notify(ctx, `正在校验 ${vendorLabel} 连接…`);
194
213
  const validation = await validateCloudCredentials(vendor, { root, credentials, temporaryStore: runtime.temporaryStore });
195
- if (validation.code === 0) break;
214
+ if (validation.code === 0 || (allowApiFallback && validation.spawnErrorCode === "ENOENT")) break;
196
215
  if (validation.spawnErrorCode === "ENOENT") { notify(ctx, missingCloudCliMessage(vendor), "error"); return; }
197
216
  if (validation.spawnErrorCode === "EACCES") { notify(ctx, inaccessibleCloudCliMessage(vendor), "error"); return; }
198
217
  notify(ctx, `连接校验失败:${formatValidationFailure(validation, credentials)}`, "error");
@@ -1,10 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
4
- import { canonicalizeWorkspaceRoot } from "../../../lib/workflow-guard.ts";
5
- import { getWorkingDirectory } from "../../../lib/working-directory.ts";
6
4
  import { containsCloudCommand, getCloudProvider } from "../../../lib/workflows/cloud/providers.ts";
7
- import { updateWorkflowState } from "../../../lib/workflows/state.ts";
8
5
  import type { CloudExtensionRuntime } from "./runtime.ts";
9
6
  import {
10
7
  appendWorkflowState, cloudArtifactDirectory, cloudDetails, notify,
@@ -20,13 +17,6 @@ export function registerCloudEvents(pi: ExtensionAPI, runtime: CloudExtensionRun
20
17
  }
21
18
  runtime.clearSecrets();
22
19
  if (!runtime.activeState) return;
23
- const root = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
24
- if (root !== runtime.activeState.root) {
25
- appendWorkflowState(pi, updateWorkflowState(runtime.activeState, { status: "cancelled", phase: "root-changed", reason: "working directory changed" }));
26
- runtime.activeState = undefined;
27
- notify(ctx, "Stored HWCode Cloud workflow disabled because the current directory changed.", "warning");
28
- return;
29
- }
30
20
  const details = cloudDetails(runtime.activeState)!;
31
21
  if (!details.terminalFailure) notify(ctx, `HWCode Cloud session restored for ${getCloudProvider(details.vendor).label}. Credentials are locked; run /hwcode-cloud to unlock before continuing.`, "warning");
32
22
  });
@@ -39,16 +29,18 @@ export function registerCloudEvents(pi: ExtensionAPI, runtime: CloudExtensionRun
39
29
  ? "Three distinct approaches already failed. Only summarize causes, progress, and changes; do not continue execution."
40
30
  : `Distinct failed approaches: ${details.failedApproaches.length}/${CLOUD_RUNTIME_DEFAULTS.workflow.maxFailedApproaches}. After the third distinct approach fails, stop and summarize.`;
41
31
  return {
42
- systemPrompt: `${event.systemPrompt}\n\nHWCODE CLOUD ACTIVE\nProvider: ${details.vendor}\nObjective: ${details.request}\nArtifact workspace: ${cloudArtifactDirectory(activeState)}\nCredentials must never be requested in chat, printed, placed in tool arguments, or read from the vault. Write all generated Cloud artifacts only under the artifact workspace: discovery/ for CLI skeletons and snapshots, terraform/ for IaC, charts/ for Helm sources/packages, reports/ for plans and summaries. Project source remains under ${activeState.root}; when a provider CLI needs a project source file, pass its absolute project-root path. Use hwcode_cloud_exec for provider CLI operations. ${details.runner ? "Use hwcode_runner_prepare and hwcode_terraform_* for Terraform source synchronization, validation, planning, approval, and apply on the selected Runner." : details.runnerPreference === "automatic" ? "The user requested an automatic temporary Runner. Provision it through approved provider CLI changes with workload identity and cloud-init, then register the discovered endpoint with hwcode_runner_connect." : "No Terraform Runner is selected. Do not provision one unless the user explicitly changes this choice."} Resource creates and changes require approval unless the user opted out; deletion always requires approval. After success, offer /hwcode-cloud-save-template to preserve the validated path locally. ${failureRule}`,
32
+ systemPrompt: `${event.systemPrompt}\n\nHWCODE CLOUD ACTIVE\nProvider: ${details.vendor}\nObjective: ${details.request}\nArtifact workspace: ${cloudArtifactDirectory(activeState)}\nCredentials must never be requested in chat, printed, placed in tool arguments, or read from the vault. Write all generated Cloud artifacts only under the artifact workspace: discovery/ for CLI skeletons and snapshots, terraform/ for IaC, charts/ for Helm sources/packages, reports/ for plans and summaries. Project source remains under ${activeState.root}; when a provider CLI needs a project source file, pass its absolute project-root path. Use hwcode_cloud_exec for provider CLI operations. ${details.runner ? "Use hwcode_runner_prepare and hwcode_terraform_* for Terraform source synchronization, validation, planning, approval, and apply on the selected Runner." : details.runnerPreference === "automatic" ? "The user requested an automatic temporary Runner. Provision it through approved provider CLI changes with workload identity and cloud-init, then register the discovered endpoint with hwcode_runner_connect." : "No Terraform Runner is selected. Do not provision one unless the user explicitly changes this choice."} Resource creates and changes require approval unless the user opted out; deletion always requires approval. After success, offer /hwcode-cloud-save-template to preserve the validated path locally. ${failureRule}`
33
+ + `\nConcurrency rule: Batch all independent read-only discovery calls concurrently in a single response turn. For resource creation, group independent operations into parallel tool calls while keeping dependent operations strictly sequential.`,
43
34
  };
44
35
  });
45
36
 
46
37
  pi.on("tool_call", async (event, ctx) => {
47
- const activeState = runtime.restore(ctx);
48
- if (!activeState || event.toolName !== "bash") return undefined;
49
- const input = event.input as Record<string, unknown>;
50
- if (typeof input.command !== "string" || !containsCloudCommand(input.command)) return undefined;
51
- return { block: true, reason: "Direct cloud and infrastructure CLI use is blocked during HWCode Cloud. Use hwcode_cloud_exec so credentials remain isolated and approvals are enforced." };
38
+ return undefined; // TODO: block direct cloud CLI calls during active workflow
39
+ // const activeState = runtime.restore(ctx);
40
+ // if (!activeState || event.toolName !== "bash") return undefined;
41
+ // const input = event.input as Record<string, unknown>;
42
+ // if (typeof input.command !== "string" || !containsCloudCommand(input.command)) return undefined;
43
+ // return { block: true, reason: "Direct cloud and infrastructure CLI use is blocked during HWCode Cloud. Use hwcode_cloud_exec so credentials remain isolated and approvals are enforced." };
52
44
  });
53
45
 
54
46
  pi.on("session_shutdown", async () => {
@@ -60,7 +60,7 @@ export function registerCloudProviderTools(pi: ExtensionAPI, runtime: CloudExten
60
60
  intent: Type.String({ description: "Concise description of what this command inspects or changes" }),
61
61
  approach: Type.String({ description: "Stable name for this technical approach" }),
62
62
  }),
63
- executionMode: "sequential",
63
+ // executionMode: "sequential", 允许并发调用;读操作无锁直通,写操作会在审批层安全受控
64
64
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
65
65
  let activeState = runtime.restore(ctx);
66
66
  let details = activeState && cloudDetails(activeState);
@@ -180,7 +180,17 @@ export function activationPrompt(state: WorkflowState): string {
180
180
  ? `\n\nReusable template guidance (${details.sourceTemplate?.name ?? "saved template"}):\n${details.templateGuidance}`
181
181
  : "";
182
182
  const runner = details.runner?.name ?? (details.runnerPreference === "automatic" ? "automatic temporary Runner requested" : "not selected");
183
- return `/skill:hwcode-cloud HWCode Cloud workflow activated.\n\nLocked project root: ${state.root}\nTask artifact workspace: ${cloudArtifactDirectory(state)}\nCloud provider: ${getCloudProvider(details.vendor).label}\nTerraform Runner: ${runner}\nDeploy current project: ${details.deployCurrentProject ? "yes" : "no"}\nUser objective:\n${details.request}${guidance}\n\nCredentials were validated and remain outside model context. Use hwcode_cloud_exec for provider CLI operations. If no Runner is selected and Terraform is required, first present the temporary Runner architecture and obtain approval to create its billable cloud resources; then use the configured Runner tools for Terraform execution.`;
183
+ return `/skill:hwcode-cloud HWCode Cloud workflow activated.
184
+
185
+ Locked project root: ${state.root}
186
+ Task artifact workspace: ${cloudArtifactDirectory(state)}
187
+ Cloud provider: ${getCloudProvider(details.vendor).label}
188
+ Terraform Runner: ${runner}
189
+ Deploy current project: ${details.deployCurrentProject ? "yes" : "no"}
190
+ User objective:\n${details.request}${guidance}
191
+
192
+ Note: If the cloud CLI is unavailable on this machine, you may use Python scripts, Node.js SDKs, or curl to invoke the cloud provider's REST APIs directly. Otherwise, prefer hwcode_cloud_exec.
193
+ `;
184
194
  }
185
195
 
186
196
  export async function approveOperation(operation: CloudOperation, params: { command: string; args: string[]; intent: string }, state: WorkflowState, ctx: ExtensionContext): Promise<"allow" | "allow-session" | "deny"> {
@@ -0,0 +1,174 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { Type } from "@earendil-works/pi-ai";
6
+ import { defineTool } from "@earendil-works/pi-coding-agent";
7
+ import { isAbsolute, relative, resolve } from "node:path";
8
+
9
+ import {
10
+ canonicalizeWorkspaceRoot,
11
+ } from "../../lib/workflow-guard.ts";
12
+ import { getWorkingDirectory } from "../../lib/working-directory.ts";
13
+ import {
14
+ WORKFLOW_STATE_TYPE,
15
+ activeWorkflow,
16
+ createWorkflowState,
17
+ updateWorkflowState,
18
+ type WorkflowState,
19
+ } from "../../lib/workflows/state.ts";
20
+ import { advanceSddProgress, SDD_PHASES } from "../../lib/workflows/sdd.ts";
21
+ import { PROJECT_SDD_SPECS_RELATIVE } from "../../lib/runtime/paths.ts";
22
+ import { confirmWorkspace, modeLabel, notify } from "./workspace-guard.ts";
23
+
24
+ interface GitState {
25
+ initialized: boolean;
26
+ dirty: boolean;
27
+ status: string;
28
+ }
29
+
30
+ async function inspectOrInitializeGit(
31
+ pi: ExtensionAPI,
32
+ root: string,
33
+ ctx: ExtensionCommandContext,
34
+ ): Promise<GitState | undefined> {
35
+ const gitVersion = await pi.exec("git", ["--version"], { cwd: root });
36
+ if (gitVersion.code !== 0) {
37
+ notify(ctx, "HWCode SDD requires Git, but the git executable is unavailable.", "error");
38
+ return undefined;
39
+ }
40
+
41
+ let initialized = false;
42
+ let topLevel = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: root });
43
+ if (topLevel.code !== 0) {
44
+ if (!ctx.hasUI || !(await ctx.ui.confirm(
45
+ "Initialize Git repository?",
46
+ `HWCode SDD requires the current directory to be a Git repository root.\n\nRun git init in:\n${root}`,
47
+ ))) return undefined;
48
+
49
+ const init = await pi.exec("git", ["init"], { cwd: root });
50
+ if (init.code !== 0) {
51
+ notify(ctx, `Git initialization failed: ${init.stderr.trim() || init.stdout.trim()}`, "error");
52
+ return undefined;
53
+ }
54
+ initialized = true;
55
+ topLevel = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: root });
56
+ }
57
+
58
+ if (topLevel.code !== 0) {
59
+ notify(ctx, "Unable to determine the Git repository root.", "error");
60
+ return undefined;
61
+ }
62
+
63
+ const gitRoot = canonicalizeWorkspaceRoot(topLevel.stdout.trim());
64
+ if (gitRoot !== root) {
65
+ notify(
66
+ ctx,
67
+ `HWCode SDD must start at the Git repository root. Restart it from: ${gitRoot}`,
68
+ "error",
69
+ );
70
+ return undefined;
71
+ }
72
+
73
+ const statusResult = await pi.exec(
74
+ "git",
75
+ ["status", "--porcelain=v1", "--untracked-files=all"],
76
+ { cwd: root },
77
+ );
78
+ if (statusResult.code !== 0) {
79
+ notify(ctx, `Unable to inspect Git status: ${statusResult.stderr.trim()}`, "error");
80
+ return undefined;
81
+ }
82
+
83
+ const status = statusResult.stdout.trim();
84
+ return { initialized, dirty: status.length > 0, status };
85
+ }
86
+
87
+ export function registerSddWorkflow(pi: ExtensionAPI) {
88
+ pi.registerCommand("hwcode-sdd", {
89
+ description: "Start the Git-root-locked HWCode spec-driven workflow",
90
+ handler: async (args, ctx) => {
91
+ if (!ctx.isIdle()) {
92
+ notify(ctx, `Wait for the current response to finish before starting ${modeLabel("sdd")}.`, "warning");
93
+ return;
94
+ }
95
+
96
+ const root = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
97
+ const existing = activeWorkflow(ctx.sessionManager.getEntries());
98
+ if (existing) {
99
+ notify(
100
+ ctx,
101
+ `${modeLabel(existing.mode)} is already active for ${existing.root}. Start a new session before activating another workflow.`,
102
+ "warning",
103
+ );
104
+ return;
105
+ }
106
+ if (!(await confirmWorkspace("sdd", root, ctx))) {
107
+ notify(ctx, `${modeLabel("sdd")} was not started.`, "warning");
108
+ return;
109
+ }
110
+
111
+ const git = await inspectOrInitializeGit(pi, root, ctx);
112
+ if (!git) return;
113
+
114
+ const state = createWorkflowState("sdd", root);
115
+ pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, state);
116
+ notify(ctx, `${modeLabel("sdd")} activated. Project root locked to ${root}.`);
117
+
118
+ const context = [
119
+ `The ${modeLabel("sdd")} workflow has been activated by its project command.`,
120
+ `Locked project root: ${root}`,
121
+ `Git repository: ${git.initialized ? "initialized now" : "already present"}; working tree: ${git.dirty ? "has changes" : "clean"}.`,
122
+ ];
123
+ if (git.status) context.push(`Current porcelain status:\n${git.status}`);
124
+ const request = args.trim();
125
+ if (request) context.push(`Initial user request:\n${request}`);
126
+ pi.sendUserMessage(`/skill:hwcode-sdd ${context.join("\n\n")}`, { expandPromptTemplates: true });
127
+ },
128
+ });
129
+
130
+ pi.registerTool(defineTool({
131
+ name: "hwcode_sdd_advance",
132
+ label: "HWCode SDD Phase Gate",
133
+ description: "Advance the active SDD workflow by exactly one explicitly approved phase.",
134
+ promptSnippet: "Use the SDD phase gate after presenting the completed artifact and receiving user approval.",
135
+ parameters: Type.Object({ nextPhase: Type.Union(SDD_PHASES.slice(1).map((phase) => Type.Literal(phase))), evidence: Type.String({ description: "Concise artifact and approval summary shown to the user" }) }),
136
+ executionMode: "sequential",
137
+ async execute(_id, params, _signal, _onUpdate, ctx) {
138
+ const state = activeWorkflow(ctx.sessionManager.getEntries());
139
+ if (!state || state.mode !== "sdd" || !state.sdd) return { content: [{ type: "text", text: "No active HWCode SDD workflow." }], isError: true, details: {} };
140
+ const evidence = params.evidence.replace(/[\r\n]+/gu, " ").trim().slice(0, 1_000);
141
+ let progress;
142
+ try { progress = advanceSddProgress(state.sdd, params.nextPhase, evidence); }
143
+ catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true, details: {} }; }
144
+ if (!ctx.hasUI || !await ctx.ui.confirm(`批准进入 SDD ${params.nextPhase} 阶段?`, evidence)) return { content: [{ type: "text", text: "SDD phase advancement was not approved." }], isError: true, details: {} };
145
+ const updated = updateWorkflowState(state, {
146
+ phase: params.nextPhase,
147
+ sdd: progress,
148
+ });
149
+ pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, updated);
150
+ return { content: [{ type: "text", text: `SDD advanced to ${params.nextPhase}.` }], details: { phase: params.nextPhase } };
151
+ },
152
+ }));
153
+
154
+ pi.on("tool_call", async (event, ctx) => {
155
+ const state = activeWorkflow(ctx.sessionManager.getEntries());
156
+ if (!state || state.mode !== "sdd" || !state.sdd) return undefined;
157
+
158
+ const input = event.input as Record<string, unknown>;
159
+ if (!["write", "edit"].includes(event.toolName) || typeof input.path !== "string") return undefined;
160
+
161
+ const path = resolve(state.root, input.path);
162
+ const projectRelative = relative(state.root, path).split("\\").join("/");
163
+ const isSpecArtifact = projectRelative.startsWith(`${PROJECT_SDD_SPECS_RELATIVE}/`);
164
+ const isTestArtifact = /(?:^|\/)(?:test|tests|__tests__)\/|(?:\.test|\.spec)\.[^/]+$/u.test(projectRelative);
165
+ const phase = state.sdd.phase;
166
+ const allowed = ["implementation", "verification"].includes(phase)
167
+ || isSpecArtifact
168
+ || phase === "tests" && isTestArtifact;
169
+ if (!allowed || isAbsolute(projectRelative) || projectRelative.startsWith("..")) {
170
+ return { block: true, reason: `SDD phase ${phase} does not allow production-file changes. Write approved spec artifacts under ${PROJECT_SDD_SPECS_RELATIVE}; test files become writable in the tests phase.` };
171
+ }
172
+ return undefined;
173
+ });
174
+ }
@@ -0,0 +1,54 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ } from "@earendil-works/pi-coding-agent";
4
+
5
+ import {
6
+ canonicalizeWorkspaceRoot,
7
+ } from "../../lib/workflow-guard.ts";
8
+ import { getWorkingDirectory } from "../../lib/working-directory.ts";
9
+ import {
10
+ WORKFLOW_STATE_TYPE,
11
+ activeWorkflow,
12
+ createWorkflowState,
13
+ type WorkflowState,
14
+ } from "../../lib/workflows/state.ts";
15
+ import { confirmWorkspace, modeLabel, notify } from "./workspace-guard.ts";
16
+
17
+ export function registerVibeWorkflow(pi: ExtensionAPI) {
18
+ pi.registerCommand("hwcode-vibe", {
19
+ description: "Start the directory-locked HWCode Vibe workflow",
20
+ handler: async (args, ctx) => {
21
+ if (!ctx.isIdle()) {
22
+ notify(ctx, `Wait for the current response to finish before starting ${modeLabel("vibe")}.`, "warning");
23
+ return;
24
+ }
25
+
26
+ const root = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
27
+ const existing = activeWorkflow(ctx.sessionManager.getEntries());
28
+ if (existing) {
29
+ notify(
30
+ ctx,
31
+ `${modeLabel(existing.mode)} is already active for ${existing.root}. Start a new session before activating another workflow.`,
32
+ "warning",
33
+ );
34
+ return;
35
+ }
36
+ if (!(await confirmWorkspace("vibe", root, ctx))) {
37
+ notify(ctx, `${modeLabel("vibe")} was not started.`, "warning");
38
+ return;
39
+ }
40
+
41
+ const state = createWorkflowState("vibe", root);
42
+ pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, state);
43
+ notify(ctx, `${modeLabel("vibe")} activated. Project root locked to ${root}.`);
44
+
45
+ const context = [
46
+ `The ${modeLabel("vibe")} workflow has been activated by its project command.`,
47
+ `Locked project root: ${root}`,
48
+ ];
49
+ const request = args.trim();
50
+ if (request) context.push(`Initial user request:\n${request}`);
51
+ pi.sendUserMessage(`/skill:hwcode-vibe ${context.join("\n\n")}`, { expandPromptTemplates: true });
52
+ },
53
+ });
54
+ }
@@ -0,0 +1,137 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import {
8
+ canonicalizeWorkspaceRoot,
9
+ } from "../../lib/workflow-guard.ts";
10
+ import { getWorkingDirectory } from "../../lib/working-directory.ts";
11
+ import {
12
+ WORKFLOW_EXTERNAL_AUDIT_TYPE,
13
+ WORKFLOW_STATE_TYPE,
14
+ activeWorkflow,
15
+ updateWorkflowState,
16
+ workflowLabel,
17
+ type WorkflowMode,
18
+ type WorkflowState,
19
+ } from "../../lib/workflows/state.ts";
20
+ import { evaluateToolPathAccess } from "../../lib/workspace/access-policy.ts";
21
+
22
+ export function modeLabel(mode: WorkflowMode): string {
23
+ return workflowLabel(mode);
24
+ }
25
+
26
+ export function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
27
+ if (ctx.hasUI) ctx.ui.notify(message, level);
28
+ }
29
+
30
+ export async function confirmWorkspace(mode: WorkflowMode, root: string, ctx: ExtensionCommandContext): Promise<boolean> {
31
+ if (!ctx.hasUI) return false;
32
+ return ctx.ui.confirm(
33
+ `Start ${modeLabel(mode)}?`,
34
+ [
35
+ `Project root: ${root}`,
36
+ "",
37
+ "All project work in this session will be limited to this root.",
38
+ "Paths inside it are allowed silently. Each external-path tool call requires separate approval.",
39
+ ].join("\n"),
40
+ );
41
+ }
42
+
43
+ export function registerWorkspaceGuard(pi: ExtensionAPI) {
44
+ let activeState: WorkflowState | undefined;
45
+ let allowExternalPathsForSession = false;
46
+
47
+ pi.on("session_start", async (_event, ctx) => {
48
+ const restored = activeWorkflow(ctx.sessionManager.getEntries());
49
+ if (!restored) {
50
+ activeState = undefined;
51
+ return;
52
+ }
53
+
54
+ const currentRoot = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
55
+ if (currentRoot !== restored.root) {
56
+ activeState = undefined;
57
+ pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, updateWorkflowState(restored, {
58
+ status: "cancelled",
59
+ phase: "root-changed",
60
+ reason: "Stored workflow root no longer matches the current execution directory.",
61
+ }));
62
+ notify(ctx, "Stored HWCode workflow disabled because the current directory changed.", "warning");
63
+ return;
64
+ }
65
+
66
+ activeState = restored;
67
+ notify(ctx, `${modeLabel(restored.mode)} restored. Root: ${restored.root}`);
68
+ });
69
+
70
+ pi.on("before_agent_start", async (event, ctx) => {
71
+ activeState = activeWorkflow(ctx.sessionManager.getEntries());
72
+ if (!activeState || activeState.mode === "cloud") return undefined;
73
+ const common = [
74
+ `HWCODE WORKFLOW ACTIVE: ${activeState.mode.toUpperCase()}`,
75
+ `The sole project root for this session is ${activeState.root}.`,
76
+ "Keep all reads, writes, commands, generated files, and project work inside that root by default.",
77
+ "An external-path approval applies only to the exact tool call that requested it. Never evade the guard through indirection, symlinks, subprocesses, alternate tools, or encoded commands.",
78
+ "If external storage such as /tmp is genuinely useful, explain why and let the tool-call approval request obtain user consent.",
79
+ ];
80
+ if (activeState.mode === "sdd") {
81
+ common.push(
82
+ "Follow the SDD phase gates. Do not implement production behavior before requirements, design, test plan, and tasks are complete and explicitly approved.",
83
+ "Develop tests before implementation with a red-green-refactor loop. Return to specification when expected behavior or a test scenario is uncertain.",
84
+ "Never create a Git commit unless the user explicitly approves that commit.",
85
+ );
86
+ }
87
+ const sddPhase = activeState.mode === "sdd" && activeState.sdd ? `\nCurrent enforced SDD phase: ${activeState.sdd.phase}. Use hwcode_sdd_advance for the next user-approved transition; never skip a phase.` : "";
88
+ return { systemPrompt: `${event.systemPrompt}\n\n${common.join("\n")}${sddPhase}` };
89
+ });
90
+
91
+ pi.on("tool_call", async (event, ctx) => {
92
+ activeState = activeWorkflow(ctx.sessionManager.getEntries());
93
+ if (!activeState) return undefined;
94
+
95
+ const input = event.input as Record<string, unknown>;
96
+ const external = evaluateToolPathAccess({ toolName: event.toolName, input }, activeState.root);
97
+
98
+ if (external.length === 0 || allowExternalPathsForSession) return undefined;
99
+ const details = external.map((entry) => `• ${entry.raw} → ${entry.resolved}`).join("\n");
100
+ if (!ctx.hasUI) {
101
+ return {
102
+ block: true,
103
+ reason: `External path requires interactive one-call approval:\n${details}`,
104
+ };
105
+ }
106
+
107
+ const choice = await ctx.ui.select(
108
+ `Allow external path for this call? ${modeLabel(activeState.mode)} is locked to ${activeState.root}.`,
109
+ [
110
+ "Allow once (仅允许本次)",
111
+ "Always allow for this session (本会话不再询问)",
112
+ "Deny (拒绝)"
113
+ ]
114
+ );
115
+ if (!choice || choice.startsWith("Deny")) {
116
+ return { block: true, reason: `User denied external path access:\n${details}` };
117
+ }
118
+ if (choice.startsWith("Always allow")) {
119
+ allowExternalPathsForSession = true;
120
+ notify(ctx, "[已开启本会话外部路径访问权限,后续不再提示]", "info");
121
+ }
122
+
123
+ pi.appendEntry(WORKFLOW_EXTERNAL_AUDIT_TYPE, {
124
+ mode: activeState.mode,
125
+ root: activeState.root,
126
+ toolName: event.toolName,
127
+ external,
128
+ approvedAt: new Date().toISOString(),
129
+ });
130
+ return undefined;
131
+ });
132
+
133
+ pi.on("session_shutdown", () => {
134
+ activeState = undefined;
135
+ allowExternalPathsForSession = false;
136
+ });
137
+ }
@@ -1,9 +1,14 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ import { registerWorkspaceGuard } from "./workflows/workspace-guard.ts";
4
+ import { registerVibeWorkflow } from "./workflows/vibe.ts";
5
+ import { registerSddWorkflow } from "./workflows/sdd.ts";
3
6
  import { registerCloudWorkflow } from "./workflows/cloud/index.ts";
4
- import { registerVibeSddWorkflows } from "./workflows/vibe-sdd.ts";
5
7
 
6
8
  export default function registerWorkflows(pi: ExtensionAPI): void {
7
- registerVibeSddWorkflows(pi);
9
+ registerWorkspaceGuard(pi);
10
+ registerVibeWorkflow(pi);
11
+ registerSddWorkflow(pi);
8
12
  registerCloudWorkflow(pi);
9
13
  }
14
+
@@ -128,6 +128,54 @@ function singleLine(value: string): string {
128
128
  return value.replace(/[\r\n]+/gu, " ").replace(/\s+/gu, " ").trim();
129
129
  }
130
130
 
131
+ interface GeneralizedVar { placeholder: string; original: string; hint: string }
132
+
133
+ const GENERALIZE_KEY_PATTERNS: Array<[RegExp, string, string]> = [
134
+ [/(?:^|--)(?:cli-)?region$/iu, "region", "使用当前凭据 region"],
135
+ [/vpc[-_]?id$/iu, "vpc-id", "ListVpcs 查询"],
136
+ [/subnet[-_]?id$/iu, "subnet-id", "ListSubnets 查询"],
137
+ [/image[-_]?id$/iu, "image-id", "ListImages 查询"],
138
+ [/secur(?:ity)?[-_]?group[-_]?id$/iu, "sg-id", "ListSecurityGroups 查询"],
139
+ [/key[-_]?(?:pair|name)$/iu, "key-name", "ListKeypairs 查询"],
140
+ ];
141
+
142
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
143
+ const HEX32_RE = /^[0-9a-f]{32}$/iu;
144
+
145
+ function generalizeArgs(args: readonly string[]): { args: string[]; vars: GeneralizedVar[] } {
146
+ const vars: GeneralizedVar[] = [];
147
+ const seen = new Set<string>();
148
+ const result = args.map((arg) => {
149
+ const eq = arg.indexOf("=");
150
+ if (eq > 0) {
151
+ const key = arg.slice(0, eq);
152
+ const val = arg.slice(eq + 1);
153
+ for (const [pattern, name, hint] of GENERALIZE_KEY_PATTERNS) {
154
+ if (pattern.test(key) && !seen.has(name)) {
155
+ seen.add(name);
156
+ vars.push({ placeholder: `<${name}>`, original: val, hint });
157
+ return `${key}=<${name}>`;
158
+ }
159
+ }
160
+ if (/id$/iu.test(key) && (UUID_RE.test(val) || HEX32_RE.test(val))) {
161
+ const name = key.replace(/^--(?:cli-)?/u, "").replace(/[_=]/gu, "-");
162
+ if (!seen.has(name)) {
163
+ seen.add(name);
164
+ vars.push({ placeholder: `<${name}>`, original: val, hint: "执行前通过只读查询获取" });
165
+ return `${key}=<${name}>`;
166
+ }
167
+ }
168
+ }
169
+ if ((UUID_RE.test(arg) || HEX32_RE.test(arg)) && !seen.has("resource-id")) {
170
+ seen.add("resource-id");
171
+ vars.push({ placeholder: "<resource-id>", original: arg, hint: "执行前通过只读查询获取" });
172
+ return "<resource-id>";
173
+ }
174
+ return arg;
175
+ });
176
+ return { args: result, vars };
177
+ }
178
+
131
179
  export function renderCloudPromptTemplate(state: WorkflowState, notes = ""): string {
132
180
  if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
133
181
  const details = state.details;
@@ -135,11 +183,16 @@ export function renderCloudPromptTemplate(state: WorkflowState, notes = ""): str
135
183
  throw new Error("No successful Cloud execution steps are available to summarize");
136
184
  }
137
185
  const provider = getCloudProvider(details.vendor);
138
- const steps = details.successfulSteps.map((step, index) => (
139
- `${index + 1}. [${step.operation}] ${singleLine(step.intent)}\n`
186
+ const allVars: GeneralizedVar[] = [];
187
+ const steps = details.successfulSteps.map((step, index) => {
188
+ const gen = generalizeArgs(step.args);
189
+ for (const v of gen.vars) {
190
+ if (!allVars.some((e) => e.placeholder === v.placeholder)) allVars.push(v);
191
+ }
192
+ return `${index + 1}. [${step.operation}] ${singleLine(step.intent)}\n`
140
193
  + ` - Validated approach: ${singleLine(step.approach)}\n`
141
- + ` - Known-good command and path: \`${inlineCode([step.command, ...step.args].join(" "))}\``
142
- )).join("\n");
194
+ + ` - Known-good command: \`${inlineCode([step.command, ...gen.args].join(" "))}\``;
195
+ }).join("\n");
143
196
  const failedPaths = details.failedApproaches.length > 0
144
197
  ? details.failedApproaches.map((failure, index) => (
145
198
  `${index + 1}. **DO NOT RETRY:** ${singleLine(failure.approach)}\n`
@@ -161,10 +214,15 @@ export function renderCloudPromptTemplate(state: WorkflowState, notes = ""): str
161
214
  "",
162
215
  "Start with read-only discovery. Re-check the active account, region, project, resource state, tool versions, and workspace paths before making changes.",
163
216
  ...(notes ? ["", "Validated prerequisites, lessons, and expensive pitfalls:", notes] : []),
217
+ ...(allVars.length > 0 ? [
218
+ "",
219
+ "Environment-specific values (resolve via read-only discovery before executing):",
220
+ ...allVars.map((v) => ` ${v.placeholder.padEnd(20)} 原值: ${v.original} → ${v.hint}`),
221
+ ] : []),
164
222
  "",
165
223
  "## Layer 3 — Preferred validated execution path",
166
224
  "",
167
- "Use this path first and preserve its order unless read-only evidence requires an adaptation. The commands and paths below are the known-good reference; do not replace them with speculative alternatives.",
225
+ "Use this path first and preserve its order. Replace <placeholder> values with discovered IDs from the current environment before executing. The command structure is the known-good reference; do not replace it with speculative alternatives.",
168
226
  "",
169
227
  steps,
170
228
  "",
@@ -20,6 +20,9 @@ Use this skill only after `/hwcode-cloud` has activated the workflow. The activa
20
20
 
21
21
  The command already collected the provider, deployment choice, objective, and validated account connection. Do not repeat those questions unless the activation context is contradictory.
22
22
 
23
+ ### Batch Discovery Policy
24
+ - **Parallel Read Queries**: When inspecting the cloud environment, emit all independent read-only `hwcode_cloud_exec` calls concurrently in a SINGLE turn (e.g. query VPCs, subnets, security groups, images, and flavors together). Do NOT probe sequentially across multiple conversational turns.
25
+
23
26
  ## Artifact workspace
24
27
 
25
28
  The activation context includes a task artifact workspace inside the locked project root. Keep all generated Cloud-task artifacts there; never write them to the project root or invent top-level task directories.
@@ -54,6 +57,10 @@ Before modifying account resources, present a plan containing:
54
57
  5. rollout, verification, observability, rollback, and estimated cost drivers;
55
58
  6. ordered commands and their expected resource impact.
56
59
 
60
+ When presenting the execution plan, explicitly organize steps into **Execution Stages**:
61
+ - Mark independent steps as **[Parallel Group N]** to indicate they will be executed concurrently.
62
+ - Mark dependent steps with their prerequisite dependencies (e.g. `Requires: Step 1 VPC ID`).
63
+
57
64
  Prefer declarative, reviewable, idempotent infrastructure as code. Use a plan/dry-run command before apply when the selected tooling supports it. Pin important versions and avoid provider defaults that materially affect cost or exposure.
58
65
 
59
66
  ### Terraform Runner
@@ -71,6 +78,10 @@ If an existing SSH host is selected, reuse it. Only ask for SSH information when
71
78
 
72
79
  ## Phase 3: Execute with approval gates
73
80
 
81
+ - **Independent Mutations**: For operations within the same `[Parallel Group]` (e.g., creating multiple independent security group rules or non-dependent subnets), invoke them concurrently in a single turn.
82
+ - **Sequential Dependencies**: Never guess resource IDs. If Step B requires an ID generated by Step A, wait for Step A's tool result before emitting Step B.
83
+ - **Deletions**: Never batch destructive deletions silently; each delete operation requires separate review.
84
+
74
85
  For every `hwcode_cloud_exec` call:
75
86
 
76
87
  - set `operation` to `read`, `change`, or `delete` honestly;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadooppei/hwcode",
3
- "version": "1.0.2",
3
+ "version": "1.0.5",
4
4
  "description": "A customizable terminal coding agent with local-model support and HWCode workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,303 +0,0 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionCommandContext,
4
- ExtensionContext,
5
- } from "@earendil-works/pi-coding-agent";
6
- import { Type } from "@earendil-works/pi-ai";
7
- import { defineTool } from "@earendil-works/pi-coding-agent";
8
- import { isAbsolute, relative, resolve } from "node:path";
9
-
10
- import {
11
- canonicalizeWorkspaceRoot,
12
- } from "../../lib/workflow-guard.ts";
13
- import { getWorkingDirectory } from "../../lib/working-directory.ts";
14
- import {
15
- WORKFLOW_EXTERNAL_AUDIT_TYPE,
16
- WORKFLOW_STATE_TYPE,
17
- activeWorkflow,
18
- createWorkflowState,
19
- updateWorkflowState,
20
- workflowLabel,
21
- type WorkflowMode,
22
- type WorkflowState,
23
- } from "../../lib/workflows/state.ts";
24
- import { advanceSddProgress, SDD_PHASES } from "../../lib/workflows/sdd.ts";
25
- import { evaluateToolPathAccess } from "../../lib/workspace/access-policy.ts";
26
- import { PROJECT_SDD_SPECS_RELATIVE } from "../../lib/runtime/paths.ts";
27
-
28
- interface GitState {
29
- initialized: boolean;
30
- dirty: boolean;
31
- status: string;
32
- }
33
-
34
- function modeLabel(mode: WorkflowMode): string {
35
- return workflowLabel(mode);
36
- }
37
-
38
- function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
39
- if (ctx.hasUI) ctx.ui.notify(message, level);
40
- }
41
-
42
- async function confirmWorkspace(mode: WorkflowMode, root: string, ctx: ExtensionCommandContext): Promise<boolean> {
43
- if (!ctx.hasUI) return false;
44
- return ctx.ui.confirm(
45
- `Start ${modeLabel(mode)}?`,
46
- [
47
- `Project root: ${root}`,
48
- "",
49
- "All project work in this session will be limited to this root.",
50
- "Paths inside it are allowed silently. Each external-path tool call requires separate approval.",
51
- ].join("\n"),
52
- );
53
- }
54
-
55
- async function inspectOrInitializeGit(
56
- pi: ExtensionAPI,
57
- root: string,
58
- ctx: ExtensionCommandContext,
59
- ): Promise<GitState | undefined> {
60
- const gitVersion = await pi.exec("git", ["--version"], { cwd: root });
61
- if (gitVersion.code !== 0) {
62
- notify(ctx, "HWCode SDD requires Git, but the git executable is unavailable.", "error");
63
- return undefined;
64
- }
65
-
66
- let initialized = false;
67
- let topLevel = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: root });
68
- if (topLevel.code !== 0) {
69
- if (!ctx.hasUI || !(await ctx.ui.confirm(
70
- "Initialize Git repository?",
71
- `HWCode SDD requires the current directory to be a Git repository root.\n\nRun git init in:\n${root}`,
72
- ))) return undefined;
73
-
74
- const init = await pi.exec("git", ["init"], { cwd: root });
75
- if (init.code !== 0) {
76
- notify(ctx, `Git initialization failed: ${init.stderr.trim() || init.stdout.trim()}`, "error");
77
- return undefined;
78
- }
79
- initialized = true;
80
- topLevel = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: root });
81
- }
82
-
83
- if (topLevel.code !== 0) {
84
- notify(ctx, "Unable to determine the Git repository root.", "error");
85
- return undefined;
86
- }
87
-
88
- const gitRoot = canonicalizeWorkspaceRoot(topLevel.stdout.trim());
89
- if (gitRoot !== root) {
90
- notify(
91
- ctx,
92
- `HWCode SDD must start at the Git repository root. Restart it from: ${gitRoot}`,
93
- "error",
94
- );
95
- return undefined;
96
- }
97
-
98
- const statusResult = await pi.exec(
99
- "git",
100
- ["status", "--porcelain=v1", "--untracked-files=all"],
101
- { cwd: root },
102
- );
103
- if (statusResult.code !== 0) {
104
- notify(ctx, `Unable to inspect Git status: ${statusResult.stderr.trim()}`, "error");
105
- return undefined;
106
- }
107
-
108
- const status = statusResult.stdout.trim();
109
- return { initialized, dirty: status.length > 0, status };
110
- }
111
-
112
- function activationPrompt(mode: WorkflowMode, root: string, git: GitState | undefined, request: string): string {
113
- const context = [
114
- `The ${modeLabel(mode)} workflow has been activated by its project command.`,
115
- `Locked project root: ${root}`,
116
- ];
117
- if (git) {
118
- context.push(
119
- `Git repository: ${git.initialized ? "initialized now" : "already present"}; working tree: ${git.dirty ? "has changes" : "clean"}.`,
120
- );
121
- if (git.status) context.push(`Current porcelain status:\n${git.status}`);
122
- }
123
- if (request) context.push(`Initial user request:\n${request}`);
124
- return `/skill:hwcode-${mode} ${context.join("\n\n")}`;
125
- }
126
-
127
- function workflowSystemPrompt(state: WorkflowState): string {
128
- const common = [
129
- `HWCODE WORKFLOW ACTIVE: ${state.mode.toUpperCase()}`,
130
- `The sole project root for this session is ${state.root}.`,
131
- "Keep all reads, writes, commands, generated files, and project work inside that root by default.",
132
- "An external-path approval applies only to the exact tool call that requested it. Never evade the guard through indirection, symlinks, subprocesses, alternate tools, or encoded commands.",
133
- "If external storage such as /tmp is genuinely useful, explain why and let the tool-call approval request obtain user consent.",
134
- ];
135
- if (state.mode === "sdd") {
136
- common.push(
137
- "Follow the SDD phase gates. Do not implement production behavior before requirements, design, test plan, and tasks are complete and explicitly approved.",
138
- "Develop tests before implementation with a red-green-refactor loop. Return to specification when expected behavior or a test scenario is uncertain.",
139
- "Never create a Git commit unless the user explicitly approves that commit.",
140
- );
141
- }
142
- return common.join("\n");
143
- }
144
-
145
- export function registerVibeSddWorkflows(pi: ExtensionAPI) {
146
- let activeState: WorkflowState | undefined;
147
- let allowExternalPathsForSession = false;
148
-
149
- async function activate(mode: WorkflowMode, args: string, ctx: ExtensionCommandContext): Promise<void> {
150
- if (!ctx.isIdle()) {
151
- notify(ctx, `Wait for the current response to finish before starting ${modeLabel(mode)}.`, "warning");
152
- return;
153
- }
154
-
155
- const root = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
156
- const existing = activeWorkflow(ctx.sessionManager.getEntries());
157
- if (existing) {
158
- notify(
159
- ctx,
160
- `${workflowLabel(existing.mode)} is already active for ${existing.root}. Start a new session before activating another workflow.`,
161
- "warning",
162
- );
163
- return;
164
- }
165
- if (!(await confirmWorkspace(mode, root, ctx))) {
166
- notify(ctx, `${modeLabel(mode)} was not started.`, "warning");
167
- return;
168
- }
169
-
170
- const git = mode === "sdd" ? await inspectOrInitializeGit(pi, root, ctx) : undefined;
171
- if (mode === "sdd" && !git) return;
172
-
173
- activeState = createWorkflowState(mode as "vibe" | "sdd", root);
174
- pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, activeState);
175
- notify(ctx, `${modeLabel(mode)} activated. Project root locked to ${root}.`);
176
- pi.sendUserMessage(activationPrompt(mode, root, git, args.trim()), { expandPromptTemplates: true });
177
- }
178
-
179
- pi.registerCommand("hwcode-vibe", {
180
- description: "Start the directory-locked HWCode Vibe workflow",
181
- handler: async (args, ctx) => activate("vibe", args, ctx),
182
- });
183
-
184
- pi.registerCommand("hwcode-sdd", {
185
- description: "Start the Git-root-locked HWCode spec-driven workflow",
186
- handler: async (args, ctx) => activate("sdd", args, ctx),
187
- });
188
-
189
- pi.registerTool(defineTool({
190
- name: "hwcode_sdd_advance",
191
- label: "HWCode SDD Phase Gate",
192
- description: "Advance the active SDD workflow by exactly one explicitly approved phase.",
193
- promptSnippet: "Use the SDD phase gate after presenting the completed artifact and receiving user approval.",
194
- parameters: Type.Object({ nextPhase: Type.Union(SDD_PHASES.slice(1).map((phase) => Type.Literal(phase))), evidence: Type.String({ description: "Concise artifact and approval summary shown to the user" }) }),
195
- executionMode: "sequential",
196
- async execute(_id, params, _signal, _onUpdate, ctx) {
197
- const state = activeWorkflow(ctx.sessionManager.getEntries());
198
- if (!state || state.mode !== "sdd" || !state.sdd) return { content: [{ type: "text", text: "No active HWCode SDD workflow." }], isError: true, details: {} };
199
- const evidence = params.evidence.replace(/[\r\n]+/gu, " ").trim().slice(0, 1_000);
200
- let progress;
201
- try { progress = advanceSddProgress(state.sdd, params.nextPhase, evidence); }
202
- catch (error) { return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true, details: {} }; }
203
- if (!ctx.hasUI || !await ctx.ui.confirm(`批准进入 SDD ${params.nextPhase} 阶段?`, evidence)) return { content: [{ type: "text", text: "SDD phase advancement was not approved." }], isError: true, details: {} };
204
- const updated = updateWorkflowState(state, {
205
- phase: params.nextPhase,
206
- sdd: progress,
207
- });
208
- activeState = updated;
209
- pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, updated);
210
- return { content: [{ type: "text", text: `SDD advanced to ${params.nextPhase}.` }], details: { phase: params.nextPhase } };
211
- },
212
- }));
213
-
214
- pi.on("session_start", async (_event, ctx) => {
215
- const restored = activeWorkflow(ctx.sessionManager.getEntries());
216
- if (!restored) {
217
- activeState = undefined;
218
- return;
219
- }
220
-
221
- const currentRoot = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
222
- if (currentRoot !== restored.root) {
223
- activeState = undefined;
224
- pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, updateWorkflowState(restored, {
225
- status: "failed",
226
- phase: "invalid-root",
227
- reason: "Stored workflow root no longer matches the current execution directory.",
228
- }));
229
- notify(ctx, "Stored HWCode workflow disabled because the current directory changed.", "warning");
230
- return;
231
- }
232
-
233
- activeState = restored;
234
- notify(ctx, `${modeLabel(restored.mode)} restored. Root: ${restored.root}`);
235
- });
236
-
237
- pi.on("before_agent_start", async (event, ctx) => {
238
- activeState = activeWorkflow(ctx.sessionManager.getEntries());
239
- if (!activeState) return undefined;
240
- const sddPhase = activeState.mode === "sdd" && activeState.sdd ? `\nCurrent enforced SDD phase: ${activeState.sdd.phase}. Use hwcode_sdd_advance for the next user-approved transition; never skip a phase.` : "";
241
- return { systemPrompt: `${event.systemPrompt}\n\n${workflowSystemPrompt(activeState)}${sddPhase}` };
242
- });
243
-
244
- pi.on("tool_call", async (event, ctx) => {
245
- activeState = activeWorkflow(ctx.sessionManager.getEntries());
246
- if (!activeState) return undefined;
247
-
248
- const input = event.input as Record<string, unknown>;
249
- if (activeState.mode === "sdd" && activeState.sdd && ["write", "edit"].includes(event.toolName) && typeof input.path === "string") {
250
- const path = resolve(activeState.root, input.path);
251
- const projectRelative = relative(activeState.root, path).split("\\").join("/");
252
- const isSpecArtifact = projectRelative.startsWith(`${PROJECT_SDD_SPECS_RELATIVE}/`);
253
- const isTestArtifact = /(?:^|\/)(?:test|tests|__tests__)\/|(?:\.test|\.spec)\.[^/]+$/u.test(projectRelative);
254
- const phase = activeState.sdd.phase;
255
- const allowed = ["implementation", "verification"].includes(phase)
256
- || isSpecArtifact
257
- || phase === "tests" && isTestArtifact;
258
- if (!allowed || isAbsolute(projectRelative) || projectRelative.startsWith("..")) {
259
- return { block: true, reason: `SDD phase ${phase} does not allow production-file changes. Write approved spec artifacts under ${PROJECT_SDD_SPECS_RELATIVE}; test files become writable in the tests phase.` };
260
- }
261
- }
262
- const external = evaluateToolPathAccess({ toolName: event.toolName, input }, activeState.root);
263
-
264
- if (external.length === 0 || allowExternalPathsForSession) return undefined;
265
- const details = external.map((entry) => `• ${entry.raw} → ${entry.resolved}`).join("\n");
266
- if (!ctx.hasUI) {
267
- return {
268
- block: true,
269
- reason: `External path requires interactive one-call approval:\n${details}`,
270
- };
271
- }
272
-
273
- const choice = await ctx.ui.select(
274
- "Allow external path for this call? `${modeLabel(activeState.mode)} is locked to ${activeState.root}.`",
275
- [
276
- "Allow once (仅允许本次)",
277
- "Always allow for this session (本会话不再询问)",
278
- "Deny (拒绝)"
279
- ]
280
- );
281
- if (!choice || choice.startsWith("Deny")) {
282
- return { block: true, reason: `User denied external path access:\n${details}` };
283
- }
284
- if (choice.startsWith("Always allow")) {
285
- allowExternalPathsForSession = true;
286
- notify(ctx, "[已开启本会话外部路径访问权限,后续不再提示]", "info");
287
- }
288
-
289
- pi.appendEntry(WORKFLOW_EXTERNAL_AUDIT_TYPE, {
290
- mode: activeState.mode,
291
- root: activeState.root,
292
- toolName: event.toolName,
293
- external,
294
- approvedAt: new Date().toISOString(),
295
- });
296
- return undefined;
297
- });
298
-
299
- pi.on("session_shutdown", () => {
300
- activeState = undefined;
301
- allowExternalPathsForSession = false;
302
- });
303
- }