@hadooppei/hwcode 0.2.4 → 1.0.1
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 +2 -0
- package/.pi/extensions/hwcode.ts +35 -2
- package/.pi/extensions/model-providers.ts +1 -2
- package/.pi/extensions/workflows/cloud/activation.ts +235 -0
- package/.pi/extensions/workflows/cloud/commands.ts +96 -0
- package/.pi/extensions/workflows/cloud/events.ts +58 -0
- package/.pi/extensions/workflows/cloud/index.ts +17 -0
- package/.pi/extensions/workflows/cloud/provider-tools.ts +127 -0
- package/.pi/extensions/workflows/cloud/runner-tools.ts +129 -0
- package/.pi/extensions/workflows/cloud/runtime.ts +97 -0
- package/.pi/extensions/workflows/cloud/shared.ts +213 -0
- package/.pi/extensions/workflows/cloud/terraform-tools.ts +126 -0
- package/.pi/extensions/workflows/vibe-sdd.ts +303 -0
- package/.pi/extensions/workflows.ts +6 -253
- package/.pi/lib/runtime/config.ts +3 -7
- package/.pi/lib/runtime/defaults.ts +20 -0
- package/.pi/lib/runtime/paths.ts +68 -0
- package/.pi/lib/runtime/session-state.ts +0 -34
- package/.pi/lib/{cloud → workflows/cloud}/adapters.ts +38 -20
- package/.pi/lib/workflows/cloud/bundles.ts +358 -0
- package/.pi/lib/workflows/cloud/execution.ts +28 -0
- package/.pi/lib/{cloud → workflows/cloud}/process.ts +5 -3
- package/.pi/lib/{cloud-providers.ts → workflows/cloud/providers.ts} +4 -4
- package/.pi/lib/workflows/cloud/remote/bootstrap.ts +23 -0
- package/.pi/lib/workflows/cloud/remote/connect.ts +83 -0
- package/.pi/lib/workflows/cloud/remote/host-key.ts +35 -0
- package/.pi/lib/workflows/cloud/remote/profiles.ts +100 -0
- package/.pi/lib/workflows/cloud/remote/ssh-transport.ts +104 -0
- package/.pi/lib/workflows/cloud/remote/workspace.ts +109 -0
- package/.pi/lib/{cloud → workflows/cloud}/template-save.ts +3 -2
- package/.pi/lib/{cloud → workflows/cloud}/templates.ts +18 -10
- package/.pi/lib/workflows/cloud/terraform/plan.ts +109 -0
- package/.pi/lib/workflows/cloud/terraform/policy.ts +36 -0
- package/.pi/lib/workflows/cloud/terraform/runner.ts +64 -0
- package/.pi/lib/{cloud-vault.ts → workflows/cloud/vault.ts} +26 -24
- package/.pi/lib/workflows/cloud/workspace.ts +37 -0
- package/.pi/lib/workflows/sdd.ts +11 -0
- package/.pi/lib/workflows/state.ts +114 -2
- package/.pi/lib/working-directory.ts +0 -58
- package/.pi/skills/hwcode-cloud/SKILL.md +32 -1
- package/.pi/skills/hwcode-sdd/SKILL.md +2 -0
- package/README.md +41 -8
- package/bin/hwcode.js +2 -6
- package/package.json +8 -3
- package/.pi/extensions/cloud.ts +0 -629
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { RemoteTargetProfile } from "../remote/profiles.ts";
|
|
4
|
+
import { runSsh, type SshRunResult } from "../remote/ssh-transport.ts";
|
|
5
|
+
import { CLOUD_RUNTIME_DEFAULTS } from "../../../runtime/defaults.ts";
|
|
6
|
+
|
|
7
|
+
export type TerraformRunnerAction = "fmt" | "init" | "validate" | "test" | "plan" | "show-plan" | "show-state" | "apply";
|
|
8
|
+
|
|
9
|
+
export interface TerraformRunnerRequest {
|
|
10
|
+
action: TerraformRunnerAction;
|
|
11
|
+
workspace: string;
|
|
12
|
+
terraformPath?: string;
|
|
13
|
+
planPath?: string;
|
|
14
|
+
mode?: "normal" | "destroy" | "refresh-only";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ACTIONS: Record<TerraformRunnerAction, readonly string[]> = {
|
|
18
|
+
fmt: ["fmt", "-check", "-no-color"],
|
|
19
|
+
init: ["init", "-input=false", "-no-color"],
|
|
20
|
+
validate: ["validate", "-no-color"],
|
|
21
|
+
test: ["test", "-no-color"],
|
|
22
|
+
plan: ["plan", "-input=false", "-no-color"],
|
|
23
|
+
"show-plan": ["show", "-json", "-no-color"],
|
|
24
|
+
"show-state": ["show", "-json", "-no-color"],
|
|
25
|
+
apply: ["apply", "-input=false", "-no-color"],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function assertWorkspace(workspace: string): void {
|
|
29
|
+
if (!workspace.startsWith("/") || workspace === "/" || workspace.includes("..")) {
|
|
30
|
+
throw new Error("invalid Terraform workspace path");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildTerraformRunnerArgs(request: TerraformRunnerRequest): string[] {
|
|
35
|
+
assertWorkspace(request.workspace);
|
|
36
|
+
const executable = request.terraformPath?.trim() || CLOUD_RUNTIME_DEFAULTS.terraform.executable;
|
|
37
|
+
if (!executable || executable.includes(" ") || executable.includes(";")) throw new Error("invalid Terraform executable");
|
|
38
|
+
const args = [...ACTIONS[request.action]];
|
|
39
|
+
if (request.action === "plan") {
|
|
40
|
+
if (request.mode === "destroy") args.push("-destroy");
|
|
41
|
+
if (request.mode === "refresh-only") args.push("-refresh-only");
|
|
42
|
+
args.push(`-out=${CLOUD_RUNTIME_DEFAULTS.terraform.planFile}`);
|
|
43
|
+
}
|
|
44
|
+
if (request.action === "show-plan" || request.action === "apply") {
|
|
45
|
+
if (!request.planPath || request.planPath.includes("..") || !request.planPath.endsWith(".tfplan")) {
|
|
46
|
+
throw new Error("a managed Terraform plan path is required");
|
|
47
|
+
}
|
|
48
|
+
args.push(request.planPath);
|
|
49
|
+
}
|
|
50
|
+
return [executable, ...args];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function executeTerraformRunner(
|
|
54
|
+
profile: RemoteTargetProfile,
|
|
55
|
+
request: TerraformRunnerRequest,
|
|
56
|
+
options: { signal?: AbortSignal; timeoutMs?: number; onOutput?: (chunk: string, stream: "stdout" | "stderr") => void } = {},
|
|
57
|
+
): Promise<SshRunResult> {
|
|
58
|
+
const [command, ...args] = buildTerraformRunnerArgs({ ...request, terraformPath: request.terraformPath ?? profile.terraformPath });
|
|
59
|
+
return runSsh(profile, command, args, { ...options, remoteCwd: request.workspace });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function digestTerraformPlan(planJson: string): string {
|
|
63
|
+
return createHash("sha256").update(planJson, "utf8").digest("hex");
|
|
64
|
+
}
|
|
@@ -14,9 +14,11 @@ import {
|
|
|
14
14
|
writeFileSync,
|
|
15
15
|
} from "node:fs";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
|
-
import { dirname
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
18
|
|
|
19
|
-
import { isCloudVendorId, type CloudCredentials, type CloudVendorId } from "./
|
|
19
|
+
import { isCloudVendorId, type CloudCredentials, type CloudVendorId } from "./providers.ts";
|
|
20
|
+
import { validateRemoteTargetProfile, type RemoteTargetProfile } from "./remote/profiles.ts";
|
|
21
|
+
import { userRuntimePaths } from "../../runtime/paths.ts";
|
|
20
22
|
|
|
21
23
|
const AAD = Buffer.from("hwcode-cloud-credentials-v1", "utf8");
|
|
22
24
|
const KEY_LENGTH = 32;
|
|
@@ -30,8 +32,9 @@ export interface CloudCredentialProfile {
|
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface VaultPayload {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
version: 2;
|
|
36
|
+
providers: Partial<Record<CloudVendorId, CloudCredentialProfile[]>>;
|
|
37
|
+
runners?: RemoteTargetProfile[];
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
interface EncryptedVault {
|
|
@@ -45,7 +48,7 @@ interface EncryptedVault {
|
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
export function defaultCloudVaultPath(home = homedir()): string {
|
|
48
|
-
return
|
|
51
|
+
return userRuntimePaths(home).cloudVault;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
export function cloudVaultExists(path = defaultCloudVaultPath()): boolean {
|
|
@@ -69,7 +72,7 @@ export function normalizeCloudVaultPayload(value: unknown): VaultPayload {
|
|
|
69
72
|
if (!payload.providers || typeof payload.providers !== "object" || Array.isArray(payload.providers)) {
|
|
70
73
|
throw new Error("invalid vault payload");
|
|
71
74
|
}
|
|
72
|
-
|
|
75
|
+
const providers: VaultPayload["providers"] = {};
|
|
73
76
|
for (const [vendor, stored] of Object.entries(payload.providers)) {
|
|
74
77
|
if (!isCloudVendorId(vendor)) throw new Error("unsupported cloud provider in vault");
|
|
75
78
|
if (payload.version === 1) {
|
|
@@ -96,7 +99,10 @@ export function normalizeCloudVaultPayload(value: unknown): VaultPayload {
|
|
|
96
99
|
});
|
|
97
100
|
}
|
|
98
101
|
if (payload.version !== 1 && payload.version !== 2) throw new Error("unsupported vault payload");
|
|
99
|
-
|
|
102
|
+
const runners = payload.runners === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: Array.isArray(payload.runners) ? payload.runners.map(validateRemoteTargetProfile) : (() => { throw new Error("invalid remote runner profiles"); })();
|
|
105
|
+
return { version: 2, providers, ...(runners ? { runners } : {}) };
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
export function readCloudVault(password: string, path = defaultCloudVaultPath()): VaultPayload {
|
|
@@ -151,23 +157,6 @@ export function writeCloudVault(payload: VaultPayload, password: string, path =
|
|
|
151
157
|
chmodSync(path, 0o600);
|
|
152
158
|
}
|
|
153
159
|
|
|
154
|
-
export function setCloudCredentials(
|
|
155
|
-
payload: VaultPayload,
|
|
156
|
-
vendor: CloudVendorId,
|
|
157
|
-
credentials: CloudCredentials,
|
|
158
|
-
): VaultPayload {
|
|
159
|
-
const first = payload.providers[vendor]?.[0];
|
|
160
|
-
return saveCloudCredentialProfile(payload, vendor, credentials, first?.id);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function getCloudCredentials(
|
|
164
|
-
payload: VaultPayload,
|
|
165
|
-
vendor: CloudVendorId,
|
|
166
|
-
): CloudCredentials | undefined {
|
|
167
|
-
const credentials = payload.providers[vendor]?.[0]?.credentials;
|
|
168
|
-
return credentials ? { ...credentials } : undefined;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
160
|
export function listCloudCredentialProfiles(
|
|
172
161
|
payload: VaultPayload,
|
|
173
162
|
vendor: CloudVendorId,
|
|
@@ -207,3 +196,16 @@ export function cloudCredentialProfileLabel(
|
|
|
207
196
|
const region = profile.credentials.region?.trim() || "未配置";
|
|
208
197
|
return `使用已有凭据${number} · Region: ${region}`;
|
|
209
198
|
}
|
|
199
|
+
|
|
200
|
+
export function listRemoteTargetProfiles(payload: VaultPayload): RemoteTargetProfile[] {
|
|
201
|
+
return (payload.runners ?? []).map((profile) => ({ ...profile }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function saveRemoteTargetProfile(payload: VaultPayload, profile: RemoteTargetProfile): VaultPayload {
|
|
205
|
+
const validated = validateRemoteTargetProfile(profile);
|
|
206
|
+
const profiles = listRemoteTargetProfiles(payload);
|
|
207
|
+
const existingIndex = profiles.findIndex((entry) => entry.id === validated.id);
|
|
208
|
+
if (existingIndex >= 0) profiles[existingIndex] = validated;
|
|
209
|
+
else profiles.push(validated);
|
|
210
|
+
return { version: 2, providers: { ...payload.providers }, runners: profiles };
|
|
211
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { PROJECT_CLOUD_RUNS_RELATIVE, projectRuntimePaths } from "../../runtime/paths.ts";
|
|
6
|
+
|
|
7
|
+
export interface CloudRunWorkspace {
|
|
8
|
+
runId: string;
|
|
9
|
+
path: string;
|
|
10
|
+
discoveryPath: string;
|
|
11
|
+
terraformPath: string;
|
|
12
|
+
chartsPath: string;
|
|
13
|
+
reportsPath: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createCloudRunWorkspace(root: string, runId = randomUUID()): CloudRunWorkspace {
|
|
17
|
+
const path = join(projectRuntimePaths(root).cloudRuns, runId);
|
|
18
|
+
const workspace: CloudRunWorkspace = {
|
|
19
|
+
runId, path,
|
|
20
|
+
discoveryPath: join(path, "discovery"),
|
|
21
|
+
terraformPath: join(path, "terraform"),
|
|
22
|
+
chartsPath: join(path, "charts"),
|
|
23
|
+
reportsPath: join(path, "reports"),
|
|
24
|
+
};
|
|
25
|
+
for (const directory of [workspace.discoveryPath, workspace.terraformPath, workspace.chartsPath, workspace.reportsPath]) {
|
|
26
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
return workspace;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isCloudRunWorkspace(root: string, path: string): boolean {
|
|
32
|
+
const resolvedRoot = resolve(root);
|
|
33
|
+
const resolvedPath = resolve(path);
|
|
34
|
+
const remainder = relative(resolvedRoot, resolvedPath);
|
|
35
|
+
return !isAbsolute(remainder) && !remainder.startsWith("..")
|
|
36
|
+
&& remainder.split(/[\\/]/u).slice(0, 3).join("/") === PROJECT_CLOUD_RUNS_RELATIVE;
|
|
37
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SddPhase, SddWorkflowProgress } from "./state.ts";
|
|
2
|
+
|
|
3
|
+
export const SDD_PHASES: readonly SddPhase[] = ["discovery", "requirements", "design", "test-plan", "tasks", "tests", "implementation", "verification"];
|
|
4
|
+
|
|
5
|
+
export function advanceSddProgress(progress: SddWorkflowProgress, nextPhase: SddPhase, evidence: string, approvedAt = new Date()): SddWorkflowProgress {
|
|
6
|
+
const currentIndex = SDD_PHASES.indexOf(progress.phase);
|
|
7
|
+
if (SDD_PHASES[currentIndex + 1] !== nextPhase) throw new Error(`Invalid SDD transition ${progress.phase} → ${nextPhase}. Complete phases in order.`);
|
|
8
|
+
const normalizedEvidence = evidence.replace(/[\r\n]+/gu, " ").trim().slice(0, 1_000);
|
|
9
|
+
if (!normalizedEvidence) throw new Error("SDD phase approval evidence is required");
|
|
10
|
+
return { phase: nextPhase, approvals: [...progress.approvals, { phase: nextPhase, evidence: normalizedEvidence, approvedAt: approvedAt.toISOString() }] };
|
|
11
|
+
}
|
|
@@ -5,10 +5,18 @@ export const WORKFLOW_EXTERNAL_AUDIT_TYPE = "hwcode-workflow-external-approval";
|
|
|
5
5
|
|
|
6
6
|
export type WorkflowMode = "vibe" | "sdd" | "cloud";
|
|
7
7
|
export type WorkflowStatus = "active" | "completed" | "cancelled" | "failed";
|
|
8
|
+
export type SddPhase = "discovery" | "requirements" | "design" | "test-plan" | "tasks" | "tests" | "implementation" | "verification";
|
|
9
|
+
|
|
10
|
+
export interface SddWorkflowProgress {
|
|
11
|
+
phase: SddPhase;
|
|
12
|
+
approvals: Array<{ phase: SddPhase; evidence: string; approvedAt: string }>;
|
|
13
|
+
}
|
|
8
14
|
|
|
9
15
|
export interface FailedApproach {
|
|
10
16
|
approach: string;
|
|
11
17
|
reason: string;
|
|
18
|
+
stage?: string;
|
|
19
|
+
attempts?: number;
|
|
12
20
|
failedAt: string;
|
|
13
21
|
}
|
|
14
22
|
|
|
@@ -21,11 +29,54 @@ export interface CloudExecutionStep {
|
|
|
21
29
|
completedAt: string;
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
export interface CloudResourceRecord {
|
|
33
|
+
id: string;
|
|
34
|
+
type: string;
|
|
35
|
+
region: string;
|
|
36
|
+
ownership: "existing" | "workflow-created";
|
|
37
|
+
status: "active" | "deleted";
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
export interface CloudTemplateSource {
|
|
25
42
|
id: string;
|
|
26
43
|
name: string;
|
|
27
44
|
createdAt: string;
|
|
28
45
|
updatedAt: string;
|
|
46
|
+
kind?: "prompt" | "terraform";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CloudRunnerSummary {
|
|
50
|
+
id: string;
|
|
51
|
+
name: string;
|
|
52
|
+
vendor: CloudWorkflowDetails["vendor"];
|
|
53
|
+
region: string;
|
|
54
|
+
host: string;
|
|
55
|
+
port: number;
|
|
56
|
+
user: string;
|
|
57
|
+
remoteRoot: string;
|
|
58
|
+
identityType: "instance-role" | "agency" | "ssh-only";
|
|
59
|
+
hostKeyFingerprint: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TerraformRunState {
|
|
63
|
+
runId: string;
|
|
64
|
+
runnerId: string;
|
|
65
|
+
phase: "synced" | "validated" | "planned" | "applied" | "failed";
|
|
66
|
+
sourceDigest: string;
|
|
67
|
+
sourcePath?: string;
|
|
68
|
+
remoteWorkspace: string;
|
|
69
|
+
planDigest?: string;
|
|
70
|
+
planSummary?: {
|
|
71
|
+
create: number;
|
|
72
|
+
update: number;
|
|
73
|
+
delete: number;
|
|
74
|
+
replace: number;
|
|
75
|
+
read: number;
|
|
76
|
+
sensitiveChangesHidden: number;
|
|
77
|
+
};
|
|
78
|
+
startedAt: string;
|
|
79
|
+
updatedAt: string;
|
|
29
80
|
}
|
|
30
81
|
|
|
31
82
|
export interface CloudWorkflowDetails {
|
|
@@ -35,9 +86,15 @@ export interface CloudWorkflowDetails {
|
|
|
35
86
|
allowNonDeleteChanges: boolean;
|
|
36
87
|
failedApproaches: FailedApproach[];
|
|
37
88
|
successfulSteps: CloudExecutionStep[];
|
|
89
|
+
resources?: CloudResourceRecord[];
|
|
38
90
|
terminalFailure: boolean;
|
|
91
|
+
artifactDirectory?: string;
|
|
39
92
|
templateGuidance?: string;
|
|
40
93
|
sourceTemplate?: CloudTemplateSource;
|
|
94
|
+
terraformSourcePath?: string;
|
|
95
|
+
runner?: CloudRunnerSummary;
|
|
96
|
+
runnerPreference?: "automatic" | "deferred" | "existing";
|
|
97
|
+
terraformRun?: TerraformRunState;
|
|
41
98
|
}
|
|
42
99
|
|
|
43
100
|
export interface WorkflowState {
|
|
@@ -49,6 +106,7 @@ export interface WorkflowState {
|
|
|
49
106
|
activatedAt: string;
|
|
50
107
|
updatedAt: string;
|
|
51
108
|
details?: CloudWorkflowDetails;
|
|
109
|
+
sdd?: SddWorkflowProgress;
|
|
52
110
|
reason?: string;
|
|
53
111
|
}
|
|
54
112
|
|
|
@@ -67,7 +125,39 @@ function isCloudTemplateSource(value: unknown): value is CloudTemplateSource {
|
|
|
67
125
|
return typeof data.id === "string"
|
|
68
126
|
&& typeof data.name === "string"
|
|
69
127
|
&& typeof data.createdAt === "string"
|
|
70
|
-
&& typeof data.updatedAt === "string"
|
|
128
|
+
&& typeof data.updatedAt === "string"
|
|
129
|
+
&& (data.kind === undefined || data.kind === "prompt" || data.kind === "terraform");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isCloudRunnerSummary(value: unknown): value is CloudRunnerSummary {
|
|
133
|
+
if (!value || typeof value !== "object") return false;
|
|
134
|
+
const data = value as Record<string, unknown>;
|
|
135
|
+
return typeof data.id === "string"
|
|
136
|
+
&& typeof data.name === "string"
|
|
137
|
+
&& typeof data.vendor === "string"
|
|
138
|
+
&& typeof data.region === "string"
|
|
139
|
+
&& typeof data.host === "string"
|
|
140
|
+
&& Number.isInteger(data.port)
|
|
141
|
+
&& typeof data.user === "string"
|
|
142
|
+
&& typeof data.remoteRoot === "string"
|
|
143
|
+
&& typeof data.hostKeyFingerprint === "string"
|
|
144
|
+
&& ["instance-role", "agency", "ssh-only"].includes(data.identityType as string);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isTerraformRunState(value: unknown): value is TerraformRunState {
|
|
148
|
+
if (!value || typeof value !== "object") return false;
|
|
149
|
+
const data = value as Record<string, unknown>;
|
|
150
|
+
const summary = data.planSummary;
|
|
151
|
+
return typeof data.runId === "string"
|
|
152
|
+
&& typeof data.runnerId === "string"
|
|
153
|
+
&& ["synced", "validated", "planned", "applied", "failed"].includes(data.phase as string)
|
|
154
|
+
&& typeof data.sourceDigest === "string"
|
|
155
|
+
&& typeof data.remoteWorkspace === "string"
|
|
156
|
+
&& typeof data.startedAt === "string"
|
|
157
|
+
&& typeof data.updatedAt === "string"
|
|
158
|
+
&& (data.planDigest === undefined || typeof data.planDigest === "string")
|
|
159
|
+
&& (summary === undefined || (summary !== null && typeof summary === "object"
|
|
160
|
+
&& Object.values(summary as Record<string, unknown>).every((entry) => Number.isInteger(entry))));
|
|
71
161
|
}
|
|
72
162
|
|
|
73
163
|
function isCloudDetails(value: unknown): value is CloudWorkflowDetails {
|
|
@@ -79,9 +169,22 @@ function isCloudDetails(value: unknown): value is CloudWorkflowDetails {
|
|
|
79
169
|
&& typeof data.allowNonDeleteChanges === "boolean"
|
|
80
170
|
&& Array.isArray(data.failedApproaches)
|
|
81
171
|
&& Array.isArray(data.successfulSteps)
|
|
172
|
+
&& (data.resources === undefined || (Array.isArray(data.resources) && data.resources.every((resource) => {
|
|
173
|
+
if (!resource || typeof resource !== "object") return false;
|
|
174
|
+
const entry = resource as Record<string, unknown>;
|
|
175
|
+
return typeof entry.id === "string" && typeof entry.type === "string" && typeof entry.region === "string"
|
|
176
|
+
&& ["existing", "workflow-created"].includes(entry.ownership as string)
|
|
177
|
+
&& ["active", "deleted"].includes(entry.status as string)
|
|
178
|
+
&& typeof entry.updatedAt === "string";
|
|
179
|
+
})))
|
|
82
180
|
&& typeof data.terminalFailure === "boolean"
|
|
181
|
+
&& (data.artifactDirectory === undefined || typeof data.artifactDirectory === "string")
|
|
83
182
|
&& (data.templateGuidance === undefined || typeof data.templateGuidance === "string")
|
|
84
|
-
&& (data.
|
|
183
|
+
&& (data.terraformSourcePath === undefined || typeof data.terraformSourcePath === "string")
|
|
184
|
+
&& (data.sourceTemplate === undefined || isCloudTemplateSource(data.sourceTemplate))
|
|
185
|
+
&& (data.runner === undefined || isCloudRunnerSummary(data.runner))
|
|
186
|
+
&& (data.runnerPreference === undefined || ["automatic", "deferred", "existing"].includes(data.runnerPreference as string))
|
|
187
|
+
&& (data.terraformRun === undefined || isTerraformRunState(data.terraformRun));
|
|
85
188
|
}
|
|
86
189
|
|
|
87
190
|
export function decodeWorkflowState(value: unknown): WorkflowState | undefined {
|
|
@@ -95,6 +198,11 @@ export function decodeWorkflowState(value: unknown): WorkflowState | undefined {
|
|
|
95
198
|
|| typeof data.activatedAt !== "string"
|
|
96
199
|
|| typeof data.updatedAt !== "string") return undefined;
|
|
97
200
|
if (data.mode === "cloud" && !isCloudDetails(data.details)) return undefined;
|
|
201
|
+
if (data.mode === "sdd" && data.sdd !== undefined) {
|
|
202
|
+
const sdd = data.sdd as Record<string, unknown>;
|
|
203
|
+
if (!( ["discovery", "requirements", "design", "test-plan", "tasks", "tests", "implementation", "verification"] as unknown[]).includes(sdd.phase)
|
|
204
|
+
|| !Array.isArray(sdd.approvals)) return undefined;
|
|
205
|
+
}
|
|
98
206
|
return data as unknown as WorkflowState;
|
|
99
207
|
}
|
|
100
208
|
|
|
@@ -141,6 +249,7 @@ export function createWorkflowState(
|
|
|
141
249
|
phase,
|
|
142
250
|
activatedAt: now,
|
|
143
251
|
updatedAt: now,
|
|
252
|
+
...(mode === "sdd" ? { sdd: { phase: "discovery", approvals: [] } } : {}),
|
|
144
253
|
};
|
|
145
254
|
}
|
|
146
255
|
|
|
@@ -150,6 +259,7 @@ export function createCloudWorkflowState(
|
|
|
150
259
|
deployCurrentProject: boolean,
|
|
151
260
|
request: string,
|
|
152
261
|
template?: { source: CloudTemplateSource; guidance: string },
|
|
262
|
+
artifactDirectory?: string,
|
|
153
263
|
): WorkflowState {
|
|
154
264
|
const now = new Date().toISOString();
|
|
155
265
|
return {
|
|
@@ -167,7 +277,9 @@ export function createCloudWorkflowState(
|
|
|
167
277
|
allowNonDeleteChanges: false,
|
|
168
278
|
failedApproaches: [],
|
|
169
279
|
successfulSteps: [],
|
|
280
|
+
resources: [],
|
|
170
281
|
terminalFailure: false,
|
|
282
|
+
...(artifactDirectory ? { artifactDirectory } : {}),
|
|
171
283
|
...(template ? {
|
|
172
284
|
templateGuidance: template.guidance,
|
|
173
285
|
sourceTemplate: template.source,
|
|
@@ -29,11 +29,6 @@ export interface DirectoryChange {
|
|
|
29
29
|
remainder: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export interface ChainedDirectoryChange {
|
|
33
|
-
argument: string;
|
|
34
|
-
standalone: boolean;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
32
|
const workingDirectories = new Map<string, WorkingDirectoryState>();
|
|
38
33
|
|
|
39
34
|
function isWorkingDirectoryState(value: unknown): value is WorkingDirectoryState {
|
|
@@ -190,59 +185,6 @@ export function parseLeadingDirectoryChange(command: string): DirectoryChange |
|
|
|
190
185
|
return { argument, remainder: command.slice(index).trimStart() };
|
|
191
186
|
}
|
|
192
187
|
|
|
193
|
-
/** Find a standalone `cd` segment in a top-level `&&` or `;` command chain. */
|
|
194
|
-
export function findChainedDirectoryChange(command: string): ChainedDirectoryChange | undefined {
|
|
195
|
-
const segments: string[] = [];
|
|
196
|
-
let segmentStart = 0;
|
|
197
|
-
let quote: "'" | '"' | undefined;
|
|
198
|
-
let escaped = false;
|
|
199
|
-
let nesting = 0;
|
|
200
|
-
|
|
201
|
-
for (let index = 0; index < command.length; index += 1) {
|
|
202
|
-
const character = command[index];
|
|
203
|
-
if (escaped) {
|
|
204
|
-
escaped = false;
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
if (character === "\\" && quote !== "'") {
|
|
208
|
-
escaped = true;
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
if (quote) {
|
|
212
|
-
if (character === quote) quote = undefined;
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
if (character === "'" || character === '"') {
|
|
216
|
-
quote = character;
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
if (character === "(" || character === "{") {
|
|
220
|
-
nesting += 1;
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
if (character === ")" || character === "}") {
|
|
224
|
-
nesting = Math.max(0, nesting - 1);
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
if (nesting > 0) continue;
|
|
228
|
-
|
|
229
|
-
const separatorLength = command.slice(index, index + 2) === "&&" ? 2 : character === ";" ? 1 : 0;
|
|
230
|
-
if (!separatorLength) continue;
|
|
231
|
-
segments.push(command.slice(segmentStart, index));
|
|
232
|
-
index += separatorLength - 1;
|
|
233
|
-
segmentStart = index + 1;
|
|
234
|
-
}
|
|
235
|
-
segments.push(command.slice(segmentStart));
|
|
236
|
-
|
|
237
|
-
for (const segment of segments) {
|
|
238
|
-
const parsed = parseLeadingDirectoryChange(segment);
|
|
239
|
-
if (parsed && !parsed.remainder) {
|
|
240
|
-
return { argument: parsed.argument, standalone: segments.length === 1 };
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return undefined;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
188
|
export function getActiveWorkflowRoot(entries: readonly SessionEntry[]): string | undefined {
|
|
247
189
|
return activeWorkflow(entries)?.root;
|
|
248
190
|
}
|
|
@@ -11,7 +11,7 @@ Use this skill only after `/hwcode-cloud` has activated the workflow. The activa
|
|
|
11
11
|
|
|
12
12
|
- Never ask for, display, infer, copy, log, summarize, or place credentials in chat, shell text, source files, environment files, tool arguments, plans, or session artifacts.
|
|
13
13
|
- Never read `~/.hwcode/cloud/credentials.enc`. The extension owns encryption, decryption, and credential injection.
|
|
14
|
-
- Use `hwcode_cloud_exec` for
|
|
14
|
+
- Use `hwcode_cloud_exec` for provider CLI operations. Use `hwcode_runner_prepare` and `hwcode_terraform_*` for Terraform execution. Direct Bash use for those commands is forbidden.
|
|
15
15
|
- Keep project reads, writes, builds, manifests, and generated artifacts inside the locked project root. External paths require the workflow guard's one-call approval.
|
|
16
16
|
- Prefer short-lived, least-privilege identities and narrowly scoped roles. Never widen permissions merely to bypass an authorization error without explaining the exact missing permission and obtaining approval.
|
|
17
17
|
- Do not expose sensitive values returned by a provider. If output unexpectedly contains a secret, do not repeat it; tell the user to rotate it.
|
|
@@ -20,6 +20,19 @@ 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
|
+
## Artifact workspace
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
The run workspace is retained after the session so `/hwcode-cloud-save-template` can safely extract its validated artifacts. Do not move or duplicate these files into another temporary directory.
|
|
28
|
+
|
|
29
|
+
- `discovery/`: provider CLI skeletons, read-only snapshots, and API input exploration;
|
|
30
|
+
- `terraform/`: Terraform source, module lock files, and generated provider configuration (never state files or tfvars with secrets);
|
|
31
|
+
- `charts/`: Helm chart sources and package archives;
|
|
32
|
+
- `reports/`: sanitized plans, verification results, and final summaries.
|
|
33
|
+
|
|
34
|
+
Provider CLIs run with this workspace as their current directory. When an argument must reference an existing project source file, use its absolute path under the locked project root.
|
|
35
|
+
|
|
23
36
|
Inspect the project only as needed to determine:
|
|
24
37
|
|
|
25
38
|
- application type, build and runtime requirements;
|
|
@@ -43,6 +56,19 @@ Before modifying account resources, present a plan containing:
|
|
|
43
56
|
|
|
44
57
|
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.
|
|
45
58
|
|
|
59
|
+
### Terraform Runner
|
|
60
|
+
|
|
61
|
+
Do not ask the user to name or manually configure a Runner. If the activation context has no selected Runner and Terraform is required:
|
|
62
|
+
|
|
63
|
+
1. design a minimal temporary Runner (image, network exposure, instance role/agency, SSH access, estimated cost);
|
|
64
|
+
2. ask for approval to create those billable cloud resources;
|
|
65
|
+
3. create them through `hwcode_cloud_exec`, preferring provider identity on the Runner over copying AK/SK;
|
|
66
|
+
4. install Terraform and `tar` through the instance image or cloud-init, then pass the resulting endpoint to `hwcode_runner_connect`; it selects a local default SSH key, shows standard OpenSSH SHA256 host-key fingerprints for verification, and stores the Runner profile encrypted;
|
|
67
|
+
5. when the target is private, use the tool's ProxyJump fields with the approved bastion instead of copying credentials or opening broad public access;
|
|
68
|
+
6. call `hwcode_runner_prepare` to create the managed Runner directories and verify required capabilities, then use only `hwcode_terraform_*` for Terraform.
|
|
69
|
+
|
|
70
|
+
If an existing SSH host is selected, reuse it. Only ask for SSH information when it cannot be derived from the provider result or saved Runner profile.
|
|
71
|
+
|
|
46
72
|
## Phase 3: Execute with approval gates
|
|
47
73
|
|
|
48
74
|
For every `hwcode_cloud_exec` call:
|
|
@@ -52,6 +78,7 @@ For every `hwcode_cloud_exec` call:
|
|
|
52
78
|
- use a stable `approach` name for the current technical strategy;
|
|
53
79
|
- pass an executable and argument vector, never shell syntax;
|
|
54
80
|
- inspect the result before proceeding.
|
|
81
|
+
- after discovering, creating, or deleting a resource, call `hwcode_cloud_record_resource` with its non-secret provider identifier, type, region, ownership, and current status. This inventory is required for completion and cleanup reporting.
|
|
55
82
|
|
|
56
83
|
The extension confirms every account-resource create or modification unless the user selects session-wide approval for non-delete changes. A deletion is always confirmed separately, even after that opt-out.
|
|
57
84
|
|
|
@@ -79,6 +106,8 @@ After three genuinely different approaches fail, stop all execution. Tell the us
|
|
|
79
106
|
|
|
80
107
|
Do not attempt a fourth approach.
|
|
81
108
|
|
|
109
|
+
Read-only inspection and explicitly confirmed cleanup deletions remain allowed after the failure budget is reached; new resource changes do not.
|
|
110
|
+
|
|
82
111
|
## Completion
|
|
83
112
|
|
|
84
113
|
On success, summarize:
|
|
@@ -89,3 +118,5 @@ On success, summarize:
|
|
|
89
118
|
- verification results;
|
|
90
119
|
- ongoing cost, security, monitoring, backup, and credential-rotation considerations;
|
|
91
120
|
- rollback and teardown procedure (do not execute teardown unless separately requested and approved).
|
|
121
|
+
|
|
122
|
+
When saving a successful Terraform run, the local Deployment Template is a schema-v2 bundle. It contains filtered Terraform/Helm/discovery artifacts, verification guidance, an exclusion report, and a digest of the actual saved content. Runtime state, plans, tfvars, credentials, and discovered runtime identifiers must not be copied. Reuse is blocked if the bundle content no longer matches its manifest digest; schema-v1 bundles are migrated in memory when read.
|
|
@@ -11,6 +11,8 @@ Make the approved specification the source of truth. Move through discovery, spe
|
|
|
11
11
|
|
|
12
12
|
Confirm that the activation message states a locked project root and Git status. If this skill was invoked directly without the `/hwcode-sdd` project command, do not begin work; ask the user to run `/hwcode-sdd [initial requirement]`. The command confirms the directory, enforces the path boundary, initializes Git when approved, and verifies that the current directory is the repository root.
|
|
13
13
|
|
|
14
|
+
The activation starts a persisted `discovery` phase. After completing and presenting each phase artifact, call `hwcode_sdd_advance` for the next phase. The extension asks the user for explicit approval and rejects skipped transitions. Before `tests`, built-in write/edit tools are limited to `.hwcode/specs/`; during `tests`, they may also create test files. Production-file changes begin only in `implementation`.
|
|
15
|
+
|
|
14
16
|
Use the locked root as the only project workspace. Use in-root paths silently. Explain a genuine external-path need and rely on the one-call approval prompt; never bypass it.
|
|
15
17
|
|
|
16
18
|
Do not implement production behavior until the user has explicitly approved the requirements and then the design, test plan, and task plan. If later evidence exposes ambiguity, return to the earliest affected artifact and obtain approval again.
|
package/README.md
CHANGED
|
@@ -106,6 +106,13 @@ iterative build-and-verify loops. `/hwcode-sdd` additionally requires the curren
|
|
|
106
106
|
to be the Git repository root, inventories the codebase, resolves requirement
|
|
107
107
|
questions, and persists approved artifacts under
|
|
108
108
|
`.hwcode/specs/<requirement-slug>/` before test-first implementation begins.
|
|
109
|
+
SDD phases are persisted and advance one step at a time through an interactive
|
|
110
|
+
approval gate. Before the tests phase, built-in file writes are restricted to
|
|
111
|
+
the spec directory; production writes begin only in implementation.
|
|
112
|
+
|
|
113
|
+
Use `/hwcode` to inspect, complete, or cancel the active workflow. Completing
|
|
114
|
+
SDD is allowed only after verification. Cloud completion warns about any
|
|
115
|
+
workflow-created resources that remain active in its resource inventory.
|
|
109
116
|
|
|
110
117
|
### Cloud workflow
|
|
111
118
|
|
|
@@ -136,15 +143,31 @@ confirmation unless the user approves remaining non-delete changes for the
|
|
|
136
143
|
session. Resource deletion is always confirmed. After three genuinely distinct
|
|
137
144
|
technical approaches fail, the workflow stops and reports causes, progress,
|
|
138
145
|
remaining resources, and local changes instead of attempting a fourth approach.
|
|
146
|
+
Read-only inspection and confirmed cleanup deletion remain available after the
|
|
147
|
+
failure budget is reached.
|
|
148
|
+
|
|
149
|
+
Terraform execution uses a pinned SSH Runner connection. New and existing hosts
|
|
150
|
+
share the same OpenSSH SHA256 host-key confirmation path; private targets can be
|
|
151
|
+
scanned through a verified ProxyJump. Runner execution requires Terraform,
|
|
152
|
+
`tar`, `sha256sum`, a declared cloud workload identity, and an explicit non-local
|
|
153
|
+
Terraform backend. The exact policy-checked file manifest is uploaded and its
|
|
154
|
+
per-file digest is verified remotely before execution.
|
|
139
155
|
|
|
140
156
|
After at least one cloud command succeeds, run
|
|
141
157
|
`/hwcode-cloud-save-template [name]` to save the objective, validated execution
|
|
142
158
|
sequence, failed approaches, prerequisites, and optional lessons learned as a
|
|
143
|
-
local Prompt Template.
|
|
159
|
+
local Prompt Template. A successful managed Terraform apply instead produces a
|
|
160
|
+
schema-v2 Deployment Template bundle containing filtered Terraform, Helm,
|
|
161
|
+
discovery and report artifacts plus verification notes, exclusion reasons, and
|
|
162
|
+
content/metadata integrity digests. Each template records its user-facing name plus creation
|
|
144
163
|
and update timestamps. Its prompt is layered into stable intent, prerequisites,
|
|
145
164
|
the preferred known-good path, expensive failed paths that must not be retried,
|
|
146
|
-
and the guarded execution contract. Templates are stored under
|
|
147
|
-
`~/.hwcode/cloud/prompts
|
|
165
|
+
and the guarded execution contract. Prompt Templates are stored under
|
|
166
|
+
`~/.hwcode/cloud/templates/prompts/`; Deployment Template bundles are stored
|
|
167
|
+
under `~/.hwcode/cloud/templates/deployments/`, both with user-only permissions.
|
|
168
|
+
Existing resources under the legacy `~/.hwcode/cloud/prompts/` and
|
|
169
|
+
`~/.hwcode/cloud/templates/` locations remain discoverable and are updated in
|
|
170
|
+
place when reused. Credential values are
|
|
148
171
|
excluded and redacted before steps are persisted.
|
|
149
172
|
|
|
150
173
|
Use `/hwcode-cloud-template [additional instructions]` to select and start a
|
|
@@ -161,14 +184,24 @@ Cloud approval guards.
|
|
|
161
184
|
## Internal architecture
|
|
162
185
|
|
|
163
186
|
Extensions under `.pi/extensions/` are Pi-facing adapters: they register events,
|
|
164
|
-
commands, tools, and UI.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
187
|
+
commands, tools, and UI. `extensions/workflows.ts` is the single Workflow entry;
|
|
188
|
+
Vibe/SDD live in `extensions/workflows/vibe-sdd.ts`, while the Cloud adapter is
|
|
189
|
+
split under `extensions/workflows/cloud/` into activation, commands, events,
|
|
190
|
+
Provider tools, Runner tools, Terraform tools, shared UI, and session runtime.
|
|
191
|
+
Reusable behavior lives under `.pi/lib/`:
|
|
192
|
+
|
|
193
|
+
- `runtime/` owns layered/replacing configuration, canonical runtime paths, and session-state primitives.
|
|
194
|
+
- `workflows/` owns the shared lifecycle schema; `workflows/cloud/` contains the complete Cloud workflow domain, including its `remote/` and `terraform/` execution components.
|
|
168
195
|
- `workspace/` owns tool and command path-boundary decisions.
|
|
169
|
-
- `cloud/` owns provider adapters, isolated processes, and prompt templates.
|
|
170
196
|
- `context/` and `models/` own compaction and provider-configuration policy.
|
|
171
197
|
|
|
198
|
+
Runtime data is separated by ownership: project SDD specifications live under
|
|
199
|
+
`.hwcode/specs/`, retained Cloud run artifacts live under
|
|
200
|
+
`.hwcode/cloud/runs/<run-id>/`, and user-private credentials, SSH trust, and
|
|
201
|
+
templates live under `~/.hwcode/cloud/`. Cloud run artifacts are deliberately
|
|
202
|
+
retained for inspection and later template extraction; HWCode does not apply a
|
|
203
|
+
time-based cleanup policy.
|
|
204
|
+
|
|
172
205
|
`settings.json` is layered as defaults → profile → project for settings such as
|
|
173
206
|
context and hidden commands. `welcome.json` uses a single replacing resource.
|
|
174
207
|
`model-providers.json` is deliberately package-scoped so a project cannot
|