kc-beta 0.6.0 → 0.6.2

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.
@@ -2,11 +2,28 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { Phase, PipelineEvent } from "./index.js";
4
4
  import { Pipeline } from "./base.js";
5
+ import { SkillValidator } from "../skill-validator.js";
5
6
 
6
7
  export class SkillAuthoringPipeline extends Pipeline {
7
- constructor(workspace) {
8
+ /**
9
+ * @param {Workspace} workspace
10
+ * @param {TaskManager|null} [taskManager] - v0.6.1 A2: pass the engine's
11
+ * TaskManager so exitCriteriaMet can require task-completion parity in
12
+ * addition to D2 filename coverage. Subagents pass null (no taskManager
13
+ * in subagent scope), in which case the gate falls back to D2-only
14
+ * behaviour.
15
+ */
16
+ constructor(workspace, taskManager = null) {
8
17
  super();
9
18
  this._workspace = workspace;
19
+ this._taskManager = taskManager;
20
+ // v0.6.2 I2: skill validator catches malformed check_r###.py at the
21
+ // skill_authoring exit boundary instead of silently passing the
22
+ // phase and breaking in production_qc (E2E #4 unified_qc.py
23
+ // SyntaxError went undiagnosed for hours).
24
+ this._validator = new SkillValidator();
25
+ this._validationFailures = [];
26
+ this._validationSkipped = false;
10
27
  this.totalRules = [];
11
28
  this.skillsAuthored = [];
12
29
  this.skillsWithScripts = [];
@@ -132,12 +149,37 @@ export class SkillAuthoringPipeline extends Pipeline {
132
149
  "`rule_catalog` tool for any catalog edits — sandbox_exec bypasses the " +
133
150
  "workspace file lock and races with parallel workers."
134
151
  ];
152
+ // v0.6.1 A2: surface task-completion parity so the agent sees the gate
153
+ let taskLine = "";
154
+ if (this._taskManager) {
155
+ const totalT = this._taskManager.countByPhase("skill_authoring");
156
+ const doneT = this._taskManager.countByPhase("skill_authoring", "completed");
157
+ const failedT = this._taskManager.countByPhase("skill_authoring", "failed");
158
+ if (totalT > 0) {
159
+ taskLine = `\n- Per-rule tasks completed: ${doneT}/${totalT}` +
160
+ (failedT > 0 ? ` (+${failedT} failed)` : "");
161
+ }
162
+ }
163
+ // v0.6.2 I2: validation status (only meaningful after first
164
+ // exitCriteriaMet call populates _validationFailures)
165
+ let validationLine = "";
166
+ if (this._validationSkipped) {
167
+ validationLine = `\n- Skill validation: SKIPPED (python3 not on PATH — install to enable)`;
168
+ } else if (this._validationFailures.length > 0) {
169
+ const f = this._validationFailures.slice(0, 5).map(({ filePath, error }) =>
170
+ `\n - ${path.relative(this._workspace.cwd, filePath)}: ${error.split("\n")[0]}`,
171
+ ).join("");
172
+ validationLine = `\n- Skills failing validation (${this._validationFailures.length}):${f}` +
173
+ (this._validationFailures.length > 5 ? `\n - … and ${this._validationFailures.length - 5} more` : "");
174
+ }
135
175
  parts.push(
136
176
  `### Progress (rule-id coverage, D2)\n` +
137
177
  `- Total rules in catalog: ${total}\n` +
138
178
  `- Rule ids covered by some skill: ${covered}\n` +
139
179
  `- Skill directories authored: ${this.skillsAuthored.length}\n` +
140
180
  `- Skills with scripts/: ${this.skillsWithScripts.length}` +
181
+ taskLine +
182
+ validationLine +
141
183
  (uncovered.length > 0
142
184
  ? `\n- Missing coverage (${uncovered.length}): ${uncovered.slice(0, 15).join(", ")}${uncovered.length > 15 ? "…" : ""}`
143
185
  : ""),
@@ -169,9 +211,57 @@ export class SkillAuthoringPipeline extends Pipeline {
169
211
  // preserved as a secondary gate on skill depth.
170
212
  const allCovered = this.totalRules.every((r) => this.ruleIdsCovered.has(r));
171
213
  if (!allCovered) return false;
214
+ // v0.6.1 A2: tasks-parity gate. The 17-minute skill_authoring transition
215
+ // in E2E #4 happened because D2 fired on 20 skeleton SK01-SK20 dirs
216
+ // covering all 110 rule_ids by filename, while only ~5 of 110 per-rule
217
+ // skill_authoring tasks had actually been worked on. Now require every
218
+ // per-rule task in TaskManager to be in a terminal state (completed or
219
+ // failed). Subagents (no taskManager) skip this gate.
220
+ if (this._taskManager) {
221
+ const total = this._taskManager.countByPhase("skill_authoring");
222
+ if (total > 0) {
223
+ const completed = this._taskManager.countByPhase("skill_authoring", "completed");
224
+ const failed = this._taskManager.countByPhase("skill_authoring", "failed");
225
+ if (completed + failed < total) return false;
226
+ }
227
+ }
228
+ // v0.6.2 I2: skill validator — every check_r###.py must parse and
229
+ // expose an entry point. Catches the unified_qc.py-style monolith
230
+ // and other malformed scripts before they break in production_qc.
231
+ // mtime cache keeps this O(1) in steady state. Failures preserved
232
+ // in this._validationFailures for describeState rendering.
233
+ const checkFiles = this._collectCheckScripts();
234
+ const v = this._validator.validateAll(checkFiles);
235
+ this._validationFailures = v.failures;
236
+ this._validationSkipped = v.skipped;
237
+ if (!v.ok) return false;
172
238
  return this.skillsWithScripts.length >= Math.max(1, this.skillsAuthored.length * 0.5);
173
239
  }
174
240
 
241
+ /**
242
+ * v0.6.2 I2: gather every check_r###.py path under rule_skills/. Used by
243
+ * the skill validator. Walks one level into each skill directory.
244
+ */
245
+ _collectCheckScripts() {
246
+ const out = [];
247
+ const dir = path.join(this._workspace.cwd, "rule_skills");
248
+ if (!fs.existsSync(dir)) return out;
249
+ const walk = (d) => {
250
+ let entries;
251
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
252
+ for (const e of entries) {
253
+ if (e.name.startsWith(".") || e.name.startsWith("__")) continue;
254
+ const p = path.join(d, e.name);
255
+ if (e.isDirectory()) { walk(p); continue; }
256
+ if (e.isFile() && /^check_r[\d_-]+\.py$/i.test(e.name)) {
257
+ out.push(p);
258
+ }
259
+ }
260
+ };
261
+ walk(dir);
262
+ return out;
263
+ }
264
+
175
265
  exportState() {
176
266
  return {
177
267
  totalRules: this.totalRules,
@@ -12,9 +12,14 @@ export class SessionState {
12
12
  * @param {string} workspacePath - Session workspace directory
13
13
  * @param {object} [opts]
14
14
  * @param {string} [opts.statePath] - Override absolute path (used for sub-agent isolation, Bug 2)
15
+ * @param {Workspace} [opts.workspace] - v0.6.2 J3: optional workspace ref so
16
+ * save() can acquire a sync file lock on session-state.json. Without it
17
+ * (subagents, tests), save() falls back to lock-free writes — same
18
+ * behavior as pre-v0.6.2.
15
19
  */
16
20
  constructor(workspacePath, opts = {}) {
17
21
  this._path = opts.statePath || path.join(workspacePath, "session-state.json");
22
+ this._workspace = opts.workspace || null;
18
23
  }
19
24
 
20
25
  /**
@@ -46,7 +51,18 @@ export class SessionState {
46
51
  pipelineMilestones: this._extractMilestones(engine.pipelines),
47
52
  };
48
53
 
49
- fs.writeFileSync(this._path, JSON.stringify(state, null, 2), "utf-8");
54
+ // v0.6.2 J3: acquire sync file lock if workspace ref available.
55
+ // session-state.json is in SHARED_COORDINATION_PATHS — concurrent
56
+ // writers (parallel ralph-loop workers + main saveState ticks)
57
+ // could otherwise interleave and corrupt the JSON.
58
+ const write = () => {
59
+ fs.writeFileSync(this._path, JSON.stringify(state, null, 2), "utf-8");
60
+ };
61
+ if (this._workspace?.withSyncFileLock) {
62
+ this._workspace.withSyncFileLock("session-state.json", write);
63
+ } else {
64
+ write();
65
+ }
50
66
  }
51
67
 
52
68
  /**
@@ -0,0 +1,149 @@
1
+ /**
2
+ * v0.6.2 I2: Skill validator (was D3c, deferred from v0.6.0/v0.6.1).
3
+ *
4
+ * E2E #4 demonstrated that broken `check_r###.py` contents go undetected
5
+ * until production_qc throws (e.g., `SyntaxError: unexpected character
6
+ * after line continuation character` from line 733 of unified_qc.py).
7
+ * This validator catches such breakage at the skill_authoring phase
8
+ * boundary instead of months later in production.
9
+ *
10
+ * Design constraints:
11
+ * - exitCriteriaMet is sync, so validation is sync (execFileSync).
12
+ * - 110 files × ~50ms subprocess = 5.5s worst case; caching by mtime
13
+ * keeps steady-state cost at ~0 (only re-validate freshly modified
14
+ * files).
15
+ * - Failures are diagnostic, not punitive: `force: true` on phase_advance
16
+ * still bypasses. The validator's job is to refuse the auto-advance,
17
+ * not to trap the agent.
18
+ *
19
+ * Validation rules per `check_r###.py`:
20
+ * 1. File ≥ 100 bytes (smoke test for empty stubs).
21
+ * 2. Passes `python3 -c "import ast; ast.parse(open(F).read())"` (no
22
+ * syntax errors).
23
+ * 3. Defines a function reachable by name `check_rule` or `verify`
24
+ * (regex match on file content).
25
+ *
26
+ * Disable mechanism: if `python3` is not on PATH, validator silently
27
+ * passes everything and emits a one-time warning — we don't want the
28
+ * gate to block on missing tooling. Gate effectively no-ops.
29
+ */
30
+
31
+ import { execFileSync } from "node:child_process";
32
+ import fs from "node:fs";
33
+ import path from "node:path";
34
+
35
+ const ENTRY_POINT_REGEX = /^\s*(?:async\s+)?def\s+(check_rule|verify)\b/m;
36
+ const MIN_BYTES = 100;
37
+
38
+ export class SkillValidator {
39
+ constructor() {
40
+ /** @type {Map<string, { mtime: number, ok: boolean, error?: string }>} */
41
+ this._cache = new Map();
42
+ /** @type {boolean|null} - null = untested, true/false once probed */
43
+ this._pythonAvailable = null;
44
+ /** @type {boolean} - one-time warning suppression */
45
+ this._warned = false;
46
+ }
47
+
48
+ /**
49
+ * Probe whether python3 is available. Cached after first call.
50
+ * @returns {boolean}
51
+ */
52
+ _probePython() {
53
+ if (this._pythonAvailable !== null) return this._pythonAvailable;
54
+ try {
55
+ execFileSync("python3", ["-c", "import ast"], { stdio: "ignore", timeout: 5000 });
56
+ this._pythonAvailable = true;
57
+ } catch {
58
+ this._pythonAvailable = false;
59
+ }
60
+ return this._pythonAvailable;
61
+ }
62
+
63
+ /**
64
+ * Validate one file. Returns `{ ok, error? }`. Cached by mtime.
65
+ * @param {string} filePath - Absolute path to the .py file
66
+ * @returns {{ ok: boolean, error?: string }}
67
+ */
68
+ validateFile(filePath) {
69
+ let mtime;
70
+ try {
71
+ mtime = fs.statSync(filePath).mtimeMs;
72
+ } catch {
73
+ return { ok: false, error: "file not found" };
74
+ }
75
+ const cached = this._cache.get(filePath);
76
+ if (cached && cached.mtime === mtime) {
77
+ return { ok: cached.ok, error: cached.error };
78
+ }
79
+ const result = this._runValidation(filePath);
80
+ this._cache.set(filePath, { mtime, ...result });
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Validate all files in a list. Returns:
86
+ * - ok: boolean — true iff every file passes
87
+ * - failures: array of { filePath, error } for each failing file
88
+ * - skipped: boolean — true if python3 unavailable (validator no-op'd)
89
+ *
90
+ * @param {string[]} filePaths
91
+ * @returns {{ ok: boolean, failures: Array<{filePath:string, error:string}>, skipped: boolean }}
92
+ */
93
+ validateAll(filePaths) {
94
+ if (!this._probePython()) {
95
+ if (!this._warned) {
96
+ // eslint-disable-next-line no-console
97
+ console.warn("[skill-validator] python3 not on PATH — skill validation skipped. " +
98
+ "Phase gate will not catch syntax errors. Install python3 to enable.");
99
+ this._warned = true;
100
+ }
101
+ return { ok: true, failures: [], skipped: true };
102
+ }
103
+ const failures = [];
104
+ for (const f of filePaths) {
105
+ const r = this.validateFile(f);
106
+ if (!r.ok) failures.push({ filePath: f, error: r.error || "unknown" });
107
+ }
108
+ return { ok: failures.length === 0, failures, skipped: false };
109
+ }
110
+
111
+ /**
112
+ * Manually invalidate cache for a path — used when the caller knows
113
+ * the file changed but mtime granularity might not have caught it.
114
+ */
115
+ invalidate(filePath) { this._cache.delete(filePath); }
116
+
117
+ // --- Internal ---
118
+
119
+ _runValidation(filePath) {
120
+ // Rule 1: size check (cheap)
121
+ let size;
122
+ try { size = fs.statSync(filePath).size; }
123
+ catch { return { ok: false, error: "stat failed" }; }
124
+ if (size < MIN_BYTES) {
125
+ return { ok: false, error: `file too small (${size} < ${MIN_BYTES} bytes)` };
126
+ }
127
+
128
+ // Rule 2: ast.parse smoke test via subprocess
129
+ try {
130
+ execFileSync("python3", [
131
+ "-c",
132
+ `import ast,sys\ntry:\n ast.parse(open(${JSON.stringify(filePath)}).read())\nexcept SyntaxError as e:\n print(f"SyntaxError: {e}", file=sys.stderr); sys.exit(1)\nexcept Exception as e:\n print(f"{type(e).__name__}: {e}", file=sys.stderr); sys.exit(1)\n`,
133
+ ], { stdio: ["ignore", "ignore", "pipe"], timeout: 10_000 });
134
+ } catch (e) {
135
+ const stderr = (e.stderr ? e.stderr.toString() : "") || e.message || "subprocess failed";
136
+ return { ok: false, error: stderr.trim().slice(0, 300) };
137
+ }
138
+
139
+ // Rule 3: entry-point regex (after parse OK so we know file is readable)
140
+ let content;
141
+ try { content = fs.readFileSync(filePath, "utf-8"); }
142
+ catch { return { ok: false, error: "read failed after parse OK" }; }
143
+ if (!ENTRY_POINT_REGEX.test(content)) {
144
+ return { ok: false, error: "no entry point: expected `def check_rule(...)` or `def verify(...)`" };
145
+ }
146
+
147
+ return { ok: true };
148
+ }
149
+ }
@@ -182,6 +182,21 @@ export class TaskManager {
182
182
  return { total, completed, inProgress, pending, failed };
183
183
  }
184
184
 
185
+ /**
186
+ * v0.6.1 A2: Phase-scoped task count. Used by SkillAuthoringPipeline's
187
+ * exitCriteriaMet to gate phase advance on TaskManager parity, not just
188
+ * filename-regex coverage. Pass a status to filter; omit for total.
189
+ *
190
+ * @param {string} phase - Phase name (e.g., "skill_authoring")
191
+ * @param {string|null} [status] - Optional status filter ("completed", "pending", etc.)
192
+ * @returns {number}
193
+ */
194
+ countByPhase(phase, status = null) {
195
+ return this._tasks.filter(
196
+ (t) => t.phase === phase && (status == null || t.status === status),
197
+ ).length;
198
+ }
199
+
185
200
  /**
186
201
  * Format task list for injection into system prompt context.
187
202
  * Compact checklist — not conversation history.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * v0.6.2 I1: Shared workflow-result normalizer + ERROR classifier.
3
+ *
4
+ * E2E #4 produced 1,150 ERROR verdicts out of 6,930 (16.6%) and
5
+ * verdict_stats keys leaked Python dataclass repr() strings like
6
+ * "VerificationResult(rule_id='R049', verdict='NOT_APPLICABLE', ...)".
7
+ * The agent's batch aggregator was using repr(result) as a dict key
8
+ * because the workflow's Python output was a dataclass instance, not
9
+ * a dict.
10
+ *
11
+ * This module fixes the boundary: anything that comes out of a
12
+ * workflow_run tool gets normalized to a strict dict shape before being
13
+ * persisted or returned to the agent. Repr-strings get parsed back into
14
+ * structured fields. ERRORs get classified into typed buckets so we can
15
+ * tell "import failed" from "extraction returned wrong shape" without
16
+ * reading 1,150 stack traces.
17
+ */
18
+
19
+ /**
20
+ * The required shape every workflow result must satisfy. Unknown extra
21
+ * keys are preserved.
22
+ */
23
+ export const REQUIRED_KEYS = ["rule_id", "verdict"];
24
+
25
+ /**
26
+ * Canonical verdict values. Anything outside this set is allowed (the
27
+ * worker LLM may extend) but generates a `nonstandard_verdict` warning
28
+ * in the result's `_warnings` array.
29
+ */
30
+ export const STANDARD_VERDICTS = new Set([
31
+ "PASS", "FAIL", "NOT_APPLICABLE", "SUPPLEMENT_NEEDED", "ERROR", "UNKNOWN",
32
+ ]);
33
+
34
+ /**
35
+ * Recognized error_type values used by classifyError(). Add to this set
36
+ * when adding a new pattern below.
37
+ */
38
+ export const ERROR_TYPES = [
39
+ "import_error",
40
+ "attribute_error",
41
+ "keyword_not_found",
42
+ "sample_unparseable",
43
+ "schema_violation",
44
+ "syntax_error",
45
+ "timeout",
46
+ "permission_error",
47
+ "unknown",
48
+ ];
49
+
50
+ /**
51
+ * Detect whether a string looks like a Python dataclass repr —
52
+ * `ClassName(field=value, field=value)`. Used both as a top-level
53
+ * detector and recursively inside dict keys.
54
+ */
55
+ const REPR_PATTERN = /^([A-Za-z_]\w*)\((.*)\)$/s;
56
+
57
+ /**
58
+ * Parse a Python-repr string into { class_name, fields: { ... } }.
59
+ * Field values are kept as strings (we don't try to re-type them — the
60
+ * downstream consumer can JSON.parse if needed). Returns null if the
61
+ * input doesn't look like a repr.
62
+ *
63
+ * Example:
64
+ * parsePyRepr("VerificationResult(rule_id='R049', verdict='NOT_APPLICABLE')")
65
+ * → { class_name: 'VerificationResult', fields: { rule_id: "'R049'", verdict: "'NOT_APPLICABLE'" } }
66
+ */
67
+ export function parsePyRepr(s) {
68
+ if (typeof s !== "string") return null;
69
+ const m = s.match(REPR_PATTERN);
70
+ if (!m) return null;
71
+ const className = m[1];
72
+ const body = m[2];
73
+ // Tokenize on top-level commas (ignore commas inside brackets/quotes)
74
+ const fields = {};
75
+ let depth = 0;
76
+ let inQuote = null;
77
+ let buf = "";
78
+ let key = null;
79
+ const flush = () => {
80
+ if (!buf.trim()) return;
81
+ if (key == null) {
82
+ // No `=` seen — entry was positional, skip
83
+ buf = "";
84
+ return;
85
+ }
86
+ fields[key] = buf.trim();
87
+ key = null;
88
+ buf = "";
89
+ };
90
+ for (let i = 0; i < body.length; i++) {
91
+ const c = body[i];
92
+ if (inQuote) {
93
+ buf += c;
94
+ if (c === inQuote && body[i - 1] !== "\\") inQuote = null;
95
+ continue;
96
+ }
97
+ if (c === "'" || c === '"') { inQuote = c; buf += c; continue; }
98
+ if (c === "(" || c === "[" || c === "{") { depth++; buf += c; continue; }
99
+ if (c === ")" || c === "]" || c === "}") { depth--; buf += c; continue; }
100
+ if (c === "=" && depth === 0 && key == null) {
101
+ key = buf.trim();
102
+ buf = "";
103
+ continue;
104
+ }
105
+ if (c === "," && depth === 0) { flush(); continue; }
106
+ buf += c;
107
+ }
108
+ flush();
109
+ return { class_name: className, fields };
110
+ }
111
+
112
+ /**
113
+ * Recursively replace any dict key that looks like a Python repr with
114
+ * a structured object. Also handles arrays. Mutates in place but also
115
+ * returns the input for chaining.
116
+ */
117
+ export function normalizeReprKeys(obj) {
118
+ if (Array.isArray(obj)) {
119
+ obj.forEach((v, i) => { obj[i] = normalizeReprKeys(v); });
120
+ return obj;
121
+ }
122
+ if (obj && typeof obj === "object") {
123
+ const newObj = {};
124
+ for (const [k, v] of Object.entries(obj)) {
125
+ const parsed = parsePyRepr(k);
126
+ if (parsed) {
127
+ // Merge under a class-name bucket. Multiple repr keys for the
128
+ // same class collapse to a counter (because verdict_stats just
129
+ // wanted distinct buckets).
130
+ const bucket = newObj[parsed.class_name] || (newObj[parsed.class_name] = []);
131
+ bucket.push({ fields: parsed.fields, count: typeof v === "number" ? v : 1 });
132
+ } else {
133
+ newObj[k] = normalizeReprKeys(v);
134
+ }
135
+ }
136
+ return newObj;
137
+ }
138
+ return obj;
139
+ }
140
+
141
+ /**
142
+ * Classify an ERROR result by inferring `error_type` from the raw_output
143
+ * stack trace or message. Returns one of ERROR_TYPES.
144
+ *
145
+ * Conservative — when in doubt, return "unknown" rather than guess wrong.
146
+ */
147
+ export function classifyError(rawOutput) {
148
+ if (!rawOutput || typeof rawOutput !== "string") return "unknown";
149
+ const s = rawOutput;
150
+ if (/ModuleNotFoundError|ImportError|No module named/i.test(s)) return "import_error";
151
+ if (/AttributeError/i.test(s)) return "attribute_error";
152
+ if (/SyntaxError|invalid syntax|unexpected character/i.test(s)) return "syntax_error";
153
+ if (/PermissionError|permission denied/i.test(s)) return "permission_error";
154
+ if (/timed out|timeout|Timeout/i.test(s)) return "timeout";
155
+ // sample parse failures usually mention pdfjs / docx / json
156
+ if (/pdfjs|docx|json\.decoder|JSONDecodeError|UnicodeDecodeError/i.test(s)) return "sample_unparseable";
157
+ // schema violations from our own normalizer would have a hint
158
+ if (/schema_violation|missing required key/i.test(s)) return "schema_violation";
159
+ // Common keyword-not-found signal: the workflow returned no match
160
+ if (/no match|not found|未找到|关键词未匹配/i.test(s)) return "keyword_not_found";
161
+ return "unknown";
162
+ }
163
+
164
+ /**
165
+ * Normalize a parsed workflow-output object to the canonical dict shape.
166
+ * - Ensures `rule_id` and `verdict` are present.
167
+ * - Strips repr-string keys (delegates to normalizeReprKeys).
168
+ * - If verdict is "ERROR" or the parse fell back to raw_output, attaches
169
+ * `error_type` from classifyError().
170
+ * - Records issues in `_warnings: string[]` so the consumer (and the
171
+ * agent reading the tool result) can see them.
172
+ *
173
+ * Inputs:
174
+ * parsed — what JSON.parse yielded (may already be a dict, or be
175
+ * the raw_output fallback object)
176
+ * ruleId — what the caller knows the rule_id should be
177
+ * rawOutput — the original stdout (used for ERROR classification)
178
+ *
179
+ * Returns the normalized result. Always returns a dict with `rule_id`
180
+ * and `verdict`. Never throws.
181
+ */
182
+ export function normalizeWorkflowResult(parsed, ruleId, rawOutput) {
183
+ const warnings = [];
184
+ let result;
185
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
186
+ result = { ...parsed };
187
+ } else if (typeof parsed === "string") {
188
+ // Parsed yielded a string — could be a repr at top level
189
+ const repr = parsePyRepr(parsed);
190
+ if (repr) {
191
+ // Strip Python's surrounding quote chars from string values so
192
+ // STANDARD_VERDICTS comparisons work and downstream code doesn't
193
+ // see "'PASS'" instead of "PASS". Conservative: only unwrap when
194
+ // the entire value is wrapped in matching ' or " quotes.
195
+ const stripped = {};
196
+ for (const [k, v] of Object.entries(repr.fields)) {
197
+ if (typeof v === "string" && /^(['"]).*\1$/s.test(v) && v.length >= 2) {
198
+ stripped[k] = v.slice(1, -1);
199
+ } else {
200
+ stripped[k] = v;
201
+ }
202
+ }
203
+ result = stripped;
204
+ result._source_class = repr.class_name;
205
+ warnings.push("toplevel_repr_string");
206
+ } else {
207
+ result = { raw_output: parsed.slice(0, 5000) };
208
+ warnings.push("toplevel_string");
209
+ }
210
+ } else {
211
+ result = { raw_output: String(parsed ?? "").slice(0, 5000) };
212
+ warnings.push("toplevel_nonobject");
213
+ }
214
+
215
+ // Recursively normalize repr keys in nested dicts (verdict_stats, etc.)
216
+ normalizeReprKeys(result);
217
+
218
+ // rule_id: prefer the caller-supplied value (it's authoritative)
219
+ if (ruleId) result.rule_id = ruleId;
220
+ else if (typeof result.rule_id !== "string") {
221
+ result.rule_id = "unknown";
222
+ warnings.push("missing_rule_id");
223
+ }
224
+
225
+ // verdict: ensure present and canonical-or-warn
226
+ if (typeof result.verdict !== "string" || result.verdict === "") {
227
+ // If the workflow fell into raw_output fallback, mark as ERROR
228
+ if (result.raw_output) {
229
+ result.verdict = "ERROR";
230
+ } else {
231
+ result.verdict = "UNKNOWN";
232
+ warnings.push("missing_verdict");
233
+ }
234
+ } else if (!STANDARD_VERDICTS.has(result.verdict)) {
235
+ warnings.push("nonstandard_verdict");
236
+ }
237
+
238
+ // ERROR classification
239
+ if (result.verdict === "ERROR") {
240
+ const trace = rawOutput || result.raw_output || result.error || "";
241
+ result.error_type = classifyError(trace);
242
+ }
243
+
244
+ if (warnings.length > 0) {
245
+ result._warnings = (result._warnings || []).concat(warnings);
246
+ }
247
+
248
+ return result;
249
+ }
@@ -19,13 +19,17 @@ export class PhaseAdvanceTool extends BaseTool {
19
19
  * @param {() => string} getCurrentPhaseFn - H1: lets the tool read the
20
20
  * engine's phase BEFORE the call, so it can distinguish "already there"
21
21
  * (silent no-op, informational) from "non-adjacent refusal" (actionable).
22
- * Before H1 both cases returned the same confusing "Either you're already
23
- * there, or transition is non-adjacent" message.
22
+ * @param {() => string[]} [getRunningSubagentsFn] - v0.6.2 J1: returns the
23
+ * list of running subagent task_ids. When non-empty, phase_advance
24
+ * refuses unless `acknowledge_stale_subagents: true` is set in input
25
+ * (or `force: true`). Forces the agent to confront live work that
26
+ * started in the prior phase before declaring the phase done.
24
27
  */
25
- constructor(advanceFn, getCurrentPhaseFn) {
28
+ constructor(advanceFn, getCurrentPhaseFn, getRunningSubagentsFn) {
26
29
  super();
27
30
  this._advance = advanceFn;
28
31
  this._getCurrentPhase = getCurrentPhaseFn || (() => null);
32
+ this._getRunningSubagents = getRunningSubagentsFn || (() => []);
29
33
  }
30
34
 
31
35
  get name() { return "phase_advance"; }
@@ -48,6 +52,11 @@ export class PhaseAdvanceTool extends BaseTool {
48
52
  type: "boolean",
49
53
  description: "Allow non-adjacent or backward transitions. Default false.",
50
54
  },
55
+ acknowledge_stale_subagents: {
56
+ type: "boolean",
57
+ description:
58
+ "Set to true after using agent_tool(operation=list|poll|kill) to confirm you've handled any subagents still running from the prior phase. Required when subagents are live; otherwise advance is refused (use force:true to bypass entirely).",
59
+ },
51
60
  },
52
61
  required: ["to"],
53
62
  };
@@ -68,8 +77,30 @@ export class PhaseAdvanceTool extends BaseTool {
68
77
  );
69
78
  }
70
79
 
80
+ // v0.6.2 J1: stale-subagents acknowledgement gate. Refuses advance if
81
+ // any subagent is still running and the agent hasn't explicitly
82
+ // acknowledged. force:true bypasses (matches existing escape pattern).
83
+ const running = this._getRunningSubagents();
84
+ if (running.length > 0 && !input.acknowledge_stale_subagents && !input.force) {
85
+ return new ToolResult(
86
+ `Refusing to advance from ${beforePhase || "?"} to ${to}: ${running.length} subagent(s) still running from prior phase: ${running.join(", ")}. ` +
87
+ `Run agent_tool(operation="list") to see status, then either ` +
88
+ `agent_tool(operation="wait"|"kill") on each, OR pass acknowledge_stale_subagents:true ` +
89
+ `to advance while leaving them running (use only if they're legitimate background work).`,
90
+ true,
91
+ );
92
+ }
93
+
71
94
  const advanced = this._advance(to, input.reason || "agent request", { force: !!input.force });
72
95
  if (advanced) {
96
+ // Log the ack so post-mortems can find phase advances that proceeded
97
+ // with live subagents
98
+ if (running.length > 0 && input.acknowledge_stale_subagents) {
99
+ return new ToolResult(
100
+ `Advanced${beforePhase ? ` from ${beforePhase}` : ""} to ${to}${input.force ? " (forced)" : ""} — ` +
101
+ `acknowledged ${running.length} running subagent(s): ${running.join(", ")}.`,
102
+ );
103
+ }
73
104
  return new ToolResult(`Advanced${beforePhase ? ` from ${beforePhase}` : ""} to ${to}${input.force ? " (forced)" : ""}`);
74
105
  }
75
106