@hadooppei/hwcode 1.0.12 → 1.0.13

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.
@@ -104,6 +104,25 @@ function finishReview(task, outcome, detail) {
104
104
  reviewAttempts.delete(task.reviewKey);
105
105
  publishReviewStatus();
106
106
  }
107
+ function deferReview(task, reason) {
108
+ clearTimeout(activeDeadlineTimer);
109
+ activeDeadlineTimer = undefined;
110
+ const attempts = reviewAttempts.get(task.reviewKey) ?? 1;
111
+ if (attempts <= 1)
112
+ reviewAttempts.delete(task.reviewKey);
113
+ else
114
+ reviewAttempts.set(task.reviewKey, attempts - 1);
115
+ if (!reviewStatus)
116
+ return;
117
+ reviewStatus.activeReview = undefined;
118
+ reviewStatus.lastDeferred = {
119
+ reviewKey: task.reviewKey,
120
+ sessionKey: task.sessionKey,
121
+ deferredAt: new Date().toISOString(),
122
+ reason: boundedError(reason),
123
+ };
124
+ publishReviewStatus();
125
+ }
107
126
  function removeLeaderMetadata(token) {
108
127
  try {
109
128
  const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8"));
@@ -355,6 +374,13 @@ function handleReviewResult(message) {
355
374
  queueMicrotask(() => void scanForReview());
356
375
  }
357
376
  }
377
+ function handleReviewDeferred(message) {
378
+ const task = activeTask;
379
+ if (!task || message.requestId !== task.requestId || message.leaderToken !== leaderToken || !leaderServer)
380
+ return;
381
+ deferReview(task, message.reason);
382
+ activeTask = undefined;
383
+ }
358
384
  const scanTimer = setInterval(() => { void scanForReview(); }, KNOWLEDGE_RUNTIME_DEFAULTS.review.intervalMs);
359
385
  scanTimer.unref();
360
386
  parentPort.on("message", (message) => {
@@ -373,6 +399,10 @@ parentPort.on("message", (message) => {
373
399
  scheduleElection(0);
374
400
  return;
375
401
  }
402
+ if (message.type === "review_deferred") {
403
+ handleReviewDeferred(message);
404
+ return;
405
+ }
376
406
  if (message.type === "review_result") {
377
407
  handleReviewResult(message);
378
408
  return;
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFileSync, readdirSync, statSync } from "node:fs";
3
- import { resolve } from "node:path";
3
+ import { resolve, sep } from "node:path";
4
4
  import { buildContextEntries, parseSessionEntries, } from "@earendil-works/pi-coding-agent";
5
5
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.js";
6
+ import { findLatestWorkflowRoot } from "../working-directory.js";
6
7
  import { knowledgeDeltaDigest } from "./extractor.js";
7
8
  import { sanitizeKnowledgeText } from "./sanitize.js";
8
9
  import { projectKnowledgeKey } from "./store.js";
@@ -98,15 +99,26 @@ function buildValidationContext(entries) {
98
99
  .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
99
100
  }
100
101
  function projectRootForSession(header, branch) {
102
+ const sessionRoot = resolve(header.cwd);
103
+ const workflowRootValue = findLatestWorkflowRoot(branch);
104
+ const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
105
+ let workingDirectory = sessionRoot;
101
106
  for (const entry of branch.slice().reverse()) {
102
107
  if (entry.type !== "custom" || entry.customType !== "hwcode-working-directory"
103
108
  || !entry.data || typeof entry.data !== "object")
104
109
  continue;
105
110
  const cwd = entry.data.cwd;
106
- if (typeof cwd === "string" && cwd)
107
- return resolve(cwd);
111
+ if (typeof cwd !== "string" || !cwd)
112
+ continue;
113
+ workingDirectory = resolve(cwd);
114
+ break;
115
+ }
116
+ if (workflowRoot && (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))) {
117
+ return workflowRoot;
108
118
  }
109
- return resolve(header.cwd);
119
+ if (workingDirectory === sessionRoot || workingDirectory.startsWith(`${sessionRoot}${sep}`))
120
+ return sessionRoot;
121
+ return workingDirectory;
110
122
  }
111
123
  export function discoverSessionFiles(root) {
112
124
  const files = [];
@@ -137,16 +137,22 @@ function semanticDuplicateIndex(candidate, catalog) {
137
137
  return bestIndex;
138
138
  }
139
139
  function unsupportedOperations(value) {
140
- return [...value.matchAll(/\bOperation\s+([A-Za-z][A-Za-z0-9]+)\s+is not supported\b/gu)]
141
- .map((match) => match[1].toLowerCase());
140
+ return [...value.matchAll(/\bOperation\s+([A-Za-z][A-Za-z0-9]+)\s+is not supported\b/gu)].map((match) => {
141
+ const suffix = value.slice(match.index + match[0].length, match.index + match[0].length + 500);
142
+ const service = /\bhcloud\s+([A-Za-z][A-Za-z0-9-]*)\s+--help\b/iu.exec(suffix)?.[1]?.toLowerCase();
143
+ return { operation: match[1].toLowerCase(), service };
144
+ });
142
145
  }
143
146
  function contradictsVerifiedFailures(body, validationText) {
144
147
  const normalizedBody = body.toLowerCase();
145
- for (const operation of unsupportedOperations(validationText)) {
146
- const index = normalizedBody.search(new RegExp(`\\b${operation}\\b`, "u"));
148
+ for (const unsupported of unsupportedOperations(validationText)) {
149
+ const operationExpression = unsupported.service
150
+ ? new RegExp(`\\b(?:hcloud\\s+)?${unsupported.service}\\b[^\\n]{0,120}\\b${unsupported.operation}\\b`, "u")
151
+ : new RegExp(`\\b${unsupported.operation}\\b`, "u");
152
+ const index = normalizedBody.search(operationExpression);
147
153
  if (index < 0)
148
154
  continue;
149
- const context = normalizedBody.slice(Math.max(0, index - 80), index + operation.length + 80);
155
+ const context = normalizedBody.slice(Math.max(0, index - 80), index + 240);
150
156
  if (!/(?:not supported|unsupported|do not|don't|avoid|不支持|不要|避免)/u.test(context))
151
157
  return true;
152
158
  }
@@ -163,38 +169,58 @@ function asStringArray(value, maxItems) {
163
169
  }
164
170
  function normalizeCandidate(value, projectKey, validationText) {
165
171
  if (!value || typeof value !== "object")
166
- return undefined;
172
+ return { reason: "invalid-schema" };
167
173
  const raw = value;
168
174
  if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
169
175
  || typeof raw.body !== "string" || typeof raw.confidence !== "number")
170
- return undefined;
176
+ return { reason: "invalid-schema" };
171
177
  if (raw.durability !== "stable")
172
- return undefined;
178
+ return { reason: "unstable" };
173
179
  if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence)
174
- return undefined;
180
+ return { reason: "low-confidence" };
181
+ if (raw.targetId !== undefined && raw.targetId !== null
182
+ && (typeof raw.targetId !== "string" || !SAFE_ID_RE.test(raw.targetId)))
183
+ return { reason: "unknown-target" };
175
184
  const explicitUserDirective = raw.explicitUserDirective === true;
176
185
  const scope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
177
186
  const body = compactKnowledgeText(raw.body, 20_000);
178
187
  const evidence = asStringArray(raw.evidence, 8);
179
188
  if (!body || evidence.length === 0)
180
- return undefined;
189
+ return { reason: "missing-body-or-evidence" };
181
190
  if (contradictsVerifiedFailures(body, validationText))
182
- return undefined;
191
+ return { reason: "contradicts-verified-failure" };
183
192
  const requestedTrack = raw.storageHint === "rule" ? "rule" : "topic";
184
193
  const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
185
194
  && body.split(/\n\s*\n/gu).length === 1
186
195
  && (explicitUserDirective || raw.confidence >= 0.9);
187
196
  const targetId = typeof raw.targetId === "string" && SAFE_ID_RE.test(raw.targetId) ? raw.targetId : undefined;
188
- return {
189
- key: compactKnowledgeText(raw.key, 160), targetId,
190
- title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
191
- summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
192
- keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
193
- scope, body, evidence, confidence: Math.min(1, raw.confidence),
194
- storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
195
- action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
196
- explicitUserDirective, durability: "stable",
197
- };
197
+ return { candidate: {
198
+ key: compactKnowledgeText(raw.key, 160), targetId,
199
+ title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
200
+ summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
201
+ keywords: asStringArray(raw.keywords, STORAGE.maxKeywordCount).map((keyword) => keyword.toLowerCase()),
202
+ scope, body, evidence, confidence: Math.min(1, raw.confidence),
203
+ storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
204
+ action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
205
+ explicitUserDirective, durability: "stable",
206
+ } };
207
+ }
208
+ function recordSkip(result, reason) {
209
+ result.skipped++;
210
+ result.skippedReasons ??= {};
211
+ result.skippedReasons[reason] = (result.skippedReasons[reason] ?? 0) + 1;
212
+ }
213
+ function writeRejectedPending(directory, reason, value) {
214
+ let serialized;
215
+ try {
216
+ serialized = JSON.stringify(value);
217
+ }
218
+ catch {
219
+ serialized = String(value);
220
+ }
221
+ const candidate = sanitizeKnowledgeText(serialized).slice(0, 10_000);
222
+ const name = `${Date.now()}-rejected-${reason}-${randomUUID().slice(0, 8)}.json`;
223
+ atomicWrite(join(directory, "pending", name), `${JSON.stringify({ reason, candidate }, null, 2)}\n`);
198
224
  }
199
225
  function renderKnowledgeFile(candidate) {
200
226
  if (candidate.storageHint === "rule")
@@ -324,19 +350,23 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
324
350
  copyCurrentContent(current, temporary, home);
325
351
  const catalog = structuredClone(current.catalog);
326
352
  const result = { saved: 0, updated: 0, pending: 0, skipped: 0 };
353
+ const rejected = [];
327
354
  for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
328
- const candidate = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
329
- if (!candidate) {
330
- result.skipped++;
355
+ const normalized = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
356
+ if (!("candidate" in normalized)) {
357
+ recordSkip(result, normalized.reason);
358
+ rejected.push({ reason: normalized.reason, value });
331
359
  continue;
332
360
  }
361
+ const candidate = normalized.candidate;
333
362
  const fingerprint = normalizedFingerprint(candidate);
334
363
  const hash = contentHash(candidate);
335
364
  const targetIndex = candidate.targetId
336
365
  ? catalog.items.findIndex((item) => item.id === candidate.targetId && applicable(item, task.projectKey))
337
366
  : -1;
338
367
  if (candidate.targetId && targetIndex < 0) {
339
- result.skipped++;
368
+ recordSkip(result, "unknown-target");
369
+ rejected.push({ reason: "unknown-target", value });
340
370
  continue;
341
371
  }
342
372
  let existingIndex = targetIndex >= 0
@@ -386,6 +416,11 @@ export function commitKnowledgeReview(values, task, leaderToken, home = homedir(
386
416
  result.saved++;
387
417
  }
388
418
  }
419
+ if (result.skipped > 0 && result.saved === 0 && result.updated === 0 && result.pending === 0) {
420
+ for (const item of rejected)
421
+ writeRejectedPending(temporary, item.reason, item.value);
422
+ result.pending += rejected.length;
423
+ }
389
424
  catalog.updatedAt = new Date().toISOString();
390
425
  const manifest = {
391
426
  version: 3, generationId, createdAt: new Date().toISOString(), leaderToken, catalog,
@@ -0,0 +1,9 @@
1
+ export function findLatestState(entries, codec) {
2
+ for (let index = entries.length - 1; index >= 0; index--) {
3
+ const entry = entries[index];
4
+ if (entry.type !== "custom" || entry.customType !== codec.customType)
5
+ continue;
6
+ return { found: true, value: codec.decode(entry.data) };
7
+ }
8
+ return { found: false };
9
+ }
@@ -0,0 +1,159 @@
1
+ import { findLatestState } from "../runtime/session-state.js";
2
+ export const WORKFLOW_STATE_TYPE = "hwcode-workflow-state";
3
+ export const WORKFLOW_EXTERNAL_AUDIT_TYPE = "hwcode-workflow-external-approval";
4
+ function isCloudTemplateSource(value) {
5
+ if (!value || typeof value !== "object")
6
+ return false;
7
+ const data = value;
8
+ return typeof data.id === "string"
9
+ && typeof data.name === "string"
10
+ && typeof data.createdAt === "string"
11
+ && typeof data.updatedAt === "string";
12
+ }
13
+ function isCloudRunnerSummary(value) {
14
+ if (!value || typeof value !== "object")
15
+ return false;
16
+ const data = value;
17
+ return typeof data.id === "string"
18
+ && typeof data.name === "string"
19
+ && typeof data.vendor === "string"
20
+ && typeof data.region === "string"
21
+ && typeof data.host === "string"
22
+ && Number.isInteger(data.port)
23
+ && typeof data.user === "string"
24
+ && typeof data.remoteRoot === "string"
25
+ && typeof data.hostKeyFingerprint === "string"
26
+ && ["instance-role", "agency", "ssh-only"].includes(data.identityType);
27
+ }
28
+ function isTerraformRunState(value) {
29
+ if (!value || typeof value !== "object")
30
+ return false;
31
+ const data = value;
32
+ const summary = data.planSummary;
33
+ return typeof data.runId === "string"
34
+ && typeof data.runnerId === "string"
35
+ && ["synced", "validated", "planned", "applied", "failed"].includes(data.phase)
36
+ && typeof data.sourceDigest === "string"
37
+ && typeof data.remoteWorkspace === "string"
38
+ && typeof data.startedAt === "string"
39
+ && typeof data.updatedAt === "string"
40
+ && (data.planDigest === undefined || typeof data.planDigest === "string")
41
+ && (summary === undefined || (summary !== null && typeof summary === "object"
42
+ && Object.values(summary).every((entry) => Number.isInteger(entry))));
43
+ }
44
+ function isCloudDetails(value) {
45
+ if (!value || typeof value !== "object")
46
+ return false;
47
+ const data = value;
48
+ return typeof data.vendor === "string"
49
+ && typeof data.deployCurrentProject === "boolean"
50
+ && typeof data.request === "string"
51
+ && typeof data.allowNonDeleteChanges === "boolean"
52
+ && Array.isArray(data.failedApproaches)
53
+ && Array.isArray(data.successfulSteps)
54
+ && (data.resources === undefined || (Array.isArray(data.resources) && data.resources.every((resource) => {
55
+ if (!resource || typeof resource !== "object")
56
+ return false;
57
+ const entry = resource;
58
+ return typeof entry.id === "string" && typeof entry.type === "string" && typeof entry.region === "string"
59
+ && ["existing", "workflow-created"].includes(entry.ownership)
60
+ && ["active", "deleted"].includes(entry.status)
61
+ && typeof entry.updatedAt === "string";
62
+ })))
63
+ && typeof data.terminalFailure === "boolean"
64
+ && (data.artifactDirectory === undefined || typeof data.artifactDirectory === "string")
65
+ && (data.templateGuidance === undefined || typeof data.templateGuidance === "string")
66
+ && (data.terraformSourcePath === undefined || typeof data.terraformSourcePath === "string")
67
+ && (data.sourceTemplate === undefined || isCloudTemplateSource(data.sourceTemplate))
68
+ && (data.runner === undefined || isCloudRunnerSummary(data.runner))
69
+ && (data.runnerPreference === undefined || ["automatic", "deferred", "existing"].includes(data.runnerPreference))
70
+ && (data.terraformRun === undefined || isTerraformRunState(data.terraformRun));
71
+ }
72
+ export function decodeWorkflowState(value) {
73
+ if (!value || typeof value !== "object")
74
+ return undefined;
75
+ const data = value;
76
+ if (data.version === 2) {
77
+ if (!["active", "completed", "cancelled", "failed"].includes(data.status)
78
+ || !["vibe", "sdd", "cloud"].includes(data.mode)
79
+ || typeof data.root !== "string"
80
+ || typeof data.phase !== "string"
81
+ || typeof data.activatedAt !== "string"
82
+ || typeof data.updatedAt !== "string")
83
+ return undefined;
84
+ if (data.mode === "cloud" && !isCloudDetails(data.details))
85
+ return undefined;
86
+ if (data.mode === "sdd" && data.sdd !== undefined) {
87
+ const sdd = data.sdd;
88
+ if (!["discovery", "requirements", "design", "test-plan", "tasks", "tests", "implementation", "verification"].includes(sdd.phase)
89
+ || !Array.isArray(sdd.approvals))
90
+ return undefined;
91
+ }
92
+ return data;
93
+ }
94
+ return undefined;
95
+ }
96
+ export const WORKFLOW_STATE_CODEC = {
97
+ customType: WORKFLOW_STATE_TYPE,
98
+ decode: decodeWorkflowState,
99
+ };
100
+ export function restoreWorkflowState(entries) {
101
+ return findLatestState(entries, WORKFLOW_STATE_CODEC).value;
102
+ }
103
+ export function activeWorkflow(entries) {
104
+ const state = restoreWorkflowState(entries);
105
+ return state?.status === "active" ? state : undefined;
106
+ }
107
+ export function cloudDetails(state) {
108
+ return state.mode === "cloud" ? state.details : undefined;
109
+ }
110
+ export function createWorkflowState(mode, root, phase = "activated") {
111
+ const now = new Date().toISOString();
112
+ return {
113
+ version: 2,
114
+ status: "active",
115
+ mode,
116
+ root,
117
+ phase,
118
+ activatedAt: now,
119
+ updatedAt: now,
120
+ ...(mode === "sdd" ? { sdd: { phase: "discovery", approvals: [] } } : {}),
121
+ };
122
+ }
123
+ export function createCloudWorkflowState(root, vendor, deployCurrentProject, request, template, artifactDirectory) {
124
+ const now = new Date().toISOString();
125
+ return {
126
+ version: 2,
127
+ status: "active",
128
+ mode: "cloud",
129
+ root,
130
+ phase: "connected",
131
+ activatedAt: now,
132
+ updatedAt: now,
133
+ details: {
134
+ vendor,
135
+ deployCurrentProject,
136
+ request,
137
+ allowNonDeleteChanges: false,
138
+ failedApproaches: [],
139
+ successfulSteps: [],
140
+ resources: [],
141
+ terminalFailure: false,
142
+ ...(artifactDirectory ? { artifactDirectory } : {}),
143
+ ...(template ? {
144
+ templateGuidance: template.guidance,
145
+ sourceTemplate: template.source,
146
+ } : {}),
147
+ },
148
+ };
149
+ }
150
+ export function updateWorkflowState(state, changes) {
151
+ return { ...state, ...changes, version: 2, updatedAt: new Date().toISOString() };
152
+ }
153
+ export function workflowLabel(mode) {
154
+ if (mode === "vibe")
155
+ return "HWCode Vibe";
156
+ if (mode === "sdd")
157
+ return "HWCode SDD";
158
+ return "HWCode Cloud";
159
+ }
@@ -0,0 +1,170 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import { activeWorkflow } from "./workflows/state.js";
5
+ export const WORKING_DIRECTORY_STATE_TYPE = "hwcode-working-directory";
6
+ const workingDirectories = new Map();
7
+ function isWorkingDirectoryState(value) {
8
+ if (!value || typeof value !== "object")
9
+ return false;
10
+ const data = value;
11
+ return data.version === 1
12
+ && typeof data.cwd === "string"
13
+ && (data.previousCwd === undefined || typeof data.previousCwd === "string");
14
+ }
15
+ export function findPersistedWorkingDirectory(entries) {
16
+ for (const entry of [...entries].reverse()) {
17
+ if (entry.type !== "custom" || entry.customType !== WORKING_DIRECTORY_STATE_TYPE)
18
+ continue;
19
+ if (isWorkingDirectoryState(entry.data))
20
+ return entry.data;
21
+ }
22
+ return undefined;
23
+ }
24
+ export function canonicalizeDirectory(path) {
25
+ const canonical = realpathSync(path);
26
+ if (!statSync(canonical).isDirectory())
27
+ throw new Error(`Not a directory: ${path}`);
28
+ return canonical;
29
+ }
30
+ function initialState(source) {
31
+ const persisted = findPersistedWorkingDirectory(source.getEntries());
32
+ if (persisted)
33
+ return { ...persisted, cwd: canonicalizeDirectory(persisted.cwd) };
34
+ return { version: 1, cwd: canonicalizeDirectory(source.getCwd()) };
35
+ }
36
+ export function getWorkingDirectoryState(source) {
37
+ return workingDirectories.get(source.getSessionId()) ?? initialState(source);
38
+ }
39
+ export function getWorkingDirectory(source) {
40
+ return getWorkingDirectoryState(source).cwd;
41
+ }
42
+ export function setWorkingDirectoryState(source, state) {
43
+ workingDirectories.set(source.getSessionId(), state);
44
+ }
45
+ export function resetWorkingDirectoryState(source) {
46
+ const state = initialState(source);
47
+ setWorkingDirectoryState(source, state);
48
+ return state;
49
+ }
50
+ export function clearWorkingDirectoryState(source) {
51
+ workingDirectories.delete(source.getSessionId());
52
+ }
53
+ function expandHome(path, home) {
54
+ if (path === "~" || path === "$HOME" || path === "${HOME}")
55
+ return home;
56
+ if (path.startsWith("~/"))
57
+ return resolve(home, path.slice(2));
58
+ if (path.startsWith("$HOME/"))
59
+ return resolve(home, path.slice(6));
60
+ if (path.startsWith("${HOME}/"))
61
+ return resolve(home, path.slice(8));
62
+ return path;
63
+ }
64
+ export function resolveDirectoryArgument(base, argument, previousCwd, home = homedir()) {
65
+ if (argument === "-") {
66
+ if (!previousCwd)
67
+ throw new Error("No previous working directory is available.");
68
+ return canonicalizeDirectory(previousCwd);
69
+ }
70
+ const expanded = expandHome(argument || home, home);
71
+ return canonicalizeDirectory(isAbsolute(expanded) ? expanded : resolve(base, expanded));
72
+ }
73
+ export function resolveWorkingPath(base, path, home = homedir()) {
74
+ const expanded = expandHome(path, home);
75
+ return isAbsolute(expanded) ? expanded : resolve(base, expanded);
76
+ }
77
+ function readShellWord(command, start) {
78
+ let value = "";
79
+ let quote;
80
+ let index = start;
81
+ for (; index < command.length; index += 1) {
82
+ const character = command[index];
83
+ if (quote) {
84
+ if (character === quote) {
85
+ quote = undefined;
86
+ continue;
87
+ }
88
+ if (character === "\\" && quote === '"' && index + 1 < command.length) {
89
+ index += 1;
90
+ value += command[index];
91
+ continue;
92
+ }
93
+ value += character;
94
+ continue;
95
+ }
96
+ if (character === "'" || character === '"') {
97
+ quote = character;
98
+ continue;
99
+ }
100
+ if (character === "\\" && index + 1 < command.length) {
101
+ index += 1;
102
+ value += command[index];
103
+ continue;
104
+ }
105
+ if (/\s/u.test(character) || character === ";" || character === "&")
106
+ break;
107
+ if ("|<>`".includes(character) || character === "$" && command[index + 1] === "(")
108
+ return undefined;
109
+ value += character;
110
+ }
111
+ if (quote || value.length === 0)
112
+ return undefined;
113
+ return { value, end: index };
114
+ }
115
+ /** Parse a leading persistent `cd`, optionally followed by `&&` or `;`. */
116
+ export function parseLeadingDirectoryChange(command) {
117
+ let index = 0;
118
+ while (/\s/u.test(command[index] ?? ""))
119
+ index += 1;
120
+ if (command.slice(index, index + 2) !== "cd")
121
+ return undefined;
122
+ const next = command[index + 2];
123
+ if (next && !/\s/u.test(next) && next !== ";" && command.slice(index + 2, index + 4) !== "&&") {
124
+ return undefined;
125
+ }
126
+ index += 2;
127
+ while (/\s/u.test(command[index] ?? ""))
128
+ index += 1;
129
+ if (command.slice(index, index + 2) === "--") {
130
+ index += 2;
131
+ while (/\s/u.test(command[index] ?? ""))
132
+ index += 1;
133
+ }
134
+ let argument = "";
135
+ if (index < command.length && command[index] !== ";" && command.slice(index, index + 2) !== "&&") {
136
+ const word = readShellWord(command, index);
137
+ if (!word)
138
+ return undefined;
139
+ argument = word.value;
140
+ index = word.end;
141
+ }
142
+ while (/\s/u.test(command[index] ?? ""))
143
+ index += 1;
144
+ if (index >= command.length)
145
+ return { argument, remainder: "" };
146
+ if (command.slice(index, index + 2) === "&&")
147
+ index += 2;
148
+ else if (command[index] === ";")
149
+ index += 1;
150
+ else
151
+ return undefined;
152
+ return { argument, remainder: command.slice(index).trimStart() };
153
+ }
154
+ export function getActiveWorkflowRoot(entries) {
155
+ return activeWorkflow(entries)?.root;
156
+ }
157
+ export function findLatestWorkflowRoot(entries) {
158
+ for (const entry of [...entries].reverse()) {
159
+ if (entry.type !== "custom" || entry.customType !== "hwcode-workflow-state"
160
+ || !entry.data || typeof entry.data !== "object")
161
+ continue;
162
+ const root = entry.data.root;
163
+ if (typeof root === "string" && root)
164
+ return root;
165
+ }
166
+ return undefined;
167
+ }
168
+ export function shellQuote(value) {
169
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
170
+ }
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
- import { dirname } from "node:path";
3
+ import { dirname, resolve, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Worker } from "node:worker_threads";
6
6
 
@@ -14,7 +14,7 @@ import { matchKnowledge } from "../lib/knowledge/matcher.ts";
14
14
  import { loadKnowledgeById, loadKnowledgeSnapshot, projectKnowledgeKey } from "../lib/knowledge/store.ts";
15
15
  import type { KnowledgeWorkerInput, KnowledgeWorkerOutput } from "../lib/knowledge/worker-protocol.ts";
16
16
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../lib/runtime/defaults.ts";
17
- import { getWorkingDirectory } from "../lib/working-directory.ts";
17
+ import { findLatestWorkflowRoot, getWorkingDirectory } from "../lib/working-directory.ts";
18
18
 
19
19
  interface ActiveReview {
20
20
  requestId: string;
@@ -98,15 +98,29 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
98
98
  try {
99
99
  const ctx = runtime.context;
100
100
  if (!ctx?.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) throw new Error("no-configured-model");
101
- if (!ctx.isIdle() || ctx.hasPendingMessages()) throw new Error("model-executor-is-busy");
101
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) {
102
+ post({
103
+ type: "review_deferred",
104
+ leaderToken: message.leaderToken,
105
+ requestId: message.requestId,
106
+ reason: "model-executor-is-busy",
107
+ });
108
+ return;
109
+ }
102
110
  const controller = new AbortController();
103
111
  runtime.activeReview = { requestId: message.requestId, leaderToken: message.leaderToken, controller };
104
112
  timeout = setTimeout(() => controller.abort(), KNOWLEDGE_RUNTIME_DEFAULTS.review.modelTimeoutMs);
105
113
  timeout.unref();
106
114
  const snapshot = loadKnowledgeSnapshot(message.task.projectKey);
115
+ const applicableCatalog = {
116
+ ...snapshot.catalog,
117
+ items: snapshot.catalog.items.filter((entry) => (
118
+ entry.scope === "global" || entry.scope === `project:${message.task.projectKey}`
119
+ )),
120
+ };
107
121
  const related: ExistingKnowledgeContext[] = matchKnowledge(
108
122
  message.task.delta,
109
- snapshot.catalog,
123
+ applicableCatalog,
110
124
  KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingContextItems,
111
125
  ).map((entry, index) => {
112
126
  const reference: ExistingKnowledgeContext = {
@@ -146,10 +160,12 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
146
160
  .map((item) => item.text).join("\n");
147
161
  post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, raw });
148
162
  } catch (error) {
149
- post({
150
- type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId,
151
- error: error instanceof Error ? error.message : String(error),
152
- });
163
+ const reason = error instanceof Error ? error.message : String(error);
164
+ if (reason === "model-executor-is-busy") {
165
+ post({ type: "review_deferred", leaderToken: message.leaderToken, requestId: message.requestId, reason });
166
+ } else {
167
+ post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, error: reason });
168
+ }
153
169
  try { updateCapability(); } catch { /* The worker lease will recover even if the extension context changed. */ }
154
170
  } finally {
155
171
  clearTimeout(timeout);
@@ -210,7 +226,13 @@ function configureForContext(ctx: ExtensionContext): void {
210
226
  }
211
227
 
212
228
  function currentProject(ctx: ExtensionContext): { root: string; key: string } {
213
- const root = getWorkingDirectory(ctx.sessionManager);
229
+ const workingDirectory = resolve(getWorkingDirectory(ctx.sessionManager));
230
+ const workflowRootValue = findLatestWorkflowRoot(ctx.sessionManager.getEntries());
231
+ const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
232
+ const root = workflowRoot
233
+ && (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))
234
+ ? workflowRoot
235
+ : workingDirectory;
214
236
  return { root, key: projectKnowledgeKey(root) };
215
237
  }
216
238
 
@@ -23,6 +23,12 @@ export interface KnowledgeReviewStatus {
23
23
  attempt: number;
24
24
  startedAt: string;
25
25
  };
26
+ lastDeferred?: {
27
+ reviewKey: string;
28
+ sessionKey: string;
29
+ deferredAt: string;
30
+ reason: string;
31
+ };
26
32
  lastReview?: {
27
33
  reviewKey: string;
28
34
  sessionKey: string;
@@ -113,6 +113,23 @@ function finishReview(
113
113
  publishReviewStatus();
114
114
  }
115
115
 
116
+ function deferReview(task: KnowledgeReviewTask, reason: string): void {
117
+ clearTimeout(activeDeadlineTimer);
118
+ activeDeadlineTimer = undefined;
119
+ const attempts = reviewAttempts.get(task.reviewKey) ?? 1;
120
+ if (attempts <= 1) reviewAttempts.delete(task.reviewKey);
121
+ else reviewAttempts.set(task.reviewKey, attempts - 1);
122
+ if (!reviewStatus) return;
123
+ reviewStatus.activeReview = undefined;
124
+ reviewStatus.lastDeferred = {
125
+ reviewKey: task.reviewKey,
126
+ sessionKey: task.sessionKey,
127
+ deferredAt: new Date().toISOString(),
128
+ reason: boundedError(reason),
129
+ };
130
+ publishReviewStatus();
131
+ }
132
+
116
133
  function removeLeaderMetadata(token: string): void {
117
134
  try {
118
135
  const current = JSON.parse(readFileSync(paths.knowledgeLeader, "utf8")) as { leaderToken?: string };
@@ -332,6 +349,13 @@ function handleReviewResult(message: Extract<KnowledgeWorkerInput, { type: "revi
332
349
  }
333
350
  }
334
351
 
352
+ function handleReviewDeferred(message: Extract<KnowledgeWorkerInput, { type: "review_deferred" }>): void {
353
+ const task = activeTask;
354
+ if (!task || message.requestId !== task.requestId || message.leaderToken !== leaderToken || !leaderServer) return;
355
+ deferReview(task, message.reason);
356
+ activeTask = undefined;
357
+ }
358
+
335
359
  const scanTimer = setInterval(() => { void scanForReview(); }, KNOWLEDGE_RUNTIME_DEFAULTS.review.intervalMs);
336
360
  scanTimer.unref();
337
361
 
@@ -348,6 +372,7 @@ parentPort.on("message", (message: KnowledgeWorkerInput) => {
348
372
  } else scheduleElection(0);
349
373
  return;
350
374
  }
375
+ if (message.type === "review_deferred") { handleReviewDeferred(message); return; }
351
376
  if (message.type === "review_result") { handleReviewResult(message); return; }
352
377
  if (message.type === "scan_now") { void scanForReview(); return; }
353
378
  stopped = true;
@@ -1,12 +1,13 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFileSync, readdirSync, statSync } from "node:fs";
3
- import { resolve } from "node:path";
3
+ import { resolve, sep } from "node:path";
4
4
 
5
5
  import {
6
6
  buildContextEntries, parseSessionEntries, type SessionEntry, type SessionHeader,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
 
9
9
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
10
+ import { findLatestWorkflowRoot } from "../working-directory.ts";
10
11
  import { knowledgeDeltaDigest } from "./extractor.ts";
11
12
  import { sanitizeKnowledgeText } from "./sanitize.ts";
12
13
  import { projectKnowledgeKey } from "./store.ts";
@@ -105,13 +106,23 @@ function buildValidationContext(entries: SessionEntry[]): string {
105
106
  }
106
107
 
107
108
  function projectRootForSession(header: SessionHeader, branch: SessionEntry[]): string {
109
+ const sessionRoot = resolve(header.cwd);
110
+ const workflowRootValue = findLatestWorkflowRoot(branch);
111
+ const workflowRoot = workflowRootValue ? resolve(workflowRootValue) : undefined;
112
+ let workingDirectory = sessionRoot;
108
113
  for (const entry of branch.slice().reverse()) {
109
114
  if (entry.type !== "custom" || entry.customType !== "hwcode-working-directory"
110
115
  || !entry.data || typeof entry.data !== "object") continue;
111
116
  const cwd = (entry.data as Record<string, unknown>).cwd;
112
- if (typeof cwd === "string" && cwd) return resolve(cwd);
117
+ if (typeof cwd !== "string" || !cwd) continue;
118
+ workingDirectory = resolve(cwd);
119
+ break;
113
120
  }
114
- return resolve(header.cwd);
121
+ if (workflowRoot && (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))) {
122
+ return workflowRoot;
123
+ }
124
+ if (workingDirectory === sessionRoot || workingDirectory.startsWith(`${sessionRoot}${sep}`)) return sessionRoot;
125
+ return workingDirectory;
115
126
  }
116
127
 
117
128
  export function discoverSessionFiles(root: string): string[] {
@@ -11,7 +11,8 @@ import { userRuntimePaths } from "../runtime/paths.ts";
11
11
  import { compactKnowledgeSummary, compactKnowledgeText, sanitizeKnowledgeText } from "./sanitize.ts";
12
12
  import type {
13
13
  KnowledgeCandidate, KnowledgeCatalog, KnowledgeCatalogEntry, KnowledgeManifest, KnowledgeReviewCursor,
14
- KnowledgeReviewTask, KnowledgeScope, KnowledgeSnapshot, KnowledgeTrack, PersistKnowledgeResult,
14
+ KnowledgeReviewTask, KnowledgeScope, KnowledgeSkipReason, KnowledgeSnapshot, KnowledgeTrack,
15
+ PersistKnowledgeResult,
15
16
  } from "./types.ts";
16
17
 
17
18
  const STORAGE = KNOWLEDGE_RUNTIME_DEFAULTS.storage;
@@ -151,17 +152,28 @@ function semanticDuplicateIndex(candidate: KnowledgeCandidate, catalog: Knowledg
151
152
  return bestIndex;
152
153
  }
153
154
 
154
- function unsupportedOperations(value: string): string[] {
155
- return [...value.matchAll(/\bOperation\s+([A-Za-z][A-Za-z0-9]+)\s+is not supported\b/gu)]
156
- .map((match) => match[1]!.toLowerCase());
155
+ interface UnsupportedOperation {
156
+ operation: string;
157
+ service?: string;
158
+ }
159
+
160
+ function unsupportedOperations(value: string): UnsupportedOperation[] {
161
+ return [...value.matchAll(/\bOperation\s+([A-Za-z][A-Za-z0-9]+)\s+is not supported\b/gu)].map((match) => {
162
+ const suffix = value.slice(match.index + match[0].length, match.index + match[0].length + 500);
163
+ const service = /\bhcloud\s+([A-Za-z][A-Za-z0-9-]*)\s+--help\b/iu.exec(suffix)?.[1]?.toLowerCase();
164
+ return { operation: match[1]!.toLowerCase(), service };
165
+ });
157
166
  }
158
167
 
159
168
  function contradictsVerifiedFailures(body: string, validationText: string): boolean {
160
169
  const normalizedBody = body.toLowerCase();
161
- for (const operation of unsupportedOperations(validationText)) {
162
- const index = normalizedBody.search(new RegExp(`\\b${operation}\\b`, "u"));
170
+ for (const unsupported of unsupportedOperations(validationText)) {
171
+ const operationExpression = unsupported.service
172
+ ? new RegExp(`\\b(?:hcloud\\s+)?${unsupported.service}\\b[^\\n]{0,120}\\b${unsupported.operation}\\b`, "u")
173
+ : new RegExp(`\\b${unsupported.operation}\\b`, "u");
174
+ const index = normalizedBody.search(operationExpression);
163
175
  if (index < 0) continue;
164
- const context = normalizedBody.slice(Math.max(0, index - 80), index + operation.length + 80);
176
+ const context = normalizedBody.slice(Math.max(0, index - 80), index + 240);
165
177
  if (!/(?:not supported|unsupported|do not|don't|avoid|不支持|不要|避免)/u.test(context)) return true;
166
178
  }
167
179
  return false;
@@ -177,25 +189,29 @@ function asStringArray(value: unknown, maxItems: number): string[] {
177
189
  .map((item) => compactKnowledgeText(item, 240)).filter(Boolean))].slice(0, maxItems);
178
190
  }
179
191
 
180
- function normalizeCandidate(value: unknown, projectKey: string, validationText: string): KnowledgeCandidate | undefined {
181
- if (!value || typeof value !== "object") return undefined;
192
+ type CandidateNormalization = { candidate: KnowledgeCandidate } | { reason: KnowledgeSkipReason };
193
+
194
+ function normalizeCandidate(value: unknown, projectKey: string, validationText: string): CandidateNormalization {
195
+ if (!value || typeof value !== "object") return { reason: "invalid-schema" };
182
196
  const raw = value as Record<string, unknown>;
183
197
  if (typeof raw.key !== "string" || typeof raw.title !== "string" || typeof raw.summary !== "string"
184
- || typeof raw.body !== "string" || typeof raw.confidence !== "number") return undefined;
185
- if (raw.durability !== "stable") return undefined;
186
- if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return undefined;
198
+ || typeof raw.body !== "string" || typeof raw.confidence !== "number") return { reason: "invalid-schema" };
199
+ if (raw.durability !== "stable") return { reason: "unstable" };
200
+ if (raw.confidence < KNOWLEDGE_RUNTIME_DEFAULTS.review.minimumConfidence) return { reason: "low-confidence" };
201
+ if (raw.targetId !== undefined && raw.targetId !== null
202
+ && (typeof raw.targetId !== "string" || !SAFE_ID_RE.test(raw.targetId))) return { reason: "unknown-target" };
187
203
  const explicitUserDirective = raw.explicitUserDirective === true;
188
204
  const scope: KnowledgeScope = raw.scope === "global" && explicitUserDirective ? "global" : `project:${projectKey}`;
189
205
  const body = compactKnowledgeText(raw.body, 20_000);
190
206
  const evidence = asStringArray(raw.evidence, 8);
191
- if (!body || evidence.length === 0) return undefined;
192
- if (contradictsVerifiedFailures(body, validationText)) return undefined;
207
+ if (!body || evidence.length === 0) return { reason: "missing-body-or-evidence" };
208
+ if (contradictsVerifiedFailures(body, validationText)) return { reason: "contradicts-verified-failure" };
193
209
  const requestedTrack: KnowledgeTrack = raw.storageHint === "rule" ? "rule" : "topic";
194
210
  const ruleEligible = body.length <= STORAGE.maxRuleChars && body.split("\n").length <= STORAGE.maxRuleFileLines
195
211
  && body.split(/\n\s*\n/gu).length === 1
196
212
  && (explicitUserDirective || raw.confidence >= 0.9);
197
213
  const targetId = typeof raw.targetId === "string" && SAFE_ID_RE.test(raw.targetId) ? raw.targetId : undefined;
198
- return {
214
+ return { candidate: {
199
215
  key: compactKnowledgeText(raw.key, 160), targetId,
200
216
  title: compactKnowledgeText(raw.title, STORAGE.maxTitleChars),
201
217
  summary: compactKnowledgeSummary(raw.summary, STORAGE.maxSummaryChars),
@@ -204,7 +220,21 @@ function normalizeCandidate(value: unknown, projectKey: string, validationText:
204
220
  storageHint: requestedTrack === "rule" && ruleEligible ? "rule" : "topic",
205
221
  action: raw.action === "revise" ? "revise" : raw.action === "reinforce" ? "reinforce" : "add",
206
222
  explicitUserDirective, durability: "stable",
207
- };
223
+ } };
224
+ }
225
+
226
+ function recordSkip(result: PersistKnowledgeResult, reason: KnowledgeSkipReason): void {
227
+ result.skipped++;
228
+ result.skippedReasons ??= {};
229
+ result.skippedReasons[reason] = (result.skippedReasons[reason] ?? 0) + 1;
230
+ }
231
+
232
+ function writeRejectedPending(directory: string, reason: KnowledgeSkipReason, value: unknown): void {
233
+ let serialized: string;
234
+ try { serialized = JSON.stringify(value); } catch { serialized = String(value); }
235
+ const candidate = sanitizeKnowledgeText(serialized).slice(0, 10_000);
236
+ const name = `${Date.now()}-rejected-${reason}-${randomUUID().slice(0, 8)}.json`;
237
+ atomicWrite(join(directory, "pending", name), `${JSON.stringify({ reason, candidate }, null, 2)}\n`);
208
238
  }
209
239
 
210
240
  function renderKnowledgeFile(candidate: KnowledgeCandidate): string {
@@ -328,15 +358,25 @@ export function commitKnowledgeReview(
328
358
  copyCurrentContent(current, temporary, home);
329
359
  const catalog: KnowledgeCatalog = structuredClone(current.catalog);
330
360
  const result: PersistKnowledgeResult = { saved: 0, updated: 0, pending: 0, skipped: 0 };
361
+ const rejected: Array<{ reason: KnowledgeSkipReason; value: unknown }> = [];
331
362
  for (const value of values.slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxCandidates)) {
332
- const candidate = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
333
- if (!candidate) { result.skipped++; continue; }
363
+ const normalized = normalizeCandidate(value, task.projectKey, `${task.context ?? ""}\n${task.delta}`);
364
+ if (!("candidate" in normalized)) {
365
+ recordSkip(result, normalized.reason);
366
+ rejected.push({ reason: normalized.reason, value });
367
+ continue;
368
+ }
369
+ const candidate = normalized.candidate;
334
370
  const fingerprint = normalizedFingerprint(candidate);
335
371
  const hash = contentHash(candidate);
336
372
  const targetIndex = candidate.targetId
337
373
  ? catalog.items.findIndex((item) => item.id === candidate.targetId && applicable(item, task.projectKey))
338
374
  : -1;
339
- if (candidate.targetId && targetIndex < 0) { result.skipped++; continue; }
375
+ if (candidate.targetId && targetIndex < 0) {
376
+ recordSkip(result, "unknown-target");
377
+ rejected.push({ reason: "unknown-target", value });
378
+ continue;
379
+ }
340
380
  let existingIndex = targetIndex >= 0
341
381
  ? targetIndex
342
382
  : catalog.items.findIndex((item) => item.fingerprint === fingerprint);
@@ -378,6 +418,10 @@ export function commitKnowledgeReview(
378
418
  if (existingIndex >= 0) { catalog.items[existingIndex] = entry; result.updated++; }
379
419
  else { catalog.items.push(entry); result.saved++; }
380
420
  }
421
+ if (result.skipped > 0 && result.saved === 0 && result.updated === 0 && result.pending === 0) {
422
+ for (const item of rejected) writeRejectedPending(temporary, item.reason, item.value);
423
+ result.pending += rejected.length;
424
+ }
381
425
  catalog.updatedAt = new Date().toISOString();
382
426
  const manifest: KnowledgeManifest = {
383
427
  version: 3, generationId, createdAt: new Date().toISOString(), leaderToken, catalog,
@@ -75,8 +75,17 @@ export interface PersistKnowledgeResult {
75
75
  updated: number;
76
76
  pending: number;
77
77
  skipped: number;
78
+ skippedReasons?: Partial<Record<KnowledgeSkipReason, number>>;
78
79
  }
79
80
 
81
+ export type KnowledgeSkipReason =
82
+ | "invalid-schema"
83
+ | "unstable"
84
+ | "low-confidence"
85
+ | "missing-body-or-evidence"
86
+ | "contradicts-verified-failure"
87
+ | "unknown-target";
88
+
80
89
  export interface KnowledgeReviewTask {
81
90
  requestId: string;
82
91
  reviewKey: string;
@@ -2,6 +2,7 @@ import type { KnowledgeReviewTask, PersistKnowledgeResult } from "./types.ts";
2
2
 
3
3
  export type KnowledgeWorkerInput =
4
4
  | { type: "configure"; modelAvailable: boolean; sessionsRoot: string }
5
+ | { type: "review_deferred"; leaderToken: string; requestId: string; reason: string }
5
6
  | { type: "review_result"; leaderToken: string; requestId: string; raw?: string; error?: string }
6
7
  | { type: "scan_now" }
7
8
  | { type: "stop" };
@@ -189,6 +189,16 @@ export function getActiveWorkflowRoot(entries: readonly SessionEntry[]): string
189
189
  return activeWorkflow(entries)?.root;
190
190
  }
191
191
 
192
+ export function findLatestWorkflowRoot(entries: readonly SessionEntry[]): string | undefined {
193
+ for (const entry of [...entries].reverse()) {
194
+ if (entry.type !== "custom" || entry.customType !== "hwcode-workflow-state"
195
+ || !entry.data || typeof entry.data !== "object") continue;
196
+ const root = (entry.data as Record<string, unknown>).root;
197
+ if (typeof root === "string" && root) return root;
198
+ }
199
+ return undefined;
200
+ }
201
+
192
202
  export function shellQuote(value: string): string {
193
203
  return `'${value.replaceAll("'", `'"'"'`)}'`;
194
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadooppei/hwcode",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "A customizable terminal coding agent with local-model support and HWCode workflows.",
5
5
  "type": "module",
6
6
  "bin": {