@hadooppei/hwcode 0.2.0 → 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/.env.example CHANGED
@@ -1,10 +1,5 @@
1
- # Keep real credentials outside version control.
2
- # Local provider credentials. Model addresses and capabilities live in
3
- # .pi/model-providers.json.
4
- PI_LOCAL_MODEL_API_KEY=local
5
-
6
- # Optional credential used as the default when logging in to the configurable
7
- # "hw" provider. Leave unset for a keyless local endpoint.
1
+ # Keep real credentials outside version control. Set this value to provide the
2
+ # required "hw" credential through the environment, or enter it through /login.
8
3
  # HW_API_KEY=
9
4
 
10
5
  # OPENAI_API_KEY=
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
- import { loadReplacingResource } from "../runtime/config.ts";
4
+ import { readJsonObject } from "../runtime/config.ts";
5
5
 
6
6
  export type ModelInput = "text" | "image";
7
7
  export interface ConfiguredModel { id: string; name?: string; reasoning?: boolean; input?: ModelInput[]; contextWindow?: number; maxTokens?: number; }
@@ -42,9 +42,9 @@ function validateModel(providerId: string, model: ConfiguredModel): void {
42
42
  if (model.maxTokens !== undefined && !isPositiveNumber(model.maxTokens)) throw new Error(`Model "${model.id}" has an invalid maxTokens`);
43
43
  }
44
44
 
45
- export function loadModelProvidersConfig(cwd = process.cwd()): ModelProvidersConfig {
45
+ export function loadModelProvidersConfig(): ModelProvidersConfig {
46
46
  const source = "model-providers.json";
47
- const config = loadReplacingResource<ModelProvidersConfig & Record<string, unknown>>(cwd, source, BUNDLED_CONFIG_PATH);
47
+ const config = readJsonObject(BUNDLED_CONFIG_PATH) as ModelProvidersConfig & Record<string, unknown>;
48
48
  if (!Array.isArray(config.providers) || config.providers.length === 0) throw new Error(`${source} must contain a non-empty providers array`);
49
49
  const providerIds = new Set<string>();
50
50
  for (const provider of config.providers) {
@@ -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
  }
@@ -1,53 +1,14 @@
1
1
  {
2
2
  "providers": [
3
- {
4
- "id": "local",
5
- "name": "Qwen3 VL (8081)",
6
- "baseUrl": "http://127.0.0.1:8081",
7
- "apiKeyEnv": "PI_LOCAL_MODEL_API_KEY",
8
- "modelDefaults": {
9
- "contextWindow": 1000000,
10
- "maxTokens": 8192
11
- },
12
- "models": [
13
- {
14
- "id": "qwen3-VL:2b",
15
- "name": "Qwen3 VL 2B",
16
- "input": ["text", "image"],
17
- "contextWindow": 1000000,
18
- "maxTokens": 8192
19
- }
20
- ]
21
- },
22
- {
23
- "id": "local-8080",
24
- "name": "Qwen3.5 9B (8080)",
25
- "baseUrl": "http://127.0.0.1:8080",
26
- "apiKeyEnv": "PI_LOCAL_MODEL_API_KEY",
27
- "modelDefaults": {
28
- "contextWindow": 1000000,
29
- "maxTokens": 8192
30
- },
31
- "models": [
32
- {
33
- "id": "Qwen3.5-9B-Q4_K_M",
34
- "name": "Qwen3.5 9B Q4_K_M",
35
- "input": ["text"],
36
- "contextWindow": 1000000,
37
- "maxTokens": 8192
38
- }
39
- ]
40
- },
41
3
  {
42
4
  "id": "hw",
43
5
  "name": "Huawei MaaS",
44
- "baseUrl": "http://127.0.0.1:8080/v1",
45
6
  "apiKeyEnv": "HW_API_KEY",
46
7
  "login": {
47
8
  "enabled": true,
48
9
  "promptBaseUrl": true,
49
10
  "promptApiKey": true,
50
- "apiKeyRequired": false,
11
+ "apiKeyRequired": true,
51
12
  "catalogPath": "models",
52
13
  "timeoutMs": 15000
53
14
  },
@@ -55,16 +16,7 @@
55
16
  "input": ["text"],
56
17
  "contextWindow": 1000000,
57
18
  "maxTokens": 8192
58
- },
59
- "models": [
60
- {
61
- "id": "Qwen3.5-9B-Q4_K_M",
62
- "name": "Qwen3.5 9B Q4_K_M",
63
- "input": ["text"],
64
- "contextWindow": 1000000,
65
- "maxTokens": 8192
66
- }
67
- ]
19
+ }
68
20
  }
69
21
  ]
70
22
  }
