@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.
- package/.pi/APPEND_SYSTEM.md +4 -1
- package/.pi/extensions/hwcode.ts +2 -2
- package/.pi/extensions/workflows/cloud/activation.ts +22 -3
- package/.pi/extensions/workflows/cloud/events.ts +8 -16
- package/.pi/extensions/workflows/cloud/provider-tools.ts +1 -1
- package/.pi/extensions/workflows/cloud/shared.ts +11 -1
- package/.pi/extensions/workflows/sdd.ts +174 -0
- package/.pi/extensions/workflows/vibe.ts +54 -0
- package/.pi/extensions/workflows/workspace-guard.ts +137 -0
- package/.pi/extensions/workflows.ts +7 -2
- package/.pi/lib/workflows/cloud/templates.ts +63 -5
- package/.pi/skills/hwcode-cloud/SKILL.md +11 -0
- package/package.json +1 -1
- package/.pi/extensions/workflows/vibe-sdd.ts +0 -303
package/.pi/APPEND_SYSTEM.md
CHANGED
|
@@ -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
|
-
|
|
7
|
+
- 在执行华为云云端资源操作时,优先使用 `hwcode_cloud_exec` 工具以保证凭据隔离;若工具不可用,允许使用脚本或 API。
|
|
8
|
+
- **并发与批处理准则**:在信息查询、环境探测及执行互无依赖的安全操作时,应在单次响应中并发调用多个工具(Parallel Tool Calling),避免多轮单步往返以提升执行效率。
|
|
9
|
+
- 禁止在未获得用户明确授权的情况下执行破坏性、删除或高危操作。
|
|
10
|
+
- 遵循工作区边界,保持修改最小化并按阶段执行。
|
package/.pi/extensions/hwcode.ts
CHANGED
|
@@ -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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
139
|
-
|
|
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
|
|
142
|
-
)
|
|
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
|
|
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,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
|
-
}
|