@hadooppei/hwcode 0.2.1 → 0.2.4
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/extensions/cloud.ts +70 -28
- package/.pi/lib/cloud/template-save.ts +107 -0
- package/.pi/lib/cloud/templates.ts +126 -26
- package/.pi/lib/workflow-guard.ts +8 -1
- package/.pi/lib/workflows/state.ts +53 -1
- package/.pi/lib/workspace/access-policy.ts +2 -2
- package/README.md +14 -7
- package/package.json +1 -1
package/.pi/extensions/cloud.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { CURSOR_MARKER, Input, type Component, type TUI } from "@earendil-works/
|
|
|
8
8
|
|
|
9
9
|
import { checkProviderCli, formatValidationFailure, prepareCloudExecution, validateCloudCredentials, type TemporaryCredentialStore } from "../lib/cloud/adapters.ts";
|
|
10
10
|
import { runProcess, truncateOutput, type ProcessResult } from "../lib/cloud/process.ts";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
cloudPromptTemplateSource, expandCloudPromptTemplate, listCloudPromptTemplates, saveCloudPromptTemplate, updateCloudPromptTemplate,
|
|
13
|
+
type CloudPromptTemplate,
|
|
14
|
+
} from "../lib/cloud/templates.ts";
|
|
15
|
+
import { saveCloudWorkflowTemplate } from "../lib/cloud/template-save.ts";
|
|
12
16
|
import {
|
|
13
17
|
CLOUD_EXECUTABLES, CLOUD_PROVIDERS, classifyCloudOperation, containsCloudCommand,
|
|
14
18
|
getCloudProvider, inaccessibleCloudCliMessage, missingCloudCliMessage, redactCredentialValues,
|
|
@@ -22,7 +26,7 @@ import {
|
|
|
22
26
|
import { canonicalizeWorkspaceRoot } from "../lib/workflow-guard.ts";
|
|
23
27
|
import { modelConfigurationIssue } from "../lib/models/readiness.ts";
|
|
24
28
|
import {
|
|
25
|
-
WORKFLOW_EXTERNAL_AUDIT_TYPE, WORKFLOW_STATE_TYPE, activeWorkflow, updateWorkflowState,
|
|
29
|
+
WORKFLOW_EXTERNAL_AUDIT_TYPE, WORKFLOW_STATE_TYPE, activeWorkflow, createCloudWorkflowState, updateWorkflowState,
|
|
26
30
|
type CloudWorkflowDetails, type WorkflowState,
|
|
27
31
|
} from "../lib/workflows/state.ts";
|
|
28
32
|
import { evaluateCommandArgumentsAccess } from "../lib/workspace/access-policy.ts";
|
|
@@ -82,6 +86,13 @@ function cloudDetails(state: WorkflowState): CloudWorkflowDetails | undefined {
|
|
|
82
86
|
return state.mode === "cloud" ? state.details : undefined;
|
|
83
87
|
}
|
|
84
88
|
|
|
89
|
+
function formatTemplateTime(value: string): string {
|
|
90
|
+
const date = new Date(value);
|
|
91
|
+
if (Number.isNaN(date.valueOf())) return value;
|
|
92
|
+
const pad = (part: number) => String(part).padStart(2, "0");
|
|
93
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
85
96
|
function restoreCloudWorkflow(ctx: ExtensionContext): WorkflowState | undefined {
|
|
86
97
|
const state = activeWorkflow(ctx.sessionManager.getEntries());
|
|
87
98
|
return state?.mode === "cloud" && state.details ? state : undefined;
|
|
@@ -121,18 +132,6 @@ function appendWorkflowState(pi: ExtensionAPI, state: WorkflowState): void {
|
|
|
121
132
|
pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, state);
|
|
122
133
|
}
|
|
123
134
|
|
|
124
|
-
function createCloudWorkflowState(root: string, vendor: CloudVendorId, deploy: boolean, request: string): WorkflowState {
|
|
125
|
-
const now = new Date().toISOString();
|
|
126
|
-
return {
|
|
127
|
-
version: 2, status: "active", mode: "cloud", root, phase: "connected",
|
|
128
|
-
activatedAt: now, updatedAt: now,
|
|
129
|
-
details: {
|
|
130
|
-
vendor, deployCurrentProject: deploy, request, allowNonDeleteChanges: false,
|
|
131
|
-
failedApproaches: [], successfulSteps: [], terminalFailure: false,
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
|
|
136
135
|
async function secretInput(ctx: ExtensionContext, title: string): Promise<string | undefined> {
|
|
137
136
|
if (ctx.mode !== "tui") return undefined;
|
|
138
137
|
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => new MaskedInput(tui, theme, title, done));
|
|
@@ -190,7 +189,10 @@ async function collectCredentials(vendor: CloudVendorId, root: string, ctx: Exte
|
|
|
190
189
|
|
|
191
190
|
function activationPrompt(state: WorkflowState): string {
|
|
192
191
|
const details = cloudDetails(state)!;
|
|
193
|
-
|
|
192
|
+
const guidance = details.templateGuidance
|
|
193
|
+
? `\n\nReusable template guidance (${details.sourceTemplate?.name ?? "saved template"}):\n${details.templateGuidance}`
|
|
194
|
+
: "";
|
|
195
|
+
return `/skill:hwcode-cloud HWCode Cloud workflow activated.\n\nLocked project root: ${state.root}\nCloud provider: ${getCloudProvider(details.vendor).label}\nDeploy current project: ${details.deployCurrentProject ? "yes" : "no"}\nUser objective:\n${details.request}${guidance}\n\nCredentials were validated and remain outside model context. Use hwcode_cloud_exec for every cloud or infrastructure command.`;
|
|
194
196
|
}
|
|
195
197
|
|
|
196
198
|
async function approveOperation(operation: CloudOperation, params: { command: string; args: string[]; intent: string }, state: WorkflowState, ctx: ExtensionContext): Promise<"allow" | "allow-session" | "deny"> {
|
|
@@ -263,11 +265,13 @@ export default function cloudExtension(pi: ExtensionAPI) {
|
|
|
263
265
|
notify(ctx, "还没有本地 Cloud Prompt Template。成功执行任务后使用 /hwcode-cloud-save-template 保存。", "warning");
|
|
264
266
|
return undefined;
|
|
265
267
|
}
|
|
268
|
+
const labels = templates.map((template) => `${template.name} · ${formatTemplateTime(template.updatedAt)}`);
|
|
266
269
|
const selected = await ctx.ui.select(
|
|
267
270
|
"选择已验证的 Cloud Prompt Template",
|
|
268
|
-
|
|
271
|
+
labels,
|
|
269
272
|
);
|
|
270
|
-
|
|
273
|
+
const selectedIndex = selected ? labels.indexOf(selected) : -1;
|
|
274
|
+
return selectedIndex >= 0 ? templates[selectedIndex] : undefined;
|
|
271
275
|
}
|
|
272
276
|
|
|
273
277
|
async function activate(args: string, ctx: ExtensionCommandContext, initialTemplate?: CloudPromptTemplate): Promise<void> {
|
|
@@ -307,6 +311,7 @@ export default function cloudExtension(pi: ExtensionAPI) {
|
|
|
307
311
|
let vendor: CloudVendorId;
|
|
308
312
|
let deployCurrentProject: boolean;
|
|
309
313
|
let request: string;
|
|
314
|
+
let templateGuidance: string | undefined;
|
|
310
315
|
if (resumedState) {
|
|
311
316
|
const details = cloudDetails(resumedState)!;
|
|
312
317
|
vendor = details.vendor;
|
|
@@ -315,7 +320,8 @@ export default function cloudExtension(pi: ExtensionAPI) {
|
|
|
315
320
|
} else if (template) {
|
|
316
321
|
vendor = template.vendor;
|
|
317
322
|
deployCurrentProject = template.deployCurrentProject;
|
|
318
|
-
request =
|
|
323
|
+
request = template.objective || template.name;
|
|
324
|
+
templateGuidance = expandCloudPromptTemplate(template, args);
|
|
319
325
|
} else {
|
|
320
326
|
const label = await ctx.ui.select("选择需要对接的云计算厂商", CLOUD_PROVIDERS.map((provider) => provider.label));
|
|
321
327
|
if (!label) return;
|
|
@@ -378,7 +384,16 @@ export default function cloudExtension(pi: ExtensionAPI) {
|
|
|
378
384
|
);
|
|
379
385
|
cleanupCredentialDirectories();
|
|
380
386
|
activeCredentials = credentials;
|
|
381
|
-
activeState = resumedState ?? createCloudWorkflowState(
|
|
387
|
+
activeState = resumedState ?? createCloudWorkflowState(
|
|
388
|
+
root,
|
|
389
|
+
vendor,
|
|
390
|
+
deployCurrentProject,
|
|
391
|
+
request,
|
|
392
|
+
template && templateGuidance ? {
|
|
393
|
+
source: cloudPromptTemplateSource(template),
|
|
394
|
+
guidance: templateGuidance,
|
|
395
|
+
} : undefined,
|
|
396
|
+
);
|
|
382
397
|
appendWorkflowState(pi, activeState);
|
|
383
398
|
notify(ctx, `${vendorLabel} 连接成功。凭据已加密保存,项目根目录锁定为 ${root}。`);
|
|
384
399
|
pi.sendUserMessage(activationPrompt(activeState), { expandPromptTemplates: true });
|
|
@@ -404,15 +419,42 @@ export default function cloudExtension(pi: ExtensionAPI) {
|
|
|
404
419
|
notify(ctx, "当前 Cloud workflow 还没有成功执行步骤,无法生成可复用模板。", "warning");
|
|
405
420
|
return;
|
|
406
421
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
422
|
+
|
|
423
|
+
const result = await saveCloudWorkflowTemplate({
|
|
424
|
+
state: activeState,
|
|
425
|
+
requestedName: args,
|
|
426
|
+
redact: activeCredentials
|
|
427
|
+
? (value) => redactCredentialValues(value, activeCredentials!)
|
|
428
|
+
: undefined,
|
|
429
|
+
interaction: {
|
|
430
|
+
async chooseSourceAction(source) {
|
|
431
|
+
const updateLabel = `更新原模板「${source.name}」`;
|
|
432
|
+
const choice = await ctx.ui.select(
|
|
433
|
+
`当前会话关联模板「${details.sourceTemplate?.name ?? source.name}」`,
|
|
434
|
+
[updateLabel, "创建新模板", "取消"],
|
|
435
|
+
);
|
|
436
|
+
if (!choice || choice === "取消") return "cancel";
|
|
437
|
+
return choice === updateLabel ? "update" : "create";
|
|
438
|
+
},
|
|
439
|
+
inputName: (defaultName) => ctx.ui.input("模板名称", defaultName),
|
|
440
|
+
editNotes: (initialNotes) => ctx.ui.editor(
|
|
441
|
+
"补充必要前置条件、成功经验及必须避免的高消耗错误路径(可留空;不得包含凭据)",
|
|
442
|
+
initialNotes,
|
|
443
|
+
),
|
|
444
|
+
sourceMissing(sourceName) {
|
|
445
|
+
notify(ctx, `本次使用的原模板「${sourceName}」已不存在,将创建新模板。`, "warning");
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
store: {
|
|
449
|
+
list: () => listCloudPromptTemplates(),
|
|
450
|
+
create: (name, state, notes) => saveCloudPromptTemplate(name, state, undefined, notes),
|
|
451
|
+
update: (template, state, notes) => updateCloudPromptTemplate(template, state, notes),
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
if (result.status === "cancelled") return;
|
|
455
|
+
activeState = replaceDetails(activeState, result.details);
|
|
456
|
+
const action = result.action === "updated" ? "已更新" : "已创建";
|
|
457
|
+
notify(ctx, `${action}模板「${result.template.name}」(${formatTemplateTime(result.template.updatedAt)})。使用 /hwcode-cloud-template 可立即复用。`);
|
|
416
458
|
},
|
|
417
459
|
});
|
|
418
460
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { CloudPromptTemplate } from "./templates.ts";
|
|
2
|
+
import { cloudPromptTemplateSource } from "./templates.ts";
|
|
3
|
+
import type { CloudWorkflowDetails, WorkflowState } from "../workflows/state.ts";
|
|
4
|
+
|
|
5
|
+
export type CloudTemplateSourceAction = "update" | "create" | "cancel";
|
|
6
|
+
export type CloudTemplateSaveStage = "source-action" | "name" | "notes";
|
|
7
|
+
|
|
8
|
+
export interface CloudTemplateSaveInteraction {
|
|
9
|
+
chooseSourceAction(source: CloudPromptTemplate): Promise<CloudTemplateSourceAction | undefined>;
|
|
10
|
+
inputName(defaultName: string): Promise<string | undefined>;
|
|
11
|
+
editNotes(initialNotes: string): Promise<string | undefined>;
|
|
12
|
+
sourceMissing(sourceName: string): void | Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CloudTemplateStore {
|
|
16
|
+
list(): CloudPromptTemplate[];
|
|
17
|
+
create(name: string, state: WorkflowState, notes: string): CloudPromptTemplate;
|
|
18
|
+
update(template: CloudPromptTemplate, state: WorkflowState, notes: string): CloudPromptTemplate;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CloudTemplateSaveResult =
|
|
22
|
+
| { status: "cancelled"; stage: CloudTemplateSaveStage }
|
|
23
|
+
| {
|
|
24
|
+
status: "saved";
|
|
25
|
+
action: "created" | "updated";
|
|
26
|
+
template: CloudPromptTemplate;
|
|
27
|
+
details: CloudWorkflowDetails;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface SaveCloudWorkflowTemplateOptions {
|
|
31
|
+
state: WorkflowState;
|
|
32
|
+
requestedName?: string;
|
|
33
|
+
interaction: CloudTemplateSaveInteraction;
|
|
34
|
+
store: CloudTemplateStore;
|
|
35
|
+
redact?: (value: string) => string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Coordinates the save/update decision without depending on Pi UI or filesystem APIs.
|
|
40
|
+
* Storage is invoked only after every interactive step succeeds, so cancellation and
|
|
41
|
+
* storage failures cannot partially change the workflow state.
|
|
42
|
+
*/
|
|
43
|
+
export async function saveCloudWorkflowTemplate(
|
|
44
|
+
options: SaveCloudWorkflowTemplateOptions,
|
|
45
|
+
): Promise<CloudTemplateSaveResult> {
|
|
46
|
+
const { state, interaction, store } = options;
|
|
47
|
+
if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
|
|
48
|
+
if (state.details.successfulSteps.length === 0) {
|
|
49
|
+
throw new Error("No successful Cloud execution steps are available to summarize");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const details = state.details;
|
|
53
|
+
let target: CloudPromptTemplate | undefined;
|
|
54
|
+
if (details.sourceTemplate) {
|
|
55
|
+
target = store.list().find((template) => template.id === details.sourceTemplate?.id);
|
|
56
|
+
if (target) {
|
|
57
|
+
const sourceAction = await interaction.chooseSourceAction(target);
|
|
58
|
+
if (!sourceAction || sourceAction === "cancel") {
|
|
59
|
+
return { status: "cancelled", stage: "source-action" };
|
|
60
|
+
}
|
|
61
|
+
if (sourceAction === "create") target = undefined;
|
|
62
|
+
} else {
|
|
63
|
+
await interaction.sourceMissing(details.sourceTemplate.name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let name = target?.name;
|
|
68
|
+
if (!name) {
|
|
69
|
+
const suppliedName = options.requestedName?.trim();
|
|
70
|
+
if (suppliedName) {
|
|
71
|
+
name = suppliedName;
|
|
72
|
+
} else {
|
|
73
|
+
const defaultName = details.sourceTemplate
|
|
74
|
+
? `${details.sourceTemplate.name} 副本`
|
|
75
|
+
: details.request.slice(0, 48);
|
|
76
|
+
name = (await interaction.inputName(defaultName))?.trim();
|
|
77
|
+
}
|
|
78
|
+
if (!name) return { status: "cancelled", stage: "name" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const notes = await interaction.editNotes(target?.notes ?? "");
|
|
82
|
+
if (notes === undefined) return { status: "cancelled", stage: "notes" };
|
|
83
|
+
|
|
84
|
+
const redact = options.redact ?? ((value: string) => value);
|
|
85
|
+
const safeName = redact(name);
|
|
86
|
+
const safeNotes = redact(notes.trim());
|
|
87
|
+
const safeState: WorkflowState = {
|
|
88
|
+
...state,
|
|
89
|
+
details: {
|
|
90
|
+
...details,
|
|
91
|
+
request: redact(details.request),
|
|
92
|
+
templateGuidance: details.templateGuidance === undefined
|
|
93
|
+
? undefined
|
|
94
|
+
: redact(details.templateGuidance),
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
const template = target
|
|
98
|
+
? store.update(target, safeState, safeNotes)
|
|
99
|
+
: store.create(safeName, safeState, safeNotes);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
status: "saved",
|
|
103
|
+
action: target ? "updated" : "created",
|
|
104
|
+
template,
|
|
105
|
+
details: { ...details, sourceTemplate: cloudPromptTemplateSource(template) },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -1,20 +1,33 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { basename, join } from "node:path";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { getCloudProvider, isCloudVendorId, type CloudVendorId } from "../cloud-providers.ts";
|
|
6
|
-
import type { WorkflowState } from "../workflows/state.ts";
|
|
6
|
+
import type { CloudTemplateSource, WorkflowState } from "../workflows/state.ts";
|
|
7
7
|
|
|
8
8
|
export interface CloudPromptTemplate {
|
|
9
9
|
id: string;
|
|
10
|
+
name: string;
|
|
10
11
|
description: string;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
updatedAt: string;
|
|
11
14
|
vendor: CloudVendorId;
|
|
12
15
|
deployCurrentProject: boolean;
|
|
13
16
|
objective: string;
|
|
17
|
+
notes: string;
|
|
14
18
|
body: string;
|
|
15
19
|
path: string;
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
export function cloudPromptTemplateSource(template: CloudPromptTemplate): CloudTemplateSource {
|
|
23
|
+
return {
|
|
24
|
+
id: template.id,
|
|
25
|
+
name: template.name,
|
|
26
|
+
createdAt: template.createdAt,
|
|
27
|
+
updatedAt: template.updatedAt,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
export function defaultCloudPromptDirectory(home = homedir()): string {
|
|
19
32
|
return join(home, ".hwcode", "cloud", "prompts");
|
|
20
33
|
}
|
|
@@ -40,6 +53,22 @@ function frontmatterValue(text: string, key: string): string | undefined {
|
|
|
40
53
|
}
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
function normalizeTimestamp(value: string | undefined, fallback: string): string {
|
|
57
|
+
if (!value) return fallback;
|
|
58
|
+
const date = new Date(value);
|
|
59
|
+
return Number.isNaN(date.valueOf()) ? fallback : date.toISOString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function legacyTemplateName(id: string): string {
|
|
63
|
+
const words = id.replace(/^hwcloud-/u, "").split("-").filter(Boolean);
|
|
64
|
+
if (words.length === 0) return "Legacy Cloud Template";
|
|
65
|
+
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function cleanTemplateName(value: string): string {
|
|
69
|
+
return value.replace(/[\r\n]+/gu, " ").replace(/\s+/gu, " ").trim().slice(0, 80);
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
export function parseCloudPromptTemplate(path: string): CloudPromptTemplate | undefined {
|
|
44
73
|
const text = readFileSync(path, "utf8");
|
|
45
74
|
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/u);
|
|
@@ -48,12 +77,20 @@ export function parseCloudPromptTemplate(path: string): CloudPromptTemplate | un
|
|
|
48
77
|
const vendorValue = frontmatterValue(metadata, "hwcode-cloud-vendor");
|
|
49
78
|
if (!vendorValue || !isCloudVendorId(vendorValue)) return undefined;
|
|
50
79
|
const id = basename(path, ".md");
|
|
80
|
+
const stats = statSync(path);
|
|
81
|
+
const modifiedAt = stats.mtime.toISOString();
|
|
82
|
+
const bornAt = stats.birthtimeMs > 0 ? stats.birthtime.toISOString() : modifiedAt;
|
|
83
|
+
const name = cleanTemplateName(frontmatterValue(metadata, "hwcode-cloud-name") ?? "") || legacyTemplateName(id);
|
|
51
84
|
return {
|
|
52
85
|
id,
|
|
53
|
-
|
|
86
|
+
name,
|
|
87
|
+
description: frontmatterValue(metadata, "description") ?? name,
|
|
88
|
+
createdAt: normalizeTimestamp(frontmatterValue(metadata, "hwcode-cloud-created-at"), bornAt),
|
|
89
|
+
updatedAt: normalizeTimestamp(frontmatterValue(metadata, "hwcode-cloud-updated-at"), modifiedAt),
|
|
54
90
|
vendor: vendorValue,
|
|
55
91
|
deployCurrentProject: frontmatterValue(metadata, "hwcode-cloud-deploy") === "true",
|
|
56
92
|
objective: frontmatterValue(metadata, "hwcode-cloud-objective") ?? "",
|
|
93
|
+
notes: frontmatterValue(metadata, "hwcode-cloud-notes") ?? "",
|
|
57
94
|
body: match[2].trim(),
|
|
58
95
|
path,
|
|
59
96
|
};
|
|
@@ -67,7 +104,7 @@ export function listCloudPromptTemplates(
|
|
|
67
104
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
68
105
|
.map((entry) => parseCloudPromptTemplate(join(directory, entry.name)))
|
|
69
106
|
.filter((template): template is CloudPromptTemplate => Boolean(template))
|
|
70
|
-
.sort((left, right) => left.
|
|
107
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || left.name.localeCompare(right.name));
|
|
71
108
|
}
|
|
72
109
|
|
|
73
110
|
export function expandCloudPromptTemplate(template: CloudPromptTemplate, argumentsText: string): string {
|
|
@@ -79,6 +116,10 @@ function inlineCode(value: string): string {
|
|
|
79
116
|
return value.replace(/[\r\n]+/gu, " ").replaceAll("`", "\\`");
|
|
80
117
|
}
|
|
81
118
|
|
|
119
|
+
function singleLine(value: string): string {
|
|
120
|
+
return value.replace(/[\r\n]+/gu, " ").replace(/\s+/gu, " ").trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
82
123
|
export function renderCloudPromptTemplate(state: WorkflowState, notes = ""): string {
|
|
83
124
|
if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
|
|
84
125
|
const details = state.details;
|
|
@@ -87,62 +128,121 @@ export function renderCloudPromptTemplate(state: WorkflowState, notes = ""): str
|
|
|
87
128
|
}
|
|
88
129
|
const provider = getCloudProvider(details.vendor);
|
|
89
130
|
const steps = details.successfulSteps.map((step, index) => (
|
|
90
|
-
`${index + 1}. [${step.operation}] ${step.intent}\n`
|
|
91
|
-
+ ` -
|
|
92
|
-
+ ` -
|
|
131
|
+
`${index + 1}. [${step.operation}] ${singleLine(step.intent)}\n`
|
|
132
|
+
+ ` - Validated approach: ${singleLine(step.approach)}\n`
|
|
133
|
+
+ ` - Known-good command and path: \`${inlineCode([step.command, ...step.args].join(" "))}\``
|
|
93
134
|
)).join("\n");
|
|
135
|
+
const failedPaths = details.failedApproaches.length > 0
|
|
136
|
+
? details.failedApproaches.map((failure, index) => (
|
|
137
|
+
`${index + 1}. **DO NOT RETRY:** ${singleLine(failure.approach)}\n`
|
|
138
|
+
+ ` - Recorded failure: ${singleLine(failure.reason)}`
|
|
139
|
+
)).join("\n")
|
|
140
|
+
: "No failed execution paths were recorded for this run.";
|
|
94
141
|
return [
|
|
95
|
-
"Execute
|
|
142
|
+
"Execute this HWCode Cloud task from a layered record of a previously successful run.",
|
|
143
|
+
"",
|
|
144
|
+
"## Layer 1 — Stable intent and boundaries",
|
|
96
145
|
"",
|
|
97
146
|
`Preferred provider: ${provider.label}`,
|
|
98
147
|
`Deploy current project: ${details.deployCurrentProject ? "yes" : "no"}`,
|
|
99
148
|
`Objective pattern: ${details.request}`,
|
|
100
149
|
"",
|
|
101
|
-
"
|
|
150
|
+
"Preserve the objective, provider boundary, credential isolation, resource approvals, and deletion confirmations. Adapt only environment-specific values.",
|
|
151
|
+
"",
|
|
152
|
+
"## Layer 2 — Preconditions and operator knowledge",
|
|
153
|
+
"",
|
|
154
|
+
"Start with read-only discovery. Re-check the active account, region, project, resource state, tool versions, and workspace paths before making changes.",
|
|
155
|
+
...(notes ? ["", "Validated prerequisites, lessons, and expensive pitfalls:", notes] : []),
|
|
156
|
+
"",
|
|
157
|
+
"## Layer 3 — Preferred validated execution path",
|
|
158
|
+
"",
|
|
159
|
+
"Use this path first and preserve its order unless read-only evidence requires an adaptation. The commands and paths below are the known-good reference; do not replace them with speculative alternatives.",
|
|
160
|
+
"",
|
|
102
161
|
steps,
|
|
103
162
|
"",
|
|
104
|
-
|
|
105
|
-
"
|
|
163
|
+
"## Layer 4 — Expensive failed paths to avoid",
|
|
164
|
+
"",
|
|
165
|
+
"Do not retry any path below, including renamed or parameter-only variants. Reconsider one only when the user explicitly directs it after reviewing the recorded failure.",
|
|
166
|
+
"",
|
|
167
|
+
failedPaths,
|
|
168
|
+
"",
|
|
169
|
+
"## Layer 5 — Execution contract",
|
|
170
|
+
"",
|
|
171
|
+
"Present the adapted plan and obtain all normal HWCode Cloud approvals. Never copy credentials, assume old resources still exist, or skip current-state verification.",
|
|
106
172
|
"If HWCode Cloud is not active, do not run cloud commands directly; ask the user to start this template through /hwcode-cloud-template.",
|
|
107
173
|
"",
|
|
108
174
|
"Additional instructions: ${@:-Use the objective and successful sequence above.}",
|
|
109
175
|
].join("\n");
|
|
110
176
|
}
|
|
111
177
|
|
|
112
|
-
|
|
113
|
-
|
|
178
|
+
function renderTemplateFile(
|
|
179
|
+
name: string,
|
|
114
180
|
state: WorkflowState,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
181
|
+
notes: string,
|
|
182
|
+
createdAt: string,
|
|
183
|
+
updatedAt: string,
|
|
184
|
+
): string {
|
|
118
185
|
if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
|
|
119
|
-
const idBase = `hwcloud-${slugify(requestedName || state.details.request)}`;
|
|
120
186
|
const body = renderCloudPromptTemplate(state, notes);
|
|
121
|
-
|
|
122
|
-
const content = [
|
|
187
|
+
return [
|
|
123
188
|
"---",
|
|
124
|
-
`description: ${JSON.stringify(
|
|
189
|
+
`description: ${JSON.stringify(name)}`,
|
|
125
190
|
'argument-hint: "[additional instructions]"',
|
|
191
|
+
`hwcode-cloud-name: ${JSON.stringify(name)}`,
|
|
192
|
+
`hwcode-cloud-created-at: ${JSON.stringify(createdAt)}`,
|
|
193
|
+
`hwcode-cloud-updated-at: ${JSON.stringify(updatedAt)}`,
|
|
126
194
|
`hwcode-cloud-vendor: ${JSON.stringify(state.details.vendor)}`,
|
|
127
195
|
`hwcode-cloud-deploy: ${state.details.deployCurrentProject}`,
|
|
128
196
|
`hwcode-cloud-objective: ${JSON.stringify(state.details.request)}`,
|
|
197
|
+
`hwcode-cloud-notes: ${JSON.stringify(notes)}`,
|
|
129
198
|
"---",
|
|
130
199
|
body,
|
|
131
200
|
"",
|
|
132
201
|
].join("\n");
|
|
202
|
+
}
|
|
133
203
|
|
|
204
|
+
function writeTemplateFile(path: string, content: string): void {
|
|
205
|
+
const directory = dirname(path);
|
|
134
206
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
135
207
|
chmodSync(directory, 0o700);
|
|
208
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
209
|
+
writeFileSync(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
|
|
210
|
+
chmodSync(temporaryPath, 0o600);
|
|
211
|
+
renameSync(temporaryPath, path);
|
|
212
|
+
chmodSync(path, 0o600);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function saveCloudPromptTemplate(
|
|
216
|
+
requestedName: string,
|
|
217
|
+
state: WorkflowState,
|
|
218
|
+
directory = defaultCloudPromptDirectory(),
|
|
219
|
+
notes = "",
|
|
220
|
+
savedAt = new Date(),
|
|
221
|
+
): CloudPromptTemplate {
|
|
222
|
+
if (state.mode !== "cloud" || !state.details) throw new Error("Cloud workflow state is required");
|
|
223
|
+
const name = cleanTemplateName(requestedName);
|
|
224
|
+
if (!name) throw new Error("Cloud Prompt Template name is required");
|
|
225
|
+
const idBase = `hwcloud-${slugify(name)}`;
|
|
226
|
+
const timestamp = savedAt.toISOString();
|
|
227
|
+
const content = renderTemplateFile(name, state, notes, timestamp, timestamp);
|
|
136
228
|
let id = idBase;
|
|
137
229
|
let path = join(directory, `${id}.md`);
|
|
138
230
|
for (let suffix = 2; existsSync(path); suffix++) {
|
|
139
231
|
id = `${idBase}-${suffix}`;
|
|
140
232
|
path = join(directory, `${id}.md`);
|
|
141
233
|
}
|
|
142
|
-
|
|
143
|
-
writeFileSync(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
|
|
144
|
-
chmodSync(temporaryPath, 0o600);
|
|
145
|
-
renameSync(temporaryPath, path);
|
|
146
|
-
chmodSync(path, 0o600);
|
|
234
|
+
writeTemplateFile(path, content);
|
|
147
235
|
return parseCloudPromptTemplate(path)!;
|
|
148
236
|
}
|
|
237
|
+
|
|
238
|
+
export function updateCloudPromptTemplate(
|
|
239
|
+
template: CloudPromptTemplate,
|
|
240
|
+
state: WorkflowState,
|
|
241
|
+
notes = template.notes,
|
|
242
|
+
updatedAt = new Date(),
|
|
243
|
+
): CloudPromptTemplate {
|
|
244
|
+
const timestamp = updatedAt.toISOString();
|
|
245
|
+
const content = renderTemplateFile(template.name, state, notes, template.createdAt, timestamp);
|
|
246
|
+
writeTemplateFile(template.path, content);
|
|
247
|
+
return parseCloudPromptTemplate(template.path)!;
|
|
248
|
+
}
|
|
@@ -11,6 +11,8 @@ export interface ExternalPathReference {
|
|
|
11
11
|
resolved: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
const APPROVAL_FREE_EXTERNAL_PATHS = new Set(["/dev/null"]);
|
|
15
|
+
|
|
14
16
|
function realpathWithMissingTail(path: string): string {
|
|
15
17
|
const tail: string[] = [];
|
|
16
18
|
let cursor = resolve(path);
|
|
@@ -41,6 +43,11 @@ export function isPathInsideRoot(root: string, candidate: string): boolean {
|
|
|
41
43
|
return relation === "" || (!relation.startsWith(`..${sep}`) && relation !== ".." && !isAbsolute(relation));
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
export function isPathAllowedWithoutApproval(root: string, candidate: string): boolean {
|
|
47
|
+
const resolved = realpathWithMissingTail(candidate);
|
|
48
|
+
return APPROVAL_FREE_EXTERNAL_PATHS.has(resolved) || isPathInsideRoot(root, resolved);
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
function stripTrailingShellPunctuation(value: string): string {
|
|
45
52
|
return value.replace(/[\]}),;]+$/g, "");
|
|
46
53
|
}
|
|
@@ -52,7 +59,7 @@ function addExternalReference(
|
|
|
52
59
|
resolved: string,
|
|
53
60
|
): void {
|
|
54
61
|
const cleanResolved = realpathWithMissingTail(stripTrailingShellPunctuation(resolved));
|
|
55
|
-
if (!
|
|
62
|
+
if (!isPathAllowedWithoutApproval(root, cleanResolved)) {
|
|
56
63
|
result.set(`${raw}\0${cleanResolved}`, { raw, resolved: cleanResolved });
|
|
57
64
|
}
|
|
58
65
|
}
|
|
@@ -21,6 +21,13 @@ export interface CloudExecutionStep {
|
|
|
21
21
|
completedAt: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface CloudTemplateSource {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
updatedAt: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
export interface CloudWorkflowDetails {
|
|
25
32
|
vendor: "aws" | "azure" | "gcp" | "huawei" | "alibaba" | "tencent";
|
|
26
33
|
deployCurrentProject: boolean;
|
|
@@ -29,6 +36,8 @@ export interface CloudWorkflowDetails {
|
|
|
29
36
|
failedApproaches: FailedApproach[];
|
|
30
37
|
successfulSteps: CloudExecutionStep[];
|
|
31
38
|
terminalFailure: boolean;
|
|
39
|
+
templateGuidance?: string;
|
|
40
|
+
sourceTemplate?: CloudTemplateSource;
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
export interface WorkflowState {
|
|
@@ -52,6 +61,15 @@ interface LegacyWorkflowState {
|
|
|
52
61
|
deactivatedAt?: string;
|
|
53
62
|
}
|
|
54
63
|
|
|
64
|
+
function isCloudTemplateSource(value: unknown): value is CloudTemplateSource {
|
|
65
|
+
if (!value || typeof value !== "object") return false;
|
|
66
|
+
const data = value as Record<string, unknown>;
|
|
67
|
+
return typeof data.id === "string"
|
|
68
|
+
&& typeof data.name === "string"
|
|
69
|
+
&& typeof data.createdAt === "string"
|
|
70
|
+
&& typeof data.updatedAt === "string";
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
function isCloudDetails(value: unknown): value is CloudWorkflowDetails {
|
|
56
74
|
if (!value || typeof value !== "object") return false;
|
|
57
75
|
const data = value as Record<string, unknown>;
|
|
@@ -61,7 +79,9 @@ function isCloudDetails(value: unknown): value is CloudWorkflowDetails {
|
|
|
61
79
|
&& typeof data.allowNonDeleteChanges === "boolean"
|
|
62
80
|
&& Array.isArray(data.failedApproaches)
|
|
63
81
|
&& Array.isArray(data.successfulSteps)
|
|
64
|
-
&& typeof data.terminalFailure === "boolean"
|
|
82
|
+
&& typeof data.terminalFailure === "boolean"
|
|
83
|
+
&& (data.templateGuidance === undefined || typeof data.templateGuidance === "string")
|
|
84
|
+
&& (data.sourceTemplate === undefined || isCloudTemplateSource(data.sourceTemplate));
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
export function decodeWorkflowState(value: unknown): WorkflowState | undefined {
|
|
@@ -124,6 +144,38 @@ export function createWorkflowState(
|
|
|
124
144
|
};
|
|
125
145
|
}
|
|
126
146
|
|
|
147
|
+
export function createCloudWorkflowState(
|
|
148
|
+
root: string,
|
|
149
|
+
vendor: CloudWorkflowDetails["vendor"],
|
|
150
|
+
deployCurrentProject: boolean,
|
|
151
|
+
request: string,
|
|
152
|
+
template?: { source: CloudTemplateSource; guidance: string },
|
|
153
|
+
): WorkflowState {
|
|
154
|
+
const now = new Date().toISOString();
|
|
155
|
+
return {
|
|
156
|
+
version: 2,
|
|
157
|
+
status: "active",
|
|
158
|
+
mode: "cloud",
|
|
159
|
+
root,
|
|
160
|
+
phase: "connected",
|
|
161
|
+
activatedAt: now,
|
|
162
|
+
updatedAt: now,
|
|
163
|
+
details: {
|
|
164
|
+
vendor,
|
|
165
|
+
deployCurrentProject,
|
|
166
|
+
request,
|
|
167
|
+
allowNonDeleteChanges: false,
|
|
168
|
+
failedApproaches: [],
|
|
169
|
+
successfulSteps: [],
|
|
170
|
+
terminalFailure: false,
|
|
171
|
+
...(template ? {
|
|
172
|
+
templateGuidance: template.guidance,
|
|
173
|
+
sourceTemplate: template.source,
|
|
174
|
+
} : {}),
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
127
179
|
export function updateWorkflowState(
|
|
128
180
|
state: WorkflowState,
|
|
129
181
|
changes: Partial<Omit<WorkflowState, "version" | "mode" | "root" | "activatedAt">>,
|
|
@@ -2,7 +2,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
findExternalPathReferences,
|
|
5
|
-
|
|
5
|
+
isPathAllowedWithoutApproval,
|
|
6
6
|
resolveToolPath,
|
|
7
7
|
type ExternalPathReference,
|
|
8
8
|
} from "../workflow-guard.ts";
|
|
@@ -21,7 +21,7 @@ export function evaluateToolPathAccess(
|
|
|
21
21
|
): ExternalPathReference[] {
|
|
22
22
|
if (FILE_PATH_TOOLS.has(request.toolName) && typeof request.input.path === "string") {
|
|
23
23
|
const resolved = resolveToolPath(root, request.input.path, homedir());
|
|
24
|
-
return
|
|
24
|
+
return isPathAllowedWithoutApproval(root, resolved)
|
|
25
25
|
? []
|
|
26
26
|
: [{ raw: request.input.path, resolved }];
|
|
27
27
|
}
|
package/README.md
CHANGED
|
@@ -100,8 +100,9 @@ Start one of the project workflows from the Pi input:
|
|
|
100
100
|
|
|
101
101
|
Both commands first confirm the current directory and lock project work to that
|
|
102
102
|
root for the session. In-root operations run normally; each tool call that names
|
|
103
|
-
an external path asks for separate approval.
|
|
104
|
-
|
|
103
|
+
an external path asks for separate approval. The non-storage sink `/dev/null` is
|
|
104
|
+
approval-free even when used by shell redirection. `/hwcode-vibe` uses short
|
|
105
|
+
iterative build-and-verify loops. `/hwcode-sdd` additionally requires the current directory
|
|
105
106
|
to be the Git repository root, inventories the codebase, resolves requirement
|
|
106
107
|
questions, and persists approved artifacts under
|
|
107
108
|
`.hwcode/specs/<requirement-slug>/` before test-first implementation begins.
|
|
@@ -138,13 +139,19 @@ remaining resources, and local changes instead of attempting a fourth approach.
|
|
|
138
139
|
|
|
139
140
|
After at least one cloud command succeeds, run
|
|
140
141
|
`/hwcode-cloud-save-template [name]` to save the objective, validated execution
|
|
141
|
-
sequence, and optional lessons learned as a
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
sequence, failed approaches, prerequisites, and optional lessons learned as a
|
|
143
|
+
local Prompt Template. Each template records its user-facing name plus creation
|
|
144
|
+
and update timestamps. Its prompt is layered into stable intent, prerequisites,
|
|
145
|
+
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/` with user-only permissions. Credential values are
|
|
148
|
+
excluded and redacted before steps are persisted.
|
|
144
149
|
|
|
145
150
|
Use `/hwcode-cloud-template [additional instructions]` to select and start a
|
|
146
|
-
saved template
|
|
147
|
-
starts without an inline request.
|
|
151
|
+
saved template by name and last-updated time. `/hwcode-cloud` also offers saved
|
|
152
|
+
templates when it starts without an inline request. If a session started from a
|
|
153
|
+
template, saving again asks whether to update that source template or create a
|
|
154
|
+
new one. The files use a Pi-compatible
|
|
148
155
|
Markdown/frontmatter format, but HWCode intentionally keeps them out of Pi
|
|
149
156
|
resource discovery so each saved template does not become another slash
|
|
150
157
|
command. Templates are extension-private resources and always run through the
|