package/README.md CHANGED
@@ -21,10 +21,11 @@ hwcode
21
21
  Run `hwcode --help` for the HWCode command reference, including model,
22
22
  session, tool, and workflow options.
23
23
 
24
- `npx @hadooppei/hwcode` is also supported without a global installation. HWCode loads a
25
- project's `.env` file when present and keeps Pi's normal user-level auth and
26
- session storage. Project-local `.pi` resources continue to load alongside the
27
- built-in HWCode profile.
24
+ `npx @hadooppei/hwcode` is also supported without a global installation. HWCode
25
+ loads a project's `.env` file when present and keeps Pi's normal user-level auth
26
+ and session storage. Project-local Pi resources continue to load alongside the
27
+ built-in HWCode profile, except for HWCode's package-scoped model-provider
28
+ definition described below.
28
29
 
29
30
  ## Repository setup
30
31
 
@@ -38,45 +39,31 @@ On first launch, review and accept Pi's project-trust prompt so it can load the
38
39
 
39
40
  ## Model providers
40
41
 
41
- OpenAI-compatible providers are declared in `.pi/model-providers.json`.
42
- Static providers list their models directly:
42
+ The published `.pi/model-providers.json` is package-scoped and is not replaced
43
+ by a file in the current project. It registers the configurable `hw` provider
44
+ without shipping a machine-specific endpoint or a pre-populated model catalog.
45
+ Use Pi's native `/login` flow, select `Huawei MaaS`, and enter the endpoint and
46
+ API key. The key can instead come from `HW_API_KEY`. HWCode then discovers every model exposed by the
47
+ OpenAI-compatible endpoint and Pi stores the selected endpoint, credential, and
48
+ dynamic catalog in its user-level stores.
43
49
 
44
- ```json
45
- {
46
- "id": "local-example",
47
- "name": "Local example",
48
- "baseUrl": "http://127.0.0.1:8082",
49
- "apiKeyEnv": "PI_LOCAL_MODEL_API_KEY",
50
- "models": [
51
- {
52
- "id": "model-id",
53
- "input": ["text", "image"],
54
- "contextWindow": 32768,
55
- "maxTokens": 8192
56
- }
57
- ]
58
- }
59
- ```
60
-
61
- Login providers use Pi's native `/login` flow and discover every model exposed
62
- by an OpenAI-compatible model endpoint:
50
+ The published provider is equivalent to:
63
51
 
