@hadooppei/hwcode 1.0.11 → 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.
@@ -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
 
@@ -8,13 +8,13 @@ import { Type } from "@earendil-works/pi-ai";
8
8
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
 
10
10
  import {
11
- buildKnowledgeExtractionPrompt, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
11
+ buildKnowledgeExtractionPrompt, type ExistingKnowledgeContext, KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
12
12
  } from "../lib/knowledge/extractor.ts";
13
13
  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,18 +98,58 @@ 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();
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
+ };
121
+ const related: ExistingKnowledgeContext[] = matchKnowledge(
122
+ message.task.delta,
123
+ applicableCatalog,
124
+ KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingContextItems,
125
+ ).map((entry, index) => {
126
+ const reference: ExistingKnowledgeContext = {
127
+ id: entry.id,
128
+ title: entry.title,
129
+ summary: entry.summary,
130
+ keywords: entry.keywords,
131
+ track: entry.track,
132
+ };
133
+ if (index >= KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingBodyItems) return reference;
134
+ const content = loadKnowledgeById(entry.id, message.task.projectKey)?.content
135
+ .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxExistingBodyChars);
136
+ return content ? { ...reference, content } : reference;
137
+ });
106
138
  const response = await ctx.modelRegistry.complete(
107
139
  ctx.model,
108
140
  {
109
141
  systemPrompt: KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT,
110
142
  messages: [{
111
143
  role: "user",
112
- content: [{ type: "text", text: buildKnowledgeExtractionPrompt(message.task.projectRoot, message.task.delta) }],
144
+ content: [{
145
+ type: "text",
146
+ text: buildKnowledgeExtractionPrompt(
147
+ message.task.projectRoot,
148
+ message.task.delta,
149
+ message.task.context,
150
+ related,
151
+ ),
152
+ }],
113
153
  timestamp: Date.now(),
114
154
  }],
115
155
  },
@@ -120,10 +160,12 @@ async function runReview(message: Extract<KnowledgeWorkerOutput, { type: "review
120
160
  .map((item) => item.text).join("\n");
121
161
  post({ type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId, raw });
122
162
  } catch (error) {
123
- post({
124
- type: "review_result", leaderToken: message.leaderToken, requestId: message.requestId,
125
- error: error instanceof Error ? error.message : String(error),
126
- });
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
+ }
127
169
  try { updateCapability(); } catch { /* The worker lease will recover even if the extension context changed. */ }
128
170
  } finally {
129
171
  clearTimeout(timeout);
@@ -184,7 +226,13 @@ function configureForContext(ctx: ExtensionContext): void {
184
226
  }
185
227
 
186
228
  function currentProject(ctx: ExtensionContext): { root: string; key: string } {
187
- 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;
188
236
  return { root, key: projectKnowledgeKey(root) };
189
237
  }
190
238
 
@@ -2,6 +2,15 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  import { KNOWLEDGE_RUNTIME_DEFAULTS } from "../runtime/defaults.ts";
4
4
 
5
+ export interface ExistingKnowledgeContext {
6
+ id: string;
7
+ title: string;
8
+ summary: string;
9
+ keywords: string[];
10
+ track: "rule" | "topic";
11
+ content?: string;
12
+ }
13
+
5
14
  export const KNOWLEDGE_EXTRACTION_SYSTEM_PROMPT = `You are HWCode's background knowledge reviewer.
6
15
  Review only the supplied conversation delta. Return strict JSON and no markdown.
7
16
  Persist only knowledge that is likely to be useful in future sessions:
@@ -10,15 +19,31 @@ Persist only knowledge that is likely to be useful in future sessions:
10
19
  - reusable architecture, testing, debugging, deployment, or operational knowledge.
11
20
  Do not persist task summaries, guesses, temporary IDs, credentials, secrets, private data, or facts recoverable by simply reading the repository.
12
21
  Do not persist mutable inventory such as current cloud resources, names, availability, status, timestamps, or account snapshots. Preserve the discovery method or selection rule instead. If useful procedure is mixed with volatile facts, remove the volatile facts.
13
- Use storageHint "rule" only for short, precise, high-value instructions. Use "topic" for multi-step SOPs and detailed experience.
14
- Use action "revise" only when the delta explicitly corrects prior knowledge; otherwise use "add" or "reinforce".
22
+ Treat failed, unsupported, invalid, or corrected operations as negative evidence. Never present an operation as valid when the supplied evidence says it failed; use only a verified alternative or record an explicit warning. Never invent commands or parameters absent from successful evidence.
23
+ Use storageHint "rule" only for one short imperative instruction of at most 240 characters. A rule must not contain incident narration, example resource names, IDs, timestamps, or evidence details. Use "topic" for multi-step SOPs and detailed experience.
24
+ Relevant existing knowledge may be supplied. If a candidate has the same intent as an existing item, do not add a translated, renamed, or reformatted duplicate. Set targetId to that exact existing ID and action to "reinforce" when the existing content remains correct, or "revise" only when the delta explicitly proves a correction. Use targetId null only for genuinely new knowledge.
15
25
  Scope may be "global" only for an explicit user directive intended across projects; otherwise use "project".
16
- Each summary must be one complete sentence of at most 160 characters. Keep each body under 3000 characters. Escape line breaks and quotes inside JSON strings. Set durability to "stable" only after removing facts likely to change or be cheaply rediscovered.
17
- Return: {"candidates":[{"key":"stable semantic key","title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body","evidence":["verified evidence"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|revise","explicitUserDirective":false,"durability":"stable"}]}
26
+ The prior validation context is only for checking facts and contradictions; do not persist it again unless the new delta independently reinforces it. Prefer accumulated workflow successfulSteps and failedApproaches over assistant narration.
27
+ Each summary must be one complete sentence of at most 160 characters. Keep each topic body under 2200 characters. Escape line breaks and quotes inside JSON strings. Set durability to "stable" only after removing facts likely to change or be cheaply rediscovered. Return at most 3 candidates.
28
+ Return: {"candidates":[{"key":"stable semantic key","targetId":"existing-id or null","title":"...","summary":"one complete sentence","keywords":["..."],"scope":"global|project","body":"markdown body","evidence":["verified evidence"],"confidence":0.0,"storageHint":"rule|topic","action":"add|reinforce|revise","explicitUserDirective":false,"durability":"stable"}]}
18
29
  Return {"candidates":[]} when nothing meets the threshold.`;
19
30
 
20
- export function buildKnowledgeExtractionPrompt(projectRoot: string, delta: string): string {
21
- return `Project root: ${projectRoot}\n\n<conversation_delta>\n${delta}\n</conversation_delta>`;
31
+ export function buildKnowledgeExtractionPrompt(
32
+ projectRoot: string,
33
+ delta: string,
34
+ context = "",
35
+ existing: ExistingKnowledgeContext[] = [],
36
+ ): string {
37
+ const related = existing.length > 0 ? JSON.stringify(existing) : "[]";
38
+ return [
39
+ `Project root: ${projectRoot}`,
40
+ "",
41
+ "<relevant_existing_knowledge>", related, "</relevant_existing_knowledge>",
42
+ "",
43
+ "<prior_validation_context>", context, "</prior_validation_context>",
44
+ "",
45
+ "<conversation_delta>", delta, "</conversation_delta>",
46
+ ].join("\n");
22
47
  }
23
48
 
24
49
  export function parseCandidateEnvelope(text: string): unknown[] {
@@ -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";
@@ -19,6 +20,9 @@ interface ReviewPiece {
19
20
  text: string;
20
21
  }
21
22
 
23
+ const FAILURE_EVIDENCE_RE = /(?:\b(?:error|failed|failure|invalid|unsupported|mistyp(?:e|ed)|typo)\b|not supported|失败|错误|不支持|无效)/iu;
24
+ const INJECTED_SKILL_RE = /<skill\b[^>]*>[\s\S]*?<\/skill>/giu;
25
+
22
26
  function hash(value: string, length = 64): string {
23
27
  return createHash("sha256").update(value).digest("hex").slice(0, length);
24
28
  }
@@ -35,20 +39,21 @@ function textContent(content: unknown): string {
35
39
 
36
40
  function reviewPiece(entry: SessionEntry, index: number): ReviewPiece | undefined {
37
41
  if (entry.type === "compaction" || entry.type === "branch_summary") {
38
- return { index, priority: 1, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
42
+ return { index, priority: 2, category: "summary", text: `[summary]\n${sanitizeKnowledgeText(entry.summary).slice(0, 6_000)}` };
39
43
  }
40
44
  if (entry.type === "custom" && entry.customType.startsWith("hwcode-workflow")) {
41
45
  return {
42
- index, priority: 0, category: "workflow",
46
+ index, priority: 1, category: "workflow",
43
47
  text: `[workflow_state:${entry.customType}]\n${sanitizeKnowledgeText(JSON.stringify(entry.data ?? {})).slice(0, 6_000)}`,
44
48
  };
45
49
  }
46
50
  if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") return undefined;
47
51
  const message = entry.message as unknown as Record<string, unknown>;
48
52
  const role = typeof message.role === "string" ? message.role : "message";
49
- const content = sanitizeKnowledgeText(textContent(message.content));
53
+ const content = sanitizeKnowledgeText(textContent(message.content).replace(INJECTED_SKILL_RE, "[loaded skill omitted]"));
50
54
  if (!content) return undefined;
51
- const priority = role === "user" ? 0 : role === "toolResult" ? 3 : 2;
55
+ const priority = role === "user" ? 0 : role === "toolResult" && FAILURE_EVIDENCE_RE.test(content) ? 2
56
+ : role === "toolResult" ? 4 : 3;
52
57
  const cap = role === "user" ? 8_000 : role === "toolResult" ? 1_500 : 4_000;
53
58
  const category = role === "user" ? "user" : role === "toolResult" ? "tool" : "assistant";
54
59
  return { index, priority, category, text: `[${role}]\n${content.slice(0, cap)}` };
@@ -61,24 +66,63 @@ function buildDelta(entries: SessionEntry[]): string {
61
66
  user: 14_000, workflow: 6_000, summary: 4_000, assistant: 8_000, tool: 4_000,
62
67
  };
63
68
  let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars;
64
- for (const piece of pieces.slice().sort((left, right) => left.priority - right.priority || left.index - right.index)) {
69
+ for (const piece of pieces.slice().sort((left, right) => {
70
+ if (left.priority !== right.priority) return left.priority - right.priority;
71
+ if (left.category === right.category && left.category !== "user") return right.index - left.index;
72
+ return left.index - right.index;
73
+ })) {
65
74
  if (remaining <= 0) break;
66
- const text = piece.text.slice(0, Math.min(remaining, categoryRemaining[piece.category]));
75
+ const separatorCharacters = selected.length > 0 ? 2 : 0;
76
+ if (remaining <= separatorCharacters) break;
77
+ const text = piece.text.slice(0, Math.min(remaining - separatorCharacters, categoryRemaining[piece.category]));
67
78
  if (text) selected.push({ ...piece, text });
68
- remaining -= text.length;
79
+ remaining -= text.length + separatorCharacters;
69
80
  categoryRemaining[piece.category] -= text.length;
70
81
  }
71
- return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n");
82
+ return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
83
+ .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxDeltaChars);
84
+ }
85
+
86
+ function buildValidationContext(entries: SessionEntry[]): string {
87
+ const pieces = entries.map(reviewPiece).filter((piece): piece is ReviewPiece => Boolean(piece));
88
+ const latestWorkflow = pieces.filter((piece) => piece.category === "workflow").at(-1);
89
+ const evidence = pieces.filter((piece) => (
90
+ (piece.category === "tool" || piece.category === "assistant" || piece.category === "summary")
91
+ && FAILURE_EVIDENCE_RE.test(piece.text)
92
+ ));
93
+ if (latestWorkflow && !evidence.includes(latestWorkflow)) evidence.push(latestWorkflow);
94
+ let remaining = KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars;
95
+ const selected: ReviewPiece[] = [];
96
+ for (const piece of evidence.slice().sort((left, right) => right.index - left.index)) {
97
+ if (remaining <= 0) break;
98
+ const separatorCharacters = selected.length > 0 ? 2 : 0;
99
+ if (remaining <= separatorCharacters) break;
100
+ const text = piece.text.slice(0, remaining - separatorCharacters);
101
+ if (text) selected.push({ ...piece, text });
102
+ remaining -= text.length + separatorCharacters;
103
+ }
104
+ return selected.sort((left, right) => left.index - right.index).map((piece) => piece.text).join("\n\n")
105
+ .slice(0, KNOWLEDGE_RUNTIME_DEFAULTS.review.maxPriorContextChars);
72
106
  }
73
107
 
74
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;
75
113
  for (const entry of branch.slice().reverse()) {
76
114
  if (entry.type !== "custom" || entry.customType !== "hwcode-working-directory"
77
115
  || !entry.data || typeof entry.data !== "object") continue;
78
116
  const cwd = (entry.data as Record<string, unknown>).cwd;
79
- if (typeof cwd === "string" && cwd) return resolve(cwd);
117
+ if (typeof cwd !== "string" || !cwd) continue;
118
+ workingDirectory = resolve(cwd);
119
+ break;
120
+ }
121
+ if (workflowRoot && (workingDirectory === workflowRoot || workingDirectory.startsWith(`${workflowRoot}${sep}`))) {
122
+ return workflowRoot;
80
123
  }
81
- return resolve(header.cwd);
124
+ if (workingDirectory === sessionRoot || workingDirectory.startsWith(`${sessionRoot}${sep}`)) return sessionRoot;
125
+ return workingDirectory;
82
126
  }
83
127
 
84
128
  export function discoverSessionFiles(root: string): string[] {
@@ -120,6 +164,7 @@ export function readReviewTask(
120
164
  const pending = branch.slice(cursorIndex + 1);
121
165
  if (pending.length === 0) return undefined;
122
166
  const delta = buildDelta(pending);
167
+ const context = cursorIndex >= 0 ? buildValidationContext(branch.slice(0, cursorIndex + 1)) : "";
123
168
  const firstEntryId = pending[0].id;
124
169
  const lastEntryId = pending[pending.length - 1].id;
125
170
  const deltaDigest = knowledgeDeltaDigest(delta || `${firstEntryId}\0${lastEntryId}`);
@@ -130,7 +175,7 @@ export function readReviewTask(
130
175
  return {
131
176
  requestId: randomUUID(), reviewKey, sessionId: header.id, sessionKey, sessionFileHash,
132
177
  projectRoot, projectKey: projectKnowledgeKey(projectRoot),
133
- firstEntryId, lastEntryId, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
178
+ firstEntryId, lastEntryId, context, delta, deltaDigest, fileSize: stat.size, fileMtimeMs: stat.mtimeMs,
134
179
  };
135
180
  }
136
181