@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.
@@ -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 { expandCloudPromptTemplate, listCloudPromptTemplates, saveCloudPromptTemplate, type CloudPromptTemplate } from "../lib/cloud/templates.ts";
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
- 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 and reusable guidance:\n${details.request}\n\nCredentials were validated and remain outside model context. Use hwcode_cloud_exec for every cloud or infrastructure command.`;
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
- templates.map((template) => `${template.id} — ${template.description}`),
271
+ labels,
269
272
  );
270
- return templates.find((template) => selected?.startsWith(`${template.id} —`));
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 = expandCloudPromptTemplate(template, args);
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(root, vendor, deployCurrentProject, request);
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
- const name = args.trim() || (await ctx.ui.input("模板名称", details.request.slice(0, 48)))?.trim();
408
- if (!name) return;
409
- const notes = (await ctx.ui.editor("补充成功经验、必要前置条件或易错点(可留空;不得包含凭据)", ""))?.trim();
410
- const safeNotes = activeCredentials && notes ? redactCredentialValues(notes, activeCredentials) : notes;
411
- const safeState = activeCredentials
412
- ? { ...activeState, details: { ...details, request: redactCredentialValues(details.request, activeCredentials) } }
413
- : activeState;
414
- const template = saveCloudPromptTemplate(name, safeState, undefined, safeNotes);
415
- notify(ctx, `已保存模板 ${template.id} 到 ${template.path}。使用 /hwcode-cloud-template 可立即复用。`);
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
- description: frontmatterValue(metadata, "description") ?? id,
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.description.localeCompare(right.description));
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
- + ` - Approach: ${step.approach}\n`
92
- + ` - Reference command: \`${inlineCode([step.command, ...step.args].join(" "))}\``
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 a reusable HWCode Cloud task based on a previously successful run.",
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
- "Known successful execution sequence (reference only; re-check current account state before reuse):",
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
- ...(notes ? ["Validated-run notes and pitfalls:", notes, ""] : []),
105
- "Start with read-only discovery and adapt account, region, project, resource names, versions, and paths to the current environment. Present the plan and obtain all normal HWCode Cloud approvals. Never copy credentials or assume resources from the previous run still exist.",
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
- export function saveCloudPromptTemplate(
113
- requestedName: string,
178
+ function renderTemplateFile(
179
+ name: string,
114
180
  state: WorkflowState,
115
- directory = defaultCloudPromptDirectory(),
116
- notes = "",
117
- ): CloudPromptTemplate {
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
- const description = `Reuse successful ${getCloudProvider(state.details.vendor).label} workflow: ${state.details.request.slice(0, 80)}`;
122
- const content = [
187
+ return [
123
188
  "---",
124
- `description: ${JSON.stringify(description)}`,
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
- const temporaryPath = `${path}.${process.pid}.tmp`;
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 (!isPathInsideRoot(root, cleanResolved)) {
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
- isPathInsideRoot,
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 isPathInsideRoot(root, resolved)
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. `/hwcode-vibe` uses short iterative
104
- build-and-verify loops. `/hwcode-sdd` additionally requires the current directory
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 local Prompt Template. Templates are
142
- stored under `~/.hwcode/cloud/prompts/` with user-only permissions. Credential
143
- values are excluded and redacted before steps are persisted.
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 immediately. `/hwcode-cloud` also offers saved templates when it
147
- starts without an inline request. The files use a Pi-compatible
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadooppei/hwcode",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "description": "A customizable terminal coding agent with local-model support and HWCode workflows.",
5
5
  "type": "module",
6
6
  "bin": {