64
52
  ```json
65
53
  {
66
54
  "id": "hw",
67
- "name": "hw",
68
- "baseUrl": "http://127.0.0.1:8080/v1",
55
+ "name": "Huawei MaaS",
69
56
  "apiKeyEnv": "HW_API_KEY",
70
57
  "login": {
71
58
  "enabled": true,
72
59
  "promptBaseUrl": true,
73
60
  "promptApiKey": true,
74
- "apiKeyRequired": false,
61
+ "apiKeyRequired": true,
75
62
  "catalogPath": "models"
76
63
  },
77
64
  "modelDefaults": {
78
65
  "input": ["text"],
79
- "contextWindow": 32768,
66
+ "contextWindow": 1000000,
80
67
  "maxTokens": 8192
81
68
  }
82
69
  }
@@ -84,12 +71,14 @@ by an OpenAI-compatible model endpoint:
84
71
 
85
72
  `id` is the stable credential and cache key; `name` is the configurable label
86
73
  shown alongside other providers in `/login`. A login authenticates the whole
87
- provider and publishes its complete model catalog to `/model`. Entries in a
88
- login provider's `models` array are optional metadata overrides for discovered
89
- IDs, which is where vision support should be declared with
90
- `"input": ["text", "image"]`. Unknown models default to text-only. Keep real
91
- API keys in `.env` or enter them through `/login`; Pi stores entered credentials
92
- in its own auth store rather than the project configuration.
74
+ provider and publishes its complete model catalog to `/model`. Unknown models
75
+ default to text-only. Keep real API keys in `.env` or enter them through
76
+ `/login`; Pi stores entered credentials in its own auth store rather than the
77
+ package configuration.
78
+
79
+ Repository maintainers can refer to `.pi/model-providers.json.template` for the
80
+ previous local static-provider examples, including multimodal metadata. The
81
+ template is not loaded at runtime and is explicitly excluded from npm packages.
93
82
 
94
83
  ## Welcome screen
95
84
 
@@ -111,8 +100,9 @@ Start one of the project workflows from the Pi input:
111
100
 
112
101
  Both commands first confirm the current directory and lock project work to that
113
102
  root for the session. In-root operations run normally; each tool call that names
114
- an external path asks for separate approval. `/hwcode-vibe` uses short iterative
115
- 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
116
106
  to be the Git repository root, inventories the codebase, resolves requirement
117
107
  questions, and persists approved artifacts under
118
108
  `.hwcode/specs/<requirement-slug>/` before test-first implementation begins.
@@ -149,13 +139,19 @@ remaining resources, and local changes instead of attempting a fourth approach.
149
139
 
150
140
  After at least one cloud command succeeds, run
151
141
  `/hwcode-cloud-save-template [name]` to save the objective, validated execution
152
- sequence, and optional lessons learned as a local Prompt Template. Templates are
153
- stored under `~/.hwcode/cloud/prompts/` with user-only permissions. Credential
154
- 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.
155
149
 
156
150
  Use `/hwcode-cloud-template [additional instructions]` to select and start a
157
- saved template immediately. `/hwcode-cloud` also offers saved templates when it
158
- 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
159
155
  Markdown/frontmatter format, but HWCode intentionally keeps them out of Pi
160
156
  resource discovery so each saved template does not become another slash
161
157
  command. Templates are extension-private resources and always run through the
@@ -174,9 +170,9 @@ commands, tools, and UI. Reusable behavior lives under `.pi/lib/`:
174
170
  - `context/` and `models/` own compaction and provider-configuration policy.
175
171
 
176
172
  `settings.json` is layered as defaults → profile → project for settings such as
177
- context and hidden commands. `welcome.json` and `model-providers.json` use a
178
- single replacing resource, preferring project, then profile, then the bundled
179
- default. This keeps configuration precedence consistent across extensions.
173
+ context and hidden commands. `welcome.json` uses a single replacing resource.
174
+ `model-providers.json` is deliberately package-scoped so a project cannot
175
+ silently replace login providers or reintroduce machine-local defaults.
180
176
 
181
177
  ## Working directory
182
178
 
@@ -204,8 +200,9 @@ HWCode defaults locally configured and dynamically discovered models to a
204
200
  1,000,000-token context window and caps configured or provider-reported values
205
201
  at that limit. If an OpenAI-compatible model catalog reports a smaller
206
202
  `context_window`, `context_length`, `max_context_length`, `max_model_len`, or
207
- `n_ctx`, the smaller server value wins. Per-model `contextWindow` values in
208
- `.pi/model-providers.json` can also select a smaller limit.
203
+ `n_ctx`, the smaller server value wins. The package-scoped provider's
204
+ `modelDefaults.contextWindow` can also select a smaller limit for dynamically
205
+ discovered models.
209
206
 
210
207
  The default context policy is configured under `hwcode.context` in
211
208
  `.pi/settings.json`:
package/bin/hwcode.js CHANGED
@@ -82,7 +82,7 @@ HWCode 交互命令:
82
82
  hwcode -p "总结当前代码库"
83
83
  hwcode @README.md "检查文档是否完整"
84
84
 
85
- 项目中的 .env 会自动加载;项目本地 .pi 资源会与 HWCode 内置配置共同生效。`;
85
+ 项目中的 .env 会自动加载;模型 Provider 使用 HWCode 发布配置,其余项目本地 .pi 资源照常生效。`;
86
86
  }
87
87
 
88
88
  if (userArgs[0] === "--help" || userArgs[0] === "-h") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadooppei/hwcode",
3
- "version": "0.2.0",
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": {
@@ -26,7 +26,7 @@
26
26
  "pi": "node --env-file-if-exists=.env ./bin/hwcode.js",
27
27
  "test": "node --test .pi/lib/*.test.ts",
28
28
  "test:workflows": "npm test",
29
- "prepack": "npm test"
29
+ "prepack": "npm test && node scripts/audit-package.mjs"
30
30
  },
31
31
  "engines": {
32
32
  "node": ">=22.19.0"